mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-28 07:40:27 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9297c1e10 | ||
|
|
94f07ab98b | ||
|
|
01a6594707 | ||
|
|
82a7154b92 | ||
|
|
9b385ac9c9 | ||
|
|
e3d4cee416 | ||
|
|
f6c79370cb | ||
|
|
39460e4acb | ||
|
|
f971c312b9 | ||
|
|
2c3a304440 | ||
|
|
286b0e03f7 | ||
|
|
447f86e2b5 | ||
|
|
0592f1a99a | ||
|
|
90e3936204 | ||
|
|
1239f622d2 | ||
|
|
d511e2bb3f | ||
|
|
ff93aa3dc7 | ||
|
|
cc8891b6a1 | ||
|
|
7c65b1eaf1 | ||
|
|
ebe3e9c63c | ||
|
|
d33a872c42 | ||
|
|
9377dc3d52 | ||
|
|
17322bb5a4 | ||
|
|
c0bcced5fb | ||
|
|
805c1d5ea2 | ||
|
|
b3399b07ad | ||
|
|
71f8504849 | ||
|
|
3b7ca1a08f | ||
|
|
57f3c4dd31 | ||
|
|
5b20ebb7b0 | ||
|
|
3a3eaeec7c | ||
|
|
b0cc7b6ee5 | ||
|
|
e5108c0427 | ||
|
|
927797f3d5 | ||
|
|
72e01f8c84 | ||
|
|
c38b98a0c4 | ||
|
|
05d27e86ce | ||
|
|
40409a3841 | ||
|
|
65bae6b57a | ||
|
|
0b6afb66d9 | ||
|
|
52be000fdd | ||
|
|
55ce5bcfd3 | ||
|
|
29888cb38b | ||
|
|
6ea4c9ec65 | ||
|
|
4bec3aa597 | ||
|
|
38c320d2c9 | ||
|
|
76cbd8a1c1 | ||
|
|
0779628be5 | ||
|
|
34ca919c4d | ||
|
|
b1b357347b | ||
|
|
129d63f66e | ||
|
|
699b46acbd | ||
|
|
7f05aee11d | ||
|
|
542036f46e | ||
|
|
b958e9e803 | ||
|
|
73fcfb5900 | ||
|
|
adabc4da46 | ||
|
|
bf073b544b | ||
|
|
a71219062a | ||
|
|
001fe01ace | ||
|
|
0aa0c44634 | ||
|
|
93126e514e | ||
|
|
1ae10ae0c6 | ||
|
|
4b693bf6e2 | ||
|
|
574c257591 | ||
|
|
d680328762 | ||
|
|
d711cdecaf | ||
|
|
c5f1792009 | ||
|
|
a5e705e6a4 | ||
|
|
007f1db339 | ||
|
|
008fb39f8f |
2
.github/prompt/default.md
vendored
2
.github/prompt/default.md
vendored
@@ -2,7 +2,7 @@
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
2
.github/prompt/release_note_prompt.txt
vendored
2
.github/prompt/release_note_prompt.txt
vendored
@@ -31,7 +31,7 @@
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
2
.github/workflows/auto-release.yml
vendored
2
.github/workflows/auto-release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Copy OpenAPI Schema
|
||||
run: |
|
||||
mkdir -p napcat-docs/src/api/${{ env.version }}
|
||||
cp packages/napcat-schema/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
echo "OpenAPI schema copied to napcat-docs/src/api/${{ env.version }}/openapi.json"
|
||||
|
||||
- name: Commit and Push
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ checkVersion.sh
|
||||
bun.lockb
|
||||
tests/run/
|
||||
guild1.db-wal
|
||||
guild1.db-shm
|
||||
guild1.db-shm
|
||||
packages/napcat-develop/config/.env
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
||||
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
||||
"build:shell:config": "pnpm --filter napcat-shell run build && pnpm --filter napcat-develop run copy-env",
|
||||
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
||||
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public",
|
||||
"tag": "latest"
|
||||
}
|
||||
}
|
||||
176
packages/napcat-adapter/index.ts
Normal file
176
packages/napcat-adapter/index.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot';
|
||||
import { NapCatProtocolAdapter } from 'napcat-protocol';
|
||||
|
||||
// 协议适配器类型
|
||||
export type ProtocolAdapterType = 'onebot11' | 'napcat-protocol';
|
||||
|
||||
// 协议适配器接口
|
||||
export interface IProtocolAdapter {
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
init (): Promise<void>;
|
||||
close (): Promise<void>;
|
||||
}
|
||||
|
||||
// 协议适配器包装器
|
||||
class OneBotAdapterWrapper implements IProtocolAdapter {
|
||||
readonly name = 'onebot11';
|
||||
private adapter: NapCatOneBot11Adapter;
|
||||
|
||||
constructor (adapter: NapCatOneBot11Adapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
get enabled (): boolean {
|
||||
return true; // OneBot11 默认启用
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.InitOneBot();
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
await this.adapter.networkManager.closeAllAdapters();
|
||||
}
|
||||
|
||||
getAdapter (): NapCatOneBot11Adapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
// NapCat Protocol 适配器包装器
|
||||
class NapCatProtocolAdapterWrapper implements IProtocolAdapter {
|
||||
readonly name = 'napcat-protocol';
|
||||
private adapter: NapCatProtocolAdapter;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
get enabled (): boolean {
|
||||
return this.adapter.isEnabled();
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.initProtocol();
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
await this.adapter.close();
|
||||
}
|
||||
|
||||
getAdapter (): NapCatProtocolAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
// 协议适配器管理器
|
||||
export class NapCatAdapterManager {
|
||||
private core: NapCatCore;
|
||||
private context: InstanceContext;
|
||||
private pathWrapper: NapCatPathWrapper;
|
||||
|
||||
// 协议适配器实例
|
||||
private onebotAdapter: OneBotAdapterWrapper | null = null;
|
||||
private napcatProtocolAdapter: NapCatProtocolAdapterWrapper | null = null;
|
||||
|
||||
// 所有已注册的适配器
|
||||
private adapters: Map<string, IProtocolAdapter> = new Map();
|
||||
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.pathWrapper = pathWrapper;
|
||||
}
|
||||
|
||||
// 初始化所有协议适配器
|
||||
async initAdapters (): Promise<void> {
|
||||
this.context.logger.log('[AdapterManager] 开始初始化协议适配器...');
|
||||
|
||||
// 初始化 OneBot11 适配器 (默认启用)
|
||||
try {
|
||||
const onebot = new NapCatOneBot11Adapter(this.core, this.context, this.pathWrapper);
|
||||
this.onebotAdapter = new OneBotAdapterWrapper(onebot);
|
||||
this.adapters.set('onebot11', this.onebotAdapter);
|
||||
await this.onebotAdapter.init();
|
||||
this.context.logger.log('[AdapterManager] OneBot11 适配器初始化完成');
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[AdapterManager] OneBot11 适配器初始化失败:', e);
|
||||
}
|
||||
|
||||
// 初始化 NapCat Protocol 适配器 (默认关闭,需要配置启用)
|
||||
try {
|
||||
const napcatProtocol = new NapCatProtocolAdapter(this.core, this.context, this.pathWrapper);
|
||||
this.napcatProtocolAdapter = new NapCatProtocolAdapterWrapper(napcatProtocol);
|
||||
this.adapters.set('napcat-protocol', this.napcatProtocolAdapter);
|
||||
|
||||
if (this.napcatProtocolAdapter.enabled) {
|
||||
await this.napcatProtocolAdapter.init();
|
||||
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器初始化完成');
|
||||
} else {
|
||||
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器未启用,跳过初始化');
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[AdapterManager] NapCat Protocol 适配器初始化失败:', e);
|
||||
}
|
||||
|
||||
this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
|
||||
}
|
||||
|
||||
// 获取 OneBot11 适配器
|
||||
getOneBotAdapter (): NapCatOneBot11Adapter | null {
|
||||
return this.onebotAdapter?.getAdapter() ?? null;
|
||||
}
|
||||
|
||||
// 获取 NapCat Protocol 适配器
|
||||
getNapCatProtocolAdapter (): NapCatProtocolAdapter | null {
|
||||
return this.napcatProtocolAdapter?.getAdapter() ?? null;
|
||||
}
|
||||
|
||||
// 获取指定适配器
|
||||
getAdapter (name: ProtocolAdapterType): IProtocolAdapter | undefined {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
// 获取所有已启用的适配器
|
||||
getEnabledAdapters (): IProtocolAdapter[] {
|
||||
return Array.from(this.adapters.values()).filter(adapter => adapter.enabled);
|
||||
}
|
||||
|
||||
// 获取所有适配器
|
||||
getAllAdapters (): IProtocolAdapter[] {
|
||||
return Array.from(this.adapters.values());
|
||||
}
|
||||
|
||||
// 关闭所有适配器
|
||||
async closeAllAdapters (): Promise<void> {
|
||||
this.context.logger.log('[AdapterManager] 开始关闭所有协议适配器...');
|
||||
|
||||
for (const [name, adapter] of this.adapters) {
|
||||
try {
|
||||
await adapter.close();
|
||||
this.context.logger.log(`[AdapterManager] ${name} 适配器已关闭`);
|
||||
} catch (e) {
|
||||
this.context.logger.logError(`[AdapterManager] 关闭 ${name} 适配器失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
this.adapters.clear();
|
||||
this.context.logger.log('[AdapterManager] 所有协议适配器已关闭');
|
||||
}
|
||||
|
||||
// 重新加载指定适配器
|
||||
async reloadAdapter (name: ProtocolAdapterType): Promise<void> {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
await adapter.init();
|
||||
this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { NapCatOneBot11Adapter } from 'napcat-onebot';
|
||||
export { NapCatProtocolAdapter } from 'napcat-protocol';
|
||||
30
packages/napcat-adapter/package.json
Normal file
30
packages/napcat-adapter/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "napcat-adapter",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"name": "napcat-common",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-common",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./src/*": {
|
||||
"import": "./src/*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"file-type": "^21.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./src/*": {
|
||||
"import": "./src/*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"file-type": "^21.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import fs from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import crypto, { randomUUID } from 'crypto';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import tls from 'node:tls';
|
||||
import { solveProblem } from '@/napcat-common/src/helper';
|
||||
|
||||
export interface HttpDownloadOptions {
|
||||
url: string;
|
||||
headers?: Record<string, string> | string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
type Uri2LocalRes = {
|
||||
@@ -96,6 +99,7 @@ export function calculateFileMD5 (filePath: string): Promise<string> {
|
||||
|
||||
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
|
||||
let url: string;
|
||||
let proxy: string | undefined;
|
||||
let headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
|
||||
};
|
||||
@@ -104,6 +108,7 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
headers['Host'] = new URL(url).hostname;
|
||||
} else {
|
||||
url = options.url;
|
||||
proxy = options.proxy;
|
||||
if (options.headers) {
|
||||
if (typeof options.headers === 'string') {
|
||||
headers = JSON.parse(options.headers);
|
||||
@@ -115,6 +120,18 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
if (useReferer && !headers['Referer']) {
|
||||
headers['Referer'] = url;
|
||||
}
|
||||
|
||||
// 如果配置了代理,使用代理下载
|
||||
if (proxy) {
|
||||
try {
|
||||
const response = await httpDownloadWithProxy(url, headers, proxy);
|
||||
return new Response(response, { status: 200, statusText: 'OK' });
|
||||
} catch (proxyError) {
|
||||
// 如果代理失败,记录错误并尝试直接下载
|
||||
console.error('代理下载失败,尝试直接下载:', proxyError);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||
if (err.cause) {
|
||||
throw err.cause;
|
||||
@@ -124,6 +141,220 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
return fetchRes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTTP/HTTPS 代理下载文件
|
||||
*/
|
||||
function httpDownloadWithProxy (url: string, headers: Record<string, string>, proxy: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const targetUrl = new URL(url);
|
||||
const proxyUrl = new URL(proxy);
|
||||
|
||||
const isTargetHttps = targetUrl.protocol === 'https:';
|
||||
const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
// 代理认证头
|
||||
const proxyAuthHeader = proxyUrl.username && proxyUrl.password
|
||||
? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') }
|
||||
: {};
|
||||
|
||||
if (isTargetHttps) {
|
||||
// HTTPS 目标:需要通过 CONNECT 建立隧道
|
||||
const connectReq = http.request({
|
||||
host: proxyUrl.hostname,
|
||||
port: proxyPort,
|
||||
method: 'CONNECT',
|
||||
path: `${targetUrl.hostname}:${targetUrl.port || 443}`,
|
||||
headers: {
|
||||
'Host': `${targetUrl.hostname}:${targetUrl.port || 443}`,
|
||||
...proxyAuthHeader,
|
||||
},
|
||||
});
|
||||
|
||||
connectReq.on('connect', (res, socket) => {
|
||||
if (res.statusCode !== 200) {
|
||||
socket.destroy();
|
||||
reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 在隧道上建立 TLS 连接
|
||||
const tlsSocket = tls.connect({
|
||||
socket: socket,
|
||||
servername: targetUrl.hostname,
|
||||
rejectUnauthorized: true,
|
||||
}, () => {
|
||||
// TLS 握手成功,发送 HTTP 请求
|
||||
const requestPath = targetUrl.pathname + targetUrl.search;
|
||||
const requestHeaders = {
|
||||
...headers,
|
||||
'Host': targetUrl.hostname,
|
||||
'Connection': 'close',
|
||||
};
|
||||
|
||||
const headerLines = Object.entries(requestHeaders)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\r\n');
|
||||
|
||||
const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`;
|
||||
tlsSocket.write(httpRequest);
|
||||
});
|
||||
|
||||
// 解析 HTTP 响应
|
||||
let responseData = Buffer.alloc(0);
|
||||
let headersParsed = false;
|
||||
let statusCode = 0;
|
||||
let isChunked = false;
|
||||
let bodyData = Buffer.alloc(0);
|
||||
let redirectLocation: string | null = null;
|
||||
|
||||
tlsSocket.on('data', (chunk: Buffer) => {
|
||||
responseData = Buffer.concat([responseData, chunk]);
|
||||
|
||||
if (!headersParsed) {
|
||||
const headerEndIndex = responseData.indexOf('\r\n\r\n');
|
||||
if (headerEndIndex !== -1) {
|
||||
headersParsed = true;
|
||||
const headerStr = responseData.subarray(0, headerEndIndex).toString();
|
||||
const headerLines = headerStr.split('\r\n');
|
||||
|
||||
// 解析状态码
|
||||
const statusLine = headerLines[0];
|
||||
const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/);
|
||||
statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0;
|
||||
|
||||
// 解析响应头
|
||||
for (const line of headerLines.slice(1)) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') {
|
||||
isChunked = true;
|
||||
} else if (key?.toLowerCase() === 'location') {
|
||||
redirectLocation = value;
|
||||
}
|
||||
}
|
||||
|
||||
bodyData = responseData.subarray(headerEndIndex + 4);
|
||||
}
|
||||
} else {
|
||||
bodyData = Buffer.concat([bodyData, chunk]);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => {
|
||||
// 处理重定向
|
||||
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
|
||||
const redirectUrl = redirectLocation.startsWith('http')
|
||||
? redirectLocation
|
||||
: `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`;
|
||||
httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 chunked 编码
|
||||
if (isChunked) {
|
||||
resolve(parseChunkedBody(bodyData));
|
||||
} else {
|
||||
resolve(bodyData);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
reject(new Error(`TLS 连接错误: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
connectReq.on('error', (err) => {
|
||||
reject(new Error(`代理连接错误: ${err.message}`));
|
||||
});
|
||||
|
||||
connectReq.end();
|
||||
} else {
|
||||
// HTTP 目标:直接通过代理请求
|
||||
const req = http.request({
|
||||
host: proxyUrl.hostname,
|
||||
port: proxyPort,
|
||||
method: 'GET',
|
||||
path: url, // 完整 URL
|
||||
headers: {
|
||||
...headers,
|
||||
'Host': targetUrl.hostname,
|
||||
...proxyAuthHeader,
|
||||
},
|
||||
}, (response) => {
|
||||
handleResponse(response, resolve, reject, url, headers, proxy);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`代理请求错误: ${err.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 chunked 编码的响应体
|
||||
*/
|
||||
function parseChunkedBody (data: Buffer): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < data.length) {
|
||||
// 查找 chunk 大小行的结束
|
||||
const lineEnd = data.indexOf('\r\n', offset);
|
||||
if (lineEnd === -1) break;
|
||||
|
||||
const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展
|
||||
const chunkSize = parseInt(sizeStr!, 16);
|
||||
|
||||
if (chunkSize === 0) break; // 最后一个 chunk
|
||||
|
||||
const chunkStart = lineEnd + 2;
|
||||
const chunkEnd = chunkStart + chunkSize;
|
||||
|
||||
if (chunkEnd > data.length) break;
|
||||
|
||||
chunks.push(data.subarray(chunkStart, chunkEnd));
|
||||
offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 响应
|
||||
*/
|
||||
function handleResponse (
|
||||
response: http.IncomingMessage,
|
||||
resolve: (value: Buffer) => void,
|
||||
reject: (reason: Error) => void,
|
||||
_url: string,
|
||||
headers: Record<string, string>,
|
||||
proxy: string
|
||||
): void {
|
||||
// 处理重定向
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
response.on('error', reject);
|
||||
}
|
||||
|
||||
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
|
||||
const useReferer = typeof options === 'string';
|
||||
let resp = await tryDownload(options);
|
||||
@@ -176,7 +407,7 @@ export async function checkUriType (Uri: string) {
|
||||
return { Uri, Type: FileUriType.Unknown };
|
||||
}
|
||||
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
|
||||
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
|
||||
|
||||
const filePath = path.join(dir, filename);
|
||||
@@ -191,7 +422,7 @@ export async function uriToLocalFile (dir: string, uri: string, filename: string
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
8
packages/napcat-core/external/appid.json
vendored
8
packages/napcat-core/external/appid.json
vendored
@@ -518,5 +518,13 @@
|
||||
"9.9.26-44725": {
|
||||
"appid": 537337569,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||
},
|
||||
"9.9.27-45627": {
|
||||
"appid": 537340060,
|
||||
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
|
||||
},
|
||||
"6.9.88-44725": {
|
||||
"appid": 537337594,
|
||||
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
||||
}
|
||||
}
|
||||
12
packages/napcat-core/external/napi2native.json
vendored
12
packages/napcat-core/external/napi2native.json
vendored
@@ -154,5 +154,17 @@
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "0A18D0C",
|
||||
"recv": "1D4BF0D"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "0A697CC",
|
||||
"recv": "1E86AC1"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "2756EF6",
|
||||
"recv": "0A36152"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "2313C68",
|
||||
"recv": "09693E4"
|
||||
}
|
||||
}
|
||||
12
packages/napcat-core/external/packet.json
vendored
12
packages/napcat-core/external/packet.json
vendored
@@ -662,5 +662,17 @@
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "2CEBB20",
|
||||
"recv": "2CEF0A0"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "2E59CC0",
|
||||
"recv": "2E5D240"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "451FE90",
|
||||
"recv": "4522A40"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "3D79168",
|
||||
"recv": "3D7BA78"
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = crypto.randomUUID();
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
return this.build(resId, packetMsg.map(msg => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
})),
|
||||
})), source, news, summary, prompt);
|
||||
})),
|
||||
source,
|
||||
news,
|
||||
summary,
|
||||
prompt,
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
packages/napcat-core/helper/session-proxy.ts
Normal file
140
packages/napcat-core/helper/session-proxy.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { ServiceNamingMapping } from '@/napcat-core/services/index';
|
||||
import { NTEventWrapper } from './event';
|
||||
|
||||
/**
|
||||
* 创建 Service 方法的代理
|
||||
* 拦截所有方法调用,通过 EventWrapper 进行调用
|
||||
*/
|
||||
function createServiceMethodProxy<S extends keyof ServiceNamingMapping>(
|
||||
serviceName: S,
|
||||
originalService: ServiceNamingMapping[S],
|
||||
eventWrapper: NTEventWrapper
|
||||
): ServiceNamingMapping[S] {
|
||||
return new Proxy(originalService as object, {
|
||||
get(target, prop, receiver) {
|
||||
const originalValue = Reflect.get(target, prop, receiver);
|
||||
|
||||
// 如果不是函数,直接返回原始值
|
||||
if (typeof originalValue !== 'function') {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
const methodName = prop as string;
|
||||
|
||||
// 返回一个包装函数,通过 EventWrapper 调用
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
|
||||
const eventPath = `${serviceName}/${methodName}`;
|
||||
|
||||
// 尝试通过 EventWrapper 调用
|
||||
try {
|
||||
// 使用 callNoListenerEvent 的底层实现逻辑
|
||||
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
|
||||
if (eventFunc) {
|
||||
return eventFunc(...args);
|
||||
}
|
||||
} catch {
|
||||
// 如果 EventWrapper 调用失败,回退到原始调用
|
||||
}
|
||||
|
||||
// 回退到原始方法调用
|
||||
return originalValue.apply(originalService, args);
|
||||
};
|
||||
},
|
||||
}) as ServiceNamingMapping[S];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Session 的双层代理
|
||||
* 第一层:拦截 getXXXService 方法
|
||||
* 第二层:拦截 Service 上的具体方法调用
|
||||
*/
|
||||
export function createSessionProxy(
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
// 缓存已代理的 Service,避免重复创建
|
||||
const serviceProxyCache = new Map<string, unknown>();
|
||||
|
||||
return new Proxy(session, {
|
||||
get(target, prop, receiver) {
|
||||
const propName = prop as string;
|
||||
|
||||
// 检查是否是 getXXXService 方法
|
||||
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
|
||||
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
|
||||
const servicePart = propName.slice(3); // 移除 'get' 前缀
|
||||
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
|
||||
|
||||
// 返回一个函数,该函数返回代理后的 Service
|
||||
return function () {
|
||||
// 检查缓存
|
||||
if (serviceProxyCache.has(serviceName)) {
|
||||
return serviceProxyCache.get(serviceName);
|
||||
}
|
||||
|
||||
// 获取原始 Service
|
||||
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
|
||||
const originalService = originalGetter.call(target);
|
||||
|
||||
// 检查是否在 ServiceNamingMapping 中定义
|
||||
if (isKnownService(serviceName)) {
|
||||
// 创建 Service 方法代理
|
||||
const proxiedService = createServiceMethodProxy(
|
||||
serviceName,
|
||||
originalService as ServiceNamingMapping[typeof serviceName],
|
||||
eventWrapper
|
||||
);
|
||||
serviceProxyCache.set(serviceName, proxiedService);
|
||||
return proxiedService;
|
||||
}
|
||||
|
||||
// 未知的 Service,直接返回原始对象
|
||||
serviceProxyCache.set(serviceName, originalService);
|
||||
return originalService;
|
||||
};
|
||||
}
|
||||
|
||||
// 非 getXXXService 方法,直接返回原始值
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Service 名称是否在已知的映射中
|
||||
*/
|
||||
function isKnownService(serviceName: string): serviceName is keyof ServiceNamingMapping {
|
||||
const knownServices: string[] = [
|
||||
'NodeIKernelAvatarService',
|
||||
'NodeIKernelBuddyService',
|
||||
'NodeIKernelFileAssistantService',
|
||||
'NodeIKernelGroupService',
|
||||
'NodeIKernelLoginService',
|
||||
'NodeIKernelMsgService',
|
||||
'NodeIKernelOnlineStatusService',
|
||||
'NodeIKernelProfileLikeService',
|
||||
'NodeIKernelProfileService',
|
||||
'NodeIKernelTicketService',
|
||||
'NodeIKernelStorageCleanService',
|
||||
'NodeIKernelRobotService',
|
||||
'NodeIKernelRichMediaService',
|
||||
'NodeIKernelDbToolsService',
|
||||
'NodeIKernelTipOffService',
|
||||
'NodeIKernelSearchService',
|
||||
'NodeIKernelCollectionService',
|
||||
];
|
||||
return knownServices.includes(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有 EventWrapper 集成的 InstanceContext
|
||||
* 这是推荐的使用方式,在创建 context 时自动代理 session
|
||||
*/
|
||||
export function createProxiedSession(
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
return createSessionProxy(session, eventWrapper);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
|
||||
import { NTEventWrapper } from '@/napcat-core/helper/event';
|
||||
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
|
||||
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
import os from 'node:os';
|
||||
@@ -44,7 +45,9 @@ export * from './helper/log';
|
||||
export * from './helper/qq-basic-info';
|
||||
export * from './helper/event';
|
||||
export * from './helper/config';
|
||||
export * from './helper/config-base';
|
||||
export * from './helper/proxy-handler';
|
||||
export * from './helper/session-proxy';
|
||||
|
||||
export enum NapCatCoreWorkingEnv {
|
||||
Unknown = 0,
|
||||
@@ -118,9 +121,19 @@ export class NapCatCore {
|
||||
// 通过构造器递过去的 runtime info 应该尽量少
|
||||
constructor (context: InstanceContext, selfInfo: SelfInfo) {
|
||||
this.selfInfo = selfInfo;
|
||||
this.context = context;
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
// 先用原始 session 创建 eventWrapper
|
||||
this.eventWrapper = new NTEventWrapper(context.session);
|
||||
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
|
||||
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
|
||||
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
|
||||
this.context = {
|
||||
...context,
|
||||
session: proxiedSession,
|
||||
};
|
||||
} else {
|
||||
this.context = context;
|
||||
}
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
|
||||
this.apis = {
|
||||
FileApi: new NTQQFileApi(this.context, this),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||
import { gunzipSync } from 'zlib';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export class PacketOperationContext {
|
||||
private readonly context: PacketContext;
|
||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||
}
|
||||
|
||||
@@ -94,12 +95,15 @@ export class PacketOperationContext {
|
||||
.filter(Boolean)
|
||||
);
|
||||
const res = await Promise.allSettled(reqList);
|
||||
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
const failedCount = res.filter((r) => r.status === 'rejected').length;
|
||||
if (failedCount > 0) {
|
||||
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async UploadImage (img: PacketMsgPicElement) {
|
||||
@@ -224,7 +228,15 @@ export class PacketOperationContext {
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
|
||||
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||
//await this.SendPreprocess(msg, groupUin);
|
||||
// 遍历上传资源
|
||||
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
|
||||
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
async MoveGroupFile (
|
||||
groupUin: number,
|
||||
fileUUID: string,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import zlib from 'node:zlib';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
export interface UploadForwardMsgParams {
|
||||
actionCommand: string;
|
||||
actionMsg: PacketMsg[];
|
||||
}
|
||||
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
|
||||
const reqdata = msg.map((item) => ({
|
||||
actionCommand: item.actionCommand,
|
||||
actionData: {
|
||||
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
|
||||
}
|
||||
}));
|
||||
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
|
||||
{
|
||||
action: reqdata,
|
||||
}
|
||||
);
|
||||
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
|
||||
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
|
||||
{
|
||||
info: {
|
||||
type: groupUin === 0 ? 1 : 3,
|
||||
uid: {
|
||||
uid: groupUin === 0 ? selfUid : groupUin.toString(),
|
||||
},
|
||||
groupUin,
|
||||
payload,
|
||||
},
|
||||
settings: {
|
||||
field1: 4, field2: 1, field3: 7, field4: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||
data: PacketBufBuilder(req),
|
||||
};
|
||||
}
|
||||
|
||||
parse (data: Buffer) {
|
||||
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new UploadForwardMsgV2();
|
||||
@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
|
||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||
4
packages/napcat-develop/config/.env.example
Normal file
4
packages/napcat-develop/config/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
NAPCAT_DISABLE_PIPE=1
|
||||
NAPCAT_DISABLE_MULTI_PROCESS=1
|
||||
NAPCAT_WEBUI_JWT_SECRET_KEY=napcat_dev_secret_key
|
||||
NAPCAT_WEBUI_SECRET_KEY=napcatqq
|
||||
39
packages/napcat-develop/config/onebot11.json
Normal file
39
packages/napcat-develop/config/onebot11.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"network": {
|
||||
"httpServers": [
|
||||
{
|
||||
"enable": true,
|
||||
"name": "HTTP",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3000,
|
||||
"enableCors": true,
|
||||
"enableWebsocket": false,
|
||||
"messagePostFormat": "array",
|
||||
"token": "",
|
||||
"debug": false
|
||||
}
|
||||
],
|
||||
"httpSseServers": [],
|
||||
"httpClients": [],
|
||||
"websocketServers": [
|
||||
{
|
||||
"enable": true,
|
||||
"name": "WebSocket",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3001,
|
||||
"reportSelfMessage": false,
|
||||
"enableForcePushEvent": true,
|
||||
"messagePostFormat": "array",
|
||||
"token": "",
|
||||
"debug": false,
|
||||
"heartInterval": 30000
|
||||
}
|
||||
],
|
||||
"websocketClients": [],
|
||||
"plugins": []
|
||||
},
|
||||
"musicSignUrl": "",
|
||||
"enableLocalFile2Url": false,
|
||||
"parseMultMsg": false,
|
||||
"imageDownloadProxy": ""
|
||||
}
|
||||
@@ -78,7 +78,7 @@ async function copyAll () {
|
||||
process.env.NAPCAT_WORKDIR = TARGET_DIR;
|
||||
// 开发环境使用固定密钥
|
||||
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
||||
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcat';
|
||||
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcatqq';
|
||||
console.log('Loading NapCat module...');
|
||||
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"name": "napcat-develop",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "powershell ./nodeTest.ps1"
|
||||
"name": "napcat-develop",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "powershell ./nodeTest.ps1",
|
||||
"copy-env": "xcopy config ..\\napcat-shell\\dist\\config /E /I /Y"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./index.js"
|
||||
},
|
||||
"./*": {
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/index';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
@@ -79,11 +79,14 @@ export async function NCoreInitFramework (
|
||||
// 启动WebUi
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
// 初始化LLNC的Onebot实现
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
await oneBotAdapter.InitOneBot();
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
await adapterManager.initAdapters();
|
||||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||||
if (oneBotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
export class NapCatFramework {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ const FrameworkBaseConfig = () =>
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
Binary file not shown.
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 962 B |
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -1,5 +1,12 @@
|
||||
import { BmpParser } from '@/napcat-image-size/src/parser/BmpParser';
|
||||
import { GifParser } from '@/napcat-image-size/src/parser/GifParser';
|
||||
import { JpegParser } from '@/napcat-image-size/src/parser/JpegParser';
|
||||
import { PngParser } from '@/napcat-image-size/src/parser/PngParser';
|
||||
import { TiffParser } from '@/napcat-image-size/src/parser/TiffParser';
|
||||
import { WebpParser } from '@/napcat-image-size/src/parser/WebpParser';
|
||||
import * as fs from 'fs';
|
||||
import { ReadStream } from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface ImageSize {
|
||||
width: number;
|
||||
@@ -12,17 +19,18 @@ export enum ImageType {
|
||||
BMP = 'bmp',
|
||||
GIF = 'gif',
|
||||
WEBP = 'webp',
|
||||
TIFF = 'tiff',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
interface ImageParser {
|
||||
export interface ImageParser {
|
||||
readonly type: ImageType;
|
||||
canParse(buffer: Buffer): boolean;
|
||||
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
|
||||
canParse (buffer: Buffer): boolean;
|
||||
parseSize (stream: ReadStream): Promise<ImageSize | undefined>;
|
||||
}
|
||||
|
||||
// 魔术匹配
|
||||
function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
if (buffer.length < offset + magic.length) {
|
||||
return false;
|
||||
}
|
||||
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PNG解析器
|
||||
class PngParser implements ImageParser {
|
||||
readonly type = ImageType.PNG;
|
||||
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
||||
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
// 所有解析器实例
|
||||
const parserInstances = {
|
||||
png: new PngParser(),
|
||||
jpeg: new JpegParser(),
|
||||
bmp: new BmpParser(),
|
||||
gif: new GifParser(),
|
||||
webp: new WebpParser(),
|
||||
tiff: new TiffParser(),
|
||||
};
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.PNG_SIGNATURE);
|
||||
}
|
||||
// 首字节到可能的图片类型映射,用于快速筛选
|
||||
const firstByteMap = new Map<number, ImageType[]>([
|
||||
[0x42, [ImageType.BMP]], // 'B' - BMP
|
||||
[0x47, [ImageType.GIF]], // 'G' - GIF
|
||||
[0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian)
|
||||
[0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian)
|
||||
[0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP)
|
||||
[0x89, [ImageType.PNG]], // PNG signature
|
||||
[0xFF, [ImageType.JPEG]], // JPEG SOI
|
||||
]);
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(24) as Buffer;
|
||||
if (!buf || buf.length < 24) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32BE(16);
|
||||
const height = buf.readUInt32BE(20);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// 类型到解析器的映射
|
||||
const typeToParser = new Map<ImageType, ImageParser>([
|
||||
[ImageType.PNG, parserInstances.png],
|
||||
[ImageType.JPEG, parserInstances.jpeg],
|
||||
[ImageType.BMP, parserInstances.bmp],
|
||||
[ImageType.GIF, parserInstances.gif],
|
||||
[ImageType.WEBP, parserInstances.webp],
|
||||
[ImageType.TIFF, parserInstances.tiff],
|
||||
]);
|
||||
|
||||
// JPEG解析器
|
||||
class JpegParser implements ImageParser {
|
||||
readonly type = ImageType.JPEG;
|
||||
// JPEG 魔术头:FF D8
|
||||
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
||||
|
||||
// JPEG标记常量
|
||||
private readonly SOF_MARKERS = {
|
||||
SOF0: 0xC0, // 基线DCT
|
||||
SOF1: 0xC1, // 扩展顺序DCT
|
||||
SOF2: 0xC2, // 渐进式DCT
|
||||
SOF3: 0xC3, // 无损
|
||||
} as const;
|
||||
|
||||
// 非SOF标记
|
||||
private readonly NON_SOF_MARKERS: number[] = [
|
||||
0xC4, // DHT
|
||||
0xC8, // JPEG扩展
|
||||
0xCC, // DAC
|
||||
] as const;
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
||||
}
|
||||
|
||||
isSOFMarker (marker: number): boolean {
|
||||
return (
|
||||
marker === this.SOF_MARKERS.SOF0 ||
|
||||
marker === this.SOF_MARKERS.SOF1 ||
|
||||
marker === this.SOF_MARKERS.SOF2 ||
|
||||
marker === this.SOF_MARKERS.SOF3
|
||||
);
|
||||
}
|
||||
|
||||
isNonSOFMarker (marker: number): boolean {
|
||||
return this.NON_SOF_MARKERS.includes(marker);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
||||
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
||||
let buffer = Buffer.alloc(0);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
|
||||
// 处理错误
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 处理数据块
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
// 追加新数据到缓冲区
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
||||
offset = 0;
|
||||
|
||||
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
||||
const bufferSize = buffer.length;
|
||||
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
||||
|
||||
// 从JPEG头部后开始扫描
|
||||
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
||||
// 寻找FF标记
|
||||
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
||||
const marker = buffer[offset + 1];
|
||||
if (!marker) {
|
||||
break;
|
||||
}
|
||||
// 跳过非SOF标记
|
||||
if (this.isNonSOFMarker(marker)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理SOF标记 (包含尺寸信息)
|
||||
if (this.isSOFMarker(marker)) {
|
||||
// 确保缓冲区中有足够数据读取尺寸
|
||||
if (offset + 9 < bufferSize) {
|
||||
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
||||
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
|
||||
found = true;
|
||||
stream.destroy();
|
||||
resolve({ width, height });
|
||||
return;
|
||||
} else {
|
||||
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset++;
|
||||
}
|
||||
|
||||
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
||||
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
||||
if (offset > BUFFER_SIZE) {
|
||||
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
||||
if (offset > KEEP_BYTES) {
|
||||
buffer = buffer.subarray(offset - KEEP_BYTES);
|
||||
offset = KEEP_BYTES;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理流结束
|
||||
stream.on('end', () => {
|
||||
if (!found) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// BMP解析器
|
||||
class BmpParser implements ImageParser {
|
||||
readonly type = ImageType.BMP;
|
||||
// BMP 魔术头:42 4D (BM)
|
||||
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.BMP_SIGNATURE);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(26) as Buffer;
|
||||
if (!buf || buf.length < 26) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32LE(18);
|
||||
const height = buf.readUInt32LE(22);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// GIF解析器
|
||||
class GifParser implements ImageParser {
|
||||
readonly type = ImageType.GIF;
|
||||
// GIF87a 魔术头:47 49 46 38 37 61
|
||||
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||
// GIF89a 魔术头:47 49 46 38 39 61
|
||||
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
||||
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
||||
);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(10) as Buffer;
|
||||
if (!buf || buf.length < 10) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt16LE(6);
|
||||
const height = buf.readUInt16LE(8);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||||
class WebpParser implements ImageParser {
|
||||
readonly type = ImageType.WEBP;
|
||||
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
||||
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
||||
// WEBP 魔术头:57 45 42 50 (WEBP)
|
||||
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
// WEBP 块头
|
||||
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
||||
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
||||
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 12 &&
|
||||
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
||||
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
||||
);
|
||||
}
|
||||
|
||||
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
||||
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 需要读取足够的字节来检测所有三种格式
|
||||
const MAX_HEADER_SIZE = 32;
|
||||
let totalBytes = 0;
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer, chunkBuffer]);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
// 检查是否有足够的字节进行格式检测
|
||||
if (totalBytes >= MAX_HEADER_SIZE) {
|
||||
stream.destroy();
|
||||
|
||||
// 检查基本的WEBP签名
|
||||
if (!this.canParse(buffer)) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
|
||||
// 检查chunk头部,位于字节12-15
|
||||
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
||||
// VP8格式 - 标准WebP
|
||||
// 宽度和高度在帧头中
|
||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
||||
// VP8L格式 - 无损WebP
|
||||
// 1字节标记后是14位宽度和14位高度
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = 1 + (bits & 0x3FFF);
|
||||
const height = 1 + ((bits >> 14) & 0x3FFF);
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
||||
// VP8X格式 - 扩展WebP
|
||||
// 24位宽度和高度(减去1)
|
||||
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
|
||||
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
|
||||
return resolve({ width, height });
|
||||
} else {
|
||||
// 未知的WebP子格式
|
||||
return resolve(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
// 如果没有读到足够的字节
|
||||
if (totalBytes < MAX_HEADER_SIZE) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parsers: ReadonlyArray<ImageParser> = [
|
||||
new PngParser(),
|
||||
new JpegParser(),
|
||||
new BmpParser(),
|
||||
new GifParser(),
|
||||
new WebpParser(),
|
||||
];
|
||||
// 所有解析器列表(用于回退)
|
||||
const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
|
||||
|
||||
export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
end: 63,
|
||||
});
|
||||
|
||||
let buffer: Buffer | null = null;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
stream.once('error', (err) => {
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.once('readable', () => {
|
||||
buffer = stream.read(64) as Buffer;
|
||||
stream.destroy();
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
chunks.push(chunkBuffer);
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
stream.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return resolve(ImageType.UNKNOWN);
|
||||
}
|
||||
|
||||
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
|
||||
resolve(ImageType.UNKNOWN);
|
||||
});
|
||||
|
||||
stream.once('end', () => {
|
||||
if (!buffer) {
|
||||
resolve(ImageType.UNKNOWN);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
|
||||
try {
|
||||
// 先检测类型
|
||||
const type = await detectImageType(filePath);
|
||||
const parser = parsers.find(p => p.type === type);
|
||||
const parser = typeToParser.get(type);
|
||||
if (!parser) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
|
||||
): Promise<ImageSize> {
|
||||
return await imageSizeFromFile(filePath) ?? fallback;
|
||||
}
|
||||
|
||||
// 从 Buffer 创建可读流
|
||||
function bufferToReadStream (buffer: Buffer): ReadStream {
|
||||
const readable = new Readable({
|
||||
read () {
|
||||
this.push(buffer);
|
||||
this.push(null);
|
||||
}
|
||||
});
|
||||
return readable as unknown as ReadStream;
|
||||
}
|
||||
|
||||
// 从 Buffer 检测图片类型(使用首字节快速筛选)
|
||||
export function detectImageTypeFromBuffer (buffer: Buffer): ImageType {
|
||||
if (buffer.length === 0) {
|
||||
return ImageType.UNKNOWN;
|
||||
}
|
||||
|
||||
const firstByte = buffer[0]!;
|
||||
const possibleTypes = firstByteMap.get(firstByte);
|
||||
|
||||
if (possibleTypes) {
|
||||
// 根据首字节快速筛选可能的类型
|
||||
for (const type of possibleTypes) {
|
||||
const parser = typeToParser.get(type);
|
||||
if (parser && parser.canParse(buffer)) {
|
||||
return parser.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:遍历所有解析器
|
||||
for (const parser of parsers) {
|
||||
if (parser.canParse(buffer)) {
|
||||
return parser.type;
|
||||
}
|
||||
}
|
||||
|
||||
return ImageType.UNKNOWN;
|
||||
}
|
||||
|
||||
// 从 Buffer 解析图片尺寸
|
||||
export async function imageSizeFromBuffer (buffer: Buffer): Promise<ImageSize | undefined> {
|
||||
const type = detectImageTypeFromBuffer(buffer);
|
||||
const parser = typeToParser.get(type);
|
||||
if (!parser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = bufferToReadStream(buffer);
|
||||
return await parser.parseSize(stream);
|
||||
} catch (err) {
|
||||
console.error(`解析图片尺寸出错: ${err}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 从 Buffer 解析图片尺寸,带回退值
|
||||
export async function imageSizeFromBufferFallBack (
|
||||
buffer: Buffer,
|
||||
fallback: ImageSize = {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
): Promise<ImageSize> {
|
||||
return await imageSizeFromBuffer(buffer) ?? fallback;
|
||||
}
|
||||
|
||||
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// BMP解析器
|
||||
export class BmpParser implements ImageParser {
|
||||
readonly type = ImageType.BMP;
|
||||
// BMP 魔术头:42 4D (BM)
|
||||
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.BMP_SIGNATURE);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(26) as Buffer;
|
||||
if (!buf || buf.length < 26) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32LE(18);
|
||||
const height = buf.readUInt32LE(22);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// GIF解析器
|
||||
export class GifParser implements ImageParser {
|
||||
readonly type = ImageType.GIF;
|
||||
// GIF87a 魔术头:47 49 46 38 37 61
|
||||
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||
// GIF89a 魔术头:47 49 46 38 39 61
|
||||
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
||||
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
||||
);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(10) as Buffer;
|
||||
if (!buf || buf.length < 10) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt16LE(6);
|
||||
const height = buf.readUInt16LE(8);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// JPEG解析器
|
||||
export class JpegParser implements ImageParser {
|
||||
readonly type = ImageType.JPEG;
|
||||
// JPEG 魔术头:FF D8
|
||||
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
||||
|
||||
// JPEG标记常量
|
||||
private readonly SOF_MARKERS = {
|
||||
SOF0: 0xC0, // 基线DCT
|
||||
SOF1: 0xC1, // 扩展顺序DCT
|
||||
SOF2: 0xC2, // 渐进式DCT
|
||||
SOF3: 0xC3, // 无损
|
||||
} as const;
|
||||
|
||||
// 非SOF标记
|
||||
private readonly NON_SOF_MARKERS: number[] = [
|
||||
0xC4, // DHT
|
||||
0xC8, // JPEG扩展
|
||||
0xCC, // DAC
|
||||
] as const;
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
||||
}
|
||||
|
||||
isSOFMarker (marker: number): boolean {
|
||||
return (
|
||||
marker === this.SOF_MARKERS.SOF0 ||
|
||||
marker === this.SOF_MARKERS.SOF1 ||
|
||||
marker === this.SOF_MARKERS.SOF2 ||
|
||||
marker === this.SOF_MARKERS.SOF3
|
||||
);
|
||||
}
|
||||
|
||||
isNonSOFMarker (marker: number): boolean {
|
||||
return this.NON_SOF_MARKERS.includes(marker);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
||||
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
||||
let buffer = Buffer.alloc(0);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
|
||||
// 处理错误
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 处理数据块
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
// 追加新数据到缓冲区
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
||||
offset = 0;
|
||||
|
||||
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
||||
const bufferSize = buffer.length;
|
||||
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
||||
|
||||
|
||||
// 从JPEG头部后开始扫描
|
||||
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
||||
// 寻找FF标记
|
||||
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
||||
const marker = buffer[offset + 1];
|
||||
if (!marker) {
|
||||
break;
|
||||
}
|
||||
// 跳过非SOF标记
|
||||
if (this.isNonSOFMarker(marker)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理SOF标记 (包含尺寸信息)
|
||||
if (this.isSOFMarker(marker)) {
|
||||
// 确保缓冲区中有足够数据读取尺寸
|
||||
if (offset + 9 < bufferSize) {
|
||||
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
||||
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
|
||||
found = true;
|
||||
stream.destroy();
|
||||
resolve({ width, height });
|
||||
return;
|
||||
} else {
|
||||
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset++;
|
||||
}
|
||||
|
||||
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
||||
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
||||
if (offset > BUFFER_SIZE) {
|
||||
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
||||
if (offset > KEEP_BYTES) {
|
||||
buffer = buffer.subarray(offset - KEEP_BYTES);
|
||||
offset = KEEP_BYTES;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理流结束
|
||||
stream.on('end', () => {
|
||||
if (!found) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// PNG解析器
|
||||
export class PngParser implements ImageParser {
|
||||
readonly type = ImageType.PNG;
|
||||
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
||||
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.PNG_SIGNATURE);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(24) as Buffer;
|
||||
if (!buf || buf.length < 24) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32BE(16);
|
||||
const height = buf.readUInt32BE(20);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// TIFF解析器
|
||||
export class TiffParser implements ImageParser {
|
||||
readonly type = ImageType.TIFF;
|
||||
// TIFF Little Endian 魔术头:49 49 2A 00 (II)
|
||||
private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00];
|
||||
// TIFF Big Endian 魔术头:4D 4D 00 2A (MM)
|
||||
private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
matchMagic(buffer, this.TIFF_LE_SIGNATURE) ||
|
||||
matchMagic(buffer, this.TIFF_BE_SIGNATURE)
|
||||
);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
const MAX_BYTES = 64 * 1024; // 最多读取 64KB
|
||||
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
chunks.push(chunkBuffer);
|
||||
totalBytes += chunkBuffer.length;
|
||||
|
||||
if (totalBytes >= MAX_BYTES) {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const size = this.parseTiffSize(buffer);
|
||||
resolve(size);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (chunks.length > 0) {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const size = this.parseTiffSize(buffer);
|
||||
resolve(size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private parseTiffSize (buffer: Buffer): ImageSize | undefined {
|
||||
if (buffer.length < 8) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 判断字节序
|
||||
const isLittleEndian = buffer[0] === 0x49; // 'I'
|
||||
|
||||
const readUInt16 = isLittleEndian
|
||||
? (offset: number) => buffer.readUInt16LE(offset)
|
||||
: (offset: number) => buffer.readUInt16BE(offset);
|
||||
|
||||
const readUInt32 = isLittleEndian
|
||||
? (offset: number) => buffer.readUInt32LE(offset)
|
||||
: (offset: number) => buffer.readUInt32BE(offset);
|
||||
|
||||
// 获取第一个 IFD 的偏移量
|
||||
const ifdOffset = readUInt32(4);
|
||||
if (ifdOffset + 2 > buffer.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 读取 IFD 条目数量
|
||||
const numEntries = readUInt16(ifdOffset);
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
|
||||
// TIFF 标签
|
||||
const TAG_IMAGE_WIDTH = 0x0100;
|
||||
const TAG_IMAGE_HEIGHT = 0x0101;
|
||||
|
||||
// 遍历 IFD 条目
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = ifdOffset + 2 + i * 12;
|
||||
if (entryOffset + 12 > buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tag = readUInt16(entryOffset);
|
||||
const type = readUInt16(entryOffset + 2);
|
||||
// const count = readUInt32(entryOffset + 4);
|
||||
|
||||
// 根据类型读取值
|
||||
let value: number;
|
||||
if (type === 3) {
|
||||
// SHORT (2 bytes)
|
||||
value = readUInt16(entryOffset + 8);
|
||||
} else if (type === 4) {
|
||||
// LONG (4 bytes)
|
||||
value = readUInt32(entryOffset + 8);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag === TAG_IMAGE_WIDTH) {
|
||||
width = value;
|
||||
} else if (tag === TAG_IMAGE_HEIGHT) {
|
||||
height = value;
|
||||
}
|
||||
|
||||
if (width !== undefined && height !== undefined) {
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== undefined && height !== undefined) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||||
export class WebpParser implements ImageParser {
|
||||
readonly type = ImageType.WEBP;
|
||||
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
||||
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
||||
// WEBP 魔术头:57 45 42 50 (WEBP)
|
||||
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
// WEBP 块头
|
||||
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
||||
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
||||
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 12 &&
|
||||
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
||||
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
||||
);
|
||||
}
|
||||
|
||||
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
||||
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 需要读取足够的字节来检测所有三种格式
|
||||
const MAX_HEADER_SIZE = 32;
|
||||
let totalBytes = 0;
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer, chunkBuffer]);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
// 检查是否有足够的字节进行格式检测
|
||||
if (totalBytes >= MAX_HEADER_SIZE) {
|
||||
stream.destroy();
|
||||
|
||||
// 检查基本的WEBP签名
|
||||
if (!this.canParse(buffer)) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
|
||||
// 检查chunk头部,位于字节12-15
|
||||
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
||||
// VP8格式 - 标准WebP
|
||||
// 宽度和高度在帧头中
|
||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
||||
// VP8L格式 - 无损WebP
|
||||
// 1字节标记后是14位宽度和14位高度
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = 1 + (bits & 0x3FFF);
|
||||
const height = 1 + ((bits >> 14) & 0x3FFF);
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
||||
// VP8X格式 - 扩展WebP
|
||||
// 24位宽度和高度(减去1)
|
||||
if (buffer.length < 30) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF);
|
||||
const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF);
|
||||
return resolve({ width, height });
|
||||
} else {
|
||||
// 未知的WebP子格式
|
||||
return resolve(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
// 如果没有读到足够的字节
|
||||
if (totalBytes < MAX_HEADER_SIZE) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { Type } from '@sinclair/typebox';
|
||||
|
||||
export class BotExit extends OneBotAction<void, void> {
|
||||
override actionName = ActionName.Exit;
|
||||
override payloadSchema = Type.Void();
|
||||
override returnSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = Type.Object({});
|
||||
override actionSummary = '退出登录';
|
||||
override actionTags = ['系统扩展'];
|
||||
override payloadExample = {};
|
||||
|
||||
@@ -12,7 +12,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetClientkey extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetClientkey;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取ClientKey';
|
||||
override actionDescription = '获取当前登录帐号的ClientKey';
|
||||
|
||||
@@ -18,7 +18,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetFriendsWithCategory;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取带分组的好友列表';
|
||||
override actionTags = ['用户扩展'];
|
||||
|
||||
@@ -22,7 +22,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetGroupIgnoreAddRequest;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取群被忽略的加群请求';
|
||||
override actionTags = ['群组接口'];
|
||||
|
||||
@@ -8,7 +8,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
|
||||
override actionName = ActionName.GetRkey;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取 RKey';
|
||||
override actionTags = ['系统扩展'];
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetRobotUinRange extends OneBotAction<void, ReturnType> {
|
||||
override returnExample = [
|
||||
{ minUin: '12345678', maxUin: '87654321' }
|
||||
];
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
|
||||
async _handle () {
|
||||
|
||||
@@ -19,7 +19,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetUnidirectionalFriendList;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取单向好友列表';
|
||||
override actionTags = ['用户扩展'];
|
||||
|
||||
@@ -22,7 +22,7 @@ export class CreateFlashTask extends OneBotAction<CreateFlashTaskPayload, any> {
|
||||
override actionName = ActionName.CreateFlashTask;
|
||||
override payloadSchema = CreateFlashTaskPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '任务创建结果' });
|
||||
override actionSummary = '创建闪照任务';
|
||||
override actionSummary = '创建闪传任务';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
files: 'C:\\test.jpg',
|
||||
|
||||
@@ -12,7 +12,7 @@ export class GetFlashFileList extends OneBotAction<GetFlashFileListPayload, any>
|
||||
override actionName = ActionName.GetFlashFileList;
|
||||
override payloadSchema = GetFlashFileListPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '文件列表' });
|
||||
override actionSummary = '获取闪照文件列表';
|
||||
override actionSummary = '获取闪传文件列表';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123'
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetFlashFileUrl extends OneBotAction<GetFlashFileUrlPayload, any> {
|
||||
override actionName = ActionName.GetFlashFileUrl;
|
||||
override payloadSchema = GetFlashFileUrlPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '文件下载链接' });
|
||||
override actionSummary = '获取闪照文件链接';
|
||||
override actionSummary = '获取闪传文件链接';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123'
|
||||
|
||||
@@ -15,7 +15,7 @@ export class SendFlashMsg extends OneBotAction<SendFlashMsgPayload, any> {
|
||||
override actionName = ActionName.SendFlashMsg;
|
||||
override payloadSchema = SendFlashMsgPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '发送结果' });
|
||||
override actionSummary = '发送闪照消息';
|
||||
override actionSummary = '发送闪传消息';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
||||
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export const SendMsgPayloadSchema = Type.Object({
|
||||
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
||||
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp: number = 0): Promise<{
|
||||
finallySendElements: SendArkElement,
|
||||
res_id?: string,
|
||||
uuid?: string,
|
||||
packetMsg: PacketMsg[],
|
||||
deleteAfterSentFiles: string[],
|
||||
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
|
||||
} | null> {
|
||||
const packetMsg: PacketMsg[] = [];
|
||||
const delFiles: string[] = [];
|
||||
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
|
||||
for (const node of messageNodes) {
|
||||
if (dp >= 3) {
|
||||
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
||||
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp + 1);
|
||||
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
||||
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
||||
if (uploadReturnData?.uuid) {
|
||||
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
|
||||
uploadReturnData.innerPacketMsg?.forEach(m => {
|
||||
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
||||
sendElements = sendElementsCreateReturn.sendElements;
|
||||
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
||||
return null;
|
||||
}
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
|
||||
const uploadMsgData: UploadForwardMsgParams[] = [{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionMsg: packetMsg,
|
||||
}];
|
||||
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
|
||||
uploadMsgData.push({
|
||||
actionCommand: uuid,
|
||||
actionMsg: msg,
|
||||
});
|
||||
});
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const uuid = crypto.randomUUID();
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
|
||||
return {
|
||||
deleteAfterSentFiles: delFiles,
|
||||
finallySendElements: {
|
||||
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
},
|
||||
} as SendArkElement,
|
||||
res_id: resid,
|
||||
uuid: uuid,
|
||||
packetMsg: packetMsg,
|
||||
innerPacketMsg: innerMsg,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1288,7 +1288,8 @@ export class OneBotMsgApi {
|
||||
}
|
||||
realUri = await this.handleObfuckName(realUri) ?? realUri;
|
||||
try {
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri);
|
||||
const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined;
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri, undefined, undefined, proxy);
|
||||
if (!success) {
|
||||
this.core.context.logger.logError('文件处理失败', errMsg);
|
||||
throw new Error('文件处理失败: ' + errMsg);
|
||||
|
||||
@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
|
||||
musicSignUrl: Type.String({ default: '' }),
|
||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||
parseMultMsg: Type.Boolean({ default: false }),
|
||||
imageDownloadProxy: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type OneBotConfig = Static<typeof OneBotConfigSchema>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from 'path';
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
}
|
||||
@@ -255,7 +256,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +358,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
// 重新加载插件
|
||||
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
|
||||
if (isDirectory) {
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
File diff suppressed because it is too large
Load Diff
39
packages/napcat-onebot/network/plugin/config.ts
Normal file
39
packages/napcat-onebot/network/plugin/config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PluginConfigItem, PluginConfigSchema } from './types';
|
||||
|
||||
/**
|
||||
* NapCat 插件配置构建器
|
||||
* 提供便捷的配置项创建方法
|
||||
*/
|
||||
export class NapCatConfig {
|
||||
static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'string', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'number', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'boolean', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
|
||||
static html (content: string): PluginConfigItem {
|
||||
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
|
||||
}
|
||||
|
||||
static plainText (content: string): PluginConfigItem {
|
||||
return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content };
|
||||
}
|
||||
|
||||
static combine (...items: PluginConfigItem[]): PluginConfigSchema {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
23
packages/napcat-onebot/network/plugin/index.ts
Normal file
23
packages/napcat-onebot/network/plugin/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// 导出类型
|
||||
export type {
|
||||
PluginPackageJson,
|
||||
PluginConfigItem,
|
||||
PluginConfigSchema,
|
||||
INapCatConfigStatic,
|
||||
NapCatConfigClass,
|
||||
IPluginManager,
|
||||
PluginConfigUIController,
|
||||
PluginLogger,
|
||||
NapCatPluginContext,
|
||||
PluginModule,
|
||||
PluginRuntimeStatus,
|
||||
PluginRuntime,
|
||||
PluginEntry,
|
||||
PluginStatusConfig,
|
||||
} from './types';
|
||||
|
||||
// 导出配置构建器
|
||||
export { NapCatConfig } from './config';
|
||||
|
||||
// 导出加载器
|
||||
export { PluginLoader } from './loader';
|
||||
320
packages/napcat-onebot/network/plugin/loader.ts
Normal file
320
packages/napcat-onebot/network/plugin/loader.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import {
|
||||
PluginPackageJson,
|
||||
PluginModule,
|
||||
PluginEntry,
|
||||
PluginStatusConfig,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 插件加载器
|
||||
* 负责扫描、加载和导入插件模块
|
||||
*/
|
||||
export class PluginLoader {
|
||||
constructor (
|
||||
private readonly pluginPath: string,
|
||||
private readonly configPath: string,
|
||||
private readonly logger: LogWrapper
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 加载插件状态配置
|
||||
*/
|
||||
loadPluginStatusConfig (): PluginStatusConfig {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
this.logger.logWarn('[PluginLoader] Error parsing plugins.json', e);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件状态配置
|
||||
*/
|
||||
savePluginStatusConfig (config: PluginStatusConfig): void {
|
||||
try {
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
this.logger.logError('[PluginLoader] Error saving plugins.json', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描插件目录,收集所有有效插件条目(异步版本,验证模块有效性)
|
||||
* 只有包含有效 plugin_init 函数的插件才会被收集
|
||||
*/
|
||||
async scanPlugins (): Promise<PluginEntry[]> {
|
||||
const entries: PluginEntry[] = [];
|
||||
|
||||
// 确保插件目录存在
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
this.logger.logWarn(`[PluginLoader] Plugin directory does not exist: ${this.pluginPath}`);
|
||||
fs.mkdirSync(this.pluginPath, { recursive: true });
|
||||
return entries;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
const statusConfig = this.loadPluginStatusConfig();
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = this.scanDirectoryPlugin(item.name, statusConfig);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果没有入口文件,跳过
|
||||
if (!entry.entryPath) {
|
||||
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: no entry file found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果插件被禁用,跳过模块验证,直接添加到列表
|
||||
if (!entry.enable) {
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证模块有效性(仅对启用的插件)
|
||||
const validation = await this.validatePluginEntry(entry.entryPath);
|
||||
if (!validation.valid) {
|
||||
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: ${validation.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描单个目录插件
|
||||
*/
|
||||
private scanDirectoryPlugin (dirname: string, statusConfig: PluginStatusConfig): PluginEntry | null {
|
||||
const pluginDir = path.join(this.pluginPath, dirname);
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json
|
||||
let packageJson: PluginPackageJson | undefined;
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
packageJson = JSON.parse(packageContent);
|
||||
} catch (error) {
|
||||
this.logger.logWarn(`[PluginLoader] Invalid package.json in ${dirname}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取插件 ID(包名或目录名)
|
||||
const pluginId = packageJson?.name || dirname;
|
||||
|
||||
// 确定入口文件
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||
|
||||
// 获取启用状态(默认禁用,内置插件除外)
|
||||
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
|
||||
|
||||
// 创建插件条目
|
||||
const entry: PluginEntry = {
|
||||
id: pluginId,
|
||||
fileId: dirname,
|
||||
name: packageJson?.name,
|
||||
version: packageJson?.version,
|
||||
description: packageJson?.description,
|
||||
author: packageJson?.author,
|
||||
pluginPath: pluginDir,
|
||||
entryPath,
|
||||
packageJson,
|
||||
enable,
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'unloaded',
|
||||
},
|
||||
};
|
||||
|
||||
// 如果没有入口文件,标记为错误
|
||||
if (!entryPath) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: `No valid entry file found for plugin directory: ${dirname}`,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
} catch (error: any) {
|
||||
// 创建错误条目
|
||||
return {
|
||||
id: dirname, // 使用目录名作为 ID
|
||||
fileId: dirname,
|
||||
pluginPath: path.join(this.pluginPath, dirname),
|
||||
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error during scan',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件目录的入口文件
|
||||
*/
|
||||
private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
|
||||
const possibleEntries = [
|
||||
packageJson?.main,
|
||||
'index.mjs',
|
||||
'index.js',
|
||||
'main.mjs',
|
||||
'main.js',
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const entry of possibleEntries) {
|
||||
const entryPath = path.join(pluginDir, entry);
|
||||
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态导入模块
|
||||
*/
|
||||
async importModule (filePath: string): Promise<any> {
|
||||
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
|
||||
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
|
||||
return await import(fileUrlWithQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件模块
|
||||
*/
|
||||
async loadPluginModule (entry: PluginEntry): Promise<PluginModule | null> {
|
||||
if (!entry.entryPath) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: 'No entry path specified',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await this.importModule(entry.entryPath);
|
||||
|
||||
if (!this.isValidPluginModule(module)) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: 'Invalid plugin module: missing plugin_init function',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
return module;
|
||||
} catch (error: any) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: error.message || 'Failed to import module',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否为有效的插件模块
|
||||
*/
|
||||
isValidPluginModule (module: any): module is PluginModule {
|
||||
return module && typeof module.plugin_init === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证插件入口文件是否包含有效的 plugin_init 函数
|
||||
* 用于扫描阶段快速验证
|
||||
*/
|
||||
async validatePluginEntry (entryPath: string): Promise<{ valid: boolean; error?: string; }> {
|
||||
try {
|
||||
const module = await this.importModule(entryPath);
|
||||
if (this.isValidPluginModule(module)) {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: false, error: 'Missing plugin_init function' };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || 'Failed to import module' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新扫描单个插件
|
||||
*/
|
||||
rescanPlugin (dirname: string): PluginEntry | null {
|
||||
const statusConfig = this.loadPluginStatusConfig();
|
||||
return this.scanDirectoryPlugin(dirname, statusConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 查找插件目录名
|
||||
*/
|
||||
findPluginDirById (pluginId: string): string | null {
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
|
||||
const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.name === pluginId) {
|
||||
return item.name;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// 如果目录名就是 ID
|
||||
if (item.name === pluginId) {
|
||||
return item.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 清除插件文件的 require 缓存
|
||||
* 用于确保卸载插件时清理 CJS 模块缓存
|
||||
*/
|
||||
clearCache (pluginPath: string): void {
|
||||
try {
|
||||
// 规范化路径以确保匹配正确
|
||||
const normalizedPluginPath = path.resolve(pluginPath);
|
||||
|
||||
// 遍历缓存并删除属于该插件目录的模块
|
||||
Object.keys(require.cache).forEach((id) => {
|
||||
if (id.startsWith(normalizedPluginPath)) {
|
||||
delete require.cache[id];
|
||||
this.logger.logDebug(`[PluginLoader] Cleared cache for: ${id}`);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.logError('[PluginLoader] Error clearing module cache:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
528
packages/napcat-onebot/network/plugin/manager.ts
Normal file
528
packages/napcat-onebot/network/plugin/manager.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { PluginConfig } from '@/napcat-onebot/config/config';
|
||||
import { NapCatConfig } from './config';
|
||||
import { PluginLoader } from './loader';
|
||||
import { PluginRouterRegistryImpl } from './router-registry';
|
||||
import {
|
||||
PluginEntry,
|
||||
PluginLogger,
|
||||
PluginStatusConfig,
|
||||
NapCatPluginContext,
|
||||
IPluginManager,
|
||||
} from './types';
|
||||
|
||||
export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
||||
private readonly pluginPath: string;
|
||||
private readonly configPath: string;
|
||||
private readonly loader: PluginLoader;
|
||||
|
||||
/** 插件注册表: ID -> 插件条目 */
|
||||
private plugins: Map<string, PluginEntry> = new Map();
|
||||
|
||||
/** 插件路由注册表: pluginId -> PluginRouterRegistry */
|
||||
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
|
||||
|
||||
declare config: PluginConfig;
|
||||
public NapCatConfig = NapCatConfig;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.getLoadedPlugins().length > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap
|
||||
) {
|
||||
const config = {
|
||||
name,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
enable: true,
|
||||
debug: true,
|
||||
};
|
||||
super(name, config, core, obContext, actions);
|
||||
this.pluginPath = this.core.context.pathWrapper.pluginPath;
|
||||
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
|
||||
this.loader = new PluginLoader(this.pluginPath, this.configPath, this.logger);
|
||||
}
|
||||
|
||||
// ==================== 插件状态配置 ====================
|
||||
|
||||
public getPluginConfig (): PluginStatusConfig {
|
||||
return this.loader.loadPluginStatusConfig();
|
||||
}
|
||||
|
||||
private savePluginConfig (config: PluginStatusConfig): void {
|
||||
this.loader.savePluginStatusConfig(config);
|
||||
}
|
||||
|
||||
// ==================== 插件扫描与加载 ====================
|
||||
|
||||
/**
|
||||
* 扫描并加载所有插件
|
||||
*/
|
||||
private async scanAndLoadPlugins (): Promise<void> {
|
||||
// 扫描所有插件目录
|
||||
const entries = await this.loader.scanPlugins();
|
||||
|
||||
// 清空现有注册表
|
||||
this.plugins.clear();
|
||||
|
||||
// 注册所有插件条目
|
||||
for (const entry of entries) {
|
||||
this.plugins.set(entry.id, entry);
|
||||
}
|
||||
|
||||
this.logger.log(`[PluginManager] Scanned ${this.plugins.size} plugins`);
|
||||
|
||||
// 加载启用的插件
|
||||
for (const entry of this.plugins.values()) {
|
||||
if (entry.enable && entry.runtime.status !== 'error') {
|
||||
await this.loadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const loadedCount = this.getLoadedPlugins().length;
|
||||
this.logger.log(`[PluginManager] Loaded ${loadedCount} plugins`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个插件
|
||||
*/
|
||||
private async loadPlugin (entry: PluginEntry): Promise<boolean> {
|
||||
if (entry.loaded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.runtime.status === 'error') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 加载模块
|
||||
const module = await this.loader.loadPluginModule(entry);
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建上下文
|
||||
const context = this.createPluginContext(entry);
|
||||
|
||||
// 初始化插件
|
||||
try {
|
||||
await module.plugin_init(context);
|
||||
|
||||
entry.loaded = true;
|
||||
entry.runtime = {
|
||||
status: 'loaded',
|
||||
module,
|
||||
context,
|
||||
};
|
||||
|
||||
this.logger.log(`[PluginManager] Initialized plugin: ${entry.id}${entry.version ? ` v${entry.version}` : ''}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: error.message || 'Initialization failed',
|
||||
};
|
||||
|
||||
this.logger.logError(`[PluginManager] Error initializing plugin ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个插件
|
||||
*/
|
||||
private async unloadPlugin (entry: PluginEntry): Promise<void> {
|
||||
if (!entry.loaded || entry.runtime.status !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, context } = entry.runtime;
|
||||
|
||||
// 调用清理方法
|
||||
if (module && context && typeof module.plugin_cleanup === 'function') {
|
||||
try {
|
||||
await module.plugin_cleanup(context);
|
||||
this.logger.log(`[PluginManager] Cleaned up plugin: ${entry.id}`);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error cleaning up plugin ${entry.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
status: 'unloaded',
|
||||
};
|
||||
|
||||
this.logger.log(`[PluginManager] Unloaded plugin: ${entry.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(entry.pluginPath, 'data');
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
const coreLogger = this.logger;
|
||||
const pluginLogger: PluginLogger = {
|
||||
log: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
|
||||
debug: (...args: any[]) => coreLogger.logDebug(pluginPrefix, ...args),
|
||||
info: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
|
||||
warn: (...args: any[]) => coreLogger.logWarn(pluginPrefix, ...args),
|
||||
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
|
||||
};
|
||||
|
||||
// 创建或获取插件路由注册器
|
||||
let router = this.pluginRouters.get(entry.id);
|
||||
if (!router) {
|
||||
router = new PluginRouterRegistryImpl(entry.id, entry.pluginPath);
|
||||
this.pluginRouters.set(entry.id, router);
|
||||
}
|
||||
|
||||
// 创建获取其他插件导出的方法
|
||||
const getPluginExports = <T = any>(pluginId: string): T | undefined => {
|
||||
const targetEntry = this.plugins.get(pluginId);
|
||||
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
|
||||
return undefined;
|
||||
}
|
||||
return targetEntry.runtime.module as T;
|
||||
};
|
||||
|
||||
return {
|
||||
core: this.core,
|
||||
oneBot: this.obContext,
|
||||
actions: this.actions,
|
||||
pluginName: entry.id,
|
||||
pluginPath: entry.pluginPath,
|
||||
dataPath,
|
||||
configPath,
|
||||
NapCatConfig,
|
||||
adapterName: this.name,
|
||||
pluginManager: this,
|
||||
logger: pluginLogger,
|
||||
router,
|
||||
getPluginExports,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 获取插件目录路径
|
||||
*/
|
||||
public getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件条目
|
||||
*/
|
||||
public getAllPlugins (): PluginEntry[] {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
public getLoadedPlugins (): PluginEntry[] {
|
||||
return Array.from(this.plugins.values()).filter(p => p.loaded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 获取插件信息
|
||||
*/
|
||||
public getPluginInfo (pluginId: string): PluginEntry | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
*/
|
||||
public async setPluginStatus (pluginId: string, enable: boolean): Promise<void> {
|
||||
const config = this.getPluginConfig();
|
||||
config[pluginId] = enable;
|
||||
this.savePluginConfig(config);
|
||||
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (entry) {
|
||||
entry.enable = enable;
|
||||
|
||||
if (enable && !entry.loaded) {
|
||||
// 启用插件
|
||||
await this.loadPlugin(entry);
|
||||
} else if (!enable && entry.loaded) {
|
||||
// 禁用插件
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 加载插件
|
||||
*/
|
||||
public async loadPluginById (pluginId: string): Promise<boolean> {
|
||||
let entry = this.plugins.get(pluginId);
|
||||
|
||||
if (!entry) {
|
||||
// 尝试查找并扫描
|
||||
const dirname = this.loader.findPluginDirById(pluginId);
|
||||
if (!dirname) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newEntry = this.loader.rescanPlugin(dirname);
|
||||
if (!newEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.plugins.set(newEntry.id, newEntry);
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
if (!entry.enable) {
|
||||
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.loadPlugin(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件(仅从内存卸载)
|
||||
*/
|
||||
public async unregisterPlugin (pluginId: string): Promise<void> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (entry) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载并删除插件
|
||||
*/
|
||||
public async uninstallPlugin (pluginId: string, cleanData: boolean = false): Promise<void> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(pluginPath, 'data');
|
||||
|
||||
// 先卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
|
||||
// 从注册表移除
|
||||
this.plugins.delete(pluginId);
|
||||
|
||||
// 删除插件目录
|
||||
if (fs.existsSync(pluginPath)) {
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载指定插件
|
||||
*/
|
||||
public async reloadPlugin (pluginId: string): Promise<boolean> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
|
||||
// 重新扫描
|
||||
const newEntry = this.loader.rescanPlugin(entry.fileId);
|
||||
if (!newEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新注册表
|
||||
this.plugins.set(newEntry.id, newEntry);
|
||||
|
||||
// 重新加载
|
||||
if (newEntry.enable) {
|
||||
await this.loadPlugin(newEntry);
|
||||
}
|
||||
|
||||
this.logger.log(`[PluginManager] Plugin ${pluginId} reloaded successfully`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error reloading plugin ${pluginId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载目录插件(用于新安装的插件)
|
||||
*/
|
||||
public async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
const entry = this.loader.rescanPlugin(dirname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
if (this.plugins.has(entry.id)) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${entry.id} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugins.set(entry.id, entry);
|
||||
|
||||
if (entry.enable && entry.runtime.status !== 'error') {
|
||||
await this.loadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
return path.join(entry.pluginPath, 'data');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
public getPluginConfigPath (pluginId: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginId), 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件路由注册器
|
||||
*/
|
||||
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
|
||||
return this.pluginRouters.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件路由注册器
|
||||
*/
|
||||
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
|
||||
return this.pluginRouters;
|
||||
}
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
this.getLoadedPlugins().map((entry) =>
|
||||
this.callPluginEventHandler(entry, event)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[PluginManager] Error handling event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件的事件处理方法
|
||||
*/
|
||||
private async callPluginEventHandler (
|
||||
entry: PluginEntry,
|
||||
event: OB11EmitEventContent
|
||||
): Promise<void> {
|
||||
if (entry.runtime.status !== 'loaded' || !entry.runtime.module || !entry.runtime.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, context } = entry.runtime;
|
||||
|
||||
try {
|
||||
// 优先使用 plugin_onevent 方法
|
||||
if (typeof module.plugin_onevent === 'function') {
|
||||
await module.plugin_onevent(context, event);
|
||||
}
|
||||
|
||||
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
|
||||
if (
|
||||
(event as any).message_type &&
|
||||
typeof module.plugin_onmessage === 'function'
|
||||
) {
|
||||
await module.plugin_onmessage(context, event as OB11Message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error calling plugin ${entry.id} event handler:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Opening plugin manager...');
|
||||
this.isEnable = true;
|
||||
|
||||
// 扫描并加载所有插件
|
||||
await this.scanAndLoadPlugins();
|
||||
|
||||
this.logger.log(`[PluginManager] Plugin manager opened with ${this.getLoadedPlugins().length} plugins loaded`);
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Closing plugin manager...');
|
||||
this.isEnable = false;
|
||||
|
||||
// 卸载所有已加载的插件
|
||||
for (const entry of this.plugins.values()) {
|
||||
if (entry.loaded) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Plugin manager closed');
|
||||
}
|
||||
|
||||
async reload (): Promise<OB11NetworkReloadType> {
|
||||
this.logger.log('[PluginManager] Reloading plugin manager...');
|
||||
|
||||
// 先关闭然后重新打开
|
||||
await this.close();
|
||||
await this.open();
|
||||
|
||||
this.logger.log('[PluginManager] Plugin manager reloaded');
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
251
packages/napcat-onebot/network/plugin/router-registry.ts
Normal file
251
packages/napcat-onebot/network/plugin/router-registry.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import {
|
||||
PluginRouterRegistry,
|
||||
PluginRequestHandler,
|
||||
PluginApiRouteDefinition,
|
||||
PluginPageDefinition,
|
||||
PluginHttpRequest,
|
||||
PluginHttpResponse,
|
||||
HttpMethod,
|
||||
MemoryStaticFile,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 包装 Express Request 为 PluginHttpRequest
|
||||
*/
|
||||
function wrapRequest (req: Request): PluginHttpRequest {
|
||||
return {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
query: req.query as Record<string, string | string[] | undefined>,
|
||||
body: req.body,
|
||||
headers: req.headers as Record<string, string | string[] | undefined>,
|
||||
params: req.params,
|
||||
raw: req,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 Express Response 为 PluginHttpResponse
|
||||
*/
|
||||
function wrapResponse (res: Response): PluginHttpResponse {
|
||||
const wrapped: PluginHttpResponse = {
|
||||
status (code: number) {
|
||||
res.status(code);
|
||||
return wrapped;
|
||||
},
|
||||
json (data: unknown) {
|
||||
res.json(data);
|
||||
},
|
||||
send (data: string | Buffer) {
|
||||
res.send(data);
|
||||
},
|
||||
setHeader (name: string, value: string) {
|
||||
res.setHeader(name, value);
|
||||
return wrapped;
|
||||
},
|
||||
sendFile (filePath: string) {
|
||||
res.sendFile(filePath);
|
||||
},
|
||||
redirect (url: string) {
|
||||
res.redirect(url);
|
||||
},
|
||||
raw: res,
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件路由注册器实现
|
||||
* 为每个插件创建独立的路由注册器,收集路由定义
|
||||
*/
|
||||
/** 内存静态路由定义 */
|
||||
interface MemoryStaticRoute {
|
||||
urlPath: string;
|
||||
files: MemoryStaticFile[];
|
||||
}
|
||||
|
||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||
private pageDefinitions: PluginPageDefinition[] = [];
|
||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||
private memoryStaticRoutes: MemoryStaticRoute[] = [];
|
||||
|
||||
constructor (
|
||||
private readonly pluginId: string,
|
||||
private readonly pluginPath: string
|
||||
) { }
|
||||
|
||||
// ==================== API 路由注册 ====================
|
||||
|
||||
api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiRoutes.push({ method, path: routePath, handler });
|
||||
}
|
||||
|
||||
get (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('get', routePath, handler);
|
||||
}
|
||||
|
||||
post (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('post', routePath, handler);
|
||||
}
|
||||
|
||||
put (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('put', routePath, handler);
|
||||
}
|
||||
|
||||
delete (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('delete', routePath, handler);
|
||||
}
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
page (pageDef: PluginPageDefinition): void {
|
||||
this.pageDefinitions.push(pageDef);
|
||||
}
|
||||
|
||||
pages (pageDefs: PluginPageDefinition[]): void {
|
||||
this.pageDefinitions.push(...pageDefs);
|
||||
}
|
||||
|
||||
// ==================== 静态资源 ====================
|
||||
|
||||
static (urlPath: string, localPath: string): void {
|
||||
// 如果是相对路径,则相对于插件目录
|
||||
const absolutePath = path.isAbsolute(localPath)
|
||||
? localPath
|
||||
: path.join(this.pluginPath, localPath);
|
||||
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
||||
}
|
||||
|
||||
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
|
||||
this.memoryStaticRoutes.push({ urlPath, files });
|
||||
}
|
||||
|
||||
// ==================== 构建路由 ====================
|
||||
|
||||
/**
|
||||
* 构建 Express Router(用于 API 路由)
|
||||
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
|
||||
*/
|
||||
buildApiRouter (): Router {
|
||||
const router = Router();
|
||||
|
||||
// 注册 API 路由
|
||||
for (const route of this.apiRoutes) {
|
||||
const handler = this.wrapHandler(route.handler);
|
||||
switch (route.method) {
|
||||
case 'get':
|
||||
router.get(route.path, handler);
|
||||
break;
|
||||
case 'post':
|
||||
router.post(route.path, handler);
|
||||
break;
|
||||
case 'put':
|
||||
router.put(route.path, handler);
|
||||
break;
|
||||
case 'delete':
|
||||
router.delete(route.path, handler);
|
||||
break;
|
||||
case 'patch':
|
||||
router.patch(route.path, handler);
|
||||
break;
|
||||
case 'all':
|
||||
router.all(route.path, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装处理器,添加错误处理和请求/响应包装
|
||||
*/
|
||||
private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const wrappedReq = wrapRequest(req);
|
||||
const wrappedRes = wrapResponse(res);
|
||||
await handler(wrappedReq, wrappedRes, next);
|
||||
} catch (error: any) {
|
||||
console.error(`[Plugin: ${this.pluginId}] Route error:`, error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
code: -1,
|
||||
message: `Plugin error: ${error.message || 'Unknown error'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 查询方法 ====================
|
||||
|
||||
/**
|
||||
* 检查是否有注册的 API 路由
|
||||
*/
|
||||
hasApiRoutes (): boolean {
|
||||
return this.apiRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的静态资源路由
|
||||
*/
|
||||
hasStaticRoutes (): boolean {
|
||||
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的页面
|
||||
*/
|
||||
hasPages (): boolean {
|
||||
return this.pageDefinitions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的页面定义
|
||||
*/
|
||||
getPages (): PluginPageDefinition[] {
|
||||
return [...this.pageDefinitions];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件 ID
|
||||
*/
|
||||
getPluginId (): string {
|
||||
return this.pluginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件路径
|
||||
*/
|
||||
getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的静态路由
|
||||
*/
|
||||
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
|
||||
return [...this.staticRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的内存静态路由
|
||||
*/
|
||||
getMemoryStaticRoutes (): MemoryStaticRoute[] {
|
||||
return [...this.memoryStaticRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空路由(用于插件卸载)
|
||||
*/
|
||||
clear (): void {
|
||||
this.apiRoutes = [];
|
||||
this.pageDefinitions = [];
|
||||
this.staticRoutes = [];
|
||||
this.memoryStaticRoutes = [];
|
||||
}
|
||||
}
|
||||
374
packages/napcat-onebot/network/plugin/types.ts
Normal file
374
packages/napcat-onebot/network/plugin/types.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { OB11EmitEventContent } from '@/napcat-onebot/network/index';
|
||||
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
|
||||
|
||||
// ==================== 插件包信息 ====================
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
export interface PluginConfigItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type PluginConfigSchema = PluginConfigItem[];
|
||||
|
||||
// ==================== NapCatConfig 静态接口 ====================
|
||||
|
||||
/** NapCatConfig 类的静态方法接口(用于 typeof NapCatConfig) */
|
||||
export interface INapCatConfigStatic {
|
||||
text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem;
|
||||
html (content: string): PluginConfigItem;
|
||||
plainText (content: string): PluginConfigItem;
|
||||
combine (...items: PluginConfigItem[]): PluginConfigSchema;
|
||||
}
|
||||
|
||||
/** NapCatConfig 类型(包含静态方法) */
|
||||
export type NapCatConfigClass = INapCatConfigStatic;
|
||||
|
||||
// ==================== 插件路由相关类型(包装层,不直接依赖 express) ====================
|
||||
|
||||
/** HTTP 请求对象(包装类型) */
|
||||
export interface PluginHttpRequest {
|
||||
/** 请求路径 */
|
||||
path: string;
|
||||
/** 请求方法 */
|
||||
method: string;
|
||||
/** 查询参数 */
|
||||
query: Record<string, string | string[] | undefined>;
|
||||
/** 请求体 */
|
||||
body: unknown;
|
||||
/** 请求头 */
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
/** 路由参数 */
|
||||
params: Record<string, string>;
|
||||
/** 原始请求对象(用于高级用法) */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/** HTTP 响应对象(包装类型) */
|
||||
export interface PluginHttpResponse {
|
||||
/** 设置状态码 */
|
||||
status (code: number): PluginHttpResponse;
|
||||
/** 发送 JSON 响应 */
|
||||
json (data: unknown): void;
|
||||
/** 发送文本响应 */
|
||||
send (data: string | Buffer): void;
|
||||
/** 设置响应头 */
|
||||
setHeader (name: string, value: string): PluginHttpResponse;
|
||||
/** 发送文件 */
|
||||
sendFile (filePath: string): void;
|
||||
/** 重定向 */
|
||||
redirect (url: string): void;
|
||||
/** 原始响应对象(用于高级用法) */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/** 下一步函数类型 */
|
||||
export type PluginNextFunction = (err?: unknown) => void;
|
||||
|
||||
/** 插件请求处理器类型 */
|
||||
export type PluginRequestHandler = (
|
||||
req: PluginHttpRequest,
|
||||
res: PluginHttpResponse,
|
||||
next: PluginNextFunction
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** HTTP 方法类型 */
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'all';
|
||||
|
||||
/** 插件 API 路由定义 */
|
||||
export interface PluginApiRouteDefinition {
|
||||
/** HTTP 方法 */
|
||||
method: HttpMethod;
|
||||
/** 路由路径(相对于插件路由前缀) */
|
||||
path: string;
|
||||
/** 请求处理器 */
|
||||
handler: PluginRequestHandler;
|
||||
}
|
||||
|
||||
/** 插件页面定义 */
|
||||
export interface PluginPageDefinition {
|
||||
/** 页面路径(用于路由,如 'settings') */
|
||||
path: string;
|
||||
/** 页面标题(显示在 Tab 上) */
|
||||
title: string;
|
||||
/** 页面图标(可选,支持 emoji 或图标名) */
|
||||
icon?: string;
|
||||
/** 页面 HTML 文件路径(相对于插件目录) */
|
||||
htmlFile: string;
|
||||
/** 页面描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 内存文件生成器 - 用于动态生成静态文件内容 */
|
||||
export type MemoryFileGenerator = () => string | Buffer | Promise<string | Buffer>;
|
||||
|
||||
/** 内存静态文件定义 */
|
||||
export interface MemoryStaticFile {
|
||||
/** 文件路径(相对于 urlPath) */
|
||||
path: string;
|
||||
/** 文件内容或生成器 */
|
||||
content: string | Buffer | MemoryFileGenerator;
|
||||
/** 可选的 MIME 类型 */
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
/** 插件路由注册器 */
|
||||
export interface PluginRouterRegistry {
|
||||
// ==================== API 路由注册 ====================
|
||||
|
||||
/**
|
||||
* 注册单个 API 路由
|
||||
* @param method HTTP 方法
|
||||
* @param path 路由路径
|
||||
* @param handler 请求处理器
|
||||
*/
|
||||
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 GET API */
|
||||
get (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 POST API */
|
||||
post (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 PUT API */
|
||||
put (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 DELETE API */
|
||||
delete (path: string, handler: PluginRequestHandler): void;
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
/**
|
||||
* 注册插件页面
|
||||
* @param page 页面定义
|
||||
*/
|
||||
page (page: PluginPageDefinition): void;
|
||||
|
||||
/**
|
||||
* 注册多个插件页面
|
||||
* @param pages 页面定义数组
|
||||
*/
|
||||
pages (pages: PluginPageDefinition[]): void;
|
||||
|
||||
// ==================== 静态资源 ====================
|
||||
|
||||
/**
|
||||
* 提供静态文件服务
|
||||
* @param urlPath URL 路径
|
||||
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
||||
*/
|
||||
static (urlPath: string, localPath: string): void;
|
||||
|
||||
/**
|
||||
* 提供内存生成的静态文件服务
|
||||
* @param urlPath URL 路径
|
||||
* @param files 内存文件列表
|
||||
*/
|
||||
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void;
|
||||
}
|
||||
|
||||
// ==================== 插件管理器接口 ====================
|
||||
|
||||
/** 插件管理器公共接口 */
|
||||
export interface IPluginManager {
|
||||
readonly config: NetworkAdapterConfig;
|
||||
getPluginPath (): string;
|
||||
getPluginConfig (): PluginStatusConfig;
|
||||
getAllPlugins (): PluginEntry[];
|
||||
getLoadedPlugins (): PluginEntry[];
|
||||
getPluginInfo (pluginId: string): PluginEntry | undefined;
|
||||
setPluginStatus (pluginId: string, enable: boolean): Promise<void>;
|
||||
loadPluginById (pluginId: string): Promise<boolean>;
|
||||
unregisterPlugin (pluginId: string): Promise<void>;
|
||||
uninstallPlugin (pluginId: string, cleanData?: boolean): Promise<void>;
|
||||
reloadPlugin (pluginId: string): Promise<boolean>;
|
||||
loadDirectoryPlugin (dirname: string): Promise<void>;
|
||||
getPluginDataPath (pluginId: string): string;
|
||||
getPluginConfigPath (pluginId: string): string;
|
||||
}
|
||||
|
||||
// ==================== 插件配置 UI 控制器 ====================
|
||||
|
||||
/** 插件配置 UI 控制器 - 用于动态控制配置界面 */
|
||||
export interface PluginConfigUIController {
|
||||
/** 更新整个 schema */
|
||||
updateSchema: (schema: PluginConfigSchema) => void;
|
||||
/** 更新单个字段 */
|
||||
updateField: (key: string, field: Partial<PluginConfigItem>) => void;
|
||||
/** 移除字段 */
|
||||
removeField: (key: string) => void;
|
||||
/** 添加字段 */
|
||||
addField: (field: PluginConfigItem, afterKey?: string) => void;
|
||||
/** 显示字段 */
|
||||
showField: (key: string) => void;
|
||||
/** 隐藏字段 */
|
||||
hideField: (key: string) => void;
|
||||
/** 获取当前配置值 */
|
||||
getCurrentConfig: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ==================== 插件日志接口 ====================
|
||||
|
||||
/**
|
||||
* 插件日志接口 - 简化的日志 API
|
||||
*/
|
||||
export interface PluginLogger {
|
||||
/** 普通日志 */
|
||||
log (...args: unknown[]): void;
|
||||
/** 调试日志 */
|
||||
debug (...args: unknown[]): void;
|
||||
/** 信息日志 */
|
||||
info (...args: unknown[]): void;
|
||||
/** 警告日志 */
|
||||
warn (...args: unknown[]): void;
|
||||
/** 错误日志 */
|
||||
error (...args: unknown[]): void;
|
||||
}
|
||||
|
||||
// ==================== 插件上下文 ====================
|
||||
|
||||
export interface NapCatPluginContext {
|
||||
core: NapCatCore;
|
||||
oneBot: NapCatOneBot11Adapter;
|
||||
actions: ActionMap;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
configPath: string;
|
||||
dataPath: string;
|
||||
/** NapCatConfig 配置构建器 */
|
||||
NapCatConfig: NapCatConfigClass;
|
||||
adapterName: string;
|
||||
/** 插件管理器实例 */
|
||||
pluginManager: IPluginManager;
|
||||
/** 插件日志器 - 自动添加插件名称前缀 */
|
||||
logger: PluginLogger;
|
||||
/**
|
||||
* WebUI 路由注册器
|
||||
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
||||
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
|
||||
*/
|
||||
router: PluginRouterRegistry;
|
||||
/**
|
||||
* 获取其他插件的导出模块
|
||||
* @param pluginId 目标插件 ID
|
||||
* @returns 插件导出的模块,如果插件未加载则返回 undefined
|
||||
*/
|
||||
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
|
||||
}
|
||||
|
||||
// ==================== 插件模块接口 ====================
|
||||
|
||||
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent, C = unknown> {
|
||||
plugin_init: (ctx: NapCatPluginContext) => void | Promise<void>;
|
||||
plugin_onmessage?: (
|
||||
ctx: NapCatPluginContext,
|
||||
event: OB11Message,
|
||||
) => void | Promise<void>;
|
||||
plugin_onevent?: (
|
||||
ctx: NapCatPluginContext,
|
||||
event: T,
|
||||
) => void | Promise<void>;
|
||||
plugin_cleanup?: (
|
||||
ctx: NapCatPluginContext
|
||||
) => void | Promise<void>;
|
||||
plugin_config_schema?: PluginConfigSchema;
|
||||
plugin_config_ui?: PluginConfigSchema;
|
||||
plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise<C>;
|
||||
plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise<void>;
|
||||
/**
|
||||
* 配置界面控制器 - 当配置界面打开时调用
|
||||
* 返回清理函数,在界面关闭时调用
|
||||
*/
|
||||
plugin_config_controller?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
initialConfig: Record<string, unknown>
|
||||
) => void | (() => void) | Promise<void | (() => void)>;
|
||||
/**
|
||||
* 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
plugin_on_config_change?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
key: string,
|
||||
value: unknown,
|
||||
currentConfig: Record<string, unknown>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== 插件运行时状态 ====================
|
||||
|
||||
export type PluginRuntimeStatus = 'loaded' | 'error' | 'unloaded';
|
||||
|
||||
export interface PluginRuntime {
|
||||
/** 运行时状态 */
|
||||
status: PluginRuntimeStatus;
|
||||
/** 错误信息(当 status 为 'error' 时) */
|
||||
error?: string;
|
||||
/** 插件模块(当 status 为 'loaded' 时) */
|
||||
module?: PluginModule;
|
||||
/** 插件上下文(当 status 为 'loaded' 时) */
|
||||
context?: NapCatPluginContext;
|
||||
}
|
||||
|
||||
// ==================== 插件条目(统一管理所有插件) ====================
|
||||
|
||||
export interface PluginEntry {
|
||||
// ===== 基础信息 =====
|
||||
/** 插件 ID(包名或目录名) */
|
||||
id: string;
|
||||
/** 文件系统目录名 */
|
||||
fileId: string;
|
||||
/** 显示名称 */
|
||||
name?: string;
|
||||
/** 版本号 */
|
||||
version?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 作者 */
|
||||
author?: string;
|
||||
/** 插件目录路径 */
|
||||
pluginPath: string;
|
||||
/** 入口文件路径 */
|
||||
entryPath?: string;
|
||||
/** package.json 内容 */
|
||||
packageJson?: PluginPackageJson;
|
||||
|
||||
// ===== 状态 =====
|
||||
/** 是否启用(用户配置) */
|
||||
enable: boolean;
|
||||
/** 运行时是否已加载 */
|
||||
loaded: boolean;
|
||||
|
||||
// ===== 运行时 =====
|
||||
/** 运行时信息 */
|
||||
runtime: PluginRuntime;
|
||||
}
|
||||
|
||||
// ==================== 插件状态配置(持久化) ====================
|
||||
|
||||
export interface PluginStatusConfig {
|
||||
[key: string]: boolean; // key: pluginId, value: enabled
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
let isClosedByError = false;
|
||||
|
||||
this.connection = new WebSocket(this.config.url, {
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
handshakeTimeout: 2000,
|
||||
perMessageDeflate: false,
|
||||
headers: {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: this.config.port,
|
||||
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: newConfig.port,
|
||||
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
if (newConfig.enable) {
|
||||
|
||||
@@ -1,45 +1,292 @@
|
||||
import type { ActionMap } from 'napcat-types/napcat-onebot/action/index';
|
||||
import { EventType } from 'napcat-types/napcat-onebot/event/index';
|
||||
import type { PluginModule } from 'napcat-types/napcat-onebot/network/plugin-manger';
|
||||
import type { PluginModule, PluginLogger, PluginConfigSchema, PluginConfigUIController } from 'napcat-types/napcat-onebot/network/plugin-manger';
|
||||
import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { NetworkAdapterConfig } from 'napcat-types/napcat-onebot/config/config';
|
||||
|
||||
|
||||
let actions: ActionMap | undefined = undefined;
|
||||
let startTime: number = Date.now();
|
||||
let logger: PluginLogger | null = null;
|
||||
|
||||
/**
|
||||
* 插件初始化
|
||||
*/
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: builtin] NapCat 内置插件已初始化');
|
||||
actions = _actions;
|
||||
interface BuiltinPluginConfig {
|
||||
prefix: string;
|
||||
enableReply: boolean;
|
||||
description: string;
|
||||
theme?: string;
|
||||
features?: string[];
|
||||
apiUrl?: string;
|
||||
apiEndpoints?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let currentConfig: BuiltinPluginConfig = {
|
||||
prefix: '#napcat',
|
||||
enableReply: true,
|
||||
description: '这是一个内置插件的配置示例'
|
||||
};
|
||||
|
||||
|
||||
export let plugin_config_ui: PluginConfigSchema = [];
|
||||
|
||||
const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
logger = ctx.logger;
|
||||
logger.info('NapCat 内置插件已初始化');
|
||||
plugin_config_ui = ctx.NapCatConfig.combine(
|
||||
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface with reactive fields.</p></div>'),
|
||||
ctx.NapCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'),
|
||||
ctx.NapCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'),
|
||||
// 代表监听 apiUrl 字段的变化
|
||||
ctx.NapCatConfig.text('apiUrl', 'API URL', '', 'Enter an API URL to load available endpoints', true),
|
||||
ctx.NapCatConfig.select('theme', 'Theme Selection', [
|
||||
{ label: 'Light Mode', value: 'light' },
|
||||
{ label: 'Dark Mode', value: 'dark' },
|
||||
{ label: 'Auto', value: 'auto' }
|
||||
], 'light', 'Select a theme for the response (Demo purpose only)'),
|
||||
ctx.NapCatConfig.multiSelect('features', 'Enabled Features', [
|
||||
{ label: 'Version Info', value: 'version' },
|
||||
{ label: 'Status Report', value: 'status' },
|
||||
{ label: 'Debug Log', value: 'debug' }
|
||||
], ['version'], 'Select features to enable'),
|
||||
ctx.NapCatConfig.text('description', 'Description', '这是一个内置插件的配置示例', 'A multi-line text area for notes')
|
||||
);
|
||||
|
||||
// Try to load config
|
||||
try {
|
||||
// Use ctx.configPath
|
||||
if (fs.existsSync(ctx.configPath)) {
|
||||
const savedConfig = JSON.parse(fs.readFileSync(ctx.configPath, 'utf-8'));
|
||||
Object.assign(currentConfig, savedConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
logger?.warn('Failed to load config', e);
|
||||
}
|
||||
|
||||
// ==================== 注册 WebUI 路由示例 ====================
|
||||
|
||||
// 注册静态资源目录
|
||||
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
|
||||
ctx.router.static('/static', 'webui');
|
||||
|
||||
// 注册内存生成的静态资源(无需鉴权)
|
||||
// 可通过 /plugin/{pluginId}/mem/dynamic/info.json 访问
|
||||
ctx.router.staticOnMem('/dynamic', [
|
||||
{
|
||||
path: '/info.json',
|
||||
contentType: 'application/json',
|
||||
// 使用生成器函数动态生成内容
|
||||
content: () => JSON.stringify({
|
||||
pluginName: ctx.pluginName,
|
||||
generatedAt: new Date().toISOString(),
|
||||
uptime: Date.now() - startTime,
|
||||
config: currentConfig
|
||||
}, null, 2)
|
||||
},
|
||||
{
|
||||
path: '/readme.txt',
|
||||
contentType: 'text/plain',
|
||||
content: `NapCat Builtin Plugin\n=====================\nThis is a demonstration of the staticOnMem feature.\nPlugin: ${ctx.pluginName}\nPath: ${ctx.pluginPath}`
|
||||
}
|
||||
]);
|
||||
|
||||
// 注册 API 路由(需要鉴权,挂载到 /api/Plugin/ext/{pluginId}/)
|
||||
ctx.router.get('/status', (_req, res) => {
|
||||
const uptime = Date.now() - startTime;
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
pluginName: ctx.pluginName,
|
||||
uptime,
|
||||
uptimeFormatted: formatUptime(uptime),
|
||||
config: currentConfig,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ctx.router.get('/config', (_req, res) => {
|
||||
res.json({
|
||||
code: 0,
|
||||
data: currentConfig
|
||||
});
|
||||
});
|
||||
|
||||
ctx.router.post('/config', (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body as Partial<BuiltinPluginConfig>;
|
||||
Object.assign(currentConfig, newConfig);
|
||||
// 保存配置
|
||||
const configDir = path.dirname(ctx.configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(ctx.configPath, JSON.stringify(currentConfig, null, 2), 'utf-8');
|
||||
res.json({ code: 0, message: 'Config saved successfully' });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ code: -1, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 插件互调用示例 ====================
|
||||
// 演示如何调用其他插件的导出方法
|
||||
ctx.router.get('/call-plugin/:pluginId', (req, res) => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
// 使用 getPluginExports 获取其他插件的导出模块
|
||||
const targetPlugin = ctx.getPluginExports<PluginModule>(pluginId);
|
||||
|
||||
if (!targetPlugin) {
|
||||
res.status(404).json({
|
||||
code: -1,
|
||||
message: `Plugin '${pluginId}' not found or not loaded`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回目标插件的信息
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
pluginId,
|
||||
hasInit: typeof targetPlugin.plugin_init === 'function',
|
||||
hasOnMessage: typeof targetPlugin.plugin_onmessage === 'function',
|
||||
hasOnEvent: typeof targetPlugin.plugin_onevent === 'function',
|
||||
hasCleanup: typeof targetPlugin.plugin_cleanup === 'function',
|
||||
hasConfigSchema: Array.isArray(targetPlugin.plugin_config_schema),
|
||||
hasConfigUI: Array.isArray(targetPlugin.plugin_config_ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册扩展页面
|
||||
ctx.router.page({
|
||||
path: 'dashboard',
|
||||
title: '插件仪表盘',
|
||||
icon: '📊',
|
||||
htmlFile: 'webui/dashboard.html',
|
||||
description: '查看内置插件的运行状态和配置'
|
||||
});
|
||||
|
||||
logger.info('WebUI 路由已注册:');
|
||||
logger.info(' - API 路由: /api/Plugin/ext/' + ctx.pluginName + '/');
|
||||
logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/');
|
||||
logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/');
|
||||
};
|
||||
|
||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||
return currentConfig;
|
||||
};
|
||||
|
||||
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config) => {
|
||||
currentConfig = config as BuiltinPluginConfig;
|
||||
if (ctx && ctx.configPath) {
|
||||
try {
|
||||
const configPath = ctx.configPath;
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
logger?.error('Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息处理
|
||||
* 当收到包含 #napcat 的消息时,回复版本信息
|
||||
* 响应式配置控制器 - 当插件配置界面打开时调用
|
||||
* 用于初始化动态 UI 控制
|
||||
*/
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
|
||||
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) {
|
||||
export const plugin_config_controller: PluginModule['plugin_config_controller'] = async (_ctx, ui, initialConfig) => {
|
||||
logger?.info('配置控制器已初始化', initialConfig);
|
||||
|
||||
// 如果初始配置中有 apiUrl,立即加载端点
|
||||
if (initialConfig['apiUrl']) {
|
||||
await loadEndpointsForUrl(ui, initialConfig['apiUrl'] as string);
|
||||
}
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
logger?.info('配置控制器已清理');
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式字段变化处理 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
export const plugin_on_config_change: PluginModule['plugin_on_config_change'] = async (_ctx, ui, key, value, _currentConfig: Partial<BuiltinPluginConfig>) => {
|
||||
logger?.info(`配置字段变化: ${key} = ${value}`);
|
||||
|
||||
if (key === 'apiUrl') {
|
||||
await loadEndpointsForUrl(ui, value as string);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 API URL 动态加载端点列表
|
||||
*/
|
||||
async function loadEndpointsForUrl (ui: PluginConfigUIController, apiUrl: string) {
|
||||
if (!apiUrl) {
|
||||
// URL 为空时,移除端点选择字段
|
||||
ui.removeField('apiEndpoints');
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟从 API 获取端点列表(实际使用时可以 fetch 真实 API)
|
||||
const mockEndpoints = [
|
||||
{ label: `${apiUrl}/users`, value: '/users' },
|
||||
{ label: `${apiUrl}/posts`, value: '/posts' },
|
||||
{ label: `${apiUrl}/comments`, value: '/comments' },
|
||||
{ label: `${apiUrl}/albums`, value: '/albums' },
|
||||
];
|
||||
|
||||
// 动态添加或更新端点选择字段
|
||||
const currentSchema = ui.getCurrentConfig();
|
||||
if ('apiEndpoints' in currentSchema) {
|
||||
// 更新现有字段的选项
|
||||
ui.updateField('apiEndpoints', {
|
||||
options: mockEndpoints,
|
||||
description: `从 ${apiUrl} 加载的端点`
|
||||
});
|
||||
} else {
|
||||
// 添加新字段
|
||||
ui.addField({
|
||||
key: 'apiEndpoints',
|
||||
type: 'multi-select',
|
||||
label: 'API Endpoints',
|
||||
description: `从 ${apiUrl} 加载的端点`,
|
||||
options: mockEndpoints,
|
||||
default: []
|
||||
}, 'apiUrl');
|
||||
}
|
||||
}
|
||||
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (_ctx, event) => {
|
||||
if (currentConfig.enableReply === false) {
|
||||
return;
|
||||
}
|
||||
const prefix = currentConfig.prefix || '#napcat';
|
||||
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith(prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const versionInfo = await getVersionInfo(adapter, instance.config);
|
||||
const versionInfo = await getVersionInfo(_ctx.actions, _ctx.adapterName, _ctx.pluginManager.config);
|
||||
if (!versionInfo) return;
|
||||
|
||||
const message = formatVersionMessage(versionInfo);
|
||||
await sendMessage(event, message, adapter, instance.config);
|
||||
await sendMessage(_ctx.actions, event, message, _ctx.adapterName, _ctx.pluginManager.config);
|
||||
|
||||
console.log('[Plugin: builtin] 已回复版本信息');
|
||||
logger?.info('已回复版本信息');
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 处理消息时发生错误:', error);
|
||||
logger?.error('处理消息时发生错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取版本信息(完美的类型推导,无需 as 断言)
|
||||
*/
|
||||
async function getVersionInfo (adapter: string, config: any) {
|
||||
async function getVersionInfo (actions: ActionMap, adapter: string, config: NetworkAdapterConfig) {
|
||||
if (!actions) return null;
|
||||
|
||||
try {
|
||||
@@ -50,14 +297,11 @@ async function getVersionInfo (adapter: string, config: any) {
|
||||
protocolVersion: data.protocol_version,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 获取版本信息失败:', error);
|
||||
logger?.error('获取版本信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化运行时间
|
||||
*/
|
||||
function formatUptime (ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -75,20 +319,12 @@ function formatUptime (ms: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化版本信息消息
|
||||
*/
|
||||
function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) {
|
||||
const uptime = Date.now() - startTime;
|
||||
return `NapCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}\n运行时间: ${formatUptime(uptime)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(完美的类型推导)
|
||||
*/
|
||||
async function sendMessage (event: OB11Message, message: string, adapter: string, config: any) {
|
||||
if (!actions) return;
|
||||
|
||||
async function sendMessage (actions: ActionMap, event: OB11Message, message: string, adapter: string, config: NetworkAdapterConfig) {
|
||||
const params: OB11PostSendMsg = {
|
||||
message,
|
||||
message_type: event.message_type,
|
||||
@@ -99,7 +335,7 @@ async function sendMessage (event: OB11Message, message: string, adapter: string
|
||||
try {
|
||||
await actions.call('send_msg', params, adapter, config);
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 发送消息失败:', error);
|
||||
logger?.error('发送消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "napcat-plugin-builtin",
|
||||
"plugin": "内置插件",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.3"
|
||||
"napcat-types": "0.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@@ -14,8 +14,10 @@ function copyToShellPlugin () {
|
||||
writeBundle () {
|
||||
try {
|
||||
const sourceDir = resolve(__dirname, 'dist');
|
||||
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin');
|
||||
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/napcat-plugin-builtin');
|
||||
const packageJsonSource = resolve(__dirname, 'package.json');
|
||||
const webuiSourceDir = resolve(__dirname, 'webui');
|
||||
const webuiTargetDir = resolve(targetDir, 'webui');
|
||||
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
@@ -44,6 +46,12 @@ function copyToShellPlugin () {
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
// 拷贝 webui 目录
|
||||
if (fs.existsSync(webuiSourceDir)) {
|
||||
copyDirRecursive(webuiSourceDir, webuiTargetDir);
|
||||
console.log(`[copy-to-shell] Copied webui directory to ${webuiTargetDir}`);
|
||||
}
|
||||
|
||||
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
|
||||
} catch (error) {
|
||||
console.error('[copy-to-shell] Failed to copy files:', error);
|
||||
@@ -53,6 +61,26 @@ function copyToShellPlugin () {
|
||||
};
|
||||
}
|
||||
|
||||
// 递归复制目录
|
||||
function copyDirRecursive (src: string, dest: string) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = resolve(src, entry.name);
|
||||
const destPath = resolve(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
|
||||
446
packages/napcat-plugin-builtin/webui/dashboard.html
Normal file
446
packages/napcat-plugin-builtin/webui/dashboard.html
Normal file
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>内置插件仪表盘</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: rgba(255, 255, 255, 0.4);
|
||||
--bg-secondary: rgba(255, 255, 255, 0.6);
|
||||
--bg-card: rgba(255, 255, 255, 0.5);
|
||||
--bg-item: rgba(0, 0, 0, 0.03);
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666;
|
||||
--text-muted: #999;
|
||||
--border-color: rgba(0, 0, 0, 0.06);
|
||||
--accent-color: #52525b;
|
||||
--accent-light: rgba(82, 82, 91, 0.1);
|
||||
--success-color: #17c964;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: rgba(0, 0, 0, 0.2);
|
||||
--bg-secondary: rgba(0, 0, 0, 0.3);
|
||||
--bg-card: rgba(255, 255, 255, 0.05);
|
||||
--bg-item: rgba(255, 255, 255, 0.05);
|
||||
--text-primary: #f5f5f5;
|
||||
--text-secondary: #a1a1a1;
|
||||
--text-muted: #666;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent-light: rgba(82, 82, 91, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: transparent;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header .icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: var(--bg-item);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-item:hover {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-item .value.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.config-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.config-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-item);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.config-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-list .key {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-list .value {
|
||||
color: var(--accent-color);
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--accent-light);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--accent-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(243, 18, 96, 0.1);
|
||||
color: #f31260;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(243, 18, 96, 0.2);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>NapCat 内置插件仪表盘</h2>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="loading">加载中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>当前配置</h2>
|
||||
</div>
|
||||
<div id="config-content">
|
||||
<div class="loading">加载中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>静态资源测试</h2>
|
||||
</div>
|
||||
<div id="static-content">
|
||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||
测试插件静态资源服务是否正常工作(不需要鉴权)
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 0;">
|
||||
<button class="btn btn-primary" onclick="testStaticResource()">
|
||||
获取 test.txt(文件系统)
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="testMemoryResource()">
|
||||
获取 info.json(内存生成)
|
||||
</button>
|
||||
</div>
|
||||
<div id="static-result" style="margin-top: 12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>NapCat Builtin Plugin - WebUI 扩展页面演示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 从 URL 参数获取 webui_token
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const webuiToken = urlParams.get('webui_token') || '';
|
||||
|
||||
// 插件 API 基础路径(需要鉴权)
|
||||
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
||||
// 插件静态资源基础路径(不需要鉴权)
|
||||
const staticBase = '/plugin/napcat-plugin-builtin/files';
|
||||
// 插件内存资源基础路径(不需要鉴权)
|
||||
const memBase = '/plugin/napcat-plugin-builtin/mem';
|
||||
|
||||
// 封装 fetch,自动携带认证
|
||||
async function authFetch (url, options = {}) {
|
||||
const headers = options.headers || {};
|
||||
if (webuiToken) {
|
||||
headers['Authorization'] = `Bearer ${webuiToken}`;
|
||||
}
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
async function fetchStatus () {
|
||||
try {
|
||||
const response = await authFetch(`${apiBase}/status`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
renderStatus(result.data);
|
||||
} else {
|
||||
showError('获取状态失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('请求失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig () {
|
||||
try {
|
||||
const response = await authFetch(`${apiBase}/config`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
renderConfig(result.data);
|
||||
} else {
|
||||
showError('获取配置失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('请求失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus (data) {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = `
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="label">插件名称</div>
|
||||
<div class="value">${data.pluginName}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="label">运行时间</div>
|
||||
<div class="value success">${data.uptimeFormatted}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="label">运行平台</div>
|
||||
<div class="value">${data.platform}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="label">系统架构</div>
|
||||
<div class="value">${data.arch}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="refresh()">
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConfig (config) {
|
||||
const content = document.getElementById('config-content');
|
||||
const items = Object.entries(config)
|
||||
.map(([key, value]) => `
|
||||
<li>
|
||||
<span class="key">${key}</span>
|
||||
<span class="value">${JSON.stringify(value)}</span>
|
||||
</li>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
content.innerHTML = `
|
||||
<ul class="config-list">
|
||||
${items || '<li><span class="key">暂无配置</span></li>'}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function showError (message) {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = `<div class="error">${message}</div>`;
|
||||
}
|
||||
|
||||
function refresh () {
|
||||
document.getElementById('content').innerHTML = '<div class="loading">加载中</div>';
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
refresh();
|
||||
|
||||
// 每 30 秒自动刷新
|
||||
setInterval(refresh, 30000);
|
||||
|
||||
// 测试静态资源
|
||||
async function testStaticResource () {
|
||||
const resultDiv = document.getElementById('static-result');
|
||||
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||
|
||||
try {
|
||||
// 静态资源不需要鉴权,直接请求
|
||||
const response = await fetch(`${staticBase}/static/test.txt`);
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
resultDiv.innerHTML = `
|
||||
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">文件系统静态资源访问成功</div>
|
||||
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="error">请求失败: ${response.status} ${response.statusText}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试内存资源
|
||||
async function testMemoryResource () {
|
||||
const resultDiv = document.getElementById('static-result');
|
||||
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||
|
||||
try {
|
||||
// 内存资源不需要鉴权,直接请求
|
||||
const response = await fetch(`${memBase}/dynamic/info.json`);
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
resultDiv.innerHTML = `
|
||||
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">内存生成资源访问成功</div>
|
||||
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${JSON.stringify(json, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="error">请求失败: ${response.status} ${response.statusText}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
6
packages/napcat-plugin-builtin/webui/test.txt
Normal file
6
packages/napcat-plugin-builtin/webui/test.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Hello from NapCat Builtin Plugin!
|
||||
|
||||
这是一个静态资源测试文件。
|
||||
如果你能看到这段文字,说明插件的静态资源服务正常工作。
|
||||
|
||||
时间戳: 2026-01-30
|
||||
@@ -1,3 +0,0 @@
|
||||
# Example Plugin
|
||||
## Install
|
||||
安装只需要将dist产物 `index.mjs` 目录放入 napcat根目录下`plugins/example`,如果没有这个目录请创建。
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { createActionMap } from 'napcat-types/dist/napcat-onebot/action/index.js';
|
||||
import { EventType } from 'napcat-types/dist/napcat-onebot/event/index.js';
|
||||
import type { PluginModule } from 'napcat-types/dist/napcat-onebot/network/plugin-manger';
|
||||
|
||||
/**
|
||||
* 导入 napcat 包时候不使用 @/napcat...,直接使用 napcat...
|
||||
* 因为 @/napcat... 会导致打包时包含整个 napcat 包,而不是只包含需要的部分
|
||||
*/
|
||||
|
||||
// action 作为参数传递时请用这个
|
||||
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
|
||||
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: example] 插件已初始化');
|
||||
actionMap = _actions;
|
||||
};
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
||||
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
|
||||
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
|
||||
}
|
||||
};
|
||||
export { plugin_init, plugin_onmessage, actionMap };
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "napcat-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "一个高级的 NapCat 插件示例",
|
||||
"dependencies": {
|
||||
"napcat-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { builtinModules } from 'module';
|
||||
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/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()],
|
||||
});
|
||||
51
packages/napcat-protocol/action/index.ts
Normal file
51
packages/napcat-protocol/action/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolResponse } from '@/napcat-protocol/types';
|
||||
|
||||
// 前向声明类型,避免循环依赖
|
||||
import type { NapCatProtocolAdapter } from '@/napcat-protocol/index';
|
||||
|
||||
// Action 基类
|
||||
export abstract class BaseAction<PayloadType = unknown, ReturnType = unknown> {
|
||||
abstract actionName: string;
|
||||
protected core: NapCatCore;
|
||||
protected adapter: NapCatProtocolAdapter;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
this.adapter = adapter;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
protected abstract _handle (payload: PayloadType): Promise<ReturnType>;
|
||||
|
||||
async handle (payload: PayloadType): Promise<NapCatProtocolResponse<ReturnType>> {
|
||||
try {
|
||||
const result = await this._handle(payload);
|
||||
return {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: result,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'failed',
|
||||
retcode: -1,
|
||||
data: null,
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action 映射类型
|
||||
export type ActionMap = Map<string, BaseAction<unknown, unknown>>;
|
||||
|
||||
// 创建 Action 映射
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function createActionMap (_adapter: NapCatProtocolAdapter, _core: NapCatCore): ActionMap {
|
||||
const actionMap = new Map<string, BaseAction<unknown, unknown>>();
|
||||
|
||||
// 这里可以注册各种 Action
|
||||
// 例如: actionMap.set('send_msg', new SendMsgAction(adapter, core));
|
||||
|
||||
return actionMap;
|
||||
}
|
||||
49
packages/napcat-protocol/api/index.ts
Normal file
49
packages/napcat-protocol/api/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolAdapter } from '@/napcat-protocol/index';
|
||||
|
||||
// NapCat Protocol API 基类
|
||||
export abstract class NapCatProtocolApiBase {
|
||||
protected adapter: NapCatProtocolAdapter;
|
||||
protected core: NapCatCore;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
this.adapter = adapter;
|
||||
this.core = core;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息 API
|
||||
export class NapCatProtocolMsgApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 消息相关 API 方法可以在这里实现
|
||||
}
|
||||
|
||||
// 用户 API
|
||||
export class NapCatProtocolUserApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 用户相关 API 方法可以在这里实现
|
||||
}
|
||||
|
||||
// 群组 API
|
||||
export class NapCatProtocolGroupApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 群组相关 API 方法可以在这里实现
|
||||
}
|
||||
|
||||
// 好友 API
|
||||
export class NapCatProtocolFriendApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 好友相关 API 方法可以在这里实现
|
||||
}
|
||||
66
packages/napcat-protocol/config/config.ts
Normal file
66
packages/napcat-protocol/config/config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import Ajv from 'ajv';
|
||||
|
||||
// WebSocket 服务端配置
|
||||
const WebsocketServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'napcat-ws-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 6700 }),
|
||||
token: Type.String({ default: '' }),
|
||||
heartInterval: Type.Number({ default: 30000 }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// WebSocket 客户端配置
|
||||
const WebsocketClientConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'napcat-ws-client' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
url: Type.String({ default: 'ws://localhost:6701' }),
|
||||
token: Type.String({ default: '' }),
|
||||
reconnectInterval: Type.Number({ default: 5000 }),
|
||||
heartInterval: Type.Number({ default: 30000 }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// HTTP 服务端配置
|
||||
const HttpServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'napcat-http-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 6702 }),
|
||||
token: Type.String({ default: '' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// 网络配置
|
||||
const NetworkConfigSchema = Type.Object({
|
||||
httpServers: Type.Array(HttpServerConfigSchema, { default: [] }),
|
||||
websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }),
|
||||
websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }),
|
||||
}, { default: {} });
|
||||
|
||||
// NapCat Protocol 主配置 - 默认关闭
|
||||
export const NapCatProtocolConfigSchema = Type.Object({
|
||||
enable: Type.Boolean({ default: false }), // 默认关闭
|
||||
network: NetworkConfigSchema,
|
||||
});
|
||||
|
||||
export type NapCatProtocolConfig = Static<typeof NapCatProtocolConfigSchema>;
|
||||
export type HttpServerConfig = Static<typeof HttpServerConfigSchema>;
|
||||
export type WebsocketServerConfig = Static<typeof WebsocketServerConfigSchema>;
|
||||
export type WebsocketClientConfig = Static<typeof WebsocketClientConfigSchema>;
|
||||
|
||||
export type NetworkAdapterConfig = HttpServerConfig | WebsocketServerConfig | WebsocketClientConfig;
|
||||
export type NetworkConfigKey = keyof NapCatProtocolConfig['network'];
|
||||
|
||||
export function loadConfig (config: Partial<NapCatProtocolConfig>): NapCatProtocolConfig {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(NapCatProtocolConfigSchema);
|
||||
const valid = validate(config);
|
||||
if (!valid) {
|
||||
throw new Error(ajv.errorsText(validate.errors));
|
||||
}
|
||||
return config as NapCatProtocolConfig;
|
||||
}
|
||||
11
packages/napcat-protocol/config/index.ts
Normal file
11
packages/napcat-protocol/config/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ConfigBase } from 'napcat-core';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolConfig, NapCatProtocolConfigSchema } from './config';
|
||||
|
||||
export class NapCatProtocolConfigLoader extends ConfigBase<NapCatProtocolConfig> {
|
||||
constructor (core: NapCatCore, configPath: string) {
|
||||
super('napcat_protocol', core, configPath, NapCatProtocolConfigSchema);
|
||||
}
|
||||
}
|
||||
|
||||
export * from './config';
|
||||
66
packages/napcat-protocol/event/NapCatProtocolEvent.ts
Normal file
66
packages/napcat-protocol/event/NapCatProtocolEvent.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
|
||||
// NapCat Protocol 事件基类
|
||||
export abstract class NapCatProtocolEvent {
|
||||
protected core: NapCatCore;
|
||||
public time: number;
|
||||
public self_id: number;
|
||||
public post_type: string;
|
||||
|
||||
constructor (core: NapCatCore) {
|
||||
this.core = core;
|
||||
this.time = Math.floor(Date.now() / 1000);
|
||||
this.self_id = parseInt(core.selfInfo.uin);
|
||||
this.post_type = 'event';
|
||||
}
|
||||
|
||||
abstract toJSON (): Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 消息事件基类
|
||||
export abstract class NapCatProtocolMessageEvent extends NapCatProtocolEvent {
|
||||
public message_type: 'private' | 'group';
|
||||
public message_id: number;
|
||||
public user_id: number;
|
||||
|
||||
constructor (core: NapCatCore, messageType: 'private' | 'group', messageId: number, userId: number) {
|
||||
super(core);
|
||||
this.post_type = 'message';
|
||||
this.message_type = messageType;
|
||||
this.message_id = messageId;
|
||||
this.user_id = userId;
|
||||
}
|
||||
}
|
||||
|
||||
// 通知事件基类
|
||||
export abstract class NapCatProtocolNoticeEvent extends NapCatProtocolEvent {
|
||||
public notice_type: string;
|
||||
|
||||
constructor (core: NapCatCore, noticeType: string) {
|
||||
super(core);
|
||||
this.post_type = 'notice';
|
||||
this.notice_type = noticeType;
|
||||
}
|
||||
}
|
||||
|
||||
// 请求事件基类
|
||||
export abstract class NapCatProtocolRequestEvent extends NapCatProtocolEvent {
|
||||
public request_type: string;
|
||||
|
||||
constructor (core: NapCatCore, requestType: string) {
|
||||
super(core);
|
||||
this.post_type = 'request';
|
||||
this.request_type = requestType;
|
||||
}
|
||||
}
|
||||
|
||||
// 元事件基类
|
||||
export abstract class NapCatProtocolMetaEvent extends NapCatProtocolEvent {
|
||||
public meta_event_type: string;
|
||||
|
||||
constructor (core: NapCatCore, metaEventType: string) {
|
||||
super(core);
|
||||
this.post_type = 'meta_event';
|
||||
this.meta_event_type = metaEventType;
|
||||
}
|
||||
}
|
||||
1
packages/napcat-protocol/event/index.ts
Normal file
1
packages/napcat-protocol/event/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './NapCatProtocolEvent';
|
||||
104
packages/napcat-protocol/index.ts
Normal file
104
packages/napcat-protocol/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
InstanceContext,
|
||||
NapCatCore,
|
||||
} from 'napcat-core';
|
||||
import { NapCatProtocolConfigLoader, NapCatProtocolConfig } from '@/napcat-protocol/config';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import {
|
||||
NapCatProtocolNetworkManager,
|
||||
} from '@/napcat-protocol/network';
|
||||
import {
|
||||
NapCatProtocolMsgApi,
|
||||
NapCatProtocolUserApi,
|
||||
NapCatProtocolGroupApi,
|
||||
NapCatProtocolFriendApi,
|
||||
} from '@/napcat-protocol/api';
|
||||
import { ActionMap, createActionMap } from '@/napcat-protocol/action';
|
||||
|
||||
interface ApiListType {
|
||||
MsgApi: NapCatProtocolMsgApi;
|
||||
UserApi: NapCatProtocolUserApi;
|
||||
GroupApi: NapCatProtocolGroupApi;
|
||||
FriendApi: NapCatProtocolFriendApi;
|
||||
}
|
||||
|
||||
// NapCat Protocol 适配器 - NapCat 私有 Bot 协议实现
|
||||
export class NapCatProtocolAdapter {
|
||||
readonly core: NapCatCore;
|
||||
readonly context: InstanceContext;
|
||||
|
||||
configLoader: NapCatProtocolConfigLoader;
|
||||
public apis: ApiListType;
|
||||
networkManager: NapCatProtocolNetworkManager;
|
||||
actions: ActionMap;
|
||||
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.configLoader = new NapCatProtocolConfigLoader(core, pathWrapper.configPath);
|
||||
this.apis = {
|
||||
MsgApi: new NapCatProtocolMsgApi(this, core),
|
||||
UserApi: new NapCatProtocolUserApi(this, core),
|
||||
GroupApi: new NapCatProtocolGroupApi(this, core),
|
||||
FriendApi: new NapCatProtocolFriendApi(this, core),
|
||||
} as const;
|
||||
this.actions = createActionMap(this, core);
|
||||
this.networkManager = new NapCatProtocolNetworkManager();
|
||||
}
|
||||
|
||||
// 检查协议是否启用
|
||||
isEnabled (): boolean {
|
||||
return this.configLoader.configData.enable;
|
||||
}
|
||||
|
||||
async createProtocolLog (config: NapCatProtocolConfig) {
|
||||
let log = '[NapCat Protocol] 配置加载\n';
|
||||
log += `协议状态: ${config.enable ? '已启用' : '已禁用'}\n`;
|
||||
|
||||
if (config.enable) {
|
||||
for (const key of config.network.httpServers) {
|
||||
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
for (const key of config.network.websocketServers) {
|
||||
log += `WebSocket服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
for (const key of config.network.websocketClients) {
|
||||
log += `WebSocket客户端: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
async initProtocol () {
|
||||
const config = this.configLoader.configData;
|
||||
|
||||
// 如果协议未启用,直接返回
|
||||
if (!config.enable) {
|
||||
this.context.logger.log('[NapCat Protocol] 协议未启用,跳过初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const selfInfo = this.core.selfInfo;
|
||||
const serviceInfo = await this.createProtocolLog(config);
|
||||
this.context.logger.log(`[Notice] ${serviceInfo}`);
|
||||
|
||||
// 注册网络适配器
|
||||
// 这里可以根据配置注册不同的网络适配器
|
||||
// 例如: WebSocket Server, WebSocket Client, HTTP Server 等
|
||||
|
||||
await this.networkManager.openAllAdapters();
|
||||
|
||||
this.context.logger.log(`[NapCat Protocol] 初始化完成,Bot: ${selfInfo.uin}`);
|
||||
}
|
||||
|
||||
async close () {
|
||||
await this.networkManager.closeAllAdapters();
|
||||
this.context.logger.log('[NapCat Protocol] 已关闭所有网络适配器');
|
||||
}
|
||||
}
|
||||
|
||||
export * from './types/index';
|
||||
export * from './api/index';
|
||||
export * from './event/index';
|
||||
export * from './config/index';
|
||||
export * from './network/index';
|
||||
37
packages/napcat-protocol/network/adapter.ts
Normal file
37
packages/napcat-protocol/network/adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NetworkAdapterConfig } from '@/napcat-protocol/config/config';
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolAdapter } from '@/napcat-protocol/index';
|
||||
import { ActionMap } from '@/napcat-protocol/action';
|
||||
import { NapCatProtocolEmitEventContent, NapCatProtocolNetworkReloadType } from '@/napcat-protocol/network/index';
|
||||
|
||||
export abstract class INapCatProtocolNetworkAdapter<CT extends NetworkAdapterConfig> {
|
||||
name: string;
|
||||
isEnable: boolean = false;
|
||||
config: CT;
|
||||
readonly logger: LogWrapper;
|
||||
readonly core: NapCatCore;
|
||||
readonly protocolContext: NapCatProtocolAdapter;
|
||||
readonly actions: ActionMap;
|
||||
|
||||
constructor (name: string, config: CT, core: NapCatCore, protocolContext: NapCatProtocolAdapter, actions: ActionMap) {
|
||||
this.name = name;
|
||||
this.config = structuredClone(config);
|
||||
this.core = core;
|
||||
this.protocolContext = protocolContext;
|
||||
this.actions = actions;
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends NapCatProtocolEmitEventContent> (event: T): Promise<void>;
|
||||
|
||||
abstract open (): void | Promise<void>;
|
||||
|
||||
abstract close (): void | Promise<void>;
|
||||
|
||||
abstract reload (config: unknown): NapCatProtocolNetworkReloadType | Promise<NapCatProtocolNetworkReloadType>;
|
||||
|
||||
get isActive (): boolean {
|
||||
return this.isEnable;
|
||||
}
|
||||
}
|
||||
112
packages/napcat-protocol/network/index.ts
Normal file
112
packages/napcat-protocol/network/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { NapCatProtocolEvent } from '@/napcat-protocol/event/NapCatProtocolEvent';
|
||||
import { NapCatProtocolMessage } from '@/napcat-protocol/types';
|
||||
import { NetworkAdapterConfig } from '@/napcat-protocol/config/config';
|
||||
import { INapCatProtocolNetworkAdapter } from '@/napcat-protocol/network/adapter';
|
||||
|
||||
export type NapCatProtocolEmitEventContent = NapCatProtocolEvent | NapCatProtocolMessage;
|
||||
|
||||
export enum NapCatProtocolNetworkReloadType {
|
||||
Normal = 0,
|
||||
ConfigChange = 1,
|
||||
NetWorkReload = 2,
|
||||
NetWorkClose = 3,
|
||||
NetWorkOpen = 4,
|
||||
}
|
||||
|
||||
export class NapCatProtocolNetworkManager {
|
||||
adapters: Map<string, INapCatProtocolNetworkAdapter<NetworkAdapterConfig>> = new Map();
|
||||
|
||||
async openAllAdapters () {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.open()));
|
||||
}
|
||||
|
||||
async emitEvent (event: NapCatProtocolEmitEventContent) {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||
if (adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEvents (events: NapCatProtocolEmitEventContent[]) {
|
||||
return Promise.all(events.map(event => this.emitEvent(event)));
|
||||
}
|
||||
|
||||
async emitEventByName (names: string[], event: NapCatProtocolEmitEventContent) {
|
||||
return Promise.all(names.map(async name => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEventByNames (map: Map<string, NapCatProtocolEmitEventContent>) {
|
||||
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
registerAdapter<CT extends NetworkAdapterConfig> (adapter: INapCatProtocolNetworkAdapter<CT>) {
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: INapCatProtocolNetworkAdapter<CT>) {
|
||||
this.registerAdapter(adapter);
|
||||
await adapter.open();
|
||||
}
|
||||
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: INapCatProtocolNetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
async closeSomeAdapterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: INapCatProtocolNetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
if (adapter.isEnable) {
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findSomeAdapter (name: string) {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
async closeAdapterByPredicate (closeFilter: (adapter: INapCatProtocolNetworkAdapter<NetworkAdapterConfig>) => boolean) {
|
||||
const adaptersToClose = Array.from(this.adapters.values()).filter(closeFilter);
|
||||
await this.closeSomeAdapters(adaptersToClose);
|
||||
}
|
||||
|
||||
async closeAllAdapters () {
|
||||
await Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.close()));
|
||||
this.adapters.clear();
|
||||
}
|
||||
|
||||
async reloadAdapter<T> (name: string, config: T) {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.reload(config);
|
||||
}
|
||||
}
|
||||
|
||||
async reloadSomeAdapters<T> (configMap: Map<string, T>) {
|
||||
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.reloadAdapter(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);
|
||||
}
|
||||
}
|
||||
|
||||
export * from './adapter';
|
||||
36
packages/napcat-protocol/package.json
Normal file
36
packages/napcat-protocol/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "napcat-protocol",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
"ws": "^8.18.3",
|
||||
"json5": "^2.2.3",
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
11
packages/napcat-protocol/tsconfig.json
Normal file
11
packages/napcat-protocol/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
70
packages/napcat-protocol/types/index.ts
Normal file
70
packages/napcat-protocol/types/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// NapCat Protocol 消息类型定义
|
||||
|
||||
export interface NapCatProtocolMessage {
|
||||
post_type: 'message' | 'notice' | 'request' | 'meta_event';
|
||||
time: number;
|
||||
self_id: number;
|
||||
message_type?: 'private' | 'group';
|
||||
sub_type?: string;
|
||||
message_id?: number;
|
||||
user_id?: number;
|
||||
group_id?: number;
|
||||
message?: NapCatProtocolMessageSegment[] | string;
|
||||
raw_message?: string;
|
||||
sender?: NapCatProtocolSender;
|
||||
}
|
||||
|
||||
export interface NapCatProtocolMessageSegment {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NapCatProtocolSender {
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
card?: string;
|
||||
sex?: 'male' | 'female' | 'unknown';
|
||||
age?: number;
|
||||
area?: string;
|
||||
level?: string;
|
||||
role?: 'owner' | 'admin' | 'member';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// API 请求类型
|
||||
export interface NapCatProtocolRequest {
|
||||
action: string;
|
||||
params?: Record<string, unknown>;
|
||||
echo?: string | number;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface NapCatProtocolResponse<T = unknown> {
|
||||
status: 'ok' | 'failed';
|
||||
retcode: number;
|
||||
data: T | null;
|
||||
message?: string;
|
||||
echo?: string | number;
|
||||
}
|
||||
|
||||
// 心跳事件
|
||||
export interface NapCatProtocolHeartbeat {
|
||||
post_type: 'meta_event';
|
||||
meta_event_type: 'heartbeat';
|
||||
time: number;
|
||||
self_id: number;
|
||||
status: {
|
||||
online: boolean;
|
||||
good: boolean;
|
||||
};
|
||||
interval: number;
|
||||
}
|
||||
|
||||
// 生命周期事件
|
||||
export interface NapCatProtocolLifecycle {
|
||||
post_type: 'meta_event';
|
||||
meta_event_type: 'lifecycle';
|
||||
time: number;
|
||||
self_id: number;
|
||||
sub_type: 'connect' | 'enable' | 'disable';
|
||||
}
|
||||
@@ -105,7 +105,8 @@ export function generateOpenAPI () {
|
||||
retcode: 0,
|
||||
data: schemas.returnExample || {},
|
||||
message: '',
|
||||
wording: ''
|
||||
wording: '',
|
||||
stream: 'normal-action'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -119,7 +120,8 @@ export function generateOpenAPI () {
|
||||
retcode: error.code,
|
||||
data: null,
|
||||
message: error.description,
|
||||
wording: error.description
|
||||
wording: error.description,
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -132,7 +134,8 @@ export function generateOpenAPI () {
|
||||
retcode: 1400,
|
||||
data: null,
|
||||
message: '请求参数错误或业务逻辑执行失败',
|
||||
wording: '请求参数错误或业务逻辑执行失败'
|
||||
wording: '请求参数错误或业务逻辑执行失败',
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -171,7 +174,8 @@ export function generateOpenAPI () {
|
||||
retcode: { type: 'number', description: '返回码' },
|
||||
data: { ...cleanReturn, description: '数据' },
|
||||
message: { type: 'string', description: '消息' },
|
||||
wording: { type: 'string', description: '提示' }
|
||||
wording: { type: 'string', description: '提示' },
|
||||
stream: { type: 'string', description: '流式响应', enum: ['stream-action', 'normal-action'] }
|
||||
},
|
||||
required: ['status', 'retcode', 'data']
|
||||
},
|
||||
|
||||
@@ -15,12 +15,12 @@ export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-schema': resolve(__dirname, './src'),
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -22,7 +22,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||||
import qrcode from 'napcat-qrcode/lib/main';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { InitWebUi } from 'napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
@@ -475,10 +475,14 @@ export class NapCatShell {
|
||||
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);
|
||||
oneBotAdapter.InitOneBot()
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(this.core, this.context, this.context.pathWrapper);
|
||||
await adapterManager.initAdapters()
|
||||
.catch(e => this.context.logger.logError('初始化协议适配器失败', e));
|
||||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||||
if (oneBotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,33 @@ import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// ES 模块中获取 __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const envPath = path.join(__dirname, 'config', '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
try {
|
||||
const data = fs.readFileSync(envPath, 'utf8');
|
||||
let loadedCount = 0;
|
||||
data.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim();
|
||||
if (line && !line.startsWith('#')) {
|
||||
const parts = line.split('=');
|
||||
const key = parts[0]?.trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key && value) {
|
||||
process.env[key] = value;
|
||||
loadedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('[NapCat] Failed to load .env file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 环境变量配置
|
||||
const ENV = {
|
||||
@@ -20,6 +42,7 @@ const ENV = {
|
||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||
} as const;
|
||||
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
@@ -27,8 +50,7 @@ interface WorkerMessage {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
|
||||
// 进程管理器和当前 Worker 进程引用
|
||||
@@ -38,6 +60,11 @@ let isElectron = false;
|
||||
let isRestarting = false;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// 进程崩溃保护:记录最近的异常退出时间戳
|
||||
const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
@@ -217,7 +244,23 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
}
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出,正在尝试重新拉起...`);
|
||||
const now = Date.now();
|
||||
|
||||
// 清理超出时间窗口的崩溃记录
|
||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||
recentCrashTimestamps.shift();
|
||||
}
|
||||
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
// 检查是否超过崩溃阈值
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
346
packages/napcat-test/imageSize.test.ts
Normal file
346
packages/napcat-test/imageSize.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
detectImageTypeFromBuffer,
|
||||
imageSizeFromBuffer,
|
||||
imageSizeFromBufferFallBack,
|
||||
imageSizeFromFile,
|
||||
matchMagic,
|
||||
ImageType,
|
||||
} from '@/napcat-image-size/src';
|
||||
|
||||
// resource 目录路径
|
||||
const resourceDir = path.resolve(__dirname, '../napcat-image-size/resource');
|
||||
|
||||
// 测试用的 Buffer 数据
|
||||
const testBuffers = {
|
||||
// PNG 测试图片 (100x200)
|
||||
png: Buffer.from([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// JPEG 测试图片 (320x240)
|
||||
jpeg: Buffer.from([
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0xFF, 0xC0, 0x00, 0x0B, 0x08,
|
||||
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
|
||||
]),
|
||||
|
||||
// BMP 测试图片 (640x480)
|
||||
bmp: (() => {
|
||||
const buf = Buffer.alloc(54);
|
||||
buf.write('BM', 0);
|
||||
buf.writeUInt32LE(54, 2);
|
||||
buf.writeUInt32LE(0, 6);
|
||||
buf.writeUInt32LE(54, 10);
|
||||
buf.writeUInt32LE(40, 14);
|
||||
buf.writeUInt32LE(640, 18);
|
||||
buf.writeUInt32LE(480, 22);
|
||||
buf.writeUInt16LE(1, 26);
|
||||
buf.writeUInt16LE(24, 28);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// GIF87a 测试图片 (800x600)
|
||||
gif87a: Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
|
||||
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// GIF89a 测试图片 (1024x768)
|
||||
gif89a: Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// WebP VP8 测试图片 (1920x1080)
|
||||
webpVP8: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8 ', 12);
|
||||
buf.writeUInt32LE(14, 16);
|
||||
buf.writeUInt8(0x9D, 20);
|
||||
buf.writeUInt8(0x01, 21);
|
||||
buf.writeUInt8(0x2A, 22);
|
||||
buf.writeUInt16LE(1920 & 0x3FFF, 26);
|
||||
buf.writeUInt16LE(1080 & 0x3FFF, 28);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// WebP VP8L 测试图片 (256x128)
|
||||
webpVP8L: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8L', 12);
|
||||
buf.writeUInt32LE(10, 16);
|
||||
buf.writeUInt8(0x2F, 20);
|
||||
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
|
||||
buf.writeUInt32LE(vp8lBits, 21);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// WebP VP8X 测试图片 (512x384)
|
||||
webpVP8X: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8X', 12);
|
||||
buf.writeUInt32LE(10, 16);
|
||||
buf.writeUInt8((512 - 1) & 0xFF, 24);
|
||||
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
|
||||
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
|
||||
buf.writeUInt8((384 - 1) & 0xFF, 27);
|
||||
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
|
||||
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// TIFF Little Endian 测试图片
|
||||
tiffLE: Buffer.from([
|
||||
0x49, 0x49, 0x2A, 0x00, // II + magic
|
||||
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
|
||||
0x02, 0x00, // 2 entries
|
||||
// Entry 1: ImageWidth = 100
|
||||
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
|
||||
// Entry 2: ImageHeight = 200
|
||||
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// TIFF Big Endian 测试图片
|
||||
tiffBE: Buffer.from([
|
||||
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
|
||||
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
|
||||
0x00, 0x02, // 2 entries
|
||||
// Entry 1: ImageWidth = 100
|
||||
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
|
||||
// Entry 2: ImageHeight = 200
|
||||
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
invalid: Buffer.from('This is not an image file'),
|
||||
empty: Buffer.alloc(0),
|
||||
};
|
||||
|
||||
describe('napcat-image-size', () => {
|
||||
describe('matchMagic', () => {
|
||||
it('should match magic bytes at the beginning', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
|
||||
});
|
||||
|
||||
it('should match magic bytes at offset', () => {
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-matching magic', () => {
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for buffer too short', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for offset beyond buffer', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectImageTypeFromBuffer', () => {
|
||||
it('should detect PNG image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
|
||||
});
|
||||
|
||||
it('should detect JPEG image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
|
||||
});
|
||||
|
||||
it('should detect BMP image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
|
||||
});
|
||||
|
||||
it('should detect GIF87a image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
|
||||
});
|
||||
|
||||
it('should detect GIF89a image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8 image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8L image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8X image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect TIFF Little Endian image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
|
||||
});
|
||||
|
||||
it('should detect TIFF Big Endian image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for invalid data', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for empty buffer', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageSizeFromBuffer', () => {
|
||||
it('should parse PNG image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should parse JPEG image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
|
||||
});
|
||||
|
||||
it('should parse BMP image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('should parse GIF87a image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('should parse GIF89a image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8 image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8L image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8X image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
|
||||
});
|
||||
|
||||
it('should parse TIFF Little Endian image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should parse TIFF Big Endian image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should return undefined for invalid data', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty buffer', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageSizeFromBufferFallBack', () => {
|
||||
it('should return actual size for valid image', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should return default fallback for invalid data', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('should return custom fallback for invalid data', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
|
||||
});
|
||||
|
||||
it('should return default fallback for empty buffer', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('should return custom fallback for empty buffer', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageType enum', () => {
|
||||
it('should have correct enum values', () => {
|
||||
expect(ImageType.JPEG).toBe('jpeg');
|
||||
expect(ImageType.PNG).toBe('png');
|
||||
expect(ImageType.BMP).toBe('bmp');
|
||||
expect(ImageType.GIF).toBe('gif');
|
||||
expect(ImageType.WEBP).toBe('webp');
|
||||
expect(ImageType.TIFF).toBe('tiff');
|
||||
expect(ImageType.UNKNOWN).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real image files from resource directory', () => {
|
||||
it('should detect and parse test-20x20.jpg', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.jpg');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.png', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.png');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.tiff', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.tiff');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.webp', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.webp');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-490x498.gif', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-490x498.gif');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 490, height: 498 });
|
||||
});
|
||||
|
||||
it('should parse real images using imageSizeFromFile', async () => {
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
"vitest": "^4.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*"
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-image-size": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, '../../'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-test': resolve(__dirname, '.'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// <reference path="./external-shims.d.ts" />
|
||||
// 聚合导出核心库的所有内容(包括枚举、类和类型)
|
||||
export * from '../napcat-core/index';
|
||||
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@sinclair/typebox": "^0.34.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.15",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
@@ -9,13 +9,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@sinclair/typebox": "^0.34.38"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 复制 cp README.md dist/ && cp package.public.json dist/package.json && cp external-shims.d.ts dist/
|
||||
// 复制 cp README.md dist/ && cp package.public.json dist/package.json
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -10,8 +10,4 @@ await copyFile(
|
||||
await copyFile(
|
||||
join(__dirname, 'README.md'),
|
||||
join(__dirname, 'dist', 'README.md')
|
||||
);
|
||||
await copyFile(
|
||||
join(__dirname, 'external-shims.d.ts'),
|
||||
join(__dirname, 'dist', 'external-shims.d.ts')
|
||||
);
|
||||
@@ -5,6 +5,111 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = fileURLToPath(new URL('../', import.meta.url));
|
||||
const distDir = join(__dirname, 'dist');
|
||||
|
||||
// 允许保留的包(白名单)
|
||||
const ALLOWED_PACKAGES = [
|
||||
'@sinclair/typebox',
|
||||
'node:', // node: 前缀的内置模块
|
||||
];
|
||||
|
||||
// 外部包类型到 any 的映射
|
||||
const EXTERNAL_TYPE_REPLACEMENTS = {
|
||||
// winston
|
||||
'winston.Logger': 'any',
|
||||
'winston.transport': 'any',
|
||||
// express
|
||||
'express.Express': 'any',
|
||||
'express.Application': 'any',
|
||||
'express.Router': 'any',
|
||||
'Express': 'any',
|
||||
'Request': 'any',
|
||||
'Response': 'any',
|
||||
'NextFunction': 'any',
|
||||
// ws
|
||||
'WebSocket': 'any',
|
||||
'WebSocketServer': 'any',
|
||||
'RawData': 'any',
|
||||
// ajv
|
||||
'Ajv': 'any',
|
||||
'AnySchema': 'any',
|
||||
'ValidateFunction': 'any',
|
||||
'ValidateFunction<T>': 'any',
|
||||
// inversify
|
||||
'Container': 'any',
|
||||
// async-mutex
|
||||
'Mutex': 'any',
|
||||
'Semaphore': 'any',
|
||||
// napcat-protobuf
|
||||
'NapProtoDecodeStructType': 'any',
|
||||
'NapProtoEncodeStructType': 'any',
|
||||
'NapProtoDecodeStructType<T>': 'any',
|
||||
'NapProtoEncodeStructType<T>': 'any',
|
||||
};
|
||||
|
||||
function isAllowedImport (importPath) {
|
||||
return ALLOWED_PACKAGES.some(pkg => importPath.startsWith(pkg));
|
||||
}
|
||||
|
||||
function removeExternalImports (content) {
|
||||
const lines = content.split('\n');
|
||||
const resultLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配 import 语句
|
||||
const importMatch = line.match(/^import\s+.*\s+from\s+['"]([^'"]+)['"]/);
|
||||
if (importMatch) {
|
||||
const importPath = importMatch[1];
|
||||
// 如果是相对路径或白名单包,保留
|
||||
if (importPath.startsWith('.') || importPath.startsWith('/') || isAllowedImport(importPath)) {
|
||||
resultLines.push(line);
|
||||
}
|
||||
// 否则移除该 import
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
return resultLines.join('\n');
|
||||
}
|
||||
|
||||
function replaceExternalTypes (content) {
|
||||
let result = content;
|
||||
|
||||
// 替换带泛型的类型(先处理复杂的)
|
||||
result = result.replace(/NapProtoDecodeStructType<[^>]+>/g, 'any');
|
||||
result = result.replace(/NapProtoEncodeStructType<[^>]+>/g, 'any');
|
||||
result = result.replace(/ValidateFunction<[^>]+>/g, 'any');
|
||||
|
||||
// 替换 winston.Logger 等带命名空间的类型
|
||||
result = result.replace(/winston\.Logger/g, 'any');
|
||||
result = result.replace(/winston\.transport/g, 'any');
|
||||
result = result.replace(/express\.Express/g, 'any');
|
||||
result = result.replace(/express\.Application/g, 'any');
|
||||
result = result.replace(/express\.Router/g, 'any');
|
||||
|
||||
// 替换独立的类型名(需要小心不要替换变量名)
|
||||
// 使用类型上下文的模式匹配
|
||||
const typeContextPatterns = [
|
||||
// : Type
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
// <Type>
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
// Type[]
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
// extends Type
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
// implements Type
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
];
|
||||
|
||||
for (const pattern of typeContextPatterns) {
|
||||
result = result.replace(pattern, (match, typeName) => {
|
||||
return match.replace(typeName, 'any');
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function traverseDirectory (dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
@@ -23,7 +128,13 @@ async function processFile (filePath) {
|
||||
// Read file content
|
||||
let content = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Replace "export declare enum" with "export enum"
|
||||
// 1. 移除外部包的 import
|
||||
content = removeExternalImports(content);
|
||||
|
||||
// 2. 替换外部类型为 any
|
||||
content = replaceExternalTypes(content);
|
||||
|
||||
// 3. Replace "export declare enum" with "export enum"
|
||||
content = content.replace(/export declare enum/g, 'export enum');
|
||||
|
||||
// Write back the modified content
|
||||
@@ -33,7 +144,7 @@ async function processFile (filePath) {
|
||||
const newPath = filePath.replace(/\.d\.ts$/, '.ts');
|
||||
await rename(filePath, newPath);
|
||||
|
||||
console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
|
||||
//console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
|
||||
}
|
||||
|
||||
console.log('Starting post-build processing...');
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
"../napcat-onebot/**/*.ts",
|
||||
"../napcat-common/**/*.ts"
|
||||
],
|
||||
"files": [
|
||||
"./external-shims.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
|
||||
@@ -27,6 +27,8 @@ import compression from 'compression';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -123,9 +125,14 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查并更新默认密码(仅在启用WebUI时)
|
||||
if (config.token === 'napcat' || !config.token) {
|
||||
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
||||
// 优先使用环境变量覆盖 Token
|
||||
if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: process.env['NAPCAT_WEBUI_SECRET_KEY'] });
|
||||
logger.log(`[NapCat] [WebUi] 检测到环境变量配置,已更新 WebUI Token 为 ${process.env['NAPCAT_WEBUI_SECRET_KEY']}`);
|
||||
config = await WebUiConfig.GetWebUIConfig();
|
||||
} else if (config.token === 'napcat' || !config.token) {
|
||||
// 只有没设置环境变量,且是默认密码时,才生成随机密码
|
||||
const randomToken = getRandomToken(8);
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||||
|
||||
@@ -226,10 +233,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
// 添加字体变量
|
||||
if (fontMode === 'aacute') {
|
||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
} else if (fontMode === 'custom') {
|
||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
} else {
|
||||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||||
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||||
}
|
||||
css += '}';
|
||||
|
||||
@@ -240,10 +250,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
// 添加字体变量
|
||||
if (fontMode === 'aacute') {
|
||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
} else if (fontMode === 'custom') {
|
||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
} else {
|
||||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||||
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||||
}
|
||||
css += '}';
|
||||
|
||||
@@ -283,6 +296,72 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||||
maxAge: '1d',
|
||||
}));
|
||||
|
||||
// 插件内存静态资源路由(不需要鉴权)
|
||||
// 路径格式: /plugin/:pluginId/mem/:urlPath/*
|
||||
app.use('/plugin/:pluginId/mem', async (req, res) => {
|
||||
const { pluginId } = req.params;
|
||||
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||||
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||
|
||||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||||
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||
|
||||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||
const memoryRoutes = routerRegistry?.getMemoryStaticRoutes() || [];
|
||||
|
||||
for (const { urlPath, files } of memoryRoutes) {
|
||||
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
|
||||
if (req.path.startsWith(prefix)) {
|
||||
const filePath = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
|
||||
const memFile = files.find(f => ('/' + f.path.replace(/^\//, '')) === filePath);
|
||||
if (memFile) {
|
||||
try {
|
||||
const content = typeof memFile.content === 'function' ? await memFile.content() : memFile.content;
|
||||
res.setHeader('Content-Type', memFile.contentType || 'application/octet-stream');
|
||||
return res.send(content);
|
||||
} catch (err) {
|
||||
console.error(`[Plugin: ${pluginId}] Error serving memory file:`, err);
|
||||
return res.status(500).json({ code: -1, message: 'Error serving memory file' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res.status(404).json({ code: -1, message: 'Memory file not found' });
|
||||
});
|
||||
|
||||
// 插件文件系统静态资源路由(不需要鉴权)
|
||||
// 路径格式: /plugin/:pluginId/files/*
|
||||
app.use('/plugin/:pluginId/files', (req, res, next) => {
|
||||
const { pluginId } = req.params;
|
||||
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||||
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||
|
||||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||||
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||
|
||||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||
const staticRoutes = routerRegistry?.getStaticRoutes() || [];
|
||||
|
||||
for (const { urlPath, localPath } of staticRoutes) {
|
||||
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
|
||||
if (req.path.startsWith(prefix) || req.path === prefix.slice(0, -1)) {
|
||||
const staticMiddleware = express.static(localPath, { maxAge: '1d' });
|
||||
const originalUrl = req.url;
|
||||
req.url = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
|
||||
return staticMiddleware(req, res, (err) => {
|
||||
req.url = originalUrl;
|
||||
err ? next(err) : next();
|
||||
});
|
||||
}
|
||||
}
|
||||
res.status(404).json({ code: -1, message: 'Static resource not found' });
|
||||
});
|
||||
|
||||
// 初始化WebSocket服务器
|
||||
const sslCerts = await checkCertificates(logger);
|
||||
const isHttps = !!sslCerts;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user