Compare commits

..

2 Commits

Author SHA1 Message Date
时瑾
b509d08ed0 fix: 优化视频缩略图生成和清理逻辑,处理文件不存在的情况 2025-12-29 02:23:05 +08:00
时瑾
81c4cd7448 fix: close #1435 2025-12-29 02:17:05 +08:00
149 changed files with 1615 additions and 7759 deletions

View File

@@ -1,4 +1,4 @@
# {VERSION} # V?.?.?
[使用文档](https://napneko.github.io/) [使用文档](https://napneko.github.io/)
## Windows 一键包 ## Windows 一键包
@@ -15,29 +15,13 @@ NapCat.Shell.Windows.OneKey.zip (无头)
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本** **注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看** **默认WebUi密钥为随机密码 控制台查看**
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)** **[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb) [LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm) [LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb) [LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm) [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) [MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll ## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe) [安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新 ## 更新
### 🐛 修复
1. 修复 WebUI 主题配置在有未保存更改时卸载组件导致字体重置的问题 (ae42eed6)
### ✨ 新增
1. 文件上传相关接口UploadGroupFile/UploadPrivateFile新增 `upload_file` 参数支持 (91e0839e)
2. 消息发送逻辑支持 PTT语音元素过滤确保语音消息正确独立发送 (47983e29)
### 🔧 优化
1. 优化合并转发消息GetForwardMsg的获取与解析逻辑提高兼容性 (334c4233)
2. 改进消息发送方法中发送者 UIN 的处理逻辑 (71bb4f68)
3. 增强 WebUI 系统信息界面中对构建产物的处理与展示 (cb061890)
---
**完整更新日志**: [v4.10.6...v4.10.7](https://github.com/NapNeko/NapCatQQ/compare/v4.10.6...v4.10.7)

View File

@@ -1,33 +1,34 @@
# NapCat Release Note Generator 注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。 格式规则:
1. 第一行:# V{TAG}
2. 第二行:[使用文档](https://napneko.github.io/)
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
## 核心规则 ## Windows 一键包
- 简短一句话介绍一键包用途
- 列出可下载的文件名(只列文件名,不写下载链接)
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号,如果版本号是小写 v 开头(如 v4.10.2),必须转换为大写 V如 V4.10.2 ## 警告
2. **语言**:全部使用简体中文 - 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
## Commit 分析规则 ## 如果WinX64缺少运行库或者xxx.dll
- 常见运行库建议
将 commit 分类为以下类型: ## 更新
- 🐛 **修复**bug fix、修复、fix 相关 按数字序列列出主要变更项,每条尽量一句话
- ✨ **新增**新功能、feat、add 相关 - 前缀短 commit id例如1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
- 🔧 **优化**优化、重构、refactor、improve、perf 相关 - 保持 4-18 条要点
- 📦 **依赖**deps、依赖更新通常可以忽略或合并
- 🔨 **构建**ci、build、workflow 相关(通常可以忽略)
## 合并和筛选 ## 开发者注意
- 列出迁移/接口断裂/配置变更;若无则省略
- **合并相似项**:同一功能的多个 commit 合并为一条 额外约束:
- **忽略琐碎项**合并冲突、格式化、typo 等可忽略 - 语言简体中文,面向最终用户
- **控制数量**:最终保持 5-15 条更新要点
- **保留 commit hash**:每条末尾附上短 hash格式 `(a1b2c3d)`
## 输出模板 - 必须严格遵守以下格式 下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5
``` # V4.9.0
# {VERSION}
[使用文档](https://napneko.github.io/) [使用文档](https://napneko.github.io/)
## Windows 一键包 ## Windows 一键包
@@ -44,68 +45,16 @@ NapCat.Shell.Windows.OneKey.zip (无头)
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本** **注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看** **默认WebUi密钥为随机密码 控制台查看**
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)** **[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb) [LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm) [LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb) [LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm) [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) [MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll ## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe) [安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新 ## 更新
1. 修改了XXXXX
### 🐛 修复 2. 新增了XXXX
1. 修复 xxx 问题 (a1b2c3d) 3. 重构了XXXX
2. 修复 yyy 崩溃 (b2c3d4e)
### ✨ 新增
1. 新增 xxx 功能 (c3d4e5f)
2. 支持 yyy 特性 (d4e5f6g)
### 🔧 优化
1. 优化 xxx 性能 (e5f6g7h)
2. 重构 yyy 模块 (f6g7h8i)
---
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
```
**格式要求 - 务必严格遵守:**
- "Windows 一键包"部分的文本必须完全一致,不要修改任何措辞
- "警告"部分必须包含所有 QQ 版本下载链接,保持原有格式
- "如果WinX64缺少运行库或者xxx.dll"这一行必须保持原样
- QQ 版本号和下载链接保持不变40990 版本)
- 只有"## 更新"部分下面的内容需要根据实际 commit 生成
## 重要约束
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

@@ -1,231 +0,0 @@
/**
* 构建状态评论模板
*/
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
export interface BuildTarget {
name: string;
status: BuildStatus;
error?: string;
downloadUrl?: string; // Artifact 直接下载链接
}
// ============== 辅助函数 ==============
function formatSha (sha: string): string {
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
}
function escapeCodeBlock (text: string): string {
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
return text.replace(/```/g, '\\`\\`\\`');
}
function getTimeString (): string {
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
switch (status) {
case 'success':
return '✅ 成功';
case 'pending':
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
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 = getTimeString();
const shortSha = formatSha(prSha);
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
'# 🔨 NapCat 构建中',
'',
'![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)',
'',
'</div>',
'',
'---',
'',
'## 📦 构建目标',
'',
'| 包名 | 状态 | 说明 |',
'| :--- | :---: | :--- |',
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
`| 📝 提交 | \`${shortSha}\` |`,
`| 🕐 开始时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **构建进行中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
return lines.join('\n');
}
// ============== 构建结果评论 ==============
export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
repository: string,
version?: string
): string {
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');
// 状态徽章
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 `[📥 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
'## 📦 构建产物',
'',
'| 包名 | 状态 | 下载 |',
'| :--- | :---: | :---: |',
...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('', '---', '', '## ⚠️ 错误详情', '');
for (const target of failedTargets) {
lines.push(
`<details>`,
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
'',
'```',
escapeCodeBlock(target.error!),
'```',
'',
'</details>',
''
);
}
}
// 添加底部提示
lines.push('---', '');
if (allSuccess) {
lines.push(
'<div align="center">',
'',
'> 🎉 **所有构建均已成功完成!**',
'>',
'> 点击上方下载链接获取构建产物进行测试',
'',
'</div>'
);
} else if (anyCancelled && !anyFailure) {
lines.push(
'<div align="center">',
'',
'> ⚪ **构建已被取消**',
'>',
'> 可能是由于新的提交触发了新的构建',
'',
'</div>'
);
} else {
lines.push(
'<div align="center">',
'',
'> ⚠️ **部分构建失败**',
'>',
'> 请查看上方错误详情或点击构建日志查看完整输出',
'',
'</div>'
);
}
return lines.join('\n');
}

View File

@@ -1,189 +0,0 @@
/**
* GitHub API 工具库
*/
import { appendFileSync } from 'node:fs';
// ============== 类型定义 ==============
export interface PullRequest {
number: number;
state: string;
head: {
sha: string;
ref: string;
repo: {
full_name: string;
};
};
}
export interface Repository {
owner: {
type: string;
};
}
export interface Artifact {
id: number;
name: string;
size_in_bytes: number;
archive_download_url: string;
}
// ============== GitHub API Client ==========================
export class GitHubAPI {
private token: string;
private baseUrl = 'https://api.github.com';
constructor (token: string) {
this.token = token;
}
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
}
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
const data = await this.request<{ permission: string; }>(
`/repos/${owner}/${repo}/collaborators/${username}/permission`
);
return data.permission;
}
async getRepository (owner: string, repo: string): Promise<Repository> {
return this.request(`/repos/${owner}/${repo}`);
}
async checkOrgMembership (org: string, username: string): Promise<boolean> {
try {
await this.request(`/orgs/${org}/members/${username}`);
return true;
} catch {
return false;
}
}
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
const data = await this.request<{ artifacts: Artifact[]; }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
);
return data.artifacts;
}
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
let page = 1;
const perPage = 100;
while (page <= 10) { // 最多检查 1000 条评论
const comments = await this.request<Array<{ id: number, body: string; }>>(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
);
if (comments.length === 0) {
return null;
}
const found = comments.find(c => c.body.includes(marker));
if (found) {
return found.id;
}
if (comments.length < perPage) {
return null;
}
page++;
}
return null;
}
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async createOrUpdateComment (
owner: string,
repo: string,
issueNumber: number,
body: string,
marker: string
): Promise<void> {
const existingId = await this.findComment(owner, repo, issueNumber, marker);
if (existingId) {
await this.updateComment(owner, repo, existingId, body);
console.log(`✓ Updated comment #${existingId}`);
} else {
await this.createComment(owner, repo, issueNumber, body);
console.log('✓ Created new comment');
}
}
}
// ============== Output 工具 ==============
export function setOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
}
console.log(` ${name}=${value}`);
}
export function setMultilineOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `EOF_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
// ============== 环境变量工具 ==============
export function getEnv (name: string, required: true): string;
export function getEnv (name: string, required?: false): string | undefined;
export function getEnv (name: string, required = false): string | undefined {
const value = process.env[name];
if (required && !value) {
throw new Error(`Environment variable ${name} is required`);
}
return value;
}
export function getRepository (): { owner: string, repo: string; } {
const repository = getEnv('GITHUB_REPOSITORY', true);
const [owner, repo] = repository.split('/');
return { owner, repo };
}

View File

@@ -1,36 +0,0 @@
/**
* PR Build - 更新构建中状态评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
async function main (): Promise<void> {
console.log('🔨 Updating building status comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA', true);
const { owner, repo } = getRepository();
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Repo: ${owner}/${repo}\n`);
const github = new GitHubAPI(token);
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

View File

@@ -1,206 +0,0 @@
/**
* PR Build Check Script
* 检查 PR 构建触发条件和用户权限
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - GITHUB_EVENT_NAME: 事件名称
* - GITHUB_EVENT_PATH: 事件 payload 文件路径
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
* - GITHUB_OUTPUT: 输出文件路径
*/
import { readFileSync } from 'node:fs';
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
import type { PullRequest } from './lib/github.ts';
// ============== 类型定义 ==============
interface GitHubPayload {
pull_request?: PullRequest;
issue?: {
number: number;
pull_request?: object;
};
comment?: {
body: string;
user: { login: string; };
};
}
interface CheckResult {
should_build: boolean;
pr_number?: number;
pr_sha?: string;
pr_head_repo?: string;
pr_head_ref?: string;
}
// ============== 权限检查 ==============
async function checkUserPermission (
github: GitHubAPI,
owner: string,
repo: string,
username: string
): Promise<boolean> {
// 方法1检查仓库协作者权限
try {
const permission = await github.getCollaboratorPermission(owner, repo, username);
if (['admin', 'write', 'maintain'].includes(permission)) {
console.log(`✓ User ${username} has ${permission} permission`);
return true;
}
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
} catch (e) {
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
}
// 方法2检查组织成员身份
try {
const repoInfo = await github.getRepository(owner, repo);
if (repoInfo.owner.type === 'Organization') {
const isMember = await github.checkOrgMembership(owner, username);
if (isMember) {
console.log(`✓ User ${username} is organization member`);
return true;
}
console.log(`✗ User ${username} is not organization member`);
}
} catch (e) {
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
}
return false;
}
// ============== 事件处理 ==============
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
const pr = payload.pull_request;
if (!pr) {
console.log('✗ No pull_request in payload');
return { should_build: false };
}
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
return { should_build: false };
}
console.log(`✓ PR #${pr.number} is open, triggering build`);
return {
should_build: true,
pr_number: pr.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
async function handleIssueComment (
payload: GitHubPayload,
github: GitHubAPI,
owner: string,
repo: string
): Promise<CheckResult> {
const { issue, comment } = payload;
if (!issue || !comment) {
console.log('✗ No issue or comment in payload');
return { should_build: false };
}
// 检查是否是 PR 的评论
if (!issue.pull_request) {
console.log('✗ Comment is not on a PR');
return { should_build: false };
}
// 检查是否是 /build 命令
if (!comment.body.trim().startsWith('/build')) {
console.log('✗ Comment is not a /build command');
return { should_build: false };
}
console.log(`→ /build command from @${comment.user.login}`);
// 获取 PR 详情
const pr = await github.getPullRequest(owner, repo, issue.number);
// 检查 PR 状态
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
return { should_build: false };
}
// 检查用户权限
const username = comment.user.login;
const hasPermission = await checkUserPermission(github, owner, repo, username);
if (!hasPermission) {
console.log(`✗ User ${username} has no permission`);
await github.createComment(
owner,
repo,
issue.number,
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
);
return { should_build: false };
}
console.log(`✓ Build triggered by @${username}`);
return {
should_build: true,
pr_number: issue.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
// ============== 主函数 ==============
async function main (): Promise<void> {
console.log('🔍 PR Build Check\n');
const token = getEnv('GITHUB_TOKEN', true);
const eventName = getEnv('GITHUB_EVENT_NAME', true);
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
const { owner, repo } = getRepository();
console.log(`Event: ${eventName}`);
console.log(`Repository: ${owner}/${repo}\n`);
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
const github = new GitHubAPI(token);
let result: CheckResult;
switch (eventName) {
case 'pull_request_target':
result = handlePullRequestTarget(payload);
break;
case 'issue_comment':
result = await handleIssueComment(payload, github, owner, repo);
break;
default:
console.log(`✗ Unsupported event: ${eventName}`);
result = { should_build: false };
}
// 输出结果
console.log('\n=== Outputs ===');
setOutput('should_build', String(result.should_build));
setOutput('pr_number', String(result.pr_number ?? ''));
setOutput('pr_sha', result.pr_sha ?? '');
setOutput('pr_head_repo', result.pr_head_repo ?? '');
setOutput('pr_head_ref', result.pr_head_ref ?? '');
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

View File

@@ -1,90 +0,0 @@
/**
* PR Build - 更新构建结果评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
* - RUN_ID: GitHub Actions Run ID
* - NAPCAT_VERSION: 构建版本号
* - FRAMEWORK_STATUS: Framework 构建状态
* - FRAMEWORK_ERROR: Framework 构建错误信息
* - SHELL_STATUS: Shell 构建状态
* - SHELL_ERROR: Shell 构建错误信息
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
import type { BuildTarget, BuildStatus } from './lib/comment.ts';
function parseStatus (value: string | undefined): BuildStatus {
if (value === 'success' || value === 'failure' || value === 'cancelled') {
return value;
}
return 'unknown';
}
async function main (): Promise<void> {
console.log('📝 Updating build result comment\n');
const token = getEnv('GITHUB_TOKEN', true);
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'));
const frameworkError = getEnv('FRAMEWORK_ERROR');
const shellStatus = parseStatus(getEnv('SHELL_STATUS'));
const shellError = getEnv('SHELL_ERROR');
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`);
const github = new GitHubAPI(token);
const repository = `${owner}/${repo}`;
// 获取 artifacts 列表,生成直接下载链接
const artifactMap: Record<string, string> = {};
try {
const artifacts = await github.getRunArtifacts(owner, repo, runId);
console.log(`Found ${artifacts.length} artifacts`);
for (const artifact of artifacts) {
// 生成直接下载链接https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
const downloadUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`;
artifactMap[artifact.name] = downloadUrl;
console.log(` - ${artifact.name}: ${downloadUrl}`);
}
} catch (e) {
console.log(`Warning: Failed to get artifacts: ${(e as Error).message}`);
}
const targets: BuildTarget[] = [
{
name: 'NapCat.Framework',
status: frameworkStatus,
error: frameworkError,
downloadUrl: artifactMap['NapCat.Framework'],
},
{
name: 'NapCat.Shell',
status: shellStatus,
error: shellError,
downloadUrl: artifactMap['NapCat.Shell'],
},
];
const comment = generateResultComment(targets, prSha, runId, repository, version);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

View File

@@ -1,149 +0,0 @@
/**
* PR Build Runner
* 执行构建步骤
*
* 用法: node pr-build-run.ts <target>
* target: framework | shell
*/
import { execSync } from 'node:child_process';
import { existsSync, renameSync, unlinkSync } from 'node:fs';
import { setOutput } from './lib/github.ts';
type BuildTarget = 'framework' | 'shell';
interface BuildStep {
name: string;
command: string;
errorMessage: string;
}
// ============== 构建步骤 ==============
function getCommonSteps (): BuildStep[] {
return [
{
name: 'Install pnpm',
command: 'npm i -g pnpm',
errorMessage: 'Failed to install pnpm',
},
{
name: 'Install dependencies',
command: 'pnpm i',
errorMessage: 'Failed to install dependencies',
},
{
name: 'Type check',
command: 'pnpm run typecheck',
errorMessage: 'Type check failed',
},
{
name: 'Test',
command: 'pnpm test',
errorMessage: 'Tests failed',
},
{
name: 'Build WebUI',
command: 'pnpm --filter napcat-webui-frontend run build',
errorMessage: 'WebUI build failed',
},
];
}
function getTargetSteps (target: BuildTarget): BuildStep[] {
if (target === 'framework') {
return [
{
name: 'Build Framework',
command: 'pnpm run build:framework',
errorMessage: 'Framework build failed',
},
];
}
return [
{
name: 'Build Shell',
command: 'pnpm run build:shell',
errorMessage: 'Shell build failed',
},
];
}
// ============== 执行器 ==============
function runStep (step: BuildStep): boolean {
console.log(`\n::group::${step.name}`);
console.log(`> ${step.command}\n`);
try {
execSync(step.command, {
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
console.log('::endgroup::');
console.log(`${step.name}`);
return true;
} catch (_error) {
console.log('::endgroup::');
console.log(`${step.name}`);
setOutput('error', step.errorMessage);
return false;
}
}
function postBuild (target: BuildTarget): void {
const srcDir = target === 'framework'
? 'packages/napcat-framework/dist'
: 'packages/napcat-shell/dist';
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
if (!existsSync(srcDir)) {
throw new Error(`Build output not found: ${srcDir}`);
}
renameSync(srcDir, destDir);
// Install production dependencies
console.log('→ Installing production dependencies');
execSync('npm install --omit=dev', {
cwd: destDir,
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
// Remove package-lock.json
const lockFile = `${destDir}/package-lock.json`;
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
console.log(`✓ Build output ready at ${destDir}`);
}
// ============== 主函数 ==============
function main (): void {
const target = process.argv[2] as BuildTarget;
if (!target || !['framework', 'shell'].includes(target)) {
console.error('Usage: node pr-build-run.ts <framework|shell>');
process.exit(1);
}
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
for (const step of steps) {
if (!runStep(step)) {
process.exit(1);
}
}
postBuild(target);
console.log('\n✅ Build completed successfully!');
}
main();

View File

@@ -46,8 +46,8 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }} GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }} NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本 QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本 QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
run: | run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}" echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}"
curl -X POST \ curl -X POST \
@@ -72,8 +72,8 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }} GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }} NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本 QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本 QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
run: | run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}" echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
curl -X POST \ curl -X POST \

View File

@@ -13,27 +13,11 @@ jobs:
steps: steps:
- name: Clone Main Repository - name: Clone Main Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X - name: Use Node.js 20.X
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x 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 - name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: | run: |
npm i -g pnpm npm i -g pnpm
pnpm i pnpm i
@@ -41,7 +25,6 @@ jobs:
pnpm test || exit 1 pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1 pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework pnpm run build:framework
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-framework/dist framework-dist mv packages/napcat-framework/dist framework-dist
cd framework-dist cd framework-dist
npm install --omit=dev npm install --omit=dev
@@ -56,27 +39,11 @@ jobs:
steps: steps:
- name: Clone Main Repository - name: Clone Main Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X - name: Use Node.js 20.X
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x 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 - name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: | run: |
npm i -g pnpm npm i -g pnpm
pnpm i pnpm i
@@ -84,7 +51,6 @@ jobs:
pnpm test || exit 1 pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1 pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell pnpm run build:shell
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-shell/dist shell-dist mv packages/napcat-shell/dist shell-dist
cd shell-dist cd shell-dist
npm install --omit=dev npm install --omit=dev

View File

