mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 14:41:14 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7054535321 | ||
|
|
38f6dad118 | ||
|
|
c3b29f1ee6 | ||
|
|
3e85d18ab5 | ||
|
|
df48c01ce4 | ||
|
|
209776a9e8 | ||
|
|
09dae7269a | ||
|
|
2dcf8004ab | ||
|
|
74b1da67d8 | ||
|
|
78ac36f670 | ||
|
|
74781fda0a | ||
|
|
3bead89d46 | ||
|
|
a4527fd8ca | ||
|
|
52b6627ebd | ||
|
|
a5769b6a62 | ||
|
|
d9297c1e10 | ||
|
|
94f07ab98b | ||
|
|
01a6594707 | ||
|
|
82a7154b92 | ||
|
|
9b385ac9c9 | ||
|
|
e3d4cee416 |
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
@ -125,18 +125,54 @@ jobs:
|
|||||||
cd "$TMPDIR"
|
cd "$TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 1) 下载 QQ x64
|
# 1) 下载 QQ x64 (使用缓存)
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
||||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
|
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
|
||||||
QQ_ZIP="$(basename "$NT_URL")"
|
QQ_ZIP="$(basename "$NT_URL")"
|
||||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
# 根据 URL 生成缓存键
|
||||||
|
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
|
||||||
|
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
|
||||||
|
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
|
||||||
|
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache QQ x64 Installer
|
||||||
|
id: cache-qq
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/qq-cache
|
||||||
|
key: ${{ env.QQ_CACHE_KEY }}
|
||||||
|
|
||||||
|
- name: Download and Extract QQ x64
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
cd "$TMPDIR"
|
||||||
|
|
||||||
|
QQ_CACHE_DIR="$HOME/qq-cache"
|
||||||
|
mkdir -p "$QQ_CACHE_DIR"
|
||||||
|
|
||||||
|
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
|
||||||
|
echo "Using cached QQ installer: $QQ_ZIP"
|
||||||
|
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
|
||||||
|
else
|
||||||
|
echo "Downloading QQ installer: $QQ_ZIP"
|
||||||
|
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||||
|
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
|
||||||
|
fi
|
||||||
|
|
||||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||||
mkdir -p "$QQ_EXTRACT"
|
mkdir -p "$QQ_EXTRACT"
|
||||||
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||||
|
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
|
||||||
|
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$WORK_TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||||
@ -146,7 +182,7 @@ jobs:
|
|||||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||||
|
|
||||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
|
||||||
mkdir -p "$NODE_EXTRACT"
|
mkdir -p "$NODE_EXTRACT"
|
||||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||||
|
|
||||||
@ -164,11 +200,18 @@ jobs:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
|
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
|
||||||
for name in "${QQ_TARGETS[@]}"; do
|
for name in "${QQ_TARGETS[@]}"; do
|
||||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 5.1) 拷贝 win64 目录下的文件
|
||||||
|
# -----------------------------
|
||||||
|
mkdir -p "$OUT_DIR/win64"
|
||||||
|
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||||
|
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@ -178,6 +221,7 @@ jobs:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
NODE_VER="22.11.0"
|
||||||
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -16,4 +16,5 @@ checkVersion.sh
|
|||||||
bun.lockb
|
bun.lockb
|
||||||
tests/run/
|
tests/run/
|
||||||
guild1.db-wal
|
guild1.db-wal
|
||||||
guild1.db-shm
|
guild1.db-shm
|
||||||
|
packages/napcat-develop/config/.env
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export class NTQQFriendApi {
|
|||||||
async getBuddyV2SimpleInfoMap () {
|
async getBuddyV2SimpleInfoMap () {
|
||||||
const buddyService = this.context.session.getBuddyService();
|
const buddyService = this.context.session.getBuddyService();
|
||||||
let uids: string[] = [];
|
let uids: string[] = [];
|
||||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
|
||||||
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
|
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
|
||||||
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
||||||
} else {
|
} else {
|
||||||
@ -55,7 +55,7 @@ export class NTQQFriendApi {
|
|||||||
const buddyService = this.context.session.getBuddyService();
|
const buddyService = this.context.session.getBuddyService();
|
||||||
let uids: string[] = [];
|
let uids: string[] = [];
|
||||||
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
|
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
|
||||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
|
||||||
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
|
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
|
||||||
uids = buddyListV2.flatMap(item => item.buddyUids);
|
uids = buddyListV2.flatMap(item => item.buddyUids);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
2
packages/napcat-core/external/appid.json
vendored
2
packages/napcat-core/external/appid.json
vendored
@ -521,7 +521,7 @@
|
|||||||
},
|
},
|
||||||
"9.9.27-45627": {
|
"9.9.27-45627": {
|
||||||
"appid": 537340060,
|
"appid": 537340060,
|
||||||
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
|
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
|
||||||
},
|
},
|
||||||
"6.9.88-44725": {
|
"6.9.88-44725": {
|
||||||
"appid": 537337594,
|
"appid": 537337594,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface LoginInitConfig {
|
|||||||
commonPath: string;
|
commonPath: string;
|
||||||
clientVer: string;
|
clientVer: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
|
externalVersion: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordLoginRetType {
|
export interface PasswordLoginRetType {
|
||||||
@ -21,7 +22,7 @@ export interface PasswordLoginRetType {
|
|||||||
jumpWord: string;
|
jumpWord: string;
|
||||||
tipsTitle: string;
|
tipsTitle: string;
|
||||||
tipsContent: string;
|
tipsContent: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordLoginArgType {
|
export interface PasswordLoginArgType {
|
||||||
@ -55,37 +56,37 @@ export interface QuickLoginResult {
|
|||||||
jumpUrl: string,
|
jumpUrl: string,
|
||||||
jumpWord: string,
|
jumpWord: string,
|
||||||
tipsTitle: string,
|
tipsTitle: string,
|
||||||
tipsContent: string
|
tipsContent: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeIKernelLoginService {
|
export interface NodeIKernelLoginService {
|
||||||
getMsfStatus: () => number;
|
getMsfStatus: () => number;
|
||||||
|
|
||||||
setLoginMiscData(arg0: string, value: string): unknown;
|
setLoginMiscData (arg0: string, value: string): unknown;
|
||||||
|
|
||||||
getMachineGuid(): string;
|
getMachineGuid (): string;
|
||||||
|
|
||||||
get(): NodeIKernelLoginService;
|
get (): NodeIKernelLoginService;
|
||||||
|
|
||||||
connect(): boolean;
|
connect (): boolean;
|
||||||
|
|
||||||
addKernelLoginListener(listener: NodeIKernelLoginListener): number;
|
addKernelLoginListener (listener: NodeIKernelLoginListener): number;
|
||||||
|
|
||||||
removeKernelLoginListener(listener: number): void;
|
removeKernelLoginListener (listener: number): void;
|
||||||
|
|
||||||
initConfig(config: LoginInitConfig): void;
|
initConfig (config: LoginInitConfig): void;
|
||||||
|
|
||||||
getLoginMiscData(data: string): Promise<GeneralCallResult & { value: string }>;
|
getLoginMiscData (data: string): Promise<GeneralCallResult & { value: string; }>;
|
||||||
|
|
||||||
getLoginList(): Promise<{
|
getLoginList (): Promise<{
|
||||||
result: number, // 0是ok
|
result: number, // 0是ok
|
||||||
LocalLoginInfoList: LoginListItem[]
|
LocalLoginInfoList: LoginListItem[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
quickLoginWithUin(uin: string): Promise<QuickLoginResult>;
|
quickLoginWithUin (uin: string): Promise<QuickLoginResult>;
|
||||||
|
|
||||||
passwordLogin(param: PasswordLoginArgType): Promise<unknown>;
|
passwordLogin (param: PasswordLoginArgType): Promise<QuickLoginResult>;
|
||||||
|
|
||||||
getQRCodePicture(): boolean;
|
getQRCodePicture (): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { GeneralCallResult } from './common';
|
import { GeneralCallResult } from './common';
|
||||||
|
|
||||||
export interface NodeIKernelNodeMiscService {
|
export interface NodeIKernelNodeMiscService {
|
||||||
writeVersionToRegistry(version: string): void;
|
writeVersionToRegistry (version: string): void;
|
||||||
|
|
||||||
getMiniAppPath(): unknown;
|
getMiniAppPath (): unknown;
|
||||||
|
|
||||||
setMiniAppVersion(version: string): unknown;
|
setMiniAppVersion (version: string): unknown;
|
||||||
|
|
||||||
wantWinScreenOCR(imagepath: string): Promise<GeneralCallResult>;
|
wantWinScreenOCR (imagepath: string): Promise<GeneralCallResult>;
|
||||||
|
|
||||||
SendMiniAppMsg(arg1: string, arg2: string, arg3: string): unknown;
|
SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown;
|
||||||
|
|
||||||
startNewMiniApp(appfile: string, params: string): unknown;
|
startNewMiniApp (appfile: string, params: string): unknown;
|
||||||
|
|
||||||
|
getQimei36WithNewSdk (): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,9 +87,9 @@ export interface NodeQQNTWrapperUtil {
|
|||||||
|
|
||||||
fullWordToHalfWord (word: string): unknown;
|
fullWordToHalfWord (word: string): unknown;
|
||||||
|
|
||||||
getNTUserDataInfoConfig (): unknown;
|
getNTUserDataInfoConfig (): Promise<string>;
|
||||||
|
|
||||||
pathIsReadableAndWriteable (path: string): unknown; // 直接的猜测
|
pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
|
||||||
|
|
||||||
resetUserDataSavePathToDocument (): unknown;
|
resetUserDataSavePathToDocument (): unknown;
|
||||||
|
|
||||||
@ -158,7 +158,7 @@ export interface NodeIQQNTStartupSessionWrapper {
|
|||||||
stop (): void;
|
stop (): void;
|
||||||
start (): void;
|
start (): void;
|
||||||
createWithModuleList (uk: unknown): unknown;
|
createWithModuleList (uk: unknown): unknown;
|
||||||
getSessionIdList (): unknown;
|
getSessionIdList (): Promise<Map<unknown, unknown>>;
|
||||||
}
|
}
|
||||||
export interface NodeIQQNTWrapperSession {
|
export interface NodeIQQNTWrapperSession {
|
||||||
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
||||||
|
|||||||
@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
|
|||||||
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
||||||
console.log(`BASE_DIR: ${BASE_DIR}`);
|
console.log(`BASE_DIR: ${BASE_DIR}`);
|
||||||
const TARGET_DIR = path.join(__dirname, 'dist');
|
const TARGET_DIR = path.join(__dirname, 'dist');
|
||||||
|
const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64');
|
||||||
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
||||||
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
||||||
|
|
||||||
@ -46,6 +47,12 @@ const itemsToCopy = [
|
|||||||
'package.json',
|
'package.json',
|
||||||
'QBar.dll',
|
'QBar.dll',
|
||||||
'wrapper.node',
|
'wrapper.node',
|
||||||
|
'LightQuic.dll'
|
||||||
|
];
|
||||||
|
|
||||||
|
const win64ItemsToCopy = [
|
||||||
|
'SSOShareInfoHelper64.dll',
|
||||||
|
'parent-ipc-core-x64.dll'
|
||||||
];
|
];
|
||||||
|
|
||||||
async function copyAll () {
|
async function copyAll () {
|
||||||
@ -53,13 +60,23 @@ async function copyAll () {
|
|||||||
const configPath = path.join(TARGET_DIR, 'config.json');
|
const configPath = path.join(TARGET_DIR, 'config.json');
|
||||||
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
||||||
await fs.pathExists(configPath) &&
|
await fs.pathExists(configPath) &&
|
||||||
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists);
|
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) &&
|
||||||
|
(await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists);
|
||||||
|
|
||||||
if (!allItemsExist) {
|
if (!allItemsExist) {
|
||||||
console.log('Copying required files...');
|
console.log('Copying required files...');
|
||||||
await fs.ensureDir(TARGET_DIR);
|
await fs.ensureDir(TARGET_DIR);
|
||||||
|
await fs.ensureDir(TARGET_WIN64_DIR);
|
||||||
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
||||||
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
||||||
|
|
||||||
|
// 复制 win64 目录下的文件
|
||||||
|
await Promise.all(win64ItemsToCopy.map(async (item) => {
|
||||||
|
await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true });
|
||||||
|
console.log(`Copied ${item} to win64`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 复制根目录下的文件
|
||||||
await Promise.all(itemsToCopy.map(async (item) => {
|
await Promise.all(itemsToCopy.map(async (item) => {
|
||||||
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
||||||
console.log(`Copied ${item}`);
|
console.log(`Copied ${item}`);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export { PluginModule } from './plugin/types';
|
|||||||
export { PluginStatusConfig } from './plugin/types';
|
export { PluginStatusConfig } from './plugin/types';
|
||||||
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
|
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
|
||||||
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
|
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
|
||||||
|
export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types';
|
||||||
export { PluginRouterRegistryImpl } from './plugin/router-registry';
|
export { PluginRouterRegistryImpl } from './plugin/router-registry';
|
||||||
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
||||||
private readonly pluginPath: string;
|
private readonly pluginPath: string;
|
||||||
@ -179,6 +180,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
this.pluginRouters.delete(entry.id);
|
this.pluginRouters.delete(entry.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理模块缓存
|
||||||
|
this.loader.clearCache(entry.pluginPath);
|
||||||
|
|
||||||
// 重置状态
|
// 重置状态
|
||||||
entry.loaded = false;
|
entry.loaded = false;
|
||||||
entry.runtime = {
|
entry.runtime = {
|
||||||
@ -211,6 +215,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
// 保存到路由注册表
|
// 保存到路由注册表
|
||||||
this.pluginRouters.set(entry.id, routerRegistry);
|
this.pluginRouters.set(entry.id, routerRegistry);
|
||||||
|
|
||||||
|
// 创建获取其他插件导出的方法
|
||||||
|
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 {
|
return {
|
||||||
core: this.core,
|
core: this.core,
|
||||||
oneBot: this.obContext,
|
oneBot: this.obContext,
|
||||||
@ -224,6 +237,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
pluginManager: this,
|
pluginManager: this,
|
||||||
logger: pluginLogger,
|
logger: pluginLogger,
|
||||||
router: routerRegistry,
|
router: routerRegistry,
|
||||||
|
getPluginExports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
import { LogWrapper } from 'napcat-core/helper/log';
|
import { LogWrapper } from 'napcat-core/helper/log';
|
||||||
import {
|
import {
|
||||||
PluginPackageJson,
|
PluginPackageJson,
|
||||||
@ -295,4 +297,24 @@ export class PluginLoader {
|
|||||||
|
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -194,6 +194,15 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
this.pluginRouters.set(entry.id, router);
|
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 {
|
return {
|
||||||
core: this.core,
|
core: this.core,
|
||||||
oneBot: this.obContext,
|
oneBot: this.obContext,
|
||||||
@ -207,6 +216,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
pluginManager: this,
|
pluginManager: this,
|
||||||
logger: pluginLogger,
|
logger: pluginLogger,
|
||||||
router,
|
router,
|
||||||
|
getPluginExports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Router, static as expressStatic, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
PluginRouterRegistry,
|
PluginRouterRegistry,
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
PluginHttpRequest,
|
PluginHttpRequest,
|
||||||
PluginHttpResponse,
|
PluginHttpResponse,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
|
MemoryStaticFile,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,10 +60,18 @@ function wrapResponse (res: Response): PluginHttpResponse {
|
|||||||
* 插件路由注册器实现
|
* 插件路由注册器实现
|
||||||
* 为每个插件创建独立的路由注册器,收集路由定义
|
* 为每个插件创建独立的路由注册器,收集路由定义
|
||||||
*/
|
*/
|
||||||
|
/** 内存静态路由定义 */
|
||||||
|
interface MemoryStaticRoute {
|
||||||
|
urlPath: string;
|
||||||
|
files: MemoryStaticFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||||
|
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
|
||||||
private pageDefinitions: PluginPageDefinition[] = [];
|
private pageDefinitions: PluginPageDefinition[] = [];
|
||||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||||
|
private memoryStaticRoutes: MemoryStaticRoute[] = [];
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private readonly pluginId: string,
|
private readonly pluginId: string,
|
||||||
@ -91,6 +100,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
this.api('delete', routePath, handler);
|
this.api('delete', routePath, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 无认证 API 路由注册 ====================
|
||||||
|
|
||||||
|
apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuthRoutes.push({ method, path: routePath, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
getNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('get', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
postNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('post', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
putNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('put', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('delete', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 页面注册 ====================
|
// ==================== 页面注册 ====================
|
||||||
|
|
||||||
page (pageDef: PluginPageDefinition): void {
|
page (pageDef: PluginPageDefinition): void {
|
||||||
@ -111,19 +142,19 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
|
||||||
|
this.memoryStaticRoutes.push({ urlPath, files });
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 构建路由 ====================
|
// ==================== 构建路由 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建 Express Router(用于 API 路由)
|
* 构建 Express Router(用于 API 路由)
|
||||||
|
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
|
||||||
*/
|
*/
|
||||||
buildApiRouter (): Router {
|
buildApiRouter (): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// 注册静态文件路由
|
|
||||||
for (const { urlPath, localPath } of this.staticRoutes) {
|
|
||||||
router.use(urlPath, expressStatic(localPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册 API 路由
|
// 注册 API 路由
|
||||||
for (const route of this.apiRoutes) {
|
for (const route of this.apiRoutes) {
|
||||||
const handler = this.wrapHandler(route.handler);
|
const handler = this.wrapHandler(route.handler);
|
||||||
@ -176,10 +207,57 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
// ==================== 查询方法 ====================
|
// ==================== 查询方法 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否有注册的 API 路由
|
* 检查是否有注册的 API 路由(需要认证)
|
||||||
*/
|
*/
|
||||||
hasApiRoutes (): boolean {
|
hasApiRoutes (): boolean {
|
||||||
return this.apiRoutes.length > 0 || this.staticRoutes.length > 0;
|
return this.apiRoutes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有注册的无认证 API 路由
|
||||||
|
*/
|
||||||
|
hasApiNoAuthRoutes (): boolean {
|
||||||
|
return this.apiNoAuthRoutes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建无认证 Express Router(用于 /plugin/{pluginId}/api/ 路径)
|
||||||
|
*/
|
||||||
|
buildApiNoAuthRouter (): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
for (const route of this.apiNoAuthRoutes) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有注册的静态资源路由
|
||||||
|
*/
|
||||||
|
hasStaticRoutes (): boolean {
|
||||||
|
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -210,12 +288,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
return this.pluginPath;
|
return this.pluginPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有注册的静态路由
|
||||||
|
*/
|
||||||
|
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
|
||||||
|
return [...this.staticRoutes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有注册的内存静态路由
|
||||||
|
*/
|
||||||
|
getMemoryStaticRoutes (): MemoryStaticRoute[] {
|
||||||
|
return [...this.memoryStaticRoutes];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空路由(用于插件卸载)
|
* 清空路由(用于插件卸载)
|
||||||
*/
|
*/
|
||||||
clear (): void {
|
clear (): void {
|
||||||
this.apiRoutes = [];
|
this.apiRoutes = [];
|
||||||
|
this.apiNoAuthRoutes = [];
|
||||||
this.pageDefinitions = [];
|
this.pageDefinitions = [];
|
||||||
this.staticRoutes = [];
|
this.staticRoutes = [];
|
||||||
|
this.memoryStaticRoutes = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,26 +125,57 @@ export interface PluginPageDefinition {
|
|||||||
description?: 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 {
|
export interface PluginRouterRegistry {
|
||||||
// ==================== API 路由注册 ====================
|
// ==================== API 路由注册(需要认证) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册单个 API 路由
|
* 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/)
|
||||||
* @param method HTTP 方法
|
* @param method HTTP 方法
|
||||||
* @param path 路由路径
|
* @param path 路由路径
|
||||||
* @param handler 请求处理器
|
* @param handler 请求处理器
|
||||||
*/
|
*/
|
||||||
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 GET API */
|
/** 注册 GET API(需要认证) */
|
||||||
get (path: string, handler: PluginRequestHandler): void;
|
get (path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 POST API */
|
/** 注册 POST API(需要认证) */
|
||||||
post (path: string, handler: PluginRequestHandler): void;
|
post (path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 PUT API */
|
/** 注册 PUT API(需要认证) */
|
||||||
put (path: string, handler: PluginRequestHandler): void;
|
put (path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 DELETE API */
|
/** 注册 DELETE API(需要认证) */
|
||||||
delete (path: string, handler: PluginRequestHandler): void;
|
delete (path: string, handler: PluginRequestHandler): void;
|
||||||
|
|
||||||
|
// ==================== 无认证 API 路由注册 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册单个无认证 API 路由(挂载到 /plugin/{pluginId}/api/)
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param path 路由路径
|
||||||
|
* @param handler 请求处理器
|
||||||
|
*/
|
||||||
|
apiNoAuth (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 GET API(无认证) */
|
||||||
|
getNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 POST API(无认证) */
|
||||||
|
postNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 PUT API(无认证) */
|
||||||
|
putNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 DELETE API(无认证) */
|
||||||
|
deleteNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
|
||||||
// ==================== 页面注册 ====================
|
// ==================== 页面注册 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -167,6 +198,13 @@ export interface PluginRouterRegistry {
|
|||||||
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
||||||
*/
|
*/
|
||||||
static (urlPath: string, localPath: string): void;
|
static (urlPath: string, localPath: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供内存生成的静态文件服务
|
||||||
|
* @param urlPath URL 路径
|
||||||
|
* @param files 内存文件列表
|
||||||
|
*/
|
||||||
|
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 插件管理器接口 ====================
|
// ==================== 插件管理器接口 ====================
|
||||||
@ -247,8 +285,15 @@ export interface NapCatPluginContext {
|
|||||||
/**
|
/**
|
||||||
* WebUI 路由注册器
|
* WebUI 路由注册器
|
||||||
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
||||||
|
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
|
||||||
*/
|
*/
|
||||||
router: PluginRouterRegistry;
|
router: PluginRouterRegistry;
|
||||||
|
/**
|
||||||
|
* 获取其他插件的导出模块
|
||||||
|
* @param pluginId 目标插件 ID
|
||||||
|
* @returns 插件导出的模块,如果插件未加载则返回 undefined
|
||||||
|
*/
|
||||||
|
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 插件模块接口 ====================
|
// ==================== 插件模块接口 ====================
|
||||||
|
|||||||
@ -65,10 +65,32 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
|
|
||||||
// ==================== 注册 WebUI 路由示例 ====================
|
// ==================== 注册 WebUI 路由示例 ====================
|
||||||
|
|
||||||
// 注册静态资源目录(webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问)
|
// 注册静态资源目录
|
||||||
|
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
|
||||||
ctx.router.static('/static', 'webui');
|
ctx.router.static('/static', 'webui');
|
||||||
|
|
||||||
// 注册 API 路由
|
// 注册内存生成的静态资源(无需鉴权)
|
||||||
|
// 可通过 /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) => {
|
ctx.router.get('/status', (_req, res) => {
|
||||||
const uptime = Date.now() - startTime;
|
const uptime = Date.now() - startTime;
|
||||||
res.json({
|
res.json({
|
||||||
@ -107,7 +129,74 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注册扩展页面
|
// ==================== 无认证 API 路由示例 ====================
|
||||||
|
// 路由挂载到 /plugin/{pluginId}/api/,无需 WebUI 登录即可访问
|
||||||
|
|
||||||
|
// 获取插件公开信息(无需鉴权)
|
||||||
|
ctx.router.getNoAuth('/public/info', (_req, res) => {
|
||||||
|
const uptime = Date.now() - startTime;
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
pluginName: ctx.pluginName,
|
||||||
|
uptime,
|
||||||
|
uptimeFormatted: formatUptime(uptime),
|
||||||
|
platform: process.platform
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 健康检查接口(无需鉴权)
|
||||||
|
ctx.router.getNoAuth('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 插件互调用示例 ====================
|
||||||
|
// 演示如何调用其他插件的导出方法
|
||||||
|
ctx.router.get('/call-plugin/:pluginId', (req, res) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
|
||||||
|
if (!pluginId) {
|
||||||
|
res.status(400).json({
|
||||||
|
code: -1,
|
||||||
|
message: 'Plugin ID is required'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 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),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册扩展页面(无需鉴权,可通过 /plugin/{pluginId}/page/dashboard 访问)
|
||||||
ctx.router.page({
|
ctx.router.page({
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
title: '插件仪表盘',
|
title: '插件仪表盘',
|
||||||
@ -116,7 +205,12 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
description: '查看内置插件的运行状态和配置'
|
description: '查看内置插件的运行状态和配置'
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName);
|
logger.info('WebUI 路由已注册:');
|
||||||
|
logger.info(' - API 路由(需认证): /api/Plugin/ext/' + ctx.pluginName + '/');
|
||||||
|
logger.info(' - API 路由(无认证): /plugin/' + ctx.pluginName + '/api/');
|
||||||
|
logger.info(' - 扩展页面: /plugin/' + ctx.pluginName + '/page/dashboard');
|
||||||
|
logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/');
|
||||||
|
logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
"description": "NapCat 内置插件",
|
"description": "NapCat 内置插件",
|
||||||
"author": "NapNeko",
|
"author": "NapNeko",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napcat-types": "0.0.14"
|
"napcat-types": "0.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.1"
|
"@types/node": "^22.0.1"
|
||||||
|
|||||||
@ -259,11 +259,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="static-content">
|
<div id="static-content">
|
||||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||||
测试插件静态资源服务是否正常工作
|
测试插件静态资源服务是否正常工作(不需要鉴权)
|
||||||
</p>
|
</p>
|
||||||
<div class="actions" style="margin-top: 0;">
|
<div class="actions" style="margin-top: 0;">
|
||||||
<button class="btn btn-primary" onclick="testStaticResource()">
|
<button class="btn btn-primary" onclick="testStaticResource()">
|
||||||
获取 test.txt
|
获取 test.txt(文件系统)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="testMemoryResource()">
|
||||||
|
获取 info.json(内存生成)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="static-result" style="margin-top: 12px;"></div>
|
<div id="static-result" style="margin-top: 12px;"></div>
|
||||||
@ -276,12 +279,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 从 URL 参数获取 webui_token
|
// 从 localStorage 获取 token(与父页面同源,可直接访问)
|
||||||
|
// 兼容旧版:如果 URL 有 webui_token 参数则优先使用
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const webuiToken = urlParams.get('webui_token') || '';
|
const webuiToken = localStorage.getItem('token') || '';
|
||||||
|
|
||||||
// 插件自行管理 API 调用
|
// 插件 API 基础路径(需要鉴权)
|
||||||
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
||||||
|
// 插件静态资源基础路径(不需要鉴权)
|
||||||
|
const staticBase = '/plugin/napcat-plugin-builtin/files';
|
||||||
|
// 插件内存资源基础路径(不需要鉴权)
|
||||||
|
const memBase = '/plugin/napcat-plugin-builtin/mem';
|
||||||
|
|
||||||
// 封装 fetch,自动携带认证
|
// 封装 fetch,自动携带认证
|
||||||
async function authFetch (url, options = {}) {
|
async function authFetch (url, options = {}) {
|
||||||
@ -392,12 +400,13 @@
|
|||||||
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${apiBase}/static/test.txt`);
|
// 静态资源不需要鉴权,直接请求
|
||||||
|
const response = await fetch(`${staticBase}/static/test.txt`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
resultDiv.innerHTML = `
|
resultDiv.innerHTML = `
|
||||||
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
<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>
|
<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>
|
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -408,6 +417,30 @@
|
|||||||
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
25
packages/napcat-rpc/package.json
Normal file
25
packages/napcat-rpc/package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "napcat-rpc",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"./src/*": {
|
||||||
|
"import": "./src/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
366
packages/napcat-rpc/src/client.ts
Normal file
366
packages/napcat-rpc/src/client.ts
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import {
|
||||||
|
type DeepProxyOptions,
|
||||||
|
type ProxyMeta,
|
||||||
|
RpcOperationType,
|
||||||
|
PROXY_META,
|
||||||
|
type RpcRequest,
|
||||||
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
serialize,
|
||||||
|
deserialize,
|
||||||
|
SimpleCallbackRegistry,
|
||||||
|
extractCallbackIds,
|
||||||
|
} from './serializer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一请求 ID
|
||||||
|
*/
|
||||||
|
function generateRequestId (): string {
|
||||||
|
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建深层 RPC 代理
|
||||||
|
*
|
||||||
|
* 将所有属性访问、方法调用等操作转换为 RPC 请求
|
||||||
|
*/
|
||||||
|
export function createDeepProxy<T = unknown> (options: DeepProxyOptions): T {
|
||||||
|
const {
|
||||||
|
transport,
|
||||||
|
rootPath = [],
|
||||||
|
refId: rootRefId,
|
||||||
|
// callbackTimeout 可供未来扩展使用
|
||||||
|
} = options;
|
||||||
|
void options.callbackTimeout;
|
||||||
|
|
||||||
|
const callbackRegistry = new SimpleCallbackRegistry();
|
||||||
|
|
||||||
|
// 注册回调处理器
|
||||||
|
if (transport.onCallback) {
|
||||||
|
transport.onCallback(async (callbackId, serializedArgs) => {
|
||||||
|
const callback = callbackRegistry.get(callbackId);
|
||||||
|
if (!callback) {
|
||||||
|
throw new Error(`Callback not found: ${callbackId}`);
|
||||||
|
}
|
||||||
|
const args = serializedArgs.map(arg => deserialize(arg, {
|
||||||
|
callbackResolver: (id) => {
|
||||||
|
const cb = callbackRegistry.get(id);
|
||||||
|
if (!cb) throw new Error(`Nested callback not found: ${id}`);
|
||||||
|
return cb;
|
||||||
|
},
|
||||||
|
proxyCreator: (path, proxyRefId, cachedProps) => createProxyAtPath(path, proxyRefId, cachedProps),
|
||||||
|
}));
|
||||||
|
const result = await callback(...args);
|
||||||
|
return serialize(result, { callbackRegistry });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定路径创建代理
|
||||||
|
* @param path 路径
|
||||||
|
* @param refId 远程对象引用 ID
|
||||||
|
* @param cachedProps 缓存的属性值(避免属性访问需要 RPC)
|
||||||
|
*/
|
||||||
|
function createProxyAtPath (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>): unknown {
|
||||||
|
const proxyMeta: ProxyMeta = {
|
||||||
|
path: [...path],
|
||||||
|
isProxy: true,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建一个函数目标,以支持 apply 和 construct
|
||||||
|
const target = function () { } as unknown as Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get (_target, prop) {
|
||||||
|
// 返回代理元数据
|
||||||
|
if (prop === PROXY_META) {
|
||||||
|
return proxyMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// then 方法特殊处理,使代理可以被 await
|
||||||
|
if (prop === 'then') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存属性(仅顶层代理,即 path 为空时)
|
||||||
|
if (path.length === 0 && cachedProps && typeof prop === 'string' && prop in cachedProps) {
|
||||||
|
return cachedProps[prop];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回新的子路径代理(继承 refId,不继承 cachedProps)
|
||||||
|
return createProxyAtPath([...path, prop], refId);
|
||||||
|
},
|
||||||
|
|
||||||
|
set (_target, prop, value) {
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.SET,
|
||||||
|
path: [...path, prop],
|
||||||
|
args: [serialize(value, { callbackRegistry })],
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步返回,但实际是异步操作
|
||||||
|
transport.send(request).catch(() => { /* ignore */ });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
apply (_target, _thisArg, args) {
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
|
||||||
|
const callbackIds = extractCallbackIds(serializedArgs);
|
||||||
|
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.APPLY,
|
||||||
|
path,
|
||||||
|
args: serializedArgs,
|
||||||
|
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createAsyncResultProxy(request);
|
||||||
|
},
|
||||||
|
|
||||||
|
construct (_target, args): object {
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
|
||||||
|
const callbackIds = extractCallbackIds(serializedArgs);
|
||||||
|
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.CONSTRUCT,
|
||||||
|
path,
|
||||||
|
args: serializedArgs,
|
||||||
|
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createAsyncResultProxy(request) as object;
|
||||||
|
},
|
||||||
|
|
||||||
|
has (_target, prop) {
|
||||||
|
// 检查是否为代理元数据符号
|
||||||
|
if (prop === PROXY_META) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 同步返回 true,实际检查通过异步完成
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
ownKeys () {
|
||||||
|
// 返回空数组,实际键需要通过异步获取
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getOwnPropertyDescriptor (_target, _prop) {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProperty (_target, prop) {
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.DELETE,
|
||||||
|
path: [...path, prop],
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
transport.send(request).catch(() => { /* ignore */ });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPrototypeOf () {
|
||||||
|
return Object.prototype;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建异步结果代理
|
||||||
|
* 返回一个 Promise-like 对象,可以被 await,
|
||||||
|
* 同时也可以继续链式访问属性
|
||||||
|
*/
|
||||||
|
function createAsyncResultProxy (request: RpcRequest): unknown {
|
||||||
|
let resultPromise: Promise<unknown> | null = null;
|
||||||
|
|
||||||
|
const getResult = async (): Promise<unknown> => {
|
||||||
|
if (!resultPromise) {
|
||||||
|
resultPromise = (async () => {
|
||||||
|
const response = await transport.send(request);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
const error = new Error(response.error ?? 'RPC call failed');
|
||||||
|
if (response.stack) {
|
||||||
|
error.stack = response.stack;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.result === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果结果是可代理对象,返回代理
|
||||||
|
if (response.isProxyable && response.result) {
|
||||||
|
const deserialized = deserialize(response.result, {
|
||||||
|
callbackResolver: (id) => {
|
||||||
|
const cb = callbackRegistry.get(id);
|
||||||
|
if (!cb) throw new Error(`Callback not found: ${id}`);
|
||||||
|
return cb;
|
||||||
|
},
|
||||||
|
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
|
||||||
|
});
|
||||||
|
return deserialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialize(response.result, {
|
||||||
|
callbackResolver: (id) => {
|
||||||
|
const cb = callbackRegistry.get(id);
|
||||||
|
if (!cb) throw new Error(`Callback not found: ${id}`);
|
||||||
|
return cb;
|
||||||
|
},
|
||||||
|
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return resultPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建一个可链式访问的代理
|
||||||
|
const target = function () { } as unknown as Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get (_target, prop) {
|
||||||
|
if (prop === 'then') {
|
||||||
|
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
|
||||||
|
getResult().then(resolve, reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'catch') {
|
||||||
|
return (reject: (error: unknown) => void) => {
|
||||||
|
getResult().catch(reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'finally') {
|
||||||
|
return (callback: () => void) => {
|
||||||
|
getResult().finally(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === PROXY_META) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链式访问:等待结果后访问其属性
|
||||||
|
return createChainedProxy(getResult(), [prop]);
|
||||||
|
},
|
||||||
|
|
||||||
|
apply (_target, _thisArg, args) {
|
||||||
|
// 等待结果后调用
|
||||||
|
return getResult().then(result => {
|
||||||
|
if (typeof result === 'function') {
|
||||||
|
return result(...args);
|
||||||
|
}
|
||||||
|
throw new Error('Result is not callable');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建链式代理
|
||||||
|
* 用于处理 await result.prop.method() 这样的链式调用
|
||||||
|
*/
|
||||||
|
function createChainedProxy (parentPromise: Promise<unknown>, path: PropertyKey[]): unknown {
|
||||||
|
const target = function () { } as unknown as Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get (_target, prop) {
|
||||||
|
if (prop === 'then') {
|
||||||
|
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
|
||||||
|
parentPromise
|
||||||
|
.then(parent => {
|
||||||
|
let value: unknown = parent;
|
||||||
|
for (const key of path) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
value = (value as Record<PropertyKey, unknown>)[key];
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'catch') {
|
||||||
|
return (reject: (error: unknown) => void) => {
|
||||||
|
parentPromise.catch(reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'finally') {
|
||||||
|
return (callback: () => void) => {
|
||||||
|
parentPromise.finally(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return createChainedProxy(parentPromise, [...path, prop]);
|
||||||
|
},
|
||||||
|
|
||||||
|
apply (_target, _thisArg, args) {
|
||||||
|
return parentPromise.then(parent => {
|
||||||
|
let value: unknown = parent;
|
||||||
|
const pathToMethod = path.slice(0, -1);
|
||||||
|
const methodName = path[path.length - 1];
|
||||||
|
|
||||||
|
for (const key of pathToMethod) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
throw new Error(`Cannot access property '${String(key)}' of ${value}`);
|
||||||
|
}
|
||||||
|
value = (value as Record<PropertyKey, unknown>)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (value as Record<PropertyKey, unknown>)[methodName!];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
throw new Error(`${String(methodName)} is not a function`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return method.call(value, ...args);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createProxyAtPath(rootPath, rootRefId) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理的元数据
|
||||||
|
*/
|
||||||
|
export function getProxyMeta (proxy: unknown): ProxyMeta | undefined {
|
||||||
|
if (proxy != null && (typeof proxy === 'object' || typeof proxy === 'function')) {
|
||||||
|
try {
|
||||||
|
// 直接访问 Symbol 属性,代理的 get 陷阱会返回元数据
|
||||||
|
const meta = (proxy as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
|
||||||
|
if (meta && meta.isProxy === true) {
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略访问错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为 RPC 代理
|
||||||
|
*/
|
||||||
|
export function isRpcProxy (value: unknown): boolean {
|
||||||
|
return getProxyMeta(value) !== undefined;
|
||||||
|
}
|
||||||
130
packages/napcat-rpc/src/easy.ts
Normal file
130
packages/napcat-rpc/src/easy.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 简化版 RPC API
|
||||||
|
*
|
||||||
|
* 提供一键创建完全隔离的 client/server 对
|
||||||
|
* 在 client 端操作就像直接操作 server 端的变量一样
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LocalTransport } from './transport.js';
|
||||||
|
import { createDeepProxy, getProxyMeta, isRpcProxy } from './client.js';
|
||||||
|
import { RpcServer } from './server.js';
|
||||||
|
import type { ProxyMeta } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 配对结果
|
||||||
|
*/
|
||||||
|
export interface RpcPair<T> {
|
||||||
|
/** 客户端代理 - 在这里操作就像直接操作服务端的变量 */
|
||||||
|
client: T;
|
||||||
|
/** 服务端原始对象 */
|
||||||
|
server: T;
|
||||||
|
/** 关闭连接 */
|
||||||
|
close (): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 RPC 配对
|
||||||
|
*
|
||||||
|
* 快速创建完全隔离的 client/server 对,client 端的所有操作都会通过 RPC 传递到 server 端执行
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { client, server } = createRpcPair({
|
||||||
|
* name: 'test',
|
||||||
|
* greet: (msg: string) => `Hello, ${msg}!`,
|
||||||
|
* register: (handlers: { onSuccess: Function, onError: Function }) => {
|
||||||
|
* handlers.onSuccess('done');
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 在 client 端操作,就像直接操作 server 端的变量
|
||||||
|
* await client.greet('world'); // 返回 'Hello, world!'
|
||||||
|
*
|
||||||
|
* // 支持包含多个回调的对象
|
||||||
|
* await client.register({
|
||||||
|
* onSuccess: (result) => console.log(result),
|
||||||
|
* onError: (err) => console.error(err)
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createRpcPair<T extends object> (target: T): RpcPair<T> {
|
||||||
|
const transport = new LocalTransport(target);
|
||||||
|
const client = createDeepProxy<T>({ transport });
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
server: target,
|
||||||
|
close: () => transport.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟远程变量
|
||||||
|
*
|
||||||
|
* 将一个本地变量包装成"看起来像远程变量"的代理,所有操作都通过 RPC 隔离
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const remoteApi = mockRemote({
|
||||||
|
* counter: 0,
|
||||||
|
* increment() { return ++this.counter; },
|
||||||
|
* async fetchData(id: number) { return { id, data: 'test' }; }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 所有操作都是异步的,通过 RPC 隔离
|
||||||
|
* await remoteApi.increment(); // 1
|
||||||
|
* await remoteApi.fetchData(123); // { id: 123, data: 'test' }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function mockRemote<T extends object> (target: T): T {
|
||||||
|
return createRpcPair(target).client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 RPC 服务端
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const server = createServer({
|
||||||
|
* users: new Map(),
|
||||||
|
* addUser(id: string, name: string) {
|
||||||
|
* this.users.set(id, { name });
|
||||||
|
* return true;
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 获取传输层供客户端连接
|
||||||
|
* const transport = server.getTransport();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createServer<T extends object> (target: T): {
|
||||||
|
target: T;
|
||||||
|
handler: RpcServer;
|
||||||
|
getTransport (): LocalTransport;
|
||||||
|
} {
|
||||||
|
const handler = new RpcServer({ target });
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
handler,
|
||||||
|
getTransport: () => new LocalTransport(target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建指向服务端的客户端
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const server = createServer(myApi);
|
||||||
|
* const client = createClient<typeof myApi>(server.getTransport());
|
||||||
|
*
|
||||||
|
* await client.someMethod();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createClient<T extends object> (transport: LocalTransport): T {
|
||||||
|
return createDeepProxy<T>({ transport });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新导出常用工具
|
||||||
|
export { getProxyMeta, isRpcProxy };
|
||||||
|
export type { ProxyMeta };
|
||||||
60
packages/napcat-rpc/src/index.ts
Normal file
60
packages/napcat-rpc/src/index.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* napcat-rpc
|
||||||
|
*
|
||||||
|
* 深层 RPC 代理库 - 将对象的所有层级操作转换为 RPC 调用
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 简化 API(推荐使用)
|
||||||
|
export {
|
||||||
|
createRpcPair,
|
||||||
|
mockRemote,
|
||||||
|
createServer,
|
||||||
|
createClient,
|
||||||
|
} from './easy.js';
|
||||||
|
|
||||||
|
// 类型导出
|
||||||
|
export {
|
||||||
|
RpcOperationType,
|
||||||
|
SerializedValueType,
|
||||||
|
PROXY_META,
|
||||||
|
type RpcRequest,
|
||||||
|
type RpcResponse,
|
||||||
|
type SerializedValue,
|
||||||
|
type RpcTransport,
|
||||||
|
type RpcServerHandler,
|
||||||
|
type RpcServerOptions,
|
||||||
|
type DeepProxyOptions,
|
||||||
|
type ProxyMeta,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// 序列化工具
|
||||||
|
export {
|
||||||
|
serialize,
|
||||||
|
deserialize,
|
||||||
|
extractCallbackIds,
|
||||||
|
SimpleCallbackRegistry,
|
||||||
|
type CallbackRegistry,
|
||||||
|
type SerializeContext,
|
||||||
|
type DeserializeContext,
|
||||||
|
} from './serializer.js';
|
||||||
|
|
||||||
|
// 客户端代理
|
||||||
|
export {
|
||||||
|
createDeepProxy,
|
||||||
|
getProxyMeta,
|
||||||
|
isRpcProxy,
|
||||||
|
} from './client.js';
|
||||||
|
|
||||||
|
// 服务端
|
||||||
|
export {
|
||||||
|
RpcServer,
|
||||||
|
createRpcServer,
|
||||||
|
} from './server.js';
|
||||||
|
|
||||||
|
// 传输层
|
||||||
|
export {
|
||||||
|
LocalTransport,
|
||||||
|
MessageTransport,
|
||||||
|
createMessageServerHandler,
|
||||||
|
type MessageTransportOptions,
|
||||||
|
} from './transport.js';
|
||||||
410
packages/napcat-rpc/src/serializer.ts
Normal file
410
packages/napcat-rpc/src/serializer.ts
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
import {
|
||||||
|
SerializedValue,
|
||||||
|
SerializedValueType,
|
||||||
|
PROXY_META,
|
||||||
|
type ProxyMeta,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回调注册器接口
|
||||||
|
*/
|
||||||
|
export interface CallbackRegistry {
|
||||||
|
register (fn: Function): string;
|
||||||
|
get (id: string): Function | undefined;
|
||||||
|
remove (id: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的回调注册器实现
|
||||||
|
*/
|
||||||
|
export class SimpleCallbackRegistry implements CallbackRegistry {
|
||||||
|
private callbacks = new Map<string, Function>();
|
||||||
|
private counter = 0;
|
||||||
|
|
||||||
|
register (fn: Function): string {
|
||||||
|
const id = `cb_${++this.counter}_${Date.now()}`;
|
||||||
|
this.callbacks.set(id, fn);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get (id: string): Function | undefined {
|
||||||
|
return this.callbacks.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove (id: string): void {
|
||||||
|
this.callbacks.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear (): void {
|
||||||
|
this.callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化上下文
|
||||||
|
*/
|
||||||
|
export interface SerializeContext {
|
||||||
|
/** 回调注册器 */
|
||||||
|
callbackRegistry?: CallbackRegistry;
|
||||||
|
/** 已序列化对象映射(用于循环引用检测) */
|
||||||
|
seen?: WeakMap<object, SerializedValue>;
|
||||||
|
/** 深度限制 */
|
||||||
|
maxDepth?: number;
|
||||||
|
/** 当前深度 */
|
||||||
|
currentDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反序列化上下文
|
||||||
|
*/
|
||||||
|
export interface DeserializeContext {
|
||||||
|
/** 回调解析器 */
|
||||||
|
callbackResolver?: (id: string) => Function;
|
||||||
|
/** 代理创建器 */
|
||||||
|
proxyCreator?: (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>) => unknown;
|
||||||
|
/** 对象引用解析器 */
|
||||||
|
refResolver?: (refId: string) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将值序列化为可传输格式
|
||||||
|
*/
|
||||||
|
export function serialize (value: unknown, context: SerializeContext = {}): SerializedValue {
|
||||||
|
const {
|
||||||
|
callbackRegistry,
|
||||||
|
seen = new WeakMap(),
|
||||||
|
maxDepth = 50,
|
||||||
|
currentDepth = 0,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
// 深度检查
|
||||||
|
if (currentDepth > maxDepth) {
|
||||||
|
return { type: SerializedValueType.STRING, value: '[Max depth exceeded]' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基本类型处理
|
||||||
|
if (value === undefined) {
|
||||||
|
return { type: SerializedValueType.UNDEFINED };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return { type: SerializedValueType.NULL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueType = typeof value;
|
||||||
|
|
||||||
|
if (valueType === 'boolean') {
|
||||||
|
return { type: SerializedValueType.BOOLEAN, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'number') {
|
||||||
|
const numValue = value as number;
|
||||||
|
if (Number.isNaN(numValue)) {
|
||||||
|
return { type: SerializedValueType.NUMBER, value: 'NaN' };
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(numValue)) {
|
||||||
|
return { type: SerializedValueType.NUMBER, value: numValue > 0 ? 'Infinity' : '-Infinity' };
|
||||||
|
}
|
||||||
|
return { type: SerializedValueType.NUMBER, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'bigint') {
|
||||||
|
return { type: SerializedValueType.BIGINT, value: (value as bigint).toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'string') {
|
||||||
|
return { type: SerializedValueType.STRING, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'symbol') {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.SYMBOL,
|
||||||
|
value: (value as symbol).description ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'function') {
|
||||||
|
const fn = value as Function;
|
||||||
|
if (callbackRegistry) {
|
||||||
|
const callbackId = callbackRegistry.register(fn);
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.FUNCTION,
|
||||||
|
callbackId,
|
||||||
|
className: fn.name || 'anonymous',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.FUNCTION,
|
||||||
|
className: fn.name || 'anonymous',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象类型处理
|
||||||
|
const obj = value as object;
|
||||||
|
|
||||||
|
// 检查是否为代理对象
|
||||||
|
if (PROXY_META in obj) {
|
||||||
|
const meta = (obj as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
|
||||||
|
if (meta) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.PROXY_REF,
|
||||||
|
proxyPath: meta.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环引用检测
|
||||||
|
if (seen.has(obj)) {
|
||||||
|
return seen.get(obj)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date
|
||||||
|
if (obj instanceof Date) {
|
||||||
|
return { type: SerializedValueType.DATE, value: obj.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegExp
|
||||||
|
if (obj instanceof RegExp) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.REGEXP,
|
||||||
|
value: { source: obj.source, flags: obj.flags },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (obj instanceof Error) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.ERROR,
|
||||||
|
value: obj.message,
|
||||||
|
className: obj.constructor.name,
|
||||||
|
properties: {
|
||||||
|
stack: serialize(obj.stack, { ...context, seen, currentDepth: currentDepth + 1 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer / Uint8Array
|
||||||
|
if (obj instanceof Uint8Array) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.BUFFER,
|
||||||
|
value: Array.from(obj as Uint8Array),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node.js Buffer
|
||||||
|
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
|
||||||
|
const BufferClass = (globalThis as unknown as { Buffer: { isBuffer (obj: unknown): boolean; }; }).Buffer;
|
||||||
|
if (BufferClass.isBuffer(obj)) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.BUFFER,
|
||||||
|
value: Array.from(obj as unknown as Uint8Array),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map
|
||||||
|
if (obj instanceof Map) {
|
||||||
|
const entries: SerializedValue[] = [];
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
for (const [k, v] of obj) {
|
||||||
|
entries.push(serialize([k, v], nextContext));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.MAP,
|
||||||
|
elements: entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set
|
||||||
|
if (obj instanceof Set) {
|
||||||
|
const elements: SerializedValue[] = [];
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
for (const v of obj) {
|
||||||
|
elements.push(serialize(v, nextContext));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.SET,
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise
|
||||||
|
if (obj instanceof Promise) {
|
||||||
|
return { type: SerializedValueType.PROMISE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const result: SerializedValue = {
|
||||||
|
type: SerializedValueType.ARRAY,
|
||||||
|
elements: [],
|
||||||
|
};
|
||||||
|
seen.set(obj, result);
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
result.elements = obj.map(item => serialize(item, nextContext));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通对象
|
||||||
|
const result: SerializedValue = {
|
||||||
|
type: SerializedValueType.OBJECT,
|
||||||
|
className: obj.constructor?.name ?? 'Object',
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
seen.set(obj, result);
|
||||||
|
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
result.properties![key] = serialize((obj as Record<string, unknown>)[key], nextContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将序列化数据还原为值
|
||||||
|
*/
|
||||||
|
export function deserialize (data: SerializedValue, context: DeserializeContext = {}): unknown {
|
||||||
|
const { callbackResolver, proxyCreator, refResolver } = context;
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case SerializedValueType.UNDEFINED:
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
case SerializedValueType.NULL:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case SerializedValueType.BOOLEAN:
|
||||||
|
return data.value;
|
||||||
|
|
||||||
|
case SerializedValueType.NUMBER:
|
||||||
|
if (data.value === 'NaN') return NaN;
|
||||||
|
if (data.value === 'Infinity') return Infinity;
|
||||||
|
if (data.value === '-Infinity') return -Infinity;
|
||||||
|
return data.value;
|
||||||
|
|
||||||
|
case SerializedValueType.BIGINT:
|
||||||
|
return BigInt(data.value as string);
|
||||||
|
|
||||||
|
case SerializedValueType.STRING:
|
||||||
|
return data.value;
|
||||||
|
|
||||||
|
case SerializedValueType.SYMBOL:
|
||||||
|
return Symbol(data.value as string);
|
||||||
|
|
||||||
|
case SerializedValueType.FUNCTION:
|
||||||
|
if (data.callbackId && callbackResolver) {
|
||||||
|
return callbackResolver(data.callbackId);
|
||||||
|
}
|
||||||
|
// 返回一个占位函数
|
||||||
|
return function placeholder () {
|
||||||
|
throw new Error('Remote function cannot be called without callback resolver');
|
||||||
|
};
|
||||||
|
|
||||||
|
case SerializedValueType.DATE:
|
||||||
|
return new Date(data.value as string);
|
||||||
|
|
||||||
|
case SerializedValueType.REGEXP: {
|
||||||
|
const { source, flags } = data.value as { source: string; flags: string; };
|
||||||
|
return new RegExp(source, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.ERROR: {
|
||||||
|
const error = new Error(data.value as string);
|
||||||
|
if (data.properties?.['stack']) {
|
||||||
|
error.stack = deserialize(data.properties['stack'], context) as string;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.BUFFER: {
|
||||||
|
const arr = data.value as number[];
|
||||||
|
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
|
||||||
|
const BufferClass = (globalThis as unknown as { Buffer: { from (arr: number[]): Uint8Array; }; }).Buffer;
|
||||||
|
return BufferClass.from(arr);
|
||||||
|
}
|
||||||
|
return new Uint8Array(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.MAP: {
|
||||||
|
const map = new Map();
|
||||||
|
if (data.elements) {
|
||||||
|
for (const element of data.elements) {
|
||||||
|
const [k, v] = deserialize(element, context) as [unknown, unknown];
|
||||||
|
map.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.SET: {
|
||||||
|
const set = new Set();
|
||||||
|
if (data.elements) {
|
||||||
|
for (const element of data.elements) {
|
||||||
|
set.add(deserialize(element, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.PROMISE:
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
case SerializedValueType.ARRAY:
|
||||||
|
return (data.elements ?? []).map(elem => deserialize(elem, context));
|
||||||
|
|
||||||
|
case SerializedValueType.PROXY_REF:
|
||||||
|
if (data.proxyPath && proxyCreator) {
|
||||||
|
return proxyCreator(data.proxyPath);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
|
||||||
|
case SerializedValueType.OBJECT_REF:
|
||||||
|
// 对象引用:在客户端创建代理,在服务端解析为实际对象
|
||||||
|
if (data.refId) {
|
||||||
|
// 优先使用 refResolver(服务端场景)
|
||||||
|
if (refResolver) {
|
||||||
|
return refResolver(data.refId);
|
||||||
|
}
|
||||||
|
// 否则创建代理(客户端场景)
|
||||||
|
if (proxyCreator) {
|
||||||
|
// 反序列化缓存的属性
|
||||||
|
let cachedValues: Record<string, unknown> | undefined;
|
||||||
|
if (data.cachedProps) {
|
||||||
|
cachedValues = {};
|
||||||
|
for (const [key, val] of Object.entries(data.cachedProps)) {
|
||||||
|
cachedValues[key] = deserialize(val, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proxyCreator([], data.refId, cachedValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
|
||||||
|
case SerializedValueType.OBJECT: {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
if (data.properties) {
|
||||||
|
for (const [key, val] of Object.entries(data.properties)) {
|
||||||
|
obj[key] = deserialize(val, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取序列化参数中的回调ID映射
|
||||||
|
*/
|
||||||
|
export function extractCallbackIds (args: SerializedValue[]): Record<number, string> {
|
||||||
|
const result: Record<number, string> = {};
|
||||||
|
args.forEach((arg, index) => {
|
||||||
|
if (arg.type === SerializedValueType.FUNCTION && arg.callbackId) {
|
||||||
|
result[index] = arg.callbackId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
577
packages/napcat-rpc/src/server.ts
Normal file
577
packages/napcat-rpc/src/server.ts
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
import {
|
||||||
|
type RpcRequest,
|
||||||
|
type RpcResponse,
|
||||||
|
type RpcServerOptions,
|
||||||
|
type SerializedValue,
|
||||||
|
RpcOperationType,
|
||||||
|
SerializedValueType,
|
||||||
|
} from './types.js';
|
||||||
|
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一引用 ID
|
||||||
|
*/
|
||||||
|
function generateRefId (): string {
|
||||||
|
return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认的代理判断函数
|
||||||
|
* 判断返回值是否应该保持代理引用(而非完全序列化)
|
||||||
|
* 策略:class 实例和有方法的对象保持代理,普通对象直接序列化
|
||||||
|
*/
|
||||||
|
function defaultShouldProxyResult (value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object' && typeof value !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 函数保持代理
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 可安全序列化的内置类型不代理
|
||||||
|
if (value instanceof Date || value instanceof RegExp || value instanceof Error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value instanceof Map || value instanceof Set) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 数组不代理
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查对象原型是否为 Object.prototype(普通对象)
|
||||||
|
const proto = Object.getPrototypeOf(value);
|
||||||
|
if (proto === Object.prototype || proto === null) {
|
||||||
|
// 普通对象检查是否有方法
|
||||||
|
const hasMethod = Object.values(value as object).some(v => typeof v === 'function');
|
||||||
|
return hasMethod;
|
||||||
|
}
|
||||||
|
// 非普通对象(class 实例)- 保持代理
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 服务端
|
||||||
|
*
|
||||||
|
* 处理来自客户端的 RPC 请求,在目标对象上执行操作
|
||||||
|
*/
|
||||||
|
export class RpcServer {
|
||||||
|
private target: unknown;
|
||||||
|
private callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
|
||||||
|
private localCallbacks = new SimpleCallbackRegistry();
|
||||||
|
/** 对象引用存储 */
|
||||||
|
private objectRefs = new Map<string, unknown>();
|
||||||
|
/** 代理判断函数 */
|
||||||
|
private shouldProxyResult: (value: unknown) => boolean;
|
||||||
|
|
||||||
|
constructor (options: RpcServerOptions) {
|
||||||
|
this.target = options.target;
|
||||||
|
this.callbackInvoker = options.callbackInvoker;
|
||||||
|
this.shouldProxyResult = options.shouldProxyResult ?? defaultShouldProxyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 RPC 请求
|
||||||
|
*/
|
||||||
|
async handleRequest (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
try {
|
||||||
|
switch (request.type) {
|
||||||
|
case RpcOperationType.GET:
|
||||||
|
return this.handleGet(request);
|
||||||
|
|
||||||
|
case RpcOperationType.SET:
|
||||||
|
return this.handleSet(request);
|
||||||
|
|
||||||
|
case RpcOperationType.APPLY:
|
||||||
|
return await this.handleApply(request);
|
||||||
|
|
||||||
|
case RpcOperationType.CONSTRUCT:
|
||||||
|
return await this.handleConstruct(request);
|
||||||
|
|
||||||
|
case RpcOperationType.HAS:
|
||||||
|
return this.handleHas(request);
|
||||||
|
|
||||||
|
case RpcOperationType.OWNKEYS:
|
||||||
|
return this.handleOwnKeys(request);
|
||||||
|
|
||||||
|
case RpcOperationType.DELETE:
|
||||||
|
return this.handleDelete(request);
|
||||||
|
|
||||||
|
case RpcOperationType.GET_DESCRIPTOR:
|
||||||
|
return this.handleGetDescriptor(request);
|
||||||
|
|
||||||
|
case RpcOperationType.GET_PROTOTYPE:
|
||||||
|
return this.handleGetPrototype(request);
|
||||||
|
|
||||||
|
case RpcOperationType.RELEASE:
|
||||||
|
return this.handleRelease(request);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: false,
|
||||||
|
error: `Unknown operation type: ${request.type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return this.createErrorResponse(request.id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析路径获取目标值,支持 refId
|
||||||
|
*/
|
||||||
|
private resolvePath (path: PropertyKey[], refId?: string): { parent: unknown; key: PropertyKey | undefined; value: unknown; } {
|
||||||
|
// 如果有 refId,从引用存储中获取根对象
|
||||||
|
let current = refId ? this.objectRefs.get(refId) : this.target;
|
||||||
|
|
||||||
|
if (refId && current === undefined) {
|
||||||
|
throw new Error(`Object reference not found: ${refId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent: unknown = null;
|
||||||
|
let key: PropertyKey | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
parent = current;
|
||||||
|
key = path[i];
|
||||||
|
if (key === undefined) {
|
||||||
|
throw new Error('Path contains undefined key');
|
||||||
|
}
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
throw new Error(`Cannot access property '${String(key)}' of ${current}`);
|
||||||
|
}
|
||||||
|
current = (current as Record<PropertyKey, unknown>)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parent, key, value: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储对象引用并返回序列化的引用
|
||||||
|
* 同时序列化可序列化的属性值,避免属性访问需要额外 RPC
|
||||||
|
*/
|
||||||
|
private storeObjectRef (value: unknown): SerializedValue {
|
||||||
|
const refId = generateRefId();
|
||||||
|
this.objectRefs.set(refId, value);
|
||||||
|
const className = value?.constructor?.name;
|
||||||
|
|
||||||
|
// 序列化非函数属性
|
||||||
|
const cachedProps: Record<string, SerializedValue> = {};
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
const propValue = (value as Record<string, unknown>)[key];
|
||||||
|
// 跳过函数(方法需要远程调用)
|
||||||
|
if (typeof propValue === 'function') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 序列化属性值
|
||||||
|
try {
|
||||||
|
cachedProps[key] = serialize(propValue, { callbackRegistry: this.localCallbacks });
|
||||||
|
} catch {
|
||||||
|
// 序列化失败的属性跳过,让客户端通过 RPC 获取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.OBJECT_REF,
|
||||||
|
refId,
|
||||||
|
className: className !== 'Object' ? className : undefined,
|
||||||
|
cachedProps: Object.keys(cachedProps).length > 0 ? cachedProps : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化结果值,如果需要代理则存储引用
|
||||||
|
*/
|
||||||
|
private serializeResult (value: unknown): { result: SerializedValue; isProxyable: boolean; refId?: string; } {
|
||||||
|
const shouldProxy = this.shouldProxyResult(value);
|
||||||
|
|
||||||
|
if (shouldProxy) {
|
||||||
|
const ref = this.storeObjectRef(value);
|
||||||
|
return {
|
||||||
|
result: ref,
|
||||||
|
isProxyable: true,
|
||||||
|
refId: ref.refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: serialize(value, { callbackRegistry: this.localCallbacks }),
|
||||||
|
isProxyable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 GET 操作
|
||||||
|
*/
|
||||||
|
private handleGet (request: RpcRequest): RpcResponse {
|
||||||
|
const { value } = this.resolvePath(request.path, request.refId);
|
||||||
|
const { result, isProxyable, refId } = this.serializeResult(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 SET 操作
|
||||||
|
*/
|
||||||
|
private handleSet (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0 && !request.refId) {
|
||||||
|
throw new Error('Cannot set root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
throw new Error(`Cannot set property '${String(key)}' of ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = request.args?.[0]
|
||||||
|
? deserialize(request.args[0], {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
(parent as Record<PropertyKey, unknown>)[key] = newValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 APPLY 操作
|
||||||
|
*/
|
||||||
|
private async handleApply (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
const path = request.path;
|
||||||
|
|
||||||
|
// 如果有 refId 且 path 为空,说明引用对象本身是函数
|
||||||
|
if (path.length === 0 && request.refId) {
|
||||||
|
const func = this.objectRefs.get(request.refId);
|
||||||
|
if (typeof func !== 'function') {
|
||||||
|
throw new Error('Referenced object is not callable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (request.args ?? []).map(arg =>
|
||||||
|
deserialize(arg, {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = func(...args);
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result = await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serializedResult,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
throw new Error('Cannot call root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodPath = path.slice(0, -1);
|
||||||
|
const methodName = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(methodPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
throw new Error(`Cannot call method on ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (parent as Record<PropertyKey, unknown>)[methodName];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
throw new Error(`${String(methodName)} is not a function`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (request.args ?? []).map(arg =>
|
||||||
|
deserialize(arg, {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = method.call(parent, ...args);
|
||||||
|
|
||||||
|
// 处理 Promise
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result = await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serializedResult,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 CONSTRUCT 操作
|
||||||
|
*/
|
||||||
|
private async handleConstruct (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
const { value: Constructor } = this.resolvePath(request.path, request.refId);
|
||||||
|
|
||||||
|
if (typeof Constructor !== 'function') {
|
||||||
|
throw new Error('Target is not a constructor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (request.args ?? []).map(arg =>
|
||||||
|
deserialize(arg, {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const instance = new (Constructor as new (...args: unknown[]) => unknown)(...args);
|
||||||
|
const { result, isProxyable, refId } = this.serializeResult(instance);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HAS 操作
|
||||||
|
*/
|
||||||
|
private handleHas (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
const has = parent !== null && parent !== undefined && key in (parent as object);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(has),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 OWNKEYS 操作
|
||||||
|
*/
|
||||||
|
private handleOwnKeys (request: RpcRequest): RpcResponse {
|
||||||
|
const { value } = this.resolvePath(request.path, request.refId);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Reflect.ownKeys(value as object);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(keys.map(k => (typeof k === 'symbol' ? k.description ?? '' : String(k)))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 DELETE 操作
|
||||||
|
*/
|
||||||
|
private handleDelete (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0 && !request.refId) {
|
||||||
|
throw new Error('Cannot delete root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
throw new Error(`Cannot delete property from ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = delete (parent as Record<PropertyKey, unknown>)[key];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(deleted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 GET_DESCRIPTOR 操作
|
||||||
|
*/
|
||||||
|
private handleGetDescriptor (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(parent as object, key);
|
||||||
|
|
||||||
|
if (!descriptor) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化描述符(排除 value 和 get/set 函数)
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize({
|
||||||
|
configurable: descriptor.configurable,
|
||||||
|
enumerable: descriptor.enumerable,
|
||||||
|
writable: descriptor.writable,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 GET_PROTOTYPE 操作
|
||||||
|
*/
|
||||||
|
private handleGetPrototype (request: RpcRequest): RpcResponse {
|
||||||
|
const { value } = this.resolvePath(request.path, request.refId);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = Object.getPrototypeOf(value);
|
||||||
|
const name = proto?.constructor?.name ?? 'Object';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize({ name }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 RELEASE 操作
|
||||||
|
*/
|
||||||
|
private handleRelease (request: RpcRequest): RpcResponse {
|
||||||
|
// 如果有 refId,释放该引用
|
||||||
|
if (request.refId) {
|
||||||
|
this.objectRefs.delete(request.refId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建回调解析器
|
||||||
|
*/
|
||||||
|
private createCallbackResolver (_request: RpcRequest): (id: string) => Function {
|
||||||
|
return (callbackId: string) => {
|
||||||
|
// 创建一个代理函数,调用时会通过 callbackInvoker 发送回客户端
|
||||||
|
return async (...args: unknown[]) => {
|
||||||
|
if (!this.callbackInvoker) {
|
||||||
|
throw new Error('Callback invoker not configured');
|
||||||
|
}
|
||||||
|
return this.callbackInvoker(callbackId, args);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建错误响应
|
||||||
|
*/
|
||||||
|
private createErrorResponse (requestId: string, error: unknown): RpcResponse {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
id: requestId,
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: requestId,
|
||||||
|
success: false,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用客户端回调
|
||||||
|
*/
|
||||||
|
async invokeCallback (callbackId: string, args: unknown[]): Promise<SerializedValue> {
|
||||||
|
if (!this.callbackInvoker) {
|
||||||
|
throw new Error('Callback invoker not configured');
|
||||||
|
}
|
||||||
|
const result = await this.callbackInvoker(callbackId, args);
|
||||||
|
return serialize(result, { callbackRegistry: this.localCallbacks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 RPC 服务端
|
||||||
|
*/
|
||||||
|
export function createRpcServer (options: RpcServerOptions): RpcServer {
|
||||||
|
return new RpcServer(options);
|
||||||
|
}
|
||||||
204
packages/napcat-rpc/src/transport.ts
Normal file
204
packages/napcat-rpc/src/transport.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
type RpcTransport,
|
||||||
|
type RpcRequest,
|
||||||
|
type RpcResponse,
|
||||||
|
type SerializedValue,
|
||||||
|
} from './types.js';
|
||||||
|
import { RpcServer } from './server.js';
|
||||||
|
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地传输层
|
||||||
|
*
|
||||||
|
* 用于在同一进程内进行 RPC 调用,主要用于测试
|
||||||
|
*/
|
||||||
|
export class LocalTransport implements RpcTransport {
|
||||||
|
private server: RpcServer;
|
||||||
|
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
|
||||||
|
private clientCallbacks = new SimpleCallbackRegistry();
|
||||||
|
|
||||||
|
constructor (target: unknown) {
|
||||||
|
this.server = new RpcServer({
|
||||||
|
target,
|
||||||
|
callbackInvoker: async (callbackId, args) => {
|
||||||
|
if (!this.callbackHandler) {
|
||||||
|
throw new Error('Callback handler not registered');
|
||||||
|
}
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry: this.clientCallbacks }));
|
||||||
|
const result = await this.callbackHandler(callbackId, serializedArgs);
|
||||||
|
return deserialize(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
// 模拟网络延迟(可选)
|
||||||
|
// await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
return this.server.handleRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
|
||||||
|
this.callbackHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
close (): void {
|
||||||
|
this.clientCallbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息传输层接口
|
||||||
|
*/
|
||||||
|
export interface MessageTransportOptions {
|
||||||
|
/** 发送消息 */
|
||||||
|
sendMessage: (message: string) => void | Promise<void>;
|
||||||
|
/** 接收消息时的回调 */
|
||||||
|
onMessage: (handler: (message: string) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于消息的传输层
|
||||||
|
*
|
||||||
|
* 可用于跨进程/网络通信
|
||||||
|
*/
|
||||||
|
export class MessageTransport implements RpcTransport {
|
||||||
|
private pendingRequests = new Map<string, {
|
||||||
|
resolve: (response: RpcResponse) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}>();
|
||||||
|
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
|
||||||
|
private sendMessage: (message: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
constructor (options: MessageTransportOptions) {
|
||||||
|
this.sendMessage = options.sendMessage;
|
||||||
|
|
||||||
|
options.onMessage(async (message) => {
|
||||||
|
const data = JSON.parse(message) as {
|
||||||
|
type: 'response' | 'callback' | 'callback_response';
|
||||||
|
id: string;
|
||||||
|
response?: RpcResponse;
|
||||||
|
callbackId?: string;
|
||||||
|
args?: SerializedValue[];
|
||||||
|
result?: SerializedValue;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.type === 'response') {
|
||||||
|
const pending = this.pendingRequests.get(data.id);
|
||||||
|
if (pending && data.response) {
|
||||||
|
this.pendingRequests.delete(data.id);
|
||||||
|
pending.resolve(data.response);
|
||||||
|
}
|
||||||
|
} else if (data.type === 'callback') {
|
||||||
|
// 处理来自服务端的回调调用
|
||||||
|
if (this.callbackHandler && data.callbackId && data.args) {
|
||||||
|
try {
|
||||||
|
const result = await this.callbackHandler(data.callbackId, data.args);
|
||||||
|
await this.sendMessage(JSON.stringify({
|
||||||
|
type: 'callback_response',
|
||||||
|
id: data.id,
|
||||||
|
result,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await this.sendMessage(JSON.stringify({
|
||||||
|
type: 'callback_response',
|
||||||
|
id: data.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pendingRequests.set(request.id, { resolve, reject });
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'request',
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.resolve(this.sendMessage(message)).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
|
||||||
|
this.callbackHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
close (): void {
|
||||||
|
for (const [, pending] of this.pendingRequests) {
|
||||||
|
pending.reject(new Error('Transport closed'));
|
||||||
|
}
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建消息传输层的服务端处理器
|
||||||
|
*/
|
||||||
|
export function createMessageServerHandler (target: unknown, options: {
|
||||||
|
sendMessage: (message: string) => void | Promise<void>;
|
||||||
|
onMessage: (handler: (message: string) => void) => void;
|
||||||
|
}): void {
|
||||||
|
const pendingCallbacks = new Map<string, {
|
||||||
|
resolve: (result: SerializedValue) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let callbackIdCounter = 0;
|
||||||
|
|
||||||
|
const server = new RpcServer({
|
||||||
|
target,
|
||||||
|
callbackInvoker: async (callbackId, args) => {
|
||||||
|
const id = `cb_call_${++callbackIdCounter}`;
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg));
|
||||||
|
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
pendingCallbacks.set(id, {
|
||||||
|
resolve: (result) => resolve(deserialize(result)),
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.sendMessage(JSON.stringify({
|
||||||
|
type: 'callback',
|
||||||
|
id,
|
||||||
|
callbackId,
|
||||||
|
args: serializedArgs,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
options.onMessage(async (message) => {
|
||||||
|
const data = JSON.parse(message) as {
|
||||||
|
type: 'request' | 'callback_response';
|
||||||
|
id: string;
|
||||||
|
request?: RpcRequest;
|
||||||
|
result?: SerializedValue;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.type === 'request' && data.request) {
|
||||||
|
const response = await server.handleRequest(data.request);
|
||||||
|
await options.sendMessage(JSON.stringify({
|
||||||
|
type: 'response',
|
||||||
|
id: data.request.id,
|
||||||
|
response,
|
||||||
|
}));
|
||||||
|
} else if (data.type === 'callback_response') {
|
||||||
|
const pending = pendingCallbacks.get(data.id);
|
||||||
|
if (pending) {
|
||||||
|
pendingCallbacks.delete(data.id);
|
||||||
|
if (data.error) {
|
||||||
|
pending.reject(new Error(data.error));
|
||||||
|
} else if (data.result) {
|
||||||
|
pending.resolve(data.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
195
packages/napcat-rpc/src/types.ts
Normal file
195
packages/napcat-rpc/src/types.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* RPC 操作类型
|
||||||
|
*/
|
||||||
|
export enum RpcOperationType {
|
||||||
|
/** 获取属性 */
|
||||||
|
GET = 'get',
|
||||||
|
/** 设置属性 */
|
||||||
|
SET = 'set',
|
||||||
|
/** 调用方法 */
|
||||||
|
APPLY = 'apply',
|
||||||
|
/** 构造函数调用 */
|
||||||
|
CONSTRUCT = 'construct',
|
||||||
|
/** 检查属性是否存在 */
|
||||||
|
HAS = 'has',
|
||||||
|
/** 获取所有键 */
|
||||||
|
OWNKEYS = 'ownKeys',
|
||||||
|
/** 删除属性 */
|
||||||
|
DELETE = 'deleteProperty',
|
||||||
|
/** 获取属性描述符 */
|
||||||
|
GET_DESCRIPTOR = 'getOwnPropertyDescriptor',
|
||||||
|
/** 获取原型 */
|
||||||
|
GET_PROTOTYPE = 'getPrototypeOf',
|
||||||
|
/** 回调调用 */
|
||||||
|
CALLBACK = 'callback',
|
||||||
|
/** 释放资源 */
|
||||||
|
RELEASE = 'release',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 请求消息
|
||||||
|
*/
|
||||||
|
export interface RpcRequest {
|
||||||
|
/** 请求 ID */
|
||||||
|
id: string;
|
||||||
|
/** 操作类型 */
|
||||||
|
type: RpcOperationType;
|
||||||
|
/** 访问路径 (从根对象开始) */
|
||||||
|
path: PropertyKey[];
|
||||||
|
/** 参数 (用于 set, apply, construct) */
|
||||||
|
args?: SerializedValue[];
|
||||||
|
/** 回调 ID 映射 (参数索引 -> 回调 ID) */
|
||||||
|
callbackIds?: Record<number, string>;
|
||||||
|
/** 远程对象引用 ID(用于对引用对象的操作) */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 响应消息
|
||||||
|
*/
|
||||||
|
export interface RpcResponse {
|
||||||
|
/** 请求 ID */
|
||||||
|
id: string;
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
/** 返回值 */
|
||||||
|
result?: SerializedValue;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 错误堆栈 */
|
||||||
|
stack?: string;
|
||||||
|
/** 结果是否为可代理对象 */
|
||||||
|
isProxyable?: boolean;
|
||||||
|
/** 远程对象引用 ID(用于深层对象代理) */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化后的值
|
||||||
|
*/
|
||||||
|
export interface SerializedValue {
|
||||||
|
/** 值类型 */
|
||||||
|
type: SerializedValueType;
|
||||||
|
/** 原始值(用于基本类型) */
|
||||||
|
value?: unknown;
|
||||||
|
/** 对象类型名称 */
|
||||||
|
className?: string;
|
||||||
|
/** 回调 ID(用于函数) */
|
||||||
|
callbackId?: string;
|
||||||
|
/** 代理路径(用于可代理对象) */
|
||||||
|
proxyPath?: PropertyKey[];
|
||||||
|
/** 数组元素或对象属性 */
|
||||||
|
properties?: Record<string, SerializedValue>;
|
||||||
|
/** 数组元素 */
|
||||||
|
elements?: SerializedValue[];
|
||||||
|
/** 远程对象引用 ID(用于保持代理能力) */
|
||||||
|
refId?: string;
|
||||||
|
/** 缓存的属性值(OBJECT_REF 时使用,避免属性访问需要 RPC) */
|
||||||
|
cachedProps?: Record<string, SerializedValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化值类型
|
||||||
|
*/
|
||||||
|
export enum SerializedValueType {
|
||||||
|
UNDEFINED = 'undefined',
|
||||||
|
NULL = 'null',
|
||||||
|
BOOLEAN = 'boolean',
|
||||||
|
NUMBER = 'number',
|
||||||
|
BIGINT = 'bigint',
|
||||||
|
STRING = 'string',
|
||||||
|
SYMBOL = 'symbol',
|
||||||
|
FUNCTION = 'function',
|
||||||
|
OBJECT = 'object',
|
||||||
|
ARRAY = 'array',
|
||||||
|
DATE = 'date',
|
||||||
|
REGEXP = 'regexp',
|
||||||
|
ERROR = 'error',
|
||||||
|
PROMISE = 'promise',
|
||||||
|
PROXY_REF = 'proxyRef',
|
||||||
|
BUFFER = 'buffer',
|
||||||
|
MAP = 'map',
|
||||||
|
SET = 'set',
|
||||||
|
/** 远程对象引用 - 保持代理能力 */
|
||||||
|
OBJECT_REF = 'objectRef',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象引用信息
|
||||||
|
*/
|
||||||
|
export interface ObjectRef {
|
||||||
|
/** 引用 ID */
|
||||||
|
refId: string;
|
||||||
|
/** 对象类型名称 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 传输层接口
|
||||||
|
*/
|
||||||
|
export interface RpcTransport {
|
||||||
|
/** 发送请求并等待响应 */
|
||||||
|
send (request: RpcRequest): Promise<RpcResponse>;
|
||||||
|
/** 注册回调处理器 */
|
||||||
|
onCallback?(handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void;
|
||||||
|
/** 关闭连接 */
|
||||||
|
close?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 服务端处理器接口
|
||||||
|
*/
|
||||||
|
export interface RpcServerHandler {
|
||||||
|
/** 处理请求 */
|
||||||
|
handleRequest (request: RpcRequest): Promise<RpcResponse>;
|
||||||
|
/** 调用客户端回调 */
|
||||||
|
invokeCallback?(callbackId: string, args: unknown[]): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深层代理选项
|
||||||
|
*/
|
||||||
|
export interface DeepProxyOptions {
|
||||||
|
/** 传输层 */
|
||||||
|
transport: RpcTransport;
|
||||||
|
/** 根路径 */
|
||||||
|
rootPath?: PropertyKey[];
|
||||||
|
/** 是否缓存属性 */
|
||||||
|
cacheProperties?: boolean;
|
||||||
|
/** 回调超时时间 (ms) */
|
||||||
|
callbackTimeout?: number;
|
||||||
|
/** 远程对象引用 ID(用于引用对象的代理) */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 服务端选项
|
||||||
|
*/
|
||||||
|
export interface RpcServerOptions {
|
||||||
|
/** 目标对象 */
|
||||||
|
target: unknown;
|
||||||
|
/** 回调调用器 */
|
||||||
|
callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* 判断返回值是否应保持代理引用(而非完全序列化)
|
||||||
|
* 默认对 class 实例和包含方法的对象返回 true
|
||||||
|
*/
|
||||||
|
shouldProxyResult?: (value: unknown) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理元数据符号
|
||||||
|
*/
|
||||||
|
export const PROXY_META = Symbol('PROXY_META');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理元数据
|
||||||
|
*/
|
||||||
|
export interface ProxyMeta {
|
||||||
|
/** 访问路径 */
|
||||||
|
path: PropertyKey[];
|
||||||
|
/** 是否为代理 */
|
||||||
|
isProxy: true;
|
||||||
|
/** 远程对象引用 ID */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
21
packages/napcat-rpc/tsconfig.json
Normal file
21
packages/napcat-rpc/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"../*/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -15,11 +15,13 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
conditions: ['node', 'default'],
|
conditions: ['node', 'default'],
|
||||||
alias: {
|
alias: {
|
||||||
|
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||||
'@/napcat-schema': resolve(__dirname, './src'),
|
'@/napcat-schema': resolve(__dirname, './src'),
|
||||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@ -109,6 +109,7 @@ async function initializeLoginService (
|
|||||||
commonPath: dataPathGlobal,
|
commonPath: dataPathGlobal,
|
||||||
clientVer: basicInfoWrapper.getFullQQVersion(),
|
clientVer: basicInfoWrapper.getFullQQVersion(),
|
||||||
hostName: hostname,
|
hostName: hostname,
|
||||||
|
externalVersion: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +221,52 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 注册密码登录回调
|
||||||
|
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
if (uin && passwordMd5) {
|
||||||
|
logger.log('正在密码登录 ', uin);
|
||||||
|
loginService.passwordLogin({
|
||||||
|
uin,
|
||||||
|
passwordMd5,
|
||||||
|
step: 0,
|
||||||
|
newDeviceLoginSig: '',
|
||||||
|
proofWaterSig: '',
|
||||||
|
proofWaterRand: '',
|
||||||
|
proofWaterSid: '',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.result === '140022008') {
|
||||||
|
const errMsg = '需要验证码,暂不支持';
|
||||||
|
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: errMsg });
|
||||||
|
} else if (res.result === '140022010') {
|
||||||
|
const errMsg = '新设备需要扫码登录,暂不支持';
|
||||||
|
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: errMsg });
|
||||||
|
} else if (res.result !== '0') {
|
||||||
|
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
|
||||||
|
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: errMsg });
|
||||||
|
} else {
|
||||||
|
WebUiDataRuntime.setQQLoginStatus(true);
|
||||||
|
WebUiDataRuntime.setQQLoginError('');
|
||||||
|
resolve({ result: true, message: '' });
|
||||||
|
}
|
||||||
|
}).catch((e) => {
|
||||||
|
logger.logError(e);
|
||||||
|
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: '密码登录发生错误' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({ result: false, message: '密码登录失败:参数不完整' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
if (quickLoginUin) {
|
if (quickLoginUin) {
|
||||||
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||||||
logger.log('正在快速登录 ', quickLoginUin);
|
logger.log('正在快速登录 ', quickLoginUin);
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napcat-core": "workspace:*",
|
"napcat-core": "workspace:*",
|
||||||
|
"napcat-rpc": "workspace:*",
|
||||||
"napcat-image-size": "workspace:*"
|
"napcat-image-size": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1468
packages/napcat-test/rpc.test.ts
Normal file
1468
packages/napcat-test/rpc.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-test': resolve(__dirname, '.'),
|
'@/napcat-test': resolve(__dirname, '.'),
|
||||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "napcat-types",
|
"name": "napcat-types",
|
||||||
"version": "0.0.14",
|
"version": "0.0.16",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./napcat-types/index.d.ts",
|
"types": "./napcat-types/index.d.ts",
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import compression from 'compression';
|
|||||||
import { napCatVersion } from 'napcat-common/src/version';
|
import { napCatVersion } from 'napcat-common/src/version';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, resolve } from 'node:path';
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@ -294,6 +296,130 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||||||
maxAge: '1d',
|
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' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插件无认证 API 路由(不需要鉴权)
|
||||||
|
// 路径格式: /plugin/:pluginId/api/*
|
||||||
|
app.use('/plugin/:pluginId/api', (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);
|
||||||
|
if (!routerRegistry || !routerRegistry.hasApiNoAuthRoutes()) {
|
||||||
|
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered no-auth API routes` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建并执行插件无认证 API 路由
|
||||||
|
const pluginRouter = routerRegistry.buildApiNoAuthRouter();
|
||||||
|
return pluginRouter(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插件页面路由(不需要鉴权)
|
||||||
|
// 路径格式: /plugin/:pluginId/page/:pagePath
|
||||||
|
app.get('/plugin/:pluginId/page/:pagePath', (req, res) => {
|
||||||
|
const { pluginId, pagePath } = 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);
|
||||||
|
if (!routerRegistry || !routerRegistry.hasPages()) {
|
||||||
|
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = routerRegistry.getPages();
|
||||||
|
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
|
||||||
|
if (!page) {
|
||||||
|
return res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginPath = routerRegistry.getPluginPath();
|
||||||
|
if (!pluginPath) {
|
||||||
|
return res.status(500).json({ code: -1, message: 'Plugin path not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlFilePath = join(pluginPath, page.htmlFile);
|
||||||
|
if (!existsSync(htmlFilePath)) {
|
||||||
|
return res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.sendFile(htmlFilePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插件文件系统静态资源路由(不需要鉴权)
|
||||||
|
// 路径格式: /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服务器
|
// 初始化WebSocket服务器
|
||||||
const sslCerts = await checkCertificates(logger);
|
const sslCerts = await checkCertificates(logger);
|
||||||
const isHttps = !!sslCerts;
|
const isHttps = !!sslCerts;
|
||||||
|
|||||||
@ -157,6 +157,8 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
|||||||
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
||||||
const PLUGINS_DIR = getPluginsDir();
|
const PLUGINS_DIR = getPluginsDir();
|
||||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||||
|
const dataDir = path.join(pluginDir, 'data');
|
||||||
|
const tempDataDir = path.join(PLUGINS_DIR, `${pluginId}.data.backup`);
|
||||||
|
|
||||||
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
|
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
|
||||||
console.log(`[extractPlugin] pluginId: ${pluginId}`);
|
console.log(`[extractPlugin] pluginId: ${pluginId}`);
|
||||||
@ -169,8 +171,19 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果目录已存在,先删除
|
// 如果目录已存在,先备份 data 文件夹,再删除
|
||||||
|
let hasDataBackup = false;
|
||||||
if (fs.existsSync(pluginDir)) {
|
if (fs.existsSync(pluginDir)) {
|
||||||
|
// 备份 data 文件夹
|
||||||
|
if (fs.existsSync(dataDir)) {
|
||||||
|
console.log(`[extractPlugin] Backing up data directory: ${dataDir}`);
|
||||||
|
if (fs.existsSync(tempDataDir)) {
|
||||||
|
fs.rmSync(tempDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(dataDir, tempDataDir);
|
||||||
|
hasDataBackup = true;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
|
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
|
||||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@ -179,10 +192,35 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
||||||
|
|
||||||
// 解压
|
try {
|
||||||
await compressing.zip.uncompress(zipPath, pluginDir);
|
// 解压
|
||||||
|
await compressing.zip.uncompress(zipPath, pluginDir);
|
||||||
|
|
||||||
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
|
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
|
||||||
|
|
||||||
|
// 恢复 data 文件夹
|
||||||
|
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||||
|
// 如果新版本也有 data 文件夹,先删除
|
||||||
|
if (fs.existsSync(dataDir)) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
console.log(`[extractPlugin] Restoring data directory: ${dataDir}`);
|
||||||
|
fs.renameSync(tempDataDir, dataDir);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解压失败时,尝试恢复 data 文件夹
|
||||||
|
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||||
|
console.log(`[extractPlugin] Extract failed, restoring data directory`);
|
||||||
|
if (!fs.existsSync(pluginDir)) {
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
}
|
||||||
|
if (fs.existsSync(dataDir)) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(tempDataDir, dataDir);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// 列出解压后的文件
|
// 列出解压后的文件
|
||||||
const files = fs.readdirSync(pluginDir);
|
const files = fs.readdirSync(pluginDir);
|
||||||
@ -254,11 +292,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
|||||||
// 删除临时文件
|
// 删除临时文件
|
||||||
fs.unlinkSync(tempZipPath);
|
fs.unlinkSync(tempZipPath);
|
||||||
|
|
||||||
// 如果 pluginManager 存在,立即注册插件
|
// 如果 pluginManager 存在,立即注册或重载插件
|
||||||
const pluginManager = getPluginManager();
|
const pluginManager = getPluginManager();
|
||||||
if (pluginManager) {
|
if (pluginManager) {
|
||||||
// 检查是否已注册,避免重复注册
|
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||||
if (!pluginManager.getPluginInfo(id)) {
|
if (pluginManager.getPluginInfo(id)) {
|
||||||
|
await pluginManager.reloadPlugin(id);
|
||||||
|
} else {
|
||||||
await pluginManager.loadPluginById(id);
|
await pluginManager.loadPluginById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -336,11 +376,14 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
|||||||
sendProgress('解压完成,正在清理...', 90);
|
sendProgress('解压完成,正在清理...', 90);
|
||||||
fs.unlinkSync(tempZipPath);
|
fs.unlinkSync(tempZipPath);
|
||||||
|
|
||||||
// 如果 pluginManager 存在,立即注册插件
|
// 如果 pluginManager 存在,立即注册或重载插件
|
||||||
const pluginManager = getPluginManager();
|
const pluginManager = getPluginManager();
|
||||||
if (pluginManager) {
|
if (pluginManager) {
|
||||||
// 检查是否已注册,避免重复注册
|
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||||
if (!pluginManager.getPluginInfo(id)) {
|
if (pluginManager.getPluginInfo(id)) {
|
||||||
|
sendProgress('正在刷新插件信息...', 95);
|
||||||
|
await pluginManager.reloadPlugin(id);
|
||||||
|
} else {
|
||||||
sendProgress('正在注册插件...', 95);
|
sendProgress('正在注册插件...', 95);
|
||||||
await pluginManager.loadPluginById(id);
|
await pluginManager.loadPluginById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,3 +108,29 @@ export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
|||||||
await WebUiDataRuntime.refreshQRCode();
|
await WebUiDataRuntime.refreshQRCode();
|
||||||
return sendSuccess(res, null);
|
return sendSuccess(res, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 密码登录
|
||||||
|
export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||||
|
// 获取QQ号和密码MD5
|
||||||
|
const { uin, passwordMd5 } = req.body;
|
||||||
|
// 判断是否已经登录
|
||||||
|
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||||
|
if (isLogin) {
|
||||||
|
return sendError(res, 'QQ Is Logined');
|
||||||
|
}
|
||||||
|
// 判断QQ号是否为空
|
||||||
|
if (isEmpty(uin)) {
|
||||||
|
return sendError(res, 'uin is empty');
|
||||||
|
}
|
||||||
|
// 判断密码MD5是否为空
|
||||||
|
if (isEmpty(passwordMd5)) {
|
||||||
|
return sendError(res, 'passwordMd5 is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行密码登录
|
||||||
|
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||||
|
if (!result) {
|
||||||
|
return sendError(res, message);
|
||||||
|
}
|
||||||
|
return sendSuccess(res, null);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,24 +1,157 @@
|
|||||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
/**
|
||||||
const ASSETS_TO_CACHE = [
|
* NapCat WebUI Service Worker
|
||||||
'/webui/'
|
*
|
||||||
];
|
* 路由缓存策略设计:
|
||||||
|
*
|
||||||
|
* 【永不缓存 - Network Only】
|
||||||
|
* - /api/* WebUI API
|
||||||
|
* - /plugin/:id/api/* 插件 API
|
||||||
|
* - /files/theme.css 动态主题 CSS
|
||||||
|
* - /webui/fonts/CustomFont.woff 用户自定义字体
|
||||||
|
* - WebSocket / SSE 连接
|
||||||
|
*
|
||||||
|
* 【强缓存 - Cache First】
|
||||||
|
* - /webui/assets/* 前端静态资源(带 hash)
|
||||||
|
* - /webui/fonts/* 内置字体(排除 CustomFont)
|
||||||
|
* - q1.qlogo.cn QQ 头像
|
||||||
|
*
|
||||||
|
* 【网络优先 - Network First】
|
||||||
|
* - /webui/* (HTML 导航) SPA 页面
|
||||||
|
* - /plugin/:id/page/* 插件页面
|
||||||
|
* - /plugin/:id/files/* 插件文件系统静态资源
|
||||||
|
*
|
||||||
|
* 【后台更新 - Stale-While-Revalidate】
|
||||||
|
* - /plugin/:id/mem/* 插件内存静态资源
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||||
|
|
||||||
|
// 缓存配置
|
||||||
|
const CACHE_CONFIG = {
|
||||||
|
// 静态资源缓存最大条目数
|
||||||
|
MAX_STATIC_ENTRIES: 200,
|
||||||
|
// QQ 头像缓存最大条目数
|
||||||
|
MAX_AVATAR_ENTRIES: 100,
|
||||||
|
// 插件资源缓存最大条目数
|
||||||
|
MAX_PLUGIN_ENTRIES: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 路由匹配辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为永不缓存的请求
|
||||||
|
*/
|
||||||
|
function isNeverCache (url, request) {
|
||||||
|
// WebUI API
|
||||||
|
if (url.pathname.startsWith('/api/')) return true;
|
||||||
|
|
||||||
|
// 插件 API: /plugin/:id/api/*
|
||||||
|
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
|
||||||
|
|
||||||
|
// 动态主题 CSS
|
||||||
|
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
|
||||||
|
|
||||||
|
// 用户自定义字体
|
||||||
|
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
|
||||||
|
|
||||||
|
// WebSocket 升级请求
|
||||||
|
if (request.headers.get('Upgrade') === 'websocket') return true;
|
||||||
|
|
||||||
|
// SSE 请求
|
||||||
|
if (request.headers.get('Accept') === 'text/event-stream') return true;
|
||||||
|
|
||||||
|
// Socket 相关
|
||||||
|
if (url.pathname.includes('/socket')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为 WebUI 静态资源(强缓存)
|
||||||
|
*/
|
||||||
|
function isWebUIStaticAsset (url) {
|
||||||
|
// /webui/assets/* - 前端构建产物(带 hash)
|
||||||
|
if (url.pathname.startsWith('/webui/assets/')) return true;
|
||||||
|
|
||||||
|
// /webui/fonts/* - 内置字体(排除 CustomFont)
|
||||||
|
if (url.pathname.startsWith('/webui/fonts/') &&
|
||||||
|
!url.pathname.includes('CustomFont.woff')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为外部头像(强缓存)
|
||||||
|
*/
|
||||||
|
function isQLogoAvatar (url) {
|
||||||
|
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件文件系统静态资源(网络优先)
|
||||||
|
*/
|
||||||
|
function isPluginStaticFiles (url) {
|
||||||
|
// /plugin/:id/files/*
|
||||||
|
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件内存静态资源(Stale-While-Revalidate)
|
||||||
|
*/
|
||||||
|
function isPluginMemoryAsset (url) {
|
||||||
|
// /plugin/:id/mem/*
|
||||||
|
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件页面(Network First)
|
||||||
|
*/
|
||||||
|
function isPluginPage (url) {
|
||||||
|
// /plugin/:id/page/*
|
||||||
|
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 缓存管理函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限制缓存条目数量
|
||||||
|
*/
|
||||||
|
async function trimCache (cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (keys.length > maxEntries) {
|
||||||
|
// 删除最早的条目
|
||||||
|
const deleteCount = keys.length - maxEntries;
|
||||||
|
for (let i = 0; i < deleteCount; i++) {
|
||||||
|
await cache.delete(keys[i]);
|
||||||
|
}
|
||||||
|
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按类型获取缓存限制
|
||||||
|
*/
|
||||||
|
function getCacheLimitForRequest (url) {
|
||||||
|
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
|
||||||
|
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
|
||||||
|
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Service Worker 生命周期 ============
|
||||||
|
|
||||||
// 安装阶段:预缓存核心文件
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
self.skipWaiting(); // 强制立即接管
|
console.log('[SW] Installing new version:', CACHE_NAME);
|
||||||
event.waitUntil(
|
self.skipWaiting();
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
// 这里的资源如果加载失败不应该阻断 SW 安装
|
|
||||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 激活阶段:清理旧缓存
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating new version:', CACHE_NAME);
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
(async () => {
|
||||||
return Promise.all(
|
// 清理所有旧版本缓存
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
cacheNames.map((cacheName) => {
|
cacheNames.map((cacheName) => {
|
||||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||||
console.log('[SW] Deleting old cache:', cacheName);
|
console.log('[SW] Deleting old cache:', cacheName);
|
||||||
@ -26,107 +159,178 @@ self.addEventListener('activate', (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
// 立即接管所有客户端
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
self.clients.claim(); // 立即控制所有客户端
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拦截请求
|
// ============ 请求拦截 ============
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
// 1. API 请求:仅网络 (Network Only)
|
// 1. 永不缓存的请求 - Network Only
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
|
if (isNeverCache(url, request)) {
|
||||||
|
// 不调用 respondWith,让请求直接穿透到网络
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 强缓存策略 (Cache First)
|
// 2. WebUI 静态资源 - Cache First
|
||||||
// - 外部 QQ 头像 (q1.qlogo.cn)
|
if (isWebUIStaticAsset(url)) {
|
||||||
// - 静态资源 (assets, fonts)
|
event.respondWith(cacheFirst(request, url));
|
||||||
// - 常见静态文件后缀
|
|
||||||
const isQLogo = url.hostname === 'q1.qlogo.cn';
|
|
||||||
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
|
|
||||||
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS,不强缓存
|
|
||||||
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
|
|
||||||
url.pathname.includes('/webui/fonts/');
|
|
||||||
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
|
|
||||||
|
|
||||||
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((response) => {
|
|
||||||
if (response) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response,
|
|
||||||
// 但 fetch(event.request) 默认会继承 request 的 mode。
|
|
||||||
// 如果是 img标签发起的请求,通常 mode 是 no-cors 或 cors。
|
|
||||||
// 对于 opaque response (status 0), cache API 允许缓存。
|
|
||||||
return fetch(event.request).then((response) => {
|
|
||||||
// 对 qlogo 允许 status 0 (opaque)
|
|
||||||
// 对其他资源要求 status 200
|
|
||||||
const isValidResponse = response && (
|
|
||||||
response.status === 200 ||
|
|
||||||
response.type === 'basic' ||
|
|
||||||
(isQLogo && response.type === 'opaque')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidResponse) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
|
// 3. QQ 头像 - Cache First(支持 opaque response)
|
||||||
if (event.request.mode === 'navigate') {
|
if (isQLogoAvatar(url)) {
|
||||||
event.respondWith(
|
event.respondWith(cacheFirstWithOpaque(request, url));
|
||||||
fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
return caches.match(event.request);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
|
// 4. 插件文件系统静态资源 - Network First
|
||||||
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
|
if (isPluginStaticFiles(url)) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 插件内存静态资源 - Stale-While-Revalidate
|
||||||
|
if (isPluginMemoryAsset(url)) {
|
||||||
|
event.respondWith(staleWhileRevalidate(request, url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 插件页面 - Network First
|
||||||
|
if (isPluginPage(url)) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. HTML 导航请求 - Network First
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 其他同源请求 - Network Only(避免意外缓存)
|
||||||
if (url.origin === self.location.origin) {
|
if (url.origin === self.location.origin) {
|
||||||
event.respondWith(
|
// 不缓存,直接穿透
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
|
||||||
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
|
||||||
const responseToCache = networkResponse.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return networkResponse;
|
|
||||||
});
|
|
||||||
// 如果有缓存,返回缓存;否则等待网络
|
|
||||||
return cachedResponse || fetchPromise;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认:网络优先
|
// 9. 其他外部请求 - Network Only
|
||||||
event.respondWith(
|
return;
|
||||||
fetch(event.request).catch(() => caches.match(event.request))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ 缓存策略实现 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache First 策略
|
||||||
|
* 优先从缓存返回,缓存未命中则从网络获取并缓存
|
||||||
|
*/
|
||||||
|
async function cacheFirst (request, url) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
// 异步清理缓存
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache First fetch failed:', error);
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache First 策略(支持 opaque response,用于跨域头像)
|
||||||
|
*/
|
||||||
|
async function cacheFirstWithOpaque (request, url) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
// opaque response 的 status 是 0,但仍可缓存
|
||||||
|
const isValidResponse = networkResponse && (
|
||||||
|
networkResponse.status === 200 ||
|
||||||
|
networkResponse.type === 'opaque'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValidResponse) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache First (opaque) fetch failed:', error);
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network First 策略
|
||||||
|
* 优先从网络获取,网络失败则返回缓存
|
||||||
|
*/
|
||||||
|
async function networkFirst (request) {
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[SW] Network First: network failed, trying cache');
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale-While-Revalidate 策略
|
||||||
|
* 立即返回缓存(如果有),同时后台更新缓存
|
||||||
|
*/
|
||||||
|
async function staleWhileRevalidate (request, url) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
// 后台刷新缓存
|
||||||
|
const fetchPromise = fetch(request).then((networkResponse) => {
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('[SW] SWR background fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有缓存,立即返回缓存
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有缓存,等待网络
|
||||||
|
const networkResponse = await fetchPromise;
|
||||||
|
if (networkResponse) {
|
||||||
|
return networkResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
onQuickLoginRequested: async () => {
|
onQuickLoginRequested: async () => {
|
||||||
return { result: false, message: '' };
|
return { result: false, message: '' };
|
||||||
},
|
},
|
||||||
|
onPasswordLoginRequested: async () => {
|
||||||
|
return { result: false, message: '密码登录功能未初始化' };
|
||||||
|
},
|
||||||
onRestartProcessRequested: async () => {
|
onRestartProcessRequested: async () => {
|
||||||
return { result: false, message: '重启功能未初始化' };
|
return { result: false, message: '重启功能未初始化' };
|
||||||
},
|
},
|
||||||
@ -136,6 +139,14 @@ export const WebUiDataRuntime = {
|
|||||||
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
||||||
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
||||||
|
|
||||||
|
setPasswordLoginCall (func: LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested']): void {
|
||||||
|
LoginRuntime.NapCatHelper.onPasswordLoginRequested = func;
|
||||||
|
},
|
||||||
|
|
||||||
|
requestPasswordLogin: function (uin: string, passwordMd5: string) {
|
||||||
|
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
|
||||||
|
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
|
||||||
|
|
||||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
getAutoLoginAccountHandler,
|
getAutoLoginAccountHandler,
|
||||||
setAutoLoginAccountHandler,
|
setAutoLoginAccountHandler,
|
||||||
QQRefreshQRcodeHandler,
|
QQRefreshQRcodeHandler,
|
||||||
|
QQPasswordLoginHandler,
|
||||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
@ -31,5 +32,7 @@ router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
|||||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||||
// router:刷新QQ登录二维码
|
// router:刷新QQ登录二维码
|
||||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||||
|
// router:密码登录
|
||||||
|
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||||
|
|
||||||
export { router as QQLoginRouter };
|
export { router as QQLoginRouter };
|
||||||
|
|||||||
@ -56,6 +56,7 @@ export interface LoginRuntimeType {
|
|||||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||||
NapCatHelper: {
|
NapCatHelper: {
|
||||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||||
|
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
|
||||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||||
QQLoginList: string[];
|
QQLoginList: string[];
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
|
|||||||
return (
|
return (
|
||||||
<Snippet
|
<Snippet
|
||||||
hideSymbol
|
hideSymbol
|
||||||
|
codeString={JSON.stringify(messages, null, 2)}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
content: '点击复制',
|
content: '点击复制',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -121,6 +121,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
|||||||
<PopoverContent>
|
<PopoverContent>
|
||||||
<Snippet
|
<Snippet
|
||||||
hideSymbol
|
hideSymbol
|
||||||
|
codeString={JSON.stringify(msg, null, 2)}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
content: '点击复制',
|
content: '点击复制',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
|
|||||||
<PopoverContent>
|
<PopoverContent>
|
||||||
<Snippet
|
<Snippet
|
||||||
hideSymbol
|
hideSymbol
|
||||||
|
codeString={JSON.stringify(data.data, null, 2)}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
content: '点击复制',
|
content: '点击复制',
|
||||||
}}
|
}}
|
||||||
|
|||||||
122
packages/napcat-webui-frontend/src/components/password_login.tsx
Normal file
122
packages/napcat-webui-frontend/src/components/password_login.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Avatar } from '@heroui/avatar';
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/dropdown';
|
||||||
|
import { Image } from '@heroui/image';
|
||||||
|
import { Input } from '@heroui/input';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import type { QQItem } from '@/components/quick_login';
|
||||||
|
import { isQQQuickNewItem } from '@/utils/qq';
|
||||||
|
|
||||||
|
interface PasswordLoginProps {
|
||||||
|
onSubmit: (uin: string, password: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
qqList: (QQItem | LoginListItem)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
|
||||||
|
const [uin, setUin] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!uin) {
|
||||||
|
toast.error('请输入QQ号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
toast.error('请输入密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(uin, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-8'>
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<Image
|
||||||
|
className='shadow-lg'
|
||||||
|
height={100}
|
||||||
|
radius='full'
|
||||||
|
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||||
|
width={100}
|
||||||
|
alt="QQ Avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="QQ账号"
|
||||||
|
placeholder="请输入QQ号"
|
||||||
|
value={uin}
|
||||||
|
onValueChange={setUin}
|
||||||
|
variant="bordered"
|
||||||
|
size='lg'
|
||||||
|
autoComplete="off"
|
||||||
|
endContent={
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button isIconOnly variant="light" size="sm" radius="full">
|
||||||
|
<IoChevronDown size={16} />
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label="QQ Login History"
|
||||||
|
items={qqList}
|
||||||
|
onAction={(key) => setUin(key.toString())}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Avatar
|
||||||
|
alt={item.uin}
|
||||||
|
className='flex-shrink-0'
|
||||||
|
size='sm'
|
||||||
|
src={
|
||||||
|
isQQQuickNewItem(item)
|
||||||
|
? item.faceUrl
|
||||||
|
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
{isQQQuickNewItem(item)
|
||||||
|
? `${item.nickName}(${item.uin})`
|
||||||
|
: item.uin}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="密码"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
value={password}
|
||||||
|
onValueChange={setPassword}
|
||||||
|
variant="bordered"
|
||||||
|
size='lg'
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-center mt-5'>
|
||||||
|
<Button
|
||||||
|
className='w-64 max-w-full'
|
||||||
|
color='primary'
|
||||||
|
isLoading={isLoading}
|
||||||
|
radius='full'
|
||||||
|
size='lg'
|
||||||
|
variant='shadow'
|
||||||
|
onPress={handleSubmit}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordLogin;
|
||||||
@ -93,4 +93,11 @@ export default class QQManager {
|
|||||||
uin,
|
uin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async passwordLogin (uin: string, passwordMd5: string) {
|
||||||
|
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
|
||||||
|
uin,
|
||||||
|
passwordMd5,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,13 +63,12 @@ export default function ExtensionPage () {
|
|||||||
}, [extensionPages]);
|
}, [extensionPages]);
|
||||||
|
|
||||||
// 获取当前选中页面的 iframe URL
|
// 获取当前选中页面的 iframe URL
|
||||||
|
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
|
||||||
const currentPageUrl = useMemo(() => {
|
const currentPageUrl = useMemo(() => {
|
||||||
if (!selectedTab) return '';
|
if (!selectedTab) return '';
|
||||||
const [pluginId, ...pathParts] = selectedTab.split(':');
|
const [pluginId, ...pathParts] = selectedTab.split(':');
|
||||||
const path = pathParts.join(':').replace(/^\//, '');
|
const path = pathParts.join(':').replace(/^\//, '');
|
||||||
// 获取认证 token
|
return `/plugin/${pluginId}/page/${path}`;
|
||||||
const token = localStorage.getItem('token') || '';
|
|
||||||
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
|
|
||||||
}, [selectedTab]);
|
}, [selectedTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -86,6 +85,13 @@ export default function ExtensionPage () {
|
|||||||
setIframeLoading(false);
|
setIframeLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 在新窗口打开页面(新路由不需要鉴权)
|
||||||
|
const openInNewWindow = (pluginId: string, path: string) => {
|
||||||
|
const cleanPath = path.replace(/^\//, '');
|
||||||
|
const url = `/plugin/${pluginId}/page/${cleanPath}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>扩展页面 - NapCat WebUI</title>
|
<title>扩展页面 - NapCat WebUI</title>
|
||||||
@ -125,8 +131,17 @@ export default function ExtensionPage () {
|
|||||||
title={
|
title={
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
{tab.icon && <span>{tab.icon}</span>}
|
{tab.icon && <span>{tab.icon}</span>}
|
||||||
<span>{tab.title}</span>
|
<span
|
||||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||||
|
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openInNewWindow(tab.pluginId, tab.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</span>
|
||||||
|
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import toast from 'react-hot-toast';
|
|||||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
|
||||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||||
@ -226,68 +227,70 @@ export default function PluginStorePage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||||
|
const hasBackground = !!backgroundImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>插件商店 - NapCat WebUI</title>
|
<title>插件商店 - NapCat WebUI</title>
|
||||||
<div className="p-2 md:p-4 relative">
|
<div className="p-2 md:p-4 relative">
|
||||||
{/* 头部 */}
|
{/* 固定头部区域 */}
|
||||||
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
|
<div className={clsx(
|
||||||
<div className="flex items-center gap-4">
|
'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
|
||||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
hasBackground
|
||||||
<Button
|
? 'bg-white/20 dark:bg-black/10'
|
||||||
isIconOnly
|
: 'bg-transparent'
|
||||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
)}>
|
||||||
radius="full"
|
{/* 头部 */}
|
||||||
onPress={() => loadPlugins(true)}
|
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
|
||||||
isLoading={loading}
|
<div className="flex items-center gap-4">
|
||||||
>
|
<h1 className="text-2xl font-bold">插件商店</h1>
|
||||||
<IoMdRefresh size={24} />
|
<Button
|
||||||
</Button>
|
isIconOnly
|
||||||
|
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||||
|
radius="full"
|
||||||
|
onPress={() => loadPlugins(true)}
|
||||||
|
isLoading={loading}
|
||||||
|
>
|
||||||
|
<IoMdRefresh size={24} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 商店列表源卡片 */}
|
||||||
|
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
||||||
|
<CardBody className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-default-500">列表源:</span>
|
||||||
|
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip content="切换列表源">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => setStoreSourceModalOpen(true)}
|
||||||
|
>
|
||||||
|
<IoMdSettings size={16} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源卡片 */}
|
{/* 搜索框 */}
|
||||||
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
<div className="mb-4">
|
||||||
<CardBody className="py-2 px-3">
|
<Input
|
||||||
<div className="flex items-center gap-3">
|
placeholder="搜索插件名称、描述、作者或标签..."
|
||||||
<div className="flex items-center gap-2">
|
startContent={<IoMdSearch className="text-default-400" />}
|
||||||
<span className="text-xs text-default-500">列表源:</span>
|
value={searchQuery}
|
||||||
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
onValueChange={setSearchQuery}
|
||||||
</div>
|
className="max-w-md"
|
||||||
<Tooltip content="切换列表源">
|
/>
|
||||||
<Button
|
</div>
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => setStoreSourceModalOpen(true)}
|
|
||||||
>
|
|
||||||
<IoMdSettings size={16} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索插件名称、描述、作者或标签..."
|
|
||||||
startContent={<IoMdSearch className="text-default-400" />}
|
|
||||||
value={searchQuery}
|
|
||||||
onValueChange={setSearchQuery}
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签页 */}
|
|
||||||
<div className="relative">
|
|
||||||
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
|
||||||
<Spinner size='lg' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* 标签页导航 */}
|
||||||
<Tabs
|
<Tabs
|
||||||
aria-label="Plugin Store Categories"
|
aria-label="Plugin Store Categories"
|
||||||
className="max-w-full"
|
className="max-w-full"
|
||||||
@ -296,32 +299,43 @@ export default function PluginStorePage () {
|
|||||||
classNames={{
|
classNames={{
|
||||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||||
|
panel: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
title={`${tab.title} (${tab.count})`}
|
title={`${tab.title} (${tab.count})`}
|
||||||
>
|
/>
|
||||||
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
|
||||||
{categorizedPlugins[tab.key]?.map((plugin) => {
|
|
||||||
const installInfo = getPluginInstallInfo(plugin);
|
|
||||||
return (
|
|
||||||
<PluginStoreCard
|
|
||||||
key={plugin.id}
|
|
||||||
data={plugin}
|
|
||||||
installStatus={installInfo.status}
|
|
||||||
installedVersion={installInfo.installedVersion}
|
|
||||||
onInstall={() => handleInstall(plugin)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 插件列表区域 */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
||||||
|
<Spinner size='lg' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
||||||
|
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||||
|
const installInfo = getPluginInstallInfo(plugin);
|
||||||
|
return (
|
||||||
|
<PluginStoreCard
|
||||||
|
key={plugin.id}
|
||||||
|
data={plugin}
|
||||||
|
installStatus={installInfo.status}
|
||||||
|
installedVersion={installInfo.installedVersion}
|
||||||
|
onInstall={() => handleInstall(plugin)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源选择弹窗 */}
|
{/* 商店列表源选择弹窗 */}
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { Tab, Tabs } from '@heroui/tabs';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png';
|
import logo from '@/assets/images/logo.png';
|
||||||
|
|
||||||
import HoverEffectCard from '@/components/effect_card';
|
import HoverEffectCard from '@/components/effect_card';
|
||||||
import { title } from '@/components/primitives';
|
import { title } from '@/components/primitives';
|
||||||
|
import PasswordLogin from '@/components/password_login';
|
||||||
import QrCodeLogin from '@/components/qr_code_login';
|
import QrCodeLogin from '@/components/qr_code_login';
|
||||||
import QuickLogin from '@/components/quick_login';
|
import QuickLogin from '@/components/quick_login';
|
||||||
import type { QQItem } from '@/components/quick_login';
|
import type { QQItem } from '@/components/quick_login';
|
||||||
@ -51,6 +53,7 @@ export default function QQLoginPage () {
|
|||||||
const lastErrorRef = useRef<string>('');
|
const lastErrorRef = useRef<string>('');
|
||||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
||||||
const [refresh, setRefresh] = useState<boolean>(false);
|
const [refresh, setRefresh] = useState<boolean>(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('shortcut');
|
||||||
const firstLoad = useRef<boolean>(true);
|
const firstLoad = useRef<boolean>(true);
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!uinValue) {
|
if (!uinValue) {
|
||||||
@ -72,6 +75,21 @@ export default function QQLoginPage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPasswordSubmit = async (uin: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 计算密码的MD5值
|
||||||
|
const passwordMd5 = CryptoJS.MD5(password).toString();
|
||||||
|
await QQManager.passwordLogin(uin, passwordMd5);
|
||||||
|
toast.success('密码登录请求已发送');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`密码登录失败: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onUpdateQrCode = async () => {
|
const onUpdateQrCode = async () => {
|
||||||
if (firstLoad.current) setIsLoading(true);
|
if (firstLoad.current) setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -91,11 +109,17 @@ export default function QQLoginPage () {
|
|||||||
setLoginError(data.loginError);
|
setLoginError(data.loginError);
|
||||||
const friendlyMsg = parseLoginError(data.loginError);
|
const friendlyMsg = parseLoginError(data.loginError);
|
||||||
|
|
||||||
dialog.alert({
|
// 仅在扫码登录 Tab 下才弹窗,或者错误不是"二维码已过期"
|
||||||
title: '登录失败',
|
// 如果是 "二维码已过期",且不在 qrcode tab,则不弹窗
|
||||||
content: friendlyMsg,
|
const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
|
||||||
confirmText: '确定',
|
|
||||||
});
|
if (!isQrCodeExpired || activeTab === 'qrcode') {
|
||||||
|
dialog.alert({
|
||||||
|
title: '登录失败',
|
||||||
|
content: friendlyMsg,
|
||||||
|
confirmText: '确定',
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (!data.loginError) {
|
} else if (!data.loginError) {
|
||||||
lastErrorRef.current = '';
|
lastErrorRef.current = '';
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
@ -197,6 +221,8 @@ export default function QQLoginPage () {
|
|||||||
}}
|
}}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
size='lg'
|
size='lg'
|
||||||
|
selectedKey={activeTab}
|
||||||
|
onSelectionChange={(key) => key !== null && setActiveTab(key.toString())}
|
||||||
>
|
>
|
||||||
<Tab key='shortcut' title='快速登录'>
|
<Tab key='shortcut' title='快速登录'>
|
||||||
<QuickLogin
|
<QuickLogin
|
||||||
@ -209,6 +235,13 @@ export default function QQLoginPage () {
|
|||||||
onUpdateQQList={onUpdateQQList}
|
onUpdateQQList={onUpdateQQList}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab key='password' title='密码登录'>
|
||||||
|
<PasswordLogin
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSubmit={onPasswordSubmit}
|
||||||
|
qqList={qqList}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
<Tab key='qrcode' title='扫码登录'>
|
<Tab key='qrcode' title='扫码登录'>
|
||||||
<QrCodeLogin
|
<QrCodeLogin
|
||||||
loginError={parseLoginError(loginError)}
|
loginError={parseLoginError(loginError)}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
'/api': backendDebugUrl,
|
'/api': backendDebugUrl,
|
||||||
'/files': backendDebugUrl,
|
'/files': backendDebugUrl,
|
||||||
|
'/plugin': backendDebugUrl,
|
||||||
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
||||||
'/webui/sw.js': backendDebugUrl,
|
'/webui/sw.js': backendDebugUrl,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -232,8 +232,8 @@ importers:
|
|||||||
packages/napcat-plugin-builtin:
|
packages/napcat-plugin-builtin:
|
||||||
dependencies:
|
dependencies:
|
||||||
napcat-types:
|
napcat-types:
|
||||||
specifier: 0.0.14
|
specifier: 0.0.16
|
||||||
version: 0.0.14
|
version: 0.0.16
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
@ -302,6 +302,12 @@ importers:
|
|||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
version: 22.19.1
|
version: 22.19.1
|
||||||
|
|
||||||
|
packages/napcat-rpc:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.1
|
||||||
|
version: 22.19.1
|
||||||
|
|
||||||
packages/napcat-schema:
|
packages/napcat-schema:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
@ -357,6 +363,9 @@ importers:
|
|||||||
napcat-image-size:
|
napcat-image-size:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-image-size
|
version: link:../napcat-image-size
|
||||||
|
napcat-rpc:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../napcat-rpc
|
||||||
devDependencies:
|
devDependencies:
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.9
|
specifier: ^4.0.9
|
||||||
@ -5448,8 +5457,8 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
napcat-types@0.0.14:
|
napcat-types@0.0.16:
|
||||||
resolution: {integrity: sha512-q5ke+vzzXeZkYPsr9jmj94NxgH63/xv5yS/lPEU++A3x2mOM8SYJqdFEMbHG1QIFciyH1u3qnnNiJ0mBxOBFbA==}
|
resolution: {integrity: sha512-y3qhpdd16ATsMp4Jf88XwisFBVKqY+XSfvGX1YqMEasVFTNXeKr1MZrIzhHMkllW1QJZXAI8iNGVJO1gkHEtLQ==}
|
||||||
|
|
||||||
napcat.protobuf@1.1.4:
|
napcat.protobuf@1.1.4:
|
||||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||||
@ -12774,7 +12783,7 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napcat-types@0.0.14:
|
napcat-types@0.0.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox': 0.34.41
|
'@sinclair/typebox': 0.34.41
|
||||||
'@types/node': 22.19.1
|
'@types/node': 22.19.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user