Compare commits

..

2 Commits

Author SHA1 Message Date
手瓜一十雪
7c8e825eba Refactor build and release workflows, update dependencies
Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
2025-11-13 15:39:20 +08:00
手瓜一十雪
c9c6942564 feat: pnpm new 2025-11-13 15:10:47 +08:00
675 changed files with 11694 additions and 51801 deletions

View File

@@ -1,6 +1,6 @@
name: Bug 反馈
description: 报告可能的 NapCat 异常行为
title: "[BUG] "
title: '[BUG] '
labels: bug
body:
- type: markdown
@@ -10,10 +10,6 @@ body:
在提交新的 Bug 反馈前,请确保您:
* 已经搜索了现有的 issues并且没有找到可以解决您问题的方法
* 不与现有的某一 issue 重复
* **不接受因发送不当内容而导致的问题报告**
- 包括但不限于:多媒体发送失败、转发消息失败、消息被拦截等因 18+ 内容、违规内容或触发风控的问题
- 提交 issue 前,请确认您发送的多媒体内容、链接、文本等均为正常合规内容,不会触发平台风控机制
- 因违规内容导致的问题,一律不予受理
- type: input
id: system-version
attributes:
@@ -34,7 +30,7 @@ body:
id: napcat-version
attributes:
label: NapCat 版本
description: 可在 WebUI 的「系统信息」页中找到
description: 可在 LiteLoaderQQNT 的设置页或是 QQNT 的设置页侧栏中找到
placeholder: 1.0.0
validations:
required: true

View File

@@ -1,60 +0,0 @@
name: Feat 请求
description: 提交新的 NapCat 功能或改进建议
title: '[FEAT] '
labels: enhancement
body:
- type: markdown
attributes:
value: |
欢迎来到 NapCat 的 Issue Tracker请填写以下表格来提交功能请求。
在提交新的功能请求前,请确保您:
* 已经搜索了现有的 issues并且没有找到类似的建议
* 不与现有的某一 issue 重复
- type: input
id: system-version
attributes:
label: 系统版本
description: 运行 QQNT 的系统版本
placeholder: Windows 11 24H2
validations:
required: true
- type: input
id: qqnt-version
attributes:
label: QQNT 版本
description: 可在 QQNT 的「关于」的设置页中找到
placeholder: 9.9.16-29927
validations:
required: true
- type: input
id: napcat-version
attributes:
label: NapCat 版本
description: 可在 WebUI 的「系统信息」页中找到
placeholder: 1.0.0
validations:
required: true
- type: textarea
id: feature-description
attributes:
label: 功能描述
description: 请详细描述你希望添加的功能或改进
validations:
required: true
- type: textarea
id: feature-reason
attributes:
label: 需求背景与理由
description: 请说明为什么需要这个功能,解决了什么问题
validations:
required: true
- type: textarea
id: feature-expected
attributes:
label: 期望的实现方式或效果
description: 请描述你期望的功能实现方式或最终效果
- type: textarea
id: other-info
attributes:
label: 其他补充信息
description: 你还想补充什么?

View File