@@ -1,303 +0,0 @@
# =============================================================================
# PR 构建工作流
# =============================================================================
# 功能:
# 1. 在 PR 提交时自动构建 Framework 和 Shell 包
# 2. 支持通过 /build 命令手动触发构建(仅协作者/组织成员)
# 3. 在 PR 中发布构建状态评论,并持续更新(不会重复创建)
# 4. 支持 Fork PR 的构建(使用 pull_request_target 获取写权限)
#
# 安全说明:
# - 使用 pull_request_target 事件,在 base 分支上下文运行
# - 构建脚本始终从 base 分支 checkout避免恶意 PR 篡改脚本
# - PR 代码单独 checkout 到 workspace 目录
# =============================================================================
name: PR Build
# =============================================================================
# 触发条件
# =============================================================================
on:
# PR 事件:打开、同步(新推送)、重新打开时触发
# 注意:使用 pull_request_target 而非 pull_request以便对 Fork PR 有写权限
pull_request_target:
types: [opened, synchronize, reopened]
# Issue 评论事件:用于响应 /build 命令
issue_comment:
types: [created]
# =============================================================================
# 权限配置
# =============================================================================
permissions:
contents: read # 读取仓库内容
pull-requests: write # 写入 PR 评论
issues: write # 写入 Issue 评论(/build 命令响应)
actions: read # 读取 Actions 信息(获取构建日志链接)
# =============================================================================
# 并发控制
# =============================================================================
# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费
# 注意:只有在 should_build=true 时才会进入实际构建流程,
# issue_comment 事件如果不是 /build 命令,会在 check-build 阶段快速退出,
# 不会取消正在进行的构建(因为 cancel-in-progress 只影响同 group 的后续任务)
concurrency:
# 使用不同的 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
# =============================================================================
# 任务定义
# =============================================================================
jobs:
# ---------------------------------------------------------------------------
# Job 1: 检查构建条件
# ---------------------------------------------------------------------------
# 判断是否应该触发构建:
# - pull_request_target 事件:总是触发
# - issue_comment 事件:检查是否为 /build 命令,且用户有权限
# ---------------------------------------------------------------------------
check-build:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }} # 是否应该构建
pr_number: ${{ steps.check.outputs.pr_number }} # PR 编号
pr_sha: ${{ steps.check.outputs.pr_sha }} # PR 最新提交 SHA
pr_head_repo: ${{ steps.check.outputs.pr_head_repo }} # PR 源仓库(用于 Fork
pr_head_ref: ${{ steps.check.outputs.pr_head_ref }} # PR 源分支
steps:
# 仅 checkout 脚本目录,加快速度
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
# 使用 Node.js 24 以支持原生 TypeScript 执行
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行检查脚本,判断是否触发构建
- name: Check trigger condition
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node --experimental-strip-types .github/scripts/pr-build-check.ts
# ---------------------------------------------------------------------------
# Job 2: 更新评论为"构建中"状态
# ---------------------------------------------------------------------------
# 在 PR 中创建或更新评论,显示构建正在进行中
# ---------------------------------------------------------------------------
update-comment-building:
needs: check-build
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新 PR 评论,显示构建中状态
- name: Update building comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
run: node --experimental-strip-types .github/scripts/pr-build-building.ts
# ---------------------------------------------------------------------------
# Job 3: 构建 Framework 包
# ---------------------------------------------------------------------------
# 执行 napcat-framework 的构建流程
# ---------------------------------------------------------------------------
build-framework:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
# 这样即使 PR 中修改了脚本,也不会被执行
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
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 # 允许失败,后续更新评论时处理
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: workspace/framework-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 4: 构建 Shell 包
# ---------------------------------------------------------------------------
# 执行 napcat-shell 的构建流程(与 Framework 并行执行)
# ---------------------------------------------------------------------------
build-shell:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
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
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
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
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: workspace/shell-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 5: 更新评论为构建结果
# ---------------------------------------------------------------------------
# 汇总所有构建结果,更新 PR 评论显示最终状态
# 使用 always() 确保即使构建失败/取消也会执行
# ---------------------------------------------------------------------------
update-comment-result:
needs: [check-build, update-comment-building, build-framework, build-shell]
if: always() && needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新评论,显示构建结果和下载链接
- name: Update result comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 }}
SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }}
SHELL_ERROR: ${{ needs.build-shell.outputs.error }}
run: node --experimental-strip-types .github/scripts/pr-build-result.ts

View File

@@ -4,48 +4,17 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
tags: tags:
- 'v*' - '*'
permissions: write-all permissions: write-all
env: env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "copilot/gemini-3-flash-preview" OPENROUTER_MODEL: "kimi-k2-0905-turbo"
RELEASE_NAME: "NapCat" RELEASE_NAME: "NapCat"
jobs: 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: Build-Framework:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone Main Repository - name: Clone Main Repository
@@ -55,14 +24,11 @@ jobs:
with: with:
node-version: 20.x node-version: 20.x
- name: Build NapCat.Framework - name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npm i -g pnpm npm i -g pnpm
pnpm i pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1 pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework pnpm run build:framework
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-framework/dist framework-dist mv packages/napcat-framework/dist framework-dist
cd framework-dist cd framework-dist
npm install --omit=dev npm install --omit=dev
@@ -74,8 +40,6 @@ jobs:
path: framework-dist path: framework-dist
Build-Shell: Build-Shell:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone Main Repository - name: Clone Main Repository
@@ -85,14 +49,11 @@ jobs:
with: with:
node-version: 20.x node-version: 20.x
- name: Build NapCat.Shell - name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npm i -g pnpm npm i -g pnpm
pnpm i pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1 pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell pnpm run build:shell
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-shell/dist shell-dist mv packages/napcat-shell/dist shell-dist
cd shell-dist cd shell-dist
npm install --omit=dev npm install --omit=dev
@@ -200,10 +161,6 @@ jobs:
with: with:
path: ./artifacts path: ./artifacts
- name: Download NapCat.Shell.Windows.OneKey.zip
run: |
curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
- name: Zip Artifacts - name: Zip Artifacts
run: | run: |
cd artifacts cd artifacts
@@ -214,10 +171,10 @@ jobs:
- name: Generate release note via OpenRouter - name: Generate release note via OpenRouter
env: env:
OPENAI_KEY: ${{ secrets.OPENAI_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }} OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }} OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名 GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
run: | run: |
set -euo pipefail set -euo pipefail
@@ -242,162 +199,41 @@ jobs:
done done
if [ -z "$PREV_TAG" ]; then if [ -z "$PREV_TAG" ]; then
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit" echo " Could not find previous tag for $CURRENT_TAG, aborting."
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1) exit 1
fi fi
echo "Previous tag: $PREV_TAG" echo "Previous tag: $PREV_TAG"
# 强制拉取上一个 tag 和当前 tag # 强制拉取上一个 tag 和当前 tag
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
# 获取 commit,使用更清晰的格式 # 获取 commit title + body + 作者,保留换行
# 格式: <type>: <subject> (<hash>) COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
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 "Commit list from $PREV_TAG to $CURRENT_TAG:"
echo "$COMMITS" echo -e "$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"
# 获取变更的关键文件列表(排除测试、配置等)
# 使用 || true 防止 grep 无匹配时返回非零退出码
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" || true | \
grep -E "\.(ts|js)$" || true | \
grep -v -E "(test|spec|\.d\.ts|config)" || true | \
head -15) || true
CODE_DIFF=""
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
CURRENT_CHARS=0
if [ -n "$KEY_FILES" ]; then
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) || true
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
fi
# 如果没有关键文件变化获取前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|yml|md)$" | head -5) || true
if [ -n "$TOP_FILES" ]; then
for file in $TOP_FILES; do
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30) || true
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
fi
done
fi
fi
# 如果仍然没有代码变化,添加说明
if [ -z "$CODE_DIFF" ]; then
CODE_DIFF="[本次更新主要涉及配置文件和文档变更,无核心代码变化]"
fi
echo "Code diff preview:"
echo "$CODE_DIFF" | head -50
# 读取 prompt # 读取 prompt
PROMPT_FILE=".github/prompt/release_note_prompt.txt" PROMPT_FILE=".github/prompt/release_note_prompt.txt"
SYSTEM_PROMPT=$(<"$PROMPT_FILE") SYSTEM_PROMPT=$(<"$PROMPT_FILE")
# 构建用户内容传递更多上下文包含文件变化和代码diff # 构建用户内容
USER_CONTENT="当前版本: $CURRENT_TAG USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
上一版本: $PREV_TAG
## 提交列表 # 构建请求 JSON
$COMMITS
## 文件变化统计
$SUMMARY_LINE
## 变更文件列表
$FILE_CHANGES
## 关键代码变化
$CODE_DIFF"
# 构建请求 JSON增加 max_tokens 以获取更完整的输出
BODY=$(jq -n \ BODY=$(jq -n \
--arg system "$SYSTEM_PROMPT" \ --arg system "$SYSTEM_PROMPT" \
--arg user "$USER_CONTENT" \ --arg user "$USER_CONTENT" \
--arg model "$OPENROUTER_MODEL" \ '{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
echo "=== OpenRouter request body ===" echo "=== OpenRouter request body ==="
echo "$BODY" | jq . echo "$BODY" | jq .
# 调用 OpenRouter # 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \ if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
-H "Authorization: Bearer $OPENAI_KEY" \ -H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$BODY"); then -d "$BODY"); then
echo "=== raw response ===" echo "=== raw response ==="
@@ -414,18 +250,13 @@ jobs:
if [ -z "$RELEASE_BODY" ]; then if [ -z "$RELEASE_BODY" ]; then
echo "❌ OpenRouter failed to generate release note, using default.md" 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 else
# 后处理:确保版本号正确,并添加比较链接
echo -e "$RELEASE_BODY" > CHANGELOG.md 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 fi
else else
echo "❌ Curl failed, using default.md" echo "❌ Curl failed, using default.md"
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md cp .github/prompt/default.md CHANGELOG.md
fi fi
echo "=== generated release note ===" echo "=== generated release note ==="
cat CHANGELOG.md cat CHANGELOG.md
@@ -440,5 +271,4 @@ jobs:
NapCat.Shell.Windows.Node.zip NapCat.Shell.Windows.Node.zip
NapCat.Framework.zip NapCat.Framework.zip
NapCat.Shell.zip NapCat.Shell.zip
NapCat.Shell.Windows.OneKey.zip
draft: true draft: true

View File

@@ -28,6 +28,7 @@
}, },
"dependencies": { "dependencies": {
"express": "^5.0.0", "express": "^5.0.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.3" "ws": "^8.18.3"
} }
} }

View File

@@ -17,7 +17,8 @@
}, },
"dependencies": { "dependencies": {
"ajv": "^8.13.0", "ajv": "^8.13.0",
"file-type": "^21.0.0" "file-type": "^21.0.0",
"silk-wasm": "^3.6.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.1" "@types/node": "^22.0.1"

View File

@@ -0,0 +1,20 @@
import { encode } from 'silk-wasm';
import { parentPort } from 'worker_threads';
export interface EncodeArgs {
input: ArrayBufferView | ArrayBuffer
sampleRate: number
}
export function recvTask<T> (cb: (taskData: T) => Promise<unknown>) {
parentPort?.on('message', async (taskData: T) => {
try {
const ret = await cb(taskData);
parentPort?.postMessage(ret);
} catch (error: unknown) {
parentPort?.postMessage({ error: (error as Error).message });
}
});
}
recvTask<EncodeArgs>(async ({ input, sampleRate }) => {
return await encode(input, sampleRate);
});

View File

@@ -2,11 +2,7 @@ import path from 'node:path';
import fs from 'fs'; import fs from 'fs';
import os from 'node:os'; import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types'; import { QQVersionConfigType, QQLevel } from './types';
import { compareSemVer } from './version'; import { RequestUtil } from './request';
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> { 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) => { return new Promise<ReturnType<T> | undefined>((resolve) => {
@@ -217,19 +213,56 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined; return undefined;
} }
// ============== GitHub Tags 获取 ============== const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
// 使用 mirror 模块统一管理镜像 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,
];
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> { async function testUrl (url: string): Promise<boolean> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ'); 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 getLatestTag (): Promise<string> { export async function getLatestTag (): Promise<string> {
const { tags } = await getAllTags(); const tags = await getAllTags();
// 使用 SemVer 规范排序 tags.sort((a, b) => compareVersion(a, b));
tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1); const latest = tags.at(-1);
if (!latest) { if (!latest) {
@@ -238,3 +271,22 @@ export async function getLatestTag (): Promise<string> {
// 去掉开头的 v // 去掉开头的 v
return latest.replace(/^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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,11 @@ import http from 'node:http';
export class RequestUtil { export class RequestUtil {
// 适用于获取服务器下发cookies时获取仅GET // 适用于获取服务器下发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; const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = client.get(url, (res) => { 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('data', () => { }); // Necessary to consume the stream
res.on('end', () => { 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.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) { if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url); const redirectUrl = new URL(res.headers.location, url);
@@ -39,7 +39,7 @@ export class RequestUtil {
return cookies; return cookies;
} }
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) { private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
setCookieHeaders.forEach((cookie) => { setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0]?.split('='); const parts = cookie.split(';')[0]?.split('=');
if (parts) { if (parts) {
@@ -53,10 +53,9 @@ export class RequestUtil {
} }
// 请求和回复都是JSON data传原始内容 自动编码json // 请求和回复都是JSON data传原始内容 自动编码json
// 支持 301/302 重定向(最多 5 次)
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: { static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string; [key: string]: string
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> { } = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
const option = new URL(url); const option = new URL(url);
const protocol = url.startsWith('https://') ? https : http; const protocol = url.startsWith('https://') ? https : http;
const options = { const options = {
@@ -72,20 +71,6 @@ export class RequestUtil {
// }, // },
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: http.IncomingMessage) => { 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 = ''; let responseBody = '';
res.on('data', (chunk: string | Buffer) => { res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString(); responseBody += chunk.toString();
@@ -124,7 +109,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); return this.HttpGetJson<string>(url, method, data, headers, false, false);
} }
} }

View File

@@ -1,118 +1,2 @@
// @ts-ignore // @ts-ignore
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev'; export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha';
/**
* 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

@@ -138,7 +138,7 @@ export class NTQQFileApi {
})).urlResult.domainUrl; })).urlResult.domainUrl;
} }
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) { async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
const fileMd5 = await calculateFileMD5(filePath); const fileMd5 = await calculateFileMD5(filePath);
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => ''); const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : ''; const ext = extOrEmpty ? `.${extOrEmpty}` : '';
@@ -146,8 +146,7 @@ export class NTQQFileApi {
if (fileName.indexOf('.') === -1) { if (fileName.indexOf('.') === -1) {
fileName += ext; fileName += ext;
} }
const fileSize = await this.getFileSize(filePath);
if (uploadGroupFile) {
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({ const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5, md5HexStr: fileMd5,
fileName, fileName,
@@ -160,7 +159,7 @@ export class NTQQFileApi {
}); });
await this.copyFile(filePath, mediaPath); await this.copyFile(filePath, mediaPath);
const fileSize = await this.getFileSize(filePath);
return { return {
md5: fileMd5, md5: fileMd5,
fileName, fileName,
@@ -169,14 +168,6 @@ export class NTQQFileApi {
ext, ext,
}; };
} }
return {
md5: fileMd5,
fileName,
path: filePath,
fileSize,
ext,
};
}
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) { async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2( const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(

View File

@@ -498,25 +498,5 @@
"6.9.86-42941": { "6.9.86-42941": {
"appid": 537328648, "appid": 537328648,
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B" "qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
},
"9.9.26-44175": {
"appid": 537336450,
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
},
"9.9.26-44343": {
"appid": 537336603,
"qua": "V1_WIN_NQ_9.9.26_44343_GW_B"
},
"3.2.23-44343": {
"appid": 537336639,
"qua": "V1_LNX_NQ_3.2.23_44343_GW_B"
},
"9.9.26-44498": {
"appid": 537337416,
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
},
"9.9.26-44725": {
"appid": 537337569,
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
} }
} }

View File

@@ -87,10 +87,6 @@
"send": "23B0330", "send": "23B0330",
"recv": "0957648" "recv": "0957648"
}, },
"3.2.21-42086-arm64": {
"send": "3D6D98C",
"recv": "14797C8"
},
"3.2.21-42086-x64": { "3.2.21-42086-x64": {
"send": "5B42CF0", "send": "5B42CF0",
"recv": "2FDA6F0" "recv": "2FDA6F0"
@@ -130,29 +126,5 @@
"6.9.86-42941-arm64": { "6.9.86-42941-arm64": {
"send": "2346108", "send": "2346108",
"recv": "09675F0" "recv": "09675F0"
},
"9.9.26-44175-x64": {
"send": "0A0F2EC",
"recv": "1D3AD4D"
},
"9.9.26-44343-x64": {
"send": "0A0F7BC",
"recv": "1D3C3CD"
},
"3.2.23-44343-arm64": {
"send": "3C867DC",
"recv": "1404938"
},
"3.2.23-44343-x64": {
"send": "59A27B0",
"recv": "2FFBE90"
},
"9.9.26-44498-x64": {
"send": "0A1051C",
"recv": "1D3BC0D"
},
"9.9.26-44725-x64": {
"send": "0A18D0C",
"recv": "1D4BF0D"
} }
} }

View File

@@ -638,29 +638,5 @@
"6.9.86-42941-arm64": { "6.9.86-42941-arm64": {
"send": "3DDDAD0", "send": "3DDDAD0",
"recv": "3DE03E0" "recv": "3DE03E0"
},
"9.9.26-44175-x64": {
"send": "2CD84A0",
"recv": "2CDBA20"
},
"3.2.23-44343-x64": {
"send": "A46F140",
"recv": "A472BE0"
},
"9.9.26-44343-x64": {
"send": "2CD8EE0",
"recv": "2CDC460"
},
"3.2.23-44343-arm64": {
"send": "6926F60",
"recv": "692A910"
},
"9.9.26-44498-x64": {
"send": "2CDAE40",
"recv": "2CDE3C0"
},
"9.9.26-44725-x64": {
"send": "2CEBB20",
"recv": "2CEF0A0"
} }
} }

View File

@@ -1,8 +1,19 @@
import fsPromise from 'fs/promises'; import fsPromise from 'fs/promises';
import path from 'node:path'; import path from 'node:path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
import { LogWrapper } from '@/napcat-core/helper/log'; import { LogWrapper } from '@/napcat-core/helper/log';
import { EncodeArgs } from 'napcat-common/src/audio-worker';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg'; import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { runTask } from 'napcat-common/src/worker';
import { fileURLToPath } from 'node:url';
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
function getWorkerPath () {
// return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
}
async function guessDuration (pttPath: string, logger: LogWrapper) { async function guessDuration (pttPath: string, logger: LogWrapper) {
const pttFileInfo = await fsPromise.stat(pttPath); const pttFileInfo = await fsPromise.stat(pttPath);
@@ -11,23 +22,51 @@ async function guessDuration (pttPath: string, logger: LogWrapper) {
return duration; return duration;
} }
async function handleWavFile (
file: Buffer,
filePath: string,
pcmPath: string
): Promise<{ input: Buffer; sampleRate: number }> {
const { fmt } = getWavFileInfo(file);
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
const result = await FFmpegService.convert(filePath, pcmPath);
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
}
return { input: file, sampleRate: fmt.sampleRate };
}
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) { export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
try { try {
const file = await fsPromise.readFile(filePath);
const pttPath = path.join(TEMP_DIR, randomUUID()); const pttPath = path.join(TEMP_DIR, randomUUID());
if (!(await FFmpegService.isSilk(filePath))) { if (!isSilk(file)) {
logger.log(`语音文件${filePath}需要转换成silk`); logger.log(`语音文件${filePath}需要转换成silk`);
await FFmpegService.convertToNTSilkTct(filePath, pttPath); const pcmPath = `${pttPath}.pcm`;
const duration = await FFmpegService.getDuration(filePath); // const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', duration); let input: Buffer;
let sampleRate: number;
if (isWav(file)) {
const result = await handleWavFile(file, filePath, pcmPath);
input = result.input;
sampleRate = result.sampleRate;
} else {
const result = await FFmpegService.convert(filePath, pcmPath);
input = await fsPromise.readFile(pcmPath);
sampleRate = result.sampleRate;
}
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input, sampleRate });
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
return { return {
converted: true, converted: true,
path: pttPath, path: pttPath,
duration: duration, duration: silk.duration / 1000,
}; };
} else { } else {
let duration = 0; let duration = 0;
try { try {
duration = await FFmpegService.getDuration(filePath); duration = getDuration(file) / 1000;
} catch (e: unknown) { } catch (e: unknown) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack); logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
duration = await guessDuration(filePath, logger); duration = await guessDuration(filePath, logger);

View File

@@ -43,19 +43,13 @@ export interface IFFmpegAdapter {
*/ */
getDuration(filePath: string): Promise<number>; getDuration(filePath: string): Promise<number>;
/**
* 判断是否为 Silk 格式
* @param filePath 文件路径
*/
isSilk (filePath: string): Promise<boolean>;
/** /**
* 转换音频为 PCM 格式 * 转换音频为 PCM 格式
* @param filePath 输入文件路径 * @param filePath 输入文件路径
* @param pcmPath 输出 PCM 文件路径 * @param pcmPath 输出 PCM 文件路径
* @returns PCM 数据 Buffer * @returns PCM 数据 Buffer
*/ */
convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>; convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
/** /**
* 转换音频文件 * 转换音频文件
@@ -71,6 +65,4 @@ export interface IFFmpegAdapter {
* @param thumbnailPath 缩略图输出路径 * @param thumbnailPath 缩略图输出路径
*/ */
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>; extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
} }

View File

@@ -5,7 +5,7 @@
import { platform, arch } from 'node:os'; import { platform, arch } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { existsSync, openSync, readSync, closeSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import type { FFmpeg } from './ffmpeg-addon'; import type { FFmpeg } from './ffmpeg-addon';
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface'; import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
@@ -87,22 +87,6 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
return addon.getDuration(filePath); return addon.getDuration(filePath);
} }
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/** /**
* 转换为 PCM * 转换为 PCM
*/ */
@@ -122,11 +106,6 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
await addon.decodeAudioToFmt(inputFile, outputFile, format); await addon.decodeAudioToFmt(inputFile, outputFile, format);
} }
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const addon = this.ensureAddon();
await addon.convertToNTSilkTct(inputFile, outputFile);
}
/** /**
* 提取缩略图 * 提取缩略图
*/ */

View File

@@ -70,6 +70,4 @@ export interface FFmpeg {
*/ */
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>; decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>; decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
} }

View File

@@ -3,7 +3,7 @@
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现 * 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
*/ */
import { readFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs'; import { readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
@@ -154,22 +154,6 @@ export class FFmpegExecAdapter implements IFFmpegAdapter {
} }
} }
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/** /**
* 转换为 PCM * 转换为 PCM
*/ */
@@ -257,8 +241,4 @@ export class FFmpegExecAdapter implements IFFmpegAdapter {
throw new Error(`提取缩略图失败: ${(error as Error).message}`); throw new Error(`提取缩略图失败: ${(error as Error).message}`);
} }
} }
async convertToNTSilkTct (_inputFile: string, _outputFile: string): Promise<void> {
throw new Error('convertToNTSilkTct is not implemented in FFmpegExecAdapter');
}
} }

View File

