mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-28 07:40:27 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896e1c209a | ||
|
|
cff07c7ce5 | ||
|
|
5c04b799a6 | ||
|
|
1fc4655ae1 | ||
|
|
eb07cdb715 | ||
|
|
964fd98914 | ||
|
|
f9764c9559 | ||
|
|
b71a4913eb | ||
|
|
f961830836 | ||
|
|
dd8b5f84a6 | ||
|
|
48ffd5597a | ||
|
|
1b73d68cbf | ||
|
|
5fec649425 | ||
|
|
052e7fa2b3 | ||
|
|
04e425d17a | ||
|
|
cbe0506577 | ||
|
|
32ec097f51 | ||
|
|
53f27ea9e2 | ||
|
|
41d94cd5e2 | ||
|
|
285d352bc8 | ||
|
|
a3b3836b8a | ||
|
|
b9f61cc0ee | ||
|
|
9998207346 | ||
|
|
4f47af233f | ||
|
|
6aadc2402d | ||
|
|
eb937b29e4 | ||
|
|
f44aca9a2f |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ permissions: write-all
|
||||
|
||||
env:
|
||||
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||
OPENROUTER_MODEL: "glm-4.7"
|
||||
OPENROUTER_MODEL: "deepseek-v3.2-chat"
|
||||
RELEASE_NAME: "NapCat"
|
||||
|
||||
jobs:
|
||||
|
||||
17
README.md
17
README.md
@@ -11,15 +11,8 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
---
|
||||
|
||||
## New Feature
|
||||
|
||||
在 v4.8.115+ 版本开始
|
||||
|
||||
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
|
||||
|
||||
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
|
||||
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||
## Notice
|
||||
NapCat 当前正在寻找新的主要维护者,欢迎email到 nanaeonn@outlook.com ,在此期不会建立任何公开社区交流群,Napcat会保证此期间的正常更新。
|
||||
|
||||
## Welcome
|
||||
|
||||
@@ -53,12 +46,6 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|:-:|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Telegram | [](https://t.me/napcatqq) |
|
||||
|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export class NTQQPacketApi {
|
||||
this.pkt = new PacketClientSession(this.core);
|
||||
await this.pkt.init(process.pid, table.recv, table.send);
|
||||
try {
|
||||
await this.pkt.operation.FetchRkey(1500);
|
||||
await this.pkt.operation.FetchRkey(3000);
|
||||
} catch (error) {
|
||||
this.logger.logError('测试Packet状态异常', error);
|
||||
return false;
|
||||
|
||||
10
packages/napcat-core/external/napcat.json
vendored
10
packages/napcat-core/external/napcat.json
vendored
@@ -5,5 +5,13 @@
|
||||
"consoleLogLevel": "info",
|
||||
"packetBackend": "auto",
|
||||
"packetServer": "",
|
||||
"o3HookMode": 0
|
||||
"o3HookMode": 1,
|
||||
"bypass": {
|
||||
"hook": false,
|
||||
"window": false,
|
||||
"module": false,
|
||||
"process": false,
|
||||
"container": false,
|
||||
"js": false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { ConfigBase } from '@/napcat-core/helper/config-base';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import { AnySchema } from 'ajv';
|
||||
import Ajv, { AnySchema } from 'ajv';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import json5 from 'json5';
|
||||
|
||||
export const BypassOptionsSchema = Type.Object({
|
||||
hook: Type.Boolean({ default: true }),
|
||||
window: Type.Boolean({ default: true }),
|
||||
module: Type.Boolean({ default: true }),
|
||||
process: Type.Boolean({ default: true }),
|
||||
container: Type.Boolean({ default: true }),
|
||||
js: Type.Boolean({ default: true }),
|
||||
});
|
||||
|
||||
export const NapcatConfigSchema = Type.Object({
|
||||
fileLog: Type.Boolean({ default: false }),
|
||||
@@ -11,10 +23,31 @@ export const NapcatConfigSchema = Type.Object({
|
||||
packetBackend: Type.String({ default: 'auto' }),
|
||||
packetServer: Type.String({ default: '' }),
|
||||
o3HookMode: Type.Number({ default: 0 }),
|
||||
bypass: Type.Optional(BypassOptionsSchema),
|
||||
});
|
||||
|
||||
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||
|
||||
/**
|
||||
* 从指定配置目录读取 napcat.json,按 NapcatConfigSchema 校验并填充默认值
|
||||
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
|
||||
*/
|
||||
export function loadNapcatConfig (configPath: string): NapcatConfig {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile<NapcatConfig>(NapcatConfigSchema);
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const configFile = path.join(configPath, 'napcat.json');
|
||||
if (fs.existsSync(configFile)) {
|
||||
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时使用 schema 默认值
|
||||
}
|
||||
validate(data);
|
||||
return data as NapcatConfig;
|
||||
}
|
||||
|
||||
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||
constructor (core: NapCatCore, configPath: string, schema: AnySchema) {
|
||||
super('napcat', core, configPath, schema);
|
||||
|
||||
@@ -194,7 +194,7 @@ export class NativePacketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async init (version: string): Promise<boolean> {
|
||||
async init (version: string, o3HookMode: boolean = false): Promise<boolean> {
|
||||
const version_arch = version + '-' + process.arch;
|
||||
try {
|
||||
if (!this.loaded) {
|
||||
@@ -215,7 +215,7 @@ export class NativePacketHandler {
|
||||
|
||||
this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => {
|
||||
this.emitPacket(type, uin, cmd, seq, hex_data);
|
||||
}, true);
|
||||
}, o3HookMode);
|
||||
this.logger.log('[PacketHandler] 初始化成功');
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,10 +4,19 @@ import fs from 'fs';
|
||||
import { constants } from 'node:os';
|
||||
import { LogWrapper } from '../../helper/log';
|
||||
|
||||
export interface BypassOptions {
|
||||
hook?: boolean;
|
||||
window?: boolean;
|
||||
module?: boolean;
|
||||
process?: boolean;
|
||||
container?: boolean;
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
export interface Napi2NativeExportType {
|
||||
initHook?: (send: string, recv: string) => boolean;
|
||||
setVerbose?: (verbose: boolean) => void; // 默认关闭日志
|
||||
enableAllBypasses?: () => void;
|
||||
enableAllBypasses?: (options?: BypassOptions) => boolean;
|
||||
}
|
||||
|
||||
export class Napi2NativeLoader {
|
||||
|
||||
@@ -29,10 +29,11 @@ export interface PasswordLoginArgType {
|
||||
uin: string;
|
||||
passwordMd5: string;// passwMD5
|
||||
step: number;// 猜测是需要二次认证 参数 一次为0
|
||||
newDeviceLoginSig: string;
|
||||
proofWaterSig: string;
|
||||
proofWaterRand: string;
|
||||
proofWaterSid: string;
|
||||
newDeviceLoginSig: Uint8Array;
|
||||
proofWaterSig: Uint8Array;
|
||||
proofWaterRand: Uint8Array;
|
||||
proofWaterSid: Uint8Array;
|
||||
unusualDeviceCheckSig: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LoginListItem {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/i
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
@@ -42,19 +43,22 @@ export async function NCoreInitFramework (
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger }); // 初始化 Napi2NativeLoader 用于后续使用
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
//console.log('[NapCat] [Napi2NativeLoader]', napi2nativeLoader.nativeExports.enableAllBypasses?.());
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
if (bypassEnabled) {
|
||||
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
|
||||
}
|
||||
logger.log('[NapCat] Napi2NativeLoader: Framework模式Bypass配置:', bypassOptions);
|
||||
} else {
|
||||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||||
}
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
// });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||||
// 在 init 之后注册监听器
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
"napcat-qrcode": "workspace:*",
|
||||
"json5": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,14 +13,12 @@ import { URL } from 'url';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||
private app: Express | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private wsServer?: WebSocketServer;
|
||||
private wsClients: WebSocket[] = [];
|
||||
private wsClientsMutex = new Mutex();
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
private wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
@@ -30,19 +28,17 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
// http server is passive, no need to emit event
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
open () {
|
||||
@@ -65,13 +61,9 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
this.server?.close();
|
||||
this.app = undefined;
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
});
|
||||
this.wsClients.forEach((wsClient) => wsClient.close());
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
this.wsServer?.close();
|
||||
}
|
||||
|
||||
@@ -153,36 +145,29 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('ping', () => {
|
||||
wsClient.pong();
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
|
||||
}
|
||||
|
||||
@@ -197,12 +182,10 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
@@ -196,9 +196,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(entry.pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 确保插件配置目录存在
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
const coreLogger = this.logger;
|
||||
@@ -358,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
if (entry.loaded) {
|
||||
await this.unloadPlugin(entry);
|
||||
@@ -372,7 +377,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
// 清理插件配置数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -440,11 +445,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
return path.join(entry.pluginPath, 'data');
|
||||
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,9 +173,14 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(entry.pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 确保插件配置目录存在
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
const coreLogger = this.logger;
|
||||
@@ -323,7 +328,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
// 先卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
@@ -336,7 +341,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
// 清理插件配置数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -404,11 +409,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
return path.join(entry.pluginPath, 'data');
|
||||
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface PluginPackageJson {
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string; };
|
||||
icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png"
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
@@ -85,9 +85,6 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
},
|
||||
|
||||
});
|
||||
this.connection.on('ping', () => {
|
||||
this.connection?.pong();
|
||||
});
|
||||
this.connection.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import { URL } from 'url';
|
||||
import { RawData, WebSocket, WebSocketServer } from 'ws';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
@@ -17,7 +16,6 @@ import json5 from 'json5';
|
||||
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||
wsServer?: WebSocketServer;
|
||||
wsClients: WebSocket[] = [];
|
||||
wsClientsMutex = new Mutex();
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
@@ -58,36 +56,29 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('ping', () => {
|
||||
wsClient.pong();
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [WebSocket Server] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
||||
}
|
||||
|
||||
@@ -100,19 +91,17 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
open () {
|
||||
@@ -136,24 +125,18 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
});
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
});
|
||||
this.wsClients.forEach((wsClient) => wsClient.close());
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
}
|
||||
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
}, this.config.heartInterval);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"express": "^5.0.0",
|
||||
"ws": "^8.18.3",
|
||||
"file-type": "^21.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"napcat-protobuf": "workspace:*",
|
||||
"json5": "^2.2.3",
|
||||
"napcat-core": "workspace:*",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { hostname, systemVersion } from 'napcat-common/src/system';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||||
import qrcode from 'napcat-qrcode/lib/main';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
@@ -31,12 +32,14 @@ import { sleep } from 'napcat-common/src/helper';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
|
||||
// NapCat Shell App ES 入口文件
|
||||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
@@ -192,6 +195,24 @@ async function handleLogin (
|
||||
return await selfInfo;
|
||||
}
|
||||
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||||
const quickPasswordMd5 = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||||
if (quickPasswordMd5) {
|
||||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5)) {
|
||||
return quickPasswordMd5.toLowerCase();
|
||||
}
|
||||
logger.logError('NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||||
}
|
||||
|
||||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||||
logger.log('检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 注册刷新二维码回调
|
||||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||||
loginService.getQRCodePicture();
|
||||
@@ -202,10 +223,12 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
if (uin) {
|
||||
logger.log('正在快速登录 ', uin);
|
||||
loginService.quickLoginWithUin(uin).then(res => {
|
||||
if (res.loginErrorInfo.errMsg) {
|
||||
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
|
||||
const quickLoginSuccess = res.result === '0' && !res.loginErrorInfo?.errMsg;
|
||||
if (!quickLoginSuccess) {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`;
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: res.loginErrorInfo.errMsg });
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
@@ -232,21 +255,43 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 0,
|
||||
newDeviceLoginSig: '',
|
||||
proofWaterSig: '',
|
||||
proofWaterRand: '',
|
||||
proofWaterSid: '',
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022008') {
|
||||
const errMsg = '需要验证码,暂不支持';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
|
||||
logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '需要验证码',
|
||||
needCaptcha: true,
|
||||
proofWaterUrl,
|
||||
});
|
||||
} else if (res.result === '140022010') {
|
||||
const errMsg = '新设备需要扫码登录,暂不支持';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '新设备需要扫码验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('异常设备需要验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
@@ -268,21 +313,170 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
}
|
||||
});
|
||||
});
|
||||
const tryPasswordFallbackLogin = async (uin: string): Promise<{ success: boolean, attempted: boolean; }> => {
|
||||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||||
if (!quickPasswordMd5) {
|
||||
logger.log(`QQ ${uin} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),将使用二维码登录方式`);
|
||||
return { success: false, attempted: false };
|
||||
}
|
||||
|
||||
logger.log('正在尝试密码回退登录 ', uin);
|
||||
const fallbackResult = await WebUiDataRuntime.requestPasswordLogin(uin, quickPasswordMd5);
|
||||
if (fallbackResult.result) {
|
||||
logger.log('密码回退登录成功 ', uin);
|
||||
return { success: true, attempted: true };
|
||||
}
|
||||
if (fallbackResult.needCaptcha) {
|
||||
const captchaTip = fallbackResult.proofWaterUrl
|
||||
? `密码回退需要验证码,请在 WebUi 中继续完成验证:${fallbackResult.proofWaterUrl}`
|
||||
: '密码回退需要验证码,请在 WebUi 中继续完成验证';
|
||||
logger.logWarn(captchaTip);
|
||||
WebUiDataRuntime.setQQLoginError('密码回退需要验证码,请在 WebUi 中继续完成验证');
|
||||
return { success: false, attempted: true };
|
||||
}
|
||||
if (fallbackResult.needNewDevice) {
|
||||
const newDeviceTip = fallbackResult.jumpUrl
|
||||
? `密码回退需要新设备验证,请在 WebUi 中继续完成验证:${fallbackResult.jumpUrl}`
|
||||
: '密码回退需要新设备验证,请在 WebUi 中继续完成验证';
|
||||
logger.logWarn(newDeviceTip);
|
||||
WebUiDataRuntime.setQQLoginError('密码回退需要新设备验证,请在 WebUi 中继续完成验证');
|
||||
return { success: false, attempted: true };
|
||||
}
|
||||
logger.logError('密码回退登录失败:', fallbackResult.message);
|
||||
return { success: false, attempted: true };
|
||||
};
|
||||
|
||||
// 注册验证码登录回调(密码登录需要验证码时的第二步)
|
||||
WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5 && ticket) {
|
||||
logger.log('正在验证码登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 1,
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new TextEncoder().encode(ticket),
|
||||
proofWaterRand: new TextEncoder().encode(randstr),
|
||||
proofWaterSid: new TextEncoder().encode(sid),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
console.log('验证码登录结果: ', res);
|
||||
if (res.result === '140022010') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '新设备需要扫码验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} 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: '验证码登录失败:参数不完整' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册新设备登录回调(密码登录需要新设备验证时的第二步)
|
||||
WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5 && newDevicePullQrCodeSig) {
|
||||
logger.log('正在新设备验证登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 2,
|
||||
newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} 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);
|
||||
loginService.quickLoginWithUin(quickLoginUin)
|
||||
.then(result => {
|
||||
if (result.loginErrorInfo.errMsg) {
|
||||
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
.then(async result => {
|
||||
const quickLoginSuccess = result.result === '0' && !result.loginErrorInfo?.errMsg;
|
||||
if (!quickLoginSuccess) {
|
||||
const errMsg = result.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${result.result}`;
|
||||
logger.logError('快速登录错误:', errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
.catch(async (error) => {
|
||||
logger.logError('快速登录异常:', error);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
});
|
||||
} else {
|
||||
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将尝试密码回退登录');
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
} else {
|
||||
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
|
||||
@@ -392,7 +586,6 @@ export async function NCoreInitShell () {
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const nativePacketHandler = new NativePacketHandler({ logger });
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
@@ -401,13 +594,16 @@ export async function NCoreInitShell () {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
|
||||
// wrapper.node 加载后再初始化 hook,按 schema 读取配置
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||||
if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') {
|
||||
napi2nativeLoader.nativeExports.setVerbose?.(true);
|
||||
}
|
||||
// wrapper.node 加载后立刻启用 Bypass(可通过环境变量禁用)
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
|
||||
if (bypassEnabled) {
|
||||
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
|
||||
}
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
} else {
|
||||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||||
}
|
||||
@@ -461,6 +657,13 @@ export async function NCoreInitShell () {
|
||||
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
|
||||
|
||||
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
|
||||
|
||||
// 登录成功后通知 Master 进程(用于切换崩溃重试策略)
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'login-success' });
|
||||
logger.log('[NapCat] 已通知主进程登录成功');
|
||||
}
|
||||
|
||||
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
|
||||
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const ENV = {
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown' | 'login-success';
|
||||
secretKey?: string;
|
||||
port?: number;
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
@@ -275,6 +276,8 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
restartWorker(message.secretKey, message.port).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
} else if (message.type === 'login-success') {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已登录成功,切换到正常重试策略`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -297,13 +300,13 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
// 检查是否超过崩溃阈值
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
|
||||
14
packages/napcat-types/external-shims.d.ts
vendored
14
packages/napcat-types/external-shims.d.ts
vendored
@@ -61,20 +61,6 @@ declare module 'yaml' {
|
||||
export const stringify: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
declare module 'async-mutex' {
|
||||
export class Mutex {
|
||||
acquire (): Promise<() => void>;
|
||||
runExclusive<T> (callback: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
export class Semaphore {
|
||||
acquire (): Promise<[() => void, number]>;
|
||||
runExclusive<T> (callback: () => T | Promise<T>): Promise<T>;
|
||||
release (): void;
|
||||
}
|
||||
const _async_mutex_default: { Mutex: typeof Mutex; Semaphore: typeof Semaphore; };
|
||||
export default _async_mutex_default;
|
||||
}
|
||||
|
||||
declare module 'napcat-protobuf' {
|
||||
export class NapProtoMsg<T = any> {
|
||||
constructor (schema: any);
|
||||
|
||||
@@ -35,9 +35,6 @@ const EXTERNAL_TYPE_REPLACEMENTS = {
|
||||
'ValidateFunction<T>': 'any',
|
||||
// inversify
|
||||
'Container': 'any',
|
||||
// async-mutex
|
||||
'Mutex': 'any',
|
||||
'Semaphore': 'any',
|
||||
// napcat-protobuf
|
||||
'NapProtoDecodeStructType': 'any',
|
||||
'NapProtoEncodeStructType': 'any',
|
||||
@@ -90,15 +87,15 @@ function replaceExternalTypes (content) {
|
||||
// 使用类型上下文的模式匹配
|
||||
const typeContextPatterns = [
|
||||
// : Type
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
// <Type>
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
// Type[]
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
// extends Type
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
// implements Type
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
];
|
||||
|
||||
for (const pattern of typeContextPatterns) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import express from 'express';
|
||||
import type { WebUiConfigType } from './src/types';
|
||||
import { createServer } from 'http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { WebUiConfigWrapper } from '@/napcat-webui-backend/src/helper/config';
|
||||
@@ -156,16 +156,60 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
WebUiDataRuntime.setWebUiConfigQuickFunction(
|
||||
async () => {
|
||||
const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
|
||||
if (autoLoginAccount) {
|
||||
try {
|
||||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||||
if (!result) {
|
||||
throw new Error(message);
|
||||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||||
const quickPasswordMd5FromEnv = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||||
if (quickPasswordMd5FromEnv) {
|
||||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5FromEnv)) {
|
||||
return quickPasswordMd5FromEnv.toLowerCase();
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] Auto login account failed.' + error);
|
||||
console.log('[NapCat] [WebUi] NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||||
}
|
||||
|
||||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||||
console.log('[NapCat] [WebUi] 检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
if (!autoLoginAccount) {
|
||||
return;
|
||||
}
|
||||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||||
|
||||
try {
|
||||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||||
if (result) {
|
||||
console.log(`[NapCat] [WebUi] 自动快速登录成功: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] 自动快速登录失败: ${message || '未知错误'}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] 自动快速登录异常:' + error);
|
||||
}
|
||||
|
||||
if (!quickPasswordMd5) {
|
||||
console.log(`[NapCat] [WebUi] QQ ${autoLoginAccount} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),保持二维码登录兜底`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { result, message, needCaptcha, needNewDevice } = await WebUiDataRuntime.requestPasswordLogin(autoLoginAccount, quickPasswordMd5);
|
||||
if (result) {
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录成功: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
if (needCaptcha) {
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录需要验证码,请在登录页面继续完成: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
if (needNewDevice) {
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录需要新设备验证,请在登录页面继续完成: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录失败: ${message || '未知错误'}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] 自动密码回退登录异常:' + error);
|
||||
}
|
||||
});
|
||||
// ------------注册中间件------------
|
||||
|
||||
90
packages/napcat-webui-backend/src/api/NapCatConfig.ts
Normal file
90
packages/napcat-webui-backend/src/api/NapCatConfig.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import json5 from 'json5';
|
||||
|
||||
import Ajv from 'ajv';
|
||||
import { NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
|
||||
// 动态获取 NapCat 配置默认值
|
||||
function getDefaultNapcatConfig (): Record<string, unknown> {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(NapcatConfigSchema);
|
||||
const data = {};
|
||||
validate(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 napcat 配置文件路径
|
||||
*/
|
||||
function getNapcatConfigPath (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 napcat 配置
|
||||
*/
|
||||
function readNapcatConfig (): Record<string, unknown> {
|
||||
const configPath = getNapcatConfigPath();
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
|
||||
}
|
||||
} catch (_e) {
|
||||
// 读取失败,使用默认值
|
||||
}
|
||||
return { ...getDefaultNapcatConfig() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 napcat 配置
|
||||
*/
|
||||
function writeNapcatConfig (config: Record<string, unknown>): void {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
mkdirSync(webUiPathWrapper.configPath, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 获取 NapCat 配置
|
||||
export const NapCatGetConfigHandler: RequestHandler = (_, res) => {
|
||||
try {
|
||||
const config = readNapcatConfig();
|
||||
return sendSuccess(res, config);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Get Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 NapCat 配置
|
||||
export const NapCatSetConfigHandler: RequestHandler = (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body;
|
||||
if (!newConfig || typeof newConfig !== 'object') {
|
||||
return sendError(res, 'config is empty or invalid');
|
||||
}
|
||||
|
||||
// 读取当前配置并合并
|
||||
const currentConfig = readNapcatConfig();
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
|
||||
// 验证 bypass 字段
|
||||
if (mergedConfig.bypass && typeof mergedConfig.bypass === 'object') {
|
||||
const bypass = mergedConfig.bypass as Record<string, unknown>;
|
||||
const validKeys = ['hook', 'window', 'module', 'process', 'container', 'js'];
|
||||
for (const key of validKeys) {
|
||||
if (key in bypass && typeof bypass[key] !== 'boolean') {
|
||||
return sendError(res, `bypass.${key} must be boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeNapcatConfig(mergedConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Set Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,49 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import compressing from 'compressing';
|
||||
|
||||
/**
|
||||
* 获取插件图标 URL
|
||||
* 优先使用 package.json 中的 icon 字段,否则检查缓存的图标文件
|
||||
*/
|
||||
function getPluginIconUrl (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
|
||||
// 1. 检查 package.json 中指定的 icon 文件
|
||||
if (iconField) {
|
||||
const iconPath = path.join(pluginPath, iconField);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
|
||||
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
|
||||
if (fs.existsSync(cachedIcon)) {
|
||||
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件图标文件的实际路径
|
||||
*/
|
||||
function findPluginIconPath (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
|
||||
// 1. 优先使用 package.json 中指定的 icon
|
||||
if (iconField) {
|
||||
const iconPath = path.join(pluginPath, iconField);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return iconPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
|
||||
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
|
||||
if (fs.existsSync(cachedIcon)) {
|
||||
return cachedIcon;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
@@ -77,6 +120,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
hasPages: boolean;
|
||||
homepage?: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
}> = new Array();
|
||||
|
||||
// 收集所有插件的扩展页面
|
||||
@@ -117,7 +161,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
homepage: p.packageJson?.homepage,
|
||||
repository: typeof p.packageJson?.repository === 'string'
|
||||
? p.packageJson.repository
|
||||
: p.packageJson?.repository?.url
|
||||
: p.packageJson?.repository?.url,
|
||||
icon: getPluginIconUrl(p.id, p.pluginPath, p.packageJson?.icon),
|
||||
});
|
||||
|
||||
// 收集插件的扩展页面
|
||||
@@ -600,3 +645,24 @@ export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, 'Failed to import plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件图标
|
||||
*/
|
||||
export const GetPluginIconHandler: RequestHandler = async (req, res) => {
|
||||
const pluginId = req.params['pluginId'];
|
||||
if (!pluginId) return sendError(res, 'Plugin ID is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(pluginId);
|
||||
if (!plugin) return sendError(res, 'Plugin not found');
|
||||
|
||||
const iconPath = findPluginIconPath(pluginId, plugin.pluginPath, plugin.packageJson?.icon);
|
||||
if (!iconPath) {
|
||||
return res.status(404).json({ code: -1, message: 'Icon not found' });
|
||||
}
|
||||
|
||||
return res.sendFile(iconPath);
|
||||
};
|
||||
|
||||
@@ -287,6 +287,95 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
||||
console.log('[extractPlugin] Extracted files:', files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后尝试缓存插件图标
|
||||
* 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录
|
||||
*/
|
||||
async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise<void> {
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
const configDir = path.join(webUiPathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
// 检查 package.json 是否已有 icon 字段
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.icon) {
|
||||
const iconPath = path.join(pluginDir, pkg.icon);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return; // 已有 icon,无需缓存
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已有缓存的图标 (固定 icon.png)
|
||||
if (fs.existsSync(path.join(configDir, 'icon.png'))) {
|
||||
return; // 已有缓存图标
|
||||
}
|
||||
|
||||
// 尝试从 GitHub 获取头像
|
||||
let avatarUrl: string | undefined;
|
||||
|
||||
// 从 downloadUrl 提取 GitHub 用户名
|
||||
if (storePlugin.downloadUrl) {
|
||||
try {
|
||||
const url = new URL(storePlugin.downloadUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 从 homepage 提取
|
||||
if (!avatarUrl && storePlugin.homepage) {
|
||||
try {
|
||||
const url = new URL(storePlugin.homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarUrl) return;
|
||||
|
||||
try {
|
||||
// 确保 config 目录存在
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
const response = await fetch(avatarUrl, {
|
||||
headers: { 'User-Agent': 'NapCat-WebUI' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const iconPath = path.join(configDir, 'icon.png');
|
||||
const fileStream = createWriteStream(iconPath);
|
||||
await pipeline(response.body as any, fileStream);
|
||||
|
||||
console.log(`[cachePluginIcon] Cached icon for ${pluginId} at ${iconPath}`);
|
||||
} catch (e: any) {
|
||||
console.warn(`[cachePluginIcon] Failed to cache icon for ${pluginId}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
@@ -374,6 +463,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段),失败可跳过
|
||||
try {
|
||||
await cachePluginIcon(id, plugin);
|
||||
} catch (e: any) {
|
||||
console.warn(`[InstallPlugin] Failed to cache icon for ${id}, skipping:`, e.message);
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
@@ -497,6 +593,12 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
|
||||
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段)
|
||||
cachePluginIcon(id, plugin).catch(e => {
|
||||
console.warn(`[cachePluginIcon] Failed to cache icon for ${id}:`, e.message);
|
||||
});
|
||||
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import https from 'https';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
@@ -7,6 +8,37 @@ import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/respons
|
||||
import { Registry20Utils, MachineInfoUtils } from '@/napcat-webui-backend/src/utils/guid';
|
||||
import os from 'node:os';
|
||||
|
||||
// oidb 新设备验证请求辅助函数
|
||||
function oidbRequest (uid: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = JSON.stringify(body);
|
||||
const req = https.request({
|
||||
hostname: 'oidb.tim.qq.com',
|
||||
path: `/v3/oidbinterface/oidb_0xc9e_8?uid=${encodeURIComponent(uid)}&getqrcode=1&sdkappid=39998&actype=2`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Failed to parse oidb response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 Registry20 路径的辅助函数
|
||||
const getRegistryPath = () => {
|
||||
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||
@@ -171,8 +203,62 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
// 执行密码登录
|
||||
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
const { result, message, needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
if (!result) {
|
||||
if (needCaptcha && proofWaterUrl) {
|
||||
return sendSuccess(res, { needCaptcha: true, proofWaterUrl });
|
||||
}
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 验证码登录(密码登录需要验证码时的第二步)
|
||||
export const QQCaptchaLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, ticket, randstr, sid } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(ticket) || isEmpty(randstr)) {
|
||||
return sendError(res, 'captcha ticket or randstr is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestCaptchaLogin(uin, passwordMd5, ticket, randstr, sid || '');
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
export const QQNewDeviceLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, newDevicePullQrCodeSig } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(newDevicePullQrCodeSig)) {
|
||||
return sendError(res, 'newDevicePullQrCodeSig is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestNewDeviceLogin(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
@@ -412,4 +498,61 @@ export const QQResetLinuxDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OIDB 新设备 QR 验证
|
||||
// ============================================================
|
||||
|
||||
// 获取新设备验证二维码 (通过 OIDB 接口)
|
||||
export const QQGetNewDeviceQRCodeHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, jumpUrl } = req.body;
|
||||
if (!uin || !jumpUrl) {
|
||||
return sendError(res, 'uin and jumpUrl are required');
|
||||
}
|
||||
|
||||
// 从 jumpUrl 中提取参数
|
||||
// jumpUrl 格式: https://accounts.qq.com/safe/verify?...&uin-token=xxx&sig=yyy
|
||||
// sig -> str_dev_auth_token, uin-token -> str_uin_token
|
||||
const url = new URL(jumpUrl);
|
||||
const strDevAuthToken = url.searchParams.get('sig') || '';
|
||||
const strUinToken = url.searchParams.get('uin-token') || '';
|
||||
|
||||
if (!strDevAuthToken || !strUinToken) {
|
||||
return sendError(res, 'Failed to get new device QR code: unable to extract sig/uin-token from jumpUrl');
|
||||
}
|
||||
|
||||
const body = {
|
||||
str_dev_auth_token: strDevAuthToken,
|
||||
uint32_flag: 1,
|
||||
uint32_url_type: 0,
|
||||
str_uin_token: strUinToken,
|
||||
str_dev_type: 'Windows',
|
||||
str_dev_name: os.hostname() || 'DESKTOP-NAPCAT',
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
return sendSuccess(res, result);
|
||||
};
|
||||
|
||||
// 轮询新设备验证二维码状态
|
||||
export const QQPollNewDeviceQRHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, bytesToken } = req.body;
|
||||
if (!uin || !bytesToken) {
|
||||
return sendError(res, 'uin and bytesToken are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
uint32_flag: 0,
|
||||
bytes_token: bytesToken, // base64 编码的 token
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
// result 应包含 uint32_guarantee_status:
|
||||
// 0 = 等待扫码, 3 = 已扫码, 1 = 已确认 (包含 str_nt_succ_token)
|
||||
return sendSuccess(res, result);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to poll QR status: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
onPasswordLoginRequested: async () => {
|
||||
return { result: false, message: '密码登录功能未初始化' };
|
||||
},
|
||||
onCaptchaLoginRequested: async () => {
|
||||
return { result: false, message: '验证码登录功能未初始化' };
|
||||
},
|
||||
onNewDeviceLoginRequested: async () => {
|
||||
return { result: false, message: '新设备登录功能未初始化' };
|
||||
},
|
||||
onRestartProcessRequested: async () => {
|
||||
return { result: false, message: '重启功能未初始化' };
|
||||
},
|
||||
@@ -148,6 +154,22 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
|
||||
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
|
||||
|
||||
setCaptchaLoginCall (func: LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onCaptchaLoginRequested = func;
|
||||
},
|
||||
|
||||
requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
|
||||
return LoginRuntime.NapCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid);
|
||||
} as LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested'],
|
||||
|
||||
setNewDeviceLoginCall (func: LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onNewDeviceLoginRequested = func;
|
||||
},
|
||||
|
||||
requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
|
||||
return LoginRuntime.NapCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
} as LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested'],
|
||||
|
||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
12
packages/napcat-webui-backend/src/router/NapCatConfig.ts
Normal file
12
packages/napcat-webui-backend/src/router/NapCatConfig.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { NapCatGetConfigHandler, NapCatSetConfigHandler } from '@/napcat-webui-backend/src/api/NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// router:获取 NapCat 配置
|
||||
router.get('/GetConfig', NapCatGetConfigHandler);
|
||||
// router:设置 NapCat 配置
|
||||
router.post('/SetConfig', NapCatSetConfigHandler);
|
||||
|
||||
export { router as NapCatConfigRouter };
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
RegisterPluginManagerHandler,
|
||||
PluginConfigSSEHandler,
|
||||
PluginConfigChangeHandler,
|
||||
ImportLocalPluginHandler
|
||||
ImportLocalPluginHandler,
|
||||
GetPluginIconHandler
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
@@ -67,7 +68,8 @@ router.post('/Config', SetPluginConfigHandler);
|
||||
router.get('/Config/SSE', PluginConfigSSEHandler);
|
||||
router.post('/Config/Change', PluginConfigChangeHandler);
|
||||
router.post('/RegisterManager', RegisterPluginManagerHandler);
|
||||
router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
|
||||
// router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); // 禁用插件上传
|
||||
router.get('/Icon/:pluginId', GetPluginIconHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
QQCaptchaLoginHandler,
|
||||
QQNewDeviceLoginHandler,
|
||||
QQGetNewDeviceQRCodeHandler,
|
||||
QQPollNewDeviceQRHandler,
|
||||
QQResetDeviceIDHandler,
|
||||
QQRestartNapCatHandler,
|
||||
QQGetDeviceGUIDHandler,
|
||||
@@ -50,6 +54,14 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
// router:验证码登录(密码登录需要验证码时的第二步)
|
||||
router.post('/CaptchaLogin', QQCaptchaLoginHandler);
|
||||
// router:新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
router.post('/NewDeviceLogin', QQNewDeviceLoginHandler);
|
||||
// router:获取新设备验证二维码 (OIDB)
|
||||
router.post('/GetNewDeviceQRCode', QQGetNewDeviceQRCodeHandler);
|
||||
// router:轮询新设备验证二维码状态 (OIDB)
|
||||
router.post('/PollNewDeviceQR', QQPollNewDeviceQRHandler);
|
||||
// router:重置设备信息
|
||||
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||
// router:重启NapCat
|
||||
|
||||
@@ -19,6 +19,7 @@ import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
import { MirrorRouter } from './Mirror';
|
||||
import { NapCatConfigRouter } from './NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@@ -53,5 +54,7 @@ router.use('/Process', ProcessRouter);
|
||||
router.use('/Plugin', PluginRouter);
|
||||
// router:镜像管理相关路由
|
||||
router.use('/Mirror', MirrorRouter);
|
||||
// router:NapCat配置相关路由
|
||||
router.use('/NapCatConfig', NapCatConfigRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@@ -57,7 +57,9 @@ 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; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; needCaptcha?: boolean; proofWaterUrl?: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onCaptchaLoginRequested: (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onNewDeviceLoginRequested: (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||
QQLoginList: string[];
|
||||
|
||||
@@ -54,7 +54,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
textValue='title'
|
||||
>
|
||||
<div className='flex items-center gap-2 justify-center'>
|
||||
<div className='w-5 h-5 -ml-3'>
|
||||
<div className='w-5 h-5 -ml-3 flex items-center justify-center'>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className='text-primary-400'>新建网络配置</div>
|
||||
|
||||
@@ -12,43 +12,18 @@ import { useState } from 'react';
|
||||
import key from '@/const/key';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
|
||||
/** 提取作者头像 URL */
|
||||
function getAuthorAvatar (homepage?: string, repository?: string): string | undefined {
|
||||
// 1. 尝试从 repository 提取 GitHub 用户名
|
||||
if (repository) {
|
||||
try {
|
||||
// 处理 git+https://github.com/... 或 https://github.com/...
|
||||
const repoUrl = repository.replace(/^git\+/, '').replace(/\.git$/, '');
|
||||
const url = new URL(repoUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
function getPluginIconUrl (iconPath?: string): string | undefined {
|
||||
if (!iconPath) return undefined;
|
||||
try {
|
||||
const raw = localStorage.getItem(key.token);
|
||||
if (!raw) return iconPath;
|
||||
const token = JSON.parse(raw);
|
||||
const url = new URL(iconPath, window.location.origin);
|
||||
url.searchParams.set('webui_token', token);
|
||||
return url.pathname + url.search;
|
||||
} catch {
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
// 2. 尝试从 homepage 提取
|
||||
if (homepage) {
|
||||
try {
|
||||
const url = new URL(homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
} else {
|
||||
// 如果是自定义域名,尝试获取 favicon
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface PluginDisplayCardProps {
|
||||
@@ -66,15 +41,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
onConfig,
|
||||
hasConfig = false,
|
||||
}) => {
|
||||
const { name, version, author, description, status, homepage, repository } = data;
|
||||
const { name, version, author, description, status, icon } = data;
|
||||
const isEnabled = status === 'active';
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
|
||||
const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
// 后端已处理 icon,前端只需拼接 token;无 icon 时兜底 Vercel 风格头像
|
||||
const avatarUrl = getPluginIconUrl(icon) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
const handleToggle = () => {
|
||||
setProcessing(true);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
interface NewDeviceVerifyProps {
|
||||
/** jumpUrl from loginErrorInfo */
|
||||
jumpUrl: string;
|
||||
/** QQ uin for OIDB requests */
|
||||
uin: string;
|
||||
/** Called when QR verification is confirmed, passes str_nt_succ_token */
|
||||
onVerified: (token: string) => void;
|
||||
/** Called when user cancels */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
type QRStatus = 'loading' | 'waiting' | 'scanned' | 'confirmed' | 'error';
|
||||
|
||||
const NewDeviceVerify: React.FC<NewDeviceVerifyProps> = ({
|
||||
jumpUrl,
|
||||
uin,
|
||||
onVerified,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [qrUrl, setQrUrl] = useState<string>('');
|
||||
const [status, setStatus] = useState<QRStatus>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback((token: string) => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const result = await QQManager.pollNewDeviceQR(uin, token);
|
||||
if (!mountedRef.current) return;
|
||||
const s = result?.uint32_guarantee_status;
|
||||
if (s === 3) {
|
||||
setStatus('scanned');
|
||||
} else if (s === 1) {
|
||||
stopPolling();
|
||||
setStatus('confirmed');
|
||||
const ntToken = result?.str_nt_succ_token || '';
|
||||
onVerified(ntToken);
|
||||
}
|
||||
// s === 0 means still waiting, do nothing
|
||||
} catch {
|
||||
// Ignore poll errors, keep polling
|
||||
}
|
||||
}, 2500);
|
||||
}, [uin, onVerified, stopPolling]);
|
||||
|
||||
const fetchQRCode = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const result = await QQManager.getNewDeviceQRCode(uin, jumpUrl);
|
||||
if (!mountedRef.current) return;
|
||||
if (result?.str_url) {
|
||||
setQrUrl(result.str_url);
|
||||
setStatus('waiting');
|
||||
// bytes_token 用于轮询,如果 OIDB 未返回则用空字符串
|
||||
startPolling(result.bytes_token || '');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setErrorMsg('获取二维码失败,请重试');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mountedRef.current) return;
|
||||
setStatus('error');
|
||||
setErrorMsg((e as Error).message || '获取二维码失败');
|
||||
}
|
||||
}, [uin, jumpUrl, startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchQRCode();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopPolling();
|
||||
};
|
||||
}, [fetchQRCode, stopPolling]);
|
||||
|
||||
const statusText: Record<QRStatus, string> = {
|
||||
loading: '正在获取二维码...',
|
||||
waiting: '请使用手机QQ扫描二维码完成验证',
|
||||
scanned: '已扫描,请在手机上确认',
|
||||
confirmed: '验证成功,正在登录...',
|
||||
error: errorMsg || '获取二维码失败',
|
||||
};
|
||||
|
||||
const statusColor: Record<QRStatus, string> = {
|
||||
loading: 'text-default-500',
|
||||
waiting: 'text-warning',
|
||||
scanned: 'text-primary',
|
||||
confirmed: 'text-success',
|
||||
error: 'text-danger',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 items-center'>
|
||||
<p className='text-warning text-sm'>
|
||||
检测到新设备登录,请使用手机QQ扫描下方二维码完成验证
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col items-center gap-3' style={{ minHeight: 280 }}>
|
||||
{status === 'loading' ? (
|
||||
<div className='flex items-center justify-center' style={{ height: 240 }}>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<div className='flex flex-col items-center justify-center gap-3' style={{ height: 240 }}>
|
||||
<p className='text-danger text-sm'>{errorMsg}</p>
|
||||
<Button color='primary' variant='flat' onPress={fetchQRCode}>
|
||||
重新获取
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-3 bg-white rounded-lg'>
|
||||
<QRCodeSVG value={qrUrl} size={220} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={`text-sm ${statusColor[status]}`}>
|
||||
{statusText[status]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
{status === 'waiting' && (
|
||||
<Button color='default' variant='flat' size='sm' onPress={fetchQRCode}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='light'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onPress={onCancel}
|
||||
>
|
||||
取消验证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewDeviceVerify;
|
||||
@@ -6,17 +6,37 @@ import { Input } from '@heroui/input';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
import TencentCaptchaModal from '@/components/tencent_captcha';
|
||||
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
|
||||
import NewDeviceVerify from '@/components/new_device_verify';
|
||||
|
||||
interface PasswordLoginProps {
|
||||
onSubmit: (uin: string, password: string) => void;
|
||||
onCaptchaSubmit?: (uin: string, password: string, captchaData: CaptchaCallbackData) => void;
|
||||
onNewDeviceVerified?: (token: string) => void;
|
||||
isLoading: boolean;
|
||||
qqList: (QQItem | LoginListItem)[];
|
||||
captchaState?: {
|
||||
needCaptcha: boolean;
|
||||
proofWaterUrl: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null;
|
||||
captchaVerifying?: boolean;
|
||||
newDeviceState?: {
|
||||
needNewDevice: boolean;
|
||||
jumpUrl: string;
|
||||
uin: string;
|
||||
} | null;
|
||||
onCaptchaCancel?: () => void;
|
||||
onNewDeviceCancel?: () => void;
|
||||
}
|
||||
|
||||
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
|
||||
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, onCaptchaSubmit, onNewDeviceVerified, isLoading, qqList, captchaState, captchaVerifying, newDeviceState, onCaptchaCancel, onNewDeviceCancel }) => {
|
||||
const [uin, setUin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -34,87 +54,128 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
|
||||
|
||||
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"
|
||||
{captchaState?.needCaptcha && captchaState.proofWaterUrl ? (
|
||||
<div className='flex flex-col gap-4 items-center'>
|
||||
{captchaVerifying ? (
|
||||
<>
|
||||
<p className='text-primary text-sm'>验证码已提交,正在等待服务器验证结果...</p>
|
||||
<div className='flex items-center justify-center py-8 gap-3'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className='text-warning text-sm'>登录需要安全验证,请完成验证码</p>
|
||||
<TencentCaptchaModal
|
||||
proofWaterUrl={captchaState.proofWaterUrl}
|
||||
onSuccess={(data) => {
|
||||
onCaptchaSubmit?.(captchaState.uin, captchaState.password, data);
|
||||
}}
|
||||
onCancel={onCaptchaCancel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant='light'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onPress={onCaptchaCancel}
|
||||
>
|
||||
取消验证
|
||||
</Button>
|
||||
</div>
|
||||
) : newDeviceState?.needNewDevice && newDeviceState.jumpUrl ? (
|
||||
<NewDeviceVerify
|
||||
jumpUrl={newDeviceState.jumpUrl}
|
||||
uin={newDeviceState.uin}
|
||||
onVerified={(token) => onNewDeviceVerified?.(token)}
|
||||
onCancel={onNewDeviceCancel}
|
||||
/>
|
||||
</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 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TencentCaptcha: new (
|
||||
appid: string,
|
||||
callback: (res: TencentCaptchaResult) => void,
|
||||
options?: Record<string, unknown>
|
||||
) => { show: () => void; destroy: () => void; };
|
||||
}
|
||||
}
|
||||
|
||||
export interface TencentCaptchaResult {
|
||||
ret: number;
|
||||
appid?: string;
|
||||
ticket?: string;
|
||||
randstr?: string;
|
||||
errorCode?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaCallbackData {
|
||||
ticket: string;
|
||||
randstr: string;
|
||||
appid: string;
|
||||
sid: string;
|
||||
}
|
||||
|
||||
interface TencentCaptchaProps {
|
||||
/** proofWaterUrl returned from login error, contains uin/sid/aid params */
|
||||
proofWaterUrl: string;
|
||||
/** Called when captcha verification succeeds */
|
||||
onSuccess: (data: CaptchaCallbackData) => void;
|
||||
/** Called when captcha is cancelled or fails */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function parseUrlParams (url: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.searchParams.forEach((v, k) => { params[k] = v; });
|
||||
} catch {
|
||||
const match = url.match(/[?&]([^#]+)/);
|
||||
if (match) {
|
||||
match[1].split('&').forEach(pair => {
|
||||
const [k, v] = pair.split('=');
|
||||
if (k) params[k] = decodeURIComponent(v || '');
|
||||
});
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function loadScript (src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.TencentCaptcha) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const tag = document.createElement('script');
|
||||
tag.src = src;
|
||||
tag.onload = () => resolve();
|
||||
tag.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
}
|
||||
|
||||
const TencentCaptchaModal: React.FC<TencentCaptchaProps> = ({
|
||||
proofWaterUrl,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const captchaRef = useRef<{ destroy: () => void; } | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const handleResult = useCallback((res: TencentCaptchaResult, sid: string) => {
|
||||
if (!mountedRef.current) return;
|
||||
if (res.ret === 0 && res.ticket && res.randstr) {
|
||||
onSuccess({
|
||||
ticket: res.ticket,
|
||||
randstr: res.randstr,
|
||||
appid: res.appid || '',
|
||||
sid,
|
||||
});
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
}, [onSuccess, onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const params = parseUrlParams(proofWaterUrl);
|
||||
const appid = params.aid || '2081081773';
|
||||
const sid = params.sid || '';
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await loadScript('https://captcha.gtimg.com/TCaptcha.js');
|
||||
} catch {
|
||||
try {
|
||||
await loadScript('https://ssl.captcha.qq.com/TCaptcha.js');
|
||||
} catch {
|
||||
// Both CDN failed, generate fallback ticket
|
||||
if (mountedRef.current) {
|
||||
handleResult({
|
||||
ret: 0,
|
||||
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
|
||||
randstr: '@' + Math.random().toString(36).substring(2),
|
||||
errorCode: 1001,
|
||||
errorMessage: 'jsload_error',
|
||||
}, sid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
try {
|
||||
const captcha = new window.TencentCaptcha(
|
||||
appid,
|
||||
(res) => handleResult(res, sid),
|
||||
{
|
||||
type: 'popup',
|
||||
showHeader: false,
|
||||
login_appid: params.login_appid,
|
||||
uin: params.uin,
|
||||
sid: params.sid,
|
||||
enableAged: true,
|
||||
}
|
||||
);
|
||||
captchaRef.current = captcha;
|
||||
captcha.show();
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
handleResult({
|
||||
ret: 0,
|
||||
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
|
||||
randstr: '@' + Math.random().toString(36).substring(2),
|
||||
errorCode: 1001,
|
||||
errorMessage: 'init_error',
|
||||
}, sid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
captchaRef.current?.destroy();
|
||||
captchaRef.current = null;
|
||||
};
|
||||
}, [proofWaterUrl, handleResult]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-default-500">正在加载验证码...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TencentCaptchaModal;
|
||||
@@ -26,6 +26,8 @@ export interface PluginItem {
|
||||
homepage?: string;
|
||||
/** 仓库链接 */
|
||||
repository?: string;
|
||||
/** 插件图标 URL(由后端返回) */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** 扩展页面信息 */
|
||||
|
||||
@@ -96,10 +96,93 @@ export default class QQManager {
|
||||
}
|
||||
|
||||
public static async passwordLogin (uin: string, passwordMd5: string) {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
needCaptcha?: boolean;
|
||||
proofWaterUrl?: string;
|
||||
needNewDevice?: boolean;
|
||||
jumpUrl?: string;
|
||||
newDevicePullQrCodeSig?: string;
|
||||
} | null>>('/QQLogin/PasswordLogin', {
|
||||
uin,
|
||||
passwordMd5,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async captchaLogin (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
needNewDevice?: boolean;
|
||||
jumpUrl?: string;
|
||||
newDevicePullQrCodeSig?: string;
|
||||
} | null>>('/QQLogin/CaptchaLogin', {
|
||||
uin,
|
||||
passwordMd5,
|
||||
ticket,
|
||||
randstr,
|
||||
sid,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async newDeviceLogin (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
needNewDevice?: boolean;
|
||||
jumpUrl?: string;
|
||||
newDevicePullQrCodeSig?: string;
|
||||
} | null>>('/QQLogin/NewDeviceLogin', {
|
||||
uin,
|
||||
passwordMd5,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async getNewDeviceQRCode (uin: string, jumpUrl: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
str_url?: string;
|
||||
bytes_token?: string;
|
||||
uint32_guarantee_status?: number;
|
||||
ActionStatus?: string;
|
||||
ErrorCode?: number;
|
||||
ErrorInfo?: string;
|
||||
}>>('/QQLogin/GetNewDeviceQRCode', {
|
||||
uin,
|
||||
jumpUrl,
|
||||
});
|
||||
const result = data.data.data;
|
||||
if (result?.str_url) {
|
||||
let bytesToken = result.bytes_token || '';
|
||||
if (!bytesToken && result.str_url) {
|
||||
// 只对 str_url 参数值做 base64 编码
|
||||
try {
|
||||
const urlObj = new URL(result.str_url);
|
||||
const strUrlParam = urlObj.searchParams.get('str_url') || '';
|
||||
bytesToken = strUrlParam ? btoa(strUrlParam) : '';
|
||||
} catch {
|
||||
bytesToken = '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
str_url: result.str_url,
|
||||
bytes_token: bytesToken,
|
||||
uint32_guarantee_status: result.uint32_guarantee_status,
|
||||
ActionStatus: result.ActionStatus,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorInfo: result.ErrorInfo,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async pollNewDeviceQR (uin: string, bytesToken: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
uint32_guarantee_status?: number;
|
||||
str_nt_succ_token?: string;
|
||||
}>>('/QQLogin/PollNewDeviceQR', {
|
||||
uin,
|
||||
bytesToken,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async resetDeviceID () {
|
||||
@@ -178,5 +261,23 @@ export default class QQManager {
|
||||
public static async resetLinuxDeviceID () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetLinuxDeviceID');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NapCat 配置管理
|
||||
// ============================================================
|
||||
|
||||
public static async getNapCatConfig () {
|
||||
const { data } = await serverRequest.get<ServerResponse<NapCatConfig>>(
|
||||
'/NapCatConfig/GetConfig'
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async setNapCatConfig (config: Partial<NapCatConfig>) {
|
||||
await serverRequest.post<ServerResponse<null>>(
|
||||
'/NapCatConfig/SetConfig',
|
||||
config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SwitchCard from '@/components/switch_card';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
interface BypassFormData {
|
||||
hook: boolean;
|
||||
window: boolean;
|
||||
module: boolean;
|
||||
process: boolean;
|
||||
container: boolean;
|
||||
js: boolean;
|
||||
o3HookMode: boolean;
|
||||
}
|
||||
|
||||
|
||||
const BypassConfigCard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
} = useForm<BypassFormData>();
|
||||
|
||||
const loadConfig = async (showTip = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const config = await QQManager.getNapCatConfig();
|
||||
const bypass = config.bypass ?? {} as Partial<BypassOptions>;
|
||||
setValue('hook', bypass.hook ?? false);
|
||||
setValue('window', bypass.window ?? false);
|
||||
setValue('module', bypass.module ?? false);
|
||||
setValue('process', bypass.process ?? false);
|
||||
setValue('container', bypass.container ?? false);
|
||||
setValue('js', bypass.js ?? false);
|
||||
setValue('o3HookMode', config.o3HookMode === 1);
|
||||
if (showTip) toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`获取配置失败: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
const { o3HookMode, ...bypass } = data;
|
||||
await QQManager.setNapCatConfig({ bypass, o3HookMode: o3HookMode ? 1 : 0 });
|
||||
toast.success('保存成功,重启后生效');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onReset = () => {
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
await loadConfig(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>反检测配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-1 mb-2'>
|
||||
<h3 className='text-lg font-semibold text-default-700'>反检测开关配置</h3>
|
||||
<p className='text-sm text-default-500'>
|
||||
控制 Napi2Native 模块的各项反检测功能,修改后需重启生效。
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='hook'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Hook'
|
||||
description='hook特征隐藏'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='window'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Window'
|
||||
description='窗口伪造'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='module'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Module'
|
||||
description='加载模块隐藏'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='process'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Process'
|
||||
description='进程反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='container'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Container'
|
||||
description='容器反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='js'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='JS'
|
||||
description='JS反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='o3HookMode'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='o3HookMode'
|
||||
description='O3 Hook 模式'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={onReset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BypassConfigCard;
|
||||
@@ -14,6 +14,7 @@ import SSLConfigCard from './ssl';
|
||||
import ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
import BackupConfigCard from './backup';
|
||||
import BypassConfigCard from './bypass';
|
||||
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -114,6 +115,11 @@ export default function ConfigPage () {
|
||||
<BackupConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
<Tab title='反检测' key='bypass'>
|
||||
<ConfigPageItem>
|
||||
<BypassConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -131,7 +131,7 @@ const WebUIConfigCard = () => {
|
||||
isLoading={isLoadingOptions}
|
||||
className='w-fit'
|
||||
>
|
||||
{!isLoadingOptions && '📥'}
|
||||
{!isLoadingOptions}
|
||||
准备选项
|
||||
</Button>
|
||||
<Button
|
||||
@@ -225,12 +225,12 @@ const WebUIConfigCard = () => {
|
||||
disabled={!registrationOptions}
|
||||
className='w-fit'
|
||||
>
|
||||
🔐 注册Passkey
|
||||
注册Passkey
|
||||
</Button>
|
||||
</div>
|
||||
{registrationOptions && (
|
||||
<div className='text-xs text-green-600'>
|
||||
✅ 注册选项已准备就绪,可以开始注册
|
||||
注册选项已准备就绪,可以开始注册
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { MdExtension } from 'react-icons/md';
|
||||
@@ -93,14 +93,45 @@ export default function ExtensionPage () {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// 拖拽滚动支持(鼠标 + 触摸)
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const scrollLeft = useRef(0);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isDragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
scrollLeft.current = el.scrollLeft;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.style.cursor = 'grabbing';
|
||||
el.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !scrollRef.current) return;
|
||||
const dx = e.clientX - startX.current;
|
||||
scrollRef.current.scrollLeft = scrollLeft.current - dx;
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !scrollRef.current) return;
|
||||
isDragging.current = false;
|
||||
scrollRef.current.releasePointerCapture(e.pointerId);
|
||||
scrollRef.current.style.cursor = 'grab';
|
||||
scrollRef.current.style.userSelect = '';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>扩展页面 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
|
||||
<PageLoading loading={loading} />
|
||||
|
||||
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex mb-4 items-center gap-4 flex-nowrap min-w-0'>
|
||||
<div className='flex items-center gap-4 shrink-0'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<MdExtension size={24} />
|
||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||
@@ -115,39 +146,49 @@ export default function ExtensionPage () {
|
||||
</Button>
|
||||
</div>
|
||||
{extensionPages.length > 0 && (
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
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',
|
||||
}}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className='overflow-x-auto min-w-0 flex-1 scrollbar-thin scrollbar-thumb-default-300 scrollbar-track-transparent cursor-grab touch-pan-x'
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='w-max min-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-nowrap',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2 whitespace-nowrap'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,30 +61,28 @@ export default function PluginPage () {
|
||||
|
||||
const handleUninstall = async (plugin: PluginItem) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let cleanData = false;
|
||||
dialog.confirm({
|
||||
title: '卸载插件',
|
||||
content: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>确定要卸载插件「{plugin.name}」吗? 此操作不可恢复。</p>
|
||||
<p className="text-small text-default-500">如果插件创建了数据文件,是否一并删除?</p>
|
||||
<p className="text-base text-default-800">确定要卸载插件「<span className="font-semibold text-danger">{plugin.name}</span>」吗? 此操作不可恢复。</p>
|
||||
<div className="mt-2 bg-default-100 dark:bg-default-50/10 p-3 rounded-lg flex flex-col gap-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => { cleanData = e.target.checked; }}
|
||||
className="w-4 h-4 cursor-pointer accent-danger"
|
||||
/>
|
||||
<span className="text-small font-medium text-default-700">同时删除其配置文件</span>
|
||||
</label>
|
||||
<p className="text-xs text-default-500 pl-6 break-all w-full">配置目录: config/plugins/{plugin.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
// This 'dialog' utility might not support returning a value from UI interacting.
|
||||
// We might need to implement a custom confirmation flow if we want a checkbox.
|
||||
// Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"?
|
||||
// Standard dialog usually has Confirm/Cancel.
|
||||
// Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data?
|
||||
// User requested: "Uninstall prompts whether to clean data".
|
||||
// Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough?
|
||||
// I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here).
|
||||
// Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return.
|
||||
// Better: Inside onConfirm, ask again?
|
||||
confirmText: '确定卸载',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
// Ask for data cleanup
|
||||
// Since we are in an async callback, we can use another dialog or confirm.
|
||||
// Native confirm is ugly but works reliably for logic:
|
||||
const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据,点击“取消”仅卸载插件。`);
|
||||
|
||||
const loadingToast = toast.loading('卸载中...');
|
||||
try {
|
||||
await PluginManager.uninstallPlugin(plugin.id, cleanData);
|
||||
@@ -184,6 +182,7 @@ export default function PluginPage () {
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
{/* 禁用插件上传
|
||||
<Button
|
||||
className="bg-primary-100/50 hover:bg-primary-200/50 text-primary-700 backdrop-blur-md"
|
||||
radius='full'
|
||||
@@ -199,6 +198,7 @@ export default function PluginPage () {
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{pluginManagerNotFound ? (
|
||||
|
||||
@@ -374,10 +374,10 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
关闭
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSave} isLoading={saving}>
|
||||
Save
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Input } from '@heroui/input';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
@@ -19,6 +20,16 @@ import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
|
||||
/** Fisher-Yates 洗牌算法,返回新数组 */
|
||||
function shuffleArray<T> (arr: T[]): T[] {
|
||||
const shuffled = [...arr];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
interface EmptySectionProps {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
@@ -86,6 +97,10 @@ export default function PluginStorePage () {
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
|
||||
|
||||
// 分页状态
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -145,6 +160,7 @@ export default function PluginStorePage () {
|
||||
tools: filtered.filter(p => p.tags?.includes('工具')),
|
||||
entertainment: filtered.filter(p => p.tags?.includes('娱乐')),
|
||||
other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))),
|
||||
random: shuffleArray(filtered),
|
||||
};
|
||||
|
||||
return categories;
|
||||
@@ -175,9 +191,30 @@ export default function PluginStorePage () {
|
||||
{ key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 },
|
||||
{ key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 },
|
||||
{ key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 },
|
||||
{ key: 'random', title: '随机', count: categorizedPlugins.random?.length || 0 },
|
||||
];
|
||||
}, [categorizedPlugins]);
|
||||
|
||||
// 当前分类的总数和分页数据
|
||||
const currentCategoryPlugins = useMemo(() => categorizedPlugins[activeTab] || [], [categorizedPlugins, activeTab]);
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(currentCategoryPlugins.length / ITEMS_PER_PAGE)), [currentCategoryPlugins.length]);
|
||||
const paginatedPlugins = useMemo(() => {
|
||||
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return currentCategoryPlugins.slice(start, start + ITEMS_PER_PAGE);
|
||||
}, [currentCategoryPlugins, currentPage]);
|
||||
|
||||
// 切换分类或搜索时重置页码
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 搜索变化时重置页码
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async (plugin: PluginStoreItem) => {
|
||||
// 弹窗选择下载镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
@@ -338,7 +375,7 @@ export default function PluginStorePage () {
|
||||
placeholder='搜索(Ctrl+F)...'
|
||||
startContent={<IoMdSearch className='text-default-400' />}
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
onValueChange={handleSearchChange}
|
||||
className='max-w-xs w-full'
|
||||
size='sm'
|
||||
isClearable
|
||||
@@ -370,7 +407,7 @@ export default function PluginStorePage () {
|
||||
aria-label='Plugin Store Categories'
|
||||
className='max-w-full'
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => setActiveTab(String(key))}
|
||||
onSelectionChange={(key) => handleTabChange(String(key))}
|
||||
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',
|
||||
@@ -395,9 +432,9 @@ export default function PluginStorePage () {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
|
||||
<EmptySection isEmpty={!currentCategoryPlugins.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-4'>
|
||||
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||
{paginatedPlugins.map((plugin) => {
|
||||
const installInfo = getPluginInstallInfo(plugin);
|
||||
return (
|
||||
<PluginStoreCard
|
||||
@@ -414,6 +451,24 @@ export default function PluginStorePage () {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex justify-center mt-6 mb-2'>
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
page={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
showControls
|
||||
showShadow
|
||||
color='primary'
|
||||
size='lg'
|
||||
classNames={{
|
||||
wrapper: 'backdrop-blur-md bg-white/40 dark:bg-black/20',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import QrCodeLogin from '@/components/qr_code_login';
|
||||
import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
@@ -58,6 +59,21 @@ export default function QQLoginPage () {
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('shortcut');
|
||||
const firstLoad = useRef<boolean>(true);
|
||||
const [captchaState, setCaptchaState] = useState<{
|
||||
needCaptcha: boolean;
|
||||
proofWaterUrl: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
const [captchaVerifying, setCaptchaVerifying] = useState(false);
|
||||
const [newDeviceState, setNewDeviceState] = useState<{
|
||||
needNewDevice: boolean;
|
||||
jumpUrl: string;
|
||||
newDevicePullQrCodeSig: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
// newDevicePullQrCodeSig is kept for step:2 login after QR verification
|
||||
const onSubmit = async () => {
|
||||
if (!uinValue) {
|
||||
toast.error('请选择快捷登录的QQ');
|
||||
@@ -83,8 +99,28 @@ export default function QQLoginPage () {
|
||||
try {
|
||||
// 计算密码的MD5值
|
||||
const passwordMd5 = CryptoJS.MD5(password).toString();
|
||||
await QQManager.passwordLogin(uin, passwordMd5);
|
||||
toast.success('密码登录请求已发送');
|
||||
const result = await QQManager.passwordLogin(uin, passwordMd5);
|
||||
if (result?.needCaptcha && result.proofWaterUrl) {
|
||||
// 需要验证码,显示验证码组件
|
||||
setCaptchaState({
|
||||
needCaptcha: true,
|
||||
proofWaterUrl: result.proofWaterUrl,
|
||||
uin,
|
||||
password,
|
||||
});
|
||||
toast('需要安全验证,请完成验证码', { icon: '🔒' });
|
||||
} else if (result?.needNewDevice && result.jumpUrl) {
|
||||
setNewDeviceState({
|
||||
needNewDevice: true,
|
||||
jumpUrl: result.jumpUrl,
|
||||
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
|
||||
uin,
|
||||
password,
|
||||
});
|
||||
toast('检测到新设备,请扫码验证', { icon: '📱' });
|
||||
} else {
|
||||
toast.success('密码登录请求已发送');
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`密码登录失败: ${msg}`);
|
||||
@@ -93,6 +129,75 @@ export default function QQLoginPage () {
|
||||
}
|
||||
};
|
||||
|
||||
const onCaptchaSubmit = async (uin: string, password: string, captchaData: CaptchaCallbackData) => {
|
||||
setIsLoading(true);
|
||||
setCaptchaVerifying(true);
|
||||
try {
|
||||
const passwordMd5 = CryptoJS.MD5(password).toString();
|
||||
const result = await QQManager.captchaLogin(uin, passwordMd5, captchaData.ticket, captchaData.randstr, captchaData.sid);
|
||||
if (result?.needNewDevice && result.jumpUrl) {
|
||||
setCaptchaState(null);
|
||||
setNewDeviceState({
|
||||
needNewDevice: true,
|
||||
jumpUrl: result.jumpUrl,
|
||||
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
|
||||
uin,
|
||||
password,
|
||||
});
|
||||
toast('检测到异常设备,请扫码验证', { icon: '📱' });
|
||||
} else {
|
||||
toast.success('验证码登录请求已发送');
|
||||
setCaptchaState(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`验证码登录失败: ${msg}`);
|
||||
setCaptchaState(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setCaptchaVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCaptchaCancel = () => {
|
||||
setCaptchaState(null);
|
||||
};
|
||||
|
||||
const onNewDeviceVerified = async (token: string) => {
|
||||
if (!newDeviceState) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const passwordMd5 = CryptoJS.MD5(newDeviceState.password).toString();
|
||||
// Use the str_nt_succ_token from QR verification as newDevicePullQrCodeSig for step:2
|
||||
const sig = token || newDeviceState.newDevicePullQrCodeSig;
|
||||
const result = await QQManager.newDeviceLogin(newDeviceState.uin, passwordMd5, sig);
|
||||
if (result?.needNewDevice && result.jumpUrl) {
|
||||
// 新设备验证后又触发了异常设备验证,更新 jumpUrl
|
||||
setNewDeviceState({
|
||||
needNewDevice: true,
|
||||
jumpUrl: result.jumpUrl,
|
||||
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
|
||||
uin: newDeviceState.uin,
|
||||
password: newDeviceState.password,
|
||||
});
|
||||
toast('检测到异常设备,请继续扫码验证', { icon: '📱' });
|
||||
} else {
|
||||
toast.success('新设备验证登录请求已发送');
|
||||
setNewDeviceState(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`新设备验证登录失败: ${msg}`);
|
||||
setNewDeviceState(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onNewDeviceCancel = () => {
|
||||
setNewDeviceState(null);
|
||||
};
|
||||
|
||||
const onUpdateQrCode = async () => {
|
||||
if (firstLoad.current) setIsLoading(true);
|
||||
try {
|
||||
@@ -249,7 +354,14 @@ export default function QQLoginPage () {
|
||||
<PasswordLogin
|
||||
isLoading={isLoading}
|
||||
onSubmit={onPasswordSubmit}
|
||||
onCaptchaSubmit={onCaptchaSubmit}
|
||||
onNewDeviceVerified={onNewDeviceVerified}
|
||||
qqList={qqList}
|
||||
captchaState={captchaState}
|
||||
captchaVerifying={captchaVerifying}
|
||||
newDeviceState={newDeviceState}
|
||||
onCaptchaCancel={onCaptchaCancel}
|
||||
onNewDeviceCancel={onNewDeviceCancel}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
|
||||
19
packages/napcat-webui-frontend/src/types/napcat_conf.d.ts
vendored
Normal file
19
packages/napcat-webui-frontend/src/types/napcat_conf.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
interface BypassOptions {
|
||||
hook: boolean;
|
||||
window: boolean;
|
||||
module: boolean;
|
||||
process: boolean;
|
||||
container: boolean;
|
||||
js: boolean;
|
||||
}
|
||||
|
||||
interface NapCatConfig {
|
||||
fileLog: boolean;
|
||||
consoleLog: boolean;
|
||||
fileLogLevel: string;
|
||||
consoleLogLevel: string;
|
||||
packetBackend: string;
|
||||
packetServer: string;
|
||||
o3HookMode: number;
|
||||
bypass?: BypassOptions;
|
||||
}
|
||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -136,6 +136,9 @@ importers:
|
||||
|
||||
packages/napcat-framework:
|
||||
dependencies:
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
napcat-adapter:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-adapter
|
||||
@@ -173,9 +176,6 @@ importers:
|
||||
ajv:
|
||||
specifier: ^8.13.0
|
||||
version: 8.17.1
|
||||
async-mutex:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
cors:
|
||||
specifier: ^2.8.5
|
||||
version: 2.8.5
|
||||
@@ -357,6 +357,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
napcat-vite:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-vite
|
||||
@@ -1907,89 +1910,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -2740,56 +2759,67 @@ packages:
|
||||
resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.53.2':
|
||||
resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.53.2':
|
||||
resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.53.2':
|
||||
resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.53.2':
|
||||
resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.53.2':
|
||||
resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==}
|
||||
@@ -2864,24 +2894,28 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.1':
|
||||
resolution: {integrity: sha512-fKzP9mRQGbhc5QhJPIsqKNNX/jyWrZgBxmo3Nz1SPaepfCUc7RFmtcJQI5q8xAun3XabXjh90wqcY/OVyg2+Kg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.1':
|
||||
resolution: {integrity: sha512-ZLjMi138uTJxb+1wzo4cB8mIbJbAsSLWRNeHc1g1pMvkERPWOGlem+LEYkkzaFzCNv1J8aKcL653Vtw8INHQeg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.1':
|
||||
resolution: {integrity: sha512-jvSI1IdsIYey5kOITzyajjofXOOySVitmLxb45OPUjoNojql4sDojvlW5zoHXXFePdA6qAX4Y6KbzAOV3T3ctA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.1':
|
||||
resolution: {integrity: sha512-X/FcDtNrDdY9r4FcXHt9QxUqC/2FbQdvZobCKHlHe8vTSKhUHOilWl5EBtkFVfsEs4D5/yAri9e3bJbwyBhhBw==}
|
||||
@@ -3246,41 +3280,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -3518,9 +3560,6 @@ packages:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
async-mutex@0.5.0:
|
||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
@@ -10398,10 +10437,6 @@ snapshots:
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async-mutex@0.5.0:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
Reference in New Issue
Block a user