mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-11 15:30:25 +00:00
Compare commits
45 Commits
feature/re
...
v4.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c24d6b700 | ||
|
|
679c980683 | ||
|
|
19766002ae | ||
|
|
c2d3a8034d | ||
|
|
58220d3fbc | ||
|
|
2daddbb030 | ||
|
|
6ec5bbeddf | ||
|
|
75236dd50c | ||
|
|
01958d47a4 | ||
|
|
772f07c58b | ||
|
|
0f9647bf64 | ||
|
|
8197ebcbcf | ||
|
|
d0519feb4f | ||
|
|
d43c6b10a3 | ||
|
|
857be5ee49 | ||
|
|
af8005dd6f | ||
|
|
6e8adad7ca | ||
|
|
0f8584b8e1 | ||
|
|
37f40a2635 | ||
|
|
1b4d604e32 | ||
|
|
81a0c07922 | ||
|
|
a8cb6b5865 | ||
|
|
d25bd65b2d | ||
|
|
e510a75f0c | ||
|
|
e3c6048a7f | ||
|
|
789c72d4cf | ||
|
|
711a060dd9 | ||
|
|
6268923f01 | ||
|
|
f6b9017429 | ||
|
|
178e51bbb8 | ||
|
|
8a232d8c68 | ||
|
|
7216755430 | ||
|
|
0c91f9c66b | ||
|
|
e8855a59b0 | ||
|
|
5de2664af4 | ||
|
|
5284e0ac5a | ||
|
|
67d6cd3f2e | ||
|
|
0ba5862753 | ||
|
|
d4478275ee | ||
|
|
163bb88751 | ||
|
|
ec6762d916 | ||
|
|
ed1872a349 | ||
|
|
a7fd70ac3a | ||
|
|
7e38f1d227 | ||
|
|
0ca68010a5 |
13
.github/workflows/auto-release.yml
vendored
13
.github/workflows/auto-release.yml
vendored
@@ -80,4 +80,17 @@ jobs:
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||
- name: Trigger Release NapCat AppImage Workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
NAPCAT_VERSION: ${{ env.latest_tag }}
|
||||
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本
|
||||
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本
|
||||
run: |
|
||||
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
pnpm test || exit 1
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:framework
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-framework/dist framework-dist
|
||||
cd framework-dist
|
||||
npm install --omit=dev
|
||||
@@ -83,6 +84,7 @@ jobs:
|
||||
pnpm test || exit 1
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:shell
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-shell/dist shell-dist
|
||||
cd shell-dist
|
||||
npm install --omit=dev
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ permissions: write-all
|
||||
|
||||
env:
|
||||
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||
OPENROUTER_MODEL: "copilot/ant/gemini-3-flash-preview"
|
||||
OPENROUTER_MODEL: "copilot/gemini-3-flash-preview"
|
||||
RELEASE_NAME: "NapCat"
|
||||
|
||||
jobs:
|
||||
@@ -62,6 +62,7 @@ jobs:
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:framework
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-framework/dist framework-dist
|
||||
cd framework-dist
|
||||
npm install --omit=dev
|
||||
@@ -91,6 +92,7 @@ jobs:
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:shell
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-shell/dist shell-dist
|
||||
cd shell-dist
|
||||
npm install --omit=dev
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
||||
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
||||
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
||||
"dev:shell": "pnpm --filter napcat-develop run dev || exit 1",
|
||||
"typecheck": "pnpm -r --if-present run typecheck",
|
||||
"test": "pnpm --filter napcat-test run test",
|
||||
|
||||
@@ -220,13 +220,13 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
||||
// ============== GitHub Tags 获取 ==============
|
||||
// 使用 mirror 模块统一管理镜像
|
||||
|
||||
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
|
||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
|
||||
export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror);
|
||||
}
|
||||
|
||||
|
||||
export async function getLatestTag (): Promise<string> {
|
||||
const { tags } = await getAllTags();
|
||||
export async function getLatestTag (mirror?: string): Promise<string> {
|
||||
const { tags } = await getAllTags(mirror);
|
||||
|
||||
// 使用 SemVer 规范排序
|
||||
tags.sort((a, b) => compareSemVer(a, b));
|
||||
|
||||
@@ -23,66 +23,50 @@ import { PromiseTimer } from './helper';
|
||||
* 懒加载测速:首次使用时自动测速,缓存 30 分钟
|
||||
*/
|
||||
export const GITHUB_FILE_MIRRORS = [
|
||||
// 延迟 < 800ms 的最快镜像
|
||||
'https://github.chenc.dev/', // 666ms
|
||||
'https://ghproxy.cfd/', // 719ms - 支持重定向
|
||||
'https://github.tbedu.top/', // 760ms
|
||||
'https://ghps.cc/', // 768ms
|
||||
'https://gh.llkk.cc/', // 774ms
|
||||
'https://ghproxy.cc/', // 777ms
|
||||
'https://gh.monlor.com/', // 779ms
|
||||
'https://cdn.akaere.online/', // 784ms
|
||||
// 延迟 800-1000ms 的快速镜像
|
||||
'https://gh.idayer.com/', // 869ms
|
||||
'https://gh-proxy.net/', // 885ms
|
||||
'https://ghpxy.hwinzniej.top/', // 890ms
|
||||
'https://github-proxy.memory-echoes.cn/', // 896ms
|
||||
'https://git.yylx.win/', // 917ms
|
||||
'https://gitproxy.mrhjx.cn/', // 950ms
|
||||
'https://jiashu.1win.eu.org/', // 954ms
|
||||
'https://ghproxy.cn/', // 981ms
|
||||
// 延迟 1000-1500ms 的中速镜像
|
||||
'https://gh.fhjhy.top/', // 1014ms
|
||||
'https://gp.zkitefly.eu.org/', // 1015ms
|
||||
'https://gh-proxy.com/', // 1022ms
|
||||
'https://hub.gitmirror.com/', // 1027ms
|
||||
'https://ghfile.geekertao.top/', // 1029ms
|
||||
'https://j.1lin.dpdns.org/', // 1037ms
|
||||
'https://ghproxy.imciel.com/', // 1047ms
|
||||
'https://github-proxy.teach-english.tech/', // 1047ms
|
||||
'https://gh.927223.xyz/', // 1071ms
|
||||
'https://github.ednovas.xyz/', // 1099ms
|
||||
'https://ghf.xn--eqrr82bzpe.top/',// 1122ms
|
||||
'https://gh.dpik.top/', // 1131ms
|
||||
'https://gh.jasonzeng.dev/', // 1139ms
|
||||
'https://gh.xxooo.cf/', // 1157ms
|
||||
'https://gh.bugdey.us.kg/', // 1228ms
|
||||
'https://ghm.078465.xyz/', // 1289ms
|
||||
'https://j.1win.ggff.net/', // 1329ms
|
||||
'https://tvv.tw/', // 1393ms
|
||||
'https://gh.chjina.com/', // 1446ms
|
||||
'https://gitproxy.127731.xyz/', // 1458ms
|
||||
// 延迟 1500-2500ms 的较慢镜像
|
||||
'https://gh.inkchills.cn/', // 1617ms
|
||||
'https://ghproxy.cxkpro.top/', // 1651ms
|
||||
'https://gh.sixyin.com/', // 1686ms
|
||||
'https://github.geekery.cn/', // 1734ms
|
||||
'https://git.669966.xyz/', // 1824ms
|
||||
'https://gh.5050net.cn/', // 1858ms
|
||||
'https://gh.felicity.ac.cn/', // 1903ms
|
||||
'https://gh.ddlc.top/', // 2056ms
|
||||
'https://cf.ghproxy.cc/', // 2058ms
|
||||
'https://gitproxy.click/', // 2068ms
|
||||
'https://github.dpik.top/', // 2313ms
|
||||
'https://gh.zwnes.xyz/', // 2434ms
|
||||
'https://ghp.keleyaa.com/', // 2440ms
|
||||
'https://gh.wsmdn.dpdns.org/', // 2744ms
|
||||
// 延迟 > 2500ms 的慢速镜像(作为备用)
|
||||
'https://ghproxy.monkeyray.net/', // 3023ms
|
||||
'https://fastgit.cc/', // 3369ms
|
||||
'https://cdn.gh-proxy.com/', // 3394ms
|
||||
'https://gh.catmak.name/', // 4119ms
|
||||
'https://gh.noki.icu/', // 5990ms
|
||||
'https://github.chenc.dev/',
|
||||
'https://ghproxy.cfd/',
|
||||
'https://github.tbedu.top/',
|
||||
'https://ghproxy.cc/',
|
||||
'https://gh.monlor.com/',
|
||||
'https://cdn.akaere.online/',
|
||||
'https://gh.idayer.com/',
|
||||
'https://gh.llkk.cc/',
|
||||
'https://ghpxy.hwinzniej.top/',
|
||||
'https://github-proxy.memory-echoes.cn/',
|
||||
'https://git.yylx.win/',
|
||||
'https://gitproxy.mrhjx.cn/',
|
||||
'https://gh.fhjhy.top/',
|
||||
'https://gp.zkitefly.eu.org/',
|
||||
'https://gh-proxy.com/',
|
||||
'https://ghfile.geekertao.top/',
|
||||
'https://j.1lin.dpdns.org/',
|
||||
'https://ghproxy.imciel.com/',
|
||||
'https://github-proxy.teach-english.tech/',
|
||||
'https://gh.927223.xyz/',
|
||||
'https://github.ednovas.xyz/',
|
||||
'https://ghf.xn--eqrr82bzpe.top/',
|
||||
'https://gh.dpik.top/',
|
||||
'https://gh.jasonzeng.dev/',
|
||||
'https://gh.xxooo.cf/',
|
||||
'https://gh.bugdey.us.kg/',
|
||||
'https://ghm.078465.xyz/',
|
||||
'https://j.1win.ggff.net/',
|
||||
'https://tvv.tw/',
|
||||
'https://gitproxy.127731.xyz/',
|
||||
'https://gh.inkchills.cn/',
|
||||
'https://ghproxy.cxkpro.top/',
|
||||
'https://gh.sixyin.com/',
|
||||
'https://github.geekery.cn/',
|
||||
'https://git.669966.xyz/',
|
||||
'https://gh.5050net.cn/',
|
||||
'https://gh.felicity.ac.cn/',
|
||||
'https://github.dpik.top/',
|
||||
'https://ghp.keleyaa.com/',
|
||||
'https://gh.wsmdn.dpdns.org/',
|
||||
'https://ghproxy.monkeyray.net/',
|
||||
'https://fastgit.cc/',
|
||||
'https://gh.catmak.name/',
|
||||
'https://gh.noki.icu/',
|
||||
'', // 原始 URL(无镜像)
|
||||
];
|
||||
|
||||
@@ -109,7 +93,6 @@ export const GITHUB_RAW_MIRRORS = [
|
||||
// 测试确认支持 raw 文件的镜像
|
||||
'https://github.chenc.dev/https://raw.githubusercontent.com',
|
||||
'https://ghproxy.cfd/https://raw.githubusercontent.com',
|
||||
'https://gh.llkk.cc/https://raw.githubusercontent.com',
|
||||
'https://ghproxy.cc/https://raw.githubusercontent.com',
|
||||
'https://gh-proxy.net/https://raw.githubusercontent.com',
|
||||
];
|
||||
@@ -150,7 +133,7 @@ const defaultConfig: MirrorConfig = {
|
||||
apiMirrors: GITHUB_API_MIRRORS,
|
||||
rawMirrors: GITHUB_RAW_MIRRORS,
|
||||
nightlyLinkMirrors: NIGHTLY_LINK_MIRRORS,
|
||||
timeout: 10000, // 10秒超时,平衡速度和可靠性
|
||||
timeout: 5000, // 5秒超时,平衡速度和可靠性
|
||||
enabled: true,
|
||||
customMirror: undefined,
|
||||
};
|
||||
@@ -274,7 +257,7 @@ export async function getFastMirrors (forceRefresh: boolean = false): Promise<st
|
||||
async function performMirrorTest (): Promise<string[]> {
|
||||
// 开始镜像测速
|
||||
|
||||
const timeout = 8000; // 测速超时 8 秒
|
||||
const timeout = 3000; // 测速超时 3 秒
|
||||
|
||||
// 并行测试所有镜像
|
||||
const mirrors = currentConfig.fileMirrors.filter(m => m);
|
||||
@@ -585,7 +568,7 @@ export async function findAvailableDownloadUrl (
|
||||
let testedCount = 0;
|
||||
for (const mirror of mirrors) {
|
||||
if (!mirror) continue; // 跳过空字符串
|
||||
|
||||
|
||||
// 特殊处理 nightly.link URL
|
||||
let mirrorUrl: string;
|
||||
if (originalUrl.includes('nightly.link')) {
|
||||
@@ -594,7 +577,7 @@ export async function findAvailableDownloadUrl (
|
||||
} else {
|
||||
mirrorUrl = buildMirrorUrl(originalUrl, mirror);
|
||||
}
|
||||
|
||||
|
||||
testedCount++;
|
||||
if (await testWithValidation(mirrorUrl)) {
|
||||
return mirrorUrl;
|
||||
@@ -650,8 +633,15 @@ function compareSemVerSimple (a: string, b: string): number {
|
||||
* 从 tags 列表中获取最新的 release tag
|
||||
* 不依赖 GitHub API
|
||||
*/
|
||||
export async function getLatestReleaseTag (owner: string, repo: string): Promise<string> {
|
||||
const result = await getAllGitHubTags(owner, repo);
|
||||
// Update definitions validation locally first if possible.
|
||||
// I'll assume valid typescript.
|
||||
// I will split this into two tool calls to avoid complexity.
|
||||
// 1. Update mirror.ts
|
||||
// 2. Update UpdateNapCat.ts
|
||||
|
||||
// This tool call: Update mirror.ts
|
||||
export async function getLatestReleaseTag (owner: string, repo: string, mirror?: string): Promise<string> {
|
||||
const result = await getAllGitHubTags(owner, repo, mirror);
|
||||
|
||||
// 过滤出符合 semver 的 tags
|
||||
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
|
||||
@@ -701,6 +691,8 @@ export async function getGitHubRelease (
|
||||
assetNames?: string[];
|
||||
/** 是否需要获取 changelog(需要调用 API) */
|
||||
fetchChangelog?: boolean;
|
||||
/** 指定镜像 */
|
||||
mirror?: string;
|
||||
} = {}
|
||||
): Promise<{
|
||||
tag_name: string;
|
||||
@@ -710,15 +702,16 @@ export async function getGitHubRelease (
|
||||
}>;
|
||||
body?: string;
|
||||
}> {
|
||||
const { assetNames = [], fetchChangelog = false } = options;
|
||||
const { assetNames = [], fetchChangelog = false, mirror } = options;
|
||||
|
||||
// 1. 获取实际的 tag 名称
|
||||
let actualTag: string;
|
||||
if (tag === 'latest') {
|
||||
actualTag = await getLatestReleaseTag(owner, repo);
|
||||
actualTag = await getLatestReleaseTag(owner, repo, mirror);
|
||||
} else {
|
||||
actualTag = tag;
|
||||
}
|
||||
// ...
|
||||
|
||||
// 2. 构建 assets 列表(不需要 API)
|
||||
const assets = assetNames.map(name => ({
|
||||
@@ -782,8 +775,8 @@ const tagsCache: Map<string, TagsCache> = new Map();
|
||||
* 获取所有 GitHub tags(带缓存)
|
||||
* 优化:并行请求多个镜像,使用第一个成功返回的结果
|
||||
*/
|
||||
export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
const cacheKey = `${owner}/${repo}`;
|
||||
export async function getAllGitHubTags (owner: string, repo: string, mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
const cacheKey = `${owner}/${repo}/${mirror || 'auto'}`;
|
||||
|
||||
// 检查缓存
|
||||
const cached = tagsCache.get(cacheKey);
|
||||
@@ -805,7 +798,7 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
|
||||
};
|
||||
|
||||
// 尝试从 URL 获取 tags
|
||||
const fetchFromUrl = async (url: string, mirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
||||
const fetchFromUrl = async (url: string, usedMirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
||||
try {
|
||||
const raw = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
@@ -813,79 +806,55 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
|
||||
);
|
||||
|
||||
// 检查返回内容是否有效(不是 HTML 错误页面)
|
||||
if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
|
||||
return null;
|
||||
if (raw.includes('refs/tags')) {
|
||||
return { tags: parseTags(raw), mirror: usedMirror };
|
||||
}
|
||||
|
||||
const tags = parseTags(raw);
|
||||
if (tags.length > 0) {
|
||||
return { tags, mirror };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
// 忽略错误
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取快速镜像列表
|
||||
let fastMirrors: string[] = [];
|
||||
try {
|
||||
fastMirrors = await getFastMirrors();
|
||||
} catch {
|
||||
// 忽略错误
|
||||
// 准备镜像列表
|
||||
let mirrors: string[] = [];
|
||||
if (mirror) {
|
||||
// 如果指定了镜像,只使用该镜像
|
||||
mirrors = [mirror];
|
||||
} else {
|
||||
// 否则使用 auto 逻辑,利用缓存的快速镜像列表
|
||||
mirrors = await getFastMirrors();
|
||||
}
|
||||
|
||||
// 构建 URL 列表(取前 5 个快速镜像 + 原始 URL 并行请求)
|
||||
const topMirrors = fastMirrors.slice(0, 5);
|
||||
const mirrorUrls = [
|
||||
{ url: baseUrl, mirror: 'github.com' }, // 原始 URL
|
||||
...topMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })),
|
||||
];
|
||||
// 并行请求
|
||||
const promises = mirrors.map(m => {
|
||||
const url = m ? buildMirrorUrl(baseUrl, m) : baseUrl;
|
||||
return fetchFromUrl(url, m || 'https://github.com');
|
||||
});
|
||||
|
||||
// 并行请求所有镜像,使用 Promise.any 获取第一个成功的结果
|
||||
try {
|
||||
const result = await Promise.any(
|
||||
mirrorUrls.map(async ({ url, mirror }) => {
|
||||
const res = await fetchFromUrl(url, mirror);
|
||||
if (res) return res;
|
||||
throw new Error('Failed');
|
||||
})
|
||||
);
|
||||
|
||||
// 缓存结果
|
||||
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
|
||||
return result;
|
||||
} catch {
|
||||
// Promise.any 全部失败,回退到顺序尝试剩余镜像
|
||||
}
|
||||
|
||||
// 回退:顺序尝试剩余镜像
|
||||
const remainingMirrors = fastMirrors.slice(5).filter(m => m);
|
||||
for (const mirror of remainingMirrors) {
|
||||
const url = buildMirrorUrl(baseUrl, mirror);
|
||||
const result = await fetchFromUrl(url, mirror);
|
||||
const result = await Promise.any(promises.filter(p => p !== null) as Promise<{ tags: string[], mirror: string; } | null>[]);
|
||||
if (result) {
|
||||
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
|
||||
tagsCache.set(cacheKey, {
|
||||
tags: result.tags,
|
||||
mirror: result.mirror,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// all failed
|
||||
}
|
||||
|
||||
// 最后尝试所有镜像
|
||||
const allMirrors = currentConfig.fileMirrors.filter(m => m && !fastMirrors.includes(m));
|
||||
for (const mirror of allMirrors) {
|
||||
const url = buildMirrorUrl(baseUrl, mirror);
|
||||
const result = await fetchFromUrl(url, mirror);
|
||||
if (result) {
|
||||
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
|
||||
return result;
|
||||
}
|
||||
if (mirror) {
|
||||
throw new Error(`指定镜像 ${mirror} 获取 tags 失败`);
|
||||
}
|
||||
|
||||
throw new Error('无法获取 tags,所有源都不可用');
|
||||
throw new Error('无法获取 tags,所有镜像源都不可用');
|
||||
}
|
||||
|
||||
// ============== Action Artifacts 支持 ==============
|
||||
|
||||
// ActionArtifact 接口定义
|
||||
export interface ActionArtifact {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -895,12 +864,14 @@ export interface ActionArtifact {
|
||||
archive_download_url: string;
|
||||
workflow_run_id?: number;
|
||||
head_sha?: string;
|
||||
workflow_title?: string;
|
||||
}
|
||||
|
||||
// ============== Action Artifacts 缓存 ==============
|
||||
|
||||
interface ArtifactsCache {
|
||||
artifacts: ActionArtifact[];
|
||||
mirror: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -920,68 +891,116 @@ export function clearArtifactsCache (): void {
|
||||
* 当 api.github.com 不可用时使用
|
||||
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
|
||||
*/
|
||||
|
||||
async function getWorkflowRunsFromHtml (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
maxRuns: number = 10
|
||||
): Promise<Array<{ id: number; created_at: string; }>> {
|
||||
maxRuns: number = 10,
|
||||
mirror?: string
|
||||
): Promise<{ runs: Array<{ id: number; created_at: string; title: string; }>; mirror: string; }> {
|
||||
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
|
||||
|
||||
|
||||
// 尝试使用镜像获取 HTML
|
||||
const mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
||||
|
||||
for (const mirror of mirrors) {
|
||||
// 如果指定了 mirror,则只使用该 mirror
|
||||
let mirrors: string[] = [];
|
||||
if (mirror) {
|
||||
mirrors = [mirror];
|
||||
} else {
|
||||
// 使用缓存的快速镜像列表
|
||||
mirrors = await getFastMirrors();
|
||||
}
|
||||
|
||||
for (const mirrorItem of mirrors) {
|
||||
try {
|
||||
const url = mirror ? buildMirrorUrl(baseUrl, mirror) : baseUrl;
|
||||
|
||||
const html = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
10000
|
||||
);
|
||||
|
||||
// 从 HTML 中提取 run IDs 和时间
|
||||
// 格式: href="/NapNeko/NapCatQQ/actions/runs/20676123968"
|
||||
// 时间格式: <relative-time datetime="2026-01-03T10:37:29Z"
|
||||
const runPattern = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"`, 'gi');
|
||||
const timePattern = /<relative-time\s+datetime="([^"]+)"/gi;
|
||||
|
||||
// 提取所有时间
|
||||
const times: string[] = [];
|
||||
let timeMatch;
|
||||
while ((timeMatch = timePattern.exec(html)) !== null) {
|
||||
times.push(timeMatch[1]);
|
||||
}
|
||||
|
||||
const runs: Array<{ id: number; created_at: string; }> = [];
|
||||
const allRuns: Array<{ id: number; created_at: string; title: string; }> = [];
|
||||
const foundIds = new Set<number>();
|
||||
let timeIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = runPattern.exec(html)) !== null && runs.length < maxRuns) {
|
||||
const id = parseInt(match[1]);
|
||||
if (!foundIds.has(id)) {
|
||||
foundIds.add(id);
|
||||
// 尝试获取对应的时间,每个 run 通常有两个时间(桌面和移动端显示)
|
||||
// 所以每找到一个 run,跳过两个时间
|
||||
const created_at = times[timeIndex] || new Date().toISOString();
|
||||
timeIndex += 2; // 跳过两个时间(桌面端和移动端各一个)
|
||||
runs.push({
|
||||
id,
|
||||
created_at,
|
||||
});
|
||||
let page = 1;
|
||||
const maxPages = 10; // 防止无限请求,最多翻10页(约250个条目)
|
||||
|
||||
while (allRuns.length < maxRuns && page <= maxPages) {
|
||||
const pageUrl = page > 1 ? `${baseUrl}?page=${page}` : baseUrl;
|
||||
const url = mirrorItem ? buildMirrorUrl(pageUrl, mirrorItem) : pageUrl;
|
||||
|
||||
const html = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
10000
|
||||
);
|
||||
|
||||
// 使用 Block 分割策略,更稳健地关联 ID 和时间
|
||||
const rows = html.split('<div class="Box-row');
|
||||
let foundOnThisPage = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
// 提取 Run ID 和 Status
|
||||
// <a href="/NapNeko/NapCatQQ/actions/runs/20799940346" ... aria-label="completed successfully: ...">
|
||||
const runMatch = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"[^>]*aria-label="([^"]*)"`, 'i').exec(row);
|
||||
|
||||
if (!runMatch || !runMatch[1] || !runMatch[2]) continue;
|
||||
|
||||
const id = parseInt(runMatch[1]);
|
||||
const ariaLabel = runMatch[2];
|
||||
const ariaLabelLower = ariaLabel.toLowerCase();
|
||||
|
||||
// 只需要判断 completed
|
||||
if (ariaLabelLower.includes('completed')) {
|
||||
if (!foundIds.has(id)) {
|
||||
// 提取时间 (取 Block 内的第一个 relative-time)
|
||||
const timeMatch = /<relative-time\s+datetime="([^"]+)"/.exec(row);
|
||||
if (timeMatch && timeMatch[1]) {
|
||||
foundIds.add(id);
|
||||
foundOnThisPage++;
|
||||
|
||||
// 优先从 markdown-title class 提取标题
|
||||
let title = '';
|
||||
const titleMatch = /class="[^"]*markdown-title[^"]*"[^>]*>([\s\S]*?)<\/span>/i.exec(row);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// 如果没找到,回退到 aria-label 逻辑
|
||||
if (!title) {
|
||||
title = ariaLabel;
|
||||
const prefixMatch = /^(completed successfully:\s*)/i.exec(title);
|
||||
if (prefixMatch) {
|
||||
title = title.substring(prefixMatch[0].length);
|
||||
}
|
||||
}
|
||||
|
||||
allRuns.push({
|
||||
id,
|
||||
created_at: timeMatch[1],
|
||||
title: title.trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果本页没有找到任何 completed 的 run(但页面可能不为空),或者页面内容太少(可能是最后一页或错误)
|
||||
// 这里简化判断: 如果本页没提取到任何有效数据,就认为没有更多数据了
|
||||
if (foundOnThisPage === 0) {
|
||||
// 也要考虑到可能是页面解析失败或者全是 failed 状态
|
||||
// 检查是否有翻页按钮可能更复杂,暂时假设如果一整页都没有 successful run,可能后面也没有了,或者我们已经获取够多了
|
||||
// 为了稳健,如果本页没找到,且 allRuns 还没满,尝试下一页 (除非页面很小说明是空页)
|
||||
if (rows.length < 2) { // 只有 split 的第一个空元素
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 分页逻辑:总是尝试下一页,直到满足 maxRuns
|
||||
page++;
|
||||
}
|
||||
|
||||
if (runs.length > 0) {
|
||||
return runs;
|
||||
|
||||
if (allRuns.length > 0) {
|
||||
return { runs: allRuns, mirror: mirrorItem || 'https://github.com' };
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
return { runs: [], mirror: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -996,122 +1015,49 @@ async function getArtifactsFromNightlyLink (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
branch: string = 'main',
|
||||
maxRuns: number = 10
|
||||
): Promise<ActionArtifact[]> {
|
||||
let workflowRuns: Array<{ id: number; head_sha?: string; created_at: string; }> = [];
|
||||
|
||||
// 策略1: 优先尝试 GitHub API
|
||||
_branch: string = 'main',
|
||||
maxRuns: number = 10,
|
||||
mirror?: string
|
||||
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||||
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||||
try {
|
||||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
|
||||
|
||||
const runsResponse = await PromiseTimer(
|
||||
RequestUtil.HttpGetJson<{
|
||||
workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>;
|
||||
}>(endpoint, 'GET', undefined, {
|
||||
'User-Agent': 'NapCat',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}),
|
||||
10000
|
||||
);
|
||||
// 以前尝试使用 GitHub API,现在弃用,完全使用 HTML 解析逻辑
|
||||
// 并获取 workflow // 直接从 HTML 页面解析
|
||||
const { runs: workflowRuns, mirror: runsMirror } = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns, mirror);
|
||||
|
||||
if (runsResponse.workflow_runs && runsResponse.workflow_runs.length > 0) {
|
||||
workflowRuns = runsResponse.workflow_runs;
|
||||
if (workflowRuns.length === 0) {
|
||||
return { artifacts: [], mirror: runsMirror };
|
||||
}
|
||||
} catch {
|
||||
// API 请求失败,继续尝试 HTML 解析
|
||||
}
|
||||
|
||||
// 策略2: API 失败时,从 HTML 页面解析
|
||||
if (workflowRuns.length === 0) {
|
||||
workflowRuns = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns);
|
||||
}
|
||||
// 直接拼接 nightly.link URL
|
||||
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||||
const artifacts: ActionArtifact[] = [];
|
||||
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
|
||||
|
||||
if (workflowRuns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// 如果 HTML 解析使用的 mirror 是 github.com(空),则 nightly.link 使用默认配置
|
||||
// 如果使用了镜像,可能需要特殊的 nightly.link 镜像,或者这里仅记录 HTML 来源镜像
|
||||
// 实际上 nightly.link 本身就是一个服务,我们使用配置中的 nightlyLinkMirrors
|
||||
const baseNightlyMirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
|
||||
|
||||
// 直接拼接 nightly.link URL
|
||||
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||||
const artifacts: ActionArtifact[] = [];
|
||||
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
|
||||
|
||||
for (const run of workflowRuns) {
|
||||
for (const artifactName of artifactNames) {
|
||||
const mirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
|
||||
artifacts.push({
|
||||
id: run.id,
|
||||
name: artifactName,
|
||||
size_in_bytes: 0,
|
||||
created_at: run.created_at,
|
||||
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
archive_download_url: `${mirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
|
||||
workflow_run_id: run.id,
|
||||
head_sha: run.head_sha,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 GitHub API 获取 artifacts(主要方案)
|
||||
*/
|
||||
async function getArtifactsFromAPI (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
branch: string = 'main',
|
||||
maxRuns: number = 10
|
||||
): Promise<ActionArtifact[]> {
|
||||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
|
||||
|
||||
const runsResponse = await PromiseTimer(
|
||||
RequestUtil.HttpGetJson<{
|
||||
workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>;
|
||||
}>(endpoint, 'GET', undefined, {
|
||||
'User-Agent': 'NapCat',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}),
|
||||
10000
|
||||
);
|
||||
|
||||
const workflowRuns = runsResponse.workflow_runs;
|
||||
if (!workflowRuns || workflowRuns.length === 0) {
|
||||
throw new Error('No successful workflow runs found');
|
||||
}
|
||||
|
||||
// 获取所有 runs 的 artifacts
|
||||
const allArtifacts: ActionArtifact[] = [];
|
||||
|
||||
for (const run of workflowRuns) {
|
||||
try {
|
||||
const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${run.id}/artifacts`;
|
||||
const artifactsResponse = await PromiseTimer(
|
||||
RequestUtil.HttpGetJson<{
|
||||
artifacts: ActionArtifact[];
|
||||
}>(artifactsEndpoint, 'GET', undefined, {
|
||||
'User-Agent': 'NapCat',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}),
|
||||
10000
|
||||
);
|
||||
|
||||
if (artifactsResponse.artifacts) {
|
||||
// 为每个 artifact 添加 run 信息
|
||||
for (const artifact of artifactsResponse.artifacts) {
|
||||
artifact.workflow_run_id = run.id;
|
||||
artifact.head_sha = run.head_sha;
|
||||
allArtifacts.push(artifact);
|
||||
}
|
||||
for (const run of workflowRuns) {
|
||||
for (const artifactName of artifactNames) {
|
||||
artifacts.push({
|
||||
id: run.id,
|
||||
name: artifactName,
|
||||
size_in_bytes: 0,
|
||||
created_at: run.created_at,
|
||||
expires_at: new Date(new Date(run.created_at).getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
archive_download_url: `${baseNightlyMirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
|
||||
workflow_run_id: run.id,
|
||||
workflow_title: run.title,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 单个 run 获取失败,继续下一个
|
||||
}
|
||||
}
|
||||
return { artifacts, mirror: runsMirror };
|
||||
|
||||
return allArtifacts;
|
||||
} catch {
|
||||
return { artifacts: [], mirror: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1121,48 +1067,41 @@ async function getArtifactsFromAPI (
|
||||
* 策略:
|
||||
* 1. 检查缓存(10分钟有效)
|
||||
* 2. 优先尝试从 nightly.link 获取(无需认证,更稳定)
|
||||
* 3. 如果失败,回退到 GitHub API
|
||||
* 3. 这里的实现已经完全移除了对 GitHub API 的依赖,直接解析 HTML
|
||||
*/
|
||||
export async function getLatestActionArtifacts (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
branch: string = 'main',
|
||||
maxRuns: number = 10
|
||||
): Promise<ActionArtifact[]> {
|
||||
const cacheKey = `${owner}/${repo}/${workflow}/${branch}`;
|
||||
|
||||
maxRuns: number = 10,
|
||||
mirror?: string
|
||||
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||||
const cacheKey = `${owner}/${repo}/${workflow}/${branch}/${mirror || 'auto'}`;
|
||||
|
||||
// 检查缓存
|
||||
const cached = artifactsCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
|
||||
return cached.artifacts;
|
||||
return { artifacts: cached.artifacts, mirror: cached.mirror };
|
||||
}
|
||||
|
||||
let artifacts: ActionArtifact[] = [];
|
||||
let result: { artifacts: ActionArtifact[], mirror: string; } = { artifacts: [], mirror: '' };
|
||||
|
||||
// 策略1: 优先使用 nightly.link(更稳定,无需认证)
|
||||
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||||
try {
|
||||
artifacts = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns);
|
||||
result = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns, mirror);
|
||||
} catch {
|
||||
// nightly.link 获取失败
|
||||
}
|
||||
|
||||
// 策略2: 回退到 GitHub API
|
||||
if (artifacts.length === 0) {
|
||||
try {
|
||||
artifacts = await getArtifactsFromAPI(owner, repo, workflow, branch, maxRuns);
|
||||
} catch {
|
||||
// API 获取失败
|
||||
}
|
||||
// 获取失败
|
||||
}
|
||||
|
||||
// 缓存结果(即使为空也缓存,避免频繁请求)
|
||||
if (artifacts.length > 0) {
|
||||
if (result.artifacts.length > 0) {
|
||||
artifactsCache.set(cacheKey, {
|
||||
artifacts,
|
||||
artifacts: result.artifacts,
|
||||
mirror: result.mirror,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ export interface IStatusHelperSubscription {
|
||||
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
emit (event: 'statusUpdate', status: SystemStatus): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,11 @@ export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
|
||||
const a = parseSemVer(v1);
|
||||
const b = parseSemVer(v2);
|
||||
|
||||
if (!a.valid || !b.valid) {
|
||||
return 0;
|
||||
if (!a.valid && !b.valid) {
|
||||
return v1.localeCompare(v2) as -1 | 0 | 1;
|
||||
}
|
||||
if (!a.valid) return -1;
|
||||
if (!b.valid) return 1;
|
||||
|
||||
// 比较主版本号
|
||||
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
|
||||
|
||||
264
packages/napcat-core/apis/flash.ts
Normal file
264
packages/napcat-core/apis/flash.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { GeneralCallResult, InstanceContext, NapCatCore } from '@/napcat-core';
|
||||
import {
|
||||
createFlashTransferResult,
|
||||
FileListResponse,
|
||||
FlashFileSetInfo,
|
||||
SendStatus,
|
||||
} from '@/napcat-core/data/flash';
|
||||
import { Peer } from '@/napcat-core/types';
|
||||
|
||||
export class NTQQFlashApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
|
||||
constructor (context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起闪传上传任务
|
||||
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
|
||||
*/
|
||||
async createFlashTransferUploadTask (fileListToUpload: string[]): Promise < GeneralCallResult & {
|
||||
createFlashTransferResult: createFlashTransferResult;
|
||||
seq: number;
|
||||
} > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const timestamp : number = Date.now();
|
||||
const selfInfo = this.core.selfInfo;
|
||||
|
||||
const fileUploadArg = {
|
||||
screen: 1, // 1
|
||||
uploaders: [{
|
||||
uin: selfInfo.uin,
|
||||
uid: selfInfo.uid,
|
||||
sendEntrance: '',
|
||||
nickname: selfInfo.nick,
|
||||
}],
|
||||
paths: fileListToUpload,
|
||||
};
|
||||
|
||||
const uploadResult = await flashService.createFlashTransferUploadTask(timestamp, fileUploadArg);
|
||||
if (uploadResult.result === 0) {
|
||||
this.context.logger.log('[Flash] 发起闪传任务成功');
|
||||
return uploadResult;
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 发起闪传上传任务失败!!');
|
||||
return uploadResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载闪传文件集
|
||||
* @param fileSetId
|
||||
*/
|
||||
async downloadFileSetBySetId (fileSetId: string): Promise < GeneralCallResult & {
|
||||
extraInfo: unknown
|
||||
} > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const result = await flashService.startFileSetDownload(fileSetId, 1, { isIncludeCompressInnerFiles: false }); // 为了方便,暂时硬编码
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 成功开始下载文件集');
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 尝试下载文件集失败!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取闪传的外链分享
|
||||
* @param fileSetId
|
||||
*/
|
||||
async getShareLinkBySetId (fileSetId: string): Promise < GeneralCallResult & {
|
||||
shareLink: string;
|
||||
expireTimestamp: string;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const result = await flashService.getShareLinkReq(fileSetId);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取闪传外链分享成功:', result.shareLink);
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 获取闪传外链失败!!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分享外链获取文件集id
|
||||
* @param shareCode
|
||||
*/
|
||||
async fromShareLinkFindSetId (shareCode: string): Promise < GeneralCallResult & {
|
||||
fileSetId: string;
|
||||
} > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const result = await flashService.getFileSetIdByCode(shareCode);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取shareCode的文件集Id成功!');
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 获取文件集ID失败!!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取fileSet的文件结构信息 (未来可能需要深度遍历)
|
||||
* == 注意返回结构和其它的不同,没有GeneralCallResult!!! ==
|
||||
* @param fileSetId
|
||||
*/
|
||||
async getFileListBySetId (fileSetId: string): Promise < FileListResponse > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const requestArg = {
|
||||
seq: 0,
|
||||
fileSetId,
|
||||
isUseCache: false,
|
||||
sceneType: 1, // 硬编码
|
||||
reqInfos: [
|
||||
{
|
||||
count: 18, // 18 ??
|
||||
paginationInfo: {},
|
||||
parentId: '',
|
||||
reqIndexPath: '',
|
||||
reqDepth: 1,
|
||||
filterCondition: {
|
||||
fileCategory: 0,
|
||||
filterType: 0,
|
||||
},
|
||||
sortConditions: [
|
||||
{
|
||||
sortField: 0,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
isNeedPhysicalInfoReady: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await flashService.getFileList(requestArg);
|
||||
if (result.rsp.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取fileSet文件信息成功!');
|
||||
return result.rsp;
|
||||
} else {
|
||||
this.context.logger.logError(`[Flash] 获取文件信息失败:ErrMsg: ${result.rsp.errMs}`);
|
||||
return result.rsp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取闪传文件集合信息
|
||||
* @param fileSetId
|
||||
*/
|
||||
async getFileSetIndoBySetId (fileSetId: string): Promise < GeneralCallResult & {
|
||||
seq: number;
|
||||
isCache: boolean;
|
||||
fileSet: FlashFileSetInfo;
|
||||
} > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const requestArg = {
|
||||
fileSetId,
|
||||
};
|
||||
|
||||
const result = await flashService.getFileSet(requestArg);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取闪传文件集信息成功!');
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 获取闪传文件信息失败!!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送闪传消息(私聊/群聊)
|
||||
* @param fileSetId
|
||||
* @param peer
|
||||
*/
|
||||
async sendFlashMessage (fileSetId: string, peer:Peer): Promise < {
|
||||
errCode: number,
|
||||
errMsg: string,
|
||||
rsp: {
|
||||
sendStatus: SendStatus[]
|
||||
}
|
||||
} > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const target = {
|
||||
destUid: peer.peerUid,
|
||||
destType: peer.chatType,
|
||||
// destUin: peer.peerUin,
|
||||
};
|
||||
|
||||
const requestsArg = {
|
||||
fileSetId,
|
||||
targets: [target],
|
||||
};
|
||||
|
||||
const result = await flashService.sendFlashTransferMsg(requestsArg);
|
||||
if (result.errCode === 0) {
|
||||
this.context.logger.log('[Flash] 消息发送成功');
|
||||
} else {
|
||||
this.context.logger.logError(`[Flash] 消息发送失败!!原因:${result.errMsg}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取闪传文件集中某个文件的下载URL(外链)
|
||||
* @param fileSetId
|
||||
* @param options
|
||||
*/
|
||||
async getFileTransUrl (fileSetId: string, options: { fileName?: string; fileIndex?: number }): Promise < GeneralCallResult & {
|
||||
transferUrl: string;
|
||||
} > {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
const result = await this.getFileListBySetId(fileSetId);
|
||||
|
||||
const { fileName, fileIndex } = options;
|
||||
|
||||
let targetFile: any;
|
||||
let file: any;
|
||||
|
||||
const allFolder = result.fileLists;
|
||||
|
||||
// eslint-disable-next-line no-labels
|
||||
searchLoop: for (const folder of allFolder) {
|
||||
const fileList = folder.fileList;
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
file = fileList[i];
|
||||
|
||||
if (fileName !== undefined && file.name === fileName) {
|
||||
targetFile = file;
|
||||
// eslint-disable-next-line no-labels
|
||||
break searchLoop;
|
||||
}
|
||||
|
||||
if (fileIndex !== undefined && i === fileIndex) {
|
||||
targetFile = file;
|
||||
// eslint-disable-next-line no-labels
|
||||
break searchLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetFile === undefined) {
|
||||
this.context.logger.logError('[Flash] 未找到对应文件!!');
|
||||
return {
|
||||
result: -1,
|
||||
errMsg: '未找到对应文件',
|
||||
transferUrl: '',
|
||||
};
|
||||
} else {
|
||||
this.context.logger.log('[Flash] 找到对应文件,准备尝试获取传输链接');
|
||||
const res = await flashService.startFileTransferUrl(targetFile);
|
||||
return {
|
||||
result: 0,
|
||||
errMsg: '',
|
||||
transferUrl: res.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,5 @@ export * from './webapi';
|
||||
export * from './system';
|
||||
export * from './packet';
|
||||
export * from './file';
|
||||
export * from './online';
|
||||
export * from './flash';
|
||||
|
||||
@@ -32,9 +32,9 @@ export class NTQQMsgApi {
|
||||
return this.context.session.getMsgService().getSourceOfReplyMsgV2(peer, clientSeq, time);
|
||||
}
|
||||
|
||||
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, count: number = 20) {
|
||||
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string = '', count: number = 20) {
|
||||
// 注意此处emojiType 可选值一般为1-2 2好像是unicode表情dec值 大部分情况 Taged Mlikiowa
|
||||
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, '', false, count);
|
||||
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, cookie, false, count);
|
||||
}
|
||||
|
||||
async setEmojiLike (peer: Peer, msgSeq: string, emojiId: string, set: boolean = true) {
|
||||
|
||||
240
packages/napcat-core/apis/online.ts
Normal file
240
packages/napcat-core/apis/online.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { InstanceContext, NapCatCore } from '@/napcat-core';
|
||||
import { Peer } from '@/napcat-core/types';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GeneralCallResultStatus } from '@/napcat-core/services/common';
|
||||
import { sleep } from '@/napcat-common/src/helper';
|
||||
|
||||
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
|
||||
|
||||
export class NTQQOnlineApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
|
||||
constructor (context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这里不等待node返回,因为the fuck wrapper.node 根本不返回(会卡死不知道为什么)!!! 只能手动查询判断死活
|
||||
* @param peer
|
||||
* @param filePath
|
||||
* @param fileName
|
||||
*/
|
||||
async sendOnlineFile (peer: Peer, filePath: string, fileName: string): Promise<any> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`[NapCat] 文件不存在: ${filePath}`);
|
||||
}
|
||||
const actualFileName = fileName || path.basename(filePath);
|
||||
const fileSize = fs.statSync(filePath).size.toString();
|
||||
|
||||
const fileElementToSend = [{
|
||||
elementType: 23,
|
||||
elementId: '',
|
||||
fileElement: {
|
||||
fileName: actualFileName,
|
||||
filePath,
|
||||
fileSize,
|
||||
},
|
||||
}];
|
||||
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const startTime = Math.floor(Date.now() / 1000) - 2; // 容错时间窗口
|
||||
|
||||
msgService.sendMsg('0', peer, fileElementToSend, new Map()).catch((_e: any) => {
|
||||
});
|
||||
|
||||
const maxRetries = 10;
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
await sleep(1000);
|
||||
retryCount++;
|
||||
|
||||
try {
|
||||
const msgListResult = await msgService.getOnlineFileMsgs(peer);
|
||||
|
||||
const msgs = msgListResult?.msgList || [];
|
||||
|
||||
const foundMsg = msgs.find((msg: any) => {
|
||||
if (parseInt(msg.msgTime) < startTime) return false;
|
||||
|
||||
const validElement = msg.elements.find((el: any) => {
|
||||
if (el.elementType !== 23 || !el.fileElement) return false;
|
||||
|
||||
const isNameMatch = el.fileElement.fileName === actualFileName;
|
||||
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(filePath);
|
||||
|
||||
return isNameMatch && isPathMatch;
|
||||
});
|
||||
|
||||
return !!validElement;
|
||||
});
|
||||
|
||||
if (foundMsg) {
|
||||
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 23);
|
||||
this.context.logger.log('[OnlineFile] 在线文件发送成功!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.OK,
|
||||
errMsg: '',
|
||||
msgId: foundMsg.msgId,
|
||||
elementId: targetElement?.elementId || '',
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
}
|
||||
}
|
||||
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.ERROR,
|
||||
errMsg: '[NapCat] Send Online File Timeout: Message not found in history.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送在线文件夹
|
||||
* @param peer
|
||||
* @param folderPath
|
||||
* @param folderName
|
||||
*/
|
||||
async sendOnlineFolder (peer: Peer, folderPath: string, folderName?: string): Promise<any> {
|
||||
const actualFolderName = folderName || path.basename(folderPath);
|
||||
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
return { result: GeneralCallResultStatus.ERROR, errMsg: `Folder not found: ${folderPath}` };
|
||||
}
|
||||
|
||||
if (!fs.statSync(folderPath).isDirectory()) {
|
||||
return { result: GeneralCallResultStatus.ERROR, errMsg: `Path is not a directory: ${folderPath}` };
|
||||
}
|
||||
const folderElementItem = {
|
||||
elementType: 30,
|
||||
elementId: '',
|
||||
fileElement: {
|
||||
fileName: actualFolderName,
|
||||
filePath: folderPath,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const startTime = Math.floor(Date.now() / 1000) - 2;
|
||||
msgService.sendMsg('0', peer, [folderElementItem], new Map()).catch((_e: any) => {
|
||||
|
||||
});
|
||||
|
||||
const maxRetries = 10;
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
await sleep(1000);
|
||||
retryCount++;
|
||||
|
||||
try {
|
||||
const msgListResult = await msgService.getOnlineFileMsgs(peer);
|
||||
const msgs = msgListResult?.msgList || [];
|
||||
|
||||
const foundMsg = msgs.find((msg: any) => {
|
||||
if (parseInt(msg.msgTime) < startTime) return false;
|
||||
|
||||
const validElement = msg.elements.find((el: any) => {
|
||||
if (el.elementType !== 30 || !el.fileElement) return false;
|
||||
|
||||
const isNameMatch = el.fileElement.fileName === actualFolderName;
|
||||
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(folderPath);
|
||||
|
||||
return isNameMatch && isPathMatch;
|
||||
});
|
||||
return !!validElement;
|
||||
});
|
||||
|
||||
if (foundMsg) {
|
||||
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 30);
|
||||
this.context.logger.log('[OnlineFile] 在线文件夹发送成功!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.OK,
|
||||
errMsg: '',
|
||||
msgId: foundMsg.msgId,
|
||||
elementId: targetElement?.elementId || '',
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
|
||||
}
|
||||
}
|
||||
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.ERROR,
|
||||
errMsg: '[NapCat] Send Online Folder Timeout: Message not found in history.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友的在线文件消息
|
||||
* @param peer
|
||||
*/
|
||||
async getOnlineFileMsg (peer: Peer) : Promise<any> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
return await msgService.getOnlineFileMsgs(peer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消在线文件的发送
|
||||
* @param peer
|
||||
* @param msgId
|
||||
*/
|
||||
async cancelMyOnlineFileMsg (peer: Peer, msgId: string) : Promise<void> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
await msgService.cancelSendMsg(peer, msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝接收在线文件
|
||||
* @param peer
|
||||
* @param msgId
|
||||
* @param elementId
|
||||
*/
|
||||
async refuseOnlineFileMsg (peer: Peer, msgId: string, elementId: string) : Promise<void> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const arrToSend = {
|
||||
msgId,
|
||||
peerUid: peer.peerUid,
|
||||
chatType: 1,
|
||||
elementId,
|
||||
downloadType: 1,
|
||||
downSourceType: 1,
|
||||
};
|
||||
|
||||
await msgService.refuseGetRichMediaElement(arrToSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收在线文件/文件夹
|
||||
* @param peer
|
||||
* @param msgId
|
||||
* @param elementId
|
||||
* @constructor
|
||||
*/
|
||||
async receiveOnlineFileOrFolder (peer: Peer, msgId: string, elementId: string) : Promise<any> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const arrToSend = {
|
||||
msgId,
|
||||
peerUid: peer.peerUid,
|
||||
chatType: 1,
|
||||
elementId,
|
||||
downSourceType: 1,
|
||||
downloadType: 1,
|
||||
};
|
||||
return await msgService.getRichMediaElement(arrToSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线文件/文件夹转离线
|
||||
* @param peer
|
||||
* @param msgId
|
||||
*/
|
||||
async switchFileToOffline (peer: Peer, msgId: string) : Promise<void> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
await msgService.switchToOfflineSendMsg(peer, msgId);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,17 @@ import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
import { qunAlbumControl } from '../data/webapi';
|
||||
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
|
||||
export interface SetNoticeRetSuccess {
|
||||
ec: number;
|
||||
em: string;
|
||||
id: number;
|
||||
ltsm: number;
|
||||
new_fid: string;
|
||||
read_only: number;
|
||||
role: number;
|
||||
srv_code: number;
|
||||
}
|
||||
|
||||
export class NTQQWebApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
@@ -25,12 +36,12 @@ export class NTQQWebApi {
|
||||
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
group_code: groupCode,
|
||||
msg_seq: msgSeq,
|
||||
msg_random: msgRandom,
|
||||
target_group_code: targetGroupCode,
|
||||
}).toString()}`;
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
group_code: groupCode,
|
||||
msg_seq: msgSeq,
|
||||
msg_random: msgRandom,
|
||||
target_group_code: targetGroupCode,
|
||||
}).toString()}`;
|
||||
try {
|
||||
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
|
||||
} catch {
|
||||
@@ -52,11 +63,11 @@ export class NTQQWebApi {
|
||||
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
page_start: page_start.toString(),
|
||||
page_limit: page_limit.toString(),
|
||||
group_code: GroupCode,
|
||||
}).toString()}`;
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
page_start: page_start.toString(),
|
||||
page_limit: page_limit.toString(),
|
||||
group_code: GroupCode,
|
||||
}).toString()}`;
|
||||
try {
|
||||
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
|
||||
url,
|
||||
@@ -76,16 +87,16 @@ export class NTQQWebApi {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const retList: Promise<WebApiGroupMemberRet>[] = [];
|
||||
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: '0',
|
||||
end: '40',
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: '0',
|
||||
end: '40',
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
|
||||
return [];
|
||||
@@ -101,16 +112,16 @@ export class NTQQWebApi {
|
||||
// 遍历批量请求
|
||||
for (let i = 2; i <= PageNum; i++) {
|
||||
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: ((i - 1) * 40).toString(),
|
||||
end: (i * 40).toString(),
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: ((i - 1) * 40).toString(),
|
||||
end: (i * 40).toString(),
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
retList.push(ret);
|
||||
}
|
||||
@@ -153,16 +164,7 @@ export class NTQQWebApi {
|
||||
imgWidth: number = 540,
|
||||
imgHeight: number = 300
|
||||
) {
|
||||
interface SetNoticeRetSuccess {
|
||||
ec: number;
|
||||
em: string;
|
||||
id: number;
|
||||
ltsm: number;
|
||||
new_fid: string;
|
||||
read_only: number;
|
||||
role: number;
|
||||
srv_code: number;
|
||||
}
|
||||
|
||||
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
|
||||
@@ -178,18 +180,18 @@ export class NTQQWebApi {
|
||||
imgHeight: imgHeight.toString(),
|
||||
};
|
||||
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
text: Content,
|
||||
pinned: pinned.toString(),
|
||||
type: type.toString(),
|
||||
settings,
|
||||
...(picId === '' ? {} : externalParam),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
text: Content,
|
||||
pinned: pinned.toString(),
|
||||
type: type.toString(),
|
||||
settings,
|
||||
...(picId === '' ? {} : externalParam),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret;
|
||||
} catch {
|
||||
@@ -201,20 +203,20 @@ export class NTQQWebApi {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
try {
|
||||
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
ft: '23',
|
||||
ni: '1',
|
||||
n: '1',
|
||||
i: '1',
|
||||
log_read: '1',
|
||||
platform: '1',
|
||||
s: '-1',
|
||||
}).toString()}&n=20`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
ft: '23',
|
||||
ni: '1',
|
||||
n: '1',
|
||||
i: '1',
|
||||
log_read: '1',
|
||||
platform: '1',
|
||||
s: '-1',
|
||||
}).toString()}&n=20`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret?.ec === 0 ? ret : undefined;
|
||||
} catch {
|
||||
@@ -222,17 +224,17 @@ export class NTQQWebApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async getDataInternal (cookieObject: { [key: string]: string }, groupCode: string, type: number) {
|
||||
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
|
||||
let resJson;
|
||||
try {
|
||||
const res = await RequestUtil.HttpGetText(
|
||||
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
|
||||
gc: groupCode,
|
||||
type: type.toString(),
|
||||
}).toString()}`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
|
||||
gc: groupCode,
|
||||
type: type.toString(),
|
||||
}).toString()}`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
|
||||
if (match?.[1]) {
|
||||
@@ -245,7 +247,7 @@ export class NTQQWebApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async getHonorList (cookieObject: { [key: string]: string }, groupCode: string, type: number) {
|
||||
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
|
||||
const data = await this.getDataInternal(cookieObject, groupCode, type);
|
||||
if (!data) {
|
||||
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
|
||||
@@ -304,11 +306,11 @@ export class NTQQWebApi {
|
||||
return HonorInfo;
|
||||
}
|
||||
|
||||
private cookieToString (cookieObject: { [key: string]: string }) {
|
||||
private cookieToString (cookieObject: { [key: string]: string; }) {
|
||||
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
public getBknFromCookie (cookieObject: { [key: string]: string }) {
|
||||
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
|
||||
const sKey = cookieObject['skey'] as string;
|
||||
|
||||
let hash = 5381;
|
||||
@@ -361,7 +363,7 @@ export class NTQQWebApi {
|
||||
uin,
|
||||
getMemberRole: '0',
|
||||
});
|
||||
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string }> } }>(api + params.toString(), 'GET', '', {
|
||||
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
|
||||
Cookie: cookies,
|
||||
});
|
||||
return response.data.album;
|
||||
@@ -384,7 +386,7 @@ export class NTQQWebApi {
|
||||
sAlbumID,
|
||||
});
|
||||
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
|
||||
const post = await RequestUtil.HttpGetJson<{ data: { session: string }, ret: number, msg: string }>(api, 'POST', body, {
|
||||
const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
@@ -430,7 +432,7 @@ export class NTQQWebApi {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const post = await response.json() as { ret: number, msg: string }; if (post.ret !== 0) {
|
||||
const post = await response.json() as { ret: number, msg: string; }; if (post.ret !== 0) {
|
||||
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
|
||||
}
|
||||
offset += chunk.length;
|
||||
@@ -475,10 +477,10 @@ export class NTQQWebApi {
|
||||
const client_key = Date.now() * 1000;
|
||||
return await this.context.session.getAlbumService().doQunComment(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
},
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
},
|
||||
qunId,
|
||||
2,
|
||||
createAlbumMediaFeed(uin, albumId, lloc),
|
||||
@@ -509,13 +511,13 @@ export class NTQQWebApi {
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
return await this.context.session.getAlbumService().doQunLike(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
}, {
|
||||
id,
|
||||
status: 1,
|
||||
},
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
}, {
|
||||
id,
|
||||
status: 1,
|
||||
},
|
||||
createAlbumFeedPublish(qunId, uin, albumId, lloc)
|
||||
);
|
||||
}
|
||||
|
||||
324
packages/napcat-core/data/flash.ts
Normal file
324
packages/napcat-core/data/flash.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
export interface FlashBaseRequest {
|
||||
fileSetId: string
|
||||
}
|
||||
|
||||
export interface UploaderInfo {
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string, // ""
|
||||
}
|
||||
|
||||
export interface thumbnailInfo {
|
||||
id: string,
|
||||
url: {
|
||||
spec: number,
|
||||
uri: string,
|
||||
}[],
|
||||
localCachePath: string,
|
||||
}
|
||||
|
||||
export interface SendTarget {
|
||||
destType: number // 1私聊
|
||||
destUin?: string,
|
||||
destUid: string,
|
||||
}
|
||||
|
||||
export interface SendTargetRequests {
|
||||
fileSetId: string
|
||||
targets: SendTarget[]
|
||||
}
|
||||
|
||||
export interface DownloadStatusInfo {
|
||||
result: number; // 0
|
||||
fileSetId: string;
|
||||
status: number;
|
||||
info: {
|
||||
curDownLoadFailFileNum: number,
|
||||
curDownLoadedPauseFileNum: number,
|
||||
curDownLoadedFileNum: number,
|
||||
curRealDownLoadedFileNum: number,
|
||||
curDownloadingFileNum: number,
|
||||
totalDownLoadedFileNum: number,
|
||||
curDownLoadedBytes: string, // "0"
|
||||
totalDownLoadedBytes: string,
|
||||
curSpeedBps: number,
|
||||
avgSpeedBps: number,
|
||||
maxSpeedBps: number,
|
||||
remainDownLoadSeconds: number,
|
||||
failFileIdList: [],
|
||||
allFileIdList: [],
|
||||
hasNormalFileDownloading: boolean,
|
||||
onlyCompressInnerFileDownloading: boolean,
|
||||
isAllFileAlreadyDownloaded: boolean,
|
||||
saveFileSetDir: string,
|
||||
allWaitingStatusTask: boolean,
|
||||
downloadSceneType: number,
|
||||
retryCount: number,
|
||||
statisticInfo: {
|
||||
downloadTaskId: string,
|
||||
downloadFilesetName: string,
|
||||
downloadFileTypeDistribution: string,
|
||||
downloadFileSizeDistribution: string
|
||||
},
|
||||
albumStorageFailImageNum: number,
|
||||
albumStorageFailVideoNum: number,
|
||||
albumStorageFailFileIdList: [],
|
||||
albumStorageSucImageNum: number,
|
||||
albumStorageSucVideoNum: number,
|
||||
albumStorageSucFileIdList: [],
|
||||
albumStorageFileNum: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface physicalInfo {
|
||||
id: string,
|
||||
url: string,
|
||||
status: number, // 2 已下载
|
||||
processing: string,
|
||||
localPath: string,
|
||||
width: 0,
|
||||
height: 0,
|
||||
time: number,
|
||||
}
|
||||
|
||||
export interface downloadInfo {
|
||||
status: number,
|
||||
curDownLoadBytes: string,
|
||||
totalFileBytes: string,
|
||||
errorCode: number,
|
||||
}
|
||||
|
||||
export interface uploadInfo {
|
||||
uploadedBytes: string,
|
||||
errorCode: number,
|
||||
svrRrrCode: number,
|
||||
errMsg: string,
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
thumbnailUploadState: number
|
||||
isSecondHit: boolean,
|
||||
hasModifiedErr: boolean,
|
||||
}
|
||||
|
||||
export interface folderUploadInfo {
|
||||
totalUploadedFileSize: string
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export interface folderDownloadInfo {
|
||||
totalDownloadedFileSize: string
|
||||
totalFileSize: string
|
||||
totalDownloadFileCount: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
pausedCount: number
|
||||
cancelCount: number
|
||||
downloadingCount: number
|
||||
partialDownloadCount: number
|
||||
curLevelDownloadedFileCount: number
|
||||
curLevelUnDownloadedFileCount: number
|
||||
}
|
||||
|
||||
export interface compressFileFolderInfo {
|
||||
downloadStatus: number
|
||||
saveFileDirPath: string
|
||||
totalFileCount: string
|
||||
totalFileSize: string
|
||||
}
|
||||
|
||||
export interface albumStorgeInfo {
|
||||
status: number
|
||||
localIdentifier: string
|
||||
errorCode: number
|
||||
timeCost: number
|
||||
}
|
||||
|
||||
export interface FlashOneFileInfo {
|
||||
fileSetId: string
|
||||
cliFileId: string // client?? 或许可以换取url
|
||||
compressedFileFolderId: string
|
||||
archiveIndex: 0
|
||||
indexPath: string
|
||||
isDir: boolean // 文件或者文件夹!!
|
||||
parentId: string
|
||||
depth: number // 1
|
||||
cliFileIndex: number
|
||||
fileType: number // 枚举!! 已完成枚举!!
|
||||
name: string
|
||||
namePinyin: string
|
||||
isCover: boolean
|
||||
isCoverOriginal: boolean
|
||||
fileSize: string
|
||||
fileCount: number
|
||||
thumbnail: thumbnailInfo
|
||||
physical: physicalInfo
|
||||
srvFileId: string // service?? 服务器上面的id吗?
|
||||
srvParentFileId: string
|
||||
svrLastUpdateTimestamp: string
|
||||
downloadInfo: downloadInfo
|
||||
saveFilePath: string
|
||||
search_relative_path: string
|
||||
disk_relative_path: string
|
||||
uploadInfo: uploadInfo
|
||||
status: number
|
||||
uploadStatus: number // 3已上传成功
|
||||
downloadStatus: number // 0未下载
|
||||
folderUploadInfo: folderUploadInfo
|
||||
folderDownloadInfo: folderDownloadInfo
|
||||
sha1: string
|
||||
bookmark: string
|
||||
compressFileFolderInfo: compressFileFolderInfo
|
||||
uploadPauseReason: string
|
||||
downloadPauseReason: string
|
||||
filePhysicalSize: string
|
||||
thumbnail_sha1: string | null
|
||||
thumbnail_size: string | null
|
||||
needAlbumStorage: boolean
|
||||
albumStorageInfo: albumStorgeInfo
|
||||
}
|
||||
|
||||
export interface fileListsInfo {
|
||||
parentId: string,
|
||||
depth: number, // 1
|
||||
fileList: FlashOneFileInfo[],
|
||||
paginationInfo: {}
|
||||
isEnd: boolean,
|
||||
isCache: boolean,
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
seq: number,
|
||||
result: number,
|
||||
errMs: string,
|
||||
fileLists: fileListsInfo[],
|
||||
}
|
||||
|
||||
export interface createFlashTransferResult {
|
||||
fileSetId: string,
|
||||
shareLink: string,
|
||||
expireTime: string,
|
||||
expireLeftTime: string,
|
||||
}
|
||||
|
||||
export interface StartFlashTaskRequests {
|
||||
screen?: number; // 1 PC-QQ
|
||||
uploaders: UploaderInfo[];
|
||||
permission?: {};
|
||||
coverPath?: string;
|
||||
paths: string[]; // 文件的绝对路径,可以是文件夹
|
||||
// excludePaths: [];
|
||||
// expireLeftTime: 0,
|
||||
// isNeedDelDeviceInfo: boolean,
|
||||
// isNeedDelLocation: boolean,
|
||||
// coverOriginalInfos: [],
|
||||
// uploadSceneType: 10, // 不知道怎么枚举 先硬编码吧
|
||||
// detectPrivacyInfoResult: {
|
||||
// exists: boolean,
|
||||
// allDetectResults: {}
|
||||
// }
|
||||
}
|
||||
|
||||
export interface FileListInfoRequests {
|
||||
seq: number, // 0
|
||||
fileSetId: string,
|
||||
isUseCache: boolean,
|
||||
sceneType: number, // 1
|
||||
reqInfos: {
|
||||
count: number, // 18 ?? 硬编码吧 不懂
|
||||
paginationInfo: {},
|
||||
parentId: string,
|
||||
reqIndexPath: string,
|
||||
reqDepth: number, // 1
|
||||
filterCondition: {
|
||||
fileCategory: number,
|
||||
filterType: number,
|
||||
}, // 0
|
||||
sortConditions: {
|
||||
sortField: number,
|
||||
sortOrder: number,
|
||||
}[],
|
||||
isNeedPhysicalInfoReady: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface FlashFileSetInfo {
|
||||
fileSetId: string,
|
||||
name: string,
|
||||
namePinyin: string,
|
||||
totalFileCount: number,
|
||||
totalFileSize: number,
|
||||
permission: {},
|
||||
shareInfo: {
|
||||
shareLink: string,
|
||||
extractionCode: string,
|
||||
},
|
||||
cover: {
|
||||
id: string,
|
||||
urls: [
|
||||
{
|
||||
spec: number, // 2
|
||||
url: string
|
||||
}
|
||||
],
|
||||
localCachePath: string
|
||||
},
|
||||
uploaders: [
|
||||
{
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string
|
||||
}
|
||||
],
|
||||
expireLeftTime: number,
|
||||
aiClusteringStatus: {
|
||||
firstClusteringList: [],
|
||||
shouldPull: boolean
|
||||
},
|
||||
createTime: number,
|
||||
expireTime: number,
|
||||
firstLevelItemCount: 1,
|
||||
svrLastUpdateTimestamp: 0,
|
||||
taskId: string, // 同 fileSetId
|
||||
uploadInfo: {
|
||||
totalUploadedFileSize: number,
|
||||
successCount: number,
|
||||
failedCount: number
|
||||
},
|
||||
downloadInfo: {
|
||||
totalDownloadedFileSize: 0,
|
||||
totalFileSize: 0,
|
||||
totalDownloadFileCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
cancelCount: 0,
|
||||
status: 0,
|
||||
curLevelDownloadedFileCount: number,
|
||||
curLevelUnDownloadedFileCount: 0
|
||||
},
|
||||
transferType: number,
|
||||
isLocalCreate: true,
|
||||
status: number, // todo 枚举全部状态
|
||||
uploadStatus: number, // todo 同上
|
||||
uploadPauseReason: 0,
|
||||
downloadStatus: 0,
|
||||
downloadPauseReason: 0,
|
||||
saveFileSetDir: string,
|
||||
uploadSceneType: 10,
|
||||
downloadSceneType: 0, // 0 PC-QQ 103 web
|
||||
retryCount: number,
|
||||
isMergeShareUpload: 0,
|
||||
isRemoveDeviceInfo: boolean,
|
||||
isRemoveLocation: boolean
|
||||
}
|
||||
|
||||
export interface SendStatus {
|
||||
result: number,
|
||||
msg: string,
|
||||
target: {
|
||||
destType: number,
|
||||
destUid: string,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
||||
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/napcat-core/index';
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
@@ -263,7 +264,13 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
|
||||
}
|
||||
|
||||
if (element.fileElement) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
if (element.fileElement.fileUuid) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.TOFURECORD) {
|
||||
return `[在线文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.ONLINEFOLDER) {
|
||||
return `[在线文件夹 ${element.fileElement.fileName}/]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.videoElement) {
|
||||
@@ -287,7 +294,11 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
|
||||
}
|
||||
|
||||
if (element.markdownElement) {
|
||||
return '[Markdown 消息]';
|
||||
if (element.markdownElement?.mdSummary) {
|
||||
return element.markdownElement.mdSummary;
|
||||
} else {
|
||||
return '[Markdown 消息]';
|
||||
}
|
||||
}
|
||||
|
||||
if (element.multiForwardMsgElement) {
|
||||
@@ -296,6 +307,8 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
|
||||
|
||||
if (element.elementType === ElementType.GreyTip) {
|
||||
return '[灰条消息]';
|
||||
} else if (element.elementType === ElementType.FILE) {
|
||||
return '[文件发送中]';
|
||||
}
|
||||
|
||||
return `[未实现 (ElementType = ${element.elementType})]`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppidTable from '@/napcat-core/external/appid.json';
|
||||
import { LogWrapper } from './log';
|
||||
import { getMajorPath } from '@/napcat-core/index';
|
||||
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'napcat-common/src/types';
|
||||
import path from 'node:path';
|
||||
|
||||
export class QQBasicInfoWrapper {
|
||||
QQMainPath: string | undefined;
|
||||
@@ -21,6 +22,10 @@ export class QQBasicInfoWrapper {
|
||||
// 基础目录获取
|
||||
this.context = context;
|
||||
this.QQMainPath = process.execPath;
|
||||
if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') {
|
||||
// 实用进程特殊处理 实用进程目录和QQ差远了
|
||||
this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ');
|
||||
}
|
||||
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||
|
||||
// 基础信息获取 无快更则启用默认模板填充
|
||||
@@ -99,7 +104,10 @@ export class QQBasicInfoWrapper {
|
||||
}
|
||||
|
||||
getAppidV2ByMajor (QQVersion: string) {
|
||||
const majorPath = getMajorPath(QQVersion);
|
||||
if (!this.QQMainPath) {
|
||||
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
||||
}
|
||||
const majorPath = getMajorPath(QQVersion, this.QQMainPath);
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
NTQQSystemApi,
|
||||
NTQQUserApi,
|
||||
NTQQWebApi,
|
||||
NTQQFlashApi,
|
||||
NTQQOnlineApi,
|
||||
} from '@/napcat-core/apis';
|
||||
import { NTQQCollectionApi } from '@/napcat-core/apis/collection';
|
||||
import {
|
||||
@@ -23,7 +25,7 @@ import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
|
||||
import { NTEventWrapper } from '@/napcat-core/helper/event';
|
||||
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
import os from 'node:os';
|
||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/napcat-core/listeners';
|
||||
@@ -44,20 +46,23 @@ export enum NapCatCoreWorkingEnv {
|
||||
Framework = 2,
|
||||
}
|
||||
|
||||
export function loadQQWrapper (QQVersion: string): WrapperNodeApi {
|
||||
export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi {
|
||||
if (process.env['NAPCAT_WRAPPER_PATH']) {
|
||||
const wrapperPath = process.env['NAPCAT_WRAPPER_PATH'];
|
||||
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
|
||||
process.dlopen(nativemodule, wrapperPath);
|
||||
return nativemodule.exports;
|
||||
}
|
||||
if (!execPath) {
|
||||
throw new Error('无法加载Wrapper,execPath未定义');
|
||||
}
|
||||
let appPath;
|
||||
if (os.platform() === 'darwin') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), '../Resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
|
||||
} else if (os.platform() === 'linux') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), './resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), './resources/app');
|
||||
} else {
|
||||
appPath = path.resolve(path.dirname(process.execPath), `./versions/${QQVersion}/`);
|
||||
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
|
||||
}
|
||||
let wrapperNodePath = path.resolve(appPath, 'wrapper.node');
|
||||
if (!fs.existsSync(wrapperNodePath)) {
|
||||
@@ -65,21 +70,21 @@ export function loadQQWrapper (QQVersion: string): WrapperNodeApi {
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(wrapperNodePath)) {
|
||||
wrapperNodePath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
|
||||
wrapperNodePath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
|
||||
}
|
||||
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
|
||||
process.dlopen(nativemodule, wrapperNodePath);
|
||||
return nativemodule.exports;
|
||||
}
|
||||
export function getMajorPath (QQVersion: string): string {
|
||||
export function getMajorPath (execPath: string, QQVersion: string): string {
|
||||
// major.node
|
||||
let appPath;
|
||||
if (os.platform() === 'darwin') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), '../Resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
|
||||
} else if (os.platform() === 'linux') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), './resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), './resources/app');
|
||||
} else {
|
||||
appPath = path.resolve(path.dirname(process.execPath), `./versions/${QQVersion}/`);
|
||||
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
|
||||
}
|
||||
let majorPath = path.resolve(appPath, 'major.node');
|
||||
if (!fs.existsSync(majorPath)) {
|
||||
@@ -87,7 +92,7 @@ export function getMajorPath (QQVersion: string): string {
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(majorPath)) {
|
||||
majorPath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/major.node`);
|
||||
majorPath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/major.node`);
|
||||
}
|
||||
return majorPath;
|
||||
}
|
||||
@@ -120,12 +125,14 @@ export class NapCatCore {
|
||||
MsgApi: new NTQQMsgApi(this.context, this),
|
||||
UserApi: new NTQQUserApi(this.context, this),
|
||||
GroupApi: new NTQQGroupApi(this.context, this),
|
||||
FlashApi: new NTQQFlashApi(this.context, this),
|
||||
OnlineApi: new NTQQOnlineApi(this.context, this),
|
||||
};
|
||||
container.bind(NapCatCore).toConstantValue(this);
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
//console.log(`Registering service handler for: ${serviceName}`);
|
||||
// console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
@@ -175,10 +182,17 @@ export class NapCatCore {
|
||||
async initNapCatCoreListeners () {
|
||||
const msgListener = new NodeIKernelMsgListener();
|
||||
|
||||
// 在线文件/文件夹消息
|
||||
msgListener.onRecvOnlineFileMsg = (msgs: RawMessage[]) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
};
|
||||
|
||||
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
|
||||
// 下线通知
|
||||
this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
|
||||
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
|
||||
this.context.logger.logError(tips);
|
||||
this.selfInfo.online = false;
|
||||
this.event.emit('KickedOffLine', tips);
|
||||
};
|
||||
msgListener.onRecvMsg = (msgs) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
@@ -292,4 +306,6 @@ export interface StableNTApiWrapper {
|
||||
MsgApi: NTQQMsgApi,
|
||||
UserApi: NTQQUserApi,
|
||||
GroupApi: NTQQGroupApi;
|
||||
FlashApi: NTQQFlashApi,
|
||||
OnlineApi: NTQQOnlineApi,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TypedEventEmitter } from './typeEvent';
|
||||
|
||||
export interface AppEvents {
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number };
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
|
||||
KickedOffLine: string;
|
||||
}
|
||||
export const appEvent = new TypedEventEmitter<AppEvents>();
|
||||
|
||||
302
packages/napcat-core/services/NodeIKernelFlashTransferService.ts
Normal file
302
packages/napcat-core/services/NodeIKernelFlashTransferService.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { GeneralCallResult } from './common';
|
||||
import {
|
||||
SendStatus,
|
||||
StartFlashTaskRequests,
|
||||
createFlashTransferResult,
|
||||
FlashBaseRequest,
|
||||
FlashFileSetInfo,
|
||||
FileListInfoRequests,
|
||||
FileListResponse,
|
||||
DownloadStatusInfo,
|
||||
SendTargetRequests,
|
||||
FlashOneFileInfo,
|
||||
} from '../data/flash';
|
||||
|
||||
export interface NodeIKernelFlashTransferService {
|
||||
/**
|
||||
* 开始闪传服务 并上传文件/文件夹(可以多选,非常好用)
|
||||
* @param timestamp
|
||||
* @param fileInfo
|
||||
*/
|
||||
createFlashTransferUploadTask(timestamp: number, fileInfo: StartFlashTaskRequests): Promise < GeneralCallResult & {
|
||||
createFlashTransferResult: createFlashTransferResult;
|
||||
seq: number;
|
||||
} >; // 2 arg 重点 // 自动上传
|
||||
|
||||
createMergeShareTask(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
updateFlashTransfer(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
getFileSetList(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getFileSetListCount(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 获取file set 的信息
|
||||
* @param fileSetIdDict
|
||||
*/
|
||||
getFileSet(fileSetIdDict: FlashBaseRequest): Promise < GeneralCallResult & {
|
||||
seq: number;
|
||||
isCache: boolean;
|
||||
fileSet: FlashFileSetInfo;
|
||||
} >; // 1 arg
|
||||
|
||||
/**
|
||||
* 获取file set 里面的文件信息(文件夹结构)
|
||||
* @param requestArgs
|
||||
*/
|
||||
getFileList(requestArgs: FileListInfoRequests): Promise < {
|
||||
rsp: FileListResponse;
|
||||
} > ; // 1 arg 这个方法QQ有bug??? 并没有,是我参数有问题
|
||||
|
||||
getDownloadedFileCount(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getLocalFileList(...args: unknown[]): unknown; // 3 arg
|
||||
|
||||
batchRemoveUserFileSetHistory(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 获取分享链接
|
||||
* @param fileSetId
|
||||
*/
|
||||
getShareLinkReq(fileSetId:string): Promise< GeneralCallResult & {
|
||||
shareLink: string;
|
||||
expireTimestamp: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 由分享链接到fileSetId
|
||||
* @param shareCode
|
||||
*/
|
||||
getFileSetIdByCode(shareCode: string): Promise < GeneralCallResult & {
|
||||
fileSetId: string;
|
||||
} > ; // 1 arg code == share code
|
||||
|
||||
batchRemoveFile(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
checkUploadPathValid(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
cleanFailedFiles(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
/**
|
||||
* 暂停所有的任务
|
||||
*/
|
||||
resumeAllUnfinishedTasks(): unknown; // 0 arg !!
|
||||
|
||||
addFileSetUploadListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetUploadListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 开始上传任务 适用于已暂停的
|
||||
* @param fileSetId
|
||||
*/
|
||||
startFileSetUpload(fileSetId: string): void; // 1 arg 并不是新建任务,应该是暂停后的启动
|
||||
|
||||
/**
|
||||
* 结束,无法再次启动
|
||||
* @param fileSetId
|
||||
*/
|
||||
stopFileSetUpload(fileSetId: string): void; // 1 arg stop 后start无效
|
||||
|
||||
/**
|
||||
* 暂停上传
|
||||
* @param fileSetId
|
||||
*/
|
||||
pauseFileSetUpload(fileSetId: string): void; // 1 arg 暂停上传
|
||||
|
||||
/**
|
||||
* 继续上传
|
||||
* @param args
|
||||
*/
|
||||
resumeFileSetUpload(...args: unknown[]): unknown; // 1 arg 继续
|
||||
|
||||
pauseFileUpload(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
resumeFileUpload(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
stopFileUpload(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
asyncGetThumbnailPath(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
setDownLoadDefaultFileDir(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
setFileSetDownloadDir(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
getFileSetDownloadDir(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
setFlashTransferDir(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
addFileSetDownloadListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetDownloadListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 开始下载file set的函数 同开始上传
|
||||
* @param fileSetId
|
||||
* @param chatType 聊天类型 //因为没有peer,其实可以硬编码为1 (好友私聊)
|
||||
* @param arg // 默认为false
|
||||
*/
|
||||
startFileSetDownload(fileSetId:string, chatType: number, arg: { isIncludeCompressInnerFiles: boolean }): Promise < GeneralCallResult & {
|
||||
extraInfo: 0
|
||||
} >; // 3 arg
|
||||
|
||||
stopFileSetDownload(fileSetId: string, arg1: { isIncludeCompressInnerFiles: boolean }): Promise < GeneralCallResult & {
|
||||
extraInfo: 0
|
||||
} > ; // 2 arg 结束不可重启!!
|
||||
|
||||
pauseFileSetDownload(fileSetId: string, arg1: { isIncludeCompressInnerFiles: boolean }): Promise < GeneralCallResult & {
|
||||
extraInfo: 0
|
||||
} > ; // 2 arg
|
||||
|
||||
resumeFileSetDownload(fileSetId: string, arg1: { isIncludeCompressInnerFiles: boolean }): Promise < GeneralCallResult & {
|
||||
extraInfo: 0
|
||||
} > ; // 2 arg
|
||||
|
||||
startFileListDownLoad(...args: unknown[]): unknown; // 4 arg // 大概率是选择set里面的部分文件进行下载,没必要,不想写
|
||||
|
||||
pauseFileListDownLoad(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
resumeFileListDownLoad(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
stopFileListDownLoad(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
startThumbnailListDownload(fileSetId: string): Promise < GeneralCallResult >; // 1 arg // 缩略图下载
|
||||
|
||||
stopThumbnailListDownload(fileSetId: string): Promise < GeneralCallResult >; // 1 arg
|
||||
|
||||
asyncRequestDownLoadStatus(fileSetId: string): Promise < DownloadStatusInfo >; // 1 arg
|
||||
|
||||
startFileTransferUrl(fileInfo: FlashOneFileInfo): Promise < {
|
||||
ret: number,
|
||||
url: string,
|
||||
expireTimestampSeconds: string
|
||||
} >; // 1 arg
|
||||
|
||||
startFileListDownLoadBySessionId(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
addFileSetSimpleStatusListener(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
addFileSetSimpleStatusMonitoring(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
removeFileSetSimpleStatusMonitoring(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
removeFileSetSimpleStatusListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addDesktopFileSetSimpleStatusListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addDesktopFileSetSimpleStatusMonitoring(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeDesktopFileSetSimpleStatusMonitoring(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeDesktopFileSetSimpleStatusListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFileSetSimpleUploadInfoListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFileSetSimpleUploadInfoMonitoring(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetSimpleUploadInfoMonitoring(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetSimpleUploadInfoListener(...args: unknown[]): unknown; // 1 arg
|
||||
/**
|
||||
* 发送闪传消息
|
||||
* @param sendArgs
|
||||
*/
|
||||
sendFlashTransferMsg(sendArgs: SendTargetRequests): Promise < {
|
||||
errCode: number,
|
||||
errMsg: string,
|
||||
rsp: {
|
||||
sendStatus: SendStatus[]
|
||||
}
|
||||
} >; // 1 arg 估计是file set id
|
||||
|
||||
addFlashTransferTaskInfoListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFlashTransferTaskInfoListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
retrieveLocalLastFailedSetTasksInfo(): unknown; // 0 arg
|
||||
|
||||
getFailedFileList(fileSetId: string): Promise < {
|
||||
rsp: {
|
||||
seq: number;
|
||||
result: number;
|
||||
errMs: string;
|
||||
fileSetId: string;
|
||||
fileList: []
|
||||
}
|
||||
} >; // 1 arg
|
||||
|
||||
getLocalFileListByStatuses(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addTransferStateListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeTransferStateListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getFileSetFirstClusteringList(...args: unknown[]): unknown; // 3 arg
|
||||
|
||||
getFileSetClusteringList(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFileSetClusteringListListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetClusteringListListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getFileSetClusteringDetail(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
doAIOFlashTransferBubbleActionWithStatus(...args: unknown[]): unknown; // 4 arg
|
||||
|
||||
getFilesTransferProgress(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
pollFilesTransferProgress(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
cancelPollFilesTransferProgress(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
checkDownloadStatusBeforeLocalFileOper(...args: unknown[]): unknown; // 3 arg
|
||||
|
||||
getCompressedFileFolder(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFolderListener(...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFolderListener(...args: unknown[]): unknown;
|
||||
|
||||
addCompressedFileListener(...args: unknown[]): unknown;
|
||||
|
||||
removeCompressedFileListener(...args: unknown[]): unknown;
|
||||
|
||||
getFileCategoryList(...args: unknown[]): unknown;
|
||||
|
||||
addDeviceStatusListener(...args: unknown[]): unknown;
|
||||
|
||||
removeDeviceStatusListener(...args: unknown[]): unknown;
|
||||
|
||||
checkDeviceStatus(...args: unknown[]): unknown;
|
||||
|
||||
pauseAllTasks(...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
resumePausedTasksAfterDeviceStatus(...args: unknown[]): unknown;
|
||||
|
||||
onSystemGoingToSleep(...args: unknown[]): unknown;
|
||||
|
||||
onSystemWokeUp(...args: unknown[]): unknown;
|
||||
|
||||
getFileMetas(...args: unknown[]): unknown;
|
||||
|
||||
addDownloadCntStatisticsListener(...args: unknown[]): unknown;
|
||||
|
||||
removeDownloadCntStatisticsListener(...args: unknown[]): unknown;
|
||||
|
||||
detectPrivacyInfoInPaths(...args: unknown[]): unknown;
|
||||
|
||||
getFileThumbnailUrl(...args: unknown[]): unknown;
|
||||
|
||||
handleDownloadFinishAfterSaveToAlbum(...args: unknown[]): unknown;
|
||||
|
||||
checkBatchFilesDownloadStatus(...args: unknown[]): unknown;
|
||||
|
||||
onCheckAlbumStorageStatusResult(...args: unknown[]): unknown;
|
||||
|
||||
addFileAlbumStorageListener(...args: unknown[]): unknown;
|
||||
|
||||
removeFileAlbumStorageListener(...args: unknown[]): unknown;
|
||||
|
||||
refreshFolderStatus(...args: unknown[]): unknown;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
export enum GeneralCallResultStatus {
|
||||
OK = 0,
|
||||
ERROR = -1,
|
||||
}
|
||||
|
||||
export interface GeneralCallResult {
|
||||
|
||||
21
packages/napcat-core/types/flashfile.ts
Normal file
21
packages/napcat-core/types/flashfile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export enum fileType {
|
||||
MP3 = 1,
|
||||
VIDEO = 2,
|
||||
DOC = 3,
|
||||
ZIP = 4,
|
||||
XLS = 6,
|
||||
PPT = 7,
|
||||
CODE = 8,
|
||||
PDF = 9,
|
||||
TXT = 10,
|
||||
UNKNOW = 11,
|
||||
FOLDER = 25,
|
||||
IMG = 26,
|
||||
}
|
||||
|
||||
export enum FileStatus {
|
||||
UPLOADING = 0,
|
||||
// DOWNLOADED = 1, ??? 不太清楚
|
||||
OK = 2,
|
||||
STOP = 3,
|
||||
}
|
||||
@@ -66,13 +66,14 @@ export enum ElementType {
|
||||
YOLOGAMERESULT = 20,
|
||||
AVRECORD = 21,
|
||||
FEED = 22,
|
||||
TOFURECORD = 23,
|
||||
TOFURECORD = 23, // tofu record?? 在线文件的id是这个
|
||||
ACEBUBBLE = 24,
|
||||
ACTIVITY = 25,
|
||||
TOFU = 26,
|
||||
FACEBUBBLE = 27,
|
||||
SHARELOCATION = 28,
|
||||
TASKTOPMSG = 29,
|
||||
ONLINEFOLDER = 30, // 在线文件夹
|
||||
RECOMMENDEDMSG = 43,
|
||||
ACTIONBAR = 44,
|
||||
}
|
||||
@@ -303,11 +304,40 @@ export enum NTVideoType {
|
||||
VIDEO_FORMAT_WMV = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪传图标
|
||||
*/
|
||||
export interface FlashTransferIcon {
|
||||
spec: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪传文件信息
|
||||
*/
|
||||
export interface FlashTransferInfo {
|
||||
filesetId: string;
|
||||
name: string;
|
||||
fileSize: string;
|
||||
thnumbnail: {
|
||||
id: string;
|
||||
urls: FlashTransferIcon[];
|
||||
localCachePath: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown元素接口
|
||||
*/
|
||||
export interface MarkdownElement {
|
||||
content: string;
|
||||
style?: {};
|
||||
processMsg?: string;
|
||||
mdSummary?: string;
|
||||
mdExtType?: number;
|
||||
mdExtInfo?: {
|
||||
flashTransferInfo: FlashTransferInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,24 +57,24 @@ export interface BaseInfo {
|
||||
}
|
||||
|
||||
// 音乐信息
|
||||
interface MusicInfo {
|
||||
export interface MusicInfo {
|
||||
buf: string;
|
||||
}
|
||||
|
||||
// 视频业务信息
|
||||
interface VideoBizInfo {
|
||||
export interface VideoBizInfo {
|
||||
cid: string;
|
||||
tvUrl: string;
|
||||
synchType: string;
|
||||
}
|
||||
|
||||
// 视频信息
|
||||
interface VideoInfo {
|
||||
export interface VideoInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 扩展在线业务信息
|
||||
interface ExtOnlineBusinessInfo {
|
||||
export interface ExtOnlineBusinessInfo {
|
||||
buf: string;
|
||||
customStatus: unknown;
|
||||
videoBizInfo: VideoBizInfo;
|
||||
@@ -82,12 +82,12 @@ interface ExtOnlineBusinessInfo {
|
||||
}
|
||||
|
||||
// 扩展缓冲区
|
||||
interface ExtBuffer {
|
||||
export interface ExtBuffer {
|
||||
buf: string;
|
||||
}
|
||||
|
||||
// 用户状态
|
||||
interface UserStatus {
|
||||
export interface UserStatus {
|
||||
uid: string;
|
||||
uin: string;
|
||||
status: number;
|
||||
@@ -109,14 +109,14 @@ interface UserStatus {
|
||||
}
|
||||
|
||||
// 特权图标
|
||||
interface PrivilegeIcon {
|
||||
export interface PrivilegeIcon {
|
||||
jumpUrl: string;
|
||||
openIconList: unknown[];
|
||||
closeIconList: unknown[];
|
||||
}
|
||||
|
||||
// 增值服务信息
|
||||
interface VasInfo {
|
||||
export interface VasInfo {
|
||||
vipFlag: boolean;
|
||||
yearVipFlag: boolean;
|
||||
svipFlag: boolean;
|
||||
@@ -149,7 +149,7 @@ interface VasInfo {
|
||||
}
|
||||
|
||||
// 关系标志
|
||||
interface RelationFlags {
|
||||
export interface RelationFlags {
|
||||
topTime: string;
|
||||
isBlock: boolean;
|
||||
isMsgDisturb: boolean;
|
||||
@@ -167,7 +167,7 @@ interface RelationFlags {
|
||||
}
|
||||
|
||||
// 通用扩展信息
|
||||
interface CommonExt {
|
||||
export interface CommonExt {
|
||||
constellation: number;
|
||||
shengXiao: number;
|
||||
kBloodType: number;
|
||||
@@ -193,14 +193,14 @@ export enum BuddyListReqType {
|
||||
}
|
||||
|
||||
// 图片信息
|
||||
interface Pic {
|
||||
export interface Pic {
|
||||
picId: string;
|
||||
picTime: number;
|
||||
picUrlMap: Record<string, string>;
|
||||
}
|
||||
|
||||
// 照片墙
|
||||
interface PhotoWall {
|
||||
export interface PhotoWall {
|
||||
picList: Pic[];
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export interface ModifyProfileParams {
|
||||
nick: string;
|
||||
longNick: string;
|
||||
sex: NTSex;
|
||||
birthday: { birthday_year: string, birthday_month: string, birthday_day: string };
|
||||
birthday: { birthday_year: string, birthday_month: string, birthday_day: string; };
|
||||
location: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { NodeIKernelMSFService } from './services/NodeIKernelMSFService';
|
||||
import { NodeIkernelTestPerformanceService } from './services/NodeIkernelTestPerformanceService';
|
||||
import { NodeIKernelECDHService } from './services/NodeIKernelECDHService';
|
||||
import { NodeIO3MiscService } from './services/NodeIO3MiscService';
|
||||
import { NodeIKernelFlashTransferService } from "./services/NodeIKernelFlashTransferService";
|
||||
|
||||
export interface NodeQQNTWrapperUtil {
|
||||
get(): NodeQQNTWrapperUtil;
|
||||
@@ -202,6 +203,8 @@ export interface NodeIQQNTWrapperSession {
|
||||
|
||||
getSearchService(): NodeIKernelSearchService;
|
||||
|
||||
getFlashTransferService(): NodeIKernelFlashTransferService;
|
||||
|
||||
getDirectSessionService(): unknown;
|
||||
|
||||
getRDeliveryService(): unknown;
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function NCoreInitFramework (
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
await applyPendingUpdates(pathWrapper, logger);
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,7 @@ const SchemaData = Type.Object({
|
||||
emojiId: Type.Union([Type.Number(), Type.String()]),
|
||||
emojiType: Type.Union([Type.Number(), Type.String()]),
|
||||
count: Type.Union([Type.Number(), Type.String()], { default: 20 }),
|
||||
cookie: Type.String({ default: '' })
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
@@ -23,7 +24,7 @@ export class FetchEmojiLike extends OneBotAction<Payload, Awaited<ReturnType<NTQ
|
||||
const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(msgIdPeer.Peer, [msgIdPeer.MsgId])).msgList[0];
|
||||
if (!msg) throw new Error('消息不存在');
|
||||
return await this.core.apis.MsgApi.getMsgEmojiLikesList(
|
||||
msgIdPeer.Peer, msg.msgSeq, payload.emojiId.toString(), payload.emojiType.toString(), +payload.count
|
||||
msgIdPeer.Peer, msg.msgSeq, payload.emojiId.toString(), payload.emojiType.toString(), payload.cookie, +payload.count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
24
packages/napcat-onebot/action/file/flash/CreateFlashTask.ts
Normal file
24
packages/napcat-onebot/action/file/flash/CreateFlashTask.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
// 不全部使用json因为:一个文件解析Form-data会变字符串!!! 但是api文档就写List
|
||||
const SchemaData = Type.Object({
|
||||
files: Type.Union([
|
||||
Type.Array(Type.String()),
|
||||
Type.String(),
|
||||
]),
|
||||
});
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class CreateFlashTask extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.CreateFlashTask;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
// todo fileset的名字和缩略图还没实现!!
|
||||
const fileList = Array.isArray(payload.files) ? payload.files : [payload.files];
|
||||
|
||||
return await this.core.apis.FlashApi.createFlashTransferUploadTask(fileList);
|
||||
}
|
||||
}
|
||||
19
packages/napcat-onebot/action/file/flash/DownloadFileset.ts
Normal file
19
packages/napcat-onebot/action/file/flash/DownloadFileset.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
fileset_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class DownloadFileset extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.DownloadFileset;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
// 默认路径 / fileset_id /为下载路径
|
||||
return await this.core.apis.FlashApi.downloadFileSetBySetId(payload.fileset_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
share_code: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetFilesetId extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetFilesetId;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
// 适配share_link 防止被传 Link无法解析
|
||||
const code = payload.share_code.includes('=') ? payload.share_code.split('=').slice(1).join('=') : payload.share_code;
|
||||
return await this.core.apis.FlashApi.fromShareLinkFindSetId(code);
|
||||
}
|
||||
}
|
||||
18
packages/napcat-onebot/action/file/flash/GetFilesetInfo.ts
Normal file
18
packages/napcat-onebot/action/file/flash/GetFilesetInfo.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
fileset_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetFilesetInfo extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetFilesetInfo;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
return await this.core.apis.FlashApi.getFileSetIndoBySetId(payload.fileset_id);
|
||||
}
|
||||
}
|
||||
18
packages/napcat-onebot/action/file/flash/GetFlashFileList.ts
Normal file
18
packages/napcat-onebot/action/file/flash/GetFlashFileList.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
fileset_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetFlashFileList extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetFlashFileList;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
return await this.core.apis.FlashApi.getFileListBySetId(payload.fileset_id);
|
||||
}
|
||||
}
|
||||
24
packages/napcat-onebot/action/file/flash/GetFlashFileUrl.ts
Normal file
24
packages/napcat-onebot/action/file/flash/GetFlashFileUrl.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
fileset_id: Type.String(),
|
||||
file_name: Type.Optional(Type.String()),
|
||||
file_index: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetFlashFileUrl extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetFlashFileUrl;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
// 文件的索引依旧从0开始
|
||||
return await this.core.apis.FlashApi.getFileTransUrl(payload.fileset_id, {
|
||||
fileName: payload.file_name,
|
||||
fileIndex: payload.file_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
18
packages/napcat-onebot/action/file/flash/GetShareLink.ts
Normal file
18
packages/napcat-onebot/action/file/flash/GetShareLink.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
fileset_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetShareLink extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetShareLink;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
return await this.core.apis.FlashApi.getShareLinkBySetId(payload.fileset_id);
|
||||
}
|
||||
}
|
||||
39
packages/napcat-onebot/action/file/flash/SendFlashMsg.ts
Normal file
39
packages/napcat-onebot/action/file/flash/SendFlashMsg.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType, Peer } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
fileset_id: Type.String(),
|
||||
user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
||||
group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class SendFlashMsg extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.SendFlashMsg;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
let peer: Peer;
|
||||
|
||||
if (payload.group_id) {
|
||||
peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() };
|
||||
} else if (payload.user_id) {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
// 可能需要更严格的判断
|
||||
const isBuddy = await this.core.apis.FriendApi.isBuddy(uid);
|
||||
peer = {
|
||||
chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP,
|
||||
peerUid: uid,
|
||||
};
|
||||
} else {
|
||||
throw new Error('user_id or group_id is required');
|
||||
}
|
||||
|
||||
return await this.core.apis.FlashApi.sendFlashMessage(payload.fileset_id, peer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||
msg_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class CancelOnlineFile extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.CancelOnlineFile;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
// 仅私聊
|
||||
const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid };
|
||||
|
||||
return await this.core.apis.OnlineApi.cancelMyOnlineFileMsg(peer, payload.msg_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetOnlineFileMessages extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetOnlineFileMessages;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
// 仅私聊
|
||||
const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid };
|
||||
|
||||
return await this.core.apis.OnlineApi.getOnlineFileMsg(peer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||
msg_id: Type.String(),
|
||||
element_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class ReceiveOnlineFile extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.ReceiveOnlineFile;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
// 默认下载路径
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid };
|
||||
|
||||
return await this.core.apis.OnlineApi.receiveOnlineFileOrFolder(peer, payload.msg_id, payload.element_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||
msg_id: Type.String(),
|
||||
element_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class RefuseOnlineFile extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.RefuseOnlineFile;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid };
|
||||
|
||||
return await this.core.apis.OnlineApi.refuseOnlineFileMsg(peer, payload.msg_id, payload.element_id);
|
||||
}
|
||||
}
|
||||
28
packages/napcat-onebot/action/file/online/SendOnlineFile.ts
Normal file
28
packages/napcat-onebot/action/file/online/SendOnlineFile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||
file_path: Type.String(),
|
||||
file_name: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class SendOnlineFile extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.SendOnlineFile;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
// 仅私聊
|
||||
const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid };
|
||||
const fileName = payload.file_name || '';
|
||||
|
||||
return await this.core.apis.OnlineApi.sendOnlineFile(peer, payload.file_path, fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ChatType } from 'napcat-core/types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||
folder_path: Type.String(),
|
||||
folder_name: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class SendOnlineFolder extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.SendOnlineFolder;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle (payload: Payload) {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!uid) throw new Error('User not found');
|
||||
|
||||
const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid };
|
||||
|
||||
return await this.core.apis.OnlineApi.sendOnlineFolder(peer, payload.folder_path, payload.folder_name);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,9 @@ import { FetchCustomFace } from './extends/FetchCustomFace';
|
||||
import GoCQHTTPUploadPrivateFile from './go-cqhttp/UploadPrivateFile';
|
||||
import { FetchEmojiLike } from './extends/FetchEmojiLike';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import type { NetworkAdapterConfig } from '../config/config';
|
||||
import { OneBotAction } from './OneBotAction';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot';
|
||||
import { SetInputStatus } from './extends/SetInputStatus';
|
||||
import { GetCSRF } from './system/GetCSRF';
|
||||
import { DelGroupNotice } from './group/DelGroupNotice';
|
||||
@@ -86,6 +88,7 @@ import { GetGroupMemberList } from './group/GetGroupMemberList';
|
||||
import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl';
|
||||
import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus';
|
||||
import { GetCredentials } from './system/GetCredentials';
|
||||
import { SetRestart } from './system/SetRestart';
|
||||
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
|
||||
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
|
||||
import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely';
|
||||
@@ -137,6 +140,20 @@ import { DownloadFileImageStream } from './stream/DownloadFileImageStream';
|
||||
import { TestDownloadStream } from './stream/TestStreamDownload';
|
||||
import { UploadFileStream } from './stream/UploadFileStream';
|
||||
import { AutoRegisterRouter } from './auto-register';
|
||||
import { CreateFlashTask } from './file/flash/CreateFlashTask';
|
||||
import { SendFlashMsg } from './file/flash/SendFlashMsg';
|
||||
import { GetFlashFileList } from './file/flash/GetFlashFileList';
|
||||
import { GetFlashFileUrl } from './file/flash/GetFlashFileUrl';
|
||||
import { GetShareLink } from './file/flash/GetShareLink';
|
||||
import { GetFilesetInfo } from './file/flash/GetFilesetInfo';
|
||||
import { DownloadFileset } from './file/flash/DownloadFileset';
|
||||
import { GetOnlineFileMessages } from './file/online/GetOnlineFileMessages';
|
||||
import { SendOnlineFile } from './file/online/SendOnlineFile';
|
||||
import { SendOnlineFolder } from './file/online/SendOnlineFolder';
|
||||
import { CancelOnlineFile } from './file/online/CancelOnlineFile';
|
||||
import { ReceiveOnlineFile } from './file/online/ReceiveOnlineFile';
|
||||
import { RefuseOnlineFile } from './file/online/RefuseOnlineFile';
|
||||
import { GetFilesetId } from './file/flash/GetFilesetIdByCode';
|
||||
|
||||
export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||
const actionHandlers = [
|
||||
@@ -266,6 +283,7 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
||||
new GetGroupFileSystemInfo(obContext, core),
|
||||
new GetGroupFilesByFolder(obContext, core),
|
||||
new GetPacketStatus(obContext, core),
|
||||
new SetRestart(obContext, core),
|
||||
new GroupPoke(obContext, core),
|
||||
new FriendPoke(obContext, core),
|
||||
new GetUserStatus(obContext, core),
|
||||
@@ -289,6 +307,20 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
||||
new CleanCache(obContext, core),
|
||||
new GetGroupAddRequest(obContext, core),
|
||||
new GetCollectionList(obContext, core),
|
||||
new CreateFlashTask(obContext, core),
|
||||
new GetFlashFileList(obContext, core),
|
||||
new GetFlashFileUrl(obContext, core),
|
||||
new SendFlashMsg(obContext, core),
|
||||
new GetShareLink(obContext, core),
|
||||
new GetFilesetInfo(obContext, core),
|
||||
new GetOnlineFileMessages(obContext, core),
|
||||
new SendOnlineFile(obContext, core),
|
||||
new SendOnlineFolder(obContext, core),
|
||||
new ReceiveOnlineFile(obContext, core),
|
||||
new RefuseOnlineFile(obContext, core),
|
||||
new CancelOnlineFile(obContext, core),
|
||||
new DownloadFileset(obContext, core),
|
||||
new GetFilesetId(obContext, core),
|
||||
];
|
||||
|
||||
type HandlerUnion = typeof actionHandlers[number];
|
||||
@@ -320,6 +352,30 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
||||
function get<K extends keyof MapType> (key: K): MapType[K] | undefined {
|
||||
return _map.get(key as keyof MapType) as MapType[K] | undefined;
|
||||
}
|
||||
return { get };
|
||||
|
||||
/**
|
||||
* 类型安全的 action 调用辅助函数
|
||||
* 根据 action 名称自动推导返回类型
|
||||
*/
|
||||
async function call<K extends keyof MapType> (
|
||||
actionName: K,
|
||||
params: unknown,
|
||||
adapter: string,
|
||||
config: NetworkAdapterConfig
|
||||
): Promise<MapType[K] extends OneBotAction<any, infer R> ? R : never> {
|
||||
const action = _map.get(actionName);
|
||||
if (!action) {
|
||||
throw new Error(`Action ${String(actionName)} not found`);
|
||||
}
|
||||
|
||||
const result = await (action as any).handle(params, adapter, config);
|
||||
if (result.status !== 'ok' || !result.data) {
|
||||
throw new Error(`Action ${String(actionName)} failed: ${result.message || 'No data returned'}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
return { get, call };
|
||||
}
|
||||
export type ActionMap = ReturnType<typeof createActionMap>;
|
||||
|
||||
@@ -81,7 +81,7 @@ export const ActionName = {
|
||||
CanSendRecord: 'can_send_record',
|
||||
GetStatus: 'get_status',
|
||||
GetVersionInfo: 'get_version_info',
|
||||
// Reboot : 'set_restart',
|
||||
Reboot: 'set_restart',
|
||||
CleanCache: 'clean_cache',
|
||||
Exit: 'bot_exit',
|
||||
// go-cqhttp
|
||||
@@ -125,8 +125,8 @@ export const ActionName = {
|
||||
// 以下为扩展napcat扩展
|
||||
Unknown: 'unknown',
|
||||
SetDiyOnlineStatus: 'set_diy_online_status',
|
||||
SharePeer: 'ArkSharePeer',// @deprecated
|
||||
ShareGroupEx: 'ArkShareGroup',// @deprecated
|
||||
SharePeer: 'ArkSharePeer', // @deprecated
|
||||
ShareGroupEx: 'ArkShareGroup', // @deprecated
|
||||
// 标准化接口
|
||||
SendGroupArkShare: 'send_group_ark_share',
|
||||
SendArkShare: 'send_ark_share',
|
||||
@@ -185,4 +185,22 @@ export const ActionName = {
|
||||
GetClientkey: 'get_clientkey',
|
||||
|
||||
SendPoke: 'send_poke',
|
||||
|
||||
// Flash (闪传) 扩展
|
||||
CreateFlashTask: 'create_flash_task',
|
||||
SendFlashMsg: 'send_flash_msg', // 因为不可能手动构造element,所以不走sendMsg
|
||||
GetShareLink: 'get_share_link',
|
||||
DownloadFileset: 'download_fileset',
|
||||
GetFilesetInfo: 'get_fileset_info',
|
||||
GetFlashFileList: 'get_flash_file_list',
|
||||
GetFlashFileUrl: 'get_flash_file_url',
|
||||
GetFilesetId: 'get_fileset_id',
|
||||
|
||||
// Online File (在线文件) 扩展
|
||||
SendOnlineFile: 'send_online_file',
|
||||
SendOnlineFolder: 'send_online_folder',
|
||||
GetOnlineFileMessages: 'get_online_file_msg',
|
||||
ReceiveOnlineFile: 'receive_online_file',
|
||||
RefuseOnlineFile: 'refuse_online_file',
|
||||
CancelOnlineFile: 'cancel_online_file',
|
||||
} as const;
|
||||
|
||||
14
packages/napcat-onebot/action/system/SetRestart.ts
Normal file
14
packages/napcat-onebot/action/system/SetRestart.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OneBotAction } from '../OneBotAction';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
|
||||
export class SetRestart extends OneBotAction<void, void> {
|
||||
override actionName = ActionName.Reboot;
|
||||
|
||||
async _handle () {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
if (!result.result) {
|
||||
throw new Error(result.message || '进程重启失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,18 @@ import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent';
|
||||
import { GroupDecreaseSubType, OB11GroupDecreaseEvent } from '../event/notice/OB11GroupDecreaseEvent';
|
||||
import { GroupAdmin } from 'napcat-core/packet/transformer/proto/message/groupAdmin';
|
||||
import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeEvent';
|
||||
import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from 'napcat-core/packet/transformer/proto';
|
||||
import {
|
||||
GroupChange,
|
||||
GroupChangeInfo,
|
||||
GroupInvite,
|
||||
PushMsgBody,
|
||||
} from 'napcat-core/packet/transformer/proto';
|
||||
import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
|
||||
import { LRUCache } from 'napcat-common/src/lru-cache';
|
||||
import { cleanTaskQueue } from 'napcat-common/src/clean-task';
|
||||
import { registerResource } from 'napcat-common/src/health';
|
||||
import { OB11OnlineFileReceiveEvent } from '@/napcat-onebot/event/notice/OB11OnlineFileReceiveEvent';
|
||||
import { OB11OnlineFileSendEvent } from '@/napcat-onebot/event/notice/OB11OnlineFileSendEvent';
|
||||
|
||||
type RawToOb11Converters = {
|
||||
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
|
||||
@@ -143,6 +150,21 @@ export class OneBotMsgApi {
|
||||
},
|
||||
|
||||
fileElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
|
||||
// 让在线文件/文件夹的消息单独出去(否则无法正确处理UUID!!!)
|
||||
if (+elementWrapper.elementType === 23 || +elementWrapper.elementType === 30) {
|
||||
// 判断为在线文件/文件夹
|
||||
return {
|
||||
type: OB11MessageDataType.onlinefile,
|
||||
data: {
|
||||
msgId: msg.msgId,
|
||||
elementId: elementWrapper.elementId,
|
||||
fileName: element.fileName,
|
||||
fileSize: element.fileSize,
|
||||
isDir: (elementWrapper.elementType === 30),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const peer = {
|
||||
chatType: msg.chatType,
|
||||
peerUid: msg.peerUid,
|
||||
@@ -538,12 +560,22 @@ export class OneBotMsgApi {
|
||||
},
|
||||
|
||||
markdownElement: async (element) => {
|
||||
return {
|
||||
type: OB11MessageDataType.markdown,
|
||||
data: {
|
||||
content: element.content,
|
||||
},
|
||||
};
|
||||
// 让QQ闪传消息独立出去
|
||||
if (element?.mdExtInfo?.flashTransferInfo?.filesetId) {
|
||||
return {
|
||||
type: OB11MessageDataType.flashtransfer,
|
||||
data: {
|
||||
fileSetId: element.mdExtInfo.flashTransferInfo.filesetId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: OB11MessageDataType.markdown,
|
||||
data: {
|
||||
content: element.content,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -587,15 +619,33 @@ export class OneBotMsgApi {
|
||||
return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || '');
|
||||
},
|
||||
|
||||
[OB11MessageDataType.reply]: async ({ data: { id } }) => {
|
||||
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
|
||||
if (!replyMsgM) {
|
||||
this.core.context.logger.logWarn('回复消息不存在', id);
|
||||
[OB11MessageDataType.reply]: async ({ data: { id, seq } }, context) => {
|
||||
let replyMsg: RawMessage | undefined;
|
||||
let replyMsgPeer: Peer | undefined;
|
||||
|
||||
// 优先使用 seq
|
||||
if (seq) {
|
||||
const msgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
|
||||
context.peer, seq.toString(), 1, true, true
|
||||
)).msgList;
|
||||
replyMsg = msgList[0];
|
||||
replyMsgPeer = context.peer;
|
||||
} else if (id) {
|
||||
// 降级使用 id
|
||||
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
|
||||
if (!replyMsgM) {
|
||||
this.core.context.logger.logWarn('回复消息不存在', id);
|
||||
return undefined;
|
||||
}
|
||||
replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(
|
||||
replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0];
|
||||
replyMsgPeer = replyMsgM.Peer;
|
||||
} else {
|
||||
this.core.context.logger.logWarn('回复消息缺少id或seq参数');
|
||||
return undefined;
|
||||
}
|
||||
const replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(
|
||||
replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0];
|
||||
return replyMsg
|
||||
|
||||
return replyMsg && replyMsgPeer
|
||||
? {
|
||||
elementType: ElementType.REPLY,
|
||||
elementId: '',
|
||||
@@ -605,7 +655,7 @@ export class OneBotMsgApi {
|
||||
senderUin: replyMsg.senderUin,
|
||||
senderUinStr: replyMsg.senderUin,
|
||||
replyMsgClientSeq: replyMsg.clientSeq,
|
||||
_replyMsgPeer: replyMsgM.Peer,
|
||||
_replyMsgPeer: replyMsgPeer,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
@@ -862,6 +912,10 @@ export class OneBotMsgApi {
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
// 不需要支持发送
|
||||
[OB11MessageDataType.onlinefile]: async () => undefined,
|
||||
|
||||
[OB11MessageDataType.flashtransfer]: async () => undefined,
|
||||
};
|
||||
|
||||
constructor (obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||
@@ -1311,6 +1365,7 @@ export class OneBotMsgApi {
|
||||
async parseSysMessage (msg: number[]) {
|
||||
const SysMessage = new NapProtoMsg(PushMsgBody).decode(Uint8Array.from(msg));
|
||||
// 邀请需要解grayTipElement
|
||||
// console.log(SysMessage.body?.msgContent);
|
||||
if (SysMessage.contentHead.type === 33 && SysMessage.body?.msgContent) {
|
||||
const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent);
|
||||
await this.core.apis.GroupApi.refreshGroupMemberCache(groupChange.groupUin.toString(), true);
|
||||
@@ -1466,6 +1521,63 @@ export class OneBotMsgApi {
|
||||
);
|
||||
} else if (SysMessage.contentHead.type === 528 && SysMessage.contentHead.subType === 39 && SysMessage.body?.msgContent) {
|
||||
return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent);
|
||||
} else if (SysMessage.contentHead.type === 166 && SysMessage.contentHead.c2CCmd === 133 && SysMessage.body?.msgContent) {
|
||||
this.core.context.logger.logDebug('在线文件通道断开');
|
||||
// 可能原因: 对方取消 对方拒绝 对方转离线
|
||||
// body不是proto,只能手动提取,可能是错的!!
|
||||
// console.log(SysMessage.body?.msgContent);
|
||||
const mainCmd = SysMessage.body.msgContent[15];
|
||||
const subCmd = SysMessage.body.msgContent[17];
|
||||
if (mainCmd === 101) {
|
||||
// 在线文件
|
||||
if (subCmd === 225) {
|
||||
// 对方取消或转离线
|
||||
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}取消了在线文件的传输(或转离线)`);
|
||||
return new OB11OnlineFileReceiveEvent(
|
||||
this.core,
|
||||
+SysMessage.responseHead.fromUin
|
||||
);
|
||||
} else if (subCmd === 230) {
|
||||
// 对方拒绝接收
|
||||
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}拒绝了你的在线文件传输`);
|
||||
return new OB11OnlineFileSendEvent(
|
||||
this.core,
|
||||
+SysMessage.responseHead.fromUin,
|
||||
'refuse'
|
||||
);
|
||||
}
|
||||
} else if (mainCmd === 136) {
|
||||
if (subCmd === 225) {
|
||||
// 对方取消或转离线
|
||||
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}取消了在线文件夹的传输(或转离线)`);
|
||||
return new OB11OnlineFileReceiveEvent(
|
||||
this.core,
|
||||
+SysMessage.responseHead.fromUin
|
||||
);
|
||||
} else if (subCmd === 230) {
|
||||
// 对方拒绝接收
|
||||
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}拒绝了你的在线文件夹传输`);
|
||||
return new OB11OnlineFileSendEvent(
|
||||
this.core,
|
||||
+SysMessage.responseHead.fromUin,
|
||||
'refuse'
|
||||
);
|
||||
}
|
||||
}
|
||||
this.core.context.logger.logDebug('未知的系统消息事件:', mainCmd, subCmd);
|
||||
return undefined;
|
||||
} else if (SysMessage.contentHead.type === 166 && SysMessage.contentHead.c2CCmd === 131 && SysMessage.body?.msgContent) {
|
||||
const mainCmd = SysMessage.body.msgContent[15];
|
||||
if (mainCmd === 101) {
|
||||
this.core.context.logger.log('在线文件传输成功!');
|
||||
} else if (mainCmd === 136) {
|
||||
this.core.context.logger.log('在线文件夹传输成功!');
|
||||
}
|
||||
return new OB11OnlineFileSendEvent(
|
||||
this.core,
|
||||
+SysMessage.responseHead.fromUin,
|
||||
'receive'
|
||||
);
|
||||
}
|
||||
// else if (SysMessage.contentHead.type == 732 && SysMessage.contentHead.subType == 16 && SysMessage.body?.msgContent) {
|
||||
// let data_wrap = PBString(2);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
|
||||
export abstract class OB11OnlineFileNoticeEvent extends OB11BaseNoticeEvent {
|
||||
peer_id: number;
|
||||
|
||||
protected constructor (core: NapCatCore, peer_id: number) {
|
||||
super(core);
|
||||
this.peer_id = peer_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OB11OnlineFileNoticeEvent } from './OB11OnlineFileNoticeEvent';
|
||||
import { NapCatCore } from '@/napcat-core';
|
||||
|
||||
export class OB11OnlineFileReceiveEvent extends OB11OnlineFileNoticeEvent {
|
||||
notice_type: string;
|
||||
sub_type: string;
|
||||
|
||||
constructor (core: NapCatCore, peer_id: number) {
|
||||
super(core, peer_id);
|
||||
this.notice_type = 'online_file_receive';
|
||||
this.sub_type = 'cancel';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { OB11OnlineFileNoticeEvent } from './OB11OnlineFileNoticeEvent';
|
||||
import { NapCatCore } from '@/napcat-core';
|
||||
|
||||
export class OB11OnlineFileSendEvent extends OB11OnlineFileNoticeEvent {
|
||||
notice_type = 'online_file_send';
|
||||
sub_type: 'receive' | 'refuse';
|
||||
|
||||
constructor (core: NapCatCore, peer_id: number, sub_type: 'receive' | 'refuse') {
|
||||
super(core, peer_id);
|
||||
this.sub_type = sub_type;
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,11 @@ import {
|
||||
OneBotConfigSchema,
|
||||
} from './config/config';
|
||||
import { OB11Message } from './types';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
|
||||
import { OB11PluginMangerAdapter } from './network/plugin-manger';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { OneBotFileApi } from './api/file';
|
||||
|
||||
@@ -160,6 +161,7 @@ export class NapCatOneBot11Adapter {
|
||||
// this.networkManager.registerAdapter(
|
||||
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
||||
// );
|
||||
// 检查插件目录是否存在,不存在则不加载插件管理器
|
||||
if (existsSync(this.context.pathWrapper.pluginPath)) {
|
||||
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
|
||||
this.networkManager.registerAdapter(
|
||||
@@ -326,6 +328,38 @@ export class NapCatOneBot11Adapter {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加入在线文件的listener
|
||||
*/
|
||||
msgListener.onRecvOnlineFileMsg = async (msg: RawMessage[]) => {
|
||||
if (!this.networkManager.hasActiveAdapters()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of msg) {
|
||||
// this.context.logger.logMessage(m, this.core.selfInfo);
|
||||
|
||||
if (this.bootTime > parseInt(m.msgTime)) {
|
||||
this.context.logger.logDebug(`在线文件消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`);
|
||||
continue;
|
||||
}
|
||||
|
||||
m.id = MessageUnique.createUniqueMsgId(
|
||||
{
|
||||
chatType: m.chatType,
|
||||
peerUid: m.peerUid,
|
||||
guildId: '',
|
||||
},
|
||||
m.msgId
|
||||
);
|
||||
|
||||
await this.emitMsg(m).catch((e) =>
|
||||
this.context.logger.logError('处理在线文件消息失败', e)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
msgListener.onAddSendMsg = async (msg) => {
|
||||
try {
|
||||
if (msg.sendStatus === SendStatusType.KSEND_STATUS_SENDING) {
|
||||
@@ -387,6 +421,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
};
|
||||
msgListener.onKickedOffLine = async (kick) => {
|
||||
WebUiDataRuntime.setQQLoginStatus(false);
|
||||
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
|
||||
this.networkManager
|
||||
.emitEvent(event)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { PluginConfig } from '../config/config';
|
||||
import { ActionMap } from '../action';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { PluginConfig } from '../config/config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -11,13 +11,39 @@ export interface PluginPackageJson {
|
||||
name?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
|
||||
plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
|
||||
plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
|
||||
plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
|
||||
plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
|
||||
plugin_init: (
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_onmessage?: (
|
||||
adapter: string,
|
||||
core: NapCatCore,
|
||||
obCtx: NapCatOneBot11Adapter,
|
||||
event: OB11Message,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_onevent?: (
|
||||
adapter: string,
|
||||
core: NapCatCore,
|
||||
obCtx: NapCatOneBot11Adapter,
|
||||
event: T,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_cleanup?: (
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoadedPlugin {
|
||||
@@ -29,16 +55,25 @@ export interface LoadedPlugin {
|
||||
module: PluginModule;
|
||||
}
|
||||
|
||||
export interface PluginStatusConfig {
|
||||
[key: string]: boolean; // key: pluginName, value: enabled
|
||||
}
|
||||
|
||||
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
private readonly pluginPath: string;
|
||||
private readonly configPath: string;
|
||||
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
||||
declare config: PluginConfig;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.loadedPlugins.size > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
|
||||
name: string,
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap
|
||||
) {
|
||||
const config = {
|
||||
name,
|
||||
@@ -49,24 +84,60 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
};
|
||||
super(name, config, core, obContext, actions);
|
||||
this.pluginPath = this.core.context.pathWrapper.pluginPath;
|
||||
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
|
||||
}
|
||||
|
||||
private loadPluginConfig (): PluginStatusConfig {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
this.logger.logWarn('[Plugin Adapter] Error parsing plugins.json', e);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private savePluginConfig (config: PluginStatusConfig) {
|
||||
try {
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
this.logger.logError('[Plugin Adapter] Error saving plugins.json', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并加载插件
|
||||
*/
|
||||
* 扫描并加载插件
|
||||
*/
|
||||
private async loadPlugins (): Promise<void> {
|
||||
try {
|
||||
// 确保插件目录存在
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`);
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`
|
||||
);
|
||||
fs.mkdirSync(this.pluginPath, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
|
||||
// 扫描文件和目录
|
||||
for (const item of items) {
|
||||
let pluginName = '';
|
||||
if (item.isFile()) {
|
||||
pluginName = path.parse(item.name).name;
|
||||
} else if (item.isDirectory()) {
|
||||
pluginName = item.name;
|
||||
}
|
||||
|
||||
// Check if plugin is disabled in config
|
||||
if (pluginConfig[pluginName] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled in config, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.isFile()) {
|
||||
// 处理单文件插件
|
||||
await this.loadFilePlugin(item.name);
|
||||
@@ -76,16 +147,18 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`);
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单文件插件 (.mjs, .js)
|
||||
*/
|
||||
private async loadFilePlugin (filename: string): Promise<void> {
|
||||
* 加载单文件插件 (.mjs, .js)
|
||||
*/
|
||||
public async loadFilePlugin (filename: string): Promise<void> {
|
||||
// 只处理支持的文件类型
|
||||
if (!this.isSupportedFile(filename)) {
|
||||
return;
|
||||
@@ -93,11 +166,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
const filePath = path.join(this.pluginPath, filename);
|
||||
const pluginName = path.parse(filename).name;
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
|
||||
// Check if plugin is disabled in config
|
||||
if (pluginConfig[pluginName] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await this.importModule(filePath);
|
||||
if (!this.isValidPluginModule(module)) {
|
||||
this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`);
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,15 +192,31 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
await this.registerPlugin(plugin);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error);
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error loading file plugin ${filename}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载目录插件
|
||||
*/
|
||||
private async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
* 加载目录插件
|
||||
*/
|
||||
public async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
const pluginDir = path.join(this.pluginPath, dirname);
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
|
||||
// Ideally we'd get the name from package.json first, but we can use dirname as a fallback identifier initially.
|
||||
// However, the list scan uses item.name (dirname) as the key. Let's stick to using dirname/filename as the config key for simplicity and consistency.
|
||||
// Wait, package.json name might override. But for management, consistent ID is better.
|
||||
// Let's check config after parsing package.json?
|
||||
// User expects to disable 'plugin-name'. But if multiple folders have same name? Not handled.
|
||||
// Let's use dirname as the key for config to be consistent with file system.
|
||||
|
||||
if (pluginConfig[dirname] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${dirname} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json
|
||||
@@ -130,14 +228,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
packageJson = JSON.parse(packageContent);
|
||||
} catch (error) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error);
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] Invalid package.json in ${dirname}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if disabled by package name IF package.json exists?
|
||||
// No, file system name is more reliable ID for resource management here.
|
||||
|
||||
// 确定入口文件
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
if (!entryFile) {
|
||||
this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`);
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,7 +251,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
const module = await this.importModule(entryPath);
|
||||
|
||||
if (!this.isValidPluginModule(module)) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`);
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,14 +268,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
await this.registerPlugin(plugin);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error);
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error loading directory plugin ${dirname}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件目录的入口文件
|
||||
*/
|
||||
private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
|
||||
* 查找插件目录的入口文件
|
||||
*/
|
||||
private findEntryFile (
|
||||
pluginDir: string,
|
||||
packageJson?: PluginPackageJson
|
||||
): string | null {
|
||||
// 优先级:package.json main > 默认文件名
|
||||
const possibleEntries = [
|
||||
packageJson?.main,
|
||||
@@ -188,53 +302,69 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为支持的文件类型
|
||||
*/
|
||||
* 检查是否为支持的文件类型
|
||||
*/
|
||||
private isSupportedFile (filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return ['.mjs', '.js'].includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态导入模块
|
||||
*/
|
||||
* 动态导入模块
|
||||
*/
|
||||
private async importModule (filePath: string): Promise<any> {
|
||||
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
|
||||
return await import(fileUrl);
|
||||
// Add timestamp to force reload cache if supported or just import
|
||||
// Note: dynamic import caching is tricky in ESM. Adding query param might help?
|
||||
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
|
||||
return await import(fileUrlWithQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否为有效的插件模块
|
||||
*/
|
||||
* 检查模块是否为有效的插件模块
|
||||
*/
|
||||
private isValidPluginModule (module: any): module is PluginModule {
|
||||
return module && typeof module.plugin_init === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件
|
||||
*/
|
||||
* 注册插件
|
||||
*/
|
||||
private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
|
||||
// 检查名称冲突
|
||||
if (this.loadedPlugins.has(plugin.name)) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`);
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedPlugins.set(plugin.name, plugin);
|
||||
this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`);
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''
|
||||
}`
|
||||
);
|
||||
|
||||
// 调用插件初始化方法(必须存在)
|
||||
try {
|
||||
await plugin.module.plugin_init(this.core, this.obContext, this.actions, this);
|
||||
await plugin.module.plugin_init(
|
||||
this.core,
|
||||
this.obContext,
|
||||
this.actions,
|
||||
this
|
||||
);
|
||||
this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error);
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error initializing plugin ${plugin.name}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
* 卸载插件
|
||||
*/
|
||||
private async unloadPlugin (pluginName: string): Promise<void> {
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
if (!plugin) {
|
||||
@@ -244,10 +374,18 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
// 调用插件清理方法
|
||||
if (typeof plugin.module.plugin_cleanup === 'function') {
|
||||
try {
|
||||
await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this);
|
||||
await plugin.module.plugin_cleanup(
|
||||
this.core,
|
||||
this.obContext,
|
||||
this.actions,
|
||||
this
|
||||
);
|
||||
this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error);
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +393,69 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
public async unregisterPlugin (pluginName: string): Promise<void> {
|
||||
return this.unloadPlugin(pluginName);
|
||||
}
|
||||
|
||||
public getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
public getPluginConfig (): PluginStatusConfig {
|
||||
return this.loadPluginConfig();
|
||||
}
|
||||
|
||||
public setPluginStatus (pluginName: string, enable: boolean): void {
|
||||
const config = this.loadPluginConfig();
|
||||
config[pluginName] = enable;
|
||||
this.savePluginConfig(config);
|
||||
|
||||
// If disabling, unload immediately if loaded
|
||||
if (!enable) {
|
||||
// Note: pluginName passed here might be the package name or the filename/dirname
|
||||
// But our registerPlugin uses plugin.name which comes from package.json or dirname.
|
||||
// This mismatch is tricky.
|
||||
// Ideally, we should use a consistent ID.
|
||||
// Let's assume pluginName passed here effectively matches the ID used in loadedPlugins.
|
||||
// But wait, loadDirectoryPlugin logic: name = packageJson.name || dirname.
|
||||
// config key = dirname.
|
||||
// If packageJson.name != dirname, we have a problem.
|
||||
// To fix this properly:
|
||||
// 1. We need to know which LoadedPlugin corresponds to the enabled/disabled item.
|
||||
// 2. Or we iterate loadedPlugins and find match.
|
||||
|
||||
for (const [_, loaded] of this.loadedPlugins.entries()) {
|
||||
const dirOrFile = path.basename(loaded.pluginPath === this.pluginPath ? loaded.entryPath : loaded.pluginPath);
|
||||
const ext = path.extname(dirOrFile);
|
||||
const simpleName = ext ? path.parse(dirOrFile).name : dirOrFile; // filename without ext
|
||||
|
||||
// But wait, config key is the FILENAME (with ext for files?).
|
||||
// In Scan loop:
|
||||
// pluginName = path.parse(item.name).name (for file)
|
||||
// pluginName = item.name (for dir)
|
||||
// config[pluginName] check.
|
||||
|
||||
// So if file is "test.js", pluginName is "test". Config key "test".
|
||||
// If dir is "test-plugin", pluginName is "test-plugin". Config key "test-plugin".
|
||||
|
||||
// loadedPlugin.name might be distinct.
|
||||
// So we need to match loadedPlugin back to its fs source to unload it?
|
||||
|
||||
// loadedPlugin.entryPath or pluginPath helps.
|
||||
// If it's a file plugin: loaded.entryPath ends with pluginName + ext.
|
||||
// If it's a dir plugin: loaded.pluginPath ends with pluginName.
|
||||
|
||||
if (pluginName === simpleName) {
|
||||
this.unloadPlugin(loaded.name).catch(e => this.logger.logError('Error unloading', e));
|
||||
}
|
||||
}
|
||||
}
|
||||
// If enabling, we need to load it.
|
||||
// But we can just rely on the API handler to call loadFile/DirectoryPlugin which now checks config.
|
||||
// Wait, if I call loadFilePlugin("test.js") and config says enable=true, it loads.
|
||||
// API handler needs to change to pass filename/dirname.
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
@@ -273,21 +474,44 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件的事件处理方法
|
||||
*/
|
||||
private async callPluginEventHandler (plugin: LoadedPlugin, event: OB11EmitEventContent): Promise<void> {
|
||||
* 调用插件的事件处理方法
|
||||
*/
|
||||
private async callPluginEventHandler (
|
||||
plugin: LoadedPlugin,
|
||||
event: OB11EmitEventContent
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 优先使用 plugin_onevent 方法
|
||||
if (typeof plugin.module.plugin_onevent === 'function') {
|
||||
await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this);
|
||||
await plugin.module.plugin_onevent(
|
||||
this.name,
|
||||
this.core,
|
||||
this.obContext,
|
||||
event,
|
||||
this.actions,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
|
||||
if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') {
|
||||
await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this);
|
||||
if (
|
||||
(event as any).message_type &&
|
||||
typeof plugin.module.plugin_onmessage === 'function'
|
||||
) {
|
||||
await plugin.module.plugin_onmessage(
|
||||
this.name,
|
||||
this.core,
|
||||
this.obContext,
|
||||
event as OB11Message,
|
||||
this.actions,
|
||||
this
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error);
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +526,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
// 加载所有插件
|
||||
await this.loadPlugins();
|
||||
|
||||
this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`);
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`
|
||||
);
|
||||
}
|
||||
|
||||
async close () {
|
||||
@@ -334,22 +560,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
public getLoadedPlugins (): LoadedPlugin[] {
|
||||
return Array.from(this.loadedPlugins.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息
|
||||
*/
|
||||
* 获取插件信息
|
||||
*/
|
||||
public getPluginInfo (pluginName: string): LoadedPlugin | undefined {
|
||||
return this.loadedPlugins.get(pluginName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载指定插件
|
||||
*/
|
||||
* 重载指定插件
|
||||
*/
|
||||
public async reloadPlugin (pluginName: string): Promise<boolean> {
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
if (!plugin) {
|
||||
@@ -362,8 +588,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
await this.unloadPlugin(pluginName);
|
||||
|
||||
// 重新加载插件
|
||||
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
// Use logic to re-determine if it is directory or file based on original paths
|
||||
// Note: we can't fully trust fs status if it's gone.
|
||||
const isDirectory =
|
||||
plugin.pluginPath !== this.pluginPath; // Simple check: if path is nested, it's a dir plugin
|
||||
|
||||
if (isDirectory) {
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
@@ -373,10 +601,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
await this.loadFilePlugin(filename);
|
||||
}
|
||||
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`);
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error);
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export enum OB11MessageDataType {
|
||||
miniapp = 'miniapp', // json类
|
||||
contact = 'contact',
|
||||
location = 'location',
|
||||
onlinefile = 'onlinefile', // 在线文件/文件夹
|
||||
flashtransfer = 'flashtransfer', // QQ闪传
|
||||
}
|
||||
|
||||
export interface OB11MessagePoke {
|
||||
@@ -103,7 +105,7 @@ export interface OB11MessageText {
|
||||
}
|
||||
|
||||
// 联系人消息接口定义
|
||||
export interface OB11MessageContext {
|
||||
export interface OB11MessageContact {
|
||||
type: OB11MessageDataType.contact;
|
||||
data: {
|
||||
type: 'qq' | 'group';
|
||||
@@ -159,7 +161,8 @@ export interface OB11MessageAt {
|
||||
export interface OB11MessageReply {
|
||||
type: OB11MessageDataType.reply;
|
||||
data: {
|
||||
id: string;
|
||||
id?: string; // msg_id 的短ID映射
|
||||
seq?: number; // msg_seq,优先使用
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,6 +256,24 @@ export interface OB11MessageForward {
|
||||
};
|
||||
}
|
||||
|
||||
export interface OB11MessageOnlineFile {
|
||||
type: OB11MessageDataType.onlinefile;
|
||||
data: {
|
||||
msgId: string;
|
||||
elementId: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
isDir: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export interface OB11MessageFlashTransfer {
|
||||
type: OB11MessageDataType.flashtransfer;
|
||||
data: {
|
||||
fileSetId: string;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息数据类型定义
|
||||
export type OB11MessageData =
|
||||
OB11MessageText |
|
||||
@@ -260,7 +281,8 @@ export type OB11MessageData =
|
||||
OB11MessageAt | OB11MessageReply |
|
||||
OB11MessageImage | OB11MessageRecord | OB11MessageFile | OB11MessageVideo |
|
||||
OB11MessageNode | OB11MessageIdMusic | OB11MessageCustomMusic | OB11MessageJson |
|
||||
OB11MessageDice | OB11MessageRPS | OB11MessageMarkdown | OB11MessageForward | OB11MessageContext | OB11MessagePoke;
|
||||
OB11MessageDice | OB11MessageRPS | OB11MessageMarkdown | OB11MessageForward | OB11MessageContact |
|
||||
OB11MessagePoke | OB11MessageOnlineFile | OB11MessageFlashTransfer;
|
||||
|
||||
// 发送消息接口定义
|
||||
export interface OB11PostSendMsg {
|
||||
|
||||
106
packages/napcat-plugin-builtin/index.ts
Normal file
106
packages/napcat-plugin-builtin/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ActionMap } from 'napcat-onebot/action';
|
||||
import { EventType } from 'napcat-onebot/event/OneBotEvent';
|
||||
import type { PluginModule } from 'napcat-onebot/network/plugin';
|
||||
import type { OB11Message, OB11PostSendMsg } from 'napcat-onebot/types/message';
|
||||
|
||||
let actions: ActionMap | undefined = undefined;
|
||||
let startTime: number = Date.now();
|
||||
|
||||
/**
|
||||
* 插件初始化
|
||||
*/
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: builtin] NapCat 内置插件已初始化');
|
||||
actions = _actions;
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息处理
|
||||
* 当收到包含 #napcat 的消息时,回复版本信息
|
||||
*/
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
|
||||
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const versionInfo = await getVersionInfo(adapter, instance.config);
|
||||
if (!versionInfo) return;
|
||||
|
||||
const message = formatVersionMessage(versionInfo);
|
||||
await sendMessage(event, message, adapter, instance.config);
|
||||
|
||||
console.log('[Plugin: builtin] 已回复版本信息');
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 处理消息时发生错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取版本信息(完美的类型推导,无需 as 断言)
|
||||
*/
|
||||
async function getVersionInfo (adapter: string, config: any) {
|
||||
if (!actions) return null;
|
||||
|
||||
try {
|
||||
const data = await actions.call('get_version_info', void 0, adapter, config);
|
||||
return {
|
||||
appName: data.app_name,
|
||||
appVersion: data.app_version,
|
||||
protocolVersion: data.protocol_version,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 获取版本信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化运行时间
|
||||
*/
|
||||
function formatUptime (ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours % 24}小时 ${minutes % 60}分钟`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}小时 ${minutes % 60}分钟`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分钟 ${seconds % 60}秒`;
|
||||
} else {
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化版本信息消息
|
||||
*/
|
||||
function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) {
|
||||
const uptime = Date.now() - startTime;
|
||||
return `NapCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}\n运行时间: ${formatUptime(uptime)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(完美的类型推导)
|
||||
*/
|
||||
async function sendMessage (event: OB11Message, message: string, adapter: string, config: any) {
|
||||
if (!actions) return;
|
||||
|
||||
const params: OB11PostSendMsg = {
|
||||
message,
|
||||
message_type: event.message_type,
|
||||
...(event.message_type === 'group' && event.group_id ? { group_id: String(event.group_id) } : {}),
|
||||
...(event.message_type === 'private' && event.user_id ? { user_id: String(event.user_id) } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await actions.call('send_msg', params, adapter, config);
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 发送消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export { plugin_init, plugin_onmessage };
|
||||
17
packages/napcat-plugin-builtin/package.json
Normal file
17
packages/napcat-plugin-builtin/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "napcat-plugin-builtin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-onebot": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
11
packages/napcat-plugin-builtin/tsconfig.json
Normal file
11
packages/napcat-plugin-builtin/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
77
packages/napcat-plugin-builtin/vite.config.ts
Normal file
77
packages/napcat-plugin-builtin/vite.config.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { builtinModules } from 'module';
|
||||
import fs from 'fs';
|
||||
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
// 构建后拷贝插件
|
||||
function copyToShellPlugin () {
|
||||
return {
|
||||
name: 'copy-to-shell',
|
||||
closeBundle () {
|
||||
try {
|
||||
const sourceDir = resolve(__dirname, 'dist');
|
||||
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin');
|
||||
const packageJsonSource = resolve(__dirname, 'package.json');
|
||||
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
console.log(`[copy-to-shell] Created directory: ${targetDir}`);
|
||||
}
|
||||
|
||||
// 拷贝 dist 目录下的所有文件
|
||||
const files = fs.readdirSync(sourceDir);
|
||||
let copiedCount = 0;
|
||||
|
||||
files.forEach(file => {
|
||||
const sourcePath = resolve(sourceDir, file);
|
||||
const targetPath = resolve(targetDir, file);
|
||||
|
||||
if (fs.statSync(sourcePath).isFile()) {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
copiedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 拷贝 package.json
|
||||
if (fs.existsSync(packageJsonSource)) {
|
||||
const packageJsonTarget = resolve(targetDir, 'package.json');
|
||||
fs.copyFileSync(packageJsonSource, packageJsonTarget);
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
|
||||
} catch (error) {
|
||||
console.error('[copy-to-shell] Failed to copy files:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@': resolve(__dirname, '../'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: 'index.ts',
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.mjs',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [...nodeModules],
|
||||
},
|
||||
},
|
||||
plugins: [nodeResolve(), copyToShellPlugin()],
|
||||
});
|
||||
@@ -3,5 +3,5 @@ REM 快速登录示例脚本
|
||||
REM -q 参数是可选的,不传则使用二维码登录
|
||||
REM
|
||||
REM 使用方法(删掉对应系统那行的 REM):
|
||||
REM ./launcher.bat -q 123456
|
||||
REM ./launcher-win10.bat -q 123456
|
||||
REM ./launcher-user.bat 123456
|
||||
REM ./launcher-win10-user.bat 123456
|
||||
|
||||
@@ -35,6 +35,7 @@ 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) => {
|
||||
@@ -127,10 +128,13 @@ async function handleLogin (
|
||||
|
||||
const loginListener = new NodeIKernelLoginListener();
|
||||
loginListener.onUserLoggedIn = (userid: string) => {
|
||||
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
|
||||
const tips = `当前账号(${userid})已登录,无法重复登录`;
|
||||
logger.logError(tips);
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
};
|
||||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||||
context.isLogined = true;
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
inner_resolve({
|
||||
uid: loginResult.uid,
|
||||
uin: loginResult.uin,
|
||||
@@ -169,13 +173,16 @@ async function handleLogin (
|
||||
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
|
||||
if (errType === 1 && errCode === 3) {
|
||||
// 二维码过期刷新
|
||||
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
|
||||
}
|
||||
loginService.getQRCodePicture();
|
||||
}
|
||||
};
|
||||
|
||||
loginListener.onLoginFailed = (...args) => {
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
|
||||
const errInfo = JSON.stringify(args);
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
|
||||
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
|
||||
};
|
||||
|
||||
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
|
||||
@@ -183,17 +190,29 @@ async function handleLogin (
|
||||
return await selfInfo;
|
||||
}
|
||||
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||||
// 注册刷新二维码回调
|
||||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||||
loginService.getQRCodePicture();
|
||||
});
|
||||
|
||||
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin) {
|
||||
logger.log('正在快速登录 ', uin);
|
||||
loginService.quickLoginWithUin(uin).then(res => {
|
||||
if (res.loginErrorInfo.errMsg) {
|
||||
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: res.loginErrorInfo.errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
resolve({ result: true, message: '' });
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '快速登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
@@ -208,6 +227,7 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
.then(result => {
|
||||
if (result.loginErrorInfo.errMsg) {
|
||||
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
})
|
||||
@@ -323,11 +343,11 @@ export async function NCoreInitShell () {
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
|
||||
// if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') {
|
||||
// await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
// }
|
||||
if (!(process.env['NAPCAT_DISABLE_PIPE'] == '1' || process.env['NAPCAT_WORKER_PROCESS'] == '1')) {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
@@ -451,6 +471,10 @@ export class NapCatShell {
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
// 监听下线通知并同步到 WebUI
|
||||
this.core.event.on('KickedOffLine', (tips: string) => {
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
});
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
@@ -458,4 +482,3 @@ export class NapCatShell {
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -18,6 +20,13 @@ const ENV = {
|
||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||
} as const;
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
secretKey?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
@@ -26,6 +35,8 @@ const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
let processManager: IProcessManager | null = null;
|
||||
let currentWorker: IWorkerProcess | null = null;
|
||||
let isElectron = false;
|
||||
let isRestarting = false;
|
||||
let isShuttingDown = false;
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
@@ -61,26 +72,34 @@ function isProcessAlive (pid: number): boolean {
|
||||
function forceKillProcess (pid: number): void {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
logger.log(`[NapCat] [Process] 已强制终止进程 ${pid}`);
|
||||
} catch (error) {
|
||||
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
|
||||
// SIGKILL 失败,在 Windows 上使用 taskkill 兜底
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
require('child_process').execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
logger.logError(`[NapCat] [Process] 强制终止进程失败: PID ${pid}`);
|
||||
}
|
||||
} else {
|
||||
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启 Worker 进程
|
||||
*/
|
||||
export async function restartWorker (): Promise<void> {
|
||||
logger.log('[NapCat] [Process] 正在重启Worker进程...');
|
||||
export async function restartWorker (secretKey?: string, port?: number): Promise<void> {
|
||||
isRestarting = true;
|
||||
|
||||
if (!currentWorker) {
|
||||
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
|
||||
await startWorker();
|
||||
await startWorker(false);
|
||||
isRestarting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const workerPid = currentWorker.pid;
|
||||
logger.log(`[NapCat] [Process] 准备关闭Worker进程,PID: ${workerPid}`);
|
||||
|
||||
// 1. 通知旧进程准备重启(旧进程会自行退出)
|
||||
currentWorker.postMessage({ type: 'restart-prepare' });
|
||||
@@ -101,45 +120,35 @@ export async function restartWorker (): Promise<void> {
|
||||
|
||||
currentWorker?.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
logger.log('[NapCat] [Process] Worker进程已正常退出');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 二次确认进程是否真的被终止(兜底检查)
|
||||
if (workerPid) {
|
||||
logger.log(`[NapCat] [Process] 检查进程 ${workerPid} 是否已终止...`);
|
||||
|
||||
if (workerPid && isProcessAlive(workerPid)) {
|
||||
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉`);
|
||||
forceKillProcess(workerPid);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (isProcessAlive(workerPid)) {
|
||||
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉(兜底)`);
|
||||
forceKillProcess(workerPid);
|
||||
|
||||
// 等待 1 秒后再次检查
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
if (isProcessAlive(workerPid)) {
|
||||
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
|
||||
} else {
|
||||
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已被强制终止`);
|
||||
}
|
||||
} else {
|
||||
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已确认终止`);
|
||||
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 等待 3 秒后启动新进程
|
||||
logger.log('[NapCat] [Process] Worker进程已关闭,等待 3 秒后启动新进程...');
|
||||
// 4. 等待后启动新进程
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// 5. 启动新进程
|
||||
await startWorker();
|
||||
logger.log('[NapCat] [Process] Worker进程重启完成');
|
||||
// 5. 启动新进程(重启模式不传递快速登录参数,传递密钥和端口)
|
||||
await startWorker(false, secretKey, port);
|
||||
isRestarting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Worker 进程
|
||||
* @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false
|
||||
* @param secretKey WebUI JWT 密钥
|
||||
* @param preferredPort 优先使用的 WebUI 端口
|
||||
*/
|
||||
async function startWorker (): Promise<void> {
|
||||
async function startWorker (passQuickLogin: boolean = true, secretKey?: string, preferredPort?: number): Promise<void> {
|
||||
if (!processManager) {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
@@ -147,10 +156,26 @@ async function startWorker (): Promise<void> {
|
||||
const workerScript = getWorkerScriptPath();
|
||||
const processType = getProcessTypeName();
|
||||
|
||||
const child = processManager.createWorker(workerScript, [], {
|
||||
// 只在首次启动时传递 -q 或 --qq 参数给 worker 进程
|
||||
const workerArgs: string[] = [];
|
||||
if (passQuickLogin) {
|
||||
const args = process.argv.slice(2);
|
||||
const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq');
|
||||
if (qIndex !== -1 && qIndex + 1 < args.length) {
|
||||
const qFlag = args[qIndex];
|
||||
const qValue = args[qIndex + 1];
|
||||
if (qFlag && qValue) {
|
||||
workerArgs.push(qFlag, qValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const child = processManager.createWorker(workerScript, workerArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
NAPCAT_WORKER_PROCESS: '1',
|
||||
...(secretKey ? { NAPCAT_WEBUI_JWT_SECRET_KEY: secretKey } : {}),
|
||||
...(preferredPort ? { NAPCAT_WEBUI_PREFERRED_PORT: String(preferredPort) } : {}),
|
||||
},
|
||||
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
@@ -173,14 +198,14 @@ async function startWorker (): Promise<void> {
|
||||
|
||||
// 监听子进程消息
|
||||
child.on('message', (msg: unknown) => {
|
||||
logger.log(`[NapCat] [${processType}] 收到Worker消息:`, msg);
|
||||
|
||||
// 处理重启请求
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
|
||||
logger.log(`[NapCat] [${processType}] 收到重启请求,正在重启Worker进程...`);
|
||||
restartWorker().catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
const message = msg as WorkerMessage;
|
||||
if (message.type === 'restart') {
|
||||
restartWorker(message.secretKey, message.port).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -189,13 +214,30 @@ async function startWorker (): Promise<void> {
|
||||
const exitCode = typeof code === 'number' ? code : 0;
|
||||
if (exitCode !== 0) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程退出,退出码: ${exitCode}`);
|
||||
} else {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程正常退出`);
|
||||
}
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出,正在尝试重新拉起...`);
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on('spawn', () => {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已生成`);
|
||||
// 等待进程成功 spawn
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSpawn = () => {
|
||||
child.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (...args: unknown[]) => {
|
||||
const err = args[0] as Error;
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程启动失败:`, err);
|
||||
child.off('spawn', onSpawn);
|
||||
reject(err);
|
||||
};
|
||||
child.once('spawn', onSpawn);
|
||||
child.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,25 +245,19 @@ async function startWorker (): Promise<void> {
|
||||
* 启动 Master 进程
|
||||
*/
|
||||
async function startMasterProcess (): Promise<void> {
|
||||
const processType = getProcessTypeName();
|
||||
logger.log(`[NapCat] [${processType}] Master进程启动,PID: ${process.pid}`);
|
||||
|
||||
// 连接命名管道(可通过环境变量禁用)
|
||||
if (!ENV.isPipeDisabled) {
|
||||
await connectToNamedPipe(logger).catch(e =>
|
||||
logger.logError('命名管道连接失败', e)
|
||||
);
|
||||
} else {
|
||||
logger.log(`[NapCat] [${processType}] 命名管道已禁用 (NAPCAT_DISABLE_PIPE=1)`);
|
||||
}
|
||||
|
||||
// 启动 Worker 进程
|
||||
await startWorker();
|
||||
|
||||
// 优雅关闭处理
|
||||
const shutdown = (signal: string) => {
|
||||
logger.log(`[NapCat] [Process] 收到${signal}信号,正在关闭...`);
|
||||
|
||||
const shutdown = () => {
|
||||
isShuttingDown = true;
|
||||
if (currentWorker) {
|
||||
currentWorker.postMessage({ type: 'shutdown' });
|
||||
setTimeout(() => {
|
||||
@@ -233,8 +269,8 @@ async function startMasterProcess (): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown());
|
||||
process.on('SIGTERM', () => shutdown());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,20 +281,25 @@ async function startWorkerProcess (): Promise<void> {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
|
||||
const processType = getProcessTypeName();
|
||||
logger.log(`[NapCat] [${processType}] Worker进程启动,PID: ${process.pid}`);
|
||||
// 预加载 Node Addon(如果设置了环境变量)
|
||||
const preloadAddonPath = process.env['NAPCAT_PRELOAD_NODE_ADDON_PATH'];
|
||||
if (preloadAddonPath) {
|
||||
try {
|
||||
const os = await import('os');
|
||||
process.dlopen({ exports: {} }, preloadAddonPath, os.constants.dlopen.RTLD_NOW | os.constants.dlopen.RTLD_GLOBAL);
|
||||
logger.log(`[NapCat] [Worker] 已预加载 Node Addon: ${preloadAddonPath}`);
|
||||
} catch (error) {
|
||||
logger.logError(`[NapCat] [Worker] 预加载 Node Addon 失败: ${preloadAddonPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听来自父进程的消息
|
||||
processManager.onParentMessage((msg: unknown) => {
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
if (msg.type === 'restart-prepare') {
|
||||
logger.log(`[NapCat] [${processType}] 收到重启准备信号,正在主动退出...`);
|
||||
if (msg.type === 'restart-prepare' || msg.type === 'shutdown') {
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
} else if (msg.type === 'shutdown') {
|
||||
logger.log(`[NapCat] [${processType}] 收到关闭信号,正在退出...`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -266,7 +307,11 @@ async function startWorkerProcess (): Promise<void> {
|
||||
// 注册重启进程函数到 WebUI
|
||||
WebUiDataRuntime.setRestartProcessCall(async () => {
|
||||
try {
|
||||
const success = processManager!.sendToParent({ type: 'restart' });
|
||||
const success = processManager!.sendToParent({
|
||||
type: 'restart',
|
||||
secretKey: AuthHelper.getSecretKey(),
|
||||
port: webUiRuntimePort,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
return { result: true, message: '进程重启请求已发送' };
|
||||
@@ -292,7 +337,6 @@ async function startWorkerProcess (): Promise<void> {
|
||||
async function main (): Promise<void> {
|
||||
// 单进程模式:直接启动核心
|
||||
if (ENV.isMultiProcessDisabled) {
|
||||
logger.log('[NapCat] [SingleProcess] 多进程模式已禁用,直接启动核心');
|
||||
await NCoreInitShell();
|
||||
return;
|
||||
}
|
||||
@@ -302,8 +346,6 @@ async function main (): Promise<void> {
|
||||
processManager = result.manager;
|
||||
isElectron = result.isElectron;
|
||||
|
||||
logger.log(`[NapCat] [Process] 检测到 ${isElectron ? 'Electron' : 'Node.js'} 环境`);
|
||||
|
||||
// 根据进程类型启动
|
||||
if (ENV.isWorkerProcess) {
|
||||
await startWorkerProcess();
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface IWorkerProcess {
|
||||
kill (): boolean;
|
||||
on (event: string, listener: (...args: unknown[]) => void): void;
|
||||
once (event: string, listener: (...args: unknown[]) => void): void;
|
||||
off (event: string, listener: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +61,7 @@ class ElectronProcessManager implements IProcessManager {
|
||||
const child: any = this.utilityProcess.fork(modulePath, args, options);
|
||||
|
||||
return {
|
||||
pid: child.pid as number | undefined,
|
||||
get pid () { return child.pid as number | undefined; },
|
||||
stdout: child.stdout as Readable | null,
|
||||
stderr: child.stderr as Readable | null,
|
||||
|
||||
@@ -79,6 +80,10 @@ class ElectronProcessManager implements IProcessManager {
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
|
||||
off (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.off(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ class NodeProcessManager implements IProcessManager {
|
||||
const child = this.forkFn(modulePath, args, options as any);
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
get pid () { return child.pid; },
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
|
||||
@@ -134,6 +139,10 @@ class NodeProcessManager implements IProcessManager {
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
|
||||
off (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.off(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,6 +173,9 @@ export async function createProcessManager (): Promise<{
|
||||
if (isElectron) {
|
||||
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
||||
const electron = await import('electron');
|
||||
if (electron.app && !electron.app.isReady()) {
|
||||
await electron.app.whenReady();
|
||||
}
|
||||
return {
|
||||
manager: new ElectronProcessManager(electron.utilityProcess),
|
||||
isElectron: true,
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "napcat-vite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
"name": "napcat-vite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"_build": "vite build"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,19 @@ export function setPendingTokenToSend (token: string | null) {
|
||||
export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
|
||||
try {
|
||||
await tryUseHost(parsedConfig.host);
|
||||
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||||
const preferredPort = parseInt(process.env['NAPCAT_WEBUI_PREFERRED_PORT'] || '', 10);
|
||||
|
||||
let port: number;
|
||||
if (preferredPort > 0) {
|
||||
try {
|
||||
port = await tryUsePort(preferredPort, parsedConfig.host, 0, true);
|
||||
} catch {
|
||||
port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||||
}
|
||||
} else {
|
||||
port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||||
}
|
||||
|
||||
return [parsedConfig.host, port, parsedConfig.token];
|
||||
} catch (error) {
|
||||
console.log('host或port不可用', error);
|
||||
@@ -356,7 +368,7 @@ async function tryUseHost (host: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function tryUsePort (port: number, host: string, tryCount: number = 0): Promise<number> {
|
||||
async function tryUsePort (port: number, host: string, tryCount: number = 0, singleTry: boolean = false): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const server = net.createServer();
|
||||
@@ -367,9 +379,12 @@ async function tryUsePort (port: number, host: string, tryCount: number = 0): Pr
|
||||
|
||||
server.on('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
if (tryCount < MAX_PORT_TRY) {
|
||||
// 使用循环代替递归
|
||||
resolve(tryUsePort(port + 1, host, tryCount + 1));
|
||||
if (singleTry) {
|
||||
// 只尝试一次,端口被占用则直接失败
|
||||
reject(new Error(`端口 ${port} 已被占用`));
|
||||
} else if (tryCount < MAX_PORT_TRY) {
|
||||
// 递归尝试下一个端口
|
||||
resolve(tryUsePort(port + 1, host, tryCount + 1, false));
|
||||
} else {
|
||||
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
|
||||
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
|
||||
import { getLatestActionArtifacts, getMirrorConfig } from '@/napcat-common/src/mirror';
|
||||
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
||||
|
||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||
@@ -35,6 +35,7 @@ export interface VersionInfo {
|
||||
size?: number;
|
||||
workflowRunId?: number;
|
||||
headSha?: string;
|
||||
workflowTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,11 +48,17 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
|
||||
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
|
||||
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
|
||||
const mirror = req.query['mirror'] as string | undefined;
|
||||
|
||||
let versions: VersionInfo[] = [];
|
||||
let actionVersions: VersionInfo[] = [];
|
||||
let usedMirror = '';
|
||||
|
||||
// If mirror is specified, report it as used (will be confirmed by actual fetching response)
|
||||
if (mirror) {
|
||||
usedMirror = mirror;
|
||||
}
|
||||
|
||||
// 懒加载:只获取需要的版本类型
|
||||
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
|
||||
const needActions = typeFilter === 'action' || typeFilter === 'all';
|
||||
@@ -59,9 +66,12 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
// 获取正式版本(仅当需要时)
|
||||
if (needReleases) {
|
||||
try {
|
||||
const result = await getAllTags();
|
||||
usedMirror = result.mirror;
|
||||
|
||||
const result = await getAllTags(mirror);
|
||||
// 如果没有指定镜像,使用实际上使用的镜像
|
||||
if (!mirror) {
|
||||
usedMirror = result.mirror;
|
||||
}
|
||||
|
||||
versions = result.tags.map(tag => {
|
||||
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
|
||||
return {
|
||||
@@ -81,14 +91,19 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
// 获取 Action Artifacts(仅当需要时)
|
||||
if (needActions) {
|
||||
try {
|
||||
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
|
||||
const { artifacts, mirror: actionMirror } = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main', 10, mirror);
|
||||
|
||||
// 根据当前工作环境自动过滤对应的 artifact 类型
|
||||
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
|
||||
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
|
||||
|
||||
// 如果没有指定镜像,且 action 实际上用了一个镜像(自动选择的),更新 usedMirror
|
||||
if (!mirror && actionMirror) {
|
||||
usedMirror = actionMirror;
|
||||
}
|
||||
|
||||
actionVersions = artifacts
|
||||
.filter(a => a.name === targetArtifactName)
|
||||
.filter(a => a && a.name === targetArtifactName)
|
||||
.map(a => ({
|
||||
tag: `action-${a.id}`,
|
||||
type: 'action' as const,
|
||||
@@ -99,6 +114,7 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
size: a.size_in_bytes,
|
||||
workflowRunId: a.workflow_run_id,
|
||||
headSha: a.head_sha,
|
||||
workflowTitle: a.workflow_title,
|
||||
}));
|
||||
} catch {
|
||||
// 获取失败时返回空列表
|
||||
@@ -114,7 +130,9 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
allVersions = allVersions.filter(v => {
|
||||
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
|
||||
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
|
||||
return tagMatch || nameMatch;
|
||||
const titleMatch = v.workflowTitle?.toLowerCase().includes(searchQuery);
|
||||
const shaMatch = v.headSha?.toLowerCase().includes(searchQuery);
|
||||
return tagMatch || nameMatch || titleMatch || shaMatch;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,3 +173,8 @@ export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
|
||||
await WebUiConfig.UpdateTheme(theme);
|
||||
sendSuccess(res, { message: '更新成功' });
|
||||
};
|
||||
|
||||
export const GetMirrorsHandler: RequestHandler = (_, res) => {
|
||||
const config = getMirrorConfig();
|
||||
sendSuccess(res, { mirrors: config.fileMirrors });
|
||||
};
|
||||
|
||||
@@ -1,231 +1,227 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { WebSocket, WebSocketServer, RawData } from 'ws';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { WebsocketServerConfig } from '@/napcat-onebot/config/config';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
import json5 from 'json5';
|
||||
|
||||
const router = Router();
|
||||
type ActionNameType = typeof ActionName[keyof typeof ActionName];
|
||||
|
||||
const router: Router = Router();
|
||||
const DEFAULT_ADAPTER_NAME = 'debug-primary';
|
||||
|
||||
/**
|
||||
* 统一的调试适配器
|
||||
* 用于注入到 OneBot NetworkManager,接收所有事件并转发给 WebSocket 客户端
|
||||
*/
|
||||
class DebugAdapter {
|
||||
name: string;
|
||||
isEnable: boolean = true;
|
||||
// 安全令牌
|
||||
class DebugAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||
readonly token: string;
|
||||
|
||||
// 添加 config 属性,模拟 PluginConfig 结构
|
||||
config: {
|
||||
enable: boolean;
|
||||
name: string;
|
||||
messagePostFormat?: string;
|
||||
reportSelfMessage?: boolean;
|
||||
debug?: boolean;
|
||||
token?: string;
|
||||
heartInterval?: number;
|
||||
};
|
||||
wsClients: Set<WebSocket> = new Set();
|
||||
wsClients: WebSocket[] = [];
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
lastActivityTime: number = Date.now();
|
||||
inactivityTimer: NodeJS.Timeout | null = null;
|
||||
readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃
|
||||
|
||||
constructor (sessionId: string) {
|
||||
this.name = `debug-${sessionId}`;
|
||||
// 生成简单的随机 token
|
||||
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
this.config = {
|
||||
constructor (sessionId: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
|
||||
const config: WebsocketServerConfig = {
|
||||
enable: true,
|
||||
name: this.name,
|
||||
name: `debug-${sessionId}`,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
token: '',
|
||||
enableForcePushEvent: true,
|
||||
debug: true,
|
||||
token: this.token,
|
||||
heartInterval: 30000
|
||||
heartInterval: 0
|
||||
};
|
||||
|
||||
super(`debug-${sessionId}`, config, core, obContext, actions);
|
||||
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
this.isEnable = false;
|
||||
this.startInactivityCheck();
|
||||
}
|
||||
|
||||
// 实现 IOB11NetworkAdapter 接口所需的抽象方法
|
||||
async open (): Promise<void> { }
|
||||
async close (): Promise<void> { this.cleanup(); }
|
||||
async reload (_config: any): Promise<any> { return 0; }
|
||||
|
||||
/**
|
||||
* OneBot 事件回调 - 转发给所有 WebSocket 客户端 (原始流)
|
||||
*/
|
||||
async onEvent (event: any) {
|
||||
this.updateActivity();
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
|
||||
if (this.wsClients.size === 0) {
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) {
|
||||
this.logger.logError('[Debug] Cannot open an already opened adapter');
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsClients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (error) {
|
||||
console.error('[Debug] 发送事件到 WebSocket 失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.logger.log('[Debug] Adapter opened:', this.name);
|
||||
this.isEnable = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OneBot API (HTTP 接口使用)
|
||||
*/
|
||||
async callApi (actionName: string, params: any): Promise<any> {
|
||||
this.updateActivity();
|
||||
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
throw new Error('OneBot 未初始化');
|
||||
}
|
||||
|
||||
const action = oneBotContext.actions.get(actionName);
|
||||
if (!action) {
|
||||
throw new Error(`不支持的 API: ${actionName}`);
|
||||
}
|
||||
|
||||
return await action.handle(params, this.name, {
|
||||
name: this.name,
|
||||
enable: true,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
debug: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息 (OneBot 标准)
|
||||
*/
|
||||
async handleWsMessage (ws: WebSocket, message: string | Buffer) {
|
||||
this.updateActivity();
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
|
||||
try {
|
||||
receiveData = JSON.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
this.sendWsResponse(ws, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
this.logger.log('[Debug] Adapter closing:', this.name);
|
||||
this.isEnable = false;
|
||||
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};
|
||||
|
||||
// 兼容 WebUI 之前可能的一些非标准格式 (如果用户是旧前端)
|
||||
// 但既然用户说要"原始流",我们优先支持标准格式
|
||||
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
this.sendWsResponse(ws, OB11Response.error('OneBot 未初始化', 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
const action = oneBotContext.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.sendWsResponse(ws, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
this.sendWsResponse(ws, OB11Response.ok(data, echo ?? '', true));
|
||||
},
|
||||
});
|
||||
this.sendWsResponse(ws, retdata);
|
||||
} catch (e: any) {
|
||||
this.sendWsResponse(ws, OB11Response.error(e.message || '内部错误', 1200, echo));
|
||||
}
|
||||
}
|
||||
|
||||
sendWsResponse (ws: WebSocket, data: any) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 WebSocket 客户端
|
||||
*/
|
||||
addWsClient (ws: WebSocket) {
|
||||
this.wsClients.add(ws);
|
||||
this.updateActivity();
|
||||
|
||||
// 发送生命周期事件 (Connect)
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext && oneBotContext.core) {
|
||||
try {
|
||||
const event = new OB11LifeCycleEvent(oneBotContext.core, LifeCycleSubType.CONNECT);
|
||||
ws.send(JSON.stringify(event));
|
||||
} catch (e) {
|
||||
console.error('[Debug] 发送生命周期事件失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 WebSocket 客户端
|
||||
*/
|
||||
removeWsClient (ws: WebSocket) {
|
||||
this.wsClients.delete(ws);
|
||||
}
|
||||
|
||||
updateActivity () {
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
startInactivityCheck () {
|
||||
this.inactivityTimer = setInterval(() => {
|
||||
const inactive = Date.now() - this.lastActivityTime;
|
||||
// 如果没有 WebSocket 连接且超时,则自动清理
|
||||
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.size === 0) {
|
||||
console.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
|
||||
this.cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
cleanup () {
|
||||
// 停止不活跃检查定时器
|
||||
if (this.inactivityTimer) {
|
||||
clearInterval(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
|
||||
// 关闭所有 WebSocket 连接
|
||||
// 关闭所有 WebSocket 连接并移除事件监听器
|
||||
this.wsClients.forEach((client) => {
|
||||
try {
|
||||
client.removeAllListeners();
|
||||
client.close();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
this.logger.logError('[Debug] 关闭 WebSocket 失败:', error);
|
||||
}
|
||||
});
|
||||
this.wsClients.clear();
|
||||
|
||||
// 从 OneBot NetworkManager 移除
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.delete(this.name);
|
||||
}
|
||||
|
||||
// 从管理器中移除
|
||||
debugAdapterManager.removeAdapter(this.name);
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
}
|
||||
|
||||
async reload (_config: unknown): Promise<OB11NetworkReloadType> {
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
|
||||
this.updateActivity();
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsClient.send(payload);
|
||||
} catch (error) {
|
||||
this.logger.logError('[Debug] 发送事件失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callApi (actionName: ActionNameType, params: Record<string, unknown>): Promise<unknown> {
|
||||
this.updateActivity();
|
||||
|
||||
const action = this.actions.get(actionName as Parameters<typeof this.actions.get>[0]);
|
||||
if (!action) {
|
||||
throw new Error(`不支持的 API: ${actionName}`);
|
||||
}
|
||||
|
||||
type ActionHandler = { handle: (params: unknown, ...args: unknown[]) => Promise<unknown>; };
|
||||
return await (action as ActionHandler).handle(params, this.name, this.config);
|
||||
}
|
||||
|
||||
private async handleMessage (wsClient: WebSocket, message: RawData): Promise<void> {
|
||||
this.updateActivity();
|
||||
let receiveData: { action: ActionNameType, params?: Record<string, unknown>, echo?: unknown; } = {
|
||||
action: ActionName.Unknown,
|
||||
params: {}
|
||||
};
|
||||
let echo: unknown = undefined;
|
||||
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
this.sendToClient(wsClient, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
receiveData.params = receiveData?.params || {};
|
||||
|
||||
const action = this.actions.get(receiveData.action as Parameters<typeof this.actions.get>[0]);
|
||||
if (!action) {
|
||||
this.logger.logError('[Debug] 不支持的API:', receiveData.action);
|
||||
this.sendToClient(wsClient, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
type ActionHandler = { websocketHandle: (params: unknown, ...args: unknown[]) => Promise<unknown>; };
|
||||
const retdata = await (action as ActionHandler).websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
this.sendToClient(wsClient, OB11Response.ok(data, echo ?? '', true));
|
||||
},
|
||||
});
|
||||
this.sendToClient(wsClient, retdata);
|
||||
} catch (e: unknown) {
|
||||
const error = e as Error;
|
||||
this.logger.logError('[Debug] 处理消息失败:', error);
|
||||
this.sendToClient(wsClient, OB11Response.error(error.message || '内部错误', 1200, echo));
|
||||
}
|
||||
}
|
||||
|
||||
private sendToClient (wsClient: WebSocket, data: unknown): void {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
} catch (error) {
|
||||
this.logger.logError('[Debug] 发送消息失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async addWsClient (ws: WebSocket): Promise<void> {
|
||||
this.wsClientWithEvent.push(ws);
|
||||
this.wsClients.push(ws);
|
||||
this.updateActivity();
|
||||
|
||||
// 发送连接事件
|
||||
this.sendToClient(ws, new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT));
|
||||
|
||||
ws.on('error', (err) => this.logger.log('[Debug] WebSocket Error:', err.message));
|
||||
ws.on('message', (message) => {
|
||||
this.handleMessage(ws, message).catch((e: unknown) => {
|
||||
this.logger.logError('[Debug] handleMessage error:', e);
|
||||
});
|
||||
});
|
||||
ws.on('ping', () => ws.pong());
|
||||
ws.once('close', () => this.removeWsClient(ws));
|
||||
}
|
||||
|
||||
private removeWsClient (ws: WebSocket): void {
|
||||
const normalIndex = this.wsClients.indexOf(ws);
|
||||
if (normalIndex !== -1) {
|
||||
this.wsClients.splice(normalIndex, 1);
|
||||
}
|
||||
const eventIndex = this.wsClientWithEvent.indexOf(ws);
|
||||
if (eventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(eventIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
updateActivity (): void {
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
startInactivityCheck (): void {
|
||||
this.inactivityTimer = setInterval(() => {
|
||||
const inactive = Date.now() - this.lastActivityTime;
|
||||
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.length === 0) {
|
||||
this.logger.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
// 先从管理器移除,避免重复销毁
|
||||
debugAdapterManager.removeAdapter(this.name);
|
||||
// 使用 NetworkManager 标准流程关闭
|
||||
oneBotContext.networkManager.closeSomeAdapters([this]).catch((e: unknown) => {
|
||||
this.logger.logError('[Debug] 自动关闭适配器失败:', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
validateToken (inputToken: string): boolean {
|
||||
return this.token === inputToken;
|
||||
}
|
||||
@@ -244,17 +240,20 @@ class DebugAdapterManager {
|
||||
return this.currentAdapter;
|
||||
}
|
||||
|
||||
// 获取 OneBot 上下文
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
throw new Error('OneBot 未初始化,无法创建调试适配器');
|
||||
}
|
||||
|
||||
// 创建新实例
|
||||
const adapter = new DebugAdapter('primary');
|
||||
const adapter = new DebugAdapter('primary', oneBotContext.core, oneBotContext, oneBotContext.actions);
|
||||
this.currentAdapter = adapter;
|
||||
|
||||
// 注册到 OneBot NetworkManager
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.set(adapter.name, adapter as any);
|
||||
} else {
|
||||
console.warn('[Debug] OneBot 未初始化,无法注册适配器');
|
||||
}
|
||||
// 使用 NetworkManager 标准流程注册并打开适配器
|
||||
oneBotContext.networkManager.registerAdapterAndOpen(adapter).catch((e: unknown) => {
|
||||
console.error('[Debug] 注册适配器失败:', e);
|
||||
});
|
||||
|
||||
return adapter;
|
||||
}
|
||||
@@ -286,8 +285,9 @@ router.post('/create', async (_req: Request, res: Response) => {
|
||||
token: adapter.token,
|
||||
message: '调试适配器已就绪',
|
||||
});
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
sendError(res, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -310,10 +310,11 @@ const handleCallApi = async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const { action, params } = req.body;
|
||||
const result = await adapter.callApi(action, params || {});
|
||||
const result = await adapter.callApi(action as ActionNameType, params || {});
|
||||
sendSuccess(res, result);
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
sendError(res, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -329,10 +330,25 @@ router.post('/close/:adapterName', async (req: Request, res: Response) => {
|
||||
if (!adapterName) {
|
||||
return sendError(res, '缺少 adapterName 参数');
|
||||
}
|
||||
|
||||
const adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
if (!adapter) {
|
||||
return sendError(res, '调试适配器不存在');
|
||||
}
|
||||
|
||||
// 先从管理器移除,避免重复销毁
|
||||
debugAdapterManager.removeAdapter(adapterName);
|
||||
|
||||
// 使用 NetworkManager 标准流程关闭适配器
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
await oneBotContext.networkManager.closeSomeAdapters([adapter]);
|
||||
}
|
||||
|
||||
sendSuccess(res, { message: '调试适配器已关闭' });
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
sendError(res, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -340,7 +356,7 @@ router.post('/close/:adapterName', async (req: Request, res: Response) => {
|
||||
* WebSocket 连接处理
|
||||
* 路径: /api/Debug/ws?adapterName=xxx&token=xxx
|
||||
*/
|
||||
export function handleDebugWebSocket (request: IncomingMessage, socket: any, head: any) {
|
||||
export function handleDebugWebSocket (request: IncomingMessage, socket: unknown, head: unknown) {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
let adapterName = url.searchParams.get('adapterName');
|
||||
const token = url.searchParams.get('token') || url.searchParams.get('access_token');
|
||||
@@ -353,8 +369,8 @@ export function handleDebugWebSocket (request: IncomingMessage, socket: any, hea
|
||||
// Debug session should provide token
|
||||
if (!token) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -362,43 +378,37 @@ export function handleDebugWebSocket (request: IncomingMessage, socket: any, hea
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
try {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
} catch (error) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 无法创建适配器', error);
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在');
|
||||
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
socket.destroy();
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adapter.validateToken(token)) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: Token 无效');
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
(socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
(socket as { destroy: () => void; }).destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建 WebSocket 服务器
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
||||
adapter.addWsClient(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
await adapter.handleWsMessage(ws, data as any);
|
||||
} catch (error: any) {
|
||||
console.error('[Debug] handleWsMessage error', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
adapter.removeWsClient(ws);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
adapter.removeWsClient(ws);
|
||||
wsServer.handleUpgrade(request, socket as never, head as Buffer, (ws) => {
|
||||
adapter.addWsClient(ws).catch((e: unknown) => {
|
||||
console.error('[Debug] 添加 WebSocket 客户端失败:', e);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
226
packages/napcat-webui-backend/src/api/Plugin.ts
Normal file
226
packages/napcat-webui-backend/src/api/Plugin.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) return null;
|
||||
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
};
|
||||
|
||||
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
// 返回成功但带特殊标记
|
||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
|
||||
}
|
||||
|
||||
// 辅助函数:根据文件名/路径生成唯一ID(作为配置键)
|
||||
const getPluginId = (fsName: string, isFile: boolean): string => {
|
||||
if (isFile) {
|
||||
return path.parse(fsName).name;
|
||||
}
|
||||
return fsName;
|
||||
};
|
||||
|
||||
const loadedPlugins = pluginManager.getLoadedPlugins();
|
||||
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
|
||||
|
||||
// 1. 整理已加载的插件
|
||||
for (const p of loadedPlugins) {
|
||||
// 计算 ID:需要回溯到加载时的入口信息
|
||||
// 对于已加载的插件,我们通过判断 pluginPath 是否等于根 pluginPath 来判断它是单文件还是目录
|
||||
const isFilePlugin = p.pluginPath === pluginManager.getPluginPath();
|
||||
const fsName = isFilePlugin ? path.basename(p.entryPath) : path.basename(p.pluginPath);
|
||||
const id = getPluginId(fsName, isFilePlugin);
|
||||
|
||||
loadedPluginMap.set(id, {
|
||||
name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name
|
||||
id: id,
|
||||
version: p.version || '0.0.0',
|
||||
description: p.packageJson?.description || '',
|
||||
author: p.packageJson?.author || '',
|
||||
status: 'active',
|
||||
filename: fsName, // 真实文件/目录名
|
||||
loadedName: p.name // 运行时注册的名称,用于重载/卸载
|
||||
});
|
||||
}
|
||||
|
||||
const pluginPath = pluginManager.getPluginPath();
|
||||
const pluginConfig = pluginManager.getPluginConfig();
|
||||
const allPlugins: any[] = [];
|
||||
|
||||
// 2. 扫描文件系统,合并状态
|
||||
if (fs.existsSync(pluginPath)) {
|
||||
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
let id = '';
|
||||
|
||||
if (item.isFile()) {
|
||||
if (!['.js', '.mjs'].includes(path.extname(item.name))) continue;
|
||||
id = getPluginId(item.name, true);
|
||||
} else if (item.isDirectory()) {
|
||||
id = getPluginId(item.name, false);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
|
||||
|
||||
if (loadedPluginMap.has(id)) {
|
||||
// 已加载,使用加载的信息
|
||||
const loadedInfo = loadedPluginMap.get(id);
|
||||
allPlugins.push(loadedInfo);
|
||||
} else {
|
||||
// 未加载 (可能是被禁用,或者加载失败,或者新增未运行)
|
||||
let version = '0.0.0';
|
||||
let description = '';
|
||||
let author = '';
|
||||
// 默认显示名称为 ID (文件名/目录名)
|
||||
let name = id;
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json 获取信息
|
||||
if (item.isDirectory()) {
|
||||
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
version = pkg.version || version;
|
||||
description = pkg.description || description;
|
||||
author = pkg.author || author;
|
||||
// 如果 package.json 有 name,优先使用
|
||||
name = pkg.name || name;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
allPlugins.push({
|
||||
name: name,
|
||||
id: id,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
// 如果配置是 false,则为 disabled;否则是 stopped (应启动但未启动)
|
||||
status: isActiveConfig ? 'stopped' : 'disabled',
|
||||
filename: item.name
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
|
||||
};
|
||||
|
||||
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { name } = req.body;
|
||||
// Note: we should probably accept ID or Name. But ReloadPlugin uses valid loaded name.
|
||||
// Let's stick to name for now, but be aware of ambiguity.
|
||||
if (!name) return sendError(res, 'Plugin Name is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, '插件管理器未加载,请检查 plugins 目录是否存在');
|
||||
}
|
||||
|
||||
const success = await pluginManager.reloadPlugin(name);
|
||||
if (success) {
|
||||
return sendSuccess(res, { message: 'Reloaded successfully' });
|
||||
} else {
|
||||
return sendError(res, 'Failed to reload plugin');
|
||||
}
|
||||
};
|
||||
|
||||
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
const { enable, filename } = req.body;
|
||||
// We Use filename / id to control config
|
||||
// Front-end should pass the 'filename' or 'id' as the key identifier
|
||||
|
||||
if (!filename) return sendError(res, 'Plugin Filename/ID is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// Calculate ID from filename (remove ext if file)
|
||||
// Or just use the logic consistent with loadPlugins
|
||||
let id = filename;
|
||||
// If it has extension .js/.mjs, remove it to get the ID used in config
|
||||
if (filename.endsWith('.js') || filename.endsWith('.mjs')) {
|
||||
id = path.parse(filename).name;
|
||||
}
|
||||
|
||||
try {
|
||||
pluginManager.setPluginStatus(id, enable);
|
||||
|
||||
// If enabling, trigger load
|
||||
if (enable) {
|
||||
const pluginPath = pluginManager.getPluginPath();
|
||||
const fullPath = path.join(pluginPath, filename);
|
||||
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
await pluginManager.loadDirectoryPlugin(filename);
|
||||
} else {
|
||||
await pluginManager.loadFilePlugin(filename);
|
||||
}
|
||||
} else {
|
||||
// Disabling is handled inside setPluginStatus usually if implemented,
|
||||
// OR we can explicitly unload here using the loaded name.
|
||||
// The Manager's setPluginStatus implementation (if added) might logic this out.
|
||||
// But our current Manager implementation just saves config.
|
||||
// Wait, I updated Manager to try to unload.
|
||||
// Let's rely on Manager's setPluginStatus or do it here?
|
||||
// I implemented a basic unload loop in Manager.setPluginStatus.
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Status updated successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to update status: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { name, filename } = req.body;
|
||||
// If it's loaded, we use name. If it's disabled, we might use filename.
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// Check if loaded
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
let fsPath = '';
|
||||
|
||||
if (plugin) {
|
||||
// Active plugin
|
||||
await pluginManager.unregisterPlugin(name);
|
||||
if (plugin.pluginPath === pluginManager.getPluginPath()) {
|
||||
fsPath = plugin.entryPath;
|
||||
} else {
|
||||
fsPath = plugin.pluginPath;
|
||||
}
|
||||
} else {
|
||||
// Disabled or not loaded
|
||||
if (filename) {
|
||||
fsPath = path.join(pluginManager.getPluginPath(), filename);
|
||||
} else {
|
||||
return sendError(res, 'Plugin not found, provide filename if disabled');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fsPath)) {
|
||||
fs.rmSync(fsPath, { recursive: true, force: true });
|
||||
}
|
||||
return sendSuccess(res, { message: 'Uninstalled successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to uninstall: ' + e.message);
|
||||
}
|
||||
};
|
||||
177
packages/napcat-webui-backend/src/api/PluginStore.ts
Normal file
177
packages/napcat-webui-backend/src/api/PluginStore.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
|
||||
|
||||
// Mock数据 - 模拟远程插件列表
|
||||
const mockPluginStoreData: PluginStoreList = {
|
||||
version: '1.0.0',
|
||||
updateTime: new Date().toISOString(),
|
||||
plugins: [
|
||||
{
|
||||
id: 'napcat-plugin-example',
|
||||
name: '示例插件',
|
||||
version: '1.0.0',
|
||||
description: '这是一个示例插件,展示如何开发NapCat插件',
|
||||
author: 'NapCat Team',
|
||||
homepage: 'https://github.com/NapNeko/NapCatQQ',
|
||||
repository: 'https://github.com/NapNeko/NapCatQQ',
|
||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-example-1.0.0.zip',
|
||||
tags: ['示例', '教程'],
|
||||
screenshots: ['https://picsum.photos/800/600?random=1'],
|
||||
minNapCatVersion: '1.0.0',
|
||||
downloads: 1234,
|
||||
rating: 4.5,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-20T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'napcat-plugin-auto-reply',
|
||||
name: '自动回复插件',
|
||||
version: '2.1.0',
|
||||
description: '支持关键词匹配的自动回复功能,可配置多种回复规则',
|
||||
author: 'Community',
|
||||
homepage: 'https://github.com/example/auto-reply',
|
||||
repository: 'https://github.com/example/auto-reply',
|
||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-auto-reply-2.1.0.zip',
|
||||
tags: ['自动回复', '消息处理'],
|
||||
minNapCatVersion: '1.0.0',
|
||||
downloads: 5678,
|
||||
rating: 4.8,
|
||||
createdAt: '2024-01-05T00:00:00Z',
|
||||
updatedAt: '2024-01-22T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'napcat-plugin-welcome',
|
||||
name: '入群欢迎插件',
|
||||
version: '1.2.3',
|
||||
description: '新成员入群时自动发送欢迎消息,支持自定义欢迎语',
|
||||
author: 'Developer',
|
||||
homepage: 'https://github.com/example/welcome',
|
||||
repository: 'https://github.com/example/welcome',
|
||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-welcome-1.2.3.zip',
|
||||
tags: ['欢迎', '群管理'],
|
||||
minNapCatVersion: '1.0.0',
|
||||
downloads: 3456,
|
||||
rating: 4.3,
|
||||
createdAt: '2024-01-10T00:00:00Z',
|
||||
updatedAt: '2024-01-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'napcat-plugin-music',
|
||||
name: '音乐点歌插件',
|
||||
version: '3.0.1',
|
||||
description: '支持网易云、QQ音乐等平台的点歌功能',
|
||||
author: 'Music Lover',
|
||||
homepage: 'https://github.com/example/music',
|
||||
repository: 'https://github.com/example/music',
|
||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-music-3.0.1.zip',
|
||||
tags: ['音乐', '娱乐'],
|
||||
screenshots: ['https://picsum.photos/800/600?random=4', 'https://picsum.photos/800/600?random=5'],
|
||||
minNapCatVersion: '1.1.0',
|
||||
downloads: 8901,
|
||||
rating: 4.9,
|
||||
createdAt: '2023-12-01T00:00:00Z',
|
||||
updatedAt: '2024-01-23T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'napcat-plugin-admin',
|
||||
name: '群管理插件',
|
||||
version: '2.5.0',
|
||||
description: '提供踢人、禁言、设置管理员等群管理功能',
|
||||
author: 'Admin Tools',
|
||||
homepage: 'https://github.com/example/admin',
|
||||
repository: 'https://github.com/example/admin',
|
||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-admin-2.5.0.zip',
|
||||
tags: ['管理', '群管理', '工具'],
|
||||
minNapCatVersion: '1.0.0',
|
||||
downloads: 6789,
|
||||
rating: 4.6,
|
||||
createdAt: '2023-12-15T00:00:00Z',
|
||||
updatedAt: '2024-01-21T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'napcat-plugin-image-search',
|
||||
name: '以图搜图插件',
|
||||
version: '1.5.2',
|
||||
description: '支持多个搜图引擎,快速找到图片来源',
|
||||
author: 'Image Hunter',
|
||||
homepage: 'https://github.com/example/image-search',
|
||||
repository: 'https://github.com/example/image-search',
|
||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-image-search-1.5.2.zip',
|
||||
tags: ['图片', '搜索', '工具'],
|
||||
minNapCatVersion: '1.0.0',
|
||||
downloads: 4567,
|
||||
rating: 4.4,
|
||||
createdAt: '2024-01-08T00:00:00Z',
|
||||
updatedAt: '2024-01-19T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
* 未来可以从远程URL读取
|
||||
*/
|
||||
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
// TODO: 未来从远程URL读取
|
||||
// const remoteUrl = 'https://napcat.example.com/plugin-list.json';
|
||||
// const response = await fetch(remoteUrl);
|
||||
// const data = await response.json();
|
||||
|
||||
// 目前返回Mock数据
|
||||
return sendSuccess(res, mockPluginStoreData);
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个插件详情
|
||||
*/
|
||||
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
|
||||
|
||||
if (!plugin) {
|
||||
return sendError(res, 'Plugin not found');
|
||||
}
|
||||
|
||||
return sendSuccess(res, plugin);
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to fetch plugin detail: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)
|
||||
* TODO: 实现实际的下载和安装逻辑
|
||||
*/
|
||||
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.body;
|
||||
|
||||
if (!id) {
|
||||
return sendError(res, 'Plugin ID is required');
|
||||
}
|
||||
|
||||
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
|
||||
|
||||
if (!plugin) {
|
||||
return sendError(res, 'Plugin not found in store');
|
||||
}
|
||||
|
||||
// TODO: 实现实际的下载和安装逻辑
|
||||
// 1. 下载插件文件
|
||||
// 2. 解压到插件目录
|
||||
// 3. 加载插件
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installation started',
|
||||
plugin: plugin
|
||||
});
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to install plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 获取QQ登录二维码
|
||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
@@ -27,9 +27,17 @@ export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
|
||||
// 获取QQ登录状态
|
||||
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
|
||||
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
const selfInfo = oneBotContext?.core?.selfInfo;
|
||||
const isOnline = selfInfo?.online;
|
||||
const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus();
|
||||
// 必须同时满足:已登录且在线(online 必须明确为 true)
|
||||
const isLogin = qqLoginStatus && isOnline === true;
|
||||
const data = {
|
||||
isLogin: WebUiDataRuntime.getQQLoginStatus(),
|
||||
isLogin,
|
||||
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
|
||||
loginError: WebUiDataRuntime.getQQLoginError(),
|
||||
};
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
@@ -88,3 +96,15 @@ export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
|
||||
await WebUiConfig.UpdateAutoLoginAccount(uin);
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 刷新QQ登录二维码
|
||||
export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
// 判断是否已经登录
|
||||
if (WebUiDataRuntime.getQQLoginStatus()) {
|
||||
// 已经登录
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 刷新二维码
|
||||
await WebUiDataRuntime.refreshQRCode();
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ interface UpdateRequestBody {
|
||||
targetVersion?: string;
|
||||
/** 是否强制更新(即使是降级也更新) */
|
||||
force?: boolean;
|
||||
/** 指定使用的镜像 */
|
||||
mirror?: string;
|
||||
}
|
||||
|
||||
// 更新配置文件接口
|
||||
@@ -124,7 +126,7 @@ async function downloadFile (url: string, dest: string): Promise<void> {
|
||||
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
// 从请求体获取目标版本(可选)
|
||||
const { targetVersion, force } = req.body as UpdateRequestBody;
|
||||
const { targetVersion, force, mirror } = req.body as UpdateRequestBody;
|
||||
|
||||
// 确定要下载的文件名
|
||||
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
||||
@@ -150,20 +152,21 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
|
||||
// 根据当前工作环境确定 artifact 名称
|
||||
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
|
||||
|
||||
|
||||
// Action artifacts 通过 nightly.link 下载
|
||||
// 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||||
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
|
||||
actualVersion = targetTag;
|
||||
|
||||
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
|
||||
|
||||
|
||||
// 使用 mirror 模块查找可用的 nightly.link 镜像
|
||||
try {
|
||||
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
|
||||
validateContent: true,
|
||||
minFileSize: 1024 * 1024,
|
||||
timeout: 10000,
|
||||
customMirror: mirror,
|
||||
});
|
||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
||||
} catch (error) {
|
||||
@@ -178,6 +181,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
|
||||
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
|
||||
fetchChangelog: false, // 不需要 changelog,避免 API 调用
|
||||
mirror,
|
||||
});
|
||||
|
||||
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
|
||||
@@ -193,6 +197,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
validateContent: true, // 验证 Content-Type 和状态码
|
||||
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
||||
timeout: 10000, // 10秒超时
|
||||
customMirror: mirror,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
uin: '',
|
||||
nick: '',
|
||||
},
|
||||
QQLoginError: '',
|
||||
QQVersion: 'unknown',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
@@ -21,6 +22,9 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
|
||||
},
|
||||
onRefreshQRCode: async () => {
|
||||
// 默认空实现,由 shell 注册真实回调
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
@@ -174,4 +178,25 @@ export const WebUiDataRuntime = {
|
||||
requestRestartProcess: async function () {
|
||||
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
|
||||
},
|
||||
|
||||
setQQLoginError (error: string): void {
|
||||
LoginRuntime.QQLoginError = error;
|
||||
},
|
||||
|
||||
getQQLoginError (): string {
|
||||
return LoginRuntime.QQLoginError;
|
||||
},
|
||||
|
||||
setRefreshQRCodeCallback (func: () => Promise<void>): void {
|
||||
LoginRuntime.onRefreshQRCode = func;
|
||||
},
|
||||
|
||||
getRefreshQRCodeCallback (): () => Promise<void> {
|
||||
return LoginRuntime.onRefreshQRCode;
|
||||
},
|
||||
|
||||
refreshQRCode: async function () {
|
||||
LoginRuntime.QQLoginError = '';
|
||||
await LoginRuntime.onRefreshQRCode();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/napcat-web
|
||||
export class AuthHelper {
|
||||
private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2);
|
||||
|
||||
public static getSecretKey (): string {
|
||||
return AuthHelper.secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名凭证方法。
|
||||
* @param hash 待签名的凭证字符串。
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
|
||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler, GetMirrorsHandler } from '../api/BaseInfo';
|
||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
||||
import { GetProxyHandler } from '../api/Proxy';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
// router: 获取nc的package.json信息
|
||||
router.get('/QQVersion', QQVersionHandler);
|
||||
router.get('/GetNapCatVersion', GetNapCatVersion);
|
||||
router.get('/getLatestTag', getLatestTagHandler);
|
||||
router.get('/getAllReleases', getAllReleasesHandler);
|
||||
router.get('/getMirrors', GetMirrorsHandler);
|
||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||
router.get('/proxy', GetProxyHandler);
|
||||
router.get('/Theme', GetThemeConfigHandler);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DeleteWebUIFontHandler, // 添加上传处理器
|
||||
} from '../api/File';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟内
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CloseTerminalHandler,
|
||||
} from '../api/Log';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
|
||||
// 日志相关路由
|
||||
router.get('/GetLog', LogHandler);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
|
||||
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
// router:读取配置
|
||||
router.post('/GetConfig', OB11GetConfigHandler);
|
||||
// router:写入配置
|
||||
|
||||
17
packages/napcat-webui-backend/src/router/Plugin.ts
Normal file
17
packages/napcat-webui-backend/src/router/Plugin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler } from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.get('/List', GetPluginListHandler);
|
||||
router.post('/Reload', ReloadPluginHandler);
|
||||
router.post('/SetStatus', SetPluginStatusHandler);
|
||||
router.post('/Uninstall', UninstallPluginHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
|
||||
router.post('/Store/Install', InstallPluginFromStoreHandler);
|
||||
|
||||
export { router as PluginRouter };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { RestartProcessHandler } from '../api/Process';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
|
||||
// POST /api/Process/Restart - 重启进程
|
||||
router.post('/Restart', RestartProcessHandler);
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
getQQLoginInfoHandler,
|
||||
getAutoLoginAccountHandler,
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
// router:获取快速登录列表
|
||||
router.all('/GetQuickLoginList', QQGetQuickLoginListHandler);
|
||||
// router:获取快速登录列表(新)
|
||||
@@ -28,5 +29,7 @@ router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
|
||||
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
||||
// router:设置自动登录QQ账号
|
||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
// router:刷新QQ登录二维码
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Router } from 'express';
|
||||
import { UpdateNapCatHandler } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
|
||||
// POST /api/UpdateNapCat/update - 更新NapCat
|
||||
router.post('/update', UpdateNapCatHandler);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UpdateWebUIConfigHandler,
|
||||
} from '@/napcat-webui-backend/src/api/WebUIConfig';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
|
||||
// 获取WebUI基础配置
|
||||
router.get('/GetConfig', GetWebUIConfigHandler);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
VerifyPasskeyAuthenticationHandler,
|
||||
} from '@/napcat-webui-backend/src/api/Auth';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
// router:登录
|
||||
router.post('/login', LoginHandler);
|
||||
// router:检查登录状态
|
||||
|
||||
@@ -17,8 +17,9 @@ import { WebUIConfigRouter } from './WebUIConfig';
|
||||
import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
|
||||
const router = Router();
|
||||
const router: Router = Router();
|
||||
|
||||
// 鉴权中间件
|
||||
router.use(auth);
|
||||
@@ -47,5 +48,7 @@ router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||
router.use('/Debug', DebugRouter);
|
||||
// router:进程管理相关路由
|
||||
router.use('/Process', ProcessRouter);
|
||||
// router:插件管理相关路由
|
||||
router.use('/Plugin', PluginRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
27
packages/napcat-webui-backend/src/types/PluginStore.ts
Normal file
27
packages/napcat-webui-backend/src/types/PluginStore.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// 插件商店相关类型定义
|
||||
|
||||
export interface PluginStoreItem {
|
||||
id: string; // 插件唯一标识
|
||||
name: string; // 插件名称
|
||||
version: string; // 最新版本
|
||||
description: string; // 插件描述
|
||||
author: string; // 作者
|
||||
homepage?: string; // 主页链接
|
||||
repository?: string; // 仓库地址
|
||||
downloadUrl: string; // 下载地址
|
||||
tags?: string[]; // 标签
|
||||
icon?: string; // 图标URL
|
||||
screenshots?: string[]; // 截图
|
||||
minNapCatVersion?: string; // 最低NapCat版本要求
|
||||
dependencies?: Record<string, string>; // 依赖
|
||||
downloads?: number; // 下载次数
|
||||
rating?: number; // 评分
|
||||
createdAt?: string; // 创建时间
|
||||
updatedAt?: string; // 更新时间
|
||||
}
|
||||
|
||||
export interface PluginStoreList {
|
||||
version: string; // 索引版本
|
||||
updateTime: string; // 更新时间
|
||||
plugins: PluginStoreItem[]; // 插件列表
|
||||
}
|
||||
@@ -43,9 +43,11 @@ export interface LoginRuntimeType {
|
||||
QQQRCodeURL: string;
|
||||
QQLoginUin: string;
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQLoginError: string;
|
||||
QQVersion: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
onRefreshQRCode: () => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import multer from 'multer';
|
||||
import { Request, Response } from 'express';
|
||||
import { Request, Response, RequestHandler } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -65,7 +65,7 @@ export const createDiskStorage = (uploadPath: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const createDiskUpload = (uploadPath: string) => {
|
||||
export const createDiskUpload = (uploadPath: string): RequestHandler => {
|
||||
const upload = multer({
|
||||
storage: createDiskStorage(uploadPath),
|
||||
limits: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Request, Response, RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 支持的字体格式
|
||||
@@ -42,7 +42,7 @@ export const webUIFontStorage = multer.diskStorage({
|
||||
},
|
||||
});
|
||||
|
||||
export const webUIFontUpload = multer({
|
||||
export const webUIFontUpload: RequestHandler = multer({
|
||||
storage: webUIFontStorage,
|
||||
fileFilter: (_, file, cb) => {
|
||||
// 验证文件类型
|
||||
|
||||
@@ -25,6 +25,8 @@ const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
|
||||
const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
|
||||
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
|
||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
|
||||
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
|
||||
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
|
||||
|
||||
function App () {
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ function App () {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthChecker ({ children }: { children: React.ReactNode }) {
|
||||
function AuthChecker ({ children }: { children: React.ReactNode; }) {
|
||||
const { isAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -76,6 +78,8 @@ function AppRoutes () {
|
||||
</Route>
|
||||
<Route path='file_manager' element={<FileManagerPage />} />
|
||||
<Route path='terminal' element={<TerminalPage />} />
|
||||
<Route path='plugins' element={<PluginPage />} />
|
||||
<Route path='plugin_store' element={<PluginStorePage />} />
|
||||
<Route path='about' element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path='/qq_login' element={<QQLoginPage />} />
|
||||
|
||||
@@ -93,7 +93,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
onPress={handleEnableDebug}
|
||||
isDisabled={editing}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
{debug ? '默认' : '调试'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
|
||||
export interface PluginDisplayCardProps {
|
||||
data: PluginItem;
|
||||
onReload: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onUninstall: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
data,
|
||||
onReload,
|
||||
onToggleStatus,
|
||||
onUninstall,
|
||||
}) => {
|
||||
const { name, version, author, description, status } = data;
|
||||
const isEnabled = status !== 'disabled';
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const handleToggle = () => {
|
||||
setProcessing(true);
|
||||
onToggleStatus().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
const handleReload = () => {
|
||||
setProcessing(true);
|
||||
onReload().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
const handleUninstall = () => {
|
||||
setProcessing(true);
|
||||
onUninstall().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
action={
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-primary/20 hover:text-primary transition-colors'
|
||||
startContent={<MdPublishedWithChanges size={16} />}
|
||||
onPress={handleReload}
|
||||
isDisabled={!isEnabled || processing}
|
||||
>
|
||||
重载
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleUninstall}
|
||||
isDisabled={processing}
|
||||
>
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={processing}
|
||||
isSelected={isEnabled}
|
||||
onChange={handleToggle}
|
||||
classNames={{
|
||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={name}
|
||||
tag={
|
||||
<Chip
|
||||
className="ml-auto"
|
||||
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
|
||||
</Chip>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
版本
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{version}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
作者
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{author || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
描述
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2'>
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginDisplayCard;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useState } from 'react';
|
||||
import { IoMdStar, IoMdDownload } from 'react-icons/io';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
}) => {
|
||||
const { name, version, author, description, tags, rating, icon } = data;
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const handleInstall = () => {
|
||||
setProcessing(true);
|
||||
onInstall().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
title={name}
|
||||
tag={
|
||||
<Chip
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
}
|
||||
enableSwitch={
|
||||
icon ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={icon}
|
||||
alt={name}
|
||||
className="w-10 h-10 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='primary'
|
||||
startContent={<IoMdDownload size={16} />}
|
||||
onPress={handleInstall}
|
||||
isLoading={processing}
|
||||
isDisabled={processing}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
作者
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{author || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
版本
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
v{version}
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
描述
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
{rating && (
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
评分
|
||||
</span>
|
||||
<div className='flex items-center gap-1 text-sm font-medium text-default-700 dark:text-white/90'>
|
||||
<IoMdStar className='text-warning' size={16} />
|
||||
<span>{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
标签
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{tags.slice(0, 2).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginStoreCard;
|
||||
@@ -52,7 +52,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
onNativeClose();
|
||||
}}
|
||||
classNames={{
|
||||
backdrop: 'z-[99]',
|
||||
backdrop: 'z-[99] backdrop-blur-sm',
|
||||
wrapper: 'z-[99]',
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -55,7 +55,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
|
||||
|
||||
if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) {
|
||||
const serverData = data as any;
|
||||
if (!serverData.token) {
|
||||
if (!serverData.token && serverData.host !== '127.0.0.1') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '安全警告',
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { IoAlertCircle, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
interface QrCodeLoginProps {
|
||||
qrcode: string
|
||||
qrcode: string;
|
||||
loginError?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode, loginError, onRefresh }) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'>
|
||||
<Spinner color='primary' />
|
||||
{loginError
|
||||
? (
|
||||
<div className='flex flex-col items-center py-4'>
|
||||
<div className='w-full flex justify-center mb-6'>
|
||||
<div className='p-4 bg-danger-50 rounded-full'>
|
||||
<IoAlertCircle className='text-danger' size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center space-y-2 px-4'>
|
||||
<div className='text-xl font-bold text-danger'>登录失败</div>
|
||||
<div className='text-default-600 text-sm leading-relaxed max-w-[300px]'>
|
||||
{loginError}
|
||||
</div>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
className='mt-8 min-w-[160px]'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startContent={<IoRefresh />}
|
||||
onPress={onRefresh}
|
||||
>
|
||||
重新获取二维码
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-0 top-0 right-0 bottom-0 bg-white dark:bg-zinc-900 bg-opacity-90 backdrop-blur-sm flex items-center justify-center z-10'>
|
||||
<Spinner color='primary' />
|
||||
</div>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode || ' '} />
|
||||
</div>
|
||||
<div className='mt-5 text-center text-default-500 text-sm'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
{onRefresh && qrcode && (
|
||||
<Button
|
||||
className='mt-4'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startContent={<IoRefresh />}
|
||||
onPress={onRefresh}
|
||||
>
|
||||
刷新二维码
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode} />
|
||||
</div>
|
||||
<div className='mt-5 text-center'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex text-sm gap-3 py-2 items-center transition-colors',
|
||||
'flex text-sm gap-3 py-2 items-baseline transition-colors',
|
||||
hasBackground
|
||||
? 'text-white/90'
|
||||
: 'text-default-600 dark:text-gray-300',
|
||||
@@ -53,13 +53,13 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-lg opacity-70">{icon}</div>
|
||||
<div className="text-lg opacity-70 self-center">{icon}</div>
|
||||
<div className='w-24 font-medium'>{title}</div>
|
||||
<div className={clsx(
|
||||
'text-xs font-mono flex-1',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>{value}</div>
|
||||
<div>{endContent}</div>
|
||||
<div className="self-center">{endContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -84,9 +84,11 @@ const UpdateDialogContent: React.FC<{
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-8 bg-default-50 dark:bg-default-100/5 rounded-xl border border-default-100 dark:border-default-100/10">
|
||||
<div className="flex flex-col items-center gap-2 min-w-0 w-full sm:w-auto">
|
||||
<span className="text-xs text-default-500 font-medium uppercase tracking-wider">当前版本</span>
|
||||
<Chip size="lg" variant="flat" color="default" classNames={{ content: "font-mono font-bold text-base sm:text-lg break-all whitespace-normal text-center h-auto py-1" }}>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
<Tooltip content={`v${currentVersion}`}>
|
||||
<Chip size="md" variant="flat" color="default" classNames={{ content: "font-mono font-bold text-sm truncate max-w-[120px] sm:max-w-[160px]" }}>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center text-primary-500 px-4 shrink-0">
|
||||
@@ -99,9 +101,11 @@ const UpdateDialogContent: React.FC<{
|
||||
|
||||
<div className="flex flex-col items-center gap-2 min-w-0 w-full sm:w-auto">
|
||||
<span className="text-xs text-primary-500 font-medium uppercase tracking-wider">最新版本</span>
|
||||
<Chip size="lg" color="primary" variant="shadow" classNames={{ content: "font-mono font-bold text-base sm:text-lg break-all whitespace-normal text-center h-auto py-1" }}>
|
||||
v{latestVersion}
|
||||
</Chip>
|
||||
<Tooltip content={`v${latestVersion}`}>
|
||||
<Chip size="md" color="primary" variant="shadow" classNames={{ content: "font-mono font-bold text-sm truncate max-w-[120px] sm:max-w-[160px]" }}>
|
||||
v{latestVersion}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,13 +140,21 @@ const UpdateDialogContent: React.FC<{
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-2 p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
|
||||
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1'>
|
||||
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1 justify-center'>
|
||||
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' />
|
||||
</svg>
|
||||
<span>请手动重启 NapCat,更新才会生效</span>
|
||||
<span>重启 NapCat 生效</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex gap-3 justify-center mt-2 w-full'>
|
||||
<button
|
||||
className='px-4 py-2 text-sm rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-sm transition-colors shadow-primary-500/20 w-full'
|
||||
onClick={() => WebUIManager.restart()}
|
||||
>
|
||||
立即重启
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -241,17 +253,17 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
|
||||
return (
|
||||
<Tooltip content='有新版本可用'>
|
||||
<div className="cursor-pointer" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||
<Chip
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
content: "font-bold text-[10px] px-1",
|
||||
base: "h-5 min-h-5"
|
||||
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
||||
base: "h-5 min-h-5 min-w-[42px]"
|
||||
}}
|
||||
>
|
||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" /> : 'New'}
|
||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -269,6 +281,7 @@ interface VersionInfo {
|
||||
size?: number;
|
||||
workflowRunId?: number;
|
||||
headSha?: string;
|
||||
workflowTitle?: string;
|
||||
}
|
||||
|
||||
// 版本选择对话框内容
|
||||
@@ -290,6 +303,14 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
|
||||
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||
cacheKey: 'napcat-mirrors',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const mirrors = mirrorsData?.mirrors || [];
|
||||
|
||||
const pageSize = 15;
|
||||
|
||||
// 获取所有可用版本(带分页、过滤和搜索)
|
||||
@@ -299,15 +320,16 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
type: activeTab,
|
||||
search: debouncedSearch
|
||||
search: debouncedSearch,
|
||||
mirror: selectedMirror
|
||||
}),
|
||||
{
|
||||
refreshDeps: [currentPage, activeTab, debouncedSearch],
|
||||
refreshDeps: [currentPage, activeTab, debouncedSearch, selectedMirror],
|
||||
}
|
||||
);
|
||||
|
||||
// 版本列表已在后端过滤,直接使用
|
||||
const filteredVersions = releasesData?.versions || [];
|
||||
const filteredVersions = (releasesData?.versions || []) as VersionInfo[];
|
||||
|
||||
// 检查是否是降级(使用语义化版本比较)
|
||||
const isDowngrade = useCallback((targetTag: string): boolean => {
|
||||
@@ -320,6 +342,22 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
const selectedVersionTag = selectedVersion?.tag || '';
|
||||
const isSelectedDowngrade = isDowngrade(selectedVersionTag);
|
||||
|
||||
const performUpdate = async (force: boolean) => {
|
||||
if (!selectedVersion) return;
|
||||
setUpdateStatus('updating');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force, selectedMirror);
|
||||
setUpdateStatus('success');
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||
setErrorMessage(errMsg);
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedVersion) return;
|
||||
|
||||
@@ -346,22 +384,6 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
await performUpdate(forceUpdate);
|
||||
};
|
||||
|
||||
const performUpdate = async (force: boolean) => {
|
||||
if (!selectedVersion) return;
|
||||
setUpdateStatus('updating');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force);
|
||||
setUpdateStatus('success');
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||
setErrorMessage(errMsg);
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
@@ -375,13 +397,30 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<div className='text-center w-full'>
|
||||
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
||||
更新到 {selectedVersionTag} 完成
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
<p className='text-xs text-default-500 mt-1 mb-6'>
|
||||
请重启 NapCat 以应用新版本
|
||||
</p>
|
||||
<div className='flex gap-3 justify-center'>
|
||||
<button
|
||||
className='px-4 py-2 text-sm rounded-lg bg-default-100 hover:bg-default-200 transition-colors text-default-700'
|
||||
onClick={onClose}
|
||||
>
|
||||
稍后重启
|
||||
</button>
|
||||
<button
|
||||
className='px-4 py-2 text-sm rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-sm transition-colors shadow-primary-500/20'
|
||||
onClick={async () => {
|
||||
await WebUIManager.restart();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
立即重启
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -463,23 +502,46 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
<Tab key='action' title='临时版本 (Action)' />
|
||||
</Tabs>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Input
|
||||
placeholder='搜索版本号...'
|
||||
size='sm'
|
||||
value={searchQuery}
|
||||
onValueChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedVersion(null);
|
||||
}}
|
||||
startContent={<IoSearch className='text-default-400' />}
|
||||
isClearable
|
||||
onClear={() => setSearchQuery('')}
|
||||
classNames={{
|
||||
inputWrapper: 'h-9',
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{/* 搜索框 */}
|
||||
<Input
|
||||
placeholder='搜索版本号...'
|
||||
size='sm'
|
||||
value={searchQuery}
|
||||
onValueChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedVersion(null);
|
||||
}}
|
||||
startContent={<IoSearch className='text-default-400' />}
|
||||
isClearable
|
||||
onClear={() => setSearchQuery('')}
|
||||
classNames={{
|
||||
inputWrapper: 'h-9',
|
||||
base: 'flex-1'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 镜像选择 */}
|
||||
<Select
|
||||
placeholder="自动选择 (默认)"
|
||||
selectedKeys={selectedMirror ? [selectedMirror] : ['default']}
|
||||
onSelectionChange={(keys) => {
|
||||
const m = Array.from(keys)[0] as string;
|
||||
setSelectedMirror(m === 'default' ? undefined : m);
|
||||
}}
|
||||
size="sm"
|
||||
className="w-48"
|
||||
classNames={{ trigger: 'h-9 min-h-9' }}
|
||||
aria-label="选择镜像源"
|
||||
>
|
||||
{['default', ...mirrors].map(m => (
|
||||
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
|
||||
{m === 'default' ? '自动选择 (默认)' : m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 版本选择 */}
|
||||
<div className='space-y-2'>
|
||||
@@ -528,7 +590,12 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{version.type === 'action' && version.artifactName ? version.artifactName : version.tag}</span>
|
||||
<span className="truncate max-w-[300px]">
|
||||
{version.type === 'action'
|
||||
? (version.workflowTitle || version.artifactName || version.tag)
|
||||
: version.tag
|
||||
}
|
||||
</span>
|
||||
{version.type === 'prerelease' && (
|
||||
<Chip size='sm' color='secondary' variant='flat'>预发布</Chip>
|
||||
)}
|
||||
@@ -543,10 +610,11 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{version.type === 'action' && (
|
||||
<div className='text-xs text-default-400'>
|
||||
{version.headSha && <span className='font-mono'>{version.headSha.slice(0, 7)}</span>}
|
||||
{version.createdAt && <span className='ml-2'>{new Date(version.createdAt).toLocaleString()}</span>}
|
||||
{version.size && <span className='ml-2'>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
|
||||
<div className='text-xs text-default-400 flex items-center gap-2'>
|
||||
<span className='font-mono bg-default-100 dark:bg-default-100/10 px-1 rounded'>{version.tag}</span>
|
||||
{version.headSha && <span className='font-mono' title={version.headSha}>{version.headSha.slice(0, 7)}</span>}
|
||||
{version.createdAt && <span>{new Date(version.createdAt).toLocaleString()}</span>}
|
||||
{version.size && <span>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -568,6 +636,7 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'py-1.5 text-sm transition-colors',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between items-center',
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
LuSignal,
|
||||
LuTerminal,
|
||||
LuZap,
|
||||
LuPackage,
|
||||
LuStore,
|
||||
} from 'react-icons/lu';
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
@@ -59,6 +61,16 @@ export const siteConfig = {
|
||||
icon: <LuFolderOpen className='w-5 h-5' />,
|
||||
href: '/file_manager',
|
||||
},
|
||||
{
|
||||
label: '插件管理',
|
||||
icon: <LuPackage className='w-5 h-5' />,
|
||||
href: '/plugins',
|
||||
},
|
||||
{
|
||||
label: '插件商店',
|
||||
icon: <LuStore className='w-5 h-5' />,
|
||||
href: '/plugin_store',
|
||||
},
|
||||
{
|
||||
label: '系统终端',
|
||||
icon: <LuTerminal className='w-5 h-5' />,
|
||||
|
||||
@@ -61,7 +61,8 @@ const messageNode = z.union([
|
||||
.object({
|
||||
type: z.literal('reply'),
|
||||
data: z.object({
|
||||
id: z.number(),
|
||||
id: z.number().optional(),
|
||||
seq: z.number().optional(),
|
||||
}),
|
||||
})
|
||||
.describe('回复消息'),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { serverRequest } from '@/utils/request';
|
||||
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
export interface PluginItem {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
status: 'active' | 'disabled' | 'stopped';
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface PluginListResponse {
|
||||
plugins: PluginItem[];
|
||||
pluginManagerNotFound: boolean;
|
||||
}
|
||||
|
||||
export interface ServerResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export default class PluginManager {
|
||||
public static async getPluginList () {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async reloadPlugin (name: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Reload', { name });
|
||||
}
|
||||
|
||||
public static async setPluginStatus (name: string, enable: boolean, filename?: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
|
||||
}
|
||||
|
||||
public static async uninstallPlugin (name: string, filename?: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
|
||||
}
|
||||
|
||||
// 插件商店相关方法
|
||||
public static async getPluginStoreList () {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async getPluginStoreDetail (id: string) {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async installPluginFromStore (id: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user