diff --git a/.github/scripts/lib/comment.ts b/.github/scripts/lib/comment.ts index 27fb2ce9..15065267 100644 --- a/.github/scripts/lib/comment.ts +++ b/.github/scripts/lib/comment.ts @@ -24,6 +24,10 @@ function escapeCodeBlock (text: string): string { return text.replace(/```/g, '\\`\\`\\`'); } +function getTimeString (): string { + return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC'; +} + // ============== 状态图标 ============== export function getStatusIcon (status: BuildStatus): string { @@ -34,30 +38,66 @@ export function getStatusIcon (status: BuildStatus): string { return '⏳ 构建中...'; case 'cancelled': return '⚪ 已取消'; - default: + case 'failure': return '❌ 失败'; + default: + return '❓ 未知'; + } +} + +function getStatusEmoji (status: BuildStatus): string { + switch (status) { + case 'success': return '✅'; + case 'pending': return '⏳'; + case 'cancelled': return '⚪'; + case 'failure': return '❌'; + default: return '❓'; } } // ============== 构建中评论 ============== export function generateBuildingComment (prSha: string, targets: string[]): string { - const time = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC'; + const time = getTimeString(); + const shortSha = formatSha(prSha); const lines: string[] = [ COMMENT_MARKER, - '## 🔨 构建状态', '', - '| 构建目标 | 状态 |', - '| :--- | :--- |', - ...targets.map(name => `| ${name} | ⏳ 构建中... |`), + '
', + '', + '# 🔨 NapCat 构建中', + '', + '![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)', + '', + '
', '', '---', '', - `📝 **提交**: \`${formatSha(prSha)}\``, - `🕐 **开始时间**: ${time}`, + '## 📦 构建目标', '', - '> 构建进行中,请稍候...', + '| 包名 | 状态 | 说明 |', + '| :--- | :---: | :--- |', + ...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`), + '', + '---', + '', + '## 📋 构建信息', + '', + `| 项目 | 值 |`, + `| :--- | :--- |`, + `| 📝 提交 | \`${shortSha}\` |`, + `| 🕐 开始时间 | ${time} |`, + '', + '---', + '', + '
', + '', + '> ⏳ **构建进行中,请稍候...**', + '>', + '> 构建完成后将自动更新此评论', + '', + '
', ]; return lines.join('\n'); @@ -69,60 +109,123 @@ export function generateResultComment ( targets: BuildTarget[], prSha: string, runId: string, - repository: string + repository: string, + version?: string ): string { - // 链接到 run 详情页,页面底部有 Artifacts 下载区域 const runUrl = `https://github.com/${repository}/actions/runs/${runId}`; + const shortSha = formatSha(prSha); + const time = getTimeString(); const allSuccess = targets.every(t => t.status === 'success'); const anyCancelled = targets.some(t => t.status === 'cancelled'); + const anyFailure = targets.some(t => t.status === 'failure'); - const headerIcon = allSuccess - ? '✅ 构建成功' - : anyCancelled - ? '⚪ 构建已取消' - : '❌ 构建失败'; + // 状态徽章 + let statusBadge: string; + let headerTitle: string; + if (allSuccess) { + statusBadge = '![Success](https://img.shields.io/badge/状态-构建成功-success?style=for-the-badge&logo=github-actions&logoColor=white)'; + headerTitle = '# ✅ NapCat 构建成功'; + } else if (anyCancelled && !anyFailure) { + statusBadge = '![Cancelled](https://img.shields.io/badge/状态-已取消-lightgrey?style=for-the-badge&logo=github-actions&logoColor=white)'; + headerTitle = '# ⚪ NapCat 构建已取消'; + } else { + statusBadge = '![Failed](https://img.shields.io/badge/状态-构建失败-critical?style=for-the-badge&logo=github-actions&logoColor=white)'; + headerTitle = '# ❌ NapCat 构建失败'; + } const downloadLink = (target: BuildTarget) => { if (target.status !== 'success') return '—'; if (target.downloadUrl) { - return `[📦 下载](${target.downloadUrl})`; + return `[📥 下载](${target.downloadUrl})`; } - // 回退到 run 详情页 - return `[📦 下载](${runUrl}#artifacts)`; + return `[📥 下载](${runUrl}#artifacts)`; }; const lines: string[] = [ COMMENT_MARKER, - `## ${headerIcon}`, '', - '| 构建目标 | 状态 | 下载 |', - '| :--- | :--- | :--- |', - ...targets.map(t => `| ${t.name} | ${getStatusIcon(t.status)} | ${downloadLink(t)} |`), + '
', + '', + headerTitle, + '', + statusBadge, + '', + '
', '', '---', '', - `📝 **提交**: \`${formatSha(prSha)}\``, - `🔗 **构建日志**: [查看详情](${runUrl})`, + '## 📦 构建产物', + '', + '| 包名 | 状态 | 下载 |', + '| :--- | :---: | :---: |', + ...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`), + '', + '---', + '', + '## 📋 构建信息', + '', + `| 项目 | 值 |`, + `| :--- | :--- |`, + ...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []), + `| 📝 提交 | \`${shortSha}\` |`, + `| 🔗 构建日志 | [查看详情](${runUrl}) |`, + `| 🕐 完成时间 | ${time} |`, ]; // 添加错误详情 const failedTargets = targets.filter(t => t.status === 'failure' && t.error); if (failedTargets.length > 0) { - lines.push('', '---', '', '## ⚠️ 错误详情'); + lines.push('', '---', '', '## ⚠️ 错误详情', ''); for (const target of failedTargets) { - lines.push('', `### ${target.name} 构建错误`, '```', escapeCodeBlock(target.error!), '```'); + lines.push( + `
`, + `🔴 ${target.name} 构建错误`, + '', + '```', + escapeCodeBlock(target.error!), + '```', + '', + '
', + '' + ); } } // 添加底部提示 + lines.push('---', ''); if (allSuccess) { - lines.push('', '> 🎉 所有构建均已成功完成,可点击上方下载链接获取构建产物进行测试。'); - } else if (anyCancelled) { - lines.push('', '> ⚪ 构建已被取消,可能是由于新的提交触发了新的构建。'); + lines.push( + '
', + '', + '> 🎉 **所有构建均已成功完成!**', + '>', + '> 点击上方下载链接获取构建产物进行测试', + '', + '
' + ); + } else if (anyCancelled && !anyFailure) { + lines.push( + '
', + '', + '> ⚪ **构建已被取消**', + '>', + '> 可能是由于新的提交触发了新的构建', + '', + '
' + ); } else { - lines.push('', '> ⚠️ 部分构建失败,请查看上方错误详情或点击构建日志查看完整输出。'); + lines.push( + '
', + '', + '> ⚠️ **部分构建失败**', + '>', + '> 请查看上方错误详情或点击构建日志查看完整输出', + '', + '
' + ); } return lines.join('\n'); } + diff --git a/.github/scripts/pr-build-result.ts b/.github/scripts/pr-build-result.ts index c8308fe1..3af0ef9c 100644 --- a/.github/scripts/pr-build-result.ts +++ b/.github/scripts/pr-build-result.ts @@ -6,6 +6,7 @@ * - PR_NUMBER: PR 编号 * - PR_SHA: PR 提交 SHA * - RUN_ID: GitHub Actions Run ID + * - NAPCAT_VERSION: 构建版本号 * - FRAMEWORK_STATUS: Framework 构建状态 * - FRAMEWORK_ERROR: Framework 构建错误信息 * - SHELL_STATUS: Shell 构建状态 @@ -30,6 +31,7 @@ async function main (): Promise { const prNumber = parseInt(getEnv('PR_NUMBER', true), 10); const prSha = getEnv('PR_SHA') || 'unknown'; const runId = getEnv('RUN_ID', true); + const version = getEnv('NAPCAT_VERSION') || ''; const { owner, repo } = getRepository(); const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS')); @@ -39,6 +41,7 @@ async function main (): Promise { console.log(`PR: #${prNumber}`); console.log(`SHA: ${prSha}`); + console.log(`Version: ${version}`); console.log(`Run: ${runId}`); console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`); console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`); @@ -76,7 +79,7 @@ async function main (): Promise { }, ]; - const comment = generateResultComment(targets, prSha, runId, repository); + const comment = generateResultComment(targets, prSha, runId, repository, version); await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER); } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3b6c420..a138a608 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,11 +13,27 @@ jobs: steps: - name: Clone Main Repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # 需要完整历史来获取 tags - name: Use Node.js 20.X uses: actions/setup-node@v4 with: node-version: 20.x + - name: Generate Version + run: | + # 获取最近的 release tag (格式: vX.X.X) + LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0") + # 去掉 v 前缀 + BASE_VERSION="${LATEST_TAG#v}" + SHORT_SHA="${GITHUB_SHA::7}" + VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}" + echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV + echo "Latest tag: ${LATEST_TAG}" + echo "Build version: ${VERSION}" - name: Build NapCat.Framework + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }} run: | npm i -g pnpm pnpm i @@ -39,11 +55,27 @@ jobs: steps: - name: Clone Main Repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # 需要完整历史来获取 tags - name: Use Node.js 20.X uses: actions/setup-node@v4 with: node-version: 20.x + - name: Generate Version + run: | + # 获取最近的 release tag (格式: vX.X.X) + LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0") + # 去掉 v 前缀 + BASE_VERSION="${LATEST_TAG#v}" + SHORT_SHA="${GITHUB_SHA::7}" + VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}" + echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV + echo "Latest tag: ${LATEST_TAG}" + echo "Build version: ${VERSION}" - name: Build NapCat.Shell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }} run: | npm i -g pnpm pnpm i diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index b631af87..5ec44812 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -126,6 +126,7 @@ jobs: outputs: status: ${{ steps.build.outcome }} # 构建结果:success/failure error: ${{ steps.build.outputs.error }} # 错误信息(如有) + version: ${{ steps.version.outputs.version }} # 构建版本号 steps: # 【安全】先从 base 分支 checkout 构建脚本 # 这样即使 PR 中修改了脚本,也不会被执行 @@ -143,16 +144,36 @@ jobs: repository: ${{ needs.check-build.outputs.pr_head_repo }} ref: ${{ needs.check-build.outputs.pr_sha }} path: workspace + fetch-depth: 0 # 需要完整历史来获取 tags - name: Setup Node.js 24 uses: actions/setup-node@v4 with: node-version: 24 + # 获取最新 release tag 并生成版本号 + - name: Generate Version + id: version + working-directory: workspace + run: | + # 获取最近的 release tag (格式: vX.X.X) + LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0") + # 去掉 v 前缀 + BASE_VERSION="${LATEST_TAG#v}" + SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}" + SHORT_SHA="${SHORT_SHA::7}" + VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}" + echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV + echo "Latest tag: ${LATEST_TAG}" + echo "Build version: ${VERSION}" + # 执行构建,使用 base 分支的脚本处理 workspace 中的代码 - name: Build id: build working-directory: workspace + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }} run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework continue-on-error: true # 允许失败,后续更新评论时处理 @@ -177,6 +198,8 @@ jobs: outputs: status: ${{ steps.build.outcome }} # 构建结果:success/failure error: ${{ steps.build.outputs.error }} # 错误信息(如有) + version: ${{ steps.version.outputs.version }} # 构建版本号 + version: ${{ steps.version.outputs.version }} # 构建版本号 steps: # 【安全】先从 base 分支 checkout 构建脚本 - name: Checkout scripts from base @@ -193,16 +216,37 @@ jobs: repository: ${{ needs.check-build.outputs.pr_head_repo }} ref: ${{ needs.check-build.outputs.pr_sha }} path: workspace + fetch-depth: 0 # 需要完整历史来获取 tags - name: Setup Node.js 24 uses: actions/setup-node@v4 with: node-version: 24 + # 获取最新 release tag 并生成版本号 + - name: Generate Version + id: version + working-directory: workspace + run: | + # 获取最近的 release tag (格式: vX.X.X) + LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0") + # 去掉 v 前缀 + BASE_VERSION="${LATEST_TAG#v}" + SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}" + SHORT_SHA="${SHORT_SHA::7}" + VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}" + echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Latest tag: ${LATEST_TAG}" + echo "Build version: ${VERSION}" + # 执行构建 - name: Build id: build working-directory: workspace + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }} run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell continue-on-error: true @@ -244,6 +288,8 @@ jobs: PR_NUMBER: ${{ needs.check-build.outputs.pr_number }} PR_SHA: ${{ needs.check-build.outputs.pr_sha }} RUN_ID: ${{ github.run_id }} + # 构建版本号 + NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }} # 获取构建状态,如果 job 被跳过则标记为 cancelled FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }} FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ecc1f04..4f4980b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,16 @@ on: workflow_dispatch: push: tags: - - '*' + - 'v[0-9]*.[0-9]*.[0-9]*' + - 'v[0-9]*.[0-9]*.[0-9]*-*' + - 'v[0-9]*.[0-9]*.[0-9]*+*' + - 'v[0-9]*.[0-9]*.[0-9]*-*+*' permissions: write-all env: OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions - OPENROUTER_MODEL: "kimi-k2-0905-turbo" + OPENROUTER_MODEL: "Antigravity/gemini-3-flash-preview" RELEASE_NAME: "NapCat" jobs: @@ -24,6 +27,8 @@ jobs: with: node-version: 20.x - name: Build NapCat.Framework + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | npm i -g pnpm pnpm i @@ -49,6 +54,8 @@ jobs: with: node-version: 20.x - name: Build NapCat.Shell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | npm i -g pnpm pnpm i @@ -171,7 +178,7 @@ jobs: - name: Generate release note via OpenRouter env: - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }} OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }} GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner @@ -233,7 +240,7 @@ jobs: # 调用 OpenRouter if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \ - -H "Authorization: Bearer $OPENROUTER_API_KEY" \ + -H "Authorization: Bearer $OPENAI_KEY" \ -H "Content-Type: application/json" \ -d "$BODY"); then echo "=== raw response ===" diff --git a/packages/napcat-common/src/helper.ts b/packages/napcat-common/src/helper.ts index 89cf38a8..f3b3d5ad 100644 --- a/packages/napcat-common/src/helper.ts +++ b/packages/napcat-common/src/helper.ts @@ -2,7 +2,11 @@ import path from 'node:path'; import fs from 'fs'; import os from 'node:os'; import { QQVersionConfigType, QQLevel } from './types'; -import { RequestUtil } from './request'; +import { compareSemVer } from './version'; +import { getAllGitHubTags as getAllTagsFromMirror } from './mirror'; + +// 导出 compareSemVer 供其他模块使用 +export { compareSemVer } from './version'; export async function solveProblem any> (func: T, ...args: Parameters): Promise | undefined> { return new Promise | undefined>((resolve) => { @@ -213,56 +217,19 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined { return undefined; } -const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack'; -const urls = [ - 'https://j.1win.ggff.net/' + baseUrl, - 'https://git.yylx.win/' + baseUrl, - 'https://ghfile.geekertao.top/' + baseUrl, - 'https://gh-proxy.net/' + baseUrl, - 'https://ghm.078465.xyz/' + baseUrl, - 'https://gitproxy.127731.xyz/' + baseUrl, - 'https://jiashu.1win.eu.org/' + baseUrl, - baseUrl, -]; +// ============== GitHub Tags 获取 ============== +// 使用 mirror 模块统一管理镜像 -async function testUrl (url: string): Promise { - try { - await PromiseTimer(RequestUtil.HttpGetText(url), 5000); - return true; - } catch { - return false; - } -} - -async function findAvailableUrl (): Promise { - for (const url of urls) { - if (await testUrl(url)) { - return url; - } - } - return null; -} - -export async function getAllTags (): Promise { - const availableUrl = await findAvailableUrl(); - if (!availableUrl) { - throw new Error('No available URL for fetching tags'); - } - const raw = await RequestUtil.HttpGetText(availableUrl); - return raw - .split('\n') - .map(line => { - const match = line.match(/refs\/tags\/(.+)$/); - return match ? match[1] : null; - }) - .filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[]; +export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> { + return getAllTagsFromMirror('NapNeko', 'NapCatQQ'); } export async function getLatestTag (): Promise { - const tags = await getAllTags(); + const { tags } = await getAllTags(); - tags.sort((a, b) => compareVersion(a, b)); + // 使用 SemVer 规范排序 + tags.sort((a, b) => compareSemVer(a, b)); const latest = tags.at(-1); if (!latest) { @@ -271,22 +238,3 @@ export async function getLatestTag (): Promise { // 去掉开头的 v return latest.replace(/^v/, ''); } - - -function compareVersion (a: string, b: string): number { - const normalize = (v: string) => - v.replace(/^v/, '') // 去掉开头的 v - .split('.') - .map(n => parseInt(n) || 0); - - const pa = normalize(a); - const pb = normalize(b); - const len = Math.max(pa.length, pb.length); - - for (let i = 0; i < len; i++) { - const na = pa[i] || 0; - const nb = pb[i] || 0; - if (na !== nb) return na - nb; - } - return 0; -} diff --git a/packages/napcat-common/src/mirror.ts b/packages/napcat-common/src/mirror.ts new file mode 100644 index 00000000..17342a63 --- /dev/null +++ b/packages/napcat-common/src/mirror.ts @@ -0,0 +1,898 @@ +/** + * GitHub 镜像配置模块 + * 提供统一的镜像源管理,支持复杂网络环境 + * + * 镜像源测试时间: 2026-01-03 + * 测试通过: 55/61 完全可用 + */ + +import https from 'https'; +import http from 'http'; +import { RequestUtil } from './request'; +import { PromiseTimer } from './helper'; + +// ============== 镜像源列表 ============== + +/** + * GitHub 文件加速镜像 + * 用于加速 release assets 下载 + * 按延迟排序,优先使用快速镜像 + * + * 测试时间: 2026-01-03 + * 镜像支持 301/302 重定向 + * 懒加载测速:首次使用时自动测速,缓存 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 + '', // 原始 URL(无镜像) +]; + +/** + * GitHub API 镜像 + * 用于访问 GitHub API(作为备选方案) + * 注:优先使用非 API 方法,减少对 API 的依赖 + * + * 经测试,大部分代理镜像不支持 API 转发 + * 建议使用 getLatestReleaseTag 等方法避免 API 调用 + */ +export const GITHUB_API_MIRRORS = [ + 'https://api.github.com', + // 目前没有可用的公共 API 代理镜像 +]; + +/** + * GitHub Raw 镜像 + * 用于访问 raw.githubusercontent.com + * 注:大多数通用代理也支持 raw 文件加速 + */ +export const GITHUB_RAW_MIRRORS = [ + 'https://raw.githubusercontent.com', + // 测试确认支持 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', +]; + +// ============== 镜像配置接口 ============== + +export interface MirrorConfig { + /** 文件下载镜像(用于 release assets) */ + fileMirrors: string[]; + /** API 镜像 */ + apiMirrors: string[]; + /** Raw 文件镜像 */ + rawMirrors: string[]; + /** 超时时间(毫秒) */ + timeout: number; + /** 是否启用镜像 */ + enabled: boolean; + /** 自定义镜像(优先使用) */ + customMirror?: string; +} + +// ============== 默认配置 ============== + +const defaultConfig: MirrorConfig = { + fileMirrors: GITHUB_FILE_MIRRORS, + apiMirrors: GITHUB_API_MIRRORS, + rawMirrors: GITHUB_RAW_MIRRORS, + timeout: 10000, // 10秒超时,平衡速度和可靠性 + enabled: true, + customMirror: undefined, +}; + +let currentConfig: MirrorConfig = { ...defaultConfig }; + +// ============== 懒加载镜像测速缓存 ============== + +interface MirrorTestResult { + mirror: string; + latency: number; + success: boolean; +} + +// 缓存的快速镜像列表(按延迟排序) +let cachedFastMirrors: string[] | null = null; +// 测速是否正在进行 +let mirrorTestingPromise: Promise | null = null; +// 缓存过期时间(30分钟) +const MIRROR_CACHE_TTL = 30 * 60 * 1000; +let cacheTimestamp: number = 0; + +/** + * 测试单个镜像的延迟(使用 HEAD 请求测试实际文件) + * 测试一个小型的实际 release 文件,确保镜像支持文件下载 + */ +async function testMirrorLatency (mirror: string, timeout: number = 5000): Promise { + // 使用一个实际存在的小文件来测试(README 或小型 release asset) + // 用 HEAD 请求,不下载实际内容 + const testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest'; + const url = buildMirrorUrl(testUrl, mirror); + const start = Date.now(); + + return new Promise((resolve) => { + try { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === 'https:'; + const client = isHttps ? https : http; + + const req = client.request({ + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'HEAD', + timeout, + headers: { + 'User-Agent': 'NapCat-Mirror-Test', + }, + }, (res) => { + const statusCode = res.statusCode || 0; + // 2xx 或 3xx 都算成功(3xx 说明镜像工作正常,会重定向) + const isValid = statusCode >= 200 && statusCode < 400; + resolve({ + mirror, + latency: Date.now() - start, + success: isValid, + }); + }); + + req.on('error', () => { + resolve({ + mirror, + latency: Infinity, + success: false, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + mirror, + latency: Infinity, + success: false, + }); + }); + + req.end(); + } catch { + resolve({ + mirror, + latency: Infinity, + success: false, + }); + } + }); +} + +/** + * 懒加载获取快速镜像列表 + * 第一次调用时会进行测速,后续使用缓存 + */ +export async function getFastMirrors (forceRefresh: boolean = false): Promise { + // 检查缓存是否有效 + const now = Date.now(); + if (!forceRefresh && cachedFastMirrors && (now - cacheTimestamp) < MIRROR_CACHE_TTL) { + return cachedFastMirrors; + } + + // 如果已经在测速中,等待结果 + if (mirrorTestingPromise) { + return mirrorTestingPromise; + } + + // 开始测速 + mirrorTestingPromise = performMirrorTest(); + + try { + const result = await mirrorTestingPromise; + cachedFastMirrors = result; + cacheTimestamp = now; + return result; + } finally { + mirrorTestingPromise = null; + } +} + +/** + * 执行镜像测速 + * 并行测试所有镜像,返回按延迟排序的可用镜像列表 + */ +async function performMirrorTest (): Promise { + // 开始镜像测速 + + const timeout = 8000; // 测速超时 8 秒 + + // 并行测试所有镜像 + const mirrors = currentConfig.fileMirrors.filter(m => m); + const results = await Promise.all( + mirrors.map(m => testMirrorLatency(m, timeout)) + ); + + // 过滤成功的镜像并按延迟排序 + const successfulMirrors = results + .filter(r => r.success) + .sort((a, b) => a.latency - b.latency) + .map(r => r.mirror); + + + + // 至少返回原始 URL + if (successfulMirrors.length === 0) { + return ['']; + } + + return successfulMirrors; +} + +/** + * 清除镜像缓存,强制下次重新测速 + */ +export function clearMirrorCache (): void { + cachedFastMirrors = null; + cacheTimestamp = 0; + +} + +/** + * 获取缓存状态 + */ +export function getMirrorCacheStatus (): { cached: boolean; count: number; age: number; } { + return { + cached: cachedFastMirrors !== null, + count: cachedFastMirrors?.length ?? 0, + age: cachedFastMirrors ? Date.now() - cacheTimestamp : 0, + }; +} + +// ============== 配置管理 ============== + +/** + * 获取当前镜像配置 + */ +export function getMirrorConfig (): MirrorConfig { + return { ...currentConfig }; +} + +/** + * 更新镜像配置 + */ +export function setMirrorConfig (config: Partial): void { + currentConfig = { ...currentConfig, ...config }; +} + +/** + * 重置为默认配置 + */ +export function resetMirrorConfig (): void { + currentConfig = { ...defaultConfig }; +} + +/** + * 设置自定义镜像(优先级最高) + */ +export function setCustomMirror (mirror: string): void { + currentConfig.customMirror = mirror; +} + +// ============== URL 工具函数 ============== + +/** + * 构建镜像 URL + * @param originalUrl 原始 URL + * @param mirror 镜像前缀 + */ +export function buildMirrorUrl (originalUrl: string, mirror: string): string { + if (!mirror) return originalUrl; + // 如果镜像已经包含完整域名,直接拼接 + if (mirror.endsWith('/')) { + return mirror + originalUrl; + } + return mirror + '/' + originalUrl; +} + +/** + * 测试 URL 是否可用(HTTP GET) + * @param url 要测试的 URL + * @param timeout 超时时间 + */ +export async function testUrl (url: string, timeout: number = 5000): Promise { + try { + await PromiseTimer(RequestUtil.HttpGetText(url), timeout); + return true; + } catch { + return false; + } +} + +/** + * 测试 URL 是否可用(HTTP HEAD,更快) + * 验证:状态码、Content-Type、Content-Length + */ +export async function testUrlHead (url: string, timeout: number = 5000): Promise { + return new Promise((resolve) => { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === 'https:'; + const client = isHttps ? https : http; + + const req = client.request({ + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'HEAD', + timeout, + headers: { + 'User-Agent': 'NapCat-Mirror-Test', + }, + }, (res) => { + const statusCode = res.statusCode || 0; + const contentType = (res.headers['content-type'] as string) || ''; + const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10); + + // 验证条件: + // 1. 状态码 2xx 或 3xx + // 2. Content-Type 不应该是 text/html(表示错误页面) + // 3. 对于 .zip 文件,Content-Length 应该 > 1MB(避免获取到错误页面) + const isValidStatus = statusCode >= 200 && statusCode < 400; + const isNotHtmlError = !contentType.includes('text/html'); + const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true; + + resolve(isValidStatus && isNotHtmlError && isValidSize); + }); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + +/** + * 详细验证 URL 响应 + * 返回验证结果和详细信息 + */ +export interface UrlValidationResult { + valid: boolean; + statusCode?: number; + contentType?: string; + contentLength?: number; + error?: string; +} + +export async function validateUrl (url: string, timeout: number = 5000): Promise { + return new Promise((resolve) => { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === 'https:'; + const client = isHttps ? https : http; + + const req = client.request({ + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'HEAD', + timeout, + headers: { + 'User-Agent': 'NapCat-Mirror-Test', + }, + }, (res) => { + const statusCode = res.statusCode || 0; + const contentType = (res.headers['content-type'] as string) || ''; + const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10); + + // 验证条件 + const isValidStatus = statusCode >= 200 && statusCode < 400; + const isNotHtmlError = !contentType.includes('text/html'); + const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true; + + if (!isValidStatus) { + resolve({ + valid: false, + statusCode, + contentType, + contentLength, + error: `HTTP ${statusCode}`, + }); + } else if (!isNotHtmlError) { + resolve({ + valid: false, + statusCode, + contentType, + contentLength, + error: '返回了 HTML 页面而非文件', + }); + } else if (!isValidSize) { + resolve({ + valid: false, + statusCode, + contentType, + contentLength, + error: `文件过小 (${contentLength} bytes),可能是错误页面`, + }); + } else { + resolve({ + valid: true, + statusCode, + contentType, + contentLength, + }); + } + }); + + req.on('error', (e: Error) => resolve({ + valid: false, + error: e.message, + })); + req.on('timeout', () => { + req.destroy(); + resolve({ + valid: false, + error: 'Timeout', + }); + }); + req.end(); + }); +} + +// ============== 查找可用 URL ============== + +/** + * 查找可用的下载 URL + * 使用懒加载的快速镜像列表 + * @param originalUrl 原始 GitHub URL + * @param options 选项 + */ +export async function findAvailableDownloadUrl ( + originalUrl: string, + options: { + mirrors?: string[]; + timeout?: number; + customMirror?: string; + testMethod?: 'head' | 'get'; + /** 是否使用详细验证(验证 Content-Type 和 Content-Length) */ + validateContent?: boolean; + /** 期望的最小文件大小(字节),用于验证 */ + minFileSize?: number; + /** 是否使用懒加载的快速镜像列表 */ + useFastMirrors?: boolean; + } = {} +): Promise { + const { + timeout = currentConfig.timeout, + customMirror = currentConfig.customMirror, + testMethod = 'head', + validateContent = true, // 默认启用内容验证 + minFileSize, + useFastMirrors = true, // 默认使用快速镜像列表 + } = options; + + // 获取镜像列表 + let mirrors = options.mirrors; + if (!mirrors) { + if (useFastMirrors) { + // 使用懒加载的快速镜像列表 + mirrors = await getFastMirrors(); + } else { + mirrors = currentConfig.fileMirrors; + } + } + + // 使用增强验证或简单测试 + const testWithValidation = async (url: string): Promise => { + if (validateContent) { + const result = await validateUrl(url, timeout); + // 额外检查文件大小 + if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) { + return false; + } + return result.valid; + } + return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout); + }; + + // 1. 如果设置了自定义镜像,优先使用 + if (customMirror) { + const customUrl = buildMirrorUrl(originalUrl, customMirror); + if (await testWithValidation(customUrl)) { + return customUrl; + } + } + + // 2. 先测试原始 URL + if (await testWithValidation(originalUrl)) { + return originalUrl; + } + + // 3. 测试镜像源(已按延迟排序) + let testedCount = 0; + for (const mirror of mirrors) { + if (!mirror) continue; // 跳过空字符串 + const mirrorUrl = buildMirrorUrl(originalUrl, mirror); + testedCount++; + if (await testWithValidation(mirrorUrl)) { + return mirrorUrl; + } + } + + throw new Error(`所有下载源都不可用(已测试 ${testedCount} 个镜像),请检查网络连接或配置自定义镜像`); +} + +// ============== 版本和 Release 相关(减少 API 依赖) ============== + +/** + * 语义化版本正则(简化版,用于排序) + */ +const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/; + +/** + * 解析语义化版本号 + */ +function parseSemVerSimple (version: string): { major: number; minor: number; patch: number; prerelease: string; } | null { + const match = version.match(SEMVER_REGEX); + if (!match) return null; + return { + major: parseInt(match[1] ?? '0', 10), + minor: parseInt(match[2] ?? '0', 10), + patch: parseInt(match[3] ?? '0', 10), + prerelease: match[4] || '', + }; +} + +/** + * 比较两个版本号 + */ +function compareSemVerSimple (a: string, b: string): number { + const pa = parseSemVerSimple(a); + const pb = parseSemVerSimple(b); + if (!pa && !pb) return 0; + if (!pa) return -1; + if (!pb) return 1; + + if (pa.major !== pb.major) return pa.major - pb.major; + if (pa.minor !== pb.minor) return pa.minor - pb.minor; + if (pa.patch !== pb.patch) return pa.patch - pb.patch; + + // 预发布版本排在正式版本前面 + if (pa.prerelease && !pb.prerelease) return -1; + if (!pa.prerelease && pb.prerelease) return 1; + + return pa.prerelease.localeCompare(pb.prerelease); +} + +/** + * 从 tags 列表中获取最新的 release tag + * 不依赖 GitHub API + */ +export async function getLatestReleaseTag (owner: string, repo: string): Promise { + const result = await getAllGitHubTags(owner, repo); + + // 过滤出符合 semver 的 tags + const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag)); + + if (releaseTags.length === 0) { + throw new Error('未找到有效的 release tag'); + } + + // 按版本号排序,取最新的 + releaseTags.sort(compareSemVerSimple); + const latest = releaseTags[releaseTags.length - 1]; + + if (!latest) { + throw new Error('未找到有效的 release tag'); + } + + return latest; +} + +/** + * 直接构建 GitHub release 下载 URL + * 不需要调用 API,直接基于 tag 和 asset 名称构建 + */ +export function buildReleaseDownloadUrl ( + owner: string, + repo: string, + tag: string, + assetName: string +): string { + return `https://github.com/${owner}/${repo}/releases/download/${tag}/${assetName}`; +} + +/** + * 获取 GitHub release 信息(优先使用非 API 方法) + * + * 策略: + * 1. 先通过 git refs 获取 tags + * 2. 直接构建下载 URL,不依赖 API + * 3. 仅当需要 changelog 时才使用 API + */ +export async function getGitHubRelease ( + owner: string, + repo: string, + tag: string = 'latest', + options: { + /** 需要获取的 asset 名称列表 */ + assetNames?: string[]; + /** 是否需要获取 changelog(需要调用 API) */ + fetchChangelog?: boolean; + } = {} +): Promise<{ + tag_name: string; + assets: Array<{ + name: string; + browser_download_url: string; + }>; + body?: string; +}> { + const { assetNames = [], fetchChangelog = false } = options; + + // 1. 获取实际的 tag 名称 + let actualTag: string; + if (tag === 'latest') { + actualTag = await getLatestReleaseTag(owner, repo); + } else { + actualTag = tag; + } + + // 2. 构建 assets 列表(不需要 API) + const assets = assetNames.map(name => ({ + name, + browser_download_url: buildReleaseDownloadUrl(owner, repo, actualTag, name), + })); + + // 3. 如果不需要 changelog 且有 assetNames,直接返回 + if (!fetchChangelog && assetNames.length > 0) { + return { + tag_name: actualTag, + assets, + body: undefined, + }; + } + + // 4. 需要更多信息时,尝试调用 API(作为备选) + const endpoint = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${actualTag}`; + + for (const apiBase of currentConfig.apiMirrors) { + try { + const url = endpoint.replace('https://api.github.com', apiBase); + const response = await PromiseTimer( + RequestUtil.HttpGetJson(url, 'GET', undefined, { + 'User-Agent': 'NapCat', + 'Accept': 'application/vnd.github.v3+json', + }), + currentConfig.timeout + ); + return response; + } catch { + continue; + } + } + + // 5. API 全部失败,但如果有 assetNames,仍然返回构建的 URL + if (assetNames.length > 0) { + return { + tag_name: actualTag, + assets, + body: undefined, + }; + } + + throw new Error('无法获取 release 信息,所有 API 源都不可用'); +} + +// ============== Tags 缓存 ============== + +interface TagsCache { + tags: string[]; + mirror: string; + timestamp: number; +} + +// 缓存 tags 结果(5 分钟有效) +const TAGS_CACHE_TTL = 5 * 60 * 1000; +const tagsCache: Map = new Map(); + +/** + * 获取所有 GitHub tags(带缓存) + * 使用懒加载的快速镜像列表,按测速延迟排序依次尝试 + */ +export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> { + const cacheKey = `${owner}/${repo}`; + + // 检查缓存 + const cached = tagsCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < TAGS_CACHE_TTL) { + return { tags: cached.tags, mirror: cached.mirror }; + } + + const baseUrl = `https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`; + + // 解析 tags 的辅助函数 + const parseTags = (raw: string): string[] => { + return raw + .split('\n') + .map((line: string) => { + const match = line.match(/refs\/tags\/(.+)$/); + return match ? match[1] : undefined; + }) + .filter((tag): tag is string => tag !== undefined && !tag.endsWith('^{}')); + }; + + // 尝试从 URL 获取 tags + const fetchFromUrl = async (url: string): Promise => { + try { + const raw = await PromiseTimer( + RequestUtil.HttpGetText(url), + currentConfig.timeout + ); + + // 检查返回内容是否有效(不是 HTML 错误页面) + if (raw.includes(' 0) { + return tags; + } + return null; + } catch { + return null; + } + }; + + // 获取快速镜像列表(懒加载,首次调用会测速,已按延迟排序) + let fastMirrors: string[] = []; + try { + fastMirrors = await getFastMirrors(); + } catch (e) { + // 忽略错误,继续使用空列表 + } + + // 构建 URL 列表(快速镜像 + 原始 URL) + const mirrorUrls = fastMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })); + mirrorUrls.push({ url: baseUrl, mirror: 'github.com' }); // 添加原始 URL + + // 按顺序尝试每个镜像(已按延迟排序),成功即返回 + for (const { url, mirror } of mirrorUrls) { + const tags = await fetchFromUrl(url); + if (tags && tags.length > 0) { + // 缓存结果 + tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() }); + return { tags, mirror }; + } + } + + // 如果快速镜像都失败,回退到原始镜像列表 + const allMirrors = currentConfig.fileMirrors.filter(m => m); + for (const mirror of allMirrors) { + // 跳过已经尝试过的镜像 + if (fastMirrors.includes(mirror)) continue; + + const url = buildMirrorUrl(baseUrl, mirror); + const tags = await fetchFromUrl(url); + if (tags && tags.length > 0) { + // 缓存结果 + tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() }); + return { tags, mirror }; + } + } + + throw new Error('无法获取 tags,所有源都不可用'); +} + +// ============== Action Artifacts 支持 ============== + +export interface ActionArtifact { + id: number; + name: string; + size_in_bytes: number; + created_at: string; + expires_at: string; + archive_download_url: string; +} + +/** + * 获取 GitHub Action 最新运行的 artifacts + * 用于下载 nightly/dev 版本 + */ +export async function getLatestActionArtifacts ( + owner: string, + repo: string, + workflow: string = 'build.yml', + branch: string = 'main' +): Promise { + const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=1`; + + try { + const runsResponse = await RequestUtil.HttpGetJson<{ + workflow_runs: Array<{ id: number; }>; + }>(endpoint, 'GET', undefined, { + 'User-Agent': 'NapCat', + 'Accept': 'application/vnd.github.v3+json', + }); + + const workflowRuns = runsResponse.workflow_runs; + if (!workflowRuns || workflowRuns.length === 0) { + throw new Error('No successful workflow runs found'); + } + + const firstRun = workflowRuns[0]; + if (!firstRun) { + throw new Error('No workflow run found'); + } + const runId = firstRun.id; + const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`; + + const artifactsResponse = await RequestUtil.HttpGetJson<{ + artifacts: ActionArtifact[]; + }>(artifactsEndpoint, 'GET', undefined, { + 'User-Agent': 'NapCat', + 'Accept': 'application/vnd.github.v3+json', + }); + + return artifactsResponse.artifacts || []; + } catch { + return []; + } +} diff --git a/packages/napcat-common/src/request.ts b/packages/napcat-common/src/request.ts index 7deed22f..3383f729 100644 --- a/packages/napcat-common/src/request.ts +++ b/packages/napcat-common/src/request.ts @@ -3,11 +3,11 @@ import http from 'node:http'; export class RequestUtil { // 适用于获取服务器下发cookies时获取,仅GET - static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> { + static async HttpsGetCookies (url: string): Promise<{ [key: string]: string; }> { const client = url.startsWith('https') ? https : http; return new Promise((resolve, reject) => { const req = client.get(url, (res) => { - const cookies: { [key: string]: string } = {}; + const cookies: { [key: string]: string; } = {}; res.on('data', () => { }); // Necessary to consume the stream res.on('end', () => { @@ -27,7 +27,7 @@ export class RequestUtil { }); } - private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> { + private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string; }): Promise<{ [key: string]: string; }> { if (res.statusCode === 301 || res.statusCode === 302) { if (res.headers.location) { const redirectUrl = new URL(res.headers.location, url); @@ -39,7 +39,7 @@ export class RequestUtil { return cookies; } - private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) { + private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) { setCookieHeaders.forEach((cookie) => { const parts = cookie.split(';')[0]?.split('='); if (parts) { @@ -53,9 +53,10 @@ export class RequestUtil { } // 请求和回复都是JSON data传原始内容 自动编码json - static async HttpGetJson(url: string, method: string = 'GET', data?: any, headers: { - [key: string]: string - } = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise { + // 支持 301/302 重定向(最多 5 次) + static async HttpGetJson (url: string, method: string = 'GET', data?: any, headers: { + [key: string]: string; + } = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise { const option = new URL(url); const protocol = url.startsWith('https://') ? https : http; const options = { @@ -71,6 +72,20 @@ export class RequestUtil { // }, return new Promise((resolve, reject) => { const req = protocol.request(options, (res: http.IncomingMessage) => { + // 处理重定向 + if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) { + if (maxRedirects <= 0) { + reject(new Error('Too many redirects')); + return; + } + const redirectUrl = new URL(res.headers.location, url).href; + // 递归跟随重定向 + this.HttpGetJson(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1) + .then(resolve) + .catch(reject); + return; + } + let responseBody = ''; res.on('data', (chunk: string | Buffer) => { responseBody += chunk.toString(); @@ -109,7 +124,7 @@ export class RequestUtil { } // 请求返回都是原始内容 - static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) { + static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) { return this.HttpGetJson(url, method, data, headers, false, false); } } diff --git a/packages/napcat-common/src/version.ts b/packages/napcat-common/src/version.ts index e798ba8a..5dd34e8f 100644 --- a/packages/napcat-common/src/version.ts +++ b/packages/napcat-common/src/version.ts @@ -1,2 +1,118 @@ // @ts-ignore -export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha'; +export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev'; + +/** + * SemVer 2.0 正则表达式 + * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息] + * 参考: https://semver.org/lang/zh-CN/ + */ +const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +export interface SemVerInfo { + valid: boolean; + normalized: string; + major: number; + minor: number; + patch: number; + prerelease: string | null; + buildmetadata: string | null; +} + +/** + * 解析并验证版本号是否符合 SemVer 2.0 规范 + * @param version - 版本字符串 (支持 v 前缀) + * @returns SemVer 解析结果 + */ +export function parseSemVer (version: string | undefined | null): SemVerInfo { + if (!version || typeof version !== 'string') { + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; + } + + const match = version.trim().match(SEMVER_REGEX); + if (match) { + const major = parseInt(match[1]!, 10); + const minor = parseInt(match[2]!, 10); + const patch = parseInt(match[3]!, 10); + const prerelease = match[4] || null; + const buildmetadata = match[5] || null; + + // 构建标准化版本号(不带 v 前缀) + let normalized = `${major}.${minor}.${patch}`; + if (prerelease) normalized += `-${prerelease}`; + if (buildmetadata) normalized += `+${buildmetadata}`; + + return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata }; + } + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; +} + +/** + * 验证版本号是否符合 SemVer 2.0 规范 + * @param version - 版本字符串 + * @returns 是否有效 + */ +export function isValidSemVer (version: string | undefined | null): boolean { + return parseSemVer(version).valid; +} + +/** + * 比较两个 SemVer 版本号 + * @param v1 - 版本号1 + * @param v2 - 版本号2 + * @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2) + */ +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.major !== b.major) return a.major > b.major ? 1 : -1; + // 比较次版本号 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1; + // 比较修订号 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1; + + // 有先行版本号的版本优先级较低 + if (a.prerelease && !b.prerelease) return -1; + if (!a.prerelease && b.prerelease) return 1; + + // 两者都有先行版本号时,按字典序比较 + if (a.prerelease && b.prerelease) { + const aParts = a.prerelease.split('.'); + const bParts = b.prerelease.split('.'); + const len = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < len; i++) { + const aPart = aParts[i]; + const bPart = bParts[i]; + + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + + const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN; + const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN; + + // 数字 vs 数字 + if (!isNaN(aNum) && !isNaN(bNum)) { + if (aNum !== bNum) return aNum > bNum ? 1 : -1; + continue; + } + // 数字优先级低于字符串 + if (!isNaN(aNum)) return -1; + if (!isNaN(bNum)) return 1; + // 字符串 vs 字符串 + if (aPart !== bPart) return aPart > bPart ? 1 : -1; + } + } + + return 0; +} + +/** + * 获取解析后的当前版本信息 + */ +export const napCatVersionInfo = parseSemVer(napCatVersion); diff --git a/packages/napcat-vite/vite-plugin-version.js b/packages/napcat-vite/vite-plugin-version.js index 2091d5dd..4136500f 100644 --- a/packages/napcat-vite/vite-plugin-version.js +++ b/packages/napcat-vite/vite-plugin-version.js @@ -6,8 +6,49 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +/** + * SemVer 2.0 正则表达式 + * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息] + * 参考: https://semver.org/lang/zh-CN/ + */ +const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** + * Validate version format according to SemVer 2.0 specification + * @param {string} version - The version string to validate (with or without 'v' prefix) + * @returns {{ valid: boolean, normalized: string, major: number, minor: number, patch: number, prerelease: string|null, buildmetadata: string|null }} + */ +function validateVersion (version) { + if (!version || typeof version !== 'string') { + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; + } + + const match = version.trim().match(SEMVER_REGEX); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3], 10); + const prerelease = match[4] || null; + const buildmetadata = match[5] || null; + + // 构建标准化版本号(不带 v 前缀) + let normalized = `${major}.${minor}.${patch}`; + if (prerelease) normalized += `-${prerelease}`; + if (buildmetadata) normalized += `+${buildmetadata}`; + + return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata }; + } + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; +} + /** * NapCat Vite Plugin: fetches latest GitHub tag (not release) and injects into import.meta.env + * + * 版本号来源优先级: + * 1. 环境变量 NAPCAT_VERSION (用于 CI 构建) + * 2. 缓存的 GitHub tag + * 3. 从 GitHub API 获取最新 tag + * 4. 兆底版本号: 1.0.0-dev */ export default function vitePluginNapcatVersion () { const pluginDir = path.resolve(__dirname, 'dist'); @@ -16,6 +57,9 @@ export default function vitePluginNapcatVersion () { const repo = 'NapCatQQ'; const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day const githubToken = process.env.GITHUB_TOKEN; + // CI 构建时可通过环境变量直接指定版本号 + const envVersion = process.env.NAPCAT_VERSION; + const fallbackVersion = '1.0.0-dev'; fs.mkdirSync(pluginDir, { recursive: true }); @@ -58,7 +102,14 @@ export default function vitePluginNapcatVersion () { try { const json = JSON.parse(data); if (Array.isArray(json) && json[0]?.name) { - resolve(json[0].name.replace(/^v/, '')); + const tagName = json[0].name; + const { valid, normalized } = validateVersion(tagName); + if (valid) { + resolve(normalized); + } else { + console.warn(`[vite-plugin-napcat-version] Invalid tag format: ${tagName}, expected vX.X.X`); + reject(new Error(`Invalid tag format: ${tagName}, expected vX.X.X`)); + } } else reject(new Error('Invalid GitHub tag response')); } catch (e) { reject(e); @@ -71,6 +122,17 @@ export default function vitePluginNapcatVersion () { } async function getVersion () { + // 优先使用环境变量指定的版本号 (CI 构建) + if (envVersion) { + const { valid, normalized } = validateVersion(envVersion); + if (valid) { + console.log(`[vite-plugin-napcat-version] Using version from NAPCAT_VERSION env: ${normalized}`); + return normalized; + } else { + console.warn(`[vite-plugin-napcat-version] Invalid NAPCAT_VERSION format: ${envVersion}, falling back to fetch`); + } + } + const cached = readCache(); if (cached) return cached; try { @@ -79,7 +141,7 @@ export default function vitePluginNapcatVersion () { return tag; } catch (e) { console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message); - return cached ?? '0.0.0'; + return cached ?? fallbackVersion; } } @@ -115,3 +177,6 @@ export default function vitePluginNapcatVersion () { }, }; } + +// Export validateVersion for external use +export { validateVersion }; diff --git a/packages/napcat-webui-backend/src/api/BaseInfo.ts b/packages/napcat-webui-backend/src/api/BaseInfo.ts index 11e5bfd6..6b4f2282 100644 --- a/packages/napcat-webui-backend/src/api/BaseInfo.ts +++ b/packages/napcat-webui-backend/src/api/BaseInfo.ts @@ -3,7 +3,8 @@ 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 } from 'napcat-common/src/helper'; +import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper'; +import { getLatestActionArtifacts } from '@/napcat-common/src/mirror'; export const GetNapCatVersion: RequestHandler = (_, res) => { const data = WebUiDataRuntime.GetNapCatVersion(); @@ -15,7 +16,121 @@ export const getLatestTagHandler: RequestHandler = async (_, res) => { const latestTag = await getLatestTag(); sendSuccess(res, latestTag); } catch (error) { - res.status(500).json({ error: 'Failed to fetch latest tag' }); + res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message }); + } +}; + +/** + * 版本信息接口 + */ +export interface VersionInfo { + tag: string; + type: 'release' | 'prerelease' | 'action'; + /** Action artifact 专用字段 */ + artifactId?: number; + artifactName?: string; + createdAt?: string; + expiresAt?: string; + size?: number; +} + +/** + * 获取所有可用的版本(release + action artifacts) + * 支持分页 + */ +export const getAllReleasesHandler: RequestHandler = async (req, res) => { + try { + const page = parseInt(req.query['page'] as string) || 1; + const pageSize = parseInt(req.query['pageSize'] as string) || 20; + const includeActions = req.query['includeActions'] !== 'false'; + const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all' + const searchQuery = (req.query['search'] as string || '').toLowerCase().trim(); + + let tags: string[] = []; + let usedMirror = ''; + try { + const result = await getAllTags(); + tags = result.tags; + usedMirror = result.mirror; + } catch { + // 如果获取 tags 失败,返回空列表而不是抛出错误 + tags = []; + } + + // 解析版本信息 + const versions: VersionInfo[] = tags.map(tag => { + // 检查是否是预发布版本 + const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag); + return { + tag, + type: isPrerelease ? 'prerelease' : 'release', + }; + }); + + // 使用语义化版本排序(最新的在前) + versions.sort((a, b) => -compareSemVer(a.tag, b.tag)); + + // 获取 Action Artifacts(如果请求) + let actionVersions: VersionInfo[] = []; + if (includeActions) { + try { + const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main'); + actionVersions = artifacts + .filter(a => a.name.includes('NapCat')) + .map(a => ({ + tag: `action-${a.id}`, + type: 'action' as const, + artifactId: a.id, + artifactName: a.name, + createdAt: a.created_at, + expiresAt: a.expires_at, + size: a.size_in_bytes, + })); + } catch { + // 忽略 action artifacts 获取失败 + } + } + + // 合并版本列表(action 在最前面) + let allVersions = [...actionVersions, ...versions]; + + // 按类型过滤 + if (typeFilter && typeFilter !== 'all') { + if (typeFilter === 'release') { + allVersions = allVersions.filter(v => v.type === 'release' || v.type === 'prerelease'); + } else if (typeFilter === 'action') { + allVersions = allVersions.filter(v => v.type === 'action'); + } + } + + // 搜索过滤 + if (searchQuery) { + allVersions = allVersions.filter(v => { + const tagMatch = v.tag.toLowerCase().includes(searchQuery); + const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery); + return tagMatch || nameMatch; + }); + } + + // 分页 + const total = allVersions.length; + const totalPages = Math.ceil(total / pageSize); + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedVersions = allVersions.slice(start, end); + + sendSuccess(res, { + versions: paginatedVersions, + pagination: { + page, + pageSize, + total, + totalPages, + }, + mirror: usedMirror + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch releases' }); } }; diff --git a/packages/napcat-webui-backend/src/api/UpdateNapCat.ts b/packages/napcat-webui-backend/src/api/UpdateNapCat.ts index 24f4d192..8dc358bd 100644 --- a/packages/napcat-webui-backend/src/api/UpdateNapCat.ts +++ b/packages/napcat-webui-backend/src/api/UpdateNapCat.ts @@ -8,14 +8,17 @@ import { webUiPathWrapper } from '../../index'; import { NapCatPathWrapper } from '@/napcat-common/src/path'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types'; +import { + getGitHubRelease, + findAvailableDownloadUrl +} from '@/napcat-common/src/mirror'; -interface Release { - tag_name: string; - assets: Array<{ - name: string; - browser_download_url: string; - }>; - body?: string; +// 更新请求体接口 +interface UpdateRequestBody { + /** 要更新到的版本 tag,如 "v4.9.9",不传则更新到最新版本 */ + targetVersion?: string; + /** 是否强制更新(即使是降级也更新) */ + force?: boolean; } // 更新配置文件接口 @@ -69,74 +72,7 @@ function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Arr return files; } -// 镜像源列表(参考ffmpeg下载实现) -const mirrorUrls = [ - 'https://j.1win.ggff.net/', - 'https://git.yylx.win/', - 'https://ghfile.geekertao.top/', - 'https://gh-proxy.net/', - 'https://ghm.078465.xyz/', - 'https://gitproxy.127731.xyz/', - 'https://jiashu.1win.eu.org/', - '', // 原始URL -]; - -/** - * 测试URL是否可用 - */ -async function testUrl (url: string): Promise { - return new Promise((resolve) => { - const req = https.get(url, { timeout: 5000 }, (res) => { - const statusCode = res.statusCode || 0; - if (statusCode >= 200 && statusCode < 300) { - req.destroy(); - resolve(true); - } else { - req.destroy(); - resolve(false); - } - }); - - req.on('error', () => resolve(false)); - req.on('timeout', () => { - req.destroy(); - resolve(false); - }); - }); -} - -/** - * 构建镜像URL - */ -function buildMirrorUrl (originalUrl: string, mirror: string): string { - if (!mirror) return originalUrl; - return mirror + originalUrl; -} - -/** - * 查找可用的下载URL - */ -async function findAvailableUrl (originalUrl: string): Promise { - console.log('Testing download URLs...'); - - // 先测试原始URL - if (await testUrl(originalUrl)) { - console.log('Using original URL:', originalUrl); - return originalUrl; - } - - // 测试镜像源 - for (const mirror of mirrorUrls) { - const mirrorUrl = buildMirrorUrl(originalUrl, mirror); - console.log('Testing mirror:', mirrorUrl); - if (await testUrl(mirrorUrl)) { - console.log('Using mirror URL:', mirrorUrl); - return mirrorUrl; - } - } - - throw new Error('所有下载源都不可用'); -} +// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理 /** * 下载文件(带进度和重试) @@ -184,24 +120,73 @@ async function downloadFile (url: string, dest: string): Promise { }); } -export const UpdateNapCatHandler: RequestHandler = async (_req, res) => { +export const UpdateNapCatHandler: RequestHandler = async (req, res) => { try { - // 获取最新release信息 - const latestRelease = await getLatestRelease() as Release; + // 从请求体获取目标版本(可选) + const { targetVersion, force } = req.body as UpdateRequestBody; + + // 确定要下载的文件名 const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip'; - const shellZipAsset = latestRelease.assets.find(asset => asset.name === ReleaseName); + + // 确定目标版本 tag + // 如果指定了版本,使用指定版本;否则使用 'latest' + const targetTag = targetVersion || 'latest'; + console.log(`[NapCat Update] Target version: ${targetTag}`); + + // 使用 mirror 模块获取 release 信息(不依赖 API) + // 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API + const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, { + assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'], + fetchChangelog: false, // 不需要 changelog,避免 API 调用 + }); + + const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName); if (!shellZipAsset) { throw new Error(`未找到${ReleaseName}文件`); } + // 检查是否需要强制更新(降级警告) + const currentVersion = WebUiDataRuntime.GetNapCatVersion(); + console.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${release.tag_name}`); + + if (!force && currentVersion) { + // 简单的版本比较(可选的降级保护) + const parseVersion = (v: string): [number, number, number] => { + const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')]; + }; + const [currMajor, currMinor, currPatch] = parseVersion(currentVersion); + const [targetMajor, targetMinor, targetPatch] = parseVersion(release.tag_name); + + const isDowngrade = + targetMajor < currMajor || + (targetMajor === currMajor && targetMinor < currMinor) || + (targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch); + + if (isDowngrade) { + console.log(`[NapCat Update] Downgrade from ${currentVersion} to ${release.tag_name}, force=${force}`); + // 不阻止降级,只是记录日志 + } + } + + console.log(`[NapCat Update] Updating to version: ${release.tag_name}`); + // 创建临时目录 const tempDir = path.join(webUiPathWrapper.binaryPath, './temp'); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } - // 查找可用的下载URL - const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url); + // 使用 mirror 模块查找可用的下载 URL + // 启用内容验证,确保返回的是有效文件而非错误页面 + const downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, { + validateContent: true, // 验证 Content-Type 和状态码 + minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面 + timeout: 10000, // 10秒超时 + }); + + console.log(`[NapCat Update] Using download URL: ${downloadUrl}`); // 下载zip const zipPath = path.join(tempDir, 'napcat-latest.zip'); @@ -264,10 +249,10 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => { // 如果有替换失败的文件,创建更新配置文件 if (failedFiles.length > 0) { const updateConfig: UpdateConfig = { - version: latestRelease.tag_name, + version: release.tag_name, updateTime: new Date().toISOString(), files: failedFiles, - changelog: latestRelease.body || '' + changelog: release.body || '' }; // 保存更新配置文件 @@ -283,7 +268,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => { sendSuccess(res, { status: 'completed', message, - newVersion: latestRelease.tag_name, + newVersion: release.tag_name, failedFilesCount: failedFiles.length }); @@ -298,28 +283,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => { } }; -async function getLatestRelease (): Promise { - return new Promise((resolve, reject) => { - https.get('https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest', { - headers: { 'User-Agent': 'NapCat-WebUI' } - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const release = JSON.parse(data) as Release; - console.log('Release info:', { - tag_name: release.tag_name, - assets: release.assets?.map(a => ({ name: a.name, url: a.browser_download_url })) - }); - resolve(release); - } catch (e) { - reject(e); - } - }); - }).on('error', reject); - }); -} +// 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease /** * 应用待处理的更新(在应用启动时调用) diff --git a/packages/napcat-webui-backend/src/router/Base.ts b/packages/napcat-webui-backend/src/router/Base.ts index 42469ee0..a12016b3 100644 --- a/packages/napcat-webui-backend/src/router/Base.ts +++ b/packages/napcat-webui-backend/src/router/Base.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo'; +import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo'; import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status'; import { GetProxyHandler } from '../api/Proxy'; @@ -8,6 +8,7 @@ const router = Router(); router.get('/QQVersion', QQVersionHandler); router.get('/GetNapCatVersion', GetNapCatVersion); router.get('/getLatestTag', getLatestTagHandler); +router.get('/getAllReleases', getAllReleasesHandler); router.get('/GetSysStatusRealTime', StatusRealTimeHandler); router.get('/proxy', GetProxyHandler); router.get('/Theme', GetThemeConfigHandler); diff --git a/packages/napcat-webui-frontend/src/components/modal.tsx b/packages/napcat-webui-frontend/src/components/modal.tsx index 92bea277..51e116b6 100644 --- a/packages/napcat-webui-frontend/src/components/modal.tsx +++ b/packages/napcat-webui-frontend/src/components/modal.tsx @@ -10,18 +10,19 @@ import { import React from 'react'; export interface ModalProps { - content: React.ReactNode - title?: React.ReactNode - size?: React.ComponentProps['size'] - scrollBehavior?: React.ComponentProps['scrollBehavior'] - onClose?: () => void - onConfirm?: () => void - onCancel?: () => void - backdrop?: 'opaque' | 'blur' | 'transparent' - showCancel?: boolean - dismissible?: boolean - confirmText?: string - cancelText?: string + content: React.ReactNode; + title?: React.ReactNode; + size?: React.ComponentProps['size']; + scrollBehavior?: React.ComponentProps['scrollBehavior']; + onClose?: () => void; + onConfirm?: () => void; + onCancel?: () => void; + backdrop?: 'opaque' | 'blur' | 'transparent'; + showCancel?: boolean; + dismissible?: boolean; + confirmText?: string; + cancelText?: string; + hideFooter?: boolean; } const Modal: React.FC = React.memo((props) => { @@ -33,6 +34,7 @@ const Modal: React.FC = React.memo((props) => { dismissible, confirmText = '确定', cancelText = '取消', + hideFooter = false, onClose, onConfirm, onCancel, @@ -62,29 +64,31 @@ const Modal: React.FC = React.memo((props) => { {title} )} {content} - - {showCancel && ( + {!hideFooter && ( + + {showCancel && ( + + )} - )} - - + + )} )} diff --git a/packages/napcat-webui-frontend/src/components/system_info.tsx b/packages/napcat-webui-frontend/src/components/system_info.tsx index 5b432be9..a9aa2ee4 100644 --- a/packages/napcat-webui-frontend/src/components/system_info.tsx +++ b/packages/napcat-webui-frontend/src/components/system_info.tsx @@ -3,17 +3,24 @@ import { Card, CardBody, CardHeader } from '@heroui/card'; import { Chip } from '@heroui/chip'; import { Spinner } from '@heroui/spinner'; import { Tooltip } from '@heroui/tooltip'; -import { useLocalStorage } from '@uidotdev/usehooks'; +import { Select, SelectItem } from '@heroui/select'; +import { Switch } from '@heroui/switch'; +import { Pagination } from '@heroui/pagination'; +import { Tabs, Tab } from '@heroui/tabs'; +import { Input } from '@heroui/input'; +import { useLocalStorage, useDebounce } from '@uidotdev/usehooks'; import { useRequest } from 'ahooks'; import clsx from 'clsx'; import { FaCircleInfo, FaQq } from 'react-icons/fa6'; -import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'; +import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5'; import { RiMacFill } from 'react-icons/ri'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import key from '@/const/key'; import WebUIManager from '@/controllers/webui_manager'; import useDialog from '@/hooks/use-dialog'; +import Modal from '@/components/modal'; +import { hasNewVersion, compareVersion } from '@/utils/version'; export interface SystemInfoItemProps { @@ -22,6 +29,8 @@ export interface SystemInfoItemProps { value?: React.ReactNode; endContent?: React.ReactNode; hasBackground?: boolean; + onClick?: () => void; + clickable?: boolean; } const SystemInfoItem: React.FC = ({ @@ -30,14 +39,20 @@ const SystemInfoItem: React.FC = ({ icon, endContent, hasBackground = false, + onClick, + clickable = false, }) => { return ( -
+
{icon}
{title}
{ -// const { currentVersion } = props; -// const dialog = useDialog(); -// const { data: releaseData, error } = useRequest(() => -// request.get( -// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases' -// ) -// ); - -// if (error) { -// return ( -// -// -// -// ); -// } - -// const latestVersion = releaseData?.data?.[0]?.tag_name; - -// if (!latestVersion || !currentVersion) { -// return null; -// } - -// if (compareVersion(latestVersion, currentVersion) <= 0) { -// return null; -// } - -// const middleVersions: GithubRelease[] = []; - -// for (let i = 0; i < releaseData.data.length; i++) { -// const versionInfo = releaseData.data[i]; -// if (compareVersion(versionInfo.tag_name, currentVersion) > 0) { -// middleVersions.push(versionInfo); -// } else { -// break; -// } -// } - -// const AISummaryComponent = () => { -// const { -// data: aiSummaryData, -// loading: aiSummaryLoading, -// error: aiSummaryError, -// run: runAiSummary, -// } = useRequest( -// (version) => -// request.get>( -// `https://release.nc.152710.xyz/?version=${version}`, -// { -// timeout: 30000, -// } -// ), -// { -// manual: true, -// } -// ); - -// useEffect(() => { -// runAiSummary(currentVersion); -// }, [currentVersion, runAiSummary]); - -// if (aiSummaryLoading) { -// return ( -//
-// -//
-// ); -// } -// if (aiSummaryError) { -// return
AI 摘要获取失败
; -// } -// return {aiSummaryData?.data.data}; -// }; - -// return ( -// -// -// -// ); -// }; - // 更新状态类型 type UpdateStatus = 'idle' | 'updating' | 'success' | 'error'; @@ -213,18 +79,29 @@ const UpdateDialogContent: React.FC<{ errorMessage?: string; }> = ({ currentVersion, latestVersion, status, errorMessage }) => { return ( -
- {/* 版本信息 */} -
-
- 当前版本 - +
+ {/* 版本对比 */} +
+
+ 当前版本 + v{currentVersion}
-
- 最新版本 - v{latestVersion} + +
+
+ + + +
+
+ +
+ 最新版本 + + v{latestVersion} +
@@ -300,7 +177,8 @@ const NewVersionTip = (props: NewVersionTipProps) => { }); const [updateStatus, setUpdateStatus] = useState('idle'); - if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) { + // 使用 SemVer 规范比较版本号 + if (error || !latestVersion || !currentVersion || !hasNewVersion(currentVersion, latestVersion)) { return null; } @@ -380,11 +258,381 @@ const NewVersionTip = (props: NewVersionTipProps) => { ); }; +// 版本信息类型 +interface VersionInfo { + tag: string; + type: 'release' | 'prerelease' | 'action'; + artifactId?: number; + artifactName?: string; + createdAt?: string; + expiresAt?: string; + size?: number; +} + +// 版本选择对话框内容 +interface VersionSelectDialogProps { + currentVersion: string; + onClose: () => void; +} + +const VersionSelectDialogContent: React.FC = ({ + currentVersion, + onClose, +}) => { + const dialog = useDialog(); + const [selectedVersion, setSelectedVersion] = useState(null); + const [forceUpdate, setForceUpdate] = useState(false); + const [updateStatus, setUpdateStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [activeTab, setActiveTab] = useState<'release' | 'action'>('release'); + const [searchQuery, setSearchQuery] = useState(''); + const debouncedSearch = useDebounce(searchQuery, 300); + const pageSize = 15; + + // 获取所有可用版本(带分页、过滤和搜索) + const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest( + () => WebUIManager.getAllReleases({ + page: currentPage, + pageSize, + includeActions: true, + type: activeTab, + search: debouncedSearch + }), + { + refreshDeps: [currentPage, activeTab, debouncedSearch], + } + ); + + // 版本列表已在后端过滤,直接使用 + const filteredVersions = releasesData?.versions || []; + + // 检查是否是降级(使用语义化版本比较) + const isDowngrade = useCallback((targetTag: string): boolean => { + if (!currentVersion || !targetTag) return false; + // Action 版本不算降级 + if (targetTag.startsWith('action-')) return false; + return compareVersion(targetTag, currentVersion) < 0; + }, [currentVersion]); + + const selectedVersionTag = selectedVersion?.tag || ''; + const isSelectedDowngrade = isDowngrade(selectedVersionTag); + + const handleUpdate = async () => { + if (!selectedVersion) return; + + if (isSelectedDowngrade && !forceUpdate) { + dialog.confirm({ + title: '确认降级', + content: ( +
+

+ 您正在尝试从 v{currentVersion} 降级到 {selectedVersionTag} +

+

+ 降级可能导致配置不兼容或功能丢失,请确认您了解相关风险。 +

+
+ ), + confirmText: '确认降级', + cancelText: '取消', + onConfirm: () => performUpdate(true), + }); + return; + } + + 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); + }; + + if (updateStatus === 'success') { + return ( +
+
+ + + +
+
+

+ 更新到 {selectedVersionTag} 完成 +

+

+ 请重启 NapCat 以应用新版本 +

+
+
+ ); + } + + if (updateStatus === 'error') { + return ( +
+
+ + + +
+
+

+ 更新失败 +

+

+ {errorMessage || '请稍后重试'} +

+
+
+ ); + } + + if (updateStatus === 'updating') { + return ( +
+ +
+

+ 正在更新到 {selectedVersionTag}... +

+

+ 请耐心等待,更新可能需要几分钟 +

+
+
+ ); + } + + const pagination = releasesData?.pagination; + + return ( +
+ {/* 当前版本 */} +
+
+ 当前版本: + + v{currentVersion} + +
+ {releasesData?.mirror && ( +
+ + 镜像: {releasesData.mirror} +
+ )} +
+ + {/* 版本类型切换 */} + { + setActiveTab(key as 'release' | 'action'); + setCurrentPage(1); + setSelectedVersion(null); + setSearchQuery(''); + }} + size='sm' + color='primary' + variant='underlined' + classNames={{ + tabList: 'gap-4', + }} + > + + + + + {/* 搜索框 */} + { + setSearchQuery(value); + setCurrentPage(1); + setSelectedVersion(null); + }} + startContent={} + isClearable + onClear={() => setSearchQuery('')} + classNames={{ + inputWrapper: 'h-9', + }} + /> + + {/* 版本选择 */} +
+
+ + {releasesData?.pagination && ( + + 共 {releasesData.pagination.total} 个版本 + + )} +
+ {releasesLoading ? ( +
+ + 加载版本列表... +
+ ) : releasesError ? ( +
+ 加载版本列表失败: {releasesError.message} +
+ ) : filteredVersions.length === 0 ? ( +
+ {searchQuery ? `未找到匹配 "${searchQuery}" 的版本` : '暂无可用版本'} +
+ ) : ( + + )} +
+ + {/* Action 版本提示 */} + {activeTab === 'action' && ( +
+

+ 临时版本来自 GitHub Actions 构建,可能不稳定,适合测试新功能。 + {selectedVersion?.expiresAt && ( + + 此版本将于 {new Date(selectedVersion.expiresAt).toLocaleDateString()} 过期 + + )} +

+
+ )} + + {/* 降级警告 */} + {selectedVersion && isSelectedDowngrade && ( +
+
+ + + +
+

+ 版本降级警告 +

+

+ 降级到旧版本可能导致配置不兼容或功能丢失 +

+
+
+
+ + + 我了解风险,确认降级 + +
+
+ )} + + {/* 分页 */} + {pagination && pagination.totalPages > 1 && ( +
+ +
+ )} + + {/* 操作按钮 */} +
+ + +
+
+ ); +}; + interface NapCatVersionProps { hasBackground?: boolean; } const NapCatVersion: React.FC = ({ hasBackground = false }) => { + const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); const { data: packageData, loading: packageLoading, @@ -397,26 +645,55 @@ const NapCatVersion: React.FC = ({ hasBackground = false }) const currentVersion = packageData?.version; + // 点击版本号时显示版本选择对话框 + const handleVersionClick = useCallback(() => { + if (!currentVersion) return; + setIsVersionModalOpen(true); + }, [currentVersion]); + return ( - } - hasBackground={hasBackground} - value={ - packageError - ? ( - `错误:${packageError.message}` - ) - : packageLoading + <> + } + hasBackground={hasBackground} + value={ + packageError ? ( - + `错误:${packageError.message}` ) - : ( - currentVersion - ) - } - endContent={} - /> + : packageLoading + ? ( + + ) + : ( + + + {currentVersion} + + + ) + } + endContent={} + /> + {isVersionModalOpen && ( + setIsVersionModalOpen(false)} + content={ + setIsVersionModalOpen(false)} + /> + } + /> + )} + ); }; diff --git a/packages/napcat-webui-frontend/src/controllers/webui_manager.ts b/packages/napcat-webui-frontend/src/controllers/webui_manager.ts index d5f7b223..c9aa30b1 100644 --- a/packages/napcat-webui-frontend/src/controllers/webui_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/webui_manager.ts @@ -54,11 +54,68 @@ export default class WebUIManager { return data.data; } + /** + * 版本信息接口 + */ + static readonly VersionTypes = { + RELEASE: 'release', + PRERELEASE: 'prerelease', + ACTION: 'action', + } as const; + + /** + * 获取所有可用的版本列表(支持分页、过滤和搜索) + */ + public static async getAllReleases (options: { + page?: number; + pageSize?: number; + includeActions?: boolean; + type?: 'release' | 'action' | 'all'; + search?: string; + } = {}) { + const { page = 1, pageSize = 20, includeActions = true, type = 'all', search = '' } = options; + const { data } = await serverRequest.get; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; + mirror?: string; + }>>('/base/getAllReleases', { + params: { page, pageSize, includeActions, type, search }, + }); + return data.data; + } + public static async UpdateNapCat () { const { data } = await serverRequest.post>( '/UpdateNapCat/update', {}, - { timeout: 60000 } // 1分钟超时 + { timeout: 120000 } // 2分钟超时 + ); + return data; + } + + /** + * 更新到指定版本 + * @param targetVersion 目标版本 tag,如 "v4.9.9" 或 "action-123456" + * @param force 是否强制更新(允许降级) + */ + public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) { + const { data } = await serverRequest.post>( + '/UpdateNapCat/update', + { targetVersion, force }, + { timeout: 120000 } // 2分钟超时 ); return data; } diff --git a/packages/napcat-webui-frontend/src/utils/request.ts b/packages/napcat-webui-frontend/src/utils/request.ts index 7c5bf6fc..59484c93 100644 --- a/packages/napcat-webui-frontend/src/utils/request.ts +++ b/packages/napcat-webui-frontend/src/utils/request.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import key from '@/const/key'; export const serverRequest = axios.create({ - timeout: 5000, + timeout: 30000, // 30秒,获取版本列表可能较慢 }); export const request = axios.create({ diff --git a/packages/napcat-webui-frontend/src/utils/version.ts b/packages/napcat-webui-frontend/src/utils/version.ts index 4d53afeb..12099df2 100644 --- a/packages/napcat-webui-frontend/src/utils/version.ts +++ b/packages/napcat-webui-frontend/src/utils/version.ts @@ -1,22 +1,59 @@ /** - * 版本号转为数字 + * SemVer 2.0 正则表达式 + * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息] + * 参考: https://semver.org/lang/zh-CN/ + */ +const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +interface SemVerInfo { + valid: boolean; + normalized: string; + major: number; + minor: number; + patch: number; + prerelease: string | null; + buildmetadata: string | null; +} + +/** + * 解析版本号 + * @param version 版本字符串 + * @returns SemVer 解析结果 + */ +export const parseSemVer = (version: string | undefined | null): SemVerInfo => { + if (!version || typeof version !== 'string') { + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; + } + + const match = version.trim().match(SEMVER_REGEX); + if (match) { + const major = parseInt(match[1]!, 10); + const minor = parseInt(match[2]!, 10); + const patch = parseInt(match[3]!, 10); + const prerelease = match[4] || null; + const buildmetadata = match[5] || null; + + let normalized = `${major}.${minor}.${patch}`; + if (prerelease) normalized += `-${prerelease}`; + if (buildmetadata) normalized += `+${buildmetadata}`; + + return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata }; + } + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; +}; + +/** + * 版本号转为数字 (兼容旧代码) * @param version 版本号 * @returns 版本号数字 */ export const versionToNumber = (version: string): number => { - const finalVersionString = version.replace(/^v/, ''); - - const versionArray = finalVersionString.split('.'); - const versionNumber = - parseInt(versionArray[2]) + - parseInt(versionArray[1]) * 100 + - parseInt(versionArray[0]) * 10000; - - return versionNumber; + const info = parseSemVer(version); + return info.patch + info.minor * 100 + info.major * 10000; }; /** - * 比较版本号 + * 比较版本号 (SemVer 2.0 规范) * @param version1 版本号1 * @param version2 版本号2 * @returns 比较结果 @@ -24,13 +61,73 @@ export const versionToNumber = (version: string): number => { * 1: version1 > version2 * -1: version1 < version2 */ -export const compareVersion = (version1: string, version2: string): number => { - const versionNumber1 = versionToNumber(version1); - const versionNumber2 = versionToNumber(version2); +export const compareVersion = (version1: string, version2: string): -1 | 0 | 1 => { + const a = parseSemVer(version1); + const b = parseSemVer(version2); - if (versionNumber1 === versionNumber2) { + if (!a.valid || !b.valid) { return 0; } - return versionNumber1 > versionNumber2 ? 1 : -1; + // 比较主版本号 + if (a.major !== b.major) return a.major > b.major ? 1 : -1; + // 比较次版本号 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1; + // 比较修订号 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1; + + // 有先行版本号的版本优先级较低 + if (a.prerelease && !b.prerelease) return -1; + if (!a.prerelease && b.prerelease) return 1; + + // 两者都有先行版本号时,按规则比较 + if (a.prerelease && b.prerelease) { + const aParts = a.prerelease.split('.'); + const bParts = b.prerelease.split('.'); + const len = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < len; i++) { + const aPart = aParts[i]; + const bPart = bParts[i]; + + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + + const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN; + const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN; + + // 数字 vs 数字 + if (!isNaN(aNum) && !isNaN(bNum)) { + if (aNum !== bNum) return aNum > bNum ? 1 : -1; + continue; + } + // 数字优先级低于字符串 + if (!isNaN(aNum)) return -1; + if (!isNaN(bNum)) return 1; + // 字符串 vs 字符串 + if (aPart !== bPart) return aPart > bPart ? 1 : -1; + } + } + + return 0; +}; + +/** + * 判断是否有新版本可用 + * 只比较正式版本 (不带先行版本号的) + * 当前版本是先行版本时,与相同基础版本的正式版相比认为需要更新 + * @param currentVersion 当前版本 + * @param latestVersion 最新版本 (release tag) + * @returns 是否有新版本 + */ +export const hasNewVersion = (currentVersion: string, latestVersion: string): boolean => { + const current = parseSemVer(currentVersion); + const latest = parseSemVer(latestVersion); + + if (!current.valid || !latest.valid) { + return false; + } + + // 使用 compareVersion 比较 + return compareVersion(latestVersion, currentVersion) > 0; };