Compare commits

...

8 Commits

Author SHA1 Message Date
手瓜一十雪
20f6101f95 Refine concurrency group logic in PR build workflow
Updated the concurrency group strategy in pr-build.yml to better handle different event types. Now, pull_request_target uses the PR number, and issue_comment only uses the PR number if the comment is a /build command, otherwise defaults to run_id. This prevents unnecessary cancellation of builds triggered by unrelated comments.
2026-01-03 15:09:33 +08:00
手瓜一十雪
018e8aa4f0 Update nativeLoader.cjs 2026-01-03 15:04:26 +08:00
手瓜一十雪
1ad700b935 Update release workflow and documentation prompts
Refactored the release workflow to add semantic version validation, improved commit and file diff collection, and enhanced release note generation with more context and formatting. Updated release note and default documentation prompts for clarity, conciseness, and better user guidance. Fixed owner typo in workflow and improved error handling for missing tags.
2026-01-03 15:01:10 +08:00
手瓜一十雪
68c8b984ad Refactor update logging to use logger interface
Replaces all console logging in the update process with the ILogWrapper logger interface for consistent logging. Updates applyPendingUpdates to require a logger parameter and propagates this change to all relevant initialization code. Also removes duplicate and unnecessary lines in workflow YAML files.
2026-01-03 14:51:56 +08:00
手瓜一十雪
8eb1aa2fb4 Refactor GitHub tag fetching and mirror management
Replaces legacy tag fetching logic in napcat-common with a new mirror.ts module that centralizes GitHub mirror configuration, selection, and tag retrieval. Updates helper.ts to use the new mirror system and semver comparison, and exports compareSemVer for broader use. Updates workflows and scripts to generate and propagate build version information, and improves build status comment formatting for PRs. Also updates release workflow to use a new OpenAI key and model.
2026-01-03 14:42:24 +08:00
Makoto
2d3f4e696b feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks (#1492)
* feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks

- Add new OB11GroupGrayTipEvent class to report unknown gray tip messages
- Modify parseOtherJsonEvent to detect forged gray tips by checking senderUin
- Real system gray tips have senderUin='0', forged ones expose attacker's QQ
- Include message_id in event for downstream recall capability
- Add try/catch for JSON.parse to handle malformed content
- Use Number() for consistent type conversion

* fix: simplify logWarn to match upstream style

* fix: remove extra closing brace that broke class structure

* fix: add validation for malformed title gray tip events
2026-01-02 20:55:24 +08:00
时瑾
b241881c74 fix: 修复用户ID类型转换错误并移除不必要的标签渲染 2026-01-02 20:50:13 +08:00
时瑾
aecf33f4dc fix: close #1488 2026-01-02 17:07:39 +08:00
32 changed files with 2572 additions and 590 deletions

View File

@@ -1,27 +1,18 @@
# V?.?.?
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
我们提供了轻量化一键部署方案,内置 QQ 和 NapCat详见使用文档。
你可以下载
可下载文件:
- NapCat.Shell.Windows.Node.zip无头模式
NapCat.Shell.Windows.OneKey.zip (无头)
## 注意事项
**推荐 QQ 版本9.9.23+,最低支持 9.9.22**
**默认 WebUI 密钥为随机密码,请在控制台查看**
启动后可自动化部署一键包,教程参考使用文档安装部分
## 运行库
如果 Windows x64 缺少 xxx.dll请安装 [VC++ 运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 警告
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
## 更新内容
详见 commit 历史。

View File

@@ -1,60 +1,94 @@
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
# NapCat Release Note Generator
格式规则:
1. 第一行:# V{TAG}
2. 第二行:[使用文档](https://napneko.github.io/)
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
## Windows 一键包
- 简短一句话介绍一键包用途
- 列出可下载的文件名(只列文件名,不写下载链接)
## 核心规则
## 警告
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号(如 v4.10.2),不要添加额外的 V 前缀
2. **语言**:全部使用简体中文
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
## 如果WinX64缺少运行库或者xxx.dll
- 常见运行库建议
## Commit 分析规则
## 更新
按数字序列列出主要变更项,每条尽量一句话
- 前缀短 commit id例如1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
- 保持 4-18 条要点
将 commit 分类为以下类型:
- 🐛 **修复**bug fix、修复、fix 相关
- ✨ **新增**新功能、feat、add 相关
- 🔧 **优化**优化、重构、refactor、improve、perf 相关
- 📦 **依赖**deps、依赖更新通常可以忽略或合并
- 🔨 **构建**ci、build、workflow 相关(通常可以忽略)
## 开发者注意
- 列出迁移/接口断裂/配置变更;若无则省略
## 合并和筛选
额外约束:
- 语言简体中文,面向最终用户
- **合并相似项**:同一功能的多个 commit 合并为一条
- **忽略琐碎项**合并冲突、格式化、typo 等可忽略
- **控制数量**:最终保持 5-15 条更新要点
- **保留 commit hash**:每条末尾附上短 hash格式 `(a1b2c3d)`
下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5
## 输出模板
# V4.9.0
```
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
我们提供了轻量化一键部署方案,内置 QQ 和 NapCat详见使用文档。
你可以下载
可下载文件:
- NapCat.Shell.Windows.Node.zip无头模式
NapCat.Shell.Windows.OneKey.zip (无头)
## 注意事项
**推荐 QQ 版本9.9.23+,最低支持 9.9.22**
**默认 WebUI 密钥为随机密码,请在控制台查看**
启动后可自动化部署一键包,教程参考使用文档安装部分
## 运行库
如果 Windows x64 缺少 xxx.dll请安装 [VC++ 运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 警告
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
## 更新内容
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
### 🐛 修复
1. 修复 xxx 问题 (a1b2c3d)
2. 修复 yyy 崩溃 (b2c3d4e)
## 更新
1. 修改了XXXXX
2. 新增了XXXX
3. 重构了XXXX
### ✨ 新增
1. 新增 xxx 功能 (c3d4e5f)
2. 支持 yyy 特性 (d4e5f6g)
### 🔧 优化
1. 优化 xxx 性能 (e5f6g7h)
2. 重构 yyy 模块 (f6g7h8i)
---
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
```
## 重要约束
1. 如果某个分类没有内容,则完全省略该分类
2. 不要编造不存在的更新
3. 保持简洁,每条更新控制在一行内
4. 使用用户友好的语言,避免过于技术化的描述
5. 重大变更Breaking Changes需要在注意事项中加粗提示
## 文件变化分析
用户会提供文件变化统计和具体代码diff帮助你理解变更内容
### 目录含义
- `packages/napcat-core/` → 核心功能、消息处理、QQ接口
- `packages/napcat-onebot/` → OneBot 协议实现、API、事件
- `packages/napcat-webui-backend/` → WebUI 后端接口
- `packages/napcat-webui-frontend/` → WebUI 前端界面
- `packages/napcat-shell/` → Shell 启动器
### 代码diff阅读指南
- `+` 开头的行是新增代码
- `-` 开头的行是删除代码
- 关注函数名、类名的变化来理解功能变更
- 关注 `fix`、`bug`、`error` 等关键词识别修复项
- 关注 `add`、`new`、`feature` 等关键词识别新功能
- 忽略纯重构(代码移动但功能不变)和格式化变更
### 截断说明
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
- 根据已有信息推断完整变更意图即可

View File

@@ -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} | ⏳ 构建中... |`),
'<div align="center">',
'',
'# 🔨 NapCat 构建中',
'',
'![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)',
'',
'</div>',
'',
'---',
'',
`📝 **提交**: \`${formatSha(prSha)}\``,
`🕐 **开始时间**: ${time}`,
'## 📦 构建目标',
'',
'> 构建进行中,请稍候...',
'| 包名 | 状态 | 说明 |',
'| :--- | :---: | :--- |',
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
`| 📝 提交 | \`${shortSha}\` |`,
`| 🕐 开始时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **构建进行中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
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)} |`),
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
`📝 **提交**: \`${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(
`<details>`,
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
'',
'```',
escapeCodeBlock(target.error!),
'```',
'',
'</details>',
''
);
}
}
// 添加底部提示
lines.push('---', '');
if (allSuccess) {
lines.push('', '> 🎉 所有构建均已成功完成,可点击上方下载链接获取构建产物进行测试。');
} else if (anyCancelled) {
lines.push('', '> ⚪ 构建已被取消,可能是由于新的提交触发了新的构建。');
lines.push(
'<div align="center">',
'',
'> 🎉 **所有构建均已成功完成!**',
'>',
'> 点击上方下载链接获取构建产物进行测试',
'',
'</div>'
);
} else if (anyCancelled && !anyFailure) {
lines.push(
'<div align="center">',
'',
'> ⚪ **构建已被取消**',
'>',
'> 可能是由于新的提交触发了新的构建',
'',
'</div>'
);
} else {
lines.push('', '> ⚠️ 部分构建失败,请查看上方错误详情或点击构建日志查看完整输出。');
lines.push(
'<div align="center">',
'',
'> ⚠️ **部分构建失败**',
'>',
'> 请查看上方错误详情或点击构建日志查看完整输出',
'',
'</div>'
);
}
return lines.join('\n');
}