@@ -64,10 +64,7 @@ export class FFmpegService {
} }
return this.adapter; return this.adapter;
} }
public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.convertToNTSilkTct(inputFile, outputFile);
}
/** /**
* 设置 FFmpeg 路径并更新适配器 * 设置 FFmpeg 路径并更新适配器
* @deprecated 建议使用 init() 方法初始化 * @deprecated 建议使用 init() 方法初始化
@@ -95,27 +92,11 @@ export class FFmpegService {
/** /**
* 转换音频文件 * 转换音频文件
*/ */
public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise<void> { public static async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
const adapter = await this.getAdapter(); const adapter = await this.getAdapter();
await adapter.convertFile(inputFile, outputFile, format); await adapter.convertFile(inputFile, outputFile, format);
} }
/**
* 获取音频时长
*/
public static async getDuration (filePath: string): Promise<number> {
const adapter = await this.getAdapter();
return adapter.getDuration(filePath);
}
/**
* 判断是否为 Silk 格式
*/
public static async isSilk (filePath: string): Promise<boolean> {
const adapter = await this.getAdapter();
return adapter.isSilk(filePath);
}
/** /**
* 转换为 PCM 格式 * 转换为 PCM 格式
*/ */

View File

@@ -17,6 +17,7 @@ import {
WrapperSessionInitConfig, WrapperSessionInitConfig,
} from '@/napcat-core/wrapper'; } from '@/napcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log'; import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
import { NodeIKernelLoginService } from '@/napcat-core/services';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info'; import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { NapCatPathWrapper } from 'napcat-common/src/path'; import { NapCatPathWrapper } from 'napcat-common/src/path';
import path from 'node:path'; import path from 'node:path';
@@ -177,10 +178,8 @@ export class NapCatCore {
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => { msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
// 下线通知 // 下线通知
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`; this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
this.context.logger.logError(tips);
this.selfInfo.online = false; this.selfInfo.online = false;
this.event.emit('KickedOffLine', tips);
}; };
msgListener.onRecvMsg = (msgs) => { msgListener.onRecvMsg = (msgs) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo)); msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
@@ -279,6 +278,7 @@ export interface InstanceContext {
readonly wrapper: WrapperNodeApi; readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession; readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper; readonly logger: LogWrapper;
readonly loginService: NodeIKernelLoginService;
readonly basicInfoWrapper: QQBasicInfoWrapper; readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper; readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler; readonly packetHandler: NativePacketHandler;

View File

@@ -53,8 +53,6 @@ export class NodeIKernelLoginListener {
onLoginState (..._args: any[]): any { onLoginState (..._args: any[]): any {
} }
onLoginRecordUpdate (..._args: any[]): any {
}
} }
export interface QRCodeLoginSucceedResult { export interface QRCodeLoginSucceedResult {

View File

@@ -1,7 +1,6 @@
import { TypedEventEmitter } from './typeEvent'; import { TypedEventEmitter } from './typeEvent';
export interface AppEvents { export interface AppEvents {
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; }; 'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number };
KickedOffLine: string;
} }
export const appEvent = new TypedEventEmitter<AppEvents>(); export const appEvent = new TypedEventEmitter<AppEvents>();

View File

@@ -14,7 +14,7 @@ export class PacketMsgBuilder {
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] { buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => { return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`; const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=640&img_type=jpg`;
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => { const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
return acc ?? msg.buildContent(); return acc ?? msg.buildContent();
}, undefined); }, undefined);

View File

@@ -510,15 +510,15 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
} }
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] { override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
//return []; return [];
if (!this.msgInfo) return []; // if (!this.msgInfo) return [];
return [{ // return [{
commonElem: { // commonElem: {
serviceType: 48, // serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo), // pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: 22, // businessType: 22,
} // }
}]; // }];
} }
override toPreview (): string { override toPreview (): string {

View File

@@ -181,7 +181,7 @@ export interface MessageElement {
tofuRecordElement?: TofuRecordElement, tofuRecordElement?: TofuRecordElement,
taskTopMsgElement?: TaskTopMsgElement, taskTopMsgElement?: TaskTopMsgElement,
recommendedMsgElement?: RecommendedMsgElement, recommendedMsgElement?: RecommendedMsgElement,
actionBarElement?: ActionBarElement; actionBarElement?: ActionBarElement
} }
/** /**
@@ -337,7 +337,7 @@ export interface InlineKeyboardElementRowButton {
*/ */
export interface InlineKeyboardElement { export interface InlineKeyboardElement {
rows: [{ rows: [{
buttons: InlineKeyboardElementRowButton[]; buttons: InlineKeyboardElementRowButton[]
}], }],
botAppid: string; botAppid: string;
} }
@@ -441,14 +441,14 @@ export interface TipGroupElement {
uid: string; uid: string;
card: string; card: string;
name: string; name: string;
role: NTGroupMemberRole; role: NTGroupMemberRole
}; };
member: { member: {
uid: string; uid: string
card: string; card: string;
name: string; name: string;
role: NTGroupMemberRole; role: NTGroupMemberRole
}; }
}; };
} }
@@ -498,7 +498,6 @@ export interface RawMessage {
sendStatus?: SendStatusType;// 消息状态 sendStatus?: SendStatusType;// 消息状态
recallTime: string;// 撤回时间,"0" 是没有撤回 recallTime: string;// 撤回时间,"0" 是没有撤回
records: RawMessage[];// 消息记录 records: RawMessage[];// 消息记录
emojiLikesList?: Array<{ emojiId: string; emojiType: string; likesCnt: string; isClicked: string; }>;
elements: MessageElement[];// 消息元素 elements: MessageElement[];// 消息元素
sourceType: MsgSourceType;// 消息来源类型 sourceType: MsgSourceType;// 消息来源类型
isOnlineMsg: boolean;// 是否为在线消息 isOnlineMsg: boolean;// 是否为在线消息
@@ -509,9 +508,9 @@ export interface RawMessage {
* 查询消息参数接口 * 查询消息参数接口
*/ */
export interface QueryMsgsParams { export interface QueryMsgsParams {
chatInfo: Peer & { privilegeFlag?: number; }; chatInfo: Peer & { privilegeFlag?: number };
// searchFields: number; // searchFields: number;
filterMsgType: Array<{ type: NTMsgType, subType: Array<number>; }>; filterMsgType: Array<{ type: NTMsgType, subType: Array<number> }>;
filterSendersUid: string[]; filterSendersUid: string[];
filterMsgFromTime: string; filterMsgFromTime: string;
filterMsgToTime: string; filterMsgToTime: string;
@@ -555,7 +554,7 @@ export interface MsgReqType {
queryOrder: boolean, queryOrder: boolean,
includeSelf: boolean, includeSelf: boolean,
includeDeleteMsg: boolean, includeDeleteMsg: boolean,
extraCnt: number; extraCnt: number
} }
/** /**

View File

@@ -73,7 +73,7 @@ export interface WebApiGroupNoticeFeed {
fn: number; fn: number;
cn: number; cn: number;
vn: number; vn: number;
settings?: { settings: {
is_show_edit_card: number is_show_edit_card: number
remind_ts: number remind_ts: number
tip_window_type: number tip_window_type: number

View File

@@ -73,8 +73,6 @@ async function copyAll () {
process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = path.join(TARGET_DIR, 'package.json'); process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = path.join(TARGET_DIR, 'package.json');
process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = path.join(TARGET_DIR, 'config.json'); process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = path.join(TARGET_DIR, 'config.json');
process.env.NAPCAT_DISABLE_PIPE = '1'; process.env.NAPCAT_DISABLE_PIPE = '1';
// 禁用重启和多进程功能
process.env.NAPCAT_DISABLE_MULTI_PROCESS = '1';
process.env.NAPCAT_WORKDIR = TARGET_DIR; process.env.NAPCAT_WORKDIR = TARGET_DIR;
// 开发环境使用固定密钥 // 开发环境使用固定密钥
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key'; process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';

View File

@@ -34,9 +34,8 @@ export async function NCoreInitFramework (
}); });
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
await applyPendingUpdates(pathWrapper);
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
await applyPendingUpdates(pathWrapper, logger);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用 const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
@@ -73,7 +72,7 @@ export async function NCoreInitFramework (
// 过早进入会导致addKernelMsgListener等Listener添加失败 // 过早进入会导致addKernelMsgListener等Listener添加失败
// await sleep(2500); // await sleep(2500);
// 初始化 NapCatFramework // 初始化 NapCatFramework
const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler); const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
await loaderObject.core.initCore(); await loaderObject.core.initCore();
// 启动WebUi // 启动WebUi
@@ -94,6 +93,7 @@ export class NapCatFramework {
wrapper: WrapperNodeApi, wrapper: WrapperNodeApi,
session: NodeIQQNTWrapperSession, session: NodeIQQNTWrapperSession,
logger: LogWrapper, logger: LogWrapper,
loginService: NodeIKernelLoginService,
selfInfo: SelfInfo, selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper, basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper, pathWrapper: NapCatPathWrapper,
@@ -105,6 +105,7 @@ export class NapCatFramework {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
}; };

View File

@@ -8,6 +8,7 @@ import react from '@vitejs/plugin-react-swc';
import napcatVersion from 'napcat-vite/vite-plugin-version.js'; import napcatVersion from 'napcat-vite/vite-plugin-version.js';
// 依赖排除 // 依赖排除
const external = [ const external = [
'silk-wasm',
'ws', 'ws',
'express', 'express',
]; ];
@@ -59,6 +60,7 @@ const FrameworkBaseConfig = () =>
lib: { lib: {
entry: { entry: {
napcat: path.resolve(__dirname, 'napcat.ts'), napcat: path.resolve(__dirname, 'napcat.ts'),
'audio-worker': path.resolve(__dirname, '../napcat-common/src/audio-worker.ts'),
'worker/conoutSocketWorker': path.resolve(__dirname, '../napcat-pty/worker/conoutSocketWorker.ts'), 'worker/conoutSocketWorker': path.resolve(__dirname, '../napcat-pty/worker/conoutSocketWorker.ts'),
}, },
formats: ['es'], formats: ['es'],

View File

@@ -1,6 +1,7 @@
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile'; import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile';
import { ActionName } from '@/napcat-onebot/action/router'; import { ActionName } from '@/napcat-onebot/action/router';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { decode } from 'silk-wasm';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg'; import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac']; const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac'];
@@ -20,13 +21,19 @@ export default class GetRecord extends GetFileBase {
if (!out_format.includes(payload.out_format)) { if (!out_format.includes(payload.out_format)) {
throw new Error('转换失败 out_format 字段可能格式不正确'); throw new Error('转换失败 out_format 字段可能格式不正确');
} }
const pcmFile = `${inputFile}.pcm`;
const outputFile = `${inputFile}.${payload.out_format}`; const outputFile = `${inputFile}.${payload.out_format}`;
try { try {
await fs.access(inputFile); await fs.access(inputFile);
try { try {
await fs.access(outputFile); await fs.access(outputFile);
} catch { } catch {
await FFmpegService.convertAudioFmt(inputFile, outputFile, payload.out_format); if (FFmpegService.getAdapterName() === 'FFmpegAddon') {
await FFmpegService.convertFile(inputFile, outputFile, payload.out_format);
} else {
await this.decodeFile(inputFile, pcmFile);
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
}
} }
const base64Data = await fs.readFile(outputFile, { encoding: 'base64' }); const base64Data = await fs.readFile(outputFile, { encoding: 'base64' });
res.file = outputFile; res.file = outputFile;
@@ -39,4 +46,15 @@ export default class GetRecord extends GetFileBase {
} }
return res; return res;
} }
private async decodeFile (inputFile: string, outputFile: string): Promise<void> {
try {
const inputData = await fs.readFile(inputFile);
const decodedData = await decode(inputData, 24000);
await fs.writeFile(outputFile, Buffer.from(decodedData.data));
} catch (error) {
console.error('Error decoding file:', error);
throw error; // 重新抛出错误以便调用者可以处理
}
}
} }

View File

@@ -100,7 +100,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
// 3. 定义协议回退逻辑函数 // 3. 定义协议回退逻辑函数
const protocolFallbackLogic = async (resId: string) => { const protocolFallbackLogic = async (resId: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId), true))?.arrayMsg; const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
if (ob) { if (ob) {
return { return {
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content, messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content,
@@ -122,7 +122,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
if (rootMsg) { if (rootMsg) {
// 5. 获取消息内容 // 5. 获取消息内容
const data = await this.core.apis.MsgApi.getMsgHistory(rootMsg.Peer, rootMsg.MsgId, 1);//getMsgsIncludeSelf const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
if (data && data.result === 0 && data.msgList.length > 0) { if (data && data.result === 0 && data.msgList.length > 0) {
const singleMsg = data.msgList[0]; const singleMsg = data.msgList[0];

View File

@@ -12,7 +12,6 @@ const SchemaData = Type.Object({
name: Type.String(), name: Type.String(),
folder: Type.Optional(Type.String()), folder: Type.Optional(Type.String()),
folder_id: Type.Optional(Type.String()), // 临时扩展 folder_id: Type.Optional(Type.String()), // 临时扩展
upload_file: Type.Boolean({ default: true }),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@@ -42,7 +41,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, Uploa
peer, peer,
deleteAfterSentFiles: [], deleteAfterSentFiles: [],
}; };
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id, payload.upload_file); const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
msgContext.deleteAfterSentFiles.push(downloadResult.path); msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles); const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);

View File

@@ -11,7 +11,6 @@ const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]), user_id: Type.Union([Type.Number(), Type.String()]),
file: Type.String(), file: Type.String(),
name: Type.String(), name: Type.String(),
upload_file: Type.Boolean({ default: true }),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@@ -52,7 +51,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, Upl
}, ContextMode.Private), }, ContextMode.Private),
deleteAfterSentFiles: [], deleteAfterSentFiles: [],
}; };
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, '', payload.upload_file); const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
msgContext.deleteAfterSentFiles.push(downloadResult.path); msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles); const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);

View File

@@ -7,26 +7,19 @@ interface GroupNotice {
publish_time: number; publish_time: number;
notice_id: string; notice_id: string;
message: { message: {
text: string; text: string
// 保持一段时间兼容性 防止以往版本出现问题 后续版本可考虑移除 // 保持一段时间兼容性 防止以往版本出现问题 后续版本可考虑移除
image: Array<{ image: Array<{
height: string; height: string
width: string; width: string
id: string; id: string
}>, }>,
images: Array<{ images: Array<{
height: string; height: string
width: string; width: string
id: string; id: string
}>; }>
}; };
settings?: {
is_show_edit_card: number,
remind_ts: number,
tip_window_type: number,
confirm_required: number;
};
read_num?: number;
} }
const SchemaData = Type.Object({ const SchemaData = Type.Object({
@@ -66,8 +59,6 @@ export class GetGroupNotice extends OneBotAction<Payload, GroupNotice[]> {
image, image,
images: image, images: image,
}, },
settings: retApiNotice.settings,
read_num: retApiNotice.read_num
}; };
retNotices.push(retNotice); retNotices.push(retNotice);
} }

View File

@@ -67,8 +67,6 @@ import GoCQHTTPUploadPrivateFile from './go-cqhttp/UploadPrivateFile';
import { FetchEmojiLike } from './extends/FetchEmojiLike'; import { FetchEmojiLike } from './extends/FetchEmojiLike';
import { NapCatCore } from 'napcat-core'; import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import type { NetworkAdapterConfig } from '../config/config';
import { OneBotAction } from './OneBotAction';
import { SetInputStatus } from './extends/SetInputStatus'; import { SetInputStatus } from './extends/SetInputStatus';
import { GetCSRF } from './system/GetCSRF'; import { GetCSRF } from './system/GetCSRF';
import { DelGroupNotice } from './group/DelGroupNotice'; import { DelGroupNotice } from './group/DelGroupNotice';
@@ -88,7 +86,6 @@ import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl'; import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl';
import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus'; import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus';
import { GetCredentials } from './system/GetCredentials'; import { GetCredentials } from './system/GetCredentials';
import { SetRestart } from './system/SetRestart';
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign'; import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain'; import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely'; import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely';
@@ -269,7 +266,6 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
new GetGroupFileSystemInfo(obContext, core), new GetGroupFileSystemInfo(obContext, core),
new GetGroupFilesByFolder(obContext, core), new GetGroupFilesByFolder(obContext, core),
new GetPacketStatus(obContext, core), new GetPacketStatus(obContext, core),
new SetRestart(obContext, core),
new GroupPoke(obContext, core), new GroupPoke(obContext, core),
new FriendPoke(obContext, core), new FriendPoke(obContext, core),
new GetUserStatus(obContext, core), new GetUserStatus(obContext, core),
@@ -324,30 +320,6 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
function get<K extends keyof MapType> (key: K): MapType[K] | undefined { function get<K extends keyof MapType> (key: K): MapType[K] | undefined {
return _map.get(key as keyof MapType) as MapType[K] | undefined; return _map.get(key as keyof MapType) as MapType[K] | undefined;
} }
return { get };
/**
* 类型安全的 action 调用辅助函数
* 根据 action 名称自动推导返回类型
*/
async function call<K extends keyof MapType> (
actionName: K,
params: unknown,
adapter: string,
config: NetworkAdapterConfig
): Promise<MapType[K] extends OneBotAction<any, infer R> ? R : never> {
const action = _map.get(actionName);
if (!action) {
throw new Error(`Action ${String(actionName)} not found`);
}
const result = await (action as any).handle(params, adapter, config);
if (result.status !== 'ok' || !result.data) {
throw new Error(`Action ${String(actionName)} failed: ${result.message || 'No data returned'}`);
}
return result.data;
}
return { get, call };
} }
export type ActionMap = ReturnType<typeof createActionMap>; export type ActionMap = ReturnType<typeof createActionMap>;

View File

@@ -36,15 +36,6 @@ class GetMsg extends OneBotAction<Payload, OB11Message> {
if (!msg) throw Error('消息不存在'); if (!msg) throw Error('消息不存在');
const retMsg = await this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat); const retMsg = await this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat);
if (!retMsg) throw Error('消息为空'); if (!retMsg) throw Error('消息为空');
retMsg.emoji_likes_list = [];
msg.emojiLikesList?.map(emoji => {
retMsg.emoji_likes_list?.push({
emoji_id: emoji.emojiId,
emoji_type: emoji.emojiType,
likes_cnt: emoji.likesCnt,
});
});
// 烘焙emoji_likes_list 仅此处烘焙
try { try {
retMsg.message_id = MessageUnique.createUniqueMsgId(peer, msg.msgId)!; retMsg.message_id = MessageUnique.createUniqueMsgId(peer, msg.msgId)!;
retMsg.message_seq = retMsg.message_id; retMsg.message_seq = retMsg.message_id;

View File

@@ -317,11 +317,11 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const MixElement = sendElements.filter( const MixElement = sendElements.filter(
element => element =>
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK && element.elementType !== ElementType.PTT element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK
); );
const SingleElement = sendElements.filter( const SingleElement = sendElements.filter(
element => element =>
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK || element.elementType === ElementType.PTT element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK
).map(e => [e]); ).map(e => [e]);
const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0); const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0);

View File

@@ -81,7 +81,7 @@ export const ActionName = {
CanSendRecord: 'can_send_record', CanSendRecord: 'can_send_record',
GetStatus: 'get_status', GetStatus: 'get_status',
GetVersionInfo: 'get_version_info', GetVersionInfo: 'get_version_info',
Reboot: 'set_restart', // Reboot : 'set_restart',
CleanCache: 'clean_cache', CleanCache: 'clean_cache',
Exit: 'bot_exit', Exit: 'bot_exit',
// go-cqhttp // go-cqhttp

View File

@@ -4,6 +4,7 @@ import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config'; import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic'; import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs'; import fs from 'fs';
import { decode } from 'silk-wasm';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg'; import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream'; import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
@@ -37,6 +38,7 @@ export class DownloadFileRecordStream extends BaseDownloadStream<Payload, Downlo
throw new Error('转换失败 out_format 字段可能格式不正确'); throw new Error('转换失败 out_format 字段可能格式不正确');
} }
const pcmFile = `${downloadPath}.pcm`;
const outputFile = `${downloadPath}.${payload.out_format}`; const outputFile = `${downloadPath}.${payload.out_format}`;
try { try {
@@ -44,8 +46,13 @@ export class DownloadFileRecordStream extends BaseDownloadStream<Payload, Downlo
await fs.promises.access(outputFile); await fs.promises.access(outputFile);
streamPath = outputFile; streamPath = outputFile;
} catch { } catch {
// 尝试解码 amr 到 out format直接 ffmpeg 转换 // 尝试解码 silk 到 pcm 再用 ffmpeg 转换
await FFmpegService.convertAudioFmt(downloadPath, outputFile, payload.out_format); if (FFmpegService.getAdapterName() === 'FFmpegAddon') {
await FFmpegService.convertFile(downloadPath, outputFile, payload.out_format);
} else {
await this.decodeFile(downloadPath, pcmFile);
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
}
streamPath = outputFile; streamPath = outputFile;
} }
} }
@@ -75,4 +82,15 @@ export class DownloadFileRecordStream extends BaseDownloadStream<Payload, Downlo
throw new Error(`Download failed: ${(error as Error).message}`); throw new Error(`Download failed: ${(error as Error).message}`);
} }
} }
private async decodeFile (inputFile: string, outputFile: string): Promise<void> {
try {
const inputData = await fs.promises.readFile(inputFile);
const decodedData = await decode(inputData, 24000);
await fs.promises.writeFile(outputFile, Buffer.from(decodedData.data));
} catch (error) {
console.error('Error decoding file:', error);
throw error;
}
}
} }

View File

@@ -1,14 +0,0 @@
import { ActionName } from '@/napcat-onebot/action/router';
import { OneBotAction } from '../OneBotAction';
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
export class SetRestart extends OneBotAction<void, void> {
override actionName = ActionName.Reboot;
async _handle () {
const result = await WebUiDataRuntime.requestRestartProcess();
if (!result.result) {
throw new Error(result.message || '进程重启失败');
}
}
}

View File

@@ -19,18 +19,16 @@ export class OneBotFileApi {
this.core = core; this.core = core;
} }
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false): Promise<SendFileElement> { async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = ''): Promise<SendFileElement> {
const { const {
fileName: _fileName, fileName: _fileName,
path, path,
fileSize, fileSize,
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE, 0, uploadGroupFile); } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
if (fileSize === 0) { if (fileSize === 0) {
throw new Error('文件异常大小为0'); throw new Error('文件异常大小为0');
} }
if (uploadGroupFile) {
context.deleteAfterSentFiles.push(path); context.deleteAfterSentFiles.push(path);
}
return { return {
elementType: ElementType.FILE, elementType: ElementType.FILE,
elementId: '', elementId: '',

View File

@@ -19,7 +19,6 @@ import { OB11GroupCardEvent } from '@/napcat-onebot/event/notice/OB11GroupCardEv
import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent'; import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent';
import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent'; import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent';
import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent'; import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent';
import { OB11GroupGrayTipEvent } from '@/napcat-onebot/event/notice/OB11GroupGrayTipEvent';
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent'; import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent'; import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid'; import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid';
@@ -207,24 +206,15 @@ export class OneBotGroupApi {
} }
return undefined; return undefined;
} }
async parseOtherJsonEvent (msg: RawMessage, jsonGrayTipElement: GrayTipElement['jsonGrayTipElement'], context: InstanceContext) {
let json: { items?: { txt?: string; param?: string[] }[] }; async parseOtherJsonEvent (msg: RawMessage, jsonStr: string, context: InstanceContext) {
try { const json = JSON.parse(jsonStr);
json = JSON.parse(jsonGrayTipElement.jsonStr); const type = json.items[json.items.length - 1]?.txt;
} 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); await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') { if (type === '头衔') {
const memberUin = json.items?.[1]?.param?.[0]; const memberUin = json.items[1].param[0];
const title = json.items?.[3]?.txt; const title = json.items[3].txt;
context.logger.logDebug('收到群成员新头衔消息', json); context.logger.logDebug('收到群成员新头衔消息', json);
if (memberUin == null || title == null) {
context.logger.logWarn('收到格式异常的群成员新头衔灰条消息', json);
return undefined;
}
return new OB11GroupTitleEvent( return new OB11GroupTitleEvent(
this.core, this.core,
+msg.peerUid, +msg.peerUid,
@@ -235,27 +225,6 @@ export class OneBotGroupApi {
context.logger.logDebug('收到机器人被踢消息', json); context.logger.logDebug('收到机器人被踢消息', json);
} else { } else {
context.logger.logWarn('收到未知的灰条消息', json); 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; return undefined;
} }
@@ -407,7 +376,7 @@ export class OneBotGroupApi {
return await this.parse51TypeEvent(msg, grayTipElement); return await this.parse51TypeEvent(msg, grayTipElement);
} else { } else {
console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement)); console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement));
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement, this.core.context); return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
} }
} }
return undefined; return undefined;

View File

@@ -587,33 +587,15 @@ export class OneBotMsgApi {
return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || ''); return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || '');
}, },
[OB11MessageDataType.reply]: async ({ data: { id, seq } }, context) => { [OB11MessageDataType.reply]: async ({ data: { id } }) => {
let replyMsg: RawMessage | undefined;
let replyMsgPeer: Peer | undefined;
// 优先使用 seq
if (seq) {
const msgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
context.peer, seq.toString(), 1, true, true
)).msgList;
replyMsg = msgList[0];
replyMsgPeer = context.peer;
} else if (id) {
// 降级使用 id
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id)); const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
if (!replyMsgM) { if (!replyMsgM) {
this.core.context.logger.logWarn('回复消息不存在', id); this.core.context.logger.logWarn('回复消息不存在', id);
return undefined; return undefined;
} }
replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId( const replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(
replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0]; replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0];
replyMsgPeer = replyMsgM.Peer; return replyMsg
} else {
this.core.context.logger.logWarn('回复消息缺少id或seq参数');
return undefined;
}
return replyMsg && replyMsgPeer
? { ? {
elementType: ElementType.REPLY, elementType: ElementType.REPLY,
elementId: '', elementId: '',
@@ -623,7 +605,7 @@ export class OneBotMsgApi {
senderUin: replyMsg.senderUin, senderUin: replyMsg.senderUin,
senderUinStr: replyMsg.senderUin, senderUinStr: replyMsg.senderUin,
replyMsgClientSeq: replyMsg.clientSeq, replyMsgClientSeq: replyMsg.clientSeq,
_replyMsgPeer: replyMsgPeer, _replyMsgPeer: replyMsgM.Peer,
}, },
} }
: undefined; : undefined;
@@ -1002,20 +984,8 @@ export class OneBotMsgApi {
disableGetUrl: boolean = false, disableGetUrl: boolean = false,
quick_reply: boolean = false quick_reply: boolean = false
) { ) {
if ((msg.senderUin === '0' || msg.senderUin === '')) { if (msg.senderUin === '0' || msg.senderUin === '') return;
if (msg.senderUid && msg.senderUid !== '' && msg.senderUid !== '0') { if (msg.peerUin === '0' || msg.peerUin === '') return;
msg.senderUin = await this.core.apis.UserApi.getUinByUidV2(msg.senderUid);
} else {
return undefined;
}
}
if (msg.peerUin === '0' || msg.peerUin === '') {
if (msg.peerUid && msg.peerUid !== '' && msg.peerUid !== '0') {
msg.peerUin = await this.core.apis.UserApi.getUinByUidV2(msg.peerUid);
} else {
return undefined;
}
}
const resMsg = this.initializeMessage(msg); const resMsg = this.initializeMessage(msg);
@@ -1093,8 +1063,7 @@ export class OneBotMsgApi {
resMsg.sub_type = 'group'; resMsg.sub_type = 'group';
const ret = await this.core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, msg.senderUid); const ret = await this.core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, msg.senderUid);
if (ret.result === 0) { if (ret.result === 0) {
// 避免uin:'' uid非空uid一般不空 const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, await this.core.apis.UserApi.getUinByUidV2(msg.senderUid));
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode); resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话'; resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
resMsg.temp_source = 0; resMsg.temp_source = 0;

View File

@@ -6,7 +6,7 @@ const HttpServerConfigSchema = Type.Object({
port: Type.Number({ default: 3000 }), port: Type.Number({ default: 3000 }),
host: Type.String({ default: '127.0.0.1' }), host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }), enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: false }), enableWebsocket: Type.Boolean({ default: true }),
messagePostFormat: Type.String({ default: 'array' }), messagePostFormat: Type.String({ default: 'array' }),
token: Type.String({ default: '' }), token: Type.String({ default: '' }),
debug: Type.Boolean({ default: false }), debug: Type.Boolean({ default: false }),
@@ -18,7 +18,7 @@ const HttpSseServerConfigSchema = Type.Object({
port: Type.Number({ default: 3000 }), port: Type.Number({ default: 3000 }),
host: Type.String({ default: '127.0.0.1' }), host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }), enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: false }), enableWebsocket: Type.Boolean({ default: true }),
messagePostFormat: Type.String({ default: 'array' }), messagePostFormat: Type.String({ default: 'array' }),
token: Type.String({ default: '' }), token: Type.String({ default: '' }),
debug: Type.Boolean({ default: false }), debug: Type.Boolean({ default: false }),

View File

@@ -1,35 +0,0 @@
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

@@ -49,11 +49,10 @@ import {
OneBotConfigSchema, OneBotConfigSchema,
} from './config/config'; } from './config/config';
import { OB11Message } from './types'; import { OB11Message } from './types';
import { existsSync } from 'node:fs';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter'; import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import { OB11HttpSSEServerAdapter } from './network/http-server-sse'; import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
import { OB11PluginMangerAdapter } from './network/plugin-manger'; import { OB11PluginMangerAdapter } from './network/plugin-manger';
import { existsSync } from 'node:fs';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler'; import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { OneBotFileApi } from './api/file'; import { OneBotFileApi } from './api/file';
@@ -161,7 +160,6 @@ export class NapCatOneBot11Adapter {
// this.networkManager.registerAdapter( // this.networkManager.registerAdapter(
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions) // new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
// ); // );
// 检查插件目录是否存在,不存在则不加载插件管理器
if (existsSync(this.context.pathWrapper.pluginPath)) { if (existsSync(this.context.pathWrapper.pluginPath)) {
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件'); this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
this.networkManager.registerAdapter( this.networkManager.registerAdapter(
@@ -307,9 +305,6 @@ export class NapCatOneBot11Adapter {
}; };
msgListener.onRecvMsg = async (msg) => { msgListener.onRecvMsg = async (msg) => {
if (!this.networkManager.hasActiveAdapters()) {
return;
}
for (const m of msg) { for (const m of msg) {
if (this.bootTime > parseInt(m.msgTime)) { if (this.bootTime > parseInt(m.msgTime)) {
this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`); this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`);
@@ -389,7 +384,6 @@ export class NapCatOneBot11Adapter {
} }
}; };
msgListener.onKickedOffLine = async (kick) => { msgListener.onKickedOffLine = async (kick) => {
WebUiDataRuntime.setQQLoginStatus(false);
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc); const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
this.networkManager this.networkManager
.emitEvent(event) .emitEvent(event)
@@ -523,14 +517,15 @@ export class NapCatOneBot11Adapter {
} }
private async emitMsg (message: RawMessage) { private async emitMsg (message: RawMessage) {
const network = await this.networkManager.getAllConfig();
this.context.logger.logDebug('收到新消息 RawMessage', message); this.context.logger.logDebug('收到新消息 RawMessage', message);
await Promise.allSettled([ await Promise.allSettled([
this.handleMsg(message), this.handleMsg(message, network),
message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message), message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message),
]); ]);
} }
private async handleMsg (message: RawMessage) { private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
// 过滤无效消息 // 过滤无效消息
if (message.msgType === NTMsgType.KMSGTYPENULL) { if (message.msgType === NTMsgType.KMSGTYPENULL) {
return; return;
@@ -540,36 +535,10 @@ export class NapCatOneBot11Adapter {
if (ob11Msg) { if (ob11Msg) {
const isSelfMsg = this.isSelfMessage(ob11Msg); const isSelfMsg = this.isSelfMessage(ob11Msg);
this.context.logger.logDebug('转化为 OB11Message', ob11Msg); this.context.logger.logDebug('转化为 OB11Message', ob11Msg);
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) { const msgMap = this.createMsgMap(network, ob11Msg, isSelfMsg, message);
const targetId = parseInt(message.peerUin); this.handleDebugNetwork(network, msgMap, message);
ob11Msg.stringMsg.target_id = targetId; this.handleNotReportSelfNetwork(network, msgMap, isSelfMsg);
ob11Msg.arrayMsg.target_id = targetId;
}
const msgMap = new Map<string, OB11Message>();
for (const adapter of this.networkManager.adapters.values()) {
if (!adapter.isActive) continue;
const config = adapter.config;
if (isSelfMsg) {
if (!('reportSelfMessage' in config) || !config.reportSelfMessage) {
continue;
}
}
const msgData = config.messagePostFormat === 'string' ? ob11Msg.stringMsg : ob11Msg.arrayMsg;
if (config.debug) {
const clone = structuredClone(msgData);
clone.raw = message;
msgMap.set(adapter.name, clone);
} else {
msgMap.set(adapter.name, msgData);
}
}
if (msgMap.size > 0) {
this.networkManager.emitEventByNames(msgMap); this.networkManager.emitEventByNames(msgMap);
} else if (this.networkManager.hasActiveAdapters()) {
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
}
} }
} catch (e) { } catch (e) {
this.context.logger.logError('constructMessage error: ', e); this.context.logger.logError('constructMessage error: ', e);
@@ -584,6 +553,48 @@ export class NapCatOneBot11Adapter {
ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin; ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin;
} }
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
const msgMap: Map<string, OB11Message> = new Map();
network.filter(e => e.enable).forEach(e => {
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
ob11Msg.stringMsg.target_id = parseInt(message.peerUin);
ob11Msg.arrayMsg.target_id = parseInt(message.peerUin);
}
if ('messagePostFormat' in e && e.messagePostFormat === 'string') {
msgMap.set(e.name, structuredClone(ob11Msg.stringMsg));
} else {
msgMap.set(e.name, structuredClone(ob11Msg.arrayMsg));
}
});
return msgMap;
}
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
const debugNetwork = network.filter(e => e.enable && e.debug);
if (debugNetwork.length > 0) {
debugNetwork.forEach(adapter => {
const msg = msgMap.get(adapter.name);
if (msg) {
msg.raw = message;
}
});
} else if (msgMap.size === 0) {
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
}
}
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
if (isSelfMsg) {
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
notReportSelfNetwork.forEach(adapter => {
msgMap.delete(adapter.name);
});
}
}
private async handleGroupEvent (message: RawMessage) { private async handleGroupEvent (message: RawMessage) {
try { try {
// 群名片修改事件解析 任何都该判断 // 群名片修改事件解析 任何都该判断

View File

@@ -30,8 +30,4 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
abstract close (): void | Promise<void>; abstract close (): void | Promise<void>;
abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>; abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>;
get isActive (): boolean {
return this.isEnable;
}
} }

View File

@@ -5,10 +5,6 @@ import { OB11HttpServerAdapter } from './http-server';
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter { export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
private sseClients: Response[] = []; private sseClients: Response[] = [];
override get isActive (): boolean {
return this.isEnable && (this.sseClients.length > 0 || super.isActive);
}
override async handleRequest (req: Request, res: Response) { override async handleRequest (req: Request, res: Response) {
if (req.path === '/_events') { if (req.path === '/_events') {
this.createSseSupport(req, res); this.createSseSupport(req, res);
@@ -30,7 +26,6 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
} }
override async onEvent<T extends OB11EmitEventContent>(event: T) { override async onEvent<T extends OB11EmitEventContent>(event: T) {
super.onEvent(event);
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
this.sseClients.forEach((res) => { this.sseClients.forEach((res) => {
promises.push(new Promise<void>((resolve, reject) => { promises.push(new Promise<void>((resolve, reject) => {

View File

@@ -1,6 +1,6 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import express, { Express, NextFunction, Request, Response } from 'express'; import express, { Express, NextFunction, Request, Response } from 'express';
import http, { IncomingMessage } from 'http'; import http from 'http';
import { OB11Response } from '@/napcat-onebot/action/OneBotAction'; import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
import cors from 'cors'; import cors from 'cors';
import { HttpServerConfig } from '@/napcat-onebot/config/config'; import { HttpServerConfig } from '@/napcat-onebot/config/config';
@@ -8,41 +8,13 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import json5 from 'json5'; import json5 from 'json5';
import { isFinished } from 'on-finished'; import { isFinished } from 'on-finished';
import typeis from 'type-is'; import typeis from 'type-is';
import { WebSocket, WebSocketServer, RawData } from 'ws';
import { URL } from 'url';
import { ActionName } from '@/napcat-onebot/action/router';
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
import { Mutex } from 'async-mutex';
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> { export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined; private app: Express | undefined;
private server: http.Server | undefined; private server: http.Server | undefined;
private wsServer?: WebSocketServer;
private wsClients: WebSocket[] = [];
private wsClientsMutex = new Mutex();
private heartbeatIntervalId: NodeJS.Timeout | null = null;
private wsClientWithEvent: WebSocket[] = [];
override get isActive (): boolean { override async onEvent<T extends OB11EmitEventContent> (_event: T) {
return this.isEnable && this.wsClientWithEvent.length > 0;
}
override async onEvent<T extends OB11EmitEventContent> (event: T) {
// http server is passive, no need to emit event // http server is passive, no need to emit event
this.wsClientsMutex.runExclusive(async () => {
const promises = this.wsClientWithEvent.map((wsClient) => {
return new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(event));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
});
await Promise.allSettled(promises);
});
} }
open () { open () {
@@ -64,24 +36,11 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.isEnable = false; this.isEnable = false;
this.server?.close(); this.server?.close();
this.app = undefined; this.app = undefined;
this.stopHeartbeat();
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
});
this.wsClients = [];
this.wsClientWithEvent = [];
});
this.wsServer?.close();
} }
private initializeServer () { private initializeServer () {
this.app = express(); this.app = express();
this.server = http.createServer(this.app); this.server = http.createServer(this.app);
if (this.config.enableWebsocket) {
this.wsServer = new WebSocketServer({ server: this.server });
this.createWSServer(this.wsServer);
}
this.app.use(cors()); this.app.use(cors());
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' })); this.app.use(express.urlencoded({ extended: true, limit: '5000mb' }));
@@ -134,137 +93,6 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
} }
} }
createWSServer (newServer: WebSocketServer) {
newServer.on('connection', async (wsClient, wsReq) => {
if (!this.isEnable) {
wsClient.close();
return;
}
if (!this.authorizeWS(this.config.token, wsClient, wsReq)) {
return;
}
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
if (!isApiConnect) {
this.connectEvent(this.core, wsClient);
}
wsClient.on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Client Error:', err.message));
wsClient.on('message', (message) => {
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
});
wsClient.on('ping', () => {
wsClient.pong();
});
wsClient.on('pong', () => {
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
});
wsClient.once('close', () => {
this.wsClientsMutex.runExclusive(async () => {
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
});
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
}
connectEvent (core: any, wsClient: WebSocket) {
try {
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e));
} catch (e) {
this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e);
}
}
private startHeartbeat () {
if (this.heartbeatIntervalId) return;
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
}
});
});
}, 30000);
}
private stopHeartbeat () {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
private authorizeWS (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length === 0) return true;
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
const QueryClientToken = url.searchParams.get('access_token');
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
if (ClientToken === token) {
return true;
}
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
wsClient.close();
return false;
}
private async checkStateAndReply<T> (data: T, wsClient: WebSocket) {
return await new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(data));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
}
private async handleWSMessage (wsClient: WebSocket, message: RawData) {
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
let echo;
try {
receiveData = json5.parse(message.toString());
echo = receiveData.echo;
} catch {
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
return;
}
receiveData.params = (receiveData?.params) ? receiveData.params : {};
const action = this.actions.get(receiveData.action as any);
if (!action) {
this.logger.logError('[OneBot] [HTTP WebSocket] 发生错误', '不支持的API ' + receiveData.action);
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
return;
}
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
send: async (data: object) => {
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
},
});
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
}
async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) { async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) {
let payload = req.body; let payload = req.body;
if (req.method === 'get') { if (req.method === 'get') {
@@ -324,7 +152,6 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
async reload (newConfig: HttpServerConfig) { async reload (newConfig: HttpServerConfig) {
const wasEnabled = this.isEnable; const wasEnabled = this.isEnable;
const oldPort = this.config.port; const oldPort = this.config.port;
const oldEnableWebsocket = this.config.enableWebsocket;
this.config = newConfig; this.config = newConfig;
if (newConfig.enable && !wasEnabled) { if (newConfig.enable && !wasEnabled) {
@@ -335,7 +162,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
return OB11NetworkReloadType.NetWorkClose; return OB11NetworkReloadType.NetWorkClose;
} }
if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) { if (oldPort !== newConfig.port) {
this.close(); this.close();
if (newConfig.enable) { if (newConfig.enable) {
this.open(); this.open();

View File

@@ -21,7 +21,7 @@ export class OB11NetworkManager {
async emitEvent (event: OB11EmitEventContent) { async emitEvent (event: OB11EmitEventContent) {
return Promise.all(Array.from(this.adapters.values()).map(async adapter => { return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
if (adapter.isActive) { if (adapter.isEnable) {
return await adapter.onEvent(event); return await adapter.onEvent(event);
} }
})); }));
@@ -34,7 +34,7 @@ export class OB11NetworkManager {
async emitEventByName (names: string[], event: OB11EmitEventContent) { async emitEventByName (names: string[], event: OB11EmitEventContent) {
return Promise.all(names.map(async name => { return Promise.all(names.map(async name => {
const adapter = this.adapters.get(name); const adapter = this.adapters.get(name);
if (adapter && adapter.isActive) { if (adapter && adapter.isEnable) {
return await adapter.onEvent(event); return await adapter.onEvent(event);
} }
})); }));
@@ -43,7 +43,7 @@ export class OB11NetworkManager {
async emitEventByNames (map: Map<string, OB11EmitEventContent>) { async emitEventByNames (map: Map<string, OB11EmitEventContent>) {
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => { return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
const adapter = this.adapters.get(name); const adapter = this.adapters.get(name);
if (adapter && adapter.isActive) { if (adapter && adapter.isEnable) {
return await adapter.onEvent(event); return await adapter.onEvent(event);
} }
})); }));
@@ -99,10 +99,6 @@ export class OB11NetworkManager {
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config))); await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config)));
} }
hasActiveAdapters (): boolean {
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
}
async getAllConfig () { async getAllConfig () {
return Array.from(this.adapters.values()).map(adapter => adapter.config); return Array.from(this.adapters.values()).map(adapter => adapter.config);
} }

View File

@@ -1,9 +1,9 @@
import { ActionMap } from '../action';
import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter'; import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { NapCatCore } from 'napcat-core';
import { PluginConfig } from '../config/config'; import { PluginConfig } from '../config/config';
import { ActionMap } from '../action';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -11,39 +11,13 @@ export interface PluginPackageJson {
name?: string; name?: string;
version?: string; version?: string;
main?: string; main?: string;
description?: string;
author?: string;
} }
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> { export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
plugin_init: ( plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
core: NapCatCore, plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
obContext: NapCatOneBot11Adapter, plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
actions: ActionMap, plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_onmessage?: (
adapter: string,
core: NapCatCore,
obCtx: NapCatOneBot11Adapter,
event: OB11Message,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_onevent?: (
adapter: string,
core: NapCatCore,
obCtx: NapCatOneBot11Adapter,
event: T,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_cleanup?: (
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
} }
export interface LoadedPlugin { export interface LoadedPlugin {
@@ -55,25 +29,12 @@ export interface LoadedPlugin {
module: PluginModule; module: PluginModule;
} }
export interface PluginStatusConfig {
[key: string]: boolean; // key: pluginName, value: enabled
}
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> { export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string; private readonly pluginPath: string;
private readonly configPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map(); private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig; declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor ( constructor (
name: string, name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap
) { ) {
const config = { const config = {
name, name,
@@ -84,26 +45,6 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}; };
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
this.pluginPath = this.core.context.pathWrapper.pluginPath; this.pluginPath = this.core.context.pathWrapper.pluginPath;
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
}
private loadPluginConfig (): PluginStatusConfig {
if (fs.existsSync(this.configPath)) {
try {
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
} catch (e) {
this.logger.logWarn('[Plugin Adapter] Error parsing plugins.json', e);
}
}
return {};
}
private savePluginConfig (config: PluginStatusConfig) {
try {
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (e) {
this.logger.logError('[Plugin Adapter] Error saving plugins.json', e);
}
} }
/** /**
@@ -113,31 +54,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
try { try {
// 确保插件目录存在 // 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) { if (!fs.existsSync(this.pluginPath)) {
this.logger.logWarn( this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`);
`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`
);
fs.mkdirSync(this.pluginPath, { recursive: true }); fs.mkdirSync(this.pluginPath, { recursive: true });
return; return;
} }
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
const pluginConfig = this.loadPluginConfig();
// 扫描文件和目录 // 扫描文件和目录
for (const item of items) { for (const item of items) {
let pluginName = '';
if (item.isFile()) {
pluginName = path.parse(item.name).name;
} else if (item.isDirectory()) {
pluginName = item.name;
}
// Check if plugin is disabled in config
if (pluginConfig[pluginName] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled in config, skipping`);
continue;
}
if (item.isFile()) { if (item.isFile()) {
// 处理单文件插件 // 处理单文件插件
await this.loadFilePlugin(item.name); await this.loadFilePlugin(item.name);
@@ -147,9 +72,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
} }
} }
this.logger.log( this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`);
`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`
);
} catch (error) { } catch (error) {
this.logger.logError('[Plugin Adapter] Error loading plugins:', error); this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
} }
@@ -158,7 +81,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
/** /**
* 加载单文件插件 (.mjs, .js) * 加载单文件插件 (.mjs, .js)
*/ */
public async loadFilePlugin (filename: string): Promise<void> { private async loadFilePlugin (filename: string): Promise<void> {
// 只处理支持的文件类型 // 只处理支持的文件类型
if (!this.isSupportedFile(filename)) { if (!this.isSupportedFile(filename)) {
return; return;
@@ -166,20 +89,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const filePath = path.join(this.pluginPath, filename); const filePath = path.join(this.pluginPath, filename);
const pluginName = path.parse(filename).name; const pluginName = path.parse(filename).name;
const pluginConfig = this.loadPluginConfig();
// Check if plugin is disabled in config
if (pluginConfig[pluginName] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled by user`);
return;
}
try { try {
const module = await this.importModule(filePath); const module = await this.importModule(filePath);
if (!this.isValidPluginModule(module)) { if (!this.isValidPluginModule(module)) {
this.logger.logWarn( this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`);
`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`
);
return; return;
} }
@@ -192,31 +106,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.registerPlugin(plugin); await this.registerPlugin(plugin);
} catch (error) { } catch (error) {
this.logger.logError( this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error);
`[Plugin Adapter] Error loading file plugin ${filename}:`,
error
);
} }
} }
/** /**
* 加载目录插件 * 加载目录插件
*/ */
public async loadDirectoryPlugin (dirname: string): Promise<void> { private async loadDirectoryPlugin (dirname: string): Promise<void> {
const pluginDir = path.join(this.pluginPath, dirname); const pluginDir = path.join(this.pluginPath, dirname);
const pluginConfig = this.loadPluginConfig();
// Ideally we'd get the name from package.json first, but we can use dirname as a fallback identifier initially.
// However, the list scan uses item.name (dirname) as the key. Let's stick to using dirname/filename as the config key for simplicity and consistency.
// Wait, package.json name might override. But for management, consistent ID is better.
// Let's check config after parsing package.json?
// User expects to disable 'plugin-name'. But if multiple folders have same name? Not handled.
// Let's use dirname as the key for config to be consistent with file system.
if (pluginConfig[dirname] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${dirname} is disabled by user`);
return;
}
try { try {
// 尝试读取 package.json // 尝试读取 package.json
@@ -228,22 +126,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8'); const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent); packageJson = JSON.parse(packageContent);
} catch (error) { } catch (error) {
this.logger.logWarn( this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error);
`[Plugin Adapter] Invalid package.json in ${dirname}:`,
error
);
} }
} }
// Check if disabled by package name IF package.json exists?
// No, file system name is more reliable ID for resource management here.
// 确定入口文件 // 确定入口文件
const entryFile = this.findEntryFile(pluginDir, packageJson); const entryFile = this.findEntryFile(pluginDir, packageJson);
if (!entryFile) { if (!entryFile) {
this.logger.logWarn( this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`);
`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`
);
return; return;
} }
@@ -251,9 +141,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const module = await this.importModule(entryPath); const module = await this.importModule(entryPath);
if (!this.isValidPluginModule(module)) { if (!this.isValidPluginModule(module)) {
this.logger.logWarn( this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`);
`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`
);
return; return;
} }
@@ -268,20 +156,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.registerPlugin(plugin); await this.registerPlugin(plugin);
} catch (error) { } catch (error) {
this.logger.logError( this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error);
`[Plugin Adapter] Error loading directory plugin ${dirname}:`,
error
);
} }
} }
/** /**
* 查找插件目录的入口文件 * 查找插件目录的入口文件
*/ */
private findEntryFile ( private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
pluginDir: string,
packageJson?: PluginPackageJson
): string | null {
// 优先级package.json main > 默认文件名 // 优先级package.json main > 默认文件名
const possibleEntries = [ const possibleEntries = [
packageJson?.main, packageJson?.main,
@@ -314,10 +196,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
*/ */
private async importModule (filePath: string): Promise<any> { private async importModule (filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`; const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
// Add timestamp to force reload cache if supported or just import return await import(fileUrl);
// Note: dynamic import caching is tricky in ESM. Adding query param might help?
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
return await import(fileUrlWithQuery);
} }
/** /**
@@ -333,32 +212,19 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private async registerPlugin (plugin: LoadedPlugin): Promise<void> { private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突 // 检查名称冲突
if (this.loadedPlugins.has(plugin.name)) { if (this.loadedPlugins.has(plugin.name)) {
this.logger.logWarn( this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`);
`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`
);
return; return;
} }
this.loadedPlugins.set(plugin.name, plugin); this.loadedPlugins.set(plugin.name, plugin);
this.logger.log( this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`);
`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''
}`
);
// 调用插件初始化方法(必须存在) // 调用插件初始化方法(必须存在)
try { try {
await plugin.module.plugin_init( await plugin.module.plugin_init(this.core, this.obContext, this.actions, this);
this.core,
this.obContext,
this.actions,
this
);
this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`); this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
} catch (error) { } catch (error) {
this.logger.logError( this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error);
`[Plugin Adapter] Error initializing plugin ${plugin.name}:`,
error
);
} }
} }
@@ -374,18 +240,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 调用插件清理方法 // 调用插件清理方法
if (typeof plugin.module.plugin_cleanup === 'function') { if (typeof plugin.module.plugin_cleanup === 'function') {
try { try {
await plugin.module.plugin_cleanup( await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this);
this.core,
this.obContext,
this.actions,
this
);
this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`); this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
} catch (error) { } catch (error) {
this.logger.logError( this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error);
`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`,
error
);
} }
} }
@@ -393,69 +251,6 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
} }
public async unregisterPlugin (pluginName: string): Promise<void> {
return this.unloadPlugin(pluginName);
}
public getPluginPath (): string {
return this.pluginPath;
}
public getPluginConfig (): PluginStatusConfig {
return this.loadPluginConfig();
}
public setPluginStatus (pluginName: string, enable: boolean): void {
const config = this.loadPluginConfig();
config[pluginName] = enable;
this.savePluginConfig(config);
// If disabling, unload immediately if loaded
if (!enable) {
// Note: pluginName passed here might be the package name or the filename/dirname
// But our registerPlugin uses plugin.name which comes from package.json or dirname.
// This mismatch is tricky.
// Ideally, we should use a consistent ID.
// Let's assume pluginName passed here effectively matches the ID used in loadedPlugins.
// But wait, loadDirectoryPlugin logic: name = packageJson.name || dirname.
// config key = dirname.
// If packageJson.name != dirname, we have a problem.
// To fix this properly:
// 1. We need to know which LoadedPlugin corresponds to the enabled/disabled item.
// 2. Or we iterate loadedPlugins and find match.
for (const [_, loaded] of this.loadedPlugins.entries()) {
const dirOrFile = path.basename(loaded.pluginPath === this.pluginPath ? loaded.entryPath : loaded.pluginPath);
const ext = path.extname(dirOrFile);
const simpleName = ext ? path.parse(dirOrFile).name : dirOrFile; // filename without ext
// But wait, config key is the FILENAME (with ext for files?).
// In Scan loop:
// pluginName = path.parse(item.name).name (for file)
// pluginName = item.name (for dir)
// config[pluginName] check.
// So if file is "test.js", pluginName is "test". Config key "test".
// If dir is "test-plugin", pluginName is "test-plugin". Config key "test-plugin".
// loadedPlugin.name might be distinct.
// So we need to match loadedPlugin back to its fs source to unload it?
// loadedPlugin.entryPath or pluginPath helps.
// If it's a file plugin: loaded.entryPath ends with pluginName + ext.
// If it's a dir plugin: loaded.pluginPath ends with pluginName.
if (pluginName === simpleName) {
this.unloadPlugin(loaded.name).catch(e => this.logger.logError('Error unloading', e));
}
}
}
// If enabling, we need to load it.
// But we can just rely on the API handler to call loadFile/DirectoryPlugin which now checks config.
// Wait, if I call loadFilePlugin("test.js") and config says enable=true, it loads.
// API handler needs to change to pass filename/dirname.
}
async onEvent<T extends OB11EmitEventContent>(event: T) { async onEvent<T extends OB11EmitEventContent>(event: T) {
if (!this.isEnable) { if (!this.isEnable) {
return; return;
@@ -476,42 +271,19 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
/** /**
* 调用插件的事件处理方法 * 调用插件的事件处理方法
*/ */
private async callPluginEventHandler ( private async callPluginEventHandler (plugin: LoadedPlugin, event: OB11EmitEventContent): Promise<void> {
plugin: LoadedPlugin,
event: OB11EmitEventContent
): Promise<void> {
try { try {
// 优先使用 plugin_onevent 方法 // 优先使用 plugin_onevent 方法
if (typeof plugin.module.plugin_onevent === 'function') { if (typeof plugin.module.plugin_onevent === 'function') {
await plugin.module.plugin_onevent( await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this);
this.name,
this.core,
this.obContext,
event,
this.actions,
this
);
} }
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用 // 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
if ( if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') {
(event as any).message_type && await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this);
typeof plugin.module.plugin_onmessage === 'function'
) {
await plugin.module.plugin_onmessage(
this.name,
this.core,
this.obContext,
event as OB11Message,
this.actions,
this
);
} }
} catch (error) { } catch (error) {
this.logger.logError( this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error);
`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`,
error
);
} }
} }
@@ -526,9 +298,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 加载所有插件 // 加载所有插件
await this.loadPlugins(); await this.loadPlugins();
this.logger.log( this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`);
`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`
);
} }
async close () { async close () {
@@ -588,10 +358,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.unloadPlugin(pluginName); await this.unloadPlugin(pluginName);
// 重新加载插件 // 重新加载插件
// Use logic to re-determine if it is directory or file based on original paths const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
// Note: we can't fully trust fs status if it's gone. plugin.pluginPath !== this.pluginPath;
const isDirectory =
plugin.pluginPath !== this.pluginPath; // Simple check: if path is nested, it's a dir plugin
if (isDirectory) { if (isDirectory) {
const dirname = path.basename(plugin.pluginPath); const dirname = path.basename(plugin.pluginPath);
@@ -601,15 +369,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.loadFilePlugin(filename); await this.loadFilePlugin(filename);
} }
this.logger.log( this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`);
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
);
return true; return true;
} catch (error) { } catch (error) {
this.logger.logError( this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error);
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
error
);
return false; return false;
} }
} }