@@ -1,43 +0,0 @@
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化的一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载
NapCat.Shell.Windows.OneKey.zip (无头)
启动后可自动化部署一键包,教程参考使用文档安装部分
## 警告
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)**
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb)
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm)
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb)
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
### 🐛 修复
1. 修复 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,111 +0,0 @@
# NapCat Release Note Generator
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
## 核心规则
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号,如果版本号是小写 v 开头(如 v4.10.2),必须转换为大写 V如 V4.10.2
2. **语言**:全部使用简体中文
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
## Commit 分析规则
将 commit 分类为以下类型:
- 🐛 **修复**bug fix、修复、fix 相关
- ✨ **新增**新功能、feat、add 相关
- 🔧 **优化**优化、重构、refactor、improve、perf 相关
- 📦 **依赖**deps、依赖更新通常可以忽略或合并
- 🔨 **构建**ci、build、workflow 相关(通常可以忽略)
## 合并和筛选
- **合并相似项**:同一功能的多个 commit 合并为一条
- **忽略琐碎项**合并冲突、格式化、typo 等可忽略
- **控制数量**:最终保持 5-15 条更新要点
- **保留 commit hash**:每条末尾附上短 hash格式 `(a1b2c3d)`
## 输出模板 - 必须严格遵守以下格式
```
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化的一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载
NapCat.Shell.Windows.OneKey.zip (无头)
启动后可自动化部署一键包,教程参考使用文档安装部分
## 警告
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)**
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb)
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm)
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb)
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
### 🐛 修复
1. 修复 xxx 问题 (a1b2c3d)
2. 修复 yyy 崩溃 (b2c3d4e)
### ✨ 新增
1. 新增 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

@@ -1,153 +0,0 @@
name: Auto Release Docker
on:
release:
types: [published]
jobs:
publish-schema:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get Version
id: get_version
run: |
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
version=${latest_tag#v}
echo "version=${version}" >> $GITHUB_ENV
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
echo "Debug: Version is ${version}"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Build napcat-schema
run: |
cd packages/napcat-schema
pnpm run build:openapi
- name: Checkout NapCatDocs
uses: actions/checkout@v4
with:
repository: NapNeko/NapCatDocs
token: ${{ secrets.NAPCAT_BUILD }}
path: napcat-docs
- name: Copy OpenAPI Schema
run: |
mkdir -p napcat-docs/src/api/${{ env.version }}
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
echo "OpenAPI schema copied to napcat-docs/src/api/${{ env.version }}/openapi.json"
- name: Commit and Push
run: |
cd napcat-docs
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src/api/${{ env.version }}/openapi.json
git commit -m "chore: update OpenAPI schema for version ${{ env.version }}" || echo "No changes to commit"
git push
shell-docker:
runs-on: ubuntu-latest
steps:
- name: Trigger NapCat-Docker docker-publish workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
-d '{"ref":"main"}'
framework-docker:
runs-on: ubuntu-latest
steps:
- name: Trigger NapCat-Framework-Docker docker-publish workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCat.Docker.Framework/actions/workflows/docker-image.yml/dispatches \
-d '{"ref":"main"}'
appimage-shell-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Get Latest NapCat Version
id: get_version
run: |
# 获取当前仓库的最新 tag
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
# 输出调试信息
echo "Debug: Latest NapCat Version is ${latest_tag}"
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
- name: Trigger Release NapCat AppImage Workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCatAppImageBuild/actions/workflows/release.yml/dispatches \
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_version_x86_64\":\"${QQ_VERSION_X86_64}\",\"qq_version_arm64\":\"${QQ_VERSION_ARM64}\"}}"
node-shell-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Get Latest NapCat Version
id: get_version
run: |
# 获取当前仓库的最新 tag
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
# 输出调试信息
echo "Debug: Latest NapCat Version is ${latest_tag}"
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
- name: Trigger Release NapCat AppImage Workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
- name: Trigger Release NapCat AppImage Workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/docker-publish.yml/dispatches \
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"

View File

@@ -1,4 +1,4 @@
name: Build NapCat Artifacts
name: "Build Action"
on:
workflow_dispatch:
push:
@@ -8,89 +8,45 @@ on:
permissions: write-all
jobs:
Build-Framework:
Build-LiteLoader:
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
pnpm run typecheck || exit 1
pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-framework/dist framework-dist
cd framework-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
cd packages/napcat-framework/dist
pnpm install --production
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: framework-dist
path: packages/napcat-framework/dist
Build-Shell:
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
pnpm run typecheck || exit 1
pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-shell/dist shell-dist
cd shell-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
cd packages/napcat-shell/dist
pnpm install --production
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: shell-dist
path: packages/napcat-shell/dist

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

@@ -1,51 +1,12 @@
name: Release NapCat
name: "Build Release"
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "copilot/gemini-3-flash-preview"
RELEASE_NAME: "NapCat"
jobs:
# 验证版本号格式
validate-version:
runs-on: ubuntu-latest
outputs:
valid: ${{ steps.check.outputs.valid }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Validate semantic version
id: check
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "Checking tag: $TAG"
# 语义化版本正则表达式
# 支持: v1.0.0, v1.0.0-beta, v1.0.0-rc.1, v1.0.0-alpha.1+build.123
SEMVER_REGEX="^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?$"
if [[ "$TAG" =~ $SEMVER_REGEX ]]; then
echo "✅ Valid semantic version: $TAG"
echo "valid=true" >> $GITHUB_OUTPUT
echo "version=$TAG" >> $GITHUB_OUTPUT
else
echo "❌ Invalid version format: $TAG"
echo "Expected format: vX.Y.Z or vX.Y.Z-prerelease"
echo "Examples: v1.0.0, v1.2.3-beta, v2.0.0-rc.1"
echo "valid=false" >> $GITHUB_OUTPUT
exit 1
fi
Build-Framework:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
Build-LiteLoader:
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@@ -55,27 +16,19 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-framework/dist framework-dist
cd framework-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
cd packages/napcat-framework/dist
pnpm install --production
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: framework-dist
path: packages/napcat-framework/dist
Build-Shell:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@@ -85,404 +38,45 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-shell/dist shell-dist
cd shell-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
cd packages/napcat-shell/dist
pnpm install --production
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: shell-dist
Download-QNX64:
needs: Build-Shell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download Artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
- name: Setup tools
run: |
sudo apt update
sudo apt install -y aria2 unzip zip p7zip-full curl jq
- name: Download QQ x64, Node.js and Assemble NapCat.Shell.Windows.Node.zip
run: |
set -euo pipefail
TMPDIR=$(mktemp -d)
cd "$TMPDIR"
# -----------------------------
# 1) 下载 QQ x64 (使用缓存)
# -----------------------------
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
QQ_ZIP="$(basename "$NT_URL")"
# 根据 URL 生成缓存键
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
- name: Cache QQ x64 Installer
id: cache-qq
uses: actions/cache@v4
with:
path: ~/qq-cache
key: ${{ env.QQ_CACHE_KEY }}
- name: Download and Extract QQ x64
run: |
set -euo pipefail
TMPDIR=$(mktemp -d)
cd "$TMPDIR"
QQ_CACHE_DIR="$HOME/qq-cache"
mkdir -p "$QQ_CACHE_DIR"
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
echo "Using cached QQ installer: $QQ_ZIP"
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
else
echo "Downloading QQ installer: $QQ_ZIP"
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
fi
QQ_EXTRACT="$TMPDIR/qq_extracted"
mkdir -p "$QQ_EXTRACT"
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
run: |
set -euo pipefail
cd "$WORK_TMPDIR"
# -----------------------------
# 2) 下载 Node.js Windows x64 zip 22.11.0
# -----------------------------
NODE_VER="22.11.0"
NODE_URL="https://nodejs.org/dist/v$NODE_VER/node-v$NODE_VER-win-x64.zip"
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
mkdir -p "$NODE_EXTRACT"
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
# -----------------------------
# 3) 创建输出目录
# -----------------------------
OUT_DIR="$GITHUB_WORKSPACE/NapCat.Shell.Windows.Node"
mkdir -p "$OUT_DIR/NapCat.Shell.Windows.Node"
# -----------------------------
# 4) 解压 NapCat.Shell.zip 到 napcat
# -----------------------------
cp -a "$GITHUB_WORKSPACE/artifacts/NapCat.Shell/." "$OUT_DIR/napcat/"
# -----------------------------
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
# -----------------------------
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
for name in "${QQ_TARGETS[@]}"; do
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
done
# -----------------------------
# 5.1) 拷贝 win64 目录下的文件
# -----------------------------
mkdir -p "$OUT_DIR/win64"
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
# -----------------------------
# 6) 拷贝仓库文件 napcat.bat 和 index.js
# -----------------------------
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/napcat.bat" "$OUT_DIR/" || true
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/index.js" "$OUT_DIR/" || true
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/QQNT.dll" "$OUT_DIR/" || true
# -----------------------------
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
# -----------------------------
NODE_VER="22.11.0"
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell.Windows.Node
path: NapCat.Shell.Windows.Node
path: packages/napcat-shell/dist
release-napcat:
needs: [Build-Framework, Build-Shell, Download-QNX64]
needs: [Build-LiteLoader, Build-Shell]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download Artifacts
- name: Download All Artifact
uses: actions/download-artifact@v4
with:
path: ./artifacts
- name: Download NapCat.Shell.Windows.OneKey.zip
- name: Compress subdirectories
run: |
curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
- name: Zip Artifacts
run: |
cd artifacts
[ -d NapCat.Framework ] && (cd NapCat.Framework && zip -qr ../../NapCat.Framework.zip .)
[ -d NapCat.Shell ] && (cd NapCat.Shell && zip -qr ../../NapCat.Shell.zip .)
[ -d NapCat.Shell.Windows.Node ] && (cd NapCat.Shell.Windows.Node && zip -qr ../../NapCat.Shell.Windows.Node.zip .)
cd ..
- name: Generate release note via OpenRouter
env:
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
run: |
set -euo pipefail
# 当前 tag
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
echo "Current tag: $CURRENT_TAG"
# 从 GitHub API 获取 tag 列表
TAGS_JSON=$(curl -s "https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/tags?per_page=100")
TAGS=( $(echo "$TAGS_JSON" | jq -r '.[].name' | sort -V) )
# 找到上一个 tag
PREV_TAG=""
for i in "${!TAGS[@]}"; do
if [ "${TAGS[$i]}" = "$CURRENT_TAG" ]; then
if [ $i -gt 0 ]; then
PREV_TAG="${TAGS[$((i-1))]}"
fi
break
fi
done
if [ -z "$PREV_TAG" ]; then
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit"
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
fi
echo "Previous tag: $PREV_TAG"
# 强制拉取上一个 tag 和当前 tag
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
# 获取 commit使用更清晰的格式
# 格式: <type>: <subject> (<hash>)
COMMITS=$(git log --pretty=format:'- %s (%h)' "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || git log --pretty=format:'- %s (%h)' -20)
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
echo "$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_FILE=".github/prompt/release_note_prompt.txt"
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
# 构建用户内容传递更多上下文包含文件变化和代码diff
USER_CONTENT="当前版本: $CURRENT_TAG
上一版本: $PREV_TAG
## 提交列表
$COMMITS
## 文件变化统计
$SUMMARY_LINE
## 变更文件列表
$FILE_CHANGES
## 关键代码变化
$CODE_DIFF"
# 构建请求 JSON增加 max_tokens 以获取更完整的输出
BODY=$(jq -n \
--arg system "$SYSTEM_PROMPT" \
--arg user "$USER_CONTENT" \
--arg model "$OPENROUTER_MODEL" \
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
echo "=== OpenRouter request body ==="
echo "$BODY" | jq .
# 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
-H "Authorization: Bearer $OPENAI_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"); then
echo "=== raw response ==="
echo "$RESPONSE"
echo "=== OpenRouter raw response ==="
if echo "$RESPONSE" | jq . >/dev/null 2>&1; then
echo "$RESPONSE" | jq .
else
echo "jq failed to parse response"
fi
# 提取生成内容
RELEASE_BODY=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // .choices[0].text // ""' 2>/dev/null || echo "")
if [ -z "$RELEASE_BODY" ]; then
echo "❌ OpenRouter failed to generate release note, using default.md"
# 替换默认模板中的版本占位符
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
else
# 后处理:确保版本号正确,并添加比较链接
echo -e "$RELEASE_BODY" > CHANGELOG.md
# 替换可能的占位符
sed -i "s/{VERSION}/$CURRENT_TAG/g" CHANGELOG.md
sed -i "s/{PREV_VERSION}/$PREV_TAG/g" CHANGELOG.md
fi
else
echo "❌ Curl failed, using default.md"
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
fi
echo "=== generated release note ==="
cat CHANGELOG.md
cd ./NapCat.Shell/
zip -q -r NapCat.Shell.zip *
cd ..
cd ./NapCat.Framework/
zip -q -r NapCat.Framework.zip *
cd ..
rm ./NapCat.Shell.zip -rf
rm ./NapCat.Framework.zip -rf
mv ./NapCat.Shell/NapCat.Shell.zip ./
mv ./NapCat.Framework/NapCat.Framework.zip ./
- name: Create Release Draft and Upload Artifacts
uses: softprops/action-gh-release@v1
with:
name: NapCat ${{ github.ref_name }}
name: NapCat
token: ${{ secrets.GITHUB_TOKEN }}
body_path: CHANGELOG.md
body: Automated release artifact (no version detection)
files: |
NapCat.Shell.Windows.Node.zip
NapCat.Framework.zip
NapCat.Shell.zip
NapCat.Shell.Windows.OneKey.zip
draft: true
draft: true

1
.gitignore vendored
View File

@@ -17,4 +17,3 @@ bun.lockb
tests/run/
guild1.db-wal
guild1.db-shm
packages/napcat-develop/config/.env

12
.vscode/launch.json vendored
View File

@@ -1,12 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"request": "launch",
"name": "调试程序",
"command": "pnpm run dev:shell",
"cwd": "${workspaceFolder}"
}
]
}

35
.vscode/settings.json vendored
View File

@@ -1,37 +1,2 @@
{
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
".env.universal": ".env.*",
"vite.config.ts": "vite*.ts",
"README.md": "CODE_OF_CONDUCT.md, RELEASES.md, CONTRIBUTING.md, CHANGELOG.md, SECURITY.md",
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
},
"css.customData": [
".vscode/tailwindcss.json"
],
"editor.detectIndentation": false,
"editor.tabSize": 2,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"editor.formatOnPaste": true,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"files.autoSave": "onFocusChange",
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert",
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.insertSpaceAfterConstructor": true,
"javascript.format.insertSpaceAfterConstructor": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.disableAutomaticTypeAcquisition": true
}

View File

@@ -1,55 +0,0 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@@ -43,14 +43,14 @@ _Modern protocol-side framework implemented based on NTQQ._
**首次使用**请务必查看如下文档看使用教程
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
## Link
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) |
|:-:|:-:|:-:|:-:|
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.top/) | [![NapCat.Top](https://img.shields.io/badge/docs%20on-NapCat.Top-red)](https://napcat.top/) |
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
|:-:|:-:|:-:|:-:|
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |

View File

@@ -1,52 +0,0 @@
import neostandard from 'neostandard';
/** 尾随逗号 */
const commaDangle = val => {
if (val?.rules?.['@stylistic/comma-dangle']?.[0] === 'warn') {
const rule = val?.rules?.['@stylistic/comma-dangle']?.[1];
Object.keys(rule).forEach(key => {
rule[key] = 'always-multiline';
});
val.rules['@stylistic/comma-dangle'][1] = rule;
}
/** 三元表达式 */
if (val?.rules?.['@stylistic/indent']) {
val.rules['@stylistic/indent'][2] = {
...val.rules?.['@stylistic/indent']?.[2],
flatTernaryExpressions: true,
offsetTernaryExpressions: false,
};
}
/** 支持下划线 - 禁用 camelcase 规则 */
if (val?.rules?.camelcase) {
val.rules.camelcase = 'off';
}
/** 未使用的变量强制报错 */
if (val?.rules?.['@typescript-eslint/no-unused-vars']) {
val.rules['@typescript-eslint/no-unused-vars'] = ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}];
}
return val;
};
/** 忽略的文件 */
const ignores = [
'node_modules',
'**/dist/**',
'launcher',
];
const options = neostandard({
ts: true,
ignores,
semi: true, // 强制使用分号
}).map(commaDangle);
export default options;

1457
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,33 +2,19 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "0.0.1",
"version": "4.9.42",
"scripts": {
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
"build:shell:config": "pnpm --filter napcat-shell run build && pnpm --filter napcat-develop run copy-env",
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
"dev:shell": "pnpm --filter napcat-develop run dev || exit 1",
"typecheck": "pnpm -r --if-present run typecheck",
"test": "pnpm --filter napcat-test run test",
"test:ui": "pnpm --filter napcat-test run test:ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"@vitest/ui": "^4.0.9",
"eslint": "^9.39.1",
"neostandard": "^0.12.2",
"typescript": "^5.3.0",
"vite": "^6.4.1",
"vite-plugin-cp": "^6.0.3",
"vitest": "^4.0.9"
"vite-plugin-cp": "^6.0.3"
},
"dependencies": {
"silk-wasm": "^3.6.1",
"express": "^5.0.0",
"ws": "^8.18.3"
}

View File

@@ -1,176 +0,0 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { NapCatOneBot11Adapter } from 'napcat-onebot';
import { NapCatProtocolAdapter } from 'napcat-protocol';
// 协议适配器类型
export type ProtocolAdapterType = 'onebot11' | 'napcat-protocol';
// 协议适配器接口
export interface IProtocolAdapter {
readonly name: string;
readonly enabled: boolean;
init (): Promise<void>;
close (): Promise<void>;
}
// 协议适配器包装器
class OneBotAdapterWrapper implements IProtocolAdapter {
readonly name = 'onebot11';
private adapter: NapCatOneBot11Adapter;
constructor (adapter: NapCatOneBot11Adapter) {
this.adapter = adapter;
}
get enabled (): boolean {
return true; // OneBot11 默认启用
}
async init (): Promise<void> {
await this.adapter.InitOneBot();
}
async close (): Promise<void> {
await this.adapter.networkManager.closeAllAdapters();
}
getAdapter (): NapCatOneBot11Adapter {
return this.adapter;
}
}
// NapCat Protocol 适配器包装器
class NapCatProtocolAdapterWrapper implements IProtocolAdapter {
readonly name = 'napcat-protocol';
private adapter: NapCatProtocolAdapter;
constructor (adapter: NapCatProtocolAdapter) {
this.adapter = adapter;
}
get enabled (): boolean {
return this.adapter.isEnabled();
}
async init (): Promise<void> {
await this.adapter.initProtocol();
}
async close (): Promise<void> {
await this.adapter.close();
}
getAdapter (): NapCatProtocolAdapter {
return this.adapter;
}
}
// 协议适配器管理器
export class NapCatAdapterManager {
private core: NapCatCore;
private context: InstanceContext;
private pathWrapper: NapCatPathWrapper;
// 协议适配器实例
private onebotAdapter: OneBotAdapterWrapper | null = null;
private napcatProtocolAdapter: NapCatProtocolAdapterWrapper | null = null;
// 所有已注册的适配器
private adapters: Map<string, IProtocolAdapter> = new Map();
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.pathWrapper = pathWrapper;
}
// 初始化所有协议适配器
async initAdapters (): Promise<void> {
this.context.logger.log('[AdapterManager] 开始初始化协议适配器...');
// 初始化 OneBot11 适配器 (默认启用)
try {
const onebot = new NapCatOneBot11Adapter(this.core, this.context, this.pathWrapper);
this.onebotAdapter = new OneBotAdapterWrapper(onebot);
this.adapters.set('onebot11', this.onebotAdapter);
await this.onebotAdapter.init();
this.context.logger.log('[AdapterManager] OneBot11 适配器初始化完成');
} catch (e) {
this.context.logger.logError('[AdapterManager] OneBot11 适配器初始化失败:', e);
}
// 初始化 NapCat Protocol 适配器 (默认关闭,需要配置启用)
try {
const napcatProtocol = new NapCatProtocolAdapter(this.core, this.context, this.pathWrapper);
this.napcatProtocolAdapter = new NapCatProtocolAdapterWrapper(napcatProtocol);
this.adapters.set('napcat-protocol', this.napcatProtocolAdapter);
if (this.napcatProtocolAdapter.enabled) {
await this.napcatProtocolAdapter.init();
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器初始化完成');
} else {
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器未启用,跳过初始化');
}
} catch (e) {
this.context.logger.logError('[AdapterManager] NapCat Protocol 适配器初始化失败:', e);
}
this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
}
// 获取 OneBot11 适配器
getOneBotAdapter (): NapCatOneBot11Adapter | null {
return this.onebotAdapter?.getAdapter() ?? null;
}
// 获取 NapCat Protocol 适配器
getNapCatProtocolAdapter (): NapCatProtocolAdapter | null {
return this.napcatProtocolAdapter?.getAdapter() ?? null;
}
// 获取指定适配器
getAdapter (name: ProtocolAdapterType): IProtocolAdapter | undefined {
return this.adapters.get(name);
}
// 获取所有已启用的适配器
getEnabledAdapters (): IProtocolAdapter[] {
return Array.from(this.adapters.values()).filter(adapter => adapter.enabled);
}
// 获取所有适配器
getAllAdapters (): IProtocolAdapter[] {
return Array.from(this.adapters.values());
}
// 关闭所有适配器
async closeAllAdapters (): Promise<void> {
this.context.logger.log('[AdapterManager] 开始关闭所有协议适配器...');
for (const [name, adapter] of this.adapters) {
try {
await adapter.close();
this.context.logger.log(`[AdapterManager] ${name} 适配器已关闭`);
} catch (e) {
this.context.logger.logError(`[AdapterManager] 关闭 ${name} 适配器失败:`, e);
}
}
this.adapters.clear();
this.context.logger.log('[AdapterManager] 所有协议适配器已关闭');
}
// 重新加载指定适配器
async reloadAdapter (name: ProtocolAdapterType): Promise<void> {
const adapter = this.adapters.get(name);
if (adapter) {
await adapter.close();
await adapter.init();
this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
}
}
}
export { NapCatOneBot11Adapter } from 'napcat-onebot';
export { NapCatProtocolAdapter } from 'napcat-protocol';

View File

@@ -1,30 +0,0 @@
{
"name": "napcat-adapter",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-onebot": "workspace:*",
"napcat-protocol": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

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

View File

@@ -1,28 +1,31 @@
{
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"./src/*": {
"import": "./src/*"
"dependencies": {
"compressing": "^1.10.1",
"json5": "^2.2.3",
"ajv": "^8.13.0",
"file-type": "^21.0.0",
"napcat-image-size": "workspace:*",
"napcat-core": "workspace:*",
"silk-wasm": "^3.6.1",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

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

@@ -0,0 +1,84 @@
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
import { LogWrapper } from '@/napcat-common/log';
import { EncodeArgs } from '@/napcat-common/audio-worker';
import { FFmpegService } from '@/napcat-common/ffmpeg';
import { runTask } from './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) {
const pttFileInfo = await fsPromise.stat(pttPath);
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
logger.log('通过文件大小估算语音的时长:', 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) {
try {
const file = await fsPromise.readFile(filePath);
const pttPath = path.join(TEMP_DIR, randomUUID());
if (!isSilk(file)) {
logger.log(`语音文件${filePath}需要转换成silk`);
const pcmPath = `${pttPath}.pcm`;
// 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 };
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 {
converted: true,
path: pttPath,
duration: silk.duration / 1000,
};
} else {
let duration = 0;
try {
duration = getDuration(file) / 1000;
} catch (e: unknown) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
duration = await guessDuration(filePath, logger);
}
return {
converted: false,
path: filePath,
duration,
};
}
} catch (error: unknown) {
logger.logError('convert silk failed', error);
return {};
}
}

View File

@@ -1,6 +1,6 @@
import path from 'node:path';
import fs from 'node:fs';
import type { NapCatCore } from '@/napcat-core';
import type { NapCatCore } from 'napcat-core';
import json5 from 'json5';
import Ajv, { AnySchema, ValidateFunction } from 'ajv';

View File

@@ -6,7 +6,7 @@ import * as os from 'os';
import * as compressing from 'compressing'; // 修正导入方式
import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url';
import { LogWrapper } from '@/napcat-core/helper/log';
import { LogWrapper } from './log';
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
const urls = [

View File

@@ -1,9 +0,0 @@
/// <reference types="vite/client" />
declare global {
interface ImportMetaEnv {
readonly VITE_NAPCAT_VERSION: string;
}
}
export {};

View File

@@ -1,6 +1,6 @@
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
import { randomUUID } from 'crypto';
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core/index';
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core';
interface InternalMapKey {
timeout: number;
@@ -54,7 +54,7 @@ export class NTEventWrapper {
Service extends keyof ServiceNamingMapping,
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
T extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
>(eventName: `${Service}/${ServiceMethod}`): T | undefined {
> (eventName: `${Service}/${ServiceMethod}`): T | undefined {
const eventNameArr = eventName.split('/');
type eventType = {
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>>; };
@@ -112,7 +112,7 @@ export class NTEventWrapper {
Service extends keyof ServiceNamingMapping,
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
>(
> (
serviceAndMethod: `${Service}/${ServiceMethod}`,
...args: Parameters<EventType>
): Promise<Awaited<ReturnType<EventType>>> {
@@ -123,7 +123,7 @@ export class NTEventWrapper {
Listener extends keyof ListenerNamingMapping,
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
>(
> (
listenerAndMethod: `${Listener}/${ListenerMethod}`,
checker: (...args: Parameters<ListenerType>) => boolean,
waitTimes = 1,
@@ -177,7 +177,7 @@ export class NTEventWrapper {
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>,
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
>(
> (
serviceAndMethod: `${Service}/${ServiceMethod}`,
listenerAndMethod: `${Listener}/${ListenerMethod}`,
args: Parameters<EventType>,

View File

@@ -3,7 +3,7 @@
* FFmpeg
*/
import { LogWrapper } from '@/napcat-core/helper/log';
import { LogWrapper } from './log';
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';

View File

@@ -27,27 +27,21 @@ export interface IFFmpegAdapter {
readonly name: string;
/** 是否可用 */
isAvailable (): Promise<boolean>;
isAvailable(): Promise<boolean>;
/**
* ()
* @param videoPath
* @returns
*/
getVideoInfo (videoPath: string): Promise<VideoInfoResult>;
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
/**
*
* @param filePath
* @returns ()
*/
getDuration (filePath: string): Promise<number>;
/**
* Silk
* @param filePath
*/
isSilk (filePath: string): Promise<boolean>;
getDuration(filePath: string): Promise<number>;
/**
* PCM
@@ -55,7 +49,7 @@ export interface IFFmpegAdapter {
* @param pcmPath PCM
* @returns PCM Buffer
*/
convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>;
convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
/**
*
@@ -63,14 +57,12 @@ export interface IFFmpegAdapter {
* @param outputFile
* @param format ('amr' | 'silk' )
*/
convertFile (inputFile: string, outputFile: string, format: string): Promise<void>;
convertFile(inputFile: string, outputFile: string, format: string): Promise<void>;
/**
*
* @param videoPath
* @param thumbnailPath
*/
extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
}

View File

@@ -5,7 +5,7 @@
import { platform, arch } from 'node:os';
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 type { FFmpeg } from './ffmpeg-addon';
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
@@ -68,13 +68,13 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
const addon = this.ensureAddon();
const info = await addon.getVideoInfo(videoPath);
const format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
let format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
console.log('[FFmpegAddonAdapter] Detected format:', format);
return {
width: info.width,
height: info.height,
duration: info.duration,
format,
format: format,
thumbnail: info.image,
};
}
@@ -87,22 +87,6 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
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
*/
@@ -122,11 +106,6 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
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; }>;
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
}

View File

@@ -3,14 +3,14 @@
* 使 execFile FFmpeg
*/
import { readFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
import { readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileTypeFromFile } from 'file-type';
import { imageSizeFallBack } from 'napcat-image-size/src/index';
import { downloadFFmpegIfNotExists } from './download-ffmpeg';
import { LogWrapper } from '@/napcat-core/helper/log';
import { LogWrapper } from './log';
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
const execFileAsync = promisify(execFile);
@@ -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
*/
@@ -257,8 +241,4 @@ export class FFmpegExecAdapter implements IFFmpegAdapter {
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

@@ -3,7 +3,7 @@ import path from 'path';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import { platform } from 'node:os';
import { LogWrapper } from '@/napcat-core/helper/log';
import { LogWrapper } from './log';
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
@@ -53,6 +53,7 @@ export class FFmpegService {
throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
}
return this.adapter.name;
}
/**
@@ -64,10 +65,7 @@ export class FFmpegService {
}
return this.adapter;
}
public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.convertToNTSilkTct(inputFile, outputFile);
}
/**
* FFmpeg
* @deprecated 使 init()
@@ -95,27 +93,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();
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
*/

View File

@@ -1,4 +1,4 @@
import { Peer } from './types';
import { Peer } from '@/napcat-core';
import { randomUUID } from 'crypto';
class TimeBasedCache<K, V> {

View File

@@ -2,14 +2,11 @@ import fs from 'fs';
import { stat } from 'fs/promises';
import crypto, { randomUUID } from 'crypto';
import path from 'node:path';
import http from 'node:http';
import tls from 'node:tls';
import { solveProblem } from '@/napcat-common/src/helper';
import { solveProblem } from '@/napcat-common/helper';
export interface HttpDownloadOptions {
url: string;
headers?: Record<string, string> | string;
proxy?: string;
}
type Uri2LocalRes = {
@@ -99,7 +96,6 @@ export function calculateFileMD5 (filePath: string): Promise<string> {
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
let url: string;
let proxy: string | undefined;
let headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
};
@@ -108,7 +104,6 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
headers['Host'] = new URL(url).hostname;
} else {
url = options.url;
proxy = options.proxy;
if (options.headers) {
if (typeof options.headers === 'string') {
headers = JSON.parse(options.headers);
@@ -120,18 +115,6 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
if (useReferer && !headers['Referer']) {
headers['Referer'] = url;
}
// 如果配置了代理,使用代理下载
if (proxy) {
try {
const response = await httpDownloadWithProxy(url, headers, proxy);
return new Response(response, { status: 200, statusText: 'OK' });
} catch (proxyError) {
// 如果代理失败,记录错误并尝试直接下载
console.error('代理下载失败,尝试直接下载:', proxyError);
}
}
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
if (err.cause) {
throw err.cause;
@@ -141,220 +124,6 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
return fetchRes;
}
/**
* 使用 HTTP/HTTPS 代理下载文件
*/
function httpDownloadWithProxy (url: string, headers: Record<string, string>, proxy: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const targetUrl = new URL(url);
const proxyUrl = new URL(proxy);
const isTargetHttps = targetUrl.protocol === 'https:';
const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
// 代理认证头
const proxyAuthHeader = proxyUrl.username && proxyUrl.password
? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') }
: {};
if (isTargetHttps) {
// HTTPS 目标:需要通过 CONNECT 建立隧道
const connectReq = http.request({
host: proxyUrl.hostname,
port: proxyPort,
method: 'CONNECT',
path: `${targetUrl.hostname}:${targetUrl.port || 443}`,
headers: {
'Host': `${targetUrl.hostname}:${targetUrl.port || 443}`,
...proxyAuthHeader,
},
});
connectReq.on('connect', (res, socket) => {
if (res.statusCode !== 200) {
socket.destroy();
reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`));
return;
}
// 在隧道上建立 TLS 连接
const tlsSocket = tls.connect({
socket: socket,
servername: targetUrl.hostname,
rejectUnauthorized: true,
}, () => {
// TLS 握手成功,发送 HTTP 请求
const requestPath = targetUrl.pathname + targetUrl.search;
const requestHeaders = {
...headers,
'Host': targetUrl.hostname,
'Connection': 'close',
};
const headerLines = Object.entries(requestHeaders)
.map(([key, value]) => `${key}: ${value}`)
.join('\r\n');
const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`;
tlsSocket.write(httpRequest);
});
// 解析 HTTP 响应
let responseData = Buffer.alloc(0);
let headersParsed = false;
let statusCode = 0;
let isChunked = false;
let bodyData = Buffer.alloc(0);
let redirectLocation: string | null = null;
tlsSocket.on('data', (chunk: Buffer) => {
responseData = Buffer.concat([responseData, chunk]);
if (!headersParsed) {
const headerEndIndex = responseData.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
headersParsed = true;
const headerStr = responseData.subarray(0, headerEndIndex).toString();
const headerLines = headerStr.split('\r\n');
// 解析状态码
const statusLine = headerLines[0];
const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/);
statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0;
// 解析响应头
for (const line of headerLines.slice(1)) {
const [key, ...valueParts] = line.split(':');
const value = valueParts.join(':').trim();
if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') {
isChunked = true;
} else if (key?.toLowerCase() === 'location') {
redirectLocation = value;
}
}
bodyData = responseData.subarray(headerEndIndex + 4);
}
} else {
bodyData = Buffer.concat([bodyData, chunk]);
}
});
tlsSocket.on('end', () => {
// 处理重定向
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
const redirectUrl = redirectLocation.startsWith('http')
? redirectLocation
: `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`;
httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject);
return;
}
if (statusCode !== 200) {
reject(new Error(`下载失败: ${statusCode}`));
return;
}
// 处理 chunked 编码
if (isChunked) {
resolve(parseChunkedBody(bodyData));
} else {
resolve(bodyData);
}
});
tlsSocket.on('error', (err) => {
reject(new Error(`TLS 连接错误: ${err.message}`));
});
});
connectReq.on('error', (err) => {
reject(new Error(`代理连接错误: ${err.message}`));
});
connectReq.end();
} else {
// HTTP 目标:直接通过代理请求
const req = http.request({
host: proxyUrl.hostname,
port: proxyPort,
method: 'GET',
path: url, // 完整 URL
headers: {
...headers,
'Host': targetUrl.hostname,
...proxyAuthHeader,
},
}, (response) => {
handleResponse(response, resolve, reject, url, headers, proxy);
});
req.on('error', (err) => {
reject(new Error(`代理请求错误: ${err.message}`));
});
req.end();
}
});
}
/**
* 解析 chunked 编码的响应体
*/
function parseChunkedBody (data: Buffer): Buffer {
const chunks: Buffer[] = [];
let offset = 0;
while (offset < data.length) {
// 查找 chunk 大小行的结束
const lineEnd = data.indexOf('\r\n', offset);
if (lineEnd === -1) break;
const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展
const chunkSize = parseInt(sizeStr!, 16);
if (chunkSize === 0) break; // 最后一个 chunk
const chunkStart = lineEnd + 2;
const chunkEnd = chunkStart + chunkSize;
if (chunkEnd > data.length) break;
chunks.push(data.subarray(chunkStart, chunkEnd));
offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n
}
return Buffer.concat(chunks);
}
/**
* 处理 HTTP 响应
*/
function handleResponse (
response: http.IncomingMessage,
resolve: (value: Buffer) => void,
reject: (reason: Error) => void,
_url: string,
headers: Record<string, string>,
proxy: string
): void {
// 处理重定向
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`));
return;
}
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => resolve(Buffer.concat(chunks)));
response.on('error', reject);
}
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
const useReferer = typeof options === 'string';
let resp = await tryDownload(options);
@@ -407,7 +176,7 @@ export async function checkUriType (Uri: string) {
return { Uri, Type: FileUriType.Unknown };
}
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
const filePath = path.join(dir, filename);
@@ -422,7 +191,7 @@ export async function uriToLocalFile (dir: string, uri: string, filename: string
}
case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}

