mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 14:41:14 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7054535321 | ||
|
|
38f6dad118 | ||
|
|
c3b29f1ee6 | ||
|
|
3e85d18ab5 | ||
|
|
df48c01ce4 | ||
|
|
209776a9e8 | ||
|
|
09dae7269a | ||
|
|
2dcf8004ab | ||
|
|
74b1da67d8 | ||
|
|
78ac36f670 | ||
|
|
74781fda0a | ||
|
|
3bead89d46 | ||
|
|
a4527fd8ca | ||
|
|
52b6627ebd | ||
|
|
a5769b6a62 |
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
@ -125,18 +125,54 @@ jobs:
|
||||
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://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="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")"
|
||||
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"
|
||||
mkdir -p "$QQ_EXTRACT"
|
||||
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
|
||||
@ -146,7 +182,7 @@ jobs:
|
||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||
|
||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
||||
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
|
||||
mkdir -p "$NODE_EXTRACT"
|
||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||
|
||||
@ -164,11 +200,18 @@ jobs:
|
||||
# -----------------------------
|
||||
# 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
|
||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||
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
|
||||
# -----------------------------
|
||||
@ -178,6 +221,7 @@ jobs:
|
||||
# -----------------------------
|
||||
# 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
|
||||
|
||||
- name: Upload Artifact
|
||||
|
||||
@ -18,7 +18,7 @@ export class NTQQFriendApi {
|
||||
async getBuddyV2SimpleInfoMap () {
|
||||
const buddyService = this.context.session.getBuddyService();
|
||||
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);
|
||||
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
||||
} else {
|
||||
@ -55,7 +55,7 @@ export class NTQQFriendApi {
|
||||
const buddyService = this.context.session.getBuddyService();
|
||||
let uids: string[] = [];
|
||||
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;
|
||||
uids = buddyListV2.flatMap(item => item.buddyUids);
|
||||
} 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": {
|
||||
"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": {
|
||||
"appid": 537337594,
|
||||
|
||||
@ -8,6 +8,7 @@ export interface LoginInitConfig {
|
||||
commonPath: string;
|
||||
clientVer: string;
|
||||
hostName: string;
|
||||
externalVersion: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordLoginRetType {
|
||||
@ -21,7 +22,7 @@ export interface PasswordLoginRetType {
|
||||
jumpWord: string;
|
||||
tipsTitle: string;
|
||||
tipsContent: string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasswordLoginArgType {
|
||||
@ -55,37 +56,37 @@ export interface QuickLoginResult {
|
||||
jumpUrl: string,
|
||||
jumpWord: string,
|
||||
tipsTitle: string,
|
||||
tipsContent: string
|
||||
tipsContent: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeIKernelLoginService {
|
||||
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
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
getNTUserDataInfoConfig (): unknown;
|
||||
getNTUserDataInfoConfig (): Promise<string>;
|
||||
|
||||
pathIsReadableAndWriteable (path: string): unknown; // 直接的猜测
|
||||
pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
|
||||
|
||||
resetUserDataSavePathToDocument (): unknown;
|
||||
|
||||
@ -158,7 +158,7 @@ export interface NodeIQQNTStartupSessionWrapper {
|
||||
stop (): void;
|
||||
start (): void;
|
||||
createWithModuleList (uk: unknown): unknown;
|
||||
getSessionIdList (): unknown;
|
||||
getSessionIdList (): Promise<Map<unknown, unknown>>;
|
||||
}
|
||||
export interface NodeIQQNTWrapperSession {
|
||||
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
||||
|
||||
@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
|
||||
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
||||
console.log(`BASE_DIR: ${BASE_DIR}`);
|
||||
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 NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
||||
|
||||
@ -46,6 +47,12 @@ const itemsToCopy = [
|
||||
'package.json',
|
||||
'QBar.dll',
|
||||
'wrapper.node',
|
||||
'LightQuic.dll'
|
||||
];
|
||||
|
||||
const win64ItemsToCopy = [
|
||||
'SSOShareInfoHelper64.dll',
|
||||
'parent-ipc-core-x64.dll'
|
||||
];
|
||||
|
||||
async function copyAll () {
|
||||
@ -53,13 +60,23 @@ async function copyAll () {
|
||||
const configPath = path.join(TARGET_DIR, 'config.json');
|
||||
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
||||
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) {
|
||||
console.log('Copying required files...');
|
||||
await fs.ensureDir(TARGET_DIR);
|
||||
await fs.ensureDir(TARGET_WIN64_DIR);
|
||||
await fs.copy(QQNT_FILE, qqntDllPath, { 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 fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
||||
console.log(`Copied ${item}`);
|
||||
|
||||
@ -216,7 +216,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
this.pluginRouters.set(entry.id, routerRegistry);
|
||||
|
||||
// 创建获取其他插件导出的方法
|
||||
const getPluginExports = <T = any>(pluginId: string): T | undefined => {
|
||||
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
|
||||
const targetEntry = this.plugins.get(pluginId);
|
||||
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
|
||||
return undefined;
|
||||
|
||||
@ -195,7 +195,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
}
|
||||
|
||||
// 创建获取其他插件导出的方法
|
||||
const getPluginExports = <T = any>(pluginId: string): T | undefined => {
|
||||
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
|
||||
const targetEntry = this.plugins.get(pluginId);
|
||||
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
|
||||
return undefined;
|
||||
|
||||
@ -68,6 +68,7 @@ interface MemoryStaticRoute {
|
||||
|
||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
|
||||
private pageDefinitions: PluginPageDefinition[] = [];
|
||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||
private memoryStaticRoutes: MemoryStaticRoute[] = [];
|
||||
@ -99,6 +100,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
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 {
|
||||
@ -184,12 +207,52 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
// ==================== 查询方法 ====================
|
||||
|
||||
/**
|
||||
* 检查是否有注册的 API 路由
|
||||
* 检查是否有注册的 API 路由(需要认证)
|
||||
*/
|
||||
hasApiRoutes (): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的静态资源路由
|
||||
*/
|
||||
@ -244,6 +307,7 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
*/
|
||||
clear (): void {
|
||||
this.apiRoutes = [];
|
||||
this.apiNoAuthRoutes = [];
|
||||
this.pageDefinitions = [];
|
||||
this.staticRoutes = [];
|
||||
this.memoryStaticRoutes = [];
|
||||
|
||||
@ -140,24 +140,42 @@ export interface MemoryStaticFile {
|
||||
|
||||
/** 插件路由注册器 */
|
||||
export interface PluginRouterRegistry {
|
||||
// ==================== API 路由注册 ====================
|
||||
// ==================== API 路由注册(需要认证) ====================
|
||||
|
||||
/**
|
||||
* 注册单个 API 路由
|
||||
* 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/)
|
||||
* @param method HTTP 方法
|
||||
* @param path 路由路径
|
||||
* @param handler 请求处理器
|
||||
*/
|
||||
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 GET API */
|
||||
/** 注册 GET API(需要认证) */
|
||||
get (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 POST API */
|
||||
/** 注册 POST API(需要认证) */
|
||||
post (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 PUT API */
|
||||
/** 注册 PUT API(需要认证) */
|
||||
put (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 DELETE API */
|
||||
/** 注册 DELETE API(需要认证) */
|
||||
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;
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -129,11 +129,47 @@ 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);
|
||||
|
||||
@ -160,7 +196,7 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 注册扩展页面
|
||||
// 注册扩展页面(无需鉴权,可通过 /plugin/{pluginId}/page/dashboard 访问)
|
||||
ctx.router.page({
|
||||
path: 'dashboard',
|
||||
title: '插件仪表盘',
|
||||
@ -170,7 +206,9 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
});
|
||||
|
||||
logger.info('WebUI 路由已注册:');
|
||||
logger.info(' - API 路由: /api/Plugin/ext/' + ctx.pluginName + '/');
|
||||
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/');
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.15"
|
||||
"napcat-types": "0.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@ -279,9 +279,10 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 从 URL 参数获取 webui_token
|
||||
// 从 localStorage 获取 token(与父页面同源,可直接访问)
|
||||
// 兼容旧版:如果 URL 有 webui_token 参数则优先使用
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const webuiToken = urlParams.get('webui_token') || '';
|
||||
const webuiToken = localStorage.getItem('token') || '';
|
||||
|
||||
// 插件 API 基础路径(需要鉴权)
|
||||
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
||||
|
||||
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,6 +15,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-schema': resolve(__dirname, './src'),
|
||||
|
||||
@ -109,6 +109,7 @@ async function initializeLoginService (
|
||||
commonPath: dataPathGlobal,
|
||||
clientVer: basicInfoWrapper.getFullQQVersion(),
|
||||
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 (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||||
logger.log('正在快速登录 ', quickLoginUin);
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-rpc": "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: {
|
||||
alias: {
|
||||
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-test': resolve(__dirname, '.'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.16",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
|
||||
@ -332,6 +332,64 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
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) => {
|
||||
|
||||
@ -157,6 +157,8 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
||||
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
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] pluginId: ${pluginId}`);
|
||||
@ -169,8 +171,19 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
||||
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
||||
}
|
||||
|
||||
// 如果目录已存在,先删除
|
||||
// 如果目录已存在,先备份 data 文件夹,再删除
|
||||
let hasDataBackup = false;
|
||||
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}`);
|
||||
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 });
|
||||
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
||||
|
||||
// 解压
|
||||
await compressing.zip.uncompress(zipPath, pluginDir);
|
||||
try {
|
||||
// 解压
|
||||
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);
|
||||
|
||||
@ -108,3 +108,29 @@ export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
await WebUiDataRuntime.refreshQRCode();
|
||||
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 = [
|
||||
'/webui/'
|
||||
];
|
||||
/**
|
||||
* NapCat WebUI Service Worker
|
||||
*
|
||||
* 路由缓存策略设计:
|
||||
*
|
||||
* 【永不缓存 - 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.skipWaiting(); // 强制立即接管
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// 这里的资源如果加载失败不应该阻断 SW 安装
|
||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
|
||||
})
|
||||
);
|
||||
console.log('[SW] Installing new version:', CACHE_NAME);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// 激活阶段:清理旧缓存
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating new version:', CACHE_NAME);
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
(async () => {
|
||||
// 清理所有旧版本缓存
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||
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) => {
|
||||
const url = new URL(event.request.url);
|
||||
const request = event.request;
|
||||
|
||||
// 1. API 请求:仅网络 (Network Only)
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
|
||||
// 1. 永不缓存的请求 - Network Only
|
||||
if (isNeverCache(url, request)) {
|
||||
// 不调用 respondWith,让请求直接穿透到网络
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 强缓存策略 (Cache First)
|
||||
// - 外部 QQ 头像 (q1.qlogo.cn)
|
||||
// - 静态资源 (assets, fonts)
|
||||
// - 常见静态文件后缀
|
||||
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;
|
||||
});
|
||||
})
|
||||
);
|
||||
// 2. WebUI 静态资源 - Cache First
|
||||
if (isWebUIStaticAsset(url)) {
|
||||
event.respondWith(cacheFirst(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
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);
|
||||
})
|
||||
);
|
||||
// 3. QQ 头像 - Cache First(支持 opaque response)
|
||||
if (isQLogoAvatar(url)) {
|
||||
event.respondWith(cacheFirstWithOpaque(request, url));
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 默认:网络优先
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
// 9. 其他外部请求 - Network Only
|
||||
return;
|
||||
});
|
||||
|
||||
// ============ 缓存策略实现 ============
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
return { result: false, message: '' };
|
||||
},
|
||||
onPasswordLoginRequested: async () => {
|
||||
return { result: false, message: '密码登录功能未初始化' };
|
||||
},
|
||||
onRestartProcessRequested: async () => {
|
||||
return { result: false, message: '重启功能未初始化' };
|
||||
},
|
||||
@ -136,6 +139,14 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
||||
} 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 {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
getAutoLoginAccountHandler,
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router: Router = Router();
|
||||
@ -31,5 +32,7 @@ router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
// router:刷新QQ登录二维码
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
|
||||
@ -56,6 +56,7 @@ export interface LoginRuntimeType {
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||
QQLoginList: string[];
|
||||
|
||||
@ -10,6 +10,7 @@ const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
|
||||
return (
|
||||
<Snippet
|
||||
hideSymbol
|
||||
codeString={JSON.stringify(messages, null, 2)}
|
||||
tooltipProps={{
|
||||
content: '点击复制',
|
||||
}}
|
||||
|
||||
@ -121,6 +121,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
codeString={JSON.stringify(msg, null, 2)}
|
||||
tooltipProps={{
|
||||
content: '点击复制',
|
||||
}}
|
||||
|
||||
@ -41,6 +41,7 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
codeString={JSON.stringify(data.data, null, 2)}
|
||||
tooltipProps={{
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// 获取当前选中页面的 iframe URL
|
||||
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
|
||||
const currentPageUrl = useMemo(() => {
|
||||
if (!selectedTab) return '';
|
||||
const [pluginId, ...pathParts] = selectedTab.split(':');
|
||||
const path = pathParts.join(':').replace(/^\//, '');
|
||||
// 获取认证 token
|
||||
const token = localStorage.getItem('token') || '';
|
||||
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
|
||||
return `/plugin/${pluginId}/page/${path}`;
|
||||
}, [selectedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -86,11 +85,10 @@ export default function ExtensionPage () {
|
||||
setIframeLoading(false);
|
||||
};
|
||||
|
||||
// 在新窗口打开页面
|
||||
// 在新窗口打开页面(新路由不需要鉴权)
|
||||
const openInNewWindow = (pluginId: string, path: string) => {
|
||||
const cleanPath = path.replace(/^\//, '');
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const url = `/api/Plugin/page/${pluginId}/${cleanPath}?webui_token=${token}`;
|
||||
const url = `/plugin/${pluginId}/page/${cleanPath}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
@ -134,8 +132,8 @@ export default function ExtensionPage () {
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline'
|
||||
title='点击在新窗口打开'
|
||||
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);
|
||||
@ -143,7 +141,7 @@ export default function ExtensionPage () {
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -9,6 +9,7 @@ import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
|
||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
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 (
|
||||
<>
|
||||
<title>插件商店 - NapCat WebUI</title>
|
||||
<div className="p-2 md:p-4 relative">
|
||||
{/* 头部 */}
|
||||
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
radius="full"
|
||||
onPress={() => loadPlugins(true)}
|
||||
isLoading={loading}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
{/* 固定头部区域 */}
|
||||
<div className={clsx(
|
||||
'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',
|
||||
hasBackground
|
||||
? 'bg-white/20 dark:bg-black/10'
|
||||
: 'bg-transparent'
|
||||
)}>
|
||||
{/* 头部 */}
|
||||
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
radius="full"
|
||||
onPress={() => loadPlugins(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>
|
||||
|
||||
{/* 商店列表源卡片 */}
|
||||
<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 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>
|
||||
)}
|
||||
{/* 搜索框 */}
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="搜索插件名称、描述、作者或标签..."
|
||||
startContent={<IoMdSearch className="text-default-400" />}
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签页导航 */}
|
||||
<Tabs
|
||||
aria-label="Plugin Store Categories"
|
||||
className="max-w-full"
|
||||
@ -296,32 +299,43 @@ export default function PluginStorePage () {
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* 商店列表源选择弹窗 */}
|
||||
|
||||
@ -5,11 +5,13 @@ import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import PasswordLogin from '@/components/password_login';
|
||||
import QrCodeLogin from '@/components/qr_code_login';
|
||||
import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
@ -51,6 +53,7 @@ export default function QQLoginPage () {
|
||||
const lastErrorRef = useRef<string>('');
|
||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('shortcut');
|
||||
const firstLoad = useRef<boolean>(true);
|
||||
const onSubmit = async () => {
|
||||
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 () => {
|
||||
if (firstLoad.current) setIsLoading(true);
|
||||
try {
|
||||
@ -91,11 +109,17 @@ export default function QQLoginPage () {
|
||||
setLoginError(data.loginError);
|
||||
const friendlyMsg = parseLoginError(data.loginError);
|
||||
|
||||
dialog.alert({
|
||||
title: '登录失败',
|
||||
content: friendlyMsg,
|
||||
confirmText: '确定',
|
||||
});
|
||||
// 仅在扫码登录 Tab 下才弹窗,或者错误不是"二维码已过期"
|
||||
// 如果是 "二维码已过期",且不在 qrcode tab,则不弹窗
|
||||
const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
|
||||
|
||||
if (!isQrCodeExpired || activeTab === 'qrcode') {
|
||||
dialog.alert({
|
||||
title: '登录失败',
|
||||
content: friendlyMsg,
|
||||
confirmText: '确定',
|
||||
});
|
||||
}
|
||||
} else if (!data.loginError) {
|
||||
lastErrorRef.current = '';
|
||||
setLoginError('');
|
||||
@ -197,6 +221,8 @@ export default function QQLoginPage () {
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
size='lg'
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => key !== null && setActiveTab(key.toString())}
|
||||
>
|
||||
<Tab key='shortcut' title='快速登录'>
|
||||
<QuickLogin
|
||||
@ -209,6 +235,13 @@ export default function QQLoginPage () {
|
||||
onUpdateQQList={onUpdateQQList}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='password' title='密码登录'>
|
||||
<PasswordLogin
|
||||
isLoading={isLoading}
|
||||
onSubmit={onPasswordSubmit}
|
||||
qqList={qqList}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
<QrCodeLogin
|
||||
loginError={parseLoginError(loginError)}
|
||||
|
||||
@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
'/api': backendDebugUrl,
|
||||
'/files': backendDebugUrl,
|
||||
'/plugin': backendDebugUrl,
|
||||
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
||||
'/webui/sw.js': backendDebugUrl,
|
||||
},
|
||||
|
||||
@ -232,8 +232,8 @@ importers:
|
||||
packages/napcat-plugin-builtin:
|
||||
dependencies:
|
||||
napcat-types:
|
||||
specifier: 0.0.15
|
||||
version: 0.0.15
|
||||
specifier: 0.0.16
|
||||
version: 0.0.16
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
@ -302,6 +302,12 @@ importers:
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
|
||||
packages/napcat-rpc:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
|
||||
packages/napcat-schema:
|
||||
dependencies:
|
||||
'@sinclair/typebox':
|
||||
@ -357,6 +363,9 @@ importers:
|
||||
napcat-image-size:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-image-size
|
||||
napcat-rpc:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-rpc
|
||||
devDependencies:
|
||||
vitest:
|
||||
specifier: ^4.0.9
|
||||
@ -5448,8 +5457,8 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napcat-types@0.0.15:
|
||||
resolution: {integrity: sha512-uOkaQPO3SVgkO/Rt0cQ+02wCI9C9jzdYVViHByHrr9sA+2ZjT1HV5nVSgNNQXUaZ9q405LUu45xQ4lysNyLpBA==}
|
||||
napcat-types@0.0.16:
|
||||
resolution: {integrity: sha512-y3qhpdd16ATsMp4Jf88XwisFBVKqY+XSfvGX1YqMEasVFTNXeKr1MZrIzhHMkllW1QJZXAI8iNGVJO1gkHEtLQ==}
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||
@ -12774,7 +12783,7 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napcat-types@0.0.15:
|
||||
napcat-types@0.0.16:
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.34.41
|
||||
'@types/node': 22.19.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user