View File

@@ -33,10 +33,6 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string; private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map(); private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig; declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor ( constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) { ) {

View File

@@ -13,10 +13,6 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
private connection: WebSocket | null = null; private connection: WebSocket | null = null;
private heartbeatRef: NodeJS.Timeout | null = null; private heartbeatRef: NodeJS.Timeout | null = null;
override get isActive (): boolean {
return this.isEnable && !!this.connection && this.connection.readyState === WebSocket.OPEN;
}
async onEvent<T extends OB11EmitEventContent> (event: T) { async onEvent<T extends OB11EmitEventContent> (event: T) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) { if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(event)); this.connection.send(JSON.stringify(event));

View File

@@ -21,10 +21,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
private heartbeatIntervalId: NodeJS.Timeout | null = null; private heartbeatIntervalId: NodeJS.Timeout | null = null;
wsClientWithEvent: WebSocket[] = []; wsClientWithEvent: WebSocket[] = [];
override get isActive (): boolean {
return this.isEnable && this.wsClientWithEvent.length > 0;
}
constructor ( constructor (
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) { ) {
@@ -74,9 +70,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
if (EventIndex !== -1) { if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1); this.wsClientWithEvent.splice(EventIndex, 1);
} }
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
}); });
}); });
await this.wsClientsMutex.runExclusive(async () => { await this.wsClientsMutex.runExclusive(async () => {
@@ -84,9 +77,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.wsClientWithEvent.push(wsClient); this.wsClientWithEvent.push(wsClient);
} }
this.wsClients.push(wsClient); this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
}); });
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message)); }).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
} }
@@ -124,6 +114,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port); this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
this.isEnable = true; this.isEnable = true;
if (this.config.heartInterval > 0) {
this.registerHeartBeat();
}
} }
async close () { async close () {
@@ -135,7 +128,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Closed'); this.logger.log('[OneBot] [WebSocket Server] Server Closed');
} }
}); });
this.stopHeartbeat(); if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
await this.wsClientsMutex.runExclusive(async () => { await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => { this.wsClients.forEach((wsClient) => {
wsClient.close(); wsClient.close();
@@ -145,8 +141,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}); });
} }
private startHeartbeat () { private registerHeartBeat () {
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
this.heartbeatIntervalId = setInterval(() => { this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => { this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => { this.wsClientWithEvent.forEach((wsClient) => {
@@ -158,13 +153,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}, this.config.heartInterval); }, this.config.heartInterval);
} }
private stopHeartbeat () {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) { private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length === 0) return true;// 客户端未设置密钥 if (!token || token.length === 0) return true;// 客户端未设置密钥
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`); const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
@@ -247,9 +235,12 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
} }
if (oldHeartbeatInterval !== newConfig.heartInterval) { if (oldHeartbeatInterval !== newConfig.heartInterval) {
this.stopHeartbeat(); if (this.heartbeatIntervalId) {
if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) { clearInterval(this.heartbeatIntervalId);
this.startHeartbeat(); this.heartbeatIntervalId = null;
}
if (newConfig.heartInterval > 0 && this.isEnable) {
this.registerHeartBeat();
} }
return OB11NetworkReloadType.NetWorkReload; return OB11NetworkReloadType.NetWorkReload;
} }

View File

@@ -31,7 +31,6 @@ export interface OB11Message {
font: number; font: number;
post_type?: EventType; post_type?: EventType;
raw?: RawMessage; raw?: RawMessage;
emoji_likes_list?: Array<{ emoji_id: string; emoji_type: string; likes_cnt: string; }>;// 仅get_msg生效
} }
// 合并转发消息接口定义 // 合并转发消息接口定义
@@ -159,8 +158,7 @@ export interface OB11MessageAt {
export interface OB11MessageReply { export interface OB11MessageReply {
type: OB11MessageDataType.reply; type: OB11MessageDataType.reply;
data: { data: {
id?: string; // msg_id 的短ID映射 id: string;
seq?: number; // msg_seq优先使用
}; };
} }
@@ -188,7 +186,7 @@ export interface OB11MessageNode {
name?: string; // compatible with go-cqhttp name?: string; // compatible with go-cqhttp
content: OB11MessageMixType; content: OB11MessageMixType;
source?: string; source?: string;
news?: { text: string; }[]; news?: { text: string }[];
summary?: string; summary?: string;
prompt?: string; prompt?: string;
time?: string; time?: string;
@@ -212,13 +210,13 @@ export interface OB11MessageIdMusic {
// 自定义音乐消息接口定义 // 自定义音乐消息接口定义
export interface OB11MessageCustomMusic { export interface OB11MessageCustomMusic {
type: OB11MessageDataType.music; type: OB11MessageDataType.music;
data: Omit<CustomMusicSignPostData, 'singer'> & { content?: string; }; data: Omit<CustomMusicSignPostData, 'singer'> & { content?: string };
} }
// JSON消息接口定义 // JSON消息接口定义
export interface OB11MessageJson { export interface OB11MessageJson {
type: OB11MessageDataType.json; type: OB11MessageDataType.json;
data: { config?: { token: string; }, data: string | object; }; data: { config?: { token: string }, data: string | object };
} }
// 骰子消息接口定义 // 骰子消息接口定义
@@ -272,7 +270,7 @@ export interface OB11PostSendMsg {
messages?: OB11MessageMixType; messages?: OB11MessageMixType;
auto_escape?: boolean | string; auto_escape?: boolean | string;
source?: string; source?: string;
news?: { text: string; }[]; news?: { text: string }[];
summary?: string; summary?: string;
prompt?: string; prompt?: string;
time?: string; time?: string;

View File

@@ -1,84 +0,0 @@
import type { ActionMap } from 'napcat-onebot/action';
import { EventType } from 'napcat-onebot/event/OneBotEvent';
import type { PluginModule } from 'napcat-onebot/network/plugin';
import type { OB11Message, OB11PostSendMsg } from 'napcat-onebot/types/message';
let actions: ActionMap | undefined = undefined;
/**
* 插件初始化
*/
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: builtin] NapCat 内置插件已初始化');
actions = _actions;
};
/**
* 消息处理
* 当收到包含 #napcat 的消息时,回复版本信息
*/
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) {
return;
}
try {
const versionInfo = await getVersionInfo(adapter, instance.config);
if (!versionInfo) return;
const message = formatVersionMessage(versionInfo);
await sendMessage(event, message, adapter, instance.config);
console.log('[Plugin: builtin] 已回复版本信息');
} catch (error) {
console.error('[Plugin: builtin] 处理消息时发生错误:', error);
}
};
/**
* 获取版本信息(完美的类型推导,无需 as 断言)
*/
async function getVersionInfo (adapter: string, config: any) {
if (!actions) return null;
try {
const data = await actions.call('get_version_info', void 0, adapter, config);
return {
appName: data.app_name,
appVersion: data.app_version,
protocolVersion: data.protocol_version,
};
} catch (error) {
console.error('[Plugin: builtin] 获取版本信息失败:', error);
return null;
}
}
/**
* 格式化版本信息消息
*/
function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) {
return `NapCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}`;
}
/**
* 发送消息(完美的类型推导)
*/
async function sendMessage (event: OB11Message, message: string, adapter: string, config: any) {
if (!actions) return;
const params: OB11PostSendMsg = {
message,
message_type: event.message_type,
...(event.message_type === 'group' && event.group_id ? { group_id: String(event.group_id) } : {}),
...(event.message_type === 'private' && event.user_id ? { user_id: String(event.user_id) } : {}),
};
try {
await actions.call('send_msg', params, adapter, config);
} catch (error) {
console.error('[Plugin: builtin] 发送消息失败:', error);
}
}
export { plugin_init, plugin_onmessage };

View File

@@ -1,17 +0,0 @@
{
"name": "napcat-plugin-builtin",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",
"description": "NapCat 内置插件",
"author": "NapNeko",
"dependencies": {
"napcat-onebot": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"scripts": {
"build": "vite build"
}
}

View File

@@ -1,11 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"*.ts",
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -1,77 +0,0 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
import fs from 'fs';
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
// 构建后拷贝插件
function copyToShellPlugin () {
return {
name: 'copy-to-shell',
closeBundle () {
try {
const sourceDir = resolve(__dirname, 'dist');
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin');
const packageJsonSource = resolve(__dirname, 'package.json');
// 确保目标目录存在
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
console.log(`[copy-to-shell] Created directory: ${targetDir}`);
}
// 拷贝 dist 目录下的所有文件
const files = fs.readdirSync(sourceDir);
let copiedCount = 0;
files.forEach(file => {
const sourcePath = resolve(sourceDir, file);
const targetPath = resolve(targetDir, file);
if (fs.statSync(sourcePath).isFile()) {
fs.copyFileSync(sourcePath, targetPath);
copiedCount++;
}
});
// 拷贝 package.json
if (fs.existsSync(packageJsonSource)) {
const packageJsonTarget = resolve(targetDir, 'package.json');
fs.copyFileSync(packageJsonSource, packageJsonTarget);
copiedCount++;
}
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
} catch (error) {
console.error('[copy-to-shell] Failed to copy files:', error);
throw error;
}
},
};
}
export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@': resolve(__dirname, '../'),
},
},
build: {
sourcemap: false,
target: 'esnext',
minify: false,
lib: {
entry: 'index.ts',
formats: ['es'],
fileName: () => 'index.mjs',
},
rollupOptions: {
external: [...nodeModules],
},
},
plugins: [nodeResolve(), copyToShellPlugin()],
});

View File

@@ -1,22 +1,12 @@
import type { createActionMap } from 'napcat-onebot/action';
import { EventType } from 'napcat-onebot/event/OneBotEvent'; import { EventType } from 'napcat-onebot/event/OneBotEvent';
import type { PluginModule } from 'napcat-onebot/network/plugin'; import type { PluginModule } from 'napcat-onebot/network/plugin';
/**
* 导入 napcat 包时候不使用 @/napcat...,直接使用 napcat...
* 因为 @/napcat... 会导致打包时包含整个 napcat 包,而不是只包含需要的部分
*/
// action 作为参数传递时请用这个
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => { const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: example] 插件已初始化'); console.log('[Plugin: example] 插件已初始化');
actionMap = _actions;
}; };
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => { const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) { if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config); await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
} }
}; };
export { plugin_init, plugin_onmessage, actionMap }; export { plugin_init, plugin_onmessage };

View File

@@ -9,7 +9,7 @@ export default defineConfig({
resolve: { resolve: {
conditions: ['node', 'default'], conditions: ['node', 'default'],
alias: { alias: {
'@/napcat-core': resolve(__dirname, '../napcat-core'), '@/napcat-core': resolve(__dirname, '../core'),
'@': resolve(__dirname, '../'), '@': resolve(__dirname, '../'),
}, },
}, },

View File

@@ -1,5 +1,5 @@
@echo off @echo off
chcp 65001 >nul chcp 65001
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
@@ -27,6 +27,6 @@ if not exist "%QQpath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
pause pause

View File

@@ -1,5 +1,5 @@
@echo off @echo off
chcp 65001 >nul chcp 65001
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
@@ -26,9 +26,8 @@ if not exist "%QQpath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
pause pause

View File

@@ -1,11 +1,11 @@
@echo off @echo off
chcp 65001 >nul chcp 65001
net session >nul 2>&1 net session >nul 2>&1
if %ERRORLEVEL% == 0 ( if %errorLevel% == 0 (
echo Administrator mode detected. echo Administrator mode detected.
) else ( ) else (
echo Please run this script in administrator mode. 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\" %1' -Verb runAs"
exit exit
) )
@@ -35,7 +35,6 @@ if not exist "%QQPath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456

View File

@@ -1,11 +1,11 @@
@echo off @echo off
chcp 65001 >nul chcp 65001
net session >nul 2>&1 net session >nul 2>&1
if %ERRORLEVEL% == 0 ( if %errorLevel% == 0 (
echo Administrator mode detected. echo Administrator mode detected.
) else ( ) else (
echo Please run this script in administrator mode. echo Please run this script in administrator mode.
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs" powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
exit exit
) )
@@ -36,4 +36,4 @@ if not exist "%QQPath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1

View File

@@ -1,7 +1,4 @@
@echo off @echo off
REM 快速登录示例脚本 REM ./launcher.bat 123456
REM -q 参数是可选的,不传则使用二维码登录 REM ./launcher-win10.bat 123456
REM REM 带有REM的为注释 删掉你需要的系统的那行REM这三个单词 修改QQ本脚本启动即可
REM 使用方法(删掉对应系统那行的 REM
REM ./launcher-user.bat 123456
REM ./launcher-win10-user.bat 123456

View File

@@ -29,6 +29,7 @@ import { napCatVersion } from 'napcat-common/src/version';
import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener'; import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener';
import { sleep } from 'napcat-common/src/helper'; import { sleep } from 'napcat-common/src/helper';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg'; import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { connectToNamedPipe } from './pipe';
import { NativePacketHandler } from 'napcat-core/packet/handler/client'; import { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log'; import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler'; import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
@@ -127,13 +128,10 @@ async function handleLogin (
const loginListener = new NodeIKernelLoginListener(); const loginListener = new NodeIKernelLoginListener();
loginListener.onUserLoggedIn = (userid: string) => { loginListener.onUserLoggedIn = (userid: string) => {
const tips = `当前账号(${userid})已登录,无法重复登录`; logger.logError(`当前账号(${userid})已登录,无法重复登录`);
logger.logError(tips);
WebUiDataRuntime.setQQLoginError(tips);
}; };
loginListener.onQRCodeLoginSucceed = async (loginResult) => { loginListener.onQRCodeLoginSucceed = async (loginResult) => {
context.isLogined = true; context.isLogined = true;
WebUiDataRuntime.setQQLoginStatus(true);
inner_resolve({ inner_resolve({
uid: loginResult.uid, uid: loginResult.uid,
uin: loginResult.uin, uin: loginResult.uin,
@@ -172,16 +170,13 @@ async function handleLogin (
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode); logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType === 1 && errCode === 3) { if (errType === 1 && errCode === 3) {
// 二维码过期刷新 // 二维码过期刷新
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
} }
loginService.getQRCodePicture(); loginService.getQRCodePicture();
} }
}; };
loginListener.onLoginFailed = (...args) => { loginListener.onLoginFailed = (...args) => {
const errInfo = JSON.stringify(args); logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
}; };
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger)); loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
@@ -189,29 +184,17 @@ async function handleLogin (
return await selfInfo; return await selfInfo;
} }
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) { async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
// 注册刷新二维码回调
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
loginService.getQRCodePicture();
});
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => { WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
if (uin) { if (uin) {
logger.log('正在快速登录 ', uin); logger.log('正在快速登录 ', uin);
loginService.quickLoginWithUin(uin).then(res => { loginService.quickLoginWithUin(uin).then(res => {
if (res.loginErrorInfo.errMsg) { if (res.loginErrorInfo.errMsg) {
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: res.loginErrorInfo.errMsg }); resolve({ result: false, message: res.loginErrorInfo.errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
} }
resolve({ result: true, message: '' });
}).catch((e) => { }).catch((e) => {
logger.logError(e); logger.logError(e);
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '快速登录发生错误' }); resolve({ result: false, message: '快速登录发生错误' });
}); });
} else { } else {
@@ -226,7 +209,6 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
.then(result => { .then(result => {
if (result.loginErrorInfo.errMsg) { if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg); logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
if (!context.isLogined) loginService.getQRCodePicture(); if (!context.isLogined) loginService.getQRCodePicture();
} }
}) })
@@ -337,14 +319,14 @@ export async function NCoreInitShell () {
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
handleUncaughtExceptions(logger); handleUncaughtExceptions(logger);
await applyPendingUpdates(pathWrapper, logger); await applyPendingUpdates(pathWrapper);
// 初始化 FFmpeg 服务 // 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger); await FFmpegService.init(pathWrapper.binaryPath, logger);
// if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') { if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') {
// await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
// } }
const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用 const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
@@ -436,6 +418,7 @@ export async function NCoreInitShell () {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
selfInfo, selfInfo,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
@@ -451,6 +434,7 @@ export class NapCatShell {
wrapper: WrapperNodeApi, wrapper: WrapperNodeApi,
session: NodeIQQNTWrapperSession, session: NodeIQQNTWrapperSession,
logger: LogWrapper, logger: LogWrapper,
loginService: NodeIKernelLoginService,
selfInfo: SelfInfo, selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper, basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper, pathWrapper: NapCatPathWrapper,
@@ -462,6 +446,7 @@ export class NapCatShell {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
}; };
@@ -470,10 +455,6 @@ export class NapCatShell {
async InitNapCat () { async InitNapCat () {
await this.core.initCore(); await this.core.initCore();
// 监听下线通知并同步到 WebUI
this.core.event.on('KickedOffLine', (tips: string) => {
WebUiDataRuntime.setQQLoginError(tips);
});
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper); const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
// 注册到 WebUiDataRuntime供调试功能使用 // 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter); WebUiDataRuntime.setOneBotContext(oneBotAdapter);
@@ -481,3 +462,4 @@ export class NapCatShell {
.catch(e => this.context.logger.logError('初始化OneBot失败', e)); .catch(e => this.context.logger.logError('初始化OneBot失败', e));
} }
} }

View File

@@ -1,345 +1,2 @@
import { NCoreInitShell } from './base'; import { NCoreInitShell } from './base';
import { NapCatPathWrapper } from '@/napcat-common/src/path'; NCoreInitShell();
import { LogWrapper } from '@/napcat-core/helper/log';
import { connectToNamedPipe } from './pipe';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
import path from 'path';
import { fileURLToPath } from 'url';
// ES 模块中获取 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 环境变量配置
const ENV = {
isWorkerProcess: process.env['NAPCAT_WORKER_PROCESS'] === '1',
isMultiProcessDisabled: process.env['NAPCAT_DISABLE_MULTI_PROCESS'] === '1',
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
} as const;
// 初始化日志
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
// 进程管理器和当前 Worker 进程引用
let processManager: IProcessManager | null = null;
let currentWorker: IWorkerProcess | null = null;
let isElectron = false;
let isRestarting = false;
/**
* 获取进程类型名称(用于日志)
*/
function getProcessTypeName (): string {
return isElectron ? 'UtilityProcess' : 'Fork';
}
/**
* 获取 Worker 脚本路径
*/
function getWorkerScriptPath (): string {
return __filename.endsWith('.mjs')
? path.join(__dirname, 'napcat.mjs')
: path.join(__dirname, 'napcat.js');
}
/**
* 检查进程是否存在
*/
function isProcessAlive (pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* 强制终止进程
*/
function forceKillProcess (pid: number): void {
try {
process.kill(pid, 'SIGKILL');
logger.log(`[NapCat] [Process] 已强制终止进程 ${pid}`);
} catch (error) {
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
}
}
/**
* 重启 Worker 进程
*/
export async function restartWorker (): Promise<void> {
logger.log('[NapCat] [Process] 正在重启Worker进程...');
isRestarting = true;
if (!currentWorker) {
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
await startWorker(false);
isRestarting = false;
return;
}
const workerPid = currentWorker.pid;
logger.log(`[NapCat] [Process] 准备关闭Worker进程PID: ${workerPid}`);
// 1. 通知旧进程准备重启(旧进程会自行退出)
currentWorker.postMessage({ type: 'restart-prepare' });
// 2. 等待进程退出(最多 5 秒,给更多时间让进程自行清理)
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
logger.logWarn('[NapCat] [Process] Worker进程未在 5 秒内退出,尝试发送强制关闭信号');
currentWorker?.postMessage({ type: 'shutdown' });
// 再等待 2 秒
setTimeout(() => {
logger.logWarn('[NapCat] [Process] Worker进程仍未退出尝试 kill');
currentWorker?.kill();
resolve();
}, 2000);
}, 5000);
currentWorker?.once('exit', () => {
clearTimeout(timeout);
logger.log('[NapCat] [Process] Worker进程已正常退出');
resolve();
});
});
// 3. 二次确认进程是否真的被终止(兜底检查)
if (workerPid) {
logger.log(`[NapCat] [Process] 检查进程 ${workerPid} 是否已终止...`);
if (isProcessAlive(workerPid)) {
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉(兜底)`);
forceKillProcess(workerPid);
// 等待 1 秒后再次检查
await new Promise(resolve => setTimeout(resolve, 1000));
if (isProcessAlive(workerPid)) {
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
} else {
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已被强制终止`);
}
} else {
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已确认终止`);
}
}
// 4. 等待 3 秒后启动新进程
logger.log('[NapCat] [Process] Worker进程已关闭等待 3 秒后启动新进程...');
await new Promise(resolve => setTimeout(resolve, 3000));
// 5. 启动新进程(重启模式不传递快速登录参数)
await startWorker(false);
isRestarting = false;
logger.log('[NapCat] [Process] Worker进程重启完成');
}
/**
* 启动 Worker 进程
* @param passQuickLogin 是否传递快速登录参数,默认为 true重启时为 false
*/
async function startWorker (passQuickLogin: boolean = true): Promise<void> {
if (!processManager) {
throw new Error('进程管理器未初始化');
}
const workerScript = getWorkerScriptPath();
const processType = getProcessTypeName();
// 只在首次启动时传递 -q 或 --qq 参数给 worker 进程
const workerArgs: string[] = [];
if (passQuickLogin) {
const args = process.argv.slice(2);
const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq');
if (qIndex !== -1 && qIndex + 1 < args.length) {
const qFlag = args[qIndex];
const qValue = args[qIndex + 1];
if (qFlag && qValue) {
workerArgs.push(qFlag, qValue);
}
}
}
const child = processManager.createWorker(workerScript, workerArgs, {
env: {
...process.env,
NAPCAT_WORKER_PROCESS: '1',
},
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
});
currentWorker = child;
// 监听标准输出(直接转发)
if (child.stdout) {
child.stdout.on('data', (data: Buffer) => {
process.stdout.write(data);
});
}
// 监听标准错误(直接转发)
if (child.stderr) {
child.stderr.on('data', (data: Buffer) => {
process.stderr.write(data);
});
}
// 监听子进程消息
child.on('message', (msg: unknown) => {
logger.log(`[NapCat] [${processType}] 收到Worker消息:`, msg);
// 处理重启请求
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
logger.log(`[NapCat] [${processType}] 收到重启请求正在重启Worker进程...`);
restartWorker().catch(e => {
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
});
}
});
// 监听子进程退出
child.on('exit', (code: unknown) => {
const exitCode = typeof code === 'number' ? code : 0;
if (exitCode !== 0) {
logger.logError(`[NapCat] [${processType}] Worker进程退出退出码: ${exitCode}`);
} else {
logger.log(`[NapCat] [${processType}] Worker进程正常退出`);
}
// 如果不是由于主动重启引起的退出,尝试自动重新拉起(保留快速登录参数)
if (!isRestarting) {
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出正在尝试重新拉起...`);
startWorker(true).catch(e => {
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
});
}
});
child.on('spawn', () => {
logger.log(`[NapCat] [${processType}] Worker进程已生成`);
});
}
/**
* 启动 Master 进程
*/
async function startMasterProcess (): Promise<void> {
const processType = getProcessTypeName();
logger.log(`[NapCat] [${processType}] Master进程启动PID: ${process.pid}`);
// 连接命名管道(可通过环境变量禁用)
if (!ENV.isPipeDisabled) {
await connectToNamedPipe(logger).catch(e =>
logger.logError('命名管道连接失败', e)
);
} else {
logger.log(`[NapCat] [${processType}] 命名管道已禁用 (NAPCAT_DISABLE_PIPE=1)`);
}
// 启动 Worker 进程
await startWorker();
// 优雅关闭处理
const shutdown = (signal: string) => {
logger.log(`[NapCat] [Process] 收到${signal}信号,正在关闭...`);
if (currentWorker) {
currentWorker.postMessage({ type: 'shutdown' });
setTimeout(() => {
currentWorker?.kill();
process.exit(0);
}, 1000);
} else {
process.exit(0);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
/**
* 启动 Worker 进程(子进程入口)
*/
async function startWorkerProcess (): Promise<void> {
if (!processManager) {
throw new Error('进程管理器未初始化');
}
const processType = getProcessTypeName();
logger.log(`[NapCat] [${processType}] Worker进程启动PID: ${process.pid}`);
// 监听来自父进程的消息
processManager.onParentMessage((msg: unknown) => {
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
if (msg.type === 'restart-prepare') {
logger.log(`[NapCat] [${processType}] 收到重启准备信号,正在主动退出...`);
setTimeout(() => {
process.exit(0);
}, 100);
} else if (msg.type === 'shutdown') {
logger.log(`[NapCat] [${processType}] 收到关闭信号,正在退出...`);
process.exit(0);
}
}
});
// 注册重启进程函数到 WebUI
WebUiDataRuntime.setRestartProcessCall(async () => {
try {
const success = processManager!.sendToParent({ type: 'restart' });
if (success) {
return { result: true, message: '进程重启请求已发送' };
} else {
return { result: false, message: '无法与主进程通信' };
}
} catch (e) {
logger.logError('[NapCat] [Process] 发送重启请求失败:', e);
return {
result: false,
message: '发送重启请求失败: ' + (e as Error).message
};
}
});
// 启动 NapCat 核心
await NCoreInitShell();
}
/**
* 主入口
*/
async function main (): Promise<void> {
// 单进程模式:直接启动核心
if (ENV.isMultiProcessDisabled) {
logger.log('[NapCat] [SingleProcess] 多进程模式已禁用,直接启动核心');
await NCoreInitShell();
return;
}
// 多进程模式:初始化进程管理器
const result = await createProcessManager();
processManager = result.manager;
isElectron = result.isElectron;
logger.log(`[NapCat] [Process] 检测到 ${isElectron ? 'Electron' : 'Node.js'} 环境`);
// 根据进程类型启动
if (ENV.isWorkerProcess) {
await startWorkerProcess();
} else {
await startMasterProcess();
}
}
// 启动应用
main().catch((e: Error) => {
logger.logError('[NapCat] [Process] 启动失败:', e);
process.exit(1);
});

View File

@@ -1,178 +0,0 @@
import type { Readable } from 'stream';
import type { fork as forkType } from 'child_process';
// 扩展 Process 类型以支持 parentPort
declare global {
namespace NodeJS {
interface Process {
parentPort?: {
on (event: 'message', listener: (e: { data: unknown; }) => void): void;
postMessage (message: unknown): void;
};
}
}
}
/**
* 统一的进程接口
*/
export interface IWorkerProcess {
readonly pid: number | undefined;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
postMessage (message: unknown): void;
kill (): boolean;
on (event: string, listener: (...args: unknown[]) => void): void;
once (event: string, listener: (...args: unknown[]) => void): void;
}
/**
* 进程创建选项
*/
export interface ProcessOptions {
env: NodeJS.ProcessEnv;
stdio: 'pipe' | 'ignore' | 'inherit' | Array<'pipe' | 'ignore' | 'inherit' | 'ipc'>;
}
/**
* 进程管理器接口
*/
export interface IProcessManager {
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess;
onParentMessage (handler: (message: unknown) => void): void;
sendToParent (message: unknown): boolean;
}
/**
* Electron utilityProcess 包装器
*/
class ElectronProcessManager implements IProcessManager {
private utilityProcess: {
fork (modulePath: string, args: string[], options: unknown): unknown;
};
constructor (utilityProcess: { fork (modulePath: string, args: string[], options: unknown): unknown; }) {
this.utilityProcess = utilityProcess;
}
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
const child: any = this.utilityProcess.fork(modulePath, args, options);
return {
pid: child.pid as number | undefined,
stdout: child.stdout as Readable | null,
stderr: child.stderr as Readable | null,
postMessage (message: unknown): void {
child.postMessage(message);
},
kill (): boolean {
return child.kill() as boolean;
},
on (event: string, listener: (...args: unknown[]) => void): void {
child.on(event, listener);
},
once (event: string, listener: (...args: unknown[]) => void): void {
child.once(event, listener);
},
};
}
onParentMessage (handler: (message: unknown) => void): void {
if (process.parentPort) {
process.parentPort.on('message', (e: { data: unknown; }) => {
handler(e.data);
});
}
}
sendToParent (message: unknown): boolean {
if (process.parentPort) {
process.parentPort.postMessage(message);
return true;
}
return false;
}
}
/**
* Node.js child_process 包装器
*/
class NodeProcessManager implements IProcessManager {
private forkFn: typeof forkType;
constructor (forkFn: typeof forkType) {
this.forkFn = forkFn;
}
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
const child = this.forkFn(modulePath, args, options as any);
return {
pid: child.pid,
stdout: child.stdout,
stderr: child.stderr,
postMessage (message: unknown): void {
if (child.send) {
child.send(message as any);
}
},
kill (): boolean {
return child.kill();
},
on (event: string, listener: (...args: unknown[]) => void): void {
child.on(event, listener);
},
once (event: string, listener: (...args: unknown[]) => void): void {
child.once(event, listener);
},
};
}
onParentMessage (handler: (message: unknown) => void): void {
process.on('message', (message: unknown) => {
handler(message);
});
}
sendToParent (message: unknown): boolean {
if (process.send) {
process.send(message as any);
return true;
}
return false;
}
}
/**
* 检测运行环境并创建对应的进程管理器
*/
export async function createProcessManager (): Promise<{
manager: IProcessManager;
isElectron: boolean;
}> {
const isElectron = typeof process.versions['electron'] !== 'undefined';
if (isElectron) {
// @ts-ignore - electron 运行时存在但类型声明可能缺失
const electron = await import('electron');
return {
manager: new ElectronProcessManager(electron.utilityProcess),
isElectron: true,
};
} else {
const { fork } = await import('child_process');
return {
manager: new NodeProcessManager(fork),
isElectron: false,
};
}
}

View File

@@ -9,9 +9,9 @@ import react from '@vitejs/plugin-react-swc';
// 依赖排除 // 依赖排除
const external = [ const external = [
'silk-wasm',
'ws', 'ws',
'express', 'express',
'electron'
]; ];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -56,6 +56,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
lib: { lib: {
entry: { entry: {
napcat: path.resolve(__dirname, 'napcat.ts'), napcat: path.resolve(__dirname, 'napcat.ts'),
'audio-worker': path.resolve(__dirname, '../napcat-common/src/audio-worker.ts'),
'worker/conoutSocketWorker': path.resolve(__dirname, '../napcat-pty/worker/conoutSocketWorker.ts'), 'worker/conoutSocketWorker': path.resolve(__dirname, '../napcat-pty/worker/conoutSocketWorker.ts'),
}, },
formats: ['es'], formats: ['es'],

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"_build": "vite build" "build": "vite build"
}, },
"exports": { "exports": {
".": { ".": {

View File

@@ -6,49 +6,8 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); 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 * 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 () { export default function vitePluginNapcatVersion () {
const pluginDir = path.resolve(__dirname, 'dist'); const pluginDir = path.resolve(__dirname, 'dist');
@@ -57,9 +16,6 @@ export default function vitePluginNapcatVersion () {
const repo = 'NapCatQQ'; const repo = 'NapCatQQ';
const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day
const githubToken = process.env.GITHUB_TOKEN; const githubToken = process.env.GITHUB_TOKEN;
// CI 构建时可通过环境变量直接指定版本号
const envVersion = process.env.NAPCAT_VERSION;
const fallbackVersion = '1.0.0-dev';
fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(pluginDir, { recursive: true });
@@ -102,14 +58,7 @@ export default function vitePluginNapcatVersion () {
try { try {
const json = JSON.parse(data); const json = JSON.parse(data);
if (Array.isArray(json) && json[0]?.name) { if (Array.isArray(json) && json[0]?.name) {
const tagName = json[0].name; resolve(json[0].name.replace(/^v/, ''));
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')); } else reject(new Error('Invalid GitHub tag response'));
} catch (e) { } catch (e) {
reject(e); reject(e);
@@ -122,17 +71,6 @@ export default function vitePluginNapcatVersion () {
} }
async function getVersion () { 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(); const cached = readCache();
if (cached) return cached; if (cached) return cached;
try { try {
@@ -141,7 +79,7 @@ export default function vitePluginNapcatVersion () {
return tag; return tag;
} catch (e) { } catch (e) {
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message); console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
return cached ?? fallbackVersion; return cached ?? '0.0.0';
} }
} }
@@ -177,6 +115,3 @@ export default function vitePluginNapcatVersion () {
}, },
}; };
} }
// Export validateVersion for external use
export { validateVersion };

View File

@@ -42,7 +42,6 @@ export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper; export let webUiPathWrapper: NapCatPathWrapper;
export let logSubscription: ISubscription; export let logSubscription: ISubscription;
export let statusHelperSubscription: IStatusHelperSubscription; export let statusHelperSubscription: IStatusHelperSubscription;
export let webUiLogger: ILogWrapper | null = null;
const MAX_PORT_TRY = 100; const MAX_PORT_TRY = 100;
export let webUiRuntimePort = 6099; export let webUiRuntimePort = 6099;
@@ -101,7 +100,6 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
webUiPathWrapper = pathWrapper; webUiPathWrapper = pathWrapper;
logSubscription = Subscription; logSubscription = Subscription;
statusHelperSubscription = statusSubscription; statusHelperSubscription = statusSubscription;
webUiLogger = logger;
WebUiConfig = new WebUiConfigWrapper(); WebUiConfig = new WebUiConfigWrapper();
let config = await WebUiConfig.GetWebUIConfig(); let config = await WebUiConfig.GetWebUIConfig();
@@ -182,56 +180,16 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 如果是自定义色彩构建一个css文件 // 如果是自定义色彩构建一个css文件
app.use('/files/theme.css', async (_req, res) => { app.use('/files/theme.css', async (_req, res) => {
const theme = await WebUiConfig.GetTheme(); const colors = await WebUiConfig.GetTheme();
const fontMode = theme.fontMode || 'system';
let css = ''; let css = ':root, .light, [data-theme="light"] {';
for (const key in colors.light) {
// 生成字体 @font-face css += `${key}: ${colors.light[key]};`;
if (fontMode === 'aacute') {
css += `
@font-face {
font-family: 'Aa偷吃可爱长大的';
src: url('/webui/fonts/AaCute.woff') format('woff');
font-display: swap;
}
`;
} else if (fontMode === 'custom') {
css += `
@font-face {
font-family: 'CustomFont';
src: url('/webui/fonts/CustomFont.woff') format('woff');
font-display: swap;
}
`;
}
// 生成颜色主题和字体变量
css += ':root, .light, [data-theme="light"] {';
for (const key in theme.light) {
css += `${key}: ${theme.light[key]};`;
}
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
} }
css += '}'; css += '}';
css += '.dark, [data-theme="dark"] {'; css += '.dark, [data-theme="dark"] {';
for (const key in theme.dark) { for (const key in colors.dark) {
css += `${key}: ${theme.dark[key]};`; css += `${key}: ${colors.dark[key]};`;
}
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
} }
css += '}'; css += '}';

View File

@@ -3,9 +3,7 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index'; import { WebUiConfig } from '@/napcat-webui-backend/index';
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper'; import { getLatestTag } from 'napcat-common/src/helper';
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
export const GetNapCatVersion: RequestHandler = (_, res) => { export const GetNapCatVersion: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.GetNapCatVersion(); const data = WebUiDataRuntime.GetNapCatVersion();
@@ -17,126 +15,7 @@ export const getLatestTagHandler: RequestHandler = async (_, res) => {
const latestTag = await getLatestTag(); const latestTag = await getLatestTag();
sendSuccess(res, latestTag); sendSuccess(res, latestTag);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message }); res.status(500).json({ error: 'Failed to fetch latest tag' });
}
};
/**
* 版本信息接口
*/
export interface VersionInfo {
tag: string;
type: 'release' | 'prerelease' | 'action';
/** Action artifact 专用字段 */
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
workflowRunId?: number;
headSha?: string;
}
/**
* 获取所有可用的版本release + action artifacts
* 支持分页,懒加载:根据 type 参数只获取需要的版本类型
*/
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 typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
let versions: VersionInfo[] = [];
let actionVersions: VersionInfo[] = [];
let usedMirror = '';
// 懒加载:只获取需要的版本类型
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
const needActions = typeFilter === 'action' || typeFilter === 'all';
// 获取正式版本(仅当需要时)
if (needReleases) {
try {
const result = await getAllTags();
usedMirror = result.mirror;
versions = result.tags.map(tag => {
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
return {
tag,
type: isPrerelease ? 'prerelease' : 'release',
} as VersionInfo;
});
// 使用语义化版本排序(最新的在前)
versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
} catch {
// 如果获取 tags 失败,返回空列表而不是抛出错误
versions = [];
}
}
// 获取 Action Artifacts仅当需要时
if (needActions) {
try {
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
// 根据当前工作环境自动过滤对应的 artifact 类型
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
actionVersions = artifacts
.filter(a => a.name === targetArtifactName)
.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,
workflowRunId: a.workflow_run_id,
headSha: a.head_sha,
}));
} catch {
// 获取失败时返回空列表
actionVersions = [];
}
}
// 合并版本列表action 在最前面)
let allVersions = [...actionVersions, ...versions];
// 搜索过滤
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

@@ -1,225 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
import path from 'path';
import fs from 'fs';
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) return null;
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
};
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// 辅助函数:根据文件名/路径生成唯一ID作为配置键
const getPluginId = (fsName: string, isFile: boolean): string => {
if (isFile) {
return path.parse(fsName).name;
}
return fsName;
};
const loadedPlugins = pluginManager.getLoadedPlugins();
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
// 1. 整理已加载的插件
for (const p of loadedPlugins) {
// 计算 ID需要回溯到加载时的入口信息
// 对于已加载的插件,我们通过判断 pluginPath 是否等于根 pluginPath 来判断它是单文件还是目录
const isFilePlugin = p.pluginPath === pluginManager.getPluginPath();
const fsName = isFilePlugin ? path.basename(p.entryPath) : path.basename(p.pluginPath);
const id = getPluginId(fsName, isFilePlugin);
loadedPluginMap.set(id, {
name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name
id: id,
version: p.version || '0.0.0',
description: p.packageJson?.description || '',
author: p.packageJson?.author || '',
status: 'active',
filename: fsName, // 真实文件/目录名
loadedName: p.name // 运行时注册的名称,用于重载/卸载
});
}
const pluginPath = pluginManager.getPluginPath();
const pluginConfig = pluginManager.getPluginConfig();
const allPlugins: any[] = [];
// 2. 扫描文件系统,合并状态
if (fs.existsSync(pluginPath)) {
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
for (const item of items) {
let id = '';
if (item.isFile()) {
if (!['.js', '.mjs'].includes(path.extname(item.name))) continue;
id = getPluginId(item.name, true);
} else if (item.isDirectory()) {
id = getPluginId(item.name, false);
} else {
continue;
}
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
if (loadedPluginMap.has(id)) {
// 已加载,使用加载的信息
const loadedInfo = loadedPluginMap.get(id);
allPlugins.push(loadedInfo);
} else {
// 未加载 (可能是被禁用,或者加载失败,或者新增未运行)
let version = '0.0.0';
let description = '';
let author = '';
// 默认显示名称为 ID (文件名/目录名)
let name = id;
try {
// 尝试读取 package.json 获取信息
if (item.isDirectory()) {
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
version = pkg.version || version;
description = pkg.description || description;
author = pkg.author || author;
// 如果 package.json 有 name优先使用
name = pkg.name || name;
}
}
} catch (e) { }
allPlugins.push({
name: name,
id: id,
version,
description,
author,
// 如果配置是 false则为 disabled否则是 stopped (应启动但未启动)
status: isActiveConfig ? 'stopped' : 'disabled',
filename: item.name
});
}
}
}
return sendSuccess(res, allPlugins);
};
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
const { name } = req.body;
// Note: we should probably accept ID or Name. But ReloadPlugin uses valid loaded name.
// Let's stick to name for now, but be aware of ambiguity.
if (!name) return sendError(res, 'Plugin Name is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
const success = await pluginManager.reloadPlugin(name);
if (success) {
return sendSuccess(res, { message: 'Reloaded successfully' });
} else {
return sendError(res, 'Failed to reload plugin');
}
};
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
const { enable, filename } = req.body;
// We Use filename / id to control config
// Front-end should pass the 'filename' or 'id' as the key identifier
if (!filename) return sendError(res, 'Plugin Filename/ID is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// Calculate ID from filename (remove ext if file)
// Or just use the logic consistent with loadPlugins
let id = filename;
// If it has extension .js/.mjs, remove it to get the ID used in config
if (filename.endsWith('.js') || filename.endsWith('.mjs')) {
id = path.parse(filename).name;
}
try {
pluginManager.setPluginStatus(id, enable);
// If enabling, trigger load
if (enable) {
const pluginPath = pluginManager.getPluginPath();
const fullPath = path.join(pluginPath, filename);
if (fs.statSync(fullPath).isDirectory()) {
await pluginManager.loadDirectoryPlugin(filename);
} else {
await pluginManager.loadFilePlugin(filename);
}
} else {
// Disabling is handled inside setPluginStatus usually if implemented,
// OR we can explicitly unload here using the loaded name.
// The Manager's setPluginStatus implementation (if added) might logic this out.
// But our current Manager implementation just saves config.
// Wait, I updated Manager to try to unload.
// Let's rely on Manager's setPluginStatus or do it here?
// I implemented a basic unload loop in Manager.setPluginStatus.
}
return sendSuccess(res, { message: 'Status updated successfully' });
} catch (e: any) {
return sendError(res, 'Failed to update status: ' + e.message);
}
};
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
const { name, filename } = req.body;
// If it's loaded, we use name. If it's disabled, we might use filename.
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// Check if loaded
const plugin = pluginManager.getPluginInfo(name);
let fsPath = '';
if (plugin) {
// Active plugin
await pluginManager.unregisterPlugin(name);
if (plugin.pluginPath === pluginManager.getPluginPath()) {
fsPath = plugin.entryPath;
} else {
fsPath = plugin.pluginPath;
}
} else {
// Disabled or not loaded
if (filename) {
fsPath = path.join(pluginManager.getPluginPath(), filename);
} else {
return sendError(res, 'Plugin not found, provide filename if disabled');
}
}
try {
if (fs.existsSync(fsPath)) {
fs.rmSync(fsPath, { recursive: true, force: true });
}
return sendSuccess(res, { message: 'Uninstalled successfully' });
} catch (e: any) {
return sendError(res, 'Failed to uninstall: ' + e.message);
}
};

View File

@@ -1,21 +0,0 @@
import type { Request, Response } from 'express';
import { WebUiDataRuntime } from '../helper/Data';
import { sendError, sendSuccess } from '../utils/response';
/**
* 重启进程处理器
* POST /api/Process/Restart
*/
export async function RestartProcessHandler (_req: Request, res: Response) {
try {
const result = await WebUiDataRuntime.requestRestartProcess();
if (result.result) {
return sendSuccess(res, { message: result.message || '进程重启请求已发送' });
} else {
return sendError(res, result.message || '进程重启失败');
}
} catch (e) {
return sendError(res, '重启进程时发生错误: ' + (e as Error).message);
}
}

View File

@@ -1,9 +1,9 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { WebUiConfig } from '@/napcat-webui-backend/index';
import { isEmpty } from '@/napcat-webui-backend/src/utils/check'; import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index';
// 获取QQ登录二维码 // 获取QQ登录二维码
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => { export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
@@ -27,17 +27,9 @@ export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
// 获取QQ登录状态 // 获取QQ登录状态
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => { export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
const oneBotContext = WebUiDataRuntime.getOneBotContext();
const selfInfo = oneBotContext?.core?.selfInfo;
const isOnline = selfInfo?.online;
const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus();
// 必须同时满足已登录且在线online 必须明确为 true
const isLogin = qqLoginStatus && isOnline === true;
const data = { const data = {
isLogin, isLogin: WebUiDataRuntime.getQQLoginStatus(),
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(), qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
loginError: WebUiDataRuntime.getQQLoginError(),
}; };
return sendSuccess(res, data); return sendSuccess(res, data);
}; };
@@ -96,15 +88,3 @@ export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
await WebUiConfig.UpdateAutoLoginAccount(uin); await WebUiConfig.UpdateAutoLoginAccount(uin);
return sendSuccess(res, null); return sendSuccess(res, null);
}; };
// 刷新QQ登录二维码
export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
// 判断是否已经登录
if (WebUiDataRuntime.getQQLoginStatus()) {
// 已经登录
return sendError(res, 'QQ Is Logined');
}
// 刷新二维码
await WebUiDataRuntime.refreshQRCode();
return sendSuccess(res, null);
};

View File

@@ -4,22 +4,18 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as https from 'https'; import * as https from 'https';
import compressing from 'compressing'; import compressing from 'compressing';
import { webUiPathWrapper, webUiLogger } from '../../index'; import { webUiPathWrapper } from '../../index';
import { NapCatPathWrapper } from '@/napcat-common/src/path'; import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types'; 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 {
interface UpdateRequestBody { tag_name: string;
/** 要更新到的版本 tag如 "v4.9.9",不传则更新到最新版本 */ assets: Array<{
targetVersion?: string; name: string;
/** 是否强制更新(即使是降级也更新) */ browser_download_url: string;
force?: boolean; }>;
body?: string;
} }
// 更新配置文件接口 // 更新配置文件接口
@@ -73,24 +69,91 @@ function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Arr
return files; return files;
} }
// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理 // 镜像源列表参考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('所有下载源都不可用');
}
/** /**
* 下载文件(带进度和重试) * 下载文件(带进度和重试)
*/ */
async function downloadFile (url: string, dest: string): Promise<void> { async function downloadFile (url: string, dest: string): Promise<void> {
webUiLogger?.log('[NapCat Update] Starting download from:', url); console.log('Starting download from:', url);
const file = fs.createWriteStream(dest); const file = fs.createWriteStream(dest);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = https.get(url, { const request = https.get(url, {
headers: { 'User-Agent': 'NapCat-WebUI' } headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => { }, (res) => {
webUiLogger?.log('[NapCat Update] Response status:', res.statusCode); console.log('Response status:', res.statusCode);
webUiLogger?.log('[NapCat Update] Content-Type:', res.headers['content-type']); console.log('Content-Type:', res.headers['content-type']);
if (res.statusCode === 302 || res.statusCode === 301) { if (res.statusCode === 302 || res.statusCode === 301) {
webUiLogger?.log('[NapCat Update] Following redirect to:', res.headers.location); console.log('Following redirect to:', res.headers.location);
file.close(); file.close();
fs.unlinkSync(dest); fs.unlinkSync(dest);
downloadFile(res.headers.location!, dest).then(resolve).catch(reject); downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
@@ -107,13 +170,13 @@ async function downloadFile (url: string, dest: string): Promise<void> {
res.pipe(file); res.pipe(file);
file.on('finish', () => { file.on('finish', () => {
file.close(); file.close();
webUiLogger?.log('[NapCat Update] Download completed'); console.log('Download completed');
resolve(); resolve();
}); });
}); });
request.on('error', (err) => { request.on('error', (err) => {
webUiLogger?.logError('[NapCat Update] Download error:', err); console.error('Download error:', err);
file.close(); file.close();
fs.unlink(dest, () => { }); fs.unlink(dest, () => { });
reject(err); reject(err);
@@ -121,128 +184,37 @@ async function downloadFile (url: string, dest: string): Promise<void> {
}); });
} }
export const UpdateNapCatHandler: RequestHandler = async (req, res) => { export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
try { try {
// 从请求体获取目标版本(可选) // 获取最新release信息
const { targetVersion, force } = req.body as UpdateRequestBody; const latestRelease = await getLatestRelease() as Release;
// 确定要下载的文件名
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip'; 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}`);
// 检查是否是 action 临时版本
const isActionVersion = targetTag.startsWith('action-');
let downloadUrl: string;
let actualVersion: string;
if (isActionVersion) {
// 处理 action 临时版本
const runId = parseInt(targetTag.replace('action-', ''));
if (isNaN(runId)) {
throw new Error(`Invalid action version format: ${targetTag}`);
}
webUiLogger?.log(`[NapCat Update] Downloading action artifact from run: ${runId}`);
// 根据当前工作环境确定 artifact 名称
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
// Action artifacts 通过 nightly.link 下载
// 格式https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
actualVersion = targetTag;
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
// 使用 mirror 模块查找可用的 nightly.link 镜像
try {
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
validateContent: true,
minFileSize: 1024 * 1024,
timeout: 10000,
});
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
} catch (error) {
// 如果镜像都不可用,直接使用原始 URL
webUiLogger?.logWarn(`[NapCat Update] All nightly.link mirrors failed, using original URL`);
downloadUrl = baseUrl;
}
} else {
// 处理标准 release 版本
// 使用 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) { if (!shellZipAsset) {
throw new Error(`未找到${ReleaseName}文件`); throw new Error(`未找到${ReleaseName}文件`);
} }
actualVersion = release.tag_name;
// 使用 mirror 模块查找可用的下载 URL
// 启用内容验证,确保返回的是有效文件而非错误页面
downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
validateContent: true, // 验证 Content-Type 和状态码
minFileSize: 1024 * 1024, // 最小 1MB确保不是错误页面
timeout: 10000, // 10秒超时
});
}
// 检查是否需要强制更新(降级警告)
const currentVersion = WebUiDataRuntime.GetNapCatVersion();
webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`);
if (!force && currentVersion && !isActionVersion) {
// 简单的版本比较(可选的降级保护)
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(actualVersion);
const isDowngrade =
targetMajor < currMajor ||
(targetMajor === currMajor && targetMinor < currMinor) ||
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
if (isDowngrade) {
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`);
// 不阻止降级,只是记录日志
}
}
webUiLogger?.log(`[NapCat Update] Updating to version: ${actualVersion}`);
// 创建临时目录 // 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp'); const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
if (!fs.existsSync(tempDir)) { if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(tempDir, { recursive: true });
} }
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`); // 查找可用的下载URL
const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url);
// 下载zip // 下载zip
const zipPath = path.join(tempDir, 'napcat-latest.zip'); const zipPath = path.join(tempDir, 'napcat-latest.zip');
webUiLogger?.log('[NapCat Update] Saving to:', zipPath); console.log('[NapCat Update] Saving to:', zipPath);
await downloadFile(downloadUrl, zipPath); await downloadFile(downloadUrl, zipPath);
// 检查文件大小 // 检查文件大小
const stats = fs.statSync(zipPath); const stats = fs.statSync(zipPath);
webUiLogger?.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes'); console.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
// 解压到临时目录 // 解压到临时目录
const extractPath = path.join(tempDir, 'napcat-extract'); const extractPath = path.join(tempDir, 'napcat-extract');
webUiLogger?.log('[NapCat Update] Extracting to:', extractPath); console.log('[NapCat Update] Extracting to:', extractPath);
await compressing.zip.uncompress(zipPath, extractPath); await compressing.zip.uncompress(zipPath, extractPath);
// 获取解压后的实际内容目录NapCat.Shell.zip直接包含文件无额外根目录 // 获取解压后的实际内容目录NapCat.Shell.zip直接包含文件无额外根目录
@@ -263,7 +235,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
// 跳过指定的文件 // 跳过指定的文件
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) { if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
webUiLogger?.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`); console.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
continue; continue;
} }
@@ -281,7 +253,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
fs.copyFileSync(fileInfo.sourcePath, targetFilePath); fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
} catch (error) { } catch (error) {
// 如果替换失败,添加到失败列表 // 如果替换失败,添加到失败列表
webUiLogger?.logError(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error); console.log(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
failedFiles.push({ failedFiles.push({
sourcePath: fileInfo.sourcePath, sourcePath: fileInfo.sourcePath,
targetPath: targetFilePath targetPath: targetFilePath
@@ -292,16 +264,16 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
// 如果有替换失败的文件,创建更新配置文件 // 如果有替换失败的文件,创建更新配置文件
if (failedFiles.length > 0) { if (failedFiles.length > 0) {
const updateConfig: UpdateConfig = { const updateConfig: UpdateConfig = {
version: actualVersion, version: latestRelease.tag_name,
updateTime: new Date().toISOString(), updateTime: new Date().toISOString(),
files: failedFiles, files: failedFiles,
changelog: '' changelog: latestRelease.body || ''
}; };
// 保存更新配置文件 // 保存更新配置文件
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json'); const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2)); fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
webUiLogger?.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`); console.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
} }
// 发送成功响应 // 发送成功响应
@@ -311,36 +283,57 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
sendSuccess(res, { sendSuccess(res, {
status: 'completed', status: 'completed',
message, message,
newVersion: actualVersion, newVersion: latestRelease.tag_name,
failedFilesCount: failedFiles.length failedFilesCount: failedFiles.length
}); });
} catch (error) { } catch (error) {
webUiLogger?.logError('[NapCat Update] 更新失败:', error); console.error('更新失败:', error);
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误')); sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
} }
} catch (error: any) { } catch (error: any) {
webUiLogger?.logError('[NapCat Update] 更新失败:', error); console.error('更新失败:', error);
sendError(res, '更新失败: ' + error.message); sendError(res, '更新失败: ' + error.message);
} }
}; };
// 注getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease 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);
});
}
/** /**
* 应用待处理的更新(在应用启动时调用) * 应用待处理的更新(在应用启动时调用)
*/ */
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper, logger: ILogWrapper): Promise<void> { export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper): Promise<void> {
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json'); const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
logger.log('[NapCat Update] No pending updates found'); console.log('No pending updates found');
return; return;
} }
try { try {
logger.log('[NapCat Update] Applying pending updates...'); console.log('[NapCat Update] Applying pending updates...');
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const remainingFiles: Array<{ const remainingFiles: Array<{
@@ -352,7 +345,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
try { try {
// 检查源文件是否存在 // 检查源文件是否存在
if (!fs.existsSync(file.sourcePath)) { if (!fs.existsSync(file.sourcePath)) {
logger.logWarn(`[NapCat Update] Source file not found: ${file.sourcePath}`); console.warn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
continue; continue;
} }
@@ -367,10 +360,10 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
fs.unlinkSync(file.targetPath); // 删除旧文件 fs.unlinkSync(file.targetPath); // 删除旧文件
} }
fs.copyFileSync(file.sourcePath, file.targetPath); fs.copyFileSync(file.sourcePath, file.targetPath);
logger.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`); console.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
} catch (error) { } catch (error) {
logger.logError(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error); console.error(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
// 如果仍然失败,保留在列表中 // 如果仍然失败,保留在列表中
remainingFiles.push(file); remainingFiles.push(file);
} }
@@ -383,13 +376,13 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
files: remainingFiles files: remainingFiles
}; };
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
logger.log(`[NapCat Update] ${remainingFiles.length} files still pending update`); console.log(`${remainingFiles.length} files still pending update`);
} else { } else {
// 所有文件都成功更新,删除配置文件 // 所有文件都成功更新,删除配置文件
fs.unlinkSync(configPath); fs.unlinkSync(configPath);
logger.log('[NapCat Update] All pending updates applied successfully'); console.log('[NapCat Update] All pending updates applied successfully');
} }
} catch (error) { } catch (error) {
logger.logError('[NapCat Update] Failed to apply pending updates:', error); console.error('[NapCat Update] Failed to apply pending updates:', error);
} }
} }