View File

@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
import { PacketMsg } from '@/napcat-core/packet/message/message';
interface ForwardMsgJson {
app: string;
app: string
config: ForwardMsgJsonConfig,
desc: string,
extra: ForwardMsgJsonExtra,
meta: ForwardMsgJsonMeta,
prompt: string,
ver: string,
view: string;
view: string
}
interface ForwardMsgJsonConfig {
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
forward: number,
round: number,
type: string,
width: number;
width: number
}
interface ForwardMsgJsonExtra {
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
}
interface ForwardMsgJsonMeta {
detail: ForwardMsgJsonMetaDetail;
detail: ForwardMsgJsonMetaDetail
}
interface ForwardMsgJsonMetaDetail {
news: {
text: string;
text: string
}[],
resid: string,
source: string,
summary: string,
uniseq: string;
uniseq: string
}
interface ForwardAdaptMsg {
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
}
export class ForwardMsgBuilder {
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
const id = uuid ?? crypto.randomUUID();
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
const id = crypto.randomUUID();
const isGroupMsg = msg.some(m => m.isGroupMsg);
if (!source) {
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
@@ -104,19 +104,13 @@ export class ForwardMsgBuilder {
return this.build(resId, []);
}
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
return this.build(resId, packetMsg.map(msg => ({
senderName: msg.senderName,
isGroupMsg: msg.groupId !== undefined,
msg: msg.msg.map(m => ({
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
})),
})),
source,
news,
summary,
prompt,
uuid,
);
})), source, news, summary, prompt);
}
}

View File

@@ -1,12 +1,8 @@
import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types';
import { compareSemVer } from './version';
import { getAllGitHubTags as getAllTagsFromMirror } from './mirror';
// 导出 compareSemVer 供其他模块使用
export { compareSemVer } from './version';
import { QQLevel } from '@/napcat-core';
import { QQVersionConfigType } from './types';
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) => {
@@ -216,25 +212,3 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined;
}
// ============== GitHub Tags 获取 ==============
// 使用 mirror 模块统一管理镜像
export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror);
}
export async function getLatestTag (mirror?: string): Promise<string> {
const { tags } = await getAllTags(mirror);
// 使用 SemVer 规范排序
tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1);
if (!latest) {
throw new Error('No tags found');
}
// 去掉开头的 v
return latest.replace(/^v/, '');
}

View File

@@ -1,24 +0,0 @@
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export interface ILogWrapper {
fileLogEnabled: boolean;
consoleLogEnabled: boolean;
cleanOldLogs (logDir: string): void;
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel): void;
setLogSelfInfo (selfInfo: { nick: string; uid: string; }): void;
setFileLogEnabled (isEnabled: boolean): void;
setConsoleLogEnabled (isEnabled: boolean): void;
formatMsg (msg: any[]): string;
_log (level: LogLevel, ...args: any[]): void;
log (...args: any[]): void;
logDebug (...args: any[]): void;
logError (...args: any[]): void;
logWarn (...args: any[]): void;
logFatal (...args: any[]): void;
logMessage (msg: unknown, selfInfo: unknown): void;
}

View File