View File

@@ -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<void> {
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<void> {
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<void> {
},
];
const comment = generateResultComment(targets, prSha, runId, repository);
const comment = generateResultComment(targets, prSha, runId, repository, version);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}

View File

@@ -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

View File

@@ -41,8 +41,14 @@ permissions:
# 并发控制
# =============================================================================
# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费
# 注意:只有在 should_build=true 时才会进入实际构建流程,
# issue_comment 事件如果不是 /build 命令,会在 check-build 阶段快速退出,
# 不会取消正在进行的构建(因为 cancel-in-progress 只影响同 group 的后续任务)
concurrency:
group: pr-build-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
# 使用不同的 group 策略:
# - pull_request_target: 使用 PR 号
# - issue_comment: 只有确认是 /build 命令时才使用 PR 号,否则使用 run_id不冲突
group: pr-build-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/build') && github.event.issue.number || github.run_id }}
cancel-in-progress: true
# =============================================================================
@@ -126,6 +132,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 +150,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 +204,7 @@ jobs:
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
- name: Checkout scripts from base
@@ -193,16 +221,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 +293,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 }}

View File

@@ -4,17 +4,48 @@ on:
workflow_dispatch:
push:
tags:
- '*'
- 'v*'
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:
# 验证版本号格式
validate-version:
runs-on: ubuntu-latest
outputs:
valid: ${{ steps.check.outputs.valid }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Validate semantic version
id: check
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "Checking tag: $TAG"
# 语义化版本正则表达式
# 支持: v1.0.0, v1.0.0-beta, v1.0.0-rc.1, v1.0.0-alpha.1+build.123
SEMVER_REGEX="^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?$"
if [[ "$TAG" =~ $SEMVER_REGEX ]]; then
echo "✅ Valid semantic version: $TAG"
echo "valid=true" >> $GITHUB_OUTPUT
echo "version=$TAG" >> $GITHUB_OUTPUT
else
echo "❌ Invalid version format: $TAG"
echo "Expected format: vX.Y.Z or vX.Y.Z-prerelease"
echo "Examples: v1.0.0, v1.2.3-beta, v2.0.0-rc.1"
echo "valid=false" >> $GITHUB_OUTPUT
exit 1
fi
Build-Framework:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@@ -24,6 +55,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@@ -40,6 +73,8 @@ jobs:
path: framework-dist
Build-Shell:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@@ -49,6 +84,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,10 +208,10 @@ 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
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
run: |
set -euo pipefail
@@ -199,41 +236,152 @@ jobs:
done
if [ -z "$PREV_TAG" ]; then
echo " Could not find previous tag for $CURRENT_TAG, aborting."
exit 1
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit"
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
fi
echo "Previous tag: $PREV_TAG"
# 强制拉取上一个 tag 和当前 tag
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
# 获取 commit title + body + 作者,保留换行
COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
# 获取 commit,使用更清晰的格式
# 格式: <type>: <subject> (<hash>)
COMMITS=$(git log --pretty=format:'- %s (%h)' "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || git log --pretty=format:'- %s (%h)' -20)
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
echo -e "$COMMITS"
echo "$COMMITS"
# 获取文件变化统计
echo "Getting file change statistics..."
FILE_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || echo "")
# 获取总体统计(最后一行)
SUMMARY_LINE=$(echo "$FILE_STATS" | tail -1)
echo "Summary: $SUMMARY_LINE"
# 获取每个文件的变化(去掉最后一行汇总)
# 截断过长的输出最多50个文件每行最多80字符
FILE_CHANGES=$(echo "$FILE_STATS" | head -n -1 | head -50 | cut -c1-80)
# 如果文件变化太多,进一步精简:只保留主要目录的变化
FILE_COUNT=$(echo "$FILE_STATS" | head -n -1 | wc -l)
if [ "$FILE_COUNT" -gt 50 ]; then
echo "Too many files ($FILE_COUNT), grouping by directory..."
# 按目录分组统计
DIR_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | head -n -1 | \
sed 's/|.*//g' | \
awk -F'/' '{if(NF>1) print $1"/"$2; else print $1}' | \
sort | uniq -c | sort -rn | head -20)
FILE_CHANGES="[按目录分组统计 - 共 $FILE_COUNT 个文件变更]
$DIR_STATS"
fi
echo "File changes:"
echo "$FILE_CHANGES"
# 获取具体代码变化关键文件的diff
echo "Getting code diff for key files..."
# 定义关键目录(优先展示这些目录的变化)
KEY_DIRS="packages/napcat-core packages/napcat-onebot packages/napcat-webui-backend"
# 获取变更的关键文件列表(排除测试、配置等)
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" | \
grep -E "\.(ts|js)$" | \
grep -v -E "(test|spec|\.d\.ts|config)" | \
head -15)
CODE_DIFF=""
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
CURRENT_CHARS=0
for file in $KEY_FILES; do
if [ "$CURRENT_CHARS" -ge "$DIFF_CHAR_LIMIT" ]; then
CODE_DIFF="$CODE_DIFF
[... 更多文件变化已截断 ...]"
break
fi
# 获取单个文件的diff限制每个文件最多50行
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50)
FILE_DIFF_LEN=${#FILE_DIFF}
# 如果单个文件diff超过1500字符截断
if [ "$FILE_DIFF_LEN" -gt 1500 ]; then
FILE_DIFF=$(echo "$FILE_DIFF" | head -c 1500)
FILE_DIFF="$FILE_DIFF
[... 文件 $file 变化已截断 ...]"
fi
if [ -n "$FILE_DIFF" ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
CURRENT_CHARS=$((CURRENT_CHARS + FILE_DIFF_LEN))
fi
done
# 如果没有关键文件变化获取前5个变更文件的diff
if [ -z "$CODE_DIFF" ]; then
echo "No key files changed, getting top changed files..."
TOP_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "\.(ts|js)$" | head -5)
for file in $TOP_FILES; do
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30)
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
fi
done
fi
echo "Code diff preview:"
echo "$CODE_DIFF" | head -50
# 读取 prompt
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
# 构建用户内容
USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
# 构建用户内容传递更多上下文包含文件变化和代码diff
USER_CONTENT="当前版本: $CURRENT_TAG
上一版本: $PREV_TAG
## 提交列表
$COMMITS
## 文件变化统计
$SUMMARY_LINE
## 变更文件列表
$FILE_CHANGES
## 关键代码变化
$CODE_DIFF"
# 构建请求 JSON
# 构建请求 JSON,增加 max_tokens 以获取更完整的输出
BODY=$(jq -n \
--arg system "$SYSTEM_PROMPT" \
--arg user "$USER_CONTENT" \
'{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
--arg model "$OPENROUTER_MODEL" \
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
echo "=== OpenRouter request body ==="
echo "$BODY" | jq .
# 调用 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 ==="
@@ -250,13 +398,18 @@ jobs:
if [ -z "$RELEASE_BODY" ]; then
echo "❌ OpenRouter failed to generate release note, using default.md"
cp .github/prompt/default.md CHANGELOG.md
# 替换默认模板中的版本占位符
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
else
# 后处理:确保版本号正确,并添加比较链接
echo -e "$RELEASE_BODY" > CHANGELOG.md
# 替换可能的占位符
sed -i "s/{VERSION}/$CURRENT_TAG/g" CHANGELOG.md
sed -i "s/{PREV_VERSION}/$PREV_TAG/g" CHANGELOG.md
fi
else
echo "❌ Curl failed, using default.md"
cp .github/prompt/default.md CHANGELOG.md
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
fi
echo "=== generated release note ==="
cat CHANGELOG.md

View File

@@ -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<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | 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<boolean> {
try {
await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
return true;
} catch {
return false;
}
}
async function findAvailableUrl (): Promise<string | null> {
for (const url of urls) {
if (await testUrl(url)) {
return url;
}
}
return null;
}
export async function getAllTags (): Promise<string[]> {
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<string> {
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<string> {
// 去掉开头的 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;
}

View File

@@ -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<string[]> | 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<MirrorTestResult> {
// 使用一个实际存在的小文件来测试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<MirrorTestResult>((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<string[]> {
// 检查缓存是否有效
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<string[]> {
// 开始镜像测速
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<MirrorConfig>): 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<boolean> {
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<boolean> {
return new Promise<boolean>((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<UrlValidationResult> {
return new Promise<UrlValidationResult>((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<string> {
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<boolean> => {
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<string> {
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<any>(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<string, TagsCache> = 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<string[] | null> => {
try {
const raw = await PromiseTimer(
RequestUtil.HttpGetText(url),
currentConfig.timeout
);
// 检查返回内容是否有效(不是 HTML 错误页面)
if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
return null;
}
const tags = parseTags(raw);
if (tags.length > 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<ActionArtifact[]> {
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 [];
}
}

View File

@@ -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<T>(url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
// 支持 301/302 重定向(最多 5 次)
static async HttpGetJson<T> (url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string;
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
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<T>(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<string>(url, method, data, headers, false, false);
}
}

View File

@@ -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);

View File

@@ -34,8 +34,9 @@ export async function NCoreInitFramework (
});
const pathWrapper = new NapCatPathWrapper();
await applyPendingUpdates(pathWrapper);
const logger = new LogWrapper(pathWrapper.logsPath);
await applyPendingUpdates(pathWrapper, logger);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用

View File

@@ -2,14 +2,8 @@
const path = require('path');
async function initializeNapCat (session, loginService, registerCallback) {
// const logFile = path.join(currentPath, 'napcat.log');
console.log('[NapCat] [Info] 开始初始化NapCat');
// fs.writeFileSync(logFile, '', { flag: 'w' });
// fs.writeFileSync(logFile, '[NapCat] [Info] NapCat 初始化成功\n', { flag: 'a' });
try {
const currentPath = path.dirname(__filename);
const { NCoreInitFramework } = await import('file://' + path.join(currentPath, './napcat.mjs'));

View File

@@ -19,6 +19,7 @@ import { OB11GroupCardEvent } from '@/napcat-onebot/event/notice/OB11GroupCardEv
import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent';
import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent';
import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent';
import { OB11GroupGrayTipEvent } from '@/napcat-onebot/event/notice/OB11GroupGrayTipEvent';
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid';
@@ -206,15 +207,24 @@ export class OneBotGroupApi {
}
return undefined;
}
async parseOtherJsonEvent (msg: RawMessage, jsonStr: string, context: InstanceContext) {
const json = JSON.parse(jsonStr);
const type = json.items[json.items.length - 1]?.txt;
async parseOtherJsonEvent (msg: RawMessage, jsonGrayTipElement: GrayTipElement['jsonGrayTipElement'], context: InstanceContext) {
let json: { items?: { txt?: string; param?: string[] }[] };
try {
json = JSON.parse(jsonGrayTipElement.jsonStr);
} catch (e) {
context.logger.logWarn('灰条消息JSON解析失败', jsonGrayTipElement.jsonStr, e);
return undefined;
}
const type = json.items?.[json.items.length - 1]?.txt;
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') {
const memberUin = json.items[1].param[0];
const title = json.items[3].txt;
const memberUin = json.items?.[1]?.param?.[0];
const title = json.items?.[3]?.txt;
context.logger.logDebug('收到群成员新头衔消息', json);
if (memberUin == null || title == null) {
context.logger.logWarn('收到格式异常的群成员新头衔灰条消息', json);
return undefined;
}
return new OB11GroupTitleEvent(
this.core,
+msg.peerUid,
@@ -225,6 +235,27 @@ export class OneBotGroupApi {
context.logger.logDebug('收到机器人被踢消息', json);
} else {
context.logger.logWarn('收到未知的灰条消息', json);
// 如果有真实发送者非0生成事件上报可用于检测和撤回伪造灰条
const senderUin = Number(msg.senderUin) || 0;
if (senderUin !== 0) {
const peer = { chatType: ChatType.KCHATTYPEGROUP, guildId: '', peerUid: msg.peerUid };
const messageId = MessageUnique.createUniqueMsgId(peer, msg.msgId);
return new OB11GroupGrayTipEvent(
this.core,
+msg.peerUin,
senderUin,
messageId,
jsonGrayTipElement.busiId,
jsonGrayTipElement.jsonStr,
{
msgSeq: msg.msgSeq,
msgTime: msg.msgTime,
msgId: msg.msgId,
json,
}
);
}
}
return undefined;
}
@@ -376,7 +407,7 @@ export class OneBotGroupApi {
return await this.parse51TypeEvent(msg, grayTipElement);
} else {
console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement));
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement, this.core.context);
}
}
return undefined;

View File

@@ -0,0 +1,35 @@
import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent';
import { NapCatCore } from 'napcat-core';
/**
* 群灰条消息事件
* 用于上报未知类型的灰条消息,便于下游检测和处理伪造灰条攻击
*/
export class OB11GroupGrayTipEvent extends OB11BaseNoticeEvent {
notice_type = 'notify';
sub_type = 'gray_tip';
group_id: number;
user_id: number; // 真实发送者QQ如果是伪造的灰条这就是攻击者
message_id: number; // 消息ID可用于撤回
busi_id: string; // 业务ID
content: string; // 灰条内容JSON字符串
raw_info: unknown; // 原始信息
constructor (
core: NapCatCore,
groupId: number,
userId: number,
messageId: number,
busiId: string,
content: string,
rawInfo: unknown
) {
super(core);
this.group_id = groupId;
this.user_id = userId;
this.message_id = messageId;
this.busi_id = busiId;
this.content = content;
this.raw_info = rawInfo;
}
}

View File

@@ -1,5 +1,5 @@
@echo off
chcp 65001
chcp 65001 >nul
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll

View File

@@ -1,5 +1,5 @@
@echo off
chcp 65001
chcp 65001 >nul
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
@@ -28,7 +28,7 @@ echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LO
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
REM 可选参数: -q <QQ号码> 用于快速登录,不传则使用二维码登录
REM 示例: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
pause

View File

@@ -1,11 +1,11 @@
@echo off
chcp 65001
chcp 65001 >nul
net session >nul 2>&1
if %errorLevel% == 0 (
if %ERRORLEVEL% == 0 (
echo Administrator mode detected.
) else (
echo Please run this script in administrator mode.
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
exit
)
@@ -37,5 +37,5 @@ echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LO
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
REM 可选参数: -q <QQ号码> 用于快速登录,不传则使用二维码登录
REM 示例: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456

View File

@@ -1,7 +1,7 @@
@echo off
chcp 65001
chcp 65001 >nul
net session >nul 2>&1
if %errorLevel% == 0 (
if %ERRORLEVEL% == 0 (
echo Administrator mode detected.
) else (
echo Please run this script in administrator mode.

View File

@@ -319,7 +319,7 @@ export async function NCoreInitShell () {
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
handleUncaughtExceptions(logger);
await applyPendingUpdates(pathWrapper);
await applyPendingUpdates(pathWrapper, logger);
// 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger);

View File

@@ -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 };

View File

@@ -42,6 +42,7 @@ export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper;
export let logSubscription: ISubscription;
export let statusHelperSubscription: IStatusHelperSubscription;
export let webUiLogger: ILogWrapper | null = null;
const MAX_PORT_TRY = 100;
export let webUiRuntimePort = 6099;
@@ -100,6 +101,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
webUiPathWrapper = pathWrapper;
logSubscription = Subscription;
statusHelperSubscription = statusSubscription;
webUiLogger = logger;
WebUiConfig = new WebUiConfigWrapper();
let config = await WebUiConfig.GetWebUIConfig();

View File

@@ -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' });
}
};

View File

@@ -4,18 +4,22 @@ import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import compressing from 'compressing';
import { webUiPathWrapper } from '../../index';
import { webUiPathWrapper, webUiLogger } 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';
import { ILogWrapper } from '@/napcat-common/src/log-interface';
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,91 +73,24 @@ 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<boolean> {
return new Promise<boolean>((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<string> {
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 模块统一管理
/**
* 下载文件(带进度和重试)
*/
async function downloadFile (url: string, dest: string): Promise<void> {
console.log('Starting download from:', url);
webUiLogger?.log('[NapCat Update] Starting download from:', url);
const file = fs.createWriteStream(dest);
return new Promise((resolve, reject) => {
const request = https.get(url, {
headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => {
console.log('Response status:', res.statusCode);
console.log('Content-Type:', res.headers['content-type']);
webUiLogger?.log('[NapCat Update] Response status:', res.statusCode);
webUiLogger?.log('[NapCat Update] Content-Type:', res.headers['content-type']);
if (res.statusCode === 302 || res.statusCode === 301) {
console.log('Following redirect to:', res.headers.location);
webUiLogger?.log('[NapCat Update] Following redirect to:', res.headers.location);
file.close();
fs.unlinkSync(dest);
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
@@ -170,13 +107,13 @@ async function downloadFile (url: string, dest: string): Promise<void> {
res.pipe(file);
file.on('finish', () => {
file.close();
console.log('Download completed');
webUiLogger?.log('[NapCat Update] Download completed');
resolve();
});
});
request.on('error', (err) => {
console.error('Download error:', err);
webUiLogger?.logError('[NapCat Update] Download error:', err);
file.close();
fs.unlink(dest, () => { });
reject(err);
@@ -184,37 +121,86 @@ async function downloadFile (url: string, dest: string): Promise<void> {
});
}
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';
webUiLogger?.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();
webUiLogger?.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) {
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${release.tag_name}, force=${force}`);
// 不阻止降级,只是记录日志
}
}
webUiLogger?.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秒超时
});
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
// 下载zip
const zipPath = path.join(tempDir, 'napcat-latest.zip');
console.log('[NapCat Update] Saving to:', zipPath);
webUiLogger?.log('[NapCat Update] Saving to:', zipPath);
await downloadFile(downloadUrl, zipPath);
// 检查文件大小
const stats = fs.statSync(zipPath);
console.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
webUiLogger?.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
// 解压到临时目录
const extractPath = path.join(tempDir, 'napcat-extract');
console.log('[NapCat Update] Extracting to:', extractPath);
webUiLogger?.log('[NapCat Update] Extracting to:', extractPath);
await compressing.zip.uncompress(zipPath, extractPath);
// 获取解压后的实际内容目录NapCat.Shell.zip直接包含文件无额外根目录
@@ -235,7 +221,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
// 跳过指定的文件
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
console.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
webUiLogger?.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
continue;
}
@@ -253,7 +239,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
} catch (error) {
// 如果替换失败,添加到失败列表
console.log(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
webUiLogger?.logError(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
failedFiles.push({
sourcePath: fileInfo.sourcePath,
targetPath: targetFilePath
@@ -264,16 +250,16 @@ 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 || ''
};
// 保存更新配置文件
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
console.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
webUiLogger?.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
}
// 发送成功响应
@@ -283,57 +269,36 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
sendSuccess(res, {
status: 'completed',
message,
newVersion: latestRelease.tag_name,
newVersion: release.tag_name,
failedFilesCount: failedFiles.length
});
} catch (error) {
console.error('更新失败:', error);
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
} catch (error: any) {
console.error('更新失败:', error);
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
sendError(res, '更新失败: ' + error.message);
}
};
async function getLatestRelease (): Promise<Release> {
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
/**
* 应用待处理的更新(在应用启动时调用)
*/
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper): Promise<void> {
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper, logger: ILogWrapper): Promise<void> {
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
if (!fs.existsSync(configPath)) {
console.log('No pending updates found');
logger.log('[NapCat Update] No pending updates found');
return;
}
try {
console.log('[NapCat Update] Applying pending updates...');
logger.log('[NapCat Update] Applying pending updates...');
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const remainingFiles: Array<{
@@ -345,7 +310,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper):
try {
// 检查源文件是否存在
if (!fs.existsSync(file.sourcePath)) {
console.warn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
logger.logWarn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
continue;
}
@@ -360,10 +325,10 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper):
fs.unlinkSync(file.targetPath); // 删除旧文件
}
fs.copyFileSync(file.sourcePath, file.targetPath);
console.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
logger.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
} catch (error) {
console.error(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
logger.logError(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
// 如果仍然失败,保留在列表中
remainingFiles.push(file);
}
@@ -376,13 +341,13 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper):
files: remainingFiles
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
console.log(`${remainingFiles.length} files still pending update`);
logger.log(`[NapCat Update] ${remainingFiles.length} files still pending update`);
} else {
// 所有文件都成功更新,删除配置文件
fs.unlinkSync(configPath);
console.log('[NapCat Update] All pending updates applied successfully');
logger.log('[NapCat Update] All pending updates applied successfully');
}
} catch (error) {
console.error('[NapCat Update] Failed to apply pending updates:', error);
logger.logError('[NapCat Update] Failed to apply pending updates:', error);
}
}

View File

@@ -37,7 +37,7 @@ export class PasskeyHelper {
} catch {
await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2));
}
} catch (error) {
} catch (_error) {
// Directory or file already exists or other error
}
}
@@ -49,7 +49,8 @@ export class PasskeyHelper {
const data = await fs.readFile(passkeyFile, 'utf-8');
const passkeys = JSON.parse(data);
return typeof passkeys === 'object' && passkeys !== null ? passkeys : {};
} catch (error) {
} catch (_error) {
console.error('Failed to read passkey file:', _error);
return {};
}
}
@@ -82,8 +83,8 @@ export class PasskeyHelper {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userID: new TextEncoder().encode(userId),
userName: userName,
userID: new TextEncoder().encode(userId) as Uint8Array<ArrayBuffer>,
userName,
attestationType: 'none',
excludeCredentials: userPasskeys.map(passkey => ({
id: passkey.id,
@@ -203,4 +204,4 @@ export class PasskeyHelper {
const userPasskeys = await this.getUserPasskeys(userId);
return userPasskeys.length > 0;
}
}
}

View File

@@ -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);

View File

@@ -3,7 +3,6 @@ import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import key from '@/const/key';
export interface ContainerProps {
title: string;
tag?: React.ReactNode;
@@ -24,7 +23,6 @@ export interface DisplayCardProps {
const DisplayCardContainer: React.FC<ContainerProps> = ({
title: _title,
action,
tag,
enableSwitch,
children,
className,
@@ -40,11 +38,6 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
)}
>
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
{tag && (
<div className='text-center text-default-500 font-medium mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-xs pointer-events-none bg-default-200/50 dark:bg-default-100/50 backdrop-blur-sm px-3 py-0.5 rounded-b-lg shadow-sm z-10'>
{tag}
</div>
)}
<div className='flex-1 min-w-0 mr-2'>
<div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>

View File

@@ -10,18 +10,19 @@ import {
import React from 'react';
export interface ModalProps {
content: React.ReactNode
title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size']
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['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<typeof NextUIModal>['size'];
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['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<ModalProps> = React.memo((props) => {
@@ -33,6 +34,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
dismissible,
confirmText = '确定',
cancelText = '取消',
hideFooter = false,
onClose,
onConfirm,
onCancel,
@@ -62,29 +64,31 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
)}
<ModalBody className='break-all'>{content}</ModalBody>
<ModalFooter>
{showCancel && (
{!hideFooter && (
<ModalFooter>
{showCancel && (
<Button
color='primary'
variant='light'
onPress={() => {
onCancel?.();
nativeClose();
}}
>
{cancelText}
</Button>
)}
<Button
color='primary'
variant='light'
onPress={() => {
onCancel?.();
onConfirm?.();
nativeClose();
}}
>
{cancelText}
{confirmText}
</Button>
)}
<Button
color='primary'
onPress={() => {
onConfirm?.();
nativeClose();
}}
>
{confirmText}
</Button>
</ModalFooter>
</ModalFooter>
)}
</>
)}
</ModalContent>

View File

@@ -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<SystemInfoItemProps> = ({
@@ -30,14 +39,20 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
icon,
endContent,
hasBackground = false,
onClick,
clickable = false,
}) => {
return (
<div className={clsx(
'flex text-sm gap-3 py-2 items-center transition-colors',
hasBackground
? 'text-white/90'
: 'text-default-600 dark:text-gray-300'
)}>
<div
className={clsx(
'flex text-sm gap-3 py-2 items-center transition-colors',
hasBackground
? 'text-white/90'
: 'text-default-600 dark:text-gray-300',
clickable && 'cursor-pointer hover:bg-default-100/50 dark:hover:bg-default-800/30 rounded-lg -mx-2 px-2'
)}
onClick={onClick}
>
<div className="text-lg opacity-70">{icon}</div>
<div className='w-24 font-medium'>{title}</div>
<div className={clsx(
@@ -53,155 +68,6 @@ export interface NewVersionTipProps {
currentVersion?: string;
}
// const NewVersionTip = (props: NewVersionTipProps) => {
// const { currentVersion } = props;
// const dialog = useDialog();
// const { data: releaseData, error } = useRequest(() =>
// request.get<GithubRelease[]>(
// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
// )
// );
// if (error) {
// return (
// <Tooltip content='检查新版本失败'>
// <Button
// isIconOnly
// radius='full'
// color='primary'
// variant='shadow'
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
// onPress={() => {
// dialog.alert({
// title: '检查新版本失败',
// content: error.message,
// });
// }}
// >
// <FaInfo />
// </Button>
// </Tooltip>
// );
// }
// 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<ServerResponse<string | null>>(
// `https://release.nc.152710.xyz/?version=${version}`,
// {
// timeout: 30000,
// }
// ),
// {
// manual: true,
// }
// );
// useEffect(() => {
// runAiSummary(currentVersion);
// }, [currentVersion, runAiSummary]);
// if (aiSummaryLoading) {
// return (
// <div className='flex justify-center py-1'>
// <Spinner size='sm' />
// </div>
// );
// }
// if (aiSummaryError) {
// return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
// }
// return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
// };
// return (
// <Tooltip content='有新版本可用'>
// <Button
// isIconOnly
// radius='full'
// color='primary'
// variant='shadow'
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
// onPress={() => {
// dialog.confirm({
// title: '有新版本可用',
// content: (
// <div className='space-y-2'>
// <div className='text-sm space-x-2'>
// <span>当前版本</span>
// <Chip color='primary' variant='flat'>
// v{currentVersion}
// </Chip>
// </div>
// <div className='text-sm space-x-2'>
// <span>最新版本</span>
// <Chip color='primary'>{latestVersion}</Chip>
// </div>
// <div className='p-2 rounded-md bg-content2 text-sm'>
// <div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
// <BsStars />
// <span>AI总结</span>
// </div>
// <AISummaryComponent />
// </div>
// <div className='text-sm space-y-2 !mt-4'>
// {middleVersions.map((versionInfo) => (
// <div
// key={versionInfo.tag_name}
// className='p-4 bg-content1 rounded-md shadow-small'
// >
// <TailwindMarkdown content={versionInfo.body} />
// </div>
// ))}
// </div>
// </div>
// ),
// scrollBehavior: 'inside',
// size: '3xl',
// confirmText: '前往下载',
// onConfirm () {
// window.open(
// 'https://github.com/NapNeko/NapCatQQ/releases',
// '_blank',
// 'noopener'
// );
// },
// });
// }}
// >
// <FaInfo />
// </Button>
// </Tooltip>
// );
// };
// 更新状态类型
type UpdateStatus = 'idle' | 'updating' | 'success' | 'error';
@@ -213,18 +79,29 @@ const UpdateDialogContent: React.FC<{
errorMessage?: string;
}> = ({ currentVersion, latestVersion, status, errorMessage }) => {
return (
<div className='space-y-4'>
{/* 版本信息 */}
<div className='space-y-2'>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary' variant='flat'>
<div className='space-y-6'>
{/* 版本对比 */}
<div className="flex items-center justify-between 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">
<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-lg" }}>
v{currentVersion}
</Chip>
</div>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary'>v{latestVersion}</Chip>
<div className="flex flex-col items-center text-primary-500 px-4">
<div className="p-2 rounded-full bg-primary-50 dark:bg-primary-900/20">
<svg className="w-6 h-6 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<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-lg" }}>
v{latestVersion}
</Chip>
</div>
</div>
@@ -300,7 +177,8 @@ const NewVersionTip = (props: NewVersionTipProps) => {
});
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('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<VersionSelectDialogProps> = ({
currentVersion,
onClose,
}) => {
const dialog = useDialog();
const [selectedVersion, setSelectedVersion] = useState<VersionInfo | null>(null);
const [forceUpdate, setForceUpdate] = useState(false);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
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: (
<div className='space-y-2'>
<p className='text-warning-600'>
<strong>v{currentVersion}</strong> <strong>{selectedVersionTag}</strong>
</p>
<p className='text-sm text-default-500'>
</p>
</div>
),
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 (
<div className='flex flex-col items-center justify-center gap-3 py-4'>
<div className='w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/40 flex items-center justify-center'>
<svg className='w-6 h-6 text-success-600 dark:text-success-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
</div>
<div className='text-center'>
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
{selectedVersionTag}
</p>
<p className='text-xs text-default-500 mt-1'>
NapCat
</p>
</div>
</div>
);
}
if (updateStatus === 'error') {
return (
<div className='flex flex-col items-center justify-center gap-3 py-4'>
<div className='w-12 h-12 rounded-full bg-danger-100 dark:bg-danger-900/40 flex items-center justify-center'>
<svg className='w-6 h-6 text-danger-600 dark:text-danger-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</div>
<div className='text-center'>
<p className='text-sm font-medium text-danger-600 dark:text-danger-400'>
</p>
<p className='text-xs text-default-500 mt-1'>
{errorMessage || '请稍后重试'}
</p>
</div>
</div>
);
}
if (updateStatus === 'updating') {
return (
<div className='flex flex-col items-center justify-center gap-3 py-6'>
<Spinner size='lg' color='primary' />
<div className='text-center'>
<p className='text-sm font-medium text-primary-600 dark:text-primary-400'>
{selectedVersionTag}...
</p>
<p className='text-xs text-default-500 mt-1'>
</p>
</div>
</div>
);
}
const pagination = releasesData?.pagination;
return (
<div className='space-y-4'>
{/* 当前版本 */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm text-default-600'>:</span>
<Chip color='primary' variant='flat' size='sm'>
v{currentVersion}
</Chip>
</div>
{releasesData?.mirror && (
<div className='text-xs text-default-400 flex items-center gap-1'>
<span className='w-2 h-2 rounded-full bg-success-500'></span>
: {releasesData.mirror}
</div>
)}
</div>
{/* 版本类型切换 */}
<Tabs
selectedKey={activeTab}
onSelectionChange={(key) => {
setActiveTab(key as 'release' | 'action');
setCurrentPage(1);
setSelectedVersion(null);
setSearchQuery('');
}}
size='sm'
color='primary'
variant='underlined'
classNames={{
tabList: 'gap-4',
}}
>
<Tab key='release' title='正式版本' />
<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='space-y-2'>
<div className='flex items-center justify-between'>
<label className='text-sm font-medium text-default-700'></label>
{releasesData?.pagination && (
<span className='text-xs text-default-400'>
{releasesData.pagination.total}
</span>
)}
</div>
{releasesLoading ? (
<div className='flex items-center gap-2 py-2'>
<Spinner size='sm' />
<span className='text-sm text-default-500'>...</span>
</div>
) : releasesError ? (
<div className='text-sm text-danger-500'>
: {releasesError.message}
</div>
) : filteredVersions.length === 0 ? (
<div className='text-sm text-default-500 py-4 text-center'>
{searchQuery ? `未找到匹配 "${searchQuery}" 的版本` : '暂无可用版本'}
</div>
) : (
<Select
label='选择版本'
placeholder='请选择要更新的版本'
selectedKeys={selectedVersion ? [selectedVersionTag] : []}
onSelectionChange={(keys) => {
const selectedTag = Array.from(keys)[0] as string;
const version = filteredVersions.find(v => v.tag === selectedTag);
setSelectedVersion(version || null);
}}
classNames={{
trigger: 'h-10',
}}
>
{filteredVersions.map((version) => {
const isCurrent = version.tag.replace(/^v/, '') === currentVersion;
const downgrade = isDowngrade(version.tag);
return (
<SelectItem
key={version.tag}
textValue={version.tag}
>
<div className='flex items-center gap-2'>
<span>{version.tag}</span>
{version.type === 'prerelease' && (
<Chip size='sm' color='secondary' variant='flat'></Chip>
)}
{version.type === 'action' && (
<Chip size='sm' color='default' variant='flat'></Chip>
)}
{isCurrent && (
<Chip size='sm' color='success' variant='flat'></Chip>
)}
{downgrade && !isCurrent && version.type !== 'action' && (
<Chip size='sm' color='warning' variant='flat'></Chip>
)}
</div>
</SelectItem>
);
})}
</Select>
)}
</div>
{/* Action 版本提示 */}
{activeTab === 'action' && (
<div className='p-3 rounded-lg bg-default-50 dark:bg-default-100/10 border border-default-200/50'>
<p className='text-xs text-default-500'>
GitHub Actions
{selectedVersion?.expiresAt && (
<span className='block mt-1 text-warning-600'>
{new Date(selectedVersion.expiresAt).toLocaleDateString()}
</span>
)}
</p>
</div>
)}
{/* 降级警告 */}
{selectedVersion && isSelectedDowngrade && (
<div className='p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
<div className='flex items-start gap-2'>
<svg className='w-5 h-5 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5' 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>
<div>
<p className='text-sm font-medium text-warning-700 dark:text-warning-400'>
</p>
<p className='text-xs text-warning-600/80 dark:text-warning-500 mt-1'>
</p>
</div>
</div>
<div className='mt-3 flex items-center gap-2'>
<Switch
size='sm'
isSelected={forceUpdate}
onValueChange={setForceUpdate}
/>
<span className='text-xs text-warning-700 dark:text-warning-400'>
</span>
</div>
</div>
)}
{/* 分页 */}
{pagination && pagination.totalPages > 1 && (
<div className='flex justify-center'>
<Pagination
total={pagination.totalPages}
page={currentPage}
onChange={handlePageChange}
size='sm'
showControls
/>
</div>
)}
{/* 操作按钮 */}
<div className='flex justify-end gap-2 pt-4 border-t border-default-100 dark:border-default-100/10'>
<button
className='px-4 py-2 text-sm rounded-lg bg-default-100 hover:bg-default-200 transition-colors'
onClick={onClose}
>
</button>
<button
className={clsx(
'px-4 py-2 text-sm rounded-lg transition-colors text-white shadow-sm',
selectedVersion && (!isSelectedDowngrade || forceUpdate)
? 'bg-primary-500 hover:bg-primary-600 shadow-primary-500/20'
: 'bg-default-300 cursor-not-allowed'
)}
disabled={!selectedVersion || (isSelectedDowngrade && !forceUpdate)}
onClick={handleUpdate}
>
{isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
</button>
</div>
</div>
);
};
interface NapCatVersionProps {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const {
data: packageData,
loading: packageLoading,
@@ -397,26 +645,55 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
const currentVersion = packageData?.version;
// 点击版本号时显示版本选择对话框
const handleVersionClick = useCallback(() => {
if (!currentVersion) return;
setIsVersionModalOpen(true);
}, [currentVersion]);
return (
<SystemInfoItem
title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={
packageError
? (
`错误:${packageError.message}`
)
: packageLoading
<>
<SystemInfoItem
title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={
packageError
? (
<Spinner size='sm' />
`错误:${packageError.message}`
)
: (
currentVersion
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
: packageLoading
? (
<Spinner size='sm' />
)
: (
<Tooltip content='点击管理版本'>
<span
className='cursor-pointer hover:text-primary-500 transition-colors underline decoration-dashed underline-offset-2'
onClick={handleVersionClick}
>
{currentVersion}
</span>
</Tooltip>
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
{isVersionModalOpen && (
<Modal
title='版本管理'
size='lg'
hideFooter={true}
onClose={() => setIsVersionModalOpen(false)}
content={
<VersionSelectDialogContent
currentVersion={currentVersion || ''}
onClose={() => setIsVersionModalOpen(false)}
/>
}
/>
)}
</>
);
};

View File

@@ -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<ServerResponse<{
versions: Array<{
tag: string;
type: 'release' | 'prerelease' | 'action';
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
}>;
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<ServerResponse<any>>(
'/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<ServerResponse<any>>(
'/UpdateNapCat/update',
{ targetVersion, force },
{ timeout: 120000 } // 2分钟超时
);
return data;
}

View File

@@ -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({

View File

@@ -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;
};