View File

@@ -14,7 +14,6 @@ const LoginRuntime: LoginRuntimeType = {
uin: '', uin: '',
nick: '', nick: '',
}, },
QQLoginError: '',
QQVersion: 'unknown', QQVersion: 'unknown',
OneBotContext: null, OneBotContext: null,
onQQLoginStatusChange: async (status: boolean) => { onQQLoginStatusChange: async (status: boolean) => {
@@ -22,9 +21,6 @@ const LoginRuntime: LoginRuntimeType = {
}, },
onWebUiTokenChange: async (_token: string) => { onWebUiTokenChange: async (_token: string) => {
},
onRefreshQRCode: async () => {
// 默认空实现,由 shell 注册真实回调
}, },
NapCatHelper: { NapCatHelper: {
onOB11ConfigChanged: async () => { onOB11ConfigChanged: async () => {
@@ -33,9 +29,6 @@ const LoginRuntime: LoginRuntimeType = {
onQuickLoginRequested: async () => { onQuickLoginRequested: async () => {
return { result: false, message: '' }; return { result: false, message: '' };
}, },
onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' };
},
QQLoginList: [], QQLoginList: [],
NewQQLoginList: [], NewQQLoginList: [],
}, },
@@ -170,33 +163,4 @@ export const WebUiDataRuntime = {
getOneBotContext (): any | null { getOneBotContext (): any | null {
return LoginRuntime.OneBotContext; return LoginRuntime.OneBotContext;
}, },
setRestartProcessCall (func: () => Promise<{ result: boolean; message: string; }>): void {
LoginRuntime.NapCatHelper.onRestartProcessRequested = func;
},
requestRestartProcess: async function () {
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
},
setQQLoginError (error: string): void {
LoginRuntime.QQLoginError = error;
},
getQQLoginError (): string {
return LoginRuntime.QQLoginError;
},
setRefreshQRCodeCallback (func: () => Promise<void>): void {
LoginRuntime.onRefreshQRCode = func;
},
getRefreshQRCodeCallback (): () => Promise<void> {
return LoginRuntime.onRefreshQRCode;
},
refreshQRCode: async function () {
LoginRuntime.QQLoginError = '';
await LoginRuntime.onRefreshQRCode();
},
}; };

View File

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

View File

@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo'; import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status'; import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
import { GetProxyHandler } from '../api/Proxy'; import { GetProxyHandler } from '../api/Proxy';
@@ -8,7 +8,6 @@ const router = Router();
router.get('/QQVersion', QQVersionHandler); router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion); router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler); router.get('/getLatestTag', getLatestTagHandler);
router.get('/getAllReleases', getAllReleasesHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler); router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler); router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler); router.get('/Theme', GetThemeConfigHandler);