@@ -1,11 +1,9 @@
import winston, { format, transports } from 'winston';
import { truncateString } from 'napcat-common/src/helper';
import { truncateString } from './helper';
import path from 'node:path';
import fs from 'node:fs/promises';
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/napcat-core/index';
import { ILogWrapper } from 'napcat-common/src/log-interface';
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from 'napcat-core/index';
import EventEmitter from 'node:events';
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
@@ -58,7 +56,7 @@ class Subscription {
export const logSubscription = new Subscription();
export class LogWrapper implements ILogWrapper {
export class LogWrapper {
fileLogEnabled = true;
consoleLogEnabled = true;
logger: winston.Logger;
@@ -264,13 +262,7 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
}
if (element.fileElement) {
if (element.fileElement.fileUuid) {
return `[文件 ${element.fileElement.fileName}]`;
} else if (element.elementType === ElementType.TOFURECORD) {
return `[在线文件 ${element.fileElement.fileName}]`;
} else if (element.elementType === ElementType.ONLINEFOLDER) {
return `[在线文件夹 ${element.fileElement.fileName}/]`;
}
return `[文件 ${element.fileElement.fileName}]`;
}
if (element.videoElement) {
@@ -294,11 +286,7 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
}
if (element.markdownElement) {
if (element.markdownElement?.mdSummary) {
return element.markdownElement.mdSummary;
} else {
return '[Markdown 消息]';
}
return '[Markdown 消息]';
}
if (element.multiForwardMsgElement) {
@@ -307,8 +295,6 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
if (element.elementType === ElementType.GreyTip) {
return '[灰条消息]';
} else if (element.elementType === ElementType.FILE) {
return '[文件发送中]';
}
return `[未实现 (ElementType = ${element.elementType})]`;

View File

@@ -1,5 +1,6 @@
import { Peer } from '@/napcat-core';
import crypto from 'crypto';
import { Peer } from './types';
export class LimitedHashTable<K, V> {
private readonly keyToValue: Map<K, V> = new Map();
private readonly valueToKey: Map<V, K> = new Map();
@@ -58,12 +59,12 @@ export class LimitedHashTable<K, V> {
}
// 获取最近刚写入的几个值
getHeads (size: number): { key: K; value: V; }[] | undefined {
getHeads (size: number): { key: K; value: V }[] | undefined {
const keyList = this.getKeyList();
if (keyList.length === 0) {
return undefined;
}
const result: { key: K; value: V; }[] = [];
const result: { key: K; value: V }[] = [];
const listSize = Math.min(size, keyList.length);
for (let i = 0; i < listSize; i++) {
const key = keyList[listSize - i];
@@ -108,7 +109,7 @@ class MessageUniqueWrapper {
return shortId;
}
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer; } | undefined {
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer } | undefined {
const data = this.msgDataMap.getKey(shortId);
if (data) {
const [msgId, chatTypeStr, peerUid] = data.split('|');
@@ -136,12 +137,6 @@ class MessageUniqueWrapper {
this.msgIdMap.resize(maxSize);
this.msgDataMap.resize(maxSize);
}
isShortId (message_id: string): boolean {
const num = Number(message_id);
// 判断是否是整数并且在 INT32 的范围内
return Number.isInteger(num) && num >= -2147483648 && num <= 2147483647;
}
}
export const MessageUnique: MessageUniqueWrapper = new MessageUniqueWrapper();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
/**
* 性能监控器 - 用于统计函数调用次数、耗时等信息
*/
import * as fs from 'fs';
import * as path from 'path';
export interface FunctionStats {
name: string;
callCount: number;
totalTime: number;
averageTime: number;
minTime: number;
maxTime: number;
fileName?: string;
lineNumber?: number;
}
export class PerformanceMonitor {
private static instance: PerformanceMonitor;
private stats = new Map<string, FunctionStats>();
private startTimes = new Map<string, number>();
private reportInterval: NodeJS.Timeout | null = null;
static getInstance (): PerformanceMonitor {
if (!PerformanceMonitor.instance) {
PerformanceMonitor.instance = new PerformanceMonitor();
// 启动定时统计报告
PerformanceMonitor.instance.startPeriodicReport();
}
return PerformanceMonitor.instance;
}
/**
* 开始定时统计报告 (每60秒)
*/
private startPeriodicReport (): void {
if (this.reportInterval) {
clearInterval(this.reportInterval);
}
this.reportInterval = setInterval(() => {
if (this.stats.size > 0) {
this.printPeriodicReport();
this.writeDetailedLogToFile();
}
}, 60000); // 60秒
}
/**
* 停止定时统计报告
*/
stopPeriodicReport (): void {
if (this.reportInterval) {
clearInterval(this.reportInterval);
this.reportInterval = null;
}
}
/**
* 打印定时统计报告 (简化版本)
*/
private printPeriodicReport (): void {
const now = new Date().toLocaleString();
console.log(`\n=== 性能监控定时报告 [${now}] ===`);
const totalFunctions = this.stats.size;
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
console.log(`📊 总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms`);
// 显示Top 5最活跃的函数
console.log('\n🔥 最活跃函数 (Top 5):');
this.getTopByCallCount(5).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
});
// 显示Top 5最耗时的函数
console.log('\n⏱ 最耗时函数 (Top 5):');
this.getTopByTotalTime(5).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms`);
});
console.log('===============================\n');
}
/**
* 将详细统计数据写入日志文件
*/
private writeDetailedLogToFile (): void {
try {
const now = new Date();
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
const timeStr = now.toTimeString().split(' ')[0]?.replace(/:/g, '-') || 'unknown-time';
const timestamp = `${dateStr}_${timeStr}`;
const fileName = `${timestamp}.log.txt`;
const logPath = path.join(process.cwd(), 'logs', fileName);
// 确保logs目录存在
const logsDir = path.dirname(logPath);
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const totalFunctions = this.stats.size;
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
let logContent = '';
logContent += '=== 性能监控详细报告 ===\n';
logContent += `生成时间: ${now.toLocaleString()}\n`;
logContent += '统计周期: 60秒\n';
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
// 详细函数统计
logContent += '=== 所有函数详细统计 ===\n';
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
allStats.forEach((stat, index) => {
logContent += `${index + 1}. 函数: ${stat.name}\n`;
logContent += ` 文件: ${stat.fileName || 'N/A'}\n`;
logContent += ` 行号: ${stat.lineNumber || 'N/A'}\n`;
logContent += ` 调用次数: ${stat.callCount}\n`;
logContent += ` 总耗时: ${stat.totalTime.toFixed(4)}ms\n`;
logContent += ` 平均耗时: ${stat.averageTime.toFixed(4)}ms\n`;
logContent += ` 最小耗时: ${stat.minTime === Infinity ? 'N/A' : stat.minTime.toFixed(4)}ms\n`;
logContent += ` 最大耗时: ${stat.maxTime.toFixed(4)}ms\n`;
logContent += ` 性能占比: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
logContent += '\n';
});
// 排行榜统计
logContent += '=== 总耗时排行榜 (Top 20) ===\n';
this.getTopByTotalTime(20).forEach((stat, index) => {
logContent += `${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
});
logContent += '\n=== 调用次数排行榜 (Top 20) ===\n';
this.getTopByCallCount(20).forEach((stat, index) => {
logContent += `${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
});
logContent += '\n=== 平均耗时排行榜 (Top 20) ===\n';
this.getTopByAverageTime(20).forEach((stat, index) => {
logContent += `${index + 1}. ${stat.name} - 平均: ${stat.averageTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms\n`;
});
logContent += '\n=== 性能热点分析 ===\n';
// 找出最耗时的前10个函数
const hotSpots = this.getTopByTotalTime(10);
hotSpots.forEach((stat, index) => {
const efficiency = stat.callCount / stat.totalTime; // 每毫秒的调用次数
logContent += `${index + 1}. ${stat.name}\n`;
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
logContent += ` 优化建议: ${stat.averageTime > 10
? '考虑优化此函数的执行效率'
: stat.callCount > 1000
? '考虑减少此函数的调用频率'
: '性能表现良好'}\n\n`;
});
logContent += '=== 报告结束 ===\n';
// 写入文件
fs.writeFileSync(logPath, logContent, 'utf8');
console.log(`📄 详细性能报告已保存到: ${logPath}`);
} catch (error) {
console.error('写入性能日志文件时出错:', error);
}
}
/**
* 开始记录函数调用
*/
startFunction (functionName: string, fileName?: string, lineNumber?: number): string {
const callId = `${functionName}_${Date.now()}_${Math.random()}`;
this.startTimes.set(callId, performance.now());
// 初始化或更新统计信息
if (!this.stats.has(functionName)) {
this.stats.set(functionName, {
name: functionName,
callCount: 0,
totalTime: 0,
averageTime: 0,
minTime: Infinity,
maxTime: 0,
fileName,
lineNumber,
});
}
const stat = this.stats.get(functionName)!;
stat.callCount++;
return callId;
}
/**
* 结束记录函数调用
*/
endFunction (callId: string, functionName: string): void {
const startTime = this.startTimes.get(callId);
if (!startTime) return;
const endTime = performance.now();
const duration = endTime - startTime;
this.startTimes.delete(callId);
const stat = this.stats.get(functionName);
if (!stat) return;
stat.totalTime += duration;
stat.averageTime = stat.totalTime / stat.callCount;
stat.minTime = Math.min(stat.minTime, duration);
stat.maxTime = Math.max(stat.maxTime, duration);
}
/**
* 获取所有统计信息
*/
getStats (): FunctionStats[] {
return Array.from(this.stats.values());
}
/**
* 获取排行榜 - 按总耗时排序
*/
getTopByTotalTime (limit = 20): FunctionStats[] {
return this.getStats()
.sort((a, b) => b.totalTime - a.totalTime)
.slice(0, limit);
}
/**
* 获取排行榜 - 按调用次数排序
*/
getTopByCallCount (limit = 20): FunctionStats[] {
return this.getStats()
.sort((a, b) => b.callCount - a.callCount)
.slice(0, limit);
}
/**
* 获取排行榜 - 按平均耗时排序
*/
getTopByAverageTime (limit = 20): FunctionStats[] {
return this.getStats()
.sort((a, b) => b.averageTime - a.averageTime)
.slice(0, limit);
}
/**
* 清空统计数据
*/
clear (): void {
this.stats.clear();
this.startTimes.clear();
}
/**
* 打印统计报告
*/
printReport (): void {
console.log('\n=== 函数性能监控报告 ===');
console.log('\n🔥 总耗时排行榜 (Top 10):');
this.getTopByTotalTime(10).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
});
console.log('\n📈 调用次数排行榜 (Top 10):');
this.getTopByCallCount(10).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
});
console.log('\n⏱ 平均耗时排行榜 (Top 10):');
this.getTopByAverageTime(10).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 平均耗时: ${stat.averageTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
});
console.log('\n========================\n');
}
/**
* 获取JSON格式的统计数据
*/
toJSON (): FunctionStats[] {
return this.getStats();
}
}
// 全局性能监控器实例
export const performanceMonitor = PerformanceMonitor.getInstance();
// 在进程退出时打印报告并停止定时器
if (typeof process !== 'undefined') {
process.on('exit', () => {
performanceMonitor.stopPeriodicReport();
performanceMonitor.printReport();
});
process.on('SIGINT', () => {
performanceMonitor.stopPeriodicReport();
performanceMonitor.printReport();
process.exit(0);
});
process.on('SIGTERM', () => {
performanceMonitor.stopPeriodicReport();
performanceMonitor.printReport();
process.exit(0);
});
}

View File

