commit b9655a5ab19954f0396542f1c48f54dc59c44379
Author: 天天的鸟蛋蛋 <100036451+DandanLLab@users.noreply.github.com>
Date: Thu Nov 12 06:27:36 2026 +0800
SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37
26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor
GitHub 关联已全量指向 DandanLLab/SnowCatQQ
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..52bb929
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,24 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+
+# Matches multiple files with brace expansion notation
+# Set default charset
+charset = utf-8
+
+# 4 space indentation
+[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
+indent_style = space
+indent_size = 2
+
+[*.bat]
+charset = latin1
+
+# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
+# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..462cbac
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,85 @@
+name: Bug 反馈
+description: 报告可能的 NapCat 异常行为
+title: "[BUG] "
+labels: bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ 欢迎来到 NapCat 的 Issue Tracker!请填写以下表格来提交 Bug。
+ 在提交新的 Bug 反馈前,请确保您:
+ * 已经搜索了现有的 issues,并且没有找到可以解决您问题的方法
+ * 不与现有的某一 issue 重复
+ * **不接受因发送不当内容而导致的问题报告**
+ - 包括但不限于:多媒体发送失败、转发消息失败、消息被拦截等因 18+ 内容、违规内容或触发风控的问题
+ - 提交 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: input
+ id: onebot-client-version
+ attributes:
+ label: OneBot 客户端
+ description: 连接至 NapCat 的客户端版本信息
+ placeholder: Karin 1.0.0
+ validations:
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: 发生了什么?
+ description: 填写你认为的 NapCat 的异常行为
+ validations:
+ required: true
+ - type: textarea
+ id: how-reproduce
+ attributes:
+ label: 如何复现
+ description: 填写应当如何操作才能触发这个异常行为
+ placeholder: |
+ 1. xxx
+ 2. xxx
+ 3. xxx
+ validations:
+ required: true
+ - type: textarea
+ id: what-expected
+ attributes:
+ label: 期望的结果?
+ description: 填写你认为 NapCat 应当执行的正常行为
+ validations:
+ required: true
+ - type: textarea
+ id: napcat-log
+ attributes:
+ label: NapCat 运行日志
+ description: 粘贴相关日志内容到此处
+ render: shell
+ - type: textarea
+ id: onebot-client-log
+ attributes:
+ label: OneBot 客户端运行日志
+ description: 粘贴 OneBot 客户端的相关日志内容到此处
+ render: shell
diff --git a/.github/ISSUE_TEMPLATE/feat_request.yml b/.github/ISSUE_TEMPLATE/feat_request.yml
new file mode 100644
index 0000000..d73d332
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feat_request.yml
@@ -0,0 +1,60 @@
+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: 你还想补充什么?
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..aff82a1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/prompt/default.md b/.github/prompt/default.md
new file mode 100644
index 0000000..43938bc
--- /dev/null
+++ b/.github/prompt/default.md
@@ -0,0 +1,33 @@
+# {VERSION}
+[使用文档](https://napneko.github.io/)
+
+## 警告
+**注意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)
diff --git a/.github/prompt/release_note_prompt.txt b/.github/prompt/release_note_prompt.txt
new file mode 100644
index 0000000..5482a26
--- /dev/null
+++ b/.github/prompt/release_note_prompt.txt
@@ -0,0 +1,111 @@
+# 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` 等关键词识别新功能
+- 忽略纯重构(代码移动但功能不变)和格式化变更
+
+### 截断说明
+- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
+- 根据已有信息推断完整变更意图即可
\ No newline at end of file
diff --git a/.github/scripts/lib/comment.ts b/.github/scripts/lib/comment.ts
new file mode 100644
index 0000000..3c78be1
--- /dev/null
+++ b/.github/scripts/lib/comment.ts
@@ -0,0 +1,417 @@
+/**
+ * 构建状态评论模板
+ */
+
+export const COMMENT_MARKER = '';
+export const DOCKER_COMMENT_MARKER = '';
+
+// 默认代理前缀(加速中国大陆访问 GitHub Release)
+// 格式:https://ghfast.top/{full-github-url}
+export const PROXY_BASE = 'https://gh.llkk.cc';
+
+/** 将 GitHub 直链转换为 ghfast.top 加速链接 */
+export function toProxyUrl (directUrl: string): string {
+ return `${PROXY_BASE}/${directUrl}`;
+}
+
+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,
+ '',
+ '
',
+ '',
+ '# 🔨 NapCat 构建中',
+ '',
+ '',
+ '',
+ '
',
+ '',
+ '---',
+ '',
+ '## 📦 构建目标',
+ '',
+ '| 包名 | 状态 | 说明 |',
+ '| :--- | :---: | :--- |',
+ ...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
+ '',
+ '---',
+ '',
+ '## 📋 构建信息',
+ '',
+ '| 项目 | 值 |',
+ '| :--- | :--- |',
+ `| 📝 提交 | \`${shortSha}\` |`,
+ `| 🕐 开始时间 | ${time} |`,
+ '',
+ '---',
+ '',
+ '',
+ '',
+ '> ⏳ **构建进行中,请稍候...**',
+ '>',
+ '> 构建完成后将自动更新此评论',
+ '',
+ '
',
+ ];
+
+ return lines.join('\n');
+}
+
+// ============== 构建结果评论 ==============
+
+export interface InstallInfo {
+ installUrl: string;
+ releaseUrl: string;
+}
+
+export function generateResultComment (
+ targets: BuildTarget[],
+ prSha: string,
+ runId: string,
+ repository: string,
+ version?: string,
+ installInfo?: InstallInfo,
+ publishFailed?: boolean
+): 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 = '';
+ headerTitle = '# ✅ NapCat 构建成功';
+ } else if (anyCancelled && !anyFailure) {
+ statusBadge = '';
+ headerTitle = '# ⚪ NapCat 构建已取消';
+ } else {
+ statusBadge = '';
+ 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,
+ '',
+ '',
+ '',
+ headerTitle,
+ '',
+ statusBadge,
+ '',
+ '
',
+ '',
+ '---',
+ '',
+ '## 📦 构建产物',
+ '',
+ '| 包名 | 状态 | 下载 |',
+ '| :--- | :---: | :---: |',
+ ...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
+ '',
+ '---',
+ '',
+ '## 📋 构建信息',
+ '',
+ '| 项目 | 值 |',
+ '| :--- | :--- |',
+ ...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
+ `| 📝 提交 | \`${shortSha}\` |`,
+ `| 🔗 构建日志 | [查看详情](${runUrl}) |`,
+ `| 🕐 完成时间 | ${time} |`,
+ ];
+
+ // 添加快速安装命令
+ if (installInfo) {
+ const directUrl = installInfo.installUrl;
+ const proxyInstallUrl = toProxyUrl(directUrl);
+ lines.push(
+ '',
+ '---',
+ '',
+ '## 🚀 快速安装 (Linux)',
+ '',
+ '**直连(需要能访问 GitHub)**',
+ '```bash',
+ `curl -sSL ${directUrl} | bash`,
+ '```',
+ '',
+ '**加速(国内推荐,ghfast.top)**',
+ '```bash',
+ `curl -sSL ${proxyInstallUrl} | bash`,
+ '```',
+ '',
+ `> 📦 [查看 Release 页面](${installInfo.releaseUrl})`,
+ '> ⚠️ 此为 PR 测试版本,约 5 天后会被自动清理。'
+ );
+ } else if (publishFailed) {
+ lines.push(
+ '',
+ '---',
+ '',
+ '## ⚠️ Release 发布失败',
+ '',
+ '> ❌ PR Release 发布过程中出现错误,无法提供快速安装命令。',
+ `> 🔗 [查看构建日志](${runUrl}) 了解详情。`
+ );
+ }
+
+ // 添加错误详情
+ const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
+ if (failedTargets.length > 0) {
+ lines.push('', '---', '', '## ⚠️ 错误详情', '');
+ for (const target of failedTargets) {
+ lines.push(
+ '',
+ `🔴 ${target.name} 构建错误
`,
+ '',
+ '```',
+ escapeCodeBlock(target.error!),
+ '```',
+ '',
+ ' ',
+ ''
+ );
+ }
+ }
+
+ // 添加底部提示
+ lines.push('---', '');
+ if (allSuccess) {
+ lines.push(
+ '',
+ '',
+ '> 🎉 **所有构建均已成功完成!**',
+ '>',
+ '> 点击上方下载链接获取构建产物进行测试',
+ '',
+ '
'
+ );
+ } else if (anyCancelled && !anyFailure) {
+ lines.push(
+ '',
+ '',
+ '> ⚪ **构建已被取消**',
+ '>',
+ '> 可能是由于新的提交触发了新的构建',
+ '',
+ '
'
+ );
+ } else {
+ lines.push(
+ '',
+ '',
+ '> ⚠️ **部分构建失败**',
+ '>',
+ '> 请查看上方错误详情或点击构建日志查看完整输出',
+ '',
+ '
'
+ );
+ }
+
+ return lines.join('\n');
+}
+
+// ============== Docker 构建评论 ==============
+
+export function generateDockerBuildingComment (prNumber: string, prSha: string): string {
+ const time = getTimeString();
+ const shortSha = formatSha(prSha);
+
+ const lines: string[] = [
+ DOCKER_COMMENT_MARKER,
+ '',
+ '',
+ '',
+ '# 🐳 Docker 测试镜像构建中',
+ '',
+ '',
+ '',
+ '
',
+ '',
+ '---',
+ '',
+ '## 📋 构建信息',
+ '',
+ '| 项目 | 值 |',
+ '| :--- | :--- |',
+ `| 📝 提交 | \`${shortSha}\` |`,
+ `| 🔗 PR | #${prNumber} |`,
+ `| 🕐 触发时间 | ${time} |`,
+ '',
+ '---',
+ '',
+ '',
+ '',
+ '> ⏳ **Docker 镜像正在构建中,请稍候...**',
+ '>',
+ '> 构建完成后将自动更新此评论',
+ '',
+ '
',
+ ];
+
+ return lines.join('\n');
+}
+
+export function generateDockerResultComment (
+ prNumber: string,
+ prSha: string,
+ success: boolean,
+ dockerImage?: string,
+ error?: string
+): string {
+ const time = getTimeString();
+ const shortSha = formatSha(prSha);
+
+ const statusBadge = success
+ ? ''
+ : '';
+ const headerTitle = success ? '# ✅ Docker 测试镜像就绪' : '# ❌ Docker 测试镜像构建失败';
+
+ const lines: string[] = [
+ DOCKER_COMMENT_MARKER,
+ '',
+ '',
+ '',
+ headerTitle,
+ '',
+ statusBadge,
+ '',
+ '
',
+ '',
+ '---',
+ '',
+ '## 📋 构建信息',
+ '',
+ '| 项目 | 值 |',
+ '| :--- | :--- |',
+ `| 📝 提交 | \`${shortSha}\` |`,
+ `| 🔗 PR | #${prNumber} |`,
+ `| 🕐 完成时间 | ${time} |`,
+ ];
+
+ if (success && dockerImage) {
+ lines.push(
+ '',
+ '---',
+ '',
+ '## 🚀 快速使用',
+ '',
+ '```bash',
+ `docker pull ${dockerImage}`,
+ '```',
+ '',
+ '> ⚠️ 此为 PR 测试镜像,保留 5 天后自动清理。'
+ );
+ }
+
+ if (!success && error) {
+ lines.push(
+ '',
+ '---',
+ '',
+ '',
+ '🔴 错误详情
',
+ '',
+ '```',
+ escapeCodeBlock(error),
+ '```',
+ '',
+ ' '
+ );
+ }
+
+ lines.push('', '---', '');
+ if (success) {
+ lines.push(
+ '',
+ '',
+ '> 🎉 **Docker 测试镜像已就绪!**',
+ '>',
+ '> 使用上方命令拉取镜像进行测试',
+ '',
+ '
'
+ );
+ } else {
+ lines.push(
+ '',
+ '',
+ '> ⚠️ **Docker 镜像构建失败**',
+ '>',
+ '> 请检查错误详情',
+ '',
+ '
'
+ );
+ }
+
+ return lines.join('\n');
+}
diff --git a/.github/scripts/lib/github.ts b/.github/scripts/lib/github.ts
new file mode 100644
index 0000000..4ada3f9
--- /dev/null
+++ b/.github/scripts/lib/github.ts
@@ -0,0 +1,321 @@
+/**
+ * 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;
+}
+
+export interface Release {
+ id: number;
+ tag_name: string;
+ name: string;
+ upload_url: string;
+ html_url: string;
+ created_at: string;
+}
+
+export interface ReleaseAsset {
+ id: number;
+ name: string;
+ browser_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 (endpoint: string, options: RequestInit = {}): Promise {
+ 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}`);
+ }
+
+ // 204 No Content(DELETE 等)不解析 body
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return response.json() as Promise;
+ }
+
+ async getPullRequest (owner: string, repo: string, pullNumber: number): Promise {
+ return this.request(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
+ }
+
+ async getCollaboratorPermission (owner: string, repo: string, username: string): Promise {
+ const data = await this.request<{ permission: string; }>(
+ `/repos/${owner}/${repo}/collaborators/${username}/permission`
+ );
+ return data.permission;
+ }
+
+ async getRepository (owner: string, repo: string): Promise {
+ return this.request(`/repos/${owner}/${repo}`);
+ }
+
+ async checkOrgMembership (org: string, username: string): Promise {
+ try {
+ await this.request(`/orgs/${org}/members/${username}`);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async getRunArtifacts (owner: string, repo: string, runId: string): Promise {
+ 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 {
+ 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 {
+ let page = 1;
+ const perPage = 100;
+
+ while (page <= 10) { // 最多检查 1000 条评论
+ const comments = await this.request>(
+ `/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 {
+ 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 {
+ 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');
+ }
+ }
+
+ // ============== Release API ==============
+
+ async getReleaseByTag (owner: string, repo: string, tag: string): Promise {
+ try {
+ return await this.request(`/repos/${owner}/${repo}/releases/tags/${tag}`);
+ } catch {
+ return null;
+ }
+ }
+
+ async deleteRelease (owner: string, repo: string, releaseId: number): Promise {
+ await this.request(`/repos/${owner}/${repo}/releases/${releaseId}`, { method: 'DELETE' });
+ }
+
+ async deleteRef (owner: string, repo: string, ref: string): Promise {
+ try {
+ await this.request(`/repos/${owner}/${repo}/git/refs/${ref}`, { method: 'DELETE' });
+ } catch {
+ // ref may not exist, ignore
+ }
+ }
+
+ async createRelease (
+ owner: string,
+ repo: string,
+ tag: string,
+ name: string,
+ body: string,
+ prerelease = true
+ ): Promise {
+ return this.request(`/repos/${owner}/${repo}/releases`, {
+ method: 'POST',
+ body: JSON.stringify({ tag_name: tag, name, body, prerelease }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ async uploadReleaseAsset (
+ uploadUrl: string,
+ fileName: string,
+ data: Buffer,
+ contentType = 'application/octet-stream'
+ ): Promise {
+ const url = uploadUrl.replace(/\{[^}]*\}/g, '') + `?name=${encodeURIComponent(fileName)}`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ Accept: 'application/vnd.github+json',
+ 'Content-Type': contentType,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ body: data,
+ });
+ if (!response.ok) {
+ throw new Error(`Upload ${fileName} failed: ${response.status} ${response.statusText}`);
+ }
+ return response.json() as Promise;
+ }
+
+ // ============== Dispatch API ==============
+
+ async repositoryDispatch (
+ owner: string,
+ repo: string,
+ eventType: string,
+ clientPayload: Record
+ ): Promise {
+ const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/dispatches`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ event_type: eventType, client_payload: clientPayload }),
+ });
+ if (!response.ok && response.status !== 204) {
+ throw new Error(`Dispatch failed: ${response.status} ${response.statusText}`);
+ }
+ }
+
+ async createWorkflowDispatch (
+ owner: string,
+ repo: string,
+ workflowFile: string,
+ ref: string,
+ inputs: Record
+ ): Promise {
+ const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/actions/workflows/${workflowFile}/dispatches`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ ref, inputs }),
+ });
+ if (!response.ok && response.status !== 204) {
+ throw new Error(`Workflow dispatch failed: ${response.status} ${response.statusText}`);
+ }
+ }
+
+ // ============== Release Cleanup API ==============
+
+ async listReleases (owner: string, repo: string, perPage = 100, page = 1): Promise {
+ return this.request(
+ `/repos/${owner}/${repo}/releases?per_page=${perPage}&page=${page}`
+ );
+ }
+}
+
+// ============== 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`);
+ }
+}
+
+// ============== 环境变量工具 ==============
+
+// Note: 不使用函数重载——Node.js --experimental-strip-types 不支持 TS overload signatures
+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 };
+}
diff --git a/.github/scripts/pr-build-building.ts b/.github/scripts/pr-build-building.ts
new file mode 100644
index 0000000..ddf91fe
--- /dev/null
+++ b/.github/scripts/pr-build-building.ts
@@ -0,0 +1,36 @@
+/**
+ * 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 {
+ 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);
+});
diff --git a/.github/scripts/pr-build-check.ts b/.github/scripts/pr-build-check.ts
new file mode 100644
index 0000000..0a02292
--- /dev/null
+++ b/.github/scripts/pr-build-check.ts
@@ -0,0 +1,206 @@
+/**
+ * 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 {
+ // 方法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 {
+ 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 {
+ 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);
+});
diff --git a/.github/scripts/pr-build-dispatch.ts b/.github/scripts/pr-build-dispatch.ts
new file mode 100644
index 0000000..38fc6ba
--- /dev/null
+++ b/.github/scripts/pr-build-dispatch.ts
@@ -0,0 +1,50 @@
+/**
+ * PR Build - Dispatch untrusted build workflow
+ *
+ * 环境变量:
+ * - GITHUB_TOKEN: GitHub API Token
+ * - PR_NUMBER: PR 编号
+ * - PR_SHA: PR HEAD SHA
+ * - PR_HEAD_REPO: PR 源仓库
+ * - PR_HEAD_REF: PR 源分支
+ * - WORKFLOW_FILE: 工作流文件名,默认 build-pr.yml
+ * - DISPATCH_REF: 触发分支,默认当前分支
+ */
+
+import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
+
+declare const process: { exit(code?: number): never; };
+
+async function main (): Promise {
+ console.log('🚀 Dispatching PR build workflow\n');
+
+ const token = getEnv('GITHUB_TOKEN', true)!;
+ const prNumber = getEnv('PR_NUMBER', true)!;
+ const prSha = getEnv('PR_SHA', true)!;
+ const prHeadRepo = getEnv('PR_HEAD_REPO', true)!;
+ const prHeadRef = getEnv('PR_HEAD_REF') || '';
+ const workflowFile = getEnv('WORKFLOW_FILE') || 'build-pr.yml';
+ const dispatchRef = getEnv('DISPATCH_REF') || getEnv('GITHUB_REF_NAME') || 'main';
+ const { owner, repo } = getRepository();
+
+ console.log(`PR: #${prNumber}`);
+ console.log(`SHA: ${prSha}`);
+ console.log(`Head repo: ${prHeadRepo}`);
+ console.log(`Workflow: ${workflowFile}`);
+ console.log(`Ref: ${dispatchRef}\n`);
+
+ const github = new GitHubAPI(token);
+ await github.createWorkflowDispatch(owner, repo, workflowFile, dispatchRef, {
+ pr_number: prNumber,
+ pr_sha: prSha,
+ pr_head_repo: prHeadRepo,
+ pr_head_ref: prHeadRef,
+ });
+
+ console.log('✅ Dispatch created');
+}
+
+main().catch((error) => {
+ console.error('❌ Error:', error);
+ process.exit(1);
+});
diff --git a/.github/scripts/pr-build-result.ts b/.github/scripts/pr-build-result.ts
new file mode 100644
index 0000000..1f9931e
--- /dev/null
+++ b/.github/scripts/pr-build-result.ts
@@ -0,0 +1,107 @@
+/**
+ * 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 构建错误信息
+ * - INSTALL_URL: PR Release 安装脚本 URL (可选)
+ * - RELEASE_URL: PR Release 页面 URL (可选)
+ */
+
+import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
+import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
+import type { BuildTarget, BuildStatus, InstallInfo } 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 {
+ 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`);
+
+ // PR Release 信息(由 publish-pr-release job 传入)
+ const installUrl = getEnv('INSTALL_URL');
+ const releaseUrl = getEnv('RELEASE_URL');
+ const publishStatus = getEnv('PUBLISH_STATUS'); // 'success' | 'failure' | 'skipped' | ''
+ const installInfo: InstallInfo | undefined =
+ installUrl && releaseUrl ? { installUrl, releaseUrl } : undefined;
+ const publishFailed = publishStatus === 'failure';
+
+ if (installInfo) {
+ console.log(`Install URL: ${installUrl}`);
+ console.log(`Release URL: ${releaseUrl}\n`);
+ } else if (publishFailed) {
+ console.log('Publish status: FAILED\n');
+ }
+
+ const github = new GitHubAPI(token);
+ const repository = `${owner}/${repo}`;
+
+ // 获取 artifacts 列表,生成直接下载链接
+ const artifactMap: Record = {};
+ 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, installInfo, publishFailed);
+
+ await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
+}
+
+main().catch((error) => {
+ console.error('❌ Error:', error);
+ process.exit(1);
+});
diff --git a/.github/scripts/pr-build-run.ts b/.github/scripts/pr-build-run.ts
new file mode 100644
index 0000000..e521e1e
--- /dev/null
+++ b/.github/scripts/pr-build-run.ts
@@ -0,0 +1,145 @@
+/**
+ * PR Build Runner
+ * 执行构建步骤
+ *
+ * 用法: node pr-build-run.ts
+ * 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 [
+ // pnpm 已通过 GitHub Action (pnpm/action-setup) 预安装并固定 v9, 此处无需再次安装
+ {
+ 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 ');
+ 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();
diff --git a/.github/scripts/pr-docker-dispatch.ts b/.github/scripts/pr-docker-dispatch.ts
new file mode 100644
index 0000000..182645b
--- /dev/null
+++ b/.github/scripts/pr-docker-dispatch.ts
@@ -0,0 +1,69 @@
+/**
+ * PR Build - 触发 NapCat-Docker 仓库构建 PR 测试镜像
+ *
+ * 功能:
+ * 1. 在 PR 中创建 Docker 构建中评论
+ * 2. 触发 NapCat-Docker 仓库的 repository_dispatch 事件
+ *
+ * 环境变量:
+ * DISPATCH_PAT - 具备目标仓库 dispatch 权限的 PAT
+ * GITHUB_TOKEN - GitHub API Token (用于写 PR 评论)
+ * PR_NUMBER - PR 编号
+ * PR_SHA - PR HEAD 提交 SHA
+ * PR_HEAD_REPO - PR 源仓库 (owner/repo)
+ * SOURCE_REPO - 当前仓库 (默认 NapNeko/NapCatQQ)
+ * RELEASE_REPO - PR release 仓库 (默认 NapNeko/napcat-pr-release)
+ * TARGET_REPO - Docker 仓库 (默认 NapNeko/NapCat-Docker)
+ */
+
+import { GitHubAPI, getEnv } from './lib/github.ts';
+import { generateDockerBuildingComment, DOCKER_COMMENT_MARKER } from './lib/comment.ts';
+
+async function main (): Promise {
+ console.log('🐳 Dispatching Docker build\n');
+
+ const token = getEnv('DISPATCH_PAT', true);
+ const githubToken = getEnv('GITHUB_TOKEN', true);
+ const prNumber = getEnv('PR_NUMBER', true);
+ const prSha = getEnv('PR_SHA', true);
+ const prHeadRepo = getEnv('PR_HEAD_REPO') || '';
+ const sourceRepo = getEnv('SOURCE_REPO') || 'NapNeko/NapCatQQ';
+ const releaseRepo = getEnv('RELEASE_REPO') || 'NapNeko/napcat-pr-release';
+ const targetRepo = getEnv('TARGET_REPO') || 'NapNeko/NapCat-Docker';
+
+ const [targetOwner, targetRepoName] = targetRepo.split('/');
+ const [sourceOwner, sourceRepoName] = sourceRepo.split('/');
+ const shortSha = prSha.substring(0, 7);
+
+ console.log(` PR: #${prNumber}`);
+ console.log(` SHA: ${shortSha}`);
+ console.log(` Target: ${targetRepo}`);
+ console.log(` Release: ${releaseRepo}\n`);
+
+ // 在 PR 中创建 Docker 构建中评论
+ console.log('💬 Creating Docker building comment...');
+ const prGithub = new GitHubAPI(githubToken);
+ const dockerComment = generateDockerBuildingComment(prNumber, prSha);
+ await prGithub.createOrUpdateComment(
+ sourceOwner, sourceRepoName, parseInt(prNumber, 10), dockerComment, DOCKER_COMMENT_MARKER
+ );
+
+ // 触发 Docker 仓库构建
+ const github = new GitHubAPI(token);
+
+ await github.repositoryDispatch(targetOwner, targetRepoName, 'napcatqq-pr-build', {
+ pr_number: prNumber,
+ pr_sha: prSha,
+ pr_short_sha: shortSha,
+ pr_head_repo: prHeadRepo,
+ source_repo: sourceRepo,
+ release_repo: releaseRepo,
+ });
+
+ console.log('✅ Dispatch succeeded');
+}
+
+main().catch((error) => {
+ console.error('❌ Error:', error);
+ process.exit(1);
+});
diff --git a/.github/scripts/pr-publish-release.ts b/.github/scripts/pr-publish-release.ts
new file mode 100644
index 0000000..b18d3bd
--- /dev/null
+++ b/.github/scripts/pr-publish-release.ts
@@ -0,0 +1,173 @@
+/**
+ * PR Build - 发布构建产物到 napcat-pr-release 仓库
+ *
+ * 功能:
+ * 1. 将 CI 构建产物(Framework / Shell)打包为 zip
+ * 2. 从 NapCat-Installer 仓库拉取 install.sh 并替换下载链接
+ * 3. 发布到 napcat-pr-release 仓库的 GitHub Release
+ *
+ * 环境变量:
+ * RELEASE_PAT - 具备目标仓库 release 写权限的 PAT
+ * PR_NUMBER - PR 编号
+ * PR_SHA - PR HEAD 提交 SHA
+ * NAPCAT_VERSION - 构建版本号
+ * ARTIFACTS_DIR - 构建产物目录 (默认 ./artifacts)
+ * RELEASE_REPO - 发布仓库 (默认 NapNeko/napcat-pr-release)
+ * INSTALLER_REPO - 安装脚本仓库 (默认 NapNeko/NapCat-Installer)
+ * INSTALLER_BRANCH - 安装脚本分支 (默认 main)
+ */
+
+import { execSync } from 'node:child_process';
+import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { GitHubAPI, getEnv, setOutput } from './lib/github.ts';
+import { toProxyUrl } from './lib/comment.ts';
+
+interface AssetEntry {
+ name: string;
+ path: string;
+ contentType: string;
+}
+
+async function main (): Promise {
+ console.log('📦 PR Release Publisher\n');
+
+ const token = getEnv('RELEASE_PAT', true);
+ const prNumber = getEnv('PR_NUMBER', true);
+ const prSha = getEnv('PR_SHA', true);
+ const version = getEnv('NAPCAT_VERSION') || 'unknown';
+ const artifactsDir = getEnv('ARTIFACTS_DIR') || './artifacts';
+ const releaseRepo = getEnv('RELEASE_REPO') || 'NapNeko/napcat-pr-release';
+ const installerRepo = getEnv('INSTALLER_REPO') || 'NapNeko/NapCat-Installer';
+ const installerBranch = getEnv('INSTALLER_BRANCH') || 'main';
+
+ const [releaseOwner, releaseRepoName] = releaseRepo.split('/');
+ const shortSha = prSha.substring(0, 7);
+ const tag = `pr-${prNumber}-${shortSha}`;
+ const releaseName = `PR #${prNumber} (${shortSha})`;
+ const downloadBase = `https://github.com/${releaseRepo}/releases/download/${tag}`;
+
+ console.log(` Tag: ${tag}`);
+ console.log(` Repo: ${releaseRepo}`);
+ console.log(` Version: ${version}`);
+ console.log(` SHA: ${shortSha}\n`);
+
+ const github = new GitHubAPI(token);
+ const releaseDir = resolve('./release-assets'); // 使用绝对路径,避免 zip cwd 切换后相对路径解析错误
+ mkdirSync(releaseDir, { recursive: true });
+
+ // ---- 打包构建产物 ----
+ const artifacts = ['NapCat.Framework', 'NapCat.Shell'];
+ for (const name of artifacts) {
+ const dir = join(artifactsDir, name);
+ if (existsSync(dir)) {
+ const zipPath = join(releaseDir, `${name}.zip`);
+ console.log(`📦 Packing ${name}...`);
+ execSync(`zip -qr "${zipPath}" .`, { cwd: dir, stdio: 'inherit' });
+ console.log(` ✓ ${name}.zip\n`);
+ } else {
+ console.log(` ⚠ ${name} 目录不存在,跳过\n`);
+ }
+ }
+
+ // ---- 拉取并修改安装脚本 ----
+ console.log('📜 Fetching install.sh...');
+ const scriptUrl = `https://raw.githubusercontent.com/${installerRepo}/${installerBranch}/script/install.sh`;
+ try {
+ const response = await fetch(scriptUrl);
+ if (response.ok) {
+ let script = await response.text();
+ // 替换下载链接(保留代理前缀 ${target_proxy:+${target_proxy}/})
+ const shellUrl = `${downloadBase}/NapCat.Shell.zip`;
+ const originalScript = script;
+ script = script.replace(
+ /napcat_download_url="\$\{target_proxy:\+\$\{target_proxy\}\/\}https:\/\/github\.com\/NapNeko\/NapCatQQ\/releases\/latest\/download\/NapCat\.Shell\.zip"/g,
+ `napcat_download_url="\${target_proxy:+\${target_proxy}/}${shellUrl}"`
+ );
+ if (script === originalScript) {
+ console.log(' ⚠ Warning: URL replacement did not match - install.sh format may have changed');
+ }
+ const scriptPath = join(releaseDir, 'install.sh');
+ writeFileSync(scriptPath, script, { mode: 0o755 });
+ console.log(` ✓ Patched download URL -> ${shellUrl}\n`);
+ } else {
+ console.log(` ⚠ Failed to fetch install.sh: ${response.status}\n`);
+ }
+ } catch (e) {
+ console.log(` ⚠ Failed to fetch install.sh: ${(e as Error).message}\n`);
+ }
+
+ // ---- 收集所有要上传的文件 ----
+ const assets: AssetEntry[] = [];
+ for (const file of readdirSync(releaseDir)) {
+ const ext = file.split('.').pop()?.toLowerCase();
+ const contentType = ext === 'zip' ? 'application/zip' : ext === 'sh' ? 'application/x-sh' : 'application/octet-stream';
+ assets.push({ name: file, path: join(releaseDir, file), contentType });
+ }
+ console.log(`📋 Assets to upload: ${assets.map(a => a.name).join(', ')}\n`);
+
+ // ---- 删除旧 release ----
+ console.log('🗑️ Checking for existing release...');
+ const existing = await github.getReleaseByTag(releaseOwner, releaseRepoName, tag);
+ if (existing) {
+ console.log(` Deleting existing release (id=${existing.id})...`);
+ await github.deleteRelease(releaseOwner, releaseRepoName, existing.id);
+ await github.deleteRef(releaseOwner, releaseRepoName, `tags/${tag}`);
+ console.log(' ✓ Deleted\n');
+ } else {
+ console.log(' No existing release\n');
+ }
+
+ // ---- 创建 Release ----
+ const directInstallUrl = `${downloadBase}/install.sh`;
+ const proxyInstallUrl = toProxyUrl(directInstallUrl);
+ const releaseBody = [
+ `## NapCat PR #${prNumber} 测试版本`,
+ '',
+ `- **版本**: \`${version}\``,
+ `- **提交**: \`${prSha}\``,
+ `- **来源**: [NapCatQQ PR #${prNumber}](https://github.com/NapNeko/NapCatQQ/pull/${prNumber})`,
+ '',
+ '### 快速安装 (Linux)',
+ '',
+ '**直连(需要能访问 GitHub)**',
+ '```bash',
+ `curl -sSL ${directInstallUrl} | bash`,
+ '```',
+ '',
+ '**加速(国内推荐,ghfast.top)**',
+ '```bash',
+ `curl -sSL ${proxyInstallUrl} | bash`,
+ '```',
+ '',
+ '> ⚠️ 此为 PR 测试版本,约 5 天后会被自动清理。',
+ ].join('\n');
+
+ console.log('🚀 Creating release...');
+ const release = await github.createRelease(
+ releaseOwner, releaseRepoName, tag, releaseName, releaseBody, true
+ );
+ console.log(` ✓ Release created: ${release.html_url}\n`);
+
+ // ---- 上传资产 ----
+ console.log('📤 Uploading assets...');
+ for (const asset of assets) {
+ const data = readFileSync(asset.path);
+ const uploaded = await github.uploadReleaseAsset(
+ release.upload_url, asset.name, data, asset.contentType
+ );
+ console.log(` ✓ ${asset.name} -> ${uploaded.browser_download_url}`);
+ }
+
+ console.log(`\n✅ Release published: ${release.html_url}`);
+
+ // 输出供后续 job 使用
+ setOutput('release_url', release.html_url);
+ setOutput('release_tag', tag);
+ setOutput('install_url', `${downloadBase}/install.sh`);
+}
+
+main().catch((error) => {
+ console.error('❌ Error:', error);
+ process.exit(1);
+});
diff --git a/.github/scripts/publish-release-hash.mjs b/.github/scripts/publish-release-hash.mjs
new file mode 100644
index 0000000..efcd1a8
--- /dev/null
+++ b/.github/scripts/publish-release-hash.mjs
@@ -0,0 +1,80 @@
+/**
+ * publish-release-hash.mjs
+ *
+ * 读取 release.json(不存在则创建),追加新版本的 Shell/Framework napcat.mjs SHA512,
+ * 保留最近 100 条后写回文件。
+ *
+ * 用法:
+ * node .github/scripts/publish-release-hash.mjs \
+ * --version v4.9.0 \
+ * --shell ./artifacts/NapCat.Shell/napcat.mjs \
+ * --framework ./artifacts/NapCat.Framework/napcat.mjs \
+ * --file ./hash-repo/release.json
+ */
+
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+
+// ─── CLI 参数解析 ────────────────────────────────────────────────────────────
+
+function parseArgs () {
+ const args = process.argv.slice(2);
+ const result = {};
+ for (let i = 0; i < args.length; i += 2) {
+ result[args[i].replace(/^--/, '')] = args[i + 1];
+ }
+ return result;
+}
+
+const { version, shell: shellPath, framework: frameworkPath, file: outputFile } = parseArgs();
+
+if (!version || !shellPath || !frameworkPath || !outputFile) {
+ console.error('Usage: node publish-release-hash.mjs --version --shell --framework --file ');
+ process.exit(1);
+}
+
+// ─── 工具函数 ────────────────────────────────────────────────────────────────
+
+function sha512File (filePath) {
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`File not found: ${filePath}`);
+ }
+ const buf = fs.readFileSync(filePath);
+ return crypto.createHash('sha512').update(buf).digest('hex');
+}
+
+// ─── 主逻辑 ──────────────────────────────────────────────────────────────────
+
+const MAX_ENTRIES = 100;
+
+// 1. 计算哈希
+console.log(`Computing SHA512 for Shell: ${shellPath}`);
+const shellHash = sha512File(shellPath);
+console.log(`Computing SHA512 for Framework: ${frameworkPath}`);
+const frameworkHash = sha512File(frameworkPath);
+
+// 2. 读取现有 release.json,不存在则创建空数组
+let entries = [];
+if (fs.existsSync(outputFile)) {
+ entries = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
+ console.log(`Loaded existing ${outputFile} with ${entries.length} entries.`);
+} else {
+ console.log(`${outputFile} not found, will create it.`);
+}
+
+// 3. 追加新条目,去除同版本旧条目,滚动保留最近 MAX_ENTRIES 条
+const newEntry = {
+ version,
+ shell: { sha512: shellHash },
+ framework: { sha512: frameworkHash },
+ updatedAt: new Date().toISOString(),
+};
+
+const updated = [
+ ...entries.filter(e => e.version !== version),
+ newEntry,
+].slice(-MAX_ENTRIES);
+
+// 4. 写回文件
+fs.writeFileSync(outputFile, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
+console.log(`✅ Written ${updated.length} entries to ${outputFile}`);
diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml
new file mode 100644
index 0000000..89a1332
--- /dev/null
+++ b/.github/workflows/build-ci.yml
@@ -0,0 +1,91 @@
+name: Build NapCat Artifacts
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions: write-all
+
+jobs:
+ typecheck-and-test:
+ name: Type Check & Test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Main Repository
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ run_install: false
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v6
+ with:
+ node-version: 20.x
+ - name: Install Dependencies
+ run: pnpm i
+ - name: Type Check
+ run: pnpm run typecheck
+ - name: Run Tests
+ run: pnpm test
+
+ build:
+ name: Build NapCat.${{ matrix.target.name }}
+ needs: typecheck-and-test
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ target:
+ - name: Framework
+ build_cmd: build:framework
+ src_dir: packages/napcat-framework/dist
+ dist_dir: framework-dist
+ - name: Shell
+ build_cmd: build:shell
+ src_dir: packages/napcat-shell/dist
+ dist_dir: shell-dist
+ steps:
+ - name: Clone Main Repository
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ run_install: false
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v6
+ with:
+ node-version: 20.x
+ - name: Generate Version
+ run: |
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ 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.${{ matrix.target.name }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
+ run: |
+ pnpm i
+ pnpm --filter napcat-webui-frontend run build || exit 1
+ pnpm run ${{ matrix.target.build_cmd }}
+ pnpm --filter napcat-plugin-builtin run build || exit 1
+ mv ${{ matrix.target.src_dir }} ${{ matrix.target.dist_dir }}
+ cd ${{ matrix.target.dist_dir }}
+ npm install --omit=dev
+ rm ./package-lock.json || exit 0
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: NapCat.${{ matrix.target.name }}
+ path: ${{ matrix.target.dist_dir }}
diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml
new file mode 100644
index 0000000..6a7db2c
--- /dev/null
+++ b/.github/workflows/build-pr.yml
@@ -0,0 +1,424 @@
+# =============================================================================
+# PR 构建工作流
+# =============================================================================
+# 安全模型:
+# 1. pull_request_target / issue_comment 入口只读取元数据、检查权限、更新评论并触发构建。
+# 2. 真正执行 PR 代码的构建流程只在 workflow_dispatch 中运行。
+# 3. 构建流程不具备写仓库/写评论/读 secrets 权限,显式清空 GITHUB_TOKEN,不持久化 checkout 凭据。
+# 4. 版本号在 Action 中提前生成,通过 NAPCAT_VERSION 传给 Vite 插件,插件无需 GitHub token。
+# 5. 发布 Release、触发 Docker 等可信操作只消费构建 artifact,不执行 PR artifact 中的代码。
+# =============================================================================
+
+name: Build PR
+
+on:
+ pull_request_target:
+ types: [opened, synchronize, reopened]
+
+ issue_comment:
+ types: [created]
+
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: PR 编号
+ required: true
+ type: string
+ pr_sha:
+ description: PR HEAD SHA
+ required: true
+ type: string
+ pr_head_repo:
+ description: PR 源仓库
+ required: true
+ type: string
+ pr_head_ref:
+ description: PR 源分支
+ required: false
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pr-build-${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || 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:
+ # ---------------------------------------------------------------------------
+ # 特权入口:只检查条件、写入“构建中”评论、触发无权限构建
+ # ---------------------------------------------------------------------------
+ check-build:
+ if: github.event_name != 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: write
+ outputs:
+ should_build: ${{ steps.check.outputs.should_build }}
+ pr_number: ${{ steps.check.outputs.pr_number }}
+ pr_sha: ${{ steps.check.outputs.pr_sha }}
+ pr_head_repo: ${{ steps.check.outputs.pr_head_repo }}
+ pr_head_ref: ${{ steps.check.outputs.pr_head_ref }}
+ steps:
+ - name: Checkout scripts
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ 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
+
+ update-comment-building:
+ needs: check-build
+ if: needs.check-build.outputs.should_build == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+ steps:
+ - name: Checkout scripts
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - 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
+
+ dispatch-build:
+ needs: [check-build, update-comment-building]
+ if: needs.check-build.outputs.should_build == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: write
+ steps:
+ - name: Checkout scripts
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Dispatch untrusted build
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
+ PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
+ PR_HEAD_REPO: ${{ needs.check-build.outputs.pr_head_repo }}
+ PR_HEAD_REF: ${{ needs.check-build.outputs.pr_head_ref }}
+ WORKFLOW_FILE: build-pr.yml
+ DISPATCH_REF: ${{ github.event.repository.default_branch }}
+ run: node --experimental-strip-types .github/scripts/pr-build-dispatch.ts
+
+ # ---------------------------------------------------------------------------
+ # 无权限构建:只由 workflow_dispatch 执行 PR 代码
+ # ---------------------------------------------------------------------------
+ build-framework:
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ status: ${{ steps.build.outcome }}
+ error: ${{ steps.build.outputs.error }}
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Checkout scripts from base
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ path: _scripts
+ persist-credentials: false
+
+ - name: Checkout PR code
+ uses: actions/checkout@v5
+ with:
+ repository: ${{ inputs.pr_head_repo }}
+ ref: ${{ inputs.pr_sha }}
+ path: workspace
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ run_install: false
+
+ - name: Generate Version
+ id: version
+ working-directory: workspace
+ run: |
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ BASE_VERSION="${LATEST_TAG#v}"
+ SHORT_SHA="${{ inputs.pr_sha }}"
+ SHORT_SHA="${SHORT_SHA::7}"
+ VERSION="${BASE_VERSION}-pr.${{ inputs.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: ''
+ 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@v6
+ with:
+ name: NapCat.Framework
+ path: workspace/framework-dist
+ retention-days: 5
+
+ build-shell:
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ status: ${{ steps.build.outcome }}
+ error: ${{ steps.build.outputs.error }}
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Checkout scripts from base
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ path: _scripts
+ persist-credentials: false
+
+ - name: Checkout PR code
+ uses: actions/checkout@v5
+ with:
+ repository: ${{ inputs.pr_head_repo }}
+ ref: ${{ inputs.pr_sha }}
+ path: workspace
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ run_install: false
+
+ - name: Generate Version
+ id: version
+ working-directory: workspace
+ run: |
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ BASE_VERSION="${LATEST_TAG#v}"
+ SHORT_SHA="${{ inputs.pr_sha }}"
+ SHORT_SHA="${SHORT_SHA::7}"
+ VERSION="${BASE_VERSION}-pr.${{ inputs.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: ''
+ 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@v6
+ with:
+ name: NapCat.Shell
+ path: workspace/shell-dist
+ retention-days: 5
+
+ # ---------------------------------------------------------------------------
+ # 可信后处理:只下载/打包/发布 artifact,不执行 artifact 中的代码
+ # ---------------------------------------------------------------------------
+ publish-pr-release:
+ needs: [build-framework, build-shell]
+ if: always() && github.event_name == 'workflow_dispatch' && (needs.build-framework.outputs.status == 'success' || needs.build-shell.outputs.status == 'success')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: read
+ outputs:
+ release_url: ${{ steps.publish.outputs.release_url }}
+ release_tag: ${{ steps.publish.outputs.release_tag }}
+ install_url: ${{ steps.publish.outputs.install_url }}
+ publish_status: ${{ steps.publish.outcome }}
+ steps:
+ - name: Checkout scripts
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Download Framework artifact
+ if: needs.build-framework.outputs.status == 'success'
+ uses: actions/download-artifact@v6
+ with:
+ name: NapCat.Framework
+ path: ./artifacts/NapCat.Framework
+
+ - name: Download Shell artifact
+ if: needs.build-shell.outputs.status == 'success'
+ uses: actions/download-artifact@v6
+ with:
+ name: NapCat.Shell
+ path: ./artifacts/NapCat.Shell
+
+ - name: Publish release
+ id: publish
+ continue-on-error: true
+ env:
+ RELEASE_PAT: ${{ secrets.PR_RELEASE_PAT }}
+ PR_NUMBER: ${{ inputs.pr_number }}
+ PR_SHA: ${{ inputs.pr_sha }}
+ NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
+ ARTIFACTS_DIR: ./artifacts
+ run: |
+ if [ -z "${RELEASE_PAT}" ]; then
+ echo "::warning::PR_RELEASE_PAT secret 未配置,跳过 PR Release 发布"
+ exit 0
+ fi
+ node --experimental-strip-types .github/scripts/pr-publish-release.ts
+
+ - name: Upload install script as artifact
+ if: steps.publish.outcome == 'success'
+ uses: actions/upload-artifact@v6
+ with:
+ name: install-script
+ path: ./release-assets/install.sh
+ retention-days: 5
+ if-no-files-found: ignore
+
+ update-comment-result:
+ needs: [build-framework, build-shell, publish-pr-release]
+ if: always() && github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: read
+ pull-requests: write
+ issues: write
+ steps:
+ - name: Checkout scripts
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Update result comment
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ inputs.pr_number }}
+ PR_SHA: ${{ inputs.pr_sha }}
+ RUN_ID: ${{ github.run_id }}
+ NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
+ 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 }}
+ INSTALL_URL: ${{ needs.publish-pr-release.outputs.install_url || '' }}
+ RELEASE_URL: ${{ needs.publish-pr-release.outputs.release_url || '' }}
+ PUBLISH_STATUS: ${{ needs.publish-pr-release.outputs.publish_status || '' }}
+ run: node --experimental-strip-types .github/scripts/pr-build-result.ts
+
+ trigger-docker:
+ needs: [build-shell, publish-pr-release]
+ if: github.event_name == 'workflow_dispatch' && needs.build-shell.outputs.status == 'success' && needs.publish-pr-release.outputs.release_tag != ''
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+ steps:
+ - name: Checkout scripts
+ uses: actions/checkout@v5
+ with:
+ sparse-checkout: .github/scripts
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Dispatch docker build
+ continue-on-error: true
+ env:
+ DISPATCH_PAT: ${{ secrets.PR_RELEASE_PAT }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ inputs.pr_number }}
+ PR_SHA: ${{ inputs.pr_sha }}
+ PR_HEAD_REPO: ${{ inputs.pr_head_repo }}
+ SOURCE_REPO: ${{ github.repository }}
+ RELEASE_REPO: NapNeko/napcat-pr-release
+ run: |
+ if [ -z "${DISPATCH_PAT}" ]; then
+ echo "::warning::PR_RELEASE_PAT secret 未配置,跳过 Docker 测试镜像构建"
+ exit 0
+ fi
+ node --experimental-strip-types .github/scripts/pr-docker-dispatch.ts
diff --git a/.github/workflows/release-downstream.yml b/.github/workflows/release-downstream.yml
new file mode 100644
index 0000000..f618bcd
--- /dev/null
+++ b/.github/workflows/release-downstream.yml
@@ -0,0 +1,153 @@
+name: Release Downstream
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag_name:
+ description: 'Release tag name (e.g., v4.10.7)'
+ required: true
+ type: string
+
+env:
+ QQ_URL_X86_64: 'https://qqdl.gtimg.cn/qqfile/QQNT/9.9.32/beta/fd40a3ec/linuxqq_3.2.30-50969_x86_64.AppImage'
+ QQ_URL_ARM64: 'https://qqdl.gtimg.cn/qqfile/QQNT/9.9.32/beta/fd40a3ec/linuxqq_3.2.30-50969_arm64.AppImage'
+
+jobs:
+ get-version:
+ runs-on: ubuntu-latest
+ outputs:
+ latest_tag: ${{ steps.get_version.outputs.latest_tag }}
+ version: ${{ steps.get_version.outputs.version }}
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v5
+ - name: Get Latest NapCat Version
+ id: get_version
+ run: |
+ if [ -n "${{ inputs.tag_name }}" ]; then
+ latest_tag="${{ inputs.tag_name }}"
+ else
+ latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
+ fi
+ version=${latest_tag#v}
+ echo "Debug: Latest NapCat Version is ${latest_tag}, version is ${version}"
+ echo "latest_tag=${latest_tag}" >> $GITHUB_OUTPUT
+ echo "version=${version}" >> $GITHUB_OUTPUT
+
+ publish-schema:
+ needs: get-version
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '20'
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+
+ - 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@v5
+ with:
+ repository: NapNeko/NapCatDocs
+ token: ${{ secrets.NAPCAT_BUILD }}
+ path: napcat-docs
+
+ - name: Copy OpenAPI Schema
+ run: |
+ VERSION="${{ needs.get-version.outputs.version }}"
+ mkdir -p napcat-docs/src/api/${VERSION}
+ cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${VERSION}/openapi.json
+ echo "OpenAPI schema copied to napcat-docs/src/api/${VERSION}/openapi.json"
+
+ - name: Commit and Push
+ run: |
+ VERSION="${{ needs.get-version.outputs.version }}"
+ 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/${VERSION}/openapi.json
+ git commit -m "chore: update OpenAPI schema for version ${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:
+ needs: get-version
+ runs-on: ubuntu-latest
+ steps:
+ - name: Trigger NapCat AppImage build workflow
+ env:
+ GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
+ NAPCAT_VERSION: ${{ needs.get-version.outputs.latest_tag }}
+ run: |
+ echo "Debug: Triggering NapCat AppImage with napcat_version=${NAPCAT_VERSION}"
+ 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\":\"${{ env.QQ_URL_X86_64 }}\",\"qq_version_arm64\":\"${{ env.QQ_URL_ARM64 }}\"}}"
+
+ node-shell:
+ needs: get-version
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
+ NAPCAT_VERSION: ${{ needs.get-version.outputs.latest_tag }}
+ steps:
+ - name: Trigger NapCat NodeLoader release workflow
+ run: |
+ echo "Debug: Triggering NapCat NodeLoader release with napcat_version=${NAPCAT_VERSION}"
+ 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\":\"${{ env.QQ_URL_X86_64 }}\",\"qq_url_arm64\":\"${{ env.QQ_URL_ARM64 }}\"}}"
+
+ - name: Trigger NapCat NodeLoader docker workflow
+ run: |
+ echo "Debug: Triggering NapCat NodeLoader docker with napcat_version=${NAPCAT_VERSION}"
+ 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\":\"${{ env.QQ_URL_X86_64 }}\",\"qq_url_arm64\":\"${{ env.QQ_URL_ARM64 }}\"}}"
diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml
new file mode 100644
index 0000000..42b2ae6
--- /dev/null
+++ b/.github/workflows/release-publish.yml
@@ -0,0 +1,505 @@
+name: Release Publish
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Release version tag (e.g., v4.10.7)'
+ required: true
+ type: string
+
+permissions: write-all
+
+env:
+ OPENAI_API_URL: https://openrouter.ai/api/v1/chat/completions
+ OPENAI_MODEL: "google/gemma-4-26b-a4b-it:free"
+ RELEASE_NAME: "NapCat"
+
+jobs:
+ build:
+ name: Build NapCat.${{ matrix.target.name }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ target:
+ - name: Framework
+ build_cmd: build:framework
+ src_dir: packages/napcat-framework/dist
+ dist_dir: framework-dist
+ - name: Shell
+ build_cmd: build:shell
+ src_dir: packages/napcat-shell/dist
+ dist_dir: shell-dist
+ steps:
+ - name: Clone Main Repository
+ uses: actions/checkout@v5
+ with:
+ ref: ${{ inputs.version }}
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ run_install: false
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v6
+ with:
+ node-version: 20.x
+ - name: Build NapCat.${{ matrix.target.name }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ pnpm i
+ pnpm --filter napcat-webui-frontend run build || exit 1
+ pnpm run ${{ matrix.target.build_cmd }}
+ pnpm --filter napcat-plugin-builtin run build || exit 1
+ mv ${{ matrix.target.src_dir }} ${{ matrix.target.dist_dir }}
+ cd ${{ matrix.target.dist_dir }}
+ npm install --omit=dev
+ rm ./package-lock.json || exit 0
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: NapCat.${{ matrix.target.name }}
+ path: ${{ matrix.target.dist_dir }}
+
+ assemble-windows-node:
+ needs: build
+ runs-on: ubuntu-latest
+ env:
+ # QQ 9.9.31.49738; SHA256 matches the microsoft/winget-pkgs installer manifest.
+ QQ_URL: https://github.com/Rodert/qq-versions/releases/download/qq-packages-20260528-3e8913a2/QQ_9.9.31_260528_x64_01.exe
+ QQ_SHA256: 0beb5cb4ff776cba822caa0abadbd53f5892f12628a86dabea1b372c82c086bc
+ NODE_VERSION: '22.11.0'
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ inputs.version }}
+
+ - name: Download Artifacts
+ uses: actions/download-artifact@v7
+ with:
+ path: ./artifacts
+
+ - name: Setup tools
+ run: |
+ sudo apt update
+ sudo apt install -y aria2 unzip zip p7zip-full curl jq
+
+ - name: Generate download cache key
+ id: download-cache-key
+ run: |
+ DOWNLOAD_HASH=$(printf '%s\n' "$QQ_URL" "$QQ_SHA256" "$NODE_VERSION" | sha256sum | cut -d ' ' -f 1)
+ echo "key=napcat-downloads-v2-$DOWNLOAD_HASH" >> "$GITHUB_OUTPUT"
+
+ - name: Cache QQ and Node.js downloads
+ id: cache-downloads
+ uses: actions/cache@v4
+ with:
+ path: /tmp/napcat-downloads
+ key: ${{ steps.download-cache-key.outputs.key }}
+
+ - name: Download QQ and Node.js
+ if: steps.cache-downloads.outputs.cache-hit != 'true'
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/napcat-downloads
+ cd /tmp/napcat-downloads
+
+ echo "Downloading QQ installer..."
+ # Retry download errors, including HTTP 404 responses.
+ curl --fail --location --retry 5 --retry-all-errors --retry-delay 5 \
+ --connect-timeout 30 --max-time 600 --user-agent "Mozilla/5.0" \
+ --output "QQ_x64.exe" "$QQ_URL"
+
+ NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-win-x64.zip"
+ echo "Downloading Node.js $NODE_VERSION..."
+ aria2c -x1 -s1 -k1M -o "node-win-x64.zip" "$NODE_URL"
+
+ - name: Verify QQ installer
+ run: |
+ echo "$QQ_SHA256 /tmp/napcat-downloads/QQ_x64.exe" | sha256sum --check --strict -
+
+ - name: Assemble NapCat.Shell.Windows.Node.zip
+ run: |
+ set -euo pipefail
+ TMPDIR=$(mktemp -d)
+ CACHE_DIR="/tmp/napcat-downloads"
+
+ # -----------------------------
+ # 1) 解压 QQ x64
+ # -----------------------------
+ QQ_EXTRACT="$TMPDIR/qq_extracted"
+ mkdir -p "$QQ_EXTRACT"
+ 7z x -y -o"$QQ_EXTRACT" "$CACHE_DIR/QQ_x64.exe" >/dev/null
+
+ # -----------------------------
+ # 2) 解压 Node.js Windows x64
+ # -----------------------------
+ NODE_EXTRACT="$TMPDIR/node_extracted"
+ mkdir -p "$NODE_EXTRACT"
+ unzip -q "$CACHE_DIR/node-win-x64.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
+ # -----------------------------
+ cp -a "$NODE_EXTRACT/node-v$NODE_VERSION-win-x64/node.exe" "$OUT_DIR/" || true
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: NapCat.Shell.Windows.Node
+ path: NapCat.Shell.Windows.Node
+
+ release-napcat:
+ needs: [build, assemble-windows-node]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Download Artifacts
+ uses: actions/download-artifact@v7
+ with:
+ path: ./artifacts
+
+ - name: Download NapCat.Shell.Windows.OneKey.zip
+ run: |
+ curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
+
+ - name: Zip Artifacts
+ 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
+ env:
+ OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
+ OPENAI_API_URL: ${{ env.OPENAI_API_URL }}
+ OPENAI_MODEL: ${{ env.OPENAI_MODEL }}
+ GITHUB_OWNER: "NapNeko"
+ GITHUB_REPO: "NapCatQQ"
+ run: |
+ set -euo pipefail
+
+ # 当前 tag
+ CURRENT_TAG="${{ inputs.version }}"
+ 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,使用更清晰的格式
+ 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"
+
+ # 获取变更的关键文件列表(排除测试、配置等)
+ 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
+ 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
+
+ FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50) || true
+ FILE_DIFF_LEN=${#FILE_DIFF}
+
+ 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 "$OPENAI_MODEL" \
+ '{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:5000}')
+
+ echo "=== request body ==="
+ echo "$BODY" | jq .
+
+ if RESPONSE=$(curl -s -X POST "$OPENAI_API_URL" \
+ -H "Authorization: Bearer $OPENAI_KEY" \
+ -H "Content-Type: application/json" \
+ -d "$BODY"); then
+ echo "=== raw response ==="
+ echo "$RESPONSE"
+ echo "=== parsed 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 "LLM 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
+
+ - name: Create Release and Upload Artifacts
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ inputs.version }}
+ name: NapCat ${{ inputs.version }}
+ body_path: CHANGELOG.md
+ files: |
+ NapCat.Shell.Windows.Node.zip
+ NapCat.Framework.zip
+ NapCat.Shell.zip
+ NapCat.Shell.Windows.OneKey.zip
+ draft: false
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ run_install: false
+
+ - name: Update apifox
+ env:
+ APIFOX_TOKEN: ${{ secrets.APIFOX_TOKEN }}
+ run: |
+ pnpm i --ignore-scripts
+ pnpm run build:openapi
+
+ jq -n --rawfile input packages/napcat-schema/dist/openapi.json \
+ '{
+ input: $input,
+ options: {
+ "endpointOverwriteBehavior": "OVERWRITE_EXISTING",
+ "schemaOverwriteBehavior": "OVERWRITE_EXISTING",
+ "updateFolderOfChangedEndpoint": true,
+ "moduleId": 1140714,
+ "deleteUnmatchedResources": true
+ }
+ }' > apifox_payload.json
+
+ curl --location -g --request POST 'https://api.apifox.com/v1/projects/5348325/import-openapi?locale=zh-CN' \
+ --header 'X-Apifox-Api-Version: 2024-03-28' \
+ --header "Authorization: Bearer $APIFOX_TOKEN" \
+ --header 'Content-Type: application/json' \
+ --data-binary @apifox_payload.json
+
+ - name: Publish release hash
+ env:
+ GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
+ run: |
+ set -euo pipefail
+ VERSION="${{ inputs.version }}"
+ HASH_REPO="NapNeko/napcat-release-hash"
+
+ gh auth setup-git
+ gh repo clone "$HASH_REPO" hash-repo
+
+ node .github/scripts/publish-release-hash.mjs \
+ --version "$VERSION" \
+ --shell "./artifacts/NapCat.Shell/napcat.mjs" \
+ --framework "./artifacts/NapCat.Framework/napcat.mjs" \
+ --file "./hash-repo/release.json"
+
+ cd hash-repo
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add release.json
+ if git diff --cached --quiet; then
+ echo "No changes to commit"
+ else
+ git commit -m "chore: update release hash for ${VERSION}"
+ git push origin HEAD:main || git push origin HEAD:master
+ fi
+
+ - name: Trigger downstream release workflows
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ echo "Triggering release-downstream workflow with tag_name=${VERSION}"
+ curl -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer $GH_TOKEN" \
+ https://api.github.com/repos/${{ github.repository }}/actions/workflows/release-downstream.yml/dispatches \
+ -d "{\"ref\":\"main\",\"inputs\":{\"tag_name\":\"${VERSION}\"}}"
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
new file mode 100644
index 0000000..bdcc8a8
--- /dev/null
+++ b/.github/workflows/release-tag.yml
@@ -0,0 +1,53 @@
+name: Release Tag
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+permissions: write-all
+
+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"
+
+ 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
+
+ trigger-release:
+ needs: [validate-version]
+ if: needs.validate-version.outputs.valid == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Trigger release-publish workflow
+ env:
+ GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
+ VERSION: ${{ needs.validate-version.outputs.version }}
+ run: |
+ echo "Triggering release-publish workflow with version=${VERSION}"
+ curl -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer $GH_TOKEN" \
+ https://api.github.com/repos/${{ github.repository }}/actions/workflows/release-publish.yml/dispatches \
+ -d "{\"ref\":\"main\",\"inputs\":{\"version\":\"${VERSION}\"}}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..61b8875
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+dist/
+dist.bak*/
+*.log
+*.pdb
+*.lib
+*.exe
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..cae7ec6
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,12 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node-terminal",
+ "request": "launch",
+ "name": "调试程序",
+ "command": "pnpm run dev:shell",
+ "cwd": "${workspaceFolder}"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..61e337a
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,37 @@
+{
+ "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, 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
+}
\ No newline at end of file
diff --git a/.vscode/tailwindcss.json b/.vscode/tailwindcss.json
new file mode 100644
index 0000000..4c40326
--- /dev/null
+++ b/.vscode/tailwindcss.json
@@ -0,0 +1,55 @@
+{
+ "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 you’d 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"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..cca4546
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+nanaeonn@outlook.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a79f4f9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Limited Redistribution License for NapCat
+
+Copyright © 2024 Mlikiowa
+
+1. Usage and Reproduction:
+ - Unauthorized use, reproduction, modification, or distribution of this code is prohibited without explicit permission from the main author of the NapCat repository.
+
+2. Redistribution:
+ - Redistribution of this code is permitted, provided that the full text of this license is included, and the source and copyright information is clearly stated.
+ - Minor modifications and extensions are allowed for redistribution purposes, but the modified code must not be publicly released.
+
+3. Non-Commercial Use:
+ - This code is not to be used for any commercial purposes.
+
+4. Additional Permissions:
+ - Any rights not explicitly addressed in this license must be requested from and granted by the main author of the NapCat repository.
+
+5. Disclaimer:
+ - This code is provided "as is," without any express or implied warranties, including but not limited to the implied warranties of merchantability and fitness for a particular purpose. In no event shall the author be liable for any damages or other liability arising from, out of, or in connection with the use or distribution of this code.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..67c0bcf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+
+
+
+# NapCat
+
+_Modern protocol-side framework implemented based on NTQQ._
+
+> 云起兮风生,心向远方兮路未曾至.
+
+
+
+---
+
+## New Framework ?
+
+可以试试更新更好用的 [SnowLuma](https://github.com/SnowLuma/SnowLuma) 作为NapCat Gui替代品。
+
+## Welcome
+
+- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
+ - NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
+
+## Feature
+
+- **Easy to Use**
+ - 作为初学者能够轻松使用.
+- **Quick and Efficient**
+ - 在低内存操作系统长时运行.
+- **Rich API Interface**
+ - 完整实现了大部分标准接口.
+- **Stable and Reliable**
+ - 持续稳定的开发与维护.
+
+## Quick Start
+
+可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
+
+**首次使用**请务必查看如下文档看使用教程
+
+> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
+
+## Link
+
+| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
+|:-:|:-:|:-:|:-:|
+
+| Docs | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
+|:-:|:-:|:-:|:-:|
+
+| QQ Group | [](https://qm.qq.com/q/E4nfkGD6oK) | [](https://qm.qq.com/q/XyiyGPqa42) | [](https://qm.qq.com/q/gq18RH7o7S) | [](https://qm.qq.com/q/VwpnklcXqo) |
+|:-:|:-:|:-:|:-:|:-:|
+
+| Telegram | [](https://t.me/napcatqq) |
+|:-:|:-:|
+
+| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
+|:-:|:-:|
+
+> QQ群因为特殊原因需要入群密钥,请在webui关于页面获取,仅最新的100个版本以内有效。
+
+社区相关日后会进一步加强审核,并严格限制一定时间的入群人数,请具备一定编程基础和问题解决能力再申请入群。
+
+[困困猫猫的高中](https://qm.qq.com/q/14aUVclRUA)
+
+## Thanks
+
+- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
+
+- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
+
+- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
+
+- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
+
+- 不过最最重要的 还是需要感谢屏幕前的你哦~
+
+---
+
+## License
+
+本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
+
+1. 第三方库代码或修改部分遵循其原始开源许可.
+2. 本项目获取部分项目授权而不受部分约束
+2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
+
+**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..faee474
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,11 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| > 4.0 | :white_check_mark: |
+| < 4.0 | :x: |
+
+## Reporting a Vulnerability
+you should open an issue
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..b413ff6
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,52 @@
+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;
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..839691c
Binary files /dev/null and b/logo.png differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2e4dc49
--- /dev/null
+++ b/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "snowcat",
+ "private": true,
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "build:shell": "pnpm --filter snowcat-shell run build || exit 1",
+ "build:shell:dev": "pnpm --filter snowcat-shell run build:dev || exit 1",
+ "build:shell:config": "pnpm --filter snowcat-shell run build && pnpm --filter snowcat-develop run copy-env",
+ "build:framework": "pnpm --filter snowcat-framework run build || exit 1",
+ "build:webui": "pnpm --filter snowcat-webui-frontend run build || exit 1",
+ "build:plugin-builtin": "pnpm --filter snowcat-plugin-builtin run build || exit 1",
+ "build:openapi": "pnpm --filter snowcat-schema run build:openapi || exit 1",
+ "dev:shell": "pnpm --filter snowcat-develop run dev || exit 1",
+ "typecheck": "pnpm -r --if-present run typecheck",
+ "test": "pnpm --filter snowcat-test run test",
+ "test:ui": "pnpm --filter snowcat-test run test:ui",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix"
+ },
+ "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"
+ },
+ "dependencies": {
+ "express": "^5.0.0",
+ "ws": "^8.18.3"
+ }
+}
\ No newline at end of file
diff --git a/packages/snowcat-adapter/index.ts b/packages/snowcat-adapter/index.ts
new file mode 100644
index 0000000..a0ecc09
--- /dev/null
+++ b/packages/snowcat-adapter/index.ts
@@ -0,0 +1,176 @@
+import { InstanceContext, SnowCatCore } from 'snowcat-core';
+import { SnowCatPathWrapper } from 'snowcat-common/src/path';
+import { NapCatOneBot11Adapter } from 'snowcat-onebot';
+import { SnowCatProtocolAdapter } from 'snowcat-protocol';
+
+// 协议适配器类型
+export type ProtocolAdapterType = 'onebot11' | 'snowcat-protocol';
+
+// 协议适配器接口
+export interface IProtocolAdapter {
+ readonly name: string;
+ readonly enabled: boolean;
+ init (): Promise;
+ close (): Promise;
+}
+
+// 协议适配器包装器
+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 {
+ await this.adapter.InitOneBot();
+ }
+
+ async close (): Promise {
+ await this.adapter.networkManager.closeAllAdapters();
+ }
+
+ getAdapter (): NapCatOneBot11Adapter {
+ return this.adapter;
+ }
+}
+
+// NapCat Protocol 适配器包装器
+class SnowCatProtocolAdapterWrapper implements IProtocolAdapter {
+ readonly name = 'snowcat-protocol';
+ private adapter: SnowCatProtocolAdapter;
+
+ constructor (adapter: SnowCatProtocolAdapter) {
+ this.adapter = adapter;
+ }
+
+ get enabled (): boolean {
+ return this.adapter.isEnabled();
+ }
+
+ async init (): Promise {
+ await this.adapter.initProtocol();
+ }
+
+ async close (): Promise {
+ await this.adapter.close();
+ }
+
+ getAdapter (): SnowCatProtocolAdapter {
+ return this.adapter;
+ }
+}
+
+// 协议适配器管理器
+export class SnowCatAdapterManager {
+ private core: SnowCatCore;
+ private context: InstanceContext;
+ private pathWrapper: SnowCatPathWrapper;
+
+ // 协议适配器实例
+ private onebotAdapter: OneBotAdapterWrapper | null = null;
+ private snowcatProtocolAdapter: SnowCatProtocolAdapterWrapper | null = null;
+
+ // 所有已注册的适配器
+ private adapters: Map = new Map();
+
+ constructor (core: SnowCatCore, context: InstanceContext, pathWrapper: SnowCatPathWrapper) {
+ this.core = core;
+ this.context = context;
+ this.pathWrapper = pathWrapper;
+ }
+
+ // 初始化所有协议适配器
+ async initAdapters (): Promise {
+ 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 snowcatProtocol = new SnowCatProtocolAdapter(this.core, this.context, this.pathWrapper);
+ this.snowcatProtocolAdapter = new SnowCatProtocolAdapterWrapper(snowcatProtocol);
+ this.adapters.set('snowcat-protocol', this.snowcatProtocolAdapter);
+
+ if (this.snowcatProtocolAdapter.enabled) {
+ await this.snowcatProtocolAdapter.init();
+ this.context.logger.log('[AdapterManager] SnowCat Protocol 适配器初始化完成');
+ } else {
+ this.context.logger.log('[AdapterManager] SnowCat Protocol 适配器未启用,跳过初始化');
+ }
+ } catch (e) {
+ this.context.logger.logError('[AdapterManager] SnowCat Protocol 适配器初始化失败:', e);
+ }
+
+ this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
+ }
+
+ // 获取 OneBot11 适配器
+ getOneBotAdapter (): NapCatOneBot11Adapter | null {
+ return this.onebotAdapter?.getAdapter() ?? null;
+ }
+
+ // 获取 NapCat Protocol 适配器
+ getNapCatProtocolAdapter (): SnowCatProtocolAdapter | null {
+ return this.snowcatProtocolAdapter?.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 {
+ 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 {
+ const adapter = this.adapters.get(name);
+ if (adapter) {
+ await adapter.close();
+ await adapter.init();
+ this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
+ }
+ }
+}
+
+export { NapCatOneBot11Adapter } from 'snowcat-onebot';
+export { SnowCatProtocolAdapter } from 'snowcat-protocol';
diff --git a/packages/snowcat-adapter/package.json b/packages/snowcat-adapter/package.json
new file mode 100644
index 0000000..8021d9e
--- /dev/null
+++ b/packages/snowcat-adapter/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "snowcat-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": {
+ "snowcat-core": "workspace:*",
+ "snowcat-common": "workspace:*",
+ "snowcat-onebot": "workspace:*",
+ "snowcat-protocol": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/snowcat-adapter/tsconfig.json b/packages/snowcat-adapter/tsconfig.json
new file mode 100644
index 0000000..48d3a03
--- /dev/null
+++ b/packages/snowcat-adapter/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": [
+ "*.ts",
+ "**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}
\ No newline at end of file
diff --git a/packages/snowcat-common/package.json b/packages/snowcat-common/package.json
new file mode 100644
index 0000000..5f5087a
--- /dev/null
+++ b/packages/snowcat-common/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "snowcat-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"
+ },
+ "./src/*": {
+ "import": "./src/*"
+ }
+ },
+ "dependencies": {
+ "file-type": "^21.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/snowcat-common/src/cancel-task.ts b/packages/snowcat-common/src/cancel-task.ts
new file mode 100644
index 0000000..4be1236
--- /dev/null
+++ b/packages/snowcat-common/src/cancel-task.ts
@@ -0,0 +1,79 @@
+export type TaskExecutor = (resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise;
+
+export class CancelableTask {
+ private promise: Promise;
+ private cancelCallback: (() => void) | null = null;
+ private isCanceled = false;
+ private cancelListeners: Array<() => void> = [];
+
+ constructor (executor: TaskExecutor) {
+ this.promise = new Promise((resolve, reject) => {
+ const onCancel = (callback: () => void) => {
+ this.cancelCallback = callback;
+ };
+
+ const execute = async () => {
+ try {
+ await executor(
+ (value) => {
+ if (!this.isCanceled) {
+ resolve(value);
+ }
+ },
+ (reason) => {
+ if (!this.isCanceled) {
+ reject(reason);
+ }
+ },
+ onCancel
+ );
+ } catch (error) {
+ if (!this.isCanceled) {
+ reject(error);
+ }
+ }
+ };
+
+ execute();
+ });
+ }
+
+ public cancel () {
+ if (this.cancelCallback) {
+ this.cancelCallback();
+ }
+ this.isCanceled = true;
+ this.cancelListeners.forEach(listener => listener());
+ }
+
+ public isTaskCanceled (): boolean {
+ return this.isCanceled;
+ }
+
+ public onCancel (listener: () => void) {
+ this.cancelListeners.push(listener);
+ }
+
+ public then(
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null,
+ onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null
+ ): Promise {
+ return this.promise.then(onfulfilled, onrejected);
+ }
+
+ public catch(
+ onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null
+ ): Promise {
+ return this.promise.catch(onrejected);
+ }
+
+ public finally (onfinally?: (() => void) | undefined | null): Promise {
+ return this.promise.finally(onfinally);
+ }
+
+ [Symbol.asyncIterator] () {
+ return {
+ next: () => this.promise.then(value => ({ value, done: true })),
+ };
+ }
+}
diff --git a/packages/snowcat-common/src/clean-task.ts b/packages/snowcat-common/src/clean-task.ts
new file mode 100644
index 0000000..06fd3d3
--- /dev/null
+++ b/packages/snowcat-common/src/clean-task.ts
@@ -0,0 +1,229 @@
+import fs from 'fs';
+// generate Claude 3.7 Sonet Thinking
+
+interface FileRecord {
+ filePath: string;
+ addedTime: number;
+ retries: number;
+}
+
+interface CleanupTask {
+ fileRecord: FileRecord;
+ timer: NodeJS.Timeout;
+}
+
+class CleanupQueue {
+ private tasks: Map = new Map();
+ private readonly MAX_RETRIES = 3;
+ private isProcessing: boolean = false;
+ private pendingOperations: Array<() => void> = [];
+
+ /**
+ * 执行队列中的待处理操作,确保异步安全
+ */
+ private executeNextOperation (): void {
+ if (this.pendingOperations.length === 0) {
+ this.isProcessing = false;
+ return;
+ }
+
+ this.isProcessing = true;
+ const operation = this.pendingOperations.shift();
+ operation?.();
+
+ // 使用 setImmediate 允许事件循环继续,防止阻塞
+ setImmediate(() => this.executeNextOperation());
+ }
+
+ /**
+ * 安全执行操作,防止竞态条件
+ * @param operation 要执行的操作
+ */
+ private safeExecute (operation: () => void): void {
+ this.pendingOperations.push(operation);
+ if (!this.isProcessing) {
+ this.executeNextOperation();
+ }
+ }
+
+ /**
+ * 检查文件是否存在
+ * @param filePath 文件路径
+ * @returns 文件是否存在
+ */
+ private fileExists (filePath: string): boolean {
+ try {
+ return fs.existsSync(filePath);
+ } catch (_error) {
+ // console.log(`检查文件存在出错: ${filePath}`, error);
+ return false;
+ }
+ }
+
+ /**
+ * 添加文件到清理队列
+ * @param filePath 文件路径
+ * @param cleanupDelay 清理延迟时间(毫秒)
+ */
+ addFile (filePath: string, cleanupDelay: number): void {
+ this.safeExecute(() => {
+ // 如果文件已在队列中,取消原来的计时器
+ if (this.tasks.has(filePath)) {
+ this.cancelCleanup(filePath);
+ }
+
+ // 创建新的文件记录
+ const fileRecord: FileRecord = {
+ filePath,
+ addedTime: Date.now(),
+ retries: 0,
+ };
+
+ // 设置计时器
+ const timer = setTimeout(() => {
+ this.cleanupFile(fileRecord, cleanupDelay);
+ }, cleanupDelay);
+
+ // 添加到任务队列
+ this.tasks.set(filePath, { fileRecord, timer });
+ });
+ }
+
+ /**
+ * 批量添加文件到清理队列
+ * @param filePaths 文件路径数组
+ * @param cleanupDelay 清理延迟时间(毫秒)
+ */
+ addFiles (filePaths: string[], cleanupDelay: number): void {
+ this.safeExecute(() => {
+ for (const filePath of filePaths) {
+ // 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
+ if (this.tasks.has(filePath)) {
+ // 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
+ const existingTask = this.tasks.get(filePath);
+ if (existingTask) {
+ clearTimeout(existingTask.timer);
+ }
+ }
+
+ const fileRecord: FileRecord = {
+ filePath,
+ addedTime: Date.now(),
+ retries: 0,
+ };
+
+ const timer = setTimeout(() => {
+ this.cleanupFile(fileRecord, cleanupDelay);
+ }, cleanupDelay);
+
+ this.tasks.set(filePath, { fileRecord, timer });
+ }
+ });
+ }
+
+ /**
+ * 清理文件
+ * @param record 文件记录
+ * @param delay 延迟时间,用于重试
+ */
+ private cleanupFile (record: FileRecord, delay: number): void {
+ this.safeExecute(() => {
+ // 首先检查文件是否存在,不存在则视为清理成功
+ if (!this.fileExists(record.filePath)) {
+ // console.log(`文件已不存在,跳过清理: ${record.filePath}`);
+ this.tasks.delete(record.filePath);
+ return;
+ }
+
+ try {
+ // 尝试删除文件
+ fs.unlinkSync(record.filePath);
+ // 删除成功,从队列中移除任务
+ this.tasks.delete(record.filePath);
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException;
+
+ // 明确处理文件不存在的情况
+ if (err.code === 'ENOENT') {
+ // console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
+ this.tasks.delete(record.filePath);
+ return;
+ }
+
+ // 文件没有访问权限等情况
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
+ // console.error(`没有权限删除文件: ${record.filePath}`, err);
+ }
+
+ // 其他删除失败情况,考虑重试
+ if (record.retries < this.MAX_RETRIES - 1) {
+ // 还有重试机会,增加重试次数
+ record.retries++;
+ // console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
+
+ // 设置相同的延迟时间再次尝试
+ const timer = setTimeout(() => {
+ this.cleanupFile(record, delay);
+ }, delay);
+
+ // 更新任务
+ this.tasks.set(record.filePath, { fileRecord: record, timer });
+ } else {
+ // 已达到最大重试次数,从队列中移除任务
+ this.tasks.delete(record.filePath);
+ // console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
+ }
+ }
+ });
+ }
+
+ /**
+ * 取消文件的清理任务
+ * @param filePath 文件路径
+ * @returns 是否成功取消
+ */
+ cancelCleanup (filePath: string): boolean {
+ let cancelled = false;
+ this.safeExecute(() => {
+ const task = this.tasks.get(filePath);
+ if (task) {
+ clearTimeout(task.timer);
+ this.tasks.delete(filePath);
+ cancelled = true;
+ }
+ });
+ return cancelled;
+ }
+
+ /**
+ * 获取队列中的文件数量
+ * @returns 文件数量
+ */
+ getQueueSize (): number {
+ return this.tasks.size;
+ }
+
+ /**
+ * 获取所有待清理的文件
+ * @returns 文件路径数组
+ */
+ getPendingFiles (): string[] {
+ return Array.from(this.tasks.keys());
+ }
+
+ /**
+ * 清空所有清理任务
+ */
+ clearAll (): void {
+ this.safeExecute(() => {
+ // 取消所有定时器
+ for (const task of this.tasks.values()) {
+ clearTimeout(task.timer);
+ }
+ this.tasks.clear();
+ // console.log('已清空所有清理任务');
+ });
+ }
+}
+
+export const cleanTaskQueue = new CleanupQueue();
diff --git a/packages/snowcat-common/src/env.d.ts b/packages/snowcat-common/src/env.d.ts
new file mode 100644
index 0000000..f2363fc
--- /dev/null
+++ b/packages/snowcat-common/src/env.d.ts
@@ -0,0 +1,9 @@
+///
+
+declare global {
+ interface ImportMetaEnv {
+ readonly VITE_NAPCAT_VERSION: string;
+ }
+}
+
+export {};
diff --git a/packages/snowcat-common/src/fall-back.ts b/packages/snowcat-common/src/fall-back.ts
new file mode 100644
index 0000000..c3bd68d
--- /dev/null
+++ b/packages/snowcat-common/src/fall-back.ts
@@ -0,0 +1,42 @@
+type Handler = () => T | Promise;
+type Checker = (result: T) => T | Promise;
+
+export class Fallback {
+ private handlers: Handler[] = [];
+ private checker: Checker;
+
+ constructor (checker?: Checker) {
+ this.checker = checker || (async (result: T) => result);
+ }
+
+ add (handler: Handler): this {
+ this.handlers.push(handler);
+ return this;
+ }
+
+ // 执行处理程序链
+ async run (): Promise {
+ const errors: Error[] = [];
+ for (const handler of this.handlers) {
+ try {
+ const result = await handler();
+ const data = await this.checker(result);
+ if (data) {
+ return data;
+ }
+ } catch (error) {
+ errors.push(error instanceof Error ? error : new Error(String(error)));
+ }
+ }
+ throw new AggregateError(errors, 'All handlers failed');
+ }
+}
+export class FallbackUtil {
+ static boolchecker(value: T, condition: boolean): T {
+ if (condition) {
+ return value;
+ } else {
+ throw new Error('Condition is false, throwing error');
+ }
+ }
+}
diff --git a/packages/snowcat-common/src/file-uuid.ts b/packages/snowcat-common/src/file-uuid.ts
new file mode 100644
index 0000000..da6f9af
--- /dev/null
+++ b/packages/snowcat-common/src/file-uuid.ts
@@ -0,0 +1,121 @@
+import { Peer } from './types';
+import { randomUUID } from 'crypto';
+
+class TimeBasedCache {
+ private cache = new Map();
+ private keyList = new Set();
+ private operationCount = 0;
+
+ constructor (private maxCapacity: number, private ttl: number = 30 * 1000 * 60, private cleanupCount: number = 10) {}
+
+ public put (key: K, value: V): void {
+ const timestamp = Date.now();
+ const cacheEntry = { value, timestamp, frequency: 1 };
+ this.cache.set(key, cacheEntry);
+ this.keyList.add(key);
+ this.operationCount++;
+ if (this.keyList.size > this.maxCapacity) this.evict();
+ if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
+ }
+
+ public get (key: K): V | undefined {
+ const entry = this.cache.get(key);
+ if (entry && Date.now() - entry.timestamp < this.ttl) {
+ entry.timestamp = Date.now();
+ entry.frequency++;
+ this.operationCount++;
+ if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
+ return entry.value;
+ } else {
+ this.deleteKey(key);
+ }
+ return undefined;
+ }
+
+ private cleanup (count: number): void {
+ const currentTime = Date.now();
+ let cleaned = 0;
+ for (const key of this.keyList) {
+ if (cleaned >= count) break;
+ const entry = this.cache.get(key);
+ if (entry && currentTime - entry.timestamp >= this.ttl) {
+ this.deleteKey(key);
+ cleaned++;
+ }
+ }
+ this.operationCount = 0; // 重置操作计数器
+ }
+
+ private deleteKey (key: K): void {
+ this.cache.delete(key);
+ this.keyList.delete(key);
+ }
+
+ private evict (): void {
+ while (this.keyList.size > this.maxCapacity) {
+ let oldestKey: K | undefined;
+ let minFrequency = Infinity;
+ for (const key of this.keyList) {
+ const entry = this.cache.get(key);
+ if (entry && entry.frequency < minFrequency) {
+ minFrequency = entry.frequency;
+ oldestKey = key;
+ }
+ }
+ if (oldestKey !== undefined) this.deleteKey(oldestKey);
+ }
+ }
+}
+
+interface FileUUIDData {
+ peer: Peer;
+ modelId?: string;
+ fileId?: string;
+ msgId?: string;
+ elementId?: string;
+ fileUUID?: string;
+}
+
+class FileUUIDManager {
+ private cache: TimeBasedCache;
+
+ constructor (ttl: number) {
+ this.cache = new TimeBasedCache(5000, ttl);
+ }
+
+ public encode (data: FileUUIDData, endString: string = '', customUUID?: string): string {
+ const uuid = customUUID || randomUUID().replace(/-/g, '') + endString;
+ this.cache.put(uuid, data);
+ return uuid;
+ }
+
+ public decode (uuid: string): FileUUIDData | undefined {
+ return this.cache.get(uuid);
+ }
+}
+
+export class FileNapCatOneBotUUIDWrap {
+ private manager: FileUUIDManager;
+
+ constructor (ttl: number = 86400000) {
+ this.manager = new FileUUIDManager(ttl);
+ }
+
+ public encodeModelId (peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
+ return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
+ }
+
+ public decodeModelId (uuid: string): FileUUIDData | undefined {
+ return this.manager.decode(uuid);
+ }
+
+ public encode (peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
+ return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
+ }
+
+ public decode (uuid: string): FileUUIDData | undefined {
+ return this.manager.decode(uuid);
+ }
+}
+
+export const FileNapCatOneBotUUID = new FileNapCatOneBotUUIDWrap();
diff --git a/packages/snowcat-common/src/file.ts b/packages/snowcat-common/src/file.ts
new file mode 100644
index 0000000..edb09f7
--- /dev/null
+++ b/packages/snowcat-common/src/file.ts
@@ -0,0 +1,440 @@
+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 '@/snowcat-common/src/helper';
+
+export interface HttpDownloadOptions {
+ url: string;
+ headers?: Record | string;
+ proxy?: string;
+}
+
+export type Uri2LocalRes = {
+ success: boolean,
+ errMsg: string,
+ fileName: string,
+ path: string;
+ isLocal: boolean; // true=用户本地文件(不可删除),false=临时下载/解码文件(需清理)
+};
+
+// 定义一个异步函数来检查文件是否存在
+export function checkFileExist (path: string, timeout: number = 3000): Promise {
+ return new Promise((resolve, reject) => {
+ const startTime = Date.now();
+
+ function check () {
+ if (fs.existsSync(path)) {
+ resolve();
+ } else if (Date.now() - startTime > timeout) {
+ reject(new Error(`文件不存在: ${path}`));
+ } else {
+ setTimeout(check, 100);
+ }
+ }
+
+ check();
+ });
+}
+
+// 定义一个异步函数来检查文件是否存在
+export async function checkFileExistV2 (path: string, timeout: number = 3000): Promise {
+ // 使用 Promise.race 来同时进行文件状态检查和超时计时
+ // Promise.race 会返回第一个解决(resolve)或拒绝(reject)的 Promise
+ await Promise.race([
+ checkFile(path),
+ timeoutPromise(timeout, `文件不存在: ${path}`),
+ ]);
+}
+
+// 转换超时时间至 Promise
+function timeoutPromise (timeout: number, errorMsg: string): Promise {
+ return new Promise((_resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error(errorMsg));
+ }, timeout);
+ });
+}
+
+// 异步检查文件是否存在
+async function checkFile (path: string): Promise {
+ try {
+ await stat(path);
+ } catch (error: unknown) {
+ if ((error as Error & { code: string; }).code === 'ENOENT') {
+ // 如果文件不存在,则抛出一个错误
+ throw new Error(`文件不存在: ${path}`);
+ } else {
+ // 对于 stat 调用的其他错误,重新抛出
+ throw error;
+ }
+ }
+ // 如果文件存在,则无需做任何事情,Promise 解决(resolve)自身
+}
+
+export function calculateFileMD5 (filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ // 创建一个流式读取器
+ const stream = fs.createReadStream(filePath);
+ const hash = crypto.createHash('md5');
+
+ stream.on('data', (data) => {
+ // 当读取到数据时,更新哈希对象的状态
+ hash.update(data);
+ });
+
+ stream.on('end', () => {
+ // 文件读取完成,计算哈希
+ const md5 = hash.digest('hex');
+ resolve(md5);
+ });
+
+ stream.on('error', (err: Error) => {
+ // 处理可能的读取错误
+ reject(err);
+ });
+ });
+}
+
+async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise {
+ let url: string;
+ let proxy: string | undefined;
+ let headers: Record = {
+ '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',
+ };
+ if (typeof options === 'string') {
+ url = options;
+ 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);
+ } else {
+ headers = options.headers;
+ }
+ }
+ }
+ 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;
+ }
+ throw err;
+ });
+ return fetchRes;
+}
+
+/**
+ * 使用 HTTP/HTTPS 代理下载文件
+ */
+function httpDownloadWithProxy (url: string, headers: Record, proxy: string): Promise {
+ 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,
+ 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,
+ 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 {
+ const useReferer = typeof options === 'string';
+ let resp = await tryDownload(options);
+ if (resp.status === 403 && useReferer) {
+ resp = await tryDownload(options, true);
+ }
+ if (!resp.ok) throw new Error(`下载文件失败: ${resp.statusText}`);
+ const blob = await resp.blob();
+ const buffer = await blob.arrayBuffer();
+ return Buffer.from(buffer);
+}
+
+export enum FileUriType {
+ Unknown = 0,
+ Local = 1,
+ Remote = 2,
+ Base64 = 3,
+}
+
+export async function checkUriType (Uri: string) {
+ const LocalFileRet = await solveProblem((uri: string) => {
+ if (fs.existsSync(path.normalize(uri))) {
+ return { Uri: path.normalize(uri), Type: FileUriType.Local };
+ }
+ return undefined;
+ }, Uri);
+ if (LocalFileRet) return LocalFileRet;
+ const OtherFileRet = await solveProblem((uri: string) => {
+ // 再判断是否是Http
+ if (uri.startsWith('http:') || uri.startsWith('https:')) {
+ return { Uri: uri, Type: FileUriType.Remote };
+ }
+ // 再判断是否是Base64
+ if (uri.startsWith('base64:')) {
+ return { Uri: uri, Type: FileUriType.Base64 };
+ }
+ // 默认file://
+ if (uri.startsWith('file:')) {
+ const filePath: string = decodeURIComponent(uri.startsWith('file:///') && process.platform === 'win32' ? uri.slice(8) : uri.slice(7));
+ return { Uri: filePath, Type: FileUriType.Local };
+ }
+ if (uri.startsWith('data:')) {
+ const data = uri.split(',')[1];
+ if (data) return { Uri: data, Type: FileUriType.Base64 };
+ }
+ return undefined;
+ }, Uri);
+ if (OtherFileRet) return OtherFileRet;
+
+ return { Uri, Type: FileUriType.Unknown };
+}
+
+export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record, proxy?: string): Promise {
+ const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
+
+ const filePath = path.join(dir, filename);
+
+ switch (UriType) {
+ case FileUriType.Local: {
+ const fileExt = path.extname(HandledUri);
+ const localFileName = path.basename(HandledUri, fileExt) + fileExt;
+ // 直接返回原始路径,不复制,避免不必要的 IO
+ return { success: true, errMsg: '', fileName: localFileName, path: HandledUri, isLocal: true };
+ }
+
+ case FileUriType.Remote: {
+ const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
+ fs.writeFileSync(filePath, buffer);
+ return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false };
+ }
+
+ case FileUriType.Base64: {
+ const base64 = HandledUri.replace(/^base64:\/\//, '');
+ const base64Buffer = Buffer.from(base64, 'base64');
+ fs.writeFileSync(filePath, base64Buffer);
+ return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false };
+ }
+
+ default:
+ return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '', isLocal: false };
+ }
+}
diff --git a/packages/snowcat-common/src/health.ts b/packages/snowcat-common/src/health.ts
new file mode 100644
index 0000000..5d38441
--- /dev/null
+++ b/packages/snowcat-common/src/health.ts
@@ -0,0 +1,280 @@
+export interface ResourceConfig {
+ /** 资源获取函数 */
+ resourceFn: (...args: T) => Promise;
+ /** 失败后禁用时间(毫秒),默认 30 秒 */
+ disableTime?: number;
+ /** 最大重试次数,默认 3 次 */
+ maxRetries?: number;
+ /** 主动测试间隔(毫秒),默认 60 秒 */
+ healthCheckInterval?: number;
+ /** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
+ maxHealthCheckFailures?: number;
+ /** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
+ healthCheckFn?: (...args: T) => Promise;
+ /** 测试参数(用于健康检查) */
+ testArgs?: T;
+}
+
+interface ResourceTypeState {
+ /** 资源配置 */
+ config: {
+ resourceFn: (...args: any[]) => Promise;
+ healthCheckFn?: (...args: any[]) => Promise;
+ disableTime: number;
+ maxRetries: number;
+ healthCheckInterval: number;
+ maxHealthCheckFailures: number;
+ testArgs?: any[];
+ };
+ /** 是否启用 */
+ isEnabled: boolean;
+ /** 禁用截止时间 */
+ disableUntil: number;
+ /** 当前重试次数 */
+ currentRetries: number;
+ /** 健康检查失败次数 */
+ healthCheckFailureCount: number;
+ /** 是否永久禁用 */
+ isPermanentlyDisabled: boolean;
+ /** 上次健康检查时间 */
+ lastHealthCheckTime: number;
+ /** 成功次数统计 */
+ successCount: number;
+ /** 失败次数统计 */
+ failureCount: number;
+}
+
+export class ResourceManager {
+ private resourceTypes = new Map();
+ private destroyed = false;
+
+ /**
+ * 调用资源(自动注册或复用已有配置)
+ */
+ async callResource(
+ type: string,
+ config: ResourceConfig,
+ ...args: T
+ ): Promise {
+ if (this.destroyed) {
+ throw new Error('ResourceManager has been destroyed');
+ }
+
+ // 获取或创建资源类型状态
+ let state = this.resourceTypes.get(type);
+
+ if (!state) {
+ // 首次注册
+ state = {
+ config: {
+ resourceFn: config.resourceFn as (...args: any[]) => Promise,
+ healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise) | undefined,
+ disableTime: config.disableTime ?? 30000,
+ maxRetries: config.maxRetries ?? 3,
+ healthCheckInterval: config.healthCheckInterval ?? 60000,
+ maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20,
+ testArgs: config.testArgs as any[] | undefined,
+ },
+ isEnabled: true,
+ disableUntil: 0,
+ currentRetries: 0,
+ healthCheckFailureCount: 0,
+ isPermanentlyDisabled: false,
+ lastHealthCheckTime: 0,
+ successCount: 0,
+ failureCount: 0,
+ };
+ this.resourceTypes.set(type, state);
+ }
+
+ // 在调用前检查是否需要进行健康检查
+ await this.checkAndPerformHealthCheck(state);
+
+ // 检查资源状态
+ if (state.isPermanentlyDisabled) {
+ throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
+ }
+
+ if (!this.isResourceAvailable(type)) {
+ const disableUntilDate = new Date(state.disableUntil).toISOString();
+ throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
+ }
+
+ // 调用资源
+ try {
+ const result = await config.resourceFn(...args);
+ this.onResourceSuccess(state);
+ return result;
+ } catch (error) {
+ this.onResourceFailure(state);
+ throw error;
+ }
+ }
+
+ /**
+ * 检查资源类型是否可用
+ */
+ isResourceAvailable (type: string): boolean {
+ const state = this.resourceTypes.get(type);
+ if (!state) {
+ return true; // 未注册的资源类型视为可用
+ }
+
+ if (state.isPermanentlyDisabled || !state.isEnabled) {
+ return false;
+ }
+
+ return Date.now() >= state.disableUntil;
+ }
+
+ /**
+ * 获取资源类型统计信息
+ */
+ getResourceStats (type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
+ const state = this.resourceTypes.get(type);
+ if (!state) {
+ return null;
+ }
+
+ return {
+ successCount: state.successCount,
+ failureCount: state.failureCount,
+ isEnabled: state.isEnabled,
+ isPermanentlyDisabled: state.isPermanentlyDisabled,
+ };
+ }
+
+ /**
+ * 获取所有资源类型统计
+ */
+ getAllResourceStats (): Map {
+ const stats = new Map();
+ for (const [type, state] of this.resourceTypes) {
+ stats.set(type, {
+ successCount: state.successCount,
+ failureCount: state.failureCount,
+ isEnabled: state.isEnabled,
+ isPermanentlyDisabled: state.isPermanentlyDisabled,
+ });
+ }
+ return stats;
+ }
+
+ /**
+ * 注销资源类型
+ */
+ unregister (type: string): boolean {
+ return this.resourceTypes.delete(type);
+ }
+
+ /**
+ * 销毁管理器
+ */
+ destroy (): void {
+ if (this.destroyed) {
+ return;
+ }
+
+ this.resourceTypes.clear();
+ this.destroyed = true;
+ }
+
+ /**
+ * 检查并执行健康检查(如果需要)
+ */
+ private async checkAndPerformHealthCheck (state: ResourceTypeState): Promise {
+ // 如果资源可用或已永久禁用,无需健康检查
+ if (state.isEnabled && Date.now() >= state.disableUntil) {
+ return;
+ }
+
+ if (state.isPermanentlyDisabled) {
+ return;
+ }
+
+ const now = Date.now();
+
+ // 检查是否还在禁用期内
+ if (now < state.disableUntil) {
+ return;
+ }
+
+ // 检查是否需要进行健康检查(根据间隔时间)
+ if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
+ return;
+ }
+
+ // 执行健康检查
+ await this.performHealthCheck(state);
+ }
+
+ private async performHealthCheck (state: ResourceTypeState): Promise {
+ state.lastHealthCheckTime = Date.now();
+
+ try {
+ let healthCheckResult: boolean;
+
+ if (state.config.healthCheckFn) {
+ const testArgs = state.config.testArgs || [];
+ healthCheckResult = await state.config.healthCheckFn(...testArgs);
+ } else {
+ const testArgs = state.config.testArgs || [];
+ await state.config.resourceFn(...testArgs);
+ healthCheckResult = true;
+ }
+
+ if (healthCheckResult) {
+ // 健康检查成功,重新启用
+ state.isEnabled = true;
+ state.disableUntil = 0;
+ state.currentRetries = 0;
+ state.healthCheckFailureCount = 0;
+ } else {
+ throw new Error('Health check function returned false');
+ }
+ } catch {
+ // 健康检查失败,增加失败计数
+ state.healthCheckFailureCount++;
+
+ // 检查是否达到最大健康检查失败次数
+ if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
+ // 永久禁用资源
+ state.isPermanentlyDisabled = true;
+ state.disableUntil = 0;
+ } else {
+ // 继续禁用一段时间
+ state.disableUntil = Date.now() + state.config.disableTime;
+ }
+ }
+ }
+
+ private onResourceSuccess (state: ResourceTypeState): void {
+ state.currentRetries = 0;
+ state.disableUntil = 0;
+ state.healthCheckFailureCount = 0;
+ state.successCount++;
+ }
+
+ private onResourceFailure (state: ResourceTypeState): void {
+ state.currentRetries++;
+ state.failureCount++;
+
+ // 如果重试次数达到上限,禁用资源
+ if (state.currentRetries >= state.config.maxRetries) {
+ state.disableUntil = Date.now() + state.config.disableTime;
+ state.currentRetries = 0;
+ }
+ }
+}
+
+// 创建全局实例
+export const resourceManager = new ResourceManager();
+
+// 便捷函数
+export async function registerResource (
+ type: string,
+ config: ResourceConfig,
+ ...args: T
+): Promise {
+ return resourceManager.callResource(type, config, ...args);
+}
diff --git a/packages/snowcat-common/src/helper.ts b/packages/snowcat-common/src/helper.ts
new file mode 100644
index 0000000..16d6138
--- /dev/null
+++ b/packages/snowcat-common/src/helper.ts
@@ -0,0 +1,268 @@
+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';
+
+export async function solveProblem any> (func: T, ...args: Parameters): Promise | undefined> {
+ return new Promise | undefined>((resolve) => {
+ try {
+ const result = func(...args);
+ resolve(result);
+ } catch {
+ resolve(undefined);
+ }
+ });
+}
+
+export async function solveAsyncProblem Promise> (func: T, ...args: Parameters): Promise> | undefined> {
+ return new Promise> | undefined>((resolve) => {
+ func(...args).then((result) => {
+ resolve(result);
+ }).catch(() => {
+ resolve(undefined);
+ });
+ });
+}
+
+export function sleep (ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export function PromiseTimer (promise: Promise, ms: number): Promise {
+ const timeoutPromise = new Promise((_resolve, reject) =>
+ setTimeout(() => reject(new Error('PromiseTimer: Operation timed out')), ms)
+ );
+ return Promise.race([promise, timeoutPromise]);
+}
+
+export async function runAllWithTimeout (tasks: Promise[], timeout: number): Promise {
+ const wrappedTasks = tasks.map((task) =>
+ PromiseTimer(task, timeout).then(
+ (result) => ({ status: 'fulfilled', value: result }),
+ (error) => ({ status: 'rejected', reason: error })
+ )
+ );
+ const results = await Promise.all(wrappedTasks);
+ return results
+ .filter((result) => result.status === 'fulfilled')
+ .map((result) => (result as { status: 'fulfilled'; value: T; }).value);
+}
+
+export function isNull (value: any) {
+ return value === undefined || value === null;
+}
+
+export function isNumeric (str: string) {
+ return /^\d+$/.test(str);
+}
+
+export function truncateString (obj: any, maxLength = 500) {
+ if (obj !== null && typeof obj === 'object') {
+ Object.keys(obj).forEach((key) => {
+ if (typeof obj[key] === 'string') {
+ // 如果是字符串且超过指定长度,则截断
+ if (obj[key].length > maxLength) {
+ obj[key] = obj[key].substring(0, maxLength) + '...';
+ }
+ } else if (typeof obj[key] === 'object') {
+ // 如果是对象或数组,则递归调用
+ truncateString(obj[key], maxLength);
+ }
+ });
+ }
+ return obj;
+}
+
+export function isEqual (obj1: any, obj2: any) {
+ if (obj1 === obj2) return true;
+ if (obj1 == null || obj2 == null) return false;
+ if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
+
+ const keys1 = Object.keys(obj1);
+ const keys2 = Object.keys(obj2);
+
+ if (keys1.length !== keys2.length) return false;
+
+ for (const key of keys1) {
+ if (!isEqual(obj1[key], obj2[key])) return false;
+ }
+ return true;
+}
+
+export function getDefaultQQVersionConfigInfo (): QQVersionConfigType {
+ if (os.platform() === 'linux') {
+ return {
+ baseVersion: '3.2.12.28060',
+ curVersion: '3.2.12.28060',
+ prevVersion: '',
+ onErrorVersions: [],
+ buildId: '27254',
+ };
+ }
+ if (os.platform() === 'darwin') {
+ return {
+ baseVersion: '6.9.53.28060',
+ curVersion: '6.9.53.28060',
+ prevVersion: '',
+ onErrorVersions: [],
+ buildId: '28060',
+ };
+ }
+ return {
+ baseVersion: '9.9.15-28131',
+ curVersion: '9.9.15-28131',
+ prevVersion: '',
+ onErrorVersions: [],
+ buildId: '28131',
+ };
+}
+
+export function getQQPackageInfoPath (exePath: string = '', version?: string): string {
+ if (process.env['NAPCAT_QQ_PACKAGE_INFO_PATH']) {
+ return process.env['NAPCAT_QQ_PACKAGE_INFO_PATH'];
+ }
+ let packagePath;
+ if (os.platform() === 'darwin') {
+ packagePath = path.join(path.dirname(exePath), '..', 'Resources', 'app', 'package.json');
+ } else if (os.platform() === 'linux') {
+ packagePath = path.join(path.dirname(exePath), './resources/app/package.json');
+ } else {
+ packagePath = path.join(path.dirname(exePath), './versions/' + version + '/resources/app/package.json');
+ }
+ // 下面是老版本兼容 未来去掉
+ if (!fs.existsSync(packagePath)) {
+ packagePath = path.join(path.dirname(exePath), './resources/app/versions/' + version + '/package.json');
+ }
+ return packagePath;
+}
+
+export function getQQVersionConfigPath (exePath: string = ''): string | undefined {
+ if (process.env['NAPCAT_QQ_VERSION_CONFIG_PATH']) {
+ return process.env['NAPCAT_QQ_VERSION_CONFIG_PATH'];
+ }
+ let configVersionInfoPath;
+ if (os.platform() === 'win32') {
+ configVersionInfoPath = path.join(path.dirname(exePath), 'versions', 'config.json');
+ } else if (os.platform() === 'darwin') {
+ const userPath = os.homedir();
+ const appDataPath = path.resolve(userPath, './Library/Application Support/QQ');
+ configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
+ } else {
+ const userPath = os.homedir();
+ const appDataPath = path.resolve(userPath, './.config/QQ');
+ configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
+ }
+ if (typeof configVersionInfoPath !== 'string') {
+ return undefined;
+ }
+ // 老版本兼容 未来去掉
+ if (!fs.existsSync(configVersionInfoPath)) {
+ configVersionInfoPath = path.join(path.dirname(exePath), './resources/app/versions/config.json');
+ }
+ if (!fs.existsSync(configVersionInfoPath)) {
+ return undefined;
+ }
+ return configVersionInfoPath;
+}
+
+export function calcQQLevel (level?: QQLevel) {
+ if (!level) return 0;
+ // const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
+ const { crownNum, sunNum, moonNum, starNum } = level;
+ // 没补类型
+ return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
+}
+
+export function stringifyWithBigInt (obj: any) {
+ return JSON.stringify(obj, (_key, value) =>
+ typeof value === 'bigint' ? value.toString() : value
+ );
+}
+
+export function parseAppidFromMajorV2 (nodeMajor: string): string | undefined {
+ const marker = Buffer.from('QQAppId/', 'utf-8');
+ const filePath = path.resolve(nodeMajor);
+ const fileContent = fs.readFileSync(filePath);
+
+ let searchPosition = 0;
+ while (true) {
+ const index = fileContent.indexOf(marker, searchPosition);
+ if (index === -1) {
+ break;
+ }
+
+ const start = index + marker.length;
+ const end = fileContent.indexOf(0x00, start);
+ if (end === -1) {
+ break;
+ }
+ const content = fileContent.subarray(start, end);
+ const str = content.toString('utf-8');
+ if (/^\d+$/.test(str)) {
+ return str;
+ }
+
+ searchPosition = end + 1;
+ }
+
+ return undefined;
+}
+
+export function parseAppidFromMajor (nodeMajor: string): string | undefined {
+ const hexSequence = 'A4 09 00 00 00 35';
+ const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
+ const filePath = path.resolve(nodeMajor);
+ const fileContent = fs.readFileSync(filePath);
+
+ let searchPosition = 0;
+ while (true) {
+ const index = fileContent.indexOf(sequenceBytes, searchPosition);
+ if (index === -1) {
+ break;
+ }
+
+ const start = index + sequenceBytes.length - 1;
+ const end = fileContent.indexOf(0x00, start);
+ if (end === -1) {
+ break;
+ }
+ const content = fileContent.subarray(start, end);
+ if (!content.every(byte => byte === 0x00)) {
+ try {
+ return content.toString('utf-8');
+ } catch {
+ break;
+ }
+ }
+
+ searchPosition = end + 1;
+ }
+
+ return undefined;
+}
+
+// ============== GitHub Tags 获取 ==============
+// 使用 mirror 模块统一管理镜像
+
+export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
+ return getAllTagsFromMirror('NapNeko', 'SnowCatQQ', mirror);
+}
+
+export async function getLatestTag (mirror?: string): Promise {
+ 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/, '');
+}
diff --git a/packages/snowcat-common/src/log-interface.ts b/packages/snowcat-common/src/log-interface.ts
new file mode 100644
index 0000000..0c4376f
--- /dev/null
+++ b/packages/snowcat-common/src/log-interface.ts
@@ -0,0 +1,24 @@
+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;
+}
diff --git a/packages/snowcat-common/src/lru-cache.ts b/packages/snowcat-common/src/lru-cache.ts
new file mode 100644
index 0000000..30520a9
--- /dev/null
+++ b/packages/snowcat-common/src/lru-cache.ts
@@ -0,0 +1,43 @@
+export class LRUCache {
+ private capacity: number;
+ public cache: Map;
+
+ constructor (capacity: number) {
+ this.capacity = capacity;
+ this.cache = new Map();
+ }
+
+ public get (key: K): V | undefined {
+ const value = this.cache.get(key);
+ if (value !== undefined) {
+ // Move the accessed key to the end to mark it as most recently used
+ this.cache.delete(key);
+ this.cache.set(key, value);
+ }
+ return value;
+ }
+
+ public put (key: K, value: V): void {
+ if (this.cache.has(key)) {
+ // If the key already exists, move it to the end to mark it as most recently used
+ this.cache.delete(key);
+ } else if (this.cache.size >= this.capacity) {
+ // If the cache is full, remove the least recently used key (the first one in the map)
+ const firstKey = this.cache.keys().next().value;
+ if (firstKey !== undefined) {
+ this.cache.delete(firstKey);
+ }
+ }
+ this.cache.set(key, value);
+ }
+
+ public resetCapacity (newCapacity: number): void {
+ this.capacity = newCapacity;
+ while (this.cache.size > this.capacity) {
+ const firstKey = this.cache.keys().next().value;
+ if (firstKey !== undefined) {
+ this.cache.delete(firstKey);
+ }
+ }
+ }
+}
diff --git a/packages/snowcat-common/src/message-unique.ts b/packages/snowcat-common/src/message-unique.ts
new file mode 100644
index 0000000..459d15f
--- /dev/null
+++ b/packages/snowcat-common/src/message-unique.ts
@@ -0,0 +1,147 @@
+import crypto from 'crypto';
+import { Peer } from './types';
+export class LimitedHashTable {
+ private readonly keyToValue: Map = new Map();
+ private readonly valueToKey: Map = new Map();
+ private maxSize: number;
+
+ constructor (maxSize: number) {
+ this.maxSize = maxSize;
+ }
+
+ resize (count: number) {
+ this.maxSize = count;
+ }
+
+ set (key: K, value: V): void {
+ this.keyToValue.set(key, value);
+ this.valueToKey.set(value, key);
+ while (this.keyToValue.size !== this.valueToKey.size) {
+ this.keyToValue.clear();
+ this.valueToKey.clear();
+ }
+ while (this.keyToValue.size > this.maxSize || this.valueToKey.size > this.maxSize) {
+ const oldestKey = this.keyToValue.keys().next().value;
+ if (oldestKey !== undefined) {
+ this.valueToKey.delete(this.keyToValue.get(oldestKey) as V);
+ this.keyToValue.delete(oldestKey);
+ }
+ }
+ }
+
+ getValue (key: K): V | undefined {
+ return this.keyToValue.get(key);
+ }
+
+ getKey (value: V): K | undefined {
+ return this.valueToKey.get(value);
+ }
+
+ deleteByValue (value: V): void {
+ const key = this.valueToKey.get(value);
+ if (key !== undefined) {
+ this.keyToValue.delete(key);
+ this.valueToKey.delete(value);
+ }
+ }
+
+ deleteByKey (key: K): void {
+ const value = this.keyToValue.get(key);
+ if (value !== undefined) {
+ this.keyToValue.delete(key);
+ this.valueToKey.delete(value);
+ }
+ }
+
+ getKeyList (): K[] {
+ return Array.from(this.keyToValue.keys());
+ }
+
+ // 获取最近刚写入的几个值
+ 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 listSize = Math.min(size, keyList.length);
+ for (let i = 0; i < listSize; i++) {
+ const key = keyList[listSize - i];
+ if (key !== undefined) {
+ result.push({ key, value: this.keyToValue.get(key)! });
+ }
+ }
+ return result;
+ }
+}
+
+class MessageUniqueWrapper {
+ private readonly msgDataMap: LimitedHashTable;
+ private readonly msgIdMap: LimitedHashTable;
+
+ constructor (maxMap: number = 5000) {
+ this.msgIdMap = new LimitedHashTable(maxMap);
+ this.msgDataMap = new LimitedHashTable(maxMap);
+ }
+
+ getRecentMsgIds (Peer: Peer, size: number): string[] {
+ const heads = this.msgIdMap.getHeads(size);
+ if (!heads) {
+ return [];
+ }
+ const data = heads.map((t) => MessageUnique.getMsgIdAndPeerByShortId(t.value));
+ const ret = data.filter((t) => t?.Peer.chatType === Peer.chatType && t?.Peer.peerUid === Peer.peerUid);
+ return ret.map((t) => t?.MsgId).filter((t) => t !== undefined);
+ }
+
+ createUniqueMsgId (peer: Peer, msgId: string) {
+ const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
+ const hash = crypto.createHash('md5').update(key).digest();
+ if (hash[0]) {
+ // 设置第一个bit为0 保证shortId为正数
+ hash[0] &= 0x7f;
+ }
+ const shortId = hash.readInt32BE(0);
+ // 减少性能损耗
+ this.msgIdMap.set(msgId, shortId);
+ this.msgDataMap.set(key, shortId);
+ return shortId;
+ }
+
+ getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer; } | undefined {
+ const data = this.msgDataMap.getKey(shortId);
+ if (data) {
+ const [msgId, chatTypeStr, peerUid] = data.split('|');
+ const peer: Peer = {
+ chatType: parseInt(chatTypeStr ?? '0'),
+ peerUid: peerUid ?? '',
+ guildId: '',
+ };
+ return { MsgId: msgId ?? '0', Peer: peer };
+ }
+ return undefined;
+ }
+
+ getShortIdByMsgId (msgId: string): number | undefined {
+ return this.msgIdMap.getValue(msgId);
+ }
+
+ getPeerByMsgId (msgId: string) {
+ const shortId = this.msgIdMap.getValue(msgId);
+ if (!shortId) return undefined;
+ return this.getMsgIdAndPeerByShortId(shortId);
+ }
+
+ resize (maxSize: number): void {
+ 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();
diff --git a/packages/snowcat-common/src/mirror.ts b/packages/snowcat-common/src/mirror.ts
new file mode 100644
index 0000000..f875f6d
--- /dev/null
+++ b/packages/snowcat-common/src/mirror.ts
@@ -0,0 +1,1091 @@
+/**
+ * GitHub 镜像配置模块
+ * 提供统一的镜像源管理,支持复杂网络环境
+ *
+ * 镜像源测试时间: 2026-01-03
+ * 测试通过: 55/61 完全可用
+ */
+
+import https from 'https';
+import http from 'http';
+import { RequestUtil } from './request';
+import { PromiseTimer } from './helper';
+
+// ============== 镜像源列表 ==============
+
+/**
+ * GitHub 文件加速镜像
+ * 用于加速 release assets 下载
+ * 按延迟排序,优先使用快速镜像
+ *
+ * 测试时间: 2026-01-03
+ * 镜像支持 301/302 重定向
+ * 懒加载测速:首次使用时自动测速,缓存 30 分钟
+ */
+export const GITHUB_FILE_MIRRORS = [
+ 'https://github.chenc.dev/',
+ 'https://ghproxy.cfd/',
+ 'https://github.tbedu.top/',
+ 'https://ghproxy.cc/',
+ 'https://gh.monlor.com/',
+ 'https://cdn.akaere.online/',
+ 'https://gh.idayer.com/',
+ 'https://gh.llkk.cc/',
+ 'https://ghpxy.hwinzniej.top/',
+ 'https://github-proxy.memory-echoes.cn/',
+ 'https://git.yylx.win/',
+ 'https://gitproxy.mrhjx.cn/',
+ 'https://gh.fhjhy.top/',
+ 'https://gp.zkitefly.eu.org/',
+ 'https://gh-proxy.com/',
+ 'https://ghfile.geekertao.top/',
+ 'https://j.1lin.dpdns.org/',
+ 'https://ghproxy.imciel.com/',
+ 'https://github-proxy.teach-english.tech/',
+ 'https://gh.927223.xyz/',
+ 'https://github.ednovas.xyz/',
+ 'https://ghf.xn--eqrr82bzpe.top/',
+ 'https://gh.dpik.top/',
+ 'https://gh.jasonzeng.dev/',
+ 'https://gh.xxooo.cf/',
+ 'https://gh.bugdey.us.kg/',
+ 'https://ghm.078465.xyz/',
+ 'https://j.1win.ggff.net/',
+ 'https://tvv.tw/',
+ 'https://gitproxy.127731.xyz/',
+ 'https://gh.inkchills.cn/',
+ 'https://ghproxy.cxkpro.top/',
+ 'https://gh.sixyin.com/',
+ 'https://github.geekery.cn/',
+ 'https://git.669966.xyz/',
+ 'https://gh.5050net.cn/',
+ 'https://gh.felicity.ac.cn/',
+ 'https://github.dpik.top/',
+ 'https://ghp.keleyaa.com/',
+ 'https://gh.wsmdn.dpdns.org/',
+ 'https://ghproxy.monkeyray.net/',
+ 'https://fastgit.cc/',
+ 'https://gh.catmak.name/',
+ 'https://gh.noki.icu/',
+ '', // 原始 URL(无镜像)
+];
+
+/**
+ * GitHub API 镜像
+ * 用于访问 GitHub API(作为备选方案)
+ * 注:优先使用非 API 方法,减少对 API 的依赖
+ *
+ * 经测试,大部分代理镜像不支持 API 转发
+ * 建议使用 getLatestReleaseTag 等方法避免 API 调用
+ */
+export const GITHUB_API_MIRRORS = [
+ 'https://api.github.com',
+ // 目前没有可用的公共 API 代理镜像
+];
+
+/**
+ * GitHub Raw 镜像
+ * 用于访问 raw.githubusercontent.com
+ * 注:大多数通用代理也支持 raw 文件加速
+ */
+export const GITHUB_RAW_MIRRORS = [
+ 'https://raw.githubusercontent.com',
+ // 测试确认支持 raw 文件的镜像
+ 'https://github.chenc.dev/https://raw.githubusercontent.com',
+ 'https://ghproxy.cfd/https://raw.githubusercontent.com',
+ 'https://ghproxy.cc/https://raw.githubusercontent.com',
+ 'https://gh-proxy.net/https://raw.githubusercontent.com',
+];
+
+/**
+ * Nightly.link 镜像
+ * 用于访问 GitHub Actions artifacts
+ * 优先使用官方服务,出现问题时可切换镜像
+ */
+export const NIGHTLY_LINK_MIRRORS = [
+ 'https://nightly.link',
+ // 可以添加其他 nightly.link 镜像(如果有的话)
+];
+
+// ============== 镜像配置接口 ==============
+
+export interface MirrorConfig {
+ /** 文件下载镜像(用于 release assets) */
+ fileMirrors: string[];
+ /** API 镜像 */
+ apiMirrors: string[];
+ /** Raw 文件镜像 */
+ rawMirrors: string[];
+ /** Nightly.link 镜像(用于 Actions artifacts) */
+ nightlyLinkMirrors: string[];
+ /** 超时时间(毫秒) */
+ timeout: number;
+ /** 是否启用镜像 */
+ enabled: boolean;
+ /** 自定义镜像(优先使用) */
+ customMirror?: string;
+}
+
+// ============== 默认配置 ==============
+
+const defaultConfig: MirrorConfig = {
+ fileMirrors: GITHUB_FILE_MIRRORS,
+ apiMirrors: GITHUB_API_MIRRORS,
+ rawMirrors: GITHUB_RAW_MIRRORS,
+ nightlyLinkMirrors: NIGHTLY_LINK_MIRRORS,
+ timeout: 5000, // 5秒超时,平衡速度和可靠性
+ enabled: true,
+ customMirror: undefined,
+};
+
+let currentConfig: MirrorConfig = { ...defaultConfig };
+
+// ============== 懒加载镜像测速缓存 ==============
+
+interface MirrorTestResult {
+ mirror: string;
+ latency: number;
+ success: boolean;
+}
+
+// 缓存的快速镜像列表(按延迟排序)
+let cachedFastMirrors: string[] | null = null;
+// 测速是否正在进行
+let mirrorTestingPromise: Promise | null = null;
+// 缓存过期时间(30分钟)
+const MIRROR_CACHE_TTL = 30 * 60 * 1000;
+let cacheTimestamp: number = 0;
+
+/**
+ * 测试单个镜像的延迟(使用 HEAD 请求测试实际文件)
+ * 测试一个小型的实际 release 文件,确保镜像支持文件下载
+ */
+async function testMirrorLatency (mirror: string, timeout: number = 5000): Promise {
+ // 使用一个实际存在的小文件来测试(README 或小型 release asset)
+ // 用 HEAD 请求,不下载实际内容
+ const testUrl = 'https://github.com/NapNeko/SnowCatQQ/releases/latest';
+ const url = buildMirrorUrl(testUrl, mirror);
+ const start = Date.now();
+
+ return new Promise((resolve) => {
+ try {
+ const urlObj = new URL(url);
+ const isHttps = urlObj.protocol === 'https:';
+ const client = isHttps ? https : http;
+
+ const req = client.request({
+ hostname: urlObj.hostname,
+ port: urlObj.port || (isHttps ? 443 : 80),
+ path: urlObj.pathname + urlObj.search,
+ method: 'HEAD',
+ timeout,
+ headers: {
+ 'User-Agent': 'NapCat-Mirror-Test',
+ },
+ }, (res) => {
+ const statusCode = res.statusCode || 0;
+ // 2xx 或 3xx 都算成功(3xx 说明镜像工作正常,会重定向)
+ const isValid = statusCode >= 200 && statusCode < 400;
+ resolve({
+ mirror,
+ latency: Date.now() - start,
+ success: isValid,
+ });
+ });
+
+ req.on('error', () => {
+ resolve({
+ mirror,
+ latency: Infinity,
+ success: false,
+ });
+ });
+
+ req.on('timeout', () => {
+ req.destroy();
+ resolve({
+ mirror,
+ latency: Infinity,
+ success: false,
+ });
+ });
+
+ req.end();
+ } catch {
+ resolve({
+ mirror,
+ latency: Infinity,
+ success: false,
+ });
+ }
+ });
+}
+
+/**
+ * 懒加载获取快速镜像列表
+ * 第一次调用时会进行测速,后续使用缓存
+ */
+export async function getFastMirrors (forceRefresh: boolean = false): Promise {
+ // 检查缓存是否有效
+ const now = Date.now();
+ if (!forceRefresh && cachedFastMirrors && (now - cacheTimestamp) < MIRROR_CACHE_TTL) {
+ return cachedFastMirrors;
+ }
+
+ // 如果已经在测速中,等待结果
+ if (mirrorTestingPromise) {
+ return mirrorTestingPromise;
+ }
+
+ // 开始测速
+ mirrorTestingPromise = performMirrorTest();
+
+ try {
+ const result = await mirrorTestingPromise;
+ cachedFastMirrors = result;
+ cacheTimestamp = now;
+ return result;
+ } finally {
+ mirrorTestingPromise = null;
+ }
+}
+
+/**
+ * 执行镜像测速
+ * 并行测试所有镜像,返回按延迟排序的可用镜像列表
+ */
+async function performMirrorTest (): Promise {
+ // 开始镜像测速
+
+ const timeout = 3000; // 测速超时 3 秒
+
+ // 并行测试所有镜像
+ const mirrors = currentConfig.fileMirrors.filter(m => m);
+ const results = await Promise.all(
+ mirrors.map(m => testMirrorLatency(m, timeout))
+ );
+
+ // 过滤成功的镜像并按延迟排序
+ const successfulMirrors = results
+ .filter(r => r.success)
+ .sort((a, b) => a.latency - b.latency)
+ .map(r => r.mirror);
+
+ // 至少返回原始 URL
+ if (successfulMirrors.length === 0) {
+ return [''];
+ }
+
+ return successfulMirrors;
+}
+
+/**
+ * 清除镜像缓存,强制下次重新测速
+ */
+export function clearMirrorCache (): void {
+ cachedFastMirrors = null;
+ cacheTimestamp = 0;
+}
+
+/**
+ * 获取缓存状态
+ */
+export function getMirrorCacheStatus (): { cached: boolean; count: number; age: number; } {
+ return {
+ cached: cachedFastMirrors !== null,
+ count: cachedFastMirrors?.length ?? 0,
+ age: cachedFastMirrors ? Date.now() - cacheTimestamp : 0,
+ };
+}
+
+// ============== 配置管理 ==============
+
+/**
+ * 获取当前镜像配置
+ */
+export function getMirrorConfig (): MirrorConfig {
+ return { ...currentConfig };
+}
+
+/**
+ * 更新镜像配置
+ */
+export function setMirrorConfig (config: Partial): void {
+ currentConfig = { ...currentConfig, ...config };
+}
+
+/**
+ * 重置为默认配置
+ */
+export function resetMirrorConfig (): void {
+ currentConfig = { ...defaultConfig };
+}
+
+/**
+ * 设置自定义镜像(优先级最高)
+ */
+export function setCustomMirror (mirror: string): void {
+ currentConfig.customMirror = mirror;
+}
+
+// ============== URL 工具函数 ==============
+
+/**
+ * 构建镜像 URL
+ * @param originalUrl 原始 URL
+ * @param mirror 镜像前缀
+ */
+export function buildMirrorUrl (originalUrl: string, mirror: string): string {
+ if (!mirror) return originalUrl;
+ // 如果镜像已经包含完整域名,直接拼接
+ if (mirror.endsWith('/')) {
+ return mirror + originalUrl;
+ }
+ return mirror + '/' + originalUrl;
+}
+
+/**
+ * 测试 URL 是否可用(HTTP GET)
+ * @param url 要测试的 URL
+ * @param timeout 超时时间
+ */
+export async function testUrl (url: string, timeout: number = 5000): Promise {
+ try {
+ await PromiseTimer(RequestUtil.HttpGetText(url), timeout);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * 测试 URL 是否可用(HTTP HEAD,更快)
+ * 验证:状态码、Content-Type、Content-Length
+ */
+export async function testUrlHead (url: string, timeout: number = 5000): Promise {
+ return new Promise((resolve) => {
+ const urlObj = new URL(url);
+ const isHttps = urlObj.protocol === 'https:';
+ const client = isHttps ? https : http;
+
+ const req = client.request({
+ hostname: urlObj.hostname,
+ port: urlObj.port || (isHttps ? 443 : 80),
+ path: urlObj.pathname + urlObj.search,
+ method: 'HEAD',
+ timeout,
+ headers: {
+ 'User-Agent': 'NapCat-Mirror-Test',
+ },
+ }, (res) => {
+ const statusCode = res.statusCode || 0;
+ const contentType = (res.headers['content-type'] as string) || '';
+
+ // 简化验证条件:
+ // 1. 状态码 2xx 或 3xx
+ // 2. Content-Type 不应该是 text/html(表示错误页面)
+ const isValidStatus = statusCode >= 200 && statusCode < 400;
+ const isNotHtmlError = !contentType.includes('text/html');
+
+ resolve(isValidStatus && isNotHtmlError);
+ });
+
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ req.end();
+ });
+}
+
+/**
+ * 详细验证 URL 响应
+ * 返回验证结果和详细信息
+ */
+export interface UrlValidationResult {
+ valid: boolean;
+ statusCode?: number;
+ contentType?: string;
+ contentLength?: number;
+ error?: string;
+}
+
+export async function validateUrl (url: string, timeout: number = 5000): Promise {
+ return new Promise((resolve) => {
+ const urlObj = new URL(url);
+ const isHttps = urlObj.protocol === 'https:';
+ const client = isHttps ? https : http;
+
+ const req = client.request({
+ hostname: urlObj.hostname,
+ port: urlObj.port || (isHttps ? 443 : 80),
+ path: urlObj.pathname + urlObj.search,
+ method: 'HEAD',
+ timeout,
+ headers: {
+ 'User-Agent': 'NapCat-Mirror-Test',
+ },
+ }, (res) => {
+ const statusCode = res.statusCode || 0;
+ const contentType = (res.headers['content-type'] as string) || '';
+ const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
+
+ // 简化验证条件
+ const isValidStatus = statusCode >= 200 && statusCode < 400;
+ const isNotHtmlError = !contentType.includes('text/html');
+
+ if (!isValidStatus) {
+ resolve({
+ valid: false,
+ statusCode,
+ contentType,
+ contentLength,
+ error: `HTTP ${statusCode}`,
+ });
+ } else if (!isNotHtmlError) {
+ resolve({
+ valid: false,
+ statusCode,
+ contentType,
+ contentLength,
+ error: '返回了 HTML 页面而非文件',
+ });
+ } else {
+ resolve({
+ valid: true,
+ statusCode,
+ contentType,
+ contentLength,
+ });
+ }
+ });
+
+ req.on('error', (e: Error) => resolve({
+ valid: false,
+ error: e.message,
+ }));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve({
+ valid: false,
+ error: 'Timeout',
+ });
+ });
+ req.end();
+ });
+}
+
+// ============== 查找可用 URL ==============
+
+/**
+ * 查找可用的下载 URL
+ * 使用懒加载的快速镜像列表
+ * @param originalUrl 原始 GitHub URL
+ * @param options 选项
+ */
+export async function findAvailableDownloadUrl (
+ originalUrl: string,
+ options: {
+ mirrors?: string[];
+ timeout?: number;
+ customMirror?: string;
+ testMethod?: 'head' | 'get';
+ /** 是否使用详细验证(验证 Content-Type 和 Content-Length) */
+ validateContent?: boolean;
+ /** 期望的最小文件大小(字节),用于验证 */
+ minFileSize?: number;
+ /** 是否使用懒加载的快速镜像列表 */
+ useFastMirrors?: boolean;
+ } = {}
+): Promise {
+ const {
+ timeout = currentConfig.timeout,
+ customMirror = currentConfig.customMirror,
+ testMethod = 'head',
+ validateContent = true, // 默认启用内容验证
+ minFileSize,
+ useFastMirrors = true, // 默认使用快速镜像列表
+ } = options;
+
+ // 获取镜像列表
+ let mirrors = options.mirrors;
+ if (!mirrors) {
+ // 检查是否是 nightly.link URL
+ if (originalUrl.includes('nightly.link')) {
+ // 使用 nightly.link 镜像列表(保持完整的 URL 格式)
+ mirrors = currentConfig.nightlyLinkMirrors;
+ } else if (useFastMirrors) {
+ // 使用懒加载的快速镜像列表
+ mirrors = await getFastMirrors();
+ } else {
+ mirrors = currentConfig.fileMirrors;
+ }
+ }
+
+ // 使用增强验证或简单测试
+ const testWithValidation = async (url: string): Promise => {
+ if (validateContent) {
+ const result = await validateUrl(url, timeout);
+ // 额外检查文件大小(仅当指定了 minFileSize 时)
+ if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
+ return false;
+ }
+ return result.valid;
+ }
+ // 不验证内容,只检查状态码
+ const isValid = testMethod === 'head' ? await testUrlHead(url, timeout) : await testUrl(url, timeout);
+ return isValid;
+ };
+
+ // 1. 如果设置了自定义镜像,直接使用(不测试,信任用户选择)
+ if (customMirror) {
+ const customUrl = buildMirrorUrl(originalUrl, customMirror);
+ return customUrl;
+ }
+
+ // 2. 先测试原始 URL
+ if (await testWithValidation(originalUrl)) {
+ return originalUrl;
+ }
+
+ // 3. 测试镜像源
+ let testedCount = 0;
+ for (const mirror of mirrors) {
+ if (!mirror) continue; // 跳过空字符串
+
+ // 特殊处理 nightly.link URL
+ let mirrorUrl: string;
+ if (originalUrl.includes('nightly.link')) {
+ // 替换 nightly.link 域名
+ mirrorUrl = originalUrl.replace('https://nightly.link', mirror.startsWith('http') ? mirror : `https://${mirror}`);
+ } else {
+ mirrorUrl = buildMirrorUrl(originalUrl, mirror);
+ }
+
+ testedCount++;
+ if (await testWithValidation(mirrorUrl)) {
+ return mirrorUrl;
+ }
+ }
+
+ throw new Error(`所有下载源都不可用(已测试 ${testedCount} 个镜像),请检查网络连接或配置自定义镜像`);
+}
+
+// ============== 版本和 Release 相关(减少 API 依赖) ==============
+
+/**
+ * 语义化版本正则(简化版,用于排序)
+ */
+const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/;
+
+/**
+ * 解析语义化版本号
+ */
+function parseSemVerSimple (version: string): { major: number; minor: number; patch: number; prerelease: string; } | null {
+ const match = version.match(SEMVER_REGEX);
+ if (!match) return null;
+ return {
+ major: parseInt(match[1] ?? '0', 10),
+ minor: parseInt(match[2] ?? '0', 10),
+ patch: parseInt(match[3] ?? '0', 10),
+ prerelease: match[4] || '',
+ };
+}
+
+/**
+ * 比较两个版本号
+ */
+function compareSemVerSimple (a: string, b: string): number {
+ const pa = parseSemVerSimple(a);
+ const pb = parseSemVerSimple(b);
+ if (!pa && !pb) return 0;
+ if (!pa) return -1;
+ if (!pb) return 1;
+
+ if (pa.major !== pb.major) return pa.major - pb.major;
+ if (pa.minor !== pb.minor) return pa.minor - pb.minor;
+ if (pa.patch !== pb.patch) return pa.patch - pb.patch;
+
+ // 预发布版本排在正式版本前面
+ if (pa.prerelease && !pb.prerelease) return -1;
+ if (!pa.prerelease && pb.prerelease) return 1;
+
+ return pa.prerelease.localeCompare(pb.prerelease);
+}
+
+/**
+ * 从 tags 列表中获取最新的 release tag
+ * 不依赖 GitHub API
+ */
+// Update definitions validation locally first if possible.
+// I'll assume valid typescript.
+// I will split this into two tool calls to avoid complexity.
+// 1. Update mirror.ts
+// 2. Update UpdateNapCat.ts
+
+// This tool call: Update mirror.ts
+export async function getLatestReleaseTag (owner: string, repo: string, mirror?: string): Promise {
+ const result = await getAllGitHubTags(owner, repo, mirror);
+
+ // 过滤出符合 semver 的 tags
+ const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
+
+ if (releaseTags.length === 0) {
+ throw new Error('未找到有效的 release tag');
+ }
+
+ // 按版本号排序,取最新的
+ releaseTags.sort(compareSemVerSimple);
+ const latest = releaseTags[releaseTags.length - 1];
+
+ if (!latest) {
+ throw new Error('未找到有效的 release tag');
+ }
+
+ return latest;
+}
+
+/**
+ * 直接构建 GitHub release 下载 URL
+ * 不需要调用 API,直接基于 tag 和 asset 名称构建
+ */
+export function buildReleaseDownloadUrl (
+ owner: string,
+ repo: string,
+ tag: string,
+ assetName: string
+): string {
+ return `https://github.com/${owner}/${repo}/releases/download/${tag}/${assetName}`;
+}
+
+/**
+ * 获取 GitHub release 信息(优先使用非 API 方法)
+ *
+ * 策略:
+ * 1. 先通过 git refs 获取 tags
+ * 2. 直接构建下载 URL,不依赖 API
+ * 3. 仅当需要 changelog 时才使用 API
+ */
+export async function getGitHubRelease (
+ owner: string,
+ repo: string,
+ tag: string = 'latest',
+ options: {
+ /** 需要获取的 asset 名称列表 */
+ assetNames?: string[];
+ /** 是否需要获取 changelog(需要调用 API) */
+ fetchChangelog?: boolean;
+ /** 指定镜像 */
+ mirror?: string;
+ } = {}
+): Promise<{
+ tag_name: string;
+ assets: Array<{
+ name: string;
+ browser_download_url: string;
+ }>;
+ body?: string;
+}> {
+ const { assetNames = [], fetchChangelog = false, mirror } = options;
+
+ // 1. 获取实际的 tag 名称
+ let actualTag: string;
+ if (tag === 'latest') {
+ actualTag = await getLatestReleaseTag(owner, repo, mirror);
+ } else {
+ actualTag = tag;
+ }
+ // ...
+
+ // 2. 构建 assets 列表(不需要 API)
+ const assets = assetNames.map(name => ({
+ name,
+ browser_download_url: buildReleaseDownloadUrl(owner, repo, actualTag, name),
+ }));
+
+ // 3. 如果不需要 changelog 且有 assetNames,直接返回
+ if (!fetchChangelog && assetNames.length > 0) {
+ return {
+ tag_name: actualTag,
+ assets,
+ body: undefined,
+ };
+ }
+
+ // 4. 需要更多信息时,尝试调用 API(作为备选)
+ const endpoint = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${actualTag}`;
+
+ for (const apiBase of currentConfig.apiMirrors) {
+ try {
+ const url = endpoint.replace('https://api.github.com', apiBase);
+ const response = await PromiseTimer(
+ RequestUtil.HttpGetJson(url, 'GET', undefined, {
+ 'User-Agent': 'SnowCat',
+ Accept: 'application/vnd.github.v3+json',
+ }),
+ currentConfig.timeout
+ );
+ return response;
+ } catch {
+ continue;
+ }
+ }
+
+ // 5. API 全部失败,但如果有 assetNames,仍然返回构建的 URL
+ if (assetNames.length > 0) {
+ return {
+ tag_name: actualTag,
+ assets,
+ body: undefined,
+ };
+ }
+
+ throw new Error('无法获取 release 信息,所有 API 源都不可用');
+}
+
+// ============== Tags 缓存 ==============
+
+interface TagsCache {
+ tags: string[];
+ mirror: string;
+ timestamp: number;
+}
+
+// 缓存 tags 结果(10 分钟有效,release 版本不会频繁变动)
+const TAGS_CACHE_TTL = 10 * 60 * 1000;
+const tagsCache: Map = new Map();
+
+/**
+ * 获取所有 GitHub tags(带缓存)
+ * 优化:并行请求多个镜像,使用第一个成功返回的结果
+ */
+export async function getAllGitHubTags (owner: string, repo: string, mirror?: string): Promise<{ tags: string[], mirror: string; }> {
+ const cacheKey = `${owner}/${repo}/${mirror || 'auto'}`;
+
+ // 检查缓存
+ const cached = tagsCache.get(cacheKey);
+ if (cached && (Date.now() - cached.timestamp) < TAGS_CACHE_TTL) {
+ return { tags: cached.tags, mirror: cached.mirror };
+ }
+
+ const baseUrl = `https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`;
+
+ // 解析 tags 的辅助函数
+ const parseTags = (raw: string): string[] => {
+ return raw
+ .split('\n')
+ .map((line: string) => {
+ const match = line.match(/refs\/tags\/(.+)$/);
+ return match ? match[1] : undefined;
+ })
+ .filter((tag): tag is string => tag !== undefined && !tag.endsWith('^{}'));
+ };
+
+ // 尝试从 URL 获取 tags
+ const fetchFromUrl = async (url: string, usedMirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
+ try {
+ const raw = await PromiseTimer(
+ RequestUtil.HttpGetText(url),
+ currentConfig.timeout
+ );
+
+ // 检查返回内容是否有效(不是 HTML 错误页面)
+ if (raw.includes('refs/tags')) {
+ return { tags: parseTags(raw), mirror: usedMirror };
+ }
+ } catch {
+ // 忽略错误
+ }
+ return null;
+ };
+
+ // 准备镜像列表
+ let mirrors: string[] = [];
+ if (mirror) {
+ // 如果指定了镜像,只使用该镜像
+ mirrors = [mirror];
+ } else {
+ // 否则使用 auto 逻辑,利用缓存的快速镜像列表
+ mirrors = await getFastMirrors();
+ }
+
+ // 并行请求
+ const promises = mirrors.map(m => {
+ const url = m ? buildMirrorUrl(baseUrl, m) : baseUrl;
+ return fetchFromUrl(url, m || 'https://github.com');
+ });
+
+ try {
+ const result = await Promise.any(promises.filter(p => p !== null) as Promise<{ tags: string[], mirror: string; } | null>[]);
+ if (result) {
+ tagsCache.set(cacheKey, {
+ tags: result.tags,
+ mirror: result.mirror,
+ timestamp: Date.now(),
+ });
+ return result;
+ }
+ } catch {
+ // all failed
+ }
+
+ if (mirror) {
+ throw new Error(`指定镜像 ${mirror} 获取 tags 失败`);
+ }
+
+ throw new Error('无法获取 tags,所有镜像源都不可用');
+}
+
+// ============== Action Artifacts 支持 ==============
+
+// ActionArtifact 接口定义
+export interface ActionArtifact {
+ id: number;
+ name: string;
+ size_in_bytes: number;
+ created_at: string;
+ expires_at: string;
+ archive_download_url: string;
+ workflow_run_id?: number;
+ head_sha?: string;
+ workflow_title?: string;
+}
+
+// ============== Action Artifacts 缓存 ==============
+
+interface ArtifactsCache {
+ artifacts: ActionArtifact[];
+ mirror: string;
+ timestamp: number;
+}
+
+// 缓存 artifacts 结果(10 分钟有效)
+const ARTIFACTS_CACHE_TTL = 10 * 60 * 1000;
+const artifactsCache: Map = new Map();
+
+/**
+ * 清除 artifacts 缓存
+ */
+export function clearArtifactsCache (): void {
+ artifactsCache.clear();
+}
+
+/**
+ * 通过解析 GitHub Actions HTML 页面获取 workflow runs(备用方案)
+ * 当 api.github.com 不可用时使用
+ * 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
+ */
+
+async function getWorkflowRunsFromHtml (
+ owner: string,
+ repo: string,
+ workflow: string = 'build.yml',
+ maxRuns: number = 10,
+ mirror?: string
+): Promise<{ runs: Array<{ id: number; created_at: string; title: string; }>; mirror: string; }> {
+ const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
+
+ // 尝试使用镜像获取 HTML
+ // 如果指定了 mirror,则只使用该 mirror
+ let mirrors: string[] = [];
+ if (mirror) {
+ mirrors = [mirror];
+ } else {
+ // 使用缓存的快速镜像列表
+ mirrors = await getFastMirrors();
+ }
+
+ for (const mirrorItem of mirrors) {
+ try {
+ const allRuns: Array<{ id: number; created_at: string; title: string; }> = [];
+ const foundIds = new Set();
+ let page = 1;
+ const maxPages = 10; // 防止无限请求,最多翻10页(约250个条目)
+
+ while (allRuns.length < maxRuns && page <= maxPages) {
+ const pageUrl = page > 1 ? `${baseUrl}?page=${page}` : baseUrl;
+ const url = mirrorItem ? buildMirrorUrl(pageUrl, mirrorItem) : pageUrl;
+
+ const html = await PromiseTimer(
+ RequestUtil.HttpGetText(url),
+ 10000
+ );
+
+ // 使用 Block 分割策略,更稳健地关联 ID 和时间
+ const rows = html.split('
+ const runMatch = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"[^>]*aria-label="([^"]*)"`, 'i').exec(row);
+
+ if (!runMatch || !runMatch[1] || !runMatch[2]) continue;
+
+ const id = parseInt(runMatch[1]);
+ const ariaLabel = runMatch[2];
+ const ariaLabelLower = ariaLabel.toLowerCase();
+
+ // 只需要判断 completed
+ if (ariaLabelLower.includes('completed')) {
+ if (!foundIds.has(id)) {
+ // 提取时间 (取 Block 内的第一个 relative-time)
+ const timeMatch = /
]*>([\s\S]*?)<\/span>/i.exec(row);
+ if (titleMatch && titleMatch[1]) {
+ title = titleMatch[1].trim();
+ }
+
+ // 如果没找到,回退到 aria-label 逻辑
+ if (!title) {
+ title = ariaLabel;
+ const prefixMatch = /^(completed successfully:\s*)/i.exec(title);
+ if (prefixMatch) {
+ title = title.substring(prefixMatch[0].length);
+ }
+ }
+
+ allRuns.push({
+ id,
+ created_at: timeMatch[1],
+ title: title.trim(),
+ });
+ }
+ }
+ }
+ }
+
+ // 如果本页没有找到任何 completed 的 run(但页面可能不为空),或者页面内容太少(可能是最后一页或错误)
+ // 这里简化判断: 如果本页没提取到任何有效数据,就认为没有更多数据了
+ if (foundOnThisPage === 0) {
+ // 也要考虑到可能是页面解析失败或者全是 failed 状态
+ // 检查是否有翻页按钮可能更复杂,暂时假设如果一整页都没有 successful run,可能后面也没有了,或者我们已经获取够多了
+ // 为了稳健,如果本页没找到,且 allRuns 还没满,尝试下一页 (除非页面很小说明是空页)
+ if (rows.length < 2) { // 只有 split 的第一个空元素
+ break;
+ }
+ }
+
+ // 分页逻辑:总是尝试下一页,直到满足 maxRuns
+ page++;
+ }
+
+ if (allRuns.length > 0) {
+ return { runs: allRuns, mirror: mirrorItem || 'https://github.com' };
+ }
+ } catch {
+ continue;
+ }
+ }
+
+ return { runs: [], mirror: '' };
+}
+
+/**
+ * 通过 API 获取最新的 workflow runs,然后直接拼接 nightly.link 下载链接
+ * 无需解析 HTML,直接使用固定的 URL 格式
+ *
+ * 策略:
+ * 1. 优先使用 GitHub API
+ * 2. API 失败时,从 GitHub Actions HTML 页面解析
+ */
+async function getArtifactsFromNightlyLink (
+ owner: string,
+ repo: string,
+ workflow: string = 'build.yml',
+ _branch: string = 'main',
+ maxRuns: number = 10,
+ mirror?: string
+): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
+ // 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
+ try {
+ // 以前尝试使用 GitHub API,现在弃用,完全使用 HTML 解析逻辑
+ // 并获取 workflow // 直接从 HTML 页面解析
+ const { runs: workflowRuns, mirror: runsMirror } = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns, mirror);
+
+ if (workflowRuns.length === 0) {
+ return { artifacts: [], mirror: runsMirror };
+ }
+
+ // 直接拼接 nightly.link URL
+ // 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
+ const artifacts: ActionArtifact[] = [];
+ const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
+
+ // 如果 HTML 解析使用的 mirror 是 github.com(空),则 nightly.link 使用默认配置
+ // 如果使用了镜像,可能需要特殊的 nightly.link 镜像,或者这里仅记录 HTML 来源镜像
+ // 实际上 nightly.link 本身就是一个服务,我们使用配置中的 nightlyLinkMirrors
+ const baseNightlyMirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
+
+ for (const run of workflowRuns) {
+ for (const artifactName of artifactNames) {
+ artifacts.push({
+ id: run.id,
+ name: artifactName,
+ size_in_bytes: 0,
+ created_at: run.created_at,
+ expires_at: new Date(new Date(run.created_at).getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
+ archive_download_url: `${baseNightlyMirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
+ workflow_run_id: run.id,
+ workflow_title: run.title,
+ });
+ }
+ }
+ return { artifacts, mirror: runsMirror };
+ } catch {
+ return { artifacts: [], mirror: '' };
+ }
+}
+
+/**
+ * 获取 GitHub Action 最新运行的 artifacts
+ * 用于下载 nightly/dev 版本
+ *
+ * 策略:
+ * 1. 检查缓存(10分钟有效)
+ * 2. 优先尝试从 nightly.link 获取(无需认证,更稳定)
+ * 3. 这里的实现已经完全移除了对 GitHub API 的依赖,直接解析 HTML
+ */
+export async function getLatestActionArtifacts (
+ owner: string,
+ repo: string,
+ workflow: string = 'build.yml',
+ branch: string = 'main',
+ maxRuns: number = 10,
+ mirror?: string
+): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
+ const cacheKey = `${owner}/${repo}/${workflow}/${branch}/${mirror || 'auto'}`;
+
+ // 检查缓存
+ const cached = artifactsCache.get(cacheKey);
+ if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
+ return { artifacts: cached.artifacts, mirror: cached.mirror };
+ }
+
+ let result: { artifacts: ActionArtifact[], mirror: string; } = { artifacts: [], mirror: '' };
+
+ // 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
+ try {
+ result = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns, mirror);
+ } catch {
+ // 获取失败
+ }
+
+ // 缓存结果(即使为空也缓存,避免频繁请求)
+ if (result.artifacts.length > 0) {
+ artifactsCache.set(cacheKey, {
+ artifacts: result.artifacts,
+ mirror: result.mirror,
+ timestamp: Date.now(),
+ });
+ }
+
+ return result;
+}
diff --git a/packages/snowcat-common/src/path.ts b/packages/snowcat-common/src/path.ts
new file mode 100644
index 0000000..e94242e
--- /dev/null
+++ b/packages/snowcat-common/src/path.ts
@@ -0,0 +1,41 @@
+import path, { dirname } from 'path';
+import { fileURLToPath } from 'url';
+import fs from 'fs';
+import os from 'os';
+
+export class SnowCatPathWrapper {
+ binaryPath: string;
+ logsPath: string;
+ configPath: string;
+ cachePath: string;
+ staticPath: string;
+ pluginPath: string;
+
+ constructor (mainPath: string = dirname(fileURLToPath(import.meta.url))) {
+ this.binaryPath = mainPath;
+ let writePath: string;
+
+ if (process.env['NAPCAT_WORKDIR']) {
+ writePath = process.env['NAPCAT_WORKDIR'];
+ } else if (os.platform() === 'darwin') {
+ writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'SnowCat');
+ } else {
+ writePath = this.binaryPath;
+ }
+
+ this.logsPath = path.join(writePath, 'logs');
+ this.configPath = path.join(writePath, 'config');
+ this.pluginPath = path.join(writePath, 'plugins');// dynamic load
+ this.cachePath = path.join(writePath, 'cache');
+ this.staticPath = path.join(this.binaryPath, 'static');
+ if (!fs.existsSync(this.logsPath)) {
+ fs.mkdirSync(this.logsPath, { recursive: true });
+ }
+ if (!fs.existsSync(this.configPath)) {
+ fs.mkdirSync(this.configPath, { recursive: true });
+ }
+ if (!fs.existsSync(this.cachePath)) {
+ fs.mkdirSync(this.cachePath, { recursive: true });
+ }
+ }
+}
diff --git a/packages/snowcat-common/src/request.ts b/packages/snowcat-common/src/request.ts
new file mode 100644
index 0000000..3383f72
--- /dev/null
+++ b/packages/snowcat-common/src/request.ts
@@ -0,0 +1,130 @@
+import https from 'node:https';
+import http from 'node:http';
+
+export class RequestUtil {
+ // 适用于获取服务器下发cookies时获取,仅GET
+ 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; } = {};
+
+ res.on('data', () => { }); // Necessary to consume the stream
+ res.on('end', () => {
+ this.handleRedirect(res, url, cookies)
+ .then(resolve)
+ .catch(reject);
+ });
+
+ if (res.headers['set-cookie']) {
+ this.extractCookies(res.headers['set-cookie'], cookies);
+ }
+ });
+
+ req.on('error', (error: Error) => {
+ reject(error);
+ });
+ });
+ }
+
+ 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);
+ const redirectCookies = await this.HttpsGetCookies(redirectUrl.href);
+ // 合并重定向过程中的cookies
+ return { ...cookies, ...redirectCookies };
+ }
+ }
+ return cookies;
+ }
+
+ private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
+ setCookieHeaders.forEach((cookie) => {
+ const parts = cookie.split(';')[0]?.split('=');
+ if (parts) {
+ const key = parts[0];
+ const value = parts[1];
+ if (key && value && key.length > 0 && value.length > 0) {
+ cookies[key] = value;
+ }
+ }
+ });
+ }
+
+ // 请求和回复都是JSON data传原始内容 自动编码json
+ // 支持 301/302 重定向(最多 5 次)
+ static async HttpGetJson (url: string, method: string = 'GET', data?: any, headers: {
+ [key: string]: string;
+ } = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise {
+ const option = new URL(url);
+ const protocol = url.startsWith('https://') ? https : http;
+ const options = {
+ hostname: option.hostname,
+ port: option.port,
+ path: option.pathname + option.search,
+ method,
+ headers,
+ };
+ // headers: {
+ // 'Content-Type': 'application/json',
+ // 'Content-Length': Buffer.byteLength(postData),
+ // },
+ return new Promise((resolve, reject) => {
+ const req = protocol.request(options, (res: http.IncomingMessage) => {
+ // 处理重定向
+ if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
+ if (maxRedirects <= 0) {
+ reject(new Error('Too many redirects'));
+ return;
+ }
+ const redirectUrl = new URL(res.headers.location, url).href;
+ // 递归跟随重定向
+ this.HttpGetJson(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1)
+ .then(resolve)
+ .catch(reject);
+ return;
+ }
+
+ let responseBody = '';
+ res.on('data', (chunk: string | Buffer) => {
+ responseBody += chunk.toString();
+ });
+
+ res.on('end', () => {
+ try {
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
+ if (isJsonRet) {
+ const responseJson = JSON.parse(responseBody);
+ resolve(responseJson as T);
+ } else {
+ resolve(responseBody as T);
+ }
+ } else {
+ reject(new Error(`Unexpected status code: ${res.statusCode}`));
+ }
+ } catch (parseError: unknown) {
+ reject(new Error((parseError as Error).message));
+ }
+ });
+ });
+
+ req.on('error', (error: Error) => {
+ reject(error);
+ });
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
+ if (isArgJson) {
+ req.write(JSON.stringify(data));
+ } else {
+ req.write(data);
+ }
+ }
+ req.end();
+ });
+ }
+
+ // 请求返回都是原始内容
+ static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
+ return this.HttpGetJson(url, method, data, headers, false, false);
+ }
+}
diff --git a/packages/snowcat-common/src/status-interface.ts b/packages/snowcat-common/src/status-interface.ts
new file mode 100644
index 0000000..4f43266
--- /dev/null
+++ b/packages/snowcat-common/src/status-interface.ts
@@ -0,0 +1,24 @@
+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;
+}
diff --git a/packages/snowcat-common/src/store.ts b/packages/snowcat-common/src/store.ts
new file mode 100644
index 0000000..9b5e3ba
--- /dev/null
+++ b/packages/snowcat-common/src/store.ts
@@ -0,0 +1,22 @@
+class Store {
+ private store = new Map();
+
+ set (key: string, value: T, ttl?: number): void {
+ this.store.set(key, value);
+ if (ttl) {
+ setTimeout(() => this.store.delete(key), ttl * 1000);
+ }
+ }
+
+ get (key: string): T | null {
+ return this.store.get(key) ?? null;
+ }
+
+ exists (...keys: string[]): number {
+ return keys.filter(key => this.store.has(key)).length;
+ }
+}
+
+const store = new Store();
+
+export default store;
diff --git a/packages/snowcat-common/src/subscription-interface.ts b/packages/snowcat-common/src/subscription-interface.ts
new file mode 100644
index 0000000..28afc6d
--- /dev/null
+++ b/packages/snowcat-common/src/subscription-interface.ts
@@ -0,0 +1,6 @@
+export type LogListener = (msg: string) => void;
+export interface ISubscription {
+ subscribe (listener: LogListener): void;
+ unsubscribe (listener: LogListener): void;
+ notify (msg: string): void;
+}
diff --git a/packages/snowcat-common/src/system.ts b/packages/snowcat-common/src/system.ts
new file mode 100644
index 0000000..ae2e3b2
--- /dev/null
+++ b/packages/snowcat-common/src/system.ts
@@ -0,0 +1,20 @@
+import os from 'node:os';
+import path from 'node:path';
+
+// 缓解Win7设备兼容性问题
+let osName: string;
+
+try {
+ osName = os.hostname();
+} catch {
+ osName = 'SnowCat'; // + crypto.randomUUID().substring(0, 4);
+}
+
+const homeDir = os.homedir();
+
+export const systemPlatform = os.platform();
+export const cpuArch = os.arch();
+export const systemVersion = os.release();
+export const hostname = osName;
+export const downloadsPath = path.join(homeDir, 'Downloads');
+export const systemName = os.type();
diff --git a/packages/snowcat-common/src/types.ts b/packages/snowcat-common/src/types.ts
new file mode 100644
index 0000000..17652a7
--- /dev/null
+++ b/packages/snowcat-common/src/types.ts
@@ -0,0 +1,28 @@
+// QQVersionType
+export type QQPackageInfoType = {
+ version: string;
+ buildVersion: string;
+ platform: string;
+ eleArch: string;
+};
+export type QQVersionConfigType = {
+ baseVersion: string;
+ curVersion: string;
+ prevVersion: string;
+ onErrorVersions: Array;
+ buildId: string;
+};
+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;
+}
diff --git a/packages/snowcat-common/src/version.ts b/packages/snowcat-common/src/version.ts
new file mode 100644
index 0000000..978566e
--- /dev/null
+++ b/packages/snowcat-common/src/version.ts
@@ -0,0 +1,120 @@
+// @ts-ignore
+export const snowCatVersion = (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 snowCatVersionInfo = parseSemVer(snowCatVersion);
diff --git a/packages/snowcat-common/src/worker.ts b/packages/snowcat-common/src/worker.ts
new file mode 100644
index 0000000..d0535ea
--- /dev/null
+++ b/packages/snowcat-common/src/worker.ts
@@ -0,0 +1,34 @@
+import { Worker } from 'worker_threads';
+
+export async function runTask (workerScript: string, taskData: T): Promise {
+ const worker = new Worker(workerScript);
+ try {
+ return await new Promise((resolve, reject) => {
+ worker.on('message', (result: R) => {
+ if ((result as any)?.log) {
+ console.error('Worker Log--->:', (result as { log: string }).log);
+ }
+ if ((result as any)?.error) {
+ reject(new Error('Worker error: ' + (result as { error: string }).error));
+ }
+ resolve(result);
+ });
+
+ worker.on('error', (error) => {
+ reject(new Error(`Worker error: ${error.message}`));
+ });
+
+ worker.on('exit', (code) => {
+ if (code !== 0) {
+ reject(new Error(`Worker stopped with exit code ${code}`));
+ }
+ });
+ worker.postMessage(taskData);
+ });
+ } catch (error: unknown) {
+ throw new Error(`Failed to run task: ${(error as Error).message}`);
+ } finally {
+ // Ensure the worker is terminated after the promise is settled
+ worker.terminate();
+ }
+}
diff --git a/packages/snowcat-common/tsconfig.json b/packages/snowcat-common/tsconfig.json
new file mode 100644
index 0000000..3293845
--- /dev/null
+++ b/packages/snowcat-common/tsconfig.json
@@ -0,0 +1,58 @@
+{
+ "compilerOptions": {
+ "target": "ES2021",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": [
+ "ES2021"
+ ],
+ "typeRoots": [
+ "./node_modules/@types"
+ ],
+ "esModuleInterop": true,
+ "outDir": "dist",
+ "rootDir": ".",
+ "noEmit": false,
+ "sourceMap": true,
+ "strict": true,
+ "noImplicitAny": false,
+ "strictFunctionTypes": true,
+ "strictBindCallApply": true,
+ "alwaysStrict": true,
+ "noImplicitThis": true,
+ "noImplicitReturns": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": false,
+ "forceConsistentCasingInFileNames": true,
+ "useUnknownInCatchVariables": true,
+ "noImplicitOverride": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "useDefineForClassFields": true,
+ "experimentalDecorators": true,
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "paths": {
+ "snowcat-common/src/*": [
+ "../../packages/snowcat-common/src/*"
+ ],
+ "snowcat-image-size/src/*": [
+ "../../packages/snowcat-image-size/src/*"
+ ],
+ "@/*": [
+ "../../packages/*"
+ ]
+ },
+ "skipLibCheck": true,
+ "skipDefaultLibCheck": true
+ },
+ "include": [
+ "src/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}
\ No newline at end of file
diff --git a/packages/snowcat-core/adapters/NodeIDependsAdapter.ts b/packages/snowcat-core/adapters/NodeIDependsAdapter.ts
new file mode 100644
index 0000000..d0b1073
--- /dev/null
+++ b/packages/snowcat-core/adapters/NodeIDependsAdapter.ts
@@ -0,0 +1,27 @@
+import { MsfChangeReasonType, MsfStatusType } from '@/snowcat-core/types/adapter';
+
+export class NodeIDependsAdapter {
+ onMSFStatusChange (_statusType: MsfStatusType, _changeReasonType: MsfChangeReasonType) {
+
+ }
+
+ onMSFSsoError (_code: number, _desc: string) {
+
+ }
+
+ getGroupCode (_args: unknown) {
+
+ }
+
+ // onSendMsfReply (_seq: string, _cmd: string, _uk1: number, _uk2: string, _rsp: {
+ // ssoRetCode: 0,
+ // trpcRetCode: 0,
+ // trpcFuncCode: 0,
+ // errorMsg: '',
+ // pbBuffer: Uint8Array,
+ // transInfoMap: Map;
+ // }) {
+
+ // console.log('[NodeIDependsAdapter] onSendMsfReply', _seq, _cmd, _uk1, _uk2, Buffer.from(_rsp.pbBuffer).toString('hex'));
+ // }
+}
diff --git a/packages/snowcat-core/adapters/NodeIDispatcherAdapter.ts b/packages/snowcat-core/adapters/NodeIDispatcherAdapter.ts
new file mode 100644
index 0000000..fb9d24b
--- /dev/null
+++ b/packages/snowcat-core/adapters/NodeIDispatcherAdapter.ts
@@ -0,0 +1,10 @@
+export class NodeIDispatcherAdapter {
+ dispatchRequest (_arg: unknown) {
+ }
+
+ dispatchCall (_arg: unknown) {
+ }
+
+ dispatchCallWithJson (_arg: unknown) {
+ }
+}
diff --git a/packages/snowcat-core/adapters/NodeIGlobalAdapter.ts b/packages/snowcat-core/adapters/NodeIGlobalAdapter.ts
new file mode 100644
index 0000000..4df9b66
--- /dev/null
+++ b/packages/snowcat-core/adapters/NodeIGlobalAdapter.ts
@@ -0,0 +1,25 @@
+export class NodeIGlobalAdapter {
+ onLog (..._args: unknown[]) {
+ }
+
+ onGetSrvCalTime (..._args: unknown[]) {
+ }
+
+ onShowErrUITips (..._args: unknown[]) {
+ }
+
+ fixPicImgType (..._args: unknown[]) {
+ }
+
+ getAppSetting (..._args: unknown[]) {
+ }
+
+ onInstallFinished (..._args: unknown[]) {
+ }
+
+ onUpdateGeneralFlag (..._args: unknown[]) {
+ }
+
+ onGetOfflineMsg (..._args: unknown[]) {
+ }
+}
diff --git a/packages/snowcat-core/adapters/index.ts b/packages/snowcat-core/adapters/index.ts
new file mode 100644
index 0000000..2db6d28
--- /dev/null
+++ b/packages/snowcat-core/adapters/index.ts
@@ -0,0 +1,3 @@
+export * from './NodeIDependsAdapter';
+export * from './NodeIDispatcherAdapter';
+export * from './NodeIGlobalAdapter';
diff --git a/packages/snowcat-core/apis/collection.ts b/packages/snowcat-core/apis/collection.ts
new file mode 100644
index 0000000..5f4118e
--- /dev/null
+++ b/packages/snowcat-core/apis/collection.ts
@@ -0,0 +1,60 @@
+import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
+
+export class NTQQCollectionApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ }
+
+ async createCollection (authorUin: string, authorUid: string, authorName: string, brief: string, rawData: string) {
+ return this.context.session.getCollectionService().createNewCollectionItem({
+ commInfo: {
+ bid: 1,
+ category: 2,
+ author: {
+ type: 1,
+ numId: authorUin,
+ strId: authorName,
+ groupId: '0',
+ groupName: '',
+ uid: authorUid,
+ },
+ customGroupId: '0',
+ createTime: Date.now().toString(),
+ sequence: Date.now().toString(),
+ },
+ richMediaSummary: {
+ originalUri: '',
+ publisher: '',
+ richMediaVersion: 0,
+ subTitle: '',
+ title: '',
+ brief,
+ picList: [],
+ contentType: 1,
+ },
+ richMediaContent: {
+ rawData,
+ bizDataList: [],
+ picList: [],
+ fileList: [],
+ },
+ need_share_url: false,
+ });
+ }
+
+ async getAllCollection (category: number = 0, count: number = 50) {
+ return this.context.session.getCollectionService().getCollectionItemList({
+ category,
+ groupId: -1,
+ forceSync: true,
+ forceFromDb: false,
+ timeStamp: '0',
+ count,
+ searchDown: true,
+ });
+ }
+}
diff --git a/packages/snowcat-core/apis/database.ts b/packages/snowcat-core/apis/database.ts
new file mode 100644
index 0000000..b127190
--- /dev/null
+++ b/packages/snowcat-core/apis/database.ts
@@ -0,0 +1,201 @@
+import path from 'node:path';
+import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
+import {
+ checkSqliteAvailable,
+ DatabaseHandle,
+ openDatabase,
+ decryptAndOpen,
+ readSingleDatabase,
+ scanAllDatabases,
+ formatScanResults,
+ decryptDatabaseFile,
+ type DatabaseScanResult,
+ type DatabaseReadOptions,
+ type TableInfo,
+} from 'snowcat-database';
+
+type SQLInputValue = import('node:sqlite').SQLInputValue;
+
+export class NTQQDatabaseApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ }
+
+ /** node:sqlite 是否可用 */
+ async isSqliteAvailable (): Promise {
+ return checkSqliteAvailable();
+ }
+
+ /** 获取当前账号的 nt_db 目录 */
+ getNtDbDir (): string {
+ return path.resolve(this.core.dataPath, this.core.selfInfo.uin, 'nt_qq', 'nt_db');
+ }
+
+ /**
+ * 获取解密缓存目录
+ * 默认: /db_/
+ * 可通过 customDir 完全自定义
+ */
+ getDefaultCacheDir (customDir?: string): string {
+ if (customDir) return customDir;
+ return path.resolve(this.core.SnowCatDataPath, `db_${this.core.selfInfo.uin}`);
+ }
+
+ /** 当前是否已获取到数据库 passphrase */
+ hasPassphrase (): boolean {
+ return !!this.core.dbPassphrase;
+ }
+
+ /** 获取 passphrase Buffer,未获取时返回 null */
+ getPassphraseBuffer (): Buffer | null {
+ return this.core.dbPassphrase ? Buffer.from(this.core.dbPassphrase) : null;
+ }
+
+ // ======================== SQLite 数据库操作 ========================
+
+ /**
+ * 打开一个已解密的 SQLite 数据库文件
+ * @param filePath 解密后 .db 文件路径
+ * @param readOnly 是否只读 (默认 true)
+ * @returns DatabaseHandle 实例,使用完毕后需调用 .close()
+ */
+ openDecryptedDb (filePath: string, readOnly = true): DatabaseHandle {
+ return openDatabase(filePath, readOnly);
+ }
+
+ /**
+ * 解密并打开一个 NTQQ 加密数据库
+ * @param dbName 数据库文件名 (如 'nt_msg.db') 或完整路径
+ * @param readOnly 是否只读 (默认 true)
+ * @param cacheDir 自定义缓存目录,默认 db_
+ * @returns DatabaseHandle 实例,使用完毕后需调用 .close()
+ */
+ openDatabase (dbName: string, readOnly = true, cacheDir?: string): DatabaseHandle | null {
+ const passphrase = this.getPassphraseBuffer();
+ if (!passphrase) return null;
+
+ const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
+ const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
+
+ return decryptAndOpen(dbPath, passphrase, resolvedCacheDir, readOnly);
+ }
+
+ /**
+ * 对加密数据库执行查询并自动关闭连接
+ * @param dbName 数据库文件名或完整路径
+ * @param sql SQL 查询语句
+ * @param params 绑定参数
+ * @returns 查询结果行数组,passphrase 不可用时返回 null
+ */
+ query> (
+ dbName: string,
+ sql: string,
+ params?: SQLInputValue[] | Record
+ ): T[] | null {
+ const db = this.openDatabase(dbName);
+ if (!db) return null;
+ try {
+ return db.query(sql, params);
+ } finally {
+ db.close();
+ }
+ }
+
+ /**
+ * 对加密数据库执行查询,仅返回第一行
+ * @param dbName 数据库文件名或完整路径
+ * @param sql SQL 查询语句
+ * @param params 绑定参数
+ */
+ queryOne> (
+ dbName: string,
+ sql: string,
+ params?: SQLInputValue[] | Record
+ ): T | undefined | null {
+ const db = this.openDatabase(dbName);
+ if (!db) return null;
+ try {
+ return db.queryOne(sql, params);
+ } finally {
+ db.close();
+ }
+ }
+
+ /**
+ * 对加密数据库执行非查询 SQL (INSERT/UPDATE/DELETE 等) 并自动关闭
+ * @param dbName 数据库文件名或完整路径
+ * @param sql SQL 语句
+ * @param params 绑定参数
+ */
+ execute (
+ dbName: string,
+ sql: string,
+ params?: SQLInputValue[] | Record
+ ): { changes: number | bigint; lastInsertRowid: number | bigint; } | null {
+ const db = this.openDatabase(dbName, false);
+ if (!db) return null;
+ try {
+ return db.execute(sql, params);
+ } finally {
+ db.close();
+ }
+ }
+
+ // ======================== 批量扫描 ========================
+
+ /**
+ * 读取单个加密数据库的表信息
+ * @param dbName 数据库文件名 (如 'nt_msg.db'),或完整路径
+ * @param cacheDir 自定义缓存目录,默认 db_
+ * @param options 其他选项
+ */
+ readDatabase (dbName: string, cacheDir?: string, options?: Omit): DatabaseScanResult | null {
+ const passphrase = this.getPassphraseBuffer();
+ if (!passphrase) return null;
+
+ const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
+ const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
+
+ return readSingleDatabase(dbPath, passphrase, { cacheDir: resolvedCacheDir, ...options });
+ }
+
+ /**
+ * 扫描所有加密数据库
+ * @param cacheDir 自定义缓存目录,默认 db_
+ * @param options 其他选项
+ */
+ scanDatabases (cacheDir?: string, options?: Omit): DatabaseScanResult[] {
+ const passphrase = this.getPassphraseBuffer();
+ if (!passphrase) return [];
+
+ const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
+ return scanAllDatabases(this.getNtDbDir(), passphrase, { cacheDir: resolvedCacheDir, ...options });
+ }
+
+ /**
+ * 仅解密数据库文件(不读取表信息,不需要 node:sqlite)
+ * @param dbName 数据库文件名或完整路径
+ * @param outputPath 输出路径,默认保存到 db_/
+ */
+ decryptDatabase (dbName: string, outputPath?: string): string | null {
+ const passphrase = this.getPassphraseBuffer();
+ if (!passphrase) return null;
+
+ const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
+ const outPath = outputPath ?? path.resolve(this.getDefaultCacheDir(), path.basename(dbName));
+
+ return decryptDatabaseFile(dbPath, passphrase, outPath);
+ }
+
+ /** 格式化扫描结果为可读字符串 */
+ formatResults (results: DatabaseScanResult[]): string {
+ return formatScanResults(results);
+ }
+}
+
+export { DatabaseHandle };
+export type { DatabaseScanResult, DatabaseReadOptions, TableInfo };
diff --git a/packages/snowcat-core/apis/file.ts b/packages/snowcat-core/apis/file.ts
new file mode 100644
index 0000000..a581d44
--- /dev/null
+++ b/packages/snowcat-core/apis/file.ts
@@ -0,0 +1,390 @@
+import {
+ ChatType,
+ ElementType,
+ IMAGE_HTTP_HOST,
+ IMAGE_HTTP_HOST_NT,
+ Peer,
+ PicElement,
+ RawMessage,
+} from '@/snowcat-core/types';
+import path from 'path';
+import fs from 'fs';
+import fsPromises from 'fs/promises';
+import { InstanceContext, SnowCatCore, SearchResultItem } from '@/snowcat-core/index';
+import { fileTypeFromFile } from 'file-type';
+import { RkeyManager } from '@/snowcat-core/helper/rkey';
+import { calculateFileMD5 } from 'snowcat-common/src/file';
+import { rkeyDataType } from '../types/file';
+import { NapProtoMsg } from 'snowcat-protobuf';
+import { FileId } from '../packet/transformer/proto/misc/fileid';
+
+export class NTQQFileApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+ rkeyManager: RkeyManager;
+ packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint; }> | undefined;
+ private fetchRkeyFailures: number = 0;
+ private readonly MAX_RKEY_FAILURES: number = 8;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ this.rkeyManager = new RkeyManager([
+ 'http://ss.xingzhige.com/music_card/rkey',
+ 'https://secret-service.bietiaop.com/rkeys',
+ ],
+ this.context.logger
+ );
+ }
+
+ private async fetchRkeyWithRetry () {
+ if (this.fetchRkeyFailures >= this.MAX_RKEY_FAILURES) {
+ throw new Error('Native.FetchRkey 已被禁用');
+ }
+ try {
+ const ret = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
+ this.fetchRkeyFailures = 0; // Reset failures on success
+ return ret;
+ } catch (error) {
+ this.fetchRkeyFailures++;
+ this.context.logger.logError('FetchRkey 失败', (error as Error).message);
+ throw error;
+ }
+ }
+
+ async getFileUrl (chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined, timeout: number = 5000) {
+ if (this.core.apis.PacketApi.packetStatus) {
+ try {
+ if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
+ return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID, timeout);
+ } else if (file10MMd5 && fileUUID) {
+ return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5, timeout);
+ }
+ } catch (error) {
+ this.context.logger.logError('获取文件URL失败', (error as Error).message);
+ }
+ }
+ throw new Error('fileUUID or file10MMd5 is undefined');
+ }
+
+ async getPttUrl (peer: string, fileUUID?: string, timeout: number = 5000) {
+ if (this.core.apis.PacketApi.packetStatus && fileUUID) {
+ const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
+ try {
+ if (appid && appid === 1403) {
+ return this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+peer, {
+ fileUuid: fileUUID,
+ storeId: 1,
+ uploadTime: 0,
+ ttl: 0,
+ subType: 0,
+ }, timeout);
+ } else if (fileUUID) {
+ return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
+ fileUuid: fileUUID,
+ storeId: 1,
+ uploadTime: 0,
+ ttl: 0,
+ subType: 0,
+ }, timeout);
+ }
+ } catch (error) {
+ this.context.logger.logError('获取文件URL失败', (error as Error).message);
+ }
+ }
+ throw new Error('packet cant get ptt url');
+ }
+
+ async getVideoUrlPacket (peer: string, fileUUID?: string, timeout: number = 5000) {
+ if (this.core.apis.PacketApi.packetStatus && fileUUID) {
+ const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
+ try {
+ if (appid && appid === 1415) {
+ return this.core.apis.PacketApi.pkt.operation.GetGroupVideoUrl(+peer, {
+ fileUuid: fileUUID,
+ storeId: 1,
+ uploadTime: 0,
+ ttl: 0,
+ subType: 0,
+ }, timeout);
+ } else if (fileUUID) {
+ return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
+ fileUuid: fileUUID,
+ storeId: 1,
+ uploadTime: 0,
+ ttl: 0,
+ subType: 0,
+ }, timeout);
+ }
+ } catch (error) {
+ this.context.logger.logError('获取文件URL失败', (error as Error).message);
+ }
+ }
+ throw new Error('packet cant get video url');
+ }
+
+ async copyFile (filePath: string, destPath: string) {
+ await this.core.util.copyFile(filePath, destPath);
+ }
+
+ async getFileSize (filePath: string): Promise {
+ return await this.core.util.getFileSize(filePath);
+ }
+
+ async getVideoUrl (peer: Peer, msgId: string, elementId: string) {
+ return (await this.context.session.getRichMediaService().getVideoPlayUrlV2(peer, msgId, elementId, 0, {
+ downSourceType: 1,
+ triggerType: 1,
+ })).urlResult.domainUrl;
+ }
+
+ async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) {
+ const fileMd5 = await calculateFileMD5(filePath);
+ const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
+ const ext = extOrEmpty ? `.${extOrEmpty}` : '';
+ let fileName = `${path.basename(filePath)}`;
+ if (fileName.indexOf('.') === -1) {
+ fileName += ext;
+ }
+ 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,
+ fileSize,
+ ext,
+ };
+ }
+
+ async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
+ const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelRichMediaService/downloadFileForModelId',
+ 'NodeIKernelMsgListener/onRichMediaDownloadComplete',
+ [peer, [modelId], unknown],
+ () => true,
+ (arg) => arg?.commonFileInfo?.fileModelId === modelId,
+ 1,
+ timeout
+ );
+ return fileTransNotifyInfo.filePath;
+ }
+
+ async downloadRawMsgMedia (msg: RawMessage[]) {
+ const res = await Promise.all(
+ msg.map(m =>
+ Promise.all(
+ m.elements
+ .filter(element =>
+ element.elementType === ElementType.PIC ||
+ element.elementType === ElementType.VIDEO ||
+ element.elementType === ElementType.PTT ||
+ element.elementType === ElementType.FILE
+ )
+ .map(element =>
+ this.downloadMedia(m.msgId, m.chatType, m.peerUid, element.elementId, '', '', 1000 * 60 * 2, true)
+ )
+ )
+ )
+ );
+ msg.forEach((m, msgIndex) => {
+ const elementResults = res[msgIndex];
+ let elementIndex = 0;
+ m.elements.forEach(element => {
+ if (
+ element.elementType === ElementType.PIC ||
+ element.elementType === ElementType.VIDEO ||
+ element.elementType === ElementType.PTT ||
+ element.elementType === ElementType.FILE
+ ) {
+ switch (element.elementType) {
+ case ElementType.PIC:
+ element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
+ break;
+ case ElementType.VIDEO:
+ element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
+ break;
+ case ElementType.PTT:
+ element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
+ break;
+ case ElementType.FILE:
+ element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
+ break;
+ }
+ elementIndex++;
+ }
+ });
+ });
+ return res.flat();
+ }
+
+ async downloadMedia (msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
+ // 用于下载文件
+ if (sourcePath && fs.existsSync(sourcePath)) {
+ if (force) {
+ try {
+ await fsPromises.unlink(sourcePath);
+ } catch {
+ //
+ }
+ } else {
+ return sourcePath;
+ }
+ }
+ const [, completeRetData] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelMsgService/downloadRichMedia',
+ 'NodeIKernelMsgListener/onRichMediaDownloadComplete',
+ [{
+ fileModelId: '0',
+ downSourceType: 0,
+ downloadSourceType: 0,
+ triggerType: 1,
+ msgId,
+ chatType,
+ peerUid,
+ elementId,
+ thumbSize: 0,
+ downloadType: 1,
+ filePath: thumbPath,
+ }],
+ () => true,
+ (arg) => arg.msgElementId === elementId && arg.msgId === msgId,
+ 1,
+ timeout
+ );
+ return completeRetData.filePath;
+ }
+
+ async searchForFile (keys: string[]): Promise {
+ const randomResultId = 100000 + Math.floor(Math.random() * 10000);
+ let searchId = 0;
+ const [, searchResult] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelFileAssistantService/searchFile',
+ 'NodeIKernelFileAssistantListener/onFileSearch',
+ [
+ keys,
+ { resultType: 2, pageLimit: 1 },
+ randomResultId,
+ ],
+ (ret) => {
+ searchId = ret;
+ return true;
+ },
+ result => result.searchId === searchId && result.resultId === randomResultId
+ );
+ return searchResult.resultItems[0];
+ }
+
+ async downloadFileById (
+ fileId: string,
+ fileSize: number = 1024576,
+ estimatedTime: number = (fileSize * 1000 / 1024576) + 5000
+ ) {
+ const [, fileData] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelFileAssistantService/downloadFile',
+ 'NodeIKernelFileAssistantListener/onFileStatusChanged',
+ [[fileId]],
+ ret => ret.result === 0,
+ status => status.fileStatus === 2 && status.fileProgress === '0',
+ 1,
+ estimatedTime // estimate 1MB/s
+ );
+ return fileData.filePath!;
+ }
+
+ async getImageUrl (element: PicElement): Promise {
+ if (!element) {
+ return '';
+ }
+
+ const url: string = element.originImageUrl ?? '';
+
+ const md5HexStr = element.md5HexStr;
+ const fileMd5 = element.md5HexStr;
+ const parsedUrl = new URL(IMAGE_HTTP_HOST + url);
+ const imageAppid = parsedUrl.searchParams.get('appid');
+ const isNTV2 = imageAppid && ['1406', '1407'].includes(imageAppid);
+ const imageFileId = parsedUrl.searchParams.get('fileid');
+ if (url && isNTV2 && imageFileId) {
+ const rkeyData = await this.getRkeyData();
+ return this.getImageUrlFromParsedUrl(imageFileId, imageAppid, rkeyData);
+ }
+ return this.getImageUrlFromMd5(fileMd5, md5HexStr);
+ }
+
+ private async getRkeyData () {
+ const rkeyData: rkeyDataType = {
+ private_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qEc3Rbib9LP4',
+ group_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qffcqm614gds',
+ online_rkey: false,
+ };
+
+ try {
+ if (this.core.apis.PacketApi.packetStatus) {
+ const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
+ const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
+ if (rkey_expired_private || rkey_expired_group) {
+ this.packetRkey = await this.fetchRkeyWithRetry();
+ }
+ if (this.packetRkey && this.packetRkey.length > 0) {
+ rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
+ rkeyData.private_rkey = this.packetRkey[0]?.rkey.slice(6) ?? '';
+ rkeyData.online_rkey = true;
+ }
+ }
+ } catch (error: unknown) {
+ this.context.logger.logDebug('获取native.rkey失败', (error as Error).message);
+ }
+
+ if (!rkeyData.online_rkey) {
+ try {
+ const tempRkeyData = await this.rkeyManager.getRkey();
+ rkeyData.group_rkey = tempRkeyData.group_rkey;
+ rkeyData.private_rkey = tempRkeyData.private_rkey;
+ rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
+ } catch (error: unknown) {
+ this.context.logger.logDebug('获取remote.rkey失败', (error as Error).message);
+ }
+ }
+ // 进行 fallback.rkey 模式
+ return rkeyData;
+ }
+
+ private getImageUrlFromParsedUrl (imageFileId: string, appid: string, rkeyData: rkeyDataType): string {
+ const rkey = appid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
+ if (rkeyData.online_rkey) {
+ return IMAGE_HTTP_HOST_NT + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}`;
+ }
+ return IMAGE_HTTP_HOST + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}&spec=0`;
+ }
+
+ private getImageUrlFromMd5 (fileMd5: string | undefined, md5HexStr: string | undefined): string {
+ if (fileMd5 || md5HexStr) {
+ return `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${(fileMd5 ?? md5HexStr ?? '').toUpperCase()}/0`;
+ }
+
+ this.context.logger.logDebug('图片url获取失败', { fileMd5, md5HexStr });
+ return '';
+ }
+}
diff --git a/packages/snowcat-core/apis/flash.ts b/packages/snowcat-core/apis/flash.ts
new file mode 100644
index 0000000..ef22670
--- /dev/null
+++ b/packages/snowcat-core/apis/flash.ts
@@ -0,0 +1,307 @@
+import { GeneralCallResult, InstanceContext, SnowCatCore } from '@/snowcat-core';
+import {
+ createFlashTransferResult,
+ FileListResponse,
+ FlashFileSetInfo,
+ SendStatus,
+ UploadSceneType,
+} from '@/snowcat-core/data/flash';
+import { Peer } from '@/snowcat-core/types';
+
+export class NTQQFlashApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ }
+
+ /**
+ * 发起闪传上传任务
+ * @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
+ * @param thumbnailPath
+ * @param filesetName
+ */
+ async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/packages/snowcat-core/apis/friend.ts b/packages/snowcat-core/apis/friend.ts
new file mode 100644
index 0000000..465b789
--- /dev/null
+++ b/packages/snowcat-core/apis/friend.ts
@@ -0,0 +1,133 @@
+import { FriendRequest, FriendV2 } from '@/snowcat-core/types';
+import { BuddyListReqType, InstanceContext, SnowCatCore } from '@/snowcat-core/index';
+import { LimitedHashTable } from 'snowcat-common/src/message-unique';
+
+export class NTQQFriendApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ }
+
+ async setBuddyRemark (uid: string, remark: string) {
+ return this.context.session.getBuddyService().setBuddyRemark({ uid, remark });
+ }
+
+ async getBuddyV2SimpleInfoMap () {
+ const buddyService = this.context.session.getBuddyService();
+ let uids: string[] = [];
+ if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
+ const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
+ uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
+ } else {
+ const buddyListV2 = await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL);
+ uids = buddyListV2.data.flatMap(item => item.buddyUids);
+ }
+ return await this.core.eventWrapper.callNoListenerEvent(
+ 'NodeIKernelProfileService/getCoreAndBaseInfo',
+ 'nodeStore',
+ uids
+ );
+ }
+
+ async getBuddy (): Promise {
+ return Array.from((await this.getBuddyV2SimpleInfoMap()).values());
+ }
+
+ async getBuddyIdMap (): Promise> {
+ const retMap: LimitedHashTable = new LimitedHashTable(5000);
+ const data = await this.getBuddyV2SimpleInfoMap();
+ data.forEach((value) => retMap.set(value.uin!, value.uid!));
+ return retMap;
+ }
+
+ async delBuddy (uid: string, tempBlock = false, tempBothDel = false) {
+ return this.context.session.getBuddyService().delBuddy({
+ friendUid: uid,
+ tempBlock,
+ tempBothDel,
+ });
+ }
+
+ async getBuddyV2ExWithCate () {
+ const buddyService = this.context.session.getBuddyService();
+ let uids: string[] = [];
+ let buddyListV2: Awaited>['data'];
+ if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
+ buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
+ uids = buddyListV2.flatMap(item => item.buddyUids);
+ } else {
+ buddyListV2 = (await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL)).data;
+ uids = buddyListV2.flatMap(item => item.buddyUids);
+ }
+ const data = await this.core.eventWrapper.callNoListenerEvent(
+ 'NodeIKernelProfileService/getCoreAndBaseInfo',
+ 'nodeStore',
+ uids
+ );
+ return buddyListV2.map(category => ({
+ categoryId: category.categoryId,
+ categorySortId: category.categorySortId,
+ categoryName: category.categroyName,
+ categoryMbCount: category.categroyMbCount,
+ onlineCount: category.onlineCount,
+ buddyList: category.buddyUids.map(uid => data.get(uid)).filter(value => !!value),
+ }));
+ }
+
+ async isBuddy (uid: string) {
+ return this.context.session.getBuddyService().isBuddy(uid);
+ }
+
+ async clearBuddyReqUnreadCnt () {
+ return this.context.session.getBuddyService().clearBuddyReqUnreadCnt();
+ }
+
+ async getBuddyReq () {
+ const [, ret] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelBuddyService/getBuddyReq',
+ 'NodeIKernelBuddyListener/onBuddyReqChange',
+ []
+ );
+ return ret;
+ }
+
+ async handleFriendRequest (notify: FriendRequest, accept: boolean) {
+ this.context.session.getBuddyService()?.approvalFriendRequest({
+ friendUid: notify.friendUid,
+ reqTime: notify.reqTime,
+ accept,
+ });
+ }
+
+ async handleDoubtFriendRequest (friendUid: string, str1: string = '', str2: string = '') {
+ this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
+ }
+
+ async getDoubtFriendRequest (count: number) {
+ const date = Date.now().toString();
+ const [, ret] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelBuddyService/getDoubtBuddyReq',
+ 'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
+ [date, count, ''],
+ () => true,
+ (data) => data.reqId === date
+ );
+ const requests = Promise.all(ret.doubtList.map(async (item) => {
+ return {
+ flag: item.uid, // 注意强制String 非isNumeric 不遵守则不符合设计
+ uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0, // 信息字段
+ nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
+ source: item.source, // 信息字段
+ reason: item.reason, // 信息字段
+ msg: item.msg, // 信息字段
+ group_code: item.groupCode, // 信息字段
+ time: item.reqTime, // 信息字段
+ type: 'doubt', // 保留字段
+ };
+ }));
+ return requests;
+ }
+}
diff --git a/packages/snowcat-core/apis/group.ts b/packages/snowcat-core/apis/group.ts
new file mode 100644
index 0000000..c622b15
--- /dev/null
+++ b/packages/snowcat-core/apis/group.ts
@@ -0,0 +1,626 @@
+import {
+ GeneralCallResult,
+ GroupMember,
+ NTGroupMemberRole,
+ NTGroupRequestOperateTypes,
+ InstanceContext,
+ KickMemberV2Req,
+ MemberExtSourceType,
+ SnowCatCore,
+ GroupNotify,
+ GroupInfoSource,
+ ShutUpGroupMember,
+ Peer,
+ ChatType,
+ GroupManagementSettings,
+} from '@/snowcat-core/index';
+import { isNumeric, solveAsyncProblem } from 'snowcat-common/src/helper';
+import { LimitedHashTable } from 'snowcat-common/src/message-unique';
+import { CancelableTask, TaskExecutor } from 'snowcat-common/src/cancel-task';
+import {
+ createGroupDetailInfoV2Param,
+ createGroupExtFilter,
+ createGroupExtInfo,
+ createGroupManagementRequests,
+} from '../data';
+import { NTEventWrapper } from '../helper/event';
+
+export class NTQQGroupApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+ groupMemberCache: Map> = new Map>();
+ essenceLRU = new LimitedHashTable(1000);
+ // psKey 滑动缓存,TTL 60 秒,惰性删除(不主动清理,等下次访问检测到过期再刷新)
+ private _qunPskeyCache: { value: string; expireAt: number; } | null = null;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ }
+
+ /** 获取 qun.qq.com 域名的 psKey,滑动 TTL 60 秒(每次访问顺延) */
+ private async getQunPskey (): Promise {
+ const now = Date.now();
+ if (this._qunPskeyCache && this._qunPskeyCache.expireAt > now) {
+ // 滑动 TTL:每次访问都顺延过期时间
+ this._qunPskeyCache.expireAt = now + 60 * 1000;
+ return this._qunPskeyCache.value;
+ }
+ // 过期或为空,惰性删除旧值(直接覆盖)并重新获取
+ const psKey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
+ this._qunPskeyCache = { value: psKey, expireAt: now + 60 * 1000 };
+ return psKey;
+ }
+
+ async setGroupRemark (groupCode: string, remark: string) {
+ return this.context.session.getGroupService().modifyGroupRemark(groupCode, remark);
+ }
+
+ async fetchGroupDetail (groupCode: string) {
+ const [, detailInfo] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelGroupService/getGroupDetailInfo',
+ 'NodeIKernelGroupListener/onGroupDetailInfoChange',
+ [groupCode, GroupInfoSource.KDATACARD],
+ (ret) => ret.result === 0,
+ (detailInfo) => detailInfo.groupCode === groupCode,
+ 1,
+ 5000
+ );
+ return detailInfo;
+ }
+
+ async initApi () {
+ this.initCache().then().catch(e => this.context.logger.logError(e));
+ }
+
+ async createGrayTip (groupCode: string, tip: string) {
+ return this.context.session.getMsgService().addLocalJsonGrayTipMsg(
+ {
+ chatType: ChatType.KCHATTYPEGROUP,
+ peerUid: groupCode,
+ } as Peer,
+ {
+ busiId: 2201,
+ jsonStr: JSON.stringify({ align: 'center', items: [{ txt: tip, type: 'nor' }] }),
+ recentAbstract: tip,
+ isServer: false,
+ },
+ true,
+ true
+ );
+ }
+
+ async initCache () {
+ for (const group of await this.getGroups(true)) {
+ this.refreshGroupMemberCache(group.groupCode, false).then().catch(e => this.context.logger.logError(e));
+ }
+ }
+
+ async fetchGroupEssenceList (groupCode: string) {
+ const pskey = await this.getQunPskey();
+ return this.context.session.getGroupService().fetchGroupEssenceList({
+ groupCode,
+ pageStart: 0,
+ pageLimit: 300,
+ }, pskey);
+ }
+
+ async getGroupShutUpMemberList (groupCode: string): Promise {
+ const executor: TaskExecutor = async (resolve, reject, onCancel) => {
+ this.core.eventWrapper.registerListen(
+ 'NodeIKernelGroupListener/onShutUpMemberListChanged',
+ (group_id) => group_id === groupCode,
+ 1,
+ 1000
+ ).then((data) => {
+ resolve(data[1]);
+ }).catch(reject);
+
+ onCancel(() => {
+ reject(new Error('Task was canceled'));
+ });
+ };
+
+ const task = new CancelableTask(executor);
+ this.context.session.getGroupService().getGroupShutUpMemberList(groupCode).then(e => {
+ if (e.result !== 0) {
+ task.cancel();
+ }
+ });
+ return await task.catch(() => []);
+ }
+
+ async clearGroupNotifiesUnreadCount (doubt: boolean) {
+ return this.context.session.getGroupService().clearGroupNotifiesUnreadCount(doubt);
+ }
+
+ async setGroupAvatar (groupCode: string, filePath: string) {
+ return this.context.session.getGroupService().setHeader(groupCode, filePath);
+ }
+
+ // 0 0 无需管理员审核
+ // 0 2 需要管理员审核
+ // 1 2 禁止Bot入群( 最好只传一个1 ?)
+ async setGroupRobotAddOption (groupCode: string, robotMemberSwitch?: number, robotMemberExamine?: number) {
+ const extInfo = createGroupExtInfo(groupCode);
+ const groupExtFilter = createGroupExtFilter();
+ if (robotMemberSwitch !== undefined) {
+ extInfo.extInfo.inviteRobotMemberSwitch = robotMemberSwitch;
+ groupExtFilter.inviteRobotMemberSwitch = 1;
+ }
+ if (robotMemberExamine !== undefined) {
+ extInfo.extInfo.inviteRobotMemberExamine = robotMemberExamine;
+ groupExtFilter.inviteRobotMemberExamine = 1;
+ }
+ return this.context.session.getGroupService().modifyGroupExtInfoV2(extInfo, groupExtFilter);
+ }
+
+ async setGroupAddOption (groupCode: string, option: {
+ addOption: number;
+ groupQuestion?: string;
+ groupAnswer?: string;
+ }) {
+ const param = createGroupDetailInfoV2Param(groupCode);
+ // 设置要修改的目标
+ param.filter.addOption = 1;
+ if (option.addOption === 4 || option.addOption === 5) {
+ // 4 问题进入答案 5 问题管理员批准
+ param.filter.groupQuestion = 1;
+ param.filter.groupAnswer = option.addOption === 4 ? 1 : 0;
+ param.modifyInfo.groupQuestion = option.groupQuestion || '';
+ param.modifyInfo.groupAnswer = option.addOption === 4 ? option.groupAnswer || '' : '';
+ }
+ param.modifyInfo.addOption = option.addOption;
+ return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
+ }
+
+ async setGroupMemberInvitePolicy (
+ groupCode: string,
+ policy: NonNullable
+ ) {
+ return this.modifyGroupManagementSettings(groupCode, { memberInvite: policy });
+ }
+
+ async setGroupMemberPermissions (
+ groupCode: string,
+ permissions: Pick<
+ GroupManagementSettings,
+ 'allowMemberUploadAlbum' | 'allowMemberTemporarySession' | 'allowMemberCreateGroup'
+ >
+ ) {
+ return this.modifyGroupManagementSettings(groupCode, permissions);
+ }
+
+ async setGroupNewMemberHistoryVisibility (groupCode: string, visible: boolean) {
+ return this.modifyGroupManagementSettings(groupCode, { newMembersSeeRecentHistory: visible });
+ }
+
+ private async modifyGroupManagementSettings (groupCode: string, settings: GroupManagementSettings) {
+ const results: Array<{ setting: string, result: GeneralCallResult; }> = [];
+ const changesKnownFlags = settings.memberInvite !== undefined ||
+ settings.allowMemberUploadAlbum !== undefined ||
+ settings.allowMemberTemporarySession !== undefined ||
+ settings.allowMemberCreateGroup !== undefined ||
+ settings.newMembersSeeRecentHistory !== undefined;
+ const currentDetail = changesKnownFlags ? await this.fetchGroupDetail(groupCode) : undefined;
+
+ for (const request of createGroupManagementRequests(
+ groupCode,
+ settings,
+ currentDetail?.privilegeFlag,
+ currentDetail?.groupFlagExt4
+ )) {
+ results.push({
+ setting: request.setting,
+ result: await this.context.session.getGroupService().modifyGroupDetailInfoV2(
+ request.param,
+ request.operationType
+ ),
+ });
+ }
+
+ return results;
+ }
+
+ async setGroupSearch (groupCode: string, option: {
+ noCodeFingerOpenFlag?: number;
+ noFingerOpenFlag?: number;
+ }) {
+ const param = createGroupDetailInfoV2Param(groupCode);
+ if (option.noCodeFingerOpenFlag) {
+ param.filter.noCodeFingerOpenFlag = 1;
+ param.modifyInfo.noCodeFingerOpenFlag = option.noCodeFingerOpenFlag;
+ }
+ if (option.noFingerOpenFlag) {
+ param.filter.noFingerOpenFlag = 1;
+ param.modifyInfo.noFingerOpenFlag = option.noFingerOpenFlag;
+ }
+ return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
+ }
+
+ async getGroups (forced: boolean = false) {
+ const [, , groupList] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelGroupService/getGroupList',
+ 'NodeIKernelGroupListener/onGroupListUpdate',
+ [forced]
+ );
+ return groupList;
+ }
+
+ async getGroupExtFE0Info (groupCodes: Array, forced = true) {
+ return this.context.session.getGroupService().getGroupExt0xEF0Info(
+ groupCodes,
+ [],
+ {
+ bindGuildId: 1,
+ blacklistExpireTime: 1,
+ companyId: 1,
+ essentialMsgPrivilege: 1,
+ essentialMsgSwitch: 1,
+ fullGroupExpansionSeq: 1,
+ fullGroupExpansionSwitch: 1,
+ gangUpId: 1,
+ groupAioBindGuildId: 1,
+ groupBindGuildIds: 1,
+ groupBindGuildSwitch: 1,
+ groupExcludeGuildIds: 1,
+ groupExtFlameData: 1,
+ groupFlagPro1: 1,
+ groupInfoExtSeq: 1,
+ groupOwnerId: 1,
+ groupSquareSwitch: 1,
+ hasGroupCustomPortrait: 1,
+ inviteRobotMemberExamine: 1,
+ inviteRobotMemberSwitch: 1,
+ inviteRobotSwitch: 1,
+ isLimitGroupRtc: 1,
+ lightCharNum: 1,
+ luckyWord: 1,
+ luckyWordId: 1,
+ msgEventSeq: 1,
+ qqMusicMedalSwitch: 1,
+ reserve: 1,
+ showPlayTogetherSwitch: 1,
+ starId: 1,
+ todoSeq: 1,
+ viewedMsgDisappearTime: 1,
+ },
+ forced
+ );
+ }
+
+ async getGroupMemberAll (groupCode: string, forced = false) {
+ return this.context.session.getGroupService().getAllMemberList(groupCode, forced);
+ }
+
+ async refreshGroupMemberCache (groupCode: string, isWait = true) {
+ const updateCache = async () => {
+ try {
+ const members = await this.getGroupMemberAll(groupCode, true);
+ this.groupMemberCache.set(groupCode, members.result.infos);
+ } catch (e) {
+ this.context.logger.logError(`刷新群成员缓存失败, 群号: ${groupCode}, 错误: ${e}`);
+ }
+ };
+
+ if (isWait) {
+ await updateCache();
+ } else {
+ updateCache();
+ }
+
+ return this.groupMemberCache.get(groupCode);
+ }
+
+ async refreshGroupMemberCachePartial (groupCode: string, uid: string) {
+ const member = await this.getGroupMemberEx(groupCode, uid, true);
+ if (member) {
+ this.groupMemberCache.get(groupCode)?.set(uid, member);
+ }
+ return member;
+ }
+
+ async getGroupMember (groupCode: string | number, memberUinOrUid: string | number) {
+ const groupCodeStr = groupCode.toString();
+ const memberUinOrUidStr = memberUinOrUid.toString();
+
+ // 获取群成员缓存
+ let members = this.groupMemberCache.get(groupCodeStr);
+ if (!members) {
+ members = (await this.refreshGroupMemberCache(groupCodeStr, true));
+ }
+
+ const getMember = () => {
+ if (isNumeric(memberUinOrUidStr)) {
+ return Array.from(members!.values()).find(member => member.uin === memberUinOrUidStr);
+ } else {
+ return members!.get(memberUinOrUidStr);
+ }
+ };
+
+ let member = getMember();
+ // 如果缓存中不存在该成员,尝试刷新缓存
+ if (!member) {
+ members = (await this.refreshGroupMemberCache(groupCodeStr, true));
+ member = getMember();
+ }
+ return member;
+ }
+
+ async getGroupRecommendContactArkJson (groupCode: string) {
+ return this.context.session.getGroupService().getGroupRecommendContactArkJson(groupCode);
+ }
+
+ async getJoinGroupLink (groupCode: string, needShortUrl = true, srcId = 73, additionalParam = '') {
+ return this.context.session.getGroupService().getJoinGroupLink({
+ groupCode,
+ srcId,
+ needShortUrl,
+ additionalParam,
+ });
+ }
+
+ async creatGroupFileFolder (groupCode: string, folderName: string) {
+ return this.context.session.getRichMediaService().createGroupFolder(groupCode, folderName);
+ }
+
+ async delGroupFile (groupCode: string, files: Array) {
+ return this.context.session.getRichMediaService().deleteGroupFile(groupCode, [102], files);
+ }
+
+ async delGroupFileFolder (groupCode: string, folderId: string) {
+ return this.context.session.getRichMediaService().deleteGroupFolder(groupCode, folderId);
+ }
+
+ async transGroupFile (groupCode: string, fileId: string) {
+ return this.context.session.getRichMediaService().transGroupFile(groupCode, fileId);
+ }
+
+ async addGroupEssence (groupCode: string, msgId: string) {
+ const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
+ chatType: 2,
+ guildId: '',
+ peerUid: groupCode,
+ }, msgId, 1, false);
+ if (!MsgData.msgList[0]) {
+ throw new Error('消息不存在');
+ }
+ const param = {
+ groupCode,
+ msgRandom: parseInt(MsgData.msgList[0].msgRandom),
+ msgSeq: parseInt(MsgData.msgList[0].msgSeq),
+ };
+ return this.context.session.getGroupService().addGroupEssence(param);
+ }
+
+ async kickMemberV2Inner (param: KickMemberV2Req) {
+ return this.context.session.getGroupService().kickMemberV2(param);
+ }
+
+ async deleteGroupBulletin (groupCode: string, noticeId: string) {
+ const psKey = await this.getQunPskey();
+ return this.context.session.getGroupService().deleteGroupBulletin(groupCode, psKey, noticeId);
+ }
+
+ async quitGroupV2 (GroupCode: string, needDeleteLocalMsg: boolean) {
+ const param = {
+ groupCode: GroupCode,
+ needDeleteLocalMsg,
+ };
+ return this.context.session.getGroupService().quitGroupV2(param);
+ }
+
+ async removeGroupEssenceBySeq (groupCode: string, msgRandom: string, msgSeq: string) {
+ const param = {
+ groupCode,
+ msgRandom: parseInt(msgRandom),
+ msgSeq: parseInt(msgSeq),
+ };
+ return this.context.session.getGroupService().removeGroupEssence(param);
+ }
+
+ async removeGroupEssence (groupCode: string, msgId: string) {
+ const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
+ chatType: 2,
+ guildId: '',
+ peerUid: groupCode,
+ }, msgId, 1, false);
+ if (!MsgData.msgList[0]) {
+ throw new Error('消息不存在');
+ }
+ const param = {
+ groupCode,
+ msgRandom: parseInt(MsgData.msgList[0].msgRandom),
+ msgSeq: parseInt(MsgData.msgList[0].msgSeq),
+ };
+ return this.context.session.getGroupService().removeGroupEssence(param);
+ }
+
+ async getSingleScreenNotifies (doubt: boolean, count: number) {
+ const [, , , notifies] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelGroupService/getSingleScreenNotifies',
+ 'NodeIKernelGroupListener/onGroupSingleScreenNotifies',
+ [
+ doubt,
+ '',
+ count,
+ ]
+ );
+ return notifies;
+ }
+
+ async searchGroup (groupCode: string) {
+ const [, ret] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelSearchService/searchGroup',
+ 'NodeIKernelSearchListener/onSearchGroupResult',
+ [{
+ keyWords: groupCode,
+ groupNum: 25,
+ exactSearch: false,
+ penetrate: '',
+ }],
+ (ret) => ret.result === 0,
+ (params) => !!params.groupInfos.find(g => g.groupCode === groupCode),
+ 1,
+ 5000
+ );
+ return ret.groupInfos.find(g => g.groupCode === groupCode);
+ }
+
+ async getGroupMemberEx (groupCode: string, uid: string, forced: boolean = false, retry: number = 2) {
+ const data = await solveAsyncProblem((eventWrapper: NTEventWrapper, GroupCode: string, uid: string, forced = false) => {
+ return eventWrapper.callNormalEventV2(
+ 'NodeIKernelGroupService/getMemberInfo',
+ 'NodeIKernelGroupListener/onMemberInfoChange',
+ [groupCode, [uid], forced],
+ (ret) => ret.result === 0,
+ (params: string, _: any, members: Map) => params === GroupCode && members.size > 0 && members.has(uid),
+ 1,
+ forced ? 2500 : 250
+ );
+ }, this.core.eventWrapper, groupCode, uid, forced);
+ if (data && data[3] instanceof Map && data[3].has(uid)) {
+ return data[3].get(uid);
+ }
+ if (retry > 0) {
+ const trydata = await this.getGroupMemberEx(groupCode, uid, true, retry - 1) as GroupMember | undefined;
+ if (trydata) return trydata;
+ }
+ return undefined;
+ }
+
+ async getGroupFileCount (groupCodes: Array) {
+ return this.context.session.getRichMediaService().batchGetGroupFileCount(groupCodes);
+ }
+
+ // getGroupFileList also carries the group's used/total space in its call
+ // result's `groupSpaceResult`; a single-item fetch is enough to read it.
+ async getGroupFileSpace (groupCode: string) {
+ const [result] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelRichMediaService/getGroupFileList',
+ 'NodeIKernelMsgListener/onGroupFileInfoUpdate',
+ [
+ groupCode,
+ { sortType: 1, fileCount: 1, startIndex: 0, sortOrder: 1, showOnlinedocFolder: 0 },
+ ],
+ () => true,
+ () => true,
+ 1,
+ 5000
+ );
+ return result?.groupSpaceResult;
+ }
+
+ async getArkJsonGroupShare (groupCode: string) {
+ const ret = await this.core.eventWrapper.callNoListenerEvent(
+ 'NodeIKernelGroupService/getGroupRecommendContactArkJson',
+ groupCode
+ ) as GeneralCallResult & { arkJson: string; };
+ return ret.arkJson;
+ }
+
+ async uploadGroupBulletinPic (groupCode: string, imageurl: string) {
+ const _Pskey = await this.getQunPskey();
+ return this.context.session.getGroupService().uploadGroupBulletinPic(groupCode, _Pskey, imageurl);
+ }
+
+ async handleGroupRequest (doubt: boolean, notify: GroupNotify, operateType: NTGroupRequestOperateTypes, reason?: string) {
+ return this.context.session.getGroupService().operateSysNotify(
+ doubt,
+ {
+ operateType,
+ targetMsg: {
+ seq: notify.seq, // 通知序列号
+ type: notify.type,
+ groupCode: notify.group.groupCode,
+ postscript: reason ?? ' ', // 仅传空值可能导致处理失败,故默认给个空格
+ },
+ });
+ }
+
+ async quitGroup (groupCode: string) {
+ return this.context.session.getGroupService().quitGroup(groupCode);
+ }
+
+ async kickMember (groupCode: string, kickUids: string[], refuseForever: boolean = false, kickReason: string = '') {
+ return this.context.session.getGroupService().kickMember(groupCode, kickUids, refuseForever, kickReason);
+ }
+
+ async banMember (groupCode: string, memList: Array<{ uid: string, timeStamp: number; }>) {
+ // timeStamp为秒数, 0为解除禁言
+ return this.context.session.getGroupService().setMemberShutUp(groupCode, memList);
+ }
+
+ async banGroup (groupCode: string, shutUp: boolean) {
+ return this.context.session.getGroupService().setGroupShutUp(groupCode, shutUp);
+ }
+
+ async setMemberCard (groupCode: string, memberUid: string, cardName: string) {
+ return this.context.session.getGroupService().modifyMemberCardName(groupCode, memberUid, cardName);
+ }
+
+ async setMemberRole (groupCode: string, memberUid: string, role: NTGroupMemberRole) {
+ return this.context.session.getGroupService().modifyMemberRole(groupCode, memberUid, role);
+ }
+
+ async setGroupName (groupCode: string, groupName: string, isNormalMember: boolean = false) {
+ return this.context.session.getGroupService().modifyGroupName(groupCode, groupName, isNormalMember);
+ }
+
+ async publishGroupBulletin (groupCode: string, content: string, picInfo: {
+ id: string,
+ width: number,
+ height: number;
+ } | undefined = undefined, pinned: number = 0, confirmRequired: number = 0) {
+ const psKey = await this.getQunPskey();
+ // text是content内容url编码
+ const data = {
+ text: encodeURI(content),
+ picInfo,
+ oldFeedsId: '',
+ pinned,
+ confirmRequired,
+ };
+ return this.context.session.getGroupService().publishGroupBulletin(groupCode, psKey!, data);
+ }
+
+ async getGroupRemainAtTimes (groupCode: string) {
+ return this.context.session.getGroupService().getGroupRemainAtTimes(groupCode);
+ }
+
+ async getMemberExtInfo (groupCode: string, uin: string) {
+ return this.context.session.getGroupService().getMemberExtInfo(
+ {
+ groupCode,
+ sourceType: MemberExtSourceType.TITLETYPE,
+ beginUin: '0',
+ dataTime: '0',
+ uinList: [uin],
+ uinNum: '',
+ seq: '',
+ groupType: '',
+ richCardNameVer: '',
+ memberExtFilter: {
+ memberLevelInfoUin: 1,
+ memberLevelInfoPoint: 1,
+ memberLevelInfoActiveDay: 1,
+ memberLevelInfoLevel: 1,
+ memberLevelInfoName: 1,
+ levelName: 1,
+ dataTime: 1,
+ userShowFlag: 1,
+ sysShowFlag: 1,
+ timeToUpdate: 1,
+ nickName: 1,
+ specialTitle: 1,
+ levelNameNew: 1,
+ userShowFlagNew: 1,
+ msgNeedField: 1,
+ cmdUinFlagExt3Grocery: 1,
+ memberIcon: 1,
+ memberInfoSeq: 1,
+ },
+ }
+ );
+ }
+}
diff --git a/packages/snowcat-core/apis/index.ts b/packages/snowcat-core/apis/index.ts
new file mode 100644
index 0000000..8dba6ac
--- /dev/null
+++ b/packages/snowcat-core/apis/index.ts
@@ -0,0 +1,12 @@
+export * from './file';
+export * from './friend';
+export * from './group';
+export * from './msg';
+export * from './user';
+export * from './webapi';
+export * from './system';
+export * from './packet';
+export * from './file';
+export * from './online';
+export * from './flash';
+export * from './database';
diff --git a/packages/snowcat-core/apis/msg.ts b/packages/snowcat-core/apis/msg.ts
new file mode 100644
index 0000000..fc5ab58
--- /dev/null
+++ b/packages/snowcat-core/apis/msg.ts
@@ -0,0 +1,325 @@
+import { ChatType, GetFileListParam, Peer, RawMessage, SendMessageElement, SendStatusType } from '@/snowcat-core/types';
+import { GroupFileInfoUpdateItem, InstanceContext, SnowCatCore, NodeIKernelMsgService } from '@/snowcat-core/index';
+import { GeneralCallResult } from '@/snowcat-core/services/common';
+
+export class NTQQMsgApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ }
+
+ async clickInlineKeyboardButton (...params: Parameters) {
+ return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
+ }
+
+ getMsgByClientSeqAndTime (peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) {
+ // https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
+ return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
+ }
+
+ async getAioFirstViewLatestMsgs (peer: Peer, MsgCount: number) {
+ return this.context.session.getMsgService().getAioFirstViewLatestMsgs(peer, MsgCount);
+ }
+
+ async sendShowInputStatusReq (peer: Peer, eventType: number) {
+ return this.context.session.getMsgService().sendShowInputStatusReq(peer.chatType, eventType, peer.peerUid);
+ }
+
+ async getSourceOfReplyMsgV2 (peer: Peer, clientSeq: string, time: string) {
+ return this.context.session.getMsgService().getSourceOfReplyMsgV2(peer, clientSeq, time);
+ }
+
+ async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string = '', count: number = 20) {
+ // 注意此处emojiType 可选值一般为1-2 2好像是unicode表情dec值 大部分情况 Taged Mlikiowa
+ return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, cookie, false, count);
+ }
+
+ async setEmojiLike (peer: Peer, msgSeq: string, emojiId: string, set: boolean = true) {
+ emojiId = emojiId.toString();
+ return this.context.session.getMsgService().setMsgEmojiLikes(peer, msgSeq, emojiId, emojiId.length > 3 ? '2' : '1', set);
+ }
+
+ async getMultiMsg (peer: Peer, rootMsgId: string, parentMsgId: string): Promise {
+ return this.context.session.getMsgService().getMultiMsg(peer, rootMsgId, parentMsgId);
+ }
+
+ async ForwardMsg (peer: Peer, msgIds: string[]) {
+ return this.context.session.getMsgService().forwardMsg(msgIds, peer, [peer], new Map());
+ }
+
+ async getMsgsByMsgId (peer: Peer | undefined, msgIds: string[] | undefined) {
+ if (!peer) throw new Error('peer is not allowed');
+ if (!msgIds) throw new Error('msgIds is not allowed');
+ // MliKiowa: 参数不合规会导致NC异常崩溃 原因是TX未对进入参数判断 对应Android标记@NotNull AndroidJADX分析可得
+ return await this.context.session.getMsgService().getMsgsByMsgId(peer, msgIds);
+ }
+
+ async getSingleMsg (peer: Peer, seq: string) {
+ return await this.context.session.getMsgService().getSingleMsg(peer, seq);
+ }
+
+ async fetchFavEmojiList (num: number) {
+ return this.context.session.getMsgService().fetchFavEmojiList('', num, true, true);
+ }
+
+ async addFavEmoji (req: Parameters[0]) {
+ return this.context.session.getMsgService().addFavEmoji(req);
+ }
+
+ async deleteFavEmoji (ids: string[]) {
+ return this.context.session.getMsgService().deleteFavEmoji(ids);
+ }
+
+ async modifyFavEmojiDesc (items: Parameters[0]) {
+ return this.context.session.getMsgService().modifyFavEmojiDesc(items);
+ }
+
+ async queryMsgsWithFilterExWithSeq (peer: Peer, msgSeq: string) {
+ return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
+ chatInfo: peer,
+ // searchFields: 3,
+ filterMsgType: [],
+ filterSendersUid: [],
+ filterMsgToTime: '0',
+ filterMsgFromTime: '0',
+ isReverseOrder: false,
+ isIncludeCurrent: true,
+ pageLimit: 1,
+ });
+ }
+
+ async queryMsgsWithFilterExWithSeqV2 (peer: Peer, msgSeq: string, MsgTime: string, SendersUid: string[]) {
+ return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
+ chatInfo: peer,
+ filterMsgType: [],
+ // searchFields: 3,
+ filterSendersUid: SendersUid,
+ filterMsgToTime: MsgTime,
+ filterMsgFromTime: MsgTime,
+ isReverseOrder: false,
+ isIncludeCurrent: true,
+ pageLimit: 1,
+ });
+ }
+
+ async queryMsgsWithFilterExWithSeqV3 (peer: Peer, msgSeq: string, SendersUid: string[]) {
+ return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
+ chatInfo: peer,
+ filterMsgType: [],
+ filterSendersUid: SendersUid,
+ filterMsgToTime: '0',
+ filterMsgFromTime: '0',
+ isReverseOrder: false,
+ // searchFields: 3,
+ isIncludeCurrent: true,
+ pageLimit: 1,
+ });
+ }
+
+ async queryFirstMsgBySeq (peer: Peer, msgSeq: string) {
+ return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
+ chatInfo: peer,
+ filterMsgType: [],
+ filterSendersUid: [],
+ filterMsgToTime: '0',
+ // searchFields: 3,
+ filterMsgFromTime: '0',
+ isReverseOrder: true,
+ isIncludeCurrent: true,
+ pageLimit: 1,
+ });
+ }
+
+ // 客户端还在用别慌
+ async getMsgsBySeqAndCount (peer: Peer, seq: string, count: number, desc: boolean, isReverseOrder: boolean) {
+ return await this.context.session.getMsgService().getMsgsBySeqAndCount(peer, seq, count, desc, isReverseOrder);
+ }
+
+ async getMsgExBySeq (peer: Peer, msgSeq: string) {
+ const DateNow = Math.floor(Date.now() / 1000);
+ const filterMsgFromTime = (DateNow - 300).toString();
+ const filterMsgToTime = DateNow.toString();
+ return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
+ chatInfo: peer, // 此处为Peer 为关键查询参数 没有啥也没有 by mlik iowa
+ filterMsgType: [],
+ filterSendersUid: [],
+ // searchFields: 3,
+ filterMsgToTime,
+ filterMsgFromTime,
+ isReverseOrder: false,
+ isIncludeCurrent: true,
+ pageLimit: 100,
+ });
+ }
+
+ async queryFirstMsgBySender (peer: Peer, SendersUid: string[]) {
+ return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
+ chatInfo: peer,
+ filterMsgType: [],
+ filterSendersUid: SendersUid,
+ // searchFields: 3,
+ filterMsgToTime: '0',
+ filterMsgFromTime: '0',
+ isReverseOrder: true,
+ isIncludeCurrent: true,
+ pageLimit: 20000,
+ });
+ }
+
+ async setMsgRead (peer: Peer) {
+ return this.context.session.getMsgService().setMsgRead(peer);
+ }
+
+ async getGroupFileList (GroupCode: string, params: GetFileListParam) {
+ const item: GroupFileInfoUpdateItem[] = [];
+ let index = params.startIndex;
+ while (true) {
+ params.startIndex = index;
+ const [, groupFileListResult] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelRichMediaService/getGroupFileList',
+ 'NodeIKernelMsgListener/onGroupFileInfoUpdate',
+ [
+ GroupCode,
+ params,
+ ],
+ () => true,
+ () => true, // 应当通过 groupFileListResult 判断
+ 1,
+ 5000
+ );
+ if (!groupFileListResult?.item?.length) break;
+ item.push(...groupFileListResult.item);
+ if (groupFileListResult.isEnd) break;
+ if (item.length === params.fileCount) break;
+ index = groupFileListResult.nextIndex;
+ }
+ return item;
+ }
+
+ async getMsgHistory (peer: Peer, msgId: string, count: number, isReverseOrder: boolean = false) {
+ // 消息时间从旧到新
+ return this.context.session.getMsgService().getMsgsIncludeSelf(peer, msgId, count, isReverseOrder);
+ }
+
+ async recallMsg (peer: Peer, msgId: string) {
+ return await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelMsgService/recallMsg',
+ 'NodeIKernelMsgListener/onMsgInfoListUpdate',
+ [peer, [msgId]],
+ () => true,
+ (updatedList) => updatedList.find(m => m.msgId === msgId && m.recallTime !== '0') !== undefined,
+ 1,
+ 1000
+ );
+ }
+
+ async PrepareTempChat (toUserUid: string, GroupCode: string, nickname: string) {
+ return this.context.session.getMsgService().prepareTempChat({
+ chatType: ChatType.KCHATTYPETEMPC2CFROMGROUP,
+ peerUid: toUserUid,
+ peerNickname: nickname,
+ fromGroupCode: GroupCode,
+ sig: '',
+ selfPhone: '',
+ selfUid: this.core.selfInfo.uid,
+ gameSession: {
+ nickname: '',
+ gameAppId: '',
+ selfTinyId: '',
+ peerRoleId: '',
+ peerOpenId: '',
+ },
+ });
+ }
+
+ async getTempChatInfo (chatType: ChatType, peerUid: string) {
+ return this.context.session.getMsgService().getTempChatInfo(chatType, peerUid);
+ }
+
+ async sendMsg (peer: Peer, msgElements: SendMessageElement[], timeout = 10000) {
+ // 唉?!我有个想法
+ if (peer.chatType === ChatType.KCHATTYPETEMPC2CFROMGROUP && peer.guildId && peer.guildId !== '') {
+ const member = await this.core.apis.GroupApi.getGroupMember(peer.guildId, peer.peerUid);
+ if (member) {
+ await this.PrepareTempChat(peer.peerUid, peer.guildId, member.nick);
+ }
+ }
+ const msgId = await this.generateMsgUniqueId(peer.chatType);
+ peer.guildId = msgId;
+ const [, msgList] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelMsgService/sendMsg',
+ 'NodeIKernelMsgListener/onMsgInfoListUpdate',
+ [
+ '0',
+ peer,
+ msgElements,
+ new Map(),
+ ],
+ (ret) => ret.result === 0,
+ msgRecords => {
+ for (const msgRecord of msgRecords) {
+ if (msgRecord.guildId === msgId && msgRecord.sendStatus === SendStatusType.KSEND_STATUS_SUCCESS) {
+ return true;
+ }
+ }
+ return false;
+ },
+ 1,
+ timeout
+ );
+ return msgList.find(msgRecord => msgRecord.guildId === msgId);
+ }
+
+ async generateMsgUniqueId (chatType: number) {
+ return this.context.session.getMsgService().generateMsgUniqueId(chatType, this.context.session.getMSFService().getServerTime());
+ }
+
+ async forwardMsg (srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
+ return this.context.session.getMsgService().forwardMsg(msgIds, srcPeer, [destPeer], new Map());
+ }
+
+ async multiForwardMsg (srcPeer: Peer, destPeer: Peer, msgIds: string[]): Promise {
+ const msgInfos = msgIds.map(id => {
+ return { msgId: id, senderShowName: this.core.selfInfo.nick };
+ });
+ const [, msgList] = await this.core.eventWrapper.callNormalEventV2(
+ 'NodeIKernelMsgService/multiForwardMsgWithComment',
+ 'NodeIKernelMsgListener/onMsgInfoListUpdate',
+ [
+ msgInfos,
+ srcPeer,
+ destPeer,
+ [],
+ new Map(),
+ ],
+ () => true,
+ (msgRecords) => msgRecords.some(
+ msgRecord => msgRecord.peerUid === destPeer.peerUid &&
+ msgRecord.senderUid === this.core.selfInfo.uid
+ )
+ );
+ for (const msg of msgList) {
+ const arkElement = msg.elements.find(ele => ele.arkElement);
+ if (!arkElement) {
+ continue;
+ }
+ const forwardData: { app: string; } = JSON.parse(arkElement.arkElement?.bytesData ?? '');
+ if (forwardData.app !== 'com.tencent.multimsg') {
+ continue;
+ }
+ if (msg.peerUid === destPeer.peerUid && msg.senderUid === this.core.selfInfo.uid) {
+ return msg;
+ }
+ }
+ throw new Error('转发消息超时');
+ }
+
+ async markAllMsgAsRead () {
+ return this.context.session.getMsgService().setAllC2CAndGroupMsgRead();
+ }
+}
diff --git a/packages/snowcat-core/apis/online.ts b/packages/snowcat-core/apis/online.ts
new file mode 100644
index 0000000..e75dbce
--- /dev/null
+++ b/packages/snowcat-core/apis/online.ts
@@ -0,0 +1,241 @@
+import { InstanceContext, SnowCatCore } from '@/snowcat-core';
+import { Peer } from '@/snowcat-core/types';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { GeneralCallResultStatus } from '@/snowcat-core/services/common';
+import { sleep } from '@/snowcat-common/src/helper';
+
+const normalizePath = (p: string) => path.normalize(p).toLowerCase();
+
+export class NTQQOnlineApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ 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 {
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`[SnowCat] 文件不存在: ${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: '[SnowCat] Send Online File Timeout: Message not found in history.',
+ };
+ }
+
+ /**
+ * 发送在线文件夹
+ * @param peer
+ * @param folderPath
+ * @param folderName
+ */
+ async sendOnlineFolder (peer: Peer, folderPath: string, folderName?: string): Promise {
+ 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: '[SnowCat] Send Online Folder Timeout: Message not found in history.',
+ };
+ }
+
+ /**
+ * 获取好友的在线文件消息
+ * @param peer
+ */
+ async getOnlineFileMsg (peer: Peer): Promise {
+ const msgService = this.context.session.getMsgService();
+ return await msgService.getOnlineFileMsgs(peer);
+ }
+
+ /**
+ * 取消在线文件的发送
+ * @param peer
+ * @param msgId
+ */
+ async cancelMyOnlineFileMsg (peer: Peer, msgId: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ const msgService = this.context.session.getMsgService();
+ await msgService.switchToOfflineSendMsg(peer, msgId);
+ }
+}
diff --git a/packages/snowcat-core/apis/packet.ts b/packages/snowcat-core/apis/packet.ts
new file mode 100644
index 0000000..cc3bcbd
--- /dev/null
+++ b/packages/snowcat-core/apis/packet.ts
@@ -0,0 +1,93 @@
+import * as os from 'os';
+import offset from '@/snowcat-core/external/napi2native.json';
+import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
+import { PacketClientSession } from '@/snowcat-core/packet/clientSession';
+import { snowCatVersion } from 'snowcat-common/src/version';
+import { LogWrapper } from '../helper/log';
+
+interface OffsetType {
+ [key: string]: {
+ recv: string;
+ send: string;
+ };
+}
+
+const typedOffset: OffsetType = offset;
+
+export class NTQQPacketApi {
+ context: InstanceContext;
+ core: SnowCatCore;
+ logger: LogWrapper;
+ qqVersion: string | undefined;
+ pkt!: PacketClientSession;
+ errStack: string[] = [];
+ packetStatus: boolean = false;
+
+ constructor (context: InstanceContext, core: SnowCatCore) {
+ this.context = context;
+ this.core = core;
+ this.logger = core.context.logger;
+ }
+
+ async initApi () {
+ this.packetStatus = (await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVersion())
+ .then((result) => {
+ return result;
+ })
+ .catch((err) => {
+ this.logger.logError(err);
+ this.pushError(err);
+ return false;
+ })) && this.pkt?.available;
+ }
+
+ get available (): boolean {
+ return this.pkt?.available ?? false;
+ }
+
+ get clientLogStack () {
+ return [this.pkt?.clientLogStack, ...this.errStack].filter(Boolean).join('\n');
+ }
+
+ private pushError (error: unknown) {
+ if (error instanceof Error) {
+ this.errStack.push(error.stack || error.message);
+ return;
+ }
+ this.errStack.push(String(error));
+ }
+
+ async InitSendPacket (qqVer: string) {
+ this.qqVersion = qqVer;
+ const table = typedOffset[qqVer + '-' + os.arch()];
+ if (!table) {
+ const err = `[Core] [Packet] PacketBackend 不支持当前QQ版本架构:${qqVer}-${os.arch()},
+ 请参照 https://github.com/NapNeko/SnowCatQQ/releases/tag/v${snowCatVersion} 配置正确的QQ版本!`;
+ this.logger.logError(err);
+ this.errStack.push(err);
+ return false;
+ }
+ if (this.core.configLoader.configData.packetBackend === 'disable') {
+ const err = '[Core] [Packet] 已禁用PacketBackend,NapCat.Packet将不会加载!';
+ this.logger.logError(err);
+ this.errStack.push(err);
+ return false;
+ }
+ this.pkt = new PacketClientSession(this.core);
+ await this.pkt.init(process.pid, table.recv, table.send);
+ if (!this.pkt.available) {
+ const err = '[Core] [Packet] NativePacketClient 初始化失败,PacketBackend 发包能力不可用!';
+ this.logger.logError(err);
+ this.errStack.push(err);
+ return false;
+ }
+ try {
+ await this.pkt.operation.FetchRkey(3000);
+ } catch (error) {
+ this.logger.logError('测试Packet状态异常', error);
+ this.pushError(error);
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/packages/snowcat-core/apis/qzone-public-web.ts b/packages/snowcat-core/apis/qzone-public-web.ts
new file mode 100644
index 0000000..d16a5e2
--- /dev/null
+++ b/packages/snowcat-core/apis/qzone-public-web.ts
@@ -0,0 +1,469 @@
+/**
+ * SnowCat · QQ空间公共空间(空友推荐)Web 线 —— 纯数据构造与请求封装
+ *
+ * SnowLuma v2.2.1 (packages/protocol/src/web/qzone-public.ts) 移植改造:
+ * - 走 h5.qzone.qq.com/http2rpc gotrpc 通道(trpc 服务 + x-oidb 命令头)
+ * - cookie 串形态对齐 NapCat getQzoneAuth(p_uin/p_skey/skey/uin)
+ * - cursor 编解码绑 uin 防串号
+ */
+
+export type QzonePublicContent = string | Record[];
+
+export interface QzonePublicFeedsPage {
+ feeds: Record[];
+ has_more: boolean;
+ next_cursor: string | null;
+ [key: string]: unknown;
+}
+
+interface PublicFeedCursor {
+ version: 1;
+ uin: string;
+ refresh_id: string;
+ attach_info: string;
+ last_client_feed_union_id: Record | null;
+}
+
+const reader = 'trpc.qzone.feeds_reader.FeedsReader';
+const interaction = 'trpc.qzone.interaction_writer.InteractionWriter';
+const upvoteService = 'trpc.public_qzone.interaction.UFOComment';
+const relation = 'trpc.public_qzone.relation.RelationService';
+
+function isObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** http2rpc 统一请求封装:retcode!=0或缺 data 抛错。 */
+export async function qzonePublicRequest(
+ cookie: string,
+ bkn: string,
+ service: string,
+ method: string,
+ oidb: string,
+ params: Record,
+): Promise> {
+ const [command, serviceType] = oidb.split('_');
+ const { RequestUtil } = await import('snowcat-common/src/request');
+ const result = await RequestUtil.HttpGetJson(
+ `https://h5.qzone.qq.com/http2rpc/gotrpc/v1/${service}/${method}?g_tk=${bkn}`,
+ 'POST',
+ params,
+ {
+ Cookie: cookie,
+ 'Content-Type': 'application/json',
+ Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
+ 'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
+ 'x-oidb': JSON.stringify({
+ uint32_command: command,
+ uint32_service_type: serviceType,
+ }),
+ },
+ );
+
+ if (!isObject(result) || result['retcode'] !== 0 || !isObject(result['data'])) {
+ const code = isObject(result) ? result['retcode'] : 'invalid response';
+ const message =
+ isObject(result) && typeof result['message'] === 'string' ? result['message'] : '';
+ throw new Error(`qzone public ${method} failed: retcode=${code} ${message}`.trim());
+ }
+
+ return result['data'];
+}
+
+/** 动态寻址参数:从 feed.cell_common.union_id 提取(完整动态才有)。 */
+export function qzonePublicFeedParams(feed: Record): Record {
+ const common = feed['cell_common'];
+
+ if (!isObject(common) || !isObject(common['union_id']) || !Object.keys(common['union_id']).length) {
+ throw new Error('空友动态缺少 cell_common.union_id,请使用推荐列表或详情返回的完整动态');
+ }
+
+ return {
+ union_id: common['union_id'],
+ ...(feed['cell_feed_transfer'] === undefined
+ ? {}
+ : { cell_feed_transfer: feed['cell_feed_transfer'] }),
+ };
+}
+
+function encodeCursor(state: PublicFeedCursor): string {
+ const cursor = Buffer.from(JSON.stringify(state)).toString('base64url');
+ if (cursor.length > 65_536) {
+ throw new Error('空友推荐分页游标过大');
+ }
+ return cursor;
+}
+
+function decodeCursor(cursor: string, uin: string): PublicFeedCursor {
+ if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) {
+ throw new Error('无效的空友推荐 cursor');
+ }
+ let value: unknown;
+ try {
+ const bytes = Buffer.from(cursor, 'base64url');
+ if (bytes.toString('base64url') !== cursor) {
+ throw new Error('non-canonical encoding');
+ }
+ value = JSON.parse(bytes.toString('utf8'));
+ } catch {
+ throw new Error('无效的空友推荐 cursor');
+ }
+ if (
+ !isObject(value) ||
+ value['version'] !== 1 ||
+ value['uin'] !== uin ||
+ typeof value['refresh_id'] !== 'string' ||
+ !value['refresh_id'] ||
+ typeof value['attach_info'] !== 'string' ||
+ !(value['last_client_feed_union_id'] === null || isObject(value['last_client_feed_union_id']))
+ ) {
+ throw new Error('空友推荐 cursor 无效或不属于当前账号');
+ }
+ return {
+ version: 1,
+ uin,
+ refresh_id: value['refresh_id'],
+ attach_info: value['attach_info'],
+ last_client_feed_union_id: value['last_client_feed_union_id'] as Record | null,
+ };
+}
+
+/** 查询账号在空友各入口的白名单能力(entrances)。 */
+export async function getQzonePublicCapabilities(
+ cookie: string,
+ bkn: string,
+ selfUin: string,
+): Promise> {
+ const data = await qzonePublicRequest(
+ cookie, bkn,
+ 'trpc.public_qzone.entrance_ctrl.EntranceCtrl',
+ 'BatchEntranceWhitelist',
+ '0x9741_0',
+ { uins: [selfUin], entrance_types: [1, 15] },
+ );
+ const result = Array.isArray(data['results'])
+ ? (data['results'] as unknown[]).find(
+ (entry) => isObject(entry) && String(entry['uin']) === selfUin,
+ )
+ : undefined;
+
+ if (!isObject(result) || !isObject(result['entrances'])) {
+ throw new Error('qzone public BatchEntranceWhitelist 缺少当前账号的 entrances');
+ }
+ return result['entrances'];
+}
+
+/** 空友推荐动态流(cursor 续翻;广告条不进续翻锚点)。 */
+export async function getQzonePublicFeeds(
+ cookie: string,
+ bkn: string,
+ selfUin: string,
+ cursor?: string,
+): Promise {
+ const state: PublicFeedCursor =
+ cursor === undefined
+ ? {
+ version: 1,
+ uin: selfUin,
+ refresh_id: `${selfUin}_${Date.now()}`,
+ attach_info: '',
+ last_client_feed_union_id: null,
+ }
+ : decodeCursor(cursor, selfUin);
+ const data = await qzonePublicRequest(cookie, bkn, reader, 'GetPublicRecomFeeds', '0x9a38_0', {
+ refresh_id: state.refresh_id,
+ ...(cursor === undefined
+ ? {}
+ : {
+ attach_info: state.attach_info,
+ ...(state.last_client_feed_union_id === null
+ ? {}
+ : { last_client_feed_union_id: state.last_client_feed_union_id }),
+ }),
+ });
+
+ if (!Array.isArray(data['feeds']) || typeof data['has_more'] !== 'boolean') {
+ throw new Error('qzone public GetPublicRecomFeeds 分页响应异常');
+ }
+
+ const feeds = (data['feeds'] as unknown[]).map((entry) => {
+ if (!isObject(entry) || !isObject(entry['feed'])) {
+ throw new Error('qzone public GetPublicRecomFeeds 动态响应异常');
+ }
+ return entry['feed'] as Record;
+ });
+ let lastUnion = state.last_client_feed_union_id;
+
+ // web 客户端跨页携带最后一条非广告动态(含纯广告页)
+ for (const feed of feeds) {
+ const common = feed['cell_common'];
+ if (isObject(common) && common['feed_type'] !== 3 && isObject(common['union_id'])) {
+ lastUnion = common['union_id'] as Record;
+ }
+ }
+
+ let nextCursor: string | null = null;
+ if (data['has_more']) {
+ if (typeof data['attach_info'] !== 'string') {
+ throw new Error('qzone public GetPublicRecomFeeds 缺少 attach_info');
+ }
+ nextCursor = encodeCursor({
+ ...state,
+ attach_info: data['attach_info'],
+ last_client_feed_union_id: lastUnion,
+ });
+ }
+
+ return { ...data, feeds, has_more: data['has_more'] as boolean, next_cursor: nextCursor };
+}
+
+/** 空友单条动态详情(含评论首屏)。 */
+export function getQzonePublicFeedDetail(
+ cookie: string,
+ bkn: string,
+ feed: Record,
+): Promise> {
+ return qzonePublicRequest(cookie, bkn, reader, 'GetFeedDetail', '0x97b1_1', {
+ ...qzonePublicFeedParams(feed),
+ feed_action_domain_type: 3,
+ from_page: 1,
+ });
+}
+
+/** 空友评论分页(cursor 用详情/上页返回的 attach_info)。 */
+export function getQzonePublicComments(
+ cookie: string,
+ bkn: string,
+ feed: Record,
+ cursor: string,
+): Promise> {
+ return qzonePublicRequest(cookie, bkn, reader, 'GetComment', '0x9bfd_1', {
+ ...qzonePublicFeedParams(feed),
+ attach_info: cursor,
+ });
+}
+
+/** 空友楼中楼回复分页。 */
+export function getQzonePublicReplies(
+ cookie: string,
+ bkn: string,
+ feed: Record,
+ comment: Record,
+ cursor: string,
+ pageSize: number,
+): Promise> {
+ return qzonePublicRequest(cookie, bkn, reader, 'GetReplies', '0x9bfe_1', {
+ ...qzonePublicFeedParams(feed),
+ cur_comment: comment,
+ comment_attach_info: '',
+ reply_attach_info: cursor || comment['reply_attach_info'] || '',
+ page_size: pageSize,
+ });
+}
+
+/** 空友点赞/取消赞(UFOComment Upvote/Downvote)。 */
+export async function setQzonePublicUpvote(
+ cookie: string,
+ bkn: string,
+ feed: Record