View File

@@ -1,11 +0,0 @@
import { Router } from 'express';
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
const router = Router();
router.get('/List', GetPluginListHandler);
router.post('/Reload', ReloadPluginHandler);
router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler);
export { router as PluginRouter };

View File

@@ -1,9 +0,0 @@
import { Router } from 'express';
import { RestartProcessHandler } from '../api/Process';
const router = Router();
// POST /api/Process/Restart - 重启进程
router.post('/Restart', RestartProcessHandler);
export { router as ProcessRouter };

View File

@@ -9,7 +9,6 @@ import {
getQQLoginInfoHandler, getQQLoginInfoHandler,
getAutoLoginAccountHandler, getAutoLoginAccountHandler,
setAutoLoginAccountHandler, setAutoLoginAccountHandler,
QQRefreshQRcodeHandler,
} from '@/napcat-webui-backend/src/api/QQLogin'; } from '@/napcat-webui-backend/src/api/QQLogin';
const router = Router(); const router = Router();
@@ -29,7 +28,5 @@ router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler); router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
// router:设置自动登录QQ账号 // router:设置自动登录QQ账号
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler); router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
// router:刷新QQ登录二维码
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
export { router as QQLoginRouter }; export { router as QQLoginRouter };

View File

@@ -16,8 +16,6 @@ import { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig'; import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat'; import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug'; import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
import { ProcessRouter } from './Process';
import { PluginRouter } from './Plugin';
const router = Router(); const router = Router();
@@ -46,9 +44,5 @@ router.use('/WebUIConfig', WebUIConfigRouter);
router.use('/UpdateNapCat', UpdateNapCatRouter); router.use('/UpdateNapCat', UpdateNapCatRouter);
// router:调试相关路由 // router:调试相关路由
router.use('/Debug', DebugRouter); router.use('/Debug', DebugRouter);
// router:进程管理相关路由
router.use('/Process', ProcessRouter);
// router:插件管理相关路由
router.use('/Plugin', PluginRouter);
export { router as ALLRouter }; export { router as ALLRouter };

View File

@@ -43,17 +43,14 @@ export interface LoginRuntimeType {
QQQRCodeURL: string; QQQRCodeURL: string;
QQLoginUin: string; QQLoginUin: string;
QQLoginInfo: SelfInfo; QQLoginInfo: SelfInfo;
QQLoginError: string;
QQVersion: string; QQVersion: string;
onQQLoginStatusChange: (status: boolean) => Promise<void>; onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>; onWebUiTokenChange: (token: string) => Promise<void>;
onRefreshQRCode: () => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>; WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能 OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: { NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[]; QQLoginList: string[];
NewQQLoginList: LoginListItem[]; NewQQLoginList: LoginListItem[];
}; };

View File

@@ -25,7 +25,6 @@ const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
const LogsPage = lazy(() => import('@/pages/dashboard/logs')); const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network')); const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal')); const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
function App () { function App () {
return ( return (
@@ -43,7 +42,7 @@ function App () {
); );
} }
function AuthChecker ({ children }: { children: React.ReactNode; }) { function AuthChecker ({ children }: { children: React.ReactNode }) {
const { isAuth } = useAuth(); const { isAuth } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -77,7 +76,6 @@ function AppRoutes () {
</Route> </Route>
<Route path='file_manager' element={<FileManagerPage />} /> <Route path='file_manager' element={<FileManagerPage />} />
<Route path='terminal' element={<TerminalPage />} /> <Route path='terminal' element={<TerminalPage />} />
<Route path='plugins' element={<PluginPage />} />
<Route path='about' element={<AboutPage />} /> <Route path='about' element={<AboutPage />} />
</Route> </Route>
<Route path='/qq_login' element={<QQLoginPage />} /> <Route path='/qq_login' element={<QQLoginPage />} />

View File

@@ -30,7 +30,6 @@ export interface CodeEditorRef {
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => { const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => {
const { isDark } = useTheme(); const { isDark } = useTheme();
const chromeless = !!props.options?.chromeless;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [val, setVal] = useState(props.value || props.defaultValue || ''); const [val, setVal] = useState(props.value || props.defaultValue || '');
const internalRef = React.useRef<ReactCodeMirrorRef>(null); const internalRef = React.useRef<ReactCodeMirrorRef>(null);
@@ -52,66 +51,36 @@ const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref)
"&": { "&": {
fontSize: "14px", fontSize: "14px",
height: "100% !important", height: "100% !important",
backgroundColor: 'transparent !important',
},
"&.cm-editor": {
backgroundColor: 'transparent !important',
}, },
".cm-scroller": { ".cm-scroller": {
fontFamily: "var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace)", fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
lineHeight: "1.6", lineHeight: "1.6",
overflow: "auto !important", overflow: "auto !important",
height: "100% !important", height: "100% !important",
backgroundColor: 'transparent !important',
}, },
".cm-gutters": { ".cm-gutters": {
backgroundColor: "transparent !important", backgroundColor: "transparent",
borderRight: "none", borderRight: "none",
color: isDark color: isDark ? "#ffffff50" : "#00000040",
? 'hsl(var(--heroui-foreground-500) / 0.75)'
: 'hsl(var(--heroui-foreground-500) / 0.65)',
}, },
".cm-gutterElement": { ".cm-gutterElement": {
paddingLeft: "12px", paddingLeft: "12px",
paddingRight: "12px", paddingRight: "12px",
}, },
".cm-activeLineGutter": { ".cm-activeLineGutter": {
backgroundColor: 'transparent !important', backgroundColor: "transparent",
color: isDark color: isDark ? "#fff" : "#000",
? 'hsl(var(--heroui-foreground) / 0.9) !important'
: 'hsl(var(--heroui-foreground) / 0.8) !important',
}, },
".cm-content": { ".cm-content": {
color: 'hsl(var(--heroui-foreground) / 0.9)', caretColor: isDark ? "#fff" : "#000",
caretColor: 'hsl(var(--heroui-foreground) / 0.9)',
paddingTop: "12px", paddingTop: "12px",
paddingBottom: "12px", paddingBottom: "12px",
backgroundColor: 'transparent !important',
}, },
".cm-activeLine": { ".cm-activeLine": {
backgroundColor: isDark backgroundColor: isDark ? "#ffffff10" : "#00000008",
? 'hsl(var(--heroui-foreground) / 0.08)'
: 'hsl(var(--heroui-foreground) / 0.06)',
}, },
".cm-selectionMatch": { ".cm-selectionMatch": {
backgroundColor: isDark backgroundColor: isDark ? "#ffffff20" : "#00000010",
? 'hsl(var(--heroui-foreground) / 0.16)'
: 'hsl(var(--heroui-foreground) / 0.12)',
},
// Syntax highlighting overrides for better readability
".ͼo": {
// JSON property names - use a softer primary color
color: isDark
? 'hsl(var(--heroui-primary) / 0.85)'
: 'hsl(var(--heroui-primary) / 0.75)',
},
".ͼd": {
// Strings - softer green
color: isDark ? '#98c379cc' : '#50a14fcc',
},
".ͼc": {
// Numbers - softer orange
color: isDark ? '#d19a66cc' : '#c18401cc',
}, },
}); });
@@ -126,20 +95,17 @@ const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref)
<div <div
style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }} style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }}
className={clsx( className={clsx(
chromeless 'rounded-xl border overflow-hidden transition-colors',
? 'overflow-hidden transition-colors bg-transparent' isDark
: 'rounded-xl border overflow-hidden transition-colors backdrop-blur-sm', ? 'border-white/10 bg-[#282c34]'
!chromeless && (isDark : 'border-default-200 bg-white'
? 'border-white/10 bg-white/5 text-default-100'
: 'border-white/40 dark:border-white/10 bg-white/60 dark:bg-black/20 text-default-700')
)} )}
> >
<CodeMirror <CodeMirror
ref={internalRef} ref={internalRef}
value={props.value ?? props.defaultValue} value={props.value ?? props.defaultValue}
height="100%" height="100%"
className="h-full w-full [&_.cm-editor]:!bg-transparent [&_.cm-scroller]:!bg-transparent" className="h-full w-full"
style={{ backgroundColor: 'transparent' }}
theme={isDark ? oneDark : 'light'} theme={isDark ? oneDark : 'light'}
extensions={extensions} extensions={extensions}
onChange={(value) => { onChange={(value) => {

View File

@@ -1,228 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TbCornerDownLeft, TbSearch } from 'react-icons/tb';
export type CommandPaletteCommand = {
id: string;
title: string;
subtitle?: string;
group?: string;
};
export type CommandPaletteExecuteMode = 'open' | 'send';
export interface CommandPaletteProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
commands: CommandPaletteCommand[];
onExecute: (commandId: string, mode: CommandPaletteExecuteMode) => void;
}
const isMobileByViewport = () => {
try {
return window.innerWidth < 768;
} catch {
return false;
}
};
export default function CommandPalette (props: CommandPaletteProps) {
const { isOpen, onOpenChange, commands, onExecute } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [mobile, setMobile] = useState(false);
useEffect(() => {
const update = () => setMobile(isMobileByViewport());
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
useEffect(() => {
if (!isOpen) return;
setQuery('');
setActiveIndex(0);
// 等 Modal 动画挂载后再 focus
const t = window.setTimeout(() => inputRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}, [isOpen]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = !q
? commands
: commands.filter((c) => {
const hay = `${c.id} ${c.title} ${c.subtitle ?? ''} ${c.group ?? ''}`.toLowerCase();
return hay.includes(q);
});
// 简单:优先 path 前缀命中
if (!q) return list;
const starts = list.filter((c) => c.id.toLowerCase().startsWith(q));
const rest = list.filter((c) => !c.id.toLowerCase().startsWith(q));
return [...starts, ...rest];
}, [commands, query]);
useEffect(() => {
if (activeIndex >= filtered.length) setActiveIndex(0);
}, [filtered.length, activeIndex]);
const active = filtered[activeIndex];
const exec = (mode: CommandPaletteExecuteMode) => {
if (!active) return;
onExecute(active.id, mode);
onOpenChange(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(0, filtered.length - 1)));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === 'Enter') {
e.preventDefault();
// Shift+Enter 仅打开Enter 打开并发送
exec(e.shiftKey ? 'open' : 'send');
return;
}
if (e.key === 'Escape') {
e.preventDefault();
onOpenChange(false);
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
size={mobile ? 'full' : '2xl'}
radius={mobile ? 'none' : 'lg'}
scrollBehavior='inside'
backdrop='blur'
>
<ModalContent>
{() => (
<>
<ModalHeader className={clsx(
'flex items-center gap-2',
mobile ? 'border-b border-default-200/50' : ''
)}>
<span className='text-sm font-semibold'></span>
<span className='text-xs text-default-400 font-normal hidden md:inline'>Ctrl/Cmd + K</span>
</ModalHeader>
<ModalBody className={clsx('gap-3', mobile ? 'p-3' : 'p-4')}>
<Input
ref={inputRef as any}
autoFocus
value={query}
onValueChange={setQuery}
onKeyDown={onKeyDown}
placeholder='输入 /set_xxx 或 描述… Enter打开并发送Shift+Enter仅打开'
startContent={<TbSearch className='opacity-40' size={16} />}
radius='lg'
variant='flat'
classNames={{
inputWrapper: 'bg-content2/50 border border-default-200/50 dark:border-default-100/20',
input: 'text-sm',
}}
/>
<div className={clsx(
'rounded-xl border border-default-200/50 dark:border-default-100/20 overflow-hidden',
mobile ? 'flex-1 min-h-0' : 'max-h-[420px]'
)}>
<div className={clsx(
'divide-y divide-default-200/50 dark:divide-default-100/20 overflow-y-auto no-scrollbar',
mobile ? 'h-full' : 'max-h-[420px]'
)}>
{filtered.length === 0 && (
<div className='p-6 text-sm text-default-400'></div>
)}
{filtered.map((c, idx) => (
<button
key={c.id}
type='button'
className={clsx(
'w-full text-left px-4 py-3 transition-colors flex items-center gap-3',
idx === activeIndex
? 'bg-primary/10'
: 'hover:bg-default-100/50 dark:hover:bg-default-50/10'
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => {
setActiveIndex(idx);
exec('open');
}}
>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 min-w-0'>
<span className='text-xs font-mono text-default-500 truncate'>{c.id}</span>
{c.group && (
<span className='text-[10px] px-2 py-0.5 rounded-full bg-default-100/60 dark:bg-default-50/20 text-default-500'>
{c.group}
</span>
)}
</div>
<div className='text-sm text-default-700 dark:text-default-200 truncate'>{c.title}</div>
{c.subtitle && (
<div className='text-xs text-default-400 truncate'>{c.subtitle}</div>
)}
</div>
<div className='flex items-center gap-2 flex-shrink-0'>
<span className='hidden md:inline text-[10px] text-default-400'>Enter</span>
<TbCornerDownLeft className='opacity-40' size={16} />
</div>
</button>
))}
</div>
</div>
</ModalBody>
{mobile && (
<ModalFooter className='border-t border-default-200/50'>
<Button radius='full' variant='flat' onPress={() => onOpenChange(false)}>
</Button>
<Button
radius='full'
variant='flat'
color='primary'
isDisabled={!active}
onPress={() => exec('open')}
>
</Button>
<Button
radius='full'
color='primary'
isDisabled={!active}
onPress={() => exec('send')}
>
</Button>
</ModalFooter>
)}
</>
)}
</ModalContent>
</Modal>
);
}

View File

@@ -37,7 +37,6 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
onEnable, onEnable,
onDelete, onDelete,
onEnableDebug, onEnableDebug,
showType,
}: NetworkDisplayCardProps<T>) => { }: NetworkDisplayCardProps<T>) => {
const { name, enable, debug } = data; const { name, enable, debug } = data;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -61,16 +60,15 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
return ( return (
<DisplayCardContainer <DisplayCardContainer
className='w-full max-w-[420px]' className="w-full max-w-[420px]"
tag={showType ? typeLabel : undefined}
action={ action={
<div className='flex gap-2 w-full'> <div className="flex gap-2 w-full">
<Button <Button
fullWidth fullWidth
radius='full' radius='full'
size='sm' size='sm'
variant='flat' variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors' className="flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors"
startContent={<FiEdit3 size={16} />} startContent={<FiEdit3 size={16} />}
onPress={onEdit} onPress={onEdit}
isDisabled={editing} isDisabled={editing}
@@ -84,16 +82,16 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
size='sm' size='sm'
variant='flat' variant='flat'
className={clsx( className={clsx(
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors', "flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
debug debug
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary' ? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success' : "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
)} )}
startContent={<CgDebug size={16} />} startContent={<CgDebug size={16} />}
onPress={handleEnableDebug} onPress={handleEnableDebug}
isDisabled={editing} isDisabled={editing}
> >
{debug ? '默认' : '调试'} {debug ? '关闭调试' : '开启调试'}
</Button> </Button>
<Button <Button
fullWidth fullWidth
@@ -115,11 +113,11 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
isSelected={enable} isSelected={enable}
onChange={handleEnable} onChange={handleEnable}
classNames={{ classNames={{
wrapper: 'group-data-[selected=true]:bg-primary-400', wrapper: "group-data-[selected=true]:bg-primary-400",
}} }}
/> />
} }
title={name} title={typeLabel}
> >
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-2 gap-3'>
{(() => { {(() => {
@@ -127,30 +125,29 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
if (targetFullField) { if (targetFullField) {
// 模式1存在全宽字段如URL布局为 // 模式1存在全宽字段如URL布局为
// Row 1: 类型 (全宽) // Row 1: 名称 (全宽)
// Row 2: 全宽字段 (全宽) // Row 2: 全宽字段 (全宽)
return ( return (
<> <>
<div <div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2' className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
> >
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span> <span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'> <div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{typeLabel} {name}
</div> </div>
</div> </div>
<div <div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2' className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
> >
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span> <span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'> <div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{targetFullField.render {targetFullField.render
? targetFullField.render(targetFullField.value) ? targetFullField.render(targetFullField.value)
: ( : (
<span className={clsx( <span className={clsx(
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : '' typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
)} )}>
>
{String(targetFullField.value)} {String(targetFullField.value)}
</span> </span>
)} )}
@@ -160,7 +157,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
); );
} else { } else {
// 模式2无全宽字段布局为 4 个小块 (2行 x 2列) // 模式2无全宽字段布局为 4 个小块 (2行 x 2列)
// Row 1: 类型 | Field 0 // Row 1: 名称 | Field 0
// Row 2: Field 1 | Field 2 // Row 2: Field 1 | Field 2
const displayFields = fields.slice(0, 3); const displayFields = fields.slice(0, 3);
return ( return (
@@ -168,9 +165,9 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
<div <div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors' className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
> >
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span> <span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'> <div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{typeLabel} {name}
</div> </div>
</div> </div>
{displayFields.map((field, index) => ( {displayFields.map((field, index) => (
@@ -179,7 +176,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors' className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
> >
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span> <span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'> <div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{field.render {field.render
? ( ? (
field.render(field.value) field.render(field.value)
@@ -187,8 +184,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
: ( : (
<span className={clsx( <span className={clsx(
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : '' typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
)} )}>
>
{String(field.value)} {String(field.value)}
</span> </span>
)} )}

Some files were not shown because too many files have changed in this diff Show More