@@ -1,4 +1,4 @@
import { LogWrapper } from '@/napcat-core/helper/log';
import { LogWrapper } from '@/napcat-common/log';
export function proxyHandlerOf (logger: LogWrapper) {
return {

View File

@@ -1,11 +1,10 @@
import fs from 'node:fs';
import { systemPlatform } from 'napcat-common/src/system';
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor } from 'napcat-common/src/helper';
import AppidTable from '@/napcat-core/external/appid.json';
import { LogWrapper } from './log';
import { getMajorPath } from '@/napcat-core/index';
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'napcat-common/src/types';
import path from 'node:path';
import { systemPlatform } from '@/napcat-common/system';
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor } from './helper';
import AppidTable from 'napcat-core/external/appid.json';
import { LogWrapper } from '@/napcat-common/log';
import { getMajorPath } from 'napcat-core';
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from './types';
export class QQBasicInfoWrapper {
QQMainPath: string | undefined;
@@ -22,10 +21,6 @@ export class QQBasicInfoWrapper {
// 基础目录获取
this.context = context;
this.QQMainPath = process.execPath;
if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') {
// 实用进程特殊处理 实用进程目录和QQ差远了
this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ');
}
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
// 基础信息获取 无快更则启用默认模板填充
@@ -104,10 +99,7 @@ export class QQBasicInfoWrapper {
}
getAppidV2ByMajor (QQVersion: string) {
if (!this.QQMainPath) {
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
}
const majorPath = getMajorPath(QQVersion, this.QQMainPath);
const majorPath = getMajorPath(QQVersion);
const appid = parseAppidFromMajor(majorPath);
return appid;
}

View File

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

View File

@@ -1,24 +0,0 @@
export interface SystemStatus {
cpu: {
model: string,
speed: string;
usage: {
system: string;
qq: string;
},
core: number;
},
memory: {
total: string;
usage: {
system: string;
qq: string;
};
},
arch: string;
}
export interface IStatusHelperSubscription {
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
emit (event: 'statusUpdate', status: SystemStatus): boolean;
}

View File

@@ -19,4 +19,4 @@ class Store {
const store = new Store();
export default store;
export default store;

View File

@@ -1,6 +0,0 @@
export type LogListener = (msg: string) => void;
export interface ISubscription {
subscribe (listener: LogListener): void;
unsubscribe (listener: LogListener): void;
notify (msg: string): void;
}

View File

@@ -15,14 +15,3 @@ export type QQVersionConfigType = {
export type QQAppidTableType = {
[key: string]: { appid: string, qua: string };
};
export interface Peer {
chatType: number; // 聊天类型
peerUid: string; // 对等方的唯一标识符
guildId?: string; // 可选的频道ID
}
export interface QQLevel {
crownNum: number;
sunNum: number;
moonNum: number;
starNum: number;
}

View File

@@ -1,120 +1 @@
// @ts-ignore
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
/**
* SemVer 2.0 正则表达式
* 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
export interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
* 解析并验证版本号是否符合 SemVer 2.0 规范
* @param version - 版本字符串 (支持 v 前缀)
* @returns SemVer 解析结果
*/
export function parseSemVer (version: string | undefined | null): SemVerInfo {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* 验证版本号是否符合 SemVer 2.0 规范
* @param version - 版本字符串
* @returns 是否有效
*/
export function isValidSemVer (version: string | undefined | null): boolean {
return parseSemVer(version).valid;
}
/**
* 比较两个 SemVer 版本号
* @param v1 - 版本号1
* @param v2 - 版本号2
* @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
*/
export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
const a = parseSemVer(v1);
const b = parseSemVer(v2);
if (!a.valid && !b.valid) {
return v1.localeCompare(v2) as -1 | 0 | 1;
}
if (!a.valid) return -1;
if (!b.valid) return 1;
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
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);
export const napCatVersion = '4.9.42';

View File

@@ -11,11 +11,11 @@
],
"esModuleInterop": true,
"outDir": "dist",
"rootDir": ".",
"rootDir": "src",
"noEmit": false,
"sourceMap": true,
"strict": true,
"noImplicitAny": false,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"alwaysStrict": true,
@@ -36,8 +36,8 @@
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": [
"../*"
"@/napcat-common/*": [
"src/*"
]
},
"skipLibCheck": true,

View File

@@ -5,7 +5,7 @@ export class NodeIDependsAdapter {
}
onMSFSsoError (_code: number, _desc: string) {
onMSFSsoError (_args: unknown) {
}
@@ -24,4 +24,4 @@ export class NodeIDependsAdapter {
// console.log('[NodeIDependsAdapter] onSendMsfReply', _seq, _cmd, _uk1, _uk2, Buffer.from(_rsp.pbBuffer).toString('hex'));
// }
}
}

View File

@@ -5,7 +5,12 @@ import {
IMAGE_HTTP_HOST_NT,
Peer,
PicElement,
PicSubType,
RawMessage,
SendFileElement,
SendPicElement,
SendPttElement,
SendVideoElement,
} from '@/napcat-core/types';
import path from 'path';
import fs from 'fs';
@@ -14,9 +19,16 @@ import { InstanceContext, NapCatCore, SearchResultItem } from '@/napcat-core/ind
import { fileTypeFromFile } from 'file-type';
import { RkeyManager } from '@/napcat-core/helper/rkey';
import { calculateFileMD5 } from 'napcat-common/src/file';
import pathLib from 'node:path';
import { defaultVideoThumbB64 } from 'napcat-common/src/video';
import { encodeSilk } from 'napcat-common/src/audio';
import { SendMessageContext } from 'napcat-onebot/api/msg';
import { getFileTypeForSendType } from '../helper/msg';
import { FFmpegService } from 'napcat-common/src/ffmpeg';
import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { FileId } from '../packet/transformer/proto/misc/fileid';
import { imageSizeFallBack } from 'napcat-image-size';
export class NTQQFileApi {
context: InstanceContext;
@@ -138,7 +150,7 @@ export class NTQQFileApi {
})).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 extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
@@ -146,38 +158,188 @@ export class NTQQFileApi {
if (fileName.indexOf('.') === -1) {
fileName += ext;
}
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
const fileSize = await this.getFileSize(filePath);
if (uploadGroupFile) {
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
return {
md5: fileMd5,
fileName,
path: mediaPath,
fileSize,
ext,
};
}
return {
md5: fileMd5,
fileName,
path: filePath,
path: mediaPath,
fileSize,
ext,
};
}
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = ''): Promise<SendFileElement> {
const {
fileName: _fileName,
path,
fileSize,
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
context.deleteAfterSentFiles.push(path);
return {
elementType: ElementType.FILE,
elementId: '',
fileElement: {
fileName: fileName || _fileName,
folderId,
filePath: path,
fileSize: fileSize.toString(),
},
};
}
async createValidSendPicElement (context: SendMessageContext, picPath: string, summary: string = '', subType: PicSubType = 0): Promise<SendPicElement> {
const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(picPath, ElementType.PIC, subType);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
const imageSize = await imageSizeFallBack(picPath);
context.deleteAfterSentFiles.push(path);
return {
elementType: ElementType.PIC,
elementId: '',
picElement: {
md5HexStr: md5,
fileSize: fileSize.toString(),
picWidth: imageSize.width,
picHeight: imageSize.height,
fileName,
sourcePath: path,
original: true,
picType: await getFileTypeForSendType(picPath),
picSubType: subType,
fileUuid: '',
fileSubId: '',
thumbFileSize: 0,
summary,
} as PicElement,
};
}
async createValidSendVideoElement (context: SendMessageContext, filePath: string, fileName: string = '', _diyThumbPath: string = ''): Promise<SendVideoElement> {
let videoInfo = {
width: 1920,
height: 1080,
time: 15,
format: 'mp4',
size: 0,
filePath,
};
let fileExt = 'mp4';
try {
const tempExt = (await fileTypeFromFile(filePath))?.ext;
if (tempExt) fileExt = tempExt;
} catch (e) {
this.context.logger.logError('获取文件类型失败', e);
}
const newFilePath = `${filePath}.${fileExt}`;
fs.copyFileSync(filePath, newFilePath);
context.deleteAfterSentFiles.push(newFilePath);
filePath = newFilePath;
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
context.deleteAfterSentFiles.push(path);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
if (!fs.existsSync(thumbPath)) {
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
throw new Error('获取视频缩略图失败');
}
} catch (e) {
this.context.logger.logError('获取视频信息失败', e);
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
if (_diyThumbPath) {
try {
await this.copyFile(_diyThumbPath, thumbPath);
} catch (e) {
this.context.logger.logError('复制自定义缩略图失败', e);
}
}
context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size;
const thumbMd5 = await calculateFileMD5(thumbPath);
context.deleteAfterSentFiles.push(thumbPath);
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`;
return {
elementType: ElementType.VIDEO,
elementId: '',
videoElement: {
fileName: uploadName,
filePath: path,
videoMd5: md5,
thumbMd5,
fileTime: videoInfo.time,
thumbPath: new Map([[0, thumbPath]]),
thumbSize,
thumbWidth: videoInfo.width,
thumbHeight: videoInfo.height,
fileSize: fileSize.toString(),
},
};
}
async createValidSendPttElement (_context: SendMessageContext, pttPath: string): Promise<SendPttElement> {
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger);
if (!silkPath) {
throw new Error('语音转换失败, 请检查语音文件是否正常');
}
const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(silkPath, ElementType.PTT);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
if (converted) {
fsPromises.unlink(silkPath).then().catch((e) => this.context.logger.logError('删除临时文件失败', e));
}
return {
elementType: ElementType.PTT,
elementId: '',
pttElement: {
fileName,
filePath: path,
md5HexStr: md5,
fileSize: fileSize.toString(),
duration: duration ?? 1,
formatType: 1,
voiceType: 1,
voiceChangeType: 0,
canConvert2Text: true,
waveAmplitudes: [
0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17,
],
fileSubId: '',
playState: 1,
autoConvertText: 0,
storeID: 0,
otherBusinessInfo: {
aiVoiceType: 0,
},
},
};
}
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/downloadFileForModelId',

View File

@@ -1,307 +0,0 @@
import { GeneralCallResult, InstanceContext, NapCatCore } from '@/napcat-core';
import {
createFlashTransferResult,
FileListResponse,
FlashFileSetInfo,
SendStatus,
UploadSceneType,
} from '@/napcat-core/data/flash';
import { Peer } from '@/napcat-core/types';
export class NTQQFlashApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/**
* 发起闪传上传任务
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
* @param thumbnailPath
* @param filesetName
*/
async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string): Promise<GeneralCallResult & {
createFlashTransferResult: createFlashTransferResult;
seq: number;
}> {
const flashService = this.context.session.getFlashTransferService();
const timestamp: number = Date.now();
const selfInfo = this.core.selfInfo;
const fileUploadArg = {
screen: 1, // 1
name: filesetName,
uploaders: [{
uin: selfInfo.uin,
uid: selfInfo.uid,
sendEntrance: '',
nickname: selfInfo.nick,
}],
coverPath: thumbnailPath,
paths: fileListToUpload,
excludePaths: [],
expireLeftTime: 0,
isNeedDelDeviceInfo: false,
isNeedDelLocation: false,
coverOriginalInfos: [
{
path: fileListToUpload[0] || '',
thumbnailPath,
},
],
uploadSceneType: UploadSceneType.KUPLOADSCENEAIOFILESELECTOR, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
detectPrivacyInfoResult: {
exists: false,
allDetectResults: new Map(),
},
};
const uploadResult = await flashService.createFlashTransferUploadTask(timestamp, fileUploadArg);
if (uploadResult.result === 0) {
this.context.logger.log('[Flash] 发起闪传任务成功');
return uploadResult;
} else {
this.context.logger.logError('[Flash] 发起闪传上传任务失败!!');
return uploadResult;
}
}
/**
* 下载闪传文件集
* @param fileSetId
*/
async downloadFileSetBySetId (fileSetId: string): Promise<GeneralCallResult & {
extraInfo: unknown;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.startFileSetDownload(fileSetId, 1, { isIncludeCompressInnerFiles: false }); // 为了方便,暂时硬编码
if (result.result === 0) {
this.context.logger.log('[Flash] 成功开始下载文件集');
} else {
this.context.logger.logError('[Flash] 尝试下载文件集失败!');
}
return result;
}
/**
* 获取闪传的外链分享
* @param fileSetId
*/
async getShareLinkBySetId (fileSetId: string): Promise<GeneralCallResult & {
shareLink: string;
expireTimestamp: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.getShareLinkReq(fileSetId);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取闪传外链分享成功:', result.shareLink);
} else {
this.context.logger.logError('[Flash] 获取闪传外链失败!!');
}
return result;
}
/**
* 从分享外链获取文件集id
* @param shareCode
*/
async fromShareLinkFindSetId (shareCode: string): Promise<GeneralCallResult & {
fileSetId: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.getFileSetIdByCode(shareCode);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取shareCode的文件集Id成功');
} else {
this.context.logger.logError('[Flash] 获取文件集ID失败');
}
return result;
}
/**
* 获取fileSet的文件结构信息 (未来可能需要深度遍历)
* == 注意返回结构和其它的不同没有GeneralCallResult!!! ==
* @param fileSetId
*/
async getFileListBySetId (fileSetId: string): Promise<FileListResponse> {
const flashService = this.context.session.getFlashTransferService();
const requestArg = {
seq: 0,
fileSetId,
isUseCache: false,
sceneType: 1, // 硬编码
reqInfos: [
{
count: 18, // 18 ??
paginationInfo: {},
parentId: '',
reqIndexPath: '',
reqDepth: 1,
filterCondition: {
fileCategory: 0,
filterType: 0,
},
sortConditions: [
{
sortField: 0,
sortOrder: 0,
},
],
isNeedPhysicalInfoReady: false,
},
],
};
const result = await flashService.getFileList(requestArg);
if (result.rsp.result === 0) {
this.context.logger.log('[Flash] 获取fileSet文件信息成功');
return result.rsp;
} else {
this.context.logger.logError(`[Flash] 获取文件信息失败ErrMsg: ${result.rsp.errMs}`);
return result.rsp;
}
}
/**
* 获取闪传文件集合信息
* @param fileSetId
*/
async getFileSetIndoBySetId (fileSetId: string): Promise<GeneralCallResult & {
seq: number;
isCache: boolean;
fileSet: FlashFileSetInfo;
}> {
const flashService = this.context.session.getFlashTransferService();
const requestArg = {
fileSetId,
};
const result = await flashService.getFileSet(requestArg);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取闪传文件集信息成功!');
} else {
this.context.logger.logError('[Flash] 获取闪传文件信息失败!!');
}
return result;
}
/**
* 发送闪传消息(私聊/群聊)
* @param fileSetId
* @param peer
*/
async sendFlashMessage (fileSetId: string, peer: Peer): Promise<{
errCode: number,
errMsg: string,
rsp: {
sendStatus: SendStatus[];
};
}> {
const flashService = this.context.session.getFlashTransferService();
const target = {
destUid: peer.peerUid,
destType: peer.chatType,
// destUin: peer.peerUin,
};
const requestsArg = {
fileSetId,
targets: [target],
};
const result = await flashService.sendFlashTransferMsg(requestsArg);
if (result.errCode === 0) {
this.context.logger.log('[Flash] 消息发送成功');
} else {
this.context.logger.logError(`[Flash] 消息发送失败!!原因:${result.errMsg}`);
}
return result;
}
/**
* 获取闪传文件集中某个文件的下载URL外链
* @param fileSetId
* @param options
*/
async getFileTransUrl (fileSetId: string, options: { fileName?: string; fileIndex?: number; }): Promise<GeneralCallResult & {
transferUrl: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await this.getFileListBySetId(fileSetId);
const { fileName, fileIndex } = options;
let targetFile: any;
let file: any;
const allFolder = result.fileLists;
// eslint-disable-next-line no-labels
searchLoop: for (const folder of allFolder) {
const fileList = folder.fileList;
for (let i = 0; i < fileList.length; i++) {
file = fileList[i];
if (fileName !== undefined && file.name === fileName) {
targetFile = file;
// eslint-disable-next-line no-labels
break searchLoop;
}
if (fileIndex !== undefined && i === fileIndex) {
targetFile = file;
// eslint-disable-next-line no-labels
break searchLoop;
}
}
}
if (targetFile === undefined) {
this.context.logger.logError('[Flash] 未找到对应文件!!');
return {
result: -1,
errMsg: '未找到对应文件',
transferUrl: '',
};
} else {
this.context.logger.log('[Flash] 找到对应文件,准备尝试获取传输链接');
const res = await flashService.startFileTransferUrl(targetFile);
return {
result: 0,
errMsg: '',
transferUrl: res.url,
};
}
}
async createFileThumbnail (filePath: string): Promise<any> {
const msgService = this.context.session.getMsgService();
const savePath = msgService.getFileThumbSavePathForSend(750, true);
const result = await this.core.util.createThumbnailImage(
'flashtransfer',
filePath,
savePath,
{
width: 520,
height: 520,
},
'jpeg',
null
);
if (result.result === 0) {
this.context.logger.log('获取缩略图成功!!');
result.targetPath = savePath;
return result;
}
return result;
}
}

View File

@@ -18,7 +18,7 @@ export class NTQQFriendApi {
async getBuddyV2SimpleInfoMap () {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
} else {
@@ -43,7 +43,7 @@ export class NTQQFriendApi {
return retMap;
}
async delBuddy (uid: string, tempBlock = false, tempBothDel = false) {
async delBuudy (uid: string, tempBlock = false, tempBothDel = false) {
return this.context.session.getBuddyService().delBuddy({
friendUid: uid,
tempBlock,
@@ -55,7 +55,7 @@ export class NTQQFriendApi {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids);
} else {

View File

@@ -15,9 +15,9 @@ import {
} from '@/napcat-core/index';
import { isNumeric, solveAsyncProblem } from 'napcat-common/src/helper';
import { LimitedHashTable } from 'napcat-common/src/message-unique';
import { NTEventWrapper } from 'napcat-common/src/event';
import { CancelableTask, TaskExecutor } from 'napcat-common/src/cancel-task';
import { createGroupDetailInfoV2Param, createGroupExtFilter, createGroupExtInfo } from '../data';
import { NTEventWrapper } from '../helper/event';
export class NTQQGroupApi {
context: InstanceContext;
@@ -395,7 +395,7 @@ export class NTQQGroupApi {
'NodeIKernelGroupListener/onMemberInfoChange',
[groupCode, [uid], forced],
(ret) => ret.result === 0,
(params: string, _: any, members: Map<string, GroupMember>) => params === GroupCode && members.size > 0 && members.has(uid),
(params, _, members) => params === GroupCode && members.size > 0 && members.has(uid),
1,
forced ? 2500 : 250
);

View File

@@ -7,5 +7,3 @@ export * from './webapi';
export * from './system';
export * from './packet';
export * from './file';
export * from './online';
export * from './flash';

View File

@@ -32,9 +32,9 @@ export class NTQQMsgApi {
return this.context.session.getMsgService().getSourceOfReplyMsgV2(peer, clientSeq, time);
}
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string = '', count: number = 20) {
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, count: number = 20) {
// 注意此处emojiType 可选值一般为1-2 2好像是unicode表情dec值 大部分情况 Taged Mlikiowa
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, cookie, false, count);
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, '', false, count);
}
async setEmojiLike (peer: Peer, msgSeq: string, emojiId: string, set: boolean = true) {

View File

@@ -1,241 +0,0 @@
import { InstanceContext, NapCatCore } from '@/napcat-core';
import { Peer } from '@/napcat-core/types';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GeneralCallResultStatus } from '@/napcat-core/services/common';
import { sleep } from '@/napcat-common/src/helper';
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
export class NTQQOnlineApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/**
* 这里不等待node返回因为the fuck wrapper.node 根本不返回(会卡死不知道为什么)!!! 只能手动查询判断死活
* @param peer
* @param filePath
* @param fileName
*/
async sendOnlineFile (peer: Peer, filePath: string, fileName: string): Promise<any> {
if (!fs.existsSync(filePath)) {
throw new Error(`[NapCat] 文件不存在: ${filePath}`);
}
const actualFileName = fileName || path.basename(filePath);
const fileSize = fs.statSync(filePath).size.toString();
const fileElementToSend = [{
elementType: 23,
elementId: '',
fileElement: {
fileName: actualFileName,
filePath,
fileSize,
},
}];
const msgService = this.context.session.getMsgService();
const startTime = Math.floor(Date.now() / 1000) - 2; // 容错时间窗口
msgService.sendMsg('0', peer, fileElementToSend, new Map()).catch((_e: any) => {
});
const maxRetries = 10;
let retryCount = 0;
while (retryCount < maxRetries) {
await sleep(1000);
retryCount++;
try {
const msgListResult = await msgService.getOnlineFileMsgs(peer);
const msgs = msgListResult?.msgList || [];
const foundMsg = msgs.find((msg: any) => {
if (parseInt(msg.msgTime) < startTime) return false;
const validElement = msg.elements.find((el: any) => {
if (el.elementType !== 23 || !el.fileElement) return false;
const isNameMatch = el.fileElement.fileName === actualFileName;
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(filePath);
return isNameMatch && isPathMatch;
});
return !!validElement;
});
if (foundMsg) {
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 23);
this.context.logger.log('[OnlineFile] 在线文件发送成功!');
return {
result: GeneralCallResultStatus.OK,
errMsg: '',
msgId: foundMsg.msgId,
elementId: targetElement?.elementId || '',
};
}
} catch (_e) {
}
}
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
return {
result: GeneralCallResultStatus.ERROR,
errMsg: '[NapCat] Send Online File Timeout: Message not found in history.',
};
}
/**
* 发送在线文件夹
* @param peer
* @param folderPath
* @param folderName
*/
async sendOnlineFolder (peer: Peer, folderPath: string, folderName?: string): Promise<any> {
const actualFolderName = folderName || path.basename(folderPath);
if (!fs.existsSync(folderPath)) {
return { result: GeneralCallResultStatus.ERROR, errMsg: `Folder not found: ${folderPath}` };
}
if (!fs.statSync(folderPath).isDirectory()) {
return { result: GeneralCallResultStatus.ERROR, errMsg: `Path is not a directory: ${folderPath}` };
}
const folderElementItem = {
elementType: 30,
elementId: '',
fileElement: {
fileName: actualFolderName,
filePath: folderPath,
fileSize: "",
},
};
const msgService = this.context.session.getMsgService();
const startTime = Math.floor(Date.now() / 1000) - 2;
msgService.sendMsg('0', peer, [folderElementItem], new Map()).catch((_e: any) => {
});
const maxRetries = 10;
let retryCount = 0;
while (retryCount < maxRetries) {
await sleep(1000);
retryCount++;
try {
const msgListResult = await msgService.getOnlineFileMsgs(peer);
const msgs = msgListResult?.msgList || [];
const foundMsg = msgs.find((msg: any) => {
if (parseInt(msg.msgTime) < startTime) return false;
const validElement = msg.elements.find((el: any) => {
if (el.elementType !== 30 || !el.fileElement) return false;
const isNameMatch = el.fileElement.fileName === actualFolderName;
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(folderPath);
return isNameMatch && isPathMatch;
});
return !!validElement;
});
if (foundMsg) {
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 30);
this.context.logger.log('[OnlineFile] 在线文件夹发送成功!');
return {
result: GeneralCallResultStatus.OK,
errMsg: '',
msgId: foundMsg.msgId,
elementId: targetElement?.elementId || '',
};
}
} catch (_e) {
}
}
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
return {
result: GeneralCallResultStatus.ERROR,
errMsg: '[NapCat] Send Online Folder Timeout: Message not found in history.',
};
}
/**
* 获取好友的在线文件消息
* @param peer
*/
async getOnlineFileMsg (peer: Peer): Promise<any> {
const msgService = this.context.session.getMsgService();
return await msgService.getOnlineFileMsgs(peer);
}
/**
* 取消在线文件的发送
* @param peer
* @param msgId
*/
async cancelMyOnlineFileMsg (peer: Peer, msgId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
await msgService.cancelSendMsg(peer, msgId);
}
/**
* 拒绝接收在线文件
* @param peer
* @param msgId
* @param elementId
*/
async refuseOnlineFileMsg (peer: Peer, msgId: string, elementId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
const arrToSend = {
msgId,
peerUid: peer.peerUid,
chatType: 1,
elementId,
downloadType: 1,
downSourceType: 1,
};
await msgService.refuseGetRichMediaElement(arrToSend);
}
/**
* 接收在线文件/文件夹
* @param peer
* @param msgId
* @param elementId
* @constructor
*/
async receiveOnlineFileOrFolder (peer: Peer, msgId: string, elementId: string): Promise<any> {
const msgService = this.context.session.getMsgService();
const arrToSend = {
msgId,
peerUid: peer.peerUid,
chatType: 1,
elementId,
downSourceType: 1,
downloadType: 1,
};
return await msgService.getRichMediaElement(arrToSend);
}
/**
* 在线文件/文件夹转离线
* @param peer
* @param msgId
*/
async switchFileToOffline (peer: Peer, msgId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
await msgService.switchToOfflineSendMsg(peer, msgId);
}
}

View File

@@ -1,9 +1,9 @@
import * as os from 'os';
import offset from '@/napcat-core/external/napi2native.json';
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
import { LogWrapper } from 'napcat-common/src/log';
import { PacketClientSession } from '@/napcat-core/packet/clientSession';
import { napCatVersion } from 'napcat-common/src/version';
import { LogWrapper } from '../helper/log';
interface OffsetType {
[key: string]: {

View File

@@ -13,17 +13,6 @@ import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { qunAlbumControl } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
export interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
export class NTQQWebApi {
context: InstanceContext;
core: NapCatCore;
@@ -36,12 +25,12 @@ export class NTQQWebApi {
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
group_code: groupCode,
msg_seq: msgSeq,
msg_random: msgRandom,
target_group_code: targetGroupCode,
}).toString()}`;
bkn: this.getBknFromCookie(cookieObject),
group_code: groupCode,
msg_seq: msgSeq,
msg_random: msgRandom,
target_group_code: targetGroupCode,
}).toString()}`;
try {
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
} catch {
@@ -63,11 +52,11 @@ export class NTQQWebApi {
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
page_start: page_start.toString(),
page_limit: page_limit.toString(),
group_code: GroupCode,
}).toString()}`;
bkn: this.getBknFromCookie(cookieObject),
page_start: page_start.toString(),
page_limit: page_limit.toString(),
group_code: GroupCode,
}).toString()}`;
try {
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
url,
@@ -87,16 +76,16 @@ export class NTQQWebApi {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const retList: Promise<WebApiGroupMemberRet>[] = [];
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: '0',
end: '40',
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: '0',
end: '40',
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
return [];
@@ -112,16 +101,16 @@ export class NTQQWebApi {
// 遍历批量请求
for (let i = 2; i <= PageNum; i++) {
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: ((i - 1) * 40).toString(),
end: (i * 40).toString(),
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: ((i - 1) * 40).toString(),
end: (i * 40).toString(),
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
retList.push(ret);
}
@@ -164,7 +153,16 @@ export class NTQQWebApi {
imgWidth: number = 540,
imgHeight: number = 300
) {
interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
@@ -180,18 +178,18 @@ export class NTQQWebApi {
imgHeight: imgHeight.toString(),
};
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
text: Content,
pinned: pinned.toString(),
type: type.toString(),
settings,
...(picId === '' ? {} : externalParam),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
text: Content,
pinned: pinned.toString(),
type: type.toString(),
settings,
...(picId === '' ? {} : externalParam),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret;
} catch {
@@ -203,20 +201,20 @@ export class NTQQWebApi {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
ft: '23',
ni: '1',
n: '1',
i: '1',
log_read: '1',
platform: '1',
s: '-1',
}).toString()}&n=20`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
ft: '23',
ni: '1',
n: '1',
i: '1',
log_read: '1',
platform: '1',
s: '-1',
}).toString()}&n=20`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret?.ec === 0 ? ret : undefined;
} catch {
@@ -224,17 +222,17 @@ export class NTQQWebApi {
}
}
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
private async getDataInternal (cookieObject: { [key: string]: string }, groupCode: string, type: number) {
let resJson;
try {
const res = await RequestUtil.HttpGetText(
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
gc: groupCode,
type: type.toString(),
}).toString()}`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
gc: groupCode,
type: type.toString(),
}).toString()}`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
if (match?.[1]) {
@@ -247,7 +245,7 @@ export class NTQQWebApi {
}
}
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
private async getHonorList (cookieObject: { [key: string]: string }, groupCode: string, type: number) {
const data = await this.getDataInternal(cookieObject, groupCode, type);
if (!data) {
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
@@ -306,11 +304,11 @@ export class NTQQWebApi {
return HonorInfo;
}
private cookieToString (cookieObject: { [key: string]: string; }) {
private cookieToString (cookieObject: { [key: string]: string }) {
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
}
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
public getBknFromCookie (cookieObject: { [key: string]: string }) {
const sKey = cookieObject['skey'] as string;
let hash = 5381;
@@ -363,7 +361,7 @@ export class NTQQWebApi {
uin,
getMemberRole: '0',
});
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string }> } }>(api + params.toString(), 'GET', '', {
Cookie: cookies,
});
return response.data.album;
@@ -386,7 +384,7 @@ export class NTQQWebApi {
sAlbumID,
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, {
const post = await RequestUtil.HttpGetJson<{ data: { session: string }, ret: number, msg: string }>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/json',
});
@@ -432,7 +430,7 @@ export class NTQQWebApi {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json() as { ret: number, msg: string; }; if (post.ret !== 0) {
const post = await response.json() as { ret: number, msg: string }; if (post.ret !== 0) {
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
}
offset += chunk.length;
@@ -477,10 +475,10 @@ export class NTQQWebApi {
const client_key = Date.now() * 1000;
return await this.context.session.getAlbumService().doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
qunId,
2,
createAlbumMediaFeed(uin, albumId, lloc),
@@ -511,13 +509,13 @@ export class NTQQWebApi {
const uin = this.core.selfInfo.uin || '10001';
return await this.context.session.getAlbumService().doQunLike(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
}, {
id,
status: 1,
},
map_info: [],
map_bytes_info: [],
map_user_account: [],
}, {
id,
status: 1,
},
createAlbumFeedPublish(qunId, uin, albumId, lloc)
);
}

View File

@@ -1,358 +0,0 @@
export interface FlashBaseRequest {
fileSetId: string;
}
export interface UploaderInfo {
uin: string,
nickname: string,
uid: string,
sendEntrance: string, // ""
}
export interface thumbnailInfo {
id: string,
url: {
spec: number,
uri: string,
}[],
localCachePath: string,
}
export interface SendTarget {
destType: number; // 1私聊
destUin?: string,
destUid: string,
}
export interface SendTargetRequests {
fileSetId: string;
targets: SendTarget[];
}
export interface DownloadStatusInfo {
result: number; // 0
fileSetId: string;
status: number;
info: {
curDownLoadFailFileNum: number,
curDownLoadedPauseFileNum: number,
curDownLoadedFileNum: number,
curRealDownLoadedFileNum: number,
curDownloadingFileNum: number,
totalDownLoadedFileNum: number,
curDownLoadedBytes: string, // "0"
totalDownLoadedBytes: string,
curSpeedBps: number,
avgSpeedBps: number,
maxSpeedBps: number,
remainDownLoadSeconds: number,
failFileIdList: [],
allFileIdList: [],
hasNormalFileDownloading: boolean,
onlyCompressInnerFileDownloading: boolean,
isAllFileAlreadyDownloaded: boolean,
saveFileSetDir: string,
allWaitingStatusTask: boolean,
downloadSceneType: DownloadSceneType,
retryCount: number,
statisticInfo: {
downloadTaskId: string,
downloadFilesetName: string,
downloadFileTypeDistribution: string,
downloadFileSizeDistribution: string;
},
albumStorageFailImageNum: number,
albumStorageFailVideoNum: number,
albumStorageFailFileIdList: [],
albumStorageSucImageNum: number,
albumStorageSucVideoNum: number,
albumStorageSucFileIdList: [],
albumStorageFileNum: number;
};
}
export interface physicalInfo {
id: string,
url: string,
status: number, // 2 已下载
processing: string,
localPath: string,
width: 0,
height: 0,
time: number,
}
export interface downloadInfo {
status: number,
curDownLoadBytes: string,
totalFileBytes: string,
errorCode: number,
}
export interface uploadInfo {
uploadedBytes: string,
errorCode: number,
svrRrrCode: number,
errMsg: string,
isNeedDelDeviceInfo: boolean,
thumbnailUploadState: number;
isSecondHit: boolean,
hasModifiedErr: boolean,
}
export interface folderUploadInfo {
totalUploadedFileSize: string;
successCount: number;
failedCount: number;
}
export interface folderDownloadInfo {
totalDownloadedFileSize: string;
totalFileSize: string;
totalDownloadFileCount: number;
successCount: number;
failedCount: number;
pausedCount: number;
cancelCount: number;
downloadingCount: number;
partialDownloadCount: number;
curLevelDownloadedFileCount: number;
curLevelUnDownloadedFileCount: number;
}
export interface compressFileFolderInfo {
downloadStatus: number;
saveFileDirPath: string;
totalFileCount: string;
totalFileSize: string;
}
export interface albumStorgeInfo {
status: number;
localIdentifier: string;
errorCode: number;
timeCost: number;
}
export interface FlashOneFileInfo {
fileSetId: string;
cliFileId: string; // client?? 或许可以换取url
compressedFileFolderId: string;
archiveIndex: 0;
indexPath: string;
isDir: boolean; // 文件或者文件夹!!
parentId: string;
depth: number; // 1
cliFileIndex: number;
fileType: number; // 枚举!! 已完成枚举!!
name: string;
namePinyin: string;
isCover: boolean;
isCoverOriginal: boolean;
fileSize: string;
fileCount: number;
thumbnail: thumbnailInfo;
physical: physicalInfo;
srvFileId: string; // service?? 服务器上面的id吗
srvParentFileId: string;
svrLastUpdateTimestamp: string;
downloadInfo: downloadInfo;
saveFilePath: string;
search_relative_path: string;
disk_relative_path: string;
uploadInfo: uploadInfo;
status: number;
uploadStatus: number; // 3已上传成功
downloadStatus: number; // 0未下载
folderUploadInfo: folderUploadInfo;
folderDownloadInfo: folderDownloadInfo;
sha1: string;
bookmark: string;
compressFileFolderInfo: compressFileFolderInfo;
uploadPauseReason: string;
downloadPauseReason: string;
filePhysicalSize: string;
thumbnail_sha1: string | null;
thumbnail_size: string | null;
needAlbumStorage: boolean;
albumStorageInfo: albumStorgeInfo;
}
export interface fileListsInfo {
parentId: string,
depth: number, // 1
fileList: FlashOneFileInfo[],
paginationInfo: {};
isEnd: boolean,
isCache: boolean,
}
export interface FileListResponse {
seq: number,
result: number,
errMs: string,
fileLists: fileListsInfo[],
}
export interface createFlashTransferResult {
fileSetId: string,
shareLink: string,
expireTime: string,
expireLeftTime: string,
}
export enum UploadSceneType {
KUPLOADSCENEUNKNOWN,
KUPLOADSCENEFLOATWINDOWRIGHTCLICKMENU,
KUPLOADSCENEFLOATWINDOWDRAG,
KUPLOADSCENEFLOATWINDOWFILESELECTOR,
KUPLOADSCENEFLOATWINDOWSHORTCUTKEYCTRLCV,
KUPLOADSCENEH5LAUNCHCLIENTRIGHTCLICKMENU,
KUPLOADSCENEH5LAUNCHCLIENTDRAG,
KUPLOADSCENEH5LAUNCHCLIENTFILESELECTOR,
KUPLOADSCENEH5LAUNCHCLIENTSHORTCUTKEYCTRLCV,
KUPLOADSCENEAIODRAG,
KUPLOADSCENEAIOFILESELECTOR,
KUPLOADSCENEAIOSHORTCUTKEYCTRLCV
}
export interface StartFlashTaskRequests {
screen: number; // 1 PC-QQ
name?: string;
uploaders: UploaderInfo[];
permission?: {};
coverPath?: string;
paths: string[]; // 文件的绝对路径,可以是文件夹
excludePaths?: string[];
expireLeftTime?: number, // 0
isNeedDelDeviceInfo: boolean,
isNeedDelLocation: boolean,
coverOriginalInfos?: {
path: string,
thumbnailPath: string,
}[],
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
detectPrivacyInfoResult: {
exists: boolean,
allDetectResults: {};
};
}
export enum BusiScene {
KBUSISCENEINVALID,
KBUSISCENEFLASHSCENE
}
export interface FileListInfoRequests {
seq: number, // 0
fileSetId: string,
isUseCache: boolean,
sceneType: BusiScene, // 1
reqInfos: {
count: number, // 18 ?? 硬编码吧 不懂
paginationInfo: {},
parentId: string,
reqIndexPath: string,
reqDepth: number, // 1
filterCondition: {
fileCategory: number,
filterType: number,
}, // 0
sortConditions: {
sortField: number,
sortOrder: number,
}[],
isNeedPhysicalInfoReady: boolean;
}[];
}
export enum DownloadSceneType {
KDOWNLOADSCENEUNKNOWN,
KDOWNLOADSCENEARKC2C,
KDOWNLOADSCENEARKC2CDETAILPAGE,
KDOWNLOADSCENEARKGROUP,
KDOWNLOADSCENEARKGROUPDETAILPAGE,
KDOWNLOADSCENELINKC2C,
KDOWNLOADSCENELINKGROUP,
KDOWNLOADSCENELINKCHANNEL,
KDOWNLOADSCENELINKTEMPCHAT,
KDOWNLOADSCENELINKOTHERINQQ,
KDOWNLOADSCENESCANQRCODE,
KDWONLOADSCENEFLASHTRANSFERCENTERCLIENT,
KDWONLOADSCENEFLASHTRANSFERCENTERSCHEMA
}
export interface FlashFileSetInfo {
fileSetId: string,
name: string,
namePinyin: string,
totalFileCount: number,
totalFileSize: number,
permission: {},
shareInfo: {
shareLink: string,
extractionCode: string,
},
cover: {
id: string,
urls: [
{
spec: number, // 2
url: string;
}
],
localCachePath: string;
},
uploaders: [
{
uin: string,
nickname: string,
uid: string,
sendEntrance: string;
}
],
expireLeftTime: number,
aiClusteringStatus: {
firstClusteringList: [],
shouldPull: boolean;
},
createTime: number,
expireTime: number,
firstLevelItemCount: 1,
svrLastUpdateTimestamp: 0,
taskId: string, // 同 fileSetId
uploadInfo: {
totalUploadedFileSize: number,
successCount: number,
failedCount: number;
},
downloadInfo: {
totalDownloadedFileSize: 0,
totalFileSize: 0,
totalDownloadFileCount: 0,
successCount: 0,
failedCount: 0,
pausedCount: 0,
cancelCount: 0,
status: 0,
curLevelDownloadedFileCount: number,
curLevelUnDownloadedFileCount: 0;
},
transferType: number,
isLocalCreate: true,
status: number, // todo 枚举全部状态
uploadStatus: number, // todo 同上
uploadPauseReason: 0,
downloadStatus: 0,
downloadPauseReason: 0,
saveFileSetDir: string,
uploadSceneType: UploadSceneType,
downloadSceneType: DownloadSceneType, // 0 PC-QQ 103 web
retryCount: number,
isMergeShareUpload: 0,
isRemoveDeviceInfo: boolean,
isRemoveLocation: boolean;
}
export interface SendStatus {
result: number,
msg: string,
target: {
destType: number,
destUid: string,
};
}

View File

@@ -466,65 +466,5 @@
"6.9.85-42086": {
"appid": 537320237,
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
},
"9.9.23-42430": {
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
},
"9.9.25-42744": {
"appid": 537328470,
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
},
"6.9.86-42744": {
"appid": 537328495,
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
},
"9.9.25-42905": {
"appid": 537328521,
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
},
"6.9.86-42905": {
"appid": 537328546,
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
},
"3.2.22-42941": {
"appid": 537328659,
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
},
"9.9.25-42941": {
"appid": 537328623,
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
},
"6.9.86-42941": {
"appid": 537328648,
"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"
},
"9.9.27-45627": {
"appid": 537340060,
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
},
"6.9.88-44725": {
"appid": 537337594,
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
}
}

View File

@@ -87,84 +87,8 @@
"send": "23B0330",
"recv": "0957648"
},
"3.2.21-42086-arm64": {
"send": "3D6D98C",
"recv": "14797C8"
},
"3.2.21-42086-x64": {
"send": "5B42CF0",
"recv": "2FDA6F0"
},
"9.9.23-42430-x64": {
"send": "0A01A34",
"recv": "1D1CFF9"
},
"9.9.25-42744-x64": {
"send": "0A0D104",
"recv": "1D3E7F9"
},
"6.9.85-42744-arm64": {
"send": "23DFEF0",
"recv": "095FD80"
},
"9.9.25-42905-x64": {
"send": "0A12E74",
"recv": "1D450FD"
},
"6.9.86-42905-arm64": {
"send": "2342408",
"recv": "09639B8"
},
"3.2.22-42941-x64": {
"send": "5BC1630",
"recv": "3011E00"
},
"3.2.22-42941-arm64": {
"send": "3DC90AC",
"recv": "1497A70"
},
"9.9.25-42941-x64": {
"send": "0A131D4",
"recv": "1D4547D"
},
"6.9.86-42941-arm64": {
"send": "2346108",
"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"
},
"9.9.27-45627-x64": {
"send": "0A697CC",
"recv": "1E86AC1"
},
"6.9.88-44725-x64": {
"send": "2756EF6",
"recv": "0A36152"
},
"6.9.88-44725-arm64": {
"send": "2313C68",
"recv": "09693E4"
}
}

View File

@@ -602,77 +602,5 @@
"3.2.21-42086-arm64": {
"send": "6B13038",
"recv": "6B169C8"
},
"9.9.23-42430-x64": {
"send": "2C9A4A0",
"recv": "2C9DA20"
},
"9.9.25-42744-x64": {
"send": "2CD8E40",
"recv": "2CDC3C0"
},
"6.9.86-42744-arm64": {
"send": "3DCC840",
"recv": "3DCF150"
},
"9.9.25-42905-x64": {
"send": "2CE46A0",
"recv": "2CE7C20"
},
"6.9.86-42905-arm64": {
"send": "3DD6098",
"recv": "3DD89A8"
},
"3.2.22-42941-x64": {
"send": "A8AD8A0",
"recv": "A8B1320"
},
"9.9.25-42941-x64": {
"send": "2CE4DA0",
"recv": "2CE8320"
},
"3.2.22-42941-arm64": {
"send": "6BC95E8",
"recv": "6BCCF78"
},
"6.9.86-42941-arm64": {
"send": "3DDDAD0",
"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"
},
"9.9.27-45627-x64": {
"send": "2E59CC0",
"recv": "2E5D240"
},
"6.9.88-44725-x64": {
"send": "451FE90",
"recv": "4522A40"
},
"6.9.88-44725-arm64": {
"send": "3D79168",
"recv": "3D7BA78"
}
}

View File

@@ -1,5 +1,5 @@
// TODO: further refactor in NapCat.Packet v2
import { NapProtoMsg, ProtoField, ScalarType } from 'napcat-protobuf';
import { NapProtoMsg, ProtoField, ScalarType } from '@napneko/nap-proto-core';
const LikeDetail = {
txt: ProtoField(1, ScalarType.STRING),

View File

@@ -1,45 +0,0 @@
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
import { LogWrapper } from '@/napcat-core/helper/log';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
async function guessDuration (pttPath: string, logger: LogWrapper) {
const pttFileInfo = await fsPromise.stat(pttPath);
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
logger.log('通过文件大小估算语音的时长:', duration);
return duration;
}
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
try {
const pttPath = path.join(TEMP_DIR, randomUUID());
if (!(await FFmpegService.isSilk(filePath))) {
logger.log(`语音文件${filePath}需要转换成silk`);
await FFmpegService.convertToNTSilkTct(filePath, pttPath);
const duration = await FFmpegService.getDuration(filePath);
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', duration);
return {
converted: true,
path: pttPath,
duration: duration,
};
} else {
let duration = 0;
try {
duration = await FFmpegService.getDuration(filePath);
} catch (e: unknown) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
duration = await guessDuration(filePath, logger);
}
return {
converted: false,
path: filePath,
duration,
};
}
} catch (error: unknown) {
logger.logError('convert silk failed', error);
return {};
}
}

View File

@@ -1,4 +1,4 @@
import { ConfigBase } from '@/napcat-core/helper/config-base';
import { ConfigBase } from 'napcat-common/src/config-base';
import { NapCatCore } from '@/napcat-core/index';
import { Type, Static } from '@sinclair/typebox';
import { AnySchema } from 'ajv';

View File

@@ -1,5 +1,5 @@
import { LogWrapper } from 'napcat-common/src/log';
import { RequestUtil } from 'napcat-common/src/request';
import { LogWrapper } from './log';
interface ServerRkeyData {
group_rkey: string;

View File

@@ -1,140 +0,0 @@
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
import { ServiceNamingMapping } from '@/napcat-core/services/index';
import { NTEventWrapper } from './event';
/**
* 创建 Service 方法的代理
* 拦截所有方法调用,通过 EventWrapper 进行调用
*/
function createServiceMethodProxy<S extends keyof ServiceNamingMapping>(
serviceName: S,
originalService: ServiceNamingMapping[S],
eventWrapper: NTEventWrapper
): ServiceNamingMapping[S] {
return new Proxy(originalService as object, {
get(target, prop, receiver) {
const originalValue = Reflect.get(target, prop, receiver);
// 如果不是函数,直接返回原始值
if (typeof originalValue !== 'function') {
return originalValue;
}
const methodName = prop as string;
// 返回一个包装函数,通过 EventWrapper 调用
return function (this: unknown, ...args: unknown[]) {
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
const eventPath = `${serviceName}/${methodName}`;
// 尝试通过 EventWrapper 调用
try {
// 使用 callNoListenerEvent 的底层实现逻辑
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
if (eventFunc) {
return eventFunc(...args);
}
} catch {
// 如果 EventWrapper 调用失败,回退到原始调用
}
// 回退到原始方法调用
return originalValue.apply(originalService, args);
};
},
}) as ServiceNamingMapping[S];
}
/**
* 创建 Session 的双层代理
* 第一层:拦截 getXXXService 方法
* 第二层:拦截 Service 上的具体方法调用
*/
export function createSessionProxy(
session: NodeIQQNTWrapperSession,
eventWrapper: NTEventWrapper
): NodeIQQNTWrapperSession {
// 缓存已代理的 Service避免重复创建
const serviceProxyCache = new Map<string, unknown>();
return new Proxy(session, {
get(target, prop, receiver) {
const propName = prop as string;
// 检查是否是 getXXXService 方法
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
const servicePart = propName.slice(3); // 移除 'get' 前缀
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
// 返回一个函数,该函数返回代理后的 Service
return function () {
// 检查缓存
if (serviceProxyCache.has(serviceName)) {
return serviceProxyCache.get(serviceName);
}
// 获取原始 Service
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
const originalService = originalGetter.call(target);
// 检查是否在 ServiceNamingMapping 中定义
if (isKnownService(serviceName)) {
// 创建 Service 方法代理
const proxiedService = createServiceMethodProxy(
serviceName,
originalService as ServiceNamingMapping[typeof serviceName],
eventWrapper
);
serviceProxyCache.set(serviceName, proxiedService);
return proxiedService;
}
// 未知的 Service直接返回原始对象
serviceProxyCache.set(serviceName, originalService);
return originalService;
};
}
// 非 getXXXService 方法,直接返回原始值
return Reflect.get(target, prop, receiver);
},
});
}
/**
* 检查 Service 名称是否在已知的映射中
*/
function isKnownService(serviceName: string): serviceName is keyof ServiceNamingMapping {
const knownServices: string[] = [
'NodeIKernelAvatarService',
'NodeIKernelBuddyService',
'NodeIKernelFileAssistantService',
'NodeIKernelGroupService',
'NodeIKernelLoginService',
'NodeIKernelMsgService',
'NodeIKernelOnlineStatusService',
'NodeIKernelProfileLikeService',
'NodeIKernelProfileService',
'NodeIKernelTicketService',
'NodeIKernelStorageCleanService',
'NodeIKernelRobotService',
'NodeIKernelRichMediaService',
'NodeIKernelDbToolsService',
'NodeIKernelTipOffService',
'NodeIKernelSearchService',
'NodeIKernelCollectionService',
];
return knownServices.includes(serviceName);
}
/**
* 创建带有 EventWrapper 集成的 InstanceContext
* 这是推荐的使用方式,在创建 context 时自动代理 session
*/
export function createProxiedSession(
session: NodeIQQNTWrapperSession,
eventWrapper: NTEventWrapper
): NodeIQQNTWrapperSession {
return createSessionProxy(session, eventWrapper);
}

View File

@@ -1,24 +1,24 @@
import os from 'node:os';
import EventEmitter from 'node:events';
import { IStatusHelperSubscription } from 'napcat-common/src/status-interface';
export interface SystemStatus {
cpu: {
model: string,
speed: string;
speed: string
usage: {
system: string;
qq: string;
system: string
qq: string
},
core: number;
core: number
},
memory: {
total: string;
total: string
usage: {
system: string;
qq: string;
};
system: string
qq: string
}
},
arch: string;
arch: string
}
export class StatusHelper {
@@ -101,7 +101,7 @@ export class StatusHelper {
}
}
class StatusHelperSubscription extends EventEmitter implements IStatusHelperSubscription {
class StatusHelperSubscription extends EventEmitter {
private statusHelper: StatusHelper;
private interval: NodeJS.Timeout | null = null;

View File

@@ -6,8 +6,6 @@ import {
NTQQSystemApi,
NTQQUserApi,
NTQQWebApi,
NTQQFlashApi,
NTQQOnlineApi,
} from '@/napcat-core/apis';
import { NTQQCollectionApi } from '@/napcat-core/apis/collection';
import {
@@ -18,36 +16,25 @@ import {
WrapperNodeApi,
WrapperSessionInitConfig,
} from '@/napcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { LogLevel, LogWrapper } from 'napcat-common/src/log';
import { NodeIKernelLoginService } from '@/napcat-core/services';
import { QQBasicInfoWrapper } from 'napcat-common/src/qq-basic-info';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import path from 'node:path';
import fs from 'node:fs';
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
import { NTEventWrapper } from '@/napcat-core/helper/event';
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
import { NTEventWrapper } from 'napcat-common/src/event';
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
import os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/napcat-core/listeners';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { proxiedListenerOf } from 'napcat-common/src/proxy-handler';
import { NTQQPacketApi } from './apis/packet';
import { NativePacketHandler } from './packet/handler/client';
import { container, ReceiverServiceRegistry } from './packet/handler/serviceRegister';
import { appEvent } from './packet/handler/eventList';
import { TypedEventEmitter } from './packet/handler/typeEvent';
export * from './wrapper';
export * from './types/index';
export * from './services/index';
export * from './listeners/index';
export * from './apis/index';
export * from './helper/log';
export * from './helper/qq-basic-info';
export * from './helper/event';
export * from './helper/config';
export * from './helper/config-base';
export * from './helper/proxy-handler';
export * from './helper/session-proxy';
export enum NapCatCoreWorkingEnv {
Unknown = 0,
@@ -55,23 +42,20 @@ export enum NapCatCoreWorkingEnv {
Framework = 2,
}
export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi {
export function loadQQWrapper (QQVersion: string): WrapperNodeApi {
if (process.env['NAPCAT_WRAPPER_PATH']) {
const wrapperPath = process.env['NAPCAT_WRAPPER_PATH'];
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
process.dlopen(nativemodule, wrapperPath);
return nativemodule.exports;
}
if (!execPath) {
throw new Error('无法加载WrapperexecPath未定义');
}
let appPath;
if (os.platform() === 'darwin') {
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
appPath = path.resolve(path.dirname(process.execPath), '../Resources/app');
} else if (os.platform() === 'linux') {
appPath = path.resolve(path.dirname(execPath), './resources/app');
appPath = path.resolve(path.dirname(process.execPath), './resources/app');
} else {
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
appPath = path.resolve(path.dirname(process.execPath), `./versions/${QQVersion}/`);
}
let wrapperNodePath = path.resolve(appPath, 'wrapper.node');
if (!fs.existsSync(wrapperNodePath)) {
@@ -79,22 +63,21 @@ export function loadQQWrapper (execPath: string | undefined, QQVersion: string):
}
// 老版本兼容 未来去掉
if (!fs.existsSync(wrapperNodePath)) {
wrapperNodePath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
wrapperNodePath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
}
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
process.dlopen(nativemodule, wrapperNodePath);
process.env['NAPCAT_WRAPPER_PATH'] = wrapperNodePath;
return nativemodule.exports;
}
export function getMajorPath (execPath: string, QQVersion: string): string {
export function getMajorPath (QQVersion: string): string {
// major.node
let appPath;
if (os.platform() === 'darwin') {
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
appPath = path.resolve(path.dirname(process.execPath), '../Resources/app');
} else if (os.platform() === 'linux') {
appPath = path.resolve(path.dirname(execPath), './resources/app');
appPath = path.resolve(path.dirname(process.execPath), './resources/app');
} else {
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
appPath = path.resolve(path.dirname(process.execPath), `./versions/${QQVersion}/`);
}
let majorPath = path.resolve(appPath, 'major.node');
if (!fs.existsSync(majorPath)) {
@@ -102,14 +85,13 @@ export function getMajorPath (execPath: string, QQVersion: string): string {
}
// 老版本兼容 未来去掉
if (!fs.existsSync(majorPath)) {
majorPath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/major.node`);
majorPath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/major.node`);
}
return majorPath;
}
export class NapCatCore {
readonly context: InstanceContext;
readonly eventWrapper: NTEventWrapper;
event = appEvent;
NapCatDataPath: string = '';
NapCatTempPath: string = '';
apis: StableNTApiWrapper;
@@ -121,19 +103,9 @@ export class NapCatCore {
// 通过构造器递过去的 runtime info 应该尽量少
constructor (context: InstanceContext, selfInfo: SelfInfo) {
this.selfInfo = selfInfo;
// 先用原始 session 创建 eventWrapper
this.eventWrapper = new NTEventWrapper(context.session);
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
this.context = {
...context,
session: proxiedSession,
};
} else {
this.context = context;
}
this.context = context;
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
this.eventWrapper = new NTEventWrapper(context.session);
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
this.apis = {
FileApi: new NTQQFileApi(this.context, this),
@@ -145,19 +117,7 @@ export class NapCatCore {
MsgApi: new NTQQMsgApi(this.context, this),
UserApi: new NTQQUserApi(this.context, this),
GroupApi: new NTQQGroupApi(this.context, this),
FlashApi: new NTQQFlashApi(this.context, this),
OnlineApi: new NTQQOnlineApi(this.context, this),
};
container.bind(NapCatCore).toConstantValue(this);
container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf();
// console.log(`Registering service handler for: ${serviceName}`);
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data);
});
});
}
async initCore () {
@@ -202,17 +162,10 @@ export class NapCatCore {
async initNapCatCoreListeners () {
const msgListener = new NodeIKernelMsgListener();
// 在线文件/文件夹消息
msgListener.onRecvOnlineFileMsg = (msgs: RawMessage[]) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
};
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
// 下线通知
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
this.context.logger.logError(tips);
this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
this.selfInfo.online = false;
this.event.emit('KickedOffLine', tips);
};
msgListener.onRecvMsg = (msgs) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
@@ -311,6 +264,7 @@ export interface InstanceContext {
readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper;
readonly loginService: NodeIKernelLoginService;
readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler;
@@ -326,6 +280,4 @@ export interface StableNTApiWrapper {
MsgApi: NTQQMsgApi,
UserApi: NTQQUserApi,
GroupApi: NTQQGroupApi;
FlashApi: NTQQFlashApi,
OnlineApi: NTQQOnlineApi,
}

View File

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

View File

@@ -1,5 +1,5 @@
import { ChatType, KickedOffLineInfo, RawMessage } from '@/napcat-core/types';
import { CommonFileInfo } from '@/napcat-core/index';
import { CommonFileInfo } from '@/napcat-core';
export interface OnRichMediaDownloadCompleteParams {
fileModelId: string,

View File

@@ -3,7 +3,7 @@ export class NodeIKernelStorageCleanListener {
}
onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any {
onScanCacheProgressChanged (_args: unknown): any {
}
@@ -11,7 +11,7 @@ export class NodeIKernelStorageCleanListener {
}
onFinishScan (_sizes: Array<`${number}`>): any {
onFinishScan (_args: unknown): any {
}

View File

@@ -4,9 +4,6 @@
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
@@ -16,18 +13,14 @@
}
},
"dependencies": {
"winston": "^3.17.0",
"json5": "^2.2.3",
"inversify": "^7.10.4",
"reflect-metadata": "^0.2.2",
"@protobuf-ts/runtime": "^2.11.1",
"@napneko/nap-proto-core": "^0.0.4",
"ajv": "^8.13.0",
"@sinclair/typebox": "^0.34.38",
"file-type": "^21.0.0",
"compressing": "^1.10.1",
"napcat-image-size": "workspace:*",
"napcat-common": "workspace:*",
"napcat-protobuf": "workspace:*"
"napcat-onebot": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"

View File

@@ -1,5 +1,5 @@
import { LogLevel, LogWrapper } from 'napcat-common/src/log';
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
import { LogWrapper, LogLevel } from '@/napcat-core/helper/log';
// TODO: check bind?
export class PacketLogger {

View File

@@ -9,16 +9,15 @@ import {
PacketMsgReplyElement,
PacketMsgVideoElement,
} from '@/napcat-core/packet/message/element';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/napcat-core/index';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/napcat-core';
import { MiniAppRawData, MiniAppReqParams } from '@/napcat-core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/napcat-core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'napcat-protobuf';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/napcat-core/packet/transformer/proto';
import { OidbPacket } from '@/napcat-core/packet/transformer/base';
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
export class PacketOperationContext {
private readonly context: PacketContext;
@@ -27,7 +26,7 @@ export class PacketOperationContext {
this.context = context;
}
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
return await this.context.client.sendOidbPacket(pkt, rsp);
}
@@ -95,15 +94,12 @@ export class PacketOperationContext {
.filter(Boolean)
);
const res = await Promise.allSettled(reqList);
const failedCount = res.filter((r) => r.status === 'rejected').length;
if (failedCount > 0) {
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
}
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
}
async UploadImage (img: PacketMsgPicElement) {
@@ -228,15 +224,7 @@ export class PacketOperationContext {
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
//await this.SendPreprocess(msg, groupUin);
// 遍历上传资源
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async MoveGroupFile (
groupUin: number,
fileUUID: string,

View File

@@ -1,5 +1,5 @@
import { PacketHighwayContext } from '@/napcat-core/packet/highway/highwayContext';
import { NapCatCore } from '@/napcat-core/index';
import { NapCatCore } from '@/napcat-core';
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';

View File

@@ -2,8 +2,8 @@ import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { constants } from 'node:os';
import { LogWrapper } from 'napcat-common/src/log';
import offset from '@/napcat-core/external/packet.json';
import { LogWrapper } from '../../helper/log';
interface OffsetType {
[key: string]: {
recv: string;
@@ -50,6 +50,7 @@ export class NativePacketHandler {
this.logger.logError('NativePacketClient 加载出错:', error);
this.loaded = false;
}
}
/**

View File

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

View File

@@ -1,28 +0,0 @@
import 'reflect-metadata';
import { Container, injectable } from 'inversify';
import { NapCatCore } from '../..';
import { TypedEventEmitter } from './typeEvent';
export const container = new Container();
export const ReceiverServiceRegistry = new Map<string, new (...args: any[]) => ServiceBase>();
export abstract class ServiceBase {
get core (): NapCatCore {
return container.get(NapCatCore);
}
get event () {
return container.get(TypedEventEmitter);
}
abstract handler (seq: number, hex_data: string): Promise<void> | void;
}
export function ReceiveService (serviceName: string) {
return function <T extends new (...args: any[]) => ServiceBase>(constructor: T) {
injectable()(constructor);
ReceiverServiceRegistry.set(serviceName, constructor);
return constructor;
};
}

View File

@@ -1,22 +0,0 @@
import { EventEmitter } from 'node:events';
export class TypedEventEmitter<E extends Record<string, any>> {
private emitter = new EventEmitter();
on<K extends keyof E>(event: K, listener: (payload: E[K]) => void) {
this.emitter.on(event as string, listener);
return () => this.off(event, listener);
}
once<K extends keyof E>(event: K, listener: (payload: E[K]) => void) {
this.emitter.once(event as string, listener);
}
off<K extends keyof E>(event: K, listener: (payload: E[K]) => void) {
this.emitter.off(event as string, listener);
}
emit<K extends keyof E>(event: K, payload: E[K]) {
this.emitter.emit(event as string, payload);
}
}

View File

@@ -11,16 +11,12 @@ import {
import { ChatType, Peer } from '@/napcat-core/index';
import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from '@/napcat-core/packet/utils/crypto/hash';
import UploadGroupImage from '@/napcat-core/packet/transformer/highway/UploadGroupImage';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import * as proto from '@/napcat-core/packet/transformer/proto';
import * as trans from '@/napcat-core/packet/transformer';
import fs from 'fs';
import path from 'path';
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video';
import { calculateFileMD5 } from 'napcat-common/src/file';
export const BlockSize = 1024 * 1024;
@@ -109,89 +105,13 @@ export class PacketHighwayContext {
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB请使用文件上传`);
}
// 如果缺少视频缩略图,自动生成一个
let tempThumbPath: string | null = null;
let thumbExists = false;
if (video.thumbPath) {
try {
await fs.promises.access(video.thumbPath, fs.constants.F_OK);
thumbExists = true;
} catch {
thumbExists = false;
}
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
if (!video.thumbPath || !thumbExists) {
tempThumbPath = await this.ensureVideoThumb(video);
}
try {
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
} finally {
// 清理临时生成的缩略图文件
if (tempThumbPath) {
const thumbToClean = tempThumbPath;
fs.promises.unlink(thumbToClean)
.then(() => this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`))
.catch((e) => {
// 文件不存在时忽略错误
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`);
}
});
}
}
}
/**
* 确保视频缩略图存在,如果不存在则自动生成
* @returns 生成的临时缩略图路径,用于后续清理
*/
private async ensureVideoThumb (video: PacketMsgVideoElement): Promise<string> {
if (!video.filePath) {
throw new Error('video.filePath is empty, cannot generate thumbnail');
}
// 生成缩略图路径
const videoDir = path.dirname(video.filePath);
const videoBasename = path.basename(video.filePath, path.extname(video.filePath));
const thumbPath = path.join(videoDir, `${videoBasename}_thumb.png`);
this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`);
try {
// 尝试使用 FFmpeg 提取视频缩略图
await FFmpegService.extractThumbnail(video.filePath, thumbPath);
try {
await fs.promises.access(thumbPath, fs.constants.F_OK);
this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg');
} catch {
throw new Error('FFmpeg failed to generate thumbnail');
}
} catch (e) {
// FFmpeg 失败时(包括未初始化的情况)使用默认缩略图
this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`);
await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
// 更新视频元素的缩略图信息
video.thumbPath = thumbPath;
const thumbStat = await fs.promises.stat(thumbPath);
video.thumbSize = thumbStat.size;
video.thumbMd5 = await calculateFileMD5(thumbPath);
// 默认缩略图尺寸(与 defaultVideoThumbB64 匹配的尺寸)
if (!video.thumbWidth) video.thumbWidth = 240;
if (!video.thumbHeight) video.thumbHeight = 383;
this.logger.debug(`[Highway] Video thumb info set: path=${thumbPath}, size=${video.thumbSize}, md5=${video.thumbMd5}`);
return thumbPath;
}
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {

View File

@@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import http from 'node:http';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { IHighwayUploader } from '@/napcat-core/packet/highway/uploader/highwayUploader';
import { Frame } from '@/napcat-core/packet/highway/frame';
import * as proto from '@/napcat-core/packet/transformer/proto';

View File

@@ -1,7 +1,7 @@
import net from 'node:net';
import stream from 'node:stream';
import crypto from 'node:crypto';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { BlockSize } from '@/napcat-core/packet/highway/highwayContext';
import { Frame } from '@/napcat-core/packet/highway/frame';
import { IHighwayUploader } from '@/napcat-core/packet/highway/uploader/highwayUploader';

View File

@@ -1,5 +1,5 @@
// import * as tea from '@/napcat-core/packet/utils/crypto/tea';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { PacketHighwayTrans } from '@/napcat-core/packet/highway/client';
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
import * as proto from '@/napcat-core/packet/transformer/proto';

View File

@@ -1,4 +1,4 @@
import { NapProtoEncodeStructType } from 'napcat-protobuf';
import { NapProtoEncodeStructType } from '@napneko/nap-proto-core';
import * as proto from '@/napcat-core/packet/transformer/proto';
export const int32ip2str = (ip: number) => {

View File

@@ -1,6 +1,6 @@
import * as crypto from 'crypto';
import { PushMsgBody } from '@/napcat-core/packet/transformer/proto';
import { NapProtoEncodeStructType } from 'napcat-protobuf';
import { NapProtoEncodeStructType } from '@napneko/nap-proto-core';
import { PacketMsg, PacketSendMsgElement } from '@/napcat-core/packet/message/message';
import { IPacketMsgElement, PacketMsgTextElement } from '@/napcat-core/packet/message/element';
import { SendTextElement } from '@/napcat-core/index';
@@ -14,7 +14,7 @@ export class PacketMsgBuilder {
buildFakeMsg (selfUid: string, element: PacketMsg[]): 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>) => {
return acc ?? msg.buildContent();
}, undefined);

View File

@@ -32,7 +32,7 @@ import {
PacketMultiMsgElement,
} from '@/napcat-core/packet/message/element';
import { PacketMsg, PacketSendMsgElement } from '@/napcat-core/packet/message/message';
import { NapProtoDecodeStructType } from 'napcat-protobuf';
import { NapProtoDecodeStructType } from '@napneko/nap-proto-core';
import { Elem } from '@/napcat-core/packet/transformer/proto';
const SupportedElementTypes = [
@@ -132,6 +132,7 @@ export class PacketMsgConverter {
time: msg.time,
msg: msg.msg.map((element) => {
if (!this.isValidElementType(element.elementType)) return null;
// @ts-ignore
return this.rawToPacketMsgConverters[element.elementType](element as MessageElement);
}).filter((e) => e !== null),
};

View File

@@ -1,5 +1,5 @@
import * as zlib from 'node:zlib';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'napcat-protobuf';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import {
CustomFace,
Elem,
@@ -32,8 +32,8 @@ import {
SendTextElement,
SendVideoElement,
Peer,
} from '@/napcat-core/index';
import { ForwardMsgBuilder } from '@/napcat-core/helper/forward-msg-builder';
} from '@/napcat-core';
import { ForwardMsgBuilder } from 'napcat-common/src/forward-msg-builder';
import { PacketMsg, PacketSendMsgElement } from '@/napcat-core/packet/message/message';
export type ParseElementFnR = [MessageElement, NapProtoDecodeStructType<typeof Elem> | null] | undefined;
@@ -510,15 +510,15 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
//return [];
if (!this.msgInfo) return [];
return [{
commonElem: {
serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: 22,
}
}];
return [];
// if (!this.msgInfo) return [];
// return [{
// commonElem: {
// serviceType: 48,
// pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
// businessType: 22,
// }
// }];
}
override toPreview (): string {

View File

@@ -1,5 +1,5 @@
import { IPacketMsgElement } from '@/napcat-core/packet/message/element';
import { SendMessageElement, SendMultiForwardMsgElement } from '@/napcat-core/index';
import { SendMessageElement, SendMultiForwardMsgElement } from '@/napcat-core';
export type PacketSendMsgElement = SendMessageElement | SendMultiForwardMsgElement;

View File

@@ -1,5 +1,5 @@
import * as proto from '@/napcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/napcat-core/packet/transformer/base';
import OidbBase from '@/napcat-core/packet/transformer/oidb/oidbBase';
import { AIVoiceChatType } from '@/napcat-core/packet/entities/aiChat';

View File

@@ -1,5 +1,5 @@
import * as proto from '@/napcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'napcat-protobuf';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/napcat-core/packet/transformer/base';
import OidbBase from '@/napcat-core/packet/transformer/oidb/oidbBase';
import { AIVoiceChatType } from '@/napcat-core/packet/entities/aiChat';

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