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 构建中', + '', + '![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)', + '', + '
', + '', + '---', + '', + '## 📦 构建目标', + '', + '| 包名 | 状态 | 说明 |', + '| :--- | :---: | :--- |', + ...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 = '![Success](https://img.shields.io/badge/状态-构建成功-success?style=for-the-badge&logo=github-actions&logoColor=white)'; + headerTitle = '# ✅ NapCat 构建成功'; + } else if (anyCancelled && !anyFailure) { + statusBadge = '![Cancelled](https://img.shields.io/badge/状态-已取消-lightgrey?style=for-the-badge&logo=github-actions&logoColor=white)'; + headerTitle = '# ⚪ NapCat 构建已取消'; + } else { + statusBadge = '![Failed](https://img.shields.io/badge/状态-构建失败-critical?style=for-the-badge&logo=github-actions&logoColor=white)'; + headerTitle = '# ❌ NapCat 构建失败'; + } + + const downloadLink = (target: BuildTarget) => { + if (target.status !== 'success') return '—'; + if (target.downloadUrl) { + return `[📥 下载](${target.downloadUrl})`; + } + return `[📥 下载](${runUrl}#artifacts)`; + }; + + const lines: string[] = [ + COMMENT_MARKER, + '', + '
', + '', + 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 测试镜像构建中', + '', + '![Building](https://img.shields.io/badge/Docker-构建中-yellow?style=for-the-badge&logo=docker&logoColor=white)', + '', + '
', + '', + '---', + '', + '## 📋 构建信息', + '', + '| 项目 | 值 |', + '| :--- | :--- |', + `| 📝 提交 | \`${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 + ? '![Success](https://img.shields.io/badge/Docker-构建成功-success?style=for-the-badge&logo=docker&logoColor=white)' + : '![Failed](https://img.shields.io/badge/Docker-构建失败-critical?style=for-the-badge&logo=docker&logoColor=white)'; + 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 +
+ +# 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 | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) | +|:-:|:-:|:-:|:-:| + +| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.top/) | [![NapCat.Top](https://img.shields.io/badge/docs%20on-NapCat.Top-red)](https://napcat.top/) | +|:-:|:-:|:-:|:-:| + +| QQ Group | [![NapCat Family Group 4](https://img.shields.io/badge/NapCat%20Family%20Group%204-Join-blue)](https://qm.qq.com/q/E4nfkGD6oK) | [![NapCat Family Group 3](https://img.shields.io/badge/NapCat%20Family%20Group%203-Join-blue)](https://qm.qq.com/q/XyiyGPqa42) | [![NapCat Family Group 2](https://img.shields.io/badge/NapCat%20Family%20Group%202-Join-blue)](https://qm.qq.com/q/gq18RH7o7S) | [![NapCat Family Group 1](https://img.shields.io/badge/NapCat%20Family%20Group%201-Join-blue)](https://qm.qq.com/q/VwpnklcXqo) | +|:-:|:-:|:-:|:-:|:-:| + +| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-napcatqq-blue)](https://t.me/napcatqq) | +|:-:|:-:| + +| DeepWiki | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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, + enable: boolean, +): Promise> { + const data = await qzonePublicRequest( + cookie, bkn, + upvoteService, + enable ? 'Upvote' : 'Downvote', + enable ? '0x9d18_1' : '0x9d19_1', + qzonePublicFeedParams(feed), + ); + if (!data['upvote']) { + throw new Error('qzone public upvote 响应缺少 upvote'); + } + return data; +} + +async function setLike( + cookie: string, + bkn: string, + feed: Record, + likeKey: unknown, + likeType: number, + enable: boolean, +): Promise> { + if (typeof likeKey !== 'string' || !likeKey) { + throw new Error('空友点赞目标缺少 like_key'); + } + const data = await qzonePublicRequest( + cookie, bkn, + interaction, + enable ? 'DoLike' : 'UnLike', + enable ? '0x9699_1' : '0x969c_1', + { + ...qzonePublicFeedParams(feed), + like_key: likeKey, + like_type: likeType, + }, + ); + if (!data['like']) { + throw new Error('qzone public like 响应缺少 like'); + } + return data; +} + +/** 空友动态点赞/取消赞(like_type=1,key 取 cell_common.cur_like_key)。 */ +export function setQzonePublicLike( + cookie: string, + bkn: string, + feed: Record, + enable: boolean, +): Promise> { + const common = feed['cell_common']; + return setLike(cookie, bkn, feed, isObject(common) ? common['cur_like_key'] : undefined, 1, enable); +} + +/** 空友评论/回复点赞(like_type:评论=2,回复=3,key 取 like.id)。 */ +export function setQzonePublicCommentLike( + cookie: string, + bkn: string, + feed: Record, + comment: Record, + enable: boolean, + reply?: Record, +): Promise> { + const like = (reply ?? comment)['like']; + return setLike(cookie, bkn, feed, isObject(like) ? like['id'] : undefined, reply ? 3 : 2, enable); +} + +function richContent(content: QzonePublicContent): Record[] { + return typeof content === 'string' ? [{ type: 0, content }] : content; +} + +/** 空友发评论。user 为评论者身份对象(详情/列表响应里取,或 {uid,uin,nickname} 形态)。 */ +export async function commentQzonePublicFeed( + cookie: string, + bkn: string, + feed: Record, + user: Record, + content: QzonePublicContent, +): Promise> { + const data = await qzonePublicRequest(cookie, bkn, interaction, 'AddComment', '0x9676_1', { + ...qzonePublicFeedParams(feed), + comment: { user, content: richContent(content) }, + }); + if (!data['comment']) { + throw new Error('qzone public AddComment 响应缺少 comment'); + } + return data; +} + +/** 空友回复评论/楼中楼。点名对象自动取 reply?.user ?? comment.user。 */ +export async function replyQzonePublicFeed( + cookie: string, + bkn: string, + feed: Record, + user: Record, + comment: Record, + content: QzonePublicContent, + reply?: Record, +): Promise> { + const targetUser = reply?.['user'] ?? comment['user']; + if (!isObject(targetUser)) { + throw new Error('空友评论或回复缺少 user'); + } + const data = await qzonePublicRequest(cookie, bkn, interaction, 'AddReply', '0x9677_1', { + ...qzonePublicFeedParams(feed), + comment, + reply: { content: richContent(content), target_user: targetUser, user }, + }); + if (!data['reply']) { + throw new Error('qzone public AddReply 响应缺少 reply'); + } + return data; +} + +/** 删空友评论。 */ +export function deleteQzonePublicComment( + cookie: string, + bkn: string, + feed: Record, + comment: Record, +): Promise> { + return qzonePublicRequest(cookie, bkn, interaction, 'DeleteComment', '0x9989_0', { + ...qzonePublicFeedParams(feed), + comment, + }); +} + +/** 删空友楼中楼回复。 */ +export function deleteQzonePublicReply( + cookie: string, + bkn: string, + feed: Record, + comment: Record, + reply: Record, +): Promise> { + return qzonePublicRequest(cookie, bkn, interaction, 'DeleteReply', '0x998a_0', { + ...qzonePublicFeedParams(feed), + comment, + reply, + }); +} + +/** 空友关注/取关作者(domain_type=3 且有 uid 用 uid,否则用 uin)。 */ +export async function setQzonePublicFollow( + cookie: string, + bkn: string, + feed: Record, + enable: boolean, +): Promise> { + const params = qzonePublicFeedParams(feed); + const common = feed['cell_common'] as Record; + const user = isObject(feed['cell_user_info']) ? (feed['cell_user_info'] as Record)['user'] : undefined; + const uid = isObject(user) && typeof user['uid'] === 'string' ? user['uid'].trim() : ''; + const uin = isObject(user) && typeof user['uin'] === 'string' ? user['uin'].trim() : ''; + const useUid = (common['domain_type'] === 3 && !!uid) || !uin; + + if (useUid ? !uid : !uin) { + throw new Error('空友动态缺少作者 uid/uin'); + } + + const union = params['union_id'] as Record; + const data = await qzonePublicRequest( + cookie, bkn, + relation, + enable ? 'Follow' : 'Unfollow', + enable ? '0x9d1a_1' : '0x9d1b_1', + { + follow_from_type: 1, + ...(useUid ? { target_uid: uid } : { target_uin: uin }), + ...(union['uf_key'] ? { follow_feed_id: union['uf_key'] } : {}), + ...(feed['cell_feed_transfer'] === undefined + ? {} + : { cell_feed_transfer: feed['cell_feed_transfer'] }), + }, + ); + + if ( + data['ret_code'] !== undefined && + data['ret_code'] !== null && + data['ret_code'] !== 0 && + data['ret_code'] !== '0' + ) { + throw new Error( + `qzone public follow failed: ret_code=${data['ret_code']} ${data['ret_msg'] ?? ''}`.trim(), + ); + } + return data; +} + +/** 转发空友动态。 */ +export function forwardQzonePublicFeed( + cookie: string, + bkn: string, + feed: Record, + reason: string, +): Promise> { + return qzonePublicRequest(cookie, bkn, 'trpc.qzone.feeds_writer.FeedsWriter', 'ForwardFeed', '0x969a_1', { + ...qzonePublicFeedParams(feed), + reason, + }); +} diff --git a/packages/snowcat-core/apis/system.ts b/packages/snowcat-core/apis/system.ts new file mode 100644 index 0000000..52ca728 --- /dev/null +++ b/packages/snowcat-core/apis/system.ts @@ -0,0 +1,36 @@ +import { InstanceContext, SnowCatCore } from '@/snowcat-core/index'; + +export class NTQQSystemApi { + context: InstanceContext; + core: SnowCatCore; + + constructor (context: InstanceContext, core: SnowCatCore) { + this.context = context; + this.core = core; + } + + async hasOtherRunningQQProcess () { + return this.core.util.hasOtherRunningQQProcess(); + } + + async ocrImage (filePath: string) { + return this.context.session.getNodeMiscService().wantWinScreenOCR(filePath); + } + + async translateEnWordToZn (words: string[]) { + return this.context.session.getRichMediaService().translateEnWordToZn(words); + } + + async getOnlineDev () { + this.context.session.getMsgService().getOnLineDev(); + } + + async getArkJsonCollection () { + return await this.core.eventWrapper.callNoListenerEvent('NodeIKernelCollectionService/collectionArkShare', '1717662698058'); + } + + async bootMiniApp (appFile: string, params: string) { + await this.context.session.getNodeMiscService().setMiniAppVersion('2.16.4'); + return this.context.session.getNodeMiscService().startNewMiniApp(appFile, params); + } +} diff --git a/packages/snowcat-core/apis/user.ts b/packages/snowcat-core/apis/user.ts new file mode 100644 index 0000000..eb44354 --- /dev/null +++ b/packages/snowcat-core/apis/user.ts @@ -0,0 +1,248 @@ +import { ModifyProfileParams, User, UserDetailSource } from '@/snowcat-core/types'; +import { RequestUtil } from 'snowcat-common/src/request'; +import { InstanceContext, SnowCatCore, ProfileBizType } from '..'; +import { solveAsyncProblem } from 'snowcat-common/src/helper'; +import { Fallback, FallbackUtil } from 'snowcat-common/src/fall-back'; + +export class NTQQUserApi { + context: InstanceContext; + core: SnowCatCore; + + constructor (context: InstanceContext, core: SnowCatCore) { + this.context = context; + this.core = core; + } + + async getCoreAndBaseInfo (uids: string[]) { + return await this.core.eventWrapper.callNoListenerEvent( + 'NodeIKernelProfileService/getCoreAndBaseInfo', + 'nodeStore', + uids + ); + } + + // 默认获取自己的 type = 2 获取别人 type = 1 + async getProfileLike (uid: string, start: number, count: number, type: number = 2) { + return this.context.session.getProfileLikeService().getBuddyProfileLike({ + friendUids: [uid], + basic: 1, + vote: 1, + favorite: 0, + userProfile: 1, + type, + start, + limit: count, + }); + } + + async setLongNick (longNick: string) { + return this.context.session.getProfileService().setLongNick(longNick); + } + + async setSelfOnlineStatus (status: number, extStatus: number, batteryStatus: number) { + return this.context.session.getMsgService().setStatus({ + status, + extStatus, + batteryStatus, + }); + } + + async setDiySelfOnlineStatus (faceId: string, wording: string, faceType: string) { + return this.context.session.getMsgService().setStatus({ + status: 10, + extStatus: 2000, + customStatus: { faceId, wording, faceType }, + batteryStatus: 0, + }); + } + + async getBuddyRecommendContactArkJson (uin: string, sencenID = '') { + return this.context.session.getBuddyService().getBuddyRecommendContactArkJson(uin, sencenID); + } + + async like (uid: string, count = 1): Promise<{ result: number, errMsg: string, succCounts: number; }> { + return this.context.session.getProfileLikeService().setBuddyProfileLike({ + friendUid: uid, + sourceId: 71, + doLikeCount: count, + doLikeTollCount: 0, + }); + } + + async setQQAvatar (filePath: string) { + const ret = await this.context.session.getProfileService().setHeader(filePath); + return { result: ret?.result, errMsg: ret?.errMsg }; + } + + async setGroupAvatar (gc: string, filePath: string) { + return this.context.session.getGroupService().setHeader(gc, filePath); + } + + async fetchUserDetailInfo (uid: string, mode: UserDetailSource = UserDetailSource.KDB) { + const [, profile] = await this.core.eventWrapper.callNormalEventV2( + 'NodeIKernelProfileService/fetchUserDetailInfo', + 'NodeIKernelProfileListener/onUserDetailInfoChanged', + [ + 'BuddyProfileStore', + [uid], + mode, + [ProfileBizType.KALL], + ], + () => true, + (profile) => profile.uid === uid + ); + return profile; + } + + async getUserDetailInfo (uid: string, no_cache: boolean = false): Promise { + let profile = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, no_cache ? UserDetailSource.KSERVER : UserDetailSource.KDB), uid); + if (profile && profile.uin !== '0' && profile.commonExt) { + return { + ...profile.simpleInfo.status, + ...profile.simpleInfo.vasInfo, + ...profile.commonExt, + ...profile.simpleInfo.baseInfo, + ...profile.simpleInfo.coreInfo, + qqLevel: profile.commonExt?.qqLevel, + age: profile.simpleInfo.baseInfo.age, + pendantId: '', + nick: profile.simpleInfo.coreInfo.nick || '', + }; + } + this.context.logger.logDebug('[SnowCat] [Mark] getUserDetailInfo Mode1 Failed.'); + profile = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER); + if (profile && profile.uin === '0') { + profile.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0'; + } + return { + ...profile.simpleInfo.status, + ...profile.simpleInfo.vasInfo, + ...profile.commonExt, + ...profile.simpleInfo.baseInfo, + ...profile.simpleInfo.coreInfo, + qqLevel: profile.commonExt?.qqLevel, + age: profile.simpleInfo.baseInfo.age, + pendantId: '', + nick: profile.simpleInfo.coreInfo.nick || '', + }; + } + + async modifySelfProfile (param: ModifyProfileParams) { + return this.context.session.getProfileService().modifyDesktopMiniProfile(param); + } + + async getCookies (domain: string) { + const ClientKeyData = await this.forceFetchClientKey(); + const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + + '&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2F' + domain + '%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27'; + const data = await RequestUtil.HttpsGetCookies(requestUrl); + if (!data['p_skey'] || data['p_skey'].length === 0) { + try { + const pskey = (await this.getPSkey([domain])).domainPskeyMap.get(domain); + if (pskey) data['p_skey'] = pskey; + } catch { + return data; + } + } + return data; + } + + async getPSkey (domainList: string[]) { + return await this.context.session.getTipOffService().getPskey(domainList, true); + } + + async getRobotUinRange (): Promise> { + const robotUinRanges = await this.context.session.getRobotService().getRobotUinRange({ + justFetchMsgConfig: '1', + type: 1, + version: 0, + aioKeywordVersion: 0, + }); + return robotUinRanges?.response?.robotUinRanges; + } + + // 需要异常处理 + + async getQzoneCookies () { + const ClientKeyData = await this.forceFetchClientKey(); + const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + '&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2Fuser.qzone.qq.com%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27'; + return await RequestUtil.HttpsGetCookies(requestUrl); + } + + // 需要异常处理 + + async getSKey (): Promise { + const ClientKeyData = await this.forceFetchClientKey(); + if (ClientKeyData.result !== 0) { + throw new Error('getClientKey Error'); + } + const clientKey = ClientKeyData.clientKey; + // const keyIndex = ClientKeyData.keyIndex; + const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + '&clientkey=' + clientKey + '&u1=https%3A%2F%2Fh5.qzone.qq.com%2Fqqnt%2Fqzoneinpcqq%2Ffriend%3Frefresh%3D0%26clientuin%3D0%26darkMode%3D0&keyindex=19%27'; + const cookies: { [key: string]: string; } = await RequestUtil.HttpsGetCookies(requestUrl); + const skey = cookies['skey']; + if (!skey) { + throw new Error('SKey is Empty'); + } + + return skey; + } + + async getUidByUinV2 (uin: string) { + if (!uin) { + return ''; + } + + const fallback = + new Fallback((uid) => FallbackUtil.boolchecker(uid, uid !== undefined && uid.indexOf('*') === -1 && uid !== '')) + .add(() => this.context.session.getUixConvertService().getUid([uin]).then((data) => data.uidInfo.get(uin))) + .add(() => this.context.session.getProfileService().getUidByUin('FriendsServiceImpl', [uin]).get(uin)) + .add(() => this.context.session.getGroupService().getUidByUins([uin]).then((data) => data.uids.get(uin))) + .add(() => this.getUserDetailInfoByUin(uin).then((data) => data.detail.uid)); + + const uid = await fallback.run().catch(() => ''); + return uid ?? ''; + } + + async getUinByUidV2 (uid: string) { + if (!uid) { + return '0'; + } + + const fallback = new Fallback((uin) => FallbackUtil.boolchecker(uin, uin !== undefined && uin !== '0' && uin !== '')) + .add(() => this.context.session.getUixConvertService().getUin([uid]).then((data) => data.uinInfo.get(uid))) + .add(() => this.context.session.getProfileService().getUinByUid('FriendsServiceImpl', [uid]).get(uid)) + .add(() => this.context.session.getGroupService().getUinByUids([uid]).then((data) => data.uins.get(uid))) + .add(() => this.getUserDetailInfo(uid).then((data) => data.uin)); + + const uin = await fallback.run().catch(() => '0'); + return uin ?? '0'; + } + + async getRecentContactListSnapShot (count: number) { + return await this.context.session.getRecentContactService().getRecentContactListSnapShot(count); + } + + async getRecentContactListSyncLimit (count: number) { + return await this.context.session.getRecentContactService().getRecentContactListSyncLimit(count); + } + + async getRecentContactListSync () { + return await this.context.session.getRecentContactService().getRecentContactListSync(); + } + + async getRecentContactList () { + return await this.context.session.getRecentContactService().getRecentContactList(); + } + + async getUserDetailInfoByUin (Uin: string) { + return await this.core.eventWrapper.callNoListenerEvent( + 'NodeIKernelProfileService/getUserDetailInfoByUin', + Uin + ); + } + + async forceFetchClientKey () { + return await this.context.session.getTicketService().forceFetchClientKey(''); + } +} diff --git a/packages/snowcat-core/apis/webapi.ts b/packages/snowcat-core/apis/webapi.ts new file mode 100644 index 0000000..1008818 --- /dev/null +++ b/packages/snowcat-core/apis/webapi.ts @@ -0,0 +1,1000 @@ +import { RequestUtil } from 'snowcat-common/src/request'; +import { + GroupEssenceMsgRet, + InstanceContext, + WebApiGroupMember, + WebApiGroupMemberRet, + WebApiGroupNoticeRet, + WebHonorType, SnowCatCore, +} from '@/snowcat-core/index'; + +import { createReadStream, readFileSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; +import { qunAlbumControl } from '../data/webapi'; +import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album'; +import { + buildQzoneDeleteBody, + buildQzonePublishBody, + buildQzoneUploadImageBody, + parseQzoneDeleteResponse, + parseQzonePublishResponse, + parseQzoneUploadImageResponseText, + toQzoneRichval, + QzoneDeleteResponse, + QzonePublishResponse, +} from '../data/qzone'; +// SnowCat · qzone Web 线扩展(SnowLuma 移植):读列表/详情/点赞/评论/回复/资料/拉黑/权限 +import { + decodeQzoneFeedCursor, + encodeQzoneFeedCursor, + initialQzoneFeedState, + mapQzoneFeeds, + nextQzoneFeedState, + qzoneFeedPosition, + type QzoneFeedState, + type RawFeedsResponse, +} from '../data/qzone-feeds'; +import { parseQzoneCallback } from '../data/qzone-web'; +import { + buildQzoneBlackBody, + buildQzoneCommentBody, + buildQzoneLikeBody, + buildQzoneMsgDetailQuery, + buildQzoneProfileQuery, + buildQzoneProfileUpdateBody, + buildQzoneReplyBody, + legacyMsgListRoute, + mapQzoneMsgDetail, + mapQzoneMsgList, + parseQzoneBlackResponse, + parseQzoneCommentResponse, + parseQzoneDetailError, + parseQzoneJson, + parseQzoneLikeResponse, + parseQzoneProfileResponse, + parseQzoneProfileUpdateResponse, + parseQzonePublishFull, + parseQzoneUpdateRightResponse, + userMsgListRoute, + buildQzoneUpdatePayload, + type QzoneProfile, + type QzoneProfileUpdate, + type QzoneReplyTarget, + type QzoneUpdateRightResult, + type RawMsgListResponse, + type RawMsgDetailResponse, + type QzoneRoute, +} from '../data/qzone-web'; +export interface SetNoticeRetSuccess { + ec: number; + em: string; + id: number; + ltsm: number; + new_fid: string; + read_only: number; + role: number; + srv_code: number; +} + +export class NTQQWebApi { + context: InstanceContext; + core: SnowCatCore; + + constructor (context: InstanceContext, core: SnowCatCore) { + this.context = context; + this.core = core; + } + + async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) { + const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); + const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({ + bkn: this.getBknFromCookie(cookieObject), + group_code: groupCode, + msg_seq: msgSeq, + msg_random: msgRandom, + target_group_code: targetGroupCode, + }).toString()}`; + try { + return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) }); + } catch { + return undefined; + } + } + + async getGroupEssenceMsgAll (GroupCode: string) { + const ret: GroupEssenceMsgRet[] = []; + for (let i = 0; i < 20; i++) { + const data = await this.getGroupEssenceMsg(GroupCode, i, 50); + if (!data) break; + ret.push(data); + if (data.data.is_end) break; + } + return ret; + } + + async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) { + const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); + const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({ + bkn: this.getBknFromCookie(cookieObject), + page_start: page_start.toString(), + page_limit: page_limit.toString(), + group_code: GroupCode, + }).toString()}`; + try { + const ret = await RequestUtil.HttpGetJson( + url, + 'GET', + '', + { Cookie: this.cookieToString(cookieObject) } + ); + return ret.retcode === 0 ? ret : undefined; + } catch { + return undefined; + } + } + + async getGroupMembers (GroupCode: string): Promise { + // logDebug('webapi 获取群成员', GroupCode); + const memberData: Array = new Array(); + const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); + const retList: Promise[] = []; + const fastRet = await RequestUtil.HttpGetJson( + `https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({ + st: '0', + end: '40', + sort: '1', + gc: GroupCode, + bkn: this.getBknFromCookie(cookieObject), + }).toString()}`, + 'POST', + '', + { Cookie: this.cookieToString(cookieObject) } + ); + if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) { + return []; + } else { + for (const key in fastRet.mems) { + if (fastRet.mems[key]) { + memberData.push(fastRet.mems[key]); + } + } + } + // 初始化获取PageNum + const PageNum = Math.ceil(fastRet.count / 40); + // 遍历批量请求 + for (let i = 2; i <= PageNum; i++) { + const ret = RequestUtil.HttpGetJson( + `https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({ + st: ((i - 1) * 40).toString(), + end: (i * 40).toString(), + sort: '1', + gc: GroupCode, + bkn: this.getBknFromCookie(cookieObject), + }).toString()}`, + 'POST', + '', + { Cookie: this.cookieToString(cookieObject) } + ); + retList.push(ret); + } + // 批量等待 + for (let i = 1; i <= PageNum; i++) { + const ret = await (retList[i]); + if (!ret?.count || ret?.errcode !== 0 || !ret?.mems) { + continue; + } + for (const key in ret.mems) { + if (ret.mems[key]) { + memberData.push(ret.mems[key]); + } + } + } + return memberData; + } + + // public async addGroupDigest(groupCode: string, msgSeq: string) { + // const url = `https://qun.qq.com/cgi-bin/group_digest/cancel_digest?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&msg_seq=${msgSeq}&msg_random=444021292`; + // const res = await this.request(url); + // return await res.json(); + // } + + // public async getGroupDigest(groupCode: string) { + // const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&page_start=0&page_limit=20`; + // const res = await this.request(url); + // return await res.json(); + // } + + async setGroupNotice ( + GroupCode: string, + Content: string, + pinned: number = 0, + type: number = 1, + is_show_edit_card: number = 1, + tip_window_type: number = 1, + confirm_required: number = 1, + picId: string = '', + imgWidth: number = 540, + imgHeight: number = 300 + ) { + const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); + + try { + const settings = JSON.stringify({ + is_show_edit_card, + tip_window_type, + confirm_required, + }); + const externalParam = { + pic: picId, + imgWidth: imgWidth.toString(), + imgHeight: imgHeight.toString(), + }; + const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson( + `https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({ + bkn: this.getBknFromCookie(cookieObject), + qid: GroupCode, + text: Content, + pinned: pinned.toString(), + type: type.toString(), + settings, + ...(picId === '' ? {} : externalParam), + }).toString()}`, + 'POST', + '', + { Cookie: this.cookieToString(cookieObject) } + ); + return ret; + } catch { + return undefined; + } + } + + async getGroupNotice (GroupCode: string): Promise { + const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); + try { + const ret = await RequestUtil.HttpGetJson( + `https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({ + bkn: this.getBknFromCookie(cookieObject), + qid: GroupCode, + ft: '23', + ni: '1', + n: '1', + i: '1', + log_read: '1', + platform: '1', + s: '-1', + }).toString()}&n=20`, + 'GET', + '', + { Cookie: this.cookieToString(cookieObject) } + ); + return ret?.ec === 0 ? ret : undefined; + } catch { + return undefined; + } + } + + private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) { + let resJson; + try { + const res = await RequestUtil.HttpGetText( + `https://qun.qq.com/interactive/honorlist?${new URLSearchParams({ + gc: groupCode, + type: type.toString(), + }).toString()}`, + 'GET', + '', + { Cookie: this.cookieToString(cookieObject) } + ); + const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res); + if (match?.[1]) { + resJson = JSON.parse(match[1].trim()); + } + return type === 1 ? resJson?.talkativeList : resJson?.actorList; + } catch (e) { + this.context.logger.logDebug('获取当前群荣耀失败', e); + return undefined; + } + } + + private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) { + const data = await this.getDataInternal(cookieObject, groupCode, type); + if (!data) { + this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`); + return []; + } + return data.map((item: { + uin: string, + name: string, + avatar: string, + desc: string, + }) => ({ + user_id: item?.uin, + nickname: item?.name, + avatar: item?.avatar, + description: item?.desc, + })); + } + + async getGroupHonorInfo (groupCode: string, getType: WebHonorType) { + const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); + const HonorInfo = { + group_id: Number(groupCode), + current_talkative: {}, + talkative_list: [], + performer_list: [], + legend_list: [], + emotion_list: [], + strong_newbie_list: [], + }; + + if (getType === WebHonorType.TALKATIVE || getType === WebHonorType.ALL) { + const talkativeList = await this.getHonorList(cookieObject, groupCode, 1); + if (talkativeList.length > 0) { + HonorInfo.current_talkative = talkativeList[0]; + HonorInfo.talkative_list = talkativeList; + } + } + + if (getType === WebHonorType.PERFORMER || getType === WebHonorType.ALL) { + HonorInfo.performer_list = await this.getHonorList(cookieObject, groupCode, 2); + } + + if (getType === WebHonorType.LEGEND || getType === WebHonorType.ALL) { + HonorInfo.legend_list = await this.getHonorList(cookieObject, groupCode, 3); + } + + if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) { + HonorInfo.emotion_list = await this.getHonorList(cookieObject, groupCode, 6); + } + + // 冒尖小春笋好像已经被tx扬了 R.I.P. + if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) { + HonorInfo.strong_newbie_list = []; + } + + return HonorInfo; + } + + private cookieToString (cookieObject: { [key: string]: string; }) { + return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; '); + } + + public getBknFromCookie (cookieObject: { [key: string]: string; }) { + const sKey = cookieObject['skey'] as string; + + let hash = 5381; + for (let i = 0; i < sKey.length; i++) { + const code = sKey.charCodeAt(i); + hash = hash + (hash << 5) + code; + } + return (hash & 0x7FFFFFFF).toString(); + } + + public getBknFromSKey (sKey: string) { + let hash = 5381; + for (let i = 0; i < sKey.length; i++) { + const code = sKey.charCodeAt(i); + hash = hash + (hash << 5) + code; + } + return (hash & 0x7FFFFFFF).toString(); + } + + public getBknFromPSKey (psKey: string) { + return this.getBknFromSKey(psKey); + } + + async getAlbumListByNTQQ (gc: string, attach_info: string = '') { + return await this.context.session.getAlbumService().getAlbumList({ + qun_id: gc, + attach_info, + seq: 3331, + request_time_line: { + request_invoke_time: '0', + }, + }); + } + + async getAlbumList (gc: string) { + const skey = await this.core.apis.UserApi.getSKey() || ''; + const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || ''; + const bkn = this.getBknFromSKey(skey); + const uin = this.core.selfInfo.uin || '10001'; + const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `; + const api = 'https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?'; + const params = new URLSearchParams({ + random: '7570', + g_tk: bkn, + format: 'json', + inCharset: 'utf-8', + outCharset: 'utf-8', + qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T', + cmd: 'qunGetAlbumList', + qunId: gc, + qunid: gc, + start: '0', + num: '1000', + uin, + getMemberRole: '0', + }); + const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', { + Cookie: cookies, + }); + return response.data.album; + } + + async createQunAlbumSession (gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, img_md5: string, uin: string) { + const img = readFileSync(path); + const img_size = img.length; + const img_name = basename(path); + const GTK = this.getBknFromSKey(skey); + const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`; + const body = qunAlbumControl({ + uin, + group_id: gc, + pskey, + pic_md5: img_md5, + img_size, + img_name, + sAlbumName, + sAlbumID, + }); + const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`; + const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/json', + }); + return post; + } + + async uploadQunAlbumSlice (path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) { + const img_size = statSync(path).size; + let seq = 0; + let offset = 0; + const GTK = this.getBknFromSKey(skey); + const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`; + + const stream = createReadStream(path, { highWaterMark: slice_size }); + + for await (const chunk of stream) { + const end = Math.min(offset + chunk.length, img_size); + const form = new FormData(); + form.append('uin', uin); + form.append('appid', 'qun'); + form.append('session', session); + form.append('offset', offset.toString()); + form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob'); + form.append('checksum', ''); + form.append('check_type', '0'); + form.append('retry', '0'); + form.append('seq', seq.toString()); + form.append('end', end.toString()); + form.append('cmd', 'FileUpload'); + form.append('slice_size', slice_size.toString()); + form.append('biz_req.iUploadType', '0'); + + const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${end}&total=${img_size}&type=form&g_tk=${GTK}`; + const response = await fetch(api, { + method: 'POST', + headers: { + Cookie: cookie, + }, + body: form, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const post = await response.json() as { ret: number, msg: string; }; if (post.ret !== 0) { + throw new Error(`分片 ${seq} 上传失败: ${post.msg}`); + } + offset += chunk.length; + seq++; + } + + return { success: true, message: '上传完成' }; + } + + async uploadImageToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) { + const skey = await this.core.apis.UserApi.getSKey() || ''; + const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || ''; + const img_md5 = createHash('md5').update(readFileSync(path)).digest('hex'); + const uin = this.core.selfInfo.uin || '10001'; + const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session; + if (!session) throw new Error('创建群相册会话失败'); + await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384); + } + + async getAlbumMediaListByNTQQ (gc: string, albumId: string, attach_info: string = '') { + return (await this.context.session.getAlbumService().getMediaList({ + qun_id: gc, + attach_info, + seq: 0, + request_time_line: { + request_invoke_time: '0', + }, + album_id: albumId, + lloc: '', + batch_id: '', + })).response; + } + + async doAlbumMediaPlainCommentByNTQQ ( + qunId: string, + albumId: string, + lloc: string, + content: string) { + const random_seq = Math.floor(Math.random() * 9000) + 1000; + const uin = this.core.selfInfo.uin || '10001'; + // 16位number数字 + const client_key = Date.now() * 1000; + return await this.context.session.getAlbumService().doQunComment( + random_seq, { + map_info: [], + map_bytes_info: [], + map_user_account: [], + }, + qunId, + 2, + createAlbumMediaFeed(uin, albumId, lloc), + createAlbumCommentRequest(uin, content, client_key) + ); + } + + async deleteAlbumMediaByNTQQ ( + qunId: string, + albumId: string, + lloc: string) { + const random_seq = Math.floor(Math.random() * 9000) + 1000; + return await this.context.session.getAlbumService().deleteMedias( + random_seq, + qunId, + albumId, + [lloc], + [] + ); + } + + async doAlbumMediaLikeByNTQQ ( + qunId: string, + albumId: string, + batchId: string, + lloc: string | undefined, + isLike: boolean + ) { + const random_seq = Math.floor(Math.random() * 9000) + 1000; + const uin = this.core.selfInfo.uin || '10001'; + + const type = isLike ? 2 : 1; + const status = isLike ? 0 : 1; + + let id = ''; + if (lloc) { + id = `421_1_0_${qunId}|${albumId}|${batchId}^||^421_1_0_${qunId}|${albumId}|${lloc}^||^0`; + } else { + id = `421_1_0_${qunId}|${albumId}|${batchId}`; + } + + return await this.context.session.getAlbumService().doQunLike( + random_seq, + { + map_info: [], + map_bytes_info: [], + map_user_account: [], + }, + type, + { + id, + status, + }, + createAlbumFeedPublish(qunId, uin, albumId, batchId) + ); + } + + private async getQzoneAuth () { + const skey = await this.core.apis.UserApi.getSKey() || ''; + const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || ''; + const uin = this.core.selfInfo.uin || '10001'; + const g_tk = this.getBknFromSKey(skey); + const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`; + return { skey, pskey, uin, g_tk, cookie }; + } + + /** + * 上传图片到 QQ 空间相册, 返回可用于发说说的 richval 字符串 + * @param base64 图片Base64编码内容(不带data:前缀) + */ + async uploadImageToQzone (base64: string): Promise { + const { skey, pskey, uin, g_tk, cookie } = await this.getQzoneAuth(); + const body = buildQzoneUploadImageBody({ uin, skey, pskey, g_tk, base64 }); + const api = `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`; + const resultText = await RequestUtil.HttpGetText(api, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const parsed = parseQzoneUploadImageResponseText(resultText); + if (parsed.code !== undefined && parsed.code !== 0) { + throw new Error(parsed.msg || `QQ空间图片上传失败, code=${parsed.code}`); + } + if (!parsed.data) { + throw new Error('QQ空间图片上传失败, 响应缺少data字段'); + } + return toQzoneRichval(parsed.data); + } + + /** + * 发表QQ空间说说 + * @param content 说说正文 + * @param richvals 每张图片对应的richval数组, 为空表示纯文字说说 + * @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见 + * @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用 + */ + async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: number[]) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const richval = richvals.length > 0 ? richvals.join('\t') : undefined; + const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined; + const body = buildQzonePublishBody({ + hostuin: uin, + content, + richval, + ugcRight, + allowUins, + }); + const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`; + const result = await RequestUtil.HttpGetJson(api, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }, true, false); + return parseQzonePublishResponse(result); + } + + /** + * 删除QQ空间说说 + * @param tid 说说Tid, 来自 publishQzoneMsg 的返回值 + */ + async deleteQzoneMsg (tid: string) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const body = buildQzoneDeleteBody({ hostuin: uin, tid }); + const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6?g_tk=${g_tk}`; + const result = await RequestUtil.HttpGetJson(api, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }, true, false); + return parseQzoneDeleteResponse(result); + } + + async getDaySignedList (groupCode: string) { + const pSkey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!; + const selfUin = this.core.selfInfo.uin; + const cookie = `p_uin=o${selfUin}; p_skey=${pSkey}; uin=o${selfUin}`; + const post = await RequestUtil.HttpGetJson<{ + retCode: number, + costTime: number, + response: { + ret?: { + code: string, + msg: string, + }, + page?: { + infos?: { + uid: string, + uidGroupNick: string, + signedTimeStamp: string, + signInRank: number, + }[], + offset: number, + total: number, + }[], + }, + funcCode: number, + }>(`https://qun.qq.com/v2/signin/trpc/GetDaySignedList?g_tk=${this.getBknFromPSKey(pSkey)}`, 'POST', { + dayYmd: new Date().toISOString().slice(0, 10).replace(/-/g, ''), + offset: 0, + limit: 100, + uid: selfUin, + groupId: groupCode, + }, { + Cookie: cookie, + 'Content-Type': 'application/json', + }); + return post; + } + + // ───────────── SnowCat · qzone Web 线扩展(SnowLuma 移植) ───────────── + + /** 获取说说列表(默认自己空间;-10000 限频自动切 user.qzone 备路) */ + async getQzoneMsgList (targetUin?: string, pos = 0, num = 20) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const host = targetUin ?? uin; + let route: QzoneRoute = legacyMsgListRoute(cookie, host, pos, num, g_tk); + let text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers); + let data = parseQzoneJson(text); + // -10000「使用人数过多」是 legacy 网关限频,切备路重试一次 + if (data.code === -10000) { + route = userMsgListRoute(cookie, host, pos, num, g_tk); + text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers); + data = parseQzoneJson(text); + } + if (typeof data.code === 'number' && data.code !== 0) { + throw new Error(`qzone msglist failed: code=${data.code} ${data.message ?? ''}`.trim()); + } + return mapQzoneMsgList(data); + } + + /** 获取单条说说详情 + 一页评论(含内联回复) */ + async getQzoneMsgDetail (targetUin: string, tid: string, pos = 0, num = 20) { + const { g_tk, cookie } = await this.getQzoneAuth(); + const query = buildQzoneMsgDetailQuery(targetUin, tid, g_tk, { pos, num }); + const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${query}`; + const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie }); + const data = parseQzoneJson(text); + parseQzoneDetailError(data, tid); + return mapQzoneMsgDetail(data, tid); + } + + /** 点赞/取消赞一条说说(abstime 传目标帖的发帖时间更稳,0 可容忍) */ + async setQzoneLike (targetUin: string, tid: string, like: boolean, abstime = 0) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const verb = like ? 'like' : 'unlike'; + const cgi = like ? 'internal_dolike_app' : 'internal_unlike_app'; + const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/likes/${cgi}?g_tk=${g_tk}`; + const body = buildQzoneLikeBody(uin, targetUin, tid, abstime); + const text = await RequestUtil.HttpGetText(url, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + parseQzoneLikeResponse(text, verb); + } + + /** 评论一条说说(richval 传图片直链,与发说说的 richval 串不同) */ + async commentQzoneMsg (hostUin: string, tid: string, content: string, richval?: string) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds?g_tk=${g_tk}`; + const body = buildQzoneCommentBody(uin, hostUin, tid, content, richval ? 1 : undefined, richval); + const text = await RequestUtil.HttpGetText(url, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + return parseQzoneCommentResponse(text, hostUin, tid); + } + + /** 楼中楼回复(独立端点 emotion_cgi_addreply_ugc;replyTo 自动转 @ 提及) */ + async replyQzoneComment (hostUin: string, tid: string, content: string, target: QzoneReplyTarget) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${g_tk}`; + const body = buildQzoneReplyBody(uin, hostUin, tid, content, target); + const text = await RequestUtil.HttpGetText(url, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + // 复用评论解析:回复的 id 在 data.id(与评论的 commentid 不同名) + const response = parseQzoneJson<{ code?: number; subcode?: number; message?: string; data?: { id?: string | number } }>(text); + if (typeof response.code === 'number' && response.code !== 0) { + throw new Error(`qzone reply failed: code=${response.code} ${response.message ?? ''}`.trim()); + } + const id = response.data?.id; + return { comment_id: id !== undefined ? String(id) : '' }; + } + + /** 发说说(富返回:tid + time;SnowCat 原版 publishQzoneMsg 只返回 tid,保留不动) */ + async publishQzoneMsgFull (content: string, richvals: string[], ugcRight: number, targetUins?: string[]) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const richval = richvals.length > 0 ? richvals.join('\t') : undefined; + const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined; + const body = buildQzonePublishBody({ hostuin: uin, content, richval, ugcRight, allowUins }); + const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`; + const resultText = await RequestUtil.HttpGetText(api, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + return parseQzonePublishFull(resultText); + } + + /** 获取空间名称与说明(默认自己,可查别人——需有访问权) */ + async getQzoneProfile (targetUin?: string): Promise { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const query = buildQzoneProfileQuery(uin, targetUin ?? uin, g_tk); + const url = `https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all?${query}`; + const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie }); + return parseQzoneProfileResponse(text); + } + + /** 修改空间名称/说明(只动传了的字段;空串=清空) */ + async setQzoneProfile (profile: QzoneProfileUpdate): Promise { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const body = buildQzoneProfileUpdateBody(uin, profile); + const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new?g_tk=${g_tk}`; + const text = await RequestUtil.HttpGetText(url, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + parseQzoneProfileUpdateResponse(text); + } + + /** 空间拉黑/解拉黑(action=1 拉黑 / 2 解拉黑) */ + async setQzoneBlack (targetUin: string, ban: boolean): Promise { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new?g_tk=${g_tk}`; + const body = buildQzoneBlackBody(uin, targetUin, ban); + const text = await RequestUtil.HttpGetText(url, 'POST', body, { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + parseQzoneBlackResponse(text, ban); + } + + /** 改已有说说的查看权限(msgdetail_v6 → emotion_cgi_update 两步全量重提) */ + async setQzoneMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + // 第一步:拉详情 + const detailQuery = buildQzoneMsgDetailQuery(uin, tid, g_tk); + const detailUrl = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${detailQuery}`; + const detailText = await RequestUtil.HttpGetText(detailUrl, 'GET', '', { Cookie: cookie }); + const detail = parseQzoneJson(detailText); + parseQzoneDetailError(detail, tid); + // 第二步:全量重提 + 覆写权限 + const bodyParams = buildQzoneUpdatePayload(detail, uin); + bodyParams.set('ugc_right', String(ugcRight)); + if (targetUins && targetUins.length > 0) { + bodyParams.set('allow_uins', targetUins.join('|')); + } + const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update?g_tk=${g_tk}`; + const text = await RequestUtil.HttpGetText(url, 'POST', bodyParams.toString(), { + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }); + return parseQzoneUpdateRightResponse(text, tid, ugcRight); + } + + /** + * 获取空间好友动态流(feeds3_html_more,HTML 载荷数据级解析)。 + * cursor 传上次返回的 next_cursor 续翻;不传从第 page_num 页走。 + */ + async getQzoneFeeds (pageNum = 1, count = 10, cursor?: string) { + const { uin, g_tk, cookie } = await this.getQzoneAuth(); + if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) { + throw new Error('空间好友动态页码或数量无效'); + } + const start = cursor === undefined + ? { page: 1, state: initialQzoneFeedState() } + : decodeQzoneFeedCursor(cursor, uin); + if (cursor !== undefined && pageNum !== 1 && pageNum !== start.page) { + throw new Error('page_num 与空间好友动态 cursor 不匹配'); + } + const target = cursor === undefined ? pageNum : start.page; + let state: QzoneFeedState = start.state; + const seen = new Set([qzoneFeedPosition(state)]); + + for (let page = start.page; page <= target; page++) { + const query = new URLSearchParams({ + uin, + scope: '0', + view: '1', + gid: '', + flag: '1', + filter: 'all', + applist: 'all', + refresh: '0', + firstGetGroup: '0', + mixnocache: '0', + scene: '0', + sidomain: 'qzonestyle.gtimg.cn', + count: String(count), + ...state, + getAisort: state.aisortNextTime, + g_tk: g_tk, + callback: '_preloadCallback', + format: 'jsonp', + useutf8: '1', + outputhtmlfeed: '1', + usertime: String(Date.now()), + rd: String(Math.random()), + windowId: String(Math.random()), + }); + query.delete('aisortNextTime'); + const url = `https://h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${query}`; + const body = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie }); + const data = parseQzoneCallback(body); + if (data.code !== 0 || (data.subcode !== undefined && data.subcode !== 0)) { + throw new Error( + `qzone feeds failed: code=${data.code} subcode=${data.subcode ?? 0} ${typeof data.message === 'string' ? data.message : ''}`.trim(), + ); + } + const main = data.data?.main; + if (main !== undefined && (main === null || typeof main !== 'object' || Array.isArray(main))) { + throw new Error('空间好友动态分页信息结构异常'); + } + if (main?.['error'] !== undefined && main['error'] !== '') { + throw new Error('空间好友动态服务端报告拉取失败'); + } + const result = mapQzoneFeeds(data); + if (result.has_more) { + if (!main) throw new Error('空间好友动态缺少下一页游标'); + state = nextQzoneFeedState(main, state); + const position = qzoneFeedPosition(state); + if (seen.has(position)) throw new Error('空间好友动态分页游标未推进'); + seen.add(position); + result.next_cursor = encodeQzoneFeedCursor(uin, page + 1, state); + } + if (page === target) return result; + if (!result.has_more) { + return { feeds: [], has_more: false, next_cursor: null }; + } + } + throw new Error('空间好友动态分页未完成'); + } + + // ───────────── SnowCat · qzone 公共空间(空友)代理(SnowLuma 移植) ───────────── + + async getQzonePublicCapabilities (cookie: string, bkn: string, uin: string) { + const impl = await import('./qzone-public-web'); + return impl.getQzonePublicCapabilities(cookie, bkn, uin); + } + + async getQzonePublicFeeds (cookie: string, bkn: string, uin: string, cursor?: string) { + const impl = await import('./qzone-public-web'); + return impl.getQzonePublicFeeds(cookie, bkn, uin, cursor); + } + + async getQzonePublicFeedDetail (cookie: string, bkn: string, feed: Record) { + const impl = await import('./qzone-public-web'); + return impl.getQzonePublicFeedDetail(cookie, bkn, feed); + } + + async getQzonePublicComments (cookie: string, bkn: string, feed: Record, cursor: string) { + const impl = await import('./qzone-public-web'); + return impl.getQzonePublicComments(cookie, bkn, feed, cursor); + } + + async getQzonePublicReplies (cookie: string, bkn: string, feed: Record, comment: Record, cursor: string, pageSize: number) { + const impl = await import('./qzone-public-web'); + return impl.getQzonePublicReplies(cookie, bkn, feed, comment, cursor, pageSize); + } + + async setQzonePublicUpvote (cookie: string, bkn: string, feed: Record, enable: boolean) { + const impl = await import('./qzone-public-web'); + return impl.setQzonePublicUpvote(cookie, bkn, feed, enable); + } + + async setQzonePublicLike (cookie: string, bkn: string, feed: Record, enable: boolean) { + const impl = await import('./qzone-public-web'); + return impl.setQzonePublicLike(cookie, bkn, feed, enable); + } + + async setQzonePublicCommentLike (cookie: string, bkn: string, feed: Record, comment: Record, enable: boolean, reply?: Record) { + const impl = await import('./qzone-public-web'); + return impl.setQzonePublicCommentLike(cookie, bkn, feed, comment, enable, reply); + } + + async commentQzonePublicFeed (cookie: string, bkn: string, feed: Record, user: Record, content: string | Record[]) { + const impl = await import('./qzone-public-web'); + return impl.commentQzonePublicFeed(cookie, bkn, feed, user, content); + } + + async replyQzonePublicFeed (cookie: string, bkn: string, feed: Record, user: Record, comment: Record, content: string | Record[], reply?: Record) { + const impl = await import('./qzone-public-web'); + return impl.replyQzonePublicFeed(cookie, bkn, feed, user, comment, content, reply); + } + + async deleteQzonePublicComment (cookie: string, bkn: string, feed: Record, comment: Record) { + const impl = await import('./qzone-public-web'); + return impl.deleteQzonePublicComment(cookie, bkn, feed, comment); + } + + async deleteQzonePublicReply (cookie: string, bkn: string, feed: Record, comment: Record, reply: Record) { + const impl = await import('./qzone-public-web'); + return impl.deleteQzonePublicReply(cookie, bkn, feed, comment, reply); + } + + async setQzonePublicFollow (cookie: string, bkn: string, feed: Record, enable: boolean) { + const impl = await import('./qzone-public-web'); + return impl.setQzonePublicFollow(cookie, bkn, feed, enable); + } + + async forwardQzonePublicFeed (cookie: string, bkn: string, feed: Record, reason: string) { + const impl = await import('./qzone-public-web'); + return impl.forwardQzonePublicFeed(cookie, bkn, feed, reason); + } +} diff --git a/packages/snowcat-core/data/album.ts b/packages/snowcat-core/data/album.ts new file mode 100644 index 0000000..6a5181c --- /dev/null +++ b/packages/snowcat-core/data/album.ts @@ -0,0 +1,212 @@ +/** + * 群相册列表请求参数接口 + */ +export interface AlbumListRequest { + qun_id: string; + attach_info: string; + seq: number; + request_time_line: { + request_invoke_time: string; + }; + album_id: string; + lloc: string; + batch_id: string; +} + +/** + * 创建群相册列表请求参数 + * @param qunId 群号 + * @param albumId 相册ID + * @param seq 请求序列号,默认值为0 + * @returns 请求参数对象 + */ +export function createAlbumListRequest ( + qunId: string, + albumId: string, + seq: number = 0 +): AlbumListRequest { + return { + qun_id: qunId, + attach_info: '', + seq, + request_time_line: { + request_invoke_time: '0', + }, + album_id: albumId, + lloc: '', + batch_id: '', + }; +} + +/** + * 相册媒体项请求接口 + */ +export interface AlbumMediaFeed { + cell_common: { + time: string; + }; + cell_user_info: { + user: { + uin: string; + }; + }; + cell_media: { + album_id: string; + batch_id: string; + media_items: Array<{ + image: { + lloc: string; + }; + }>; + }; +} + +/** + * 创建相册媒体请求参数 + * @param uin 用户QQ号 + * @param albumId 相册ID + * @param lloc + * @returns 媒体请求参数对象 + */ +export function createAlbumMediaFeed ( + uin: string, + albumId: string, + lloc: string +): AlbumMediaFeed { + return { + cell_common: { + time: '', + }, + cell_user_info: { + user: { + uin, + }, + }, + cell_media: { + album_id: albumId, + batch_id: '', + media_items: [{ + image: { + lloc, + }, + }], + }, + }; +} +/** + * 相册评论内容接口 + */ +export interface AlbumCommentContent { + type: number; + content: string; + who: number; + uid: string; + name: string; + url: string; +} + +/** + * 相册评论请求接口 + */ +export interface AlbumCommentReplyContent { + client_key: number; + content: AlbumCommentContent[]; + user: { + uin: string; + }; +} +export enum RichMsgType { + KRICHMSGTYPEPLAINTEXT, + KRICHMSGTYPEAT, + KRICHMSGTYPEURL, + KRICHMSGTYPEMEDIA, +} + +/** + * 创建相册评论请求参数 + * @param uin 用户QQ号 + * @param content 评论内容 + * @param client_key 客户端鉴权密钥 + * @returns 评论请求参数对象 + */ +export function createAlbumCommentRequest ( + uin: string, + content: string, + client_key: number +): AlbumCommentReplyContent { + return { + client_key, + // 暂时只支持纯文本吧 + content: [{ + type: RichMsgType.KRICHMSGTYPEPLAINTEXT, + content, + who: 0, + uid: '', + name: '', + url: '', + }], + user: { + uin, + }, + }; +} + +export interface AlbumFeedLikePublish { + cell_common: { + time: number; + feed_id: string; + }; + cell_user_info: { + user: { + uin: string; + }; + }; + cell_media: { + album_id: string; + batch_id: string | number; + media_items?: { + type: number; + image: { + lloc: string; + sloc: string; + }; + }[]; + }; + cell_qun_info: { + qun_id: string; + }; +} + +/** + * 创建相册动态发布请求参数 + * @param qunId 群号 + * @param uin 用户QQ号 + * @param albumId 相册ID + * @param batchId 批次ID + * @returns 动态发布请求参数对象 + */ +export function createAlbumFeedPublish ( + qunId: string, + uin: string, + albumId: string, + batchId: string +) { + return { + cell_common: { + time: Date.now(), + feed_id: `422_0_${batchId}`, // 需要补全feed_id + }, + cell_user_info: { + user: { + uin, + }, + }, + cell_media: { + album_id: albumId, + batch_id: batchId, + }, + cell_qun_info: { + qun_id: qunId, + }, + }; +} diff --git a/packages/snowcat-core/data/flash.ts b/packages/snowcat-core/data/flash.ts new file mode 100644 index 0000000..4636ecf --- /dev/null +++ b/packages/snowcat-core/data/flash.ts @@ -0,0 +1,358 @@ +export interface FlashBaseRequest { + fileSetId: string; +} + +export interface UploaderInfo { + uin: string, + nickname: string, + uid: string, + sendEntrance: string, // "" +} + +export interface thumbnailInfo { + id: string, + url: { + spec: number, + uri: string, + }[], + localCachePath: string, +} + +export interface SendTarget { + destType: number; // 1私聊 + destUin?: string, + destUid: string, +} + +export interface SendTargetRequests { + fileSetId: string; + targets: SendTarget[]; +} + +export interface DownloadStatusInfo { + result: number; // 0 + fileSetId: string; + status: number; + info: { + curDownLoadFailFileNum: number, + curDownLoadedPauseFileNum: number, + curDownLoadedFileNum: number, + curRealDownLoadedFileNum: number, + curDownloadingFileNum: number, + totalDownLoadedFileNum: number, + curDownLoadedBytes: string, // "0" + totalDownLoadedBytes: string, + curSpeedBps: number, + avgSpeedBps: number, + maxSpeedBps: number, + remainDownLoadSeconds: number, + failFileIdList: [], + allFileIdList: [], + hasNormalFileDownloading: boolean, + onlyCompressInnerFileDownloading: boolean, + isAllFileAlreadyDownloaded: boolean, + saveFileSetDir: string, + allWaitingStatusTask: boolean, + downloadSceneType: DownloadSceneType, + retryCount: number, + statisticInfo: { + downloadTaskId: string, + downloadFilesetName: string, + downloadFileTypeDistribution: string, + downloadFileSizeDistribution: string; + }, + albumStorageFailImageNum: number, + albumStorageFailVideoNum: number, + albumStorageFailFileIdList: [], + albumStorageSucImageNum: number, + albumStorageSucVideoNum: number, + albumStorageSucFileIdList: [], + albumStorageFileNum: number; + }; +} + +export interface physicalInfo { + id: string, + url: string, + status: number, // 2 已下载 + processing: string, + localPath: string, + width: 0, + height: 0, + time: number, +} + +export interface downloadInfo { + status: number, + curDownLoadBytes: string, + totalFileBytes: string, + errorCode: number, +} + +export interface uploadInfo { + uploadedBytes: string, + errorCode: number, + svrRrrCode: number, + errMsg: string, + isNeedDelDeviceInfo: boolean, + thumbnailUploadState: number; + isSecondHit: boolean, + hasModifiedErr: boolean, +} + +export interface folderUploadInfo { + totalUploadedFileSize: string; + successCount: number; + failedCount: number; +} + +export interface folderDownloadInfo { + totalDownloadedFileSize: string; + totalFileSize: string; + totalDownloadFileCount: number; + successCount: number; + failedCount: number; + pausedCount: number; + cancelCount: number; + downloadingCount: number; + partialDownloadCount: number; + curLevelDownloadedFileCount: number; + curLevelUnDownloadedFileCount: number; +} + +export interface compressFileFolderInfo { + downloadStatus: number; + saveFileDirPath: string; + totalFileCount: string; + totalFileSize: string; +} + +export interface albumStorgeInfo { + status: number; + localIdentifier: string; + errorCode: number; + timeCost: number; +} + +export interface FlashOneFileInfo { + fileSetId: string; + cliFileId: string; // client?? 或许可以换取url + compressedFileFolderId: string; + archiveIndex: 0; + indexPath: string; + isDir: boolean; // 文件或者文件夹!! + parentId: string; + depth: number; // 1 + cliFileIndex: number; + fileType: number; // 枚举!! 已完成枚举!! + name: string; + namePinyin: string; + isCover: boolean; + isCoverOriginal: boolean; + fileSize: string; + fileCount: number; + thumbnail: thumbnailInfo; + physical: physicalInfo; + srvFileId: string; // service?? 服务器上面的id吗? + srvParentFileId: string; + svrLastUpdateTimestamp: string; + downloadInfo: downloadInfo; + saveFilePath: string; + search_relative_path: string; + disk_relative_path: string; + uploadInfo: uploadInfo; + status: number; + uploadStatus: number; // 3已上传成功 + downloadStatus: number; // 0未下载 + folderUploadInfo: folderUploadInfo; + folderDownloadInfo: folderDownloadInfo; + sha1: string; + bookmark: string; + compressFileFolderInfo: compressFileFolderInfo; + uploadPauseReason: string; + downloadPauseReason: string; + filePhysicalSize: string; + thumbnail_sha1: string | null; + thumbnail_size: string | null; + needAlbumStorage: boolean; + albumStorageInfo: albumStorgeInfo; +} + +export interface fileListsInfo { + parentId: string, + depth: number, // 1 + fileList: FlashOneFileInfo[], + paginationInfo: {}; + isEnd: boolean, + isCache: boolean, +} + +export interface FileListResponse { + seq: number, + result: number, + errMs: string, + fileLists: fileListsInfo[], +} + +export interface createFlashTransferResult { + fileSetId: string, + shareLink: string, + expireTime: string, + expireLeftTime: string, +} +export enum UploadSceneType { + KUPLOADSCENEUNKNOWN, + KUPLOADSCENEFLOATWINDOWRIGHTCLICKMENU, + KUPLOADSCENEFLOATWINDOWDRAG, + KUPLOADSCENEFLOATWINDOWFILESELECTOR, + KUPLOADSCENEFLOATWINDOWSHORTCUTKEYCTRLCV, + KUPLOADSCENEH5LAUNCHCLIENTRIGHTCLICKMENU, + KUPLOADSCENEH5LAUNCHCLIENTDRAG, + KUPLOADSCENEH5LAUNCHCLIENTFILESELECTOR, + KUPLOADSCENEH5LAUNCHCLIENTSHORTCUTKEYCTRLCV, + KUPLOADSCENEAIODRAG, + KUPLOADSCENEAIOFILESELECTOR, + KUPLOADSCENEAIOSHORTCUTKEYCTRLCV, +} +export interface StartFlashTaskRequests { + screen: number; // 1 PC-QQ + name?: string; + uploaders: UploaderInfo[]; + permission?: {}; + coverPath?: string; + paths: string[]; // 文件的绝对路径,可以是文件夹 + excludePaths?: string[]; + expireLeftTime?: number, // 0 + isNeedDelDeviceInfo: boolean, + isNeedDelLocation: boolean, + coverOriginalInfos?: { + path: string, + thumbnailPath: string, + }[], + uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10) + detectPrivacyInfoResult: { + exists: boolean, + allDetectResults: {}; + }; +} +export enum BusiScene { + KBUSISCENEINVALID, + KBUSISCENEFLASHSCENE, +} +export interface FileListInfoRequests { + seq: number, // 0 + fileSetId: string, + isUseCache: boolean, + sceneType: BusiScene, // 1 + reqInfos: { + count: number, // 18 ?? 硬编码吧 不懂 + paginationInfo: {}, + parentId: string, + reqIndexPath: string, + reqDepth: number, // 1 + filterCondition: { + fileCategory: number, + filterType: number, + }, // 0 + sortConditions: { + sortField: number, + sortOrder: number, + }[], + isNeedPhysicalInfoReady: boolean; + }[]; +} +export enum DownloadSceneType { + KDOWNLOADSCENEUNKNOWN, + KDOWNLOADSCENEARKC2C, + KDOWNLOADSCENEARKC2CDETAILPAGE, + KDOWNLOADSCENEARKGROUP, + KDOWNLOADSCENEARKGROUPDETAILPAGE, + KDOWNLOADSCENELINKC2C, + KDOWNLOADSCENELINKGROUP, + KDOWNLOADSCENELINKCHANNEL, + KDOWNLOADSCENELINKTEMPCHAT, + KDOWNLOADSCENELINKOTHERINQQ, + KDOWNLOADSCENESCANQRCODE, + KDWONLOADSCENEFLASHTRANSFERCENTERCLIENT, + KDWONLOADSCENEFLASHTRANSFERCENTERSCHEMA, +} +export interface FlashFileSetInfo { + fileSetId: string, + name: string, + namePinyin: string, + totalFileCount: number, + totalFileSize: number, + permission: {}, + shareInfo: { + shareLink: string, + extractionCode: string, + }, + cover: { + id: string, + urls: [ + { + spec: number, // 2 + url: string; + } + ], + localCachePath: string; + }, + uploaders: [ + { + uin: string, + nickname: string, + uid: string, + sendEntrance: string; + } + ], + expireLeftTime: number, + aiClusteringStatus: { + firstClusteringList: [], + shouldPull: boolean; + }, + createTime: number, + expireTime: number, + firstLevelItemCount: 1, + svrLastUpdateTimestamp: 0, + taskId: string, // 同 fileSetId + uploadInfo: { + totalUploadedFileSize: number, + successCount: number, + failedCount: number; + }, + downloadInfo: { + totalDownloadedFileSize: 0, + totalFileSize: 0, + totalDownloadFileCount: 0, + successCount: 0, + failedCount: 0, + pausedCount: 0, + cancelCount: 0, + status: 0, + curLevelDownloadedFileCount: number, + curLevelUnDownloadedFileCount: 0; + }, + transferType: number, + isLocalCreate: true, + status: number, // todo 枚举全部状态 + uploadStatus: number, // todo 同上 + uploadPauseReason: 0, + downloadStatus: 0, + downloadPauseReason: 0, + saveFileSetDir: string, + uploadSceneType: UploadSceneType, + downloadSceneType: DownloadSceneType, // 0 PC-QQ 103 web + retryCount: number, + isMergeShareUpload: 0, + isRemoveDeviceInfo: boolean, + isRemoveLocation: boolean; +} + +export interface SendStatus { + result: number, + msg: string, + target: { + destType: number, + destUid: string, + }; +} diff --git a/packages/snowcat-core/data/group.ts b/packages/snowcat-core/data/group.ts new file mode 100644 index 0000000..6306735 --- /dev/null +++ b/packages/snowcat-core/data/group.ts @@ -0,0 +1,386 @@ +import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter, GroupManagementSettings } from '../types'; + +const MEMBER_INVITE_PRIVILEGE_MASK = 0x06100000; +const MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK = 0x1; +const MEMBER_CREATE_GROUP_FLAG = 0x8000; +const MEMBER_TEMPORARY_SESSION_FLAG = 0x10000; +const NEW_MEMBER_RECENT_HISTORY_FLAG = 0x4; + +function mergeMaskedFlag (currentFlag: number, requestedFlag: number, maskValue: number): number { + const mask = BigInt(maskValue); + return Number((BigInt(currentFlag) & ~mask) | (BigInt(requestedFlag) & mask)); +} + +export function applyGroupManagementSettings ( + param: GroupDetailInfoV2Param, + settings: GroupManagementSettings +): boolean { + let changed = false; + + if (settings.memberInvite !== undefined) { + const privilegeByPolicy = { + disabled: 0x04000000, + require_approval: 0, + no_approval: 0x00100000, + no_approval_under_100: 0x02000000, + } satisfies Record, number>; + + param.filter.allowMemberInvite = 1; + param.modifyInfo.allowMemberInvite = settings.memberInvite === 'disabled' ? 0 : 1; + param.filter.appPrivilegeFlag = 1; + param.filter.appPrivilegeMask = 1; + param.modifyInfo.appPrivilegeMask |= MEMBER_INVITE_PRIVILEGE_MASK; + param.modifyInfo.appPrivilegeFlag |= privilegeByPolicy[settings.memberInvite]; + changed = true; + } + + if (settings.allowMemberUploadAlbum !== undefined) { + param.filter.appPrivilegeFlag = 1; + param.filter.appPrivilegeMask = 1; + param.modifyInfo.appPrivilegeMask |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK; + if (!settings.allowMemberUploadAlbum) param.modifyInfo.appPrivilegeFlag |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK; + changed = true; + } + + if (settings.allowMemberTemporarySession !== undefined) { + param.filter.appPrivilegeFlag = 1; + param.filter.appPrivilegeMask = 1; + param.modifyInfo.appPrivilegeMask |= MEMBER_TEMPORARY_SESSION_FLAG; + if (!settings.allowMemberTemporarySession) param.modifyInfo.appPrivilegeFlag |= MEMBER_TEMPORARY_SESSION_FLAG; + changed = true; + } + + if (settings.allowMemberCreateGroup !== undefined) { + param.filter.appPrivilegeFlag = 1; + param.filter.appPrivilegeMask = 1; + param.modifyInfo.appPrivilegeMask |= MEMBER_CREATE_GROUP_FLAG; + if (!settings.allowMemberCreateGroup) param.modifyInfo.appPrivilegeFlag |= MEMBER_CREATE_GROUP_FLAG; + changed = true; + } + + if (settings.newMembersSeeRecentHistory !== undefined) { + param.filter.groupFlagExt4 = 1; + param.filter.groupFlagExt4Mask = 1; + param.modifyInfo.groupFlagExt4Mask |= NEW_MEMBER_RECENT_HISTORY_FLAG; + if (settings.newMembersSeeRecentHistory) param.modifyInfo.groupFlagExt4 |= NEW_MEMBER_RECENT_HISTORY_FLAG; + changed = true; + } + + return changed; +} + +export function createGroupManagementRequests ( + groupCode: string, + settings: GroupManagementSettings, + initialAppPrivilegeFlag = 0, + initialGroupFlagExt4 = 0 +) { + const requests: Array<{ setting: string, param: GroupDetailInfoV2Param, operationType: number; }> = []; + const requestSettings: Array<{ + setting: string, + value: GroupManagementSettings, + operationType: number; + }> = [ + { setting: 'member_invite', value: { memberInvite: settings.memberInvite }, operationType: 0 }, + { + setting: 'new_members_see_recent_history', + value: { newMembersSeeRecentHistory: settings.newMembersSeeRecentHistory }, + operationType: 0, + }, + { + setting: 'allow_member_upload_album', + value: { allowMemberUploadAlbum: settings.allowMemberUploadAlbum }, + operationType: 8, + }, + { + setting: 'allow_member_temporary_session', + value: { allowMemberTemporarySession: settings.allowMemberTemporarySession }, + operationType: 8, + }, + { + setting: 'allow_member_create_group', + value: { allowMemberCreateGroup: settings.allowMemberCreateGroup }, + operationType: 8, + }, + ]; + + let currentAppPrivilegeFlag = initialAppPrivilegeFlag; + let currentGroupFlagExt4 = initialGroupFlagExt4; + for (const requestSetting of requestSettings) { + const param = createGroupDetailInfoV2Param(groupCode); + if (applyGroupManagementSettings(param, requestSetting.value)) { + if (param.filter.appPrivilegeFlag) { + const nextAppPrivilegeFlag = mergeMaskedFlag( + currentAppPrivilegeFlag, + param.modifyInfo.appPrivilegeFlag, + param.modifyInfo.appPrivilegeMask + ); + if (requestSetting.operationType === 8 && nextAppPrivilegeFlag === currentAppPrivilegeFlag) continue; + currentAppPrivilegeFlag = nextAppPrivilegeFlag; + // Linux NTQQ's wrapper builds this field from the current complete privilege value. + // Supplying only the targeted bit makes zero-valued clears get acknowledged but ignored. + param.modifyInfo.appPrivilegeFlag = currentAppPrivilegeFlag; + } + if (param.filter.groupFlagExt4) { + const nextGroupFlagExt4 = mergeMaskedFlag( + currentGroupFlagExt4, + param.modifyInfo.groupFlagExt4, + param.modifyInfo.groupFlagExt4Mask + ); + if (nextGroupFlagExt4 === currentGroupFlagExt4) continue; + currentGroupFlagExt4 = nextGroupFlagExt4; + param.modifyInfo.groupFlagExt4 = currentGroupFlagExt4; + } + requests.push({ setting: requestSetting.setting, param, operationType: requestSetting.operationType }); + } + } + + return requests; +} + +export function createGroupDetailInfoV2Param (group_code: string): GroupDetailInfoV2Param { + return { + groupCode: group_code, + filter: + { + noCodeFingerOpenFlag: 0, + noFingerOpenFlag: 0, + groupName: 0, + classExt: 0, + classText: 0, + fingerMemo: 0, + richFingerMemo: 0, + tagRecord: 0, + groupGeoInfo: + { + ownerUid: 0, + setTime: 0, + cityId: 0, + longitude: 0, + latitude: 0, + geoContent: 0, + poiId: 0, + }, + groupExtAdminNum: 0, + flag: 0, + groupMemo: 0, + groupAioSkinUrl: 0, + groupBoardSkinUrl: 0, + groupCoverSkinUrl: 0, + groupGrade: 0, + activeMemberNum: 0, + certificationType: 0, + certificationText: 0, + groupNewGuideLines: + { + enabled: 0, + content: 0, + }, + groupFace: 0, + addOption: 0, + shutUpTime: 0, + groupTypeFlag: 0, + appPrivilegeFlag: 0, + appPrivilegeMask: 0, + groupExtOnly: + { + tribeId: 0, + moneyForAddGroup: 0, + }, + groupSecLevel: 0, + groupSecLevelInfo: 0, + subscriptionUin: 0, + subscriptionUid: '', + allowMemberInvite: 0, + groupQuestion: 0, + groupAnswer: 0, + groupFlagExt3: 0, + groupFlagExt3Mask: 0, + groupOpenAppid: 0, + rootId: 0, + msgLimitFrequency: 0, + hlGuildAppid: 0, + hlGuildSubType: 0, + hlGuildOrgId: 0, + groupFlagExt4: 0, + groupFlagExt4Mask: 0, + groupSchoolInfo: { + location: 0, + grade: 0, + school: 0, + }, + groupCardPrefix: + { + introduction: 0, + rptPrefix: 0, + }, + allianceId: 0, + groupFlagPro1: 0, + groupFlagPro1Mask: 0, + }, + modifyInfo: { + noCodeFingerOpenFlag: 0, + noFingerOpenFlag: 0, + groupName: '', + classExt: 0, + classText: '', + fingerMemo: '', + richFingerMemo: '', + tagRecord: [], + groupGeoInfo: { + ownerUid: '', + SetTime: 0, + CityId: 0, + Longitude: '', + Latitude: '', + GeoContent: '', + poiId: '', + }, + groupExtAdminNum: 0, + flag: 0, + groupMemo: '', + groupAioSkinUrl: '', + groupBoardSkinUrl: '', + groupCoverSkinUrl: '', + groupGrade: 0, + activeMemberNum: 0, + certificationType: 0, + certificationText: '', + groupNewGuideLines: { + enabled: false, + content: '', + }, + groupFace: 0, + addOption: 0, + shutUpTime: 0, + groupTypeFlag: 0, + appPrivilegeFlag: 0, + appPrivilegeMask: 0, + groupExtOnly: { + tribeId: 0, + moneyForAddGroup: 0, + }, + groupSecLevel: 0, + groupSecLevelInfo: 0, + subscriptionUin: '', + subscriptionUid: '', + allowMemberInvite: 0, + groupQuestion: '', + groupAnswer: '', + groupFlagExt3: 0, + groupFlagExt3Mask: 0, + groupOpenAppid: 0, + rootId: '', + msgLimitFrequency: 0, + hlGuildAppid: 0, + hlGuildSubType: 0, + hlGuildOrgId: 0, + groupFlagExt4: 0, + groupFlagExt4Mask: 0, + groupSchoolInfo: { + location: '', + grade: 0, + school: '', + }, + groupCardPrefix: + { + introduction: '', + rptPrefix: [], + }, + allianceId: '', + groupFlagPro1: 0, + groupFlagPro1Mask: 0, + }, + }; +} +export function createGroupExtInfo (group_code: string): GroupExtInfo { + return { + groupCode: group_code, + resultCode: 0, + extInfo: { + groupInfoExtSeq: 0, + reserve: 0, + luckyWordId: '', + lightCharNum: 0, + luckyWord: '', + starId: 0, + essentialMsgSwitch: 0, + todoSeq: 0, + blacklistExpireTime: 0, + isLimitGroupRtc: 0, + companyId: 0, + hasGroupCustomPortrait: 0, + bindGuildId: '', + groupOwnerId: { + memberUin: '', + memberUid: '', + memberQid: '', + }, + essentialMsgPrivilege: 0, + msgEventSeq: '', + inviteRobotSwitch: 0, + gangUpId: '', + qqMusicMedalSwitch: 0, + showPlayTogetherSwitch: 0, + groupFlagPro1: '', + groupBindGuildIds: { + guildIds: [], + }, + viewedMsgDisappearTime: '', + groupExtFlameData: { + switchState: 0, + state: 0, + dayNums: [], + version: 0, + updateTime: '', + isDisplayDayNum: false, + }, + groupBindGuildSwitch: 0, + groupAioBindGuildId: '', + groupExcludeGuildIds: { + guildIds: [], + }, + fullGroupExpansionSwitch: 0, + fullGroupExpansionSeq: '', + inviteRobotMemberSwitch: 0, + inviteRobotMemberExamine: 0, + groupSquareSwitch: 0, + }, + }; +} +export function createGroupExtFilter (): GroupExtFilter { + return { + groupInfoExtSeq: 0, + reserve: 0, + luckyWordId: 0, + lightCharNum: 0, + luckyWord: 0, + starId: 0, + essentialMsgSwitch: 0, + todoSeq: 0, + blacklistExpireTime: 0, + isLimitGroupRtc: 0, + companyId: 0, + hasGroupCustomPortrait: 0, + bindGuildId: 0, + groupOwnerId: 0, + essentialMsgPrivilege: 0, + msgEventSeq: 0, + inviteRobotSwitch: 0, + gangUpId: 0, + qqMusicMedalSwitch: 0, + showPlayTogetherSwitch: 0, + groupFlagPro1: 0, + groupBindGuildIds: 0, + viewedMsgDisappearTime: 0, + groupExtFlameData: 0, + groupBindGuildSwitch: 0, + groupAioBindGuildId: 0, + groupExcludeGuildIds: 0, + fullGroupExpansionSwitch: 0, + fullGroupExpansionSeq: 0, + inviteRobotMemberSwitch: 0, + inviteRobotMemberExamine: 0, + groupSquareSwitch: 0, + }; +} diff --git a/packages/snowcat-core/data/index.ts b/packages/snowcat-core/data/index.ts new file mode 100644 index 0000000..8a78f59 --- /dev/null +++ b/packages/snowcat-core/data/index.ts @@ -0,0 +1 @@ +export * from './group'; diff --git a/packages/snowcat-core/data/qzone-feeds.ts b/packages/snowcat-core/data/qzone-feeds.ts new file mode 100644 index 0000000..f0926e1 --- /dev/null +++ b/packages/snowcat-core/data/qzone-feeds.ts @@ -0,0 +1,412 @@ +/** + * SnowCat · QQ空间好友动态流(feeds3_html_more)—— 纯数据构造与解析 + * + * SnowLuma v2.2.1 (qzone-feed-cursor.ts + qzone-feed-content.ts + qzone.ts feeds 部分) 移植改造: + * - cursor 状态编解码(base64url,绑 uin 防串号) + * - HTML feed 内容解析(htmlparser2,不执行远程内容) + * - feeds 响应映射 + */ + +// ─────────────── cursor 状态机 ─────────────── + +const numericFields = [ + 'begintime', + 'dayspac', + 'pagenum', + 'aisortEndTime', + 'aisortOffset', + 'aisortBeginTime', + 'aisortNextTime', + 'icServerTime', +] as const; +const stringFields = ['externparam', 'daylist', 'uinlist'] as const; +const fields = [...numericFields, ...stringFields]; +type StateField = (typeof fields)[number]; +export type QzoneFeedState = Record; + +export function initialQzoneFeedState(): QzoneFeedState { + return { + begintime: '0', + dayspac: '0', + pagenum: '1', + aisortEndTime: '0', + aisortOffset: '0', + aisortBeginTime: '0', + aisortNextTime: '0', + icServerTime: '0', + externparam: '', + daylist: '', + uinlist: '', + }; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** 承接 web 客户端用的字段,含不透明服务端状态。 */ +export function nextQzoneFeedState( + main: Record, + previous: QzoneFeedState, +): QzoneFeedState { + const next = { ...previous }; + for (const field of numericFields) { + const value = main[field]; + if (value === undefined || value === '') continue; + if ( + (typeof value !== 'number' && typeof value !== 'string') || + !/^\d+$/.test(String(value)) || + !Number.isSafeInteger(Number(value)) || + (field === 'pagenum' && Number(value) < 1) + ) { + throw new Error(`空间好友动态分页字段异常: ${field}`); + } + next[field] = String(Number(value)); + } + for (const field of stringFields) { + const value = main[field]; + if (value === undefined || (value === '' && field !== 'uinlist')) continue; + if (typeof value !== 'string' || value.length > 32_768) { + throw new Error(`空间好友动态分页字段异常: ${field}`); + } + next[field] = value; + } + return next; +} + +/** 服务端时钟单独不算翻页进度。 */ +export function qzoneFeedPosition(state: QzoneFeedState): string { + return JSON.stringify( + fields.filter((field) => field !== 'icServerTime').map((field) => state[field]), + ); +} + +export function encodeQzoneFeedCursor(uin: string, page: number, state: QzoneFeedState): string { + if (!Number.isSafeInteger(page) || page < 2) { + throw new Error('空间好友动态分页页码超出范围'); + } + const cursor = Buffer.from(JSON.stringify({ version: 1, uin, page, state })).toString( + 'base64url', + ); + if (cursor.length > 65_536) { + throw new Error('空间好友动态分页游标过大'); + } + return cursor; +} + +export function decodeQzoneFeedCursor( + cursor: string, + uin: string, +): { page: number; state: QzoneFeedState } { + 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 ( + !record(value) || + value['version'] !== 1 || + value['uin'] !== uin || + typeof value['page'] !== 'number' || + !Number.isSafeInteger(value['page']) || + value['page'] < 2 || + !record(value['state']) + ) { + throw new Error('空间好友动态 cursor 无效或不属于当前账号'); + } + const rawState = value['state']; + if ( + Object.keys(rawState).length !== fields.length || + !fields.every((field) => typeof rawState[field] === 'string') + ) { + throw new Error('无效的空间好友动态 cursor 状态'); + } + const state = nextQzoneFeedState(rawState, initialQzoneFeedState()); + if (fields.some((field) => state[field] !== rawState[field])) { + throw new Error('无效的空间好友动态 cursor 状态'); + } + return { page: value['page'], state }; +} + +// ─────────────── HTML feed 内容解析(htmlparser2,数据级不执行) ─────────────── + +import { DomUtils, parseDocument } from 'htmlparser2'; + +type Element = ReturnType[number]; +type Node = ReturnType['children'][number]; + +export interface QzoneFeedContent { + tid: string | null; + content: string | null; + content_complete: boolean | null; + images: string[] | null; + like_count: number | null; + comment_count: number | null; +} + +function hasClass(node: Element, name: string): boolean { + return (node.attribs['class'] ?? '').split(/\s+/).includes(name); +} + +function isContentNode(node: Element, root: Element): boolean { + for (let parent = node.parent; parent && parent !== root; parent = parent.parent) { + if ( + 'attribs' in parent && + (hasClass(parent, 'mod-comments') || + hasClass(parent, 'comments-list') || + hasClass(parent, 'f-item-passive') || + hasClass(parent, 'f-ct-video')) + ) { + return false; + } + } + return true; +} + +function text(node: Element): string { + const chunks: string[] = []; + const stack: (Node | string)[] = [...node.children].reverse(); + while (stack.length) { + const next = stack.pop()!; + if (typeof next === 'string') { + chunks.push(next); + } else if (next.type === 'text') { + chunks.push(next.data.replace(/\s+/g, ' ')); + } else if (next.type === 'tag') { + if (next.attribs['data-cmd'] === 'qz_toggle' || hasClass(next, 'load_img')) { + continue; + } + if (next.name === 'br') { + chunks.push('\n'); + } else if (next.name === 'img') { + chunks.push(next.attribs['alt'] ?? ''); + } else { + if (['div', 'p', 'li'].includes(next.name)) { + chunks.push('\n'); + stack.push('\n'); + } + for (let i = next.children.length - 1; i >= 0; i--) { + const child = next.children[i]; + if (child !== undefined) stack.push(child); + } + } + } + } + return chunks + .join('') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function count(value: unknown): number | null { + if (typeof value !== 'number' && (typeof value !== 'string' || !/^\d+$/.test(value))) { + return null; + } + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : null; +} + +function imageUrl(value: string | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value.startsWith('//') ? `https:${value}` : value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null; + } catch { + return null; + } +} + +/** 只抽帖子本身的内容;评论/头像/视频封面另算。 */ +export function parseQzoneFeedContent( + html: string, + key: string, + uin: number, + appid: number, + likeCount?: unknown, + commentCount?: unknown, +): QzoneFeedContent { + const doc = parseDocument(html); + const elements = DomUtils.findAll(() => true, doc.children); + const body = elements.find( + (node) => hasClass(node, 'f-item') && node.attribs['data-key'] === key, + ); + const result: QzoneFeedContent = { + tid: null, + content: null, + content_complete: null, + images: null, + like_count: count(likeCount), + comment_count: count(commentCount), + }; + if (!body) return result; + + let root = body; + for (let parent = body.parent; parent && 'attribs' in parent; parent = parent.parent) { + if (hasClass(parent, 'f-single')) { + root = parent; + break; + } + } + + const bodyElements = DomUtils.findAll((node) => isContentNode(node, body), body.children); + const metadata = bodyElements.find( + (node) => + node.attribs['name'] === 'feed_data' && + node.attribs['data-uin'] === String(uin) && + node.attribs['data-fkey'] === key, + ); + + if (appid === 311 && metadata?.attribs['data-tid']) { + result.tid = metadata.attribs['data-tid']; + } + + const contentNodes = bodyElements.filter((node) => hasClass(node, 'f-info')); + const complete = contentNodes.find( + (node) => hasClass(node, 'qz_info_complete') && text(node) !== '', + ); + const content = complete ?? contentNodes.find((node) => !hasClass(node, 'qz_info_complete')); + + if (content) { + result.content = text(content); + result.content_complete = + !!complete || + !DomUtils.findAll((node) => node.attribs['data-cmd'] === 'qz_toggle', content.children) + .length; + } + + const images = new Set(); + for (const item of bodyElements.filter((node) => hasClass(node, 'img-item'))) { + if (item.attribs['data-v_itemid'] || item.attribs['data-videotype']) { + continue; + } + const full = imageUrl(item.attribs['data-originurl'] ?? item.attribs['data-url3']); + if (full) { + images.add(full); + continue; + } + for (const image of DomUtils.findAll((node) => node.name === 'img', item.children)) { + const url = imageUrl(image.attribs['truesrc'] ?? image.attribs['src']); + if (url) { + images.add(url); + } + } + } + + result.images = [...images]; + const like = DomUtils.findAll( + (node) => hasClass(node, 'qz_like_btn_v3') && isContentNode(node, root), + root.children, + )[0]; + result.like_count ??= count(like?.attribs['data-likecnt']); + return result; +} + +// ─────────────── feeds 响应 shape 与映射 ─────────────── + +export interface RawFeedsItem { + uin?: number | string; + nickname?: string; + abstime?: number | string; + appid?: number | string; + key?: string; + feedskey?: string; + html?: string; + likecnt?: unknown; + commentcnt?: unknown; +} + +interface RawFeedsMain { + hasMoreFeeds?: boolean | number | string; + error?: string; + [key: string]: unknown; +} + +export interface RawFeedsResponse { + code?: number; + subcode?: number; + message?: string; + data?: { + main?: RawFeedsMain | null; + hasmore?: boolean | number | string; + data?: (RawFeedsItem | null | undefined)[] | null; + } | null; +} + +export interface QzoneFeed { + uin: number; + nickname: string; + time: number; + appid: number; + key: string; + html: string; +} + +export interface QzoneFeedWithContent extends QzoneFeed, QzoneFeedContent {} + +export interface QzoneFeedsPage { + feeds: QzoneFeedWithContent[]; + has_more: boolean; + next_cursor: string | null; +} + +function feedHasMore(data: RawFeedsResponse): boolean { + const value = data.data?.main === undefined ? data.data?.hasmore : data.data?.main?.hasMoreFeeds; + if (value === true || value === 1 || value === '1') return true; + if (value === false || value === 0 || value === '0') return false; + throw new Error('空间好友动态缺少有效的分页结束标志'); +} + +/** 纯映射;翻页续传由 getQzoneFeeds 接线。 */ +export function mapQzoneFeeds(data: RawFeedsResponse): QzoneFeedsPage { + if (!Array.isArray(data.data?.data)) { + throw new Error('无法获取空间好友动态(响应结构异常)'); + } + // web 载荷用 null/undefined 填充列表;这些不是 feed 条目 + const list = data.data.data.filter((item) => item != null); + return { + feeds: list.map((item) => { + if (typeof item !== 'object' || Array.isArray(item)) { + throw new Error('空间好友动态条目结构异常'); + } + const feed: QzoneFeed = { + uin: Number(item['uin'] ?? 0), + nickname: String(item['nickname'] ?? ''), + time: Number(item['abstime'] ?? 0), + appid: Number(item['appid'] ?? 0), + key: String(item['key'] ?? item['feedskey'] ?? ''), + html: String(item['html'] ?? ''), + }; + if ( + ![feed.uin, feed.time, feed.appid].every( + (n) => Number.isSafeInteger(n) && n >= 0, + ) || + typeof feed.nickname !== 'string' || + typeof feed.html !== 'string' + ) { + throw new Error('空间好友动态条目字段异常'); + } + return { + ...feed, + ...parseQzoneFeedContent( + feed.html, + feed.key, + feed.uin, + feed.appid, + item['likecnt'], + item['commentcnt'], + ), + }; + }), + has_more: feedHasMore(data), + next_cursor: null, + }; +} diff --git a/packages/snowcat-core/data/qzone-web.ts b/packages/snowcat-core/data/qzone-web.ts new file mode 100644 index 0000000..ed10a2a --- /dev/null +++ b/packages/snowcat-core/data/qzone-web.ts @@ -0,0 +1,961 @@ +/** + * SnowCat · QQ空间(Qzone)个人空间 Web 线 —— 纯数据构造与解析 + * + * 从 SnowLuma v2.2.1 (packages/protocol/src/web/qzone.ts) 移植改造: + * - SnowCat 已有 buildQzoneUploadImageBody/buildQzonePublishBody/buildQzoneDeleteBody + * (data/qzone.ts 原件保留),本文件只补缺的:读列表/详情/点赞/评论/回复/资料/拉黑/权限。 + * - 不发起任何网络请求,方便单独测试(对齐 data/qzone.ts 头注)。 + * - 统一走 NapCat 的 cookie 串(getQzoneAuth 产出的 p_uin/p_skey/skey/uin 形态), + * 不用 SnowLuma 的 cookieObject Record 形态——但保留 tolerant 解析器。 + */ + +// ─────────────── tolerant JSON/JSONP 解析(SnowLuma 原样移植) ─────────────── + +/** + * 解析 Qzone CGI body:可能是裸 JSON,也可能是 JSONP 回调包裹 + * (`_Callback({...});` / `frameElement.callback({...});` / `back({...})`)。 + * 分层 fallback:直接 JSON.parse {} 切片 → back({...}) → 末个 back(...) 提取。 + * 无对象体时抛错(如 HTML 错误页)。 + */ +export function parseQzoneJson(text: string): T { + const s = text.trim(); + + // 1st: 直接 JSON.parse {} 切片 + try { + const start = s.indexOf('{'); + const end = s.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + return JSON.parse(s.slice(start, end + 1)) as T; + } + } catch { /* fallback */ } + + // 2nd: back({...}) 整体 + try { + const start = s.indexOf('back({'); + const end = s.lastIndexOf('})'); + if (start !== -1 && end !== -1 && end > start) { + return JSON.parse(s.slice(start + 5, end + 1)) as T; + } + } catch { /* fallback */ } + + // 3rd: 找最后一个 back(...),取内部 {} + const backStart = s.lastIndexOf('back('); + if (backStart === -1) { + throw new Error('invalid response from qzone api'); + } + const callEnd = s.indexOf(');', backStart); + if (callEnd === -1) { + throw new Error('invalid response from qzone api'); + } + const inner = s.slice(backStart + 5, callEnd).trim(); + const braceStart = inner.indexOf('{'); + const braceEnd = inner.lastIndexOf('}'); + if (braceStart === -1 || braceEnd === -1 || braceEnd < braceStart) { + throw new Error('invalid response from qzone api'); + } + return JSON.parse(inner.slice(braceStart, braceEnd + 1)) as T; +} + +/** + * 解析 feeds3_html_more 的 JS 对象字面量载荷(不执行)。 + * `_preloadCallback({ … })` 的 data 用无引号键/单引号串/\xNN 转义/字面 undefined + * ——给浏览器回调 eval 用的,JSON.parse 咽不下。替代 eval(vm 沙箱不是安全边界), + * 递归下降把字面量当数据解析。`__proto__` 键丢弃(防原型污染)。 + * SnowLuma 原样移植。 + */ +export function parseJsLiteral(src: string): unknown { + let i = 0; + const n = src.length; + const isWs = (c: string): boolean => + c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v'; + + const skipWs = (): void => { + while (i < n && isWs(src[i]!)) i++; + }; + + function parseString(quote: string): string { + i++; // 开引号 + let out = ''; + while (i < n) { + const c = src[i]!; + if (c === '\\') { + const e = src[i + 1]; + if (e === 'x') { + out += String.fromCharCode(parseInt(src.slice(i + 2, i + 4), 16)); + i += 4; + continue; + } + if (e === 'u') { + out += String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16)); + i += 6; + continue; + } + const simple: Record = { + n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0', + }; + out += e !== undefined ? (simple[e] ?? e) : ''; + i += 2; + continue; + } + if (c === quote) { + i++; + return out; + } + out += c; + i++; + } + throw new Error('unterminated string in qzone feeds payload'); + } + + function parseKey(): string { + skipWs(); + const c = src[i]; + if (c === '"' || c === "'") return parseString(c); + let s = ''; + while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) { + s += src[i]; + i++; + } + if (!s) throw new Error('expected object key in qzone feeds payload'); + return s; + } + + function parseObject(): Record { + i++; // { + const obj: Record = {}; + skipWs(); + if (src[i] === '}') { i++; return obj; } + for (;;) { + const key = parseKey(); + skipWs(); + if (src[i] !== ':') throw new Error('expected ":" in qzone feeds payload'); + i++; + const value = parseValue(); + if (key !== '__proto__') obj[key] = value; + skipWs(); + const ch = src[i]; + if (ch === ',') { + i++; + skipWs(); + if (src[i] === '}') { i++; return obj; } + continue; + } + if (ch === '}') { i++; return obj; } + throw new Error('expected "," or "}" in qzone feeds payload'); + } + } + + function parseArray(): unknown[] { + i++; // [ + const arr: unknown[] = []; + skipWs(); + if (src[i] === ']') { i++; return arr; } + for (;;) { + arr.push(parseValue()); + skipWs(); + const ch = src[i]; + if (ch === ',') { + i++; + skipWs(); + if (src[i] === ']') { i++; return arr; } + continue; + } + if (ch === ']') { i++; return arr; } + throw new Error('expected "," or "]" in qzone feeds payload'); + } + } + + function parseValue(): unknown { + skipWs(); + const c = src[i]; + if (c === undefined) throw new Error('unexpected end of qzone feeds payload'); + if (c === '{') return parseObject(); + if (c === '[') return parseArray(); + if (c === '"' || c === "'") return parseString(c); + let token = ''; + while (i < n && !/[,}\]:\s]/.test(src[i]!)) { + token += src[i]; + i++; + } + if (token === 'true') return true; + if (token === 'false') return false; + if (token === 'null') return null; + if (token === 'undefined') return undefined; + if (token !== '') { + const num = Number(token); + if (!Number.isNaN(num)) return num; + } + throw new Error('unexpected token in qzone feeds payload: ' + token.slice(0, 20)); + } + + return parseValue(); +} + +/** + * 提取 Qzone feeds JSONP body 回调实参对象,按数据解析(不执行)。 + * 从首个 `{` 切片解析一个平衡值;尾部 `);` 忽略。非对象体抛错。 + */ +export function parseQzoneCallback(text: string): T { + const start = text.indexOf('{'); + if (start === -1) { + throw new Error('invalid feeds response from qzone api'); + } + const value = parseJsLiteral(text.slice(start)); + if (value === null || typeof value !== 'object') { + throw new Error('invalid feeds response from qzone api'); + } + return value as T; +} + +// ─────────────── 原始响应 shape(taotao emotion_cgi_msglist_v6) ─────────────── + +interface RawPic { + url1?: string; + url2?: string; + url3?: string; + smallurl?: string; +} + +interface RawEmotion { + tid?: string; + content?: string; + created_time?: number; + cmtnum?: number; + secret?: number; + pic?: RawPic[]; +} + +export interface RawMsgListResponse { + code?: number; + subcode?: number; + message?: string; + total?: number; + msglist?: RawEmotion[] | null; +} + +// ─────────────── OneBot 面向 shape ─────────────── + +/** 一条说说,规范化后对 OneBot 友好的形态。 */ +export interface QzoneEmotion { + tid: string; + content: string; + /** 发帖 unix 秒 */ + time: number; + comment_num: number; + /** 仅自己可见标记 */ + is_private: boolean; + /** 每张图取最大可用的 URL */ + images: string[]; +} + +export interface QzoneMsgListResult { + /** 该账号说说总数(非本页数量) */ + total: number; + msglist: QzoneEmotion[]; +} + +export interface QzoneProfile { + name: string; + description: string; +} + +export interface QzoneProfileUpdate { + name?: string; + description?: string; +} + +export interface QzoneCommentReply { + uin: number; + name: string; + /** unix 秒 */ + time: number; + content: string; + /** 在所属说说(或父评论)内唯一 */ + comment_id: string; +} + +export interface QzoneComment extends QzoneCommentReply { + /** Qzone 内联附带的回复;可能不含全部回复 */ + replies: QzoneCommentReply[]; +} + +export interface QzoneMsgDetailResult { + tid: string; + content: string; + /** 服务端总数,非本页评论数 */ + comment_num: number; + comments: QzoneComment[]; +} + +function pickPicUrl(pic: RawPic): string | undefined { + return pic.url1 ?? pic.url2 ?? pic.url3 ?? pic.smallurl; +} + +/** 纯映射:msglist_v6 → 规范化列表。 */ +export function mapQzoneMsgList(data: RawMsgListResponse): QzoneMsgListResult { + const list = data.msglist ?? []; + return { + total: Number(data.total ?? list.length), + msglist: list.map((e) => ({ + tid: String(e.tid ?? ''), + content: e.content ?? '', + time: Number(e.created_time ?? 0), + comment_num: Number(e.cmtnum ?? 0), + is_private: Number(e.secret ?? 0) !== 0, + images: (e.pic ?? []).map(pickPicUrl).filter((u): u is string => !!u), + })), + }; +} + +// ─────────────── 说说列表路由(双路防 -10000 限频) ─────────────── + +export interface QzoneRoute { + url: string; + headers: Record; +} + +/** 主路(legacy):h5.qzone.qq.com → taotao.qzone.qq.com。 */ +export function legacyMsgListRoute( + cookie: string, + targetUin: string, + pos: number, + num: number, + bkn: string, +): QzoneRoute { + const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams( + { + uin: targetUin, + ftype: '0', + sort: '0', + pos: String(pos), + num: String(num), + replynum: '100', + g_tk: bkn, + callback: '_preloadCallback', + code_version: '1', + format: 'jsonp', + need_private_comment: '1', + }, + ).toString()}`; + return { url, headers: { Cookie: cookie } }; +} + +/** 备路:user.qzone.qq.com → taotao.qq.com(注意域不同)。实测不吃 legacy 路的 -10000 限频。 */ +export function userMsgListRoute( + cookie: string, + targetUin: string, + pos: number, + num: number, + bkn: string, +): QzoneRoute { + const url = `https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams( + { + uin: targetUin, + ftype: '0', + sort: '0', + pos: String(pos), + num: String(num), + g_tk: bkn, + code_version: '1', + format: 'json', + }, + ).toString()}`; + return { + url, + headers: { + Cookie: cookie, + Referer: `https://user.qzone.qq.com/${targetUin}`, + }, + }; +} + +// ─────────────── 权限/目标校验 ─────────────── + +export const QZONE_UGC_RIGHTS = new Set([1, 4, 16, 64, 128]); + +export function normalizeQzoneUgcRight(ugcRight: number): number { + if (!QZONE_UGC_RIGHTS.has(ugcRight)) { + throw new Error('ugc_right must be one of 1, 4, 16, 64, 128'); + } + return ugcRight; +} + +export function normalizeTargetUins(targetUins?: string | number[]): string { + const raw = Array.isArray(targetUins) + ? targetUins.join('|') + : (targetUins ?? '').trim(); + if (!raw) return ''; + const seen = new Set(); + for (const part of raw.split('|')) { + const uin = part.trim(); + if (!uin) continue; + if (!/^\d+$/.test(uin)) { + throw new Error('target_uins must contain QQ numbers separated by |'); + } + seen.add(uin); + } + return [...seen].join('|'); +} + +// ─────────────── 发说说 body(SnowCat 已有 buildQzonePublishBody,此处为富参版) ─────────────── + +export interface RawPublishResponse { + code?: number; + subcode?: number; + message?: string; + // publish_v6 成功包络把新 feed id 命名为 t1_tid、时间 t1_time(字符串)。 + // tid/now 是防御性 fallback,t1_tid 才是主名。 + t1_tid?: string; + t1_time?: string; + tid?: string; + now?: number; +} + +export interface QzonePublishResult { + tid: string; + time: number; +} + +/** 解析发说说响应,提取 tid+time。code!=0 或缺 tid 都抛错。 */ +export function parseQzonePublishFull(text: string): QzonePublishResult { + const data = parseQzoneJson(text); + if (typeof data.code === 'number' && data.code !== 0) { + throw new Error(`qzone publish failed: code=${data.code} ${data.message ?? ''}`.trim()); + } + const tid = data.t1_tid ?? data.tid; + if (!tid) { + throw new Error('发表说说失败:响应缺少 tid'); + } + return { tid: String(tid), time: Number(data.t1_time ?? data.now ?? 0) }; +} + +// ─────────────── 点赞/取消赞(internal_dolike_app) ─────────────── + +export interface RawLikeResponse { + code?: number; + subcode?: number; + message?: string; +} + +/** 构造点赞/取消赞表单体。unikey/curkey 定位说说(mood, appid 311),fid=tid。 */ +export function buildQzoneLikeBody( + opUin: string, + targetUin: string, + tid: string, + abstime = 0, +): string { + const unikey = `http://user.qzone.qq.com/${targetUin}/mood/${tid}`; + return new URLSearchParams({ + qzreferrer: `https://user.qzone.qq.com/${opUin}`, + opuin: opUin, + unikey, + curkey: unikey, + appid: '311', + typeid: '0', + abstime: String(abstime), + fid: tid, + from: '1', + active: '0', + fupdate: '1', + format: 'json', + }).toString(); +} + +/** 解析点赞响应:非零 code/subcode 抛错。 */ +export function parseQzoneLikeResponse(text: string, verb: string): void { + const data = parseQzoneJson(text); + if (typeof data.code === 'number' && data.code !== 0) { + throw new Error(`qzone ${verb} failed: code=${data.code} ${data.message ?? ''}`.trim()); + } + if (typeof data.subcode === 'number' && data.subcode !== 0) { + throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${data.message ?? ''}`.trim()); + } +} + +// ─────────────── 评论说说(emotion_cgi_re_feeds) ─────────────── + +interface RawCommentResponse { + code?: number; + subcode?: number; + message?: string; + commentid?: string | number; + commentId?: string | number; +} + +export interface QzoneCommentResult { + /** 新评论 id(响应缺失时为 ''——code 0 不算失败) */ + comment_id: string; +} + +export interface QzoneReplyTarget { + /** 顶层评论 id(即便回复的是它的楼中楼) */ + commentId: string; + commentUin: string; + /** 可选:楼中楼里点名的作者 */ + replyTo?: { uin: string; name: string }; +} + +export interface QzoneReplyResult extends QzoneCommentResult { + parent_comment_id: string; + parent_comment_uin: number; +} + +/** 构造评论说说表单体。topicId 形如 `___1`(__1 后缀为社区实现主流形态)。 */ +export function buildQzoneCommentBody( + selfUin: string, + hostUin: string, + tid: string, + content: string, + richType?: number, + richval?: string, +): string { + return new URLSearchParams({ + qzreferrer: `https://user.qzone.qq.com/${selfUin}`, + inCharset: 'utf-8', + outCharset: 'utf-8', + hostUin, + format: 'json', + ref: 'feeds', + topicId: `${hostUin}_${tid}__1`, + feedsType: '100', + private: '0', + paramstr: '1', + richtype: richType !== undefined ? String(richType) : '', + richval: richval ?? '', + isSignIn: '', + uin: selfUin, + content, + plat: 'qzone', + source: 'ic', + platformid: '52', + }).toString(); +} + +/** 解析评论响应。 */ +export function parseQzoneCommentResponse(text: string, hostUin: string, tid: string): QzoneCommentResult { + const data = parseQzoneJson(text); + if (typeof data.code === 'number' && data.code !== 0) { + throw new Error(`qzone comment failed: code=${data.code} ${data.message ?? ''}`.trim()); + } + if (typeof data.subcode === 'number' && data.subcode !== 0) { + throw new Error(`qzone comment failed: subcode=${data.subcode} ${data.message ?? ''}`.trim()); + } + const commentId = data.commentid ?? data.commentId; + void hostUin; void tid; // 日志参数留位 + return { comment_id: commentId !== undefined ? String(commentId) : '' }; +} + +/** 构造楼中楼回复表单体(独立端点 emotion_cgi_addreply_ugc)。 */ +export function buildQzoneReplyBody( + selfUin: string, + hostUin: string, + tid: string, + content: string, + target: QzoneReplyTarget, +): string { + let replyContent = content; + if (target.replyTo !== undefined) { + const { uin, name } = target.replyTo; + // 对齐 web 编辑器的 @ 提及编码 + const escapedName = name + .trim() + .replace(/%/g, '%25') + .replace(/,/g, '%2C') + .replace(/}/g, '%7D'); + replyContent = `@{uin:${uin},nick:${escapedName},auto:1} ${content}`; + } + return new URLSearchParams({ + uin: selfUin, + hostUin, + hostuin: selfUin, + topicId: `${hostUin}_${tid}`, + commentId: target.commentId, + commentUin: target.commentUin, + content: replyContent, + inCharset: 'utf-8', + outCharset: 'utf-8', + ref: '', + code_version: '1', + format: 'fs', + qzreferrer: `https://user.qzone.qq.com/${hostUin}`, + }).toString(); +} + +// ─────────────── 拉黑/解拉黑(cgi_black_action_new) ─────────────── + +interface RawBlackActionResponse { + code?: number; + subcode?: number; + message?: string; + msg?: string; +} + +/** 构造空间拉黑表单体。action=1 拉黑,action=2 解拉黑。 */ +export function buildQzoneBlackBody(selfUin: string, targetUin: string, ban: boolean): string { + return new URLSearchParams({ + uin: selfUin, + act_uin: targetUin, + action: ban ? '1' : '2', + fupdate: '1', + qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`, + }).toString(); +} + +/** 解析拉黑响应(响应带 {name:"Ack"} 无引号键碎片,先补引号再解析)。 */ +export function parseQzoneBlackResponse(text: string, ban: boolean): void { + const verb = ban ? 'ban' : 'unban'; + let data: RawBlackActionResponse; + try { + data = parseQzoneJson(text.replace(/\{\s*name\s*:/, '{"name":')); + } catch { + const snippet = text.trim().slice(0, 300); + throw new Error(`qzone ${verb} failed: ${snippet}${text.length > 300 ? '…' : ''}`); + } + const msg = data.message ?? data.msg ?? ''; + if (typeof data.subcode === 'number' && data.subcode !== 0) { + throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${msg}`.trim()); + } + if (typeof data.code === 'number' && data.code !== 0) { + throw new Error(`qzone ${verb} failed: code=${data.code} ${msg}`.trim()); + } +} + +// ─────────────── 说说详情(emotion_cgi_msgdetail_v6) ─────────────── + +interface RawMsgDetailPic { + pic_id?: string; + pictype?: number | string; + type?: number | string; + height?: number | string; + b_height?: number | string; + width?: number | string; + b_width?: number | string; + smallurl?: string; + url1?: string; + url2?: string; + url3?: string; +} + +export interface RawMsgDetailResponse { + code?: number; + subcode?: number; + message?: string; + msg?: string; + tid?: string; + uin?: number | string; + content?: string; + cmtnum?: unknown; + commentlist?: unknown; + conlist?: Array<{ con?: string }> | null; + pic?: RawMsgDetailPic[] | null; + richtype?: number | string; + richval?: string; + pic_template?: string; + special_url?: string; + t1_subtype?: number | string; + subrichtype?: number | string; + feedversion?: number | string; + ver?: number | string; + ugc_right?: number | string; + to_sign?: number | string; + ugcright_id?: string; + code_version?: number | string; +} + +export interface RawUpdateResponse { + code?: number; + subcode?: number; + message?: string; + msg?: string; + ugc_right?: number | string; +} + +export interface QzoneUpdateRightResult { + /** 更新后的 ugc_right(服务端回显时用回显值) */ + ugc_right: number; +} + +/** 构造详情 GET 查询串(注意代理域是 taotao.qq.com 非 taotao.qzone.qq.com)。 */ +export function buildQzoneMsgDetailQuery( + targetUin: string, + tid: string, + bkn: string, + page?: { pos: number; num: number }, +): string { + return new URLSearchParams({ + tid, + uin: targetUin, + t1_source: '1', + not_trunc_con: '1', + need_right: '1', + not_adapt_outpic: '1', + g_tk: bkn, + ...(page ? { pos: String(page.pos), num: String(page.num) } : {}), + }).toString(); +} + +/** 从 msgdetail_v6 响应重建 emotion_cgi_update 全量载荷(php-qzone buildUpdatePayloadFromDetail 逐字段移植)。 */ +export function buildQzoneUpdatePayload( + detail: RawMsgDetailResponse, + selfUin: string, +): URLSearchParams { + if (!detail.tid) { + throw new Error('qzone msg detail is missing tid'); + } + + const pics = detail.pic ?? []; + const richtype = + detail.richtype !== undefined ? Number(detail.richtype) : pics.length === 0 ? '' : 1; + const richvals: string[] = []; + const picBoItems: string[] = []; + + for (const pic of pics) { + const picId = String(pic.pic_id ?? ''); + if (!picId) continue; + const parts = picId.split(','); + const albumId = parts[1] ?? ''; + const lloc = parts[2] ?? ''; + if (!albumId || !lloc) { + throw new Error('qzone msg detail has unsupported pic info'); + } + const sloc = lloc; + const picType = String(pic.pictype ?? pic.type ?? 22); + const height = String(pic.height ?? pic.b_height ?? 0); + const width = String(pic.width ?? pic.b_width ?? 0); + richvals.push(`,${albumId},${lloc},${sloc},${picType},${height},${width},,0,0`); + + for (const urlKey of ['smallurl', 'url1', 'url2', 'url3'] as const) { + const picUrl = pic[urlKey]; + if (!picUrl) continue; + const match = /[?&]bo=([^&#]+)/.exec(picUrl); + if (match) { + picBoItems.push(decodeURIComponent(match[1]!)); + break; + } + } + } + + if (richtype === 1 && richvals.length === 0) { + throw new Error('qzone image post detail is missing pic info'); + } + + let content = detail.content ?? ''; + const conParts = (detail.conlist ?? []) + .map((c) => c.con) + .filter((c): c is string => c !== undefined); + if (conParts.length > 0) { + content = conParts.join('').trim(); + } + + const subrichtype = detail.t1_subtype ?? detail.subrichtype ?? (richvals.length === 0 ? '' : 1); + const boGroup = picBoItems.join(','); + const picBo = boGroup ? `${boGroup}\t${boGroup}` : ''; + + const hostuin = String(detail.uin ?? selfUin); + return new URLSearchParams({ + syn_tweet_verson: '1', + tid: String(detail.tid), + paramstr: '1', + pic_template: detail.pic_template ?? '', + richtype: String(richtype), + richval: richvals.length > 0 ? richvals.join('\t') : (detail.richval ?? ''), + special_url: detail.special_url ?? '', + subrichtype: String(subrichtype), + pic_bo: picBo, + con: content, + feedversion: String(detail.feedversion ?? 1), + ver: String(detail.ver ?? 1), + ugc_right: String(Number(detail.ugc_right ?? 1)), + to_sign: String(Number(detail.to_sign ?? 0)), + ugcright_id: String(detail.ugcright_id ?? detail.tid), + hostuin, + code_version: String(detail.code_version ?? 1), + format: 'fs', + qzreferrer: `https://user.qzone.qq.com/${hostuin}`, + }); +} + +// 详情字段严格校验小件 + +export function detailObject(value: unknown, field: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`invalid qzone msg detail: ${field} must be an object`); + } + return value as Record; +} + +export function detailString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`invalid qzone msg detail: ${field} must be a string`); + } + return value; +} + +export function detailInteger(value: unknown, field: string, min = 0): number { + const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value; + if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min) { + throw new Error(`invalid qzone msg detail: ${field} must be an integer >= ${min}`); + } + return number; +} + +export function detailList(value: unknown, field: string): unknown[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + throw new Error(`invalid qzone msg detail: ${field} must be an array`); + } + return value; +} + +function mapQzoneComment(value: Record, field: string): QzoneCommentReply { + const id = + typeof value['tid'] === 'number' + ? String(detailInteger(value['tid'], `${field}.tid`)) + : detailString(value['tid'], `${field}.tid`); + if (!id) { + throw new Error(`invalid qzone msg detail: ${field}.tid is empty`); + } + return { + comment_id: id, + uin: detailInteger(value['uin'], `${field}.uin`, 1), + name: detailString(value['name'], `${field}.name`), + time: detailInteger(value['create_time'], `${field}.create_time`), + content: detailString(value['content'], `${field}.content`), + }; +} + +/** + * 详情响应 → 规范化(含评论+内联回复)。结构坏数据不得伪装成成功的空页。 + * 对齐 SmartHypercube/Qzone-API 的 Comment / Emotion.load。 + */ +export function mapQzoneMsgDetail( + data: RawMsgDetailResponse, + tid: string, +): QzoneMsgDetailResult { + const resultTid = detailString(data.tid, 'tid'); + if (!resultTid || resultTid !== tid) { + throw new Error('invalid qzone msg detail: tid does not match the requested post'); + } + return { + tid: resultTid, + content: detailString(data.content, 'content'), + comment_num: detailInteger(data.cmtnum, 'cmtnum'), + comments: detailList(data.commentlist, 'commentlist').map((value, index) => { + const field = `commentlist[${index}]`; + const comment = detailObject(value, field); + const mappedComment = mapQzoneComment(comment, field); + return { + ...mappedComment, + replies: detailList(comment['list_3'], `${field}.list_3`).map((reply, replyIndex) => { + const replyField = `${field}.list_3[${replyIndex}]`; + return { + ...mapQzoneComment(detailObject(reply, replyField), replyField), + parent_comment_id: mappedComment.comment_id, + parent_comment_uin: mappedComment.uin, + }; + }), + }; + }), + }; +} + +/** 详情响应错误包络统一判定(code 或 subcode 任一非零即失败,subcode=0 不遮掩 code!=0)。 */ +export function parseQzoneDetailError(data: RawMsgDetailResponse, tid: string): void { + const code = + data.code !== undefined && data.code !== 0 ? data.code : (data.subcode ?? data.code ?? -1); + if (code !== 0) { + throw new Error( + `qzone msg detail failed: code=${code} ${data.message ?? data.msg ?? ''}`.trim(), + ); + } + void tid; +} + +/** 解析 emotion_cgi_update(改权限)响应。 */ +export function parseQzoneUpdateRightResponse( + text: string, + tid: string, + right: number, +): QzoneUpdateRightResult { + const data = parseQzoneJson(text); + if (typeof data.subcode === 'number' && data.subcode !== 0) { + throw new Error( + `qzone update right failed: subcode=${data.subcode} ${data.message ?? data.msg ?? ''}`.trim(), + ); + } + if (typeof data.code === 'number' && data.code !== 0) { + throw new Error( + `qzone update right failed: code=${data.code} ${data.message ?? data.msg ?? ''}`.trim(), + ); + } + void tid; + return { ugc_right: Number(data.ugc_right ?? right) }; +} + +// ─────────────── 空间资料 ─────────────── + +interface RawQzoneProfileResponse { + code?: number; + subcode?: number; + message?: string; + data?: { + spacename?: string; + desc?: string; + }; +} + +/** 构造空间资料 GET 查询(cgi_userinfo_get_all)。 */ +export function buildQzoneProfileQuery(selfUin: string, targetUin: string, bkn: string): string { + return new URLSearchParams({ + uin: targetUin, + vuin: selfUin, + fupdate: '1', + rd: String(Math.random()), + g_tk: bkn, + }).toString(); +} + +/** 解析空间资料响应。 */ +export function parseQzoneProfileResponse(text: string): QzoneProfile { + const response = parseQzoneJson(text); + if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) { + throw new Error( + `qzone profile failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(), + ); + } + if (typeof response.data?.spacename !== 'string' || typeof response.data.desc !== 'string') { + throw new Error('invalid qzone profile response'); + } + return { name: response.data.spacename, description: response.data.desc }; +} + +/** 构造空间资料修改表单体(字段位图 mb:spacename=2048, desc=4096)。 */ +export function buildQzoneProfileUpdateBody( + selfUin: string, + profile: QzoneProfileUpdate, +): string { + const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' }); + let bitmap = 0; + // 空间设置表单用字段位图;独立富文本签名不含。 + // https://qzonestyle.gtimg.cn/qzone/v6/setting/profile/scripts/profile.js + if (profile.name !== undefined) { + params.set('spacename', profile.name.replaceAll('\r', '')); + bitmap |= 2048; + } + if (profile.description !== undefined) { + params.set('desc', profile.description.replaceAll('\r', '')); + bitmap |= 4096; + } + params.set('mb', String(bitmap)); + return params.toString(); +} + +/** 解析空间资料修改响应。 */ +export function parseQzoneProfileUpdateResponse(text: string): void { + const response = parseQzoneJson(text); + if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) { + throw new Error( + `qzone profile update failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(), + ); + } +} diff --git a/packages/snowcat-core/data/qzone.ts b/packages/snowcat-core/data/qzone.ts new file mode 100644 index 0000000..8c48f48 --- /dev/null +++ b/packages/snowcat-core/data/qzone.ts @@ -0,0 +1,199 @@ +/** + * QQ空间(Qzone)发说说/上传图片相关的纯数据构造与解析函数 + * 不发起任何网络请求,方便单独测试 + */ + +export interface QzoneUploadImageParams { + uin: string; + skey: string; + pskey: string; + g_tk: string; + base64: string; +} + +/** + * 构造上传图片到 QQ 空间的表单请求体 (application/x-www-form-urlencoded) + * 对齐 https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image + */ +export function buildQzoneUploadImageBody (params: QzoneUploadImageParams): string { + const { uin, skey, pskey, g_tk, base64 } = params; + const backUrls = `http://upbak.photo.qzone.qq.com/cgi-bin/upload/cgi_upload_image,http://119.147.64.75/cgi-bin/upload/cgi_upload_image&url=https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`; + const query = new URLSearchParams({ + filename: 'filename', + uin, + skey, + zzpaneluin: uin, + p_uin: uin, + p_skey: pskey, + uploadtype: '1', + albumtype: '7', + exttype: '0', + refer: 'shuoshuo', + output_type: 'jsonhtml', + charset: 'utf-8', + output_charset: 'utf-8', + upload_hd: '1', + hd_width: '2048', + hd_height: '10000', + hd_quality: '96', + backUrls, + base64: '1', + jsonhtml_callback: 'callback', + picfile: base64, + qzreferrer: `https://user.qzone.qq.com/${uin}/main`, + }); + return query.toString(); +} + +export interface QzoneUploadImageData { + albumid: string; + lloc: string; + type: string; + height: string; + width: string; + url?: string; +} + +export interface QzoneUploadImageRawResponse { + code?: number; + msg?: string; + data?: QzoneUploadImageData; +} + +/** + * 解析上传图片接口返回的 `frameElement.callback({...});` 包裹响应 + */ +export function parseQzoneUploadImageResponseText (raw: string): QzoneUploadImageRawResponse { + const startTag = 'frameElement.callback'; + const endTag = ''; + const startIdx = raw.indexOf(startTag); + const endIdx = raw.indexOf(endTag); + if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) { + throw new Error('QQ空间图片上传响应格式异常'); + } + const segment = raw.slice(startIdx + startTag.length, endIdx); + const jsonStart = segment.indexOf('('); + const jsonEnd = segment.lastIndexOf(')'); + if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) { + throw new Error('QQ空间图片上传响应解析失败'); + } + const jsonText = segment.slice(jsonStart + 1, jsonEnd); + try { + return JSON.parse(jsonText) as QzoneUploadImageRawResponse; + } catch { + throw new Error('QQ空间图片上传响应JSON解析失败'); + } +} + +/** + * 依据上传图片返回数据拼接 richval 字段 + * 格式: ,albumid,lloc,sloc,type,height,width,,height,width (sloc 与 lloc 一致) + */ +export function toQzoneRichval (data: QzoneUploadImageData): string { + const { albumid, lloc, type, height, width } = data; + return `,${albumid},${lloc},${lloc},${type},${height},${width},,${height},${width}`; +} + +export interface QzonePublishParams { + hostuin: string; + content: string; + /** 多张图片使用 \t 拼接后的 richval,无图时不传 */ + richval?: string; + ugcRight: number; + /** ugc_right 为 16/128 时必填,多个QQ号使用 | 拼接 */ + allowUins?: string; +} + +/** + * 构造发表说说的表单请求体 (application/x-www-form-urlencoded) + * 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6 + */ +export function buildQzonePublishBody (params: QzonePublishParams): string { + const { hostuin, content, richval, ugcRight, allowUins } = params; + const fields: Record = { + syn_tweet_verson: '1', + paramstr: '1', + con: content, + feedversion: '1', + ver: '1', + ugc_right: String(ugcRight), + to_sign: '0', + hostuin, + code_version: '1', + format: 'json', + qzreferrer: `https://user.qzone.qq.com/${hostuin}/main`, + }; + if (richval) { + fields['richtype'] = '1'; + fields['richval'] = richval; + } + if (allowUins) { + fields['allow_uins'] = allowUins; + fields['who'] = '1'; + } + return new URLSearchParams(fields).toString(); +} + +export interface QzonePublishResponse { + subcode?: number; + code?: number; + tid?: string; + t1_tid?: string; + message?: string; + msg?: string; +} + +/** + * 解析发表说说接口返回的 JSON, 提取 tid + */ +export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; } { + const subcode = json.subcode ?? json.code; + if (subcode !== undefined && subcode !== 0) { + throw new Error(json.message || json.msg || `QQ空间发说说失败, subcode=${subcode}`); + } + const tid = json.t1_tid || json.tid; + if (!tid) { + throw new Error('QQ空间发说说失败, 未返回tid'); + } + return { tid }; +} + +export interface QzoneDeleteParams { + hostuin: string; + tid: string; +} + +/** + * 构造删除说说的表单请求体 (application/x-www-form-urlencoded) + * 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6 + */ +export function buildQzoneDeleteBody (params: QzoneDeleteParams): string { + const { hostuin, tid } = params; + const fields: Record = { + hostuin, + tid, + t1_source: '1', + code_version: '1', + format: 'json', + qzreferrer: `https://user.qzone.qq.com/${hostuin}`, + }; + return new URLSearchParams(fields).toString(); +} + +export interface QzoneDeleteResponse { + subcode?: number; + code?: number; + message?: string; + msg?: string; +} + +/** + * 解析删除说说接口返回的 JSON + */ +export function parseQzoneDeleteResponse (json: QzoneDeleteResponse): { success: true; } { + const subcode = json.subcode ?? json.code; + if (subcode !== undefined && subcode !== 0) { + throw new Error(json.message || json.msg || `QQ空间删说说失败, subcode=${subcode}`); + } + return { success: true }; +} diff --git a/packages/snowcat-core/data/webapi.ts b/packages/snowcat-core/data/webapi.ts new file mode 100644 index 0000000..0434e8a --- /dev/null +++ b/packages/snowcat-core/data/webapi.ts @@ -0,0 +1,176 @@ +export interface ControlReq { + appid?: string; + asy_upload?: number; + biz_req?: BizReq; + check_type?: number; + checksum?: string; + cmd?: string; + env?: Env; + file_len?: number; + model?: number; + session?: string; + token?: Token; + uin?: string; +} + +export interface BizReq { + iAlbumTypeID: number; + iBatchID: number; + iBitmap: number; + iDistinctUse: number; + iNeedFeeds: number; + iPicHight: number; + iPicWidth: number; + iUploadTime: number; + iUploadType: number; + iUpPicType: number; + iWaterType: number; + mapExt: MapExt; + sAlbumID: string; + sAlbumName: string; + sPicDesc: string; + sPicPath: string; + sPicTitle: string; + stExtendInfo: StExtendInfo; +} + +export interface MapExt { + appid: string; + userid: string; +} + +export interface StExtendInfo { + mapParams: MapParams; +} + +export interface MapParams { + batch_num: string; + photo_num: string; + video_num: string; +} + +export interface Env { + deviceInfo: string; + refer: string; +} + +export interface Token { + appid: number; + data: string; + type: number; +} + +export function qunAlbumControl ({ + uin, + group_id, + pskey, + pic_md5, + img_size, + img_name, + sAlbumName, + sAlbumID, + photo_num = '1', + video_num = '0', + batch_num = '1', +}: { + uin: string, + group_id: string, + pskey: string, + pic_md5: string, + img_size: number, + img_name: string, + sAlbumName: string, + sAlbumID: string, + photo_num?: string, + video_num?: string, + batch_num?: string +} +): { + control_req: ControlReq[] + } { + const timestamp = Math.floor(Date.now() / 1000); + + return { + control_req: [ + { + uin, + token: { + type: 4, + data: pskey, + appid: 5, + }, + appid: 'qun', + checksum: pic_md5, + check_type: 0, + file_len: img_size, + env: { + refer: 'qzone', + deviceInfo: 'h5', + }, + model: 0, + biz_req: { + sPicTitle: img_name, + sPicDesc: '', + sAlbumName, + sAlbumID, + iAlbumTypeID: 0, + iBitmap: 0, + iUploadType: 0, + iUpPicType: 0, + iBatchID: timestamp, + sPicPath: '', + iPicWidth: 0, + iPicHight: 0, + iWaterType: 0, + iDistinctUse: 0, + iNeedFeeds: 1, + iUploadTime: timestamp, + mapExt: { + appid: 'qun', + userid: group_id, + }, + stExtendInfo: { + mapParams: { + photo_num, + video_num, + batch_num, + }, + }, + }, + session: '', + asy_upload: 0, + cmd: 'FileUpload', + }], + }; +} + +export function createStreamUpload ( + { + uin, + session, + offset, + seq, + end, + slice_size, + data, + + }: { uin: string, session: string, offset: number, seq: number, end: number, slice_size: number, data: string } +) { + return { + uin, + appid: 'qun', + session, + offset, // 分片起始位置 + data, // base64编码数据 + checksum: '', + check_type: 0, + retry: 0, // 重试次数 + seq, // 分片序号 + end, // 分片结束位置 文件总大小 + cmd: 'FileUpload', + slice_size, // 分片大小16KB 16384 + biz_req: { + iUploadType: 3, + }, + }; +} diff --git a/packages/snowcat-core/external/appid.json b/packages/snowcat-core/external/appid.json new file mode 100644 index 0000000..560bb86 --- /dev/null +++ b/packages/snowcat-core/external/appid.json @@ -0,0 +1,638 @@ +{ + "9.9.15-28060": { + "appid": 537246092, + "qua": "V1_WIN_NQ_9.9.15_28060_GW_B" + }, + "9.9.15-28131": { + "appid": 537246092, + "qua": "V1_WIN_NQ_9.9.15_28131_GW_B" + }, + "3.2.12-28060": { + "appid": 537246140, + "qua": "V1_LNX_NQ_3.2.12_28060_GW_B" + }, + "3.2.12-28131": { + "appid": 537246140, + "qua": "V1_LNX_NQ_3.2.12_28131_GW_B" + }, + "6.9.55-28131": { + "appid": 537246115, + "qua": "V1_MAC_NQ_6.9.55_28131_GW_B" + }, + "9.9.15-28327": { + "appid": 537249321, + "qua": "V1_WIN_NQ_9.9.15_28327_GW_B" + }, + "3.2.12-28327": { + "appid": 537249393, + "qua": "V1_LNX_NQ_3.2.12_28327_GW_B" + }, + "9.9.15-28418": { + "appid": 537249321, + "qua": "V1_WIN_NQ_9.9.15_28418_GW_B" + }, + "3.2.12-28418": { + "appid": 537249393, + "qua": "V1_LNX_NQ_3.2.12_28418_GW_B" + }, + "6.9.56-28418": { + "appid": 537249367, + "qua": "V1_MAC_NQ_6.9.56_28418_GW_B" + }, + "9.9.15-28498": { + "appid": 537249321, + "qua": "V1_WIN_NQ_9.9.15_28498_GW_B" + }, + "3.2.13-28788": { + "appid": 537249787, + "qua": "V1_LNX_NQ_3.2.13_28788_GW_B" + }, + "9.9.16-28788": { + "appid": 537249739, + "qua": "V1_WIN_NQ_9.9.16_28788_GW_B" + }, + "9.9.16-28971": { + "appid": 537249775, + "qua": "V1_WIN_NQ_9.9.16_28971_GW_B" + }, + "3.2.13-28971": { + "appid": 537249848, + "qua": "V1_LNX_NQ_3.2.13_28971_GW_B" + }, + "6.9.58-28971": { + "appid": 537249826, + "qua": "V1_MAC_NQ_6.9.58_28971_GW_B" + }, + "9.9.16-29271": { + "appid": 537249813, + "qua": "V1_WIN_NQ_9.9.16_29271_GW_B" + }, + "3.2.13-29271": { + "appid": 537249913, + "qua": "V1_LNX_NQ_3.2.13_29271_GW_B" + }, + "6.9.59-29271": { + "appid": 537249863, + "qua": "V1_MAC_NQ_6.9.59_29271_GW_B" + }, + "9.9.16-29456": { + "appid": 537249875, + "qua": "V1_WIN_NQ_9.9.16_29456_GW_B" + }, + "3.2.13-29456": { + "appid": 537249996, + "qua": "V1_LNX_NQ_3.2.13_29456_GW_B" + }, + "6.9.59-29456": { + "appid": 537249961, + "qua": "V1_MAC_NQ_6.9.59_29456_GW_B" + }, + "9.9.16-29927": { + "appid": 537255812, + "qua": "V1_WIN_NQ_9.9.16_29927_GW_B" + }, + "3.2.13-29927": { + "appid": 537255847, + "qua": "V1_LNX_NQ_3.2.13_29927_GW_B" + }, + "6.9.61-29927": { + "appid": 537255836, + "qua": "V1_MAC_NQ_6.9.61_29927_GW_B" + }, + "9.9.17-30366": { + "appid": 537258389, + "qua": "V1_WIN_NQ_9.9.17_30366_GW_B" + }, + "3.2.15-30366": { + "appid": 537258413, + "qua": "V1_LNX_NQ_3.2.15_30366_GW_B" + }, + "6.9.62-30366": { + "appid": 537258401, + "qua": "V1_MAC_NQ_6.9.62_30366_GW_B" + }, + "9.9.17-30483": { + "appid": 537258439, + "qua": "V1_WIN_NQ_9.9.17_30483_GW_B" + }, + "6.9.62-30483": { + "appid": 537258463, + "qua": "V1_MAC_NQ_6.9.62_30483_GW_B" + }, + "3.2.15-30483": { + "appid": 537258474, + "qua": "V1_LNX_NQ_3.2.15_30483_GW_B" + }, + "9.9.17-30594": { + "appid": 537258439, + "qua": "V1_WIN_NQ_9.9.17_30594_GW_B" + }, + "6.9.62-30594": { + "appid": 537258463, + "qua": "V1_MAC_NQ_6.9.62_30594_GW_B" + }, + "3.2.15-30594": { + "appid": 537258474, + "qua": "V1_LNX_NQ_3.2.15_30594_GW_B" + }, + "9.9.17-30851": { + "appid": 537263796, + "qua": "V1_WIN_NQ_9.9.17_30851_GW_B" + }, + "3.2.15-30851": { + "appid": 537263831, + "qua": "V1_LNX_NQ_3.2.15_30851_GW_B" + }, + "6.9.63-30851": { + "appid": 537263820, + "qua": "V1_MAC_NQ_6.9.63_30851_GW_B" + }, + "9.9.17-30899": { + "appid": 537263796, + "qua": "V1_WIN_NQ_9.9.17_30899_GW_B" + }, + "3.2.15-30899": { + "appid": 537263831, + "qua": "V1_LNX_NQ_3.2.15_30899_GW_B" + }, + "6.9.63-30899": { + "appid": 537263820, + "qua": "V1_MAC_NQ_6.9.63_30899_GW_B" + }, + "9.9.17-31219": { + "appid": 537266450, + "qua": "V1_WIN_NQ_9.9.17_31219_GW_B" + }, + "9.9.17-31245": { + "appid": 537266450, + "qua": "V1_WIN_NQ_9.9.17_31245_GW_B" + }, + "3.2.15-31245": { + "appid": 537266485, + "qua": "V1_LNX_NQ_3.2.15_31245_GW_B" + }, + "6.9.63-31245": { + "appid": 537266474, + "qua": "V1_MAC_NQ_6.9.63_31245_GW_B" + }, + "3.2.15-31363": { + "appid": 537266535, + "qua": "V1_LNX_NQ_3.2.15_31363_GW_B" + }, + "6.9.65-31363": { + "appid": 537266524, + "qua": "V1_MAC_NQ_6.9.65_31363_GW_B" + }, + "9.9.17-31363": { + "appid": 537266500, + "qua": "V1_WIN_NQ_9.9.17_31363_GW_B" + }, + "3.2.16-32690": { + "appid": 537271229, + "qua": "V1_LNX_NQ_3.2.16_32690_GW_B" + }, + "9.9.18-32690": { + "appid": 537271194, + "qua": "V1_WIN_NQ_9.9.18_32690_GW_B" + }, + "6.9.66-32690": { + "appid": 537271218, + "qua": "V1_MAC_NQ_6.9.66_32690_GW_B" + }, + "3.2.16-32721": { + "appid": 537271229, + "qua": "V1_LNX_NQ_3.2.16_32721_GW_B" + }, + "9.9.18-32793": { + "appid": 537271244, + "qua": "V1_WIN_NQ_9.9.18_32793_GW_B" + }, + "3.2.16-32793": { + "appid": 537271279, + "qua": "V1_LNX_NQ_3.2.16_32793_GW_B" + }, + "3.2.16-32869": { + "appid": 537271329, + "qua": "V1_LNX_NQ_3.2.16_32869_GW_B" + }, + "9.9.18-32869": { + "appid": 537271294, + "qua": "V1_WIN_NQ_9.9.18_32869_GW_B" + }, + "3.2.16-33139": { + "appid": 537273909, + "qua": "V1_LNX_NQ_3.2.16_33139_GW_B" + }, + "9.9.18-33139": { + "appid": 537273874, + "qua": "V1_WIN_NQ_9.9.18_33139_GW_B" + }, + "9.9.18-33800": { + "appid": 537273974, + "qua": "V1_WIN_NQ_9.9.18_33800_GW_B" + }, + "3.2.16-33800": { + "appid": 537274009, + "qua": "V1_LNX_NQ_3.2.16_33800_GW_B" + }, + "9.9.19-34231": { + "appid": 537279209, + "qua": "V1_WIN_NQ_9.9.19_34231_GW_B" + }, + "3.2.17-34231": { + "appid": 537279245, + "qua": "V1_LNX_NQ_3.2.17_34231_GW_B" + }, + "9.9.19-34362": { + "appid": 537279260, + "qua": "V1_WIN_NQ_9.9.19_34362_GW_B" + }, + "3.2.17-34362": { + "appid": 537279296, + "qua": "V1_LNX_NQ_3.2.17_34362_GW_B" + }, + "9.9.19-34467": { + "appid": 537282256, + "qua": "V1_WIN_NQ_9.9.19_34467_GW_B" + }, + "3.2.17-34467": { + "appid": 537282292, + "qua": "V1_LNX_NQ_3.2.17_34467_GW_B" + }, + "9.9.19-34566": { + "appid": 537282307, + "qua": "V1_WIN_NQ_9.9.19_34566_GW_B" + }, + "3.2.17-34566": { + "appid": 537282343, + "qua": "V1_LNX_NQ_3.2.17_34566_GW_B" + }, + "3.2.17-34606": { + "appid": 537282343, + "qua": "V1_LNX_NQ_3.2.17_34606_GW_B" + }, + "9.9.19-34606": { + "appid": 537282307, + "qua": "V1_WIN_NQ_9.9.19_34606_GW_B" + }, + "9.9.19-34740": { + "appid": 537290691, + "qua": "V1_WIN_NQ_9.9.19_34740_GW_B" + }, + "3.2.17-34740": { + "appid": 537290727, + "qua": "V1_LNX_NQ_3.2.17_34740_GW_B" + }, + "9.9.19-34958": { + "appid": 537290742, + "qua": "V1_WIN_NQ_9.9.19_34958_GW_B" + }, + "3.2.17-35184": { + "appid": 537291084, + "qua": "V1_LNX_NQ_3.2.17_35184_GW_B" + }, + "9.9.19-35184": { + "appid": 537291048, + "qua": "V1_WIN_NQ_9.9.19_35184_GW_B" + }, + "3.2.17-35341": { + "appid": 537291383, + "qua": "V1_LNX_NQ_3.2.17_35341_GW_B" + }, + "9.9.19-35341": { + "appid": 537291347, + "qua": "V1_WIN_NQ_9.9.19_35341_GW_B" + }, + "9.9.19-35469": { + "appid": 537291398, + "qua": "V1_WIN_NQ_9.9.19_35469_GW_B" + }, + "3.2.18-35951": { + "appid": 537296013, + "qua": "V1_LNX_NQ_3.2.18_35951_GW_B" + }, + "9.9.20-35951": { + "appid": 537295977, + "qua": "V1_WIN_NQ_9.9.20_35951_GW_B" + }, + "3.2.18-36580": { + "appid": 537298509, + "qua": "V1_LNX_NQ_3.2.18_36580_GW_B" + }, + "9.9.20-36580": { + "appid": 537298473, + "qua": "V1_WIN_NQ_9.9.20_36580_GW_B" + }, + "9.9.20-37012": { + "appid": 537304071, + "qua": "V1_WIN_NQ_9.9.20_37012_GW_B" + }, + "3.2.18-37012": { + "appid": 537304107, + "qua": "V1_LNX_NQ_3.2.18_37012_GW_B" + }, + "3.2.18-37051": { + "appid": 537304158, + "qua": "V1_LNX_NQ_3.2.18_37051_GW_B" + }, + "9.9.20-37051": { + "appid": 537304122, + "qua": "V1_WIN_NQ_9.9.20_37051_GW_B" + }, + "9.9.20-37475": { + "appid": 537304173, + "qua": "V1_WIN_NQ_9.9.20_37475_GW_B" + }, + "3.2.18-37475": { + "appid": 537304210, + "qua": "V1_LNX_NQ_3.2.18_37475_GW_B" + }, + "9.9.20-37625": { + "appid": 537304224, + "qua": "V1_WIN_NQ_9.9.20_37625_GW_B" + }, + "3.2.18-37625": { + "appid": 537304261, + "qua": "V1_LNX_NQ_3.2.18_37625_GW_B" + }, + "9.9.21-38503": { + "appid": 537307604, + "qua": "V1_WIN_NQ_9.9.21_38503_GW_B" + }, + "3.2.19-38503": { + "appid": 537307640, + "qua": "V1_LNX_NQ_3.2.19_38503_GW_B" + }, + "3.2.19-38626": { + "appid": 537307691, + "qua": "V1_LNX_NQ_3.2.19_38626_GW_B" + }, + "9.9.21-38711": { + "appid": 537307655, + "qua": "V1_WIN_NQ_9.9.21_38626_GW_B" + }, + "9.9.21-38960": { + "appid": 537313855, + "qua": "V1_WIN_NQ_9.9.21_38960_GW_B" + }, + "3.2.19-38960": { + "appid": 537313891, + "qua": "V1_LNX_NQ_3.2.19_38960_GW_B" + }, + "3.2.19-39038": { + "appid": 537313942, + "qua": "V1_LNX_NQ_3.2.19_39038_GW_B" + }, + "9.9.21-39038": { + "appid": 537313906, + "qua": "V1_WIN_NQ_9.9.21_39038_GW_B" + }, + "9.9.22-40362": { + "appid": 537314212, + "qua": "V1_WIN_NQ_9.9.22_40362_GW_B" + }, + "3.2.20-40768": { + "appid": 537319840, + "qua": "V1_LNX_NQ_3.2.20_40768_GW_B" + }, + "9.9.22-40768": { + "appid": 537319804, + "qua": "V1_WIN_NQ_9.9.22_40768_GW_B" + }, + "6.9.82-40768": { + "appid": 537319829, + "qua": "V1_MAC_NQ_6.9.82_40768_GW_B" + }, + "3.2.20-40824": { + "appid": 537319840, + "qua": "V1_LNX_NQ_3.2.20_40824_GW_B" + }, + "9.9.22-40824": { + "appid": 537319804, + "qua": "V1_WIN_NQ_9.9.22_40824_GW_B" + }, + "6.9.82-40824": { + "appid": 537319829, + "qua": "V1_MAC_NQ_6.9.82_40824_GW_B" + }, + "6.9.82-40990": { + "appid": 537319880, + "qua": "V1_MAC_NQ_6.9.82_40990_GW_B" + }, + "9.9.22-40990": { + "appid": 537319855, + "qua": "V1_WIN_NQ_9.9.22.40990_GW_B" + }, + "3.2.20-40990": { + "appid": 537319891, + "qua": "V1_LNX_NQ_3.2.20_40990_GW_B" + }, + "9.9.23-41679": { + "appid": 537320110, + "qua": "V1_WIN_NQ_9.9.23_41679_GW_B" + }, + "6.9.83-41679": { + "appid": 537320135, + "qua": "V1_MAC_NQ_6.9.83_41679_GW_B" + }, + "9.9.23-41785": { + "appid": 537320110, + "qua": "V1_WIN_NQ_9.9.23_41785_GW_B" + }, + "6.9.83-41785": { + "appid": 537320135, + "qua": "V1_MAC_NQ_6.9.83_41785_GW_B" + }, + "9.9.23-41857": { + "appid": 537320161, + "qua": "V1_WIN_NQ_9.9.23_41857_GW_B" + }, + "3.2.21-41857": { + "appid": 537320197, + "qua": "V1_LNX_NQ_3.2.21_41857_GW_B" + }, + "6.9.83-41857": { + "appid": 537320186, + "qua": "V1_MAC_NQ_6.9.83_41857_GW_B" + }, + "3.2.21-42086": { + "appid": 537320248, + "qua": "V1_LNX_NQ_3.2.21_42086_GW_B" + }, + "9.9.23-42086": { + "appid": 537320212, + "qua": "V1_WIN_NQ_9.9.23_42086_GW_B" + }, + "6.9.85-42086": { + "appid": 537320237, + "qua": "V1_MAC_NQ_6.9.85_42086_GW_B" + }, + "9.9.23-42430": { + "appid": 537320212, + "qua": "V1_WIN_NQ_9.9.23_42430_GW_B" + }, + "9.9.25-42744": { + "appid": 537328470, + "qua": "V1_WIN_NQ_9.9.23_42744_GW_B" + }, + "6.9.86-42744": { + "appid": 537328495, + "qua": "V1_MAC_NQ_6.9.85_42744_GW_B" + }, + "9.9.25-42905": { + "appid": 537328521, + "qua": "V1_WIN_NQ_9.9.25_42905_GW_B" + }, + "6.9.86-42905": { + "appid": 537328546, + "qua": "V1_MAC_NQ_6.9.86_42905_GW_B" + }, + "3.2.22-42941": { + "appid": 537328659, + "qua": "V1_LNX_NQ_3.2.22_42941_GW_B" + }, + "9.9.25-42941": { + "appid": 537328623, + "qua": "V1_WIN_NQ_9.9.25_42941_GW_B" + }, + "6.9.86-42941": { + "appid": 537328648, + "qua": "V1_MAC_NQ_6.9.86_42941_GW_B" + }, + "9.9.26-44175": { + "appid": 537336450, + "qua": "V1_WIN_NQ_9.9.26_44175_GW_B" + }, + "9.9.26-44343": { + "appid": 537336603, + "qua": "V1_WIN_NQ_9.9.26_44343_GW_B" + }, + "3.2.23-44343": { + "appid": 537336639, + "qua": "V1_LNX_NQ_3.2.23_44343_GW_B" + }, + "9.9.26-44498": { + "appid": 537337416, + "qua": "V1_WIN_NQ_9.9.26_44498_GW_B" + }, + "9.9.26-44725": { + "appid": 537337569, + "qua": "V1_WIN_NQ_9.9.26_44725_GW_B" + }, + "9.9.27-45627": { + "appid": 537340060, + "qua": "V1_WIN_NQ_9.9.27_45627_GW_B" + }, + "6.9.88-44725": { + "appid": 537337594, + "qua": "V1_MAC_NQ_6.9.88_44725_GW_B" + }, + "3.2.25-45758": { + "appid": 537340249, + "qua": "V1_LNX_NQ_3.2.25_45758_GW_B" + }, + "9.9.27-45758": { + "appid": 537340213, + "qua": "V1_WIN_NQ_9.9.27_45758_GW_B" + }, + "3.2.26-46494": { + "appid": 537345891, + "qua": "V1_LNX_NQ_3.2.26_46494_GW_B" + }, + "9.9.28-46494": { + "appid": 537345855, + "qua": "V1_WIN_NQ_9.9.28_46494_GW_B" + }, + "6.9.90-46494": { + "appid": 537345879, + "qua": "V1_MAC_NQ_6.9.90_46494_GW_B" + }, + "3.2.26-46928": { + "appid": 537345994, + "qua": "V1_LNX_NQ_3.2.26_46928_GW_B" + }, + "9.9.28-46928": { + "appid": 537345957, + "qua": "V1_WIN_NQ_9.9.28_46928_GW_B" + }, + "3.2.27-47354": { + "appid": 537346908, + "qua": "V1_LNX_NQ_3.2.27_47354_GW_B" + }, + "9.9.29-47354": { + "appid": 537346872, + "qua": "V1_WIN_NQ_9.9.29_47354_GW_B" + }, + "6.9.93-47354": { + "appid": 537346896, + "qua": "V1_MAC_NQ_6.9.93_47354_GW_B" + }, + "9.9.30-48517": { + "appid": 537352474, + "qua": "V1_WIN_NQ_9.9.30_48517_GW_B" + }, + "3.2.28-48517": { + "appid": 537352510, + "qua": "V1_LNX_NQ_3.2.28_48517_GW_B" + }, + "9.9.30-48762": { + "appid": 537352525, + "qua": "V1_WIN_NQ_9.9.30_48762_GW_B" + }, + "9.9.31-49599": { + "appid": 537355779, + "qua": "V1_WIN_NQ_9.9.31_49599_GW_B" + }, + "9.9.31-49738": { + "appid": 537355830, + "qua": "V1_WIN_NQ_9.9.31_49738_GW_B" + }, + "3.2.29-49738": { + "appid": 537355867, + "qua": "V1_LNX_NQ_3.2.29_49738_GW_B" + }, + "9.9.32-50828": { + "appid": 537358739, + "qua": "V1_WIN_NQ_9.9.32_50828_GW_B" + }, + "3.2.30-50828": { + "appid": 537358775, + "qua": "V1_LNX_NQ_3.2.30_50828_GW_B" + }, + "9.9.32-50969": { + "appid": 537376308, + "qua": "V1_WIN_NQ_9.9.32_50969_GW_B" + }, + "3.2.30-50969": { + "appid": 537376344, + "qua": "V1_LNX_NQ_3.2.30_50969_GW_B" + }, + "3.2.30-50969-arm64": { + "appid": 537376345, + "qua": "V1_LNX_NQ_3.2.30_50969_GW_B" + }, + "9.9.33-52230": { + "appid": 537379411, + "qua": "V1_WIN_NQ_9.9.33_52230_GW_B" + }, + "3.2.32-52194": { + "appid": 537379447, + "qua": "V1_LNX_NQ_3.2.32_52194_GW_B" + }, + "3.2.32-52194-arm64": { + "appid": 537379448, + "qua": "V1_LNX_NQ_3.2.32_52194_GW_B" + }, + "9.9.35-52892": { + "appid": 537382819, + "qua": "V1_WIN_NQ_9.9.35_52892_GW_B" + }, + "3.2.33-52892": { + "appid": 537382855, + "qua": "V1_LNX_NQ_3.2.33_52892_GW_B" + }, + "3.2.33-52892-arm64": { + "appid": 537382856, + "qua": "V1_LNX_NQ_3.2.33_52892_GW_B" + } +} diff --git a/packages/snowcat-core/external/face_config.json b/packages/snowcat-core/external/face_config.json new file mode 100644 index 0000000..ba937ea --- /dev/null +++ b/packages/snowcat-core/external/face_config.json @@ -0,0 +1,4017 @@ +{ + "sysface": [ + { + "QSid": "392", + "QDes": "/龙年快乐", + "IQLid": "392", + "AQLid": "392", + "EMCode": "10392", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "38" + }, + { + "QSid": "393", + "QDes": "/新年中龙", + "IQLid": "393", + "AQLid": "393", + "EMCode": "10393", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "39" + }, + { + "QSid": "364", + "QDes": "/超级赞", + "IQLid": "364", + "AQLid": "364", + "EMCode": "10364", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "1", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "366", + "QDes": "/芒狗", + "IQLid": "366", + "AQLid": "366", + "EMCode": "10366", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "2", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "362", + "QDes": "/好兄弟", + "IQLid": "362", + "AQLid": "362", + "EMCode": "10362", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "3", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "397", + "QDes": "/抛媚眼", + "IQLid": "397", + "AQLid": "397", + "EMCode": "10397", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "4", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "396", + "QDes": "/狼狗", + "IQLid": "396", + "AQLid": "396", + "EMCode": "10396", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "5", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "360", + "QDes": "/亲亲", + "IQLid": "360", + "AQLid": "360", + "EMCode": "10360", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "6", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "361", + "QDes": "/狗狗笑哭", + "IQLid": "361", + "AQLid": "361", + "EMCode": "10361", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "7", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "363", + "QDes": "/狗狗可怜", + "IQLid": "363", + "AQLid": "363", + "EMCode": "10363", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "8", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "365", + "QDes": "/狗狗生气", + "IQLid": "365", + "AQLid": "365", + "EMCode": "10365", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "9", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "367", + "QDes": "/狗狗疑问", + "IQLid": "367", + "AQLid": "367", + "EMCode": "10367", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "10", + "AniStickerPackId": "2", + "AniStickerPackName": "汪汪" + }, + { + "QSid": "399", + "QDes": "/tui", + "IQLid": "399", + "AQLid": "399", + "EMCode": "10399", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "1", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "398", + "QDes": "/超级ok", + "IQLid": "398", + "AQLid": "398", + "EMCode": "10398", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "2", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "373", + "QDes": "/忙", + "IQLid": "373", + "AQLid": "373", + "EMCode": "10373", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "3", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "370", + "QDes": "/祝贺", + "IQLid": "370", + "AQLid": "370", + "EMCode": "10370", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "4", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "375", + "QDes": "/超级鼓掌", + "IQLid": "375", + "AQLid": "375", + "EMCode": "10375", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "5", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "368", + "QDes": "/奥特笑哭", + "IQLid": "368", + "AQLid": "368", + "EMCode": "10368", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "6", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "369", + "QDes": "/彩虹", + "IQLid": "369", + "AQLid": "369", + "EMCode": "10369", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "7", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "371", + "QDes": "/冒泡", + "IQLid": "371", + "AQLid": "371", + "EMCode": "10371", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "8", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "372", + "QDes": "/气呼呼", + "IQLid": "372", + "AQLid": "372", + "EMCode": "10372", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "9", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "374", + "QDes": "/波波流泪", + "IQLid": "374", + "AQLid": "374", + "EMCode": "10374", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "10", + "AniStickerPackId": "6", + "AniStickerPackName": "噗噗星人" + }, + { + "QSid": "382", + "QDes": "/emo", + "IQLid": "382", + "AQLid": "382", + "EMCode": "10382", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "1", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "383", + "QDes": "/企鹅爱心", + "IQLid": "383", + "AQLid": "383", + "EMCode": "10383", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "2", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "401", + "QDes": "/超级转圈", + "IQLid": "401", + "AQLid": "401", + "EMCode": "10401", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "3", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "400", + "QDes": "/快乐", + "IQLid": "400", + "AQLid": "400", + "EMCode": "10400", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "4", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "380", + "QDes": "/真棒", + "IQLid": "380", + "AQLid": "380", + "EMCode": "10380", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "5", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "381", + "QDes": "/路过", + "IQLid": "381", + "AQLid": "381", + "EMCode": "10381", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "6", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "379", + "QDes": "/企鹅流泪", + "IQLid": "379", + "AQLid": "379", + "EMCode": "10379", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "7", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "376", + "QDes": "/跺脚", + "IQLid": "376", + "AQLid": "376", + "EMCode": "10376", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "8", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "378", + "QDes": "/企鹅笑哭", + "IQLid": "378", + "AQLid": "378", + "EMCode": "10378", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "9", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "377", + "QDes": "/嗨", + "IQLid": "377", + "AQLid": "377", + "EMCode": "10377", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "10", + "AniStickerPackId": "5", + "AniStickerPackName": "企鹅" + }, + { + "QSid": "403", + "QDes": "/出去玩", + "IQLid": "403", + "AQLid": "403", + "EMCode": "10403", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "1", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "402", + "QDes": "/别说话", + "IQLid": "402", + "AQLid": "402", + "EMCode": "10402", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "2", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "390", + "QDes": "/太头秃", + "IQLid": "390", + "AQLid": "390", + "EMCode": "10390", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "3", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "391", + "QDes": "/太沧桑", + "IQLid": "391", + "AQLid": "391", + "EMCode": "10391", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "4", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "388", + "QDes": "/太头疼", + "IQLid": "388", + "AQLid": "388", + "EMCode": "10388", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "5", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "389", + "QDes": "/太赞了", + "IQLid": "389", + "AQLid": "389", + "EMCode": "10389", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "6", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "386", + "QDes": "/呜呜呜", + "IQLid": "386", + "AQLid": "386", + "EMCode": "10386", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "7", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "385", + "QDes": "/太气了", + "IQLid": "385", + "AQLid": "385", + "EMCode": "10385", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "8", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "384", + "QDes": "/晚安", + "IQLid": "384", + "AQLid": "384", + "EMCode": "10384", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "9", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "387", + "QDes": "/太好笑", + "IQLid": "387", + "AQLid": "387", + "EMCode": "10387", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "10", + "AniStickerPackId": "4", + "AniStickerPackName": "QQ黄脸" + }, + { + "QSid": "413", + "QDes": "/摇起来", + "IQLid": "413", + "AQLid": "413", + "EMCode": "10413", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "1", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "405", + "QDes": "/好运来", + "IQLid": "405", + "AQLid": "405", + "EMCode": "10405", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "2", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "404", + "QDes": "/闪亮登场", + "IQLid": "404", + "AQLid": "404", + "EMCode": "10404", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "3", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "406", + "QDes": "/姐是女王", + "IQLid": "406", + "AQLid": "406", + "EMCode": "10406", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "4", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "410", + "QDes": "/么么哒", + "IQLid": "410", + "AQLid": "410", + "EMCode": "10410", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "5", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "411", + "QDes": "/一起嗨", + "IQLid": "411", + "AQLid": "411", + "EMCode": "10411", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "6", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "407", + "QDes": "/我听听", + "IQLid": "407", + "AQLid": "407", + "EMCode": "10407", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "7", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "408", + "QDes": "/臭美", + "IQLid": "408", + "AQLid": "408", + "EMCode": "10408", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "8", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "412", + "QDes": "/开心", + "IQLid": "412", + "AQLid": "412", + "EMCode": "10412", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "9", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "409", + "QDes": "/送你花花", + "IQLid": "409", + "AQLid": "409", + "EMCode": "10409", + "QHide": "1", + "AniStickerType": 1, + "AniStickerId": "10", + "AniStickerPackId": "3", + "AniStickerPackName": "喜花妮" + }, + { + "QSid": "394", + "QDes": "/新年大龙", + "IQLid": "394", + "AQLid": "394", + "EMCode": "10394", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "40" + }, + { + "QSid": "14", + "QDes": "/微笑", + "IQLid": "23", + "AQLid": "23", + "EMCode": "100" + }, + { + "QSid": "1", + "QDes": "/撇嘴", + "IQLid": "40", + "AQLid": "40", + "EMCode": "101" + }, + { + "QSid": "2", + "QDes": "/色", + "IQLid": "19", + "AQLid": "19", + "EMCode": "102" + }, + { + "QSid": "3", + "QDes": "/发呆", + "IQLid": "43", + "AQLid": "43", + "EMCode": "103" + }, + { + "QSid": "4", + "QDes": "/得意", + "IQLid": "21", + "AQLid": "21", + "EMCode": "104" + }, + { + "QSid": "6", + "QDes": "/害羞", + "IQLid": "20", + "AQLid": "20", + "EMCode": "106" + }, + { + "QSid": "7", + "QDes": "/闭嘴", + "IQLid": "104", + "AQLid": "106", + "EMCode": "107" + }, + { + "QSid": "8", + "QDes": "/睡", + "IQLid": "35", + "AQLid": "35", + "EMCode": "108" + }, + { + "QSid": "9", + "QDes": "/大哭", + "IQLid": "10", + "AQLid": "10", + "EMCode": "109" + }, + { + "QSid": "5", + "QDes": "/流泪", + "IQLid": "9", + "AQLid": "9", + "EMCode": "105", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "16" + }, + { + "QSid": "10", + "QDes": "/尴尬", + "IQLid": "25", + "AQLid": "25", + "EMCode": "110" + }, + { + "QSid": "11", + "QDes": "/发怒", + "IQLid": "24", + "AQLid": "24", + "EMCode": "111" + }, + { + "QSid": "12", + "QDes": "/调皮", + "IQLid": "1", + "AQLid": "1", + "EMCode": "112" + }, + { + "QSid": "13", + "QDes": "/呲牙", + "IQLid": "0", + "AQLid": "0", + "EMCode": "113" + }, + { + "QSid": "0", + "QDes": "/惊讶", + "IQLid": "33", + "AQLid": "33", + "EMCode": "114" + }, + { + "QSid": "15", + "QDes": "/难过", + "IQLid": "32", + "AQLid": "32", + "EMCode": "115" + }, + { + "QSid": "16", + "QDes": "/酷", + "IQLid": "12", + "AQLid": "12", + "EMCode": "116" + }, + { + "QSid": "96", + "QDes": "/冷汗", + "IQLid": "27", + "AQLid": "27", + "EMCode": "117" + }, + { + "QSid": "18", + "QDes": "/抓狂", + "IQLid": "13", + "AQLid": "13", + "EMCode": "118" + }, + { + "QSid": "19", + "QDes": "/吐", + "IQLid": "22", + "AQLid": "22", + "EMCode": "119" + }, + { + "QSid": "20", + "QDes": "/偷笑", + "IQLid": "3", + "AQLid": "3", + "EMCode": "120" + }, + { + "QSid": "21", + "QDes": "/可爱", + "IQLid": "18", + "AQLid": "18", + "EMCode": "121" + }, + { + "QSid": "22", + "QDes": "/白眼", + "IQLid": "30", + "AQLid": "30", + "EMCode": "122" + }, + { + "QSid": "23", + "QDes": "/傲慢", + "IQLid": "31", + "AQLid": "31", + "EMCode": "123" + }, + { + "QSid": "24", + "QDes": "/饥饿", + "IQLid": "79", + "AQLid": "81", + "EMCode": "124" + }, + { + "QSid": "25", + "QDes": "/困", + "IQLid": "80", + "AQLid": "82", + "EMCode": "125" + }, + { + "QSid": "26", + "QDes": "/惊恐", + "IQLid": "26", + "AQLid": "26", + "EMCode": "126" + }, + { + "QSid": "27", + "QDes": "/流汗", + "IQLid": "2", + "AQLid": "2", + "EMCode": "127" + }, + { + "QSid": "28", + "QDes": "/憨笑", + "IQLid": "37", + "AQLid": "37", + "EMCode": "128" + }, + { + "QSid": "29", + "QDes": "/悠闲", + "IQLid": "50", + "AQLid": "50", + "EMCode": "129" + }, + { + "QSid": "30", + "QDes": "/奋斗", + "IQLid": "42", + "AQLid": "42", + "EMCode": "130" + }, + { + "QSid": "31", + "QDes": "/咒骂", + "IQLid": "81", + "AQLid": "83", + "EMCode": "131" + }, + { + "QSid": "32", + "QDes": "/疑问", + "IQLid": "34", + "AQLid": "34", + "EMCode": "132" + }, + { + "QSid": "33", + "QDes": "/嘘", + "IQLid": "11", + "AQLid": "11", + "EMCode": "133" + }, + { + "QSid": "34", + "QDes": "/晕", + "IQLid": "49", + "AQLid": "49", + "EMCode": "134" + }, + { + "QSid": "35", + "QDes": "/折磨", + "IQLid": "82", + "AQLid": "84", + "EMCode": "135" + }, + { + "QSid": "36", + "QDes": "/衰", + "IQLid": "39", + "AQLid": "39", + "EMCode": "136" + }, + { + "QSid": "37", + "QDes": "/骷髅", + "isStatic": "1", + "IQLid": "76", + "AQLid": "78", + "EMCode": "137" + }, + { + "QSid": "38", + "QDes": "/敲打", + "IQLid": "5", + "AQLid": "5", + "EMCode": "138" + }, + { + "QSid": "39", + "QDes": "/再见", + "IQLid": "4", + "AQLid": "4", + "EMCode": "139" + }, + { + "QSid": "97", + "QDes": "/擦汗", + "IQLid": "6", + "AQLid": "6", + "EMCode": "140" + }, + { + "QSid": "98", + "QDes": "/抠鼻", + "IQLid": "83", + "AQLid": "85", + "EMCode": "141" + }, + { + "QSid": "99", + "QDes": "/鼓掌", + "IQLid": "84", + "AQLid": "86", + "EMCode": "142" + }, + { + "QSid": "100", + "QDes": "/糗大了", + "IQLid": "85", + "AQLid": "87", + "EMCode": "143" + }, + { + "QSid": "101", + "QDes": "/坏笑", + "IQLid": "46", + "AQLid": "46", + "EMCode": "144" + }, + { + "QSid": "102", + "QDes": "/左哼哼", + "IQLid": "86", + "AQLid": "88", + "EMCode": "145" + }, + { + "QSid": "103", + "QDes": "/右哼哼", + "IQLid": "44", + "AQLid": "44", + "EMCode": "146" + }, + { + "QSid": "104", + "QDes": "/哈欠", + "IQLid": "87", + "AQLid": "89", + "EMCode": "147" + }, + { + "QSid": "105", + "QDes": "/鄙视", + "IQLid": "48", + "AQLid": "48", + "EMCode": "148" + }, + { + "QSid": "106", + "QDes": "/委屈", + "IQLid": "14", + "AQLid": "14", + "EMCode": "149" + }, + { + "QSid": "107", + "QDes": "/快哭了", + "IQLid": "88", + "AQLid": "90", + "EMCode": "150" + }, + { + "QSid": "108", + "QDes": "/阴险", + "IQLid": "41", + "AQLid": "41", + "EMCode": "151" + }, + { + "QSid": "305", + "QDes": "/右亲亲", + "IQLid": "305", + "AQLid": "305", + "EMCode": "10305" + }, + { + "QSid": "109", + "QDes": "/左亲亲", + "IQLid": "36", + "AQLid": "36", + "EMCode": "152" + }, + { + "QSid": "110", + "QDes": "/吓", + "IQLid": "89", + "AQLid": "91", + "EMCode": "153" + }, + { + "QSid": "111", + "QDes": "/可怜", + "IQLid": "51", + "AQLid": "51", + "EMCode": "154" + }, + { + "QSid": "172", + "QDes": "/眨眼睛", + "IQLid": "142", + "AQLid": "164", + "EMCode": "242" + }, + { + "QSid": "182", + "QDes": "/笑哭", + "IQLid": "152", + "AQLid": "174", + "EMCode": "252" + }, + { + "QSid": "179", + "QDes": "/doge", + "IQLid": "149", + "AQLid": "171", + "EMCode": "249" + }, + { + "QSid": "173", + "QDes": "/泪奔", + "IQLid": "143", + "AQLid": "165", + "EMCode": "243" + }, + { + "QSid": "174", + "QDes": "/无奈", + "IQLid": "144", + "AQLid": "166", + "EMCode": "244" + }, + { + "QSid": "212", + "QDes": "/托腮", + "IQLid": "182", + "AQLid": "161", + "EMCode": "282" + }, + { + "QSid": "175", + "QDes": "/卖萌", + "IQLid": "145", + "AQLid": "167", + "EMCode": "245" + }, + { + "QSid": "178", + "QDes": "/斜眼笑", + "IQLid": "148", + "AQLid": "170", + "EMCode": "248" + }, + { + "QSid": "177", + "QDes": "/喷血", + "IQLid": "147", + "AQLid": "169", + "EMCode": "247" + }, + { + "QSid": "176", + "QDes": "/小纠结", + "IQLid": "146", + "AQLid": "168", + "EMCode": "246" + }, + { + "QSid": "183", + "QDes": "/我最美", + "IQLid": "153", + "AQLid": "175", + "EMCode": "253" + }, + { + "QSid": "262", + "QDes": "/脑阔疼", + "IQLid": "262", + "AQLid": "262", + "EMCode": "10262" + }, + { + "QSid": "263", + "QDes": "/沧桑", + "IQLid": "263", + "AQLid": "263", + "EMCode": "10263" + }, + { + "QSid": "264", + "QDes": "/捂脸", + "IQLid": "264", + "AQLid": "264", + "EMCode": "10264" + }, + { + "QSid": "265", + "QDes": "/辣眼睛", + "IQLid": "265", + "AQLid": "265", + "EMCode": "10265" + }, + { + "QSid": "266", + "QDes": "/哦哟", + "IQLid": "266", + "AQLid": "266", + "EMCode": "10266" + }, + { + "QSid": "267", + "QDes": "/头秃", + "IQLid": "267", + "AQLid": "267", + "EMCode": "10267" + }, + { + "QSid": "268", + "QDes": "/问号脸", + "IQLid": "268", + "AQLid": "268", + "EMCode": "10268" + }, + { + "QSid": "269", + "QDes": "/暗中观察", + "IQLid": "269", + "AQLid": "269", + "EMCode": "10269" + }, + { + "QSid": "270", + "QDes": "/emm", + "IQLid": "270", + "AQLid": "270", + "EMCode": "10270" + }, + { + "QSid": "271", + "QDes": "/吃瓜", + "IQLid": "271", + "AQLid": "271", + "EMCode": "10271" + }, + { + "QSid": "272", + "QDes": "/呵呵哒", + "IQLid": "272", + "AQLid": "272", + "EMCode": "10272" + }, + { + "QSid": "277", + "QDes": "/汪汪", + "IQLid": "277", + "AQLid": "277", + "EMCode": "10277" + }, + { + "QSid": "307", + "QDes": "/喵喵", + "IQLid": "307", + "AQLid": "307", + "EMCode": "10307" + }, + { + "QSid": "306", + "QDes": "/牛气冲天", + "isStatic": "1", + "IQLid": "306", + "AQLid": "306", + "EMCode": "10306" + }, + { + "QSid": "281", + "QDes": "/无眼笑", + "IQLid": "281", + "AQLid": "281", + "EMCode": "10281" + }, + { + "QSid": "282", + "QDes": "/敬礼", + "IQLid": "282", + "AQLid": "282", + "EMCode": "10282" + }, + { + "QSid": "283", + "QDes": "/狂笑", + "IQLid": "283", + "AQLid": "283", + "EMCode": "10283" + }, + { + "QSid": "284", + "QDes": "/面无表情", + "IQLid": "284", + "AQLid": "284", + "EMCode": "10284" + }, + { + "QSid": "285", + "QDes": "/摸鱼", + "IQLid": "285", + "AQLid": "285", + "EMCode": "10285" + }, + { + "QSid": "293", + "QDes": "/摸锦鲤", + "IQLid": "293", + "AQLid": "293", + "EMCode": "10293" + }, + { + "QSid": "286", + "QDes": "/魔鬼笑", + "IQLid": "286", + "AQLid": "286", + "EMCode": "10286" + }, + { + "QSid": "287", + "QDes": "/哦", + "IQLid": "287", + "AQLid": "287", + "EMCode": "10287" + }, + { + "QSid": "289", + "QDes": "/睁眼", + "IQLid": "289", + "AQLid": "289", + "EMCode": "10289" + }, + { + "QSid": "294", + "QDes": "/期待", + "IQLid": "294", + "AQLid": "294", + "EMCode": "10294" + }, + { + "QSid": "297", + "QDes": "/拜谢", + "IQLid": "297", + "AQLid": "297", + "EMCode": "10297" + }, + { + "QSid": "298", + "QDes": "/元宝", + "IQLid": "298", + "AQLid": "298", + "EMCode": "10298" + }, + { + "QSid": "299", + "QDes": "/牛啊", + "IQLid": "299", + "AQLid": "299", + "EMCode": "10299" + }, + { + "QSid": "300", + "QDes": "/胖三斤", + "IQLid": "300", + "AQLid": "300", + "EMCode": "10300" + }, + { + "QSid": "323", + "QDes": "/嫌弃", + "IQLid": "323", + "AQLid": "323", + "EMCode": "10323" + }, + { + "QSid": "332", + "QDes": "/举牌牌", + "IQLid": "332", + "AQLid": "332", + "EMCode": "10332" + }, + { + "QSid": "336", + "QDes": "/豹富", + "IQLid": "336", + "AQLid": "336", + "EMCode": "10336" + }, + { + "QSid": "353", + "QDes": "/拜托", + "IQLid": "353", + "AQLid": "353", + "EMCode": "10353" + }, + { + "QSid": "355", + "QDes": "/耶", + "IQLid": "355", + "AQLid": "355", + "EMCode": "10355" + }, + { + "QSid": "356", + "QDes": "/666", + "IQLid": "356", + "AQLid": "356", + "EMCode": "10356" + }, + { + "QSid": "354", + "QDes": "/尊嘟假嘟", + "IQLid": "354", + "AQLid": "354", + "EMCode": "10354" + }, + { + "QSid": "352", + "QDes": "/咦", + "IQLid": "352", + "AQLid": "352", + "EMCode": "10352" + }, + { + "QSid": "357", + "QDes": "/裂开", + "IQLid": "357", + "AQLid": "357", + "EMCode": "10357" + }, + { + "QSid": "334", + "QDes": "/虎虎生威", + "IQLid": "334", + "AQLid": "334", + "EMCode": "10334" + }, + { + "QSid": "347", + "QDes": "/大展宏兔", + "IQLid": "347", + "AQLid": "347", + "EMCode": "10347" + }, + { + "QSid": "303", + "QDes": "/右拜年", + "IQLid": "303", + "AQLid": "303", + "EMCode": "10303" + }, + { + "QSid": "302", + "QDes": "/左拜年", + "IQLid": "302", + "AQLid": "302", + "EMCode": "10302" + }, + { + "QSid": "295", + "QDes": "/拿到红包", + "IQLid": "295", + "AQLid": "295", + "EMCode": "10295" + }, + { + "QSid": "311", + "QDes": "/打call", + "IQLid": "311", + "AQLid": "311", + "EMCode": "10311", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "1" + }, + { + "QSid": "312", + "QDes": "/变形", + "IQLid": "312", + "AQLid": "312", + "EMCode": "10312", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "2" + }, + { + "QSid": "314", + "QDes": "/仔细分析", + "IQLid": "314", + "AQLid": "314", + "EMCode": "10314", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "4" + }, + { + "QSid": "317", + "QDes": "/菜汪", + "IQLid": "317", + "AQLid": "317", + "EMCode": "10317", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "7" + }, + { + "QSid": "318", + "QDes": "/崇拜", + "IQLid": "318", + "AQLid": "318", + "EMCode": "10318", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "8" + }, + { + "QSid": "319", + "QDes": "/比心", + "IQLid": "319", + "AQLid": "319", + "EMCode": "10319", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "9" + }, + { + "QSid": "320", + "QDes": "/庆祝", + "IQLid": "320", + "AQLid": "320", + "EMCode": "10320", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "10" + }, + { + "QSid": "324", + "QDes": "/吃糖", + "IQLid": "324", + "AQLid": "324", + "EMCode": "10324", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "12" + }, + { + "QSid": "325", + "QDes": "/惊吓", + "IQLid": "325", + "AQLid": "325", + "EMCode": "10325", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "14" + }, + { + "QSid": "337", + "QDes": "/花朵脸", + "IQLid": "337", + "AQLid": "337", + "EMCode": "10337", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "22" + }, + { + "QSid": "338", + "QDes": "/我想开了", + "IQLid": "338", + "AQLid": "338", + "EMCode": "10338", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "20" + }, + { + "QSid": "339", + "QDes": "/舔屏", + "IQLid": "339", + "AQLid": "339", + "EMCode": "10339", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "21" + }, + { + "QSid": "341", + "QDes": "/打招呼", + "IQLid": "341", + "AQLid": "341", + "EMCode": "10341", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "24" + }, + { + "QSid": "342", + "QDes": "/酸Q", + "IQLid": "342", + "AQLid": "342", + "EMCode": "10342", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "26" + }, + { + "QSid": "343", + "QDes": "/我方了", + "IQLid": "343", + "AQLid": "343", + "EMCode": "10343", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "27" + }, + { + "QSid": "344", + "QDes": "/大怨种", + "IQLid": "344", + "AQLid": "344", + "EMCode": "10344", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "28" + }, + { + "QSid": "345", + "QDes": "/红包多多", + "IQLid": "345", + "AQLid": "345", + "EMCode": "10345", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "29" + }, + { + "QSid": "346", + "QDes": "/你真棒棒", + "IQLid": "346", + "AQLid": "346", + "EMCode": "10346", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "25" + }, + { + "QSid": "181", + "QDes": "/戳一戳", + "IQLid": "151", + "AQLid": "173", + "EMCode": "251", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "37" + }, + { + "QSid": "74", + "QDes": "/太阳", + "isStatic": "1", + "IQLid": "73", + "AQLid": "75", + "EMCode": "176", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "35" + }, + { + "QSid": "75", + "QDes": "/月亮", + "isStatic": "1", + "IQLid": "67", + "AQLid": "68", + "EMCode": "175", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "36" + }, + { + "QSid": "351", + "QDes": "/敲敲", + "IQLid": "351", + "AQLid": "351", + "EMCode": "10351", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "30" + }, + { + "QSid": "349", + "QDes": "/坚强", + "IQLid": "349", + "AQLid": "349", + "EMCode": "10349", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "32" + }, + { + "QSid": "350", + "QDes": "/贴贴", + "IQLid": "350", + "AQLid": "350", + "EMCode": "10350", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "31" + }, + { + "QSid": "395", + "QDes": "/略略略", + "IQLid": "395", + "AQLid": "395", + "EMCode": "10395", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "41" + }, + { + "QSid": "114", + "QDes": "/篮球", + "IQLid": "90", + "AQLid": "92", + "EMCode": "158", + "AniStickerType": 2, + "AniStickerPackId": "1", + "AniStickerId": "13" + }, + { + "QSid": "358", + "QDes": "/骰子", + "IQLid": "358", + "AQLid": "358", + "QHide": "1", + "EMCode": "10358", + "AniStickerType": 2, + "AniStickerPackId": "1", + "AniStickerId": "33" + }, + { + "QSid": "359", + "QDes": "/包剪锤", + "IQLid": "359", + "AQLid": "359", + "QHide": "1", + "EMCode": "10359", + "AniStickerType": 2, + "AniStickerPackId": "1", + "AniStickerId": "34" + }, + { + "QSid": "326", + "QDes": "/生气", + "IQLid": "326", + "AQLid": "326", + "EMCode": "10326", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "15" + }, + { + "QSid": "53", + "QDes": "/蛋糕", + "IQLid": "59", + "AQLid": "59", + "EMCode": "168", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "17" + }, + { + "QSid": "49", + "QDes": "/拥抱", + "IQLid": "45", + "AQLid": "45", + "EMCode": "178" + }, + { + "QSid": "66", + "QDes": "/爱心", + "IQLid": "28", + "AQLid": "28", + "EMCode": "166" + }, + { + "QSid": "63", + "QDes": "/玫瑰", + "IQLid": "8", + "AQLid": "8", + "EMCode": "163" + }, + { + "QSid": "64", + "QDes": "/凋谢", + "IQLid": "57", + "AQLid": "57", + "EMCode": "164" + }, + { + "QSid": "187", + "QDes": "/幽灵", + "IQLid": "157", + "AQLid": "179", + "EMCode": "257" + }, + { + "QSid": "146", + "QDes": "/爆筋", + "IQLid": "116", + "AQLid": "118", + "EMCode": "121011" + }, + { + "QSid": "116", + "QDes": "/示爱", + "IQLid": "29", + "AQLid": "29", + "EMCode": "165" + }, + { + "QSid": "67", + "QDes": "/心碎", + "IQLid": "72", + "AQLid": "74", + "EMCode": "167" + }, + { + "QSid": "60", + "QDes": "/咖啡", + "IQLid": "66", + "AQLid": "66", + "EMCode": "160" + }, + { + "QSid": "185", + "QDes": "/羊驼", + "IQLid": "155", + "AQLid": "177", + "EMCode": "255" + }, + { + "QSid": "137", + "QDes": "/鞭炮", + "isStatic": "1", + "IQLid": "107", + "AQLid": "109", + "EMCode": "121002", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "18" + }, + { + "QSid": "333", + "QDes": "/烟花", + "isStatic": "1", + "IQLid": "333", + "AQLid": "333", + "EMCode": "10333", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "19" + }, + { + "QSid": "76", + "QDes": "/赞", + "IQLid": "52", + "AQLid": "52", + "EMCode": "179" + }, + { + "QSid": "124", + "QDes": "/OK", + "IQLid": "64", + "AQLid": "64", + "EMCode": "189" + }, + { + "QSid": "118", + "QDes": "/抱拳", + "IQLid": "56", + "AQLid": "56", + "EMCode": "183" + }, + { + "QSid": "78", + "QDes": "/握手", + "IQLid": "54", + "AQLid": "54", + "EMCode": "181" + }, + { + "QSid": "119", + "QDes": "/勾引", + "IQLid": "63", + "AQLid": "63", + "EMCode": "184" + }, + { + "QSid": "79", + "QDes": "/胜利", + "IQLid": "55", + "AQLid": "55", + "EMCode": "182" + }, + { + "QSid": "120", + "QDes": "/拳头", + "IQLid": "71", + "AQLid": "73", + "EMCode": "185" + }, + { + "QSid": "121", + "QDes": "/差劲", + "IQLid": "70", + "AQLid": "72", + "EMCode": "186" + }, + { + "QSid": "77", + "QDes": "/踩", + "IQLid": "53", + "AQLid": "53", + "EMCode": "180" + }, + { + "QSid": "123", + "QDes": "/NO", + "IQLid": "92", + "AQLid": "94", + "EMCode": "188" + }, + { + "QSid": "201", + "QDes": "/点赞", + "IQLid": "171", + "AQLid": "150", + "EMCode": "271" + }, + { + "QSid": "273", + "QDes": "/我酸了", + "isStatic": "1", + "IQLid": "273", + "AQLid": "273", + "EMCode": "10273" + }, + { + "QSid": "46", + "QDes": "/猪头", + "isStatic": "1", + "IQLid": "7", + "AQLid": "7", + "EMCode": "162" + }, + { + "QSid": "112", + "QDes": "/菜刀", + "IQLid": "17", + "AQLid": "17", + "EMCode": "155" + }, + { + "QSid": "56", + "QDes": "/刀", + "IQLid": "68", + "AQLid": "70", + "EMCode": "171" + }, + { + "QSid": "169", + "QDes": "/手枪", + "isStatic": "1", + "IQLid": "139", + "AQLid": "141", + "EMCode": "121034" + }, + { + "QSid": "171", + "QDes": "/茶", + "IQLid": "141", + "AQLid": "163", + "EMCode": "241" + }, + { + "QSid": "59", + "QDes": "/便便", + "IQLid": "15", + "AQLid": "15", + "EMCode": "174" + }, + { + "QSid": "144", + "QDes": "/喝彩", + "isStatic": "1", + "IQLid": "114", + "AQLid": "116", + "EMCode": "121009" + }, + { + "QSid": "147", + "QDes": "/棒棒糖", + "isStatic": "1", + "IQLid": "117", + "AQLid": "119", + "EMCode": "121012" + }, + { + "QSid": "89", + "QDes": "/西瓜", + "isStatic": "1", + "IQLid": "60", + "AQLid": "60", + "EMCode": "156" + }, + { + "QSid": "148", + "QDes": "/喝奶", + "isStatic": "1", + "IQLid": "118", + "AQLid": "120", + "QHide": "1", + "EMCode": "121013" + }, + { + "QSid": "55", + "QDes": "/炸弹", + "isStatic": "1", + "IQLid": "16", + "AQLid": "16", + "QHide": "1", + "EMCode": "170" + }, + { + "QSid": "41", + "QDes": "/发抖", + "isStatic": "1", + "IQLid": "69", + "AQLid": "71", + "EMCode": "193" + }, + { + "QSid": "125", + "QDes": "/转圈", + "IQLid": "95", + "AQLid": "97", + "EMCode": "195" + }, + { + "QSid": "42", + "QDes": "/爱情", + "IQLid": "38", + "AQLid": "38", + "EMCode": "190" + }, + { + "QSid": "43", + "QDes": "/跳跳", + "IQLid": "93", + "AQLid": "95", + "EMCode": "192" + }, + { + "QSid": "86", + "QDes": "/怄火", + "IQLid": "94", + "AQLid": "96", + "EMCode": "194" + }, + { + "QSid": "129", + "QDes": "/挥手", + "IQLid": "77", + "AQLid": "79", + "EMCode": "199" + }, + { + "QSid": "226", + "QDes": "/拍桌", + "IQLid": "196", + "isCMEmoji": "1", + "AQLid": "198", + "QHide": "1", + "EMCode": "297" + }, + { + "QSid": "85", + "QDes": "/飞吻", + "isStatic": "1", + "IQLid": "47", + "AQLid": "47", + "EMCode": "191" + }, + { + "QSid": "215", + "QDes": "/糊脸", + "IQLid": "185", + "isCMEmoji": "1", + "AQLid": "187", + "QHide": "1", + "EMCode": "285" + }, + { + "QSid": "214", + "QDes": "/啵啵", + "IQLid": "184", + "isCMEmoji": "1", + "AQLid": "186", + "QHide": "1", + "EMCode": "284" + }, + { + "QSid": "222", + "QDes": "/抱抱", + "IQLid": "192", + "isCMEmoji": "1", + "AQLid": "194", + "QHide": "1", + "EMCode": "292" + }, + { + "QSid": "203", + "QDes": "/托脸", + "IQLid": "173", + "AQLid": "152", + "QHide": "1", + "EMCode": "273" + }, + { + "QSid": "235", + "QDes": "/颤抖", + "IQLid": "205", + "isCMEmoji": "1", + "AQLid": "207", + "QHide": "1", + "EMCode": "305" + }, + { + "QSid": "241", + "QDes": "/生日快乐", + "IQLid": "211", + "isCMEmoji": "1", + "AQLid": "213", + "QHide": "1", + "EMCode": "311" + }, + { + "QSid": "237", + "QDes": "/偷看", + "IQLid": "207", + "isCMEmoji": "1", + "AQLid": "209", + "QHide": "1", + "EMCode": "307" + }, + { + "QSid": "218", + "QDes": "/舔一舔", + "IQLid": "188", + "isCMEmoji": "1", + "AQLid": "190", + "QHide": "1", + "EMCode": "288" + }, + { + "QSid": "233", + "QDes": "/掐一掐", + "IQLid": "203", + "isCMEmoji": "1", + "AQLid": "205", + "QHide": "1", + "EMCode": "303" + }, + { + "QSid": "232", + "QDes": "/佛系", + "IQLid": "202", + "isCMEmoji": "1", + "AQLid": "204", + "QHide": "1", + "EMCode": "302" + }, + { + "QSid": "238", + "QDes": "/扇脸", + "IQLid": "208", + "isCMEmoji": "1", + "AQLid": "210", + "QHide": "1", + "EMCode": "308" + }, + { + "QSid": "217", + "QDes": "/扯一扯", + "IQLid": "187", + "isCMEmoji": "1", + "AQLid": "189", + "QHide": "1", + "EMCode": "287" + }, + { + "QSid": "225", + "QDes": "/撩一撩", + "IQLid": "195", + "isCMEmoji": "1", + "AQLid": "197", + "QHide": "1", + "EMCode": "296" + }, + { + "QSid": "230", + "QDes": "/嘲讽", + "IQLid": "200", + "isCMEmoji": "1", + "AQLid": "202", + "QHide": "1", + "EMCode": "300" + }, + { + "QSid": "194", + "QDes": "/不开心", + "IQLid": "164", + "AQLid": "143", + "QHide": "1", + "EMCode": "264" + }, + { + "QSid": "210", + "QDes": "/飙泪", + "IQLid": "180", + "AQLid": "159", + "QHide": "1", + "EMCode": "280" + }, + { + "QSid": "193", + "QDes": "/大笑", + "IQLid": "163", + "AQLid": "185", + "QHide": "1", + "EMCode": "263" + }, + { + "QSid": "204", + "QDes": "/吃", + "IQLid": "174", + "AQLid": "153", + "QHide": "1", + "EMCode": "274" + }, + { + "QSid": "200", + "QDes": "/求求", + "IQLid": "170", + "AQLid": "149", + "QHide": "1", + "EMCode": "270" + }, + { + "QSid": "290", + "QDes": "/敲开心", + "IQLid": "290", + "isCMEmoji": "1", + "AQLid": "290", + "QHide": "1", + "EMCode": "20240" + }, + { + "QSid": "224", + "QDes": "/开枪", + "IQLid": "194", + "isCMEmoji": "1", + "AQLid": "196", + "QHide": "1", + "EMCode": "295" + }, + { + "QSid": "229", + "QDes": "/干杯", + "IQLid": "199", + "isCMEmoji": "1", + "AQLid": "201", + "QHide": "1", + "EMCode": "299" + }, + { + "QSid": "221", + "QDes": "/顶呱呱", + "IQLid": "191", + "isCMEmoji": "1", + "AQLid": "193", + "QHide": "1", + "EMCode": "291" + }, + { + "QSid": "219", + "QDes": "/蹭一蹭", + "IQLid": "189", + "isCMEmoji": "1", + "AQLid": "191", + "QHide": "1", + "EMCode": "289" + }, + { + "QSid": "227", + "QDes": "/拍手", + "IQLid": "197", + "isCMEmoji": "1", + "AQLid": "199", + "QHide": "1", + "EMCode": "294" + }, + { + "QSid": "216", + "QDes": "/拍头", + "IQLid": "186", + "isCMEmoji": "1", + "AQLid": "188", + "QHide": "1", + "EMCode": "286" + }, + { + "QSid": "231", + "QDes": "/哼", + "IQLid": "201", + "isCMEmoji": "1", + "AQLid": "203", + "QHide": "1", + "EMCode": "301" + }, + { + "QSid": "244", + "QDes": "/扔狗", + "IQLid": "214", + "isCMEmoji": "1", + "AQLid": "216", + "QHide": "1", + "EMCode": "312" + }, + { + "QSid": "223", + "QDes": "/暴击", + "IQLid": "193", + "isCMEmoji": "1", + "AQLid": "195", + "QHide": "1", + "EMCode": "293" + }, + { + "QSid": "243", + "QDes": "/甩头", + "IQLid": "213", + "isCMEmoji": "1", + "AQLid": "215", + "QHide": "1", + "EMCode": "313" + }, + { + "QSid": "211", + "QDes": "/我不看", + "IQLid": "181", + "AQLid": "160", + "QHide": "1", + "EMCode": "281" + }, + { + "QSid": "292", + "QDes": "/让我康康", + "IQLid": "292", + "isCMEmoji": "1", + "AQLid": "292", + "QHide": "1", + "EMCode": "20242" + }, + { + "QSid": "240", + "QDes": "/喷脸", + "IQLid": "210", + "isCMEmoji": "1", + "AQLid": "212", + "QHide": "1", + "EMCode": "310" + }, + { + "QSid": "180", + "QDes": "/惊喜", + "IQLid": "150", + "AQLid": "172", + "QHide": "1", + "EMCode": "250" + }, + { + "QSid": "122", + "QDes": "/爱你", + "IQLid": "65", + "AQLid": "65", + "QHide": "1", + "EMCode": "187" + }, + { + "QSid": "202", + "QDes": "/无聊", + "IQLid": "172", + "AQLid": "151", + "QHide": "1", + "EMCode": "272" + }, + { + "QSid": "278", + "QDes": "/汗", + "IQLid": "278", + "isCMEmoji": "1", + "AQLid": "278", + "QHide": "1", + "EMCode": "20237" + }, + { + "QSid": "301", + "QDes": "/好闪", + "IQLid": "301", + "AQLid": "301", + "QHide": "1", + "EMCode": "10301" + }, + { + "QSid": "288", + "QDes": "/请", + "IQLid": "288", + "AQLid": "288", + "QHide": "1", + "EMCode": "10288" + }, + { + "QSid": "322", + "QDes": "/拒绝", + "IQLid": "322", + "AQLid": "322", + "QHide": "1", + "EMCode": "10322" + }, + { + "QSid": "198", + "QDes": "/呃", + "IQLid": "168", + "AQLid": "147", + "QHide": "1", + "EMCode": "268" + }, + { + "QSid": "348", + "QDes": "/福萝卜", + "IQLid": "348", + "AQLid": "348", + "QHide": "1", + "EMCode": "10348" + }, + { + "QSid": "206", + "QDes": "/害怕", + "IQLid": "176", + "AQLid": "155", + "QHide": "1", + "EMCode": "276" + }, + { + "QSid": "239", + "QDes": "/原谅", + "IQLid": "209", + "isCMEmoji": "1", + "AQLid": "211", + "QHide": "1", + "EMCode": "309" + }, + { + "QSid": "424", + "QDes": "/续标识", + "IQLid": "424", + "AQLid": "424", + "EMCode": "10424", + "QHide": "0", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "52" + }, + { + "QSid": "415", + "QDes": "/划龙舟", + "IQLid": "415", + "AQLid": "415", + "EMCode": "10415", + "QHide": "0", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "43" + }, + { + "QSid": "416", + "QDes": "/中龙舟", + "IQLid": "416", + "AQLid": "416", + "EMCode": "10416", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "44" + }, + { + "QSid": "417", + "QDes": "/大龙舟", + "IQLid": "417", + "AQLid": "417", + "EMCode": "10417", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "45" + }, + { + "QSid": "425", + "QDes": "/求放过", + "IQLid": "425", + "AQLid": "425", + "EMCode": "10425", + "QHide": "0", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "53" + }, + { + "QSid": "427", + "QDes": "/偷感", + "IQLid": "427", + "AQLid": "427", + "EMCode": "10427", + "QHide": "0", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "55" + }, + { + "QSid": "426", + "QDes": "/玩火", + "IQLid": "426", + "AQLid": "426", + "EMCode": "10426", + "QHide": "0", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "54" + }, + { + "QSid": "419", + "QDes": "/火车", + "IQLid": "419", + "AQLid": "419", + "EMCode": "10419", + "QHide": "0", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "47" + }, + { + "QSid": "420", + "QDes": "/中火车", + "IQLid": "420", + "AQLid": "420", + "EMCode": "10420", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "48" + }, + { + "QSid": "421", + "QDes": "/大火车", + "IQLid": "421", + "AQLid": "421", + "EMCode": "10421", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "49" + }, + { + "QSid": "429", + "QDes": "/蛇年快乐", + "IQLid": "429", + "AQLid": "429", + "EMCode": "10429", + "QHide": "0", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "56" + }, + { + "QSid": "430", + "QDes": "/蛇身", + "IQLid": "430", + "AQLid": "430", + "EMCode": "10430", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "57" + }, + { + "QSid": "431", + "QDes": "/蛇尾", + "IQLid": "431", + "AQLid": "431", + "EMCode": "10431", + "QHide": "1", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "56" + }, + { + "QSid": "428", + "QDes": "/收到", + "IQLid": "428", + "AQLid": "428", + "EMCode": "10428", + "QHide": "0", + "AniStickerType": 0, + "AniStickerPackId": "0", + "AniStickerId": "0" + }, + { + "QSid": "422", + "QDes": "/粽于等到你", + "IQLid": "422", + "AQLid": "422", + "EMCode": "10422", + "QHide": "1", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "50" + }, + { + "QSid": "423", + "QDes": "/复兴号", + "IQLid": "423", + "AQLid": "423", + "EMCode": "10423", + "QHide": "1", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "51" + }, + { + "QSid": "432", + "QDes": "/灵蛇献瑞", + "IQLid": "432", + "AQLid": "432", + "EMCode": "10432", + "QHide": "1", + "AniStickerType": 1, + "AniStickerPackId": "1", + "AniStickerId": "59" + }, + { + "QSid": "450", + "QDes": "/撇嘴", + "EMCode": "10450" + }, + { + "QSid": "451", + "QDes": "/色", + "EMCode": "10451" + }, + { + "QSid": "452", + "QDes": "/微笑", + "EMCode": "10452" + }, + { + "QSid": "453", + "QDes": "/发呆", + "EMCode": "10453" + }, + { + "QSid": "454", + "QDes": "/得意", + "EMCode": "10454" + }, + { + "QSid": "455", + "QDes": "/害羞", + "EMCode": "10455" + }, + { + "QSid": "456", + "QDes": "/闭嘴", + "EMCode": "10456" + }, + { + "QSid": "457", + "QDes": "/睡", + "EMCode": "10457" + }, + { + "QSid": "458", + "QDes": "/我吗", + "EMCode": "10458" + }, + { + "QSid": "459", + "QDes": "/优雅", + "EMCode": "10459" + }, + { + "QSid": "460", + "QDes": "/硬撑", + "EMCode": "10460" + }, + { + "QSid": "461", + "QDes": "/宕机", + "EMCode": "10461" + }, + { + "QSid": "462", + "QDes": "/无语", + "EMCode": "10462" + }, + { + "QSid": "463", + "QDes": "/新年快乐", + "EMCode": "10463" + }, + { + "QSid": "464", + "QDes": "/马上到", + "EMCode": "10464" + }, + { + "QSid": "465", + "QDes": "/拆红包", + "EMCode": "10465" + }, + { + "QSid": "466", + "QDes": "/羞羞哒", + "EMCode": "10466" + }, + { + "QSid": "467", + "QDes": "/摇花手", + "EMCode": "10467" + }, + { + "QSid": "468", + "QDes": "/失眠", + "EMCode": "10468" + }, + { + "QSid": "469", + "QDes": "/坚毅", + "EMCode": "10469" + }, + { + "QSid": "470", + "QDes": "/马到成功", + "EMCode": "10470" + }, + { + "QSid": "472", + "QDes": "/心动", + "EMCode": "10472" + }, + { + "QSid": "474", + "QDes": "/给你一拳", + "EMCode": "10474" + }, + { + "QSid": "475", + "QDes": "/干饭", + "EMCode": "10475" + }, + { + "QSid": "476", + "QDes": "/不是吧", + "EMCode": "10476" + }, + { + "QSid": "477", + "QDes": "/你懂的", + "EMCode": "10477" + }, + { + "QSid": "478", + "QDes": "/对的对的", + "EMCode": "10478" + }, + { + "QSid": "479", + "QDes": "/不对不对", + "EMCode": "10479" + }, + { + "QSid": "480", + "QDes": "/散味儿", + "EMCode": "10480" + }, + { + "QSid": "481", + "QDes": "/学习", + "EMCode": "10481" + }, + { + "QSid": "482", + "QDes": "/热化了", + "EMCode": "10482" + }, + { + "QSid": "483", + "QDes": "/略", + "EMCode": "10483" + }, + { + "QSid": "484", + "QDes": "/比爱心", + "EMCode": "10484" + } + ], + "emoji": [ + { + "QSid": "😊", + "QCid": "128522", + "AQLid": "0", + "QDes": "/嘿嘿", + "EMCode": "400832" + }, + { + "QSid": "😌", + "QCid": "128524", + "AQLid": "1", + "QDes": "/羞涩", + "EMCode": "400834" + }, + { + "QSid": "😚", + "QCid": "128538", + "AQLid": "2", + "QDes": "/亲亲", + "EMCode": "400848" + }, + { + "QSid": "😓", + "QCid": "128531", + "AQLid": "3", + "QDes": "/汗", + "EMCode": "400841" + }, + { + "QSid": "😰", + "QCid": "128560", + "AQLid": "4", + "QDes": "/紧张", + "EMCode": "400870" + }, + { + "QSid": "😝", + "QCid": "128541", + "AQLid": "5", + "QDes": "/吐舌", + "EMCode": "400851" + }, + { + "QSid": "😁", + "QCid": "128513", + "AQLid": "6", + "QDes": "/呲牙", + "EMCode": "400823" + }, + { + "QSid": "😜", + "QCid": "128540", + "AQLid": "7", + "QDes": "/淘气", + "EMCode": "400850" + }, + { + "QSid": "☺", + "QCid": "9786", + "AQLid": "8", + "QDes": "/可爱", + "EMCode": "401181" + }, + { + "QSid": "😉", + "QCid": "128521", + "AQLid": "9", + "QDes": "/媚眼", + "QHide": "1", + "EMCode": "400831" + }, + { + "QSid": "😍", + "QCid": "128525", + "AQLid": "10", + "QDes": "/花痴", + "EMCode": "400835" + }, + { + "QSid": "😔", + "QCid": "128532", + "AQLid": "11", + "QDes": "/失落", + "EMCode": "400842" + }, + { + "QSid": "😄", + "QCid": "128516", + "AQLid": "12", + "QDes": "/高兴", + "EMCode": "400826" + }, + { + "QSid": "😏", + "QCid": "128527", + "AQLid": "13", + "QDes": "/哼哼", + "EMCode": "400837" + }, + { + "QSid": "😒", + "QCid": "128530", + "AQLid": "14", + "QDes": "/不屑", + "EMCode": "400840" + }, + { + "QSid": "😳", + "QCid": "128563", + "AQLid": "15", + "QDes": "/瞪眼", + "EMCode": "400873" + }, + { + "QSid": "😘", + "QCid": "128536", + "AQLid": "16", + "QDes": "/飞吻", + "EMCode": "400846" + }, + { + "QSid": "😭", + "QCid": "128557", + "AQLid": "17", + "QDes": "/大哭", + "EMCode": "400867" + }, + { + "QSid": "😱", + "QCid": "128561", + "AQLid": "18", + "QDes": "/害怕", + "EMCode": "400871" + }, + { + "QSid": "😂", + "QCid": "128514", + "AQLid": "19", + "QDes": "/激动", + "EMCode": "400824" + }, + { + "QSid": "💪", + "QCid": "128170", + "AQLid": "20", + "QDes": "/肌肉", + "EMCode": "400644" + }, + { + "QSid": "👊", + "QCid": "128074", + "AQLid": "21", + "QDes": "/拳头", + "EMCode": "400390" + }, + { + "QSid": "👍", + "QCid": "128077", + "AQLid": "22", + "QDes": "/厉害", + "EMCode": "400408" + }, + { + "QSid": "☝", + "QCid": "9757", + "AQLid": "23", + "QDes": "/向上", + "QHide": "1", + "EMCode": "401203" + }, + { + "QSid": "👏", + "QCid": "128079", + "AQLid": "24", + "QDes": "/鼓掌", + "EMCode": "400420" + }, + { + "QSid": "✌", + "QCid": "9996", + "AQLid": "25", + "QDes": "/胜利", + "QHide": "1", + "EMCode": "401210" + }, + { + "QSid": "👎", + "QCid": "128078", + "AQLid": "26", + "QDes": "/鄙视", + "EMCode": "400414" + }, + { + "QSid": "🙏", + "QCid": "128591", + "AQLid": "27", + "QDes": "/合十", + "EMCode": "400396" + }, + { + "QSid": "👌", + "QCid": "128076", + "AQLid": "28", + "QDes": "/好的", + "EMCode": "400402" + }, + { + "QSid": "👈", + "QCid": "128072", + "AQLid": "29", + "QDes": "/向左", + "QHide": "1", + "EMCode": "400378" + }, + { + "QSid": "👉", + "QCid": "128073", + "AQLid": "30", + "QDes": "/向右", + "QHide": "1", + "EMCode": "400384" + }, + { + "QSid": "👆", + "QCid": "128070", + "AQLid": "31", + "QDes": "/向上", + "EMCode": "400366" + }, + { + "QSid": "👇", + "QCid": "128071", + "AQLid": "32", + "QDes": "/向下", + "QHide": "1", + "EMCode": "400372" + }, + { + "QSid": "👀", + "QCid": "128064", + "AQLid": "33", + "QDes": "/眼睛", + "EMCode": "400351" + }, + { + "QSid": "👃", + "QCid": "128067", + "AQLid": "34", + "QDes": "/鼻子", + "QHide": "1", + "EMCode": "400358" + }, + { + "QSid": "👄", + "QCid": "128068", + "AQLid": "35", + "QDes": "/嘴唇", + "QHide": "1", + "EMCode": "400364" + }, + { + "QSid": "👂", + "QCid": "128066", + "AQLid": "36", + "QDes": "/耳朵", + "QHide": "1", + "EMCode": "400352" + }, + { + "QSid": "🍚", + "QCid": "127834", + "AQLid": "37", + "QDes": "/米饭", + "QHide": "1", + "EMCode": "400149" + }, + { + "QSid": "🍝", + "QCid": "127837", + "AQLid": "38", + "QDes": "/意面", + "QHide": "1", + "EMCode": "400152" + }, + { + "QSid": "🍜", + "QCid": "127836", + "AQLid": "39", + "QDes": "/拉面", + "EMCode": "400151" + }, + { + "QSid": "🍙", + "QCid": "127833", + "AQLid": "40", + "QDes": "/饭团", + "QHide": "1", + "EMCode": "400148" + }, + { + "QSid": "🍧", + "QCid": "127847", + "AQLid": "41", + "QDes": "/刨冰", + "EMCode": "400162" + }, + { + "QSid": "🍣", + "QCid": "127843", + "AQLid": "42", + "QDes": "/寿司", + "QHide": "1", + "EMCode": "400158" + }, + { + "QSid": "🎂", + "QCid": "127874", + "AQLid": "43", + "QDes": "/蛋糕", + "QHide": "1", + "EMCode": "400186" + }, + { + "QSid": "🍞", + "QCid": "127838", + "AQLid": "44", + "QDes": "/面包", + "EMCode": "400153" + }, + { + "QSid": "🍔", + "QCid": "127828", + "AQLid": "45", + "QDes": "/汉堡", + "QHide": "1", + "EMCode": "400143" + }, + { + "QSid": "🍳", + "QCid": "127859", + "AQLid": "46", + "QDes": "/煎蛋", + "QHide": "1", + "EMCode": "400174" + }, + { + "QSid": "🍟", + "QCid": "127839", + "AQLid": "47", + "QDes": "/薯条", + "QHide": "1", + "EMCode": "400154" + }, + { + "QSid": "🍺", + "QCid": "127866", + "AQLid": "48", + "QDes": "/啤酒", + "EMCode": "400181" + }, + { + "QSid": "🍻", + "QCid": "127867", + "AQLid": "49", + "QDes": "/干杯", + "EMCode": "400182" + }, + { + "QSid": "🍸", + "QCid": "127864", + "AQLid": "50", + "QDes": "/高脚杯", + "QHide": "1", + "EMCode": "400179" + }, + { + "QSid": "☕", + "QCid": "9749", + "AQLid": "51", + "QDes": "/咖啡", + "EMCode": "401262" + }, + { + "QSid": "🍎", + "QCid": "127822", + "AQLid": "52", + "QDes": "/苹果", + "EMCode": "400137" + }, + { + "QSid": "🍊", + "QCid": "127818", + "AQLid": "53", + "QDes": "/橙子", + "QHide": "1", + "EMCode": "400133" + }, + { + "QSid": "🍓", + "QCid": "127827", + "AQLid": "54", + "QDes": "/草莓", + "EMCode": "400142" + }, + { + "QSid": "🍉", + "QCid": "127817", + "AQLid": "55", + "QDes": "/西瓜", + "EMCode": "400132" + }, + { + "QSid": "💊", + "QCid": "128138", + "AQLid": "56", + "QDes": "/药丸", + "QHide": "1", + "EMCode": "400612" + }, + { + "QSid": "🚬", + "QCid": "128684", + "AQLid": "57", + "QDes": "/吸烟", + "EMCode": "400987" + }, + { + "QSid": "🎄", + "QCid": "127876", + "AQLid": "58", + "QDes": "/圣诞树", + "QHide": "1", + "EMCode": "400188" + }, + { + "QSid": "🌹", + "QCid": "127801", + "AQLid": "59", + "QDes": "/玫瑰", + "EMCode": "400116" + }, + { + "QSid": "🎉", + "QCid": "127881", + "AQLid": "60", + "QDes": "/庆祝", + "EMCode": "400198" + }, + { + "QSid": "🌴", + "QCid": "127796", + "AQLid": "61", + "QDes": "/椰子树", + "QHide": "1", + "EMCode": "400112" + }, + { + "QSid": "💝", + "QCid": "128157", + "AQLid": "62", + "QDes": "/礼物", + "EMCode": "400631" + }, + { + "QSid": "🎀", + "QCid": "127872", + "AQLid": "63", + "QDes": "/蝴蝶结", + "QHide": "1", + "EMCode": "400184" + }, + { + "QSid": "🎈", + "QCid": "127880", + "AQLid": "64", + "QDes": "/气球", + "QHide": "1", + "EMCode": "400197" + }, + { + "QSid": "🐚", + "QCid": "128026", + "AQLid": "65", + "QDes": "/海螺", + "QHide": "1", + "EMCode": "400314" + }, + { + "QSid": "💍", + "QCid": "128141", + "AQLid": "66", + "QDes": "/戒指", + "QHide": "1", + "EMCode": "400615" + }, + { + "QSid": "💣", + "QCid": "128163", + "AQLid": "67", + "QDes": "/炸弹", + "EMCode": "400637" + }, + { + "QSid": "👑", + "QCid": "128081", + "AQLid": "68", + "QDes": "/皇冠", + "QHide": "1", + "EMCode": "400432" + }, + { + "QSid": "🔔", + "QCid": "128276", + "AQLid": "69", + "QDes": "/铃铛", + "QHide": "1", + "EMCode": "400751" + }, + { + "QSid": "⭐", + "QCid": "11088", + "AQLid": "70", + "QDes": "/星星", + "QHide": "1", + "EMCode": "401686" + }, + { + "QSid": "✨", + "QCid": "10024", + "AQLid": "71", + "QDes": "/闪光", + "EMCode": "401137" + }, + { + "QSid": "💨", + "QCid": "128168", + "AQLid": "72", + "QDes": "/吹气", + "EMCode": "400642" + }, + { + "QSid": "💦", + "QCid": "128166", + "AQLid": "73", + "QDes": "/水", + "EMCode": "400640" + }, + { + "QSid": "🔥", + "QCid": "128293", + "AQLid": "74", + "QDes": "/火", + "EMCode": "400768" + }, + { + "QSid": "🏆", + "QCid": "127942", + "AQLid": "75", + "QDes": "/奖杯", + "QHide": "1", + "EMCode": "400256" + }, + { + "QSid": "💰", + "QCid": "128176", + "AQLid": "76", + "QDes": "/钱", + "QHide": "1", + "EMCode": "400655" + }, + { + "QSid": "💤", + "QCid": "128164", + "AQLid": "77", + "QDes": "/睡觉", + "EMCode": "400638" + }, + { + "QSid": "⚡", + "QCid": "9889", + "AQLid": "78", + "QDes": "/闪电", + "QHide": "1", + "EMCode": "401685" + }, + { + "QSid": "👣", + "QCid": "128099", + "AQLid": "79", + "QDes": "/脚印", + "QHide": "1", + "EMCode": "400450" + }, + { + "QSid": "💩", + "QCid": "128169", + "AQLid": "80", + "QDes": "/便便", + "EMCode": "400643" + }, + { + "QSid": "💉", + "QCid": "128137", + "AQLid": "81", + "QDes": "/打针", + "EMCode": "400611" + }, + { + "QSid": "♨", + "QCid": "9832", + "AQLid": "82", + "QDes": "/热", + "QHide": "1", + "EMCode": "401287" + }, + { + "QSid": "📫", + "QCid": "128235", + "AQLid": "83", + "QDes": "/邮箱", + "EMCode": "400714" + }, + { + "QSid": "🔑", + "QCid": "128273", + "AQLid": "84", + "QDes": "/钥匙", + "QHide": "1", + "EMCode": "400748" + }, + { + "QSid": "🔒", + "QCid": "128274", + "AQLid": "85", + "QDes": "/锁", + "QHide": "1", + "EMCode": "400749" + }, + { + "QSid": "✈", + "QCid": "9992", + "AQLid": "86", + "QDes": "/飞机", + "QHide": "1", + "EMCode": "401298" + }, + { + "QSid": "🚄", + "QCid": "128644", + "AQLid": "87", + "QDes": "/列车", + "QHide": "1", + "EMCode": "400942" + }, + { + "QSid": "🚗", + "QCid": "128663", + "AQLid": "88", + "QDes": "/汽车", + "QHide": "1", + "EMCode": "400961" + }, + { + "QSid": "🚤", + "QCid": "128676", + "AQLid": "89", + "QDes": "/快艇", + "QHide": "1", + "EMCode": "400979" + }, + { + "QSid": "🚲", + "QCid": "128690", + "AQLid": "90", + "QDes": "/自行车", + "QHide": "1", + "EMCode": "400993" + }, + { + "QSid": "🐎", + "QCid": "128014", + "AQLid": "91", + "QDes": "/骑马", + "EMCode": "400302" + }, + { + "QSid": "🚀", + "QCid": "128640", + "AQLid": "92", + "QDes": "/火箭", + "QHide": "1", + "EMCode": "400938" + }, + { + "QSid": "🚌", + "QCid": "128652", + "AQLid": "93", + "QDes": "/公交", + "QHide": "1", + "EMCode": "400950" + }, + { + "QSid": "⛵", + "QCid": "9973", + "AQLid": "94", + "QDes": "/船", + "QHide": "1", + "EMCode": "401294" + }, + { + "QSid": "👩", + "QCid": "128105", + "AQLid": "95", + "QDes": "/妈妈", + "QHide": "1", + "EMCode": "400482" + }, + { + "QSid": "👨", + "QCid": "128104", + "AQLid": "96", + "QDes": "/爸爸", + "QHide": "1", + "EMCode": "400465" + }, + { + "QSid": "👧", + "QCid": "128103", + "AQLid": "97", + "QDes": "/女孩", + "EMCode": "400459" + }, + { + "QSid": "👦", + "QCid": "128102", + "AQLid": "98", + "QDes": "/男孩", + "EMCode": "400453" + }, + { + "QSid": "🐵", + "QCid": "128053", + "AQLid": "99", + "QDes": "/猴", + "EMCode": "400341" + }, + { + "QSid": "🐙", + "QCid": "128025", + "AQLid": "100", + "QDes": "/章鱼", + "QHide": "1", + "EMCode": "400313" + }, + { + "QSid": "🐷", + "QCid": "128055", + "AQLid": "101", + "QDes": "/猪", + "EMCode": "400343" + }, + { + "QSid": "💀", + "QCid": "128128", + "AQLid": "102", + "QDes": "/骷髅", + "QHide": "1", + "EMCode": "400572" + }, + { + "QSid": "🐤", + "QCid": "128036", + "AQLid": "103", + "QDes": "/小鸡", + "QHide": "1", + "EMCode": "400324" + }, + { + "QSid": "🐨", + "QCid": "128040", + "AQLid": "104", + "QDes": "/树懒", + "QHide": "1", + "EMCode": "400328" + }, + { + "QSid": "🐮", + "QCid": "128046", + "AQLid": "105", + "QDes": "/牛", + "EMCode": "400334" + }, + { + "QSid": "🐔", + "QCid": "128020", + "AQLid": "106", + "QDes": "/公鸡", + "EMCode": "400308" + }, + { + "QSid": "🐸", + "QCid": "128056", + "AQLid": "107", + "QDes": "/青蛙", + "EMCode": "400344" + }, + { + "QSid": "👻", + "QCid": "128123", + "AQLid": "108", + "QDes": "/幽灵", + "EMCode": "400562" + }, + { + "QSid": "🐛", + "QCid": "128027", + "AQLid": "109", + "QDes": "/虫", + "EMCode": "400315" + }, + { + "QSid": "🐠", + "QCid": "128032", + "AQLid": "110", + "QDes": "/鱼", + "QHide": "1", + "EMCode": "400320" + }, + { + "QSid": "🐶", + "QCid": "128054", + "AQLid": "111", + "QDes": "/狗", + "EMCode": "400342" + }, + { + "QSid": "🐯", + "QCid": "128047", + "AQLid": "112", + "QDes": "/老虎", + "QHide": "1", + "EMCode": "400335" + }, + { + "QSid": "👼", + "QCid": "128124", + "AQLid": "113", + "QDes": "/天使", + "QHide": "1", + "EMCode": "400563" + }, + { + "QSid": "🐧", + "QCid": "128039", + "AQLid": "114", + "QDes": "/企鹅", + "QHide": "1", + "EMCode": "400327" + }, + { + "QSid": "🐳", + "QCid": "128051", + "AQLid": "115", + "QDes": "/鲸鱼", + "EMCode": "400339" + }, + { + "QSid": "🐭", + "QCid": "128045", + "AQLid": "116", + "QDes": "/老鼠", + "QHide": "1", + "EMCode": "400333" + }, + { + "QSid": "👒", + "QCid": "128082", + "AQLid": "117", + "QDes": "/帽子", + "QHide": "1", + "EMCode": "400433" + }, + { + "QSid": "👗", + "QCid": "128087", + "AQLid": "118", + "QDes": "/连衣裙", + "QHide": "1", + "EMCode": "400438" + }, + { + "QSid": "💄", + "QCid": "128132", + "AQLid": "119", + "QDes": "/口红", + "QHide": "1", + "EMCode": "400591" + }, + { + "QSid": "👠", + "QCid": "128096", + "AQLid": "120", + "QDes": "/高跟鞋", + "QHide": "1", + "EMCode": "400447" + }, + { + "QSid": "👢", + "QCid": "128098", + "AQLid": "121", + "QDes": "/靴子", + "EMCode": "400449" + }, + { + "QSid": "🌂", + "QCid": "127746", + "AQLid": "122", + "QDes": "/雨伞", + "QHide": "1", + "EMCode": "400077" + }, + { + "QSid": "👜", + "QCid": "128092", + "AQLid": "123", + "QDes": "/包", + "QHide": "1", + "EMCode": "400443" + }, + { + "QSid": "👙", + "QCid": "128089", + "AQLid": "124", + "QDes": "/内衣", + "QHide": "1", + "EMCode": "400440" + }, + { + "QSid": "👕", + "QCid": "128085", + "AQLid": "125", + "QDes": "/衣服", + "QHide": "1", + "EMCode": "400436" + }, + { + "QSid": "👟", + "QCid": "128095", + "AQLid": "126", + "QDes": "/鞋子", + "QHide": "1", + "EMCode": "400446" + }, + { + "QSid": "☁", + "QCid": "9729", + "AQLid": "127", + "QDes": "/云朵", + "QHide": "1", + "EMCode": "401329" + }, + { + "QSid": "☀", + "QCid": "9728", + "AQLid": "128", + "QDes": "/晴天", + "EMCode": "401328" + }, + { + "QSid": "☔", + "QCid": "9748", + "AQLid": "129", + "QDes": "/雨天", + "QHide": "1", + "EMCode": "401342" + }, + { + "QSid": "🌙", + "QCid": "127769", + "AQLid": "130", + "QDes": "/月亮", + "QHide": "1", + "EMCode": "400100" + }, + { + "QSid": "⛄", + "QCid": "9924", + "AQLid": "131", + "QDes": "/雪人", + "QHide": "1", + "EMCode": "401346" + }, + { + "QSid": "⭕", + "QCid": "11093", + "AQLid": "132", + "QDes": "/正确", + "QHide": "1", + "EMCode": "401687" + }, + { + "QSid": "❌", + "QCid": "10060", + "AQLid": "133", + "QDes": "/错误", + "QHide": "1", + "EMCode": "401142" + }, + { + "QSid": "❔", + "QCid": "10068", + "AQLid": "134", + "QDes": "/问号", + "EMCode": "401145" + }, + { + "QSid": "❕", + "QCid": "10069", + "AQLid": "135", + "QDes": "/叹号", + "QHide": "1", + "EMCode": "401146" + }, + { + "QSid": "☎", + "QCid": "9742", + "AQLid": "136", + "QDes": "/电话", + "QHide": "1", + "EMCode": "401398" + }, + { + "QSid": "📷", + "QCid": "128247", + "AQLid": "137", + "QDes": "/相机", + "QHide": "1", + "EMCode": "400726" + }, + { + "QSid": "📱", + "QCid": "128241", + "AQLid": "138", + "QDes": "/手机", + "QHide": "1", + "EMCode": "400720" + }, + { + "QSid": "📠", + "QCid": "128224", + "AQLid": "139", + "QDes": "/传真", + "QHide": "1", + "EMCode": "400703" + }, + { + "QSid": "💻", + "QCid": "128187", + "AQLid": "140", + "QDes": "/电脑", + "QHide": "1", + "EMCode": "400666" + }, + { + "QSid": "🎥", + "QCid": "127909", + "AQLid": "141", + "QDes": "/摄影机", + "QHide": "1", + "EMCode": "400214" + }, + { + "QSid": "🎤", + "QCid": "127908", + "AQLid": "142", + "QDes": "/话筒", + "QHide": "1", + "EMCode": "400213" + }, + { + "QSid": "🔫", + "QCid": "128299", + "AQLid": "143", + "QDes": "/手枪", + "EMCode": "400774" + }, + { + "QSid": "💿", + "QCid": "128191", + "AQLid": "144", + "QDes": "/光碟", + "QHide": "1", + "EMCode": "400670" + }, + { + "QSid": "💓", + "QCid": "128147", + "AQLid": "145", + "QDes": "/爱心", + "EMCode": "400621" + }, + { + "QSid": "♣", + "QCid": "9827", + "AQLid": "146", + "QDes": "/扑克", + "QHide": "1", + "EMCode": "401385" + }, + { + "QSid": "🀄", + "QCid": "126980", + "AQLid": "147", + "QDes": "/麻将", + "QHide": "1", + "EMCode": "401386" + }, + { + "QSid": "〽", + "QCid": "12349", + "AQLid": "148", + "QDes": "/股票", + "QHide": "1", + "EMCode": "401691" + }, + { + "QSid": "🎰", + "QCid": "127920", + "AQLid": "149", + "QDes": "/老虎机", + "QHide": "1", + "EMCode": "400225" + }, + { + "QSid": "🚥", + "QCid": "128677", + "AQLid": "150", + "QDes": "/信号灯", + "QHide": "1", + "EMCode": "400980" + }, + { + "QSid": "🚧", + "QCid": "128679", + "AQLid": "151", + "QDes": "/路障", + "QHide": "1", + "EMCode": "400982" + }, + { + "QSid": "🎸", + "QCid": "127928", + "AQLid": "152", + "QDes": "/吉他", + "QHide": "1", + "EMCode": "400233" + }, + { + "QSid": "💈", + "QCid": "128136", + "AQLid": "153", + "QDes": "/理发厅", + "QHide": "1", + "EMCode": "400610" + }, + { + "QSid": "🛀", + "QCid": "128704", + "AQLid": "154", + "QDes": "/浴缸", + "QHide": "1", + "EMCode": "401022" + }, + { + "QSid": "🚽", + "QCid": "128701", + "AQLid": "155", + "QDes": "/马桶", + "QHide": "1", + "EMCode": "401019" + }, + { + "QSid": "🏠", + "QCid": "127968", + "AQLid": "156", + "QDes": "/家", + "QHide": "1", + "EMCode": "400271" + }, + { + "QSid": "⛪", + "QCid": "9962", + "AQLid": "157", + "QDes": "/教堂", + "QHide": "1", + "EMCode": "401281" + }, + { + "QSid": "🏦", + "QCid": "127974", + "AQLid": "158", + "QDes": "/银行", + "QHide": "1", + "EMCode": "400277" + }, + { + "QSid": "🏥", + "QCid": "127973", + "AQLid": "159", + "QDes": "/医院", + "QHide": "1", + "EMCode": "400276" + }, + { + "QSid": "🏨", + "QCid": "127976", + "AQLid": "160", + "QDes": "/酒店", + "QHide": "1", + "EMCode": "400279" + }, + { + "QSid": "🏧", + "QCid": "127975", + "AQLid": "161", + "QDes": "/取款机", + "QHide": "1", + "EMCode": "400278" + }, + { + "QSid": "🏪", + "QCid": "127978", + "AQLid": "162", + "QDes": "/便利店", + "EMCode": "400281" + }, + { + "QSid": "🚹", + "QCid": "128697", + "AQLid": "163", + "QDes": "/男性", + "QHide": "1", + "EMCode": "401015" + }, + { + "QSid": "🚺", + "QCid": "128698", + "AQLid": "164", + "QDes": "/女性", + "QHide": "1", + "EMCode": "401016" + } + ] +} diff --git a/packages/snowcat-core/external/napcat.json b/packages/snowcat-core/external/napcat.json new file mode 100644 index 0000000..9b8b28d --- /dev/null +++ b/packages/snowcat-core/external/napcat.json @@ -0,0 +1,17 @@ +{ + "fileLog": false, + "consoleLog": true, + "fileLogLevel": "debug", + "consoleLogLevel": "info", + "packetBackend": "auto", + "packetServer": "", + "o3HookMode": 1, + "bypass": { + "hook": false, + "window": false, + "module": false, + "process": false, + "container": false, + "js": false + } +} \ No newline at end of file diff --git a/packages/snowcat-core/external/napi2native.json b/packages/snowcat-core/external/napi2native.json new file mode 100644 index 0000000..20df48a --- /dev/null +++ b/packages/snowcat-core/external/napi2native.json @@ -0,0 +1,314 @@ +{ + "9.9.22-40990-x64": { + "send": "1B5699C", + "recv": "1D8CA9D" + }, + "9.9.22-40824-x64": { + "send": "1B5699C", + "recv": "1D8CA9D" + }, + "9.9.22-40768-x64": { + "send": "1B5699C", + "recv": "1D8CA9D" + }, + "3.2.20-40768-x64": { + "send": "2A1B840", + "recv": "2D28F20" + }, + "3.2.20-40824-x64": { + "send": "2A1B840", + "recv": "2D28F20" + }, + "3.2.20-40990-x64": { + "send": "2A1B840", + "recv": "2D28F20" + }, + "3.2.20-40990-arm64": { + "send": "157C0E8", + "recv": "1546658" + }, + "3.2.20-40824-arm64": { + "send": "157C0E8", + "recv": "1546658" + }, + "3.2.20-40768-arm64": { + "send": "157C0E8", + "recv": "1546658" + }, + "9.9.23-41679-x64": { + "send": "09FF0F4", + "recv": "1D1A039" + }, + "6.9.82-40824-arm64": { + "send": "05FA930", + "recv": "0B41B90" + }, + "6.9.82-40768-arm64": { + "send": "05FA930", + "recv": "0B41B90" + }, + "6.9.82-40990-arm64": { + "send": "05FA930", + "recv": "0B41B90" + }, + "6.9.83-41679-arm64": { + "send": "237D114", + "recv": "0957648" + }, + "6.9.83-41785-arm64": { + "send": "23B0BF0", + "recv": "095567C" + }, + "9.9.23-41785-x64": { + "send": "09FF0A4", + "recv": "1D19FF9" + }, + "6.9.83-41857-arm64": { + "send": "0815774", + "recv": "0958B3C" + }, + "3.2.21-41857-x64": { + "send": "5B44510", + "recv": "2FDB0B0" + }, + "3.2.21-41857-arm64": { + "send": "3D6EE6C", + "recv": "1479EDC" + }, + "9.9.23-41857-x64": { + "send": "0A01394", + "recv": "1D1C4F9" + }, + "9.9.23-42086-x64": { + "send": "0A01814", + "recv": "1D1C9B9" + }, + "6.9.85-42086-arm64": { + "send": "23B0330", + "recv": "0957648" + }, + "3.2.21-42086-arm64": { + "send": "3D6D98C", + "recv": "14797C8" + }, + "3.2.21-42086-x64": { + "send": "5B42CF0", + "recv": "2FDA6F0" + }, + "9.9.23-42430-x64": { + "send": "0A01A34", + "recv": "1D1CFF9" + }, + "9.9.25-42744-x64": { + "send": "0A0D104", + "recv": "1D3E7F9" + }, + "6.9.85-42744-arm64": { + "send": "23DFEF0", + "recv": "095FD80" + }, + "9.9.25-42905-x64": { + "send": "0A12E74", + "recv": "1D450FD" + }, + "6.9.86-42905-arm64": { + "send": "2342408", + "recv": "09639B8" + }, + "3.2.22-42941-x64": { + "send": "5BC1630", + "recv": "3011E00" + }, + "3.2.22-42941-arm64": { + "send": "3DC90AC", + "recv": "1497A70" + }, + "9.9.25-42941-x64": { + "send": "0A131D4", + "recv": "1D4547D" + }, + "6.9.86-42941-arm64": { + "send": "2346108", + "recv": "09675F0" + }, + "9.9.26-44175-x64": { + "send": "0A0F2EC", + "recv": "1D3AD4D" + }, + "9.9.26-44343-x64": { + "send": "0A0F7BC", + "recv": "1D3C3CD" + }, + "3.2.23-44343-arm64": { + "send": "3C867DC", + "recv": "1404938" + }, + "3.2.23-44343-x64": { + "send": "59A27B0", + "recv": "2FFBE90" + }, + "9.9.26-44498-x64": { + "send": "0A1051C", + "recv": "1D3BC0D" + }, + "9.9.26-44725-x64": { + "send": "0A18D0C", + "recv": "1D4BF0D" + }, + "9.9.27-45627-x64": { + "send": "0A697CC", + "recv": "1E86AC1" + }, + "6.9.88-44725-x64": { + "send": "2756EF6", + "recv": "0A36152" + }, + "6.9.88-44725-arm64": { + "send": "2313C68", + "recv": "09693E4" + }, + "3.2.25-45758-x64": { + "send": "5CF8F30", + "recv": "31667E0" + }, + "3.2.25-45758-arm64": { + "send": "3EB6554", + "recv": "149AF0C" + }, + "9.9.27-45758-x64": { + "send": "0A6A6BC", + "recv": "1E87EC1" + }, + "9.9.28-46494-x64": { + "send": "0A6D66C", + "recv": "1E9890D" + }, + "3.2.26-46494-x64": { + "send": "5D30910", + "recv": "31814B0" + }, + "3.2.26-46494-arm64": { + "send": "3EDC654", + "recv": "14A3124" + }, + "3.2.26-46928-x64": { + "send": "5D30910", + "recv": "31814B0" + }, + "3.2.26-46928-arm64": { + "send": "3EDC654", + "recv": "14A3124" + }, + "9.9.28-46928-x64": { + "send": "0A6D66C", + "recv": "1E9890D" + }, + "3.2.27-47354-x64": { + "send": "5D38930", + "recv": "318D090" + }, + "3.2.27-47354-arm64": { + "send": "3EE8B24", + "recv": "14D422C" + }, + "9.9.29-47354-x64": { + "send": "0A70B3C", + "recv": "1EAFD51" + }, + "6.9.93-47354-arm64": { + "send": "244066C", + "recv": "09BCCF8" + }, + "6.9.93-47354-x64": { + "send": "28ADE46", + "recv": "0A8FD5A" + }, + "9.9.30-48517-x64": { + "send": "0A9B1BC", + "recv": "1F0324D" + }, + "3.2.28-48517-x64": { + "send": "5E41890", + "recv": "3216D10" + }, + "3.2.28-48517-arm64": { + "send": "3F9C7E4", + "recv": "14E1AA8" + }, + "9.9.30-48762-x64": { + "send": "0A9B1BC", + "recv": "1F0324D" + }, + "9.9.31-49599-x64": { + "send": "0AF885C", + "recv": "20058E1" + }, + "9.9.31-49738-x64": { + "send": "0AF9FCC", + "recv": "2007901" + }, + "3.2.29-49738-x64": { + "send": "6234600", + "recv": "33D89D0" + }, + "3.2.29-49738-arm64": { + "send": "426AA04", + "recv": "1596254" + }, + "9.9.32-50828-x64": { + "send": "0B245CC", + "recv": "2073D65" + }, + "3.2.30-50828-x64": { + "send": "6340530", + "recv": "346DA50" + }, + "3.2.30-50828-arm64": { + "send": "4327764", + "recv": "15e3184" + }, + "9.9.32-50969-x64": { + "send": "0B2E2AC", + "recv": "209C709" + }, + "3.2.30-50969-x64": { + "send": "63F47B0", + "recv": "34B9E30" + }, + "3.2.30-50969-arm64": { + "send": "43A4804", + "recv": "1608CC8" + }, + "9.9.33-52230-x64": { + "send": "BF2B5F", + "recv": "23BB4DD" + }, + "3.2.32-52194-x64": { + "send": "6C45D20", + "recv": "393DE00" + }, + "3.2.32-52194-arm64": { + "send": "493FAD8", + "recv": "17EA1DC" + }, + "9.9.35-52892-x64": { + "send": "C361DF", + "recv": "246F3A1" + }, + "3.2.33-52892-x64": { + "send": "6DDB4C0", + "recv": "39DA860" + }, + "3.2.33-52892-arm64": { + "send": "4A5D658", + "recv": "1813A34" + }, + "9.9.37-54395-x64": { + "send": "C5927F", + "recv": "24CC2B5" + }, + "9.9.37-54276-x64": { + "send": "C5927F", + "recv": "24CC2B5" + } +} \ No newline at end of file diff --git a/packages/snowcat-core/external/packet.json b/packages/snowcat-core/external/packet.json new file mode 100644 index 0000000..5bd3b53 --- /dev/null +++ b/packages/snowcat-core/external/packet.json @@ -0,0 +1,814 @@ +{ + "3.2.12-28418-x64": { + "recv": "A0723E0", + "send": "A06EAE0" + }, + "9.9.15-28418-x64": { + "recv": "37A9004", + "send": "37A4BD0" + }, + "6.9.56-28418-x64": { + "send": "4471360", + "recv": "4473BCC" + }, + "6.9.56-28418-arm64": { + "send": "3FBDBF8", + "recv": "3FC0410" + }, + "9.9.15-28498-x64": { + "recv": "37A9004", + "send": "37A4BD0" + }, + "9.9.16-28788-x64": { + "send": "38076D0", + "recv": "380BB04" + }, + "3.2.13-28788-x64": { + "send": "A0CEC20", + "recv": "A0D2520" + }, + "3.2.13-28788-arm64": { + "send": "6E91018", + "recv": "6E94850" + }, + "9.9.16-28971-x64": { + "send": "38079F0", + "recv": "380BE24" + }, + "3.2.13-28971-x64": { + "send": "A0CEF60", + "recv": "A0D2860" + }, + "3.2.12-28971-arm64": { + "send": "6E91318", + "recv": "6E94B50" + }, + "6.9.58-28971-x64": { + "send": "449ACA0", + "recv": "449D50C" + }, + "6.9.58-28971-arm64": { + "send": "3FE0DB0", + "recv": "3FE35C8" + }, + "9.9.16-29271-x64": { + "send": "3833510", + "recv": "3837944" + }, + "3.2.13-29271-x64": { + "send": "A11E680", + "recv": "A121F80" + }, + "3.2.13-29271-arm64": { + "send": "6ECA098", + "recv": "6ECD8D0" + }, + "9.9.16-29456-x64": { + "send": "3835CD0", + "recv": "383A104" + }, + "3.2.13-29456-x64": { + "send": "A11E820", + "recv": "A122120" + }, + "3.2.13-29456-arm64": { + "send": "6ECA130", + "recv": "6ECD968" + }, + "6.9.59-29456-x64": { + "send": "44C57A0", + "recv": "44C800C" + }, + "6.9.59-29456-arm64": { + "send": "4005FE8", + "recv": "4008800" + }, + "9.9.16-29927-x64": { + "send": "3869C50", + "recv": "386E084" + }, + "3.2.13-29927-x64": { + "send": "A1913A0", + "recv": "A194CA0" + }, + "3.2.13-29927-arm64": { + "send": "6F1C7E0", + "recv": "6F20018" + }, + "6.9.61-29927-x64": { + "send": "44FCC60", + "recv": "44FF4CC" + }, + "6.9.61-29927-arm64": { + "send": "4038740", + "recv": "403AF58" + }, + "9.9.17-30366-x64": { + "send": "39AB0B0", + "recv": "39AF4E4" + }, + "3.2.15-30366-x64": { + "send": "A402380", + "recv": "A405C80" + }, + "3.2.15-30366-arm64": { + "send": "70C3FA8", + "recv": "70C77E0" + }, + "6.9.62-30366-x64": { + "send": "4669760", + "recv": "466BFCC" + }, + "6.9.62-30366-arm64": { + "send": "4189770", + "recv": "418BF88" + }, + "9.9.17-30483-x64": { + "send": "39AC1B0", + "recv": "39B05E4" + }, + "6.9.62-30483-arm64": { + "send": "41896B0", + "recv": "418bec8" + }, + "6.9.62-30483-x64": { + "send": "4669460", + "recv": "466BCCC" + }, + "3.2.15-30483-x64": { + "send": "A402540", + "recv": "A405E40" + }, + "3.2.15-30483-arm64": { + "send": "70C40E8", + "recv": "70C7920" + }, + "9.9.17-30594-x64": { + "send": "39AC1B0", + "recv": "39B05E4" + }, + "6.9.62-30594-arm64": { + "send": "41896B0", + "recv": "418bec8" + }, + "6.9.62-30594-x64": { + "send": "4669460", + "recv": "466BCCC" + }, + "3.2.15-30594-x64": { + "send": "A402540", + "recv": "A405E40" + }, + "3.2.15-30594-arm64": { + "send": "70C40E8", + "recv": "70C7920" + }, + "9.9.17-30851-x64": { + "send": "395C150", + "recv": "3960584" + }, + "3.2.15-30851-x64": { + "send": "A4A03E0", + "recv": "A4A3CE0" + }, + "3.2.15-30851-arm64": { + "send": "713A318", + "recv": "713DB50" + }, + "6.9.63-30851-x64": { + "send": "46C8040", + "recv": "46CA8AC" + }, + "6.9.63-30851-arm64": { + "send": "41DCBD8", + "recv": "41DF3F0" + }, + "9.9.17-30899-x64": { + "send": "395C150", + "recv": "3960584" + }, + "3.2.15-30899-x64": { + "send": "A4A03E0", + "recv": "A4A3CE0" + }, + "3.2.15-30899-arm64": { + "send": "713A318", + "recv": "713DB50" + }, + "6.9.63-30899-x64": { + "send": "46C8040", + "recv": "46CA8AC" + }, + "6.9.63-30899-arm64": { + "send": "41DCBD8", + "recv": "41DF3F0" + }, + "9.9.17-31219-x64": { + "send": "39C1350", + "recv": "39C5784" + }, + "9.9.17-31245-x64": { + "send": "39C1350", + "recv": "39C5784" + }, + "6.9.63-31245-x64": { + "send": "4720A40", + "recv": "47232AC" + }, + "6.9.63-31245-arm64": { + "send": "41DCBD8", + "recv": "422D4E8" + }, + "3.2.15-31245-x64": { + "send": "A550F80", + "recv": "A554880" + }, + "3.2.15-31245-arm64": { + "send": "71BEBB8", + "recv": "71C23F0" + }, + "9.9.17-31363-x64": { + "send": "39C1910", + "recv": "39C5d44" + }, + "3.2.15-31363-x64": { + "send": "A554500", + "recv": "A557E00" + }, + "3.2.15-31363-arm64": { + "send": "71BFD48", + "recv": "71C3580" + }, + "6.9.65-31363-x64": { + "send": "4720E80", + "recv": "47236EC" + }, + "6.9.65-31363-arm64": { + "send": "422CEF8", + "recv": "422F710" + }, + "9.9.18-32690-x64": { + "send": "39F9630", + "recv": "39FDE30" + }, + "3.2.16-32690-x64": { + "send": "A5E24C0", + "recv": "A5E5EE0" + }, + "3.2.16-32690-arm64": { + "send": "7226630", + "recv": "7229F60" + }, + "3.2.16-32721-x64": { + "send": "A5E24C0", + "recv": "A5E5EE0" + }, + "3.2.16-32721-arm64": { + "send": "7226630", + "recv": "7229F60" + }, + "9.9.18-32793-x64": { + "send": "39F9A30", + "recv": "39FE230" + }, + "3.2.16-32793-x64": { + "send": "A5E24C0", + "recv": "A5E5EE0" + }, + "3.2.16-32793-arm64": { + "send": "7226630", + "recv": "7229F60" + }, + "9.9.18-32869-x64": { + "send": "39F9A30", + "recv": "39FE230" + }, + "3.2.16-32869-x64": { + "send": "A5E24C0", + "recv": "A5E5EE0" + }, + "3.2.16-32869-arm64": { + "send": "7226630", + "recv": "7229F60" + }, + "9.9.18-33139-x64": { + "send": "39F5870", + "recv": "39FA070" + }, + "3.2.16-33139-x64": { + "send": "A634F60", + "recv": "A638980" + }, + "3.2.16-33139-arm64": { + "send": "7262BB0", + "recv": "72664E0" + }, + "9.9.18-33800-x64": { + "send": "39F5870", + "recv": "39FA070" + }, + "3.2.16-33800-x64": { + "send": "A634F60", + "recv": "A638980" + }, + "3.2.16-33800-arm64": { + "send": "7262BB0", + "recv": "72664E0" + }, + "9.9.19-34231-x64": { + "send": "3BD73D0", + "recv": "3BDBBD0" + }, + "3.2.17-34231-x64": { + "send": "AD787E0", + "recv": "AD7C200" + }, + "3.2.17-34231-arm64": { + "send": "770CDC0", + "recv": "77106F0" + }, + "9.9.19-34362-x64": { + "send": "3BD80D0", + "recv": "3BDC8D0" + }, + "9.9.19-34467-x64": { + "send": "3BD8690", + "recv": "3BDCE90" + }, + "9.9.19-34566-x64": { + "send": "3BDA110", + "recv": "3BDE910" + }, + "9.9.19-34606-x64": { + "send": "3BDA110", + "recv": "3BDE910" + }, + "3.2.17-34606-x64": { + "send": "AD7DC60", + "recv": "AD81680" + }, + "3.2.17-34606-arm64": { + "send": "7711270", + "recv": "7714BA0" + }, + "9.9.19-34740-x64": { + "send": "3BDD8D0", + "recv": "3BE20D0" + }, + "3.2.17-34740-x64": { + "send": "ADDF0A0", + "recv": "ADE2AC0" + }, + "3.2.17-34740-arm64": { + "send": "7753BB8", + "recv": "77574E8" + }, + "9.9.19-34958-x64": { + "send": "3BDD8D0", + "recv": "3BE20D0" + }, + "3.2.17-35184-x64": { + "send": "AE0DDE0", + "recv": "AE11800" + }, + "3.2.17-35184-arm64": { + "send": "7776028", + "recv": "7779958" + }, + "9.9.19-35184-x64": { + "send": "3BE5A10", + "recv": "3BEA210" + }, + "9.9.19-35341-x64": { + "send": "3BF1D50", + "recv": "3BF6550" + }, + "9.9.19-35469-x64": { + "send": "3BF1D50", + "recv": "3BF6550" + }, + "3.2.17-35341-x64": { + "send": "AE2F700", + "recv": "AE33120" + }, + "3.2.17-35341-arm64": { + "send": "778D840", + "recv": "7791170" + }, + "9.9.20-35951-x64": { + "send": "3034BAC", + "recv": "3038354" + }, + "3.2.18-35951-x64": { + "send": "AFBBB00", + "recv": "AFBF520" + }, + "9.9.20-36580-x64": { + "send": "30824B8", + "recv": "3085C5C" + }, + "3.2.18-36580-x64": { + "send": "B0853E0", + "recv": "B088E60" + }, + "3.2.18-36580-arm64": { + "send": "793DAC8", + "recv": "7941458" + }, + "3.2.18-37012-x64": { + "send": "B20F960", + "recv": "B2133E0" + }, + "3.2.18-37012-arm64": { + "send": "7A19E00", + "recv": "7A1D790" + }, + "9.9.20-37012-x64": { + "send": "30CC958", + "recv": "30D00FC" + }, + "3.2.18-37051-x64": { + "send": "B20F960", + "recv": "B2133E0" + }, + "3.2.18-37051-arm64": { + "send": "7A19E00", + "recv": "7A1D790" + }, + "9.9.20-37051-x64": { + "send": "30CC958", + "recv": "30D00FC" + }, + "9.9.20-37475-x64": { + "send": "30D30D8", + "recv": "30D687C" + }, + "3.2.18-37475-x64": { + "send": "B238EC0", + "recv": "B23C940" + }, + "3.2.18-37475-arm64": { + "send": "7A34B38", + "recv": "7A384C8" + }, + "9.9.20-37625-x64": { + "send": "30D39D8", + "recv": "30D717C" + }, + "3.2.18-37625-x64": { + "send": "B2397E0", + "recv": "B23D260" + }, + "3.2.18-37625-arm64": { + "send": "7A350D8", + "recv": "7A38A68" + }, + "9.9.21-38503-x64": { + "send": "3105F38", + "recv": "31096DC" + }, + "3.2.19-38503-x64": { + "send": "B2C1A60", + "recv": "B2C54E0" + }, + "3.2.19-38626-x64": { + "send": "B2C1BE0", + "recv": "B2C5660" + }, + "9.9.21-38626-x64": { + "send": "310A758", + "recv": "310DEFC" + }, + "3.2.19-38626-arm64": { + "send": "7A8A490", + "recv": "7A8DE20" + }, + "9.9.21-38711-x64": { + "send": "310A758", + "recv": "310DEFC" + }, + "3.2.19-38960-x64": { + "send": "B3740E0", + "recv": "B377B60" + }, + "9.9.21-38960-x64": { + "send": "313F7D8", + "recv": "3142F7C" + }, + "3.2.19-38960-arm64": { + "send": "7B01D98", + "recv": "7B05728" + }, + "3.2.19-39038-x64": { + "send": "B3759E0", + "recv": "B379460" + }, + "3.2.19-39038-arm64": { + "send": "7B025C8", + "recv": "7B05F58" + }, + "9.9.21-39038-x64": { + "send": "313FB58", + "recv": "31432FC" + }, + "9.9.22-40362-x64": { + "send": "31C0EB8", + "recv": "31C465C" + }, + "3.2.20-40768-x64": { + "send": "B69CFE0", + "recv": "B6A0A60" + }, + "9.9.22-40768-x64": { + "send": "31C1838", + "recv": "31C4FDC" + }, + "3.2.20-40768-arm64": { + "send": "7D49B18", + "recv": "7D4D4A8" + }, + "6.9.82-40768-arm64": { + "send": "202A198", + "recv": "202B718" + }, + "9.9.22-40824-x64": { + "send": "31C1838", + "recv": "31C4FDC" + }, + "3.2.20-40824-arm64": { + "send": "7D49B18", + "recv": "7D4D4A8" + }, + "6.9.82-40824-arm64": { + "send": "202A198", + "recv": "202B718" + }, + "3.2.20-40990-x64": { + "send": "B69CFE0", + "recv": "B6A0A60" + }, + "3.2.20-40990-arm64": { + "send": "7D49B18", + "recv": "7D4D4A8" + }, + "9.9.22-40990-x64": { + "send": "31C1838", + "recv": "31C4FDC" + }, + "6.9.82-40990-arm64": { + "send": "202A198", + "recv": "202B718" + }, + "9.9.23-41679-x64": { + "send": "2C94520", + "recv": "2C97AA0" + }, + "6.9.83-41679-arm64": { + "send": "3D718F8", + "recv": "3D74208" + }, + "9.9.23-41785-x64": { + "send": "2C944A0", + "recv": "2C97A20" + }, + "6.9.83-41785-arm64": { + "send": "3D6DA28", + "recv": "3D70338" + }, + "6.9.83-41857-arm64": { + "send": "3D74610", + "recv": "3D76F20" + }, + "3.2.21-41857-x64": { + "send": "A7B40A0", + "recv": "A7B7B20" + }, + "9.9.23-41857-x64": { + "send": "2C98F00", + "recv": "2C9C480" + }, + "3.2.21-41857-arm64": { + "send": "6B159F8", + "recv": "6B19388" + }, + "9.9.23-42086-x64": { + "send": "2C99800", + "recv": "2C9CD80" + }, + "3.2.21-42086-x64": { + "send": "A7B1060", + "recv": "A7B4AE0" + }, + "3.2.21-42086-arm64": { + "send": "6B13038", + "recv": "6B169C8" + }, + "9.9.23-42430-x64": { + "send": "2C9A4A0", + "recv": "2C9DA20" + }, + "9.9.25-42744-x64": { + "send": "2CD8E40", + "recv": "2CDC3C0" + }, + "6.9.86-42744-arm64": { + "send": "3DCC840", + "recv": "3DCF150" + }, + "9.9.25-42905-x64": { + "send": "2CE46A0", + "recv": "2CE7C20" + }, + "6.9.86-42905-arm64": { + "send": "3DD6098", + "recv": "3DD89A8" + }, + "3.2.22-42941-x64": { + "send": "A8AD8A0", + "recv": "A8B1320" + }, + "9.9.25-42941-x64": { + "send": "2CE4DA0", + "recv": "2CE8320" + }, + "3.2.22-42941-arm64": { + "send": "6BC95E8", + "recv": "6BCCF78" + }, + "6.9.86-42941-arm64": { + "send": "3DDDAD0", + "recv": "3DE03E0" + }, + "9.9.26-44175-x64": { + "send": "2CD84A0", + "recv": "2CDBA20" + }, + "3.2.23-44343-x64": { + "send": "A46F140", + "recv": "A472BE0" + }, + "9.9.26-44343-x64": { + "send": "2CD8EE0", + "recv": "2CDC460" + }, + "3.2.23-44343-arm64": { + "send": "6926F60", + "recv": "692A910" + }, + "9.9.26-44498-x64": { + "send": "2CDAE40", + "recv": "2CDE3C0" + }, + "9.9.26-44725-x64": { + "send": "2CEBB20", + "recv": "2CEF0A0" + }, + "9.9.27-45627-x64": { + "send": "2E59CC0", + "recv": "2E5D240" + }, + "6.9.88-44725-x64": { + "send": "451FE90", + "recv": "4522A40" + }, + "6.9.88-44725-arm64": { + "send": "3D79168", + "recv": "3D7BA78" + }, + "3.2.25-45758-x64": { + "send": "AAC4E00", + "recv": "AAC88A0" + }, + "9.9.27-45758-x64": { + "send": "2E5C4A0", + "recv": "2E5FA20" + }, + "3.2.25-45758-arm64": { + "send": "6D6F9A0", + "recv": "6D73350" + }, + "3.2.26-46494-x64": { + "send": "AB341C0", + "recv": "AB37C60" + }, + "9.9.28-46494-x64": { + "send": "2E77C20", + "recv": "2E7B1A0" + }, + "3.2.26-46494-arm64": { + "send": "6DBBBB0", + "recv": "6DBF560" + }, + "3.2.26-46928-arm64": { + "send": "6DBBBB0", + "recv": "6DBF560" + }, + "3.2.26-46928-x64": { + "send": "AB341C0", + "recv": "AB37C60" + }, + "9.9.28-46928-x64": { + "send": "2E77C20", + "recv": "2E7B1A0" + }, + "3.2.27-47354-x64": { + "send": "AB6A6C0", + "recv": "AB6E160" + }, + "3.2.27-47354-arm64": { + "send": "6DE1438", + "recv": "6DE4DE8" + }, + "9.9.29-47354-x64": { + "send": "2E84920", + "recv": "2E87EA0" + }, + "6.9.93-47354-arm64": { + "send": "3FD1E98", + "recv": "3FD47A8" + }, + "6.9.93-47354-x64": { + "send": "47CD940", + "recv": "47D04F0" + }, + "3.2.28-48517-x64": { + "send": "AD7C280", + "recv": "AD7FD20" + }, + "9.9.30-48517-x64": { + "send": "2F3B960", + "recv": "2F3EEE0" + }, + "3.2.28-48517-arm64": { + "send": "6F48B70", + "recv": "6F4C520" + }, + "9.9.30-48762-x64": { + "send": "2F3B960", + "recv": "2F3EEE0" + }, + "9.9.31-49599-x64": { + "send": "3082C40", + "recv": "30861C8" + }, + "9.9.31-49738-x64": { + "send": "3086C80", + "recv": "308A208" + }, + "3.2.29-49738-x64": { + "send": "B3B20A0", + "recv": "B3B5B40" + }, + "3.2.29-49738-arm64": { + "send": "7342800", + "recv": "73461B0" + }, + "9.9.32-50828-x64": { + "send": "3155200", + "recv": "3158788" + }, + "9.9.32-50828-arm64": { + "send": "4327764", + "recv": "15E3184" + }, + "3.2.30-50828-x64": { + "send": "B5D0FC0", + "recv": "B5D4A60" + }, + "9.9.32-50969-x64": { + "send": "31994E0", + "recv": "319CA68" + }, + "3.2.30-50969-x64": { + "send": "B7394C0", + "recv": "B73CF60" + }, + "3.2.30-50969-arm64": { + "send": "75BD1D0", + "recv": "75C0B80" + }, + "9.9.33-52230-x64": { + "send": "367FE18", + "recv": "36835CC" + }, + "3.2.32-52194-x64": { + "send": "C7DBF80", + "recv": "C7DFA20" + }, + "3.2.32-52194-arm64": { + "send": "80F36D0", + "recv": "80F7080" + }, + "9.9.35-52892-x64": { + "send": "37C30D8", + "recv": "37C688C" + }, + "3.2.33-52892-x64": { + "send": "CB06D60", + "recv": "CB0A800" + }, + "3.2.33-52892-arm64": { + "send": "832ECA0", + "recv": "8332650" + } +} diff --git a/packages/snowcat-core/helper/adaptDecoder.ts b/packages/snowcat-core/helper/adaptDecoder.ts new file mode 100644 index 0000000..6d9c829 --- /dev/null +++ b/packages/snowcat-core/helper/adaptDecoder.ts @@ -0,0 +1,61 @@ +// TODO: further refactor in NapCat.Packet v2 +import { NapProtoMsg, ProtoField, ScalarType } from 'snowcat-protobuf'; + +const LikeDetail = { + txt: ProtoField(1, ScalarType.STRING), + uin: ProtoField(3, ScalarType.INT64), + nickname: ProtoField(5, ScalarType.STRING), +}; + +const LikeMsg = { + times: ProtoField(1, ScalarType.INT32), + time: ProtoField(2, ScalarType.INT32), + detail: ProtoField(3, () => LikeDetail), +}; + +const ProfileLikeSubTip = { + msg: ProtoField(14, () => LikeMsg), +}; + +const ProfileLikeTip = { + msgType: ProtoField(1, ScalarType.INT32), + subType: ProtoField(2, ScalarType.INT32), + content: ProtoField(203, () => ProfileLikeSubTip), +}; + +const SysMessageHeader = { + PeerNumber: ProtoField(1, ScalarType.UINT32), + PeerString: ProtoField(2, ScalarType.STRING), + Uin: ProtoField(5, ScalarType.UINT32), + Uid: ProtoField(6, ScalarType.STRING, true), +}; + +const SysMessageMsgSpec = { + msgType: ProtoField(1, ScalarType.UINT32), + subType: ProtoField(2, ScalarType.UINT32), + subSubType: ProtoField(3, ScalarType.UINT32), + msgSeq: ProtoField(5, ScalarType.UINT32), + time: ProtoField(6, ScalarType.UINT32), + msgId: ProtoField(12, ScalarType.UINT64), + other: ProtoField(13, ScalarType.UINT32), +}; + +const SysMessageBodyWrapper = { + wrappedBody: ProtoField(2, ScalarType.BYTES), +}; + +const SysMessage = { + header: ProtoField(1, () => SysMessageHeader, false, true), + msgSpec: ProtoField(2, () => SysMessageMsgSpec, false, true), + bodyWrapper: ProtoField(3, () => SysMessageBodyWrapper), +}; + +export function decodeProfileLikeTip (buffer: Uint8Array) { + const msg = new NapProtoMsg(ProfileLikeTip); + return msg.decode(buffer); +} + +export function decodeSysMessage (buffer: Uint8Array) { + const msg = new NapProtoMsg(SysMessage); + return msg.decode(buffer); +} diff --git a/packages/snowcat-core/helper/audio.ts b/packages/snowcat-core/helper/audio.ts new file mode 100644 index 0000000..2d83ac9 --- /dev/null +++ b/packages/snowcat-core/helper/audio.ts @@ -0,0 +1,45 @@ +import fsPromise from 'fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'crypto'; +import { LogWrapper } from '@/snowcat-core/helper/log'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; + +async function guessDuration (pttPath: string, logger: LogWrapper) { + const pttFileInfo = await fsPromise.stat(pttPath); + const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s + logger.log('通过文件大小估算语音的时长:', duration); + return duration; +} + +export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) { + try { + const pttPath = path.join(TEMP_DIR, randomUUID()); + if (!(await FFmpegService.isSilk(filePath))) { + logger.log(`语音文件${filePath}需要转换成silk`); + await FFmpegService.convertToNTSilkTct(filePath, pttPath); + const duration = await FFmpegService.getDuration(filePath); + logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', duration); + return { + converted: true, + path: pttPath, + duration, + }; + } else { + let duration = 0; + try { + duration = await FFmpegService.getDuration(filePath); + } catch (e: unknown) { + logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack); + duration = await guessDuration(filePath, logger); + } + return { + converted: false, + path: filePath, + duration, + }; + } + } catch (error: unknown) { + logger.logError('convert silk failed', error); + return {}; + } +} diff --git a/packages/snowcat-core/helper/config-base.ts b/packages/snowcat-core/helper/config-base.ts new file mode 100644 index 0000000..02a4240 --- /dev/null +++ b/packages/snowcat-core/helper/config-base.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import type { SnowCatCore } from '@/snowcat-core'; +import json5 from 'json5'; +import { TypeCompiler, TypeCheck } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; +import { TSchema } from '@sinclair/typebox'; + +export abstract class ConfigBase { + name: string; + core: SnowCatCore; + configPath: string; + configData: T = {} as T; + configSchema: TSchema; + validate: TypeCheck; + + protected constructor (name: string, core: SnowCatCore, configPath: string, ConfigSchema: TSchema) { + this.name = name; + this.core = core; + this.configPath = configPath; + this.configSchema = ConfigSchema; + this.validate = TypeCompiler.Compile(ConfigSchema); + fs.mkdirSync(this.configPath, { recursive: true }); + this.read(); + } + + getConfigPath (pathName?: string): string { + const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`; + return path.join(this.configPath, filename); + } + + read (): T { + const configPath = this.getConfigPath(this.core.selfInfo.uin); + const defaultConfigPath = this.getConfigPath(); + if (!fs.existsSync(configPath)) { + if (fs.existsSync(defaultConfigPath)) { + this.configData = this.loadConfig(defaultConfigPath); + } + this.save(); + return this.configData; + } + return this.loadConfig(configPath); + } + + private loadConfig (configPath: string): T { + try { + const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8')); + let data: unknown = newConfigData; + data = Value.Parse(this.configSchema, data); + if (!this.validate.Check(data)) { + throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', ')); + } + this.configData = data as T; + this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData); + return this.configData; + } catch (e: unknown) { + this.handleError(e, '读取配置文件时发生错误'); + return {} as T; + } + } + + save (newConfigData: T = this.configData): void { + const configPath = this.getConfigPath(this.core.selfInfo.uin); + let data: unknown = newConfigData; + data = Value.Parse(this.configSchema, data); + if (!this.validate.Check(data)) { + throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', ')); + } + this.configData = data as T; + try { + fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2)); + } catch (e: unknown) { + this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`); + } + } + + private handleError (e: unknown, message: string): void { + if (e instanceof SyntaxError) { + this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message); + } else { + this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message); + } + } +} diff --git a/packages/snowcat-core/helper/config.ts b/packages/snowcat-core/helper/config.ts new file mode 100644 index 0000000..87f00a6 --- /dev/null +++ b/packages/snowcat-core/helper/config.ts @@ -0,0 +1,56 @@ +import { ConfigBase } from '@/snowcat-core/helper/config-base'; +import { SnowCatCore } from '@/snowcat-core/index'; +import { Type, Static, TSchema } from '@sinclair/typebox'; + +import { Value } from '@sinclair/typebox/value'; + +import path from 'node:path'; +import fs from 'node:fs'; +import json5 from 'json5'; + +export const BypassOptionsSchema = Type.Object({ + hook: Type.Boolean({ default: true }), + window: Type.Boolean({ default: true }), + module: Type.Boolean({ default: true }), + process: Type.Boolean({ default: true }), + container: Type.Boolean({ default: true }), + js: Type.Boolean({ default: true }), +}); + +export const SnowCatConfigSchema = Type.Object({ + fileLog: Type.Boolean({ default: false }), + consoleLog: Type.Boolean({ default: true }), + fileLogLevel: Type.String({ default: 'debug' }), + consoleLogLevel: Type.String({ default: 'info' }), + packetBackend: Type.String({ default: 'auto' }), + packetServer: Type.String({ default: '' }), + o3HookMode: Type.Number({ default: 0 }), + autoTimeSync: Type.Boolean({ default: true }), + bypass: Type.Optional(BypassOptionsSchema), +}); + +export type SnowCatConfigFile = Static; + +/** + * 从指定配置目录读取 napcat.json,按 SnowCatConfigSchema 校验并填充默认值 + * 用于登录前(无 SnowCatCore 实例时)的早期配置读取 + */ +export function loadSnowCatConfig (configPath: string): SnowCatConfigFile { + let data: Record = {}; + try { + const configFile = path.join(configPath, 'napcat.json'); + if (fs.existsSync(configFile)) { + data = json5.parse(fs.readFileSync(configFile, 'utf-8')); + } + } catch { + // 读取失败时使用 schema 默认值 + } + data = Value.Parse(SnowCatConfigSchema, data) as Record; + return data as SnowCatConfigFile; +} + +export class SnowCatConfigLoader extends ConfigBase { + constructor (core: SnowCatCore, configPath: string, schema: TSchema) { + super('napcat', core, configPath, schema); + } +} diff --git a/packages/snowcat-core/helper/event.ts b/packages/snowcat-core/helper/event.ts new file mode 100644 index 0000000..c79fd2a --- /dev/null +++ b/packages/snowcat-core/helper/event.ts @@ -0,0 +1,272 @@ +import { NodeIQQNTWrapperSession } from '@/snowcat-core/wrapper'; +import { randomUUID } from 'crypto'; +import { ListenerNamingMapping, ServiceNamingMapping } from '@/snowcat-core/index'; + +interface InternalMapKey { + timeout: number; + createtime: number; + func: (...arg: any[]) => any; + checker: ((...args: any[]) => boolean) | undefined; +} + +type EnsureFunc = T extends (...args: any) => any ? T : never; + +type FuncKeys = Extract< + { + [K in keyof T]: EnsureFunc extends never ? never : K; + }[keyof T], + string +>; + +export type ListenerClassBase = Record; + +export class NTEventWrapper { + private readonly WrapperSession: NodeIQQNTWrapperSession | undefined; // WrapperSession + private readonly listenerManager: Map = new Map(); // ListenerName-Unique -> Listener实例 + private readonly EventTask = new Map>>(); // tasks ListenerMainName -> ListenerSubName-> uuid -> {timeout,createtime,func} + + constructor ( + wrapperSession: NodeIQQNTWrapperSession + ) { + this.WrapperSession = wrapperSession; + } + + createProxyDispatch (ListenerMainName: string) { + const dispatcherListenerFunc = this.dispatcherListener.bind(this); + return new Proxy( + {}, + { + get (target: any, prop: any, receiver: any) { + if (typeof target[prop] === 'undefined') { + // 如果方法不存在,返回一个函数,这个函数调用existentMethod + return (...args: any[]) => { + dispatcherListenerFunc(ListenerMainName, prop, ...args).then(); + }; + } + // 如果方法存在,正常返回 + return Reflect.get(target, prop, receiver); + }, + } + ); + } + + createEventFunction< + Service extends keyof ServiceNamingMapping, + ServiceMethod extends FuncKeys, + T extends (...args: any) => any = EnsureFunc + >(eventName: `${Service}/${ServiceMethod}`): T | undefined { + const eventNameArr = eventName.split('/'); + type eventType = { + [key: string]: () => { [key: string]: (...params: Parameters) => Promise>; }; + }; + if (eventNameArr.length > 1) { + const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? ''); + const eventName = eventNameArr[1]; + const services = (this.WrapperSession as unknown as eventType)[serviceName]?.(); + if (!services || !eventName) { + return undefined; + } + let event = services[eventName]; + + // 重新绑定this + event = event?.bind(services); + if (event) { + return event as T; + } + return undefined; + } + return undefined; + } + + createListenerFunction (listenerMainName: string, uniqueCode: string = ''): T { + const existListener = this.listenerManager.get(listenerMainName + uniqueCode); + if (!existListener) { + const Listener = this.createProxyDispatch(listenerMainName); + const ServiceSubName = /^NodeIKernel(.*?)Listener$/.exec(listenerMainName)![1]; + const Service = `NodeIKernel${ServiceSubName}Service/addKernel${ServiceSubName}Listener`; + + // @ts-ignore + this.createEventFunction(Service)(Listener as T); + this.listenerManager.set(listenerMainName + uniqueCode, Listener); + return Listener as T; + } + return existListener as T; + } + + // 统一回调清理事件 + async dispatcherListener (ListenerMainName: string, ListenerSubName: string, ...args: any[]) { + this.EventTask.get(ListenerMainName) + ?.get(ListenerSubName) + ?.forEach((task, uuid) => { + if (task.createtime + task.timeout < Date.now()) { + this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.delete(uuid); + return; + } + if (task?.checker?.(...args)) { + task.func(...args); + } + }); + } + + async callNoListenerEvent< + Service extends keyof ServiceNamingMapping, + ServiceMethod extends FuncKeys, + EventType extends (...args: any) => any = EnsureFunc + >( + serviceAndMethod: `${Service}/${ServiceMethod}`, + ...args: Parameters + ): Promise>> { + return (this.createEventFunction(serviceAndMethod))!(...args); + } + + async registerListen< + Listener extends keyof ListenerNamingMapping, + ListenerMethod extends FuncKeys, + ListenerType extends (...args: any) => any = EnsureFunc + >( + listenerAndMethod: `${Listener}/${ListenerMethod}`, + checker: (...args: Parameters) => boolean, + waitTimes = 1, + timeout = 5000 + ) { + return new Promise>((resolve, reject) => { + const ListenerNameList = listenerAndMethod.split('/'); + const ListenerMainName = ListenerNameList[0] ?? ''; + const ListenerSubName = ListenerNameList[1] ?? ''; + const id = randomUUID(); + let complete = 0; + let retData: Parameters | undefined; + + function sendDataCallback () { + if (complete === 0) { + reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout')); + } else { + resolve(retData!); + } + } + + const timeoutRef = setTimeout(sendDataCallback, timeout); + const eventCallback = { + timeout, + createtime: Date.now(), + checker, + func: (...args: Parameters) => { + complete++; + retData = args; + if (complete >= waitTimes) { + clearTimeout(timeoutRef); + sendDataCallback(); + } + }, + }; + if (!this.EventTask.get(ListenerMainName)) { + this.EventTask.set(ListenerMainName, new Map()); + } + if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) { + this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map()); + } + this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback); + this.createListenerFunction(ListenerMainName); + }); + } + + async callNormalEventV2< + Service extends keyof ServiceNamingMapping, + ServiceMethod extends FuncKeys, + Listener extends keyof ListenerNamingMapping, + ListenerMethod extends FuncKeys, + EventType extends (...args: any) => any = EnsureFunc, + ListenerType extends (...args: any) => any = EnsureFunc + >( + serviceAndMethod: `${Service}/${ServiceMethod}`, + listenerAndMethod: `${Listener}/${ListenerMethod}`, + args: Parameters, + checkerEvent: (ret: Awaited>) => boolean = () => true, + checkerListener: (...args: Parameters) => boolean = () => true, + callbackTimesToWait = 1, + timeout = 5000 + ) { + const id = randomUUID(); + let complete = 0; + let retData: Parameters | undefined; + let retEvent: any = {}; + + function sendDataCallback (resolve: any, reject: any) { + if (complete === 0) { + reject( + new Error( + 'Timeout: NTEvent serviceAndMethod:' + + serviceAndMethod + + ' ListenerName:' + + listenerAndMethod + + ' EventRet:\n' + + JSON.stringify(retEvent, null, 4) + + '\n' + ) + ); + } else { + resolve([retEvent as Awaited>, ...retData!]); + } + } + + const ListenerNameList = listenerAndMethod.split('/'); + const ListenerMainName = ListenerNameList[0] ?? ''; + const ListenerSubName = ListenerNameList[1] ?? ''; + + return new Promise<[EventRet: Awaited>, ...Parameters]>( + (resolve, reject) => { + const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout); + + const eventCallback = { + timeout, + createtime: Date.now(), + checker: checkerListener, + func: (...args: any[]) => { + complete++; + retData = args as Parameters; + if (complete >= callbackTimesToWait) { + clearTimeout(timeoutRef); + sendDataCallback(resolve, reject); + } + }, + }; + if (!this.EventTask.get(ListenerMainName)) { + this.EventTask.set(ListenerMainName, new Map()); + } + if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) { + this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map()); + } + this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback); + this.createListenerFunction(ListenerMainName); + + const eventResult = this.createEventFunction(serviceAndMethod)!(...(args)); + + const eventRetHandle = (eventData: any) => { + retEvent = eventData; + if (!checkerEvent(retEvent) && timeoutRef.hasRef()) { + clearTimeout(timeoutRef); + reject( + new Error( + 'EventChecker Failed: NTEvent serviceAndMethod:' + + serviceAndMethod + + ' ListenerName:' + + listenerAndMethod + + ' EventRet:\n' + + JSON.stringify(retEvent, null, 4) + + '\n' + ) + ); + } + }; + if (eventResult instanceof Promise) { + eventResult.then((eventResult: any) => { + eventRetHandle(eventResult); + }) + .catch(reject); + } else { + eventRetHandle(eventResult); + } + } + ); + } +} diff --git a/packages/snowcat-core/helper/ffmpeg/download-ffmpeg.ts b/packages/snowcat-core/helper/ffmpeg/download-ffmpeg.ts new file mode 100644 index 0000000..1f53481 --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/download-ffmpeg.ts @@ -0,0 +1,359 @@ +// 更正导入语句 +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; +import * as os from 'os'; +import * as compressing from 'compressing'; // 修正导入方式 +import { pipeline } from 'stream/promises'; +import { fileURLToPath } from 'url'; +import { LogWrapper } from '@/snowcat-core/helper/log'; + +const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip'; +const urls = [ + 'https://j.1win.ggff.net/' + downloadOri, + 'https://git.yylx.win/' + downloadOri, + 'https://ghfile.geekertao.top/' + downloadOri, + 'https://gh-proxy.net/' + downloadOri, + 'https://ghm.078465.xyz/' + downloadOri, + 'https://gitproxy.127731.xyz/' + downloadOri, + 'https://jiashu.1win.eu.org/' + downloadOri, + 'https://github.tbedu.top/' + downloadOri, + downloadOri, +]; + +/** + * 测试URL是否可用 + * @param url 待测试的URL + * @returns 如果URL可访问返回true,否则返回false + */ +async function testUrl (url: string): Promise { + return new Promise((resolve) => { + const req = https.get(url, { timeout: 5000 }, (res) => { + // 检查状态码是否表示成功 + const statusCode = res.statusCode || 0; + if (statusCode >= 200 && statusCode < 300) { + // 终止请求并返回true + req.destroy(); + resolve(true); + } else { + req.destroy(); + resolve(false); + } + }); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * 查找第一个可用的URL + * @returns 返回第一个可用的URL,如果都不可用则返回null + */ +async function findAvailableUrl (): Promise { + for (const url of urls) { + try { + const available = await testUrl(url); + if (available) { + return url; + } + } catch (_error) { + // 忽略错误 + } + } + + return null; +} +/** + * 下载文件 + * @param url 下载URL + * @param destPath 目标保存路径 + * @returns 成功返回true,失败返回false + */ +async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise { + return new Promise((resolve) => { + const file = fs.createWriteStream(destPath); + + const req = https.get(url, (res) => { + const statusCode = res.statusCode || 0; + + if (statusCode >= 200 && statusCode < 300) { + // 获取文件总大小 + const totalSize = parseInt(res.headers['content-length'] || '0', 10); + let downloadedSize = 0; + let lastReportedPercent = -1; // 上次报告的百分比 + let lastReportTime = 0; // 上次报告的时间戳 + + // 如果有内容长度和进度回调,则添加数据监听 + if (totalSize > 0 && progressCallback) { + // 初始报告 0% + progressCallback(0); + lastReportTime = Date.now(); + + res.on('data', (chunk) => { + downloadedSize += chunk.length; + const currentPercent = Math.floor((downloadedSize / totalSize) * 100); + const now = Date.now(); + + // 只在以下条件触发回调: + // 1. 百分比变化至少为1% + // 2. 距离上次报告至少500毫秒 + // 3. 确保报告100%完成 + if ((currentPercent !== lastReportedPercent && + (currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) && + (now - lastReportTime >= 1000 || currentPercent === 100)) { + progressCallback(currentPercent); + lastReportedPercent = currentPercent; + lastReportTime = now; + } + }); + } + + pipeline(res, file) + .then(() => { + // 确保最后报告100% + if (progressCallback && lastReportedPercent !== 100) { + progressCallback(100); + } + resolve(true); + }) + .catch(() => resolve(false)); + } else { + file.close(); + fs.unlink(destPath, () => { }); + resolve(false); + } + }); + + req.on('error', () => { + file.close(); + fs.unlink(destPath, () => { }); + resolve(false); + }); + }); +} + +/** + * 解压缩zip文件中的特定内容 + * 只解压bin目录中的文件到目标目录 + * @param zipPath 压缩文件路径 + * @param extractDir 解压目标路径 + */ +async function extractBinDirectory (zipPath: string, extractDir: string): Promise { + // 确保目标目录存在 + if (!fs.existsSync(extractDir)) { + fs.mkdirSync(extractDir, { recursive: true }); + } + + // 解压文件 + const zipStream = new compressing.zip.UncompressStream({ source: zipPath }); + + return new Promise((resolve, reject) => { + // 监听条目事件 + zipStream.on('entry', (header, stream, next) => { + // 获取文件路径 + const filePath = header.name; + + // 匹配内层bin目录中的文件 + // 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe + if (filePath.includes('/bin/') && filePath.endsWith('.exe')) { + // 提取文件名 + const fileName = path.basename(filePath); + const targetPath = path.join(extractDir, fileName); + + // 创建写入流 + const writeStream = fs.createWriteStream(targetPath); + + // 将流管道连接到文件 + stream.pipe(writeStream); + + // 监听写入完成事件 + writeStream.on('finish', () => { + next(); + }); + + writeStream.on('error', () => { + next(); + }); + } else { + // 跳过不需要的文件 + stream.resume(); + next(); + } + }); + + zipStream.on('error', (err) => { + reject(err); + }); + + zipStream.on('finish', () => { + resolve(); + }); + }); +} + +/** + * 下载并设置FFmpeg + * @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹 + * @param tempDir 临时文件目录,默认为系统临时目录 + * @returns 返回ffmpeg可执行文件的路径,如果失败则返回null + */ +export async function downloadFFmpeg ( + destDir?: string, + tempDir?: string, + progressCallback?: (percent: number, stage: string) => void +): Promise { + // 仅限Windows + if (os.platform() !== 'win32') { + return null; + } + + const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg'); + const tempDirectory = tempDir || os.tmpdir(); + const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录 + const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe'); + + // 确保目录存在 + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir, { recursive: true }); + } + + // 确保临时目录存在 + if (!fs.existsSync(tempDirectory)) { + fs.mkdirSync(tempDirectory, { recursive: true }); + } + + // 如果ffmpeg已经存在,直接返回路径 + if (fs.existsSync(ffmpegExePath)) { + if (progressCallback) progressCallback(100, '已找到FFmpeg'); + return ffmpegExePath; + } + + // 查找可用URL + if (progressCallback) progressCallback(0, '查找可用下载源'); + const availableUrl = await findAvailableUrl(); + if (!availableUrl) { + return null; + } + + // 下载文件 + if (progressCallback) progressCallback(5, '开始下载FFmpeg'); + const downloaded = await downloadFile( + availableUrl, + zipFilePath, + (percent) => { + // 下载占总进度的70% + if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg'); + } + ); + + if (!downloaded) { + return null; + } + + try { + // 直接解压bin目录文件到目标目录 + if (progressCallback) progressCallback(75, '解压FFmpeg'); + await extractBinDirectory(zipFilePath, destinationDir); + + // 清理下载文件 + if (progressCallback) progressCallback(95, '清理临时文件'); + try { + fs.unlinkSync(zipFilePath); + } catch (_err) { + // 忽略清理临时文件失败的错误 + } + + // 检查ffmpeg.exe是否成功解压 + if (fs.existsSync(ffmpegExePath)) { + if (progressCallback) progressCallback(100, 'FFmpeg安装完成'); + return ffmpegExePath; + } else { + return null; + } + } catch (_err) { + return null; + } +} + +/** + * 检查系统PATH环境变量中是否存在指定可执行文件 + * @param executable 可执行文件名 + * @returns 如果找到返回完整路径,否则返回null + */ +function findExecutableInPath (executable: string): string | null { + // 仅适用于Windows系统 + if (os.platform() !== 'win32') return null; + + // 获取PATH环境变量 + const pathEnv = process.env['PATH'] || ''; + const pathDirs = pathEnv.split(';'); + + // 检查每个目录 + for (const dir of pathDirs) { + if (!dir) continue; + try { + const filePath = path.join(dir, executable); + if (fs.existsSync(filePath)) { + return filePath; + } + } catch (_error) { + continue; + } + } + + return null; +} + +export async function downloadFFmpegIfNotExists (log: LogWrapper) { + // 仅限Windows + if (os.platform() !== 'win32') { + return { + path: null, + reset: false, + }; + } + const ffmpegInPath = findExecutableInPath('ffmpeg.exe'); + const ffprobeInPath = findExecutableInPath('ffprobe.exe'); + + if (ffmpegInPath && ffprobeInPath) { + const ffmpegDir = path.dirname(ffmpegInPath); + return { + path: ffmpegDir, + reset: true, + }; + } + + // 如果环境变量中没有,检查项目目录中是否存在 + const currentPath = path.dirname(fileURLToPath(import.meta.url)); + const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe')); + const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe')); + + if (!ffmpeg_exist || !ffprobe_exist) { + const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => { + log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`); + }); + if (!url) { + log.log('[FFmpeg] [Error] 下载FFmpeg失败'); + return { + path: null, + reset: false, + }; + } + return { + path: path.join(currentPath, 'ffmpeg'), + reset: true, + }; + } + + return { + path: path.join(currentPath, 'ffmpeg'), + reset: true, + }; +} diff --git a/packages/snowcat-core/helper/ffmpeg/ffmpeg-adapter-factory.ts b/packages/snowcat-core/helper/ffmpeg/ffmpeg-adapter-factory.ts new file mode 100644 index 0000000..088485f --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/ffmpeg-adapter-factory.ts @@ -0,0 +1,130 @@ +/** + * FFmpeg Adapter Factory + * 自动检测并选择最佳的 FFmpeg 适配器 + */ + +import { LogWrapper } from '@/snowcat-core/helper/log'; +import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter'; +import { FFmpegExecAdapter } from './ffmpeg-exec-adapter'; +import type { IFFmpegAdapter } from './ffmpeg-adapter-interface'; + +/** + * FFmpeg 适配器工厂 + */ +export class FFmpegAdapterFactory { + private static instance: IFFmpegAdapter | null = null; + private static initPromise: Promise | null = null; + + /** + * 初始化并获取最佳的 FFmpeg 适配器 + * @param logger 日志记录器 + * @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器) + * @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器) + * @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器) + */ + static async getAdapter ( + logger: LogWrapper, + ffmpegPath: string = 'ffmpeg', + ffprobePath: string = 'ffprobe', + binaryPath?: string + ): Promise { + // 如果已经初始化,直接返回 + if (this.instance) { + return this.instance; + } + + // 如果正在初始化,等待初始化完成 + if (this.initPromise) { + return this.initPromise; + } + + // 开始初始化 + this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath); + + try { + this.instance = await this.initPromise; + return this.instance; + } finally { + this.initPromise = null; + } + } + + /** + * 初始化适配器 + */ + private static async initialize ( + logger: LogWrapper, + ffmpegPath: string, + ffprobePath: string, + binaryPath?: string + ): Promise { + // 1. 优先尝试使用 Native Addon + if (binaryPath) { + const addonAdapter = new FFmpegAddonAdapter(binaryPath); + + logger.log('[FFmpeg] 检查 Native Addon 可用性...'); + if (await addonAdapter.isAvailable()) { + logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器'); + return addonAdapter; + } + + logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具'); + } else { + logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测'); + } + + // 2. 降级到 execFile 实现 + const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger); + + logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`); + if (await execAdapter.isAvailable()) { + logger.log('[FFmpeg] 使用命令行工具适配器 ✓'); + return execAdapter; + } + + // 3. 都不可用,返回 execAdapter 但会在使用时报错 + logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败'); + return execAdapter; + } + + /** + * 重置适配器(用于测试或重新初始化) + */ + static reset (): void { + this.instance = null; + this.initPromise = null; + } + + /** + * 更新 FFmpeg 路径并重新初始化 + * @param logger 日志记录器 + * @param ffmpegPath FFmpeg 可执行文件路径 + * @param ffprobePath FFprobe 可执行文件路径 + */ + static async updateFFmpegPath ( + logger: LogWrapper, + ffmpegPath: string, + ffprobePath: string + ): Promise { + // 如果当前使用的是 Exec 适配器,更新路径 + if (this.instance && this.instance instanceof FFmpegExecAdapter) { + logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`); + this.instance.setFFmpegPath(ffmpegPath); + this.instance.setFFprobePath(ffprobePath); + + // 验证新路径是否可用 + if (await this.instance.isAvailable()) { + logger.log('[FFmpeg] 新路径验证成功 ✓'); + } else { + logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用'); + } + } + } + + /** + * 获取当前适配器(不初始化) + */ + static getCurrentAdapter (): IFFmpegAdapter | null { + return this.instance; + } +} diff --git a/packages/snowcat-core/helper/ffmpeg/ffmpeg-adapter-interface.ts b/packages/snowcat-core/helper/ffmpeg/ffmpeg-adapter-interface.ts new file mode 100644 index 0000000..79e6dfb --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/ffmpeg-adapter-interface.ts @@ -0,0 +1,76 @@ +/** + * FFmpeg Adapter Interface + * 定义统一的 FFmpeg 操作接口,支持多种实现方式 + */ + +/** + * 视频信息结果 + */ +export interface VideoInfoResult { + /** 视频宽度(像素) */ + width: number; + /** 视频高度(像素) */ + height: number; + /** 视频时长(秒) */ + duration: number; + /** 容器格式 */ + format: string; + /** 缩略图 Buffer */ + thumbnail?: Buffer; +} + +/** + * FFmpeg 适配器接口 + */ +export interface IFFmpegAdapter { + /** 适配器名称 */ + readonly name: string; + + /** 是否可用 */ + isAvailable (): Promise; + + /** + * 获取视频信息(包含缩略图) + * @param videoPath 视频文件路径 + * @returns 视频信息 + */ + getVideoInfo (videoPath: string): Promise; + + /** + * 获取音视频文件时长 + * @param filePath 文件路径 + * @returns 时长(秒) + */ + getDuration (filePath: string): Promise; + + /** + * 判断是否为 Silk 格式 + * @param filePath 文件路径 + */ + isSilk (filePath: string): Promise; + + /** + * 转换音频为 PCM 格式 + * @param filePath 输入文件路径 + * @param pcmPath 输出 PCM 文件路径 + * @returns PCM 数据 Buffer + */ + convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>; + + /** + * 转换音频文件 + * @param inputFile 输入文件路径 + * @param outputFile 输出文件路径 + * @param format 目标格式 ('amr' | 'silk' 等) + */ + convertFile (inputFile: string, outputFile: string, format: string): Promise; + + /** + * 提取视频缩略图 + * @param videoPath 视频文件路径 + * @param thumbnailPath 缩略图输出路径 + */ + extractThumbnail (videoPath: string, thumbnailPath: string): Promise; + + convertToNTSilkTct (inputFile: string, outputFile: string): Promise; +} diff --git a/packages/snowcat-core/helper/ffmpeg/ffmpeg-addon-adapter.ts b/packages/snowcat-core/helper/ffmpeg/ffmpeg-addon-adapter.ts new file mode 100644 index 0000000..c15a5aa --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/ffmpeg-addon-adapter.ts @@ -0,0 +1,140 @@ +/** + * FFmpeg Native Addon Adapter + * 使用原生 Node.js Addon 实现的 FFmpeg 适配器 + */ + +import { platform, arch } from 'node:os'; +import path from 'node:path'; +import { existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import type { FFmpeg } from './ffmpeg-addon'; +import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface'; +import { dlopen } from 'node:process'; + +/** + * 获取 Native Addon 路径 + * @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath) + */ +function getAddonPath (binaryPath: string): string { + const platformName = platform(); + const archName = arch(); + + const addonFileName: string = process.platform + '.' + process.arch; + const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`); + if (!existsSync(addonPath)) { + throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`); + } + return addonPath; +} + +/** + * FFmpeg Native Addon 适配器实现 + */ +export class FFmpegAddonAdapter implements IFFmpegAdapter { + public readonly name = 'FFmpegAddon'; + private addon: FFmpeg | null = null; + private binaryPath: string; + + constructor (binaryPath: string) { + this.binaryPath = binaryPath; + } + + /** + * 检查 Addon 是否可用 + */ + async isAvailable (): Promise { + try { + const temp_addon = { exports: {} }; + dlopen(temp_addon, getAddonPath(this.binaryPath)); + this.addon = temp_addon.exports as FFmpeg; + return this.addon !== null; + } catch (error) { + console.log('[FFmpegAddonAdapter] Failed to load addon:', error); + return false; + } + } + + private ensureAddon (): FFmpeg { + if (!this.addon) { + throw new Error('FFmpeg Addon is not available'); + } + return this.addon; + } + + /** + * 获取视频信息 + */ + async getVideoInfo (videoPath: string): Promise { + const addon = this.ensureAddon(); + const info = await addon.getVideoInfo(videoPath); + + const format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format; + console.log('[FFmpegAddonAdapter] Detected format:', format); + return { + width: info.width, + height: info.height, + duration: info.duration, + format, + thumbnail: info.image, + }; + } + + /** + * 获取时长 + */ + async getDuration (filePath: string): Promise { + const addon = this.ensureAddon(); + return addon.getDuration(filePath); + } + + /** + * 判断是否为 Silk 格式 + */ + async isSilk (filePath: string): Promise { + try { + const fd = openSync(filePath, 'r'); + const buffer = Buffer.alloc(10); + readSync(fd, buffer, 0, 10, 0); + closeSync(fd); + const header = buffer.toString(); + return header.includes('#!SILK') || header.includes('\x02#!SILK'); + } catch { + return false; + } + } + + /** + * 转换为 PCM + */ + async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> { + const addon = this.ensureAddon(); + const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000); + + return result; + } + + /** + * 转换文件 + */ + async convertFile (inputFile: string, outputFile: string, format: string): Promise { + const addon = this.ensureAddon(); + console.log('[FFmpegAddonAdapter] Converting file:', inputFile, 'to', outputFile, 'as', format); + await addon.decodeAudioToFmt(inputFile, outputFile, format); + } + + async convertToNTSilkTct (inputFile: string, outputFile: string): Promise { + const addon = this.ensureAddon(); + await addon.convertToNTSilkTct(inputFile, outputFile); + } + + /** + * 提取缩略图 + */ + async extractThumbnail (videoPath: string, thumbnailPath: string): Promise { + const addon = this.ensureAddon(); + const info = await addon.getVideoInfo(videoPath); + + // 将缩略图写入文件 + await writeFile(thumbnailPath, info.image); + } +} diff --git a/packages/snowcat-core/helper/ffmpeg/ffmpeg-addon.ts b/packages/snowcat-core/helper/ffmpeg/ffmpeg-addon.ts new file mode 100644 index 0000000..3ecb0cd --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/ffmpeg-addon.ts @@ -0,0 +1,75 @@ +/** + * FFmpeg Node.js Native Addon Type Definitions + * + * This addon provides FFmpeg functionality for Node.js including: + * - Video information extraction with thumbnail generation + * - Audio/Video duration detection + * - Audio format conversion to NTSILK + * - Audio decoding to PCM + */ + +/** + * Video information result object + */ +export interface VideoInfo { + /** Video width in pixels */ + width: number; + + /** Video height in pixels */ + height: number; + + /** Video duration in seconds */ + duration: number; + + /** Generated thumbnail image format */ + format: string; + + /** Video codec name (e.g., "h264", "hevc", "vp9") */ + videoCodec: string; + + /** First frame thumbnail as JPEG image buffer */ + image: Buffer; +} + +/** + * Audio PCM decoding result object + */ +export interface AudioPCMResult { + /** PCM audio data as 16-bit signed integer samples */ + pcm: Buffer; + + /** Sample rate in Hz (e.g., 44100, 48000, 24000) */ + sampleRate: number; + + /** Number of audio channels (1 for mono, 2 for stereo) */ + channels: number; +} + +/** + * FFmpeg interface providing all audio/video processing methods + */ +export interface FFmpeg { + convertFile (inputFile: string, outputFile: string, format: string): Promise<{ success: boolean; }>; + /** + * Get video information including resolution, duration, format, codec and first frame thumbnail + */ + getVideoInfo (filePath: string, format?: 'bmp' | 'bmp24'): Promise; + + /** + * Get duration of audio or video file in seconds + */ + getDuration (filePath: string): Promise; + + /** + * Convert audio file to NTSILK format (WeChat voice message format) + */ + convertToNTSilkTct (inputPath: string, outputPath: string): Promise; + + /** + * Decode audio file to raw PCM data + */ + decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>; + decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>; + + convertToNTSilkTct (inputFile: string, outputFile: string): Promise; +} diff --git a/packages/snowcat-core/helper/ffmpeg/ffmpeg-exec-adapter.ts b/packages/snowcat-core/helper/ffmpeg/ffmpeg-exec-adapter.ts new file mode 100644 index 0000000..52bbd9b --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/ffmpeg-exec-adapter.ts @@ -0,0 +1,266 @@ +/** + * FFmpeg Exec Adapter + * 使用 execFile 调用 FFmpeg 命令行工具的适配器实现 + */ + +import { readFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs'; +import { dirname, join } from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { fileTypeFromFile } from 'file-type'; +import { imageSizeFallBack } from 'snowcat-image-size/src/index'; +import { downloadFFmpegIfNotExists } from './download-ffmpeg'; +import { LogWrapper } from '@/snowcat-core/helper/log'; +import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface'; + +const execFileAsync = promisify(execFile); + +/** + * 确保目录存在 + */ +function ensureDirExists (filePath: string): void { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * FFmpeg 命令行适配器实现 + */ +export class FFmpegExecAdapter implements IFFmpegAdapter { + public readonly name = 'FFmpegExec'; + private downloadAttempted = false; + + constructor ( + private ffmpegPath: string = 'ffmpeg', + private ffprobePath: string = 'ffprobe', + private binaryPath?: string, + private logger?: LogWrapper + ) { } + + /** + * 检查 FFmpeg 是否可用,如果不可用则尝试下载 + */ + async isAvailable (): Promise { + // 首先检查当前路径 + try { + await execFileAsync(this.ffmpegPath, ['-version']); + return true; + } catch { + // 如果失败且未尝试下载,尝试下载 + if (!this.downloadAttempted && this.binaryPath && this.logger) { + this.downloadAttempted = true; + + if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) { + return false; + } + + this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...'); + const result = await downloadFFmpegIfNotExists(this.logger); + + if (result.path && result.reset) { + // 更新路径 + if (process.platform === 'win32') { + this.ffmpegPath = join(result.path, 'ffmpeg.exe'); + this.ffprobePath = join(result.path, 'ffprobe.exe'); + this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath); + + // 再次检查 + try { + await execFileAsync(this.ffmpegPath, ['-version']); + return true; + } catch { + return false; + } + } + } + } + return false; + } + } + + /** + * 设置 FFmpeg 路径 + */ + setFFmpegPath (ffmpegPath: string): void { + this.ffmpegPath = ffmpegPath; + } + + /** + * 设置 FFprobe 路径 + */ + setFFprobePath (ffprobePath: string): void { + this.ffprobePath = ffprobePath; + } + + /** + * 获取视频信息 + */ + async getVideoInfo (videoPath: string): Promise { + // 获取文件大小和类型 + const [fileType, duration] = await Promise.all([ + fileTypeFromFile(videoPath).catch(() => null), + this.getDuration(videoPath).catch(() => 60), + ]); + + // 创建临时缩略图路径 + const thumbnailPath = `${videoPath}.thumbnail.bmp`; + let width = 100; + let height = 100; + let thumbnail: Buffer | undefined; + + try { + await this.extractThumbnail(videoPath, thumbnailPath); + + // 获取图片尺寸 + const dimensions = await imageSizeFallBack(thumbnailPath); + width = dimensions.width ?? 100; + height = dimensions.height ?? 100; + + // 读取缩略图 + if (existsSync(thumbnailPath)) { + thumbnail = readFileSync(thumbnailPath); + } + } catch (_error) { + // 使用默认值 + } + + return { + width, + height, + duration, + format: fileType?.ext ?? 'mp4', + thumbnail, + }; + } + + /** + * 获取时长 + */ + async getDuration (filePath: string): Promise { + const { stdout } = await execFileAsync(this.ffprobePath, [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + filePath, + ]); + + const duration = parseFloat(stdout.trim()); + if (isNaN(duration)) { + throw new Error(`ffprobe 返回了无效的时长值: "${stdout.trim()}"`); + } + return duration; + } + + /** + * 判断是否为 Silk 格式 + */ + async isSilk (filePath: string): Promise { + try { + const fd = openSync(filePath, 'r'); + const buffer = Buffer.alloc(10); + readSync(fd, buffer, 0, 10, 0); + closeSync(fd); + const header = buffer.toString(); + return header.includes('#!SILK') || header.includes('\x02#!SILK'); + } catch { + return false; + } + } + + /** + * 转换为 PCM + */ + async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> { + try { + ensureDirExists(pcmPath); + + await execFileAsync(this.ffmpegPath, [ + '-y', + '-i', filePath, + '-ar', '24000', + '-ac', '1', + '-f', 's16le', + pcmPath, + ]); + + if (!existsSync(pcmPath)) { + throw new Error('转换PCM失败,输出文件不存在'); + } + + return { result: true, sampleRate: 24000 }; + } catch (error: any) { + throw new Error(`FFmpeg处理转换出错: ${error.message}`); + } + } + + /** + * 转换文件 + */ + async convertFile (inputFile: string, outputFile: string, format: string): Promise { + try { + ensureDirExists(outputFile); + + const params = format === 'amr' + ? [ + '-f', 's16le', + '-ar', '24000', + '-ac', '1', + '-i', inputFile, + '-ar', '8000', + '-b:a', '12.2k', + '-y', + outputFile, + ] + : [ + '-f', 's16le', + '-ar', '24000', + '-ac', '1', + '-i', inputFile, + '-y', + outputFile, + ]; + + await execFileAsync(this.ffmpegPath, params); + + if (!existsSync(outputFile)) { + throw new Error('转换失败,输出文件不存在'); + } + } catch (error) { + console.error('Error converting file:', error); + throw new Error(`文件转换失败: ${(error as Error).message}`); + } + } + + /** + * 提取缩略图 + */ + async extractThumbnail (videoPath: string, thumbnailPath: string): Promise { + try { + ensureDirExists(thumbnailPath); + + const { stderr } = await execFileAsync(this.ffmpegPath, [ + '-i', videoPath, + '-ss', '00:00:01.000', + '-vframes', '1', + '-y', // 覆盖输出文件 + thumbnailPath, + ]); + + if (!existsSync(thumbnailPath)) { + throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`); + } + } catch (error) { + console.error('Error extracting thumbnail:', error); + throw new Error(`提取缩略图失败: ${(error as Error).message}`); + } + } + + async convertToNTSilkTct (_inputFile: string, _outputFile: string): Promise { + throw new Error('convertToNTSilkTct is not implemented in FFmpegExecAdapter'); + } +} diff --git a/packages/snowcat-core/helper/ffmpeg/ffmpeg.ts b/packages/snowcat-core/helper/ffmpeg/ffmpeg.ts new file mode 100644 index 0000000..9fecfc6 --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/ffmpeg.ts @@ -0,0 +1,182 @@ +import { statSync, existsSync, writeFileSync } from 'fs'; +import path from 'path'; +import type { VideoInfo } from './video'; +import { fileTypeFromFile } from 'file-type'; +import { platform } from 'node:os'; +import { LogWrapper } from '@/snowcat-core/helper/log'; +import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory'; +import type { IFFmpegAdapter } from './ffmpeg-adapter-interface'; +import { FFmpegExecAdapter } from './ffmpeg-exec-adapter'; + +const getFFmpegPath = (tool: string, binaryPath?: string): string => { + if (process.platform === 'win32' && binaryPath) { + const exeName = `${tool}.exe`; + const localPath = path.join(binaryPath, 'ffmpeg', exeName); + const isLocalExeExists = existsSync(localPath); + return isLocalExeExists ? localPath : exeName; + } + return tool; +}; + +export let FFMPEG_CMD = 'ffmpeg'; +export let FFPROBE_CMD = 'ffprobe'; +export class FFmpegService { + private static adapter: IFFmpegAdapter | null = null; + private static initialized = false; + + /** + * 初始化 FFmpeg 服务 + * @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath) + * @param logger 日志记录器 + */ + public static async init (binaryPath: string, logger: LogWrapper): Promise { + if (this.initialized) { + return; + } + + // 检查本地 ffmpeg 路径 + FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath); + FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath); + + // 立即初始化适配器(会触发自动下载等逻辑) + this.adapter = await FFmpegAdapterFactory.getAdapter( + logger, + FFMPEG_CMD, + FFPROBE_CMD, + binaryPath + ); + + this.initialized = true; + } + + public static getAdapterName (): string { + if (!this.adapter) { + throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.'); + } + return this.adapter.name; + } + + /** + * 获取 FFmpeg 适配器 + */ + private static async getAdapter (): Promise { + if (!this.adapter) { + throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.'); + } + return this.adapter; + } + + public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise { + const adapter = await this.getAdapter(); + await adapter.convertToNTSilkTct(inputFile, outputFile); + } + + /** + * 设置 FFmpeg 路径并更新适配器 + * @deprecated 建议使用 init() 方法初始化 + */ + public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise { + if (platform() === 'win32') { + FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe'); + FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe'); + logger.log('[Check] ffmpeg:', FFMPEG_CMD); + logger.log('[Check] ffprobe:', FFPROBE_CMD); + + // 更新适配器路径 + await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD); + } + } + + /** + * 提取视频缩略图 + */ + public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise { + const adapter = await this.getAdapter(); + await adapter.extractThumbnail(videoPath, thumbnailPath); + } + + /** + * 直接发送到 QQ kernel 的旧视频元素使用 PNG 缩略图。 + * Packet/forward 路径继续使用当前适配器生成的 JPEG。 + */ + public static async extractLegacyVideoThumbnail (videoPath: string, thumbnailPath: string): Promise { + const adapter = new FFmpegExecAdapter(FFMPEG_CMD, FFPROBE_CMD); + await adapter.extractThumbnail(videoPath, thumbnailPath); + } + + /** + * 转换音频文件 + */ + public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise { + const adapter = await this.getAdapter(); + await adapter.convertFile(inputFile, outputFile, format); + } + + /** + * 获取音频时长 + */ + public static async getDuration (filePath: string): Promise { + const adapter = await this.getAdapter(); + return adapter.getDuration(filePath); + } + + /** + * 判断是否为 Silk 格式 + */ + public static async isSilk (filePath: string): Promise { + const adapter = await this.getAdapter(); + return adapter.isSilk(filePath); + } + + /** + * 转换为 PCM 格式 + */ + public static async convert (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> { + const adapter = await this.getAdapter(); + return adapter.convertToPCM(filePath, pcmPath); + } + + /** + * 获取视频信息 + */ + public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise { + const adapter = await this.getAdapter(); + + try { + // 获取文件大小 + const fileSize = statSync(videoPath).size; + + // 使用适配器获取视频信息 + const videoInfo = await adapter.getVideoInfo(videoPath); + + // 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径 + if (thumbnailPath && videoInfo.thumbnail) { + writeFileSync(thumbnailPath, videoInfo.thumbnail); + } + + const result: VideoInfo = { + width: videoInfo.width, + height: videoInfo.height, + time: videoInfo.duration, + format: videoInfo.format, + size: fileSize, + filePath: videoPath, + }; + + return result; + } catch (_error) { + // 降级处理:返回默认值 + const fileType = await fileTypeFromFile(videoPath).catch(() => null); + const fileSize = statSync(videoPath).size; + + return { + width: 100, + height: 100, + time: 60, + format: fileType?.ext ?? 'mp4', + size: fileSize, + filePath: videoPath, + }; + } + } +} diff --git a/packages/snowcat-core/helper/ffmpeg/video.ts b/packages/snowcat-core/helper/ffmpeg/video.ts new file mode 100644 index 0000000..e926029 --- /dev/null +++ b/packages/snowcat-core/helper/ffmpeg/video.ts @@ -0,0 +1,11 @@ +export const defaultVideoThumbB64 = + '/9j/4AAQSkZJRgABAQAAAQABAAD//gAXR2VuZXJhdGVkIGJ5IFNuaXBhc3Rl/9sAhAAKBwcIBwYKCAgICwoKCw4YEA4NDQ4dFRYRGCMfJSQiHyIhJis3LyYpNCkhIjBBMTQ5Oz4+PiUuRElDPEg3PT47AQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAF/APADAREAAhEBAxEB/8QBogAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoLEAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+foBAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKCxEAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDiAayNxwagBwNAC5oAM0xBmgBM0ANJoAjY0AQsaBkTGgCM0DEpAFAC0AFMBaACgAoEJTASgQlACUwCgQ4UAOFADhQA4UAOFADxQIkBqDQUGgBwagBQaBC5pgGaAELUAMLUARs1AETGgBhNAxhoASkAUALQIKYxaBBQAUwEoAQ0CEoASmAUAOoEKKAHCgBwoAeKAHigQ7NZmoZpgLmgBd1Ahd1ABupgNLUAMLUAMY0AMJoAYaAENACUCCgAoAWgAoAWgBKYCUAJQISgApgLQAooEOFACigB4oAeKBDxQAVmaiZpgGaAFzQAbqAE3UAIWpgNJoAYTQIaaAEoAQ0CEoASgBaACgBaACmAUAJQAlAgoAKYC0AKKBCigB4FADgKBDwKAHigBuazNRM0DEzTAM0AJmgAzQAhNAhpNACGmA2gQlACUCEoAKACgBaAFpgFACUAJQAUCCmAUALQIcBQA4CgB4FADgKBDhQA4UAMzWZqNzTGJQAZoATNABmgBKAEoEIaYCUCEoASgQlABQAtABQAtMBKACgAoEFABimAYoEKBQA4CgB4FADwKBDgKAFFADhQBCazNhKAEpgFACUAFACUAFAhDTAbQISgAoEJQAUALQAtMAoAKADFABigQYoAMUALimIUCgBwFAh4FADgKAHUALQAtAENZmwlACUwEoAKAEoAKACgQlMBpoEJQAUCCgBcUAFABTAXFAC4oAMUAGKBBigAxQIKYCigQ8UAOFADhQAtAC0ALQBDWZqJQMSgBKYBQAlABQISgBKYCGgQlAC0CCgBcUAFABTAUCkA7FMAxQAYoEJQAUCCmAooEOFADxQA4UAFAC0ALQBDWZqJQAlACUxhQAlABQIKAEoASmISgBcUCCgBaACgBcUAKBQAuKYC0CEoAQ0AJQISmAooEPFADhQA4UALQAtAC0AQ1maiUAFACUAJTAKAEoAKAEoAMUxBigAxQIWgAoAKAFAoAWgBaYBQIQ0ANNACUCCmIUUAOFADxQA4UALQAtABQBFWZqFACUAFACYpgFACUAFACUAFAgxTEFABQAUALQAooAWgAoAKYDTQIaaAEpiCgQ4UAOFAh4oGOFAC0ALSAKYEdZmglABQAUDDFACUwEoASgAoAKBBQIKYBQAUALQAtAC0AJQAhpgNJoENJoATNMQCgQ8UCHigB4oAWgYtABQAUAMrM0CgAoAKADFACUxiUAJQAlAgoAKYgoAKACgYtAC0AFAhDTAQmgBhNAhpNACZpiFBoEPFAEi0CHigB1ABQAUDEoAbWZoFABQAtABTAQ0ANNAxDQAlAhaAEpiCgAoGFAC0AFABmgBCaYhpNADCaBDSaBBmgABpiJFNAEimgB4NADqAFzQAlACE0AJWZoFAC0AFAC0wEIoAaaAG0AJQAUCCgApjCgAoAKADNABmgBpNMQ0mgBpNAhhNAgzQAoNADwaAHqaAJAaBDgaYC5oATNACZoAWszQKACgBaBDqYCGgBpoAYaBiUCCgBKYBQMKACgAoAM0AITQIaTQA0mmA0mgQ3NAhKAHCgBwNADwaAHg0AOBpiFzQAZoATNAD6zNAoAKAFoEOpgBoAaaAGGmAw0AJmgAzQMM0AGaADNABmgBM0AITQIaTQAhNMQw0AJQIKAFFADhQA4GgBwNADs0xC5oAM0CDNAEtZmoUCCgBaAHUwCgBppgRtQAw0ANzQAZoAM0AGaADNABmgBKAEoAQ0ANNMQhoEJQAlMBaQDgaAFBoAcDTAdmgQuaADNAgzQBPWZqFAgoAWgBaYC0CGmmBG1AyM0ANJoATNACZoAXNABmgAzQAUAJQAhoAQ0xDTQISmAUALQAUgHA0AKDTAdmgQuaBBQAtAFiszQKACgBaAFFMAoEIaYEbUDI2oAYaAEoASgAzQAuaACgAoAKAENMQ00AJTEFAhKACgAoAXNACg0AOBoAWgQtAC0AWazNAoAKACgBaYBQIQ0AMNMYw0AMIoAbQAlMAoAKACgAzSAKYhKAENACUxBQIKACgBKACgBaAHCgQ4UALQAUAWqzNAoAKACgApgFACGgQ00xjTQAwigBCKAG4pgJQAlABQAUCCgBKACgBKYgoEFABQISgAoAWgBRQA4UALQAUCLdZmoUAFABQAlMAoASgBDQA00wENACYoATFMBpFADSKAEoEJQAUAFABQAlMQtAgoASgQUAJQAUAKKAHCgBaBBQBbrM1CgAoAKACmAUAJQAlADaYBQAlACYpgIRQA0igBpFAhtABQAUAFMAoEFABQIKAEoASgQUALQAooAWgQUAW81mbC0CCgApgFACUAIaAEpgJQAUAFABQAhFMBpFADSKAGkUCExQAYoAMUAGKADFMQYoAMUCExSATFABQIKYBQAtABQIt5qDYM0ALmgQtIApgIaAENADaACmAlAC0ALQAUwGkUANIoAaRQAmKBBigAxQAYoAMUAGKBBigBMUAJigQmKAExTAKBC0AFAFnNQaig0AKDQAtAgoASgBDQAlMBKACgAFADhQAtMBCKAGkUAIRQAmKADFABigQmKADFACYoAXFABigQmKAExQAmKBCYpgJigAoAnzUGgZoAcDQAuaBC0AJQAhoASmAlABQAtADhQAtMAoATFACEUAJigAxQAYoATFAhMUAFABQAuKADFABigBpWgBCKBCYpgJigB+ag0DNADgaBDgaAFzQITNACUAJTAKACgBRQAopgOoAWgBKAEoAKACgAoASgBpoEJQAooAWgBaBhigBMUCEIoAQigBMUAJSLCgBQaBDgaQC5oEFACUwCgBKACmAtADhQA4UALQAUAJQAUAJQAUAJQAhoENoAWgBRQAooGLQAUAGKAGkUAIRQIZSKEoGKKBDhQAUCCgAoAKBBQAUwFoGKKAHCgBaACgAoASgAoASgBCaAEoEJmgAoAUGgBQaAHZoGFABQAUANoAjpDEoAWgBaAFoEFACUALQAUCCmAUAOFAxRQAtAC0AJQAUAJQAmaBDSaAEzQAmaYBmgBQaAHA0gFzQAuaBhmgAzQAlAEdIYUALQAtAgoAKAEoEFAC0AFMAoAUUDFFAC0ALQAUAJQAhoENNACE0wEoATNABmgBc0ALmgBc0gDNAC5oATNABmgBKRQlACigB1AgoASgQlABTAWgBKACgBaBi0ALQAZoAM0AFACGgQ00wENACUAJQAUCFzQMM0ALmgAzQAZoAM0AGaQC0igoAUUALQIWgBDQISmAUAFACUAFABQAuaBi5oAM0AGaBBmgBKAEpgIaAG0AJQAUCFoAM0DDNAC5oATNABmgAzQBJUlBQAooAWgQtACGmIaaACgAoASgBKACgBc0DCgQUAGaADNABTASgBDQAlACUAFAgoAKBhQAUAFABQAlAE1SUFAxRQIWgQtMBDQIQ0AJQAlAhKBiUAFABmgBc0AGaADNABTAKACgBKAEoASgQlABQAUAFAC0AFACUAFAE1SaBQAUCHCgQtMBKBCUAJQISgBDQA00DEzQAuaADNMBc0AGaADNABQAUAJQAlABQISgAoAKACgBaACgBKAEoAnqTQSgBRQIcKBC0xCUAJQISgBKAENADDQAmaYwzQAuaADNAC0AFABQAUAFAhKACgBKACgAoAWgAoELQAlAxKAJqk0EoAWgQooELTEFADaBCUABoENNMY00ANNAwzQAZoAXNAC0AFAC0CFoASgAoASgBKACgAoAWgQtABQAUANNAyWpNAoAKBCimIWgQUCEoASmIQ0ANNADTQMaaAEoGLmgAzQAtADhQIWgBaACgQhoASgYlACUALQIWgBaACgBKAENAyWpNBKYBQIcKBC0CEoEJTAKBCUANNADDQMQ0ANoGFAC5oAUGgBwNAhRQIWgBaAENACGgBtAwoAKAFzQIXNABmgAoAQ0DJKRoJQAtAhRQSLQIKYCUCCgBDQA00AMNAxpoGNoAM0AGaAFBoAcDQIcKBDqACgBDQAhoAQ0DEoAKADNAC5oEGaBhmgAoAkpGgUCCgQooELQIKYhKACgBKAGmgBpoGMNAxDQAlAwzQIUUAOFAhwoAcKBC0AJQAhoGNNACUAFABQAZoAXNABQAUAS0ixKACgQoNAhaYgoEFACUABoAaaAGmgYw0DENAxtABQAooEOFADhQIcKAFoASgBDQAhoGJQAUAFACUALQIKBi0CJDSLEoATNAhc0CHZpiCgQUAJQIKBjTQAhoGNNAxpoATFABigBQKAHCgBwoAWgAoAKACgBKAEoASgAoASgBaAAUAOoEONIoaTQAZoAUGmIUGgQtAgzQISgAoAQ0DGmgYlAxKACgAxQAtACigBRQAtAxaACgAoATFABigBCKAG0CEoAWgBRTAUUAf//Z'; + +export interface VideoInfo { + width: number; + height: number; + time: number; + format: string; + size: number; + filePath: string; +} diff --git a/packages/snowcat-core/helper/forward-msg-builder.ts b/packages/snowcat-core/helper/forward-msg-builder.ts new file mode 100644 index 0000000..fef5127 --- /dev/null +++ b/packages/snowcat-core/helper/forward-msg-builder.ts @@ -0,0 +1,122 @@ +import * as crypto from 'node:crypto'; +import { PacketMsg } from '@/snowcat-core/packet/message/message'; + +interface ForwardMsgJson { + app: string; + config: ForwardMsgJsonConfig, + desc: string, + extra: ForwardMsgJsonExtra, + meta: ForwardMsgJsonMeta, + prompt: string, + ver: string, + view: string; +} + +interface ForwardMsgJsonConfig { + autosize: number, + forward: number, + round: number, + type: string, + width: number; +} + +interface ForwardMsgJsonExtra { + filename: string, + tsum: number, +} + +interface ForwardMsgJsonMeta { + detail: ForwardMsgJsonMetaDetail; +} + +interface ForwardMsgJsonMetaDetail { + news: { + text: string; + }[], + resid: string, + source: string, + summary: string, + uniseq: string; +} + +interface ForwardAdaptMsg { + senderName?: string; + isGroupMsg?: boolean; + msg?: ForwardAdaptMsgElement[]; +} + +interface ForwardAdaptMsgElement { + preview?: string; +} + +export class ForwardMsgBuilder { + private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson { + const id = uuid ?? crypto.randomUUID(); + const isGroupMsg = msg.some(m => m.isGroupMsg); + if (!source) { + source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录'); + } + if (!news) { + news = msg.length === 0 + ? [{ + text: 'Nya~ This message is send from NapCat.Packet!', + }] + : msg.map(m => ({ + text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`, + })); + } + if (!summary) { + summary = `查看${msg.length}条转发消息`; + } + if (!prompt) { + prompt = '[聊天记录]'; + } + return { + app: 'com.tencent.multimsg', + config: { + autosize: 1, + forward: 1, + round: 1, + type: 'normal', + width: 300, + }, + desc: prompt, + extra: { + filename: id, + tsum: msg.length, + }, + meta: { + detail: { + news, + resid: resId, + source, + summary, + uniseq: id, + }, + }, + prompt, + ver: '0.0.0.5', + view: 'contact', + }; + } + + static fromResId (resId: string): ForwardMsgJson { + return this.build(resId, []); + } + + static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson { + return this.build(resId, packetMsg.map(msg => ({ + senderName: msg.senderName, + isGroupMsg: msg.groupId !== undefined, + msg: msg.msg.map(m => ({ + preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]', + })), + })), + source, + news, + summary, + prompt, + uuid + ); + } +} diff --git a/packages/snowcat-core/helper/log.ts b/packages/snowcat-core/helper/log.ts new file mode 100644 index 0000000..e16a743 --- /dev/null +++ b/packages/snowcat-core/helper/log.ts @@ -0,0 +1,345 @@ +import winston, { format, transports } from 'winston'; +import { truncateString } from 'snowcat-common/src/helper'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/snowcat-core/index'; +import { ILogWrapper } from 'snowcat-common/src/log-interface'; +import EventEmitter from 'node:events'; + +export enum LogLevel { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +function getFormattedTimestamp () { + const now = new Date(); + const year = now.getFullYear(); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + const milliseconds = now.getMilliseconds().toString().padStart(3, '0'); + return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}.${milliseconds}`; +} + +const logEmitter = new EventEmitter(); +export type LogListener = (msg: string) => void; +class Subscription { + public static MAX_HISTORY = 100; + public static history: string[] = []; + + subscribe (listener: LogListener) { + for (const history of Subscription.history) { + try { + listener(history); + } catch { + // ignore + } + } + logEmitter.on('log', listener); + } + + unsubscribe (listener: LogListener) { + logEmitter.off('log', listener); + } + + notify (msg: string) { + logEmitter.emit('log', msg); + if (Subscription.history.length >= Subscription.MAX_HISTORY) { + Subscription.history.shift(); + } + Subscription.history.push(msg); + } +} + +export const logSubscription = new Subscription(); + +export class LogWrapper implements ILogWrapper { + fileLogEnabled = false; + consoleLogEnabled = true; + logger: winston.Logger; + + private logPath: string; + private fileTransportAdded = false; + private fileLogLevel: string = LogLevel.DEBUG; + + constructor (logDir: string) { + const filename = `${getFormattedTimestamp()}.log`; + this.logPath = path.join(logDir, filename); + + this.logger = winston.createLogger({ + level: 'debug', + format: format.combine( + format.timestamp({ format: 'MM-DD HH:mm:ss' }), + format.printf(({ timestamp, level, message, ...meta }) => { + const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : ''; + return `${timestamp} [${level}] ${userInfo}${message}`; + }) + ), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), + format.printf(({ timestamp, level, message, ...meta }) => { + const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : ''; + return `${timestamp} [${level}] ${userInfo}${message}`; + }) + ), + }), + ], + }); + + this.setLogSelfInfo({ nick: '', uid: '' }); + this.cleanOldLogs(logDir); + } + + cleanOldLogs (logDir: string) { + const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + fs.readdir(logDir).then((files) => { + files.forEach((file) => { + const filePath = path.join(logDir, file); + this.deleteOldLogFile(filePath, oneWeekAgo); + }); + }).catch((err) => { + this.logError('Failed to read log directory', err); + }); + } + + private deleteOldLogFile (filePath: string, oneWeekAgo: number) { + fs.stat(filePath).then((stats) => { + if (stats.mtime.getTime() < oneWeekAgo) { + fs.unlink(filePath).catch((err) => { + if (err) { + if (err.code === 'ENOENT') { + this.logWarn(`File already deleted: ${filePath}`); + } else { + this.logError('Failed to delete old log file', err); + } + } else { + this.log(`Deleted old log file: ${filePath}`); + } + }); + } + }).catch((err) => { + this.logError('Failed to get file stats', err); + }); + } + + setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel) { + this.fileLogLevel = fileLogLevel; + this.logger.transports.forEach((transport) => { + if (transport instanceof transports.File) { + transport.level = fileLogLevel; + } else if (transport instanceof transports.Console) { + transport.level = consoleLogLevel; + } + }); + } + + setLogSelfInfo (selfInfo: { nick: string; uid: string; }) { + const userInfo = `${selfInfo.nick}`; + this.logger.defaultMeta = { userInfo }; + } + + setFileLogEnabled (isEnabled: boolean) { + this.fileLogEnabled = isEnabled; + if (isEnabled && !this.fileTransportAdded) { + this.fileTransportAdded = true; + this.logger.add(new transports.File({ + filename: this.logPath, + level: this.fileLogLevel, + maxsize: 5 * 1024 * 1024, // 5MB + maxFiles: 5, + })); + } + this.logger.transports.forEach((transport) => { + if (transport instanceof transports.File) { + transport.silent = !isEnabled; + } + }); + } + + setConsoleLogEnabled (isEnabled: boolean) { + this.consoleLogEnabled = isEnabled; + this.logger.transports.forEach((transport) => { + if (transport instanceof transports.Console) { + transport.silent = !isEnabled; + } + }); + } + + formatMsg (msg: any[]) { + return msg + .map((msgItem) => { + if (msgItem instanceof Error) { + return msgItem.stack; + } else if (typeof msgItem === 'object') { + return JSON.stringify(truncateString(JSON.parse(JSON.stringify(msgItem, null, 2)))); + } + return msgItem; + }) + .join(' '); + } + + _log (level: LogLevel, ...args: any[]) { + if (this.consoleLogEnabled || this.fileLogEnabled) { + // eslint-disable-next-line no-control-regex + const message = this.formatMsg(args).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1B\[[0-?]*[ -/]*[@-~]|\x1B/g, ''); + if (this.consoleLogEnabled && this.fileLogEnabled) { + this.logger.log(level, message); + } else if (this.consoleLogEnabled) { + this.logger.log(level, message); + } else if (this.fileLogEnabled) { + this.logger.log(level, message); + } + logSubscription.notify(JSON.stringify({ level, message })); + } + } + + log (...args: any[]) { + this._log(LogLevel.INFO, ...args); + } + + logDebug (...args: any[]) { + this._log(LogLevel.DEBUG, ...args); + } + + logError (...args: any[]) { + this._log(LogLevel.ERROR, ...args); + } + + logWarn (...args: any[]) { + this._log(LogLevel.WARN, ...args); + } + + logFatal (...args: any[]) { + this._log(LogLevel.FATAL, ...args); + } + + logMessage (msg: RawMessage, selfInfo: SelfInfo) { + const isSelfSent = msg.senderUin === selfInfo.uin; + + if (msg.elements[0]?.elementType === ElementType.GreyTip) { + return; + } + + this.log(`${isSelfSent ? '发送 ->' : '接收 <-'} ${rawMessageToText(msg)}`); + } +} + +export function rawMessageToText (msg: RawMessage, recursiveLevel = 0): string { + if (recursiveLevel > 2) { + return '...'; + } + + const tokens: string[] = []; + + if (msg.chatType === ChatType.KCHATTYPEC2C) { + tokens.push(`私聊 (${msg.peerUin})`); + } else if (msg.chatType === ChatType.KCHATTYPEGROUP) { + if (recursiveLevel < 1) { + tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`); + } + if (msg.senderUin !== '0') { + tokens.push(`[${msg.sendMemberName || msg.sendRemarkName || msg.sendNickName}(${msg.senderUin})]`); + } + } else if (msg.chatType === ChatType.KCHATTYPEDATALINE) { + tokens.push('移动设备'); + } else { + tokens.push(`临时消息 (${msg.peerUin})`); + } + + for (const element of msg.elements) { + tokens.push(msgElementToText(element, msg, recursiveLevel)); + } + + return tokens.join(' '); +} + +function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLevel: number): string { + if (element.textElement) { + return textElementToText(element.textElement); + } + + if (element.replyElement) { + return replyElementToText(element.replyElement, msg, recursiveLevel); + } + + if (element.picElement) { + return '[图片]'; + } + + if (element.fileElement) { + if (element.fileElement.fileUuid) { + return `[文件 ${element.fileElement.fileName}]`; + } else if (element.elementType === ElementType.TOFURECORD) { + return `[在线文件 ${element.fileElement.fileName}]`; + } else if (element.elementType === ElementType.ONLINEFOLDER) { + return `[在线文件夹 ${element.fileElement.fileName}/]`; + } + } + + if (element.videoElement) { + return '[视频]'; + } + + if (element.pttElement) { + return `[语音 ${element.pttElement.duration}s]`; + } + + if (element.arkElement) { + return '[卡片消息]'; + } + + if (element.faceElement) { + return `[表情 ${element.faceElement.faceText ?? ''}]`; + } + + if (element.marketFaceElement) { + return element.marketFaceElement.faceName; + } + + if (element.markdownElement) { + if (element.markdownElement?.mdSummary) { + return element.markdownElement.mdSummary; + } else { + return '[Markdown 消息]'; + } + } + + if (element.multiForwardMsgElement) { + return '[转发消息]'; + } + + if (element.elementType === ElementType.GreyTip) { + return '[灰条消息]'; + } else if (element.elementType === ElementType.FILE) { + return '[文件发送中]'; + } + + return `[未实现 (ElementType = ${element.elementType})]`; +} + +function textElementToText (textElement: any): string { + if (textElement.atType === NTMsgAtType.ATTYPEUNKNOWN) { + const originalContentLines = textElement.content.split('\n'); + return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`; + } else if (textElement.atType === NTMsgAtType.ATTYPEALL) { + return '@全体成员'; + } else if (textElement.atType === NTMsgAtType.ATTYPEONE) { + return `${textElement.content} (${textElement.atUid})`; + } + return ''; +} + +function replyElementToText (replyElement: any, msg: RawMessage, recursiveLevel: number): string { + const recordMsgOrNull = msg.records.find((record) => replyElement.sourceMsgIdInRecords === record.msgId); + return `[回复消息 ${recordMsgOrNull && recordMsgOrNull.peerUin !== '284840486' && recordMsgOrNull.peerUin !== '1094950020' + ? rawMessageToText(recordMsgOrNull, recursiveLevel + 1) + : `未找到消息记录 (MsgId = ${replyElement.sourceMsgIdInRecords})` + }]`; +} diff --git a/packages/snowcat-core/helper/msg.ts b/packages/snowcat-core/helper/msg.ts new file mode 100644 index 0000000..8eec767 --- /dev/null +++ b/packages/snowcat-core/helper/msg.ts @@ -0,0 +1,14 @@ +import { fileTypeFromFile } from 'file-type'; +import { PicType } from '../types'; +export async function getFileTypeForSendType (picPath: string): Promise { + const fileTypeResult = (await fileTypeFromFile(picPath))?.ext ?? 'jpg'; + const picTypeMap: { [key: string]: PicType } = { + // 'webp': PicType.NEWPIC_WEBP, + gif: PicType.NEWPIC_GIF, + // 'png': PicType.NEWPIC_APNG, + // 'jpg': PicType.NEWPIC_JPEG, + // 'jpeg': PicType.NEWPIC_JPEG, + // 'bmp': PicType.NEWPIC_BMP, + }; + return picTypeMap[fileTypeResult] ?? PicType.NEWPIC_JPEG; +} diff --git a/packages/snowcat-core/helper/proxy-handler.ts b/packages/snowcat-core/helper/proxy-handler.ts new file mode 100644 index 0000000..803826a --- /dev/null +++ b/packages/snowcat-core/helper/proxy-handler.ts @@ -0,0 +1,21 @@ +import { LogWrapper } from '@/snowcat-core/helper/log'; + +export function proxyHandlerOf (logger: LogWrapper) { + return { + get (target: any, prop: any, receiver: any) { + if (typeof target[prop] === 'undefined') { + // 如果方法不存在,返回一个函数,这个函数调用existentMethod + + return (..._args: unknown[]) => { + logger.logDebug(`${target.constructor.name} has no method ${prop}`); + }; + } + // 如果方法存在,正常返回 + return Reflect.get(target, prop, receiver); + }, + }; +} + +export function proxiedListenerOf (listener: T, logger: LogWrapper) { + return new Proxy(listener, proxyHandlerOf(logger)); +} diff --git a/packages/snowcat-core/helper/qq-basic-info.ts b/packages/snowcat-core/helper/qq-basic-info.ts new file mode 100644 index 0000000..31962c5 --- /dev/null +++ b/packages/snowcat-core/helper/qq-basic-info.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import { systemPlatform } from 'snowcat-common/src/system'; +import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'snowcat-common/src/helper'; +import AppidTable from '@/snowcat-core/external/appid.json'; +import { LogWrapper } from './log'; +import { getMajorPath } from '@/snowcat-core/index'; +import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'snowcat-common/src/types'; +import path from 'node:path'; + +export class QQBasicInfoWrapper { + QQMainPath: string | undefined; + QQPackageInfoPath: string | undefined; + QQVersionConfigPath: string | undefined; + isQuickUpdate: boolean | undefined; + QQVersionConfig: QQVersionConfigType | undefined; + QQPackageInfo: QQPackageInfoType | undefined; + QQVersionAppid: string | undefined; + QQVersionQua: string | undefined; + context: { logger: LogWrapper; }; + + constructor (context: { logger: LogWrapper; }) { + // 基础目录获取 + this.context = context; + this.QQMainPath = process.execPath; + if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') { + // 实用进程特殊处理 实用进程目录和QQ差远了 + this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ'); + } + this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath); + + // 基础信息获取 无快更则启用默认模板填充 + this.isQuickUpdate = !!this.QQVersionConfigPath; + this.QQVersionConfig = this.isQuickUpdate + ? JSON.parse(fs.readFileSync(this.QQVersionConfigPath!).toString()) + : getDefaultQQVersionConfigInfo(); + + this.QQPackageInfoPath = getQQPackageInfoPath(this.QQMainPath, this.QQVersionConfig?.curVersion); + this.QQPackageInfo = JSON.parse(fs.readFileSync(this.QQPackageInfoPath).toString()); + const { appid: IQQVersionAppid, qua: IQQVersionQua } = this.getAppidV2(); + this.QQVersionAppid = IQQVersionAppid; + this.QQVersionQua = IQQVersionQua; + } + + // 基础函数 + getQQBuildStr () { + return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion; + } + + getFullQQVersion () { + const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version; + if (!version) throw new Error('QQ版本获取失败'); + return version; + } + + requireMinNTQQBuild (buildStr: string) { + const currentBuild = +(this.getQQBuildStr() ?? '0'); + if (currentBuild === 0) throw new Error('QQBuildStr获取失败'); + return currentBuild >= parseInt(buildStr); + } + + // 此方法不要直接使用 + getQUAFallback () { + const platformMapping: Partial> = { + win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`, + darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`, + linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`, + }; + return platformMapping[systemPlatform] ?? (platformMapping.win32)!; + } + + getAppIdFallback () { + const platformMapping: Partial> = { + win32: '537246092', + darwin: '537246140', + linux: '537246140', + }; + return platformMapping[systemPlatform] ?? '537246092'; + } + + getAppidV2 (): { appid: string; qua: string; } { + // 通过已有表 性能好 + const appidTable = AppidTable as unknown as QQAppidTableType; + const fullVersion = this.getFullQQVersion(); + if (fullVersion) { + // 同一 Linux 版本的 x64 和 arm64 使用不同的 AppID,优先匹配架构。 + const data = appidTable[`${fullVersion}-${process.arch}`] ?? appidTable[fullVersion]; + if (data) { + return data; + } + } + // 通过Major拉取 性能差 + try { + const majorAppid = this.getAppidV2ByMajor(fullVersion); + if (majorAppid) { + this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat'); + return { appid: majorAppid, qua: this.getQUAFallback() }; + } + } catch { + this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常'); + } + // 最终兜底为老版本 + this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常'); + this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`); + return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() }; + } + + getAppidV2ByMajor (QQVersion: string) { + if (!this.QQMainPath) { + throw new Error('QQMainPath未定义 无法通过Major获取Appid'); + } + const majorPath = getMajorPath(this.QQMainPath, QQVersion); + // 优先通过 QQAppId/ 标记搜索 + const appidV2 = parseAppidFromMajorV2(majorPath); + if (appidV2) { + return appidV2; + } + // 回落到旧方式 + const appid = parseAppidFromMajor(majorPath); + return appid; + } +} diff --git a/packages/snowcat-core/helper/rkey.ts b/packages/snowcat-core/helper/rkey.ts new file mode 100644 index 0000000..0214f8a --- /dev/null +++ b/packages/snowcat-core/helper/rkey.ts @@ -0,0 +1,140 @@ +import { RequestUtil } from 'snowcat-common/src/request'; +import { LogWrapper } from './log'; + +interface ServerRkeyData { + group_rkey: string; + private_rkey: string; + expired_time: number; +} +interface OneBotApiRet { + status: string, + retcode: number, + data: ServerRkeyData, + message: string, + wording: string, +} +interface UrlFailureInfo { + count: number; + lastTimestamp: number; +} + +export class RkeyManager { + serverUrl: string[] = []; + logger: LogWrapper; + private rkeyData: ServerRkeyData = { + group_rkey: '', + private_rkey: '', + expired_time: 0, + }; + + private urlFailures: Map = new Map(); + private readonly FAILURE_LIMIT: number = 4; + private readonly ONE_DAY: number = 24 * 60 * 60 * 1000; + + constructor (serverUrl: string[], logger: LogWrapper) { + this.logger = logger; + this.serverUrl = serverUrl; + } + + async getRkey () { + const availableUrls = this.getAvailableUrls(); + if (availableUrls.length === 0) { + this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制'); + throw new Error('获取rkey失败:所有服务URL均已被禁用'); + } + + if (this.isExpired()) { + try { + await this.refreshRkey(); + } catch (e) { + throw new Error(`${e}`); + } + } + return this.rkeyData; + } + + private getAvailableUrls (): string[] { + return this.serverUrl.filter(url => !this.isUrlDisabled(url)); + } + + private isUrlDisabled (url: string): boolean { + const failureInfo = this.urlFailures.get(url); + if (!failureInfo) return false; + + const now = new Date().getTime(); + // 如果已经过了一天,重置失败计数 + if (now - failureInfo.lastTimestamp > this.ONE_DAY) { + failureInfo.count = 0; + this.urlFailures.set(url, failureInfo); + return false; + } + + return failureInfo.count >= this.FAILURE_LIMIT; + } + + private updateUrlFailure (url: string) { + const now = new Date().getTime(); + const failureInfo = this.urlFailures.get(url) || { count: 0, lastTimestamp: 0 }; + + // 如果已经过了一天,重置失败计数 + if (now - failureInfo.lastTimestamp > this.ONE_DAY) { + failureInfo.count = 1; + } else { + failureInfo.count++; + } + + failureInfo.lastTimestamp = now; + this.urlFailures.set(url, failureInfo); + + if (failureInfo.count >= this.FAILURE_LIMIT) { + this.logger.logError(`[Rkey] URL ${url} 已被禁用,失败次数达到 ${this.FAILURE_LIMIT} 次`); + } + } + + isExpired (): boolean { + const now = new Date().getTime() / 1000; + return now > this.rkeyData.expired_time; + } + + async refreshRkey () { + const availableUrls = this.getAvailableUrls(); + + if (availableUrls.length === 0) { + this.logger.logError('[Rkey] 所有服务均已禁用'); + throw new Error('获取rkey失败:所有服务URL均已被禁用'); + } + + for (const url of availableUrls) { + try { + let temp = await RequestUtil.HttpGetJson(url, 'GET'); + if (typeof temp === 'object' && temp !== null && 'retcode' in temp) { + // 支持Onebot Ret风格 + temp = (temp as unknown as OneBotApiRet).data; + } + if ( + typeof temp?.group_rkey !== 'string' || + typeof temp?.private_rkey !== 'string' || + typeof temp?.expired_time !== 'number' || + temp.group_rkey.length <= 6 || + temp.private_rkey.length <= 6 || + temp.expired_time <= 0 + ) { + throw new Error('Rkey 服务返回格式无效:group_rkey/private_rkey/expired_time 缺失或不合法'); + } + this.rkeyData = { + group_rkey: temp.group_rkey.slice(6), + private_rkey: temp.private_rkey.slice(6), + expired_time: temp.expired_time, + }; + return; + } catch (e) { + this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e); + this.updateUrlFailure(url); + + if (url === availableUrls[availableUrls.length - 1]) { + throw new Error(`获取rkey失败: ${e}`); + } + } + } + } +} diff --git a/packages/snowcat-core/helper/server-time.ts b/packages/snowcat-core/helper/server-time.ts new file mode 100644 index 0000000..ce042bb --- /dev/null +++ b/packages/snowcat-core/helper/server-time.ts @@ -0,0 +1,34 @@ +/** + * 服务器时间矫正 - 全局 Hook + * + * 保留 Date.now 原始引用,启动后通过 getServerTime 计算偏移量, + * 直接覆盖 Date.now(),使全局所有调用自动获得矫正后的时间戳。 + */ + +/** 原始 Date.now 引用,不受 hook 影响 */ +export const OriginalDateNow: () => number = Date.now.bind(Date); + +/** + * 初始化全局时间矫正,覆盖 Date.now() + * @param getServerTimeFn 获取服务器时间的函数,返回毫秒级时间戳字符串 + * @returns 偏移量(毫秒) + */ +export function hookGlobalDateNow (getServerTimeFn: () => string): number { + const start = OriginalDateNow(); + const serverTimeStr = getServerTimeFn(); + const end = OriginalDateNow(); + const serverTimeMs = parseInt(serverTimeStr, 10); + + if (isNaN(serverTimeMs) || serverTimeMs <= 0) { + return 0; + } + + // 补偿网络延迟:假定往返时间对称,服务器时间对应本地时间的中点 (start + end) / 2 + const offsetMs = Math.round(serverTimeMs - (start + end) / 2); + + if (Math.abs(offsetMs) > 5000) { + Date.now = () => OriginalDateNow() + offsetMs; + } + + return offsetMs; +} diff --git a/packages/snowcat-core/helper/session-proxy.ts b/packages/snowcat-core/helper/session-proxy.ts new file mode 100644 index 0000000..978761d --- /dev/null +++ b/packages/snowcat-core/helper/session-proxy.ts @@ -0,0 +1,140 @@ +import { NodeIQQNTWrapperSession } from '@/snowcat-core/wrapper'; +import { ServiceNamingMapping } from '@/snowcat-core/services/index'; +import { NTEventWrapper } from './event'; + +/** + * 创建 Service 方法的代理 + * 拦截所有方法调用,通过 EventWrapper 进行调用 + */ +function createServiceMethodProxy ( + serviceName: S, + originalService: ServiceNamingMapping[S], + eventWrapper: NTEventWrapper +): ServiceNamingMapping[S] { + return new Proxy(originalService as object, { + get (target, prop, receiver) { + const originalValue = Reflect.get(target, prop, receiver); + + // 如果不是函数,直接返回原始值 + if (typeof originalValue !== 'function') { + return originalValue; + } + + const methodName = prop as string; + + // 返回一个包装函数,通过 EventWrapper 调用 + return function (this: unknown, ...args: unknown[]) { + // 构造 EventWrapper 需要的路径格式: ServiceName/MethodName + const eventPath = `${serviceName}/${methodName}`; + + // 尝试通过 EventWrapper 调用 + try { + // 使用 callNoListenerEvent 的底层实现逻辑 + const eventFunc = (eventWrapper as any).createEventFunction(eventPath); + if (eventFunc) { + return eventFunc(...args); + } + } catch { + // 如果 EventWrapper 调用失败,回退到原始调用 + } + + // 回退到原始方法调用 + return originalValue.apply(originalService, args); + }; + }, + }) as ServiceNamingMapping[S]; +} + +/** + * 创建 Session 的双层代理 + * 第一层:拦截 getXXXService 方法 + * 第二层:拦截 Service 上的具体方法调用 + */ +export function createSessionProxy ( + session: NodeIQQNTWrapperSession, + eventWrapper: NTEventWrapper +): NodeIQQNTWrapperSession { + // 缓存已代理的 Service,避免重复创建 + const serviceProxyCache = new Map(); + + return new Proxy(session, { + get (target, prop, receiver) { + const propName = prop as string; + + // 检查是否是 getXXXService 方法 + if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) { + // 提取 Service 名称: getMsgService -> NodeIKernelMsgService + const servicePart = propName.slice(3); // 移除 'get' 前缀 + const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping; + + // 返回一个函数,该函数返回代理后的 Service + return function () { + // 检查缓存 + if (serviceProxyCache.has(serviceName)) { + return serviceProxyCache.get(serviceName); + } + + // 获取原始 Service + const originalGetter = Reflect.get(target, prop, receiver) as () => unknown; + const originalService = originalGetter.call(target); + + // 检查是否在 ServiceNamingMapping 中定义 + if (isKnownService(serviceName)) { + // 创建 Service 方法代理 + const proxiedService = createServiceMethodProxy( + serviceName, + originalService as ServiceNamingMapping[typeof serviceName], + eventWrapper + ); + serviceProxyCache.set(serviceName, proxiedService); + return proxiedService; + } + + // 未知的 Service,直接返回原始对象 + serviceProxyCache.set(serviceName, originalService); + return originalService; + }; + } + + // 非 getXXXService 方法,直接返回原始值 + return Reflect.get(target, prop, receiver); + }, + }); +} + +/** + * 检查 Service 名称是否在已知的映射中 + */ +function isKnownService (serviceName: string): serviceName is keyof ServiceNamingMapping { + const knownServices: string[] = [ + 'NodeIKernelAvatarService', + 'NodeIKernelBuddyService', + 'NodeIKernelFileAssistantService', + 'NodeIKernelGroupService', + 'NodeIKernelLoginService', + 'NodeIKernelMsgService', + 'NodeIKernelOnlineStatusService', + 'NodeIKernelProfileLikeService', + 'NodeIKernelProfileService', + 'NodeIKernelTicketService', + 'NodeIKernelStorageCleanService', + 'NodeIKernelRobotService', + 'NodeIKernelRichMediaService', + 'NodeIKernelDbToolsService', + 'NodeIKernelTipOffService', + 'NodeIKernelSearchService', + 'NodeIKernelCollectionService', + ]; + return knownServices.includes(serviceName); +} + +/** + * 创建带有 EventWrapper 集成的 InstanceContext + * 这是推荐的使用方式,在创建 context 时自动代理 session + */ +export function createProxiedSession ( + session: NodeIQQNTWrapperSession, + eventWrapper: NTEventWrapper +): NodeIQQNTWrapperSession { + return createSessionProxy(session, eventWrapper); +} diff --git a/packages/snowcat-core/helper/status.ts b/packages/snowcat-core/helper/status.ts new file mode 100644 index 0000000..964ed68 --- /dev/null +++ b/packages/snowcat-core/helper/status.ts @@ -0,0 +1,138 @@ +import os from 'node:os'; +import EventEmitter from 'node:events'; +import { IStatusHelperSubscription } from 'snowcat-common/src/status-interface'; +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 class StatusHelper { + private psCpuUsage = process.cpuUsage(); + private psCurrentTime = process.hrtime(); + private cpuTimes = os.cpus().map(cpu => cpu.times); + + private replaceNaN (value: number) { + return isNaN(value) ? 0 : value; + } + + private sysCpuInfo () { + const currentTimes = os.cpus().map(cpu => cpu.times); + const { total, active } = currentTimes.map((times, index) => { + const prevTimes = this.cpuTimes[index]; + const totalCurrent = times.user + times.nice + times.sys + times.idle + times.irq; + const totalPrev = (prevTimes?.user ?? 0) + (prevTimes?.nice ?? 0) + (prevTimes?.sys ?? 0) + (prevTimes?.idle ?? 0) + (prevTimes?.irq ?? 0); + const activeCurrent = totalCurrent - times.idle; + const activePrev = totalPrev - (prevTimes?.idle ?? 0); + return { + total: totalCurrent - totalPrev, + active: activeCurrent - activePrev, + }; + }).reduce((acc, cur) => ({ + total: acc.total + cur.total, + active: acc.active + cur.active, + }), { total: 0, active: 0 }); + this.cpuTimes = currentTimes; + return { + usage: this.replaceNaN(((active / total) * 100)).toFixed(2), + model: os.cpus()[0]?.model ?? 'none', + speed: os.cpus()[0]?.speed ?? 0, + core: os.cpus().length, + }; + } + + private sysMemoryUsage () { + const { total, free } = { total: os.totalmem(), free: os.freemem() }; + return ((total - free) / 1024 / 1024).toFixed(2); + } + + private qqUsage () { + const mem = process.memoryUsage(); + const numCpus = os.cpus().length; + const usageDiff = process.cpuUsage(this.psCpuUsage); + const endTime = process.hrtime(this.psCurrentTime); + this.psCpuUsage = process.cpuUsage(); + this.psCurrentTime = process.hrtime(); + const usageMS = (usageDiff.user + usageDiff.system) / 1e3; + const totalMS = endTime[0] * 1e3 + endTime[1] / 1e6; + const normPercent = (usageMS / totalMS / numCpus) * 100; + return { + cpu: this.replaceNaN(normPercent).toFixed(2), + memory: ((mem.heapTotal + mem.external + mem.arrayBuffers) / 1024 / 1024).toFixed(2), + }; + } + + systemStatus (): SystemStatus { + const qqUsage = this.qqUsage(); + const sysCpuInfo = this.sysCpuInfo(); + return { + cpu: { + core: sysCpuInfo.core, + model: sysCpuInfo.model, + speed: (sysCpuInfo.speed / 1000).toFixed(2), + usage: { + system: sysCpuInfo.usage, + qq: qqUsage.cpu, + }, + }, + memory: { + total: (os.totalmem() / 1024 / 1024).toFixed(2), + usage: { + system: this.sysMemoryUsage(), + qq: qqUsage.memory, + }, + }, + arch: `${os.platform()} ${os.arch()} ${os.release()}`, + }; + } +} + +class StatusHelperSubscription extends EventEmitter implements IStatusHelperSubscription { + private statusHelper: StatusHelper; + private interval: NodeJS.Timeout | null = null; + + constructor (time: number = 3000) { + super(); + this.statusHelper = new StatusHelper(); + this.on('newListener', (event: string) => { + if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) { + this.startInterval(time); + } + }); + this.on('removeListener', (event: string) => { + if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) { + this.stopInterval(); + } + }); + } + + private startInterval (time: number) { + this.interval ??= setInterval(() => { + const status = this.statusHelper.systemStatus(); + this.emit('statusUpdate', status); + }, time); + } + + private stopInterval () { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +export const statusHelperSubscription = new StatusHelperSubscription(); diff --git a/packages/snowcat-core/index.ts b/packages/snowcat-core/index.ts new file mode 100644 index 0000000..6ad9d6f --- /dev/null +++ b/packages/snowcat-core/index.ts @@ -0,0 +1,362 @@ +import { + NTQQFileApi, + NTQQFriendApi, + NTQQGroupApi, + NTQQMsgApi, + NTQQSystemApi, + NTQQUserApi, + NTQQWebApi, + NTQQFlashApi, + NTQQOnlineApi, + NTQQDatabaseApi, +} from '@/snowcat-core/apis'; +import { NTQQCollectionApi } from '@/snowcat-core/apis/collection'; +import { + NodeIQQNTWrapperSession, + NodeQQNTWrapperUtil, + PlatformType, + VendorType, + WrapperNodeApi, + WrapperSessionInitConfig, +} from '@/snowcat-core/wrapper'; +import { LogLevel, LogWrapper } from '@/snowcat-core/helper/log'; +import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info'; +import { SnowCatPathWrapper } from 'snowcat-common/src/path'; +import path from 'node:path'; +import fs from 'node:fs'; +import { hostname, systemName, systemVersion } from 'snowcat-common/src/system'; +import { NTEventWrapper } from '@/snowcat-core/helper/event'; +import { createSessionProxy } from '@/snowcat-core/helper/session-proxy'; +import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/snowcat-core/types'; +import { SnowCatConfigLoader, SnowCatConfigSchema } from '@/snowcat-core/helper/config'; +import os from 'node:os'; +import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/snowcat-core/listeners'; +import { proxiedListenerOf } from '@/snowcat-core/helper/proxy-handler'; +import { NTQQPacketApi } from './apis/packet'; +import { NativePacketHandler } from './packet/handler/client'; +import { Napi2NativeLoader } from './packet/handler/napi2nativeLoader'; +import { container, ReceiverServiceRegistry } from './packet/handler/serviceRegister'; +import { appEvent } from './packet/handler/eventList'; +import { TypedEventEmitter } from './packet/handler/typeEvent'; +import { hookGlobalDateNow } from './helper/server-time'; +export * from './wrapper'; +export * from './types/index'; +export * from './services/index'; +export * from './listeners/index'; +export * from './apis/index'; +export * from './helper/log'; +export * from './helper/qq-basic-info'; +export * from './helper/event'; +export * from './helper/config'; +export * from './helper/config-base'; +export * from './helper/proxy-handler'; +export * from './helper/session-proxy'; +export * from './helper/server-time'; + +export enum SnowCatCoreWorkingEnv { + Unknown = 0, + Shell = 1, + Framework = 2, +} + +export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi { + if (process.env['NAPCAT_WRAPPER_PATH']) { + const wrapperPath = process.env['NAPCAT_WRAPPER_PATH']; + const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi }; + process.dlopen(nativemodule, wrapperPath); + return nativemodule.exports; + } + if (!execPath) { + throw new Error('无法加载Wrapper,execPath未定义'); + } + let appPath; + if (os.platform() === 'darwin') { + appPath = path.resolve(path.dirname(execPath), '../Resources/app'); + } else if (os.platform() === 'linux') { + appPath = path.resolve(path.dirname(execPath), './resources/app'); + } else { + appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`); + } + let wrapperNodePath = path.resolve(appPath, 'wrapper.node'); + if (!fs.existsSync(wrapperNodePath)) { + wrapperNodePath = path.join(appPath, './resources/app/wrapper.node'); + } + // 老版本兼容 未来去掉 + if (!fs.existsSync(wrapperNodePath)) { + wrapperNodePath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/wrapper.node`); + } + const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi }; + process.dlopen(nativemodule, wrapperNodePath); + process.env['NAPCAT_WRAPPER_PATH'] = wrapperNodePath; + return nativemodule.exports; +} +export function getMajorPath (execPath: string, QQVersion: string): string { + // major.node + let appPath; + if (os.platform() === 'darwin') { + appPath = path.resolve(path.dirname(execPath), '../Resources/app'); + } else if (os.platform() === 'linux') { + appPath = path.resolve(path.dirname(execPath), './resources/app'); + } else { + appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`); + } + let majorPath = path.resolve(appPath, 'major.node'); + if (!fs.existsSync(majorPath)) { + majorPath = path.join(appPath, './resources/app/major.node'); + } + // 老版本兼容 未来去掉 + if (!fs.existsSync(majorPath)) { + majorPath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/major.node`); + } + return majorPath; +} +export class SnowCatCore { + readonly context: InstanceContext; + readonly eventWrapper: NTEventWrapper; + event = appEvent; + SnowCatDataPath: string = ''; + SnowCatTempPath: string = ''; + apis: StableNTApiWrapper; + // runtime info, not readonly + selfInfo: SelfInfo; + util: NodeQQNTWrapperUtil; + configLoader: SnowCatConfigLoader; + /** 数据库 passphrase,由 OidbSvcTrpcTcp.0xcde_2 包获取 */ + dbPassphrase: string | undefined; + + // 通过构造器递过去的 runtime info 应该尽量少 + constructor (context: InstanceContext, selfInfo: SelfInfo) { + this.selfInfo = selfInfo; + // 先用原始 session 创建 eventWrapper + this.eventWrapper = new NTEventWrapper(context.session); + // 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理 + if (process.env['NAPCAT_SESSION_PROXY'] === '1') { + const proxiedSession = createSessionProxy(context.session, this.eventWrapper); + this.context = { + ...context, + session: proxiedSession, + }; + } else { + this.context = context; + } + this.util = this.context.wrapper.NodeQQNTWrapperUtil; + this.configLoader = new SnowCatConfigLoader(this, this.context.pathWrapper.configPath, SnowCatConfigSchema); + this.apis = { + FileApi: new NTQQFileApi(this.context, this), + SystemApi: new NTQQSystemApi(this.context, this), + CollectionApi: new NTQQCollectionApi(this.context, this), + PacketApi: new NTQQPacketApi(this.context, this), + WebApi: new NTQQWebApi(this.context, this), + FriendApi: new NTQQFriendApi(this.context, this), + MsgApi: new NTQQMsgApi(this.context, this), + UserApi: new NTQQUserApi(this.context, this), + GroupApi: new NTQQGroupApi(this.context, this), + FlashApi: new NTQQFlashApi(this.context, this), + OnlineApi: new NTQQOnlineApi(this.context, this), + DatabaseApi: new NTQQDatabaseApi(this.context, this), + }; + container.bind(SnowCatCore).toConstantValue(this); + container.bind(TypedEventEmitter).toConstantValue(this.event); + ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => { + container.bind(ServiceClass).toSelf(); + // console.log(`Registering service handler for: ${serviceName}`); + this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => { + const serviceInstance = container.get(ServiceClass); + return serviceInstance.handler(seq, hex_data); + }); + }); + } + + async initCore () { + this.SnowCatDataPath = path.join(this.dataPath, 'SnowCat'); + fs.mkdirSync(this.SnowCatDataPath, { recursive: true }); + this.SnowCatTempPath = path.join(this.SnowCatDataPath, 'temp'); + // 创建临时目录 + if (!fs.existsSync(this.SnowCatTempPath)) { + fs.mkdirSync(this.SnowCatTempPath, { recursive: true }); + } + // 通过 getServerTime 全局 hook Date.now() 矫正本地时间偏差 + // 可通过环境变量 NAPCAT_DISABLE_TIME_SYNC=1 或配置 autoTimeSync: false 禁用 + if (process.env['NAPCAT_DISABLE_TIME_SYNC'] === '1' || !this.configLoader.configData.autoTimeSync) { + this.context.logger.log('[ServerTime] 自动对时已禁用'); + } else { + const doTimeSync = () => { + try { + const offsetMs = hookGlobalDateNow(() => this.context.session.getMSFService().getServerTime()); + if (Math.abs(offsetMs) > 5000) { + this.context.logger.logWarn(`[ServerTime] 本地时间与服务器时间偏差 ${(offsetMs / 1000).toFixed(1)}s,已自动矫正`); + } else { + this.context.logger.log(`[ServerTime] 时间同步完成,偏差 ${offsetMs}ms`); + } + } catch (e) { + this.context.logger.logError('[ServerTime] 时间矫正失败', e); + } + }; + doTimeSync(); + // 每小时定期重同步,防止长时间运行后本地时钟漂移触发风控 + const syncTimer = setInterval(doTimeSync, 3600000); + syncTimer.unref(); + } + // 遍历this.apis[i].initApi 如果存在该函数进行async 调用 + for (const apiKey in this.apis) { + const api = this.apis[apiKey as keyof StableNTApiWrapper]; + if ('initApi' in api && typeof api.initApi === 'function') { + await api.initApi(); + } + } + this.initNapCatCoreListeners().then().catch((e) => this.context.logger.logError(e)); + + this.context.logger.setFileLogEnabled( + this.configLoader.configData.fileLog + ); + this.context.logger.setConsoleLogEnabled( + this.configLoader.configData.consoleLog + ); + this.context.logger.setFileAndConsoleLogLevel( + this.configLoader.configData.fileLogLevel as LogLevel, + this.configLoader.configData.consoleLogLevel as LogLevel + ); + } + + get dataPath (): string { + let result = this.context.wrapper.NodeQQNTWrapperUtil.getNTUserDataInfoConfig(); + if (!result) { + result = path.resolve(os.homedir(), './.config/QQ'); + fs.mkdirSync(result, { recursive: true }); + } + return result; + } + + // Renamed from 'InitDataListener' + async initNapCatCoreListeners () { + const msgListener = new NodeIKernelMsgListener(); + + // 在线文件/文件夹消息 + msgListener.onRecvOnlineFileMsg = (msgs: RawMessage[]) => { + msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo)); + }; + + msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => { + // 下线通知 + const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`; + this.context.logger.logError(tips); + this.selfInfo.online = false; + this.event.emit('KickedOffLine', tips); + }; + msgListener.onRecvMsg = (msgs) => { + msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo)); + }; + msgListener.onAddSendMsg = (msg) => { + this.context.logger.logMessage(msg, this.selfInfo); + }; + this.context.session.getMsgService().addKernelMsgListener( + proxiedListenerOf(msgListener, this.context.logger) + ); + + const profileListener = new NodeIKernelProfileListener(); + profileListener.onProfileDetailInfoChanged = (profile) => { + if (profile.uid === this.selfInfo.uid) { + Object.assign(this.selfInfo, profile); + } + }; + profileListener.onSelfStatusChanged = (Info: SelfStatusInfo) => { + if (Info.status === 20) { + this.selfInfo.online = false; + this.context.logger.log('账号状态变更为离线'); + } else { + this.selfInfo.online = true; + } + }; + this.context.session.getProfileService().addKernelProfileListener( + proxiedListenerOf(profileListener, this.context.logger) + ); + } +} + +export async function genSessionConfig ( + guid: string, + QQVersionAppid: string, + QQVersion: string, + selfUin: string, + selfUid: string, + account_path: string +): Promise { + const downloadPath = path.join(account_path, 'SnowCat', 'temp'); + fs.mkdirSync(downloadPath, { recursive: true }); + const platformMapping: Partial> = { + win32: PlatformType.KWINDOWS, + darwin: PlatformType.KMAC, + linux: PlatformType.KLINUX, + }; + const systemPlatform = platformMapping[os.platform()] ?? PlatformType.KWINDOWS; + return { + selfUin, + selfUid, + desktopPathConfig: { + account_path, // 可以通过NodeQQNTWrapperUtil().getNTUserDataInfoConfig()获取 + }, + clientVer: QQVersion, + a2: '', + d2: '', + d2Key: '', + machineId: '', + platform: systemPlatform, // 3是Windows? + platVer: systemVersion, // 系统版本号, 应该可以固定 + appid: QQVersionAppid, + rdeliveryConfig: { + appKey: '', + systemId: 0, + appId: '', + logicEnvironment: '', + platform: systemPlatform, + language: '', + sdkVersion: '', + userId: '', + appVersion: '', + osVersion: '', + bundleId: '', + serverUrl: '', + fixedAfterHitKeys: [''], + }, + defaultFileDownloadPath: downloadPath, + deviceInfo: { + guid, + buildVer: QQVersion, + localId: 2052, + devName: hostname, + devType: systemName, + vendorName: '', + osVer: systemVersion, + vendorOsName: systemName, + setMute: false, + vendorType: VendorType.KNOSETONIOS, + }, + deviceConfig: '{"appearance":{"isSplitViewMode":true},"msg":{}}', + }; +} + +export interface InstanceContext { + readonly workingEnv: SnowCatCoreWorkingEnv; + readonly wrapper: WrapperNodeApi; + readonly session: NodeIQQNTWrapperSession; + readonly logger: LogWrapper; + readonly basicInfoWrapper: QQBasicInfoWrapper; + readonly pathWrapper: SnowCatPathWrapper; + readonly packetHandler: NativePacketHandler; + readonly napi2nativeLoader: Napi2NativeLoader; +} + +export interface StableNTApiWrapper { + FileApi: NTQQFileApi, + SystemApi: NTQQSystemApi, + PacketApi: NTQQPacketApi, + CollectionApi: NTQQCollectionApi, + WebApi: NTQQWebApi, + FriendApi: NTQQFriendApi, + MsgApi: NTQQMsgApi, + UserApi: NTQQUserApi, + GroupApi: NTQQGroupApi; + FlashApi: NTQQFlashApi, + OnlineApi: NTQQOnlineApi, + DatabaseApi: NTQQDatabaseApi, +} diff --git a/packages/snowcat-core/listeners/NodeIKernelBuddyListener.ts b/packages/snowcat-core/listeners/NodeIKernelBuddyListener.ts new file mode 100644 index 0000000..97bab5d --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelBuddyListener.ts @@ -0,0 +1,76 @@ +import { BuddyCategoryType, FriendRequestNotify } from '@/snowcat-core/types'; + +export type OnBuddyChangeParams = BuddyCategoryType[]; + +export class NodeIKernelBuddyListener { + onBuddyListChangedV2 (_arg: unknown): any { + } + + onAddBuddyNeedVerify (_arg: unknown): any { + } + + onAddMeSettingChanged (_arg: unknown): any { + } + + onAvatarUrlUpdated (_arg: unknown): any { + } + + onBlockChanged (_arg: unknown): any { + } + + onBuddyDetailInfoChange (_arg: unknown): any { + } + + onBuddyInfoChange (_arg: unknown): any { + } + + onBuddyListChange (_arg: OnBuddyChangeParams): any { + } + + onBuddyRemarkUpdated (_arg: unknown): any { + } + + onBuddyReqChange (_arg: FriendRequestNotify): any { + } + + onBuddyReqUnreadCntChange (_arg: unknown): any { + } + + onCheckBuddySettingResult (_arg: unknown): any { + } + + onDelBatchBuddyInfos (_arg: unknown): any { + } + + onDoubtBuddyReqChange (_arg: + { + reqId: string; + cookie: string; + doubtList: Array<{ + uid: string; + nick: string; + age: number, + sex: number; + commFriendNum: number; + reqTime: string; + msg: string; + source: string; + reason: string; + groupCode: string; + nameMore?: null; + }>; + }): void | Promise { + } + + onDoubtBuddyReqUnreadNumChange (_num: number): void | Promise { + } + + onNickUpdated (_arg: unknown): any { + } + + onSmartInfos (_arg: unknown): any { + } + + onSpacePermissionInfos (_arg: unknown): any { + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelFileAssistantListener.ts b/packages/snowcat-core/listeners/NodeIKernelFileAssistantListener.ts new file mode 100644 index 0000000..8231131 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelFileAssistantListener.ts @@ -0,0 +1,60 @@ +export class NodeIKernelFileAssistantListener { + onFileStatusChanged (_fileStatus: { + id: string, + fileStatus: number, + fileProgress: `${number}`, + fileSize: `${number}`, + fileSpeed: number, + thumbPath: string | null, + filePath: string | null, + }): any { + } + + onSessionListChanged (..._args: unknown[]): any { + } + + onSessionChanged (..._args: unknown[]): any { + } + + onFileListChanged (..._args: unknown[]): any { + } + + onFileSearch (_searchResult: SearchResultWrapper): any { + } +} + +export type SearchResultWrapper = { + searchId: number, + resultId: number, + hasMore: boolean, + resultItems: SearchResultItem[], +}; + +export type SearchResultItem = { + id: string, + fileName: string, + fileNameHits: string[], + fileStatus: number, + fileSize: string, + isSend: boolean, + source: number, + fileTime: string, + expTime: string, + session: { + context: null, + uid: string, + nick: string, + remark: string, + memberCard: string, + groupCode: string, + groupName: string, + groupRemark: string, + count: number, + }, + thumbPath: string, + filePath: string, + msgId: string, + chatType: number, + peerUid: string, + fileType: number, +}; diff --git a/packages/snowcat-core/listeners/NodeIKernelGroupListener.ts b/packages/snowcat-core/listeners/NodeIKernelGroupListener.ts new file mode 100644 index 0000000..e07d254 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelGroupListener.ts @@ -0,0 +1,105 @@ +import { DataSource, Group, GroupDetailInfo, GroupListUpdateType, GroupMember, GroupNotify, ShutUpGroupMember } from '@/snowcat-core/types'; + +export class NodeIKernelGroupListener { + onGroupListInited (_listEmpty: boolean): any { } + // 发现于Win 9.9.9 23159 + onGroupMemberLevelInfoChange (..._args: unknown[]): any { + + } + + onGetGroupBulletinListResult (..._args: unknown[]): any { + } + + onGroupAllInfoChange (..._args: unknown[]): any { + } + + // 9.9.37-54395 新增:群通知类回调改为 List 形态,兼容映射到旧回调 + onGroupAllInfoListChange (..._args: unknown[]): any { + return this.onGroupAllInfoChange(..._args); + } + + onGroupDetailInfoListChange (..._args: unknown[]): any { + return (this.onGroupDetailInfoChange as (...a: unknown[]) => unknown)(..._args); + } + + onGroupAIOSlotInfoChange (..._args: unknown[]): any { + } + + onGroupNotifiesUpdatedV2 (..._args: unknown[]): any { + return (this.onGroupNotifiesUpdated as (...a: unknown[]) => unknown)(..._args); + } + + onGroupNotifiesUnreadCountUpdatedV2 (..._args: unknown[]): any { + return this.onGroupNotifiesUnreadCountUpdated(..._args); + } + + onGroupBulletinChange (..._args: unknown[]): any { + } + + onGroupBulletinRemindNotify (..._args: unknown[]): any { + } + + onGroupArkInviteStateResult (..._args: unknown[]): any { + } + + onGroupBulletinRichMediaDownloadComplete (..._args: unknown[]): any { + } + + onGroupConfMemberChange (..._args: unknown[]): any { + } + + onGroupDetailInfoChange (_detailInfo: GroupDetailInfo): any { + } + + onGroupExtListUpdate (..._args: unknown[]): any { + } + + onGroupFirstBulletinNotify (..._args: unknown[]): any { + } + + onGroupListUpdate (_updateType: GroupListUpdateType, _groupList: Group[]): any { + } + + onGroupNotifiesUpdated (_dboubt: boolean, _notifies: GroupNotify[]): any { + } + + onGroupBulletinRichMediaProgressUpdate (..._args: unknown[]): any { + } + + onGroupNotifiesUnreadCountUpdated (..._args: unknown[]): any { + } + + onGroupSingleScreenNotifies (_doubt: boolean, _seq: string, _notifies: GroupNotify[]): any { + } + + onGroupsMsgMaskResult (..._args: unknown[]): any { + } + + onGroupStatisticInfoChange (..._args: unknown[]): any { + } + + onJoinGroupNotify (..._args: unknown[]): any { + } + + onJoinGroupNoVerifyFlag (..._args: unknown[]): any { + } + + onMemberInfoChange (_groupCode: string, _dateSource: DataSource, _members: Map): any { + } + + onMemberListChange (_arg: { + sceneId: string, + ids: string[], + infos: Map, // uid -> GroupMember + hasPrev: boolean, + hasNext: boolean, + hasRobot: boolean; + }): any { + } + + onSearchMemberChange (..._args: unknown[]): any { + } + + onShutUpMemberListChanged (_groupCode: string, _members: Array): any { + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelLoginListener.ts b/packages/snowcat-core/listeners/NodeIKernelLoginListener.ts new file mode 100644 index 0000000..a9e8515 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelLoginListener.ts @@ -0,0 +1,70 @@ +export class NodeIKernelLoginListener { + onLoginConnected (): Promise | void { + } + + onLoginDisConnected (..._args: any[]): any { + } + + onLoginConnecting (..._args: any[]): any { + } + + onQRCodeGetPicture (_arg: { pngBase64QrcodeData: string, qrcodeUrl: string; }): any { + // let base64Data: string = arg.pngBase64QrcodeData + // base64Data = base64Data.split("data:image/png;base64,")[1] + // let buffer = Buffer.from(base64Data, 'base64') + // console.log("onQRCodeGetPicture", arg); + } + + onQRCodeLoginPollingStarted (..._args: any[]): any { + } + + onQRCodeSessionUserScaned (..._args: any[]): any { + } + + onQRCodeLoginSucceed (_arg: QRCodeLoginSucceedResult): any { + } + + onQRCodeSessionFailed (..._args: any[]): any { + } + + onLoginFailed (..._args: any[]): any { + } + + onLogoutSucceed (..._args: any[]): any { + } + + onLogoutFailed (..._args: any[]): any { + } + + onUserLoggedIn (..._args: any[]): any { + } + + onQRCodeSessionQuickLoginFailed (..._args: any[]): any { + } + + onPasswordLoginFailed (..._args: any[]): any { + } + + OnConfirmUnusualDeviceFailed (..._args: any[]): any { + } + + onQQLoginNumLimited (..._args: any[]): any { + } + + onLoginState (..._args: any[]): any { + } + + onLoginRecordUpdate (..._args: any[]): any { + } +} + +export interface QRCodeLoginSucceedResult { + account: string; + mainAccount: string; + uin: string; // 拿UIN + uid: string; // 拿UID + nickName: string; // 一般是空的 拿不到 + gender: number; + age: number; + faceUrl: string;// 一般是空的 拿不到 +} diff --git a/packages/snowcat-core/listeners/NodeIKernelMsgListener.ts b/packages/snowcat-core/listeners/NodeIKernelMsgListener.ts new file mode 100644 index 0000000..d2f8ce1 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelMsgListener.ts @@ -0,0 +1,391 @@ +import { ChatType, KickedOffLineInfo, RawMessage } from '@/snowcat-core/types'; +import { CommonFileInfo } from '@/snowcat-core/index'; + +export interface OnRichMediaDownloadCompleteParams { + fileModelId: string, + msgElementId: string, + msgId: string, + fileId: string, + fileProgress: string, // '0' + fileSpeed: string, // '0' + fileErrCode: string, // '0' + fileErrMsg: string, + fileDownType: number, // 暂时未知 + thumbSize: number, + filePath: string, + totalSize: string, + trasferStatus: number, + step: number, + commonFileInfo?: CommonFileInfo, + fileSrvErrCode: string, + clientMsg: string, + businessId: number, + userTotalSpacePerDay: unknown, + userUsedSpacePerDay: unknown, + chatType: number, +} + +export interface GroupFileInfoUpdateParamType { + retCode: number; + retMsg: string; + clientWording: string; + isEnd: boolean; + item: Array; + allFileCount: number; + nextIndex: number; + reqId: number; +} + +// { +// sessionType: 1, +// chatType: 100, +// peerUid: 'u_PVQ3tl6K78xxxx', +// groupCode: '809079648', +// fromNick: '拾xxxx, +// sig: '0x' +// } + +export interface GroupFileInfoUpdateItem { + peerId: string; + type: number; + folderInfo?: { + folderId: string; + parentFolderId: string; + folderName: string; + createTime: number; + modifyTime: number; + createUin: string; + creatorName: string; + totalFileCount: number; + modifyUin: string; + modifyName: string; + usedSpace: string; + }, + fileInfo?: { + fileModelId: string; + fileId: string; + fileName: string; + fileSize: string; + busId: number; + uploadedSize: string; + uploadTime: number; + deadTime: number; + modifyTime: number; + downloadTimes: number; + sha: string; + sha3: string; + md5: string; + uploaderLocalPath: string; + uploaderName: string; + uploaderUin: string; + parentFolderId: string; + localPath: string; + transStatus: number; + transType: number; + elementId: string; + isFolder: boolean; + }, +} + +export interface TempOnRecvParams { + sessionType: number, // 1 + chatType: ChatType, // 100 + peerUid: string, // uid + groupCode: string, // gc + fromNick: string, // gc name + sig: string, + +} + +export class NodeIKernelMsgListener { + onAddSendMsg (_msgRecord: RawMessage): any { + + } + + onBroadcastHelperDownloadComplete (_broadcastHelperTransNotifyInfo: unknown): any { + + } + + onBroadcastHelperProgressUpdate (_broadcastHelperTransNotifyInfo: unknown): any { + + } + + onChannelFreqLimitInfoUpdate (_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any { + + } + + onContactUnreadCntUpdate (_hashMap: unknown): any { + + } + + onCustomWithdrawConfigUpdate (_customWithdrawConfig: unknown): any { + + } + + onDraftUpdate (_contact: unknown, _arrayList: unknown, _j2: unknown): any { + + } + + onEmojiDownloadComplete (_emojiNotifyInfo: unknown): any { + + } + + onEmojiResourceUpdate (_emojiResourceInfo: unknown): any { + + } + + onFeedEventUpdate (_firstViewDirectMsgNotifyInfo: unknown): any { + + } + + onFileMsgCome (_arrayList: unknown): any { + + } + + onFirstViewDirectMsgUpdate (_firstViewDirectMsgNotifyInfo: unknown): any { + + } + + onFirstViewGroupGuildMapping (_arrayList: unknown): any { + + } + + onGrabPasswordRedBag (_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any { + + } + + onGroupFileInfoAdd (_groupItem: unknown): any { + + } + + onGroupFileInfoUpdate (_groupFileListResult: GroupFileInfoUpdateParamType): any { + + } + + onGroupGuildUpdate (_groupGuildNotifyInfo: unknown): any { + + } + + onGroupTransferInfoAdd (_groupItem: unknown): any { + + } + + onGroupTransferInfoUpdate (_groupFileListResult: unknown): any { + + } + + onGuildInteractiveUpdate (_guildInteractiveNotificationItem: unknown): any { + + } + + onGuildMsgAbFlagChanged (_guildMsgAbFlag: unknown): any { + + } + + onGuildNotificationAbstractUpdate (_guildNotificationAbstractInfo: unknown): any { + + } + + onHitCsRelatedEmojiResult (_downloadRelateEmojiResultInfo: unknown): any { + + } + + onHitEmojiKeywordResult (_hitRelatedEmojiWordsResult: unknown): any { + + } + + onHitRelatedEmojiResult (_relatedWordEmojiInfo: unknown): any { + + } + + onImportOldDbProgressUpdate (_importOldDbMsgNotifyInfo: unknown): any { + + } + + onInputStatusPush (_inputStatusInfo: { + chatType: number; + eventType: number; + fromUin: string; + interval: string; + showTime: string; + statusText: string; + timestamp: string; + toUin: string; + }): any { + + } + + onKickedOffLine (_kickedInfo: KickedOffLineInfo): any { + + } + + onLineDev (_arrayList: unknown): any { + + } + + onLogLevelChanged (_j2: unknown): any { + + } + + onMsgAbstractUpdate (_arrayList: unknown): any { + + } + + onMsgBoxChanged (_arrayList: unknown): any { + + } + + onMsgDelete (_contact: unknown, _arrayList: unknown): any { + + } + + onMsgEventListUpdate (_hashMap: unknown): any { + + } + + onMsgInfoListAdd (_arrayList: unknown): any { + + } + + onMsgInfoListUpdate (_msgList: RawMessage[]): any { + + } + + onMsgQRCodeStatusChanged (_i2: unknown): any { + + } + + onMsgRecall (_chatType: ChatType, _uid: string, _msgSeq: string): any { + + } + + onMsgSecurityNotify (_msgRecord: unknown): any { + + } + + onMsgSettingUpdate (_msgSetting: unknown): any { + + } + + onNtFirstViewMsgSyncEnd (): any { + + } + + onNtMsgSyncEnd (): any { + + } + + onNtMsgSyncStart (): any { + + } + + // 9.9.37-54395 新增:联系人未读同步回调,等价 onNtMsgSyncEnd 时机 + onNtMsgSyncContactUnread (..._args: unknown[]): any { + return this.onNtMsgSyncEnd(); + } + + onReadFeedEventUpdate (_firstViewDirectMsgNotifyInfo: unknown): any { + + } + + onRecvGroupGuildFlag (_i2: unknown): any { + + } + + onRecvMsg (_arrayList: RawMessage[]): any { + + } + + onRecvMsgSvrRspTransInfo (_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any { + + } + + onRecvOnlineFileMsg (_arrayList: unknown): any { + + } + + onRecvS2CMsg (_arrayList: unknown): any { + + } + + onRecvSysMsg (_arrayList: Array): any { + + } + + onRecvUDCFlag (_i2: unknown): any { + + } + + onRichMediaDownloadComplete (_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any { + } + + onRichMediaProgerssUpdate (_fileTransNotifyInfo: unknown): any { + + } + + onRichMediaUploadComplete (_fileTransNotifyInfo: unknown): any { + + } + + onSearchGroupFileInfoUpdate (_searchGroupFileResult: unknown): any { + + } + + onSendMsgError (_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any { + + } + + onSysMsgNotification (_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any { + + } + + onTempChatInfoUpdate (_tempChatInfo: TempOnRecvParams): any { + + } + + onUnreadCntAfterFirstView (_hashMap: unknown): any { + + } + + onUnreadCntUpdate (_hashMap: unknown): any { + + } + + onUserChannelTabStatusChanged (_z: unknown): any { + + } + + onUserOnlineStatusChanged (_z: unknown): any { + + } + + onUserTabStatusChanged (_arrayList: unknown): any { + + } + + onlineStatusBigIconDownloadPush (_i2: unknown, _j2: unknown, _str: unknown): any { + + } + + onlineStatusSmallIconDownloadPush (_i2: unknown, _j2: unknown, _str: unknown): any { + + } + + // 第一次发现于Linux + onUserSecQualityChanged (..._args: unknown[]): any { + + } + + onMsgWithRichLinkInfoUpdate (..._args: unknown[]): any { + + } + + onRedTouchChanged (..._args: unknown[]): any { + + } + + // 第一次发现于Win 9.9.9-23159 + onBroadcastHelperProgerssUpdate (..._args: unknown[]): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelProfileListener.ts b/packages/snowcat-core/listeners/NodeIKernelProfileListener.ts new file mode 100644 index 0000000..ea4fa0a --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelProfileListener.ts @@ -0,0 +1,71 @@ +import { User, UserDetailInfoListenerArg } from '@/snowcat-core/types'; + +export class NodeIKernelProfileListener { + onUserDetailInfoChanged (_arg: UserDetailInfoListenerArg): void { + + } + + onProfileSimpleChanged (..._args: unknown[]): any { + + } + + onProfileDetailInfoChanged (_profile: User): any { + + } + + onStatusUpdate (..._args: unknown[]): any { + + } + + onSelfStatusChanged (..._args: unknown[]): any { + + } + + onStrangerRemarkChanged (..._args: unknown[]): any { + + } + + onMemberListChange (..._args: unknown[]): any { + + } + + onMemberInfoChange (..._args: unknown[]): any { + + } + + onGroupListUpdate (..._args: unknown[]): any { + + } + + onGroupAllInfoChange (..._args: unknown[]): any { + + } + + onGroupDetailInfoChange (..._args: unknown[]): any { + + } + + onGroupConfMemberChange (..._args: unknown[]): any { + + } + + onGroupExtListUpdate (..._args: unknown[]): any { + + } + + onGroupNotifiesUpdated (..._args: unknown[]): any { + + } + + onGroupNotifiesUnreadCountUpdated (..._args: unknown[]): any { + + } + + onGroupMemberLevelInfoChange (..._args: unknown[]): any { + + } + + onGroupBulletinChange (..._args: unknown[]): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelRecentContactListener.ts b/packages/snowcat-core/listeners/NodeIKernelRecentContactListener.ts new file mode 100644 index 0000000..2a8e7bb --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelRecentContactListener.ts @@ -0,0 +1,25 @@ +export class NodeIKernelRecentContactListener { + onDeletedContactsNotify (..._args: unknown[]): any { + + } + + onRecentContactNotification (_msgList: any, _arg0: { msgListUnreadCnt: string; }, _arg1: number): any { + + } + + onMsgUnreadCountUpdate (..._args: unknown[]): any { + + } + + onGuildDisplayRecentContactListChanged (..._args: unknown[]): any { + + } + + onRecentContactListChanged (..._args: unknown[]): any { + + } + + onRecentContactListChangedVer2 (..._args: unknown[]): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelRobotListener.ts b/packages/snowcat-core/listeners/NodeIKernelRobotListener.ts new file mode 100644 index 0000000..34b49b3 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelRobotListener.ts @@ -0,0 +1,13 @@ +export class NodeIKernelRobotListener { + onRobotFriendListChanged (..._args: unknown[]): any { + + } + + onRobotListChanged (..._args: unknown[]): any { + + } + + onRobotProfileChanged (..._args: unknown[]): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelSearchListener.ts b/packages/snowcat-core/listeners/NodeIKernelSearchListener.ts new file mode 100644 index 0000000..179bade --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelSearchListener.ts @@ -0,0 +1,120 @@ +import { ChatType, RawMessage } from '@/snowcat-core/index'; +export interface SearchGroupInfo { + groupCode: string; + ownerUid: string; + groupFlag: number; + groupFlagExt: number; + maxMemberNum: number; + memberNum: number; + groupOption: number; + classExt: number; + groupName: string; + fingerMemo: string; + groupQuestion: string; + certType: number; + shutUpAllTimestamp: number; + shutUpMeTimestamp: number; + groupTypeFlag: number; + privilegeFlag: number; + groupSecLevel: number; + groupFlagExt3: number; + isConfGroup: number; + isModifyConfGroupFace: number; + isModifyConfGroupName: number; + noFigerOpenFlag: number; + noCodeFingerOpenFlag: number; + groupFlagExt4: number; + groupMemo: string; + cmdUinMsgSeq: number; + cmdUinJoinTime: number; + cmdUinUinFlag: number; + cmdUinMsgMask: number; + groupSecLevelInfo: number; + cmdUinPrivilege: number; + cmdUinFlagEx2: number; + appealDeadline: number; + remarkName: string; + isTop: boolean; + richFingerMemo: string; + groupAnswer: string; + joinGroupAuth: string; + isAllowModifyConfGroupName: number; +} + +export interface GroupInfo { + groupCode: string; + searchGroupInfo: SearchGroupInfo; + privilege: number; +} + +export interface GroupSearchResult { + keyWord: string; + errorCode: number; + groupInfos: GroupInfo[]; + penetrate: string; + isEnd: boolean; + nextPos: number; +} +export interface NodeIKernelSearchListener { + + onSearchGroupResult(params: GroupSearchResult): any; + + onSearchFileKeywordsResult(params: { + searchId: string, + hasMore: boolean, + resultItems: { + chatType: ChatType, + buddyChatInfo: any[], + discussChatInfo: any[], + groupChatInfo: { + groupCode: string, + isConf: boolean, + hasModifyConfGroupFace: boolean, + hasModifyConfGroupName: boolean, + groupName: string, + remark: string + }[], + dataLineChatInfo: any[], + tmpChatInfo: any[], + msgId: string, + msgSeq: string, + msgTime: string, + senderUid: string, + senderNick: string, + senderRemark: string, + senderCard: string, + elemId: string, + elemType: number, + fileSize: string, + filePath: string, + fileName: string, + hits: { + start: number, + end: number + }[] + }[] + }): any; + + onSearchMsgKeywordsResult(params: { + searchId: string, + hasMore: boolean, + resultItems: Array<{ + msgId: string, + msgSeq: string, + msgTime: string, + senderUid: string, + senderUin: string, + senderNick: string, + senderNickHits: unknown[], + senderRemark: string, + senderRemarkHits: unknown[], + senderCard: string, + senderCardHits: unknown[], + fieldType: number, + fieldText: string, + msgRecord: RawMessage; + hitsInfo: Array, + msgAbstract: unknown, + }> + }): void | Promise; +} diff --git a/packages/snowcat-core/listeners/NodeIKernelSessionListener.ts b/packages/snowcat-core/listeners/NodeIKernelSessionListener.ts new file mode 100644 index 0000000..fdc79c6 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelSessionListener.ts @@ -0,0 +1,25 @@ +export class NodeIKernelSessionListener { + onNTSessionCreate (_args: unknown): any { + + } + + onGProSessionCreate (_args: unknown): any { + + } + + onSessionInitComplete (_args: unknown): any { + + } + + onOpentelemetryInit (_info: { is_init: boolean, is_report: boolean; }): any { + + } + + onUserOnlineResult (_args: unknown): any { + + } + + onGetSelfTinyId (_args: unknown): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelStorageCleanListener.ts b/packages/snowcat-core/listeners/NodeIKernelStorageCleanListener.ts new file mode 100644 index 0000000..bfe32b3 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelStorageCleanListener.ts @@ -0,0 +1,21 @@ +export class NodeIKernelStorageCleanListener { + onCleanCacheProgressChanged (_args: unknown): any { + + } + + onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any { + + } + + onCleanCacheStorageChanged (_args: unknown): any { + + } + + onFinishScan (_sizes: Array<`${number}`>): any { + + } + + onChatCleanDone (_args: unknown): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIKernelTicketListener.ts b/packages/snowcat-core/listeners/NodeIKernelTicketListener.ts new file mode 100644 index 0000000..c70ead2 --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIKernelTicketListener.ts @@ -0,0 +1,5 @@ +export class NodeIKernelTicketListener { + listener (): any { + + } +} diff --git a/packages/snowcat-core/listeners/NodeIO3MiscListener.ts b/packages/snowcat-core/listeners/NodeIO3MiscListener.ts new file mode 100644 index 0000000..c8da28f --- /dev/null +++ b/packages/snowcat-core/listeners/NodeIO3MiscListener.ts @@ -0,0 +1,5 @@ +export class NodeIO3MiscListener { + getOnAmgomDataPiece (..._arg: unknown[]): any { + + } +} diff --git a/packages/snowcat-core/listeners/index.ts b/packages/snowcat-core/listeners/index.ts new file mode 100644 index 0000000..eaf6f72 --- /dev/null +++ b/packages/snowcat-core/listeners/index.ts @@ -0,0 +1,40 @@ +import type { + NodeIKernelBuddyListener, + NodeIKernelFileAssistantListener, + NodeIKernelGroupListener, + NodeIKernelLoginListener, + NodeIKernelMsgListener, + NodeIKernelProfileListener, + NodeIKernelRobotListener, + NodeIKernelSessionListener, + NodeIKernelStorageCleanListener, + NodeIKernelTicketListener, +} from '.'; +import { NodeIKernelSearchListener } from './NodeIKernelSearchListener'; + +export * from './NodeIKernelSessionListener'; +export * from './NodeIKernelLoginListener'; +export * from './NodeIKernelMsgListener'; +export * from './NodeIKernelGroupListener'; +export * from './NodeIKernelBuddyListener'; +export * from './NodeIKernelProfileListener'; +export * from './NodeIKernelRobotListener'; +export * from './NodeIKernelProfileListener'; +export * from './NodeIKernelTicketListener'; +export * from './NodeIKernelStorageCleanListener'; +export * from './NodeIKernelFileAssistantListener'; +export * from './NodeIKernelSearchListener'; + +export type ListenerNamingMapping = { + NodeIKernelSessionListener: NodeIKernelSessionListener; + NodeIKernelLoginListener: NodeIKernelLoginListener; + NodeIKernelMsgListener: NodeIKernelMsgListener; + NodeIKernelGroupListener: NodeIKernelGroupListener; + NodeIKernelBuddyListener: NodeIKernelBuddyListener; + NodeIKernelProfileListener: NodeIKernelProfileListener; + NodeIKernelRobotListener: NodeIKernelRobotListener; + NodeIKernelTicketListener: NodeIKernelTicketListener; + NodeIKernelStorageCleanListener: NodeIKernelStorageCleanListener; + NodeIKernelFileAssistantListener: NodeIKernelFileAssistantListener; + NodeIKernelSearchListener: NodeIKernelSearchListener; +}; diff --git a/packages/snowcat-core/package.json b/packages/snowcat-core/package.json new file mode 100644 index 0000000..e0c1abc --- /dev/null +++ b/packages/snowcat-core/package.json @@ -0,0 +1,39 @@ +{ + "name": "snowcat-core", + "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": { + "@protobuf-ts/runtime": "^2.11.1", + "@sinclair/typebox": "^0.34.38", + "compressing": "^1.10.1", + "file-type": "^21.0.0", + "inversify": "^7.10.4", + "json5": "^2.2.3", + "snowcat-common": "workspace:*", + "snowcat-database": "workspace:*", + "snowcat-image-size": "workspace:*", + "snowcat-protobuf": "workspace:*", + "reflect-metadata": "^0.2.2", + "winston": "^3.17.0", + "htmlparser2": "10.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/snowcat-core/packet/client/nativeClient.ts b/packages/snowcat-core/packet/client/nativeClient.ts new file mode 100644 index 0000000..f949f2f --- /dev/null +++ b/packages/snowcat-core/packet/client/nativeClient.ts @@ -0,0 +1,98 @@ +import { LogStack } from '@/snowcat-core/packet/context/clientContext'; +import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext'; +import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext'; +import { OidbPacket, PacketBuf } from '@/snowcat-core/packet/transformer/base'; +import { Napi2NativeLoader } from '@/snowcat-core/packet/handler/napi2nativeLoader'; +export interface RecvPacket { + type: string, // 仅recv + data: RecvPacketData; +} + +export interface RecvPacketData { + seq: number; + cmd: string; + data: Buffer; +} + +export class NativePacketClient { + protected readonly napcore: NapCoreContext; + protected readonly logger: PacketLogger; + protected readonly cb = new Map Promise | any>(); // hash-type callback + protected readonly napi2nativeLoader: Napi2NativeLoader; + logStack: LogStack; + available: boolean = false; + + constructor (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack, napi2nativeLoader: Napi2NativeLoader) { + this.napcore = napCore; + this.logger = logger; + this.logStack = logStack; + this.napi2nativeLoader = napi2nativeLoader; + } + + check (): boolean { + if (!this.napi2nativeLoader.loaded) { + this.logStack.pushLogWarn('NativePacketClient: Napi2NativeLoader 未成功加载'); + return false; + } + return true; + } + + async init (_pid: number, recv: string, send: string): Promise { + const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild('40824'); + if (!isNewQQ) { + this.logStack.pushLogWarn('[PacketClient] 当前 QQ 版本低于 NativePacketClient 要求,跳过 Hook 初始化'); + return; + } + const success = this.napi2nativeLoader.initHook(send, recv); + if (success) { + this.available = true; + this.logStack.pushLogInfo(`[PacketClient] NativePacketClient Hook 初始化成功 platform=${process.platform}.${process.arch}`); + return; + } + this.logStack.pushLogError(`[PacketClient] NativePacketClient Hook 初始化失败 platform=${process.platform}.${process.arch} send=${send} recv=${recv}`); + } + + async sendPacket ( + cmd: string, + data: PacketBuf, + rsp = false, + timeout = 5000 + ): Promise { + if (!rsp) { + this.napcore + .sendSsoCmdReqByContend(cmd, data) + .catch((err: any) => + this.logger.error( + `[PacketClient] sendPacket 无响应命令发送失败 cmd=${cmd} err=${err}` + ) + ); + return { seq: 0, cmd, data: Buffer.alloc(0) }; + } + + const sendPromise = this.napcore + .sendSsoCmdReqByContend(cmd, data) + .then(ret => ({ + seq: 0, + cmd, + data: (ret as { rspbuffer: Buffer; }).rspbuffer, + })); + + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout( + () => + reject( + new Error( + `[PacketClient] sendPacket 超时 cmd=${cmd} timeout=${timeout}ms` + ) + ), + timeout + ); + }); + + return Promise.race([sendPromise, timeoutPromise]); + } + + async sendOidbPacket (pkt: OidbPacket, rsp = false, timeout = 5000): Promise { + return await this.sendPacket(pkt.cmd, pkt.data, rsp, timeout); + } +} diff --git a/packages/snowcat-core/packet/clientSession.ts b/packages/snowcat-core/packet/clientSession.ts new file mode 100644 index 0000000..81f05e7 --- /dev/null +++ b/packages/snowcat-core/packet/clientSession.ts @@ -0,0 +1,31 @@ +import { PacketContext } from '@/snowcat-core/packet/context/packetContext'; +import { SnowCatCore } from '@/snowcat-core/index'; + +export class PacketClientSession { + private readonly context: PacketContext; + + constructor (core: SnowCatCore) { + this.context = new PacketContext(core); + } + + init (pid: number, recv: string, send: string): Promise { + return this.context.client.init(pid, recv, send); + } + + get clientLogStack () { + return this.context.client.clientLogStack; + } + + get available () { + return this.context.client.available; + } + + get operation () { + return this.context.operation; + } + + // TODO: global message element adapter (? + get msgConverter () { + return this.context.msgConverter; + } +} diff --git a/packages/snowcat-core/packet/context/clientContext.ts b/packages/snowcat-core/packet/context/clientContext.ts new file mode 100644 index 0000000..bfb7fc7 --- /dev/null +++ b/packages/snowcat-core/packet/context/clientContext.ts @@ -0,0 +1,83 @@ +import { NativePacketClient } from '@/snowcat-core/packet/client/nativeClient'; +import { OidbPacket } from '@/snowcat-core/packet/transformer/base'; +import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext'; +import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext'; +import { Napi2NativeLoader } from '@/snowcat-core/packet/handler/napi2nativeLoader'; + +export class LogStack { + private stack: string[] = []; + private readonly logger: PacketLogger; + + constructor (logger: PacketLogger) { + this.logger = logger; + } + + push (msg: string) { + this.stack.push(msg); + } + + pushLogInfo (msg: string) { + this.logger.info(msg); + this.stack.push(`${new Date().toISOString()} [INFO] ${msg}`); + } + + pushLogWarn (msg: string) { + this.logger.warn(msg); + this.stack.push(`${new Date().toISOString()} [WARN] ${msg}`); + } + + pushLogError (msg: string) { + this.logger.error(msg); + this.stack.push(`${new Date().toISOString()} [ERROR] ${msg}`); + } + + clear () { + this.stack = []; + } + + content () { + return this.stack.join('\n'); + } +} + +export class PacketClientContext { + private readonly napCore: NapCoreContext; + private readonly logger: PacketLogger; + private readonly logStack: LogStack; + private readonly napi2nativeLoader: Napi2NativeLoader; + private readonly _client: NativePacketClient; + + constructor (napCore: NapCoreContext, logger: PacketLogger, napi2nativeLoader: Napi2NativeLoader) { + this.napCore = napCore; + this.logger = logger; + this.logStack = new LogStack(logger); + this.napi2nativeLoader = napi2nativeLoader; + this._client = this.newClient(); + } + + get available (): boolean { + return this._client.available; + } + + get clientLogStack (): string { + return this._client.logStack.content(); + } + + async init (pid: number, recv: string, send: string): Promise { + await this._client.init(pid, recv, send); + } + + async sendOidbPacket (pkt: OidbPacket, rsp?: T, timeout?: number): Promise { + const raw = await this._client.sendOidbPacket(pkt, rsp, timeout); + return raw.data as T extends true ? Buffer : void; + } + + private newClient (): NativePacketClient { + this.logger.info('使用 NativePacketClient 作为后端'); + const client = new NativePacketClient(this.napCore, this.logger, this.logStack, this.napi2nativeLoader); + if (!client.check()) { + throw new Error('[Core] [Packet] NativePacketClient 不可用,NapCat.Packet将不会加载!'); + } + return client; + } +} diff --git a/packages/snowcat-core/packet/context/loggerContext.ts b/packages/snowcat-core/packet/context/loggerContext.ts new file mode 100644 index 0000000..46c5271 --- /dev/null +++ b/packages/snowcat-core/packet/context/loggerContext.ts @@ -0,0 +1,35 @@ +import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext'; +import { LogWrapper, LogLevel } from '@/snowcat-core/helper/log'; + +// TODO: check bind? +export class PacketLogger { + private readonly napLogger: LogWrapper; + + constructor (napcore: NapCoreContext) { + this.napLogger = napcore.logger; + } + + private _log (level: LogLevel, ...msg: any[]): void { + this.napLogger._log(level, '[Core] [Packet] ' + msg); + } + + debug (...msg: any[]): void { + this._log(LogLevel.DEBUG, msg); + } + + info (...msg: any[]): void { + this._log(LogLevel.INFO, msg); + } + + warn (...msg: any[]): void { + this._log(LogLevel.WARN, msg); + } + + error (...msg: any[]): void { + this._log(LogLevel.ERROR, msg); + } + + fatal (...msg: any[]): void { + this._log(LogLevel.FATAL, msg); + } +} diff --git a/packages/snowcat-core/packet/context/napCoreContext.ts b/packages/snowcat-core/packet/context/napCoreContext.ts new file mode 100644 index 0000000..47a9882 --- /dev/null +++ b/packages/snowcat-core/packet/context/napCoreContext.ts @@ -0,0 +1,42 @@ +import { SnowCatCore } from '@/snowcat-core/index'; + +export interface NapCoreCompatBasicInfo { + readonly requireMinNTQQBuild: (buildVer: string) => boolean; + readonly uin: number; + readonly uid: string; + readonly uin2uid: (uin: number) => Promise; + readonly uid2uin: (uid: string) => Promise; + readonly sendSsoCmdReqByContend: (cmd: string, trace_id: string) => Promise; +} + +export class NapCoreContext { + private readonly core: SnowCatCore; + + constructor (core: SnowCatCore) { + this.core = core; + } + + get logger () { + return this.core.context.logger; + } + + get basicInfo () { + return { + requireMinNTQQBuild: (buildVer: string) => this.core.context.basicInfoWrapper.requireMinNTQQBuild(buildVer), + uin: +this.core.selfInfo.uin, + uid: this.core.selfInfo.uid, + uin2uid: (uin: number) => this.core.apis.UserApi.getUidByUinV2(String(uin)).then(res => res ?? ''), + uid2uin: (uid: string) => this.core.apis.UserApi.getUinByUidV2(uid).then(res => +res), + } as NapCoreCompatBasicInfo; + } + + get config () { + return this.core.configLoader.configData; + } + + get napi2nativeLoader () { + return this.core.context.napi2nativeLoader; + } + + sendSsoCmdReqByContend = (cmd: string, data: Buffer) => this.core.context.session.getMsgService().sendSsoCmdReqByContend(cmd, data); +} diff --git a/packages/snowcat-core/packet/context/operationContext.ts b/packages/snowcat-core/packet/context/operationContext.ts new file mode 100644 index 0000000..8362b35 --- /dev/null +++ b/packages/snowcat-core/packet/context/operationContext.ts @@ -0,0 +1,428 @@ +import * as crypto from 'crypto'; +import { PacketContext } from '@/snowcat-core/packet/context/packetContext'; +import * as trans from '@/snowcat-core/packet/transformer'; +import { PacketMsg } from '@/snowcat-core/packet/message/message'; +import { + PacketMsgFileElement, + PacketMsgPicElement, + PacketMsgPttElement, + PacketMsgReplyElement, + PacketMsgVideoElement, +} from '@/snowcat-core/packet/message/element'; +import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/snowcat-core/index'; +import { MiniAppRawData, MiniAppReqParams } from '@/snowcat-core/packet/entities/miniApp'; +import { AIVoiceChatType } from '@/snowcat-core/packet/entities/aiChat'; +import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/snowcat-core/packet/transformer/proto'; +import { OidbPacket } from '@/snowcat-core/packet/transformer/base'; +import { ImageOcrResult } from '@/snowcat-core/packet/entities/ocrResult'; +import { gunzipSync } from 'zlib'; +import { PacketMsgConverter } from '@/snowcat-core/packet/message/converter'; +import { UploadForwardMsgParams } from '@/snowcat-core/packet/transformer/message/UploadForwardMsgV2'; +import { assertUploadResults } from '@/snowcat-core/packet/context/uploadResult'; + +export class PacketOperationContext { + private readonly context: PacketContext; + + constructor (context: PacketContext) { + this.context = context; + } + + async sendPacket (pkt: OidbPacket, rsp?: T): Promise { + return await this.context.client.sendOidbPacket(pkt, rsp); + } + + async SendPoke (is_group: boolean, peer: number, target?: number) { + const req = trans.SendPoke.build(is_group, peer, target ?? peer); + await this.context.client.sendOidbPacket(req); + } + + async SetGroupTodo (groupUin: number, msgSeq: string) { + const req = trans.SetGroupTodo.build(groupUin, msgSeq); + await this.context.client.sendOidbPacket(req, true); + } + + async CompleteGroupTodo (groupUin: number, msgSeq: string) { + const req = trans.CompleteGroupTodo.build(groupUin, msgSeq); + await this.context.client.sendOidbPacket(req, true); + } + + async CancelGroupTodo (groupUin: number, msgSeq: string) { + const req = trans.CancelGroupTodo.build(groupUin, msgSeq); + await this.context.client.sendOidbPacket(req, true); + } + + async FetchRkey (timeout: number = 10000) { + const req = trans.FetchRkey.build(); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.FetchRkey.parse(resp); + return res.data.rkeyList; + } + + async GroupSign (groupUin: number) { + const req = trans.GroupSign.build(this.context.napcore.basicInfo.uin, groupUin); + await this.context.client.sendOidbPacket(req); + } + + async GetStrangerStatus (uin: number) { + let status = 0; + try { + const req = trans.GetStrangerInfo.build(uin); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.GetStrangerInfo.parse(resp); + const extBigInt = BigInt(res.data.status.value); + if (extBigInt <= 10n) { + return { status: Number(extBigInt) * 10, ext_status: 0 }; + } + status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn)); + return { status: 10, ext_status: status }; + } catch { + return undefined; + } + } + + async SetGroupSpecialTitle (groupUin: number, uid: string, title: string) { + const req = trans.SetSpecialTitle.build(groupUin, uid, title); + await this.context.client.sendOidbPacket(req); + } + + async UploadResources (msg: PacketMsg[], groupUin: number = 0) { + const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C; + const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid; + const reqList = msg.flatMap((m) => + m.msg + .map((e) => { + if (e instanceof PacketMsgPicElement) { + return this.context.highway.uploadImage({ chatType, peerUid }, e); + } else if (e instanceof PacketMsgVideoElement) { + return this.context.highway.uploadVideo({ chatType, peerUid }, e); + } else if (e instanceof PacketMsgPttElement) { + return this.context.highway.uploadPtt({ chatType, peerUid }, e); + } else if (e instanceof PacketMsgFileElement) { + return this.context.highway.uploadFile({ chatType, peerUid }, e); + } + return null; + }) + .filter(Boolean) + ); + const res = await Promise.allSettled(reqList); + assertUploadResults(res, this.context.logger); + } + + async UploadImage (img: PacketMsgPicElement) { + await this.context.highway.uploadImage( + { + chatType: ChatType.KCHATTYPEC2C, + peerUid: this.context.napcore.basicInfo.uid, + }, + img + ); + const index = img.msgInfo?.msgInfoBody?.at(0)?.index; + if (!index) { + throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined'); + } + return await this.GetImageUrl(this.context.napcore.basicInfo.uid, index); + } + + async GetImageUrl (selfUid: string, node: NapProtoEncodeStructType) { + const req = trans.DownloadImage.build(selfUid, node); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.DownloadImage.parse(resp); + return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; + } + + async GetPttUrl (selfUid: string, node: NapProtoEncodeStructType, timeout?: number) { + const req = trans.DownloadPtt.build(selfUid, node); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadPtt.parse(resp); + return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; + } + + async GetVideoUrl (selfUid: string, node: NapProtoEncodeStructType, timeout?: number) { + const req = trans.DownloadVideo.build(selfUid, node); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadVideo.parse(resp); + return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; + } + + async GetGroupImageUrl (groupUin: number, node: NapProtoEncodeStructType, timeout?: number) { + const req = trans.DownloadGroupImage.build(groupUin, node); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadImage.parse(resp); + return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; + } + + async GetGroupPttUrl (groupUin: number, node: NapProtoEncodeStructType, timeout?: number) { + const req = trans.DownloadGroupPtt.build(groupUin, node); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadImage.parse(resp); + return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; + } + + async GetGroupVideoUrl (groupUin: number, node: NapProtoEncodeStructType, timeout: number = 20000) { + const req = trans.DownloadGroupVideo.build(groupUin, node); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadImage.parse(resp); + return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; + } + + async ImageOCR (imgUrl: string) { + const req = trans.ImageOCR.build(imgUrl); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.ImageOCR.parse(resp); + return { + texts: res.ocrRspBody.textDetections.map((item) => { + return { + text: item.detectedText, + confidence: item.confidence, + coordinates: item.polygon.coordinates.map((c) => { + return { + x: c.x, + y: c.y, + }; + }), + }; + }), + language: res.ocrRspBody.language, + } as ImageOcrResult; + } + + private async SendPreprocess (msg: PacketMsg[], groupUin: number = 0) { + const ps = msg.map((m) => { + return m.msg.map(async (e) => { + if (e instanceof PacketMsgReplyElement && !e.targetElems) { + this.context.logger.debug('Cannot find reply element\'s targetElems, prepare to fetch it...'); + if (!e.targetPeer?.peerUid) { + this.context.logger.error('targetPeer is undefined!'); + } + let targetMsg: NapProtoEncodeStructType[] | undefined; + if (e.isGroupReply) { + targetMsg = await this.FetchGroupMessage(+(e.targetPeer?.peerUid ?? 0), e.targetMessageSeq, e.targetMessageSeq); + } else { + targetMsg = await this.FetchC2CMessage(await this.context.napcore.basicInfo.uin2uid(e.targetUin), e.targetMessageSeq, e.targetMessageSeq); + } + e.targetElems = targetMsg.at(0)?.body?.richText?.elems; + e.targetSourceMsg = targetMsg.at(0); + } + }); + }).flat(); + await Promise.all(ps); + await this.UploadResources(msg, groupUin); + } + + async FetchGroupMessage (groupUin: number, startSeq: number, endSeq: number): Promise[]> { + const req = trans.FetchGroupMessage.build(groupUin, startSeq, endSeq); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.FetchGroupMessage.parse(resp); + return res.body.messages; + } + + async FetchC2CMessage (targetUid: string, startSeq: number, endSeq: number): Promise[]> { + const req = trans.FetchC2CMessage.build(targetUid, startSeq, endSeq); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.FetchC2CMessage.parse(resp); + return res.messages; + } + + async UploadForwardMsg (msg: PacketMsg[], groupUin: number = 0) { + await this.SendPreprocess(msg, groupUin); + const req = trans.UploadForwardMsg.build(this.context.napcore.basicInfo.uid, msg, groupUin); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.UploadForwardMsg.parse(resp); + return res.result.resId; + } + + async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) { + // 遍历上传资源(跳过已有原始数据的项) + await Promise.all(msg.filter(item => item.actionMsg).map(async (item) => { + await this.SendPreprocess(item.actionMsg!, groupUin); + })); + const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.UploadForwardMsg.parse(resp); + return res.result.resId; + } + + async MoveGroupFile ( + groupUin: number, + fileUUID: string, + currentParentDirectory: string, + targetParentDirectory: string + ) { + const req = trans.MoveGroupFile.build(groupUin, fileUUID, currentParentDirectory, targetParentDirectory); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.MoveGroupFile.parse(resp); + return res.move.retCode; + } + + async RenameGroupFile (groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string) { + const req = trans.RenameGroupFile.build(groupUin, fileUUID, currentParentDirectory, newName); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.RenameGroupFile.parse(resp); + return res.rename.retCode; + } + + async GetGroupFileUrl (groupUin: number, fileUUID: string, timeout?: number) { + const req = trans.DownloadGroupFile.build(groupUin, fileUUID); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadGroupFile.parse(resp); + return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`; + } + + async GetPrivateFileUrl (self_id: string, fileUUID: string, md5: string, timeout?: number) { + const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5); + const resp = await this.context.client.sendOidbPacket(req, true, timeout); + const res = trans.DownloadPrivateFile.parse(resp); + return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`; + } + + async GetMiniAppAdaptShareInfo (param: MiniAppReqParams) { + const req = trans.GetMiniAppAdaptShareInfo.build(param); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.GetMiniAppAdaptShareInfo.parse(resp); + return JSON.parse(res.content.jsonContent) as MiniAppRawData; + } + + async FetchAiVoiceList (groupUin: number, chatType: AIVoiceChatType) { + const req = trans.FetchAiVoiceList.build(groupUin, chatType); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.FetchAiVoiceList.parse(resp); + if (!res.content) return null; + return res.content.map((item) => { + return { + category: item.category, + voices: item.voices, + }; + }); + } + + async GetAiVoice ( + groupUin: number, + voiceId: string, + text: string, + chatType: AIVoiceChatType + ): Promise> { + let reqTime = 0; + const reqMaxTime = 30; + const sessionId = crypto.randomBytes(4).readUInt32BE(0); + while (true) { + if (reqTime >= reqMaxTime) { + throw new Error(`sendAiVoiceChatReq failed after ${reqMaxTime} times`); + } + reqTime++; + const req = trans.GetAiVoice.build(groupUin, voiceId, text, sessionId, chatType); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.GetAiVoice.parse(resp); + if (!res.msgInfo) continue; + return res.msgInfo; + } + } + + async FetchForwardMsgRaw (resId: string) { + const req = trans.DownloadForwardMsg.build(this.context.napcore.basicInfo.uid, resId); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.DownloadForwardMsg.parse(resp); + const inflate = gunzipSync(res.result.payload); + const result = new NapProtoMsg(LongMsgResult).decode(inflate); + return result.action; + } + + async FetchForwardMsg (res_id: string): Promise { + const req = trans.DownloadForwardMsg.build(this.context.napcore.basicInfo.uid, res_id); + const resp = await this.context.client.sendOidbPacket(req, true); + const res = trans.DownloadForwardMsg.parse(resp); + const inflate = gunzipSync(res.result.payload); + const result = new NapProtoMsg(LongMsgResult).decode(inflate); + + const main = result.action.find((r) => r.actionCommand === 'MultiMsg'); + if (!main?.actionData.msgBody) { + throw new Error('msgBody is empty'); + } + this.context.logger.debug('rawChains ', inflate.toString('hex')); + + const messagesPromises = main.actionData.msgBody.map(async (msg) => { + if (!msg?.body?.richText?.elems) { + throw new Error('msg.body.richText.elems is empty'); + } + const rawChains = new PacketMsgConverter().packetMsgToRaw(msg?.body?.richText?.elems); + const elements = await Promise.all( + rawChains.map(async ([element, rawElem]) => { + if (element.picElement && rawElem?.commonElem?.pbElem) { + const extra = new NapProtoMsg(MsgInfo).decode(rawElem.commonElem.pbElem); + const index = extra?.msgInfoBody[0]?.index; + if (msg?.responseHead.grp !== undefined) { + const groupUin = msg?.responseHead.grp?.groupUin ?? 0; + element.picElement = { + ...element.picElement, + originImageUrl: await this.GetGroupImageUrl(groupUin, index!), + }; + } else { + element.picElement = { + ...element.picElement, + originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!), + }; + } + return element; + } + if (element.videoElement && rawElem?.commonElem?.pbElem) { + const extra = new NapProtoMsg(MsgInfo).decode(rawElem.commonElem.pbElem); + const index = extra?.msgInfoBody[0]?.index; + if (!index) return element; + if (msg?.responseHead.grp !== undefined) { + element.videoElement = { + ...element.videoElement, + filePath: await this.GetGroupVideoUrl(msg.responseHead.grp.groupUin ?? 0, index), + }; + } else { + element.videoElement = { + ...element.videoElement, + filePath: await this.GetVideoUrl(this.context.napcore.basicInfo.uid, index), + }; + } + return element; + } + return element; + }) + ); + const isGroup = msg?.responseHead.grp !== undefined; + const peerUin = isGroup + ? String(msg?.responseHead.grp?.groupUin ?? 0) + : String(msg?.responseHead.fromUin ?? 0); + const peerUid = isGroup + ? String(msg?.responseHead.grp?.groupUin ?? 0) + : String(msg?.responseHead.fromUid ?? ''); + const sendNickName = isGroup + ? (msg?.responseHead.grp?.memberName ?? '') + : (msg?.responseHead.forward?.friendName ?? ''); + + return { + chatType: isGroup ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C, + elements, + guildId: '', + isOnlineMsg: false, + msgId: '7467703692092974645', // TODO: no necessary + msgRandom: '0', + msgSeq: String(msg.contentHead.sequence ?? 0), + msgTime: String(msg.contentHead.timeStamp ?? 0), + msgType: NTMsgType.KMSGTYPEMIX, + parentMsgIdList: [], + parentMsgPeer: { + chatType: isGroup ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C, + peerUid, + }, + peerName: '', + peerUid, + peerUin, + recallTime: '0', + records: [], + sendNickName, + sendRemarkName: sendNickName, + senderUid: msg?.responseHead.fromUid ?? '', + senderUin: String(msg?.responseHead.fromUin ?? 0), + sourceType: MsgSourceType.K_DOWN_SOURCETYPE_UNKNOWN, + subMsgType: 1, + }; + }); + return await Promise.all(messagesPromises); + } +} diff --git a/packages/snowcat-core/packet/context/packetContext.ts b/packages/snowcat-core/packet/context/packetContext.ts new file mode 100644 index 0000000..864de3f --- /dev/null +++ b/packages/snowcat-core/packet/context/packetContext.ts @@ -0,0 +1,25 @@ +import { PacketHighwayContext } from '@/snowcat-core/packet/highway/highwayContext'; +import { SnowCatCore } from '@/snowcat-core/index'; +import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext'; +import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext'; +import { PacketClientContext } from '@/snowcat-core/packet/context/clientContext'; +import { PacketOperationContext } from '@/snowcat-core/packet/context/operationContext'; +import { PacketMsgConverter } from '@/snowcat-core/packet/message/converter'; + +export class PacketContext { + readonly msgConverter: PacketMsgConverter; + readonly napcore: NapCoreContext; + readonly logger: PacketLogger; + readonly client: PacketClientContext; + readonly highway: PacketHighwayContext; + readonly operation: PacketOperationContext; + + constructor (core: SnowCatCore) { + this.msgConverter = new PacketMsgConverter(); + this.napcore = new NapCoreContext(core); + this.logger = new PacketLogger(this.napcore); + this.client = new PacketClientContext(this.napcore, this.logger, this.napcore.napi2nativeLoader); + this.highway = new PacketHighwayContext(this.napcore, this.logger, this.client); + this.operation = new PacketOperationContext(this); + } +} diff --git a/packages/snowcat-core/packet/context/uploadResult.ts b/packages/snowcat-core/packet/context/uploadResult.ts new file mode 100644 index 0000000..4ac41b9 --- /dev/null +++ b/packages/snowcat-core/packet/context/uploadResult.ts @@ -0,0 +1,26 @@ +export interface UploadResultLogger { + warn (message: string): unknown; + error (message: string): unknown; +} + +export function assertUploadResults ( + results: PromiseSettledResult[], + logger: UploadResultLogger +): void { + const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failures.length === 0) return; + + logger.warn(`上传资源${results.length}个,失败${failures.length}个`); + results.forEach((result, index) => { + if (result.status === 'rejected') { + const reason = result.reason instanceof Error + ? result.reason.stack ?? result.reason.message + : String(result.reason); + logger.error(`上传第${index + 1}个资源失败:${reason}`); + } + }); + throw new AggregateError( + failures.map(result => result.reason), + `上传转发消息资源失败:${failures.length}/${results.length}` + ); +} diff --git a/packages/snowcat-core/packet/entities/aiChat.ts b/packages/snowcat-core/packet/entities/aiChat.ts new file mode 100644 index 0000000..f565bc0 --- /dev/null +++ b/packages/snowcat-core/packet/entities/aiChat.ts @@ -0,0 +1,16 @@ +export enum AIVoiceChatType { + Unknown = 0, + Sound = 1, + Sing = 2, +} + +export interface AIVoiceItem { + voiceId: string; + voiceDisplayName: string; + voiceExampleUrl: string; +} + +export interface AIVoiceItemList { + category: string; + voices: AIVoiceItem[]; +} diff --git a/packages/snowcat-core/packet/entities/miniApp.ts b/packages/snowcat-core/packet/entities/miniApp.ts new file mode 100644 index 0000000..08782c0 --- /dev/null +++ b/packages/snowcat-core/packet/entities/miniApp.ts @@ -0,0 +1,80 @@ +export interface MiniAppReqCustomParams { + title: string; + desc: string; + picUrl: string; + jumpUrl: string; + webUrl?: string; +} + +export interface MiniAppReqTemplateParams { + sdkId: string; + appId: string; + scene: number; + iconUrl: string; + templateType: number; + businessType: number; + verType: number; + shareType: number; + versionId: string; + withShareTicket: number; +} + +export interface MiniAppReqParams extends MiniAppReqCustomParams, MiniAppReqTemplateParams {} + +export interface MiniAppData { + ver: string; + prompt: string; + config: Config; + app: string; + view: string; + meta: MetaData; + miniappShareOrigin: number; + miniappOpenRefer: string; +} + +export interface MiniAppRawData { + appName: string; + appView: string; + ver: string; + desc: string; + prompt: string; + metaData: MetaData; + config: Config; +} + +interface Config { + type: string; + width: number; + height: number; + forward: number; + autoSize: number; + ctime: number; + token: string; +} + +interface Host { + uin: number; + nick: string; +} + +interface Detail { + appid: string; + appType: number; + title: string; + desc: string; + icon: string; + preview: string; + url: string; + scene: number; + host: Host; + shareTemplateId: string; + shareTemplateData: Record; + showLittleTail: string; + gamePoints: string; + gamePointsUrl: string; + shareOrigin: number; +} + +interface MetaData { + detail_1: Detail; +} diff --git a/packages/snowcat-core/packet/entities/ocrResult.ts b/packages/snowcat-core/packet/entities/ocrResult.ts new file mode 100644 index 0000000..4f5eac7 --- /dev/null +++ b/packages/snowcat-core/packet/entities/ocrResult.ts @@ -0,0 +1,15 @@ +export interface ImageOcrResult { + texts: TextDetection[]; + language: string; +} + +export interface TextDetection { + text: string; + confidence: number; + coordinates: Coordinate[]; +} + +export interface Coordinate { + x: number; + y: number; +} diff --git a/packages/snowcat-core/packet/handler/client.ts b/packages/snowcat-core/packet/handler/client.ts new file mode 100644 index 0000000..c340452 --- /dev/null +++ b/packages/snowcat-core/packet/handler/client.ts @@ -0,0 +1,226 @@ +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { constants } from 'node:os'; +import offset from '@/snowcat-core/external/packet.json'; +import { LogWrapper } from '../../helper/log'; +interface OffsetType { + [key: string]: { + recv: string; + send: string; + }; +} + +const typedOffset: OffsetType = offset; +// 0 send 1 recv +export interface NativePacketExportType { + initHook?: (send: string, recv: string, callback: (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean; +} + +export type PacketType = 0 | 1; // 0: send, 1: recv +export type PacketCallback = (data: { type: PacketType, uin: string, cmd: string, seq: number, hex_data: string; }) => void; + +interface ListenerEntry { + callback: PacketCallback; + once: boolean; +} + +export class NativePacketHandler { + private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64']; + private readonly MoeHooExport: { exports: NativePacketExportType; } = { exports: {} }; + protected readonly logger: LogWrapper; + private loaded: boolean = false; + + // 统一的监听器存储 - key: 'all' | 'type:0' | 'type:1' | 'cmd:xxx' | 'exact:type:cmd' + private readonly listeners: Map> = new Map(); + + constructor ({ logger }: { logger: LogWrapper; }) { + this.logger = logger; + try { + const platform = process.platform + '.' + process.arch; + const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/packet/MoeHoo.' + platform + '.node'); + if (!fs.existsSync(moehoo_path)) { + this.logger.logWarn(`NativePacketClient: 缺失运行时文件: ${moehoo_path}`); + this.loaded = false; + } + process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY); + this.loaded = true; + this.logger.log('[PacketHandler] 加载成功'); + } catch (error) { + this.logger.logError('NativePacketClient 加载出错:', error); + this.loaded = false; + } + } + + /** + * 添加监听器的通用方法 + */ + private addListener (key: string, callback: PacketCallback, once: boolean = false): () => void { + if (!this.listeners.has(key)) { + this.listeners.set(key, new Set()); + } + const entry: ListenerEntry = { callback, once }; + this.listeners.get(key)!.add(entry); + return () => this.removeListener(key, callback); + } + + /** + * 移除监听器的通用方法 + */ + private removeListener (key: string, callback: PacketCallback): boolean { + const entries = this.listeners.get(key); + if (!entries) return false; + + for (const entry of entries) { + if (entry.callback === callback) { + return entries.delete(entry); + } + } + return false; + } + + // ===== 永久监听器 ===== + + /** 监听所有数据包 */ + onAll (callback: PacketCallback): () => void { + return this.addListener('all', callback); + } + + /** 监听特定类型的数据包 (0: send, 1: recv) */ + onType (type: PacketType, callback: PacketCallback): () => void { + return this.addListener(`type:${type}`, callback); + } + + /** 监听所有发送的数据包 */ + onSend (callback: PacketCallback): () => void { + return this.onType(0, callback); + } + + /** 监听所有接收的数据包 */ + onRecv (callback: PacketCallback): () => void { + return this.onType(1, callback); + } + + /** 监听特定cmd的数据包(不限type) */ + onCmd (cmd: string, callback: PacketCallback): () => void { + return this.addListener(`cmd:${cmd}`, callback); + } + + /** 监听特定type和cmd的数据包(精确匹配) */ + onExact (type: PacketType, cmd: string, callback: PacketCallback): () => void { + return this.addListener(`exact:${type}:${cmd}`, callback); + } + + // ===== 一次性监听器 ===== + + /** 一次性监听所有数据包 */ + onceAll (callback: PacketCallback): () => void { + return this.addListener('all', callback, true); + } + + /** 一次性监听特定类型的数据包 */ + onceType (type: PacketType, callback: PacketCallback): () => void { + return this.addListener(`type:${type}`, callback, true); + } + + /** 一次性监听所有发送的数据包 */ + onceSend (callback: PacketCallback): () => void { + return this.onceType(0, callback); + } + + /** 一次性监听所有接收的数据包 */ + onceRecv (callback: PacketCallback): () => void { + return this.onceType(1, callback); + } + + /** 一次性监听特定cmd的数据包 */ + onceCmd (cmd: string, callback: PacketCallback): () => void { + return this.addListener(`cmd:${cmd}`, callback, true); + } + + /** 一次性监听特定type和cmd的数据包 */ + onceExact (type: PacketType, cmd: string, callback: PacketCallback): () => void { + return this.addListener(`exact:${type}:${cmd}`, callback, true); + } + + // ===== 移除监听器 ===== + + /** 移除特定的全局监听器 */ + off (key: string, callback: PacketCallback): boolean { + return this.removeListener(key, callback); + } + + /** 移除特定key下的所有监听器 */ + offAll (key: string): void { + this.listeners.delete(key); + } + + /** 移除所有监听器 */ + removeAllListeners (): void { + this.listeners.clear(); + } + + /** + * 触发监听器 - 按优先级触发: 精确匹配 > cmd匹配 > type匹配 > 全局 + */ + private emitPacket (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string): void { + const keys = [ + `exact:${type}:${cmd}`, // 精确匹配 + `cmd:${cmd}`, // cmd匹配 + `type:${type}`, // type匹配 + 'all', // 全局 + ]; + + for (const key of keys) { + const entries = this.listeners.get(key); + if (!entries) continue; + + const toRemove: ListenerEntry[] = []; + for (const entry of entries) { + try { + entry.callback({ type, uin, cmd, seq, hex_data }); + if (entry.once) { + toRemove.push(entry); + } + } catch (error) { + this.logger.logError('监听器回调执行出错:', error); + } + } + + // 移除一次性监听器 + for (const entry of toRemove) { + entries.delete(entry); + } + } + } + + async init (version: string, o3HookMode: boolean = false): Promise { + const version_arch = version + '-' + process.arch; + try { + if (!this.loaded) { + this.logger.logWarn('NativePacketClient 未成功加载,无法初始化'); + return false; + } + const send = typedOffset[version_arch]?.send; + const recv = typedOffset[version_arch]?.recv; + if (!send || !recv) { + this.logger.logWarn(`NativePacketClient: 未找到对应版本的偏移数据: ${version_arch}`); + return false; + } + const platform = process.platform + '.' + process.arch; + if (!this.supportedPlatforms.includes(platform)) { + this.logger.logWarn(`NativePacketClient: 不支持的平台: ${platform}`); + return false; + } + + this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => { + this.emitPacket(type, uin, cmd, seq, hex_data); + }, o3HookMode); + this.logger.log('[PacketHandler] 初始化成功'); + return true; + } catch (error) { + this.logger.logError('NativePacketClient 初始化出错:', error); + return false; + } + } +} diff --git a/packages/snowcat-core/packet/handler/eventList.ts b/packages/snowcat-core/packet/handler/eventList.ts new file mode 100644 index 0000000..b43c3db --- /dev/null +++ b/packages/snowcat-core/packet/handler/eventList.ts @@ -0,0 +1,7 @@ +import { TypedEventEmitter } from './typeEvent'; + +export interface AppEvents { + 'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; }; + KickedOffLine: string; +} +export const appEvent = new TypedEventEmitter(); diff --git a/packages/snowcat-core/packet/handler/napi2nativeLoader.ts b/packages/snowcat-core/packet/handler/napi2nativeLoader.ts new file mode 100644 index 0000000..b7012a5 --- /dev/null +++ b/packages/snowcat-core/packet/handler/napi2nativeLoader.ts @@ -0,0 +1,90 @@ +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { constants } from 'node:os'; +import { LogWrapper } from '../../helper/log'; + +export interface BypassOptions { + hook?: boolean; + window?: boolean; + module?: boolean; + process?: boolean; + container?: boolean; + js?: boolean; +} + +export interface Napi2NativeExportType { + initHook?: (send: string, recv: string) => boolean; + setVerbose?: (verbose: boolean) => void; // 默认关闭日志 + enableAllBypasses?: (options?: BypassOptions) => boolean; +} + +export class Napi2NativeLoader { + private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64']; + private readonly exports: { exports: Napi2NativeExportType; } = { exports: {} }; + protected readonly logger: LogWrapper; + private _loaded: boolean = false; + + constructor ({ logger }: { logger: LogWrapper; }) { + this.logger = logger; + this.load(); + } + + private load (): void { + const platform = process.platform + '.' + process.arch; + + if (!this.supportedPlatforms.includes(platform)) { + this.logger.logWarn(`Napi2NativeLoader: 不支持的平台: ${platform}`); + this._loaded = false; + return; + } + + const nativeModulePath = path.join( + dirname(fileURLToPath(import.meta.url)), + './native/napi2native/napi2native.' + platform + '.node' + ); + + if (!fs.existsSync(nativeModulePath)) { + this.logger.logWarn(`Napi2NativeLoader: 缺失运行时文件: ${nativeModulePath}`); + this._loaded = false; + return; + } + + try { + process.dlopen(this.exports, nativeModulePath, constants.dlopen.RTLD_LAZY); + this._loaded = true; + this.logger.log('[Napi2NativeLoader] 加载成功'); + } catch (error) { + this.logger.logError('Napi2NativeLoader 加载出错:', error); + this._loaded = false; + } + } + + get loaded (): boolean { + return this._loaded; + } + + get nativeExports (): Napi2NativeExportType { + return this.exports.exports; + } + + /** + * 初始化 Hook + * @param send send 偏移地址 + * @param recv recv 偏移地址 + * @returns 是否初始化成功 + */ + initHook (send: string, recv: string): boolean { + if (!this._loaded) { + this.logger.logWarn('Napi2NativeLoader 未成功加载,无法初始化 Hook'); + return false; + } + + try { + return this.nativeExports.initHook?.(send, recv) ?? false; + } catch (error) { + this.logger.logError('Napi2NativeLoader initHook 出错:', error); + return false; + } + } +} diff --git a/packages/snowcat-core/packet/handler/serviceRegister.ts b/packages/snowcat-core/packet/handler/serviceRegister.ts new file mode 100644 index 0000000..9bc402a --- /dev/null +++ b/packages/snowcat-core/packet/handler/serviceRegister.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { Container, injectable } from 'inversify'; +import { SnowCatCore } from '../..'; +import { TypedEventEmitter } from './typeEvent'; + +export const container = new Container(); + +export const ReceiverServiceRegistry = new Map ServiceBase>(); + +export abstract class ServiceBase { + get core (): SnowCatCore { + return container.get(SnowCatCore); + } + + get event () { + return container.get(TypedEventEmitter); + } + + abstract handler (seq: number, hex_data: string): Promise | void; +} + +export function ReceiveService (serviceName: string) { + return function ServiceBase>(constructor: T) { + injectable()(constructor); + ReceiverServiceRegistry.set(serviceName, constructor); + return constructor; + }; +} diff --git a/packages/snowcat-core/packet/handler/typeEvent.ts b/packages/snowcat-core/packet/handler/typeEvent.ts new file mode 100644 index 0000000..be6efba --- /dev/null +++ b/packages/snowcat-core/packet/handler/typeEvent.ts @@ -0,0 +1,22 @@ +import { EventEmitter } from 'node:events'; + +export class TypedEventEmitter> { + private emitter = new EventEmitter(); + + on(event: K, listener: (payload: E[K]) => void) { + this.emitter.on(event as string, listener); + return () => this.off(event, listener); + } + + once(event: K, listener: (payload: E[K]) => void) { + this.emitter.once(event as string, listener); + } + + off(event: K, listener: (payload: E[K]) => void) { + this.emitter.off(event as string, listener); + } + + emit(event: K, payload: E[K]) { + this.emitter.emit(event as string, payload); + } +} diff --git a/packages/snowcat-core/packet/highway/client.ts b/packages/snowcat-core/packet/highway/client.ts new file mode 100644 index 0000000..c8391f1 --- /dev/null +++ b/packages/snowcat-core/packet/highway/client.ts @@ -0,0 +1,73 @@ +import * as stream from 'node:stream'; +import { ReadStream } from 'node:fs'; +import { HighwayTcpUploader } from '@/snowcat-core/packet/highway/uploader/highwayTcpUploader'; +import { HighwayHttpUploader } from '@/snowcat-core/packet/highway/uploader/highwayHttpUploader'; +import { PacketHighwaySig } from '@/snowcat-core/packet/highway/highwayContext'; +import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext'; + +export interface PacketHighwayTrans { + uin: string; + cmd: number; + command: string; + data: stream.Readable; + sum: Uint8Array; + size: number; + ticket: Uint8Array; + loginSig?: Uint8Array; + ext: Uint8Array; + encrypt: boolean; + timeout?: number; + server: string; + port: number; +} + +export class PacketHighwayClient { + sig: PacketHighwaySig; + server: string = 'htdata3.qq.com'; + port: number = 80; + logger: PacketLogger; + + constructor (sig: PacketHighwaySig, logger: PacketLogger, _server: string = 'htdata3.qq.com', _port: number = 80) { + this.sig = sig; + this.logger = logger; + } + + changeServer (server: string, port: number) { + this.server = server; + this.port = port; + } + + private buildDataUpTrans (cmd: number, data: ReadStream, fileSize: number, md5: Uint8Array, extendInfo: Uint8Array, timeout: number = 1200): PacketHighwayTrans { + return { + uin: this.sig.uin, + cmd, + command: 'PicUp.DataUp', + data, + sum: md5, + size: fileSize, + ticket: this.sig.sigSession!, + ext: extendInfo, + encrypt: false, + timeout, + server: this.server, + port: this.port, + } as PacketHighwayTrans; + } + + async upload (cmd: number, data: ReadStream, fileSize: number, md5: Uint8Array, extendInfo: Uint8Array): Promise { + const trans = this.buildDataUpTrans(cmd, data, fileSize, md5, extendInfo); + try { + const tcpUploader = new HighwayTcpUploader(trans, this.logger); + await tcpUploader.upload(); + } catch (e) { + this.logger.error(`[Highway] upload failed: ${e}, fallback to http upload`); + try { + const httpUploader = new HighwayHttpUploader(trans, this.logger); + await httpUploader.upload(); + } catch (e) { + this.logger.error(`[Highway] http upload failed: ${e}`); + throw e; + } + } + } +} diff --git a/packages/snowcat-core/packet/highway/frame.ts b/packages/snowcat-core/packet/highway/frame.ts new file mode 100644 index 0000000..8e6c5f6 --- /dev/null +++ b/packages/snowcat-core/packet/highway/frame.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert'; + +export class Frame { + static pack (head: Buffer, body: Buffer): Buffer { + const totalLength = 9 + head.length + body.length + 1; + const buffer = Buffer.allocUnsafe(totalLength); + buffer[0] = 0x28; + buffer.writeUInt32BE(head.length, 1); + buffer.writeUInt32BE(body.length, 5); + head.copy(buffer, 9); + body.copy(buffer, 9 + head.length); + buffer[totalLength - 1] = 0x29; + return buffer; + } + + static unpack (frame: Buffer): [Buffer, Buffer] { + assert(frame[0] === 0x28 && frame[frame.length - 1] === 0x29, 'Invalid frame!'); + const headLen = frame.readUInt32BE(1); + const bodyLen = frame.readUInt32BE(5); + // assert(frame.length === 9 + headLen + bodyLen + 1, `Frame ${frame.toString('hex')} length does not match head and body lengths!`); + return [frame.subarray(9, 9 + headLen), frame.subarray(9 + headLen, 9 + headLen + bodyLen)]; + } +} diff --git a/packages/snowcat-core/packet/highway/highwayContext.ts b/packages/snowcat-core/packet/highway/highwayContext.ts new file mode 100644 index 0000000..9a00824 --- /dev/null +++ b/packages/snowcat-core/packet/highway/highwayContext.ts @@ -0,0 +1,655 @@ +import { PacketHighwayClient } from '@/snowcat-core/packet/highway/client'; +import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext'; +import FetchSessionKey from '@/snowcat-core/packet/transformer/highway/FetchSessionKey'; +import { int32ip2str, oidbIpv4s2HighwayIpv4s } from '@/snowcat-core/packet/highway/utils'; +import { + PacketMsgFileElement, + PacketMsgPicElement, + PacketMsgPttElement, + PacketMsgVideoElement, +} from '@/snowcat-core/packet/message/element'; +import { ChatType, Peer } from '@/snowcat-core/index'; +import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from '@/snowcat-core/packet/utils/crypto/hash'; +import UploadGroupImage from '@/snowcat-core/packet/transformer/highway/UploadGroupImage'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import * as trans from '@/snowcat-core/packet/transformer'; +import fs from 'fs'; +import path from 'path'; +import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext'; +import { PacketClientContext } from '@/snowcat-core/packet/context/clientContext'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; +import { defaultVideoThumbB64 } from '@/snowcat-core/helper/ffmpeg/video'; +import { calculateFileMD5 } from 'snowcat-common/src/file'; +import { randomUUID } from 'node:crypto'; +import { imageSizeFallBack } from 'snowcat-image-size/src/index'; + +export const BlockSize = 1024 * 1024; +const VIDEO_THUMBNAIL_MAX_BYTES = 1024 * 1024; + +interface HighwayServerAddr { + ip: string; + port: number; +} + +export interface PacketHighwaySig { + uin: string; + uid: string; + sigSession: Uint8Array | null; + sessionKey: Uint8Array | null; + serverAddr: HighwayServerAddr[]; +} + +export class PacketHighwayContext { + private readonly napcore: NapCoreContext; + private readonly client: PacketClientContext; + protected sig: PacketHighwaySig; + protected logger: PacketLogger; + protected hwClient: PacketHighwayClient; + private cachedPrepareReq: Promise | null = null; + + constructor (napcore: NapCoreContext, logger: PacketLogger, client: PacketClientContext) { + this.napcore = napcore; + this.client = client; + this.sig = { + uin: String(this.napcore.basicInfo.uin), + uid: this.napcore.basicInfo.uid, + sigSession: null, + sessionKey: null, + serverAddr: [], + }; + this.logger = logger; + this.hwClient = new PacketHighwayClient(this.sig, this.logger); + } + + private async checkAvailable () { + if (this.sig.sigSession === null || this.sig.sessionKey === null) { + if (this.cachedPrepareReq === null) { + this.cachedPrepareReq = this.prepareUpload().finally(() => { + this.cachedPrepareReq = null; + }); + } + await this.cachedPrepareReq; + } + } + + private async prepareUpload (): Promise { + this.logger.debug('[Highway] on prepareUpload!'); + const packet = FetchSessionKey.build(); + const req = await this.client.sendOidbPacket(packet, true); + const rsp = FetchSessionKey.parse(req); + this.sig.sigSession = rsp.httpConn.sigSession; + this.sig.sessionKey = rsp.httpConn.sessionKey; + for (const info of rsp.httpConn.serverInfos) { + if (info.serviceType !== 1) continue; + for (const addr of info.serverAddrs) { + this.logger.debug(`[Highway PrepareUpload] server addr add: ${int32ip2str(addr.ip)}:${addr.port}`); + this.sig.serverAddr.push({ + ip: int32ip2str(addr.ip), + port: addr.port, + }); + this.hwClient.changeServer(int32ip2str(addr.ip), addr.port); + } + } + if (this.sig.serverAddr.length === 0) { + this.logger.warn('[Highway PrepareUpload] server addr is empty!'); + } + } + + async uploadImage (peer: Peer, img: PacketMsgPicElement): Promise { + await this.checkAvailable(); + if (peer.chatType === ChatType.KCHATTYPEGROUP) { + await this.uploadGroupImage(+peer.peerUid, img); + } else if (peer.chatType === ChatType.KCHATTYPEC2C) { + await this.uploadC2CImage(peer.peerUid, img); + } else { + throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`); + } + } + + async uploadVideo (peer: Peer, video: PacketMsgVideoElement): Promise { + await this.checkAvailable(); + if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) { + throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB,请使用文件上传!`); + } + + const cleanupThumbPaths: string[] = []; + try { + let thumbExists = false; + if (video.thumbPath) { + try { + await fs.promises.access(video.thumbPath, fs.constants.F_OK); + thumbExists = true; + } catch { + thumbExists = false; + } + } + + let shouldGenerateThumb = !video.thumbPath || !thumbExists; + if (!shouldGenerateThumb) { + try { + await this.updateVideoThumbInfo(video); + if ((video.thumbSize ?? 0) > VIDEO_THUMBNAIL_MAX_BYTES) { + this.logger.warn(`[Highway] Video thumbnail is too large: ${video.thumbSize} bytes, regenerating`); + shouldGenerateThumb = true; + } + } catch (e) { + this.logger.warn(`[Highway] Failed to inspect video thumbnail, regenerating. Reason: ${e instanceof Error ? e.message : e}`); + shouldGenerateThumb = true; + } + } + + if (shouldGenerateThumb) { + cleanupThumbPaths.push(await this.ensureVideoThumb(video)); + } + video.businessType = peer.chatType === ChatType.KCHATTYPEGROUP ? 21 : 11; + + if (peer.chatType === ChatType.KCHATTYPEGROUP) { + await this.uploadGroupVideo(+peer.peerUid, video); + } else if (peer.chatType === ChatType.KCHATTYPEC2C) { + await this.uploadC2CVideo(peer.peerUid, video); + } else { + throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`); + } + } finally { + await Promise.allSettled(cleanupThumbPaths.map(async (thumbToClean) => { + try { + await fs.promises.unlink(thumbToClean); + this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`); + } + } + })); + } + } + + /** + * 使用当前 FFmpeg 适配器生成符合 QQ 大小限制的视频缩略图 + * @returns 生成的临时缩略图路径,用于后续清理 + */ + private async ensureVideoThumb (video: PacketMsgVideoElement): Promise { + if (!video.filePath) { + throw new Error('video.filePath is empty, cannot generate thumbnail'); + } + + // 生成缩略图路径 + const videoDir = path.dirname(video.filePath); + const videoBasename = path.basename(video.filePath, path.extname(video.filePath)); + const thumbPath = path.join(videoDir, `${videoBasename}_thumb_${randomUUID()}.jpg`); + + this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`); + video.thumbPath = thumbPath; + + try { + // 尝试使用 FFmpeg 提取视频缩略图 + await FFmpegService.extractThumbnail(video.filePath, thumbPath); + try { + await fs.promises.access(thumbPath, fs.constants.F_OK); + this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg'); + } catch { + throw new Error('FFmpeg failed to generate thumbnail'); + } + await this.updateVideoThumbInfo(video); + if ((video.thumbSize ?? 0) > VIDEO_THUMBNAIL_MAX_BYTES) { + throw new Error(`generated thumbnail is too large: ${video.thumbSize} bytes`); + } + } catch (e) { + // FFmpeg 失败时(包括未初始化的情况)使用默认缩略图 + this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`); + await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64')); + await this.updateVideoThumbInfo(video); + } + + return thumbPath; + } + + private async updateVideoThumbInfo (video: PacketMsgVideoElement): Promise { + if (!video.thumbPath) { + throw new Error('video.thumbPath is empty, cannot inspect thumbnail'); + } + const [thumbStat, thumbMd5, dimensions] = await Promise.all([ + fs.promises.stat(video.thumbPath), + calculateFileMD5(video.thumbPath), + imageSizeFallBack(video.thumbPath), + ]); + video.thumbSize = thumbStat.size; + video.thumbMd5 = thumbMd5; + video.thumbWidth = dimensions.width; + video.thumbHeight = dimensions.height; + this.logger.debug(`[Highway] Video thumbnail ready: ${dimensions.width}x${dimensions.height}, ${thumbStat.size} bytes`); + } + + async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise { + await this.checkAvailable(); + if (peer.chatType === ChatType.KCHATTYPEGROUP) { + await this.uploadGroupPtt(+peer.peerUid, ptt); + } else if (peer.chatType === ChatType.KCHATTYPEC2C) { + await this.uploadC2CPtt(peer.peerUid, ptt); + } else { + throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`); + } + } + + async uploadFile (peer: Peer, file: PacketMsgFileElement): Promise { + await this.checkAvailable(); + if (peer.chatType === ChatType.KCHATTYPEGROUP) { + await this.uploadGroupFile(+peer.peerUid, file); + } else if (peer.chatType === ChatType.KCHATTYPEC2C) { + await this.uploadC2CFile(peer.peerUid, file); + } else { + throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`); + } + } + + private async uploadGroupImage (groupUin: number, img: PacketMsgPicElement): Promise { + img.sha1 = Buffer.from(await calculateSha1(img.path)).toString('hex'); + const req = UploadGroupImage.build(groupUin, img); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = UploadGroupImage.parse(resp); + const ukey = preRespData.upload.uKey; + if (ukey && ukey !== '') { + this.logger.debug(`[Highway] uploadGroupImageReq get upload ukey: ${ukey}, need upload!`); + const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index; + const sha1 = Buffer.from(index.info.fileSha1, 'hex'); + const md5 = Buffer.from(index.info.fileHash, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: index.fileUuid, + uKey: ukey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: [sha1], + }, + }); + await this.hwClient.upload( + 1004, + fs.createReadStream(img.path, { highWaterMark: BlockSize }), + img.size, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadGroupImageReq get upload invalid ukey ${ukey}, don't need upload!`); + } + img.msgInfo = preRespData.upload.msgInfo; + // img.groupPicExt = new NapProtoMsg(CustomFace).decode(preRespData.tcpUpload.compatQMsg) + } + + private async uploadC2CImage (peerUid: string, img: PacketMsgPicElement): Promise { + img.sha1 = Buffer.from(await calculateSha1(img.path)).toString('hex'); + const req = trans.UploadPrivateImage.build(peerUid, img); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadPrivateImage.parse(resp); + const ukey = preRespData.upload.uKey; + if (ukey && ukey !== '') { + this.logger.debug(`[Highway] uploadC2CImageReq get upload ukey: ${ukey}, need upload!`); + const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index; + const sha1 = Buffer.from(index.info.fileSha1, 'hex'); + const md5 = Buffer.from(index.info.fileHash, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: index.fileUuid, + uKey: ukey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: [sha1], + }, + }); + await this.hwClient.upload( + 1003, + fs.createReadStream(img.path, { highWaterMark: BlockSize }), + img.size, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadC2CImageReq get upload invalid ukey ${ukey}, don't need upload!`); + } + img.msgInfo = preRespData.upload.msgInfo; + } + + private async uploadGroupVideo (groupUin: number, video: PacketMsgVideoElement): Promise { + if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty'); + video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex'); + video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex'); + const req = trans.UploadGroupVideo.build(groupUin, video); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadGroupVideo.parse(resp); + const videoIndex = preRespData.upload.msgInfo.msgInfoBody[0]?.index; + const thumbIndex = preRespData.upload.msgInfo.msgInfoBody[1]?.index; + if (!videoIndex || !thumbIndex) { + throw new Error('[Highway] uploadGroupVideo response is missing video or thumbnail MsgInfo'); + } + const ukey = preRespData.upload.uKey; + if (ukey && ukey !== '') { + this.logger.debug(`[Highway] uploadGroupVideoReq get upload video ukey: ${ukey}, need upload!`); + const md5 = Buffer.from(videoIndex.info.fileHash, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: videoIndex.fileUuid, + uKey: ukey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: await calculateSha1StreamBytes(video.filePath), + }, + }); + await this.hwClient.upload( + 1005, + fs.createReadStream(video.filePath, { highWaterMark: BlockSize }), + +video.fileSize!, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`); + } + const subFile = preRespData.upload.subFileInfos[0]; + if (subFile?.uKey && subFile.uKey !== '') { + this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`); + const md5 = Buffer.from(thumbIndex.info.fileHash, 'hex'); + const sha1 = Buffer.from(thumbIndex.info.fileSha1, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: thumbIndex.fileUuid, + uKey: subFile.uKey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: [sha1], + }, + }); + await this.hwClient.upload( + 1006, + fs.createReadStream(video.thumbPath, { highWaterMark: BlockSize }), + +video.thumbSize!, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid thumb ukey ${subFile?.uKey ?? ''}, don't need upload!`); + } + video.msgInfo = preRespData.upload.msgInfo; + } + + private async uploadC2CVideo (peerUid: string, video: PacketMsgVideoElement): Promise { + if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty'); + video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex'); + video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex'); + const req = trans.UploadPrivateVideo.build(peerUid, video); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadPrivateVideo.parse(resp); + const videoIndex = preRespData.upload.msgInfo.msgInfoBody[0]?.index; + const thumbIndex = preRespData.upload.msgInfo.msgInfoBody[1]?.index; + if (!videoIndex || !thumbIndex) { + throw new Error('[Highway] uploadC2CVideo response is missing video or thumbnail MsgInfo'); + } + const ukey = preRespData.upload.uKey; + if (ukey && ukey !== '') { + this.logger.debug(`[Highway] uploadC2CVideoReq get upload video ukey: ${ukey}, need upload!`); + const md5 = Buffer.from(videoIndex.info.fileHash, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: videoIndex.fileUuid, + uKey: ukey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: await calculateSha1StreamBytes(video.filePath), + }, + }); + await this.hwClient.upload( + 1001, + fs.createReadStream(video.filePath, { highWaterMark: BlockSize }), + +video.fileSize!, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`); + } + const subFile = preRespData.upload.subFileInfos[0]; + if (subFile?.uKey && subFile.uKey !== '') { + this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`); + const md5 = Buffer.from(thumbIndex.info.fileHash, 'hex'); + const sha1 = Buffer.from(thumbIndex.info.fileSha1, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: thumbIndex.fileUuid, + uKey: subFile.uKey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: [sha1], + }, + }); + await this.hwClient.upload( + 1002, + fs.createReadStream(video.thumbPath, { highWaterMark: BlockSize }), + +video.thumbSize!, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid thumb ukey ${subFile?.uKey ?? ''}, don't need upload!`); + } + video.msgInfo = preRespData.upload.msgInfo; + } + + private async uploadGroupPtt (groupUin: number, ptt: PacketMsgPttElement): Promise { + ptt.fileSha1 = Buffer.from(await calculateSha1(ptt.filePath)).toString('hex'); + const req = trans.UploadGroupPtt.build(groupUin, ptt); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadGroupPtt.parse(resp); + const ukey = preRespData.upload.uKey; + if (ukey && ukey !== '') { + this.logger.debug(`[Highway] uploadGroupPttReq get upload ptt ukey: ${ukey}, need upload!`); + const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index; + const md5 = Buffer.from(index.info.fileHash, 'hex'); + const sha1 = Buffer.from(index.info.fileSha1, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: index.fileUuid, + uKey: ukey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: [sha1], + }, + }); + await this.hwClient.upload( + 1008, + fs.createReadStream(ptt.filePath, { highWaterMark: BlockSize }), + ptt.fileSize, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadGroupPttReq get upload invalid ukey ${ukey}, don't need upload!`); + } + ptt.msgInfo = preRespData.upload.msgInfo; + } + + private async uploadC2CPtt (peerUid: string, ptt: PacketMsgPttElement): Promise { + ptt.fileSha1 = Buffer.from(await calculateSha1(ptt.filePath)).toString('hex'); + const req = trans.UploadPrivatePtt.build(peerUid, ptt); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadPrivatePtt.parse(resp); + const ukey = preRespData.upload.uKey; + if (ukey && ukey !== '') { + this.logger.debug(`[Highway] uploadC2CPttReq get upload ptt ukey: ${ukey}, need upload!`); + const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index; + const md5 = Buffer.from(index.info.fileHash, 'hex'); + const sha1 = Buffer.from(index.info.fileSha1, 'hex'); + const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({ + fileUuid: index.fileUuid, + uKey: ukey, + network: { + ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S), + }, + msgInfoBody: preRespData.upload.msgInfo.msgInfoBody, + blockSize: BlockSize, + hash: { + fileSha1: [sha1], + }, + }); + await this.hwClient.upload( + 1007, + fs.createReadStream(ptt.filePath, { highWaterMark: BlockSize }), + ptt.fileSize, + md5, + extend + ); + } else { + this.logger.debug(`[Highway] uploadC2CPttReq get upload invalid ukey ${ukey}, don't need upload!`); + } + ptt.msgInfo = preRespData.upload.msgInfo; + } + + private async uploadGroupFile (groupUin: number, file: PacketMsgFileElement): Promise { + file.isGroupFile = true; + file.fileMd5 = await computeMd5AndLengthWithLimit(file.filePath); + file.fileSha1 = await calculateSha1(file.filePath); + const req = trans.UploadGroupFile.build(groupUin, file); + const resp = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadGroupFile.parse(resp); + if (!preRespData?.upload?.boolFileExist) { + this.logger.debug('[Highway] uploadGroupFileReq file not exist, need upload!'); + const ext = new NapProtoMsg(proto.FileUploadExt).encode({ + unknown1: 100, + unknown2: 1, + entry: { + busiBuff: { + senderUin: BigInt(this.sig.uin), + receiverUin: BigInt(groupUin), + groupCode: BigInt(groupUin), + }, + fileEntry: { + fileSize: BigInt(file.fileSize), + md5: file.fileMd5, + md5S2: file.fileMd5, + checkKey: preRespData.upload.checkKey, + fileId: preRespData.upload.fileId, + uploadKey: preRespData.upload.fileKey, + }, + clientInfo: { + clientType: 3, + appId: '100', + terminalType: 3, + clientVer: '1.1.1', + unknown: 4, + }, + fileNameInfo: { + fileName: file.fileName, + }, + host: { + hosts: [ + { + url: { + host: preRespData.upload.uploadIp, + unknown: 1, + }, + port: preRespData.upload.uploadPort, + }, + ], + }, + }, + unknown200: 0, + }); + await this.hwClient.upload( + 71, + fs.createReadStream(file.filePath, { highWaterMark: BlockSize }), + file.fileSize, + file.fileMd5, + ext + ); + } else { + this.logger.debug('[Highway] uploadGroupFileReq file exist, don\'t need upload!'); + } + file.fileUuid = preRespData.upload.fileId; + } + + private async uploadC2CFile (peerUid: string, file: PacketMsgFileElement): Promise { + file.isGroupFile = false; + file.fileMd5 = await computeMd5AndLengthWithLimit(file.filePath); + file.fileSha1 = await calculateSha1(file.filePath); + const req = await trans.UploadPrivateFile.build(this.sig.uid, peerUid, file); + const res = await this.client.sendOidbPacket(req, true); + const preRespData = trans.UploadPrivateFile.parse(res); + if (!preRespData.upload?.boolFileExist) { + this.logger.debug('[Highway] uploadC2CFileReq file not exist, need upload!'); + const ext = new NapProtoMsg(proto.FileUploadExt).encode({ + unknown1: 100, + unknown2: 1, + entry: { + busiBuff: { + senderUin: BigInt(this.sig.uin), + }, + fileEntry: { + fileSize: BigInt(file.fileSize), + md5: file.fileMd5, + md5S2: file.fileMd5, + checkKey: file.fileSha1, + fileId: preRespData.upload?.uuid, + uploadKey: preRespData.upload?.mediaPlatformUploadKey, + }, + clientInfo: { + clientType: 3, + appId: '100', + terminalType: 3, + clientVer: '1.1.1', + unknown: 4, + }, + fileNameInfo: { + fileName: file.fileName, + }, + host: { + hosts: [ + { + url: { + host: preRespData.upload?.uploadIp, + unknown: 1, + }, + port: preRespData.upload?.uploadPort, + }, + ], + }, + }, + unknown200: 1, + unknown3: 0, + }); + await this.hwClient.upload( + 95, + fs.createReadStream(file.filePath, { highWaterMark: BlockSize }), + file.fileSize, + file.fileMd5, + ext + ); + } + file.fileUuid = preRespData.upload?.uuid; + file.fileHash = preRespData.upload?.fileAddon; + const fileExistReq = trans.DownloadOfflineFile.build(file.fileUuid!, file.fileHash!, this.sig.uid, peerUid); + const fileExistRes = await this.client.sendOidbPacket(fileExistReq, true); + file._e37_800_rsp = trans.DownloadOfflineFile.parse(fileExistRes); + file._private_send_uid = this.sig.uid; + file._private_recv_uid = peerUid; + } +} diff --git a/packages/snowcat-core/packet/highway/uploader/highwayHttpUploader.ts b/packages/snowcat-core/packet/highway/uploader/highwayHttpUploader.ts new file mode 100644 index 0000000..66f6841 --- /dev/null +++ b/packages/snowcat-core/packet/highway/uploader/highwayHttpUploader.ts @@ -0,0 +1,75 @@ +import crypto from 'node:crypto'; +import http from 'node:http'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { IHighwayUploader } from '@/snowcat-core/packet/highway/uploader/highwayUploader'; +import { Frame } from '@/snowcat-core/packet/highway/frame'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; + +export class HighwayHttpUploader extends IHighwayUploader { + async upload (): Promise { + const controller = new AbortController(); + const { signal } = controller; + const upload = (async () => { + let offset = 0; + for await (const chunk of this.trans.data) { + if (signal.aborted) { + throw new Error('Upload aborted due to timeout'); + } + const block = chunk as Buffer; + try { + await this.uploadBlock(block, offset); + } catch (err) { + throw new Error(`[Highway] httpUpload Error uploading block at offset ${offset}: ${err}`); + } + offset += block.length; + } + })(); + const timeout = this.timeout().catch((err) => { + controller.abort(); + throw new Error(err.message); + }); + await Promise.race([upload, timeout]); + } + + private async uploadBlock (block: Buffer, offset: number): Promise { + const chunkMD5 = crypto.createHash('md5').update(block).digest(); + const payload = this.buildPicUpHead(offset, block.length, chunkMD5); + const frame = Frame.pack(Buffer.from(payload), block); + const resp = await this.httpPostHighwayContent(frame, `http://${this.trans.server}:${this.trans.port}/cgi-bin/httpconn?htcmd=0x6FF0087&uin=${this.trans.uin}`); + const [head, body] = Frame.unpack(resp); + const headData = new NapProtoMsg(proto.RespDataHighwayHead).decode(head); + this.logger.debug(`[Highway] httpUploadBlock: ${headData.errorCode} | ${headData.msgSegHead?.retCode} | ${headData.bytesRspExtendInfo} | ${head.toString('hex')} | ${body.toString('hex')}`); + if (headData.errorCode !== 0) throw new Error(`[Highway] httpUploadBlock failed (code=${headData.errorCode})`); + } + + private async httpPostHighwayContent (frame: Buffer, serverURL: string): Promise { + return new Promise((resolve, reject) => { + try { + const options: http.RequestOptions = { + method: 'POST', + headers: { + Connection: 'keep-alive', + 'Accept-Encoding': 'identity', + 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2)', + 'Content-Length': frame.length.toString(), + }, + }; + const req = http.request(serverURL, options, (res) => { + const data: Buffer[] = []; + res.on('data', (chunk) => { + data.push(chunk); + }); + res.on('end', () => { + resolve(Buffer.concat(data)); + }); + }); + req.write(frame); + req.on('error', (error: Error) => { + reject(error); + }); + } catch (error: unknown) { + reject(new Error((error as Error).message)); + } + }); + } +} diff --git a/packages/snowcat-core/packet/highway/uploader/highwayTcpUploader.ts b/packages/snowcat-core/packet/highway/uploader/highwayTcpUploader.ts new file mode 100644 index 0000000..9639425 --- /dev/null +++ b/packages/snowcat-core/packet/highway/uploader/highwayTcpUploader.ts @@ -0,0 +1,86 @@ +import net from 'node:net'; +import stream from 'node:stream'; +import crypto from 'node:crypto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { BlockSize } from '@/snowcat-core/packet/highway/highwayContext'; +import { Frame } from '@/snowcat-core/packet/highway/frame'; +import { IHighwayUploader } from '@/snowcat-core/packet/highway/uploader/highwayUploader'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; + +class HighwayTcpUploaderTransform extends stream.Transform { + uploader: HighwayTcpUploader; + offset: number; + + constructor (uploader: HighwayTcpUploader) { + super(); + this.uploader = uploader; + this.offset = 0; + } + + override _transform (data: Buffer, _: BufferEncoding, callback: stream.TransformCallback) { + let chunkOffset = 0; + while (chunkOffset < data.length) { + const chunkSize = Math.min(BlockSize, data.length - chunkOffset); + const chunk = data.subarray(chunkOffset, chunkOffset + chunkSize); + const chunkMd5 = crypto.createHash('md5').update(chunk).digest(); + const head = this.uploader.buildPicUpHead(this.offset, chunk.length, chunkMd5); + chunkOffset += chunk.length; + this.offset += chunk.length; + this.push(Frame.pack(Buffer.from(head), chunk)); + } + callback(null); + } +} + +export class HighwayTcpUploader extends IHighwayUploader { + async upload (): Promise { + const controller = new AbortController(); + const { signal } = controller; + const upload = new Promise((resolve, reject) => { + const highwayTransForm = new HighwayTcpUploaderTransform(this); + const socket = net.connect(this.trans.port, this.trans.server, () => { + this.trans.data.pipe(highwayTransForm).pipe(socket, { end: false }); + }); + const handleRspHeader = (header: Buffer) => { + const rsp = new NapProtoMsg(proto.RespDataHighwayHead).decode(header); + if (rsp.errorCode !== 0) { + socket.end(); + reject(new Error(`[Highway] tcpUpload failed (code=${rsp.errorCode})`)); + } + const percent = ((Number(rsp.msgSegHead?.dataOffset) + Number(rsp.msgSegHead?.dataLength)) / Number(rsp.msgSegHead?.filesize)).toFixed(2); + this.logger.debug(`[Highway] tcpUpload ${rsp.errorCode} | ${percent} | ${Buffer.from(header).toString('hex')}`); + if (Number(rsp.msgSegHead?.dataOffset) + Number(rsp.msgSegHead?.dataLength) >= Number(rsp.msgSegHead?.filesize)) { + this.logger.debug('[Highway] tcpUpload finished.'); + socket.end(); + resolve(); + } + }; + socket.on('data', (chunk: Buffer) => { + if (signal.aborted) { + socket.end(); + reject(new Error('Upload aborted due to timeout')); + } + + const [head, _] = Frame.unpack(chunk); + handleRspHeader(head); + }); + socket.on('close', () => { + this.logger.debug('[Highway] tcpUpload socket closed.'); + resolve(); + }); + socket.on('error', (err) => { + socket.end(); + reject(new Error(`[Highway] tcpUpload socket.on error: ${err}`)); + }); + this.trans.data.on('error', (err) => { + socket.end(); + reject(new Error(`[Highway] tcpUpload readable error: ${err}`)); + }); + }); + const timeout = this.timeout().catch((err) => { + controller.abort(); + throw new Error(err.message); + }); + await Promise.race([upload, timeout]); + } +} diff --git a/packages/snowcat-core/packet/highway/uploader/highwayUploader.ts b/packages/snowcat-core/packet/highway/uploader/highwayUploader.ts new file mode 100644 index 0000000..eacf796 --- /dev/null +++ b/packages/snowcat-core/packet/highway/uploader/highwayUploader.ts @@ -0,0 +1,64 @@ +// import * as tea from '@/snowcat-core/packet/utils/crypto/tea'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { PacketHighwayTrans } from '@/snowcat-core/packet/highway/client'; +import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; + +export abstract class IHighwayUploader { + readonly trans: PacketHighwayTrans; + readonly logger: PacketLogger; + + constructor (trans: PacketHighwayTrans, logger: PacketLogger) { + this.trans = trans; + this.logger = logger; + } + + // TODO: 没用到的加密方法先注释掉 + // private encryptTransExt (key: Uint8Array) { + // if (!this.trans.encrypt) return; + // this.trans.ext = tea.encrypt(Buffer.from(this.trans.ext), Buffer.from(key)); + // } + + protected timeout (): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error(`[Highway] timeout after ${this.trans.timeout}s`)); + }, (this.trans.timeout ?? Infinity) * 1000 + ); + }); + } + + buildPicUpHead (offset: number, bodyLength: number, bodyMd5: Uint8Array): Uint8Array { + return new NapProtoMsg(proto.ReqDataHighwayHead).encode({ + msgBaseHead: { + version: 1, + uin: this.trans.uin, + command: 'PicUp.DataUp', + seq: 0, + retryTimes: 0, + appId: 1600001604, + dataFlag: 16, + commandId: this.trans.cmd, + }, + msgSegHead: { + serviceId: 0, + filesize: BigInt(this.trans.size), + dataOffset: BigInt(offset), + dataLength: bodyLength, + serviceTicket: this.trans.ticket, + md5: bodyMd5, + fileMd5: this.trans.sum, + cacheAddr: 0, + cachePort: 0, + }, + bytesReqExtendInfo: this.trans.ext, + timestamp: BigInt(0), + msgLoginSigHead: { + uint32LoginSigType: 8, + appId: 1600001604, + }, + }); + } + + abstract upload (): Promise; +} diff --git a/packages/snowcat-core/packet/highway/utils.ts b/packages/snowcat-core/packet/highway/utils.ts new file mode 100644 index 0000000..e3f46e1 --- /dev/null +++ b/packages/snowcat-core/packet/highway/utils.ts @@ -0,0 +1,19 @@ +import { NapProtoEncodeStructType } from 'snowcat-protobuf'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; + +export const int32ip2str = (ip: number) => { + ip = ip & 0xffffffff; + return [ip & 0xff, (ip & 0xff00) >> 8, (ip & 0xff0000) >> 16, ((ip & 0xff000000) >> 24) & 0xff].join('.'); +}; + +export const oidbIpv4s2HighwayIpv4s = (ipv4s: NapProtoEncodeStructType[]): NapProtoEncodeStructType[] => { + return ipv4s.map((ip) => { + return { + domain: { + isEnable: true, + ip: int32ip2str(ip.outIP ?? 0), + }, + port: ip.outPort!, + } as NapProtoEncodeStructType; + }); +}; diff --git a/packages/snowcat-core/packet/message/builder.ts b/packages/snowcat-core/packet/message/builder.ts new file mode 100644 index 0000000..f0147ce --- /dev/null +++ b/packages/snowcat-core/packet/message/builder.ts @@ -0,0 +1,70 @@ +import * as crypto from 'crypto'; +import { PushMsgBody } from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType } from 'snowcat-protobuf'; +import { PacketMsg, PacketSendMsgElement } from '@/snowcat-core/packet/message/message'; +import { IPacketMsgElement, PacketMsgTextElement } from '@/snowcat-core/packet/message/element'; +import { SendTextElement } from '@/snowcat-core/index'; + +export class PacketMsgBuilder { + protected static failBackText = new PacketMsgTextElement( + { + textElement: { content: '[该消息类型暂不支持查看]' }, + } as SendTextElement + ); + + buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType[] { + return element.map((node): NapProtoEncodeStructType => { + const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`; + const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement) => { + return acc ?? msg.buildContent(); + }, undefined); + const msgElement = node.msg.flatMap(msg => msg.buildElement() ?? []); + if (!msgContent && !msgElement.length) { + msgElement.push(PacketMsgBuilder.failBackText.buildElement()); + } + return { + responseHead: { + fromUin: node.senderUin, + type: 0, + sigMap: 0, + toUin: 0, + fromUid: '', + forward: node.groupId + ? undefined + : { + friendName: node.senderName, + }, + toUid: node.groupId ? undefined : selfUid, + grp: node.groupId + ? { + groupUin: node.groupId, + memberName: node.senderName, + unknown5: 2, + } + : undefined, + }, + contentHead: { + type: node.groupId ? 82 : 9, + subType: node.groupId ? undefined : 4, + divSeq: node.groupId ? undefined : 4, + autoReply: 0, + sequence: crypto.randomBytes(4).readUInt32LE(0), + timeStamp: +node.time.toString().substring(0, 10), + forward: { + field1: 0, + field2: 0, + field3: node.groupId ? 1 : 2, + unknownBase64: avatar, + avatar, + }, + }, + body: { + richText: { + elems: msgElement, + }, + msgContent, + }, + }; + }); + } +} diff --git a/packages/snowcat-core/packet/message/converter.ts b/packages/snowcat-core/packet/message/converter.ts new file mode 100644 index 0000000..32764ae --- /dev/null +++ b/packages/snowcat-core/packet/message/converter.ts @@ -0,0 +1,173 @@ +import { + ChatType, + ElementType, + MessageElement, + Peer, + RawMessage, +} from '@/snowcat-core/types/msg'; +import { + SendArkElement, + SendFaceElement, + SendFileElement, + SendMarkdownElement, + SendMarketFaceElement, + SendMultiForwardMsgElement, + SendPicElement, + SendPttElement, + SendReplyElement, + SendTextElement, + SendVideoElement, +} from '@/snowcat-core/types/element'; +import { + IPacketMsgElement, + PacketMsgAtElement, + PacketMsgFaceElement, + PacketMsgFileElement, + PacketMsgLightAppElement, + PacketMsgMarkDownElement, + PacketMsgMarkFaceElement, + PacketMsgPicElement, + PacketMsgPttElement, + PacketMsgReplyElement, + PacketMsgTextElement, + PacketMsgVideoElement, + PacketMultiMsgElement, +} from '@/snowcat-core/packet/message/element'; +import { PacketMsg, PacketSendMsgElement } from '@/snowcat-core/packet/message/message'; +import { NapProtoDecodeStructType } from 'snowcat-protobuf'; +import { Elem } from '@/snowcat-core/packet/transformer/proto'; + +const SupportedElementTypes = [ + ElementType.TEXT, + ElementType.PIC, + ElementType.REPLY, + ElementType.FACE, + ElementType.MFACE, + ElementType.VIDEO, + ElementType.FILE, + ElementType.PTT, + ElementType.ARK, + ElementType.MARKDOWN, + ElementType.MULTIFORWARD, +]; + +type SendMessageTypeElementMap = { + [ElementType.TEXT]: SendTextElement, + [ElementType.PIC]: SendPicElement, + [ElementType.FILE]: SendFileElement, + [ElementType.PTT]: SendPttElement, + [ElementType.VIDEO]: SendVideoElement, + [ElementType.FACE]: SendFaceElement, + [ElementType.REPLY]: SendReplyElement, + [ElementType.ARK]: SendArkElement, + [ElementType.MFACE]: SendMarketFaceElement, + [ElementType.MULTIFORWARD]: SendMultiForwardMsgElement, + [ElementType.MARKDOWN]: SendMarkdownElement, +}; + +type ElementToPacketMsgConverters = { + [K in keyof SendMessageTypeElementMap]: ( + sendElement: MessageElement + ) => IPacketMsgElement; +}; + +export type rawMsgWithSendMsg = { + senderUin: number; + senderUid?: string; + senderName: string; + groupId?: number; + time: number; + msg: PacketSendMsgElement[] +}; + +// TODO: make it become adapter? +export class PacketMsgConverter { + private isValidElementType (type: ElementType): type is keyof ElementToPacketMsgConverters { + return SupportedElementTypes.includes(type); + } + + private readonly rawToPacketMsgConverters: ElementToPacketMsgConverters = { + [ElementType.TEXT]: (element) => { + if (element.textElement?.atType) { + return new PacketMsgAtElement(element as SendTextElement); + } + return new PacketMsgTextElement(element as SendTextElement); + }, + [ElementType.PIC]: (element) => { + return new PacketMsgPicElement(element as SendPicElement); + }, + [ElementType.REPLY]: (element) => { + return new PacketMsgReplyElement(element as SendReplyElement); + }, + [ElementType.FACE]: (element) => { + return new PacketMsgFaceElement(element as SendFaceElement); + }, + [ElementType.MFACE]: (element) => { + return new PacketMsgMarkFaceElement(element as SendMarketFaceElement); + }, + [ElementType.VIDEO]: (element) => { + return new PacketMsgVideoElement(element as SendVideoElement); + }, + [ElementType.FILE]: (element) => { + return new PacketMsgFileElement(element as SendFileElement); + }, + [ElementType.PTT]: (element) => { + return new PacketMsgPttElement(element as SendPttElement); + }, + [ElementType.ARK]: (element) => { + return new PacketMsgLightAppElement(element as SendArkElement); + }, + [ElementType.MARKDOWN]: (element) => { + return new PacketMsgMarkDownElement(element as SendMarkdownElement); + }, + [ElementType.MULTIFORWARD]: (element) => { + return new PacketMultiMsgElement(element as SendMultiForwardMsgElement); + }, + }; + + rawMsgWithSendMsgToPacketMsg (msg: rawMsgWithSendMsg): PacketMsg { + return { + senderUid: msg.senderUid ?? '', + senderUin: msg.senderUin, + senderName: msg.senderName, + groupId: msg.groupId, + time: msg.time, + msg: msg.msg.map((element) => { + if (!this.isValidElementType(element.elementType)) return null; + return this.rawToPacketMsgConverters[element.elementType](element as MessageElement); + }).filter((e) => e !== null), + }; + } + + rawMsgToPacketMsg (msg: RawMessage, ctxPeer: Peer): PacketMsg { + return { + seq: +msg.msgSeq, + groupId: ctxPeer.chatType === ChatType.KCHATTYPEGROUP ? +msg.peerUid : undefined, + senderUid: msg.senderUid, + senderUin: +msg.senderUin, + senderName: msg.sendMemberName && msg.sendMemberName !== '' + ? msg.sendMemberName + : msg.sendNickName && msg.sendNickName !== '' + ? msg.sendNickName + : 'QQ用户', + time: +msg.msgTime, + msg: msg.elements.map((element) => { + if (!this.isValidElementType(element.elementType)) return null; + return this.rawToPacketMsgConverters[element.elementType](element); + }).filter((e) => e !== null), + }; + } + + packetMsgToRaw (msg: NapProtoDecodeStructType[]): [MessageElement, NapProtoDecodeStructType | null][] { + const converters = [PacketMsgTextElement.parseElement, + PacketMsgAtElement.parseElement, PacketMsgReplyElement.parseElement, + PacketMsgVideoElement.parseElement, PacketMsgPicElement.parseElement]; + return msg.map((element) => { + for (const converter of converters) { + const result = converter(element); + if (result) return result; + } + return null; + }).filter((e) => e !== null); + } +} diff --git a/packages/snowcat-core/packet/message/element.ts b/packages/snowcat-core/packet/message/element.ts new file mode 100644 index 0000000..6d4dea5 --- /dev/null +++ b/packages/snowcat-core/packet/message/element.ts @@ -0,0 +1,742 @@ +import * as crypto from 'node:crypto'; +import * as zlib from 'node:zlib'; +import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { + CustomFace, + Elem, + FileExtra, + GroupFileExtra, + MarkdownData, + MentionExtra, + MsgInfo, + NotOnlineImage, + OidbSvcTrpcTcp0XE37_800Response, + PushMsgBody, + QBigFaceExtra, + QSmallFaceExtra, +} from '@/snowcat-core/packet/transformer/proto'; +import { + ElementType, + FaceType, + MessageElement, + NTMsgAtType, + NTVideoType, + PicType, + Peer, +} from '@/snowcat-core/types/msg'; +import { + SendArkElement, + SendFaceElement, + SendFileElement, + SendMarkdownElement, + SendMarketFaceElement, + SendPicElement, + SendPttElement, + SendReplyElement, + SendMultiForwardMsgElement, + SendTextElement, + SendVideoElement, +} from '@/snowcat-core/types/element'; +import { ForwardMsgBuilder } from '@/snowcat-core/helper/forward-msg-builder'; +import { PacketMsg, PacketSendMsgElement } from '@/snowcat-core/packet/message/message'; + +export type ParseElementFnR = [MessageElement, NapProtoDecodeStructType | null] | undefined; +type ParseElementFn = (elem: NapProtoDecodeStructType) => ParseElementFnR; + +// raw <-> packet +// TODO: SendStructLongMsgElement +export abstract class IPacketMsgElement { + protected constructor (_rawElement: T) { + } + + get valid (): boolean { + return true; + } + + buildContent (): Uint8Array | undefined { + return undefined; + } + + buildElement (): NapProtoEncodeStructType[] { + return []; + } + + static parseElement: ParseElementFn; + + toPreview (): string { + return '[暂不支持该消息类型喵~]'; + } +} + +export class PacketMsgTextElement extends IPacketMsgElement { + text: string; + + constructor (element: SendTextElement) { + super(element); + this.text = element.textElement.content; + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + text: { + str: this.text, + }, + }]; + } + + static override parseElement = (elem: NapProtoDecodeStructType): ParseElementFnR => { + if (elem.text?.str && (elem.text?.attr6Buf === undefined || elem.text?.attr6Buf?.length === 0)) { + return [{ + textElement: { + content: elem.text?.str, + atType: NTMsgAtType.ATTYPEUNKNOWN, + atUid: '', + atTinyId: '', + atNtUid: '', + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }, null]; + } + return undefined; + }; + + override toPreview (): string { + return this.text; + } +} + +export class PacketMsgAtElement extends PacketMsgTextElement { + targetUid: string; + atAll: boolean; + + constructor (element: SendTextElement) { + super(element); + this.targetUid = element.textElement.atNtUid; + this.atAll = element.textElement.atType === NTMsgAtType.ATTYPEALL; + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + text: { + str: this.text, + pbReserve: new NapProtoMsg(MentionExtra).encode({ + type: this.atAll ? 1 : 2, + uin: 0, + field5: 0, + uid: this.targetUid, + } + ), + }, + }]; + } + + static override parseElement = (elem: NapProtoDecodeStructType): ParseElementFnR => { + if (elem.text?.str && (elem.text?.attr6Buf?.length ?? 100) >= 11) { + return [{ + textElement: { + content: elem.text?.str, + atType: NTMsgAtType.ATTYPEONE, + atUid: String(Buffer.from(elem.text!.attr6Buf!).readUInt32BE(7)), // FIXME: hack + atTinyId: '', + atNtUid: '', + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }, null]; + } + return undefined; + }; +} + +export class PacketMsgReplyElement extends IPacketMsgElement { + time: number; + targetMessageId: bigint; + targetMessageSeq: number; + targetMessageClientSeq: number; + targetUin: number; + targetUid: string; + targetElems?: NapProtoEncodeStructType[]; + targetSourceMsg?: NapProtoEncodeStructType; + targetPeer?: Peer; + + constructor (element: SendReplyElement) { + super(element); + this.time = +(element.replyElement.replyMsgTime ?? Math.floor(Date.now() / 1000)); + this.targetMessageId = BigInt(element.replyElement.replayMsgId ?? 0); + this.targetMessageSeq = +(element.replyElement.replayMsgSeq ?? 0); + this.targetMessageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0); + this.targetUin = +(element.replyElement.senderUin ?? 0); + this.targetUid = element.replyElement.senderUidStr ?? ''; + this.targetPeer = element.replyElement._replyMsgPeer; + } + + get isGroupReply (): boolean { + return this.targetMessageClientSeq === 0; + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + srcMsg: { + origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq], + senderUin: BigInt(this.targetUin), + time: this.time, + elems: this.targetElems ?? [], + sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}), + toUin: BigInt(0), + }, + }]; + } + + static override parseElement = (elem: NapProtoDecodeStructType): ParseElementFnR => { + if (elem.srcMsg && elem.srcMsg.pbReserve) { + const reserve = elem.srcMsg.pbReserve; + return [{ + replyElement: { + replayMsgSeq: String(reserve.friendSeq ?? elem.srcMsg?.origSeqs?.[0] ?? 0), + replayMsgId: String(reserve.messageId ?? 0), + senderUin: String(elem?.srcMsg ?? 0), + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }, null]; + } + return undefined; + }; + + override toPreview (): string { + return '[回复消息]'; + } +} + +export class PacketMsgFaceElement extends IPacketMsgElement { + faceId: number; + isLargeFace: boolean; + resultId?: string; + + constructor (element: SendFaceElement) { + super(element); + this.faceId = element.faceElement.faceIndex; + this.resultId = element.faceElement.resultId; + this.isLargeFace = element.faceElement.faceType === FaceType.AniSticke; + } + + override buildElement (): NapProtoEncodeStructType[] { + if (this.isLargeFace) { + return [{ + commonElem: { + serviceType: 37, + pbElem: new NapProtoMsg(QBigFaceExtra).encode({ + aniStickerPackId: '1', + aniStickerId: '8', + faceId: this.faceId, + sourceType: 1, + resultId: this.resultId, + preview: '', + randomType: 1, + }), + businessType: 1, + }, + }]; + } else if (this.faceId < 260) { + return [{ + face: { + index: this.faceId, + }, + }]; + } else { + return [{ + commonElem: { + serviceType: 33, + pbElem: new NapProtoMsg(QSmallFaceExtra).encode({ + faceId: this.faceId, + preview: '', + preview2: '', + }), + businessType: 1, + }, + }]; + } + } + + static override parseElement = (elem: NapProtoDecodeStructType): ParseElementFnR => { + if (elem.face?.index) { + return [{ + faceElement: { + faceIndex: elem.face.index, + faceType: FaceType.Normal, + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }, null]; + } + if (elem?.commonElem?.serviceType === 37 && elem?.commonElem?.pbElem) { + const qface = new NapProtoMsg(QBigFaceExtra).decode(elem?.commonElem?.pbElem); + if (qface?.faceId) { + return [{ + faceElement: { + faceIndex: qface.faceId, + faceType: FaceType.Normal, + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }, null]; + } + } + if (elem?.commonElem?.serviceType === 33 && elem?.commonElem?.pbElem) { + const qface = new NapProtoMsg(QSmallFaceExtra).decode(elem?.commonElem?.pbElem); + if (qface?.faceId) { + return [{ + faceElement: { + faceIndex: qface.faceId, + faceType: FaceType.Normal, + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }, null]; + } + } + return undefined; + }; + + override toPreview (): string { + return '[表情]'; + } +} + +export class PacketMsgMarkFaceElement extends IPacketMsgElement { + emojiName: string; + emojiId: string; + emojiPackageId: number; + emojiKey: string; + + constructor (element: SendMarketFaceElement) { + super(element); + this.emojiName = element.marketFaceElement.faceName; + this.emojiId = element.marketFaceElement.emojiId; + this.emojiPackageId = element.marketFaceElement.emojiPackageId; + this.emojiKey = element.marketFaceElement.key; + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + marketFace: { + faceName: this.emojiName, + itemType: 6, + faceInfo: 1, + faceId: Buffer.from(this.emojiId, 'hex'), + tabId: this.emojiPackageId, + subType: 3, + key: this.emojiKey, + imageWidth: 300, + imageHeight: 300, + pbReserve: { + field8: 1, + }, + }, + }]; + } + + override toPreview (): string { + return `${this.emojiName}`; + } +} + +export class PacketMsgPicElement extends IPacketMsgElement { + path: string; + name: string; + size: number; + md5: string; + width: number; + height: number; + picType: PicType; + picSubType: number; + summary: string; + sha1: string | null = null; + msgInfo: NapProtoEncodeStructType | null = null; + groupPicExt: NapProtoEncodeStructType | null = null; + c2cPicExt: NapProtoEncodeStructType | null = null; + + constructor (element: SendPicElement) { + super(element); + this.path = element.picElement.sourcePath; + this.name = element.picElement.fileName; + this.size = +element.picElement.fileSize; + this.md5 = element.picElement.md5HexStr ?? ''; + this.width = element.picElement.picWidth; + this.height = element.picElement.picHeight; + this.picType = element.picElement.picType; + this.picSubType = element.picElement.picSubType ?? 0; + this.summary = element.picElement.summary === '' + ? ( + element.picElement.picSubType === 0 ? '[图片]' : '[动画表情]' + ) + : element.picElement.summary; + } + + override get valid (): boolean { + return !!this.msgInfo; + } + + override buildElement (): NapProtoEncodeStructType[] { + if (!this.msgInfo) return []; + return [{ + commonElem: { + serviceType: 48, + pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo), + businessType: 10, + }, + }]; + } + + static override parseElement = (elem: NapProtoDecodeStructType): ParseElementFnR => { + if (elem?.commonElem?.serviceType === 48 && + [10, 20].includes(elem.commonElem.businessType ?? 0) && + elem.commonElem.pbElem) { + const extra = new NapProtoMsg(MsgInfo).decode(elem.commonElem.pbElem); + const msgInfoBody = extra.msgInfoBody[0]; + const index = msgInfoBody?.index; + return [{ + picElement: { + fileSize: index?.info.fileSize ?? 0, + picWidth: index?.info?.width ?? 0, + picHeight: index?.info?.height ?? 0, + fileName: index?.info?.fileHash ?? '', + sourcePath: '', + original: false, + picType: PicType.NEWPIC_APNG, + fileUuid: '', + fileSubId: '', + thumbFileSize: 0, + summary: '[图片]', + thumbPath: new Map(), + }, + elementType: ElementType.PIC, + elementId: '', + }, elem]; + } + if (elem?.notOnlineImage) { + const img = elem?.notOnlineImage; // url in originImageUrl + const preImg: MessageElement = { + picElement: { + fileSize: img.fileLen ?? 0, + picWidth: img.picWidth ?? 0, + picHeight: img.picHeight ?? 0, + fileName: Buffer.from(img.picMd5!).toString('hex') ?? '', + sourcePath: '', + original: false, + picType: PicType.NEWPIC_APNG, + fileUuid: '', + fileSubId: '', + thumbFileSize: 0, + summary: '[图片]', + thumbPath: new Map(), + }, + elementType: ElementType.UNKNOWN, + elementId: '', + }; + if (img.origUrl?.includes('&fileid=')) { + preImg.picElement!.originImageUrl = `https://multimedia.nt.qq.com.cn${img.origUrl}`; + } else { + preImg.picElement!.originImageUrl = `https://gchat.qpic.cn${img.origUrl}`; + } + return [preImg, elem]; + } + return undefined; + }; + + override toPreview (): string { + return this.summary; + } +} + +export class PacketMsgVideoElement extends IPacketMsgElement { + fileName: string; + fileSize?: string; + filePath?: string; + fileTime: number; + fileFormat: NTVideoType; + fileWidth?: number; + fileHeight?: number; + thumbSize?: number; + thumbPath?: string; + fileMd5?: string; + fileSha1?: string; + thumbMd5?: string; + thumbSha1?: string; + thumbWidth?: number; + thumbHeight?: number; + businessType: 11 | 21 = 21; + msgInfo: NapProtoEncodeStructType | null = null; + + constructor (element: SendVideoElement) { + super(element); + this.fileName = element.videoElement.fileName; + this.fileSize = element.videoElement.fileSize; + this.filePath = element.videoElement.filePath; + this.fileTime = Math.max(0, Math.round(element.videoElement.fileTime ?? 0)); + this.fileFormat = element.videoElement.fileFormat ?? NTVideoType.VIDEO_FORMAT_MP4; + this.fileWidth = element.videoElement.fileWidth; + this.fileHeight = element.videoElement.fileHeight; + this.thumbSize = element.videoElement.thumbSize; + this.thumbPath = element.videoElement.thumbPath?.get(0) as string | undefined; + this.fileMd5 = element.videoElement.videoMd5; + this.thumbMd5 = element.videoElement.thumbMd5; + this.thumbWidth = element.videoElement.thumbWidth; + this.thumbHeight = element.videoElement.thumbHeight; + } + + override get valid (): boolean { + return !!this.msgInfo; + } + + override buildElement (): NapProtoEncodeStructType[] { + if (!this.msgInfo) return []; + return [{ + commonElem: { + serviceType: 48, + pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo), + businessType: this.businessType, + }, + }]; + } + + static override parseElement = (elem: NapProtoDecodeStructType): ParseElementFnR => { + if (elem?.commonElem?.serviceType !== 48 || + ![11, 21].includes(elem.commonElem.businessType ?? 0) || + !elem.commonElem.pbElem) { + return undefined; + } + const extra = new NapProtoMsg(MsgInfo).decode(elem.commonElem.pbElem); + const videoIndex = extra.msgInfoBody[0]?.index; + const thumbIndex = extra.msgInfoBody[1]?.index; + if (!videoIndex) return undefined; + return [{ + videoElement: { + filePath: '', + fileName: videoIndex.info.fileName || videoIndex.info.fileHash, + videoMd5: videoIndex.info.fileHash, + thumbMd5: thumbIndex?.info.fileHash, + fileTime: videoIndex.info.time, + fileFormat: (videoIndex.info.type.videoFormat || NTVideoType.VIDEO_FORMAT_MP4) as NTVideoType, + fileSize: String(videoIndex.info.fileSize), + fileWidth: videoIndex.info.width, + fileHeight: videoIndex.info.height, + thumbWidth: thumbIndex?.info.width, + thumbHeight: thumbIndex?.info.height, + thumbSize: thumbIndex?.info.fileSize, + thumbPath: new Map(), + fileUuid: videoIndex.fileUuid, + busiType: 0, + subBusiType: 0, + }, + elementType: ElementType.VIDEO, + elementId: '', + }, elem]; + }; + + override toPreview (): string { + return '[视频]'; + } +} + +export class PacketMsgPttElement extends IPacketMsgElement { + filePath: string; + fileSize: number; + fileMd5: string; + fileSha1?: string; + fileDuration: number; + msgInfo: NapProtoEncodeStructType | null = null; + + constructor (element: SendPttElement) { + super(element); + this.filePath = element.pttElement.filePath; + this.fileSize = +element.pttElement.fileSize; // TODO: cc + this.fileMd5 = element.pttElement.md5HexStr; + this.fileDuration = Math.round(element.pttElement.duration); // TODO: cc + } + + override get valid (): boolean { + return false; + } + + override buildElement (): NapProtoEncodeStructType[] { + // return []; + if (!this.msgInfo) return []; + return [{ + commonElem: { + serviceType: 48, + pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo), + businessType: 22, + }, + }]; + } + + override toPreview (): string { + return '[语音]'; + } +} + +export class PacketMsgFileElement extends IPacketMsgElement { + fileName: string; + filePath: string; + fileSize: number; + fileSha1?: Uint8Array; + fileMd5?: Uint8Array; + fileUuid?: string; + fileHash?: string; + isGroupFile?: boolean; + _private_send_uid?: string; + _private_recv_uid?: string; + _e37_800_rsp?: NapProtoEncodeStructType; + + constructor (element: SendFileElement) { + super(element); + this.fileName = element.fileElement.fileName; + this.filePath = element.fileElement.filePath; + this.fileSize = +element.fileElement.fileSize; + } + + override get valid (): boolean { + return this.isGroupFile || Boolean(this._e37_800_rsp); + } + + override buildContent (): Uint8Array | undefined { + if (this.isGroupFile || !this._e37_800_rsp) return undefined; + return new NapProtoMsg(FileExtra).encode({ + file: { + fileType: 0, + fileUuid: this.fileUuid, + fileMd5: this.fileMd5, + fileName: this.fileName, + fileSize: BigInt(this.fileSize), + subcmd: 1, + dangerEvel: 0, + expireTime: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + fileHash: this.fileHash, + }, + field6: { + field2: { + field1: this._e37_800_rsp?.body?.field30?.field110, + fileUuid: this.fileUuid, + fileName: this.fileName, + field6: this._e37_800_rsp?.body?.field30?.field3, + field7: this._e37_800_rsp?.body?.field30?.field101, + field8: this._e37_800_rsp?.body?.field30?.field100, + timestamp1: this._e37_800_rsp?.body?.field30?.timestamp1, + fileHash: this.fileHash, + selfUid: this._private_send_uid, + destUid: this._private_recv_uid, + }, + }, + }); + } + + override buildElement (): NapProtoEncodeStructType[] { + if (!this.isGroupFile) return []; + const lb = Buffer.alloc(2); + const transElemVal = new NapProtoMsg(GroupFileExtra).encode({ + field1: 6, + fileName: this.fileName, + inner: { + info: { + busId: 102, + fileId: this.fileUuid, + fileSize: BigInt(this.fileSize), + fileName: this.fileName, + fileSha: this.fileSha1, + extInfoString: '', + fileMd5: this.fileMd5, + }, + }, + }); + lb.writeUInt16BE(transElemVal.length); + return [{ + transElem: { + elemType: 24, + elemValue: Buffer.concat([Buffer.from([0x01]), lb, transElemVal]), // TLV + }, + }]; + } + + override toPreview (): string { + return `[文件]${this.fileName}`; + } +} + +export class PacketMsgLightAppElement extends IPacketMsgElement { + payload: string; + + constructor (element: SendArkElement) { + super(element); + this.payload = element.arkElement.bytesData; + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + lightAppElem: { + data: Buffer.concat([ + Buffer.from([0x01]), + zlib.deflateSync(Buffer.from(this.payload, 'utf-8')), + ]), + }, + }]; + } + + override toPreview (): string { + return '[卡片消息]'; + } +} + +export class PacketMsgMarkDownElement extends IPacketMsgElement { + content: string; + + constructor (element: SendMarkdownElement) { + super(element); + this.content = element.markdownElement.content; + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + commonElem: { + serviceType: 45, + pbElem: new NapProtoMsg(MarkdownData).encode({ + content: this.content, + }), + businessType: 1, + }, + }]; + } + + override toPreview (): string { + return `[Markdown消息 ${this.content}]`; + } +} + +export class PacketMultiMsgElement extends IPacketMsgElement { + resid: string; + message: PacketMsg[]; + uuid: string; + + constructor (rawElement: SendMultiForwardMsgElement, message?: PacketMsg[]) { + super(rawElement); + this.resid = rawElement.multiForwardMsgElement.resId; + this.message = message ?? []; + this.uuid = rawElement.multiForwardMsgElement.fileName || crypto.randomUUID(); + } + + override buildElement (): NapProtoEncodeStructType[] { + return [{ + lightAppElem: { + data: Buffer.concat([ + Buffer.from([0x01]), + zlib.deflateSync(Buffer.from(JSON.stringify(ForwardMsgBuilder.fromPacketMsg(this.resid, this.message, undefined, undefined, undefined, undefined, this.uuid)), 'utf-8')), + ]), + }, + }]; + } + + override toPreview (): string { + return '[聊天记录]'; + } +} diff --git a/packages/snowcat-core/packet/message/message.ts b/packages/snowcat-core/packet/message/message.ts new file mode 100644 index 0000000..90022e6 --- /dev/null +++ b/packages/snowcat-core/packet/message/message.ts @@ -0,0 +1,15 @@ +import { IPacketMsgElement } from '@/snowcat-core/packet/message/element'; +import { SendMessageElement, SendMultiForwardMsgElement } from '@/snowcat-core/index'; + +export type PacketSendMsgElement = SendMessageElement | SendMultiForwardMsgElement; + +export interface PacketMsg { + seq?: number; + clientSeq?: number; + groupId?: number; + senderUid: string; + senderUin: number; + senderName: string; + time: number; + msg: IPacketMsgElement[] +} diff --git a/packages/snowcat-core/packet/transformer/action/CancelGroupTodo.ts b/packages/snowcat-core/packet/transformer/action/CancelGroupTodo.ts new file mode 100644 index 0000000..5ad7156 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/CancelGroupTodo.ts @@ -0,0 +1,20 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class CancelGroupTodo extends PacketTransformer { + build (peer: number, msgSeq: string): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90).encode({ + groupUin: peer, + msgSeq: BigInt(msgSeq), + }); + return OidbBase.build(0xF90, 3, data); + } + + parse (data: Buffer) { + return OidbBase.parse(data); + } +} + +export default new CancelGroupTodo(); diff --git a/packages/snowcat-core/packet/transformer/action/CompleteGroupTodo.ts b/packages/snowcat-core/packet/transformer/action/CompleteGroupTodo.ts new file mode 100644 index 0000000..1a05977 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/CompleteGroupTodo.ts @@ -0,0 +1,20 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class CompleteGroupTodo extends PacketTransformer { + build (peer: number, msgSeq: string): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90).encode({ + groupUin: peer, + msgSeq: BigInt(msgSeq), + }); + return OidbBase.build(0xF90, 2, data); + } + + parse (data: Buffer) { + return OidbBase.parse(data); + } +} + +export default new CompleteGroupTodo(); diff --git a/packages/snowcat-core/packet/transformer/action/FetchAiVoiceList.ts b/packages/snowcat-core/packet/transformer/action/FetchAiVoiceList.ts new file mode 100644 index 0000000..f7bec95 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/FetchAiVoiceList.ts @@ -0,0 +1,22 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { AIVoiceChatType } from '@/snowcat-core/packet/entities/aiChat'; + +class FetchAiVoiceList extends PacketTransformer { + build (groupUin: number, chatType: AIVoiceChatType): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0X929D_0).encode({ + groupUin, + chatType, + }); + return OidbBase.build(0x929D, 0, data); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0X929D_0Resp).decode(oidbBody); + } +} + +export default new FetchAiVoiceList(); diff --git a/packages/snowcat-core/packet/transformer/action/GetAiVoice.ts b/packages/snowcat-core/packet/transformer/action/GetAiVoice.ts new file mode 100644 index 0000000..d450e28 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/GetAiVoice.ts @@ -0,0 +1,27 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { AIVoiceChatType } from '@/snowcat-core/packet/entities/aiChat'; + +class GetAiVoice extends PacketTransformer { + build (groupUin: number, voiceId: string, text: string, sessionId: number, chatType: AIVoiceChatType): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0X929B_0).encode({ + groupUin, + voiceId, + text, + chatType, + session: { + sessionId, + }, + }); + return OidbBase.build(0x929B, 0, data); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0X929B_0Resp).decode(oidbBody); + } +} + +export default new GetAiVoice(); diff --git a/packages/snowcat-core/packet/transformer/action/GetMiniAppAdaptShareInfo.ts b/packages/snowcat-core/packet/transformer/action/GetMiniAppAdaptShareInfo.ts new file mode 100644 index 0000000..9268406 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/GetMiniAppAdaptShareInfo.ts @@ -0,0 +1,49 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import { MiniAppReqParams } from '@/snowcat-core/packet/entities/miniApp'; + +class GetMiniAppAdaptShareInfo extends PacketTransformer { + build (req: MiniAppReqParams): OidbPacket { + const data = new NapProtoMsg(proto.MiniAppAdaptShareInfoReq).encode({ + appId: req.sdkId, + body: { + extInfo: { + field2: Buffer.alloc(0), + }, + appid: req.appId, + title: req.title, + desc: req.desc, + time: BigInt(Date.now()), + scene: req.scene, + templateType: req.templateType, + businessType: req.businessType, + picUrl: req.picUrl, + vidUrl: '', + jumpUrl: req.jumpUrl, + iconUrl: req.iconUrl, + verType: req.verType, + shareType: req.shareType, + versionId: req.versionId, + withShareTicket: req.withShareTicket, + webURL: req.webUrl ?? '', + appidRich: Buffer.alloc(0), + template: { + templateId: '', + templateData: '', + }, + field20: '', + }, + }); + return { + cmd: 'LightAppSvc.mini_app_share.AdaptShareInfo', + data: PacketBufBuilder(data), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.MiniAppAdaptShareInfoResp).decode(data); + } +} + +export default new GetMiniAppAdaptShareInfo(); diff --git a/packages/snowcat-core/packet/transformer/action/GetStrangerInfo.ts b/packages/snowcat-core/packet/transformer/action/GetStrangerInfo.ts new file mode 100644 index 0000000..04e445e --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/GetStrangerInfo.ts @@ -0,0 +1,21 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class GetStrangerInfo extends PacketTransformer { + build (uin: number): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0XFE1_2).encode({ + uin, + key: [{ key: 27372 }], + }); + return OidbBase.build(0XFE1, 2, body); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0XFE1_2RSP).decode(oidbBody); + } +} + +export default new GetStrangerInfo(); diff --git a/packages/snowcat-core/packet/transformer/action/GroupSign.ts b/packages/snowcat-core/packet/transformer/action/GroupSign.ts new file mode 100644 index 0000000..d3825c3 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/GroupSign.ts @@ -0,0 +1,25 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class GroupSign extends PacketTransformer { + build (uin: number, groupCode: number): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0XEB7).encode( + { + body: { + uin: String(uin), + groupUin: String(groupCode), + version: '9.0.90', + }, + } + ); + return OidbBase.build(0XEB7, 1, body, false, false); + } + + parse (data: Buffer) { + return OidbBase.parse(data); + } +} + +export default new GroupSign(); diff --git a/packages/snowcat-core/packet/transformer/action/ImageOCR.ts b/packages/snowcat-core/packet/transformer/action/ImageOCR.ts new file mode 100644 index 0000000..edfbf25 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/ImageOCR.ts @@ -0,0 +1,33 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class ImageOCR extends PacketTransformer { + build (url: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0xE07_0).encode( + { + version: 1, + client: 0, + entrance: 1, + ocrReqBody: { + imageUrl: url, + originMd5: '', + afterCompressMd5: '', + afterCompressFileSize: '', + afterCompressWeight: '', + afterCompressHeight: '', + isCut: false, + }, + } + ); + return OidbBase.build(0XEB7, 1, body, false, false); + } + + parse (data: Buffer) { + const base = OidbBase.parse(data); + return new NapProtoMsg(proto.OidbSvcTrpcTcp0xE07_0_Response).decode(base.body); + } +} + +export default new ImageOCR(); diff --git a/packages/snowcat-core/packet/transformer/action/MoveGroupFile.ts b/packages/snowcat-core/packet/transformer/action/MoveGroupFile.ts new file mode 100644 index 0000000..1e114be --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/MoveGroupFile.ts @@ -0,0 +1,31 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class MoveGroupFile extends PacketTransformer { + build (groupUin: number, fileUUID: string, currentParentDirectory: string, targetParentDirectory: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({ + move: { + groupUin, + appId: 5, + busId: 102, + fileId: fileUUID, + parentDirectory: currentParentDirectory, + targetDirectory: targetParentDirectory, + }, + }); + return OidbBase.build(0x6D6, 5, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody); + if (res.move.retCode !== 0) { + throw new Error(`sendGroupFileMoveReq error: ${res.move.clientWording} (code=${res.move.retCode})`); + } + return res; + } +} + +export default new MoveGroupFile(); diff --git a/packages/snowcat-core/packet/transformer/action/RenameGroupFile.ts b/packages/snowcat-core/packet/transformer/action/RenameGroupFile.ts new file mode 100644 index 0000000..800b3ae --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/RenameGroupFile.ts @@ -0,0 +1,30 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class RenameGroupFile extends PacketTransformer { + build (groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({ + rename: { + groupUin, + busId: 102, + fileId: fileUUID, + parentFolder: currentParentDirectory, + newFileName: newName, + }, + }); + return OidbBase.build(0x6D6, 4, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody); + if (res.rename.retCode !== 0) { + throw new Error(`sendGroupFileRenameReq error: ${res.rename.clientWording} (code=${res.rename.retCode})`); + } + return res; + } +} + +export default new RenameGroupFile(); diff --git a/packages/snowcat-core/packet/transformer/action/SendPoke.ts b/packages/snowcat-core/packet/transformer/action/SendPoke.ts new file mode 100644 index 0000000..dc74a34 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/SendPoke.ts @@ -0,0 +1,22 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class SendPoke extends PacketTransformer { + build (is_group: boolean, peer: number, target: number): OidbPacket { + const payload = { + uin: target, + ext: 0, + ...(is_group ? { groupUin: peer } : { friendUin: peer }), + }; + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XED3_1).encode(payload); + return OidbBase.build(0xED3, 1, data); + } + + parse (data: Buffer) { + return OidbBase.parse(data); + } +} + +export default new SendPoke(); diff --git a/packages/snowcat-core/packet/transformer/action/SetGroupTodo.ts b/packages/snowcat-core/packet/transformer/action/SetGroupTodo.ts new file mode 100644 index 0000000..eed5468 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/SetGroupTodo.ts @@ -0,0 +1,20 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class SetGroupTodo extends PacketTransformer { + build (peer: number, msgSeq: string): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90).encode({ + groupUin: peer, + msgSeq: BigInt(msgSeq), + }); + return OidbBase.build(0xF90, 1, data); + } + + parse (data: Buffer) { + return OidbBase.parse(data); + } +} + +export default new SetGroupTodo(); diff --git a/packages/snowcat-core/packet/transformer/action/SetSpecialTitle.ts b/packages/snowcat-core/packet/transformer/action/SetSpecialTitle.ts new file mode 100644 index 0000000..d9ef870 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/SetSpecialTitle.ts @@ -0,0 +1,25 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class SetSpecialTitle extends PacketTransformer { + build (groupCode: number, uid: string, title: string): OidbPacket { + const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({ + groupUin: +groupCode, + body: { + targetUid: uid, + specialTitle: title, + expiredTime: -1, + uinName: title, + }, + }); + return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false); + } + + parse (data: Buffer) { + return OidbBase.parse(data); + } +} + +export default new SetSpecialTitle(); diff --git a/packages/snowcat-core/packet/transformer/action/index.ts b/packages/snowcat-core/packet/transformer/action/index.ts new file mode 100644 index 0000000..25d9531 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/action/index.ts @@ -0,0 +1,13 @@ +export { default as FetchAiVoiceList } from './FetchAiVoiceList'; +export { default as GetAiVoice } from './GetAiVoice'; +export { default as GetMiniAppAdaptShareInfo } from './GetMiniAppAdaptShareInfo'; +export { default as GroupSign } from './GroupSign'; +export { default as GetStrangerInfo } from './GetStrangerInfo'; +export { default as SendPoke } from './SendPoke'; +export { default as SetSpecialTitle } from './SetSpecialTitle'; +export { default as ImageOCR } from './ImageOCR'; +export { default as MoveGroupFile } from './MoveGroupFile'; +export { default as RenameGroupFile } from './RenameGroupFile'; +export { default as CompleteGroupTodo } from './CompleteGroupTodo'; +export { default as CancelGroupTodo } from './CancelGroupTodo'; +export { default as SetGroupTodo } from './SetGroupTodo'; diff --git a/packages/snowcat-core/packet/transformer/base.ts b/packages/snowcat-core/packet/transformer/base.ts new file mode 100644 index 0000000..43f2641 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/base.ts @@ -0,0 +1,25 @@ +import { NapProtoDecodeStructType } from 'snowcat-protobuf'; +import { PacketMsgBuilder } from '@/snowcat-core/packet/message/builder'; + +export type PacketBuf = Buffer & { readonly hexNya: unique symbol; }; + +export const PacketBufBuilder = (str: Uint8Array): PacketBuf => { + return Buffer.from(str) as PacketBuf; +}; + +export interface OidbPacket { + cmd: string; + data: PacketBuf; +} + +export abstract class PacketTransformer { + protected msgBuilder: PacketMsgBuilder; + + constructor () { + this.msgBuilder = new PacketMsgBuilder(); + } + + abstract build (...args: any[]): OidbPacket | Promise; + + abstract parse (data: Buffer): NapProtoDecodeStructType; +} diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadGroupFile.ts b/packages/snowcat-core/packet/transformer/highway/DownloadGroupFile.ts new file mode 100644 index 0000000..1e30563 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadGroupFile.ts @@ -0,0 +1,29 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class DownloadGroupFile extends PacketTransformer { + build (groupUin: number, fileUUID: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({ + download: { + groupUin, + appId: 7, + busId: 102, + fileId: fileUUID, + }, + }); + return OidbBase.build(0x6D6, 2, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody); + if (res.download.retCode !== 0) { + throw new Error(`sendGroupFileDownloadReq error: ${res.download.clientWording} (code=${res.download.retCode})`); + } + return res; + } +} + +export default new DownloadGroupFile(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadGroupImage.ts b/packages/snowcat-core/packet/transformer/highway/DownloadGroupImage.ts new file mode 100644 index 0000000..bae653f --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadGroupImage.ts @@ -0,0 +1,46 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { IndexNode } from '@/snowcat-core/packet/transformer/proto'; + +class DownloadGroupImage extends PacketTransformer { + build (group_uin: number, node: NapProtoEncodeStructType): OidbPacket { + const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 200, + }, + scene: { + requestType: 2, + businessType: 1, + sceneType: 2, + group: { + groupUin: group_uin, + }, + }, + client: { + agentType: 2, + }, + }, + download: { + node, + download: { + video: { + busiType: 0, + sceneType: 0, + }, + }, + }, + }); + return OidbBase.build(0x11C4, 200, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new DownloadGroupImage(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadGroupPtt.ts b/packages/snowcat-core/packet/transformer/highway/DownloadGroupPtt.ts new file mode 100644 index 0000000..d68b26b --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadGroupPtt.ts @@ -0,0 +1,45 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class DownloadGroupPtt extends PacketTransformer { + build (groupUin: number, node: NapProtoEncodeStructType): OidbPacket { + const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 4, + command: 200, + }, + scene: { + requestType: 1, + businessType: 3, + sceneType: 2, + group: { + groupUin, + }, + }, + client: { + agentType: 2, + }, + }, + download: { + node, + download: { + video: { + busiType: 0, + sceneType: 0, + }, + }, + }, + }); + return OidbBase.build(0x126E, 200, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new DownloadGroupPtt(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadGroupVideo.ts b/packages/snowcat-core/packet/transformer/highway/DownloadGroupVideo.ts new file mode 100644 index 0000000..a9d3c68 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadGroupVideo.ts @@ -0,0 +1,46 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { IndexNode } from '@/snowcat-core/packet/transformer/proto'; + +class DownloadGroupVideo extends PacketTransformer { + build (groupUin: number, node: NapProtoEncodeStructType): OidbPacket { + const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 200, + }, + scene: { + requestType: 2, + businessType: 2, + sceneType: 2, + group: { + groupUin, + }, + }, + client: { + agentType: 2, + }, + }, + download: { + node, + download: { + video: { + busiType: 0, + sceneType: 0, + }, + }, + }, + }); + return OidbBase.build(0x11EA, 200, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new DownloadGroupVideo(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadImage.ts b/packages/snowcat-core/packet/transformer/highway/DownloadImage.ts new file mode 100644 index 0000000..d28001e --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadImage.ts @@ -0,0 +1,47 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { IndexNode } from '@/snowcat-core/packet/transformer/proto'; + +class DownloadImage extends PacketTransformer { + build (selfUid: string, node: NapProtoEncodeStructType): OidbPacket { + const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 200, + }, + scene: { + requestType: 2, + businessType: 1, + sceneType: 1, + c2C: { + accountType: 2, + targetUid: selfUid, + }, + }, + client: { + agentType: 2, + }, + }, + download: { + node, + download: { + video: { + busiType: 0, + sceneType: 0, + }, + }, + }, + }); + return OidbBase.build(0x11C5, 200, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new DownloadImage(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadOfflineFile.ts b/packages/snowcat-core/packet/transformer/highway/DownloadOfflineFile.ts new file mode 100644 index 0000000..12707a9 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadOfflineFile.ts @@ -0,0 +1,31 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class DownloadOfflineFile extends PacketTransformer { + build (fileUUID: string, fileHash: string, senderUid: string, receiverUid: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0XE37_800).encode({ + subCommand: 800, + field2: 0, + body: { + senderUid, + receiverUid, + fileUuid: fileUUID, + fileHash, + }, + field101: 3, + field102: 1, + field200: 1, + }); + return OidbBase.build(0xE37, 800, body, false, false); + } + + // TODO:check + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0XE37Response).decode(oidbBody); + } +} + +export default new DownloadOfflineFile(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadPrivateFile.ts b/packages/snowcat-core/packet/transformer/highway/DownloadPrivateFile.ts new file mode 100644 index 0000000..0986cfc --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadPrivateFile.ts @@ -0,0 +1,32 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class DownloadPrivateFile extends PacketTransformer { + build (selfUid: string, fileUUID: string, fileHash: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0XE37_1200).encode({ + subCommand: 1200, + field2: 1, + body: { + receiverUid: selfUid, + fileUuid: fileUUID, + type: 2, + fileHash, + t2: 0, + }, + field101: 3, + field102: 103, + field200: 1, + field99999: Buffer.from([0xc0, 0x85, 0x2c, 0x01]), + }); + return OidbBase.build(0xE37, 1200, body, false, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0XE37_1200Response).decode(oidbBody); + } +} + +export default new DownloadPrivateFile(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadPtt.ts b/packages/snowcat-core/packet/transformer/highway/DownloadPtt.ts new file mode 100644 index 0000000..f88e5e8 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadPtt.ts @@ -0,0 +1,47 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { IndexNode } from '@/snowcat-core/packet/transformer/proto'; + +class DownloadPtt extends PacketTransformer { + build (selfUid: string, node: NapProtoEncodeStructType): OidbPacket { + const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 200, + }, + scene: { + requestType: 1, + businessType: 3, + sceneType: 1, + c2C: { + accountType: 2, + targetUid: selfUid, + }, + }, + client: { + agentType: 2, + }, + }, + download: { + node, + download: { + video: { + busiType: 0, + sceneType: 0, + }, + }, + }, + }); + return OidbBase.build(0x126D, 200, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new DownloadPtt(); diff --git a/packages/snowcat-core/packet/transformer/highway/DownloadVideo.ts b/packages/snowcat-core/packet/transformer/highway/DownloadVideo.ts new file mode 100644 index 0000000..5cc0213 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/DownloadVideo.ts @@ -0,0 +1,47 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { IndexNode } from '@/snowcat-core/packet/transformer/proto'; + +class DownloadVideo extends PacketTransformer { + build (selfUid: string, node: NapProtoEncodeStructType): OidbPacket { + const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 200, + }, + scene: { + requestType: 2, + businessType: 2, + sceneType: 1, + c2C: { + accountType: 2, + targetUid: selfUid, + }, + }, + client: { + agentType: 2, + }, + }, + download: { + node, + download: { + video: { + busiType: 0, + sceneType: 0, + }, + }, + }, + }); + return OidbBase.build(0x11E9, 200, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new DownloadVideo(); diff --git a/packages/snowcat-core/packet/transformer/highway/FetchSessionKey.ts b/packages/snowcat-core/packet/transformer/highway/FetchSessionKey.ts new file mode 100644 index 0000000..781ee51 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/FetchSessionKey.ts @@ -0,0 +1,33 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; + +class FetchSessionKey extends PacketTransformer { + build (): OidbPacket { + const req = new NapProtoMsg(proto.HttpConn0x6ff_501).encode({ + httpConn: { + field1: 0, + field2: 0, + field3: 16, + field4: 1, + field6: 3, + serviceTypes: [1, 5, 10, 21], + // tgt: "", // TODO: do we really need tgt? seems not + field9: 2, + field10: 9, + field11: 8, + ver: '1.0.1', + }, + }); + return { + cmd: 'HttpConn.0x6ff_501', + data: PacketBufBuilder(req), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.HttpConn0x6ff_501Response).decode(data); + } +} + +export default new FetchSessionKey(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadGroupFile.ts b/packages/snowcat-core/packet/transformer/highway/UploadGroupFile.ts new file mode 100644 index 0000000..40f652a --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadGroupFile.ts @@ -0,0 +1,34 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { PacketMsgFileElement } from '@/snowcat-core/packet/message/element'; + +class UploadGroupFile extends PacketTransformer { + build (groupUin: number, file: PacketMsgFileElement): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({ + file: { + groupUin, + appId: 4, + busId: 102, + entrance: 6, + targetDirectory: '/', // TODO: + fileName: file.fileName, + localDirectory: `/${file.fileName}`, + fileSize: BigInt(file.fileSize), + fileMd5: file.fileMd5, + fileSha1: file.fileSha1, + fileSha3: Buffer.alloc(0), + field15: true, + }, + }); + return OidbBase.build(0x6D6, 0, body, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody); + } +} + +export default new UploadGroupFile(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadGroupImage.ts b/packages/snowcat-core/packet/transformer/highway/UploadGroupImage.ts new file mode 100644 index 0000000..51ccf5e --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadGroupImage.ts @@ -0,0 +1,86 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import crypto from 'node:crypto'; +import { PacketMsgPicElement } from '@/snowcat-core/packet/message/element'; + +class UploadGroupImage extends PacketTransformer { + build (groupUin: number, img: PacketMsgPicElement): OidbPacket { + const data = new NapProtoMsg(proto.NTV2RichMediaReq).encode( + { + reqHead: { + common: { + requestId: 1, + command: 100, + }, + scene: { + requestType: 2, + businessType: 1, + sceneType: 2, + group: { + groupUin, + }, + }, + client: { + agentType: 2, + }, + }, + upload: { + uploadInfo: [ + { + fileInfo: { + fileSize: +img.size, + fileHash: img.md5, + fileSha1: img.sha1!, + fileName: img.name, + type: { + type: 1, + picFormat: img.picType, // TODO: extend NapCat imgType /cc @MliKiowa + videoFormat: 0, + voiceFormat: 0, + }, + width: img.width, + height: img.height, + time: 0, + original: 1, + }, + subFileType: 0, + }, + ], + tryFastUploadCompleted: true, + srvSendMsg: false, + clientRandomId: crypto.randomBytes(8).readBigUInt64BE() & BigInt('0x7FFFFFFFFFFFFFFF'), + compatQMsgSceneType: 2, + extBizInfo: { + pic: { + bizType: img.picSubType, + bytesPbReserveTroop: { + subType: img.picSubType, + }, + textSummary: img.summary, + }, + video: { + bytesPbReserve: Buffer.alloc(0), + }, + ptt: { + bytesPbReserve: Buffer.alloc(0), + bytesReserve: Buffer.alloc(0), + bytesGeneralFlags: Buffer.alloc(0), + }, + }, + clientSeq: 0, + noNeedCompatMsg: false, + }, + } + ); + return OidbBase.build(0x11C4, 100, data, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new UploadGroupImage(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadGroupPtt.ts b/packages/snowcat-core/packet/transformer/highway/UploadGroupPtt.ts new file mode 100644 index 0000000..796ff3c --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadGroupPtt.ts @@ -0,0 +1,82 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import crypto from 'node:crypto'; +import { PacketMsgPttElement } from '@/snowcat-core/packet/message/element'; + +class UploadGroupPtt extends PacketTransformer { + build (groupUin: number, ptt: PacketMsgPttElement): OidbPacket { + const data = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 100, + }, + scene: { + requestType: 2, + businessType: 3, + sceneType: 2, + group: { + groupUin, + }, + }, + client: { + agentType: 2, + }, + }, + upload: { + uploadInfo: [ + { + fileInfo: { + fileSize: ptt.fileSize, + fileHash: ptt.fileMd5, + fileSha1: ptt.fileSha1, + fileName: `${ptt.fileMd5}.amr`, + type: { + type: 3, + picFormat: 0, + videoFormat: 0, + voiceFormat: 1, + }, + height: 0, + width: 0, + time: ptt.fileDuration, + original: 0, + }, + subFileType: 0, + }, + ], + tryFastUploadCompleted: true, + srvSendMsg: false, + clientRandomId: crypto.randomBytes(8).readBigUInt64BE() & BigInt('0x7FFFFFFFFFFFFFFF'), + compatQMsgSceneType: 2, + extBizInfo: { + pic: { + textSummary: 'Nya~', + }, + video: { + bytesPbReserve: Buffer.alloc(0), + }, + ptt: { + bytesPbReserve: Buffer.alloc(0), + bytesReserve: Buffer.from([0x08, 0x00, 0x38, 0x00]), + bytesGeneralFlags: Buffer.from([0x9a, 0x01, 0x07, 0xaa, 0x03, 0x04, 0x08, 0x08, 0x12, 0x00]), + }, + }, + clientSeq: 0, + noNeedCompatMsg: false, + }, + }); + return OidbBase.build(0x126E, 100, data, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +const instance = new UploadGroupPtt(); + +export default instance; diff --git a/packages/snowcat-core/packet/transformer/highway/UploadGroupVideo.ts b/packages/snowcat-core/packet/transformer/highway/UploadGroupVideo.ts new file mode 100644 index 0000000..63c114f --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadGroupVideo.ts @@ -0,0 +1,100 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import crypto from 'node:crypto'; +import { PacketMsgVideoElement } from '@/snowcat-core/packet/message/element'; + +class UploadGroupVideo extends PacketTransformer { + build (groupUin: number, video: PacketMsgVideoElement): OidbPacket { + if (!video.fileSize || !video.thumbSize) throw new Error('video.fileSize or video.thumbSize is empty'); + const data = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 3, + command: 100, + }, + scene: { + requestType: 2, + businessType: 2, + sceneType: 2, + group: { + groupUin, + }, + }, + client: { + agentType: 2, + }, + }, + upload: { + uploadInfo: [ + { + fileInfo: { + fileSize: +video.fileSize, + fileHash: video.fileMd5, + fileSha1: video.fileSha1, + fileName: video.fileName || 'video.mp4', + type: { + type: 2, + picFormat: 0, + videoFormat: video.fileFormat, + voiceFormat: 0, + }, + height: video.fileHeight ?? 0, + width: video.fileWidth ?? 0, + time: video.fileTime, + original: 0, + }, + subFileType: 0, + }, { + fileInfo: { + fileSize: +video.thumbSize, + fileHash: video.thumbMd5, + fileSha1: video.thumbSha1, + fileName: `${video.thumbMd5 || 'video-thumb'}.jpg`, + type: { + type: 1, + picFormat: 0, + videoFormat: 0, + voiceFormat: 0, + }, + height: video.thumbHeight, + width: video.thumbWidth, + time: 0, + original: 0, + }, + subFileType: 100, + }, + ], + tryFastUploadCompleted: true, + srvSendMsg: false, + clientRandomId: crypto.randomBytes(8).readBigUInt64BE() & BigInt('0x7FFFFFFFFFFFFFFF'), + compatQMsgSceneType: 2, + extBizInfo: { + pic: { + bizType: 0, + textSummary: 'Nya~', + }, + video: { + bytesPbReserve: Buffer.from([0x80, 0x01, 0x00]), + }, + ptt: { + bytesPbReserve: Buffer.alloc(0), + bytesReserve: Buffer.alloc(0), + bytesGeneralFlags: Buffer.alloc(0), + }, + }, + clientSeq: 0, + noNeedCompatMsg: false, + }, + }); + return OidbBase.build(0x11EA, 100, data, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new UploadGroupVideo(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadPrivateFile.ts b/packages/snowcat-core/packet/transformer/highway/UploadPrivateFile.ts new file mode 100644 index 0000000..98f0fb2 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadPrivateFile.ts @@ -0,0 +1,37 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import { PacketMsgFileElement } from '@/snowcat-core/packet/message/element'; +import { computeMd5AndLengthWithLimit } from '@/snowcat-core/packet/utils/crypto/hash'; + +class UploadPrivateFile extends PacketTransformer { + async build (selfUid: string, peerUid: string, file: PacketMsgFileElement): Promise { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0XE37_1700).encode({ + command: 1700, + seq: 0, + upload: { + senderUid: selfUid, + receiverUid: peerUid, + fileSize: file.fileSize, + fileName: file.fileName, + md510MCheckSum: await computeMd5AndLengthWithLimit(file.filePath, 10 * 1024 * 1024), + sha1CheckSum: file.fileSha1, + localPath: '/', + md5CheckSum: file.fileMd5, + sha3CheckSum: Buffer.alloc(0), + }, + businessId: 3, + clientType: 1, + flagSupportMediaPlatform: 1, + }); + return OidbBase.build(0xE37, 1700, body, false, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0XE37Response).decode(oidbBody); + } +} + +export default new UploadPrivateFile(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadPrivateImage.ts b/packages/snowcat-core/packet/transformer/highway/UploadPrivateImage.ts new file mode 100644 index 0000000..8e2fe60 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadPrivateImage.ts @@ -0,0 +1,86 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import crypto from 'node:crypto'; +import { PacketMsgPicElement } from '@/snowcat-core/packet/message/element'; + +class UploadPrivateImage extends PacketTransformer { + build (peerUin: string, img: PacketMsgPicElement): OidbPacket { + const data = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 1, + command: 100, + }, + scene: { + requestType: 2, + businessType: 1, + sceneType: 1, + c2C: { + accountType: 2, + targetUid: peerUin, + }, + }, + client: { + agentType: 2, + }, + }, + upload: { + uploadInfo: [ + { + fileInfo: { + fileSize: +img.size, + fileHash: img.md5, + fileSha1: img.sha1!, + fileName: img.name, + type: { + type: 1, + picFormat: img.picType, // TODO: extend NapCat imgType /cc @MliKiowa + videoFormat: 0, + voiceFormat: 0, + }, + width: img.width, + height: img.height, + time: 0, + original: 1, + }, + subFileType: 0, + }, + ], + tryFastUploadCompleted: true, + srvSendMsg: false, + clientRandomId: crypto.randomBytes(8).readBigUInt64BE() & BigInt('0x7FFFFFFFFFFFFFFF'), + compatQMsgSceneType: 1, + extBizInfo: { + pic: { + bizType: img.picSubType, + bytesPbReserveC2C: { + subType: img.picSubType, + }, + textSummary: img.summary, + }, + video: { + bytesPbReserve: Buffer.alloc(0), + }, + ptt: { + bytesPbReserve: Buffer.alloc(0), + bytesReserve: Buffer.alloc(0), + bytesGeneralFlags: Buffer.alloc(0), + }, + }, + clientSeq: 0, + noNeedCompatMsg: false, + }, + } + ); + return OidbBase.build(0x11C5, 100, data, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new UploadPrivateImage(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadPrivatePtt.ts b/packages/snowcat-core/packet/transformer/highway/UploadPrivatePtt.ts new file mode 100644 index 0000000..05aee24 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadPrivatePtt.ts @@ -0,0 +1,77 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import crypto from 'node:crypto'; +import { PacketMsgPttElement } from '@/snowcat-core/packet/message/element'; + +class UploadPrivatePtt extends PacketTransformer { + build (peerUin: string, ptt: PacketMsgPttElement): OidbPacket { + const data = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 4, + command: 100, + }, + scene: { + requestType: 2, + businessType: 3, + sceneType: 1, + c2C: { + accountType: 2, + targetUid: peerUin, + }, + }, + client: { + agentType: 2, + }, + }, + upload: { + uploadInfo: [ + { + fileInfo: { + fileSize: ptt.fileSize, + fileHash: ptt.fileMd5, + fileSha1: ptt.fileSha1, + fileName: `${ptt.fileMd5}.amr`, + type: { + type: 3, + picFormat: 0, + videoFormat: 0, + voiceFormat: 1, + }, + height: 0, + width: 0, + time: ptt.fileDuration, + original: 0, + }, + subFileType: 0, + }, + ], + tryFastUploadCompleted: true, + srvSendMsg: false, + clientRandomId: crypto.randomBytes(8).readBigUInt64BE() & BigInt('0x7FFFFFFFFFFFFFFF'), + compatQMsgSceneType: 1, + extBizInfo: { + pic: { + textSummary: 'Nya~', + }, + ptt: { + bytesReserve: Buffer.from([0x08, 0x00, 0x38, 0x00]), + bytesGeneralFlags: Buffer.from([0x9a, 0x01, 0x0b, 0xaa, 0x03, 0x08, 0x08, 0x04, 0x12, 0x04, 0x00, 0x00, 0x00, 0x00]), + }, + }, + clientSeq: 0, + noNeedCompatMsg: false, + }, + }); + return OidbBase.build(0x126D, 100, data, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new UploadPrivatePtt(); diff --git a/packages/snowcat-core/packet/transformer/highway/UploadPrivateVideo.ts b/packages/snowcat-core/packet/transformer/highway/UploadPrivateVideo.ts new file mode 100644 index 0000000..df11b26 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/UploadPrivateVideo.ts @@ -0,0 +1,101 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; +import crypto from 'node:crypto'; +import { PacketMsgVideoElement } from '@/snowcat-core/packet/message/element'; + +class UploadPrivateVideo extends PacketTransformer { + build (peerUin: string, video: PacketMsgVideoElement): OidbPacket { + if (!video.fileSize || !video.thumbSize) throw new Error('video.fileSize or video.thumbSize is empty'); + const data = new NapProtoMsg(proto.NTV2RichMediaReq).encode({ + reqHead: { + common: { + requestId: 3, + command: 100, + }, + scene: { + requestType: 2, + businessType: 2, + sceneType: 1, + c2C: { + accountType: 2, + targetUid: peerUin, + }, + }, + client: { + agentType: 2, + }, + }, + upload: { + uploadInfo: [ + { + fileInfo: { + fileSize: +video.fileSize, + fileHash: video.fileMd5, + fileSha1: video.fileSha1, + fileName: video.fileName || 'video.mp4', + type: { + type: 2, + picFormat: 0, + videoFormat: video.fileFormat, + voiceFormat: 0, + }, + height: video.fileHeight ?? 0, + width: video.fileWidth ?? 0, + time: video.fileTime, + original: 0, + }, + subFileType: 0, + }, { + fileInfo: { + fileSize: +video.thumbSize, + fileHash: video.thumbMd5, + fileSha1: video.thumbSha1, + fileName: `${video.thumbMd5 || 'video-thumb'}.jpg`, + type: { + type: 1, + picFormat: 0, + videoFormat: 0, + voiceFormat: 0, + }, + height: video.thumbHeight, + width: video.thumbWidth, + time: 0, + original: 0, + }, + subFileType: 100, + }, + ], + tryFastUploadCompleted: true, + srvSendMsg: false, + clientRandomId: crypto.randomBytes(8).readBigUInt64BE() & BigInt('0x7FFFFFFFFFFFFFFF'), + compatQMsgSceneType: 2, + extBizInfo: { + pic: { + bizType: 0, + textSummary: 'Nya~', + }, + video: { + bytesPbReserve: Buffer.from([0x80, 0x01, 0x00]), + }, + ptt: { + bytesPbReserve: Buffer.alloc(0), + bytesReserve: Buffer.alloc(0), + bytesGeneralFlags: Buffer.alloc(0), + }, + }, + clientSeq: 0, + noNeedCompatMsg: false, + }, + }); + return OidbBase.build(0x11E9, 100, data, true, false); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody); + } +} + +export default new UploadPrivateVideo(); diff --git a/packages/snowcat-core/packet/transformer/highway/index.ts b/packages/snowcat-core/packet/transformer/highway/index.ts new file mode 100644 index 0000000..596d156 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/highway/index.ts @@ -0,0 +1,18 @@ +export { default as DownloadGroupFile } from './DownloadGroupFile'; +export { default as DownloadGroupPtt } from './DownloadGroupPtt'; +export { default as DownloadOfflineFile } from './DownloadOfflineFile'; +export { default as DownloadPrivateFile } from './DownloadPrivateFile'; +export { default as FetchSessionKey } from './FetchSessionKey'; +export { default as UploadGroupFile } from './UploadGroupFile'; +export { default as UploadGroupImage } from './UploadGroupImage'; +export { default as UploadGroupPtt } from './UploadGroupPtt'; +export { default as UploadGroupVideo } from './UploadGroupVideo'; +export { default as UploadPrivateFile } from './UploadPrivateFile'; +export { default as UploadPrivateImage } from './UploadPrivateImage'; +export { default as UploadPrivatePtt } from './UploadPrivatePtt'; +export { default as UploadPrivateVideo } from './UploadPrivateVideo'; +export { default as DownloadImage } from './DownloadImage'; +export { default as DownloadGroupImage } from './DownloadGroupImage'; +export { default as DownloadVideo } from './DownloadVideo'; +export { default as DownloadGroupVideo } from './DownloadGroupVideo'; +export { default as DownloadPtt } from './DownloadPtt'; diff --git a/packages/snowcat-core/packet/transformer/index.ts b/packages/snowcat-core/packet/transformer/index.ts new file mode 100644 index 0000000..e4693da --- /dev/null +++ b/packages/snowcat-core/packet/transformer/index.ts @@ -0,0 +1,4 @@ +export * from './action'; +export * from './highway'; +export * from './message'; +export * from './system'; diff --git a/packages/snowcat-core/packet/transformer/message/DownloadForwardMsg.ts b/packages/snowcat-core/packet/transformer/message/DownloadForwardMsg.ts new file mode 100644 index 0000000..df308cc --- /dev/null +++ b/packages/snowcat-core/packet/transformer/message/DownloadForwardMsg.ts @@ -0,0 +1,33 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; + +class DownloadForwardMsg extends PacketTransformer { + build (uid: string, resId: string): OidbPacket { + const req = new NapProtoMsg(proto.RecvLongMsgReq).encode({ + info: { + uid: { + uid, + }, + resId, + acquire: true, + }, + settings: { + field1: 2, + field2: 0, + field3: 0, + field4: 0, + }, + }); + return { + cmd: 'trpc.group.long_msg_interface.MsgService.SsoRecvLongMsg', + data: PacketBufBuilder(req), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.RecvLongMsgResp).decode(data); + } +} + +export default new DownloadForwardMsg(); diff --git a/packages/snowcat-core/packet/transformer/message/FetchC2CMessage.ts b/packages/snowcat-core/packet/transformer/message/FetchC2CMessage.ts new file mode 100644 index 0000000..424db99 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/message/FetchC2CMessage.ts @@ -0,0 +1,23 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; + +class FetchC2CMessage extends PacketTransformer { + build (targetUid: string, startSeq: number, endSeq: number): OidbPacket { + const req = new NapProtoMsg(proto.SsoGetC2cMsg).encode({ + friendUid: targetUid, + startSequence: startSeq, + endSequence: endSeq, + }); + return { + cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg', + data: PacketBufBuilder(req), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.SsoGetC2cMsgResponse).decode(data); + } +} + +export default new FetchC2CMessage(); diff --git a/packages/snowcat-core/packet/transformer/message/FetchGroupMessage.ts b/packages/snowcat-core/packet/transformer/message/FetchGroupMessage.ts new file mode 100644 index 0000000..c298e7f --- /dev/null +++ b/packages/snowcat-core/packet/transformer/message/FetchGroupMessage.ts @@ -0,0 +1,26 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; + +class FetchGroupMessage extends PacketTransformer { + build (groupUin: number, startSeq: number, endSeq: number): OidbPacket { + const req = new NapProtoMsg(proto.SsoGetGroupMsg).encode({ + info: { + groupUin, + startSequence: startSeq, + endSequence: endSeq, + }, + direction: true, + }); + return { + cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg', + data: PacketBufBuilder(req), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.SsoGetGroupMsgResponse).decode(data); + } +} + +export default new FetchGroupMessage(); diff --git a/packages/snowcat-core/packet/transformer/message/UploadForwardMsg.ts b/packages/snowcat-core/packet/transformer/message/UploadForwardMsg.ts new file mode 100644 index 0000000..3de7998 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/message/UploadForwardMsg.ts @@ -0,0 +1,47 @@ +import zlib from 'node:zlib'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import { PacketMsg } from '@/snowcat-core/packet/message/message'; + +class UploadForwardMsg extends PacketTransformer { + build (selfUid: string, msg: PacketMsg[], groupUin: number = 0): OidbPacket { + const msgBody = this.msgBuilder.buildFakeMsg(selfUid, msg); + const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode( + { + action: [{ + actionCommand: 'MultiMsg', + actionData: { + msgBody, + }, + }], + } + ); + const payload = zlib.gzipSync(Buffer.from(longMsgResultData)); + const req = new NapProtoMsg(proto.SendLongMsgReq).encode( + { + info: { + type: groupUin === 0 ? 1 : 3, + uid: { + uid: groupUin === 0 ? selfUid : groupUin.toString(), + }, + groupUin, + payload, + }, + settings: { + field1: 4, field2: 1, field3: 7, field4: 0, + }, + } + ); + return { + cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg', + data: PacketBufBuilder(req), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.SendLongMsgResp).decode(data); + } +} + +export default new UploadForwardMsg(); diff --git a/packages/snowcat-core/packet/transformer/message/UploadForwardMsgV2.ts b/packages/snowcat-core/packet/transformer/message/UploadForwardMsgV2.ts new file mode 100644 index 0000000..d911569 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/message/UploadForwardMsgV2.ts @@ -0,0 +1,52 @@ +import zlib from 'node:zlib'; +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import { PacketMsg } from '@/snowcat-core/packet/message/message'; + +export interface UploadForwardMsgParams { + actionCommand: string; + actionMsg?: PacketMsg[]; + actionMsgBody?: NapProtoEncodeStructType[]; +} +class UploadForwardMsgV2 extends PacketTransformer { + build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket { + const reqdata = msg.map((item) => ({ + actionCommand: item.actionCommand, + actionData: { + msgBody: item.actionMsgBody ?? this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg ?? []), + }, + })); + const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode( + { + action: reqdata, + } + ); + const payload = zlib.gzipSync(Buffer.from(longMsgResultData)); + const req = new NapProtoMsg(proto.SendLongMsgReq).encode( + { + info: { + type: groupUin === 0 ? 1 : 3, + uid: { + uid: groupUin === 0 ? selfUid : groupUin.toString(), + }, + groupUin, + payload, + }, + settings: { + field1: 4, field2: 1, field3: 7, field4: 0, + }, + } + ); + return { + cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg', + data: PacketBufBuilder(req), + }; + } + + parse (data: Buffer) { + return new NapProtoMsg(proto.SendLongMsgResp).decode(data); + } +} + +export default new UploadForwardMsgV2(); diff --git a/packages/snowcat-core/packet/transformer/message/index.ts b/packages/snowcat-core/packet/transformer/message/index.ts new file mode 100644 index 0000000..2fa1cc1 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/message/index.ts @@ -0,0 +1,5 @@ +export { default as UploadForwardMsg } from './UploadForwardMsg'; +export { default as FetchGroupMessage } from './FetchGroupMessage'; +export { default as FetchC2CMessage } from './FetchC2CMessage'; +export { default as DownloadForwardMsg } from './DownloadForwardMsg'; +export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2'; diff --git a/packages/snowcat-core/packet/transformer/oidb/oidbBase.ts b/packages/snowcat-core/packet/transformer/oidb/oidbBase.ts new file mode 100644 index 0000000..44ff53b --- /dev/null +++ b/packages/snowcat-core/packet/transformer/oidb/oidbBase.ts @@ -0,0 +1,28 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; + +class OidbBase extends PacketTransformer { + build (cmd: number, subCmd: number, body: Uint8Array, isUid: boolean = true, _isLafter: boolean = false): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcpBase).encode({ + command: cmd, + subCommand: subCmd, + body, + isReserved: isUid ? 1 : 0, + }); + return { + cmd: `OidbSvcTrpcTcp.0x${cmd.toString(16).toUpperCase()}_${subCmd}`, + data: PacketBufBuilder(data), + }; + } + + parse (data: Buffer) { + const res = new NapProtoMsg(proto.OidbSvcTrpcTcpBase).decode(data); + if (res.errorCode !== 0) { + throw new Error(`OidbSvcTrpcTcpBase parse error: ${res.errorMsg} (code=${res.errorCode})`); + } + return res; + } +} + +export default new OidbBase(); diff --git a/packages/snowcat-core/packet/transformer/proto/action/action.ts b/packages/snowcat-core/packet/transformer/proto/action/action.ts new file mode 100644 index 0000000..023c283 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/action/action.ts @@ -0,0 +1,114 @@ +import { ScalarType } from '@protobuf-ts/runtime'; +import { ProtoField } from 'snowcat-protobuf'; +import { ContentHead, MessageBody, MessageControl, RoutingHead } from '@/snowcat-core/packet/transformer/proto'; + +export const FaceRoamRequest = { + comm: ProtoField(1, () => PlatInfo, true), + selfUin: ProtoField(2, ScalarType.UINT32), + subCmd: ProtoField(3, ScalarType.UINT32), + field6: ProtoField(6, ScalarType.UINT32), +}; + +export const PlatInfo = { + imPlat: ProtoField(1, ScalarType.UINT32), + osVersion: ProtoField(2, ScalarType.STRING, true), + qVersion: ProtoField(3, ScalarType.STRING, true), +}; + +export const FaceRoamResponse = { + retCode: ProtoField(1, ScalarType.UINT32), + errMsg: ProtoField(2, ScalarType.STRING), + subCmd: ProtoField(3, ScalarType.UINT32), + userInfo: ProtoField(6, () => FaceRoamUserInfo), +}; + +export const FaceRoamUserInfo = { + fileName: ProtoField(1, ScalarType.STRING, false, true), + deleteFile: ProtoField(2, ScalarType.STRING, false, true), + bid: ProtoField(3, ScalarType.STRING), + maxRoamSize: ProtoField(4, ScalarType.UINT32), + emojiType: ProtoField(5, ScalarType.UINT32, false, true), +}; + +export const SendMessageRequest = { + state: ProtoField(1, ScalarType.INT32), + sizeCache: ProtoField(2, ScalarType.INT32), + unknownFields: ProtoField(3, ScalarType.BYTES), + routingHead: ProtoField(4, () => RoutingHead), + contentHead: ProtoField(5, () => ContentHead), + messageBody: ProtoField(6, () => MessageBody), + msgSeq: ProtoField(7, ScalarType.INT32), + msgRand: ProtoField(8, ScalarType.INT32), + syncCookie: ProtoField(9, ScalarType.BYTES), + msgVia: ProtoField(10, ScalarType.INT32), + dataStatist: ProtoField(11, ScalarType.INT32), + messageControl: ProtoField(12, () => MessageControl), + multiSendSeq: ProtoField(13, ScalarType.INT32), +}; + +export const SendMessageResponse = { + result: ProtoField(1, ScalarType.INT32), + errMsg: ProtoField(2, ScalarType.STRING, true), + timestamp1: ProtoField(3, ScalarType.UINT32), + field10: ProtoField(10, ScalarType.UINT32), + groupSequence: ProtoField(11, ScalarType.UINT32, true), + timestamp2: ProtoField(12, ScalarType.UINT32), + privateSequence: ProtoField(14, ScalarType.UINT32), +}; + +export const SetStatus = { + status: ProtoField(1, ScalarType.UINT32), + extStatus: ProtoField(2, ScalarType.UINT32), + batteryStatus: ProtoField(3, ScalarType.UINT32), + customExt: ProtoField(4, () => SetStatusCustomExt, true), +}; + +export const SetStatusCustomExt = { + faceId: ProtoField(1, ScalarType.UINT32), + text: ProtoField(2, ScalarType.STRING, true), + field3: ProtoField(3, ScalarType.UINT32), +}; + +export const SetStatusResponse = { + message: ProtoField(2, ScalarType.STRING), +}; + +export const HttpConn = { + field1: ProtoField(1, ScalarType.INT32), + field2: ProtoField(2, ScalarType.INT32), + field3: ProtoField(3, ScalarType.INT32), + field4: ProtoField(4, ScalarType.INT32), + tgt: ProtoField(5, ScalarType.STRING), + field6: ProtoField(6, ScalarType.INT32), + serviceTypes: ProtoField(7, ScalarType.INT32, false, true), + field9: ProtoField(9, ScalarType.INT32), + field10: ProtoField(10, ScalarType.INT32), + field11: ProtoField(11, ScalarType.INT32), + ver: ProtoField(15, ScalarType.STRING), +}; + +export const HttpConn0x6ff_501 = { + httpConn: ProtoField(0x501, () => HttpConn), +}; + +export const HttpConn0x6ff_501Response = { + httpConn: ProtoField(0x501, () => HttpConnResponse), +}; + +export const HttpConnResponse = { + sigSession: ProtoField(1, ScalarType.BYTES), + sessionKey: ProtoField(2, ScalarType.BYTES), + serverInfos: ProtoField(3, () => ServerInfo, false, true), +}; + +export const ServerAddr = { + type: ProtoField(1, ScalarType.UINT32), + ip: ProtoField(2, ScalarType.FIXED32), + port: ProtoField(3, ScalarType.UINT32), + area: ProtoField(4, ScalarType.UINT32), +}; + +export const ServerInfo = { + serviceType: ProtoField(1, ScalarType.UINT32), + serverAddrs: ProtoField(2, () => ServerAddr, false, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/action/miniAppAdaptShareInfo.ts b/packages/snowcat-core/packet/transformer/proto/action/miniAppAdaptShareInfo.ts new file mode 100644 index 0000000..a1eb332 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/action/miniAppAdaptShareInfo.ts @@ -0,0 +1,48 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const MiniAppAdaptShareInfoReq = { + appId: ProtoField(2, ScalarType.STRING), + body: ProtoField(4, () => MiniAppAdaptShareInfoReqBody), +}; + +export const MiniAppAdaptShareInfoReqBody = { + extInfo: ProtoField(1, () => ExtInfo), + appid: ProtoField(2, ScalarType.STRING), + title: ProtoField(3, ScalarType.STRING), + desc: ProtoField(4, ScalarType.STRING), + time: ProtoField(5, ScalarType.UINT64), + scene: ProtoField(6, ScalarType.UINT32), + templateType: ProtoField(7, ScalarType.UINT32), + businessType: ProtoField(8, ScalarType.UINT32), + picUrl: ProtoField(9, ScalarType.STRING), + vidUrl: ProtoField(10, ScalarType.STRING), + jumpUrl: ProtoField(11, ScalarType.STRING), + iconUrl: ProtoField(12, ScalarType.STRING), + verType: ProtoField(13, ScalarType.UINT32), + shareType: ProtoField(14, ScalarType.UINT32), + versionId: ProtoField(15, ScalarType.STRING), + withShareTicket: ProtoField(16, ScalarType.UINT32), + webURL: ProtoField(17, ScalarType.STRING), + appidRich: ProtoField(18, ScalarType.BYTES), + template: ProtoField(19, () => Template), + field20: ProtoField(20, ScalarType.STRING), +}; + +export const ExtInfo = { + field2: ProtoField(2, ScalarType.BYTES), +}; + +export const Template = { + templateId: ProtoField(1, ScalarType.STRING), + templateData: ProtoField(2, ScalarType.STRING), +}; + +export const MiniAppAdaptShareInfoResp = { + field2: ProtoField(2, ScalarType.UINT32), + field3: ProtoField(3, ScalarType.STRING), + content: ProtoField(4, () => MiniAppAdaptShareInfoRespContent), +}; + +export const MiniAppAdaptShareInfoRespContent = { + jsonContent: ProtoField(2, ScalarType.STRING), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/highway/highway.ts b/packages/snowcat-core/packet/transformer/proto/highway/highway.ts new file mode 100644 index 0000000..3bf4fe7 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/highway/highway.ts @@ -0,0 +1,154 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { MsgInfoBody } from '@/snowcat-core/packet/transformer/proto'; + +export const DataHighwayHead = { + version: ProtoField(1, ScalarType.UINT32), + uin: ProtoField(2, ScalarType.STRING, true), + command: ProtoField(3, ScalarType.STRING, true), + seq: ProtoField(4, ScalarType.UINT32, true), + retryTimes: ProtoField(5, ScalarType.UINT32, true), + appId: ProtoField(6, ScalarType.UINT32), + dataFlag: ProtoField(7, ScalarType.UINT32), + commandId: ProtoField(8, ScalarType.UINT32), + buildVer: ProtoField(9, ScalarType.BYTES, true), +}; + +export const FileUploadExt = { + unknown1: ProtoField(1, ScalarType.INT32), + unknown2: ProtoField(2, ScalarType.INT32), + unknown3: ProtoField(3, ScalarType.INT32), + entry: ProtoField(100, () => FileUploadEntry), + unknown200: ProtoField(200, ScalarType.INT32), +}; + +export const FileUploadEntry = { + busiBuff: ProtoField(100, () => ExcitingBusiInfo), + fileEntry: ProtoField(200, () => ExcitingFileEntry), + clientInfo: ProtoField(300, () => ExcitingClientInfo), + fileNameInfo: ProtoField(400, () => ExcitingFileNameInfo), + host: ProtoField(500, () => ExcitingHostConfig), +}; + +export const ExcitingBusiInfo = { + busId: ProtoField(1, ScalarType.INT32), + senderUin: ProtoField(100, ScalarType.UINT64), + receiverUin: ProtoField(200, ScalarType.UINT64), + groupCode: ProtoField(400, ScalarType.UINT64), +}; + +export const ExcitingFileEntry = { + fileSize: ProtoField(100, ScalarType.UINT64), + md5: ProtoField(200, ScalarType.BYTES), + checkKey: ProtoField(300, ScalarType.BYTES), + md5S2: ProtoField(400, ScalarType.BYTES), + fileId: ProtoField(600, ScalarType.STRING), + uploadKey: ProtoField(700, ScalarType.BYTES), +}; + +export const ExcitingClientInfo = { + clientType: ProtoField(100, ScalarType.INT32), + appId: ProtoField(200, ScalarType.STRING), + terminalType: ProtoField(300, ScalarType.INT32), + clientVer: ProtoField(400, ScalarType.STRING), + unknown: ProtoField(600, ScalarType.INT32), +}; + +export const ExcitingFileNameInfo = { + fileName: ProtoField(100, ScalarType.STRING), +}; + +export const ExcitingHostConfig = { + hosts: ProtoField(200, () => ExcitingHostInfo, false, true), +}; + +export const ExcitingHostInfo = { + url: ProtoField(1, () => ExcitingUrlInfo), + port: ProtoField(2, ScalarType.UINT32), +}; + +export const ExcitingUrlInfo = { + unknown: ProtoField(1, ScalarType.INT32), + host: ProtoField(2, ScalarType.STRING), +}; + +export const LoginSigHead = { + uint32LoginSigType: ProtoField(1, ScalarType.UINT32), + bytesLoginSig: ProtoField(2, ScalarType.BYTES), + appId: ProtoField(3, ScalarType.UINT32), +}; + +export const NTV2RichMediaHighwayExt = { + fileUuid: ProtoField(1, ScalarType.STRING), + uKey: ProtoField(2, ScalarType.STRING), + network: ProtoField(5, () => NTHighwayNetwork), + msgInfoBody: ProtoField(6, () => MsgInfoBody, false, true), + blockSize: ProtoField(10, ScalarType.UINT32), + hash: ProtoField(11, () => NTHighwayHash), +}; + +export const NTHighwayHash = { + fileSha1: ProtoField(1, ScalarType.BYTES, false, true), +}; + +export const NTHighwayNetwork = { + ipv4s: ProtoField(1, () => NTHighwayIPv4, false, true), +}; + +export const NTHighwayIPv4 = { + domain: ProtoField(1, () => NTHighwayDomain), + port: ProtoField(2, ScalarType.UINT32), +}; + +export const NTHighwayDomain = { + isEnable: ProtoField(1, ScalarType.BOOL), + ip: ProtoField(2, ScalarType.STRING), +}; + +export const ReqDataHighwayHead = { + msgBaseHead: ProtoField(1, () => DataHighwayHead, true), + msgSegHead: ProtoField(2, () => SegHead, true), + bytesReqExtendInfo: ProtoField(3, ScalarType.BYTES, true), + timestamp: ProtoField(4, ScalarType.UINT64), + msgLoginSigHead: ProtoField(5, () => LoginSigHead, true), +}; + +export const RespDataHighwayHead = { + msgBaseHead: ProtoField(1, () => DataHighwayHead, true), + msgSegHead: ProtoField(2, () => SegHead, true), + errorCode: ProtoField(3, ScalarType.UINT32), + allowRetry: ProtoField(4, ScalarType.UINT32), + cacheCost: ProtoField(5, ScalarType.UINT32), + htCost: ProtoField(6, ScalarType.UINT32), + bytesRspExtendInfo: ProtoField(7, ScalarType.BYTES, true), + timestamp: ProtoField(8, ScalarType.UINT64), + range: ProtoField(9, ScalarType.UINT64), + isReset: ProtoField(10, ScalarType.UINT32), +}; + +export const SegHead = { + serviceId: ProtoField(1, ScalarType.UINT32, true), + filesize: ProtoField(2, ScalarType.UINT64), + dataOffset: ProtoField(3, ScalarType.UINT64, true), + dataLength: ProtoField(4, ScalarType.UINT32), + retCode: ProtoField(5, ScalarType.UINT32, true), + serviceTicket: ProtoField(6, ScalarType.BYTES), + flag: ProtoField(7, ScalarType.UINT32, true), + md5: ProtoField(8, ScalarType.BYTES), + fileMd5: ProtoField(9, ScalarType.BYTES), + cacheAddr: ProtoField(10, ScalarType.UINT32, true), + queryTimes: ProtoField(11, ScalarType.UINT32), + updateCacheIp: ProtoField(12, ScalarType.UINT32), + cachePort: ProtoField(13, ScalarType.UINT32, true), +}; + +export const GroupAvatarExtra = { + type: ProtoField(1, ScalarType.UINT32), + groupUin: ProtoField(2, ScalarType.UINT32), + field3: ProtoField(3, () => GroupAvatarExtraField3), + field5: ProtoField(5, ScalarType.UINT32), + field6: ProtoField(6, ScalarType.UINT32), +}; + +export const GroupAvatarExtraField3 = { + field1: ProtoField(1, ScalarType.UINT32), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/index.ts b/packages/snowcat-core/packet/transformer/proto/index.ts new file mode 100644 index 0000000..4a6f195 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/index.ts @@ -0,0 +1,34 @@ +// action folder +export * from './action/action'; +export * from './action/miniAppAdaptShareInfo'; + +// highway folder +export * from './highway/highway'; + +// message folder +export * from './message/action'; +export * from './message/c2c'; +export * from './message/component'; +export * from './message/element'; +export * from './message/group'; +export * from './message/message'; +export * from './message/notify'; +export * from './message/routing'; + +// oidb folder +export * from './oidb/common/Ntv2.RichMediaReq'; +export * from './oidb/common/Ntv2.RichMediaResp'; +export * from './oidb/Oidb.0x6D6'; +export * from './oidb/Oidb.0x8FC_2'; +export * from './oidb/Oidb.0x9067_202'; +export * from './oidb/Oidb.0x929'; +export * from './oidb/Oidb.0xE37_1200'; +export * from './oidb/Oidb.0xE37_1700'; +export * from './oidb/Oidb.0XE37_800'; +export * from './oidb/Oidb.0xCDE_2'; +export * from './oidb/Oidb.0xEB7'; +export * from './oidb/Oidb.0xED3_1'; +export * from './oidb/Oidb.0XFE1_2'; +export * from './oidb/OidbBase'; +export * from './oidb/Oidb.0xE07'; +export * from './oidb/Oidb.0xf90'; diff --git a/packages/snowcat-core/packet/transformer/proto/message/action.ts b/packages/snowcat-core/packet/transformer/proto/message/action.ts new file mode 100644 index 0000000..c6c038d --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/action.ts @@ -0,0 +1,116 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { PushMsgBody } from '@/snowcat-core/packet/transformer/proto'; + +export const LongMsgResult = { + action: ProtoField(2, () => LongMsgAction, false, true), +}; + +export const LongMsgAction = { + actionCommand: ProtoField(1, ScalarType.STRING), + actionData: ProtoField(2, () => LongMsgContent), +}; + +export const LongMsgContent = { + msgBody: ProtoField(1, () => PushMsgBody, false, true), +}; + +export const RecvLongMsgReq = { + info: ProtoField(1, () => RecvLongMsgInfo, true), + settings: ProtoField(15, () => LongMsgSettings, true), +}; + +export const RecvLongMsgInfo = { + uid: ProtoField(1, () => LongMsgUid, true), + resId: ProtoField(2, ScalarType.STRING, true), + acquire: ProtoField(3, ScalarType.BOOL), +}; + +export const LongMsgUid = { + uid: ProtoField(2, ScalarType.STRING, true), +}; + +export const LongMsgSettings = { + field1: ProtoField(1, ScalarType.UINT32), + field2: ProtoField(2, ScalarType.UINT32), + field3: ProtoField(3, ScalarType.UINT32), + field4: ProtoField(4, ScalarType.UINT32), +}; + +export const RecvLongMsgResp = { + result: ProtoField(1, () => RecvLongMsgResult), + settings: ProtoField(15, () => LongMsgSettings), +}; + +export const RecvLongMsgResult = { + resId: ProtoField(3, ScalarType.STRING), + payload: ProtoField(4, ScalarType.BYTES), +}; + +export const SendLongMsgReq = { + info: ProtoField(2, () => SendLongMsgInfo), + settings: ProtoField(15, () => LongMsgSettings), +}; + +export const SendLongMsgInfo = { + type: ProtoField(1, ScalarType.UINT32), + uid: ProtoField(2, () => LongMsgUid, true), + groupUin: ProtoField(3, ScalarType.UINT32, true), + payload: ProtoField(4, ScalarType.BYTES, true), +}; + +export const SendLongMsgResp = { + result: ProtoField(2, () => SendLongMsgResult), + settings: ProtoField(15, () => LongMsgSettings), +}; + +export const SendLongMsgResult = { + resId: ProtoField(3, ScalarType.STRING), +}; + +export const SsoGetGroupMsg = { + info: ProtoField(1, () => SsoGetGroupMsgInfo), + direction: ProtoField(2, ScalarType.BOOL), +}; + +export const SsoGetGroupMsgInfo = { + groupUin: ProtoField(1, ScalarType.UINT32), + startSequence: ProtoField(2, ScalarType.UINT32), + endSequence: ProtoField(3, ScalarType.UINT32), +}; + +export const SsoGetGroupMsgResponse = { + body: ProtoField(3, () => SsoGetGroupMsgResponseBody), +}; + +export const SsoGetGroupMsgResponseBody = { + groupUin: ProtoField(3, ScalarType.UINT32), + startSequence: ProtoField(4, ScalarType.UINT32), + endSequence: ProtoField(5, ScalarType.UINT32), + messages: ProtoField(6, () => PushMsgBody, false, true), +}; + +export const SsoGetRoamMsg = { + friendUid: ProtoField(1, ScalarType.STRING, true), + time: ProtoField(2, ScalarType.UINT32), + random: ProtoField(3, ScalarType.UINT32), + count: ProtoField(4, ScalarType.UINT32), + direction: ProtoField(5, ScalarType.BOOL), +}; + +export const SsoGetRoamMsgResponse = { + friendUid: ProtoField(3, ScalarType.STRING), + timestamp: ProtoField(5, ScalarType.UINT32), + random: ProtoField(6, ScalarType.UINT32), + messages: ProtoField(7, () => PushMsgBody, false, true), +}; + +export const SsoGetC2cMsg = { + friendUid: ProtoField(2, ScalarType.STRING, true), + startSequence: ProtoField(3, ScalarType.UINT32), + endSequence: ProtoField(4, ScalarType.UINT32), +}; + +export const SsoGetC2cMsgResponse = { + friendUid: ProtoField(4, ScalarType.STRING), + messages: ProtoField(7, () => PushMsgBody, false, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/c2c.ts b/packages/snowcat-core/packet/transformer/proto/message/c2c.ts new file mode 100644 index 0000000..b272b0b --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/c2c.ts @@ -0,0 +1,10 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const C2C = { + uin: ProtoField(1, ScalarType.UINT32, true), + uid: ProtoField(2, ScalarType.STRING, true), + field3: ProtoField(3, ScalarType.UINT32, true), + sig: ProtoField(4, ScalarType.UINT32, true), + receiverUin: ProtoField(5, ScalarType.UINT32, true), + receiverUid: ProtoField(6, ScalarType.STRING, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/component.ts b/packages/snowcat-core/packet/transformer/proto/message/component.ts new file mode 100644 index 0000000..eec4b94 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/component.ts @@ -0,0 +1,165 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { Elem } from '@/snowcat-core/packet/transformer/proto'; + +export const Attr = { + codePage: ProtoField(1, ScalarType.INT32), + time: ProtoField(2, ScalarType.INT32), + random: ProtoField(3, ScalarType.INT32), + color: ProtoField(4, ScalarType.INT32), + size: ProtoField(5, ScalarType.INT32), + effect: ProtoField(6, ScalarType.INT32), + charSet: ProtoField(7, ScalarType.INT32), + pitchAndFamily: ProtoField(8, ScalarType.INT32), + fontName: ProtoField(9, ScalarType.STRING), + reserveData: ProtoField(10, ScalarType.BYTES), +}; + +export const NotOnlineFile = { + fileType: ProtoField(1, ScalarType.INT32, true), + sig: ProtoField(2, ScalarType.BYTES, true), + fileUuid: ProtoField(3, ScalarType.STRING, true), + fileMd5: ProtoField(4, ScalarType.BYTES, true), + fileName: ProtoField(5, ScalarType.STRING, true), + fileSize: ProtoField(6, ScalarType.INT64, true), + note: ProtoField(7, ScalarType.BYTES, true), + reserved: ProtoField(8, ScalarType.INT32, true), + subcmd: ProtoField(9, ScalarType.INT32, true), + microCloud: ProtoField(10, ScalarType.INT32, true), + bytesFileUrls: ProtoField(11, ScalarType.BYTES, false, true), + downloadFlag: ProtoField(12, ScalarType.INT32, true), + dangerEvel: ProtoField(50, ScalarType.INT32, true), + lifeTime: ProtoField(51, ScalarType.INT32, true), + uploadTime: ProtoField(52, ScalarType.INT32, true), + absFileType: ProtoField(53, ScalarType.INT32, true), + clientType: ProtoField(54, ScalarType.INT32, true), + expireTime: ProtoField(55, ScalarType.INT32, true), + pbReserve: ProtoField(56, ScalarType.BYTES, true), + fileHash: ProtoField(57, ScalarType.STRING, true), +}; + +export const Ptt = { + fileType: ProtoField(1, ScalarType.INT32), + srcUin: ProtoField(2, ScalarType.UINT64), + fileUuid: ProtoField(3, ScalarType.STRING), + fileMd5: ProtoField(4, ScalarType.BYTES), + fileName: ProtoField(5, ScalarType.STRING), + fileSize: ProtoField(6, ScalarType.INT32), + reserve: ProtoField(7, ScalarType.BYTES), + fileId: ProtoField(8, ScalarType.INT32), + serverIp: ProtoField(9, ScalarType.INT32), + serverPort: ProtoField(10, ScalarType.INT32), + boolValid: ProtoField(11, ScalarType.BOOL), + signature: ProtoField(12, ScalarType.BYTES), + shortcut: ProtoField(13, ScalarType.BYTES), + fileKey: ProtoField(14, ScalarType.BYTES), + magicPttIndex: ProtoField(15, ScalarType.INT32), + voiceSwitch: ProtoField(16, ScalarType.INT32), + pttUrl: ProtoField(17, ScalarType.BYTES), + groupFileKey: ProtoField(18, ScalarType.STRING), + time: ProtoField(19, ScalarType.INT32), + downPara: ProtoField(20, ScalarType.BYTES), + format: ProtoField(29, ScalarType.INT32), + pbReserve: ProtoField(30, ScalarType.BYTES), + bytesPttUrls: ProtoField(31, ScalarType.BYTES, false, true), + downloadFlag: ProtoField(32, ScalarType.INT32), +}; + +export const RichText = { + attr: ProtoField(1, () => Attr, true), + elems: ProtoField(2, () => Elem, false, true), + notOnlineFile: ProtoField(3, () => NotOnlineFile, true), + ptt: ProtoField(4, () => Ptt, true), +}; + +export const ButtonExtra = { + data: ProtoField(1, () => KeyboardData), +}; + +export const KeyboardData = { + rows: ProtoField(1, () => Row, false, true), +}; + +export const Row = { + buttons: ProtoField(1, () => Button, false, true), +}; + +export const Button = { + id: ProtoField(1, ScalarType.STRING), + renderData: ProtoField(2, () => RenderData), + action: ProtoField(3, () => Action), +}; + +export const RenderData = { + label: ProtoField(1, ScalarType.STRING), + visitedLabel: ProtoField(2, ScalarType.STRING), + style: ProtoField(3, ScalarType.INT32), +}; + +export const Action = { + type: ProtoField(1, ScalarType.INT32), + permission: ProtoField(2, () => Permission), + unsupportTips: ProtoField(4, ScalarType.STRING), + data: ProtoField(5, ScalarType.STRING), + reply: ProtoField(7, ScalarType.BOOL), + enter: ProtoField(8, ScalarType.BOOL), +}; + +export const Permission = { + type: ProtoField(1, ScalarType.INT32), + specifyRoleIds: ProtoField(2, ScalarType.STRING, false, true), + specifyUserIds: ProtoField(3, ScalarType.STRING, false, true), +}; + +export const FileExtra = { + file: ProtoField(1, () => NotOnlineFile), + field6: ProtoField(6, () => PrivateFileExtra), +}; + +export const PrivateFileExtra = { + field2: ProtoField(2, () => PrivateFileExtraField2), +}; + +export const PrivateFileExtraField2 = { + field1: ProtoField(1, ScalarType.UINT32), + fileUuid: ProtoField(4, ScalarType.STRING), + fileName: ProtoField(5, ScalarType.STRING), + field6: ProtoField(6, ScalarType.UINT32), + field7: ProtoField(7, ScalarType.BYTES), + field8: ProtoField(8, ScalarType.BYTES), + timestamp1: ProtoField(9, ScalarType.UINT32), + fileHash: ProtoField(14, ScalarType.STRING), + selfUid: ProtoField(15, ScalarType.STRING), + destUid: ProtoField(16, ScalarType.STRING), +}; + +export const GroupFileExtra = { + field1: ProtoField(1, ScalarType.UINT32), + fileName: ProtoField(2, ScalarType.STRING), + display: ProtoField(3, ScalarType.STRING), + inner: ProtoField(7, () => GroupFileExtraInner), +}; + +export const GroupFileExtraInner = { + info: ProtoField(2, () => GroupFileExtraInfo), +}; + +export const GroupFileExtraInfo = { + busId: ProtoField(1, ScalarType.UINT32), + fileId: ProtoField(2, ScalarType.STRING), + fileSize: ProtoField(3, ScalarType.UINT64), + fileName: ProtoField(4, ScalarType.STRING), + field5: ProtoField(5, ScalarType.UINT32), + fileSha: ProtoField(6, ScalarType.BYTES), + extInfoString: ProtoField(7, ScalarType.STRING), + fileMd5: ProtoField(8, ScalarType.BYTES), +}; + +export const ImageExtraUrl = { + origUrl: ProtoField(30, ScalarType.STRING), +}; + +export const PokeExtra = { + type: ProtoField(1, ScalarType.UINT32), + field7: ProtoField(7, ScalarType.UINT32), + field8: ProtoField(8, ScalarType.UINT32), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/element.ts b/packages/snowcat-core/packet/transformer/proto/message/element.ts new file mode 100644 index 0000000..77cda3b --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/element.ts @@ -0,0 +1,360 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const Elem = { + text: ProtoField(1, () => Text, true), + face: ProtoField(2, () => Face, true), + onlineImage: ProtoField(3, () => OnlineImage, true), + notOnlineImage: ProtoField(4, () => NotOnlineImage, true), + transElem: ProtoField(5, () => TransElem, true), + marketFace: ProtoField(6, () => MarketFace, true), + customFace: ProtoField(8, () => CustomFace, true), + elemFlags2: ProtoField(9, () => ElemFlags2, true), + richMsg: ProtoField(12, () => RichMsg, true), + groupFile: ProtoField(13, () => GroupFile, true), + extraInfo: ProtoField(16, () => ExtraInfo, true), + videoFile: ProtoField(19, () => VideoFile, true), + anonymousGroupMessage: ProtoField(21, () => AnonymousGroupMessage, true), + customElem: ProtoField(31, () => CustomElem, true), + generalFlags: ProtoField(37, () => GeneralFlags, true), + srcMsg: ProtoField(45, () => SrcMsg, true), + lightAppElem: ProtoField(51, () => LightAppElem, true), + commonElem: ProtoField(53, () => CommonElem, true), +}; + +export const Text = { + str: ProtoField(1, ScalarType.STRING, true), + lint: ProtoField(2, ScalarType.STRING, true), + attr6Buf: ProtoField(3, ScalarType.BYTES, true), + attr7Buf: ProtoField(4, ScalarType.BYTES, true), + buf: ProtoField(11, ScalarType.BYTES, true), + pbReserve: ProtoField(12, ScalarType.BYTES, true), +}; + +export const Face = { + index: ProtoField(1, ScalarType.INT32, true), + old: ProtoField(2, ScalarType.BYTES, true), + buf: ProtoField(11, ScalarType.BYTES, true), +}; + +export const OnlineImage = { + guid: ProtoField(1, ScalarType.BYTES), + filePath: ProtoField(2, ScalarType.BYTES), + oldVerSendFile: ProtoField(3, ScalarType.BYTES), +}; + +export const NotOnlineImage = { + filePath: ProtoField(1, ScalarType.STRING), + fileLen: ProtoField(2, ScalarType.UINT32), + downloadPath: ProtoField(3, ScalarType.STRING), + oldVerSendFile: ProtoField(4, ScalarType.BYTES), + imgType: ProtoField(5, ScalarType.INT32), + previewsImage: ProtoField(6, ScalarType.BYTES), + picMd5: ProtoField(7, ScalarType.BYTES), + picHeight: ProtoField(8, ScalarType.UINT32), + picWidth: ProtoField(9, ScalarType.UINT32), + resId: ProtoField(10, ScalarType.STRING), + flag: ProtoField(11, ScalarType.BYTES), + thumbUrl: ProtoField(12, ScalarType.STRING), + original: ProtoField(13, ScalarType.INT32), + bigUrl: ProtoField(14, ScalarType.STRING), + origUrl: ProtoField(15, ScalarType.STRING), + bizType: ProtoField(16, ScalarType.INT32), + result: ProtoField(17, ScalarType.INT32), + index: ProtoField(18, ScalarType.INT32), + opFaceBuf: ProtoField(19, ScalarType.BYTES), + oldPicMd5: ProtoField(20, ScalarType.BOOL), + thumbWidth: ProtoField(21, ScalarType.INT32), + thumbHeight: ProtoField(22, ScalarType.INT32), + fileId: ProtoField(23, ScalarType.INT32), + showLen: ProtoField(24, ScalarType.UINT32), + downloadLen: ProtoField(25, ScalarType.UINT32), + x400Url: ProtoField(26, ScalarType.STRING), + x400Width: ProtoField(27, ScalarType.INT32), + x400Height: ProtoField(28, ScalarType.INT32), + pbRes: ProtoField(29, () => NotOnlineImage_PbReserve), +}; + +export const NotOnlineImage_PbReserve = { + subType: ProtoField(1, ScalarType.INT32), + field3: ProtoField(3, ScalarType.INT32), + field4: ProtoField(4, ScalarType.INT32), + summary: ProtoField(8, ScalarType.STRING), + field10: ProtoField(10, ScalarType.INT32), + field20: ProtoField(20, () => NotOnlineImage_PbReserve2), + url: ProtoField(30, ScalarType.STRING), + md5Str: ProtoField(31, ScalarType.STRING), +}; + +export const NotOnlineImage_PbReserve2 = { + field1: ProtoField(1, ScalarType.INT32), + field2: ProtoField(2, ScalarType.STRING), + field3: ProtoField(3, ScalarType.INT32), + field4: ProtoField(4, ScalarType.INT32), + field5: ProtoField(5, ScalarType.INT32), + field7: ProtoField(7, ScalarType.STRING), +}; + +export const TransElem = { + elemType: ProtoField(1, ScalarType.INT32), + elemValue: ProtoField(2, ScalarType.BYTES), +}; + +export const MarketFace = { + faceName: ProtoField(1, ScalarType.STRING), + itemType: ProtoField(2, ScalarType.INT32), + faceInfo: ProtoField(3, ScalarType.INT32), + faceId: ProtoField(4, ScalarType.BYTES), + tabId: ProtoField(5, ScalarType.INT32), + subType: ProtoField(6, ScalarType.INT32), + key: ProtoField(7, ScalarType.STRING), + param: ProtoField(8, ScalarType.BYTES), + mediaType: ProtoField(9, ScalarType.INT32), + imageWidth: ProtoField(10, ScalarType.INT32), + imageHeight: ProtoField(11, ScalarType.INT32), + mobileparam: ProtoField(12, ScalarType.BYTES), + pbReserve: ProtoField(13, () => MarketFacePbRes), +}; + +export const MarketFacePbRes = { + field8: ProtoField(8, ScalarType.INT32), +}; + +export const CustomFace = { + guid: ProtoField(1, ScalarType.BYTES), + filePath: ProtoField(2, ScalarType.STRING), + shortcut: ProtoField(3, ScalarType.STRING), + buffer: ProtoField(4, ScalarType.BYTES), + flag: ProtoField(5, ScalarType.BYTES), + oldData: ProtoField(6, ScalarType.BYTES, true), + fileId: ProtoField(7, ScalarType.UINT32), + serverIp: ProtoField(8, ScalarType.INT32, true), + serverPort: ProtoField(9, ScalarType.INT32, true), + fileType: ProtoField(10, ScalarType.INT32), + signature: ProtoField(11, ScalarType.BYTES), + useful: ProtoField(12, ScalarType.INT32), + md5: ProtoField(13, ScalarType.BYTES), + thumbUrl: ProtoField(14, ScalarType.STRING), + bigUrl: ProtoField(15, ScalarType.STRING), + origUrl: ProtoField(16, ScalarType.STRING), + bizType: ProtoField(17, ScalarType.INT32), + repeatIndex: ProtoField(18, ScalarType.INT32), + repeatImage: ProtoField(19, ScalarType.INT32), + imageType: ProtoField(20, ScalarType.INT32), + index: ProtoField(21, ScalarType.INT32), + width: ProtoField(22, ScalarType.INT32), + height: ProtoField(23, ScalarType.INT32), + source: ProtoField(24, ScalarType.INT32), + size: ProtoField(25, ScalarType.UINT32), + origin: ProtoField(26, ScalarType.INT32), + thumbWidth: ProtoField(27, ScalarType.INT32, true), + thumbHeight: ProtoField(28, ScalarType.INT32, true), + showLen: ProtoField(29, ScalarType.INT32), + downloadLen: ProtoField(30, ScalarType.INT32), + x400Url: ProtoField(31, ScalarType.STRING, true), + x400Width: ProtoField(32, ScalarType.INT32), + x400Height: ProtoField(33, ScalarType.INT32), + pbRes: ProtoField(34, () => CustomFace_PbReserve, true), +}; + +export const CustomFace_PbReserve = { + subType: ProtoField(1, ScalarType.INT32), + summary: ProtoField(9, ScalarType.STRING), +}; + +export const ElemFlags2 = { + colorTextId: ProtoField(1, ScalarType.UINT32), + msgId: ProtoField(2, ScalarType.UINT64), + whisperSessionId: ProtoField(3, ScalarType.UINT32), + pttChangeBit: ProtoField(4, ScalarType.UINT32), + vipStatus: ProtoField(5, ScalarType.UINT32), + compatibleId: ProtoField(6, ScalarType.UINT32), + insts: ProtoField(7, () => Instance, false, true), + msgRptCnt: ProtoField(8, ScalarType.UINT32), + srcInst: ProtoField(9, () => Instance), + longtitude: ProtoField(10, ScalarType.UINT32), + latitude: ProtoField(11, ScalarType.UINT32), + customFont: ProtoField(12, ScalarType.UINT32), + pcSupportDef: ProtoField(13, () => PcSupportDef), + crmFlags: ProtoField(14, ScalarType.UINT32, true), +}; + +export const PcSupportDef = { + pcPtlBegin: ProtoField(1, ScalarType.UINT32), + pcPtlEnd: ProtoField(2, ScalarType.UINT32), + macPtlBegin: ProtoField(3, ScalarType.UINT32), + macPtlEnd: ProtoField(4, ScalarType.UINT32), + ptlsSupport: ProtoField(5, ScalarType.INT32, false, true), + ptlsNotSupport: ProtoField(6, ScalarType.UINT32, false, true), +}; + +export const Instance = { + appId: ProtoField(1, ScalarType.UINT32), + instId: ProtoField(2, ScalarType.UINT32), +}; + +export const RichMsg = { + template1: ProtoField(1, ScalarType.BYTES, true), + serviceId: ProtoField(2, ScalarType.INT32, true), + msgResId: ProtoField(3, ScalarType.BYTES, true), + rand: ProtoField(4, ScalarType.INT32, true), + seq: ProtoField(5, ScalarType.UINT32, true), +}; + +export const GroupFile = { + filename: ProtoField(1, ScalarType.BYTES), + fileSize: ProtoField(2, ScalarType.UINT64), + fileId: ProtoField(3, ScalarType.BYTES), + batchId: ProtoField(4, ScalarType.BYTES), + fileKey: ProtoField(5, ScalarType.BYTES), + mark: ProtoField(6, ScalarType.BYTES), + sequence: ProtoField(7, ScalarType.UINT64), + batchItemId: ProtoField(8, ScalarType.BYTES), + feedMsgTime: ProtoField(9, ScalarType.INT32), + pbReserve: ProtoField(10, ScalarType.BYTES), +}; + +export const ExtraInfo = { + nick: ProtoField(1, ScalarType.BYTES), + groupCard: ProtoField(2, ScalarType.BYTES), + level: ProtoField(3, ScalarType.INT32), + flags: ProtoField(4, ScalarType.INT32), + groupMask: ProtoField(5, ScalarType.INT32), + msgTailId: ProtoField(6, ScalarType.INT32), + senderTitle: ProtoField(7, ScalarType.BYTES), + apnsTips: ProtoField(8, ScalarType.BYTES), + uin: ProtoField(9, ScalarType.UINT64), + msgStateFlag: ProtoField(10, ScalarType.INT32), + apnsSoundType: ProtoField(11, ScalarType.INT32), + newGroupFlag: ProtoField(12, ScalarType.INT32), +}; + +export const VideoFile = { + fileUuid: ProtoField(1, ScalarType.STRING), + fileMd5: ProtoField(2, ScalarType.BYTES), + fileName: ProtoField(3, ScalarType.STRING), + fileFormat: ProtoField(4, ScalarType.INT32), + fileTime: ProtoField(5, ScalarType.INT32), + fileSize: ProtoField(6, ScalarType.INT32), + thumbWidth: ProtoField(7, ScalarType.INT32), + thumbHeight: ProtoField(8, ScalarType.INT32), + thumbFileMd5: ProtoField(9, ScalarType.BYTES), + source: ProtoField(10, ScalarType.BYTES), + thumbFileSize: ProtoField(11, ScalarType.INT32), + busiType: ProtoField(12, ScalarType.INT32), + fromChatType: ProtoField(13, ScalarType.INT32), + toChatType: ProtoField(14, ScalarType.INT32), + boolSupportProgressive: ProtoField(15, ScalarType.BOOL), + fileWidth: ProtoField(16, ScalarType.INT32), + fileHeight: ProtoField(17, ScalarType.INT32), + subBusiType: ProtoField(18, ScalarType.INT32), + videoAttr: ProtoField(19, ScalarType.INT32), + bytesThumbFileUrls: ProtoField(20, ScalarType.BYTES, false, true), + bytesVideoFileUrls: ProtoField(21, ScalarType.BYTES, false, true), + thumbDownloadFlag: ProtoField(22, ScalarType.INT32), + videoDownloadFlag: ProtoField(23, ScalarType.INT32), + pbReserve: ProtoField(24, ScalarType.BYTES), +}; + +export const AnonymousGroupMessage = { + flags: ProtoField(1, ScalarType.INT32), + anonId: ProtoField(2, ScalarType.BYTES), + anonNick: ProtoField(3, ScalarType.BYTES), + headPortrait: ProtoField(4, ScalarType.INT32), + expireTime: ProtoField(5, ScalarType.INT32), + bubbleId: ProtoField(6, ScalarType.INT32), + rankColor: ProtoField(7, ScalarType.BYTES), +}; + +export const CustomElem = { + desc: ProtoField(1, ScalarType.BYTES), + data: ProtoField(2, ScalarType.BYTES), + enumType: ProtoField(3, ScalarType.INT32), + ext: ProtoField(4, ScalarType.BYTES), + sound: ProtoField(5, ScalarType.BYTES), +}; + +export const GeneralFlags = { + bubbleDiyTextId: ProtoField(1, ScalarType.INT32), + groupFlagNew: ProtoField(2, ScalarType.INT32), + uin: ProtoField(3, ScalarType.UINT64), + rpId: ProtoField(4, ScalarType.BYTES), + prpFold: ProtoField(5, ScalarType.INT32), + longTextFlag: ProtoField(6, ScalarType.INT32), + longTextResId: ProtoField(7, ScalarType.STRING, true), + groupType: ProtoField(8, ScalarType.INT32), + toUinFlag: ProtoField(9, ScalarType.INT32), + glamourLevel: ProtoField(10, ScalarType.INT32), + memberLevel: ProtoField(11, ScalarType.INT32), + groupRankSeq: ProtoField(12, ScalarType.UINT64), + olympicTorch: ProtoField(13, ScalarType.INT32), + babyqGuideMsgCookie: ProtoField(14, ScalarType.BYTES), + uin32ExpertFlag: ProtoField(15, ScalarType.INT32), + bubbleSubId: ProtoField(16, ScalarType.INT32), + pendantId: ProtoField(17, ScalarType.UINT64), + rpIndex: ProtoField(18, ScalarType.BYTES), + pbReserve: ProtoField(19, ScalarType.BYTES), +}; + +export const SrcMsg = { + origSeqs: ProtoField(1, ScalarType.UINT32, false, true), + senderUin: ProtoField(2, ScalarType.UINT64), + time: ProtoField(3, ScalarType.INT32, true), + flag: ProtoField(4, ScalarType.INT32, true), + elems: ProtoField(5, () => Elem, false, true), + type: ProtoField(6, ScalarType.INT32, true), + richMsg: ProtoField(7, ScalarType.BYTES, true), + pbReserve: ProtoField(8, () => SrcMsgPbRes, true), + sourceMsg: ProtoField(9, ScalarType.BYTES, true), + toUin: ProtoField(10, ScalarType.UINT64, true), + troopName: ProtoField(11, ScalarType.BYTES, true), +}; + +export const SrcMsgPbRes = { + messageId: ProtoField(3, ScalarType.UINT64), + senderUid: ProtoField(6, ScalarType.STRING, true), + receiverUid: ProtoField(7, ScalarType.STRING, true), + friendSeq: ProtoField(8, ScalarType.UINT32, true), +}; + +export const LightAppElem = { + data: ProtoField(1, ScalarType.BYTES), + msgResid: ProtoField(2, ScalarType.BYTES, true), +}; + +export const CommonElem = { + serviceType: ProtoField(1, ScalarType.INT32), + pbElem: ProtoField(2, ScalarType.BYTES), + businessType: ProtoField(3, ScalarType.UINT32), +}; + +export const FaceExtra = { + faceId: ProtoField(1, ScalarType.INT32, true), +}; + +export const MentionExtra = { + type: ProtoField(3, ScalarType.INT32, true), + uin: ProtoField(4, ScalarType.UINT32, true), + field5: ProtoField(5, ScalarType.INT32, true), + uid: ProtoField(9, ScalarType.STRING, true), +}; + +export const QBigFaceExtra = { + AniStickerPackId: ProtoField(1, ScalarType.STRING, true), + AniStickerId: ProtoField(2, ScalarType.STRING, true), + faceId: ProtoField(3, ScalarType.INT32, true), + sourceType: ProtoField(4, ScalarType.INT32, true), + AniStickerType: ProtoField(5, ScalarType.INT32, true), + resultId: ProtoField(6, ScalarType.STRING, true), + preview: ProtoField(7, ScalarType.STRING, true), + randomType: ProtoField(9, ScalarType.INT32, true), +}; + +export const QSmallFaceExtra = { + faceId: ProtoField(1, ScalarType.UINT32), + preview: ProtoField(2, ScalarType.STRING), + preview2: ProtoField(3, ScalarType.STRING), +}; + +export const MarkdownData = { + content: ProtoField(1, ScalarType.STRING), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/group.ts b/packages/snowcat-core/packet/transformer/proto/message/group.ts new file mode 100644 index 0000000..5e5b8be --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/group.ts @@ -0,0 +1,18 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const GroupRecallMsg = { + type: ProtoField(1, ScalarType.UINT32), + groupUin: ProtoField(2, ScalarType.UINT32), + field3: ProtoField(3, () => GroupRecallMsgField3), + field4: ProtoField(4, () => GroupRecallMsgField4), +}; + +export const GroupRecallMsgField3 = { + sequence: ProtoField(1, ScalarType.UINT32), + random: ProtoField(2, ScalarType.UINT32), + field3: ProtoField(3, ScalarType.UINT32), +}; + +export const GroupRecallMsgField4 = { + field1: ProtoField(1, ScalarType.UINT32), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/groupAdmin.ts b/packages/snowcat-core/packet/transformer/proto/message/groupAdmin.ts new file mode 100644 index 0000000..f21e67b --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/groupAdmin.ts @@ -0,0 +1,18 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const GroupAdminExtra = { + adminUid: ProtoField(1, ScalarType.STRING), + isPromote: ProtoField(2, ScalarType.BOOL), +}; + +export const GroupAdminBody = { + extraDisable: ProtoField(1, () => GroupAdminExtra), + extraEnable: ProtoField(2, () => GroupAdminExtra), +}; + +export const GroupAdmin = { + groupUin: ProtoField(1, ScalarType.UINT32), + flag: ProtoField(2, ScalarType.UINT32), + isPromote: ProtoField(3, ScalarType.BOOL), + body: ProtoField(4, () => GroupAdminBody), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/message.ts b/packages/snowcat-core/packet/transformer/proto/message/message.ts new file mode 100644 index 0000000..d5a819e --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/message.ts @@ -0,0 +1,138 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { + C2C, + ForwardHead, + Grp, + GrpTmp, + ResponseForward, + ResponseGrp, RichText, + Trans0X211, + WPATmp, +} from '@/snowcat-core/packet/transformer/proto'; + +export const ContentHead = { + type: ProtoField(1, ScalarType.UINT32), + subType: ProtoField(2, ScalarType.UINT32, true), + c2cCmd: ProtoField(3, ScalarType.UINT32, true), + ranDom: ProtoField(4, ScalarType.UINT32, true), + sequence: ProtoField(5, ScalarType.UINT32, true), + timeStamp: ProtoField(6, ScalarType.UINT32, true), + pkgNum: ProtoField(7, ScalarType.UINT64, true), + pkgIndex: ProtoField(8, ScalarType.UINT32, true), + divSeq: ProtoField(9, ScalarType.UINT32, true), + autoReply: ProtoField(10, ScalarType.UINT32), + ntMsgSeq: ProtoField(10, ScalarType.UINT32, true), + newId: ProtoField(12, ScalarType.UINT64, true), + forward: ProtoField(15, () => ForwardHead, true), +}; + +export const MessageBody = { + richText: ProtoField(1, () => RichText, true), + msgContent: ProtoField(2, ScalarType.BYTES, true), + msgEncryptContent: ProtoField(3, ScalarType.BYTES, true), +}; + +export const Message = { + routingHead: ProtoField(1, () => RoutingHead, true), + contentHead: ProtoField(2, () => ContentHead, true), + body: ProtoField(3, () => MessageBody, true), + clientSequence: ProtoField(4, ScalarType.UINT32, true), + random: ProtoField(5, ScalarType.UINT32, true), + syncCookie: ProtoField(6, ScalarType.BYTES, true), + via: ProtoField(8, ScalarType.UINT32, true), + dataStatist: ProtoField(9, ScalarType.UINT32, true), + ctrl: ProtoField(12, () => MessageControl, true), + multiSendSeq: ProtoField(14, ScalarType.UINT32), +}; + +export const MessageControl = { + msgFlag: ProtoField(1, ScalarType.INT32), +}; + +export const PushMsg = { + message: ProtoField(1, () => PushMsgBody), + status: ProtoField(3, ScalarType.INT32, true), + pingFlag: ProtoField(5, ScalarType.INT32, true), + generalFlag: ProtoField(9, ScalarType.INT32, true), +}; + +export const GroupChangeInfo = { + operator: ProtoField(1, () => GroupChangeOperator, true), +}; + +export const GroupChangeOperator = { + operatorUid: ProtoField(1, ScalarType.STRING, true), +}; + +export const GroupChange = { + groupUin: ProtoField(1, ScalarType.UINT32), + flag: ProtoField(2, ScalarType.UINT32), + memberUid: ProtoField(3, ScalarType.STRING, true), + decreaseType: ProtoField(4, ScalarType.UINT32), + operatorInfo: ProtoField(5, ScalarType.BYTES, true), + increaseType: ProtoField(6, ScalarType.UINT32), + field7: ProtoField(7, ScalarType.BYTES, true), +}; + +// Group Reaction Related +export const GroupReactionDataInnerDataTarget = { + seq: ProtoField(1, ScalarType.UINT64, true), +}; + +export const GroupReactionDataContent = { + code: ProtoField(1, ScalarType.STRING, true), + count: ProtoField(3, ScalarType.UINT32, true), + operatorUid: ProtoField(4, ScalarType.STRING, true), + type: ProtoField(5, ScalarType.UINT32, true), +}; + +export const GroupReactionDataInnerData = { + groupReactionTarget: ProtoField(2, () => GroupReactionDataInnerDataTarget, true), + groupReactionDataContent: ProtoField(3, () => GroupReactionDataContent, true), +}; +export const GroupReactionDataInner = { + data: ProtoField(1, () => GroupReactionDataInnerData, true), +}; +export const GroupReactionData = { + data: ProtoField(1, () => GroupReactionDataInner, true), +}; + +export const GroupReactNotify = { + groupUin: ProtoField(4, ScalarType.UINT64, true), + field13: ProtoField(13, ScalarType.UINT32, true), + groupReactionData: ProtoField(44, () => GroupReactionData, true), +}; + +// Group Invite Related +export const GroupInvite = { + groupUin: ProtoField(1, ScalarType.UINT32), + field2: ProtoField(2, ScalarType.UINT32), + field3: ProtoField(2, ScalarType.UINT32), + field4: ProtoField(2, ScalarType.UINT32), + invitorUid: ProtoField(5, ScalarType.STRING), +}; + +export const PushMsgBody = { + responseHead: ProtoField(1, () => ResponseHead), + contentHead: ProtoField(2, () => ContentHead), + body: ProtoField(3, () => MessageBody, true), +}; + +export const ResponseHead = { + fromUin: ProtoField(1, ScalarType.UINT32), + fromUid: ProtoField(2, ScalarType.STRING, true), + type: ProtoField(3, ScalarType.UINT32), + sigMap: ProtoField(4, ScalarType.UINT32), + toUin: ProtoField(5, ScalarType.UINT32), + toUid: ProtoField(6, ScalarType.STRING, true), + forward: ProtoField(7, () => ResponseForward, true), + grp: ProtoField(8, () => ResponseGrp, true), +}; + +export const RoutingHead = { + c2c: ProtoField(1, () => C2C, true), + grp: ProtoField(2, () => Grp, true), + grpTmp: ProtoField(3, () => GrpTmp, true), + wpaTmp: ProtoField(6, () => WPATmp, true), + trans0X211: ProtoField(15, () => Trans0X211, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/notify.ts b/packages/snowcat-core/packet/transformer/proto/message/notify.ts new file mode 100644 index 0000000..9225cff --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/notify.ts @@ -0,0 +1,21 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const FriendRecall = { + info: ProtoField(1, () => FriendRecallInfo), + instId: ProtoField(2, ScalarType.UINT32), + appId: ProtoField(3, ScalarType.UINT32), + longMessageFlag: ProtoField(4, ScalarType.UINT32), + reserved: ProtoField(5, ScalarType.BYTES), +}; + +export const FriendRecallInfo = { + fromUid: ProtoField(1, ScalarType.STRING), + toUid: ProtoField(2, ScalarType.STRING), + sequence: ProtoField(3, ScalarType.UINT32), + newId: ProtoField(4, ScalarType.UINT64), + time: ProtoField(5, ScalarType.UINT32), + random: ProtoField(6, ScalarType.UINT32), + pkgNum: ProtoField(7, ScalarType.UINT32), + pkgIndex: ProtoField(8, ScalarType.UINT32), + divSeq: ProtoField(9, ScalarType.UINT32), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/message/routing.ts b/packages/snowcat-core/packet/transformer/proto/message/routing.ts new file mode 100644 index 0000000..0756a5d --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/message/routing.ts @@ -0,0 +1,40 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const ForwardHead = { + field1: ProtoField(1, ScalarType.UINT32, true), + field2: ProtoField(2, ScalarType.UINT32, true), + field3: ProtoField(3, ScalarType.UINT32, true), + unknownBase64: ProtoField(5, ScalarType.STRING, true), + avatar: ProtoField(6, ScalarType.STRING, true), +}; + +export const Grp = { + groupCode: ProtoField(1, ScalarType.UINT32, true), +}; + +export const GrpTmp = { + groupUin: ProtoField(1, ScalarType.UINT32, true), + toUin: ProtoField(2, ScalarType.UINT32, true), +}; + +export const ResponseForward = { + friendName: ProtoField(6, ScalarType.STRING, true), +}; + +export const ResponseGrp = { + groupUin: ProtoField(1, ScalarType.UINT32), + memberName: ProtoField(4, ScalarType.STRING), + unknown5: ProtoField(5, ScalarType.UINT32), + groupName: ProtoField(7, ScalarType.STRING), +}; + +export const Trans0X211 = { + toUin: ProtoField(1, ScalarType.UINT64, true), + ccCmd: ProtoField(2, ScalarType.UINT32, true), + uid: ProtoField(8, ScalarType.STRING, true), +}; + +export const WPATmp = { + toUin: ProtoField(1, ScalarType.UINT64), + sig: ProtoField(2, ScalarType.BYTES), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/misc/fileid.ts b/packages/snowcat-core/packet/transformer/proto/misc/fileid.ts new file mode 100644 index 0000000..bcbb67b --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/misc/fileid.ts @@ -0,0 +1,6 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const FileId = { + appid: ProtoField(4, ScalarType.UINT32, true), + ttl: ProtoField(10, ScalarType.UINT32, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0XE37_800.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0XE37_800.ts new file mode 100644 index 0000000..f098f92 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0XE37_800.ts @@ -0,0 +1,61 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { OidbSvcTrpcTcp0XE37_800_1200Metadata } from '@/snowcat-core/packet/transformer/proto'; + +export const OidbSvcTrpcTcp0XE37_800 = { + subCommand: ProtoField(1, ScalarType.UINT32), + field2: ProtoField(2, ScalarType.INT32), + body: ProtoField(10, () => OidbSvcTrpcTcp0XE37_800Body, true), + field101: ProtoField(101, ScalarType.INT32), + field102: ProtoField(102, ScalarType.INT32), + field200: ProtoField(200, ScalarType.INT32), +}; + +export const OidbSvcTrpcTcp0XE37_800Body = { + senderUid: ProtoField(10, ScalarType.STRING, true), + receiverUid: ProtoField(20, ScalarType.STRING, true), + fileUuid: ProtoField(30, ScalarType.STRING, true), + fileHash: ProtoField(40, ScalarType.STRING, true), +}; + +export const OidbSvcTrpcTcp0XE37Response = { + command: ProtoField(1, ScalarType.UINT32), + seq: ProtoField(2, ScalarType.INT32), + upload: ProtoField(19, () => ApplyUploadRespV3, true), + businessId: ProtoField(101, ScalarType.INT32), + clientType: ProtoField(102, ScalarType.INT32), + flagSupportMediaPlatform: ProtoField(200, ScalarType.INT32), +}; + +export const ApplyUploadRespV3 = { + retCode: ProtoField(10, ScalarType.INT32), + retMsg: ProtoField(20, ScalarType.STRING, true), + totalSpace: ProtoField(30, ScalarType.INT64), + usedSpace: ProtoField(40, ScalarType.INT64), + uploadedSize: ProtoField(50, ScalarType.INT64), + uploadIp: ProtoField(60, ScalarType.STRING, true), + uploadDomain: ProtoField(70, ScalarType.STRING, true), + uploadPort: ProtoField(80, ScalarType.UINT32), + uuid: ProtoField(90, ScalarType.STRING, true), + uploadKey: ProtoField(100, ScalarType.BYTES, true), + boolFileExist: ProtoField(110, ScalarType.BOOL), + packSize: ProtoField(120, ScalarType.INT32), + uploadIpList: ProtoField(130, ScalarType.STRING, false, true), // repeated + uploadHttpsPort: ProtoField(140, ScalarType.INT32), + uploadHttpsDomain: ProtoField(150, ScalarType.STRING, true), + uploadDns: ProtoField(160, ScalarType.STRING, true), + uploadLanip: ProtoField(170, ScalarType.STRING, true), + fileAddon: ProtoField(200, ScalarType.STRING, true), + mediaPlatformUploadKey: ProtoField(220, ScalarType.BYTES, true), +}; + +export const OidbSvcTrpcTcp0XE37_800Response = { + command: ProtoField(1, ScalarType.UINT32, true), + subCommand: ProtoField(2, ScalarType.UINT32, true), + body: ProtoField(10, () => OidbSvcTrpcTcp0XE37_800ResponseBody, true), + field50: ProtoField(50, ScalarType.UINT32, true), +}; + +export const OidbSvcTrpcTcp0XE37_800ResponseBody = { + field10: ProtoField(10, ScalarType.UINT32, true), + field30: ProtoField(30, () => OidbSvcTrpcTcp0XE37_800_1200Metadata, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0XFE1_2.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0XFE1_2.ts new file mode 100644 index 0000000..910f8a1 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0XFE1_2.ts @@ -0,0 +1,22 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0XFE1_2 = { + uin: ProtoField(1, ScalarType.UINT32), + key: ProtoField(3, () => OidbSvcTrpcTcp0XFE1_2Key, false, true), +}; + +export const OidbSvcTrpcTcp0XFE1_2Key = { + key: ProtoField(1, ScalarType.UINT32), +}; +export const OidbSvcTrpcTcp0XFE1_2RSP_Status = { + key: ProtoField(1, ScalarType.UINT32), + value: ProtoField(2, ScalarType.UINT64), +}; + +export const OidbSvcTrpcTcp0XFE1_2RSP_Data = { + status: ProtoField(2, () => OidbSvcTrpcTcp0XFE1_2RSP_Status), +}; + +export const OidbSvcTrpcTcp0XFE1_2RSP = { + data: ProtoField(1, () => OidbSvcTrpcTcp0XFE1_2RSP_Data), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x6D6.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x6D6.ts new file mode 100644 index 0000000..159702f --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x6D6.ts @@ -0,0 +1,99 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0x6D6 = { + file: ProtoField(1, () => OidbSvcTrpcTcp0x6D6Upload, true), + download: ProtoField(3, () => OidbSvcTrpcTcp0x6D6Download, true), + delete: ProtoField(4, () => OidbSvcTrpcTcp0x6D6Delete, true), + rename: ProtoField(5, () => OidbSvcTrpcTcp0x6D6Rename, true), + move: ProtoField(6, () => OidbSvcTrpcTcp0x6D6Move, true), +}; + +export const OidbSvcTrpcTcp0x6D6Upload = { + groupUin: ProtoField(1, ScalarType.UINT32), + appId: ProtoField(2, ScalarType.UINT32), + busId: ProtoField(3, ScalarType.UINT32), + entrance: ProtoField(4, ScalarType.UINT32), + targetDirectory: ProtoField(5, ScalarType.STRING), + fileName: ProtoField(6, ScalarType.STRING), + localDirectory: ProtoField(7, ScalarType.STRING), + fileSize: ProtoField(8, ScalarType.UINT64), + fileSha1: ProtoField(9, ScalarType.BYTES), + fileSha3: ProtoField(10, ScalarType.BYTES), + fileMd5: ProtoField(11, ScalarType.BYTES), + field15: ProtoField(15, ScalarType.BOOL), +}; + +export const OidbSvcTrpcTcp0x6D6Download = { + groupUin: ProtoField(1, ScalarType.UINT32), + appId: ProtoField(2, ScalarType.UINT32), + busId: ProtoField(3, ScalarType.UINT32), + fileId: ProtoField(4, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0x6D6Delete = { + groupUin: ProtoField(1, ScalarType.UINT32), + busId: ProtoField(3, ScalarType.UINT32), + fileId: ProtoField(5, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0x6D6Rename = { + groupUin: ProtoField(1, ScalarType.UINT32), + busId: ProtoField(3, ScalarType.UINT32), + fileId: ProtoField(4, ScalarType.STRING), + parentFolder: ProtoField(5, ScalarType.STRING), + newFileName: ProtoField(6, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0x6D6Move = { + groupUin: ProtoField(1, ScalarType.UINT32), + appId: ProtoField(2, ScalarType.UINT32), + busId: ProtoField(3, ScalarType.UINT32), + fileId: ProtoField(4, ScalarType.STRING), + parentDirectory: ProtoField(5, ScalarType.STRING), + targetDirectory: ProtoField(6, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0x6D6Response = { + upload: ProtoField(1, () => OidbSvcTrpcTcp0x6D6_0Response), + download: ProtoField(3, () => OidbSvcTrpcTcp0x6D6_2Response), + delete: ProtoField(4, () => OidbSvcTrpcTcp0x6D6_3_4_5Response), + rename: ProtoField(5, () => OidbSvcTrpcTcp0x6D6_3_4_5Response), + move: ProtoField(6, () => OidbSvcTrpcTcp0x6D6_3_4_5Response), +}; + +export const OidbSvcTrpcTcp0x6D6_0Response = { + retCode: ProtoField(1, ScalarType.INT32), + retMsg: ProtoField(2, ScalarType.STRING), + clientWording: ProtoField(3, ScalarType.STRING), + uploadIp: ProtoField(4, ScalarType.STRING), + serverDns: ProtoField(5, ScalarType.STRING), + busId: ProtoField(6, ScalarType.INT32), + fileId: ProtoField(7, ScalarType.STRING), + checkKey: ProtoField(8, ScalarType.BYTES), + fileKey: ProtoField(9, ScalarType.BYTES), + boolFileExist: ProtoField(10, ScalarType.BOOL), + uploadIpLanV4: ProtoField(12, ScalarType.STRING, false, true), + uploadIpLanV6: ProtoField(13, ScalarType.STRING, false, true), + uploadPort: ProtoField(14, ScalarType.UINT32), +}; + +export const OidbSvcTrpcTcp0x6D6_2Response = { + retCode: ProtoField(1, ScalarType.INT32), + retMsg: ProtoField(2, ScalarType.STRING), + clientWording: ProtoField(3, ScalarType.STRING), + downloadIp: ProtoField(4, ScalarType.STRING), + downloadDns: ProtoField(5, ScalarType.STRING), + downloadUrl: ProtoField(6, ScalarType.BYTES), + fileSha1: ProtoField(7, ScalarType.BYTES), + fileSha3: ProtoField(8, ScalarType.BYTES), + fileMd5: ProtoField(9, ScalarType.BYTES), + cookieVal: ProtoField(10, ScalarType.BYTES), + saveFileName: ProtoField(11, ScalarType.STRING), + previewPort: ProtoField(12, ScalarType.UINT32), +}; + +export const OidbSvcTrpcTcp0x6D6_3_4_5Response = { + retCode: ProtoField(1, ScalarType.INT32), + retMsg: ProtoField(2, ScalarType.STRING), + clientWording: ProtoField(3, ScalarType.STRING), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x8FC_2.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x8FC_2.ts new file mode 100644 index 0000000..c14af43 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x8FC_2.ts @@ -0,0 +1,14 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +// 设置群头衔 OidbSvcTrpcTcp.0x8fc_2 +export const OidbSvcTrpcTcp0X8FC_2_Body = { + targetUid: ProtoField(1, ScalarType.STRING), + specialTitle: ProtoField(5, ScalarType.STRING, true), + expiredTime: ProtoField(6, ScalarType.INT32), + uinName: ProtoField(7, ScalarType.STRING, true), + targetName: ProtoField(8, ScalarType.STRING), +}; +export const OidbSvcTrpcTcp0X8FC_2 = { + groupUin: ProtoField(1, ScalarType.UINT32), + body: ProtoField(3, () => OidbSvcTrpcTcp0X8FC_2_Body), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x9067_202.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x9067_202.ts new file mode 100644 index 0000000..e2e2a7d --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x9067_202.ts @@ -0,0 +1,26 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { MultiMediaReqHead } from './common/Ntv2.RichMediaReq'; + +// Req +export const OidbSvcTrpcTcp0X9067_202 = { + ReqHead: ProtoField(1, () => MultiMediaReqHead), + DownloadRKeyReq: ProtoField(4, () => OidbSvcTrpcTcp0X9067_202Key), +}; +export const OidbSvcTrpcTcp0X9067_202Key = { + key: ProtoField(1, ScalarType.INT32, false, true), +}; + +// Rsp +export const OidbSvcTrpcTcp0X9067_202_RkeyList = { + rkey: ProtoField(1, ScalarType.STRING), + ttl: ProtoField(2, ScalarType.UINT64), + time: ProtoField(4, ScalarType.UINT32), + type: ProtoField(5, ScalarType.UINT32), + +}; +export const OidbSvcTrpcTcp0X9067_202_Data = { + rkeyList: ProtoField(1, () => OidbSvcTrpcTcp0X9067_202_RkeyList, false, true), +}; +export const OidbSvcTrpcTcp0X9067_202_Rsp_Body = { + data: ProtoField(4, () => OidbSvcTrpcTcp0X9067_202_Data), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x929.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x929.ts new file mode 100644 index 0000000..acc76ef --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0x929.ts @@ -0,0 +1,41 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { MsgInfo } from '@/snowcat-core/packet/transformer/proto'; + +export const OidbSvcTrpcTcp0X929D_0 = { + groupUin: ProtoField(1, ScalarType.UINT32), + chatType: ProtoField(2, ScalarType.UINT32), +}; + +export const OidbSvcTrpcTcp0X929D_0Resp = { + content: ProtoField(1, () => OidbSvcTrpcTcp0X929D_0RespContent, false, true), +}; + +export const OidbSvcTrpcTcp0X929D_0RespContent = { + category: ProtoField(1, ScalarType.STRING), + voices: ProtoField(2, () => OidbSvcTrpcTcp0X929D_0RespContentVoice, false, true), +}; + +export const OidbSvcTrpcTcp0X929D_0RespContentVoice = { + voiceId: ProtoField(1, ScalarType.STRING), + voiceDisplayName: ProtoField(2, ScalarType.STRING), + voiceExampleUrl: ProtoField(3, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0X929B_0 = { + groupUin: ProtoField(1, ScalarType.UINT32), + voiceId: ProtoField(2, ScalarType.STRING), + text: ProtoField(3, ScalarType.STRING), + chatType: ProtoField(4, ScalarType.UINT32), + session: ProtoField(5, () => OidbSvcTrpcTcp0X929B_0_Session), +}; + +export const OidbSvcTrpcTcp0X929B_0_Session = { + sessionId: ProtoField(1, ScalarType.UINT32), +}; + +export const OidbSvcTrpcTcp0X929B_0Resp = { + statusCode: ProtoField(1, ScalarType.UINT32), + field2: ProtoField(2, ScalarType.UINT32, true), + field3: ProtoField(3, ScalarType.UINT32), + msgInfo: ProtoField(4, () => MsgInfo, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xCDE_2.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xCDE_2.ts new file mode 100644 index 0000000..baebeef --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xCDE_2.ts @@ -0,0 +1,11 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +// OidbSvcTrpcTcp.0xcde_2 响应体 +// body.field2.field1 为目标字符串值 +export const OidbSvcTrpcTcp0XCDE_2RespBodyInner = { + value: ProtoField(1, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0XCDE_2RespBody = { + inner: ProtoField(2, () => OidbSvcTrpcTcp0XCDE_2RespBodyInner), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE07.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE07.ts new file mode 100644 index 0000000..41c77da --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE07.ts @@ -0,0 +1,59 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0xE07_0 = { + version: ProtoField(1, ScalarType.UINT32), + client: ProtoField(2, ScalarType.UINT32), + entrance: ProtoField(3, ScalarType.UINT32), + ocrReqBody: ProtoField(10, () => OcrReqBody, true), +}; + +export const OcrReqBody = { + imageUrl: ProtoField(1, ScalarType.STRING), + languageType: ProtoField(2, ScalarType.UINT32), + scene: ProtoField(3, ScalarType.UINT32), + originMd5: ProtoField(10, ScalarType.STRING), + afterCompressMd5: ProtoField(11, ScalarType.STRING), + afterCompressFileSize: ProtoField(12, ScalarType.STRING), + afterCompressWeight: ProtoField(13, ScalarType.STRING), + afterCompressHeight: ProtoField(14, ScalarType.STRING), + isCut: ProtoField(15, ScalarType.BOOL), +}; + +export const OidbSvcTrpcTcp0xE07_0_Response = { + retCode: ProtoField(1, ScalarType.INT32), + errMsg: ProtoField(2, ScalarType.STRING), + wording: ProtoField(3, ScalarType.STRING), + ocrRspBody: ProtoField(10, () => OcrRspBody), +}; + +export const OcrRspBody = { + textDetections: ProtoField(1, () => TextDetection, false, true), + language: ProtoField(2, ScalarType.STRING), + requestId: ProtoField(3, ScalarType.STRING), + ocrLanguageList: ProtoField(101, ScalarType.STRING, false, true), + dstTranslateLanguageList: ProtoField(102, ScalarType.STRING, false, true), + languageList: ProtoField(103, () => Language, false, true), + afterCompressWeight: ProtoField(111, ScalarType.UINT32), + afterCompressHeight: ProtoField(112, ScalarType.UINT32), +}; + +export const TextDetection = { + detectedText: ProtoField(1, ScalarType.STRING), + confidence: ProtoField(2, ScalarType.UINT32), + polygon: ProtoField(3, () => Polygon), + advancedInfo: ProtoField(4, ScalarType.STRING), +}; + +export const Polygon = { + coordinates: ProtoField(1, () => Coordinate, false, true), +}; + +export const Coordinate = { + x: ProtoField(1, ScalarType.INT32), + y: ProtoField(2, ScalarType.INT32), +}; + +export const Language = { + languageCode: ProtoField(1, ScalarType.STRING), + languageDesc: ProtoField(2, ScalarType.STRING), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE37_1200.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE37_1200.ts new file mode 100644 index 0000000..5927704 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE37_1200.ts @@ -0,0 +1,60 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0XE37_1200 = { + subCommand: ProtoField(1, ScalarType.UINT32, true), + field2: ProtoField(2, ScalarType.INT32, true), + body: ProtoField(14, () => OidbSvcTrpcTcp0XE37_1200Body, true), + field101: ProtoField(101, ScalarType.INT32, true), + field102: ProtoField(102, ScalarType.INT32, true), + field200: ProtoField(200, ScalarType.INT32, true), + field99999: ProtoField(99999, ScalarType.BYTES, true), +}; + +export const OidbSvcTrpcTcp0XE37_1200Body = { + receiverUid: ProtoField(10, ScalarType.STRING, true), + fileUuid: ProtoField(20, ScalarType.STRING, true), + type: ProtoField(30, ScalarType.INT32, true), + fileHash: ProtoField(60, ScalarType.STRING, true), + t2: ProtoField(601, ScalarType.INT32, true), +}; + +export const OidbSvcTrpcTcp0XE37_1200Response = { + command: ProtoField(1, ScalarType.UINT32, true), + subCommand: ProtoField(2, ScalarType.UINT32, true), + body: ProtoField(14, () => OidbSvcTrpcTcp0XE37_1200ResponseBody, true), + field50: ProtoField(50, ScalarType.UINT32, true), +}; + +export const OidbSvcTrpcTcp0XE37_1200ResponseBody = { + field10: ProtoField(10, ScalarType.UINT32, true), + state: ProtoField(20, ScalarType.STRING, true), + result: ProtoField(30, () => OidbSvcTrpcTcp0XE37_1200Result, true), + metadata: ProtoField(40, () => OidbSvcTrpcTcp0XE37_800_1200Metadata, true), +}; + +export const OidbSvcTrpcTcp0XE37_1200Result = { + server: ProtoField(20, ScalarType.STRING, true), + port: ProtoField(40, ScalarType.UINT32, true), + url: ProtoField(50, ScalarType.STRING, true), + additionalServer: ProtoField(60, ScalarType.STRING, false, true), + ssoPort: ProtoField(80, ScalarType.UINT32, true), + ssoUrl: ProtoField(90, ScalarType.STRING, true), + extra: ProtoField(120, ScalarType.BYTES, true), +}; + +export const OidbSvcTrpcTcp0XE37_800_1200Metadata = { + uin: ProtoField(1, ScalarType.UINT32, true), + field2: ProtoField(2, ScalarType.UINT32, true), + field3: ProtoField(3, ScalarType.UINT32, true), + size: ProtoField(4, ScalarType.UINT32, true), + timestamp: ProtoField(5, ScalarType.UINT32, true), + fileUuid: ProtoField(6, ScalarType.STRING, true), + fileName: ProtoField(7, ScalarType.STRING, true), + field100: ProtoField(100, ScalarType.BYTES, true), + field101: ProtoField(101, ScalarType.BYTES, true), + field110: ProtoField(110, ScalarType.UINT32, true), + timestamp1: ProtoField(130, ScalarType.UINT32, true), + fileHash: ProtoField(140, ScalarType.STRING, true), + field141: ProtoField(141, ScalarType.BYTES, true), + field142: ProtoField(142, ScalarType.BYTES, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE37_1700.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE37_1700.ts new file mode 100644 index 0000000..efabb25 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xE37_1700.ts @@ -0,0 +1,22 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0XE37_1700 = { + command: ProtoField(1, ScalarType.UINT32, true), + seq: ProtoField(2, ScalarType.INT32, true), + upload: ProtoField(19, () => ApplyUploadReqV3, true), + businessId: ProtoField(101, ScalarType.INT32, true), + clientType: ProtoField(102, ScalarType.INT32, true), + flagSupportMediaPlatform: ProtoField(200, ScalarType.INT32, true), +}; + +export const ApplyUploadReqV3 = { + senderUid: ProtoField(10, ScalarType.STRING, true), + receiverUid: ProtoField(20, ScalarType.STRING, true), + fileSize: ProtoField(30, ScalarType.UINT32, true), + fileName: ProtoField(40, ScalarType.STRING, true), + md510MCheckSum: ProtoField(50, ScalarType.BYTES, true), + sha1CheckSum: ProtoField(60, ScalarType.BYTES, true), + localPath: ProtoField(70, ScalarType.STRING, true), + md5CheckSum: ProtoField(110, ScalarType.BYTES, true), + sha3CheckSum: ProtoField(120, ScalarType.BYTES, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xEB7.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xEB7.ts new file mode 100644 index 0000000..c1771e1 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xEB7.ts @@ -0,0 +1,11 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0XEB7_Body = { + uin: ProtoField(1, ScalarType.STRING), + groupUin: ProtoField(2, ScalarType.STRING), + version: ProtoField(3, ScalarType.STRING), +}; + +export const OidbSvcTrpcTcp0XEB7 = { + body: ProtoField(2, () => OidbSvcTrpcTcp0XEB7_Body), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xED3_1.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xED3_1.ts new file mode 100644 index 0000000..fcbed2f --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xED3_1.ts @@ -0,0 +1,9 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +// Send Poke +export const OidbSvcTrpcTcp0XED3_1 = { + uin: ProtoField(1, ScalarType.UINT32), + groupUin: ProtoField(2, ScalarType.UINT32), + friendUin: ProtoField(5, ScalarType.UINT32), + ext: ProtoField(6, ScalarType.UINT32, true), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xf90.ts b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xf90.ts new file mode 100644 index 0000000..1b996e9 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/Oidb.0xf90.ts @@ -0,0 +1,6 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcp0XF90 = { + groupUin: ProtoField(1, ScalarType.UINT32), + msgSeq: ProtoField(2, ScalarType.UINT64), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/OidbBase.ts b/packages/snowcat-core/packet/transformer/proto/oidb/OidbBase.ts new file mode 100644 index 0000000..a3bccc4 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/OidbBase.ts @@ -0,0 +1,13 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const OidbSvcTrpcTcpBase = { + command: ProtoField(1, ScalarType.UINT32), + subCommand: ProtoField(2, ScalarType.UINT32), + errorCode: ProtoField(3, ScalarType.UINT32), + body: ProtoField(4, ScalarType.BYTES), + errorMsg: ProtoField(5, ScalarType.STRING, true), + isReserved: ProtoField(12, ScalarType.UINT32), +}; +export const OidbSvcTrpcTcpBaseRsp = { + body: ProtoField(4, ScalarType.BYTES), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq.ts b/packages/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq.ts new file mode 100644 index 0000000..93f6727 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq.ts @@ -0,0 +1,237 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; + +export const NTV2RichMediaReq = { + ReqHead: ProtoField(1, () => MultiMediaReqHead), + Upload: ProtoField(2, () => UploadReq), + Download: ProtoField(3, () => DownloadReq), + DownloadRKey: ProtoField(4, () => DownloadRKeyReq), + Delete: ProtoField(5, () => DeleteReq), + UploadCompleted: ProtoField(6, () => UploadCompletedReq), + MsgInfoAuth: ProtoField(7, () => MsgInfoAuthReq), + UploadKeyRenewal: ProtoField(8, () => UploadKeyRenewalReq), + DownloadSafe: ProtoField(9, () => DownloadSafeReq), + Extension: ProtoField(99, ScalarType.BYTES, true), +}; + +export const MultiMediaReqHead = { + Common: ProtoField(1, () => CommonHead), + Scene: ProtoField(2, () => SceneInfo), + Client: ProtoField(3, () => ClientMeta), +}; + +export const CommonHead = { + RequestId: ProtoField(1, ScalarType.UINT32), + Command: ProtoField(2, ScalarType.UINT32), +}; + +export const SceneInfo = { + RequestType: ProtoField(101, ScalarType.UINT32), + BusinessType: ProtoField(102, ScalarType.UINT32), + SceneType: ProtoField(200, ScalarType.UINT32), + C2C: ProtoField(201, () => C2CUserInfo, true), + Group: ProtoField(202, () => NTGroupInfo, true), +}; + +export const C2CUserInfo = { + AccountType: ProtoField(1, ScalarType.UINT32), + TargetUid: ProtoField(2, ScalarType.STRING), +}; + +export const NTGroupInfo = { + GroupUin: ProtoField(1, ScalarType.UINT32), +}; + +export const ClientMeta = { + AgentType: ProtoField(1, ScalarType.UINT32), +}; + +export const DownloadReq = { + Node: ProtoField(1, () => IndexNode), + Download: ProtoField(2, () => DownloadExt), +}; + +export const IndexNode = { + Info: ProtoField(1, () => FileInfo), + FileUuid: ProtoField(2, ScalarType.STRING), + StoreId: ProtoField(3, ScalarType.UINT32), + UploadTime: ProtoField(4, ScalarType.UINT32), + Ttl: ProtoField(5, ScalarType.UINT32), + SubType: ProtoField(6, ScalarType.UINT32), +}; + +export const FileInfo = { + FileSize: ProtoField(1, ScalarType.UINT32), + FileHash: ProtoField(2, ScalarType.STRING), + FileSha1: ProtoField(3, ScalarType.STRING), + FileName: ProtoField(4, ScalarType.STRING), + Type: ProtoField(5, () => FileType), + Width: ProtoField(6, ScalarType.UINT32), + Height: ProtoField(7, ScalarType.UINT32), + Time: ProtoField(8, ScalarType.UINT32), + Original: ProtoField(9, ScalarType.UINT32), +}; + +export const FileType = { + Type: ProtoField(1, ScalarType.UINT32), + PicFormat: ProtoField(2, ScalarType.UINT32), + VideoFormat: ProtoField(3, ScalarType.UINT32), + VoiceFormat: ProtoField(4, ScalarType.UINT32), +}; + +export const DownloadExt = { + Pic: ProtoField(1, () => PicDownloadExt), + Video: ProtoField(2, () => VideoDownloadExt), + Ptt: ProtoField(3, () => PttDownloadExt), +}; + +export const VideoDownloadExt = { + BusiType: ProtoField(1, ScalarType.UINT32), + SceneType: ProtoField(2, ScalarType.UINT32), + SubBusiType: ProtoField(3, ScalarType.UINT32), +}; + +export const PicDownloadExt = {}; + +export const PttDownloadExt = {}; + +export const DownloadRKeyReq = { + Types: ProtoField(1, ScalarType.INT32, false, true), +}; + +export const DeleteReq = { + Index: ProtoField(1, () => IndexNode, false, true), + NeedRecallMsg: ProtoField(2, ScalarType.BOOL), + MsgSeq: ProtoField(3, ScalarType.UINT64), + MsgRandom: ProtoField(4, ScalarType.UINT64), + MsgTime: ProtoField(5, ScalarType.UINT64), +}; + +export const UploadCompletedReq = { + SrvSendMsg: ProtoField(1, ScalarType.BOOL), + ClientRandomId: ProtoField(2, ScalarType.UINT64), + MsgInfo: ProtoField(3, () => MsgInfo), + ClientSeq: ProtoField(4, ScalarType.UINT32), +}; + +export const MsgInfoAuthReq = { + Msg: ProtoField(1, ScalarType.BYTES), + AuthTime: ProtoField(2, ScalarType.UINT64), +}; + +export const DownloadSafeReq = { + Index: ProtoField(1, () => IndexNode), +}; + +export const UploadKeyRenewalReq = { + OldUKey: ProtoField(1, ScalarType.STRING), + SubType: ProtoField(2, ScalarType.UINT32), +}; + +export const MsgInfo = { + MsgInfoBody: ProtoField(1, () => MsgInfoBody, false, true), + ExtBizInfo: ProtoField(2, () => ExtBizInfo), +}; + +export const MsgInfoBody = { + Index: ProtoField(1, () => IndexNode), + Picture: ProtoField(2, () => PictureInfo), + Video: ProtoField(3, () => VideoInfo), + Audio: ProtoField(4, () => AudioInfo), + FileExist: ProtoField(5, ScalarType.BOOL), + HashSum: ProtoField(6, ScalarType.BYTES), +}; + +export const VideoInfo = {}; + +export const AudioInfo = {}; + +export const PictureInfo = { + UrlPath: ProtoField(1, ScalarType.STRING), + Ext: ProtoField(2, () => PicUrlExtInfo), + Domain: ProtoField(3, ScalarType.STRING), +}; + +export const PicUrlExtInfo = { + OriginalParameter: ProtoField(1, ScalarType.STRING), + BigParameter: ProtoField(2, ScalarType.STRING), + ThumbParameter: ProtoField(3, ScalarType.STRING), +}; + +export const VideoExtInfo = { + VideoCodecFormat: ProtoField(1, ScalarType.UINT32), +}; + +export const ExtBizInfo = { + Pic: ProtoField(1, () => PicExtBizInfo), + Video: ProtoField(2, () => VideoExtBizInfo), + Ptt: ProtoField(3, () => PttExtBizInfo), + BusiType: ProtoField(10, ScalarType.UINT32), +}; + +export const PttExtBizInfo = { + SrcUin: ProtoField(1, ScalarType.UINT64), + PttScene: ProtoField(2, ScalarType.UINT32), + PttType: ProtoField(3, ScalarType.UINT32), + ChangeVoice: ProtoField(4, ScalarType.UINT32), + Waveform: ProtoField(5, ScalarType.BYTES), + AutoConvertText: ProtoField(6, ScalarType.UINT32), + BytesReserve: ProtoField(11, ScalarType.BYTES), + BytesPbReserve: ProtoField(12, ScalarType.BYTES), + BytesGeneralFlags: ProtoField(13, ScalarType.BYTES), +}; + +export const VideoExtBizInfo = { + FromScene: ProtoField(1, ScalarType.UINT32), + ToScene: ProtoField(2, ScalarType.UINT32), + BytesPbReserve: ProtoField(3, ScalarType.BYTES), +}; + +export const PicExtBizInfo = { + BizType: ProtoField(1, ScalarType.UINT32), + TextSummary: ProtoField(2, ScalarType.STRING), + BytesPbReserveC2c: ProtoField(11, () => BytesPbReserveC2c), + BytesPbReserveTroop: ProtoField(12, () => BytesPbReserveTroop), + FromScene: ProtoField(1001, ScalarType.UINT32), + ToScene: ProtoField(1002, ScalarType.UINT32), + OldFileId: ProtoField(1003, ScalarType.UINT32), +}; + +export const UploadReq = { + UploadInfo: ProtoField(1, () => UploadInfo, false, true), + TryFastUploadCompleted: ProtoField(2, ScalarType.BOOL), + SrvSendMsg: ProtoField(3, ScalarType.BOOL), + ClientRandomId: ProtoField(4, ScalarType.UINT64), + CompatQMsgSceneType: ProtoField(5, ScalarType.UINT32), + ExtBizInfo: ProtoField(6, () => ExtBizInfo), + ClientSeq: ProtoField(7, ScalarType.UINT32), + NoNeedCompatMsg: ProtoField(8, ScalarType.BOOL), +}; + +export const UploadInfo = { + FileInfo: ProtoField(1, () => FileInfo), + SubFileType: ProtoField(2, ScalarType.UINT32), +}; + +export const BytesPbReserveC2c = { + subType: ProtoField(1, ScalarType.UINT32), + field3: ProtoField(3, ScalarType.UINT32), + field4: ProtoField(4, ScalarType.UINT32), + field8: ProtoField(8, ScalarType.STRING), + field10: ProtoField(10, ScalarType.UINT32), + field12: ProtoField(12, ScalarType.STRING), + field18: ProtoField(18, ScalarType.STRING), + field19: ProtoField(19, ScalarType.STRING), + field20: ProtoField(20, ScalarType.BYTES), +}; + +export const BytesPbReserveTroop = { + subType: ProtoField(1, ScalarType.UINT32), + field3: ProtoField(3, ScalarType.UINT32), + field4: ProtoField(4, ScalarType.UINT32), + field9: ProtoField(9, ScalarType.STRING), + field10: ProtoField(10, ScalarType.UINT32), + field12: ProtoField(12, ScalarType.STRING), + field18: ProtoField(18, ScalarType.STRING), + field19: ProtoField(19, ScalarType.STRING), + field21: ProtoField(21, ScalarType.BYTES), +}; diff --git a/packages/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaResp.ts b/packages/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaResp.ts new file mode 100644 index 0000000..a0fa564 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaResp.ts @@ -0,0 +1,113 @@ +import { ProtoField, ScalarType } from 'snowcat-protobuf'; +import { CommonHead, MsgInfo, PicUrlExtInfo, VideoExtInfo } from '@/snowcat-core/packet/transformer/proto'; + +export const NTV2RichMediaResp = { + respHead: ProtoField(1, () => MultiMediaRespHead), + upload: ProtoField(2, () => UploadResp), + download: ProtoField(3, () => DownloadResp), + downloadRKey: ProtoField(4, () => DownloadRKeyResp), + delete: ProtoField(5, () => DeleteResp), + uploadCompleted: ProtoField(6, () => UploadCompletedResp), + msgInfoAuth: ProtoField(7, () => MsgInfoAuthResp), + uploadKeyRenewal: ProtoField(8, () => UploadKeyRenewalResp), + downloadSafe: ProtoField(9, () => DownloadSafeResp), + extension: ProtoField(99, ScalarType.BYTES, true), +}; + +export const MultiMediaRespHead = { + common: ProtoField(1, () => CommonHead), + retCode: ProtoField(2, ScalarType.UINT32), + message: ProtoField(3, ScalarType.STRING), +}; + +export const DownloadResp = { + rKeyParam: ProtoField(1, ScalarType.STRING), + rKeyTtlSecond: ProtoField(2, ScalarType.UINT32), + info: ProtoField(3, () => DownloadInfo), + rKeyCreateTime: ProtoField(4, ScalarType.UINT32), +}; + +export const DownloadInfo = { + domain: ProtoField(1, ScalarType.STRING), + urlPath: ProtoField(2, ScalarType.STRING), + httpsPort: ProtoField(3, ScalarType.UINT32), + ipv4s: ProtoField(4, () => IPv4, false, true), + ipv6s: ProtoField(5, () => IPv6, false, true), + picUrlExtInfo: ProtoField(6, () => PicUrlExtInfo), + videoExtInfo: ProtoField(7, () => VideoExtInfo), +}; + +export const IPv4 = { + outIP: ProtoField(1, ScalarType.UINT32), + outPort: ProtoField(2, ScalarType.UINT32), + inIP: ProtoField(3, ScalarType.UINT32), + inPort: ProtoField(4, ScalarType.UINT32), + ipType: ProtoField(5, ScalarType.UINT32), +}; + +export const IPv6 = { + outIP: ProtoField(1, ScalarType.BYTES), + outPort: ProtoField(2, ScalarType.UINT32), + inIP: ProtoField(3, ScalarType.BYTES), + inPort: ProtoField(4, ScalarType.UINT32), + ipType: ProtoField(5, ScalarType.UINT32), +}; + +export const UploadResp = { + uKey: ProtoField(1, ScalarType.STRING, true), + uKeyTtlSecond: ProtoField(2, ScalarType.UINT32), + ipv4s: ProtoField(3, () => IPv4, false, true), + ipv6s: ProtoField(4, () => IPv6, false, true), + msgSeq: ProtoField(5, ScalarType.UINT64), + msgInfo: ProtoField(6, () => MsgInfo), + ext: ProtoField(7, () => RichMediaStorageTransInfo, false, true), + compatQMsg: ProtoField(8, ScalarType.BYTES), + subFileInfos: ProtoField(10, () => SubFileInfo, false, true), +}; + +export const RichMediaStorageTransInfo = { + subType: ProtoField(1, ScalarType.UINT32), + extType: ProtoField(2, ScalarType.UINT32), + extValue: ProtoField(3, ScalarType.BYTES), +}; + +export const SubFileInfo = { + subType: ProtoField(1, ScalarType.UINT32), + uKey: ProtoField(2, ScalarType.STRING), + uKeyTtlSecond: ProtoField(3, ScalarType.UINT32), + ipv4s: ProtoField(4, () => IPv4, false, true), + ipv6s: ProtoField(5, () => IPv6, false, true), +}; + +export const DownloadSafeResp = { +}; + +export const UploadKeyRenewalResp = { + ukey: ProtoField(1, ScalarType.STRING), + ukeyTtlSec: ProtoField(2, ScalarType.UINT64), +}; + +export const MsgInfoAuthResp = { + authCode: ProtoField(1, ScalarType.UINT32), + msg: ProtoField(2, ScalarType.BYTES), + resultTime: ProtoField(3, ScalarType.UINT64), +}; + +export const UploadCompletedResp = { + msgSeq: ProtoField(1, ScalarType.UINT64), +}; + +export const DeleteResp = { +}; + +export const DownloadRKeyResp = { + rKeys: ProtoField(1, () => RKeyInfo, false, true), +}; + +export const RKeyInfo = { + rkey: ProtoField(1, ScalarType.STRING), + rkeyTtlSec: ProtoField(2, ScalarType.UINT64), + storeId: ProtoField(3, ScalarType.UINT32), + rkeyCreateTime: ProtoField(4, ScalarType.UINT32, true), + type: ProtoField(5, ScalarType.UINT32, true), +}; diff --git a/packages/snowcat-core/packet/transformer/system/FetchRkey.ts b/packages/snowcat-core/packet/transformer/system/FetchRkey.ts new file mode 100644 index 0000000..e5aa00e --- /dev/null +++ b/packages/snowcat-core/packet/transformer/system/FetchRkey.ts @@ -0,0 +1,36 @@ +import * as proto from '@/snowcat-core/packet/transformer/proto'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +class FetchRkey extends PacketTransformer { + build (): OidbPacket { + const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0X9067_202).encode({ + reqHead: { + common: { + requestId: 1, + command: 202, + }, + scene: { + requestType: 2, + businessType: 1, + sceneType: 0, + }, + client: { + agentType: 2, + }, + }, + downloadRKeyReq: { + key: [10, 20, 2], + }, + }); + return OidbBase.build(0x9067, 202, data); + } + + parse (data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + return new NapProtoMsg(proto.OidbSvcTrpcTcp0X9067_202_Rsp_Body).decode(oidbBody); + } +} + +export default new FetchRkey(); diff --git a/packages/snowcat-core/packet/transformer/system/index.ts b/packages/snowcat-core/packet/transformer/system/index.ts new file mode 100644 index 0000000..a4387d8 --- /dev/null +++ b/packages/snowcat-core/packet/transformer/system/index.ts @@ -0,0 +1 @@ +export { default as FetchRkey } from './FetchRkey'; diff --git a/packages/snowcat-core/packet/utils/crypto/hash.ts b/packages/snowcat-core/packet/utils/crypto/hash.ts new file mode 100644 index 0000000..be9baae --- /dev/null +++ b/packages/snowcat-core/packet/utils/crypto/hash.ts @@ -0,0 +1,47 @@ +// love from https://github.com/LagrangeDev/lagrangejs & https://github.com/takayama-lily/oicq +import * as crypto from 'crypto'; +import * as stream from 'stream'; +import * as fs from 'fs'; +import { CalculateStreamBytesTransform } from '@/snowcat-core/packet/utils/crypto/sha1StreamBytesTransform'; + +function sha1Stream (readable: stream.Readable) { + return new Promise((resolve, reject) => { + readable.on('error', reject); + readable.pipe(crypto.createHash('sha1').on('error', reject).on('data', resolve)); + }); +} + +function md5Stream (readable: stream.Readable) { + return new Promise((resolve, reject) => { + readable.on('error', reject); + readable.pipe(crypto.createHash('md5').on('error', reject).on('data', resolve)); + }); +} + +export function calculateSha1 (filePath: string): Promise { + const readable = fs.createReadStream(filePath); + return sha1Stream(readable); +} + +export function computeMd5AndLengthWithLimit (filePath: string, limit?: number): Promise { + const readStream = fs.createReadStream(filePath, limit ? { start: 0, end: limit - 1 } : {}); + return md5Stream(readStream); +} + +export function calculateSha1StreamBytes (filePath: string): Promise { + return new Promise((resolve, reject) => { + const readable = fs.createReadStream(filePath); + const calculateStreamBytes = new CalculateStreamBytesTransform(); + const byteArrayList: Buffer[] = []; + calculateStreamBytes.on('data', (chunk: Buffer) => { + byteArrayList.push(chunk); + }); + calculateStreamBytes.on('end', () => { + resolve(byteArrayList); + }); + calculateStreamBytes.on('error', (err: Error) => { + reject(err); + }); + readable.pipe(calculateStreamBytes); + }); +} diff --git a/packages/snowcat-core/packet/utils/crypto/sha1Stream.test.ts b/packages/snowcat-core/packet/utils/crypto/sha1Stream.test.ts new file mode 100644 index 0000000..d43ba5f --- /dev/null +++ b/packages/snowcat-core/packet/utils/crypto/sha1Stream.test.ts @@ -0,0 +1,19 @@ +import crypto from 'crypto'; +import assert from 'assert'; +import { Sha1Stream } from './sha1Stream'; + +function testSha1Stream () { + for (let i = 0; i < 100000; i++) { + const randomLength = Math.floor(Math.random() * 1024); + const randomData = crypto.randomBytes(randomLength); + const sha1Stream = new Sha1Stream(); + sha1Stream.update(randomData); + const hash = sha1Stream.final(); + const expectedDigest = crypto.createHash('sha1').update(randomData).digest(); + assert.strictEqual(hash.toString('hex'), expectedDigest.toString('hex')); + console.log(`Test ${i + 1}: Passed`); + } + console.log('All tests passed successfully.'); +} + +testSha1Stream(); diff --git a/packages/snowcat-core/packet/utils/crypto/sha1Stream.ts b/packages/snowcat-core/packet/utils/crypto/sha1Stream.ts new file mode 100644 index 0000000..d22fb89 --- /dev/null +++ b/packages/snowcat-core/packet/utils/crypto/sha1Stream.ts @@ -0,0 +1,125 @@ +// @ts-nocheck +export class Sha1Stream { + readonly Sha1BlockSize = 64; + readonly Sha1DigestSize = 20; + private readonly _padding = Buffer.concat([Buffer.from([0x80]), Buffer.alloc(63)]); + private readonly _state = new Uint32Array(5); + private readonly _count = new Uint32Array(2); + private readonly _buffer = Buffer.allocUnsafe(this.Sha1BlockSize); + private readonly _w = new Uint32Array(80); + + constructor () { + this.reset(); + } + + private reset (): void { + this._state[0] = 0x67452301; + this._state[1] = 0xEFCDAB89; + this._state[2] = 0x98BADCFE; + this._state[3] = 0x10325476; + this._state[4] = 0xC3D2E1F0; + this._count[0] = 0; + this._count[1] = 0; + this._buffer.fill(0); + } + + private rotateLeft (v: number, o: number): number { + return ((v << o) | (v >>> (32 - o))) >>> 0; + } + + private transform (chunk: Buffer, offset: number): void { + const w = this._w; + const view = new DataView(chunk.buffer, chunk.byteOffset + offset, 64); + + for (let i = 0; i < 16; i++) { + w[i] = view.getUint32(i * 4, false); + } + + for (let i = 16; i < 80; i++) { + w[i] = this.rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1) >>> 0; + } + + let a = this._state[0]; + let b = this._state[1]; + let c = this._state[2]; + let d = this._state[3]; + let e = this._state[4]; + + for (let i = 0; i < 80; i++) { + let temp; + if (i < 20) { + temp = ((b & c) | (~b & d)) + 0x5A827999; + } else if (i < 40) { + temp = (b ^ c ^ d) + 0x6ED9EBA1; + } else if (i < 60) { + temp = ((b & c) | (b & d) | (c & d)) + 0x8F1BBCDC; + } else { + temp = (b ^ c ^ d) + 0xCA62C1D6; + } + temp += ((this.rotateLeft(a, 5) + e + w[i]) >>> 0); + e = d; + d = c; + c = this.rotateLeft(b, 30) >>> 0; + b = a; + a = temp; + } + + this._state[0] = (this._state[0] + a) >>> 0; + this._state[1] = (this._state[1] + b) >>> 0; + this._state[2] = (this._state[2] + c) >>> 0; + this._state[3] = (this._state[3] + d) >>> 0; + this._state[4] = (this._state[4] + e) >>> 0; + } + + public update (data: Buffer, len?: number): void { + let index = ((this._count[0] >>> 3) & 0x3F) >>> 0; + const dataLen = len ?? data.length; + this._count[0] = (this._count[0] + (dataLen << 3)) >>> 0; + + if (this._count[0] < (dataLen << 3)) this._count[1] = (this._count[1] + 1) >>> 0; + + this._count[1] = (this._count[1] + (dataLen >>> 29)) >>> 0; + + const partLen = (this.Sha1BlockSize - index) >>> 0; + let i = 0; + + if (dataLen >= partLen) { + data.copy(this._buffer, index, 0, partLen); + this.transform(this._buffer, 0); + for (i = partLen; (i + this.Sha1BlockSize) <= dataLen; i = (i + this.Sha1BlockSize) >>> 0) { + this.transform(data, i); + } + index = 0; + } + + data.copy(this._buffer, index, i, dataLen); + } + + public hash (bigEndian: boolean = true): Buffer { + const digest = Buffer.allocUnsafe(this.Sha1DigestSize); + if (bigEndian) { + for (let i = 0; i < 5; i++) digest.writeUInt32BE(this._state[i], i * 4); + } else { + for (let i = 0; i < 5; i++) digest.writeUInt32LE(this._state[i], i * 4); + } + return digest; + } + + public final (): Buffer { + const digest = Buffer.allocUnsafe(this.Sha1DigestSize); + const bits = Buffer.allocUnsafe(8); + bits.writeUInt32BE(this._count[1], 0); + bits.writeUInt32BE(this._count[0], 4); + + const index = ((this._count[0] >>> 3) & 0x3F) >>> 0; + const padLen = ((index < 56) ? (56 - index) : (120 - index)) >>> 0; + this.update(this._padding, padLen); + this.update(bits); + + for (let i = 0; i < 5; i++) { + digest.writeUInt32BE(this._state[i], i * 4); + } + + return digest; + } +} diff --git a/packages/snowcat-core/packet/utils/crypto/sha1StreamBytesTransform.ts b/packages/snowcat-core/packet/utils/crypto/sha1StreamBytesTransform.ts new file mode 100644 index 0000000..cd28857 --- /dev/null +++ b/packages/snowcat-core/packet/utils/crypto/sha1StreamBytesTransform.ts @@ -0,0 +1,53 @@ +import * as stream from 'node:stream'; +import { Sha1Stream } from '@/snowcat-core/packet/utils/crypto/sha1Stream'; + +export class CalculateStreamBytesTransform extends stream.Transform { + private readonly blockSize = 1024 * 1024; + private readonly sha1: Sha1Stream; + private buffer: Buffer; + private bytesRead: number; + private readonly byteArrayList: Buffer[]; + + constructor () { + super(); + this.sha1 = new Sha1Stream(); + this.buffer = Buffer.alloc(0); + this.bytesRead = 0; + this.byteArrayList = []; + } + + override _transform (chunk: Buffer, _: BufferEncoding, callback: stream.TransformCallback): void { + try { + this.buffer = Buffer.concat([this.buffer, chunk]); + let offset = 0; + while (this.buffer.length - offset >= this.sha1.Sha1BlockSize) { + const block = this.buffer.subarray(offset, offset + this.sha1.Sha1BlockSize); + this.sha1.update(block); + offset += this.sha1.Sha1BlockSize; + this.bytesRead += this.sha1.Sha1BlockSize; + if (this.bytesRead % this.blockSize === 0) { + const digest = this.sha1.hash(false); + this.byteArrayList.push(Buffer.from(digest)); + } + } + this.buffer = this.buffer.subarray(offset); + callback(null); + } catch (err) { + callback(err as Error); + } + } + + override _flush (callback: stream.TransformCallback): void { + try { + if (this.buffer.length > 0) this.sha1.update(this.buffer); + const finalDigest = this.sha1.final(); + this.byteArrayList.push(Buffer.from(finalDigest)); + for (const digest of this.byteArrayList) { + this.push(digest); + } + callback(null); + } catch (err) { + callback(err as Error); + } + } +} diff --git a/packages/snowcat-core/packet/utils/crypto/tea.ts b/packages/snowcat-core/packet/utils/crypto/tea.ts new file mode 100644 index 0000000..c916db6 --- /dev/null +++ b/packages/snowcat-core/packet/utils/crypto/tea.ts @@ -0,0 +1,88 @@ +// love from https://github.com/LagrangeDev/lagrangejs/blob/main/src/core/tea.ts & https://github.com/takayama-lily/oicq/blob/main/lib/core/tea.ts + +// @ts-nocheck +const BUF7 = Buffer.alloc(7); +const deltas = [ + 0x9e3779b9, 0x3c6ef372, 0xdaa66d2b, 0x78dde6e4, 0x1715609d, 0xb54cda56, 0x5384540f, 0xf1bbcdc8, 0x8ff34781, + 0x2e2ac13a, 0xcc623af3, 0x6a99b4ac, 0x08d12e65, 0xa708a81e, 0x454021d7, 0xe3779b90, +]; + +function _toUInt32 (num: number) { + return num >>> 0; +} + +function _encrypt (x: number, y: number, k0: number, k1: number, k2: number, k3: number): [number, number] { + for (let i = 0; i < 16; ++i) { + let aa = ((_toUInt32(((y << 4) >>> 0) + k0) ^ _toUInt32(y + deltas[i])) >>> 0) ^ _toUInt32(~~(y / 32) + k1); + aa >>>= 0; + x = _toUInt32(x + aa); + let bb = ((_toUInt32(((x << 4) >>> 0) + k2) ^ _toUInt32(x + deltas[i])) >>> 0) ^ _toUInt32(~~(x / 32) + k3); + bb >>>= 0; + y = _toUInt32(y + bb); + } + return [x, y]; +} + +export function encrypt (data: Buffer, key: Buffer) { + let n = (6 - data.length) >>> 0; + n = (n % 8) + 2; + const v = Buffer.concat([Buffer.from([(n - 2) | 0xf8]), Buffer.allocUnsafe(n), data, BUF7]); + const k0 = key.readUInt32BE(0); + const k1 = key.readUInt32BE(4); + const k2 = key.readUInt32BE(8); + const k3 = key.readUInt32BE(12); + let r1 = 0; let r2 = 0; let t1 = 0; let t2 = 0; + for (let i = 0; i < v.length; i += 8) { + const a1 = v.readUInt32BE(i); + const a2 = v.readUInt32BE(i + 4); + const b1 = a1 ^ r1; + const b2 = a2 ^ r2; + const [x, y] = _encrypt(b1 >>> 0, b2 >>> 0, k0, k1, k2, k3); + r1 = x ^ t1; + r2 = y ^ t2; + t1 = b1; + t2 = b2; + v.writeInt32BE(r1, i); + v.writeInt32BE(r2, i + 4); + } + return v; +} + +function _decrypt (x: number, y: number, k0: number, k1: number, k2: number, k3: number) { + for (let i = 15; i >= 0; --i) { + const aa = ((_toUInt32(((x << 4) >>> 0) + k2) ^ _toUInt32(x + deltas[i])) >>> 0) ^ _toUInt32(~~(x / 32) + k3); + y = (y - aa) >>> 0; + const bb = ((_toUInt32(((y << 4) >>> 0) + k0) ^ _toUInt32(y + deltas[i])) >>> 0) ^ _toUInt32(~~(y / 32) + k1); + x = (x - bb) >>> 0; + } + return [x, y]; +} + +export function decrypt (encrypted: Buffer, key: Buffer) { + if (encrypted.length % 8) throw ERROR_ENCRYPTED_LENGTH; + const k0 = key.readUInt32BE(0); + const k1 = key.readUInt32BE(4); + const k2 = key.readUInt32BE(8); + const k3 = key.readUInt32BE(12); + let r1 = 0; let r2 = 0; let t1 = 0; let t2 = 0; let x = 0; let y = 0; + for (let i = 0; i < encrypted.length; i += 8) { + const a1 = encrypted.readUInt32BE(i); + const a2 = encrypted.readUInt32BE(i + 4); + const b1 = a1 ^ x; + const b2 = a2 ^ y; + [x, y] = _decrypt(b1 >>> 0, b2 >>> 0, k0, k1, k2, k3); + r1 = x ^ t1; + r2 = y ^ t2; + t1 = a1; + t2 = a2; + encrypted.writeInt32BE(r1, i); + encrypted.writeInt32BE(r2, i + 4); + } + if (Buffer.compare(encrypted.subarray(encrypted.length - 7), BUF7) !== 0) throw ERROR_ENCRYPTED_ILLEGAL; + // if (Buffer.compare(encrypted.slice(encrypted.length - 7), BUF7) !== 0) throw ERROR_ENCRYPTED_ILLEGAL; + return encrypted.subarray((encrypted[0] & 0x07) + 3, encrypted.length - 7); + // return encrypted.slice((encrypted[0] & 0x07) + 3, encrypted.length - 7); +} + +const ERROR_ENCRYPTED_LENGTH = new Error('length of encrypted data must be a multiple of 8'); +const ERROR_ENCRYPTED_ILLEGAL = new Error('encrypted data is illegal'); diff --git a/packages/snowcat-core/packet/utils/helper/miniAppHelper.ts b/packages/snowcat-core/packet/utils/helper/miniAppHelper.ts new file mode 100644 index 0000000..02e6ae5 --- /dev/null +++ b/packages/snowcat-core/packet/utils/helper/miniAppHelper.ts @@ -0,0 +1,94 @@ +import { + MiniAppData, + MiniAppReqParams, + MiniAppRawData, + MiniAppReqCustomParams, + MiniAppReqTemplateParams, +} from '@/snowcat-core/packet/entities/miniApp'; + +type MiniAppTemplateNameList = 'bili' | 'weibo'; + +export abstract class MiniAppInfo { + static readonly sdkId: string = 'V1_PC_MINISDK_99.99.99_1_APP_A'; + template: MiniAppReqTemplateParams; + + private static readonly appMap = new Map(); + + protected constructor (template: MiniAppReqTemplateParams) { + this.template = template; + } + + static get (name: MiniAppTemplateNameList): MiniAppInfo | undefined { + return this.appMap.get(name); + } + + static readonly Bili = new class extends MiniAppInfo { + constructor () { + super({ + sdkId: MiniAppInfo.sdkId, + appId: '1109937557', + scene: 1, + templateType: 1, + businessType: 0, + verType: 3, + shareType: 0, + versionId: 'cfc5f7b05b44b5956502edaecf9d2240', + withShareTicket: 0, + iconUrl: 'https://miniapp.gtimg.cn/public/appicon/51f90239b78a2e4994c11215f4c4ba15_200.jpg', + }); + MiniAppInfo.appMap.set('bili', this); + } + }(); + + static readonly WeiBo = new class extends MiniAppInfo { + constructor () { + super({ + sdkId: MiniAppInfo.sdkId, + appId: '1109224783', + scene: 1, + templateType: 1, + businessType: 0, + verType: 3, + shareType: 0, + versionId: 'e482a3cc4e574d9b772e96ba6eec9ba2', + withShareTicket: 0, + iconUrl: 'https://miniapp.gtimg.cn/public/appicon/35bbb44dc68e65194cfacfb206b8f1f7_200.jpg', + }); + MiniAppInfo.appMap.set('weibo', this); + } + }(); +} + +export class MiniAppInfoHelper { + static generateReq (custom: MiniAppReqCustomParams, template: MiniAppReqTemplateParams): MiniAppReqParams { + return { + ...custom, + ...template, + }; + } + + static RawToSend (rawData: MiniAppRawData): MiniAppData { + return { + ver: rawData.ver, + prompt: rawData.prompt, + config: rawData.config, + app: rawData.appName, + view: rawData.appView, + meta: rawData.metaData, + miniappShareOrigin: 3, + miniappOpenRefer: '10002', + }; + } + + static SendToRaw (data: MiniAppData): MiniAppRawData { + return { + appName: data.app, + appView: data.view, + ver: data.ver, + desc: data.meta.detail_1.desc, + prompt: data.prompt, + metaData: data.meta, + config: data.config, + }; + } +} diff --git a/packages/snowcat-core/protocol/OlpushSerivce.ts b/packages/snowcat-core/protocol/OlpushSerivce.ts new file mode 100644 index 0000000..694d567 --- /dev/null +++ b/packages/snowcat-core/protocol/OlpushSerivce.ts @@ -0,0 +1,36 @@ +import { NapProtoMsg } from 'snowcat-protobuf'; +import { ReceiveService, ServiceBase } from '../packet/handler/serviceRegister'; +import { GroupReactNotify, PushMsg } from '../packet/transformer/proto'; + +@ReceiveService('trpc.msg.olpush.OlPushService.MsgPush') +export class OlPushService extends ServiceBase { + async handler (_seq: number, hex_data: string) { + const data = new NapProtoMsg(PushMsg).decode(Buffer.from(hex_data, 'hex')); + if (data.message.contentHead.type === 732 && data.message.contentHead.subType === 16) { + const pbNotify = data.message.body?.msgContent?.slice(7); + if (!pbNotify) { + return; + } + // 开始解析Notify + const notify = new NapProtoMsg(GroupReactNotify).decode(pbNotify); + if ((notify.field13 ?? 0) === 35) { + // Group React Notify + const groupCode = notify.groupUin?.toString() ?? ''; + const operatorUid = notify.groupReactionData?.data?.data?.groupReactionDataContent?.operatorUid ?? ''; + const type = notify.groupReactionData?.data?.data?.groupReactionDataContent?.type ?? 0; + const seq = notify.groupReactionData?.data?.data?.groupReactionTarget?.seq?.toString() ?? ''; + const code = notify.groupReactionData?.data?.data?.groupReactionDataContent?.code ?? ''; + const count = notify.groupReactionData?.data?.data?.groupReactionDataContent?.count ?? 0; + const senderUin = await this.core.apis.UserApi.getUinByUidV2(operatorUid); + this.event.emit('event:emoji_like', { + groupId: groupCode, + senderUin, + emojiId: code, + msgSeq: seq, + isAdd: type === 1, + count, + }); + } + } + } +} diff --git a/packages/snowcat-core/services/NodeIKernelAVSDKService.ts b/packages/snowcat-core/services/NodeIKernelAVSDKService.ts new file mode 100644 index 0000000..af174ba --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelAVSDKService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelAVSDKService { + addKernelAVSDKListener (listener: unknown): number; + + removeKernelAVSDKListener (listenerId: number): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelAddBuddyService.ts b/packages/snowcat-core/services/NodeIKernelAddBuddyService.ts new file mode 100644 index 0000000..102792a --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelAddBuddyService.ts @@ -0,0 +1,15 @@ +export interface NodeIKernelAddBuddyService { + addBuddy (arg1: string, arg2: unknown, arg3: unknown): unknown; + + getAddBuddyRequestTag (arg1: string, arg2: unknown, arg3: unknown): unknown; + + getBuddySetting (arg1: string, arg2: unknown, arg3: unknown): unknown; + + getSmartInfo (arg1: string, arg2: unknown, arg3: unknown): unknown; + + queryUinSafetyFlag (arg1: string, arg2: unknown, arg3: unknown): unknown; + + requestInfoByAccount (arg1: string, arg2: unknown, arg3: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelAlbumService.ts b/packages/snowcat-core/services/NodeIKernelAlbumService.ts new file mode 100644 index 0000000..e5058e7 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelAlbumService.ts @@ -0,0 +1,102 @@ +import { AlbumCommentReplyContent, AlbumFeedLikePublish, AlbumListRequest, AlbumMediaFeed } from '../data/album'; + +export interface NodeIKernelAlbumService { + + setAlbumServiceInfo (arg1: string, arg2: string, arg3: string): unknown;// needs 3 arguments + + getMainPage (arg1: unknown, arg2: unknown): unknown;// needs 2 arguments + + getAlbumList (params: { + qun_id: string, + attach_info: string, + seq: number, + request_time_line: { + request_invoke_time: string; + }; + }): Promise<{ + response: { + seq: number, + result: number, + errMs: string, // 没错就是errMs不是errMsg + trace_id: string, + is_from_cache: boolean, + request_time_line: unknown, + album_list: Array<{ name: string, album_id: string; }>, + attach_info: string, + has_more: boolean, + right: unknown, + banner: unknown; + }; + }>; + getAlbumInfo (arg: unknown): unknown;// needs 1 arguments + + deleteAlbum (arg1: number, arg2: string, arg3: string): unknown;// needs 3 arguments + + addAlbum (arg1: unknown, arg2: unknown): unknown;// needs 2 arguments + + deleteMedias (seq: number, group_code: string, album_id: string, media_ids: string[], ban_ids: unknown[]): Promise;// needs 4 arguments + + modifyAlbum (arg1: number, arg2: unknown, arg3: Array[]): unknown;// needs 3 arguments + + getMediaList (param: AlbumListRequest): Promise<{ + response: { + seq: number, + result: number, + errMs: string, // 没错就是errMs不是errMsg + trace_id: string, + request_time_line: unknown, + }; + }>;// needs 1 arguments + + quoteToQzone (arg: unknown): unknown;// needs 1 arguments + + quoteToQunAlbum (arg: unknown): unknown;// needs 1 arguments + + queryQuoteToQunAlbumStatus (arg: unknown): unknown;// needs 1 arguments + + getQunFeeds (arg: unknown): unknown;// needs 1 arguments + + getQunFeedDetail (arg: unknown): unknown;// needs 1 arguments + + getQunNoticeList (arg1: number, arg2: unknown, arg3: string, arg4: string): unknown;// needs 4 arguments + + getQunComment (arg: unknown): unknown;// needs 1 arguments + + getQunLikes (arg1: number, arg2: unknown, arg3: string, arg4: string): unknown;// needs 4 arguments + + deleteQunFeed (arg: unknown): unknown;// needs 1 arguments + // seq random + // stCommonExt {"map_info":[],"map_bytes_info":[],"map_user_account":[]} + // qunId string + doQunComment (seq: number, ext: { + map_info: unknown[], + map_bytes_info: unknown[], + map_user_account: unknown[]; + }, + qunId: string, + commentType: number, + feed: AlbumMediaFeed, + content: AlbumCommentReplyContent, + ): Promise;// needs 6 arguments + + doQunReply (arg1: number, arg2: unknown, arg3: string, arg4: number, arg5: unknown, arg6: unknown, arg7: unknown): unknown;// needs 7 arguments + + doQunLike ( + seq: number, + ext: { + map_info: unknown[], + map_bytes_info: unknown[], + map_user_account: unknown[]; + }, + type: number, // type : number 1 取消 2 点赞 + param: { + // {"id":"421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|V5bCgAxMDEyOTU5MjU3e*KqaLVYdic!^||^421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|17560336594^||^1","status":1} + id: string, + status: number; + }, + like: AlbumFeedLikePublish + ): Promise;// needs 5 arguments + + getRedPoints (arg1: string, arg2: number, arg3: string): unknown;// needs 3 arguments + +} diff --git a/packages/snowcat-core/services/NodeIKernelAvatarService.ts b/packages/snowcat-core/services/NodeIKernelAvatarService.ts new file mode 100644 index 0000000..ccb8e99 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelAvatarService.ts @@ -0,0 +1,31 @@ +export interface NodeIKernelAvatarService { + addAvatarListener(listener: unknown): void; + + removeAvatarListener(listenerId: number): void; + + getAvatarPath(arg1: unknown, arg2: unknown): unknown; + + forceDownloadAvatar(uid: string, useCache: number): Promise; + + getGroupAvatarPath(arg1: unknown, arg2: unknown): unknown; + + getConfGroupAvatarPath(arg: unknown): unknown; + + forceDownloadGroupAvatar(arg1: unknown, arg2: unknown): unknown; + + getGroupPortraitPath(arg1: string, arg2: number, arg3: number): unknown; + + forceDownloadGroupPortrait(arg1: string, arg2: number, arg3: number): unknown; + + getAvatarPaths(arg1: Array[], arg2: number): unknown; + + getGroupAvatarPaths(arg1: Array[], arg2: string): unknown; + + getConfGroupAvatarPaths(arg: unknown): unknown; + + getAvatarPathByUin(arg1: unknown, arg2: unknown): unknown; + + forceDownloadAvatarByUin(arg1: unknown, arg2: unknown): unknown; + + isNull(): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelBaseEmojiService.ts b/packages/snowcat-core/services/NodeIKernelBaseEmojiService.ts new file mode 100644 index 0000000..de6c622 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelBaseEmojiService.ts @@ -0,0 +1,37 @@ +import { DownloadBaseEmojiByIdReq, DownloadBaseEmojiByUrlReq, GetBaseEmojiPathReq, PullSysEmojisReq } from '../types'; +import { GeneralCallResult } from './common'; + +export interface NodeIKernelBaseEmojiService { + removeKernelBaseEmojiListener(listenerId: number): void; + + addKernelBaseEmojiListener(listener: unknown): number; + + isBaseEmojiPathExist(args: Array): unknown; + + fetchFullSysEmojis(pullSysEmojisReq: PullSysEmojisReq): Promise, + downloadInfo: Array + }, + normalPanelResult: { + SysEmojiGroupList: Array, + downloadInfo: Array + }, + superPanelResult: { + SysEmojiGroupList: Array, + downloadInfo: Array + }, + redHeartPanelResult: { + SysEmojiGroupList: Array, + downloadInfo: Array + } + } + }>; + + getBaseEmojiPathByIds(getBaseEmojiPathReqs: Array): unknown; + + downloadBaseEmojiByIdWithUrl(downloadBaseEmojiByUrlReq: DownloadBaseEmojiByUrlReq): unknown; + + downloadBaseEmojiById(downloadBaseEmojiByIdReq: DownloadBaseEmojiByIdReq): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelBdhUploadService.ts b/packages/snowcat-core/services/NodeIKernelBdhUploadService.ts new file mode 100644 index 0000000..67c7282 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelBdhUploadService.ts @@ -0,0 +1,9 @@ +export interface NodeIKernelBdhUploadService { + addKernelBdhUploadListener (listener: unknown): number; + + removeKernelBdhUploadListener (listenerId: number): void; + + setBdhTestEnv (arg1: string, arg2: number): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelBuddyService.ts b/packages/snowcat-core/services/NodeIKernelBuddyService.ts new file mode 100644 index 0000000..0c7aea7 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelBuddyService.ts @@ -0,0 +1,150 @@ +import { GeneralCallResult } from '@/snowcat-core/services/common'; +import { NodeIKernelBuddyListener } from '@/snowcat-core/listeners'; +import { BuddyListReqType } from '@/snowcat-core/types/user'; + +export interface NodeIKernelBuddyService { + getBuddyListV2 (callFrom: string, reqType: BuddyListReqType): Promise; + }>; + }>; + getBuddyListV2 (callFrom: string, isPullRefresh: boolean, reqType: BuddyListReqType): Promise; + }>; + }>; + + getBuddyListFromCache (reqType: BuddyListReqType): Promise;// Uids + }>>; + + addKernelBuddyListener (listener: NodeIKernelBuddyListener): number; + + getAllBuddyCount (): number; + + removeKernelBuddyListener (listenerId: number): void; + + // getBuddyList(nocache: boolean): Promise; + + getBuddyNick (uid: number): string; + + getBuddyRemark (uid: number): string; + + setBuddyRemark (param: { uid: string, remark: string, signInfo?: unknown; }): void; + + getAvatarUrl (uid: number): string; + + isBuddy (uid: string): boolean; + + getCategoryNameWithUid (uid: number): string; + + getTargetBuddySetting (uid: number): unknown; + + getTargetBuddySettingByType (uid: number, type: number): unknown; + + getBuddyReqUnreadCnt (): number; + + getBuddyReq (): Promise; + + delBuddyReq (uid: number): void; + + clearBuddyReqUnreadCnt (): Promise; + + reqToAddFriends (uid: number, msg: string): void; + + setSpacePermission (uid: number, permission: number): void; + + approvalFriendRequest (arg: { + friendUid: string; + reqTime: string; + accept: boolean; + }): Promise; + + delBuddy (param: { + friendUid: string; + tempBlock: boolean; + tempBothDel: boolean; + }): Promise; + + delBatchBuddy (uids: number[]): void; + + getSmartInfos (uid: number): unknown; + + setBuddyCategory (uid: number, category: number): void; + + setBatchBuddyCategory (uids: number[], category: number): void; + + addCategory (category: string): void; + + delCategory (category: string): void; + + renameCategory (oldCategory: string, newCategory: string): void; + + resortCategory (categorys: string[]): void; + + pullCategory (uid: number, category: string): void; + + setTop (uid: number, isTop: boolean): void; + + SetSpecialCare (uid: number, isSpecialCare: boolean): void; + + setMsgNotify (uid: number, isNotify: boolean): void; + + hasBuddyList (): boolean; + + setBlock (uid: number, isBlock: boolean): void; + + isBlocked (uid: number): boolean; + + modifyAddMeSetting (setting: unknown): void; + + getAddMeSetting (): unknown; + + getDoubtBuddyReq (reqId: string, num: number, uk: string): Promise; + + getDoubtBuddyUnreadNum (): number; + + approvalDoubtBuddyReq (uid: string, str1: string, str2: string): void; + + delDoubtBuddyReq (uid: number): void; + + delAllDoubtBuddyReq (): Promise; + + reportDoubtBuddyReqUnread (): void; + + getBuddyRecommendContactArkJson (uid: string, phoneNumber: string): Promise; + + checkIsBuddyAsync (uid: string): Promise; + + areBuddies (callFrom: string, uids: string[]): unknown; + + getCategoryById (id: number): unknown; + + addCategoryV2 (name: string, buddyUids?: unknown): Promise; + + isNull (): boolean; + + getAddFriendBlockedList (): unknown; + + getAddFriendBlockedRedPoint (): unknown; + + reportAddFriendBlocked (): unknown; + + setWXMsgNotify (arg: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelCollectionService.ts b/packages/snowcat-core/services/NodeIKernelCollectionService.ts new file mode 100644 index 0000000..03527aa --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelCollectionService.ts @@ -0,0 +1,91 @@ +import { GeneralCallResult } from './common'; + +export interface NodeIKernelCollectionService { + addKernelCollectionListener (listener: unknown): void;// needs 1 arguments + + removeKernelCollectionListener (listenerId: number): void; + + getCollectionItemList (param: { + category: number, + groupId: number, + forceSync: boolean, + forceFromDb: boolean, + timeStamp: string, + count: number, + searchDown: boolean; + }): Promise, + hasMore: boolean, + bottomTimeStamp: string; + }; + } + >; + + getCollectionContent (arg1: string, arg2: number, arg3: string, arg4: string, arg5: boolean): unknown;// needs 5 arguments + + getCollectionCustomGroupList (): unknown;// needs 0 arguments + + getCollectionUserInfo (): unknown;// needs 0 arguments + + searchCollectionItemList (arg1: string, arg2: unknown): unknown;// needs 2 arguments + + addMsgToCollection (arg1: unknown, arg2: unknown): unknown;// needs 2 arguments + + collectionArkShare (arg: unknown): unknown;// needs 1 arguments + + collectionFileForward (arg1: number, arg2: string, arg3: unknown): unknown;// needs 3 arguments + + downloadCollectionFile (arg1: string, arg2: string, arg3: unknown, arg4: string): unknown;// needs 4 arguments + + downloadCollectionFileThumbPic (arg1: string, arg2: string, arg3: unknown, arg4: number): unknown;// needs 4 arguments + + downloadCollectionPic (arg1: string, arg2: string, arg3: unknown): unknown;// needs 3 arguments + + cancelDownloadCollectionFile (arg: unknown): unknown;// needs 1 arguments + + deleteCollectionItemList (arg: unknown): unknown;// needs 1 arguments + + editCollectionItem (arg1: unknown, arg2: unknown): unknown;// needs 2 arguments + + getEditPicInfoByPath (arg: unknown): unknown;// needs 1 arguments + + collectionFastUpload (arg: unknown): unknown;// needs 1 arguments + + editCollectionItemAfterFastUpload (arg1: unknown, arg2: unknown): unknown;// needs 2 arguments + + createNewCollectionItem (arg: unknown): unknown;// needs 1 arguments +} diff --git a/packages/snowcat-core/services/NodeIKernelConfigMgrService.ts b/packages/snowcat-core/services/NodeIKernelConfigMgrService.ts new file mode 100644 index 0000000..885b0ae --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelConfigMgrService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelConfigMgrService { + addKernelConfigMgrListener (listener: unknown): number; + + removeKernelConfigMgrListener (listenerId: number): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelDbToolsService.ts b/packages/snowcat-core/services/NodeIKernelDbToolsService.ts new file mode 100644 index 0000000..468faab --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelDbToolsService.ts @@ -0,0 +1,9 @@ +export interface NodeIKernelDbToolsService { + + depositDatabase (arg: unknown): unknown; + + backupDatabase (arg: unknown): unknown; + + retrieveDatabase (arg: unknown): unknown; + +} diff --git a/packages/snowcat-core/services/NodeIKernelDirectSessionService.ts b/packages/snowcat-core/services/NodeIKernelDirectSessionService.ts new file mode 100644 index 0000000..1986dad --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelDirectSessionService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelDirectSessionService { + addKernelDirectSessionListener (listener: unknown): number; + + removeKernelDirectSessionListener (listenerId: number): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelECDHService.ts b/packages/snowcat-core/services/NodeIKernelECDHService.ts new file mode 100644 index 0000000..441b6ec --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelECDHService.ts @@ -0,0 +1,13 @@ +export interface NodeIKernelECDHService { + sendOIDBECRequest: (data: Uint8Array) => Promise; + + init (): unknown; + + setIsDebug (isDebug: boolean): unknown; + + setGuid (guid: string): unknown; + + sendOIDBRequest (cmd: number, serviceType: number, subCmd: number, data: string, extraData: unknown): Promise; + + sendSSORequest (cmd: string, serviceType: number, data: string, extraData: unknown): Promise; +} diff --git a/packages/snowcat-core/services/NodeIKernelEmojiService.ts b/packages/snowcat-core/services/NodeIKernelEmojiService.ts new file mode 100644 index 0000000..2a4f23e --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelEmojiService.ts @@ -0,0 +1,5 @@ +export interface NodeIKernelEmojiService { + getAIGCEmojiList (arg1: unknown, arg2: boolean): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelFeedService.ts b/packages/snowcat-core/services/NodeIKernelFeedService.ts new file mode 100644 index 0000000..a845534 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelFeedService.ts @@ -0,0 +1,19 @@ +export interface NodeIKernelFeedService { + addKernelFeedListener (listener: unknown): number; + + removeKernelFeedListener (listenerId: number): void; + + getChannelDraft (arg1: string, arg2: number): unknown; + + getFeedCount (arg: unknown): unknown; + + getFeedLikeUserList (arg1: unknown, arg2: number): unknown; + + getFeedRichMediaFilePath (arg1: number, arg2: string, arg3: string, arg4: number, arg5: boolean): unknown; + + getJoinedRecommendItems (arg1: unknown, arg2: boolean): unknown; + + setChannelDraft (arg1: string, arg2: string, arg3: number): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelFileAssistantService.ts b/packages/snowcat-core/services/NodeIKernelFileAssistantService.ts new file mode 100644 index 0000000..ab122b9 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelFileAssistantService.ts @@ -0,0 +1,41 @@ +import { NodeIKernelFileAssistantListener } from '@/snowcat-core/index'; + +export interface NodeIKernelFileAssistantService { + addKernelFileAssistantListener (listener: NodeIKernelFileAssistantListener): unknown; + + removeKernelFileAssistantListener (arg1: unknown[]): unknown; + + getFileAssistantList (arg1: unknown[]): unknown; + + getMoreFileAssistantList (arg1: unknown[]): unknown; + + getFileSessionList (): unknown; + + searchFile (keywords: string[], params: { resultType: number, pageLimit: number; }, resultId: number): number; + + resetSearchFileSortType (arg1: number, arg2: number, arg3: number): unknown; + + searchMoreFile (arg1: unknown[]): unknown; + + cancelSearchFile (arg1: number, arg2: number, arg3: string): unknown; + + downloadFile (fileIds: string[]): { result: number, errMsg: string; }; + + forwardFile (arg1: unknown, arg2: unknown, arg3: unknown): unknown; + + cancelFileAction (arg1: unknown[]): unknown; + + retryFileAction (arg1: unknown[]): unknown; + + deleteFile (arg1: unknown[]): unknown; + + saveAs (arg1: unknown, arg2: unknown): unknown; + + saveAsWithRename (arg1: string, arg2: string, arg3: string): unknown; + + getFilePathCount (arg: unknown): unknown; + + updateRecentOperateForMsg (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelFileBridgeClientService.ts b/packages/snowcat-core/services/NodeIKernelFileBridgeClientService.ts new file mode 100644 index 0000000..39ce972 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelFileBridgeClientService.ts @@ -0,0 +1,13 @@ +export interface NodeIKernelFileBridgeClientService { + addKernelFileBridgeClientListener (listener: unknown): number; + + removeKernelFileBridgeClientListener (listenerId: number): void; + + getPageContent (arg1: boolean, arg2: string): unknown; + + getThumbnail (arg1: boolean, arg2: string, arg3: unknown): unknown; + + searchFolderForFiles (arg1: string, arg2: string, arg3: string): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelFileBridgeHostService.ts b/packages/snowcat-core/services/NodeIKernelFileBridgeHostService.ts new file mode 100644 index 0000000..cf49a9f --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelFileBridgeHostService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelFileBridgeHostService { + addKernelFileBridgeHostListener (listener: unknown): number; + + removeKernelFileBridgeHostListener (listenerId: number): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelFlashTransferService.ts b/packages/snowcat-core/services/NodeIKernelFlashTransferService.ts new file mode 100644 index 0000000..6fb3e0b --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelFlashTransferService.ts @@ -0,0 +1,303 @@ +import { GeneralCallResult } from './common'; +import { + SendStatus, + StartFlashTaskRequests, + createFlashTransferResult, + FlashBaseRequest, + FlashFileSetInfo, + FileListInfoRequests, + FileListResponse, + DownloadStatusInfo, + SendTargetRequests, + FlashOneFileInfo, + DownloadSceneType, +} from '../data/flash'; + +export interface NodeIKernelFlashTransferService { + /** + * 开始闪传服务 并上传文件/文件夹(可以多选,非常好用) + * @param timestamp + * @param fileInfo + */ + createFlashTransferUploadTask (timestamp: number, fileInfo: StartFlashTaskRequests): Promise; // 2 arg 重点 // 自动上传 + + createMergeShareTask (arg1: unknown, arg2: unknown): unknown; // 2 arg + + updateFlashTransfer (arg1: unknown, arg2: unknown): unknown; // 2 arg + + getFileSetList (arg: unknown): unknown; // 1 arg + + getFileSetListCount (arg: unknown): unknown; // 1 arg + + /** + * 获取file set 的信息 + * @param fileSetIdDict + */ + getFileSet (fileSetIdDict: FlashBaseRequest): Promise; // 1 arg + + /** + * 获取file set 里面的文件信息(文件夹结构) + * @param requestArgs + */ + getFileList (requestArgs: FileListInfoRequests): Promise<{ + rsp: FileListResponse; + }>; // 1 arg 这个方法QQ有bug??? 并没有,是我参数有问题 + + getDownloadedFileCount (arg: unknown): unknown; // 1 arg + + getLocalFileList (arg1: number, arg2: string, arg3: Array[]): unknown; // 3 arg + + batchRemoveUserFileSetHistory (arg: unknown): unknown; // 1 arg + + /** + * 获取分享链接 + * @param fileSetId + */ + getShareLinkReq (fileSetId: string): Promise; + + /** + * 由分享链接到fileSetId + * @param shareCode + */ + getFileSetIdByCode (shareCode: string): Promise; // 1 arg code == share code + + batchRemoveFile (arg: unknown): unknown; // 1 arg + + checkUploadPathValid (arg: unknown): unknown; // 1 arg + + cleanFailedFiles (arg1: number, arg2: Array[]): unknown; // 2 arg + + /** + * 暂停所有的任务 + */ + resumeAllUnfinishedTasks (): unknown; // 0 arg !! + + addFileSetUploadListener (listener: unknown): unknown; // 1 arg + + removeFileSetUploadListener (listenerId: unknown): unknown; // 1 arg + + /** + * 开始上传任务 适用于已暂停的 + * @param fileSetId + */ + startFileSetUpload (fileSetId: unknown): void; // 1 arg 并不是新建任务,应该是暂停后的启动 + + /** + * 结束,无法再次启动 + * @param fileSetId + */ + stopFileSetUpload (fileSetId: string): void; // 1 arg stop 后start无效 + + /** + * 暂停上传 + * @param fileSetId + */ + pauseFileSetUpload (fileSetId: string): void; // 1 arg 暂停上传 + + /** + * 继续上传 + * @param args + */ + resumeFileSetUpload (fileSetId: unknown): unknown; // 1 arg 继续 + + pauseFileUpload (arg: unknown): unknown; // 1 arg + + resumeFileUpload (arg: unknown): unknown; // 1 arg + + stopFileUpload (arg: unknown): unknown; // 1 arg + + asyncGetThumbnailPath (arg1: unknown, arg2: unknown): unknown; // 2 arg + + setDownLoadDefaultFileDir (dir: unknown): unknown; // 1 arg + + setFileSetDownloadDir (arg1: unknown, arg2: unknown): unknown; // 2 arg + + getFileSetDownloadDir (arg: unknown): unknown; // 1 arg + + setFlashTransferDir (arg1: unknown, arg2: unknown): unknown; // 2 arg + + addFileSetDownloadListener (listener: unknown): unknown; // 1 arg + + removeFileSetDownloadListener (listenerId: unknown): unknown; // 1 arg + + /** + * 开始下载file set的函数 同开始上传 + * @param fileSetId + * @param downloadSceneType 下载类型 //因为没有peer,其实可以硬编码为1 (好友私聊) + * @param arg // 默认为false + */ + startFileSetDownload (fileSetId: string, downloadSceneType: DownloadSceneType, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise; // 3 arg + + stopFileSetDownload (fileSetId: string, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise; // 2 arg 结束不可重启!! + + pauseFileSetDownload (fileSetId: string, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise; // 2 arg + + resumeFileSetDownload (fileSetId: string, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise; // 2 arg + + startFileListDownLoad (arg1: string, arg2: number, arg3: Array[], arg4: unknown): unknown; // 4 arg // 大概率是选择set里面的部分文件进行下载,没必要,不想写 + + pauseFileListDownLoad (arg1: unknown, arg2: unknown): unknown; // 2 arg + + resumeFileListDownLoad (arg1: unknown, arg2: unknown): unknown; // 2 arg + + stopFileListDownLoad (arg1: unknown, arg2: unknown): unknown; // 2 arg + + startThumbnailListDownload (fileSetId: string): Promise; // 1 arg // 缩略图下载 + + stopThumbnailListDownload (fileSetId: string): Promise; // 1 arg + + asyncRequestDownLoadStatus (fileSetId: string): Promise; // 1 arg + + startFileTransferUrl (fileInfo: FlashOneFileInfo): Promise<{ + ret: number, + url: string, + expireTimestampSeconds: string; + }>; // 1 arg + + startFileListDownLoadBySessionId (arg1: unknown, arg2: unknown): unknown; // 2 arg + + addFileSetSimpleStatusListener (arg1: unknown, arg2: unknown): unknown; // 2 arg + + addFileSetSimpleStatusMonitoring (arg1: unknown, arg2: unknown): unknown; // 2 arg + + removeFileSetSimpleStatusMonitoring (arg1: unknown, arg2: unknown): unknown; // 2 arg + + removeFileSetSimpleStatusListener (arg: unknown): unknown; // 1 arg + + addDesktopFileSetSimpleStatusListener (arg: unknown): unknown; // 1 arg + + addDesktopFileSetSimpleStatusMonitoring (arg: unknown): unknown; // 1 arg + + removeDesktopFileSetSimpleStatusMonitoring (arg: unknown): unknown; // 1 arg + + removeDesktopFileSetSimpleStatusListener (arg: unknown): unknown; // 1 arg + + addFileSetSimpleUploadInfoListener (arg: unknown): unknown; // 1 arg + + addFileSetSimpleUploadInfoMonitoring (arg: unknown): unknown; // 1 arg + + removeFileSetSimpleUploadInfoMonitoring (arg: unknown): unknown; // 1 arg + + removeFileSetSimpleUploadInfoListener (arg: unknown): unknown; // 1 arg + /** + * 发送闪传消息 + * @param sendArgs + */ + sendFlashTransferMsg (sendArgs: SendTargetRequests): Promise<{ + errCode: number, + errMsg: string, + rsp: { + sendStatus: SendStatus[]; + }; + }>; // 1 arg 估计是file set id + + addFlashTransferTaskInfoListener (listener: unknown): unknown; // 1 arg + + removeFlashTransferTaskInfoListener (listenerId: unknown): unknown; // 1 arg + + retrieveLocalLastFailedSetTasksInfo (): unknown; // 0 arg + + getFailedFileList (fileSetId: string): Promise<{ + rsp: { + seq: number; + result: number; + errMs: string; + fileSetId: string; + fileList: []; + }; + }>; // 1 arg + + getLocalFileListByStatuses (arg: unknown): unknown; // 1 arg + + addTransferStateListener (listener: unknown): unknown; // 1 arg + + removeTransferStateListener (listenerId: unknown): unknown; // 1 arg + + getFileSetFirstClusteringList (arg1: number, arg2: string, arg3: number): unknown; // 3 arg + + getFileSetClusteringList (arg: unknown): unknown; // 1 arg + + addFileSetClusteringListListener (listener: unknown): unknown; // 1 arg + + removeFileSetClusteringListListener (listenerId: unknown): unknown; // 1 arg + + getFileSetClusteringDetail (arg: unknown): unknown; // 1 arg + + doAIOFlashTransferBubbleActionWithStatus (arg1: string, arg2: number, arg3: number, arg4: unknown): unknown; // 4 arg + + getFilesTransferProgress (arg: unknown): unknown; // 1 arg + + pollFilesTransferProgress (arg: unknown): unknown; // 1 arg + + cancelPollFilesTransferProgress (arg: unknown): unknown; // 1 arg + + checkDownloadStatusBeforeLocalFileOper (arg1: number, arg2: string, arg3: string): unknown; // 3 arg + + getCompressedFileFolder (arg: unknown): unknown; // 1 arg + + addFolderListener (listener: unknown): unknown; // 1 arg + + removeFolderListener (listenerId: unknown): unknown; + + addCompressedFileListener (listener: unknown): unknown; + + removeCompressedFileListener (listenerId: unknown): unknown; + + getFileCategoryList (arg: unknown): unknown; + + addDeviceStatusListener (listener: unknown): unknown; + + removeDeviceStatusListener (listenerId: unknown): unknown; + + checkDeviceStatus (arg: unknown): unknown; + + pauseAllTasks (arg1: number, arg2: number): unknown; // 2 arg + + resumePausedTasksAfterDeviceStatus (arg: unknown): unknown; + + onSystemGoingToSleep (arg: unknown): unknown; + + onSystemWokeUp (arg: unknown): unknown; + + getFileMetas (arg: unknown): unknown; + + addDownloadCntStatisticsListener (listener: unknown): unknown; + + removeDownloadCntStatisticsListener (listenerId: unknown): unknown; + + detectPrivacyInfoInPaths (arg: unknown): unknown; + + getFileThumbnailUrl (arg: unknown): unknown; + + handleDownloadFinishAfterSaveToAlbum (arg: unknown): unknown; + + checkBatchFilesDownloadStatus (arg: unknown): unknown; + + onCheckAlbumStorageStatusResult (arg: unknown): unknown; + + addFileAlbumStorageListener (listener: unknown): unknown; + + removeFileAlbumStorageListener (listenerId: unknown): unknown; + + refreshFolderStatus (arg: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelGroupService.ts b/packages/snowcat-core/services/NodeIKernelGroupService.ts new file mode 100644 index 0000000..250e1bb --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelGroupService.ts @@ -0,0 +1,493 @@ +import { NodeIKernelGroupListener } from '@/snowcat-core/listeners/NodeIKernelGroupListener'; +import { + GroupExt0xEF0InfoFilter, + GroupExtParam, + GroupInfoSource, + GroupMember, + NTGroupMemberRole, + GroupNotifyMsgType, + NTGroupRequestOperateTypes, + KickMemberV2Req, + GroupDetailInfoV2Param, + GroupExtInfo, + GroupExtFilter, +} from '@/snowcat-core/types'; +import { GeneralCallResult } from '@/snowcat-core/services/common'; + +export interface NodeIKernelGroupService { + + modifyGroupExtInfoV2 (groupExtInfo: GroupExtInfo, groupExtFilter: GroupExtFilter): Promise; + + // ---> + // 待启用 For Next Version 3.2.0 + // isTroopMember ? 0 : 111 + getGroupMemberMaxNum (groupCode: string, serviceType: number): Promise; + + getAllGroupPrivilegeFlag (troopUinList: string[], serviceType: number): Promise; + // <--- + getGroupExt0xEF0Info (enableGroupCodes: string[], bannedGroupCodes: string[], filter: GroupExt0xEF0InfoFilter, forceFetch: boolean): + Promise; }; }>; + + kickMemberV2 (param: KickMemberV2Req): Promise; + + quitGroupV2 (param: { groupCode: string; needDeleteLocalMsg: boolean; }): Promise; + + getMemberCommonInfo (Req: { + groupCode: string, + startUin: string, + identifyFlag: string, + uinList: string[], + memberCommonFilter: { + memberUin: number, + uinFlag: number, + uinFlagExt: number, + uinMobileFlag: number, + shutUpTime: number, + privilege: number, + }, + memberNum: number, + filterMethod: string, + onlineFlag: string, + realSpecialTitleFlag: number; + }): Promise; + + getGroupMemberLevelInfo (groupCode: string): Promise; + + getGroupInfoForJoinGroup (groupCode: string, needPrivilegeFlag: boolean, serviceType: number): Promise; + + getGroupHonorList (req: { groupCodes: Array; }): Promise; + + getUinByUids (uins: string[]): Promise<{ + errCode: number, + errMsg: string, + uins: Map; + }>; + + getUidByUins (uins: string[]): Promise<{ + errCode: number, + errMsg: string, + uids: Map; + }>; + + checkGroupMemberCache (arrayList: Array): Promise; + + getGroupLatestEssenceList (groupCode: string): Promise; + + shareDigest (Req: { + appId: string, + appType: number, + msgStyle: number, + recvUin: string, + sendType: number, + clientInfo: { + platform: number; + }, + richMsg: { + usingArk: boolean, + title: string, + summary: string, + url: string, + pictureUrl: string, + brief: string; + }; + }): Promise; + + isEssenceMsg (req: { groupCode: string, msgRandom: number, msgSeq: number; }): Promise; + + queryCachedEssenceMsg (req: { groupCode: string, msgRandom: number, msgSeq: number; }): Promise<{ items: Array; }>; + + fetchGroupEssenceList (req: { + groupCode: string, + pageStart: number, + pageLimit: number; + }, Arg: string): Promise; + + getAllMemberList (groupCode: string, forceFetch: boolean): Promise<{ + errCode: number, + errMsg: string, + result: { + ids: Array<{ + uid: string, + index: number;// 0 + }>, + infos: Map, + finish: true, + hasRobot: false; + }; + }>; + + setHeader (uid: string, path: string): Promise; + + addKernelGroupListener (listener: NodeIKernelGroupListener): number; + + removeKernelGroupListener (listenerId: number): void; + + createMemberListScene (groupCode: string, scene: string): string; + + destroyMemberListScene (SceneId: string): void; + + getNextMemberList (sceneId: string, groupMemberInfoListId: { index: number, uid: string; } | undefined, num: number): Promise<{ + errCode: number, + errMsg: string, + result: { ids: string[], infos: Map, finish: boolean, hasRobot: boolean; }; + }>; + + getPrevMemberList (): unknown; + + monitorMemberList (): unknown; + + searchMember (sceneId: string, keywords: string[]): unknown; + + getMemberInfo (group_id: string, uids: string[], forceFetch: boolean): Promise; + + kickMember (groupCode: string, memberUids: string[], refuseForever: boolean, kickReason: string): Promise; + + modifyMemberRole (groupCode: string, uid: string, role: NTGroupMemberRole): void; + + modifyMemberCardName (groupCode: string, uid: string, cardName: string): void; + + getTransferableMemberInfo (groupCode: string): unknown;// 获取整个群的 + + transferGroup (uid: string): void; + + getGroupList (force: boolean): Promise; + + getGroupExtList (force: boolean): Promise; + + getGroupDetailInfo (groupCode: string, groupInfoSource: GroupInfoSource): Promise; + + getMemberExtInfo (param: GroupExtParam): Promise;// req + + getGroupAllInfo (groupId: string, sourceId: number): Promise; + + getDiscussExistInfo (): unknown; + + getGroupConfMember (): unknown; + + getGroupMsgMask (): unknown; + + getGroupPortrait (): void; + + modifyGroupName (groupCode: string, groupName: string, isNormalMember: boolean): Promise; + + modifyGroupRemark (groupCode: string, remark: string): Promise; + + modifyGroupDetailInfo (groupCode: string, arg: unknown): void; + + // 第二个参数在大多数情况为0 设置群成员权限 例如上传群文件权限和群成员付费/加入邀请加入时为8 + modifyGroupDetailInfoV2 (param: GroupDetailInfoV2Param, arg: number): Promise; + + setGroupMsgMask (groupCode: string, arg: unknown): void; + + changeGroupShieldSettingTemp (groupCode: string, arg: unknown): void; + + inviteToGroup (arg: unknown): void; + + inviteMembersToGroup (args: unknown[]): void; + + inviteMembersToGroupWithMsg (args: unknown): void; + + createGroup (arg: unknown): void; + + createGroupWithMembers (arg: unknown): void; + + quitGroup (groupCode: string): void; + + destroyGroup (groupCode: string): void; + + getSingleScreenNotifies (doubt: boolean, startSeq: string, count: number): Promise; + + clearGroupNotifies (groupCode: string): void; + + getGroupNotifiesUnreadCount (doubt: boolean): Promise; + + clearGroupNotifiesUnreadCount (doubt: boolean): void; + + operateSysNotify ( + doubt: boolean, + operateMsg: { + operateType: NTGroupRequestOperateTypes, + targetMsg: { + seq: string, + type: GroupNotifyMsgType, + groupCode: string, + postscript: string; + }; + }): Promise; + + setTop (groupCode: string, isTop: boolean): void; + + getGroupBulletin (groupCode: string): unknown; + + deleteGroupBulletin (groupCode: string, seq: string, noticeId: string): void; + + publishGroupBulletin (groupCode: string, pskey: string, data: unknown): Promise; + + publishInstructionForNewcomers (groupCode: string, arg: unknown): void; + + uploadGroupBulletinPic (groupCode: string, pskey: string, imagePath: string): Promise; + + downloadGroupBulletinRichMedia (groupCode: string): unknown; + + getGroupBulletinList (groupCode: string): unknown; + + getGroupStatisticInfo (groupCode: string): unknown; + + getGroupRemainAtTimes (groupCode: string): Promise & { + errCode: number, + atInfo: { + canAtAll: boolean; + RemainAtAllCountForUin: number; + RemainAtAllCountForGroup: number; + atTimesMsg: string; + canNotAtAllMsg: ''; + }; + }>; + + getJoinGroupNoVerifyFlag (groupCode: string): unknown; + + getGroupArkInviteState (groupCode: string): unknown; + + reqToJoinGroup (groupCode: string, arg: unknown): void; + + setGroupShutUp (groupCode: string, shutUp: boolean): Promise; + + getGroupShutUpMemberList (groupCode: string): Promise; + + setMemberShutUp (groupCode: string, memberTimes: { uid: string, timeStamp: number; }[]): Promise; + + getGroupRecommendContactArkJson (groupCode: string): Promise; + + getJoinGroupLink (param: { + groupCode: string, + srcId: number, // 73 + needShortUrl: boolean, // true + additionalParam: string;// '' + }): Promise; + + modifyGroupExtInfo (groupCode: string, arg: unknown): void; + + addGroupEssence (param: { + groupCode: string; + msgRandom: number, + msgSeq: number; + }): Promise; + + removeGroupEssence (param: { + groupCode: string; + msgRandom: number, + msgSeq: number; + }): Promise; + + isNull (): boolean; + + // --- Methods from IDA binary analysis --- + clearGroupNotifyLocalUnreadCount (groupCode: string, arg: number): unknown; + + getCardAppList (groupCode: string, arg: boolean): unknown; + + getGroupBulletinDetail (arg1: string, arg2: string, arg3: string, arg4: boolean): unknown; + + getGroupBulletinReadUsers (arg1: string, arg2: string, arg3: string, arg4: number, arg5: number, arg6: number): unknown; + + getGroupDetailInfoByFilter (arg1: unknown, arg2: number, arg3: number, arg4: boolean): unknown; + + getGroupDetailInfoForMqq (arg1: string, arg2: number, arg3: number, arg4: boolean): unknown; + + getMemberInfoForMqq (arg1: string, arg2: Array[], arg3: boolean): unknown; + + getMemberInfoForMqqV2 (arg1: string, arg2: Array[], arg3: boolean, arg4: string): unknown; + + getRecGroups (arg1: string, arg2: unknown, arg3: string): unknown; + + getSingleScreenNotifiesV2 (arg1: boolean, arg2: string, arg3: number, arg4: number): unknown; + + modifyWxNotifyStatus (arg1: string, arg2: number): unknown; + + operateSpecialFocus (arg1: string, arg2: Array[], arg3: number): unknown; + + remindGroupBulletinRead (arg1: string, arg2: string, arg3: string): unknown; + + transferGroupV2 (arg1: string, arg2: string, arg3: string): unknown; + + operateSysNotifyV2 (arg1: unknown, arg2: unknown): Promise; + + getAllMemberListV2 (groupCode: string, arg: unknown): unknown; + + createGroupV2 (arg1: unknown, arg2: unknown): unknown; + + modifyGroupExtInfoV2 (groupExtInfo: GroupExtInfo, groupExtFilter: GroupExtFilter): Promise; + + modifyGroupDetailInfoV2 (param: GroupDetailInfoV2Param, arg: number): Promise; + + setGroupMsgMaskV2 (arg1: unknown, arg2: unknown): unknown; + + getGroupSquareRedpointInfo (arg1: unknown, arg2: unknown): unknown; + + getGroupSquareHomeHead (arg1: unknown, arg2: unknown): unknown; + + getCapsuleApp (arg1: unknown, arg2: unknown): unknown; + + getCapsuleAppPro (arg1: unknown, arg2: unknown): unknown; + + getMemberInfoCache (arg1: unknown, arg2: unknown): unknown; + + getGroupSecLevelInfo (arg1: unknown, arg2: unknown): unknown; + + getSubGroupInfo (arg: unknown): unknown; + + getSwitchStatusForEssenceMsg (arg: unknown): unknown; + + getTeamUpDetail (arg: unknown): unknown; + + getTeamUpList (arg: unknown): unknown; + + getTeamUpMembers (arg: unknown): unknown; + + getTeamUpTemplateList (arg: unknown): unknown; + + getTopicPage (arg1: string, arg2: string, arg3: string, arg4: string): unknown; + + getTopicRecall (arg: unknown): unknown; + + getWxNotifyStatus (arg: unknown): unknown; + + getGroupPayToJoinStatus (arg: unknown): unknown; + + getGroupSeqAndJoinTimeForGrayTips (arg: unknown): unknown; + + getGroupTagRecords (arg: unknown): unknown; + + getGroupBindGuilds (arg: unknown): unknown; + + getGroupFlagForThirdApp (arg: unknown): unknown; + + getGroupMsgLimitFreq (arg: unknown): unknown; + + getGroupMedalList (arg: unknown): unknown; + + getGroupDBVersion (arg: unknown): unknown; + + getGroupInviteNoAuthLimitNum (arg: unknown): unknown; + + getAIOBindGuildInfo (arg: unknown): unknown; + + getAppCenter (arg: unknown): unknown; + + getAICommonVoice (arg: unknown): unknown; + + groupBlacklistDelApply (arg: unknown): unknown; + + groupBlacklistGetAllApply (arg: unknown): unknown; + + fetchGroupNotify (arg: unknown): unknown; + + queryJoinGroupCanNoVerify (arg: unknown): unknown; + + halfScreenPullNotice (arg: unknown): unknown; + + halfScreenReportClick (arg: unknown): unknown; + + joinGroup (arg: unknown): unknown; + + listAllAIVoice (arg: unknown): unknown; + + miniAppGetGroupInfo (arg: unknown): unknown; + + postTeamUp (arg: unknown): unknown; + + queryAIOBindGuild (arg: unknown): unknown; + + removeGroupFromGroupList (arg: unknown): unknown; + + saveAIVoice (arg: unknown): unknown; + + setActiveExtGroup (arg: unknown): unknown; + + setAIOBindGuild (arg: unknown): unknown; + + setCapsuleSwitch (arg: unknown): unknown; + + setGroupAppList (arg: unknown): unknown; + + setGroupGeoInfo (arg: unknown): unknown; + + setGroupRelationToGuild (arg: unknown): unknown; + + setRcvJoinVerifyMsg (arg: unknown): unknown; + + teamUpCreateGroup (arg: unknown): unknown; + + teamUpInviteToGroup (arg: unknown): unknown; + + teamUpRequestToJoin (arg: unknown): unknown; + + teamUpSubmitDeadline (arg: unknown): unknown; + + topicFeedback (arg: unknown): unknown; + + topicReport (arg: unknown): unknown; + + shareTopic (arg: unknown): unknown; + + unbindAllGuilds (arg: unknown): unknown; + + updateGroupInfoByMqq (arg: unknown): unknown; + + updateMemberInfoByMqq (arg: unknown): unknown; + + updateTeamUp (arg: unknown): unknown; + + applyTeamUp (arg: unknown): unknown; + + deleteTeamUp (arg: unknown): unknown; + + getFindPageRecommendGroup (arg: unknown): unknown; + + getTransferableMemberInfo (groupCode: string): unknown; + + createGroupProfileShare (arg: unknown): unknown; + + destroyMemberListScene (sceneId: string): void; + + clearGroupSquareRedpointCache (arg: unknown): unknown; + + checkGroupMemberCache (arrayList: Array): Promise; + + cleanCapsuleCache (arg: unknown): unknown; + + downloadGroupBulletinRichMedia (groupCode: string): unknown; + + kickMemberV2 (param: KickMemberV2Req): Promise; + + destroyGroupV2 (arg: unknown): unknown; + + quitGroupV2 (param: { groupCode: string; needDeleteLocalMsg: boolean; }): Promise; + + inviteToGroupV2 (arg: unknown): unknown; + + getGroupMsgMask (): unknown; + + batchQueryCachedGroupDetailInfo (arg: unknown): unknown; + + getGroupMemberLevelInfo (groupCode: string): Promise; + + getIllegalMemberList (arg: unknown): unknown; + + getGroupRecommendContactArkJsonToWechat (arg: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelGroupTabService.ts b/packages/snowcat-core/services/NodeIKernelGroupTabService.ts new file mode 100644 index 0000000..9a13f70 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelGroupTabService.ts @@ -0,0 +1,9 @@ +export interface NodeIKernelGroupTabService { + addListener (listener: unknown): number; + + removeListener (listenerId: number): void; + + getGroupTab (arg1: unknown, arg2: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelHandOffService.ts b/packages/snowcat-core/services/NodeIKernelHandOffService.ts new file mode 100644 index 0000000..f32d4bc --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelHandOffService.ts @@ -0,0 +1,15 @@ +export interface NodeIKernelHandOffService { + addKernelHandOffListener (listener: unknown): number; + + removeKernelHandOffListener (listenerId: number): void; + + changeHandOffActivities (arg: unknown): unknown; + + deleteRecentHandOffActivities (arg: unknown): unknown; + + getHandOffActivities (arg: unknown): unknown; + + sendCapsulePanelActivities (arg1: string, arg2: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelLiteBusinessService.ts b/packages/snowcat-core/services/NodeIKernelLiteBusinessService.ts new file mode 100644 index 0000000..94792ed --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelLiteBusinessService.ts @@ -0,0 +1,19 @@ +export interface NodeIKernelLiteBusinessService { + addListener (listener: unknown): number; + + removeListener (listenerId: number): void; + + clearLiteBusiness (arg1: string, arg2: unknown): unknown; + + clickLiteAction (arg1: unknown, arg2: unknown): unknown; + + exposeLiteAction (arg1: unknown, arg2: unknown): unknown; + + getLiteBusiness (arg1: string, arg2: unknown): unknown; + + getRevealTofuAuthority (arg: unknown): unknown; + + recentRevealExposure (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelLockService.ts b/packages/snowcat-core/services/NodeIKernelLockService.ts new file mode 100644 index 0000000..c034a20 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelLockService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelLockService { + addKernelLockListener (listener: unknown): number; + + removeKernelLockListener (listenerId: number): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelLoginService.ts b/packages/snowcat-core/services/NodeIKernelLoginService.ts new file mode 100644 index 0000000..43cf8b8 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelLoginService.ts @@ -0,0 +1,121 @@ +import { NodeIKernelLoginListener } from '@/snowcat-core/listeners/NodeIKernelLoginListener'; +import { GeneralCallResult } from './common'; + +export interface LoginInitConfig { + machineId: ''; + appid: string; + platVer: string; + commonPath: string; + clientVer: string; + hostName: string; + externalVersion: boolean; +} + +export interface PasswordLoginRetType { + result: string, + loginErrorInfo: { + step: number; + errMsg: string; + proofWaterUrl: string; + newDevicePullQrCodeSig: string; + jumpUrl: string, + jumpWord: string; + tipsTitle: string; + tipsContent: string; + }; +} + +export interface PasswordLoginArgType { + uin: string; + passwordMd5: string;// passwMD5 + step: number;// 猜测是需要二次认证 参数 一次为0 + newDeviceLoginSig: Uint8Array; + proofWaterSig: Uint8Array; + proofWaterRand: Uint8Array; + proofWaterSid: Uint8Array; + unusualDeviceCheckSig: Uint8Array; +} + +export interface LoginListItem { + uin: string; + uid: string; + nickName: string; + faceUrl: string; + facePath: string; + loginType: 1; // 1是二维码登录? + isQuickLogin: boolean; // 是否可以快速登录 + isAutoLogin: boolean; // 是否可以自动登录 +} + +export interface QuickLoginResult { + result: string; + loginErrorInfo: { + step: number, + errMsg: string, + proofWaterUrl: string, + newDevicePullQrCodeSig: string, + jumpUrl: string, + jumpWord: string, + tipsTitle: string, + tipsContent: string; + }; +} + +export interface NodeIKernelLoginService { + getMsfStatus: () => number; + + setLoginMiscData (key: string, value: string): unknown; + + getLoginMiscData (key: string): Promise; + + getMachineGuid (): string; + + get (): NodeIKernelLoginService; + + connect (): boolean; + + addKernelLoginListener (listener: NodeIKernelLoginListener): number; + + removeKernelLoginListener (listenerId: number): void; + + initConfig (config: LoginInitConfig): void; + + getLoginList (): Promise<{ + result: number, + LocalLoginInfoList: LoginListItem[]; + }>; + + quickLoginWithUin (uin: string): Promise; + + passwordLogin (param: PasswordLoginArgType): Promise; + + getQRCodePicture (): boolean; + + destroy (): unknown; + + cancel (): unknown; + + abortPolling (): unknown; + + startPolling (): unknown; + + deleteLoginInfo (arg: unknown): unknown; + + isHasLoginInfo (uin: string): boolean; + + loadNoLoginUnitedConfig (arg: unknown): unknown; + + loginUnusualDevice (arg: unknown): unknown; + + registerUnitedConfigPushGroupList (groupList: unknown): unknown; + + resetLoginInfo (arg: unknown): unknown; + + setAutoLogin (arg: unknown): unknown; + + setRemerberPwd (remember: boolean): unknown; + + online (): unknown; + + offline (): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelMSFService.ts b/packages/snowcat-core/services/NodeIKernelMSFService.ts new file mode 100644 index 0000000..f1b1275 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelMSFService.ts @@ -0,0 +1,57 @@ +import { GeneralCallResult } from './common'; +enum ProxyType { + CLOSE = 0, + HTTP = 1, + SOCKET = 2, +} +export interface NodeIKernelMSFService { + getServerTime (): string; + getMsfStatus (): number; + online (): unknown; + offline (): unknown; + setNetworkProxy (param: { + userName: string, + userPwd: string, + address: string, + port: number, + proxyType: ProxyType, + domain: string, + isSocket: boolean; + }): Promise; + getNetworkProxy (): Promise<{ + userName: string, + userPwd: string, + address: string, + port: number, + proxyType: ProxyType, + domain: string, + isSocket: boolean; + }>; + // http + // userName: '', + // userPwd: '', + // address: '127.0.0.1', + // port: 5666, + // proxyType: 1, + // domain: '', + // isSocket: false + // socket + // userName: '', + // userPwd: '', + // address: '127.0.0.1', + // port: 5667, + // proxyType: 2, + // domain: '', + // isSocket: true + sendMsfRequest (seq: string, cmd: string, reqData: Uint8Array, option: { + sendTimeout: number, + sendTimeoutOnSlowNet: number, + resendNum: number, + sendOptions: number, + reqTargetAccountType: number, + account: string, + accountType: number, + transInfoMap: Map; + }): Promise; + onMsfPushForTesting (arg1: unknown, arg2: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelMiniAppService.ts b/packages/snowcat-core/services/NodeIKernelMiniAppService.ts new file mode 100644 index 0000000..a6e5cc2 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelMiniAppService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelMiniAppService { + addKernelMiniAppListener (listener: unknown): number; + + removeKernelMiniAppListener (listenerId: number): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelMsgBackupService.ts b/packages/snowcat-core/services/NodeIKernelMsgBackupService.ts new file mode 100644 index 0000000..b32db2f --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelMsgBackupService.ts @@ -0,0 +1,27 @@ +export interface NodeIKernelMsgBackupService { + addKernelMsgBackupListener (listener: unknown): number; + + removeKernelMsgBackupListener (listenerId: number): void; + + getMsgBackupLocation (): unknown;// needs 0 arguments + + setMsgBackupLocation (arg: unknown): unknown;// needs 1 arguments + + requestMsgBackup (): unknown;// needs 0 arguments + + requestMsgRestore (arg: unknown): unknown;// needs 1 arguments + + requestMsgMigrate (arg: unknown): unknown;// needs 1 arguments + + getLocalStorageBackup (): unknown;// needs 0 arguments + + deleteLocalBackup (arg: unknown): unknown;// needs 1 arguments + + clearCache (): unknown;// needs 0 arguments + + start (arg: unknown): unknown;// needs 1 arguments + + stop (arg: unknown): unknown;// needs 1 arguments + + pause (arg1: unknown, arg2: unknown): unknown;// needs 2 arguments +} diff --git a/packages/snowcat-core/services/NodeIKernelMsgService.ts b/packages/snowcat-core/services/NodeIKernelMsgService.ts new file mode 100644 index 0000000..6afbd2d --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelMsgService.ts @@ -0,0 +1,747 @@ +import { ElementType, MessageElement, Peer, RawMessage, FileElement, SendMessageElement, AvRecordElement, TofuRecordElement } from '@/snowcat-core/types'; +import { NodeIKernelMsgListener } from '@/snowcat-core/listeners/NodeIKernelMsgListener'; +import { GeneralCallResult } from '@/snowcat-core/services/common'; +import { MsgReqType, QueryMsgsParams, TmpChatInfoApi, MsgTypeFilter, MsgIdentity, SgrpStreamParams, GrayTipJsonInfo, ForwardFileInfo, LocalGrayTipInfo, TokenInfo, BackGroundInfo } from '@/snowcat-core/types/msg'; + +export interface NodeIKernelMsgService { + buildMultiForwardMsg (req: { srcMsgIds: Array, srcContact: Peer; }): Promise; + + generateMsgUniqueId (chatType: number, time: string): string; + + addKernelMsgListener (nodeIKernelMsgListener: NodeIKernelMsgListener): number; + + sendMsg (msgId: string, peer: Peer, msgElements: SendMessageElement[], map: Map): Promise; + + recallMsg (peer: Peer, msgIds: string[]): Promise; + + addKernelMsgImportToolListener (listener: unknown): string; + + removeKernelMsgListener (listenerId: string): void; + + addKernelTempChatSigListener (listener: unknown): string; + + removeKernelTempChatSigListener (listenerId: string): void; + + setAutoReplyTextList (AutoReplyText: Array, i2: number): unknown; + + getAutoReplyTextList (): unknown; + + getOnLineDev (): void; + + kickOffLine (DevInfo: unknown): unknown; + + setStatus (args: { status: number, extStatus: number, batteryStatus: number, customStatus?: { faceId: string, wording: string, faceType: string; }; }): Promise; + + fetchStatusMgrInfo (): unknown; + + fetchStatusUnitedConfigInfo (): unknown; + + getOnlineStatusSmallIconBasePath (): unknown; + + getOnlineStatusSmallIconFileNameByUrl (Url: string): unknown; + + downloadOnlineStatusSmallIconByUrl (arg0: number, arg1: string): unknown; + + getOnlineStatusBigIconBasePath (): unknown; + + downloadOnlineStatusBigIconByUrl (arg0: number, arg1: string): unknown; + + getOnlineStatusCommonPath (arg: string): unknown; + + getOnlineStatusCommonFileNameByUrl (Url: string): unknown; + + downloadOnlineStatusCommonByUrl (arg0: string, arg1: string): unknown; + + setToken (tokenInfo: TokenInfo): Promise; + + switchForeGround (): unknown; + + switchBackGround (info: BackGroundInfo): Promise; + + setTokenForMqq (token: string): unknown; + + switchForeGroundForMqq (data: string | Uint8Array): Promise; + + switchBackGroundForMqq (data: string | Uint8Array): Promise; + + getMsgSetting (): unknown; + + setMsgSetting (setting: unknown): unknown; + + addSendMsg (msgId: string, peer: Peer, msgElements: SendMessageElement[], map: Map): unknown; + + cancelSendMsg (peer: Peer, msgId: string): Promise; + + switchToOfflineSendMsg (peer: Peer, MsgId: string): unknown; + + reqToOfflineSendMsg (peer: Peer, msgId: string): unknown; + + refuseReceiveOnlineFileMsg (peer: Peer, MsgId: string): unknown; + + resendMsg (peer: Peer, msgId: string): Promise; + + reeditRecallMsg (peer: Peer, msgId: string): unknown; + + forwardMsg (msgIds: string[], peer: Peer, dstPeers: Peer[], commentElements: unknown): Promise; + + forwardMsgWithComment (msgIds: string[], srcContact: Peer, dstContacts: Peer[], commentElements: Array, arg5: unknown): unknown; + + forwardSubMsgWithComment (msgIds: string[], subMsgIds: string[], srcContact: Peer, dstContacts: Peer[], commentElements: Array, arg6: unknown): unknown; + + forwardRichMsgInVist (richMsgInfos: Array, dstContacts: Peer[]): unknown; + + forwardFile (fileInfo: ForwardFileInfo, peer: Peer): unknown; + + multiForwardMsg (peer: Peer, srcContact: Peer, msgIds: string[]): unknown; + + multiForwardMsgWithComment (msgInfos: Array, srcContact: Peer, dstContact: Peer, commentElements: Array, arg5: unknown): unknown; + + deleteRecallMsg (peer: Peer, msgId: string): unknown; + + deleteRecallMsgForLocal (peer: Peer, msgId: string): unknown; + + addLocalGrayTipMsg (peer: Peer, grayTipInfo: LocalGrayTipInfo, isUnread: boolean): unknown; + + addLocalJsonGrayTipMsg (arg1: Peer, arg2: GrayTipJsonInfo, arg3: boolean, arg4: boolean): unknown; + + addLocalJsonGrayTipMsgExt (arg1: Peer, arg2: MsgIdentity, arg3: GrayTipJsonInfo, arg4: boolean, arg5: boolean): unknown; + + IsLocalJsonTipValid (tipType: number): boolean; + + addLocalAVRecordMsg (peer: Peer, avRecord: AvRecordElement): unknown; + + addLocalTofuRecordMsg (peer: Peer, tofuRecord: TofuRecordElement): unknown; + + addLocalRecordMsg (Peer: Peer, msgId: string, ele: MessageElement, attr: Array | number, front: boolean): Promise; + + addLocalRecordMsgWithExtInfos (peer: Peer, msgId: string, extInfos: unknown): unknown; + + deleteMsg (Peer: Peer, msgIds: Array): Promise; + + updateElementExtBufForUI (arg1: Peer, arg2: string, arg3: string, arg4: string | Uint8Array): unknown; + + updateMsgRecordExtPbBufForUI (arg1: Peer, arg2: string, arg3: unknown): unknown; + + startMsgSync (): unknown; + + startGuildMsgSync (): unknown; + + isGuildChannelSync (): unknown; + + getMsgUniqueId (UniqueId: string): string; + + isMsgMatched (matchInfo: unknown): unknown; + + getOnlineFileMsgs (peer: Peer): Promise; + + getAllOnlineFileMsgs (): unknown; + + getLatestDbMsgs (peer: Peer, cnt: number): Promise; + + getLastMessageList (peer: Peer[]): Promise; + + getAioFirstViewLatestMsgs (peer: Peer, num: number): Promise; + + // getMsgService/getMsgs { chatType: 2, peerUid: '975206796', privilegeFlag: 336068800 } 0 20 true + getMsgs (peer: Peer & { privilegeFlag: number; }, msgId: string, count: number, queryOrder: boolean): Promise; + + getMsgsIncludeSelf (peer: Peer, msgId: string, count: number, queryOrder: boolean): Promise; + + // @deprecated + getMsgsWithMsgTimeAndClientSeqForC2C (peer: Peer, arg2: string, arg3: string, arg4: number, arg5: boolean, arg6: boolean, arg7: boolean): Promise; + + getMsgsWithStatus (params: { + peer: Peer; + msgId: string; + msgTime: unknown; + cnt: unknown; + queryOrder: boolean; + isIncludeSelf: boolean; + appid: unknown; + }): Promise; + + getMsgsBySeqRange (peer: Peer, startSeq: string, endSeq: string): Promise; + + // @deprecated + getMsgsBySeqAndCount (peer: Peer, seq: string, count: number, desc: boolean, isReverseOrder: boolean): Promise; + + getMsgsByMsgId (peer: Peer, ids: string[]): Promise; + + getRecallMsgsByMsgId (peer: Peer, MsgId: string[]): Promise; + + getMsgsBySeqList (peer: Peer, seqList: string[]): Promise; + + getMsgsExt (msgReq: MsgReqType): Promise; + + getSingleMsg (Peer: Peer, msgSeq: string): Promise; + + // 下面的msgid全部不真实 + getSourceOfReplyMsg (peer: Peer, msgId: string, sourceSeq: string): Promise; + + // 用法和聊天记录一样 + getSourceOfReplyMsgV2 (peer: Peer, rootMsgId: string, replyMsgId: string): Promise; + + getMsgByClientSeqAndTime (peer: Peer, clientSeq: string, time: string): Promise; + + getSourceOfReplyMsgByClientSeqAndTime (peer: Peer, clientSeq: string, time: string, replyMsgId: string): Promise; + + getMsgsByTypeFilter (peer: Peer, msgId: string, cnt: Array, queryOrder: boolean, typeFilter: { + type: number, + subtype: Array; + }): Promise; + + getMsgsByTypeFilters (peer: Peer, msgId: string, cnt: number, queryOrder: boolean, typeFilters: Array<{ + type: number, + subtype: Array; + }>): Promise; + + getMsgWithAbstractByFilterParam (arg1: Peer, arg2: string, arg3: string, arg4: number, arg5: MsgTypeFilter): Promise; + + queryMsgsWithFilter (msgId: string, msgTime: string, param: QueryMsgsParams): Promise; + + // queryMsgsWithFilterVer2(MsgId: string, MsgTime: string, param: QueryMsgsParams): Promise; + + queryMsgsWithFilterEx (msgId: string, msgTime: string, megSeq: string, param: QueryMsgsParams): Promise; + + queryFileMsgsDesktop (msgId: string, msgTime: string, msgSeq: string, param: QueryMsgsParams): Promise; + + setMsgRichInfoFlag (flag: boolean): void; + + queryPicOrVideoMsgs (msgId: string, msgTime: string, megSeq: string, param: QueryMsgsParams): Promise; + + queryPicOrVideoMsgsDesktop (msgId: string, msgTime: string, msgSeq: string, param: QueryMsgsParams): unknown; + + queryEmoticonMsgs (msgId: string, msgTime: string, msgSeq: string, Params: QueryMsgsParams): Promise; + + queryTroopEmoticonMsgs (msgId: string, msgTime: string, msgSeq: string, Params: QueryMsgsParams): Promise; + + queryMsgsAndAbstractsWithFilter (msgId: string, msgTime: string, megSeq: string, param: QueryMsgsParams): unknown; + + setFocusOnGuild (arg: unknown): unknown; + + setFocusSession (arg: unknown): unknown; + + enableFilterUnreadInfoNotify (arg: unknown): unknown; + + enableFilterMsgAbstractNotify (arg: unknown): unknown; + + onScenesChangeForSilenceMode (arg: unknown): unknown; + + getContactUnreadCnt (peers: Peer[]): unknown; + + getUnreadCntInfo (arg: unknown): unknown; + + getGuildUnreadCntInfo (arg: unknown): unknown; + + getGuildUnreadCntTabInfo (arg: unknown): unknown; + + getAllGuildUnreadCntInfo (arg: unknown): unknown; + + getAllJoinGuildCnt (arg: unknown): unknown; + + getAllDirectSessionUnreadCntInfo (arg: unknown): unknown; + + getCategoryUnreadCntInfo (arg: unknown): unknown; + + getGuildFeedsUnreadCntInfo (arg: unknown): unknown; + + setUnVisibleChannelCntInfo (arg: unknown): unknown; + + setUnVisibleChannelTypeCntInfo (arg: unknown): unknown; + + setVisibleGuildCntInfo (arg: unknown): unknown; + + setMsgRead (peer: Peer): Promise; + + setAllC2CAndGroupMsgRead (): Promise; + + setGuildMsgRead (arg: unknown): unknown; + + setAllGuildMsgRead (arg: unknown): unknown; + + setMsgReadAndReport (peer: Peer, msg: RawMessage): unknown; + + setSpecificMsgReadAndReport (arg1: Peer, arg2: string): unknown; + + setLocalMsgRead (peer: Peer): unknown; + + setGroupGuildMsgRead (arg: unknown): unknown; + + getGuildGroupTransData (arg: unknown): unknown; + + setGroupGuildBubbleRead (arg: unknown): unknown; + + getGuildGroupBubble (arg: unknown): unknown; + + fetchGroupGuildUnread (arg: unknown): unknown; + + setGroupGuildFlag (arg: unknown): unknown; + + setGuildUDCFlag (arg: unknown): unknown; + + setGuildTabUserFlag (arg: unknown): unknown; + + setBuildMode (flag: number/* 0 1 3 */): unknown; + + setConfigurationServiceData (arg: unknown): unknown; + + setMarkUnreadFlag (peer: Peer, unread: boolean): unknown; + + getChannelEventFlow (arg: unknown): unknown; + + getMsgEventFlow (arg: unknown): unknown; + + getRichMediaFilePathForMobileQQSend (arg: unknown): unknown; + + getRichMediaFilePathForGuild (arg: { + md5HexStr: string, + fileName: string, + elementType: ElementType, + elementSubType: number, + thumbSize: 0, + needCreate: true, + downloadType: 1, + file_uuid: ''; + }): string; + + assembleMobileQQRichMediaFilePath (arg: unknown): unknown; + + getFileThumbSavePathForSend (thumbSize: number, createNeed: boolean): string; + + getFileThumbSavePath (arg1: string, arg2: number, arg3: boolean): unknown; + + translatePtt2Text (msgId: string, peer: Peer, msgElement: MessageElement): Promise; + + setPttPlayedState (arg1: string, arg2: Peer, arg3: string): unknown; + + fetchFavEmojiList (str: string, num: number, backward: boolean, forceRefresh: boolean): Promise; + }>; + + addFavEmoji (arg: { + emojiId?: string, + packageId?: number, + emojiPath: string, + fileSize: string, + fileName: string, + md5: string, + isMarkFace?: boolean, + isOrigin?: boolean, + }): unknown; + + fetchMarketEmoticonList (arg1: number, arg2: number): unknown; + + fetchMarketEmoticonShowImage (arg: unknown): unknown; + + fetchMarketEmoticonAioImage (arg: unknown): unknown; + + fetchMarketEmotionJsonFile (arg: unknown): unknown; + + getMarketEmoticonPath (arg1: number, arg2: Array[], arg3: number): unknown; + + getMarketEmoticonPathBySync (arg1: number, arg2: Array[], arg3: number): unknown; + + fetchMarketEmoticonFaceImages (arg: unknown): unknown; + + fetchMarketEmoticonAuthDetail (arg: unknown): unknown; + + getFavMarketEmoticonInfo (tabId: number, emojiId: string): unknown; + + addRecentUsedFace (arg: unknown): unknown; + + getRecentUsedFaceList (arg: unknown): unknown; + + getMarketEmoticonEncryptKeys (arg1: number, arg2: Array[]): unknown; + + downloadEmojiPic (arg1: number, arg2: Array[], arg3: number, arg4: Map): unknown; + + deleteFavEmoji (arg: string[]): unknown; + + modifyFavEmojiDesc (arg: Array<{ + emojiId: number, + resId: string, + md5: string, + desc: string, + }>): unknown; + + queryFavEmojiByDesc (arg: unknown): unknown; + + getHotPicInfoListSearchString (arg1: string, arg2: string, arg3: number, arg4: number, arg5: boolean): unknown; + + getHotPicSearchResult (arg: unknown): unknown; + + getHotPicHotWords (arg: unknown): unknown; + + getHotPicJumpInfo (arg: unknown): unknown; + + getEmojiResourcePath (arg: unknown): unknown; + + JoinDragonGroupEmoji (JoinDragonGroupEmojiReq: { + latestMsgSeq: string, + manageEmojiId: number, + manageMsgSeq: string, + peerContact: Peer; + }): Promise; + + getMsgAbstracts (arg: unknown): unknown; + + getMsgAbstract (arg1: Peer, arg2: string): unknown; + + getMsgAbstractList (arg: unknown): unknown; + + getMsgAbstractListBySeqRange (arg: unknown): unknown; + + refreshMsgAbstracts (arg: unknown): unknown; + + refreshMsgAbstractsByGuildIds (arg: unknown): unknown; + + getRichMediaElement (arg: { + msgId: string, + peerUid: string, + chatType: number, + elementId: string, + downSourceType: number, + downloadType: number, + }): Promise; + + cancelGetRichMediaElement (arg: unknown): unknown; + + refuseGetRichMediaElement (args: { + msgId: string, + peerUid: string, + chatType: number, + elementId: string, + downloadType: number, // 1 + downSourceType: number, // 1 + }): Promise; + + switchToOfflineGetRichMediaElement (arg: unknown): unknown; + + downloadRichMedia (args: { + fileModelId: string, + downSourceType: number, + triggerType: number, + msgId: string, + chatType: number, + peerUid: string, + elementId: string, + thumbSize: number, + downloadType: number, + filePath: string; + } & { + downloadSourceType: number, // 33800左右一下的老版本 新版34606已经完全上面格式 + }): unknown; + + getFirstUnreadMsgSeq (args: { + peerUid: string; + guildId: string; + }): Promise; + + getFirstUnreadCommonMsg (arg: unknown): unknown; + + getFirstUnreadAtmeMsg (peer: Peer): unknown; + + getFirstUnreadAtallMsg (peer: Peer): unknown; + + getNavigateInfo (arg: unknown): unknown; + + getChannelFreqLimitInfo (arg: unknown): unknown; + + getRecentUseEmojiList (): unknown; + + getRecentEmojiList (arg: unknown): unknown; + + setMsgEmojiLikes (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, setOrCancel: boolean): unknown; + + getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string, bForward: boolean, number: number): Promise<{ + result: number, + errMsg: string, + emojiLikesList: + Array<{ + tinyId: string, + nickName: string, + headUrl: string; + }>, + cookie: string, + isLastPage: boolean, + isFirstPage: boolean; + }>; + + setMsgEmojiLikesForRole (arg1: Peer, arg2: string, arg3: string, arg4: string, arg5: string, arg6: string, arg7: boolean, arg8: boolean, arg9: SgrpStreamParams): unknown; + + clickInlineKeyboardButton (params: { + guildId?: string, + peerId: string, + botAppid: string, + msgSeq: string, + buttonId: string, + callback_data: string, + dmFlag: number, + chatType: number; // 1私聊 2群 + }): Promise; + + setCurOnScreenMsg (arg: unknown): unknown; + + setCurOnScreenMsgForMsgEvent (peer: Peer, msgRegList: Map): void; + + getMiscData (key: string): unknown; + + setMiscData (key: string, value: string): unknown; + + getBookmarkData (key: string): unknown; + + setBookmarkData (key: string, value: string): unknown; + + sendShowInputStatusReq (ChatType: number, EventType: number, toUid: string): Promise; + + queryCalendar (peer: Peer, msgTime: number): unknown; + + queryFirstMsgSeq (peer: Peer, msgTime: number): unknown; + + queryRoamCalendar (peer: Peer, msgTime: number): unknown; + + queryFirstRoamMsg (peer: Peer, msgTime: number): unknown; + + fetchLongMsg (peer: Peer, msgId: string): unknown; + + fetchLongMsgWithCb (peer: Peer, msgId: number): unknown; + + setIsStopKernelFetchLongMsg (arg: unknown): unknown; + + insertGameResultAsMsgToDb (arg: unknown): unknown; + + getMultiMsg (arg1: Peer, arg2: string, arg3: string): Promise; + + setDraft (arg1: Peer, arg2: Array[]): unknown; + + getDraft (peer: Peer): unknown; + + deleteDraft (peer: Peer): unknown; + + getRecentHiddenSesionList (): unknown; + + setRecentHiddenSession (arg: unknown): unknown; + + delRecentHiddenSession (arg: unknown): unknown; + + getCurHiddenSession (): unknown; + + setCurHiddenSession (arg: unknown): unknown; + + setReplyDraft (arg1: Peer, arg2: string, arg3: Array[]): unknown; + + getReplyDraft (arg1: Peer, arg2: string): unknown; + + deleteReplyDraft (arg1: Peer, arg2: string): unknown; + + getFirstUnreadAtMsg (peer: Peer): unknown; + + clearMsgRecords (peer: Peer): unknown; + + IsExistOldDb (): unknown; + + canImportOldDbMsg (): unknown; + + setPowerStatus (isPowerOn: boolean): unknown; + + canProcessDataMigration (): unknown; + + importOldDbMsg (): unknown; + + stopImportOldDbMsgAndroid (): unknown; + + isMqqDataImportFinished (): unknown; + + getMqqDataImportTableNames (): unknown; + + getCurChatImportStatusByUin (arg1: unknown, arg2: unknown): unknown; + + getDataImportUserLevel (): unknown; + + getMsgQRCode (): unknown; + + getGuestMsgAbstracts (arg: unknown): unknown; + + getGuestMsgByRange (arg: unknown): unknown; + + getGuestMsgAbstractByRange (arg: unknown): unknown; + + registerSysMsgNotification (arg1: number, arg2: string, arg3: Array[]): unknown; + + unregisterSysMsgNotification (arg1: number, arg2: string, arg3: Array[]): unknown; + + enterOrExitAio (arg: unknown): unknown; + + prepareTempChat (args: unknown): unknown; + + sendSsoCmdReqByContend (cmd: string, param: unknown): Promise; + + getTempChatInfo (ChatType: number, Uid: string): Promise; + + setContactLocalTop (peer: Peer, isTop: boolean): unknown; + + switchAnonymousChat (arg1: string, arg2: boolean): unknown; + + renameAnonyChatNick (arg: unknown): unknown; + + getAnonymousInfo (peer: Peer): unknown; + + updateAnonymousInfo (peer: Peer, arg2: unknown): unknown; + + sendSummonMsg (peer: Peer, MsgElement: unknown, MsgAttributeInfo: unknown): Promise;// 频道的东西 + + outputGuildUnreadInfo (arg: unknown): unknown; + + checkMsgWithUrl (arg: unknown): unknown; + + checkTabListStatus (): unknown; + + getABatchOfContactMsgBoxInfo (arg: unknown): unknown; + + insertMsgToMsgBox (peer: Peer, msgId: string, arg: 2006): unknown; + + isHitEmojiKeyword (arg: unknown): unknown; + + getKeyWordRelatedEmoji (arg: unknown): unknown; + + recordEmoji (type: number, emojiList: Array): unknown; + + fetchGetHitEmotionsByWord (args: unknown): Promise;// 表情推荐? + + deleteAllRoamMsgs (arg1: number, arg2: string): unknown;// 漫游消息? + + packRedBag (arg: unknown): unknown; + + grabRedBag (arg: unknown): unknown; + + pullDetail (arg: unknown): unknown; + + selectPasswordRedBag (arg: unknown): unknown; + + pullRedBagPasswordList (): unknown; + + requestTianshuAdv (arg: unknown): unknown; + + tianshuReport (arg: unknown): unknown; + + tianshuMultiReport (arg: unknown): unknown; + + GetMsgSubType (a0: number, a1: number): unknown; + + setIKernelPublicAccountAdapter (arg: unknown): unknown; + + // tempChatGameSession有关 + createUidFromTinyId (fromTinyId: string, toTinyId: string): string; + + dataMigrationGetDataAvaiableContactList (): unknown; + + dataMigrationGetMsgList (arg1: unknown, arg2: unknown): unknown; + + dataMigrationStopOperation (arg: unknown): unknown; + + dataMigrationImportMsgPbRecord (DataMigrationMsgInfo: Array<{ + extensionData: string;// "Hex" + extraData: string; // "" + chatType: number; + chatUin: string; + msgType: number; + msgTime: string; + msgSeq: string; + msgRandom: string; + }>, DataMigrationResourceInfo: { + extraData: string; + filePath: string; + fileSize: string; + msgRandom: string; + msgSeq: string; + msgSubType: number; + msgType: number; + }): unknown; + + dataMigrationGetResourceLocalDestinyPath (arg: unknown): unknown; + + dataMigrationSetIOSPathPrefix (arg: unknown): unknown; + + getServiceAssistantSwitch (arg: unknown): unknown; + + setServiceAssistantSwitch (arg: unknown): unknown; + + setSubscribeFolderUsingSmallRedPoint (arg: unknown): unknown; + + clearGuildNoticeRedPoint (arg: unknown): unknown; + + clearFeedNoticeRedPoint (arg: unknown): unknown; + + clearFeedSquareRead (arg: unknown): unknown; + + IsC2CStyleChatType (chatType: unknown): unknown; + + IsTempChatType (uin: number): unknown;// 猜的 + + getGuildInteractiveNotification (arg: unknown): unknown; + + getGuildNotificationAbstract (arg: unknown): unknown; + + setFocusOnBase (arg: unknown): unknown; + + queryArkInfo (arg: unknown): unknown; + + queryUserSecQuality (): unknown; + + getGuildMsgAbFlag (arg: unknown): unknown; + + getGroupMsgStorageTime (): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelNearbyProService.ts b/packages/snowcat-core/services/NodeIKernelNearbyProService.ts new file mode 100644 index 0000000..efc8e09 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelNearbyProService.ts @@ -0,0 +1,11 @@ +export interface NodeIKernelNearbyProService { + addKernelNearbyProListener (listener: unknown): number; + + removeKernelNearbyProListener (listenerId: number): void; + + fetchNearbyProUserInfo (arg1: unknown[], arg2: unknown, arg3: boolean): unknown; + + setCommonExtInfo (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelNodeMiscService.ts b/packages/snowcat-core/services/NodeIKernelNodeMiscService.ts new file mode 100644 index 0000000..5b2cdd7 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelNodeMiscService.ts @@ -0,0 +1,131 @@ +import { GeneralCallResult } from './common'; + +export interface NodeIKernelNodeMiscService { + writeVersionToRegistry (version: string): void; + + getMiniAppPath (): unknown; + + setMiniAppVersion (version: string): unknown; + + wantWinScreenOCR (imagepath: string): Promise; + + SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown; + + startNewMiniApp (appfile: string, params: string): unknown; + + getQimei36WithNewSdk (): Promise; + + adaptMiniAppShareInfo (arg: unknown): unknown; + + addBind (arg1: unknown, arg2: unknown): unknown; + + changeSendKey (arg: unknown): unknown; + + checkIfHaveAvailableSidecarDevice (arg: unknown): unknown; + + clearQzoneUnreadCount (arg: unknown): unknown; + + clearQzoneUnreadCountWithRedDot (arg: unknown): unknown; + + closeWXMiniApp (arg: unknown): unknown; + + delBind (arg: unknown): unknown; + + deleteShareFile (arg: unknown): unknown; + + dispatchWmpfEvent (arg: unknown): unknown; + + doAction (arg1: unknown, arg2: unknown): unknown; + + doPostAction (arg1: unknown, arg2: unknown): unknown; + + downloadMiniApp (arg: unknown): unknown; + + downloadMiniGame (arg: unknown): unknown; + + encodeAES (arg1: unknown, arg2: unknown): unknown; + + flashWindowInTaskbar (arg1: unknown, arg2: unknown): unknown; + + getAppLaunchInfo (arg: unknown): unknown; + + getCurWindowInfo (arg: unknown): unknown; + + getCurWindowInfoExceptList (arg: unknown): unknown; + + getMiniGameV2EngineConfig (arg: unknown): unknown; + + getMyAppList (arg: unknown): unknown; + + getOpenAuth (arg1: unknown, arg2: unknown): unknown; + + getQQlevelInfo (arg: unknown): unknown; + + getQzoneUnreadCount (arg: unknown): unknown; + + installApp (arg1: unknown, arg2: unknown): unknown; + + isAppInstalled (arg: unknown): unknown; + + isOldQQRunning (arg: unknown): unknown; + + judgeTimingRequest (arg: unknown): unknown; + + listenWindowEvents (arg: unknown): unknown; + + loginWXMiniApp (arg: unknown): unknown; + + openFileAndDirSelectDlg (arg: unknown): unknown; + + prefetch (arg: unknown): unknown; + + qqConnectBatchShare (arg1: unknown, arg2: unknown): unknown; + + qqConnectShare (arg: unknown): unknown; + + qqConnectShareCheck (arg: unknown): unknown; + + registerSchemes (arg: unknown): unknown; + + registerScreenCaptureShortcutWithKeycode (arg: unknown): unknown; + + registerScreenRecordShortcutWithKeycode (arg: unknown): unknown; + + removeQuarantineAttribute (arg: unknown): unknown; + + reportExecuteRequest (arg: unknown): unknown; + + scanQBar (arg: unknown): unknown; + + sendMessageResponseToWX (arg1: unknown, arg2: unknown): unknown; + + sendRequestToApiGateway (arg: unknown): unknown; + + sendWXCustomMenuClickedAction (arg1: unknown, arg2: unknown): unknown; + + setBackgroudWindowLevel (arg1: unknown, arg2: unknown): unknown; + + setMiniGameVersion (arg: unknown): unknown; + + setVulkanEnable (arg: unknown): unknown; + + setWindowLevelNT (arg1: unknown, arg2: unknown): unknown; + + setWindowsMenuInstallStatus (arg: unknown): unknown; + + setWXCustomMenuConfig (arg1: unknown, arg2: unknown): unknown; + + startNewApp (arg: unknown): unknown; + + startScreenCapture (arg1: unknown, arg2: unknown): unknown; + + stopFlashWindow (arg: unknown): unknown; + + unlistenWindowEvents (arg: unknown): unknown; + + unregisterHotkey (arg: unknown): unknown; + + writeBitmapToClipboard (arg: unknown): unknown; + + writeClipboard (arg1: unknown, arg2: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelOnlineStatusService.ts b/packages/snowcat-core/services/NodeIKernelOnlineStatusService.ts new file mode 100644 index 0000000..a7a2e28 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelOnlineStatusService.ts @@ -0,0 +1,29 @@ +export interface NodeIKernelOnlineStatusService { + + addKernelOnlineStatusListener (listener: unknown): number; + + removeKernelOnlineStatusListener (listenerId: number): void; + + getShouldShowAIOStatusAnimation (arg: unknown): unknown; + + setReadLikeList (arg: unknown): unknown; + + getLikeList (arg: unknown): Promise; + + setLikeStatus (arg: unknown): Promise; + + setOnlineStatusLiteBusinessSwitch (enabled: boolean): void; + + getAggregationPageEntrance (): unknown; + + didClickAggregationPageEntrance (): unknown; + + getAggregationGroupModels (): unknown; + + checkLikeStatus (param: { + businessType: number, + uins: string[]; + }): Promise; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelPersonalAlbumService.ts b/packages/snowcat-core/services/NodeIKernelPersonalAlbumService.ts new file mode 100644 index 0000000..24ead3f --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelPersonalAlbumService.ts @@ -0,0 +1,75 @@ +export interface NodeIKernelPersonalAlbumService { + addAlbumPermissions (arg: unknown): unknown; + + addComment (arg: unknown): unknown; + + addReply (arg: unknown): unknown; + + createAlbum (arg: unknown): unknown; + + delBatchPhoto (arg: unknown): unknown; + + deleteAlbum (arg: unknown): unknown; + + deleteComment (arg: unknown): unknown; + + deleteReply (arg: unknown): unknown; + + doLike (arg: unknown): unknown; + + editAlbum (arg: unknown): unknown; + + editTravelAlbumScence (arg: unknown): unknown; + + forwardAlbumToQzone (arg: unknown): unknown; + + getAlbumInviteJoinPage (arg: unknown): unknown; + + getAlbumJoinApprovalPage (arg: unknown): unknown; + + getAlbumList (arg: unknown): unknown; + + getAlbumMemberList (arg: unknown): unknown; + + getCommentList (arg: unknown): unknown; + + getLayerTailpageRecommend (arg: unknown): unknown; + + getPhotoList (arg: unknown): unknown; + + getPhotoListByTimeLine (arg: unknown): unknown; + + getPhotoTabByTimeLine (arg: unknown): unknown; + + getShareInfo (arg: unknown): unknown; + + getVideoTabByTimeLine (arg: unknown): unknown; + + inviteCheckForLoversAlbum (arg: unknown): unknown; + + joinShareAlbum (arg: unknown): unknown; + + moveBatchPhoto (arg: unknown): unknown; + + queryAlbum (arg: unknown): unknown; + + quitSharedAlbum (arg: unknown): unknown; + + removeAlbumMember (arg: unknown): unknown; + + respondToJoinRequest (arg: unknown): unknown; + + sendAlbumInvitation (arg: unknown): unknown; + + setAlbumServiceInfo (arg1: string, arg2: string, arg3: string): unknown; + + setTopAlbum (arg: unknown): unknown; + + unLike (arg: unknown): unknown; + + updateAlbumMember (arg: unknown): unknown; + + verifyAlbumQuestion (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelProfileLikeService.ts b/packages/snowcat-core/services/NodeIKernelProfileLikeService.ts new file mode 100644 index 0000000..e02e263 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelProfileLikeService.ts @@ -0,0 +1,37 @@ +import { BuddyProfileLikeReq, GeneralCallResult, NTVoteInfo } from '@/snowcat-core/index'; + +export interface NodeIKernelProfileLikeService { + addKernelProfileLikeListener (listener: unknown): number; + + removeKernelProfileLikeListener (listenerId: number): void; + + setBuddyProfileLike (arg: unknown): Promise<{ result: number, errMsg: string, succCounts: number; }>; + + getBuddyProfileLike (req: BuddyProfileLikeReq): Promise, + total_count: number, + last_time: number, + today_count: number; + }, + voteInfo: { + total_count: number, + new_count: number, + new_nearby_count: number, + last_visit_time: number, + userInfos: Array; + }; + }>, + friendMaxVotes: number, + start: number; + }; + }>; + + getProfileLikeScidResourceInfo (arg: unknown): void; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelProfileService.ts b/packages/snowcat-core/services/NodeIKernelProfileService.ts new file mode 100644 index 0000000..d50c777 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelProfileService.ts @@ -0,0 +1,96 @@ +import { AnyCnameRecord } from 'node:dns'; +import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailInfoListenerArg, UserDetailSource } from '@/snowcat-core/index'; +import { GeneralCallResult } from '@/snowcat-core/services/common'; + +export interface NodeIKernelProfileService { + getOtherFlag (callfrom: string, uids: string[]): Promise>; + + getVasInfo (callfrom: string, uids: string[]): Promise>; + + getRelationFlag (callfrom: string, uids: string[]): Promise>; + + getUidByUin (callfrom: string, uin: Array): Map; + + getUinByUid (callfrom: string, uid: Array): Map; + + getCoreAndBaseInfo (callfrom: string, uids: string[]): Promise>; + + fetchUserDetailInfo (trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise detail + detail: Map, + } + >; + + addKernelProfileListener (listener: NodeIKernelProfileListener): number; + + removeKernelProfileListener (listenerId: number): void; + + prepareRegionConfig (): unknown; + + getLocalStrangerRemark (): Promise; + + enumCountryOptions (): Array; + + enumProvinceOptions (country: string): Array; + + enumCityOptions (country: string, province: string): unknown; + + enumAreaOptions (arg1: string, arg2: string, arg3: string): unknown; + + modifySelfProfile (param: unknown): Promise; + + modifyDesktopMiniProfile (param: ModifyProfileParams): Promise; + + setNickName (nickName: string): Promise; + + setLongNick (longNick: string): Promise; + + setBirthday (year: number, month: number, day: number): Promise; + + setGander (gender: unknown): Promise; + + setHeader (arg: string): Promise; + + setRecommendImgFlag (flag: unknown): Promise; + + getUserSimpleInfo (force: boolean, uids: string[]): Promise; + + getUserDetailInfo (uid: string): Promise; + + getUserDetailInfoWithBizInfo (uid: string, Biz: BizKey[]): Promise; + + getUserDetailInfoByUin (uin: string): Promise; + + getZplanAvatarInfos (args: string[]): Promise; + + getStatus (uid: string): Promise; + + startStatusPolling (isForceReset: boolean): Promise; + + getSelfStatus (): Promise; + + setdisableEmojiShortCuts (arg: unknown): unknown; + + getProfileQzonePicInfo (uid: string, type: number, force: boolean): Promise; + + // UserRemarkServiceImpl::getStrangerRemarkByUid [] + getCoreInfo (sceneId: string, arg: unknown[]): unknown; + + isNull (): boolean; + + addKernelProfileListenerForUICache (listener: unknown): number; + + asyncGetCoreInfo (callfrom: string, uids: string[]): unknown; + + getIntimate (uid: string, arg: unknown): unknown; + + getStatusInfo (uid: string, arg: unknown): unknown; + + getStockLocalData (key: string, arg: unknown): unknown; + + updateProfileData (uid: string, data: unknown): unknown; + + updateStockLocalData (key: string, data: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelPublicAccountService.ts b/packages/snowcat-core/services/NodeIKernelPublicAccountService.ts new file mode 100644 index 0000000..6dd7325 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelPublicAccountService.ts @@ -0,0 +1,15 @@ +export interface NodeIKernelPublicAccountService { + addListener (listener: unknown): number; + + removeListener (listenerId: number): void; + + follow (arg: unknown): unknown; + + queryTemplateInfo (arg: unknown): unknown; + + subscribeTemplate (arg: unknown): unknown; + + unfollow (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelQQPlayService.ts b/packages/snowcat-core/services/NodeIKernelQQPlayService.ts new file mode 100644 index 0000000..958c67f --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelQQPlayService.ts @@ -0,0 +1,19 @@ +export interface NodeIKernelQQPlayService { + addKernelQQPlayListener (listener: unknown): number; + + removeKernelQQPlayListener (listenerId: number): void; + + createLnkShortcut (arg1: string, arg2: string, arg3: string, arg4: string): unknown; + + getSystemRegValue (arg1: number, arg2: string, arg3: string): unknown; + + setSystemRegValue (arg1: number, arg2: string, arg3: string, arg4: string): unknown; + + sendMsg2Simulator (arg1: unknown, arg2: unknown): unknown; + + setForegroundWindow (arg: unknown): unknown; + + startSimulator (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelQiDianService.ts b/packages/snowcat-core/services/NodeIKernelQiDianService.ts new file mode 100644 index 0000000..37ca318 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelQiDianService.ts @@ -0,0 +1,21 @@ +export interface NodeIKernelQiDianService { + addKernelQiDianListener (listener: unknown): number; + + removeKernelQiDianListener (listenerId: number): void; + + requestExtUinForRemoteControl (arg1: string, arg2: string, arg3: number): unknown; + + requestMainUinForRemoteControl (arg: unknown): unknown; + + requestNaviConfig (arg: unknown): unknown; + + requestQidianUidFromUin (arg: unknown): unknown; + + requestWpaCorpInfo (arg: unknown): unknown; + + requestWpaSigT (arg1: unknown, arg2: unknown): unknown; + + requestWpaUserInfo (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelRDeliveryService.ts b/packages/snowcat-core/services/NodeIKernelRDeliveryService.ts new file mode 100644 index 0000000..581b627 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelRDeliveryService.ts @@ -0,0 +1,13 @@ +export interface NodeIKernelRDeliveryService { + addDataChangeListener (listener: unknown): number; + + removeDataChangeListener (listenerId: number): void; + + getRDeliveryDataByKey (arg: unknown): unknown; + + requestBatchRemoteDataByScene (arg1: unknown, arg2: unknown): unknown; + + requestSingleRemoteDataByKey (arg1: string, arg2: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelRecentContactService.ts b/packages/snowcat-core/services/NodeIKernelRecentContactService.ts new file mode 100644 index 0000000..a8bd579 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelRecentContactService.ts @@ -0,0 +1,85 @@ +import { ChatType, Peer } from '@/snowcat-core/types'; +import { NodeIKernelRecentContactListener } from '@/snowcat-core/listeners/NodeIKernelRecentContactListener'; +import { GeneralCallResult } from '@/snowcat-core/services/common'; +import { FSABRecentContactParams } from '@/snowcat-core/types/contact'; + +export interface NodeIKernelRecentContactService { + setGuildDisplayStatus (arg1: unknown, arg2: unknown): unknown; + + setContactListTop (peer: Peer, isTop: boolean): unknown; + + updateRecentContactExtBufForUI (peer: Peer, extBuf: unknown): unknown; + + upsertRecentContactManually (arg: unknown): unknown; + + manageContactMergeWindow (arg: unknown): unknown; + + enterOrExitMsgList (arg: unknown): unknown; + + getRecentContactListSnapShot (count: number): Promise, + changedList: Array<{ + remark: unknown; + peerName: unknown; + sendMemberName: unknown; + sendNickName: unknown; + peerUid: string; + peerUin: string, + msgTime: string, + chatType: ChatType, + msgId: string; + }>; + }; + }>; + + clearMsgUnreadCount (peer: Peer): unknown; + + getRecentContactListSyncLimit (count: number): unknown; + + jumpToSpecifyRecentContact (arg: unknown): unknown; + + fetchAndSubscribeABatchOfRecentContact (params: FSABRecentContactParams): unknown; + + addRecentContact (peer: Peer): unknown; + + deleteRecentContacts (peer: Peer): unknown; + + getContacts (peers: Peer[]): Promise; + + setThirdPartyBusinessInfos (arg: unknown): unknown; + + updateGameMsgConfigs (arg: unknown): unknown; + + removeKernelRecentContactListener (listenerId: number): unknown; + + addKernelRecentContactListener (listener: NodeIKernelRecentContactListener): void; + + clearRecentContactsByChatType (chatType: ChatType): unknown; + + upInsertModule (arg: unknown): unknown; + + jumpToSpecifyRecentContactVer2 (arg: unknown): unknown; + + deleteRecentContactsVer2 (arg: unknown): unknown; + + getRecentContactList (): Promise; + + getMsgUnreadCount (): unknown; + + clearRecentContacts (): unknown; + + getServiceAssistantRecentContactInfos (): unknown; + + getRecentContactInfos (): unknown; + + getUnreadDetailsInfos (): unknown; + + cleanAllModule (): unknown; + + setAllGameMsgRead (): unknown; + + getRecentContactListSync (): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelRemotingService.ts b/packages/snowcat-core/services/NodeIKernelRemotingService.ts new file mode 100644 index 0000000..a5331e1 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelRemotingService.ts @@ -0,0 +1,17 @@ +export interface NodeIKernelRemotingService { + addKernelRemotingListener (listener: unknown): number; + + removeKernelRemotingListener (listenerId: number): void; + + accept (arg1: string, arg2: boolean): unknown; + + setPenetrateBuffer (arg1: number, arg2: number, arg3: string): unknown; + + startRemotingClient (arg: unknown): unknown; + + startRemotingInvite (arg: unknown): unknown; + + stopRemoting (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelRichMediaService.ts b/packages/snowcat-core/services/NodeIKernelRichMediaService.ts new file mode 100644 index 0000000..ba9bdee --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelRichMediaService.ts @@ -0,0 +1,286 @@ +import { GetFileListParam, MessageElement, Peer } from '@/snowcat-core/types'; +import { GeneralCallResult } from './common'; + +export enum UrlFileDownloadType { + KUNKNOWN, + KURLFILEDOWNLOADPRIVILEGEICON, + KURLFILEDOWNLOADPHOTOWALL, + KURLFILEDOWNLOADQZONE, + KURLFILEDOWNLOADCOMMON, + KURLFILEDOWNLOADINSTALLAPP, +} + +export enum RMBizTypeEnum { + KUNKNOWN, + KC2CFILE, + KGROUPFILE, + KC2CPIC, + KGROUPPIC, + KDISCPIC, + KC2CVIDEO, + KGROUPVIDEO, + KC2CPTT, + KGROUPPTT, + KFEEDCOMMENTPIC, + KGUILDFILE, + KGUILDPIC, + KGUILDPTT, + KGUILDVIDEO, +} + +export interface CommonFileInfo { + bizType: number; + chatType: number; + elemId: string; + favId: string; + fileModelId: string; + fileName: string; + fileSize: string; + md5: string; + md510m: string; + msgId: string; + msgTime: string; + parent: string; + peerUid: string; + picThumbPath: Array; + sha: string; + sha3: string; + subId: string; + uuid: string; +} + +export interface NodeIKernelRichMediaService { + // getVideoPlayUrl(peer, msgId, elemId, videoCodecFormat, VideoRequestWay.KHAND, cb); + // public enum VideoCodecFormatType { + // KCODECFORMATH264, + // KCODECFORMATH265, + // KCODECFORMATH266, + // KCODECFORMATAV1 + // } + // public enum VideoRequestWay { + // KUNKNOW, + // KHAND, + // KAUTO + // } + getVideoPlayUrl (peer: Peer, msgId: string, elemId: string, videoCodecFormat: number, VideoRequestWay: number): Promise; + + // exParams (RMReqExParams) + // this.downSourceType = i2; + // this.triggerType = i3; + // peer, msgId, elemId, videoCodecFormat, exParams + // 1 0 频道在用 + // 1 1 + // 0 2 + + // public static final int KCOMMONREDENVELOPEMSGTYPEINMSGBOX = 1007; + // public static final int KDOWNSOURCETYPEAIOINNER = 1; + // public static final int KDOWNSOURCETYPEBIGSCREEN = 2; + // public static final int KDOWNSOURCETYPEHISTORY = 3; + // public static final int KDOWNSOURCETYPEUNKNOWN = 0; + + // public static final int KTRIGGERTYPEAUTO = 1; + // public static final int KTRIGGERTYPEMANUAL = 0; + + getVideoPlayUrlV2 (peer: Peer, msgId: string, elemId: string, videoCodecFormat: number, exParams: { + downSourceType: number, + triggerType: number; + }): Promise, + videoCodecFormat: number; + }; + }>; + + getRichMediaFileDir (elementType: number, downType: number, isTemp: boolean): unknown; + + getVideoPlayUrlInVisit (arg: { + downloadType: number, + thumbSize: number, + msgId: string, + msgRandom: string, + msgSeq: string, + msgTime: string, + chatType: number, + senderUid: string, + peerUid: string, + guildId: string, + ele: MessageElement, + useHttps: boolean; + }): Promise; + + isFileExpired (arg: number): unknown; + + deleteGroupFolder (GroupCode: string, FolderId: string): Promise; + + // 参数与getVideoPlayUrlInVisit一样 + downloadRichMediaInVisit (arg: { + downloadType: number, + thumbSize: number, + msgId: string, + msgRandom: string, + msgSeq: string, + msgTime: string, + chatType: number, + senderUid: string, + peerUid: string, + guildId: string, + ele: MessageElement, + useHttps: boolean; + }): unknown; + + downloadFileForModelId (peer: Peer, ModelId: string[], unknown: string): Promise; + + // 第三个参数 Array + // this.fileId = ""; + // this.fileName = ""; + // this.fileId = str; + // this.fileName = str2; + // this.fileSize = j2; + // this.fileModelId = j3; + + downloadFileForFileUuid (peer: Peer, uuid: string, arg3: { + fileId: string, + fileName: string, + fileSize: string, + fileModelId: string; + }[]): Promise; + + downloadFileByUrlList (fileDownloadTyp: UrlFileDownloadType, urlList: Array): unknown; + + downloadFileForFileInfo (fileInfo: CommonFileInfo[], savePath: string): unknown; + + createGroupFolder (GroupCode: string, FolderName: string): Promise; }; + }>; + + downloadFile (arg1: unknown, arg2: number, arg3: number, arg4: string): unknown; + + createGroupFolder (arg1: unknown, arg2: unknown): unknown; + + downloadGroupFolder (arg1: string, arg2: string, arg3: string): unknown; + + renameGroupFolder (arg1: string, arg2: string, arg3: string): unknown; + + deleteGroupFolder (arg1: unknown, arg2: unknown): unknown; + + deleteTransferInfo (arg1: unknown, arg2: unknown): unknown; + + cancelTransferTask (arg1: Peer, arg2: Array[], arg3: string): unknown; + + cancelUrlDownload (arg: unknown): unknown; + + updateOnlineVideoElemStatus (arg: unknown): unknown; + + getGroupSpace (arg: unknown): unknown; + + getGroupFileList (groupCode: string, params: GetFileListParam): Promise; + + getGroupFileInfo (arg1: unknown, arg2: unknown): unknown; + + getGroupTransferList (arg1: string, arg2: unknown): unknown; + + renameGroupFile (arg1: string, arg2: number, arg3: string, arg4: string, arg5: string): unknown; + + moveGroupFile (groupCode: string, busId: Array, fileList: Array, currentParentDirectory: string, targetParentDirectory: string): Promise, + failFileIdList: Array; + }; + }>; + + transGroupFile (groupCode: string, fileId: string): Promise; + + searchGroupFile ( + keywords: Array, + param: { + groupIds: Array, + fileType: number, + context: string, + count: number, + sortType: number, + groupNames: Array; + }): Promise; + + searchGroupFileByWord (arg1: unknown[], arg2: unknown[], arg3: string, arg4: string, arg5: number): unknown; + + deleteGroupFile (GroupCode: string, params: Array, Files: Array): Promise; + failFileIdList: Array; + }; + }>; + + translateEnWordToZn (words: string[]): Promise; + + getScreenOCR (path: string): Promise; + + batchGetGroupFileCount (Gids: Array): Promise, + groupFileCounts: Array; + }>; + + queryPicDownloadSize (arg: unknown): unknown; + + searchGroupFile (arg1: unknown, arg2: unknown): unknown; + + searchMoreGroupFile (arg: unknown): unknown; + + cancelSearcheGroupFile (arg1: number, arg2: number, arg3: string): unknown; + + onlyDownloadFile (arg1: Peer, arg2: string, arg3: Array[]): unknown; + + onlyUploadFile (arg1: unknown, arg2: unknown): unknown; + + isExtraLargePic (arg1: unknown, arg2: unknown, arg3: unknown): unknown; + + uploadRMFileWithoutMsg (arg: { + bizType: RMBizTypeEnum, + filePath: string, + peerUid: string, + transferId: string; + useNTV2: string; + }): Promise; + + isNull (): boolean; + + getRichMediaCodecInfo (arg: unknown): unknown; + + getScreenOCRWithSourceType (arg1: unknown, arg2: unknown): unknown; + + imageTranslate (arg1: string, arg2: string, arg3: number): unknown; + + downloadFileByUrl (arg1: number, arg2: string, arg3: boolean): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelRobotService.ts b/packages/snowcat-core/services/NodeIKernelRobotService.ts new file mode 100644 index 0000000..eb07c80 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelRobotService.ts @@ -0,0 +1,128 @@ +import { NodeIKernelRobotListener } from '@/snowcat-core/listeners'; +import { GeneralCallResult, Peer } from '..'; + +export interface NodeIKernelRobotService { + addKernelRobotListener (listener: NodeIKernelRobotListener): number; + + removeKernelRobotListener (listenerId: number): void; + + fetchGroupRobotStoreDiscovery (arg: unknown): unknown; + + fetchGroupRobotStoreCategoryList (arg: unknown): unknown; + + FetchSubscribeMsgTemplate (arg: unknown): unknown; + + FetchSubcribeMsgTemplateStatus (arg: unknown): unknown; + + SubscribeMsgTemplateSet (arg1: unknown, arg2: unknown): unknown; + + fetchRecentUsedRobots (arg: unknown): unknown; + + fetchShareArkInfo (arg: unknown): unknown; + + getAllRobotFriendsFromCache (): Promise; + + fetchAllRobots (arg1: boolean, arg2: unknown): unknown; + + removeAllRecommendCache (): unknown; + + setRobotPickTts (arg1: unknown, arg2: unknown): unknown; + + getRobotUinRange (data: unknown): Promise<{ response: { robotUinRanges: Array; }; }>; + + getRobotFunctions (peer: Peer, params: { + uins: Array, + num: 0, + client_info: { platform: 4, version: '', build_num: 9999; }, + tinyids: [], + page: 0, + full_fetch: false, + scene: 4, + filter: 1, + bkn: ''; + }): Promise, next_page: number; }; }>; + + fetchRobotShareLimit (arg1: unknown, arg2: unknown): unknown; + + updateGroupRobotProfile (arg1: unknown, arg2: unknown): unknown; + + sendCommonRobotToGuild (arg1: unknown, arg2: unknown): unknown; + + sendGroupRobotStoreSearch (arg: unknown): unknown; + + fetchMyRobotLists (arg: unknown): unknown; + + batchGetBotsMenu (arg: unknown): unknown; + + fetchAddRobotGroupList (arg: unknown): unknown; + + getGuildRobotList (arg: unknown): unknown; + + querySessionList (arg: unknown): unknown; + + fetchListRobot (arg: unknown): unknown; + + subscribeGuildGlobalRobot (arg: unknown): unknown; + + addGuildRobot (arg: unknown): unknown; + + upMicGuildRobot (arg: unknown): unknown; + + downMicGuildRobot (arg: unknown): unknown; + + getRedDot (arg: unknown): unknown; + + delRedDot (arg: unknown): unknown; + + changeMyBot (arg: unknown): unknown; + + getAudioLiveRobotStatus (arg: unknown): unknown; + + backFlowRobotCoreInfos (arg: unknown): unknown; + + batchFetchRobotCoreInfos (arg: unknown): unknown; + + queryPicRecomQuestions (arg: unknown): unknown; + + delSessionMsgs (arg: unknown): unknown; + + fetchMobileRobotRecommendCards (arg: unknown): unknown; + + saveSelectedAIModelOrOptIds (arg: unknown): unknown; + + setRobotStoryEnter (arg: unknown): unknown; + + aiGenAvatar (arg: unknown): unknown; + + fetchRobotFeatureWithReq (arg: unknown): unknown; + + fetchGroupRobotProfileWithReq (arg: unknown): unknown; + + setAddRobotToGroup (arg: unknown): unknown; + + setRemoveRobotFromGroup (arg: unknown): unknown; + + FetchGroupRobotInfo (arg: unknown): unknown; + + fetchGuildRobotInfo (arg: unknown): unknown; + + aiGenBotInfo (arg: unknown): unknown; + + fetchAiGenTemplateInfo (arg: unknown): unknown; + + fetchShareInfo (arg: unknown): unknown; + + updateShareInfo (arg: unknown): unknown; + + queryGuildGlobalRobotSubscription (arg: unknown): unknown; + + resetConversation (arg: unknown): unknown; + + setGuildRobotPermission (arg: unknown): unknown; + + fetchGuildRobotPermission (arg: unknown): unknown; + + editSession (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelSearchService.ts b/packages/snowcat-core/services/NodeIKernelSearchService.ts new file mode 100644 index 0000000..b46ae75 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelSearchService.ts @@ -0,0 +1,148 @@ +import { ChatType, Peer } from '@/snowcat-core/types'; +import { GeneralCallResult } from './common'; + +export interface NodeIKernelSearchService { + + addKernelSearchListener (listener: unknown): number; + + removeKernelSearchListener (listenerId: number): void; + + searchStranger (keyword: string, searchType: unknown, searchParams: unknown): Promise; + + searchGroup (param: { + keyWords: string, + groupNum: number, + exactSearch: boolean, + penetrate: string; + }): Promise; + + searchLocalInfo (keywords: string, type: number): unknown; + + cancelSearchLocalInfo (arg1: number, arg2: number, arg3: string): unknown; + + searchBuddyChatInfo (arg1: unknown, arg2: unknown): unknown; + + searchMoreBuddyChatInfo (arg: unknown): unknown; + + cancelSearchBuddyChatInfo (arg1: number, arg2: number, arg3: string): unknown; + + searchContact (arg1: Array[], arg2: unknown): unknown; + + searchMoreContact (arg: unknown): unknown; + + cancelSearchContact (arg1: number, arg2: number, arg3: string): unknown; + + searchGroupChatInfo (arg1: unknown[], arg2: unknown, arg3: number): unknown; + + resetSearchGroupChatInfoSortType (arg1: number, arg2: number, arg3: number): unknown; + + resetSearchGroupChatInfoFilterMembers (arg1: number, arg2: Array[], arg3: number): unknown; + + searchMoreGroupChatInfo (arg: unknown): unknown; + + cancelSearchGroupChatInfo (arg1: number, arg2: number, arg3: string): unknown; + + searchChatsWithKeywords (arg1: unknown[], arg2: number, arg3: number): unknown; + + searchMoreChatsWithKeywords (arg: unknown): unknown; + + cancelSearchChatsWithKeywords (arg1: number, arg2: number, arg3: string): unknown; + + searchChatMsgs (arg1: Array[], arg2: unknown): unknown; + + searchMoreChatMsgs (arg: unknown): unknown; + + cancelSearchChatMsgs (arg1: number, arg2: number, arg3: string): unknown; + + searchMsgWithKeywords (keyWords: string[], param: Peer & { searchFields: number, pageLimit: number; }): Promise; + + searchMoreMsgWithKeywords (arg: unknown): unknown; + + cancelSearchMsgWithKeywords (arg1: number, arg2: number, arg3: string): unknown; + + searchFileWithKeywords (keywords: string[], source: number): Promise; + + searchMoreFileWithKeywords (arg: unknown): unknown; + + cancelSearchFileWithKeywords (arg1: number, arg2: number, arg3: string): unknown; + + searchFileInFileCenterForPC (arg1: unknown, arg2: unknown): unknown; + + searchMoreFileInFileCenter (arg: unknown): unknown; + + cancelSearchFileInFileCenter (arg1: number, arg2: number, arg3: string): unknown; + + searchAtMeChats (arg1: boolean, arg2: number, arg3: number): unknown; + + searchMoreAtMeChats (arg: unknown): unknown; + + cancelSearchAtMeChats (arg1: number, arg2: number, arg3: string): unknown; + + searchChatAtMeMsgs (arg: unknown): unknown; + + searchMoreChatAtMeMsgs (arg: unknown): unknown; + + cancelSearchChatAtMeMsgs (arg1: number, arg2: number, arg3: string): unknown; + + searchRobot (arg: unknown): unknown; + + searchCache (arg1: string, arg2: string, arg3: unknown): unknown; + + addSearchHistory (param: { + type: number, + contactList: [], + id: number, + groupInfos: [], + msgs: [], + fileInfos: Array<{ + chatType: ChatType, + buddyChatInfo: Array<{ category_name: string, peerUid: string, peerUin: string, remark: string; }>, + discussChatInfo: [], + groupChatInfo: Array<{ + groupCode: string, + isConf: boolean, + hasModifyConfGroupFace: boolean, + hasModifyConfGroupName: boolean, + groupName: string, + remark: string; + }>, + dataLineChatInfo: [], + tmpChatInfo: [], + msgId: string, + msgSeq: string, + msgTime: string, + senderUid: string, + senderNick: string, + senderRemark: string, + senderCard: string, + elemId: string, + elemType: string, + fileSize: string, + filePath: string, + fileName: string, + hits: Array<{ start: number, end: number; }>; + }>; + }): Promise<{ + result: number, + errMsg: string, + id?: number; + }>; + + removeSearchHistory (arg: unknown): unknown; + + addOrUpdateSearchMostUseItem (arg1: unknown, arg2: unknown): unknown; + + getSearchMostUseItem (arg: unknown): unknown; + + deleteSearchMostUseItem (arg: unknown): unknown; + + deleteGroupHistoryFile (arg: unknown): unknown; + + clearSearchCache (arg: unknown): unknown; + + clearSearchHistory (): unknown; + + loadSearchHistory (): unknown; + + initTokenizeUtil (): unknown; +} diff --git a/packages/snowcat-core/services/NodeIKernelSettingService.ts b/packages/snowcat-core/services/NodeIKernelSettingService.ts new file mode 100644 index 0000000..f13dfdb --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelSettingService.ts @@ -0,0 +1,53 @@ +export interface NodeIKernelSettingService { + addKernelSettingListener (listener: unknown): number; + + removeKernelSettingListener (listenerId: number): void; + + getSettingForBuffer (key: unknown): unknown; + + getSettingForNum (key: unknown): unknown; + + getSettingForStr (key: unknown): unknown; + + setSettingForBuffer (arg: unknown): unknown; + + setSettingForNum (arg: unknown): unknown; + + setSettingForStr (arg: unknown): unknown; + + setAutoLoginSwitch (enabled: boolean): unknown; + + setNeedConfirmSwitch (enabled: boolean): unknown; + + setPrivacySetting (arg: unknown): unknown; + + setSelfStartSwitch (enabled: boolean): unknown; + + modifyAccount (arg: unknown): unknown; + + verifyNewAccount (arg: unknown): unknown; + + openUrlWithQQBrowser (url: string): unknown; + + openUrlInIM (url: string): unknown; + + clearCache (arg: unknown): unknown; + + destroyAccount (): unknown; + + isQQBrowserInstall (): boolean; + + getSelfStartSwitch (): unknown; + + getAutoLoginSwitch (): unknown; + + getNeedConfirmSwitch (): unknown; + + getPrivacySetting (): unknown; + + scanCache (): unknown; + + getQQBrowserSwitchFromQldQQ (): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelSkinService.ts b/packages/snowcat-core/services/NodeIKernelSkinService.ts new file mode 100644 index 0000000..e854594 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelSkinService.ts @@ -0,0 +1,21 @@ +export interface NodeIKernelSkinService { + addKernelSkinListener (listener: unknown): number; + + removeKernelSkinListener (listenerId: number): void; + + getRecommendAIOColor (arg1: unknown, arg2: unknown): unknown; + + getRecommendBubbleColor (arg1: unknown, arg2: unknown): unknown; + + getThemeInfoFromImage (arg: unknown): unknown; + + previewTheme (arg1: number, arg2: unknown, arg3: unknown): unknown; + + setTemplateCustomPrimaryColor (arg1: unknown, arg2: unknown): unknown; + + setThemeInfo (arg1: number, arg2: unknown, arg3: unknown): unknown; + + uploadImage (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelStorageCleanService.ts b/packages/snowcat-core/services/NodeIKernelStorageCleanService.ts new file mode 100644 index 0000000..abc2f5c --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelStorageCleanService.ts @@ -0,0 +1,58 @@ +import { NodeIKernelStorageCleanListener } from '@/snowcat-core/listeners'; +import { GeneralCallResult } from './common'; + +export interface NodeIKernelStorageCleanService { + + addKernelStorageCleanListener (listener: NodeIKernelStorageCleanListener): number; + + removeKernelStorageCleanListener (listenerId: number): void; + // [ + // "hotUpdate", + // [ + // "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\packages" + // ] + // ], + // [ + // "tmp", + // [ + // "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\tmp" + // ] + // ], + // [ + // "SilentCacheappSessionPartation9212", + // [ + // "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\Partitions\\qqnt_9212" + // ] + // ] + addCacheScanedPaths (paths: Map<'tmp' | 'SilentCacheappSessionPartation9212' | 'hotUpdate', unknown>): unknown; + + addFilesScanedPaths (arg: unknown): unknown; + + scanCache (): Promise; + + addReportData (arg: unknown): unknown; + + reportData (): unknown; + + getChatCacheInfo (tableType: number, pageSize: number, order: number, startPosition: number): Promise; + + getFileCacheInfo (fileType: number, restart: boolean, pageSize: number, lastRecord: number, param: unknown): Promise; + + clearChatCacheInfo (chatInfoList: unknown[], clearKeys: number[]): Promise; + + clearCacheDataByKeys (keys: Array): Promise; + + setSilentScan (isSilent: boolean): unknown; + + closeCleanWindow (): unknown; + + clearAllChatCacheInfo (): unknown; + + endScan (arg: unknown): unknown; + + addNewDownloadOrUploadFile (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelThirdPartySigService.ts b/packages/snowcat-core/services/NodeIKernelThirdPartySigService.ts new file mode 100644 index 0000000..2bbb295 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelThirdPartySigService.ts @@ -0,0 +1,17 @@ +export interface NodeIKernelThirdPartySigService { + addOnSigChangeListener (listener: unknown): number; + + removeSigChangeListener (listenerId: number): void; + + initConfig (arg: unknown): unknown; + + delThirdPartySigByUin (arg: unknown): unknown; + + getOpenIDByUin (arg1: string, arg2: number, arg3: string): unknown; + + getPT4tokenByUin (arg1: string, arg2: number, arg3: Array[]): unknown; + + getThirdPartySigByUin (arg1: string, arg2: number, arg3: number, arg4: number, arg5: string, arg6: string): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelTianShuService.ts b/packages/snowcat-core/services/NodeIKernelTianShuService.ts new file mode 100644 index 0000000..40cf7b6 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelTianShuService.ts @@ -0,0 +1,8 @@ +export interface NodeIKernelTianShuService { + addKernelTianShuListener (listener: unknown): number; + + removeKernelTianShuListener (listenerId: number): void; + + reportTianShuNumeralRed (arg: unknown): unknown;// needs 1 arguments + +} diff --git a/packages/snowcat-core/services/NodeIKernelTicketService.ts b/packages/snowcat-core/services/NodeIKernelTicketService.ts new file mode 100644 index 0000000..ac4ab7f --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelTicketService.ts @@ -0,0 +1,12 @@ +import { ForceFetchClientKeyRetType } from './common'; + +export interface NodeIKernelTicketService { + + addKernelTicketListener (listener: unknown): number; + + removeKernelTicketListener (listenerId: number): void; + + forceFetchClientKey (arg: string): Promise; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelTipOffService.ts b/packages/snowcat-core/services/NodeIKernelTipOffService.ts new file mode 100644 index 0000000..db45e0c --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelTipOffService.ts @@ -0,0 +1,20 @@ +import { GeneralCallResult } from './common'; + +export interface NodeIKernelTipOffService { + + addKernelTipOffListener (listener: unknown): number; + + removeKernelTipOffListener (listenerId: number): void; + + tipOffSendJsData (arg1: unknown, arg2: unknown): Promise; + + getPskey (domainList: string[], nocache: boolean): Promise; + }>; + + tipOffMsgs (arg: unknown): Promise; + + encodeUinAesInfo (arg1: unknown, arg2: unknown): Promise; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelUixConvertService.ts b/packages/snowcat-core/services/NodeIKernelUixConvertService.ts new file mode 100644 index 0000000..45ab995 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelUixConvertService.ts @@ -0,0 +1,5 @@ +export interface NodeIKernelUixConvertService { + getUin(uid: string[]): Promise<{ uinInfo: Map }>; + + getUid(uin: string[]): Promise<{ uidInfo: Map }>; +} diff --git a/packages/snowcat-core/services/NodeIKernelUnifySearchService.ts b/packages/snowcat-core/services/NodeIKernelUnifySearchService.ts new file mode 100644 index 0000000..646014a --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelUnifySearchService.ts @@ -0,0 +1,23 @@ +export interface NodeIKernelUnifySearchService { + checkAIAuth (arg: unknown): unknown; + + getNetResultTabs (arg: unknown): unknown; + + getNetSugWords (arg: unknown): unknown; + + getSearchAppendingInfo (arg: unknown): unknown; + + getSearchBoxSugWords (arg: unknown): unknown; + + search (arg: unknown): unknown; + + unifySearch (arg: unknown): unknown; + + unifySearchDiscovery (arg: unknown): unknown; + + unifySearchDiscoveryInCache (arg: unknown): unknown; + + wxSearchReport (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelUnitedConfigService.ts b/packages/snowcat-core/services/NodeIKernelUnitedConfigService.ts new file mode 100644 index 0000000..90dafd9 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelUnitedConfigService.ts @@ -0,0 +1,17 @@ +export interface NodeIKernelUnitedConfigService { + + addKernelUnitedConfigListener (listener: unknown): number; + + removeKernelUnitedConfigListener (listenerId: number): void; + + fetchUnitedSwitchConfig (configIds: string[]): void; + + isUnitedConfigSwitchOn (configId: string): boolean; + + registerUnitedConfigPushGroupList (groupList: string[]): void; + + fetchUnitedCommendConfig (ids: string[]): void; + + loadUnitedConfig (id: string): Promise; + +} diff --git a/packages/snowcat-core/services/NodeIKernelVasSystemUpdateService.ts b/packages/snowcat-core/services/NodeIKernelVasSystemUpdateService.ts new file mode 100644 index 0000000..62a5b68 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelVasSystemUpdateService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelVasSystemUpdateService { + getResPath (arg: unknown): unknown; + + isExist (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelWiFiPhotoClientService.ts b/packages/snowcat-core/services/NodeIKernelWiFiPhotoClientService.ts new file mode 100644 index 0000000..3fd3521 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelWiFiPhotoClientService.ts @@ -0,0 +1,37 @@ +export interface NodeIKernelWiFiPhotoClientService { + addKernelWiFiPhotoClientListener (listener: unknown): number; + + removeKernelWiFiPhotoClientListener (listenerId: number): void; + + cancelGetPhoto (arg1: unknown, arg2: unknown): unknown; + + cancelGetPhotoThumbBatch (arg: unknown): unknown; + + cancelRequest (arg: unknown): unknown; + + connectToHostForTest (arg: unknown): unknown; + + deletePhotoBatch (arg: unknown): unknown; + + disconnect (arg: unknown): unknown; + + getAlbumFileSavePath (arg: unknown): unknown; + + getAllPhotoSimpleInfo (arg: unknown): unknown; + + getPhotoAndSaveAs (arg1: string, arg2: string, arg3: string): unknown; + + getPhotoBatch (arg1: unknown, arg2: unknown): unknown; + + getPhotoInfoBatch (arg1: unknown, arg2: unknown): unknown; + + getPhotoSimpleInfoForFirstView (arg1: string, arg2: number): unknown; + + getPhotoThumbBatchWithConfig (arg1: unknown, arg2: unknown): unknown; + + getWiFiPhotoDownFileInfos (arg1: string, arg2: Array[]): unknown; + + resumeUncompleteDownloadRecords (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelWiFiPhotoHostService.ts b/packages/snowcat-core/services/NodeIKernelWiFiPhotoHostService.ts new file mode 100644 index 0000000..30f28cd --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelWiFiPhotoHostService.ts @@ -0,0 +1,15 @@ +export interface NodeIKernelWiFiPhotoHostService { + addKernelWiFiPhotoHostListener (listener: unknown): number; + + removeKernelWiFiPhotoHostListener (listenerId: number): void; + + acceptRequest (arg1: number, arg2: unknown): unknown; + + disconnect (arg: unknown): unknown; + + rejectRequest (arg1: number, arg2: number): unknown; + + setAlbumAccessDelegate (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIKernelYellowFaceForManagerService.ts b/packages/snowcat-core/services/NodeIKernelYellowFaceForManagerService.ts new file mode 100644 index 0000000..5820cd1 --- /dev/null +++ b/packages/snowcat-core/services/NodeIKernelYellowFaceForManagerService.ts @@ -0,0 +1,7 @@ +export interface NodeIKernelYellowFaceForManagerService { + download (arg1: string, arg2: string, arg3: string, arg4: boolean): unknown; + + setHistory (arg: unknown): unknown; + + isNull (): boolean; +} diff --git a/packages/snowcat-core/services/NodeIO3MiscService.ts b/packages/snowcat-core/services/NodeIO3MiscService.ts new file mode 100644 index 0000000..1ddec26 --- /dev/null +++ b/packages/snowcat-core/services/NodeIO3MiscService.ts @@ -0,0 +1,15 @@ +import { NodeIO3MiscListener } from '@/snowcat-core/listeners/NodeIO3MiscListener'; + +export interface NodeIO3MiscService { + get (): NodeIO3MiscService; + + addO3MiscListener (listener: NodeIO3MiscListener): number; + + removeO3MiscListener (listenerId: number): void; + + passthroughO3Data (arg1: unknown, arg2: unknown): unknown; + + reportAmgomWeather (arg1: unknown, arg2: unknown, arg3: unknown): unknown; + + setAmgomDataPiece (appid: string, dataPiece: Uint8Array): void; +} diff --git a/packages/snowcat-core/services/NodeIYellowFaceService.ts b/packages/snowcat-core/services/NodeIYellowFaceService.ts new file mode 100644 index 0000000..5681d72 --- /dev/null +++ b/packages/snowcat-core/services/NodeIYellowFaceService.ts @@ -0,0 +1,11 @@ +export interface NodeIYellowFaceService { + addListener (listener: unknown): number; + + removeListener (listenerId: number): void; + + download (resourceConfigJson: string, resourceDir: string, cacheDir: string, force: boolean): void; + + setHistory (fullMd5: string): void; + + update (arg: unknown): unknown; +} diff --git a/packages/snowcat-core/services/NodeIkernelTestPerformanceService.ts b/packages/snowcat-core/services/NodeIkernelTestPerformanceService.ts new file mode 100644 index 0000000..69e43b4 --- /dev/null +++ b/packages/snowcat-core/services/NodeIkernelTestPerformanceService.ts @@ -0,0 +1,15 @@ +import { MessageElement, Peer } from '@/snowcat-core/types'; + +export interface NodeIkernelTestPerformanceService { + + insertMsg(MsgParam: { + peer: Peer + msgTime: string + msgId: string + msgSeq: string + batchNums: number + timesPerBatch: number + numPerTime: number + }, msg: Array): Promise; + +} diff --git a/packages/snowcat-core/services/common.ts b/packages/snowcat-core/services/common.ts new file mode 100644 index 0000000..c7c8e0a --- /dev/null +++ b/packages/snowcat-core/services/common.ts @@ -0,0 +1,16 @@ +export enum GeneralCallResultStatus { + OK = 0, + ERROR = -1, +} + +export interface GeneralCallResult { + result: GeneralCallResultStatus, + errMsg: string +} + +export interface ForceFetchClientKeyRetType extends GeneralCallResult { + url: string; + keyIndex: string; + clientKey: string; + expireTime: string; +} diff --git a/packages/snowcat-core/services/index.ts b/packages/snowcat-core/services/index.ts new file mode 100644 index 0000000..b0b7beb --- /dev/null +++ b/packages/snowcat-core/services/index.ts @@ -0,0 +1,88 @@ +import type { + NodeIKernelAvatarService, + NodeIKernelBuddyService, + NodeIKernelCollectionService, + NodeIKernelDbToolsService, + NodeIKernelFileAssistantService, + NodeIKernelGroupService, + NodeIKernelLoginService, + NodeIKernelMsgService, + NodeIKernelOnlineStatusService, + NodeIKernelProfileLikeService, + NodeIKernelProfileService, + NodeIKernelRichMediaService, + NodeIKernelRobotService, + NodeIKernelSearchService, + NodeIKernelStorageCleanService, + NodeIKernelTicketService, + NodeIKernelTipOffService, +} from '.'; + +export * from './common'; +export * from './NodeIKernelAvatarService'; +export * from './NodeIKernelBuddyService'; +export * from './NodeIKernelFileAssistantService'; +export * from './NodeIKernelGroupService'; +export * from './NodeIKernelLoginService'; +export * from './NodeIKernelMsgService'; +export * from './NodeIKernelOnlineStatusService'; +export * from './NodeIKernelProfileLikeService'; +export * from './NodeIKernelProfileService'; +export * from './NodeIKernelTicketService'; +export * from './NodeIKernelStorageCleanService'; +export * from './NodeIKernelRobotService'; +export * from './NodeIKernelRichMediaService'; +export * from './NodeIKernelDbToolsService'; +export * from './NodeIKernelTipOffService'; +export * from './NodeIKernelSearchService'; +export * from './NodeIKernelCollectionService'; +// === New service exports from IDA analysis === +export * from './NodeIKernelSettingService'; +export * from './NodeIKernelQiDianService'; +export * from './NodeIKernelSkinService'; +export * from './NodeIKernelQQPlayService'; +export * from './NodeIKernelRDeliveryService'; +export * from './NodeIKernelRemotingService'; +export * from './NodeIKernelLiteBusinessService'; +export * from './NodeIKernelGroupTabService'; +export * from './NodeIKernelLockService'; +export * from './NodeIKernelHandOffService'; +export * from './NodeIKernelMiniAppService'; +export * from './NodeIKernelPublicAccountService'; +export * from './NodeIKernelThirdPartySigService'; +export * from './NodeIKernelUnifySearchService'; +export * from './NodeIKernelVasSystemUpdateService'; +export * from './NodeIKernelPersonalAlbumService'; +export * from './NodeIKernelConfigMgrService'; +export * from './NodeIKernelFeedService'; +export * from './NodeIKernelBdhUploadService'; +export * from './NodeIKernelDirectSessionService'; +export * from './NodeIKernelFileBridgeClientService'; +export * from './NodeIKernelFileBridgeHostService'; +export * from './NodeIKernelWiFiPhotoHostService'; +export * from './NodeIKernelWiFiPhotoClientService'; +export * from './NodeIKernelEmojiService'; +export * from './NodeIKernelNearbyProService'; +export * from './NodeIKernelAVSDKService'; +export * from './NodeIKernelAddBuddyService'; +export * from './NodeIKernelYellowFaceForManagerService'; + +export type ServiceNamingMapping = { + NodeIKernelAvatarService: NodeIKernelAvatarService; + NodeIKernelBuddyService: NodeIKernelBuddyService; + NodeIKernelFileAssistantService: NodeIKernelFileAssistantService; + NodeIKernelGroupService: NodeIKernelGroupService; + NodeIKernelLoginService: NodeIKernelLoginService; + NodeIKernelMsgService: NodeIKernelMsgService; + NodeIKernelOnlineStatusService: NodeIKernelOnlineStatusService; + NodeIKernelProfileLikeService: NodeIKernelProfileLikeService; + NodeIKernelProfileService: NodeIKernelProfileService; + NodeIKernelTicketService: NodeIKernelTicketService; + NodeIKernelStorageCleanService: NodeIKernelStorageCleanService; + NodeIKernelRobotService: NodeIKernelRobotService; + NodeIKernelRichMediaService: NodeIKernelRichMediaService; + NodeIKernelDbToolsService: NodeIKernelDbToolsService; + NodeIKernelTipOffService: NodeIKernelTipOffService; + NodeIKernelSearchService: NodeIKernelSearchService; + NodeIKernelCollectionService: NodeIKernelCollectionService; +}; diff --git a/packages/snowcat-core/tsconfig.json b/packages/snowcat-core/tsconfig.json new file mode 100644 index 0000000..fd28287 --- /dev/null +++ b/packages/snowcat-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": [ + "*.ts", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-core/types/adapter.ts b/packages/snowcat-core/types/adapter.ts new file mode 100644 index 0000000..7c25eef --- /dev/null +++ b/packages/snowcat-core/types/adapter.ts @@ -0,0 +1,11 @@ +export enum MsfStatusType { + KUNKNOWN = 0, + KDISCONNECTED = 1, + KCONNECTED = 2, +} +export enum MsfChangeReasonType { + KUNKNOWN = 0, + KUSERLOGININ = 1, + KUSERLOGINOUT = 2, + KAUTO = 3, +} diff --git a/packages/snowcat-core/types/cache.ts b/packages/snowcat-core/types/cache.ts new file mode 100644 index 0000000..85c6fd4 --- /dev/null +++ b/packages/snowcat-core/types/cache.ts @@ -0,0 +1,68 @@ +import { ChatType } from './msg'; + +/** + * 聊天缓存列表 + */ +export interface ChatCacheList { + pageCount: number; // 页数 + infos: ChatCacheListItem[]; // 聊天缓存项列表 +} + +/** + * 聊天缓存列表项 + */ +export interface ChatCacheListItem { + chatType: ChatType; // 聊天类型 + basicChatCacheInfo: ChatCacheListItemBasic; // 基本聊天缓存信息 + guildChatCacheInfo: unknown[]; // 公会聊天缓存信息 +} + +/** + * 基本聊天缓存信息 + */ +export interface ChatCacheListItemBasic { + chatSize: string; // 聊天大小 + chatTime: string; // 聊天时间 + uid: string; // 用户ID + uin: string; // 用户号码 + remarkName: string; // 备注名 + nickName: string; // 昵称 + chatType?: ChatType; // 聊天类型(可选) + isChecked?: boolean; // 是否已检查(可选) +} + +/** + * 缓存文件类型枚举 + */ +export enum CacheFileType { + IMAGE = 0, // 图片 + VIDEO = 1, // 视频 + AUDIO = 2, // 音频 + DOCUMENT = 3, // 文档 + OTHER = 4, // 其他 +} + +/** + * 缓存文件列表 + */ +export interface CacheFileList { + infos: CacheFileListItem[]; // 缓存文件项列表 +} + +/** + * 缓存文件列表项 + */ +export interface CacheFileListItem { + fileSize: string; // 文件大小 + fileTime: string; // 文件时间 + fileKey: string; // 文件键 + elementId: string; // 元素ID + elementIdStr: string; // 元素ID字符串 + fileType: CacheFileType; // 文件类型 + path: string; // 路径 + fileName: string; // 文件名 + senderId: string; // 发送者ID + previewPath: string; // 预览路径 + senderName: string; // 发送者名称 + isChecked?: boolean; // 是否已检查(可选) +} diff --git a/packages/snowcat-core/types/constant.ts b/packages/snowcat-core/types/constant.ts new file mode 100644 index 0000000..696a9ae --- /dev/null +++ b/packages/snowcat-core/types/constant.ts @@ -0,0 +1,2 @@ +export const IMAGE_HTTP_HOST = 'https://gchat.qpic.cn'; +export const IMAGE_HTTP_HOST_NT = 'https://multimedia.nt.qq.com.cn'; diff --git a/packages/snowcat-core/types/contact.ts b/packages/snowcat-core/types/contact.ts new file mode 100644 index 0000000..340f556 --- /dev/null +++ b/packages/snowcat-core/types/contact.ts @@ -0,0 +1,11 @@ +export interface FSABRecentContactParams { + anchorPointContact: { + contactId: string; + sortField: string; + pos: number; + }; + relativeMoveCount: number; + listType: number; + count: number; + fetchOld: boolean; +} diff --git a/packages/snowcat-core/types/element.ts b/packages/snowcat-core/types/element.ts new file mode 100644 index 0000000..167bd9d --- /dev/null +++ b/packages/snowcat-core/types/element.ts @@ -0,0 +1,391 @@ +import { + ElementType, + MessageElement, + NTGrayTipElementSubTypeV2, + PicSubType, + PicType, + TipAioOpGrayTipElement, + TipGroupElement, + NTVideoType, + FaceType, + Peer, +} from './msg'; + +type ElementFullBase = Omit; + +export interface SendElementBase { + elementType: ET; + elementId: string; + extBufForUI?: string; +} + +type ElementBase< + K extends keyof ElementFullBase, + S extends Partial<{ [P in K]: keyof NonNullable | Array> }> = object +> = { + [P in K]: + S[P] extends Array + ? Pick, U & keyof NonNullable> + : S[P] extends keyof NonNullable + ? Pick, S[P]> + : NonNullable; +}; + +export interface TextElement { + content: string; + atType: number; + atUid: string; + atTinyId: string; + atNtUid: string; +} + +export interface FaceElement { + pokeType?: number; + faceIndex: number; + faceType: FaceType; + faceText?: string; + packId?: string; + stickerId?: string; + sourceType?: number; + stickerType?: number; + resultId?: string; + surpriseId?: string; + randomType?: number; + chainCount?: number; +} +export interface GrayTipRovokeElement { + operatorRole: string; + operatorUid: string; + operatorNick: string; + operatorRemark: string; + isSelfOperate: boolean; // 是否是自己撤回的 + operatorMemRemark?: string; + wording: string; // 自定义的撤回提示语 +} + +export interface XmlToJsonParam { + busiType: string; + busiId: string; + c2cType: number; + serviceType: number; + ctrlFlag: number; + content: string; + templId: string; + seqId: string; + templParam: Map; + pbReserv: null | string; + members: Map; +} + +export interface GrayTipElement { + subElementType: NTGrayTipElementSubTypeV2; + revokeElement: GrayTipRovokeElement; + aioOpGrayTipElement: TipAioOpGrayTipElement; + groupElement: TipGroupElement; + xmlElement: { + busiId: string; + content: string; + templId: string; + }; + jsonGrayTipElement: { + busiId: string; + jsonStr: string; + recentAbstract?: string; + isServer?: boolean; + xmlToJsonParam?: XmlToJsonParam; + }; +} + +export interface ArkElement { + bytesData: string; + linkInfo: null; + subElementType: null; +} + +export interface MarketFaceElement { + emojiPackageId: number; + faceName: string; + emojiId: string; + key: string; +} + +export interface VideoElement { + filePath: string; + fileName: string; + videoMd5?: string; + thumbMd5?: string; + fileTime?: number; // second + thumbSize?: number; // byte + fileFormat?: NTVideoType; // 2表示mp4 参考下面条目 + fileSize?: string; // byte + fileWidth?: number; + fileHeight?: number; + thumbWidth?: number; + thumbHeight?: number; + busiType?: 0; // + subBusiType?: 0; // 未知 + thumbPath?: Map; + transferStatus?: 0; // 未知 + progress?: 0; // 下载进度? + invalidState?: 0; // 未知 + fileUuid?: string; // 可以用于下载链接? + fileSubId?: string; + fileBizId?: null; + originVideoMd5?: string; + import_rich_media_context?: null; + sourceVideoCodecFormat?: number; +} +export interface PicElement { + md5HexStr?: string; + filePath?: string; + fileSize: number | string;// number + picWidth: number; + picHeight: number; + fileName: string; + sourcePath: string; + original: boolean; + picType: PicType; + picSubType?: PicSubType; + fileUuid: string; + fileSubId: string; + thumbFileSize: number; + summary: string; + thumbPath: Map; + originImageMd5?: string; + originImageUrl?: string; +} +export interface InlineKeyboardButton { + id: string; + label: string; + visitedLabel: string; + unsupportTips: string; + data: string; + specifyRoleIds: string[]; + specifyTinyids: string[]; + style: number; + type: number; + clickLimit: number; + atBotShowChannelList: boolean; + permissionType: number; +} + +// 非element +interface InlineKeyboardRow { + buttons: InlineKeyboardButton[]; +} + +// 非element +interface TofuElementContent { + color: string; + tittle: string; +} + +export interface ActionBarElement { + rows: InlineKeyboardRow[]; + botAppid: string; +} + +export interface RecommendedMsgElement { + rows: InlineKeyboardRow[]; + botAppid: string; +} + +export interface TofuRecordElement { + type: number; + busiid: string; + busiuuid: string; + descriptionContent: string; + contentlist: TofuElementContent[], + background: string; + icon: string; + uinlist: string[], + uidlist: string[], + busiExtra: string; + updateTime: string; + dependedmsgid: string; + msgtime: string; + onscreennotify: boolean; +} + +export interface FileElement { + fileMd5?: string; + fileName: string; + filePath: string; + fileSize: string; + picHeight?: number; + picWidth?: number; + folderId?: string; + picThumbPath?: Map; + file10MMd5?: string; + fileSha?: string; + fileSha3?: string; + fileUuid?: string; + fileSubId?: string; + thumbFileSize?: number; + fileBizId?: number; +} + +export interface ShareLocationElement { + text: string; + ext: string; +} + +export interface StructLongMsgElement { + xmlContent: string; + resId: string; +} + +export interface ReplyElement { + sourceMsgIdInRecords?: string; + replayMsgSeq: string; + replayMsgId: string; + senderUin: string; + senderUidStr?: string; + replyMsgTime?: string; + replyMsgClientSeq?: string; + // HACK: Attributes that were not originally available, + // but were added due to NTQQ and NapCat's internal implementation, are used to supplement NapCat + _replyMsgPeer?: Peer; +} + +export interface CalendarElement { + summary: string; + msg: string; + expireTimeMs: string; + schemaType: number; + schema: string; +} + +export interface GiphyElement { + id: string; + isClip: boolean; + width: number; + height: number; +} + +export interface AvRecordElement { + type: number; + time: string; + text: string; + mainType: number; + hasRead: boolean; + extraType: number; +} + +// 非element +interface YoloUserInfo { + uid: string; + result: number; + rank: number; + bizId: string; +} + +export interface YoloGameResultElement { + UserInfo: YoloUserInfo[]; +} + +export interface FaceBubbleElement { + faceCount: number; + faceSummary: string; + faceFlag: number; + content: string; + oldVersionStr: string; + faceType: FaceType; + others: string; + yellowFaceInfo: { + index: number; + buf: string; + compatibleText: string; + text: string; + }; +} + +export interface TaskTopMsgElement { + msgTitle: string; + msgSummary: string; + iconUrl: string; + topMsgType: number; +} + +export interface PttElement { + canConvert2Text: boolean; + duration: number; + fileBizId: null; + fileId: number; + fileName: string; + filePath: string; + fileSize: string; + fileSubId: string; + fileUuid: string; // FileId + formatType: number; // Todo 已定义 但是未替换 + invalidState: number; + md5HexStr: string; + playState: number; + progress: number; // 进度 + text: string; + transferStatus: number; + translateStatus: number; + voiceChangeType: number; + voiceType: number; + waveAmplitudes: number[]; + autoConvertText: number; + storeID: number; + otherBusinessInfo: { + aiVoiceType: number; + }; +} + +export type SendRecommendedMsgElement = SendElementBase & ElementBase<'recommendedMsgElement'>; + +export type SendTaskTopMsgElement = SendElementBase & ElementBase<'taskTopMsgElement'>; + +export type SendTofuRecordElement = SendElementBase & ElementBase<'tofuRecordElement'>; + +export type SendFaceBubbleElement = SendElementBase & ElementBase<'faceBubbleElement'>; + +export type SendAvRecordElement = SendElementBase & ElementBase<'avRecordElement'>; + +export type SendInlineKeyboardElement = SendElementBase & ElementBase<'inlineKeyboardElement'>; + +export type SendYoloGameResultElement = SendElementBase & ElementBase<'yoloGameResultElement'>; + +export type SendGiphyElement = SendElementBase & ElementBase<'giphyElement'>; + +export type SendWalletElement = SendElementBase & ElementBase<'walletElement'>; + +export type SendCalendarElement = SendElementBase & ElementBase<'calendarElement'>; + +export type SendLiveGiftElement = SendElementBase & ElementBase<'liveGiftElement'>; + +export type SendTextElement = SendElementBase & ElementBase<'textElement'>; + +export type SendReplyElement = SendElementBase & ElementBase<'replyElement'>; + +export type SendFaceElement = SendElementBase & ElementBase<'faceElement'>; + +export type SendMarketFaceElement = SendElementBase & ElementBase<'marketFaceElement'>; + +export type SendStructLongMsgElement = SendElementBase & ElementBase<'structLongMsgElement'>; + +export type SendPicElement = SendElementBase & ElementBase<'picElement'>; + +export type SendPttElement = SendElementBase & ElementBase<'pttElement', { + pttElement: ['fileName', 'filePath', 'md5HexStr', 'fileSize', 'duration', 'formatType', 'voiceType', + 'voiceChangeType', 'canConvert2Text', 'waveAmplitudes', 'fileSubId', 'playState', 'autoConvertText', 'storeID', 'otherBusinessInfo']; +}>; + +export type SendFileElement = SendElementBase & ElementBase<'fileElement'>; + +export type SendVideoElement = SendElementBase & ElementBase<'videoElement'>; + +export type SendArkElement = SendElementBase & ElementBase<'arkElement'>; + +export type SendMarkdownElement = SendElementBase & ElementBase<'markdownElement'>; + +export type SendShareLocationElement = SendElementBase & ElementBase<'shareLocationElement'>; + +export type SendMultiForwardMsgElement = SendElementBase & ElementBase<'multiForwardMsgElement'>; + +export type SendMessageElement = SendTextElement | SendPttElement | + SendPicElement | SendReplyElement | SendFaceElement | SendMarketFaceElement | SendFileElement | + SendVideoElement | SendArkElement | SendMarkdownElement | SendShareLocationElement; diff --git a/packages/snowcat-core/types/emoji.ts b/packages/snowcat-core/types/emoji.ts new file mode 100644 index 0000000..653588b --- /dev/null +++ b/packages/snowcat-core/types/emoji.ts @@ -0,0 +1,54 @@ +export enum PullMomentType { + REINSTALL = 0, + RESTART_FIRST_AIO = 1, + LOGIN_APP = 2, + SINGEL_PULL_NOTIFY = 3, + TRIGGER_SPECIFIC_EMOJI_RANDOM_RESULT = 4, +} + +export interface PullSysEmojisReq { + fetchAdvaceSource: boolean; + fetchBaseSource: boolean; + pullMoment: PullMomentType; + pullType: number; + refresh: boolean; + thresholdValue: number; +} + +export enum BaseEmojiType { + NORMAL_EMOJI = 0, + SUPER_EMOJI = 1, + RANDOM_SUPER_EMOJI = 2, + CHAIN_SUPER_EMOJI = 3, + EMOJI_EMOJI = 4, +} + +export interface GetBaseEmojiPathReq { + emojiId: string; + type: BaseEmojiType; +} +export enum EmojiPanelCategory { + OTHER_PANEL = 0, + NORMAL_PANEL = 1, + SUPER_PANEL = 2, + RED_HEART_PANEL = 3, +} + +export interface DownloadBaseEmojiInfo { + baseResDownloadUrl: string; + advancedResDownloadUrl: string; +} + +export interface DownloadBaseEmojiByUrlReq { + emojiId: string; + groupName: string; + panelCategory: EmojiPanelCategory; + downloadInfo: DownloadBaseEmojiInfo; +} + +export interface DownloadBaseEmojiByIdReq { + emojiId: string; + groupName: string; + panelCategory: EmojiPanelCategory; + qzoneCode: string; +} diff --git a/packages/snowcat-core/types/file.ts b/packages/snowcat-core/types/file.ts new file mode 100644 index 0000000..1a08d60 --- /dev/null +++ b/packages/snowcat-core/types/file.ts @@ -0,0 +1,5 @@ +export interface rkeyDataType { + private_rkey: string; + group_rkey: string; + online_rkey: boolean; +} diff --git a/packages/snowcat-core/types/flashfile.ts b/packages/snowcat-core/types/flashfile.ts new file mode 100644 index 0000000..b057cfb --- /dev/null +++ b/packages/snowcat-core/types/flashfile.ts @@ -0,0 +1,21 @@ +export enum fileType { + MP3 = 1, + VIDEO = 2, + DOC = 3, + ZIP = 4, + XLS = 6, + PPT = 7, + CODE = 8, + PDF = 9, + TXT = 10, + UNKNOW = 11, + FOLDER = 25, + IMG = 26, +} + +export enum FileStatus { + UPLOADING = 0, + // DOWNLOADED = 1, ??? 不太清楚 + OK = 2, + STOP = 3, +} diff --git a/packages/snowcat-core/types/graytip.ts b/packages/snowcat-core/types/graytip.ts new file mode 100644 index 0000000..49462ec --- /dev/null +++ b/packages/snowcat-core/types/graytip.ts @@ -0,0 +1,74 @@ +export enum JsonGrayBusiId { + AIO_AV_C2C_NOTICE = 2021, + AIO_AV_GROUP_NOTICE = 2022, + AIO_C2C_DONT_DISTURB = 2100, + AIO_CRM_FLAGS_TIPS = 2050, + AIO_GROUP_ESSENCE_MSG_TIP = 2401, + AIO_NUDGE_CUSTOM_GUIDE = 2041, + AIO_PUSH_GUIDE_GRAY_TIPS = 2701, + AIO_RECALL_MSGCUSTOM_WORDINGGUIDE = 2000, + AIO_ROBOT_SAFETY_TIP = 2201, + AIO_ZPLAN_EMOTICON_GUIDE = 2301, + AIO_ZPLAN_SCENE_LINKAGE = 2302, + AIO_ZPLAN_SEND_MEME = 2300, + DISBAND_DISCUSSION_GRAY_TIP_ID = 2603, + FILE_SENDING_SIZE_4GB_LIMIT = 3003, + GROUP_AIO_CONFIGURABLE_GRAY_TIPS = 2407, + GROUP_AIO_HOME_SCHOOL_WELCOME_GRAY_TIP_ID = 2404, + GROUP_AIO_MSG_FREQUENCY_GRAY_TIP_ID = 2406, + GROUP_AIO_SHUTUP_GRAY_TIP_ID = 2402, + GROUP_AIO_TEMPORARY_GRAY_TIP_ID = 2405, + GROUP_AIO_UNREAD_MSG_AI_SUMMARY = 2408, + GROUP_AIO_UPLOAD_PERMISSIONS_GRAY_TIP_ID = 2403, + LITE_ACTION = 86, + ONLINE_FILE_CANCEL_RECV_ON_RECVING = 4, + ONLINE_FILE_GO_OFFLINE = 11, + ONLINE_FILE_GO_OFFLINE_ALL = 12, + ONLINE_FILE_RECV_BY_MOBILE = 13, + ONLINE_FILE_RECV_ERROR = 10, + ONLINE_FILE_REFUSE_ALL_RECV = 7, + ONLINE_FILE_REFUSE_ALL_RECV_ON_RECVING = 8, + ONLINE_FILE_REFUSE_RECV = 3, + ONLINE_FILE_SEND_ERROR = 9, + ONLINE_FILE_STOP_ALL_SEND = 5, + ONLINE_FILE_STOP_ALL_SEND_ON_SENDING = 6, + ONLINE_FILE_STOP_SEND = 1, + ONLINE_FILE_STOP_SEND_ON_SENDING = 2, + ONLINE_GROUP_HOME_WORK = 51, + PTT_AUTO_CHANGE_GUIDE = 2060, + QCIRCLE_SHOW_FULE_TIPS = 2601, + QWALLET_GRAY_TIP_ID = 2602, + RED_BAG = 81, + RELATION_C2C_GROUP_AIO_SETUP_GROUP_AND_REMARK = 1005, + RELATION_C2C_LOVER_BONUS = 1003, + RELATION_C2C_MEMBER_ADD = 1017, + RELATION_C2C_REACTIVE_DEGRADE_MSG = 1019, + RELATION_C2C_REACTIVE_UPGRADE_MSG = 1018, + RELATION_C2C_SAY_HELLO = 1004, + RELATION_CHAIN_BLACKED = 1000, + RELATION_CHAIN_MATCH_FRIEND = 1007, + RELATION_CREATE_GROUP_GRAY_TIP_ID = 1009, + RELATION_EMOJIEGG_SHOW = 1001, + RELATION_EMOJIEGG_WILL_DEGRADE = 1002, + RELATION_FRIEND_CLONE_INFO = 1006, + RELATION_GROUP_BATCH_ADD_FRIEND = 1020, + RELATION_GROUP_MEMBER_ADD = 1022, + RELATION_GROUP_MEMBER_ADD_WITH_MODIFY_NAME = 1015, + RELATION_GROUP_MEMBER_ADD_WITH_WELCOME = 1016, + RELATION_GROUP_MEMBER_RECOMMEND = 1021, + RELATION_GROUP_SHUT_UP = 1014, + RELATION_LIMIT_TMP_CONVERSATION_SET = 1011, + RELATION_NEARBY_GOTO_VERIFY = 1008, + RELATION_ONEWAY_FRIEND_GRAY_TIP_ID = 1012, + RELATION_ONEWAY_FRIEND_NEW_GRAY_TIP_ID = 1013, + RELATION_YQT = 1010, + TROOP_ADD_FRIEND_ACTIVE = 19264, + TROOP_ADD_FRIEND_HOT_CHAT = 19265, + TROOP_ADD_FRIEND_NEW_MEMBER = 19267, + TROOP_ADD_FRIEND_REPLY_OR_AT = 19266, + TROOP_BREAK_ICE = 10405, + TROOP_FLAME_IGNITED = 19273, + UI_RESERVE_100000_110000 = 100000, + VAS_FILE_UPLOAD_OVER_1G = 3002, + VAS_FILE_UPLOAD_OVER_LIMIT = 3001, +} diff --git a/packages/snowcat-core/types/group.ts b/packages/snowcat-core/types/group.ts new file mode 100644 index 0000000..0c2372b --- /dev/null +++ b/packages/snowcat-core/types/group.ts @@ -0,0 +1,582 @@ +import { QQLevel, NTSex } from './user'; +export interface GroupExtInfo { + groupCode: string; + resultCode: number; + extInfo: EXTInfo; +} +export interface GroupExtFilter { + groupInfoExtSeq: number; + reserve: number; + luckyWordId: number; + lightCharNum: number; + luckyWord: number; + starId: number; + essentialMsgSwitch: number; + todoSeq: number; + blacklistExpireTime: number; + isLimitGroupRtc: number; + companyId: number; + hasGroupCustomPortrait: number; + bindGuildId: number; + groupOwnerId: number; + essentialMsgPrivilege: number; + msgEventSeq: number; + inviteRobotSwitch: number; + gangUpId: number; + qqMusicMedalSwitch: number; + showPlayTogetherSwitch: number; + groupFlagPro1: number; + groupBindGuildIds: number; + viewedMsgDisappearTime: number; + groupExtFlameData: number; + groupBindGuildSwitch: number; + groupAioBindGuildId: number; + groupExcludeGuildIds: number; + fullGroupExpansionSwitch: number; + fullGroupExpansionSeq: number; + inviteRobotMemberSwitch: number; + inviteRobotMemberExamine: number; + groupSquareSwitch: number; +} + +export interface EXTInfo { + groupInfoExtSeq: number; + reserve: number; + luckyWordId: string; + lightCharNum: number; + luckyWord: string; + starId: number; + essentialMsgSwitch: number; + todoSeq: number; + blacklistExpireTime: number; + isLimitGroupRtc: number; + companyId: number; + hasGroupCustomPortrait: number; + bindGuildId: string; + groupOwnerId: GroupOwnerID; + essentialMsgPrivilege: number; + msgEventSeq: string; + inviteRobotSwitch: number; + gangUpId: string; + qqMusicMedalSwitch: number; + showPlayTogetherSwitch: number; + groupFlagPro1: string; + groupBindGuildIds: GroupGuildIDS; + viewedMsgDisappearTime: string; + groupExtFlameData: GroupEXTFlameData; + groupBindGuildSwitch: number; + groupAioBindGuildId: string; + groupExcludeGuildIds: GroupGuildIDS; + fullGroupExpansionSwitch: number; + fullGroupExpansionSeq: string; + inviteRobotMemberSwitch: number; + inviteRobotMemberExamine: number; + groupSquareSwitch: number; +} + +export type GroupMemberInvitePolicy = + | 'disabled' + | 'require_approval' + | 'no_approval' + | 'no_approval_under_100'; + +export interface GroupManagementSettings { + memberInvite?: GroupMemberInvitePolicy; + allowMemberUploadAlbum?: boolean; + allowMemberTemporarySession?: boolean; + allowMemberCreateGroup?: boolean; + newMembersSeeRecentHistory?: boolean; +} + +export interface GroupGuildIDS { + guildIds: any[]; +} + +export interface GroupEXTFlameData { + switchState: number; + state: number; + dayNums: any[]; + version: number; + updateTime: string; + isDisplayDayNum: boolean; +} + +export interface GroupOwnerID { + memberUin: string; + memberUid: string; + memberQid: string; +} + +export interface KickMemberInfo { + optFlag: number; + optOperate: number; + optMemberUid: string; + optBytesMsg: string; +} + +export interface GroupDetailInfoV2Param { + groupCode: string; + filter: Filter; + modifyInfo: ModifyInfo; +} + +export interface Filter { + noCodeFingerOpenFlag: number; + noFingerOpenFlag: number; + groupName: number; + classExt: number; + classText: number; + fingerMemo: number; + richFingerMemo: number; + tagRecord: number; + groupGeoInfo: FilterGroupGeoInfo; + groupExtAdminNum: number; + flag: number; + groupMemo: number; + groupAioSkinUrl: number; + groupBoardSkinUrl: number; + groupCoverSkinUrl: number; + groupGrade: number; + activeMemberNum: number; + certificationType: number; + certificationText: number; + groupNewGuideLines: FilterGroupNewGuideLines; + groupFace: number; + addOption: number; + shutUpTime: number; + groupTypeFlag: number; + appPrivilegeFlag: number; + appPrivilegeMask: number; + groupExtOnly: GroupEXTOnly; + groupSecLevel: number; + groupSecLevelInfo: number; + subscriptionUin: number; + subscriptionUid: string; + allowMemberInvite: number; + groupQuestion: number; + groupAnswer: number; + groupFlagExt3: number; + groupFlagExt3Mask: number; + groupOpenAppid: number; + rootId: number; + msgLimitFrequency: number; + hlGuildAppid: number; + hlGuildSubType: number; + hlGuildOrgId: number; + groupFlagExt4: number; + groupFlagExt4Mask: number; + groupSchoolInfo: FilterGroupSchoolInfo; + groupCardPrefix: FilterGroupCardPrefix; + allianceId: number; + groupFlagPro1: number; + groupFlagPro1Mask: number; +} + +export interface FilterGroupCardPrefix { + introduction: number; + rptPrefix: number; +} + +export interface GroupEXTOnly { + tribeId: number; + moneyForAddGroup: number; +} + +export interface FilterGroupGeoInfo { + ownerUid: number; + setTime: number; + cityId: number; + longitude: number; + latitude: number; + geoContent: number; + poiId: number; +} + +export interface FilterGroupNewGuideLines { + enabled: number; + content: number; +} + +export interface FilterGroupSchoolInfo { + location: number; + grade: number; + school: number; +} + +export interface ModifyInfo { + noCodeFingerOpenFlag: number; + noFingerOpenFlag: number; + groupName: string; + classExt: number; + classText: string; + fingerMemo: string; + richFingerMemo: string; + tagRecord: any[]; + groupGeoInfo: ModifyInfoGroupGeoInfo; + groupExtAdminNum: number; + flag: number; + groupMemo: string; + groupAioSkinUrl: string; + groupBoardSkinUrl: string; + groupCoverSkinUrl: string; + groupGrade: number; + activeMemberNum: number; + certificationType: number; + certificationText: string; + groupNewGuideLines: ModifyInfoGroupNewGuideLines; + groupFace: number; + addOption: number;// 0 空设置 1 任何人都可以进入 2 需要管理员批准 3 不允许任何人入群 4 问题进入答案 5 问题管理员批准 + shutUpTime: number; + groupTypeFlag: number; + appPrivilegeFlag: number; + // 需要管理员审核 + // 0000 0000 0000 0000 0000 0000 0000 + // 无需审核入群 + // 0000 0001 0000 0000 0000 0000 0000 + // 成员数100内无审核 + // 0100 0000 0000 0000 0000 0000 0000 + // 禁用 群成员邀请好友 + // 0100 0000 0000 0000 0000 0000 0000 + + appPrivilegeMask: number; + // 0110 0001 0000 0000 0000 0000 0000 + // 101711872 + groupExtOnly: GroupEXTOnly; + groupSecLevel: number; + groupSecLevelInfo: number; + subscriptionUin: string; + subscriptionUid: string; + allowMemberInvite: number; + groupQuestion: string; + groupAnswer: string; + groupFlagExt3: number; + groupFlagExt3Mask: number; + groupOpenAppid: number; + rootId: string; + msgLimitFrequency: number; + hlGuildAppid: number; + hlGuildSubType: number; + hlGuildOrgId: number; + groupFlagExt4: number; + groupFlagExt4Mask: number; + groupSchoolInfo: ModifyInfoGroupSchoolInfo; + groupCardPrefix: ModifyInfoGroupCardPrefix; + allianceId: string; + groupFlagPro1: number; + groupFlagPro1Mask: number; +} + +export interface ModifyInfoGroupCardPrefix { + introduction: string; + rptPrefix: any[]; +} + +export interface ModifyInfoGroupGeoInfo { + ownerUid: string; + SetTime: number; + CityId: number; + Longitude: string; + Latitude: string; + GeoContent: string; + poiId: string; +} + +export interface ModifyInfoGroupNewGuideLines { + enabled: boolean; + content: string; +} + +export interface ModifyInfoGroupSchoolInfo { + location: string; + grade: number; + school: string; +} + +// 获取群详细信息的来源类型 +export enum GroupInfoSource { + KUNSPECIFIED, + KBIGDATACARD, + KDATACARD, + KNOTICE, + KAIO, + KRECENTCONTACT, + KMOREPANEL, +} +export interface GroupDetailInfo { + groupCode: string; + groupUin: string; + ownerUid: string; + ownerUin: string; + groupFlag: number; + groupFlagExt: number; + maxMemberNum: number; + memberNum: number; + groupOption: number; + classExt: number; + groupName: string; + fingerMemo: string; + groupQuestion: string; + certType: number; + richFingerMemo: string; + tagRecord: unknown[]; + shutUpAllTimestamp: number; + shutUpMeTimestamp: number; + groupTypeFlag: number; + privilegeFlag: number; + groupSecLevel: number; + groupFlagExt3: number; + isConfGroup: number; + isModifyConfGroupFace: number; + isModifyConfGroupName: number; + groupFlagExt4: number; + groupMemo: string; + cmdUinMsgSeq: number; + cmdUinJoinTime: number; + cmdUinUinFlag: number; + cmdUinMsgMask: number; + groupSecLevelInfo: number; + cmdUinPrivilege: number; + cmdUinFlagEx2: number; + appealDeadline: number; + remarkName: string; + isTop: boolean; + groupFace: number; + groupGeoInfo: { + ownerUid: string; + SetTime: number; + CityId: number; + Longitude: string; + Latitude: string; + GeoContent: string; + poiId: string; + }; + certificationText: string; + cmdUinRingtoneId: number; + longGroupName: string; + autoAgreeJoinGroupUserNumForConfGroup: number; + autoAgreeJoinGroupUserNumForNormalGroup: number; + cmdUinFlagExt3Grocery: number; + groupCardPrefix: { + introduction: string; + rptPrefix: unknown[]; + }; + groupExt: { + groupInfoExtSeq: number; + reserve: number; + luckyWordId: string; + lightCharNum: number; + luckyWord: string; + starId: number; + essentialMsgSwitch: number; + todoSeq: number; + blacklistExpireTime: number; + isLimitGroupRtc: number; + companyId: number; + hasGroupCustomPortrait: number; + bindGuildId: string; + groupOwnerId: { + memberUin: string; + memberUid: string; + memberQid: string; + }; + essentialMsgPrivilege: number; + msgEventSeq: string; + inviteRobotSwitch: number; + gangUpId: string; + qqMusicMedalSwitch: number; + showPlayTogetherSwitch: number; + groupFlagPro1: string; + groupBindGuildIds: { + guildIds: unknown[]; + }; + viewedMsgDisappearTime: string; + groupExtFlameData: { + switchState: number; + state: number; + dayNums: unknown[]; + version: number; + updateTime: string; + isDisplayDayNum: boolean; + }; + groupBindGuildSwitch: number; + groupAioBindGuildId: string; + groupExcludeGuildIds: { + guildIds: unknown[]; + }; + fullGroupExpansionSwitch: number; + fullGroupExpansionSeq: string; + inviteRobotMemberSwitch: number; + inviteRobotMemberExamine: number; + groupSquareSwitch: number; + }; + msgLimitFrequency: number; + hlGuildAppid: number; + hlGuildSubType: number; + isAllowRecallMsg: number; + confUin: string; + confMaxMsgSeq: number; + confToGroupTime: number; + groupSchoolInfo: { + location: string; + grade: number; + school: string; + }; + activeMemberNum: number; + groupGrade: number; + groupCreateTime: number; + subscriptionUin: string; + subscriptionUid: string; + noFingerOpenFlag: number; + noCodeFingerOpenFlag: number; + isGroupFreeze: number; + allianceId: string; + groupExtOnly: { + tribeId: number; + moneyForAddGroup: number; + }; + isAllowConfGroupMemberModifyGroupName: number; + isAllowConfGroupMemberNick: number; + isAllowConfGroupMemberAtAll: number; + groupClassText: string; + groupFreezeReason: number; + headPortraitSeq: number; + groupHeadPortrait: { + portraitCnt: number; + portraitInfo: unknown[]; + defaultId: number; + verifyingPortraitCnt: number; + verifyingPortraitInfo: unknown[]; + }; + cmdUinJoinMsgSeq: number; + cmdUinJoinRealMsgSeq: number; + groupAnswer: string; + groupAdminMaxNum: number; + inviteNoAuthNumLimit: string; + hlGuildOrgId: number; + isAllowHlGuildBinary: number; + localExitGroupReason: number; +} +export interface GroupExt0xEF0InfoFilter { + bindGuildId: number; + blacklistExpireTime: number; + companyId: number; + essentialMsgPrivilege: number; + essentialMsgSwitch: number; + fullGroupExpansionSeq: number; + fullGroupExpansionSwitch: number; + gangUpId: number; + groupAioBindGuildId: number; + groupBindGuildIds: number; + groupBindGuildSwitch: number; + groupExcludeGuildIds: number; + groupExtFlameData: number; + groupFlagPro1: number; + groupInfoExtSeq: number; + groupOwnerId: number; + groupSquareSwitch: number; + hasGroupCustomPortrait: number; + inviteRobotMemberExamine: number; + inviteRobotMemberSwitch: number; + inviteRobotSwitch: number; + isLimitGroupRtc: number; + lightCharNum: number; + luckyWord: number; + luckyWordId: number; + msgEventSeq: number; + qqMusicMedalSwitch: number; + reserve: number; + showPlayTogetherSwitch: number; + starId: number; + todoSeq: number; + viewedMsgDisappearTime: number; +} + +export interface KickMemberV2Req { + groupCode: string; + kickFlag: number; + kickList: Array; + kickListUids: Array; + kickMsg: string; +} + +// 数据来源类型 +export enum DataSource { + LOCAL = 0, + REMOTE = 1, +} + +// 群列表更新类型 +export enum GroupListUpdateType { + REFRESHALL = 0, + GETALL = 1, + MODIFIED = 2, + REMOVE = 3, +} + +export interface GroupMemberCache { + group: { + data: GroupMember[]; + isExpired: boolean; + }; + isExpired: boolean; +} + +export interface Group { + groupCode: string; + createTime?: string; + maxMember: number; + memberCount: number; + groupName: string; + groupStatus: number; + memberRole: number; + isTop: boolean; + toppedTimestamp: string; + privilegeFlag: number; + isConf: boolean; + hasModifyConfGroupFace: boolean; + hasModifyConfGroupName: boolean; + remarkName: string; + hasMemo: boolean; + groupShutupExpireTime: string; + personShutupExpireTime: string; + discussToGroupUin: string; + discussToGroupMaxMsgSeq: number; + discussToGroupTime: number; + groupFlagExt: number; + authGroupType: number; + groupCreditLevel: number; + groupFlagExt3: number; + groupOwnerId: { + memberUin: string; + memberUid: string; + }; +} +export enum NTGroupMemberRole { + KUNSPECIFIED = 0, + KSTRANGER = 1, + KMEMBER = 2, + KADMIN = 3, + KOWNER = 4, +} +export interface GroupMember { + memberRealLevel: number | undefined; + memberSpecialTitle?: string; + avatarPath: string; + cardName: string; + cardType: number; + isDelete: boolean; + nick: string; + qid: string; + remark: string; + role: NTGroupMemberRole; + shutUpTime: number; // 禁言时间(S) + uid: string; + uin: string; + isRobot: boolean; + sex?: NTSex; + age?: number; + qqLevel?: QQLevel; + isChangeRole: boolean; + joinTime: string; + lastSpeakTime: string; +} diff --git a/packages/snowcat-core/types/index.ts b/packages/snowcat-core/types/index.ts new file mode 100644 index 0000000..a43181c --- /dev/null +++ b/packages/snowcat-core/types/index.ts @@ -0,0 +1,17 @@ +export * from './user'; +export * from './group'; +export * from './msg'; +export * from './notify'; +export * from './cache'; +export * from './system'; +export * from './webapi'; +export * from './sign'; +export * from './element'; +export * from './constant'; +export * from './graytip'; +export * from './emoji'; +export * from './service'; +export * from './adapter'; +export * from './contact'; +export * from './file'; +export * from './flashfile'; diff --git a/packages/snowcat-core/types/msg.ts b/packages/snowcat-core/types/msg.ts new file mode 100644 index 0000000..8b1278e --- /dev/null +++ b/packages/snowcat-core/types/msg.ts @@ -0,0 +1,691 @@ +import { NTGroupMemberRole } from './group'; +import { ActionBarElement, ArkElement, AvRecordElement, CalendarElement, FaceBubbleElement, FaceElement, FileElement, GiphyElement, GrayTipElement, MarketFaceElement, PicElement, PttElement, RecommendedMsgElement, ReplyElement, ShareLocationElement, StructLongMsgElement, TaskTopMsgElement, TextElement, TofuRecordElement, VideoElement, YoloGameResultElement } from './element'; + +/* + * 2024/11/22 Refactor Mlikiowa + */ + +/** + * 表示对等方的信息 + */ +export interface Peer { + chatType: ChatType; // 聊天类型 + peerUid: string; // 对等方的唯一标识符 + guildId?: string; // 可选的频道ID +} + +/** + * 表示被踢下线的信息 + */ +export interface KickedOffLineInfo { + appId: number; // 应用ID + instanceId: number; // 实例ID + sameDevice: boolean; // 是否为同一设备 + tipsDesc: string; // 提示描述 + tipsTitle: string; // 提示标题 + kickedType: number; // 被踢类型 + securityKickedType: number; // 安全踢出类型 +} + +/** + * 获取文件列表的参数 + */ +export interface GetFileListParam { + sortType: number; + fileCount: number; + startIndex: number; + sortOrder: number; + showOnlinedocFolder: number; + folderId?: string; +} + +/** + * 消息元素类型枚举 + */ +export enum ElementType { + UNKNOWN = 0, + TEXT = 1, + PIC = 2, + FILE = 3, + PTT = 4, + VIDEO = 5, + FACE = 6, + REPLY = 7, + GreyTip = 8, // “小灰条”,包括拍一拍 (Poke)、撤回提示等 + WALLET = 9, + ARK = 10, + MFACE = 11, + LIVEGIFT = 12, + STRUCTLONGMSG = 13, + MARKDOWN = 14, + GIPHY = 15, + MULTIFORWARD = 16, + INLINEKEYBOARD = 17, + INTEXTGIFT = 18, + CALENDAR = 19, + YOLOGAMERESULT = 20, + AVRECORD = 21, + FEED = 22, + TOFURECORD = 23, // tofu record?? 在线文件的id是这个 + ACEBUBBLE = 24, + ACTIVITY = 25, + TOFU = 26, + FACEBUBBLE = 27, + SHARELOCATION = 28, + TASKTOPMSG = 29, + ONLINEFOLDER = 30, // 在线文件夹 + RECOMMENDEDMSG = 43, + ACTIONBAR = 44, +} + +/** + * 消息类型枚举 + */ +export enum NTMsgType { + KMSGTYPEARKSTRUCT = 11, + KMSGTYPEFACEBUBBLE = 24, + KMSGTYPEFILE = 3, + KMSGTYPEGIFT = 14, + KMSGTYPEGIPHY = 13, + KMSGTYPEGRAYTIPS = 5, + KMSGTYPEMIX = 2, + KMSGTYPEMULTIMSGFORWARD = 8, + KMSGTYPENULL = 1, + KMSGTYPEONLINEFILE = 21, + KMSGTYPEONLINEFOLDER = 27, + KMSGTYPEPROLOGUE = 29, + KMSGTYPEPTT = 6, + KMSGTYPEREPLY = 9, + KMSGTYPESHARELOCATION = 25, + KMSGTYPESTRUCT = 4, + KMSGTYPESTRUCTLONGMSG = 12, + KMSGTYPETEXTGIFT = 15, + KMSGTYPEUNKNOWN = 0, + KMSGTYPEVIDEO = 7, + KMSGTYPEWALLET = 10, +} + +/** + * 图片类型枚举 + */ +export enum PicType { + NEWPIC_APNG = 2001, + NEWPIC_BMP = 1005, + NEWPIC_GIF = 2000, + NEWPIC_JPEG = 1000, + NEWPIC_PNG = 1001, + NEWPIC_PROGERSSIV_JPEG = 1003, + NEWPIC_SHARPP = 1004, + NEWPIC_WEBP = 1002, +} +/** + * 图片子类型枚举 + */ +export enum PicSubType { + KNORMAL = 0, + KCUSTOM = 1, + KHOT = 2, + KDIPPERCHART = 3, + KSMART = 4, + KSPACE = 5, + KUNKNOW = 6, + KRELATED = 7, +} +/** + * 消息AT类型枚举 + */ +export enum NTMsgAtType { + ATTYPEALL = 1, + ATTYPECATEGORY = 512, + ATTYPECHANNEL = 16, + ATTYPEME = 4, + ATTYPEONE = 2, + ATTYPEONLINE = 64, + ATTYPEROLE = 8, + ATTYPESUMMON = 32, + ATTYPESUMMONONLINE = 128, + ATTYPESUMMONROLE = 256, + ATTYPEUNKNOWN = 0, +} + +/** + * 消息元素接口 + */ +export interface MessageElement { + elementType: ElementType, + elementId: string, + extBufForUI?: string, // "0x", + textElement?: TextElement; + faceElement?: FaceElement, + marketFaceElement?: MarketFaceElement, + replyElement?: ReplyElement, + picElement?: PicElement, + pttElement?: PttElement, + videoElement?: VideoElement, + grayTipElement?: GrayTipElement, + arkElement?: ArkElement, + fileElement?: FileElement, + liveGiftElement?: null, + markdownElement?: MarkdownElement, + structLongMsgElement?: StructLongMsgElement, + multiForwardMsgElement?: MultiForwardMsgElement, + giphyElement?: GiphyElement, + walletElement?: null, + inlineKeyboardElement?: InlineKeyboardElement, + textGiftElement?: null, // ???? + calendarElement?: CalendarElement, + yoloGameResultElement?: YoloGameResultElement, + avRecordElement?: AvRecordElement, + structMsgElement?: null, + faceBubbleElement?: FaceBubbleElement, + shareLocationElement?: ShareLocationElement, + tofuRecordElement?: TofuRecordElement, + taskTopMsgElement?: TaskTopMsgElement, + recommendedMsgElement?: RecommendedMsgElement, + actionBarElement?: ActionBarElement; +} + +/** + * 消息来源类型枚举 + */ +export enum MsgSourceType { + K_DOWN_SOURCETYPE_AIOINNER = 1, + K_DOWN_SOURCETYPE_BIGSCREEN = 2, + K_DOWN_SOURCETYPE_HISTORY = 3, + K_DOWN_SOURCETYPE_UNKNOWN = 0, +} + +/** + * 聊天类型枚举 + */ +export enum ChatType { + KCHATTYPEADELIE = 42, + KCHATTYPEBUDDYNOTIFY = 5, + KCHATTYPEC2C = 1, + KCHATTYPECIRCLE = 113, + KCHATTYPEDATALINE = 8, + KCHATTYPEDATALINEMQQ = 134, + KCHATTYPEDISC = 3, + KCHATTYPEFAV = 41, + KCHATTYPEGAMEMESSAGE = 105, + KCHATTYPEGAMEMESSAGEFOLDER = 116, + KCHATTYPEGROUP = 2, + KCHATTYPEGROUPBLESS = 133, + KCHATTYPEGROUPGUILD = 9, + KCHATTYPEGROUPHELPER = 7, + KCHATTYPEGROUPNOTIFY = 6, + KCHATTYPEGUILD = 4, + KCHATTYPEGUILDMETA = 16, + KCHATTYPEMATCHFRIEND = 104, + KCHATTYPEMATCHFRIENDFOLDER = 109, + KCHATTYPENEARBY = 106, + KCHATTYPENEARBYASSISTANT = 107, + KCHATTYPENEARBYFOLDER = 110, + KCHATTYPENEARBYHELLOFOLDER = 112, + KCHATTYPENEARBYINTERACT = 108, + KCHATTYPEQQNOTIFY = 132, + KCHATTYPERELATEACCOUNT = 131, + KCHATTYPESERVICEASSISTANT = 118, + KCHATTYPESERVICEASSISTANTSUB = 201, + KCHATTYPESQUAREPUBLIC = 115, + KCHATTYPESUBSCRIBEFOLDER = 30, + KCHATTYPETEMPADDRESSBOOK = 111, + KCHATTYPETEMPBUSSINESSCRM = 102, + KCHATTYPETEMPC2CFROMGROUP = 100, + KCHATTYPETEMPC2CFROMUNKNOWN = 99, + KCHATTYPETEMPFRIENDVERIFY = 101, + KCHATTYPETEMPNEARBYPRO = 119, + KCHATTYPETEMPPUBLICACCOUNT = 103, + KCHATTYPETEMPWPA = 117, + KCHATTYPEUNKNOWN = 0, + KCHATTYPEWEIYUN = 40, +} + +/** + * 灰色提示元素子类型枚举 + */ +export enum NTGrayTipElementSubTypeV2 { + GRAYTIP_ELEMENT_SUBTYPE_AIOOP = 15, + GRAYTIP_ELEMENT_SUBTYPE_BLOCK = 14, + GRAYTIP_ELEMENT_SUBTYPE_BUDDY = 5, + GRAYTIP_ELEMENT_SUBTYPE_BUDDYNOTIFY = 9, + GRAYTIP_ELEMENT_SUBTYPE_EMOJIREPLY = 3, + GRAYTIP_ELEMENT_SUBTYPE_ESSENCE = 7, + GRAYTIP_ELEMENT_SUBTYPE_FEED = 6, + GRAYTIP_ELEMENT_SUBTYPE_FEEDCHANNELMSG = 11, + GRAYTIP_ELEMENT_SUBTYPE_FILE = 10, + GRAYTIP_ELEMENT_SUBTYPE_GROUP = 4, + GRAYTIP_ELEMENT_SUBTYPE_GROUPNOTIFY = 8, + GRAYTIP_ELEMENT_SUBTYPE_JSON = 17, + GRAYTIP_ELEMENT_SUBTYPE_LOCALMSG = 13, + GRAYTIP_ELEMENT_SUBTYPE_PROCLAMATION = 2, + GRAYTIP_ELEMENT_SUBTYPE_REVOKE = 1, + GRAYTIP_ELEMENT_SUBTYPE_UNKNOWN = 0, + GRAYTIP_ELEMENT_SUBTYPE_WALLET = 16, + GRAYTIP_ELEMENT_SUBTYPE_XMLMSG = 12, +} + +/** + * Poke 类型枚举 + */ +export enum PokeType { + POKE_TYPE_APPROVE = 3, + POKE_TYPE_GIVING_HEART = 2, + POKE_TYPE_GREAT_MOVE = 6, + POKE_TYPE_HEART_BREAK = 4, + POKE_TYPE_HI_TOGETHER = 5, + POKE_TYPE_POKE = 1, + POKE_TYPE_POKE_OLD = 0, + POKE_TYPE_VAS_POKE = 126, +} + +/** + * 表情索引枚举 + */ +export enum FaceIndex { + DICE = 358, + RPS = 359, +} + +/** + * 视频类型枚举 + */ +export enum NTVideoType { + VIDEO_FORMAT_AFS = 7, + VIDEO_FORMAT_AVI = 1, + VIDEO_FORMAT_MKV = 4, + VIDEO_FORMAT_MOD = 9, + VIDEO_FORMAT_MOV = 8, + VIDEO_FORMAT_MP4 = 2, + VIDEO_FORMAT_MTS = 11, + VIDEO_FORMAT_RM = 6, + VIDEO_FORMAT_RMVB = 5, + VIDEO_FORMAT_TS = 10, + VIDEO_FORMAT_WMV = 3, +} + +/** + * 闪传图标 + */ +export interface FlashTransferIcon { + spec: number; + url: string; +} + +/** + * 闪传文件信息 + */ +export interface FlashTransferInfo { + filesetId: string; + name: string; + fileSize: string; + thnumbnail: { + id: string; + urls: FlashTransferIcon[]; + localCachePath: string; + }; +} + +/** + * Markdown元素接口 + */ +export interface MarkdownElement { + content: string; + style?: {}; + processMsg?: string; + mdSummary?: string; + mdExtType?: number; + mdExtInfo?: { + flashTransferInfo: FlashTransferInfo; + }; +} + +/** + * 内联键盘按钮接口 + */ +export interface InlineKeyboardElementRowButton { + id: string; + label: string; + visitedLabel: string; + style: 1; // 未知 + type: 2; // 未知 + clickLimit: 0; // 未知 + unsupportTips: string; + data: string; + atBotShowChannelList: boolean; + permissionType: number; + specifyRoleIds: []; + specifyTinyids: []; + isReply: false; + anchor: 0; + enter: false; + subscribeDataTemplateIds: []; +} + +/** + * 内联键盘元素接口 + */ +export interface InlineKeyboardElement { + rows: [{ + buttons: InlineKeyboardElementRowButton[]; + }], + botAppid: string; +} + +/** + * Aio操作灰色提示元素接口 + */ +export interface TipAioOpGrayTipElement { + operateType: number; + peerUid: string; + fromGrpCodeOfTmpChat: string; +} + +/** + * 群提示元素类型枚举 + */ +export enum TipGroupElementType { + KUNKNOWN = 0, + KMEMBERADD = 1, + KDISBANDED = 2, + KQUITTE = 3, + KCREATED = 4, + KGROUPNAMEMODIFIED = 5, + KBLOCK = 6, + KUNBLOCK = 7, + KSHUTUP = 8, + KBERECYCLED = 9, + KDISBANDORBERECYCLED = 10, +} + +/** + * 群加入ShowType + */ +export enum MemberAddShowType { + K_OTHER_ADD = 0, + K_OTHER_ADD_BY_OTHER_QRCODE = 2, + K_OTHER_ADD_BY_YOUR_QRCODE = 3, + K_OTHER_INVITE_OTHER = 5, + K_OTHER_INVITE_YOU = 6, + K_YOU_ADD = 1, + K_YOU_ADD_BY_OTHER_QRCODE = 4, + K_YOU_ALREADY_MEMBER = 8, + K_YOU_INVITE_OTHER = 7, +} + +/** + * 群提示元素成员角色枚举 + */ +export enum NTGroupGrayElementRole { + KOTHER = 0, + KMEMBER = 1, + KADMIN = 2, +} + +/** + * 群灰色提示成员接口 + * */ + +export interface NTGroupGrayMember { + serialVersionUID: string; + uid: string; + name: string; +} +/** + * 群灰色提示邀请者和被邀请者接口 + * + * */ +export interface NTGroupGrayInviterAndInvite { + invited: NTGroupGrayMember; + inviter: NTGroupGrayMember; + serialVersionUID: string; +} +/** + * 群提示元素接口 + */ +export interface TipGroupElement { + type: TipGroupElementType; + role: NTGroupGrayElementRole; + groupName: string; + memberUid: string; + memberNick: string; + memberRemark: string; + adminUid: string; + adminNick: string; + adminRemark: string; + createGroup: null; + memberAdd?: { + showType: MemberAddShowType; + otherAdd: NTGroupGrayMember; + otherAddByOtherQRCode: NTGroupGrayInviterAndInvite; + otherAddByYourQRCode: NTGroupGrayMember; + youAddByOtherQRCode: NTGroupGrayMember; + otherInviteOther: NTGroupGrayInviterAndInvite; + otherInviteYou: NTGroupGrayMember; + youInviteOther: NTGroupGrayMember; + }; + shutUp?: { + curTime: string; + duration: string; // 禁言时间,秒 + admin: { + uid: string; + card: string; + name: string; + role: NTGroupMemberRole; + }; + member: { + uid: string; + card: string; + name: string; + role: NTGroupMemberRole; + }; + }; +} + +/** + * 多条转发消息元素接口 + */ +export interface MultiForwardMsgElement { + xmlContent: string; // xml格式的消息内容 + resId: string; + fileName: string; +} + +/** + * 发送状态类型枚举 + */ +export enum SendStatusType { + KSEND_STATUS_FAILED = 0, + KSEND_STATUS_SENDING = 1, + KSEND_STATUS_SUCCESS = 2, + KSEND_STATUS_SUCCESS_NOSEQ = 3, +} + +/** + * 原始消息接口 + */ +export interface RawMessage { + parentMsgPeer: Peer; // 父消息的Peer + parentMsgIdList: string[];// 父消息 ID 列表 + id?: number;// 扩展字段,与 Ob11 msg ID 有关 + guildId: string;// 频道ID + msgRandom: string;// 消息ID相关 + msgId: string;// 雪花ID + msgTime: string;// 消息时间戳 + msgSeq: string;// 消息序列号 + msgType: NTMsgType;// 消息类型 + subMsgType: number;// 子消息类型 + senderUid: string;// 发送者 UID + senderUin: string;// 发送者 QQ 号 + peerUid: string;// 群号 / 用户 UID + peerUin: string;// 群号 / 用户 QQ 号 + remark?: string;// 备注 + peerName: string;// Peer名称 + sendNickName: string;// 发送者昵称 + sendRemarkName: string;// 发送者好友备注 + sendMemberName?: string;// 发送者群名片(如果是群消息) + chatType: ChatType;// 会话类型 + sendStatus?: SendStatusType;// 消息状态 + recallTime: string;// 撤回时间,"0" 是没有撤回 + records: RawMessage[];// 消息记录 + emojiLikesList?: Array<{ emojiId: string; emojiType: string; likesCnt: string; isClicked: string; }>; + elements: MessageElement[];// 消息元素 + sourceType: MsgSourceType;// 消息来源类型 + isOnlineMsg: boolean;// 是否为在线消息 + clientSeq?: string; +} + +/** + * 查询消息参数接口 + */ +export interface QueryMsgsParams { + chatInfo: Peer & { privilegeFlag?: number; }; + // searchFields: number; + filterMsgType: Array<{ type: NTMsgType, subType: Array; }>; + filterSendersUid: string[]; + filterMsgFromTime: string; + filterMsgToTime: string; + pageLimit: number; + isReverseOrder: boolean; + isIncludeCurrent: boolean; +} + +/** + * 消息引用标识(msgId + msgSeq + msgTime) + */ +export interface MsgRef { + msgId: string; + msgSeq: string; + msgTime: string; +} + +/** + * 消息完整标识(含cliSeq和msgRandom) + */ +export interface MsgIdentity { + msgId: string; + msgSeq: string; + cliSeq: string; + msgTime: string; + msgRandom: string; +} + +/** + * 灰条JSON消息信息 + */ +export interface GrayTipJsonInfo { + busiId: number | string; + jsonStr: string; + recentAbstract: string; + isServer: boolean; + xmlToJsonParam?: unknown; +} + +/** + * 转发文件信息 + */ +export interface ForwardFileInfo { + targetMsgId: string; + targetElemId: string; + commonFileInfo: unknown; +} + +/** + * 本地灰条提示信息 + */ +export interface LocalGrayTipInfo { + type: number; + robot?: unknown; + direct?: unknown; + extraJson: string; +} + +/** + * Token设置信息 + */ +export interface TokenInfo { + tokenType: number; + apnsToken?: string | Uint8Array; + voipToken?: string | Uint8Array; + profileId?: string; +} + +/** + * 后台切换时的未读计数信息 + */ +export interface BackGroundInfo { + c2cUnreadCnt: number; + groupUnreadCnt: number; + guildUnreadCnt: number; + guildPsvboxUnreadCnt: number; + verifyUnreadCnt: number; + contactUnreadCnt: number; + groupUnreadCodes: string[]; +} + +/** + * 消息类型过滤参数 + */ +export interface MsgTypeFilter { + filterMsgType: Array<{ type: number; subType: Array; }>; + filterSendersUid: string[]; +} + +/** + * 空间流参数 + */ +export interface SgrpStreamParams { + sgrpStreamPginSourceName: string; + sgrpVisitFrom: string; + sgrpSessionId: string; +} + +/** + * 临时聊天信息API接口 + */ +export interface TmpChatInfoApi { + errMsg: string; + result: number; + tmpChatInfo?: TmpChatInfo; +} + +/** + * 临时聊天信息接口 + */ +export interface TmpChatInfo { + chatType: number; + fromNick: string; + groupCode: string; + peerUid: string; + sessionType: number; + sig: string; +} + +/** + * 消息请求类型接口 + */ +export interface MsgReqType { + peer: Peer, + byType: number, + msgId: string, + msgSeq: string, + msgTime: string, + clientSeq: string, + cnt: number, + queryOrder: boolean, + includeSelf: boolean, + includeDeleteMsg: boolean, + extraCnt: number; +} + +/** + * 表情类型枚举 + */ +export enum FaceType { + Unknown = 0, + OldFace = 1, // 老表情 + Normal = 2, // 常规表情 + AniSticke = 3, // 动画贴纸 + Lottie = 4, // 新格式表情 + Poke = 5, // 可变Poke +} diff --git a/packages/snowcat-core/types/notify.ts b/packages/snowcat-core/types/notify.ts new file mode 100644 index 0000000..d798af8 --- /dev/null +++ b/packages/snowcat-core/types/notify.ts @@ -0,0 +1,198 @@ +export enum GroupNotifyMsgType { + UN_SPECIFIED = 0, + INVITED_BY_MEMBER = 1, + REFUSE_INVITED = 2, + REFUSED_BY_ADMINI_STRATOR = 3, + AGREED_TOJOIN_DIRECT = 4, // 有人接受了邀请入群 + INVITED_NEED_ADMINI_STRATOR_PASS = 5, + AGREED_TO_JOIN_BY_ADMINI_STRATOR = 6, + REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS = 7, + SET_ADMIN = 8, + KICK_MEMBER_NOTIFY_ADMIN = 9, + KICK_MEMBER_NOTIFY_KICKED = 10, + MEMBER_LEAVE_NOTIFY_ADMIN = 11, // 主动退出 + CANCEL_ADMIN_NOTIFY_CANCELED = 12, + CANCEL_ADMIN_NOTIFY_ADMIN = 13, // 其他人取消管理员 + TRANSFER_GROUP_NOTIFY_OLDOWNER = 14, + TRANSFER_GROUP_NOTIFY_ADMIN = 15, +} + +export interface GroupNotifies { + doubt: boolean; + nextStartSeq: string; + notifies: GroupNotify[]; +} + +export enum GroupNotifyMsgStatus { + KINIT = 0, // 初始化 + KUNHANDLE = 1, // 未处理 + KAGREED = 2, // 同意 + KREFUSED = 3, // 拒绝 + KIGNORED = 4, // 忽略 +} + +export enum GroupInviteStatus { + INIT = 0, + WAIT_TO_APPROVE = 1, + JOINED = 2, + REFUSED_BY_ADMINI_STRATOR = 3, +} + +export enum GroupInviteType { + BYBUDDY = 0, + BYGROUPMEMBER = 1, + BYDISCUSSMEMBER = 2, +} +export interface ShutUpGroupHonor { + [key: string]: number; +} + +export interface ShutUpGroupMember { + uid: string; + qid: string; + uin: string; + nick: string; + remark: string; + cardType: number; + cardName: string; + role: number; + avatarPath: string; + shutUpTime: number; + isDelete: boolean; + isSpecialConcerned: boolean; + isSpecialShield: boolean; + isRobot: boolean; + groupHonor: ShutUpGroupHonor; + memberRealLevel: number; + memberLevel: number; + globalGroupLevel: number; + globalGroupPoint: number; + memberTitleId: number; + memberSpecialTitle: string; + specialTitleExpireTime: string; + userShowFlag: number; + userShowFlagNew: number; + richFlag: number; + mssVipType: number; + bigClubLevel: number; + bigClubFlag: number; + autoRemark: string; + creditLevel: number; + joinTime: number; + lastSpeakTime: number; + memberFlag: number; + memberFlagExt: number; + memberMobileFlag: number; + memberFlagExt2: number; + isSpecialShielded: boolean; + cardNameId: number; +} + +export interface GroupNotify { + seq: string; // 通知序列号 + type: GroupNotifyMsgType; + status: GroupNotifyMsgStatus; + group: { groupCode: string; groupName: string }; + user1: { uid: string; nickName: string }; // 被设置管理员的人 + user2: { uid: string; nickName: string }; // 操作者 + actionUser: { uid: string; nickName: string }; // 未知 + actionTime: string; + invitationExt: { + srcType: GroupInviteType; // 邀请来源 + groupCode: string; + waitStatus: GroupInviteStatus + }; + postscript: string; // 加群用户填写的验证信息 + repeatSeqs: []; + warningTips: string; +} + +export enum NTGroupRequestOperateTypes { + KUNSPECIFIED = 0, + KAGREE = 1, + KREFUSE = 2, + KIGNORE = 3, + KDELETE = 4, +} + +export enum BuddyReqType { + KMEINITIATOR = 0, + KPEERINITIATOR = 1, + KMEAGREED = 2, + KMEAGREEDANDADDED = 3, + KPEERAGREED = 4, + KPEERAGREEDANDADDED = 5, + KPEERREFUSED = 6, + KMEREFUSED = 7, + KMEIGNORED = 8, + KMEAGREEANYONE = 9, + KMESETQUESTION = 10, + KMEAGREEANDADDFAILED = 11, + KMSGINFO = 12, + KMEINITIATORWAITPEERCONFIRM = 13, +} + +// 其中 ? 代表新版本参数 +export interface FriendRequest { + isInitiator?: boolean; + isDecide: boolean; + friendUid: string; + reqType: BuddyReqType, + reqTime: string; // 时间戳 秒 + flag?: number; // 0 + preGroupingId?: number; // 0 + commFriendNum?: number; // 共同好友数 + extWords: string; // 申请人填写的验证消息 + isUnread: boolean; + isDoubt?: boolean; // 是否是可疑的好友请求 + nameMore?: string; + friendNick: string; + sourceId: number; + groupCode: string; + isBuddy?: boolean; + isAgreed?: boolean; + relation?: number; +} + +export interface FriendRequestNotify { + unreadNums: number; + buddyReqs: FriendRequest[]; +} + +export enum MemberExtSourceType { + DEFAULTTYPE = 0, + TITLETYPE = 1, + NEWGROUPTYPE = 2, +} + +export interface GroupExtParam { + groupCode: string; + seq: string; + beginUin: string; + dataTime: string; + uinList: Array; + uinNum: string; + groupType: string; + richCardNameVer: string; + sourceType: MemberExtSourceType; + memberExtFilter: { + memberLevelInfoUin: number + memberLevelInfoPoint: number + memberLevelInfoActiveDay: number + memberLevelInfoLevel: number + memberLevelInfoName: number + levelName: number + dataTime: number + userShowFlag: number + sysShowFlag: number + timeToUpdate: number + nickName: number + specialTitle: number + levelNameNew: number + userShowFlagNew: number + msgNeedField: number + cmdUinFlagExt3Grocery: number + memberIcon: number + memberInfoSeq: number + }; +} diff --git a/packages/snowcat-core/types/service.ts b/packages/snowcat-core/types/service.ts new file mode 100644 index 0000000..32a0c9d --- /dev/null +++ b/packages/snowcat-core/types/service.ts @@ -0,0 +1,35 @@ +export enum LoginErrorCode { + KLOGINERRORACCOUNTNOTUIN = 140022018, + KLOGINERRORACCOUNTORPASSWORDERROR = 140022013, + KLOGINERRORBLACKACCOUNT = 150022021, + KLOGINERRORDEFAULT = 140022000, + KLOGINERROREXPIRETICKET = 140022014, + KLOGINERRORFROZEN = 140022005, + KLOGINERRORILLAGETICKET = 140022016, + KLOGINERRORINVAILDCOOKIE = 140022012, + KLOGINERRORINVALIDPARAMETER = 140022001, + KLOGINERRORKICKEDTICKET = 140022015, + KLOGINERRORMUTIPLEPASSWORDINCORRECT = 150022029, + KLOGINERRORNEEDUPDATE = 140022004, + KLOGINERRORNEEDVERIFYREALNAME = 140022019, + KLOGINERRORNEWDEVICE = 140022010, + KLOGINERRORNICEACCOUNTEXPIRED = 150022020, + KLOGINERRORNICEACCOUNTPARENTCHILDEXPIRED = 150022025, + KLOGINERRORPASSWORD = 2, + KLOGINERRORPROOFWATER = 140022008, + KLOGINERRORPROTECT = 140022006, + KLOGINERRORREFUSEPASSOWRDLOGIN = 140022009, + KLOGINERRORREMINDCANAELLATEDSTATUS = 150022028, + KLOGINERRORSCAN = 1, + KLOGINERRORSCCESS = 0, + KLOGINERRORSECBEAT = 140022017, + KLOGINERRORSMSINVALID = 150022026, + KLOGINERRORSTRICK = 140022007, + KLOGINERRORSYSTEMFAILED = 140022002, + KLOGINERRORTGTGTEXCHAGEA1FORBID = 150022027, + KLOGINERRORTIMEOUTRETRY = 140022003, + KLOGINERRORTOOMANYTIMESTODAY = 150022023, + KLOGINERRORTOOOFTEN = 150022022, + KLOGINERRORUNREGISTERED = 150022024, + KLOGINERRORUNUSUALDEVICE = 140022011, +} diff --git a/packages/snowcat-core/types/sign.ts b/packages/snowcat-core/types/sign.ts new file mode 100644 index 0000000..146701a --- /dev/null +++ b/packages/snowcat-core/types/sign.ts @@ -0,0 +1,25 @@ +export interface IdMusicSignPostData { + type: 'qq' | '163' | 'kugou' | 'migu' | 'kuwo', + id: string | number, +} + +export interface CustomMusicSignPostData { + type: 'qq' | '163' | 'kugou' | 'migu' | 'kuwo' | 'custom', + id: undefined, + url: string, + audio?: string, + title?: string, + image: string, + singer?: string +} + +export interface MiniAppLuaJsonType { + prompt: string, + title: string, + preview: string, + jumpUrl: string, + tag: string, + tagIcon: string, + source: string, + sourcelogo: string, +} diff --git a/packages/snowcat-core/types/system.ts b/packages/snowcat-core/types/system.ts new file mode 100644 index 0000000..e6234cf --- /dev/null +++ b/packages/snowcat-core/types/system.ts @@ -0,0 +1,18 @@ +import { SelfInfo } from './user'; + +export interface LineDevice { + instanceId: number; + clientType: number; + devUid: string; +} + +export interface OBLineDevice { + app_id: string; + device_name: string; + device_kind: string; +} + +export interface CoreCache { + selfInfo: SelfInfo, + DeviceList: OBLineDevice[] +} diff --git a/packages/snowcat-core/types/user.ts b/packages/snowcat-core/types/user.ts new file mode 100644 index 0000000..669f233 --- /dev/null +++ b/packages/snowcat-core/types/user.ts @@ -0,0 +1,374 @@ +// 性别枚举 +export enum NTSex { + GENDER_UNKOWN = 0, + GENDER_MALE = 1, + GENDER_FEMALE = 2, + GENDER_PRIVACY = 255, +} +export interface NTVoteInfo { + age: number; + bAvailableCnt: number; + bTodayVotedCnt: number; + count: number; + customId: number; + gender: number; + giftCount: number; + isFriend: boolean; + isSvip: boolean; + isvip: boolean; + lastCharged: number; + latestTime: number; + nick: string; + src: number; + uid: string; + uin: number; +} + +// 好友分类类型 +export interface BuddyCategoryType { + categoryId: number; + categoryName: string; + categoryMbCount: number; + buddyList: User[]; +} + +// 核心信息 +export interface CoreInfo { + uid: string; + uin: string; + nick?: string; + remark: string; +} + +// 基本信息 +export interface BaseInfo { + qid: string; + longNick: string; + birthday_year: number; + birthday_month: number; + birthday_day: number; + age: number; + sex: number; + eMail: string; + phoneNum: string; + categoryId: number; + richTime: number; + richBuffer: string; +} + +// 音乐信息 +export interface MusicInfo { + buf: string; +} + +// 视频业务信息 +export interface VideoBizInfo { + cid: string; + tvUrl: string; + synchType: string; +} + +// 视频信息 +export interface VideoInfo { + name: string; +} + +// 扩展在线业务信息 +export interface ExtOnlineBusinessInfo { + buf: string; + customStatus: unknown; + videoBizInfo: VideoBizInfo; + videoInfo: VideoInfo; +} + +// 扩展缓冲区 +export interface ExtBuffer { + buf: string; +} + +// 用户状态 +export interface UserStatus { + uid: string; + uin: string; + status: number; + extStatus: number; + batteryStatus: number; + termType: number; + netType: number; + iconType: number; + customStatus: unknown; + setTime: string; + specialFlag: number; + abiFlag: number; + eNetworkType: number; + showName: string; + termDesc: string; + musicInfo: MusicInfo; + extOnlineBusinessInfo: ExtOnlineBusinessInfo; + extBuffer: ExtBuffer; +} + +// 特权图标 +export interface PrivilegeIcon { + jumpUrl: string; + openIconList: unknown[]; + closeIconList: unknown[]; +} + +// 增值服务信息 +export interface VasInfo { + vipFlag: boolean; + yearVipFlag: boolean; + svipFlag: boolean; + vipLevel: number; + bigClub: boolean; + bigClubLevel: number; + nameplateVipType: number; + grayNameplateFlag: number; + superVipTemplateId: number; + diyFontId: number; + pendantId: number; + pendantDiyId: number; + faceId: number; + vipFont: number; + vipFontType: number; + magicFont: number; + fontEffect: number; + newLoverDiamondFlag: number; + extendNameplateId: number; + diyNameplateIDs: unknown[]; + vipStartFlag: number; + vipDataFlag: number; + gameNameplateId: string; + gameLastLoginTime: string; + gameRank: number; + gameIconShowFlag: boolean; + gameCardId: string; + vipNameColorId: string; + privilegeIcon: PrivilegeIcon; +} + +// 关系标志 +export interface RelationFlags { + topTime: string; + isBlock: boolean; + isMsgDisturb: boolean; + isSpecialCareOpen: boolean; + isSpecialCareZone: boolean; + ringId: string; + isBlocked: boolean; + recommendImgFlag: number; + disableEmojiShortCuts: number; + qidianMasterFlag: number; + qidianCrewFlag: number; + qidianCrewFlag2: number; + isHideQQLevel: number; + isHidePrivilegeIcon: number; +} + +// 通用扩展信息 +export interface CommonExt { + constellation: number; + shengXiao: number; + kBloodType: number; + homeTown: string; + makeFriendCareer: number; + pos: string; + college: string; + country: string; + province: string; + city: string; + postCode: string; + address: string; + regTime: number; + interest: string; + labels: string[]; + qqLevel: QQLevel; +} + +// 好友列表请求类型枚举 +export enum BuddyListReqType { + KNOMAL, + KLETTER, +} + +// 图片信息 +export interface Pic { + picId: string; + picTime: number; + picUrlMap: Record; +} + +// 照片墙 +export interface PhotoWall { + picList: Pic[]; +} + +// 简单信息 +export interface SimpleInfo { + qqLevel?: QQLevel;// 临时添加 + uid?: string; + uin?: string; + coreInfo: CoreInfo; + baseInfo: BaseInfo; + status: UserStatus | null; + vasInfo: VasInfo | null; + relationFlags: RelationFlags | null; + otherFlags: unknown; + intimate: unknown; +} + +// 好友类型 +export type FriendV2 = SimpleInfo; + +// 自身状态信息 +export interface SelfStatusInfo { + uid: string; + status: number; + extStatus: number; + termType: number; + netType: number; + iconType: number; + customStatus: unknown; + setTime: string; +} + +// 用户详细信息监听参数 +export interface UserDetailInfoListenerArg { + uid: string; + uin: string; + simpleInfo: SimpleInfo; + commonExt: CommonExt; + photoWall: PhotoWall; +} + +// 修改个人资料参数 +export interface ModifyProfileParams { + nick: string; + longNick: string; + sex: NTSex; + birthday: { birthday_year: string, birthday_month: string, birthday_day: string; }; + location: unknown; +} + +// 好友资料点赞请求 +export interface BuddyProfileLikeReq { + friendUids: string[]; + basic: number; + vote: number; + favorite: number; + userProfile: number; + type: number; + start: number; + limit?: number; +} + +// QQ等级信息 +export interface QQLevel { + crownNum: number; + sunNum: number; + moonNum: number; + starNum: number; +} + +// 用户信息 +export interface User { + uid: string; + uin: string; + nick: string; + avatarUrl?: string; + longNick?: string; + remark?: string; + sex?: NTSex; + age?: number; + qqLevel?: QQLevel; + qid?: string; + birthday_year?: number; + birthday_month?: number; + birthday_day?: number; + topTime?: string; + constellation?: number; + shengXiao?: number; + kBloodType?: number; + homeTown?: string; + makeFriendCareer?: number; + pos?: string; + eMail?: string; + phoneNum?: string; + college?: string; + country?: string; + province?: string; + city?: string; + postCode?: string; + address?: string; + isBlock?: boolean; + isSpecialCareOpen?: boolean; + isSpecialCareZone?: boolean; + ringId?: string; + regTime?: number; + interest?: string; + labels?: string[]; + isHideQQLevel?: number; + privilegeIcon?: { + jumpUrl: string; + openIconList: unknown[]; + closeIconList: unknown[]; + }; + photoWall?: { + picList: unknown[]; + }; + vipFlag?: boolean; + yearVipFlag?: boolean; + svipFlag?: boolean; + vipLevel?: number; + status?: number; + qidianMasterFlag?: number; + qidianCrewFlag?: number; + qidianCrewFlag2?: number; + extStatus?: number; + recommendImgFlag?: number; + disableEmojiShortCuts?: number; + pendantId?: string; +} + +// 自身信息 +export interface SelfInfo extends User { + online?: boolean; +} + +// 好友类型 +export type Friend = User; + +// 业务键枚举 +export enum BizKey { + KPRIVILEGEICON = 0, + KPHOTOWALL = 1, +} + +// 根据UIN获取用户详细信息 +export interface UserDetailInfoByUin { + result: number; + errMsg: string; + detail: { + uid: string; + uin: string; + simpleInfo: SimpleInfo; + commonExt: CommonExt; + photoWall: null; + }; +} + +// 用户详细信息来源枚举 +export enum UserDetailSource { + KDB, + KSERVER, +} + +// 个人资料业务类型枚举 +export enum ProfileBizType { + KALL = 0, + KBASEEXTEND = 1, + KVAS = 2, + KQZONE = 3, + KOTHER = 4, +} diff --git a/packages/snowcat-core/types/webapi.ts b/packages/snowcat-core/types/webapi.ts new file mode 100644 index 0000000..4424ce9 --- /dev/null +++ b/packages/snowcat-core/types/webapi.ts @@ -0,0 +1,131 @@ +export enum WebHonorType { + ALL = 'all', + /** + * 群聊之火 + */ + TALKATIVE = 'talkative', + /** + * 群聊炽焰 + */ + PERFORMER = 'performer', + /** + * 龙王 + */ + LEGEND = 'legend', + /** + * 冒尖小春笋(R.I.P) + */ + STRONG_NEWBIE = 'strong_newbie', + /** + * 快乐源泉 + */ + EMOTION = 'emotion', +} + +export interface WebApiGroupMember { + uin: number; + role: number; + g: number; + join_time: number; + last_speak_time: number; + lv: { + point: number + level: number + }; + card: string; + tags: string; + flag: number; + nick: string; + qage: number; + rm: number; +} + +export interface WebApiGroupMemberRet { + ec: number; + errcode: number; + em: string; + cache: number; + adm_num: number; + levelname: string; + mems: WebApiGroupMember[]; + count: number; + svr_time: number; + max_count: number; + search_count: number; + extmode: number; +} + +export interface WebApiGroupNoticeFeed { + u: number;// 发送者 + fid: string;// fid,notice_id + pubt: number;// 时间 + msg: { + text: string + text_face: string + title: string, + pics?: { + id: string, + w: string, + h: string + }[] + }; + type: number; + fn: number; + cn: number; + vn: number; + settings?: { + is_show_edit_card: number + remind_ts: number + tip_window_type: number + confirm_required: number + }; + read_num: number; + is_read: number; + is_all_confirm: number; +} + +export interface WebApiGroupNoticeRet { + ec: number + em: string + ltsm: number + srv_code: number + read_only: number + role: number + feeds: WebApiGroupNoticeFeed[] + group: { + group_id: number + class_ext: number + } + sta: number, + gln: number + tst: number, + ui: unknown + server_time: number + svrt: number + ad: number +} + +export interface GroupEssenceMsg { + group_code: string; + msg_seq: number; + msg_random: number; + sender_uin: string; + sender_nick: string; + sender_time: number; + add_digest_uin: string; + add_digest_nick: string; + add_digest_time: number; + msg_content: { msg_type: number, text?: string, image_url?: string }[]; + can_be_removed: true; +} + +export interface GroupEssenceMsgRet { + retcode: number; + retmsg: string; + data: { + msg_list: GroupEssenceMsg[] + is_end: boolean + group_role: number + config_page_url: string + }; +} diff --git a/packages/snowcat-core/wrapper.ts b/packages/snowcat-core/wrapper.ts new file mode 100644 index 0000000..2081918 --- /dev/null +++ b/packages/snowcat-core/wrapper.ts @@ -0,0 +1,482 @@ +import { NodeIDependsAdapter, NodeIDispatcherAdapter, NodeIGlobalAdapter } from './adapters'; +import { + GeneralCallResult, + NodeIKernelAvatarService, + NodeIKernelBuddyService, + NodeIKernelGroupService, + NodeIKernelLoginService, + NodeIKernelMsgService, + NodeIKernelProfileLikeService, + NodeIKernelProfileService, + NodeIKernelRichMediaService, + NodeIKernelRobotService, + NodeIKernelSessionListener, + NodeIKernelStorageCleanService, + NodeIKernelTicketService, + NodeIKernelTipOffService, +} from '@/snowcat-core/index'; +import { NodeIKernelNodeMiscService } from './services/NodeIKernelNodeMiscService'; +import { NodeIKernelUixConvertService } from './services/NodeIKernelUixConvertService'; +import { NodeIKernelMsgBackupService } from './services/NodeIKernelMsgBackupService'; +import { NodeIKernelAlbumService } from './services/NodeIKernelAlbumService'; +import { NodeIKernelTianShuService } from './services/NodeIKernelTianShuService'; +import { NodeIKernelUnitedConfigService } from './services/NodeIKernelUnitedConfigService'; +import { NodeIKernelSearchService } from './services/NodeIKernelSearchService'; +import { NodeIKernelCollectionService } from './services/NodeIKernelCollectionService'; +import { NodeIKernelRecentContactService } from './services/NodeIKernelRecentContactService'; +import { NodeIKernelMSFService } from './services/NodeIKernelMSFService'; +import { NodeIkernelTestPerformanceService } from './services/NodeIkernelTestPerformanceService'; +import { NodeIKernelECDHService } from './services/NodeIKernelECDHService'; +import { NodeIO3MiscService } from './services/NodeIO3MiscService'; +import { NodeIKernelFlashTransferService } from './services/NodeIKernelFlashTransferService'; +import { NodeIKernelOnlineStatusService } from './services/NodeIKernelOnlineStatusService'; +import { NodeIKernelBaseEmojiService } from './services/NodeIKernelBaseEmojiService'; +import { NodeIKernelSettingService } from './services/NodeIKernelSettingService'; +import { NodeIKernelFileAssistantService } from './services/NodeIKernelFileAssistantService'; +import { NodeIKernelDbToolsService } from './services/NodeIKernelDbToolsService'; +import { NodeIYellowFaceService } from './services/NodeIYellowFaceService'; +import { NodeIKernelQiDianService } from './services/NodeIKernelQiDianService'; +import { NodeIKernelSkinService } from './services/NodeIKernelSkinService'; +import { NodeIKernelQQPlayService } from './services/NodeIKernelQQPlayService'; +import { NodeIKernelRDeliveryService } from './services/NodeIKernelRDeliveryService'; +import { NodeIKernelRemotingService } from './services/NodeIKernelRemotingService'; +import { NodeIKernelLiteBusinessService } from './services/NodeIKernelLiteBusinessService'; +import { NodeIKernelGroupTabService } from './services/NodeIKernelGroupTabService'; +import { NodeIKernelLockService } from './services/NodeIKernelLockService'; +import { NodeIKernelHandOffService } from './services/NodeIKernelHandOffService'; +import { NodeIKernelMiniAppService } from './services/NodeIKernelMiniAppService'; +import { NodeIKernelPublicAccountService } from './services/NodeIKernelPublicAccountService'; +import { NodeIKernelThirdPartySigService } from './services/NodeIKernelThirdPartySigService'; +import { NodeIKernelUnifySearchService } from './services/NodeIKernelUnifySearchService'; +import { NodeIKernelVasSystemUpdateService } from './services/NodeIKernelVasSystemUpdateService'; +import { NodeIKernelPersonalAlbumService } from './services/NodeIKernelPersonalAlbumService'; +import { NodeIKernelConfigMgrService } from './services/NodeIKernelConfigMgrService'; + +export interface NodeQQNTWrapperUtil { + get (): NodeQQNTWrapperUtil; + + getNTUserDataInfoConfig (): string; + + emptyWorkingSet (n: number): void; + + getSsoCmdOfOidbReq (arg1: number, arg2: number): unknown; + + getSsoBufferOfOidbReq (arg1: unknown, arg2: unknown, arg3: unknown): unknown; + + getOidbRspInfo (arg: string): unknown; + + getFileSize (path: string): Promise; + + genFileMd5Buf (arg: string): unknown; + + genFileMd5Hex (path: string): unknown; + + genFileShaBuf (path: string): unknown; + + genFileCumulateSha1 (path: string): unknown; + + genFileShaHex (path: string): unknown; + + fileIsExist (path: string): unknown; + + startTrace (path: string): unknown; + + copyFile (src: string, dst: string): unknown; + + genFileShaAndMd5Hex (path: string, unknown: number): unknown; + + setTraceInfo (unknown: unknown): unknown; + + encodeOffLine (unknown: unknown): unknown; + + decodeOffLine (arg: string): unknown; + + DecoderRecentInfo (arg: string): unknown; + + getPinyin (arg0: string, arg1: boolean): unknown; + + getPinyinExt (arg0: string, arg1: boolean): unknown; + + matchInPinyin (arg0: unknown[], arg1: string): unknown; + + makeDirByPath (arg0: string): unknown; + + runProcess (arg0: string, arg1: boolean): unknown; + + runProcessArgs (arg0: string, arg1: { [key: string]: string; }, arg2: boolean): unknown; + + calcThumbSize (arg0: number, arg1: number, arg2: unknown): unknown; + + fullWordToHalfWord (word: string): unknown; + + getNTUserDataInfoConfig (): Promise; + + pathIsReadableAndWriteable (path: string, type: number): Promise; // type 2 , result 0 成功 + + resetUserDataSavePathToDocument (): unknown; + + getSoBuildInfo (): unknown; // 例如 0[0]_d491dc01e0a_0 + + registerCountInstruments (arg0: string, arg1: string[], arg2: number, arg3: number): unknown; + + registerValueInstruments (arg0: string, arg1: string[], arg2: number, arg3: number): unknown; + + registerValueInstrumentsWithBoundary ( + arg0: string, + arg1: unknown, + arg2: unknown, + arg3: number, + arg4: number, + ): unknown; + + reportCountIndicators ( + arg0: string, + arg1: Map, + arg2: string, + arg3: number, + arg4: boolean, + ): unknown; + + reportValueIndicators ( + arg0: string, + arg1: Map, + arg2: string, + arg3: boolean, + arg4: number, + ): unknown; + + checkNewUserDataSaveDirAvailable (arg0: string): unknown; + + copyUserData (arg0: string, arg1: string): Promise; + + setUserDataSaveDirectory (arg0: string): Promise; + + hasOtherRunningQQProcess (): boolean; + + quitAllRunningQQProcess (arg: boolean): unknown; + + checkNvidiaConfig (): unknown; + + repairNvidiaConfig (): unknown; + + getNvidiaDriverVersion (): unknown; + + isNull (): unknown; + + deletePath (path: string): unknown; + + calculateDirectoryTotalSize (path: string): unknown; + + GetBaseEmojiPathByIds (arg: unknown): unknown; + + SetMobileBaseEmojiPath (arg0: unknown, arg1: unknown): unknown; + + setCreateThumbailSupportedFileExtensions (arg0: unknown, arg1: unknown): unknown; + + setFileDropNativeWindowHide (arg: unknown): unknown; + + setFileDropWindowNativeWindowHandle (arg: unknown): unknown; + + startListenFileDragEvent (arg: unknown): unknown; + + stopAccessingSecurityScopedResource (arg: unknown): unknown; + + createThumbnailImage ( + serviceName: string, + filePath: string, + targetPath: string, + imgSize: { + width: number, + height: number; + }, + fileFormat: string, + arg: number | null | undefined, // null undefined都行 + ): Promise; +} +export interface NodeIQQNTStartupSessionWrapper { + create (): NodeIQQNTStartupSessionWrapper; + stop (): void; + start (): void; + createWithModuleList (uk: unknown): unknown; + getSessionIdList (): Promise>; +} +export interface NodeIQQNTWrapperSession { + getNTWrapperSession (str: string): NodeIQQNTWrapperSession; + + get (): NodeIQQNTWrapperSession; + + new(): NodeIQQNTWrapperSession; + + create (): NodeIQQNTWrapperSession; + + init ( + wrapperSessionInitConfig: WrapperSessionInitConfig, + nodeIDependsAdapter: NodeIDependsAdapter, + nodeIDispatcherAdapter: NodeIDispatcherAdapter, + nodeIKernelSessionListener: NodeIKernelSessionListener, + ): void; + + startNT (session: number): void; + + startNT (): void; + + // === Session lifecycle === + close (arg: unknown): void; + + onLine (arg: unknown): void; + + offLine (arg: unknown): void; + + disableIpDirect (arg: unknown): void; + + getAccountPath (arg: unknown): string; + + updateTicket (arg: unknown): void; + + // === SSO/Network dispatch === + onDispatchPush (arg1: unknown, arg2: unknown): void; + + onDispatchPushWithJson (arg1: unknown, arg2: unknown): void; + + onDispatchRequestReply (arg1: unknown, arg2: unknown, arg3: unknown): void; + + onMsfPush (arg1: unknown, arg2: unknown, arg3: unknown): void; + + onNetReply (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown): void; + + onSendOidbReply (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): void; + + onSendSSOReply (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): void; + + onUIConfigUpdate (arg1: unknown, arg2: unknown): void; + + setOnMsfStatusChanged (arg1: unknown, arg2: unknown, arg3: unknown): void; + + setOnNetworkChanged (arg: unknown): void; + + setOnWeakNetChanged (arg: unknown): void; + + // === Service getters === + getBdhUploadService (): unknown; + + getECDHService (): NodeIKernelECDHService; + + getMsgService (): NodeIKernelMsgService; + + getProfileService (): NodeIKernelProfileService; + + getProfileLikeService (): NodeIKernelProfileLikeService; + + getGroupService (): NodeIKernelGroupService; + + getStorageCleanService (): NodeIKernelStorageCleanService; + + getBuddyService (): NodeIKernelBuddyService; + + getRobotService (): NodeIKernelRobotService; + + getTicketService (): NodeIKernelTicketService; + + getTipOffService (): NodeIKernelTipOffService; + + getNodeMiscService (): NodeIKernelNodeMiscService; + + getRichMediaService (): NodeIKernelRichMediaService; + + getMsgBackupService (): NodeIKernelMsgBackupService; + + getAlbumService (): NodeIKernelAlbumService; + + getTianShuService (): NodeIKernelTianShuService; + + getUnitedConfigService (): NodeIKernelUnitedConfigService; + + getSearchService (): NodeIKernelSearchService; + + getFlashTransferService (): NodeIKernelFlashTransferService; + + getDirectSessionService (): unknown; + + getRDeliveryService (): NodeIKernelRDeliveryService; + + getAvatarService (): NodeIKernelAvatarService; + + getFeedChannelService (): unknown; + + getYellowFaceService (): NodeIYellowFaceService; + + getCollectionService (): NodeIKernelCollectionService; + + getSettingService (): NodeIKernelSettingService; + + getQiDianService (): NodeIKernelQiDianService; + + getFileAssistantService (): NodeIKernelFileAssistantService; + + getGuildService (): unknown; + + getSkinService (): NodeIKernelSkinService; + + getTestPerformanceService (): NodeIkernelTestPerformanceService; + + getQQPlayService (): NodeIKernelQQPlayService; + + getDbToolsService (): NodeIKernelDbToolsService; + + getUixConvertService (): NodeIKernelUixConvertService; + + getOnlineStatusService (): NodeIKernelOnlineStatusService; + + getRemotingService (): NodeIKernelRemotingService; + + getGroupTabService (): NodeIKernelGroupTabService; + + getGroupSchoolService (): unknown; + + getLiteBusinessService (): NodeIKernelLiteBusinessService; + + getGuildMsgService (): unknown; + + getLockService (): NodeIKernelLockService; + + getMSFService (): NodeIKernelMSFService; + + getGuildHotUpdateService (): unknown; + + getAVSDKService (): unknown; + + getRecentContactService (): NodeIKernelRecentContactService; + + getConfigMgrService (): NodeIKernelConfigMgrService; + + getBaseEmojiService (): NodeIKernelBaseEmojiService; + + getHandOffService (): NodeIKernelHandOffService; + + getMiniAppService (): NodeIKernelMiniAppService; + + getPublicAccountService (): NodeIKernelPublicAccountService; + + getThirdPartySigService (): NodeIKernelThirdPartySigService; + + getUnifySearchService (): NodeIKernelUnifySearchService; + + getVasSystemUpdateService (): NodeIKernelVasSystemUpdateService; + + getPersonalAlbumService (): NodeIKernelPersonalAlbumService; + + getGProGuildMsgService (): unknown; + + getFileBridgeHostService (): unknown; + + getWiFiPhotoClientService (): unknown; +} + +export interface EnginInitDesktopConfig { + base_path_prefix: string; + platform_type: PlatformType; + app_type: 4; + app_version: string; + os_version: string; + use_xlog: boolean; + qua: string; + global_path_config: { + desktopGlobalPath: string; + }; + thumb_config: { maxSide: 324; minSide: 48; longLimit: 6; density: 2; }; +} + +export interface NodeIQQNTWrapperEngine { + get (): NodeIQQNTWrapperEngine; + + initWithDeskTopConfig (config: EnginInitDesktopConfig, nodeIGlobalAdapter: NodeIGlobalAdapter): void; + + initWithMobileConfig (config: unknown, nodeIGlobalAdapter: NodeIGlobalAdapter): void; + + initLog (arg: unknown): void; + + setLogLevel (arg: unknown): void; + + onSendSSOReply (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): void; +} + +export interface WrapperNodeApi { + NodeIO3MiscService: NodeIO3MiscService; + NodeQQNTWrapperUtil: NodeQQNTWrapperUtil; + NodeIQQNTWrapperSession: NodeIQQNTWrapperSession; + NodeIQQNTStartupSessionWrapper: NodeIQQNTStartupSessionWrapper; + NodeIQQNTWrapperEngine: NodeIQQNTWrapperEngine; + NodeIKernelLoginService: NodeIKernelLoginService; + +} +export enum PlatformType { + KUNKNOWN, + KANDROID, + KIOS, + KWINDOWS, + KMAC, + KLINUX, +} + +export enum DeviceType { + KUNKNOWN, + KPHONE, + KPAD, + KCOMPUTER, +} + +// 推送类型 +export enum VendorType { + KNOSETONIOS = 0, + KSUPPORTGOOGLEPUSH = 99, + KSUPPORTHMS = 3, + KSUPPORTOPPOPUSH = 4, + KSUPPORTTPNS = 2, + KSUPPORTVIVOPUSH = 5, + KUNSUPPORTANDROIDPUSH = 1, +} + +export interface WrapperSessionInitConfig { + selfUin: string; + selfUid: string; + desktopPathConfig: { + account_path: string; // 可以通过NodeQQNTWrapperUtil().getNTUserDataInfoConfig()获取 + }; + clientVer: string; // 9.9.8-22355 + a2: string; + d2: string; + d2Key: string; + machineId: string; + platform: PlatformType; // 3是Windows? + platVer: string; // 系统版本号, 应该可以固定 + appid: string; + rdeliveryConfig: { + appKey: string; + systemId: number; + appId: string; + logicEnvironment: string; + platform: PlatformType; + language: string; + sdkVersion: string; + userId: string; + appVersion: string; + osVersion: string; + bundleId: string; + serverUrl: string; + fixedAfterHitKeys: string[]; + }; + defaultFileDownloadPath: string; // 这个可以通过环境变量获取? + deviceInfo: { + guid: string; + buildVer: string; + localId: number; + devName: string; + devType: string; + vendorName: string; + osVer: string; + vendorOsName: string; + setMute: boolean; + vendorType: VendorType; + }; + deviceConfig: '{"appearance":{"isSplitViewMode":true},"msg":{}}'; +} diff --git a/packages/snowcat-database/package.json b/packages/snowcat-database/package.json new file mode 100644 index 0000000..4af9e25 --- /dev/null +++ b/packages/snowcat-database/package.json @@ -0,0 +1,25 @@ +{ + "name": "snowcat-database", + "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": {}, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-database/src/constants.ts b/packages/snowcat-database/src/constants.ts new file mode 100644 index 0000000..dab2090 --- /dev/null +++ b/packages/snowcat-database/src/constants.ts @@ -0,0 +1,35 @@ +/** 文件头部扩展区大小 */ +export const EXT_HEADER_SIZE = 1024; + +/** SQLCipher 页大小 */ +export const PAGE_SIZE = 4096; + +/** PBKDF2 salt 长度 */ +export const SALT_SIZE = 16; + +/** AES-256 密钥长度 */ +export const KEY_SIZE = 32; + +/** AES-CBC IV 长度 */ +export const IV_SIZE = 16; + +/** HMAC-SHA1 长度 */ +export const HMAC_SIZE = 20; + +/** 每页保留区大小: IV(16) + HMAC_SHA1(20) + pad(12) = 48 */ +export const RESERVE_SIZE = 48; + +/** PBKDF2 主密钥迭代次数 */ +export const KDF_ITERATIONS = 4000; + +/** PBKDF2 HMAC 密钥迭代次数 */ +export const FAST_ITERATIONS = 2; + +/** HMAC salt XOR 掩码 */ +export const HMAC_MASK = 0x3a; + +/** SQLite 加密文件头 */ +export const SQLITE_HEADER = Buffer.from('SQLite header 3\0'); + +/** SQLite 明文文件头 */ +export const SQLITE_FORMAT = Buffer.from('SQLite format 3\0'); diff --git a/packages/snowcat-database/src/crypto.ts b/packages/snowcat-database/src/crypto.ts new file mode 100644 index 0000000..1d9c245 --- /dev/null +++ b/packages/snowcat-database/src/crypto.ts @@ -0,0 +1,29 @@ +/** + * 密钥派生 + * enc_key = PBKDF2_HMAC_SHA512(passphrase, salt, 4000, 32) + * hmac_key = PBKDF2_HMAC_SHA512(enc_key, salt ^ 0x3a, 2, 32) + */ + +import { pbkdf2Sync } from 'node:crypto'; +import { + KEY_SIZE, + KDF_ITERATIONS, + FAST_ITERATIONS, + HMAC_MASK, +} from './constants'; + +export interface DerivedKeys { + encKey: Buffer; + hmacKey: Buffer; +} +export function deriveKeys (passphrase: Buffer, salt: Buffer): DerivedKeys { + const encKey = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_SIZE, 'sha512'); + + const hmacSalt = Buffer.alloc(salt.length); + for (let i = 0; i < salt.length; i++) { + hmacSalt[i] = salt[i]! ^ HMAC_MASK; + } + const hmacKey = pbkdf2Sync(encKey, hmacSalt, FAST_ITERATIONS, KEY_SIZE, 'sha512'); + + return { encKey, hmacKey }; +} diff --git a/packages/snowcat-database/src/decrypt.ts b/packages/snowcat-database/src/decrypt.ts new file mode 100644 index 0000000..f37c24f --- /dev/null +++ b/packages/snowcat-database/src/decrypt.ts @@ -0,0 +1,109 @@ +import { createDecipheriv } from 'node:crypto'; +import fs from 'node:fs'; +import { + EXT_HEADER_SIZE, + PAGE_SIZE, + SALT_SIZE, + IV_SIZE, + RESERVE_SIZE, + SQLITE_HEADER, + SQLITE_FORMAT, +} from './constants'; +import { deriveKeys } from './crypto'; + +/** + * 解密单个 SQLCipher 页面 + * @param pageData 原始页数据 (4096 字节) + * @param encKey AES-256 密钥 + * @param skipSalt 页 1 需跳过前 16 字节 salt + */ +function decryptPage (pageData: Buffer, encKey: Buffer, skipSalt: number = 0): Buffer { + const data = pageData.subarray(skipSalt); + const contentLen = data.length - RESERVE_SIZE; + const encrypted = data.subarray(0, contentLen); + const iv = data.subarray(contentLen, contentLen + IV_SIZE); + + const decipher = createDecipheriv('aes-256-cbc', encKey, iv); + decipher.setAutoPadding(false); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +} + +/** + * 检查文件是否为 NTQQ 加密数据库 + */ +export function isEncryptedNTDB (fileData: Buffer): boolean { + if (fileData.length < EXT_HEADER_SIZE + PAGE_SIZE) return false; + return fileData.subarray(0, 16).equals(SQLITE_HEADER); +} + +/** + * 将加密的 NTQQ 数据库解密为标准 SQLite 数据库 + * @param fileData 完整的加密数据库文件内容 + * @param passphrase 服务端返回的密钥口令 + * @returns 解密后的 SQLite 数据库 Buffer,失败返回 null + */ +export function decryptDatabase (fileData: Buffer, passphrase: Buffer): Buffer | null { + if (!isEncryptedNTDB(fileData)) return null; + + const scData = fileData.subarray(EXT_HEADER_SIZE); + const totalPages = Math.floor(scData.length / PAGE_SIZE); + if (totalPages === 0) return null; + + const salt = scData.subarray(0, SALT_SIZE); + const { encKey } = deriveKeys(passphrase, salt); + + const output = Buffer.alloc(totalPages * PAGE_SIZE); + let offset = 0; + + for (let pgno = 1; pgno <= totalPages; pgno++) { + const pageOffset = (pgno - 1) * PAGE_SIZE; + const rawPage = scData.subarray(pageOffset, pageOffset + PAGE_SIZE); + const skip = pgno === 1 ? SALT_SIZE : 0; + const decrypted = decryptPage(rawPage, encKey, skip); + + if (pgno === 1) { + // 重建 Page 1: SQLite 标准文件头 + 解密内容 + SQLITE_FORMAT.copy(output, 0); + decrypted.copy(output, 16); + // 修正 page_size 字段 (offset 16-17, big-endian) + output.writeUInt16BE(PAGE_SIZE, 16); + // 保留原始 reserved-bytes-per-page 值 (byte 20) + // NTQQ SQLCipher 使用 reserved=80 用于 B-tree 布局 (usable=4016) + // 即使加密保留区 (IV+HMAC) 实际只有 48 字节 + // 清零会导致 SQLite 误判溢出指针,报 "database disk image is malformed" + offset = PAGE_SIZE; + } else { + decrypted.copy(output, offset); + offset += PAGE_SIZE; + } + } + + return output; +} + +/** + * 解密数据库文件并写入输出路径 + * @param inputPath 加密数据库路径 + * @param passphrase 密钥口令 + * @param outputPath 输出路径 (默认: 原路径加 _decrypted 后缀) + * @returns 输出路径,失败返回 null + */ +export function decryptDatabaseFile ( + inputPath: string, + passphrase: Buffer, + outputPath?: string +): string | null { + const fileData = fs.readFileSync(inputPath); + const result = decryptDatabase(fileData, passphrase); + if (!result) return null; + + if (!outputPath) { + const dotIdx = inputPath.lastIndexOf('.'); + outputPath = dotIdx > 0 + ? inputPath.substring(0, dotIdx) + '_decrypted' + inputPath.substring(dotIdx) + : inputPath + '_decrypted'; + } + + fs.writeFileSync(outputPath, result); + return outputPath; +} diff --git a/packages/snowcat-database/src/index.ts b/packages/snowcat-database/src/index.ts new file mode 100644 index 0000000..351e91a --- /dev/null +++ b/packages/snowcat-database/src/index.ts @@ -0,0 +1,29 @@ +export { deriveKeys, type DerivedKeys } from './crypto'; +export { decryptDatabase, decryptDatabaseFile, isEncryptedNTDB } from './decrypt'; +export { + checkSqliteAvailable, + DatabaseHandle, + openDatabase, + decryptAndOpen, + listTablesFromBuffer, + listTablesFromFile, + readSingleDatabase, + scanAllDatabases, + formatScanResults, + type TableInfo, + type ColumnInfo, + type DatabaseScanResult, + type DatabaseReadOptions, +} from './reader'; +export { + EXT_HEADER_SIZE, + PAGE_SIZE, + SALT_SIZE, + KEY_SIZE, + IV_SIZE, + HMAC_SIZE, + RESERVE_SIZE, + KDF_ITERATIONS, + FAST_ITERATIONS, + HMAC_MASK, +} from './constants'; diff --git a/packages/snowcat-database/src/reader.ts b/packages/snowcat-database/src/reader.ts new file mode 100644 index 0000000..feacd04 --- /dev/null +++ b/packages/snowcat-database/src/reader.ts @@ -0,0 +1,425 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { decryptDatabase, decryptDatabaseFile, isEncryptedNTDB } from './decrypt'; + +type DatabaseSyncType = import('node:sqlite').DatabaseSync; +type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean; }) => DatabaseSyncType; +type SQLInputValue = import('node:sqlite').SQLInputValue; + +let _DatabaseSync: DatabaseSyncCtor | null = null; +let _sqliteChecked = false; + +async function loadSqlite (): Promise { + if (_sqliteChecked) return _DatabaseSync; + _sqliteChecked = true; + try { + const mod = await import('node:sqlite'); + _DatabaseSync = mod.DatabaseSync as unknown as DatabaseSyncCtor; + return _DatabaseSync; + } catch { + _DatabaseSync = null; + return null; + } +} + +function getDatabaseSync (): DatabaseSyncCtor { + if (!_DatabaseSync) { + throw new Error( + 'node:sqlite 不可用。请使用 Node.js 22+ 并添加 --experimental-sqlite 标志,' + + '或调用 checkSqliteAvailable() 检查可用性。' + ); + } + return _DatabaseSync; +} + +export async function checkSqliteAvailable (): Promise { + const ctor = await loadSqlite(); + return ctor !== null; +} + +// ======================== 数据库连接管理 ======================== + +/** 数据库连接句柄,封装 DatabaseSync 实例 */ +export class DatabaseHandle { + private db: DatabaseSyncType; + private _closed = false; + readonly filePath: string; + readonly readOnly: boolean; + + constructor (filePath: string, readOnly = false) { + const DB = getDatabaseSync(); + this.db = new DB(filePath, { readOnly }); + this.filePath = filePath; + this.readOnly = readOnly; + } + + /** 数据库是否已关闭 */ + get closed (): boolean { + return this._closed; + } + + private ensureOpen (): void { + if (this._closed) throw new Error('数据库已关闭'); + } + + /** + * 执行 SELECT 查询,返回所有结果行 + * @param sql SQL 语句 + * @param params 绑定参数 (位置参数数组 或 命名参数对象) + */ + query> (sql: string, params?: SQLInputValue[] | Record): T[] { + this.ensureOpen(); + const stmt = this.db.prepare(sql); + if (params) { + if (Array.isArray(params)) { + return stmt.all(...params) as T[]; + } + return stmt.all(params) as T[]; + } + return stmt.all() as T[]; + } + + /** + * 执行 SELECT 查询,仅返回第一行 + * @param sql SQL 语句 + * @param params 绑定参数 + */ + queryOne> (sql: string, params?: SQLInputValue[] | Record): T | undefined { + this.ensureOpen(); + const stmt = this.db.prepare(sql); + if (params) { + if (Array.isArray(params)) { + return stmt.get(...params) as T | undefined; + } + return stmt.get(params) as T | undefined; + } + return stmt.get() as T | undefined; + } + + /** + * 执行非查询 SQL (INSERT/UPDATE/DELETE/CREATE 等) + * @param sql SQL 语句 + * @param params 绑定参数 + * @returns 受影响的行数信息 + */ + execute (sql: string, params?: SQLInputValue[] | Record): { changes: number | bigint; lastInsertRowid: number | bigint; } { + this.ensureOpen(); + const stmt = this.db.prepare(sql); + if (params) { + if (Array.isArray(params)) { + return stmt.run(...params); + } + return stmt.run(params); + } + return stmt.run(); + } + + /** 列出所有表的信息 */ + listTables (): TableInfo[] { + this.ensureOpen(); + return extractTablesInfo(this.db); + } + + /** 获取指定表的列信息 */ + getTableColumns (tableName: string): ColumnInfo[] { + this.ensureOpen(); + const cols = this.db.prepare(`PRAGMA table_info([${tableName}])`).all() as { + cid: number; name: string; type: string; notnull: number; pk: number; + }[]; + return cols.map(col => ({ + cid: col.cid, + name: col.name, + type: col.type, + notnull: col.notnull === 1, + pk: col.pk === 1, + })); + } + + /** 获取表的行数 */ + getRowCount (tableName: string): number { + this.ensureOpen(); + const cnt = this.db.prepare(`SELECT count(*) as cnt FROM [${tableName}]`).get() as { cnt: number; } | undefined; + return cnt?.cnt ?? 0; + } + + /** 关闭数据库连接 */ + close (): void { + if (!this._closed) { + this.db.close(); + this._closed = true; + } + } +} + +/** + * 打开一个已解密的 SQLite 数据库文件 + * @param filePath 解密后的 .db 文件路径 + * @param readOnly 是否以只读模式打开 (默认 true) + */ +export function openDatabase (filePath: string, readOnly = true): DatabaseHandle { + return new DatabaseHandle(filePath, readOnly); +} + +/** + * 解密并打开一个加密的 NTQQ 数据库 + * @param dbPath 加密数据库路径 + * @param passphrase 密钥口令 + * @param cacheDir 解密缓存目录 (可选) + * @param readOnly 是否只读 (默认 true) + */ +export function decryptAndOpen ( + dbPath: string, + passphrase: Buffer, + cacheDir?: string, + readOnly = true +): DatabaseHandle { + const filename = path.basename(dbPath); + + if (cacheDir) { + const cachedPath = path.join(cacheDir, filename); + // 已存在缓存直接打开 + if (fs.existsSync(cachedPath)) { + return new DatabaseHandle(cachedPath, readOnly); + } + fs.mkdirSync(cacheDir, { recursive: true }); + const outPath = decryptDatabaseFile(dbPath, passphrase, cachedPath); + if (!outPath) throw new Error(`解密失败: ${dbPath}`); + return new DatabaseHandle(outPath, readOnly); + } + + // 无缓存目录: 解密到临时文件 + const tmpPath = path.join(os.tmpdir(), `napcat_db_${Date.now()}_${Math.random().toString(36).slice(2)}.db`); + const fileData = fs.readFileSync(dbPath); + const decryptedBuf = decryptDatabase(fileData, passphrase); + if (!decryptedBuf) throw new Error(`解密失败: ${dbPath}`); + fs.writeFileSync(tmpPath, decryptedBuf); + return new DatabaseHandle(tmpPath, readOnly); +} + +/** 单个数据库表信息 */ +export interface TableInfo { + name: string; + type: string; // 'table' | 'view' | ... + rowCount: number; + columns: ColumnInfo[]; +} + +/** 列信息 */ +export interface ColumnInfo { + cid: number; + name: string; + type: string; + notnull: boolean; + pk: boolean; +} + +/** 单个数据库扫描结果 */ +export interface DatabaseScanResult { + /** 数据库文件名 */ + filename: string; + /** 原始加密文件路径 */ + sourcePath: string; + /** 解密后缓存文件路径 (仅当指定了 cacheDir 时) */ + cachePath?: string; + success: boolean; + error?: string; + tables: TableInfo[]; +} + +/** 扫描/读取选项 */ +export interface DatabaseReadOptions { + /** 解密后数据库缓存目录,不设置则使用临时文件 */ + cacheDir?: string; + /** 是否跳过已存在的缓存文件 (默认 true) */ + skipExistingCache?: boolean; +} + +/** + * 从 DatabaseSync 对象提取表信息 + */ +function extractTablesInfo (db: DatabaseSyncType): TableInfo[] { + const tables: TableInfo[] = []; + + const masterRows = db.prepare( + "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY name" + ).all() as { name: string; type: string; }[]; + + for (const { name: tableName, type: tableType } of masterRows) { + // 获取列信息 + const columns: ColumnInfo[] = []; + try { + const cols = db.prepare(`PRAGMA table_info([${tableName}])`).all() as { + cid: number; name: string; type: string; notnull: number; pk: number; + }[]; + for (const col of cols) { + columns.push({ + cid: col.cid, + name: col.name, + type: col.type, + notnull: col.notnull === 1, + pk: col.pk === 1, + }); + } + } catch { + // PRAGMA 可能对某些表失败 + } + + // 获取行数 + let rowCount = 0; + try { + const cnt = db.prepare(`SELECT count(*) as cnt FROM [${tableName}]`).get() as { cnt: number; } | undefined; + rowCount = cnt?.cnt ?? 0; + } catch { + rowCount = -1; + } + + tables.push({ + name: tableName, + type: tableType, + rowCount, + columns, + }); + } + + return tables; +} + +/** + * 打开一个解密后的 SQLite 文件,列出所有表和列信息 + */ +export function listTablesFromFile (filePath: string): TableInfo[] { + const DB = getDatabaseSync(); + const db = new DB(filePath, { readOnly: true }); + try { + return extractTablesInfo(db); + } finally { + db.close(); + } +} + +export function listTablesFromBuffer (data: Buffer): TableInfo[] { + const tmpPath = path.join(os.tmpdir(), `napcat_db_${Date.now()}_${Math.random().toString(36).slice(2)}.db`); + try { + fs.writeFileSync(tmpPath, data); + return listTablesFromFile(tmpPath); + } finally { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } +} + +/** + * 解密并读取单个数据库文件 + * + * @param dbPath 加密数据库文件路径 + * @param passphrase 密钥口令 + * @param options 可选配置 (缓存目录等) + * @returns 数据库扫描结果 + */ +export function readSingleDatabase ( + dbPath: string, + passphrase: Buffer, + options?: DatabaseReadOptions +): DatabaseScanResult { + const filename = path.basename(dbPath); + const result: DatabaseScanResult = { + filename, + sourcePath: dbPath, + success: false, + tables: [], + }; + + try { + // 如果指定了缓存目录,且缓存文件已存在,直接读取 + if (options?.cacheDir) { + const cachedPath = path.join(options.cacheDir, filename); + result.cachePath = cachedPath; + + if (options?.skipExistingCache !== false && fs.existsSync(cachedPath)) { + result.tables = listTablesFromFile(cachedPath); + result.success = true; + return result; + } + } + + const fileData = fs.readFileSync(dbPath); + if (!isEncryptedNTDB(fileData)) { + result.error = 'Not an encrypted NTQQ database'; + return result; + } + + // 有缓存目录: 解密写入文件再读取 + if (options?.cacheDir) { + fs.mkdirSync(options.cacheDir, { recursive: true }); + const outPath = decryptDatabaseFile(dbPath, passphrase, result.cachePath); + if (!outPath) { + result.error = 'Decryption failed'; + return result; + } + result.cachePath = outPath; + result.tables = listTablesFromFile(outPath); + } else { + // 无缓存目录: 解密到内存再用临时文件读取 + const decryptedBuf = decryptDatabase(fileData, passphrase); + if (!decryptedBuf) { + result.error = 'Decryption returned null'; + return result; + } + result.tables = listTablesFromBuffer(decryptedBuf); + } + + result.success = true; + } catch (e) { + result.error = e instanceof Error ? e.message : String(e); + } + + return result; +} + +export function scanAllDatabases ( + dbDir: string, + passphrase: Buffer, + options?: DatabaseReadOptions +): DatabaseScanResult[] { + const results: DatabaseScanResult[] = []; + + if (!fs.existsSync(dbDir)) return results; + + const files = fs.readdirSync(dbDir).filter(f => f.endsWith('.db')).sort(); + + for (const filename of files) { + const filePath = path.join(dbDir, filename); + const result = readSingleDatabase(filePath, passphrase, options); + results.push(result); + } + + return results; +} + +/** + * 格式化扫描结果为可读字符串 + */ +export function formatScanResults (results: DatabaseScanResult[]): string { + const lines: string[] = []; + let totalTables = 0; + let successCount = 0; + + for (const db of results) { + if (db.success) { + successCount++; + const cacheNote = db.cachePath ? ` → ${db.cachePath}` : ''; + lines.push(`=== ${db.filename} (${db.tables.length} tables)${cacheNote} ===`); + for (const table of db.tables) { + totalTables++; + const colList = table.columns.map(c => `${c.name}:${c.type || '?'}`).join(', '); + lines.push(` ${table.name}: ${table.rowCount} rows [${colList}]`); + } + } else { + lines.push(`=== ${db.filename}: SKIP (${db.error}) ===`); + } + lines.push(''); + } + + lines.push(`Summary: ${successCount}/${results.length} databases decrypted, ${totalTables} total tables`); + return lines.join('\n'); +} diff --git a/packages/snowcat-database/tsconfig.json b/packages/snowcat-database/tsconfig.json new file mode 100644 index 0000000..473a385 --- /dev/null +++ b/packages/snowcat-database/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-develop/QQNT.dll b/packages/snowcat-develop/QQNT.dll new file mode 100644 index 0000000..9dca3a7 Binary files /dev/null and b/packages/snowcat-develop/QQNT.dll differ diff --git a/packages/snowcat-develop/config/.env.example b/packages/snowcat-develop/config/.env.example new file mode 100644 index 0000000..f672922 --- /dev/null +++ b/packages/snowcat-develop/config/.env.example @@ -0,0 +1,4 @@ +NAPCAT_DISABLE_PIPE=1 +NAPCAT_DISABLE_MULTI_PROCESS=1 +NAPCAT_WEBUI_JWT_SECRET_KEY=napcat_dev_secret_key +NAPCAT_WEBUI_SECRET_KEY=napcatqq \ No newline at end of file diff --git a/packages/snowcat-develop/config/onebot11.json b/packages/snowcat-develop/config/onebot11.json new file mode 100644 index 0000000..fa3000d --- /dev/null +++ b/packages/snowcat-develop/config/onebot11.json @@ -0,0 +1,39 @@ +{ + "network": { + "httpServers": [ + { + "enable": true, + "name": "HTTP", + "host": "127.0.0.1", + "port": 3000, + "enableCors": true, + "enableWebsocket": false, + "messagePostFormat": "array", + "token": "", + "debug": false + } + ], + "httpSseServers": [], + "httpClients": [], + "websocketServers": [ + { + "enable": true, + "name": "WebSocket", + "host": "127.0.0.1", + "port": 3001, + "reportSelfMessage": false, + "enableForcePushEvent": true, + "messagePostFormat": "array", + "token": "", + "debug": false, + "heartInterval": 30000 + } + ], + "websocketClients": [], + "plugins": [] + }, + "musicSignUrl": "", + "enableLocalFile2Url": false, + "parseMultMsg": false, + "imageDownloadProxy": "" +} \ No newline at end of file diff --git a/packages/snowcat-develop/index.js b/packages/snowcat-develop/index.js new file mode 100644 index 0000000..98c9def --- /dev/null +++ b/packages/snowcat-develop/index.js @@ -0,0 +1,14 @@ +const path = require('path'); +const { pathToFileURL } = require('url'); + +// 公共目录变量 +const BASE_DIR = __dirname; +const WRAPPER_NODE_PATH = path.join(BASE_DIR, 'wrapper.node'); +const PACKAGE_JSON_PATH = path.join(BASE_DIR, 'package.json'); +const CONFIG_JSON_PATH = path.join(BASE_DIR, 'config.json'); +const NAPCAT_MJS_PATH = path.join(BASE_DIR, 'napcat', 'napcat.mjs'); +process.env.NAPCAT_WRAPPER_PATH = WRAPPER_NODE_PATH; +process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = PACKAGE_JSON_PATH; +process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = CONFIG_JSON_PATH; +process.env.NAPCAT_DISABLE_PIPE = '1'; +import(pathToFileURL(NAPCAT_MJS_PATH).href); diff --git a/packages/snowcat-develop/loadNapCat.cjs b/packages/snowcat-develop/loadNapCat.cjs new file mode 100644 index 0000000..4380dcc --- /dev/null +++ b/packages/snowcat-develop/loadNapCat.cjs @@ -0,0 +1,103 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const mainPath = process.argv[2]; +if (!mainPath) { + console.error('Please provide the base directory as the first argument.'); + process.exit(1); +} + +// 动态获取 versions 下唯一的版本文件夹,并拼接 resources/app 路径 +const versionsDir = path.join(mainPath, 'versions'); +console.log(`Looking for version folders in: ${versionsDir}`); +const versionFolders = fs.readdirSync(versionsDir).filter(f => fs.statSync(path.join(versionsDir, f)).isDirectory()); +let selectedFolder; +if (versionFolders.length === 0) { + console.error('versions 文件夹下没有找到版本目录'); + process.exit(1); +} else if (versionFolders.length === 1) { + selectedFolder = versionFolders[0]; +} else { + // 获取时间最新的文件夹 + const stats = versionFolders.map(folder => { + const folderPath = path.join(versionsDir, folder); + return { folder, mtime: fs.statSync(folderPath).mtime }; + }); + stats.sort((a, b) => b.mtime - a.mtime); + selectedFolder = stats[0].folder; + console.log(`多个版本文件夹存在,选择最新的: ${selectedFolder}`); +} + +const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app'); +console.log(`BASE_DIR: ${BASE_DIR}`); +const TARGET_DIR = path.join(__dirname, 'dist'); +const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64'); +const QQNT_FILE = path.join(__dirname, 'QQNT.dll'); +const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs'); + +const itemsToCopy = [ + 'avif_convert.dll', + 'broadcast_ipc.dll', + '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', +]; + +const win64ItemsToCopy = [ + 'SSOShareInfoHelper64.dll', + 'parent-ipc-core-x64.dll', +]; + +async function copyAll () { + const qqntDllPath = path.join(TARGET_DIR, 'QQNT.dll'); + const configPath = path.join(TARGET_DIR, 'config.json'); + const allItemsExist = await fs.pathExists(qqntDllPath) && + await fs.pathExists(configPath) && + (await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) && + (await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists); + + if (!allItemsExist) { + console.log('Copying required files...'); + await fs.ensureDir(TARGET_DIR); + await fs.ensureDir(TARGET_WIN64_DIR); + await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true }); + await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true }); + + // 复制 win64 目录下的文件 + await Promise.all(win64ItemsToCopy.map(async (item) => { + await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true }); + console.log(`Copied ${item} to win64`); + })); + + // 复制根目录下的文件 + await Promise.all(itemsToCopy.map(async (item) => { + await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true }); + console.log(`Copied ${item}`); + })); + console.log('All files copied successfully.'); + } else { + console.log('Files already exist, skipping copy.'); + } + + process.env.NAPCAT_WRAPPER_PATH = path.join(TARGET_DIR, 'wrapper.node'); + process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = path.join(TARGET_DIR, 'package.json'); + process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = path.join(TARGET_DIR, 'config.json'); + process.env.NAPCAT_DISABLE_PIPE = '1'; + // 禁用重启和多进程功能 + process.env.NAPCAT_DISABLE_MULTI_PROCESS = '1'; + process.env.NAPCAT_WORKDIR = TARGET_DIR; + // 开发环境使用固定密钥 + process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key'; + process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcatqq'; + console.log('Loading NapCat module...'); + await import(pathToFileURL(NAPCAT_MJS_PATH).href); +} + +copyAll().catch(console.error); diff --git a/packages/snowcat-develop/napcat.bat b/packages/snowcat-develop/napcat.bat new file mode 100644 index 0000000..0c15cd1 --- /dev/null +++ b/packages/snowcat-develop/napcat.bat @@ -0,0 +1 @@ +node.exe ./index.js \ No newline at end of file diff --git a/packages/snowcat-develop/nodeTest.ps1 b/packages/snowcat-develop/nodeTest.ps1 new file mode 100644 index 0000000..9ae0b15 --- /dev/null +++ b/packages/snowcat-develop/nodeTest.ps1 @@ -0,0 +1,8 @@ +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$regPath = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ' +$uninstall = (Get-ItemProperty -Path $regPath -Name UninstallString -ErrorAction Stop).UninstallString +$uninstall = $uninstall.Trim('"') +$qqPath = Split-Path $uninstall -Parent + +Write-Host "QQPath: $qqPath" +node.exe --inspect "./loadNapCat.cjs" "$qqPath" diff --git a/packages/snowcat-develop/package.json b/packages/snowcat-develop/package.json new file mode 100644 index 0000000..00072d3 --- /dev/null +++ b/packages/snowcat-develop/package.json @@ -0,0 +1,28 @@ +{ + "name": "snowcat-develop", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.js", + "scripts": { + "dev": "powershell ./nodeTest.ps1", + "copy-env": "xcopy config ..\\snowcat-shell\\dist\\config /E /I /Y" + }, + "exports": { + ".": { + "require": "./index.js" + }, + "./*": { + "require": "./*" + } + }, + "dependencies": { + "fs-extra": "^11.3.2" + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-develop/tsconfig.json b/packages/snowcat-develop/tsconfig.json new file mode 100644 index 0000000..3b90685 --- /dev/null +++ b/packages/snowcat-develop/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true + }, + "include": [ + "*.cjs", + "**/*.cjs", + "**/*.ts", + "*.js", + "**/*.js" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-dpapi/LICENSE b/packages/snowcat-dpapi/LICENSE new file mode 100644 index 0000000..cc72d92 --- /dev/null +++ b/packages/snowcat-dpapi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Xavier Monin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/snowcat-dpapi/README.md b/packages/snowcat-dpapi/README.md new file mode 100644 index 0000000..d7f8c6a --- /dev/null +++ b/packages/snowcat-dpapi/README.md @@ -0,0 +1,4 @@ +# @primno/dpapi + +## 协议与说明 +全部遵守原仓库要求 \ No newline at end of file diff --git a/packages/snowcat-dpapi/index.ts b/packages/snowcat-dpapi/index.ts new file mode 100644 index 0000000..5680203 --- /dev/null +++ b/packages/snowcat-dpapi/index.ts @@ -0,0 +1,65 @@ +/** + * snowcat-dpapi - Windows DPAPI wrapper + * + * Loads the native @primno+dpapi.node addon from the runtime + * native/dpapi/ directory using process.dlopen, consistent + * with how other native modules (ffmpeg, packet, pty) are loaded. + */ + +import { fileURLToPath } from 'node:url'; +import path, { dirname } from 'node:path'; + +export type DataProtectionScope = 'CurrentUser' | 'LocalMachine'; + +export interface DpapiBindings { + protectData (dataToEncrypt: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array; + unprotectData (encryptData: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array; +} + +let dpapiBindings: DpapiBindings | null = null; +let loadError: Error | null = null; + +function getAddonPath (): string { + // At runtime, import.meta.url resolves to dist/ directory. + // Native files are at dist/native/dpapi/{platform}-{arch}/@primno+dpapi.node + const importDir = dirname(fileURLToPath(import.meta.url)); + const platform = process.platform; // 'win32' + const arch = process.arch; // 'x64' or 'arm64' + return path.join(importDir, 'native', 'dpapi', `${platform}-${arch}`, '@primno+dpapi.node'); +} + +function loadDpapi (): DpapiBindings { + if (dpapiBindings) { + return dpapiBindings; + } + if (loadError) { + throw loadError; + } + try { + const addonPath = getAddonPath(); + const nativeModule: { exports: DpapiBindings } = { exports: {} as DpapiBindings }; + process.dlopen(nativeModule, addonPath); + dpapiBindings = nativeModule.exports; + return dpapiBindings; + } catch (e) { + loadError = e as Error; + throw new Error(`Failed to load DPAPI native addon: ${(e as Error).message}`); + } +} + +export const isPlatformSupported = process.platform === 'win32'; + +export function protectData (data: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array { + return loadDpapi().protectData(data, optionalEntropy, scope); +} + +export function unprotectData (data: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array { + return loadDpapi().unprotectData(data, optionalEntropy, scope); +} + +export const Dpapi = { + protectData, + unprotectData, +}; + +export default Dpapi; diff --git a/packages/snowcat-dpapi/package.json b/packages/snowcat-dpapi/package.json new file mode 100644 index 0000000..665bde3 --- /dev/null +++ b/packages/snowcat-dpapi/package.json @@ -0,0 +1,18 @@ +{ + "name": "snowcat-dpapi", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.ts", + "exports": { + ".": { + "import": "./index.ts" + } + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-dpapi/tsconfig.json b/packages/snowcat-dpapi/tsconfig.json new file mode 100644 index 0000000..b0b9beb --- /dev/null +++ b/packages/snowcat-dpapi/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file diff --git a/packages/snowcat-framework/napcat.cjs b/packages/snowcat-framework/napcat.cjs new file mode 100644 index 0000000..7ead94b --- /dev/null +++ b/packages/snowcat-framework/napcat.cjs @@ -0,0 +1,92 @@ +const process = require('process'); +const os = require('os'); +const path = require('path'); + +// 保存原始dlopen +const dlopenOrig = process.dlopen; + +let wrapperSession; +let wrapperNodeApi; +let wrapperLoginService; +let getWebUiUrlFunc; +let ncCallback = () => { }; +let napCatInitialized = false; // 添加一个标志 + +function createServiceProxy (ServiceName) { + return new Proxy(() => { }, { + get: (target, FunctionName) => { + if (ServiceName === 'NodeIQQNTWrapperSession' && FunctionName === 'create') { + return () => new Proxy({}, { + get: function (target, ClassFunName, receiver) { + return function () { + if (ClassFunName === 'init') { + const origin = arguments[3].onOpentelemetryInit; + arguments[3].onOpentelemetryInit = function (result) { + origin(...arguments); + if (result.is_init) { + ncCallback(); + } + }; + } + const ret = wrapperSession[ClassFunName](...arguments); + return ret; + }; + }, + }); + } + if (ServiceName === 'NodeIKernelLoginService' && FunctionName === 'get') { + return () => wrapperLoginService; + } + return wrapperNodeApi[ServiceName][FunctionName]; + }, + }); +} + +function clearHook () { + process.dlopen = dlopenOrig; +} + +async function initializeNapCat () { + console.log('[NapCat] [Info] 开始初始化NapCat'); + try { + const currentPath = path.dirname(__filename); + const { NCoreInitFramework, getWebUiUrl } = await import('file://' + path.join(currentPath, './napcat.mjs')); + getWebUiUrlFunc = getWebUiUrl; + await NCoreInitFramework(wrapperSession, wrapperLoginService, (callback) => { ncCallback = callback; }); + } catch (error) { + console.log('[NapCat] [Error] 初始化NapCat', error); + } +} + +process.dlopen = function (module, filename, flags = os.constants.dlopen.RTLD_LAZY) { + const dlopenRet = dlopenOrig(module, filename, flags); + if (!filename.includes('wrapper.node') || napCatInitialized) return dlopenRet; + napCatInitialized = true; // 初始化完成后设置标志 + clearHook(); + wrapperNodeApi = module.exports; + wrapperLoginService = wrapperNodeApi.NodeIKernelLoginService.get(); + wrapperSession = wrapperNodeApi.NodeIQQNTWrapperSession.create(); + + initializeNapCat().then().catch(); + + module.exports = new Proxy({}, { + get: (target, ServiceName) => { + if (ServiceName === 'NodeIKernelLoginService' || ServiceName === 'NodeIQQNTWrapperSession') { + return createServiceProxy(ServiceName); + } + return wrapperNodeApi[ServiceName]; + }, + }); + + return dlopenRet; +}; + +module.exports = { + NCgetWebUiUrl: async () => { + if (!getWebUiUrlFunc) { + console.log('[NapCat] [Error] 未初始化完成'); + return ''; + } + return await getWebUiUrlFunc(); + }, +}; diff --git a/packages/snowcat-framework/nativeLoader.cjs b/packages/snowcat-framework/nativeLoader.cjs new file mode 100644 index 0000000..b644cd0 --- /dev/null +++ b/packages/snowcat-framework/nativeLoader.cjs @@ -0,0 +1,25 @@ +// const fs = require('fs'); +const path = require('path'); + +async function initializeNapCat (session, loginService, registerCallback) { + // const logFile = path.join(currentPath, 'napcat.log'); + + console.log('[NapCat] [Info] 开始初始化NapCat'); + + // fs.writeFileSync(logFile, '', { flag: 'w' }); + + // fs.writeFileSync(logFile, '[NapCat] [Info] NapCat 初始化成功\n', { flag: 'a' }); + + try { + const currentPath = path.dirname(__filename); + const { NCoreInitFramework } = await import('file://' + path.join(currentPath, './napcat.mjs')); + await NCoreInitFramework(session, loginService, (callback) => { registerCallback(callback); }); + } catch (error) { + console.log('[NapCat] [Error] 初始化NapCat', error); + // fs.writeFileSync(logFile, `[NapCat] [Error] 初始化NapCat失败: ${error.message}\n`, { flag: 'a' }); + } +} + +module.exports = { + initializeNapCat, +}; diff --git a/packages/snowcat-framework/package.json b/packages/snowcat-framework/package.json new file mode 100644 index 0000000..0ccdd31 --- /dev/null +++ b/packages/snowcat-framework/package.json @@ -0,0 +1,36 @@ +{ + "name": "snowcat-framework", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.ts", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" + }, + "exports": { + ".": { + "import": "./index.ts" + }, + "./*": { + "import": "./*" + } + }, + "dependencies": { + "snowcat-core": "workspace:*", + "snowcat-common": "workspace:*", + "snowcat-database": "workspace:*", + "snowcat-protobuf": "workspace:*", + "snowcat-adapter": "workspace:*", + "snowcat-webui-backend": "workspace:*", + "snowcat-vite": "workspace:*", + "snowcat-qrcode": "workspace:*", + "json5": "^2.2.3" + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-framework/snowcat.ts b/packages/snowcat-framework/snowcat.ts new file mode 100644 index 0000000..76858a4 --- /dev/null +++ b/packages/snowcat-framework/snowcat.ts @@ -0,0 +1,441 @@ +import { SnowCatPathWrapper } from 'snowcat-common/src/path'; +import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'snowcat-webui-backend/index'; +import { SnowCatAdapterManager } from 'snowcat-adapter'; +import { NativePacketHandler } from 'snowcat-core/packet/handler/client'; +import { Napi2NativeLoader } from 'snowcat-core/packet/handler/napi2nativeLoader'; +import { loadSnowCatConfig } from '@/snowcat-core/helper/config'; +import { FFmpegService } from 'snowcat-core/helper/ffmpeg/ffmpeg'; +import { logSubscription, LogWrapper } from 'snowcat-core/helper/log'; +import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info'; +import { InstanceContext, loadQQWrapper, SnowCatCore, SnowCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/snowcat-core'; +import { proxiedListenerOf } from '@/snowcat-core/helper/proxy-handler'; +import { statusHelperSubscription } from '@/snowcat-core/helper/status'; +import { applyPendingUpdates } from '@/snowcat-webui-backend/src/api/UpdateNapCat'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbSvcTrpcTcpBase, OidbSvcTrpcTcp0XCDE_2RespBody } from '@/snowcat-core/packet/transformer/proto'; + +// Framework ES入口文件 +export async function getWebUiUrl () { + const WebUiConfigData = (await WebUiConfig.GetWebUIConfig()); + return 'http://127.0.0.1:' + webUiRuntimePort + '/webui/?token=' + encodeURIComponent(WebUiConfigData.token); +} + +export async function NCoreInitFramework ( + session: NodeIQQNTWrapperSession, + loginService: NodeIKernelLoginService, + registerInitCallback: (callback: () => void) => void +) { + // 在进入本层前是否登录未进行判断 + console.log('SnowCat Framework App Loading...'); + + process.on('uncaughtException', (err) => { + console.log('[SnowCat] [Error] Unhandled Exception:', err.message); + }); + + process.on('unhandledRejection', (reason) => { + console.log('[SnowCat] [Error] unhandledRejection:', reason); + }); + + const pathWrapper = new SnowCatPathWrapper(); + + const logger = new LogWrapper(pathWrapper.logsPath); + await applyPendingUpdates(pathWrapper, logger); + const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); + const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion()); + const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用 + const napi2nativeLoader = new Napi2NativeLoader({ logger }); // 初始化 Napi2NativeLoader 用于后续使用 + const snowcatConfig = loadSnowCatConfig(pathWrapper.configPath); + // console.log('[SnowCat] [Napi2NativeLoader]', napi2nativeLoader.nativeExports.enableAllBypasses?.()); + if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') { + const bypassOptions = snowcatConfig.bypass ?? {}; + const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions); + if (bypassEnabled) { + logger.log('[SnowCat] Napi2NativeLoader: 已启用Bypass'); + } + logger.log('[SnowCat] Napi2NativeLoader: Framework模式Bypass配置:', bypassOptions); + } else { + logger.log('[SnowCat] Napi2NativeLoader: Bypass已通过环境变量禁用'); + } + // nativePacketHandler.onAll((packet) => { + // console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data); + // }); + await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), snowcatConfig.o3HookMode === 1); + // 在 init 之后注册监听器 + + // 登录前监听 OidbSvcTrpcTcp.0xcde_2 数据包,获取数据库 passphrase + let dbPassphrase: string | undefined; + nativePacketHandler.onCmd('OidbSvcTrpcTcp.0xcde_2', ({ type, hex_data }) => { + if (type !== 1) return; // 仅处理接收方向 + try { + const raw = Buffer.from(hex_data, 'hex'); + const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(raw); + if (base.body && base.body.length > 0) { + const body = new NapProtoMsg(OidbSvcTrpcTcp0XCDE_2RespBody).decode(base.body); + if (body.inner?.value) { + dbPassphrase = body.inner.value; + logger.log('[SnowCat] 已启用数据库辅助支持能力'); + } + } + } catch (e) { + logger.logError('[SnowCat] [0xCDE_2] 解析失败:', e); + } + }); + + // 初始化 FFmpeg 服务 + await FFmpegService.init(pathWrapper.binaryPath, logger); + + // 提前启动 WebUI,使其在登录前可用,支持通过 WebUI 操控登录过程 + WebUiDataRuntime.setWorkingEnv(SnowCatCoreWorkingEnv.Framework); + InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e)); + + // 直到登录成功后,执行下一步 + const selfInfo = await new Promise((resolve) => { + const loginContext = { isLogined: false }; + + const loginListener = new NodeIKernelLoginListener(); + + loginListener.onUserLoggedIn = (userid: string) => { + const tips = `当前账号(${userid})已登录,无法重复登录`; + logger.logError(tips); + WebUiDataRuntime.setQQLoginError(tips); + }; + + loginListener.onQRCodeLoginSucceed = async (loginResult) => { + loginContext.isLogined = true; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + if (oneBotContext?.core?.selfInfo) { + oneBotContext.core.selfInfo.online = true; + WebUiDataRuntime.setQQLoginPhase('ready'); + } else { + WebUiDataRuntime.setQQLoginPhase('initializing'); + } + await new Promise(resolve => { + registerInitCallback(() => resolve()); + }); + resolve({ + uid: loginResult.uid, + uin: loginResult.uin, + nick: '', // 获取不到 + online: true, + }); + }; + + loginListener.onQRCodeGetPicture = ({ qrcodeUrl }) => { + WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl); + WebUiDataRuntime.setQQLoginError(''); + WebUiDataRuntime.setQQLoginPhase('waiting_qrcode'); + logger.log('[SnowCat] [Framework] 二维码已更新, URL:', qrcodeUrl); + }; + + loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => { + if (!loginContext.isLogined) { + logger.logError('[SnowCat] [Framework] [Login] QRCode Session Failed, ErrType:', errType, 'ErrCode:', errCode); + if (errType === 1 && errCode === 3) { + WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新'); + } + } + }; + + loginListener.onLoginFailed = (...args) => { + const errInfo = JSON.stringify(args); + logger.logError('[SnowCat] [Framework] [Login] Login Failed, ErrInfo:', errInfo); + WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`); + }; + + loginListener.onLoginConnected = () => { + logger.log('[SnowCat] [Framework] 登录服务已连接'); + // 注册 WebUI 登录操作回调 + registerWebUiLoginCallbacks(loginService, loginContext, logger); + // 获取历史登录列表供 WebUI 使用 + loginService.getLoginList().then((res) => { + const list = res.LocalLoginInfoList.filter((item) => item.isQuickLogin); + WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString())); + WebUiDataRuntime.setQQNewLoginList(list); + }).catch(e => logger.logError('[SnowCat] [Framework] 获取登录列表失败:', e)); + if (!loginContext.isLogined) { + const requested = loginService.getQRCodePicture(); + logger.logWarn('[SnowCat] [Framework] 登录服务连接完成,正在获取二维码', { requested, msfStatus: loginService.getMsfStatus() }); + } + }; + + loginListener.onQRCodeSessionUserScaned = () => { + WebUiDataRuntime.setQQLoginPhase('qrcode_scanned'); + logger.log('[SnowCat] [Framework] 二维码已被扫描,等待确认...'); + }; + + loginListener.onPasswordLoginFailed = (...args) => { + logger.logError('[SnowCat] [Framework] 密码登录失败:', ...args); + }; + + loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger)); + }); + // 过早进入会导致addKernelMsgListener等Listener添加失败 + // await sleep(2500); + // 初始化 SnowCatFramework + const loaderObject = new SnowCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler, napi2nativeLoader); + // 将捕获的 passphrase 设置到 core 上,供 DatabaseApi 使用 + if (dbPassphrase) { + loaderObject.core.dbPassphrase = dbPassphrase; + } + await loaderObject.core.initCore(); + + // 登录成功后设置数据路径 + WebUiDataRuntime.setQQDataPath(loaderObject.core.dataPath); + + // // 测试 DatabaseApi + // if (dbPassphrase) { + // const dbApi = loaderObject.core.apis.DatabaseApi; + // const sqliteOk = await dbApi.isSqliteAvailable(); + // if (sqliteOk) { + // try { + // const result = dbApi.readDatabase('nt_msg.db'); + // if (result) { + // logger.log('[SnowCat] [Database] nt_msg.db:\n' + dbApi.formatResults([result])); + // } + // } catch (e) { + // logger.logError('[SnowCat] [Database] 读取失败:', e); + // } + // } + // } + + // 使用 SnowCatAdapterManager 统一管理协议适配器 + const adapterManager = new SnowCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper); + await adapterManager.initAdapters(); + // 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用 + const oneBotAdapter = adapterManager.getOneBotAdapter(); + if (oneBotAdapter) { + WebUiDataRuntime.setOneBotContext(oneBotAdapter); + if (WebUiDataRuntime.getQQLoginStatus()) { + WebUiDataRuntime.setQQLoginPhase('ready'); + } + } + + // 监听下线通知并同步到 WebUI(兜底保障,防止 WebUI 状态不同步) + let restartRequested = false; + loaderObject.core.event.on('KickedOffLine', (tips: string) => { + WebUiDataRuntime.setQQLoginStatus(false); + WebUiDataRuntime.setQQLoginQrcodeURL(''); + WebUiDataRuntime.setQQLoginError(tips); + WebUiDataRuntime.setQQLoginPhase('reconnecting'); + if (restartRequested) return; + restartRequested = true; + setTimeout(() => { + WebUiDataRuntime.requestRestartProcess().then((restartResult) => { + logger.logWarn('[SnowCat] [Framework] 账号被踢下线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult); + if (!restartResult.result) { + restartRequested = false; + WebUiDataRuntime.setQQLoginPhase('offline'); + WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${restartResult.message}`); + } + }).catch((error) => { + restartRequested = false; + WebUiDataRuntime.setQQLoginPhase('offline'); + WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${(error as Error).message}`); + }); + }, 3_500); + }); +} + +function registerWebUiLoginCallbacks ( + loginService: NodeIKernelLoginService, + loginContext: { isLogined: boolean; }, + logger: LogWrapper +) { + // 刷新二维码 + WebUiDataRuntime.setRefreshQRCodeCallback(async () => { + loginContext.isLogined = false; + const msfStatus = loginService.getMsfStatus(); + if (msfStatus === 2) { + WebUiDataRuntime.setQQLoginPhase('reconnecting'); + const connected = loginService.connect(); + logger.logWarn('[SnowCat] [Framework] 登录服务离线,正在重连以获取二维码', { connected, msfStatus }); + if (!connected) { + throw new Error('QQ 登录服务无法重新连接'); + } + return; + } + const requested = loginService.getQRCodePicture(); + logger.logWarn('[SnowCat] [Framework] 请求刷新二维码', { requested, msfStatus }); + if (!requested) { + throw new Error('QQ 未接受二维码刷新请求'); + } + }); + + // 快速登录 + WebUiDataRuntime.setQuickLoginCall(async (uin: string) => { + return await new Promise((resolve) => { + if (!uin) { + return resolve({ result: false, message: '快速登录失败' }); + } + logger.log('[SnowCat] [Framework] 正在快速登录', uin); + loginService.quickLoginWithUin(uin).then(res => { + const success = res.result === '0' && !res.loginErrorInfo?.errMsg; + if (!success) { + const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`; + WebUiDataRuntime.setQQLoginError(errMsg); + resolve({ result: false, message: errMsg }); + } else { + loginContext.isLogined = true; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError('[SnowCat] [Framework] 快速登录异常:', e); + WebUiDataRuntime.setQQLoginError('快速登录发生错误'); + resolve({ result: false, message: '快速登录发生错误' }); + }); + }); + }); + + // 密码登录 + WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => { + return await new Promise((resolve) => { + if (!uin || !passwordMd5) { + return resolve({ result: false, message: '密码登录失败:参数不完整' }); + } + logger.log('[SnowCat] [Framework] 正在密码登录', uin); + loginService.passwordLogin({ + uin, + passwordMd5, + step: 0, + newDeviceLoginSig: new Uint8Array(), + proofWaterSig: new Uint8Array(), + proofWaterRand: new Uint8Array(), + proofWaterSid: new Uint8Array(), + unusualDeviceCheckSig: new Uint8Array(), + }).then(res => { + if (res.result === '140022008') { + const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || ''; + resolve({ result: false, message: '需要验证码', needCaptcha: true, proofWaterUrl }); + } else if (res.result === '140022010' || res.result === '140022011') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + resolve({ result: false, message: '新设备需要扫码验证', needNewDevice: true, jumpUrl, newDevicePullQrCodeSig }); + } else if (res.result !== '0') { + const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败'; + WebUiDataRuntime.setQQLoginError(errMsg); + resolve({ result: false, message: errMsg }); + } else { + loginContext.isLogined = true; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError('[SnowCat] [Framework] 密码登录异常:', e); + WebUiDataRuntime.setQQLoginError('密码登录发生错误'); + resolve({ result: false, message: '密码登录发生错误' }); + }); + }); + }); + + // 验证码登录(密码登录需要验证码时的第二步) + WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => { + return await new Promise((resolve) => { + if (!uin || !passwordMd5 || !ticket) { + return resolve({ result: false, message: '验证码登录失败:参数不完整' }); + } + logger.log('[SnowCat] [Framework] 正在验证码登录', uin); + loginService.passwordLogin({ + uin, + passwordMd5, + step: 1, + newDeviceLoginSig: new Uint8Array(), + proofWaterSig: new TextEncoder().encode(ticket), + proofWaterRand: new TextEncoder().encode(randstr), + proofWaterSid: new TextEncoder().encode(sid), + unusualDeviceCheckSig: new Uint8Array(), + }).then(res => { + if (res.result === '140022010' || res.result === '140022011') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + resolve({ result: false, message: '新设备需要扫码验证', needNewDevice: true, jumpUrl, newDevicePullQrCodeSig }); + } else if (res.result !== '0') { + const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败'; + WebUiDataRuntime.setQQLoginError(errMsg); + resolve({ result: false, message: errMsg }); + } else { + loginContext.isLogined = true; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError('[SnowCat] [Framework] 验证码登录异常:', e); + WebUiDataRuntime.setQQLoginError('验证码登录发生错误'); + resolve({ result: false, message: '验证码登录发生错误' }); + }); + }); + }); + + // 新设备验证登录(密码登录需要新设备验证时的第二步) + WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => { + return await new Promise((resolve) => { + if (!uin || !passwordMd5 || !newDevicePullQrCodeSig) { + return resolve({ result: false, message: '新设备验证登录失败:参数不完整' }); + } + logger.log('[SnowCat] [Framework] 正在新设备验证登录', uin); + loginService.passwordLogin({ + uin, + passwordMd5, + step: 2, + newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig), + proofWaterSig: new Uint8Array(), + proofWaterRand: new Uint8Array(), + proofWaterSid: new Uint8Array(), + unusualDeviceCheckSig: new Uint8Array(), + }).then(res => { + if (res.result === '140022011') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const sig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + resolve({ result: false, message: '异常设备需要验证', needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig }); + } else if (res.result !== '0') { + const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败'; + WebUiDataRuntime.setQQLoginError(errMsg); + resolve({ result: false, message: errMsg }); + } else { + loginContext.isLogined = true; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError('[SnowCat] [Framework] 新设备验证登录异常:', e); + WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误'); + resolve({ result: false, message: '新设备验证登录发生错误' }); + }); + }); + }); +} + +export class SnowCatFramework { + public core: SnowCatCore; + context: InstanceContext; + + constructor ( + wrapper: WrapperNodeApi, + session: NodeIQQNTWrapperSession, + logger: LogWrapper, + selfInfo: SelfInfo, + basicInfoWrapper: QQBasicInfoWrapper, + pathWrapper: SnowCatPathWrapper, + packetHandler: NativePacketHandler, + napi2nativeLoader: Napi2NativeLoader + ) { + this.context = { + packetHandler, + napi2nativeLoader, + workingEnv: SnowCatCoreWorkingEnv.Framework, + wrapper, + session, + logger, + basicInfoWrapper, + pathWrapper, + }; + this.core = new SnowCatCore(this.context, selfInfo); + } +} diff --git a/packages/snowcat-framework/tsconfig.json b/packages/snowcat-framework/tsconfig.json new file mode 100644 index 0000000..fd28287 --- /dev/null +++ b/packages/snowcat-framework/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": [ + "*.ts", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-framework/vite.config.ts b/packages/snowcat-framework/vite.config.ts new file mode 100644 index 0000000..4c99587 --- /dev/null +++ b/packages/snowcat-framework/vite.config.ts @@ -0,0 +1,77 @@ +import cp from 'vite-plugin-cp'; +import { defineConfig, PluginOption, UserConfig } from 'vite'; +import path, { resolve } from 'path'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import { autoIncludeTSPlugin } from 'snowcat-vite/vite-auto-include.js'; +import { builtinModules } from 'module'; +import react from '@vitejs/plugin-react-swc'; +import napcatVersion from 'snowcat-vite/vite-plugin-version.js'; +// 依赖排除 +const external = [ + 'ws', + 'express', +]; +const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`), 'node:sqlite'].flat(); +const FrameworkBaseConfigPlugin: PluginOption[] = [ + autoIncludeTSPlugin({ + entries: [ + { entry: 'snowcat.ts', dir: path.resolve(__dirname, '../snowcat-core/protocol') }, + { entry: 'snowcat.ts', dir: path.resolve(__dirname, '../snowcat-onebot/action/test') }, + ], + }), + react({ tsDecorators: true }), + cp({ + targets: [ + { src: '../snowcat-napi-loader/', dest: 'dist', flatten: true }, + { src: '../snowcat-native/', dest: 'dist/native', flatten: false }, + { src: '../snowcat-core/external/napcat.json', dest: 'dist/config/' }, + { src: '../snowcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false }, + { src: './napcat.cjs', dest: 'dist' }, + { src: './nativeLoader.cjs', dest: 'dist' }, + { src: './preload.cjs', dest: 'dist' }, + { src: './renderer.js', dest: 'dist' }, + { src: '../../package.json', dest: 'dist' }, + { src: '../../logo.png', dest: 'dist' }, + ], + }), + nodeResolve(), + napcatVersion(), +]; +const FrameworkBaseConfig = () => + defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/snowcat-core': resolve(__dirname, '../snowcat-core'), + '@/snowcat-common': resolve(__dirname, '../snowcat-common'), + '@/snowcat-onebot': resolve(__dirname, '../snowcat-onebot'), + '@/snowcat-pty': resolve(__dirname, '../snowcat-pty'), + '@/snowcat-webui-backend': resolve(__dirname, '../snowcat-webui-backend'), + '@/snowcat-image-size': resolve(__dirname, '../snowcat-image-size'), + '@/snowcat-protocol': resolve(__dirname, '../snowcat-protocol'), + '@/snowcat-adapter': resolve(__dirname, '../snowcat-adapter'), + }, + }, + build: { + sourcemap: false, + target: 'esnext', + minify: false, + lib: { + entry: { + napcat: path.resolve(__dirname, 'snowcat.ts'), + 'worker/conoutSocketWorker': path.resolve(__dirname, '../snowcat-pty/worker/conoutSocketWorker.ts'), + }, + formats: ['es'], + fileName: (_, entryName) => `${entryName}.mjs`, + }, + rollupOptions: { + external: [...nodeModules, ...external], + }, + }, + }); +export default defineConfig((): UserConfig => { + return { + ...FrameworkBaseConfig(), + plugins: [...FrameworkBaseConfigPlugin], + }; +}); diff --git a/packages/snowcat-image-size/package.json b/packages/snowcat-image-size/package.json new file mode 100644 index 0000000..56396d0 --- /dev/null +++ b/packages/snowcat-image-size/package.json @@ -0,0 +1,21 @@ +{ + "name": "snowcat-image-size", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts" + }, + "./src/*": { + "import": "./src/*" + } + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-image-size/resource/test-20x20.jpg b/packages/snowcat-image-size/resource/test-20x20.jpg new file mode 100644 index 0000000..214ae24 Binary files /dev/null and b/packages/snowcat-image-size/resource/test-20x20.jpg differ diff --git a/packages/snowcat-image-size/resource/test-20x20.png b/packages/snowcat-image-size/resource/test-20x20.png new file mode 100644 index 0000000..3cef9a8 Binary files /dev/null and b/packages/snowcat-image-size/resource/test-20x20.png differ diff --git a/packages/snowcat-image-size/resource/test-20x20.tiff b/packages/snowcat-image-size/resource/test-20x20.tiff new file mode 100644 index 0000000..33711d0 Binary files /dev/null and b/packages/snowcat-image-size/resource/test-20x20.tiff differ diff --git a/packages/snowcat-image-size/resource/test-20x20.webp b/packages/snowcat-image-size/resource/test-20x20.webp new file mode 100644 index 0000000..60e96f0 Binary files /dev/null and b/packages/snowcat-image-size/resource/test-20x20.webp differ diff --git a/packages/snowcat-image-size/resource/test-490x498.gif b/packages/snowcat-image-size/resource/test-490x498.gif new file mode 100644 index 0000000..5dcae1b Binary files /dev/null and b/packages/snowcat-image-size/resource/test-490x498.gif differ diff --git a/packages/snowcat-image-size/src/index.ts b/packages/snowcat-image-size/src/index.ts new file mode 100644 index 0000000..a515433 --- /dev/null +++ b/packages/snowcat-image-size/src/index.ts @@ -0,0 +1,221 @@ +import { BmpParser } from '@/snowcat-image-size/src/parser/BmpParser'; +import { GifParser } from '@/snowcat-image-size/src/parser/GifParser'; +import { JpegParser } from '@/snowcat-image-size/src/parser/JpegParser'; +import { PngParser } from '@/snowcat-image-size/src/parser/PngParser'; +import { TiffParser } from '@/snowcat-image-size/src/parser/TiffParser'; +import { WebpParser } from '@/snowcat-image-size/src/parser/WebpParser'; +import * as fs from 'fs'; +import { ReadStream } from 'fs'; +import { Readable } from 'stream'; + +export interface ImageSize { + width: number; + height: number; +} + +export enum ImageType { + JPEG = 'jpeg', + PNG = 'png', + BMP = 'bmp', + GIF = 'gif', + WEBP = 'webp', + TIFF = 'tiff', + UNKNOWN = 'unknown', +} + +export interface ImageParser { + readonly type: ImageType; + canParse (buffer: Buffer): boolean; + parseSize (stream: ReadStream): Promise; +} + +// 魔术匹配 +export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean { + if (buffer.length < offset + magic.length) { + return false; + } + + for (let i = 0; i < magic.length; i++) { + if (buffer[offset + i] !== magic[i]) { + return false; + } + } + return true; +} + +// 所有解析器实例 +const parserInstances = { + png: new PngParser(), + jpeg: new JpegParser(), + bmp: new BmpParser(), + gif: new GifParser(), + webp: new WebpParser(), + tiff: new TiffParser(), +}; + +// 首字节到可能的图片类型映射,用于快速筛选 +const firstByteMap = new Map([ + [0x42, [ImageType.BMP]], // 'B' - BMP + [0x47, [ImageType.GIF]], // 'G' - GIF + [0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian) + [0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian) + [0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP) + [0x89, [ImageType.PNG]], // PNG signature + [0xFF, [ImageType.JPEG]], // JPEG SOI +]); + +// 类型到解析器的映射 +const typeToParser = new Map([ + [ImageType.PNG, parserInstances.png], + [ImageType.JPEG, parserInstances.jpeg], + [ImageType.BMP, parserInstances.bmp], + [ImageType.GIF, parserInstances.gif], + [ImageType.WEBP, parserInstances.webp], + [ImageType.TIFF, parserInstances.tiff], +]); + +// 所有解析器列表(用于回退) +const parsers: ReadonlyArray = Object.values(parserInstances); + +export async function detectImageType (filePath: string): Promise { + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath, { + highWaterMark: 64, // 优化读取buffer大小 + start: 0, + end: 63, + }); + + const chunks: Buffer[] = []; + + stream.on('error', (err) => { + stream.destroy(); + reject(err); + }); + + stream.on('data', (chunk: Buffer | string) => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(chunkBuffer); + }); + + stream.on('end', () => { + const buffer = Buffer.concat(chunks); + + if (buffer.length === 0) { + return resolve(ImageType.UNKNOWN); + } + + for (const parser of parsers) { + if (parser.canParse(buffer)) { + return resolve(parser.type); + } + } + + resolve(ImageType.UNKNOWN); + }); + }); +} + +export async function imageSizeFromFile (filePath: string): Promise { + try { + // 先检测类型 + const type = await detectImageType(filePath); + const parser = typeToParser.get(type); + if (!parser) { + return undefined; + } + + // 用流式方式解析尺寸 + const stream = fs.createReadStream(filePath); + try { + return await parser.parseSize(stream); + } catch (err) { + console.error(`解析图片尺寸出错: ${err}`); + return undefined; + } finally { + if (!stream.destroyed) { + stream.destroy(); + } + } + } catch (err) { + console.error(`检测图片类型出错: ${err}`); + return undefined; + } +} + +export async function imageSizeFallBack ( + filePath: string, + fallback: ImageSize = { + width: 1024, + height: 1024, + } +): Promise { + return await imageSizeFromFile(filePath) ?? fallback; +} + +// 从 Buffer 创建可读流 +function bufferToReadStream (buffer: Buffer): ReadStream { + const readable = new Readable({ + read () { + this.push(buffer); + this.push(null); + }, + }); + return readable as unknown as ReadStream; +} + +// 从 Buffer 检测图片类型(使用首字节快速筛选) +export function detectImageTypeFromBuffer (buffer: Buffer): ImageType { + if (buffer.length === 0) { + return ImageType.UNKNOWN; + } + + const firstByte = buffer[0]!; + const possibleTypes = firstByteMap.get(firstByte); + + if (possibleTypes) { + // 根据首字节快速筛选可能的类型 + for (const type of possibleTypes) { + const parser = typeToParser.get(type); + if (parser && parser.canParse(buffer)) { + return parser.type; + } + } + } + + // 回退:遍历所有解析器 + for (const parser of parsers) { + if (parser.canParse(buffer)) { + return parser.type; + } + } + + return ImageType.UNKNOWN; +} + +// 从 Buffer 解析图片尺寸 +export async function imageSizeFromBuffer (buffer: Buffer): Promise { + const type = detectImageTypeFromBuffer(buffer); + const parser = typeToParser.get(type); + if (!parser) { + return undefined; + } + + try { + const stream = bufferToReadStream(buffer); + return await parser.parseSize(stream); + } catch (err) { + console.error(`解析图片尺寸出错: ${err}`); + return undefined; + } +} + +// 从 Buffer 解析图片尺寸,带回退值 +export async function imageSizeFromBufferFallBack ( + buffer: Buffer, + fallback: ImageSize = { + width: 1024, + height: 1024, + } +): Promise { + return await imageSizeFromBuffer(buffer) ?? fallback; +} diff --git a/packages/snowcat-image-size/src/parser/BmpParser.ts b/packages/snowcat-image-size/src/parser/BmpParser.ts new file mode 100644 index 0000000..083ffa5 --- /dev/null +++ b/packages/snowcat-image-size/src/parser/BmpParser.ts @@ -0,0 +1,32 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/snowcat-image-size/src'; +import { ReadStream } from 'fs'; + +// BMP解析器 +export class BmpParser implements ImageParser { + readonly type = ImageType.BMP; + // BMP 魔术头:42 4D (BM) + private readonly BMP_SIGNATURE = [0x42, 0x4D]; + + canParse (buffer: Buffer): boolean { + return matchMagic(buffer, this.BMP_SIGNATURE); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + stream.once('error', reject); + stream.once('readable', () => { + const buf = stream.read(26) as Buffer; + if (!buf || buf.length < 26) { + return resolve(undefined); + } + if (this.canParse(buf)) { + const width = buf.readUInt32LE(18); + const height = buf.readUInt32LE(22); + resolve({ width, height }); + } else { + resolve(undefined); + } + }); + }); + } +} diff --git a/packages/snowcat-image-size/src/parser/GifParser.ts b/packages/snowcat-image-size/src/parser/GifParser.ts new file mode 100644 index 0000000..d98f915 --- /dev/null +++ b/packages/snowcat-image-size/src/parser/GifParser.ts @@ -0,0 +1,37 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/snowcat-image-size/src'; +import { ReadStream } from 'fs'; + +// GIF解析器 +export class GifParser implements ImageParser { + readonly type = ImageType.GIF; + // GIF87a 魔术头:47 49 46 38 37 61 + private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; + // GIF89a 魔术头:47 49 46 38 39 61 + private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; + + canParse (buffer: Buffer): boolean { + return ( + matchMagic(buffer, this.GIF87A_SIGNATURE) || + matchMagic(buffer, this.GIF89A_SIGNATURE) + ); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + stream.once('error', reject); + stream.once('readable', () => { + const buf = stream.read(10) as Buffer; + if (!buf || buf.length < 10) { + return resolve(undefined); + } + if (this.canParse(buf)) { + const width = buf.readUInt16LE(6); + const height = buf.readUInt16LE(8); + resolve({ width, height }); + } else { + resolve(undefined); + } + }); + }); + } +} diff --git a/packages/snowcat-image-size/src/parser/JpegParser.ts b/packages/snowcat-image-size/src/parser/JpegParser.ts new file mode 100644 index 0000000..6d82770 --- /dev/null +++ b/packages/snowcat-image-size/src/parser/JpegParser.ts @@ -0,0 +1,122 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/snowcat-image-size/src'; +import { ReadStream } from 'fs'; + +// JPEG解析器 +export class JpegParser implements ImageParser { + readonly type = ImageType.JPEG; + // JPEG 魔术头:FF D8 + private readonly JPEG_SIGNATURE = [0xFF, 0xD8]; + + // JPEG标记常量 + private readonly SOF_MARKERS = { + SOF0: 0xC0, // 基线DCT + SOF1: 0xC1, // 扩展顺序DCT + SOF2: 0xC2, // 渐进式DCT + SOF3: 0xC3, // 无损 + } as const; + + // 非SOF标记 + private readonly NON_SOF_MARKERS: number[] = [ + 0xC4, // DHT + 0xC8, // JPEG扩展 + 0xCC, // DAC + ] as const; + + canParse (buffer: Buffer): boolean { + return matchMagic(buffer, this.JPEG_SIGNATURE); + } + + isSOFMarker (marker: number): boolean { + return ( + marker === this.SOF_MARKERS.SOF0 || + marker === this.SOF_MARKERS.SOF1 || + marker === this.SOF_MARKERS.SOF2 || + marker === this.SOF_MARKERS.SOF3 + ); + } + + isNonSOFMarker (marker: number): boolean { + return this.NON_SOF_MARKERS.includes(marker); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整 + let buffer = Buffer.alloc(0); + let offset = 0; + let found = false; + + // 处理错误 + stream.on('error', (err) => { + stream.destroy(); + reject(err); + }); + + // 处理数据块 + stream.on('data', (chunk: Buffer | string) => { + // 追加新数据到缓冲区 + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]); + offset = 0; + + // 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配 + const bufferSize = buffer.length; + const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数 + + // 从JPEG头部后开始扫描 + while (offset < bufferSize - MIN_REQUIRED_BYTES) { + // 寻找FF标记 + if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) { + const marker = buffer[offset + 1]; + if (!marker) { + break; + } + // 跳过非SOF标记 + if (this.isNonSOFMarker(marker)) { + offset += 2; + continue; + } + + // 处理SOF标记 (包含尺寸信息) + if (this.isSOFMarker(marker)) { + // 确保缓冲区中有足够数据读取尺寸 + if (offset + 9 < bufferSize) { + // 解析尺寸: FF XX YY YY PP HH HH WW WW ... + // XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽 + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + + found = true; + stream.destroy(); + resolve({ width, height }); + return; + } else { + // 如果缓冲区内数据不够,保留当前位置等待更多数据 + break; + } + } + } + + offset++; + } + + // 缓冲区管理: 如果处理了许多数据但没找到标记, + // 保留最后N字节用于跨块匹配,丢弃之前的数据 + if (offset > BUFFER_SIZE) { + const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况 + if (offset > KEEP_BYTES) { + buffer = buffer.subarray(offset - KEEP_BYTES); + offset = KEEP_BYTES; + } + } + }); + + // 处理流结束 + stream.on('end', () => { + if (!found) { + resolve(undefined); + } + }); + }); + } +} diff --git a/packages/snowcat-image-size/src/parser/PngParser.ts b/packages/snowcat-image-size/src/parser/PngParser.ts new file mode 100644 index 0000000..5bf37fe --- /dev/null +++ b/packages/snowcat-image-size/src/parser/PngParser.ts @@ -0,0 +1,32 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/snowcat-image-size/src'; +import { ReadStream } from 'fs'; + +// PNG解析器 +export class PngParser implements ImageParser { + readonly type = ImageType.PNG; + // PNG 魔术头:89 50 4E 47 0D 0A 1A 0A + private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + canParse (buffer: Buffer): boolean { + return matchMagic(buffer, this.PNG_SIGNATURE); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + stream.once('error', reject); + stream.once('readable', () => { + const buf = stream.read(24) as Buffer; + if (!buf || buf.length < 24) { + return resolve(undefined); + } + if (this.canParse(buf)) { + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + resolve({ width, height }); + } else { + resolve(undefined); + } + }); + }); + } +} diff --git a/packages/snowcat-image-size/src/parser/TiffParser.ts b/packages/snowcat-image-size/src/parser/TiffParser.ts new file mode 100644 index 0000000..734da76 --- /dev/null +++ b/packages/snowcat-image-size/src/parser/TiffParser.ts @@ -0,0 +1,124 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/snowcat-image-size/src'; +import { ReadStream } from 'fs'; + +// TIFF解析器 +export class TiffParser implements ImageParser { + readonly type = ImageType.TIFF; + // TIFF Little Endian 魔术头:49 49 2A 00 (II) + private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00]; + // TIFF Big Endian 魔术头:4D 4D 00 2A (MM) + private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A]; + + canParse (buffer: Buffer): boolean { + return ( + matchMagic(buffer, this.TIFF_LE_SIGNATURE) || + matchMagic(buffer, this.TIFF_BE_SIGNATURE) + ); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + const MAX_BYTES = 64 * 1024; // 最多读取 64KB + + stream.on('error', reject); + + stream.on('data', (chunk: Buffer | string) => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(chunkBuffer); + totalBytes += chunkBuffer.length; + + if (totalBytes >= MAX_BYTES) { + stream.destroy(); + } + }); + + stream.on('end', () => { + const buffer = Buffer.concat(chunks); + const size = this.parseTiffSize(buffer); + resolve(size); + }); + + stream.on('close', () => { + if (chunks.length > 0) { + const buffer = Buffer.concat(chunks); + const size = this.parseTiffSize(buffer); + resolve(size); + } + }); + }); + } + + private parseTiffSize (buffer: Buffer): ImageSize | undefined { + if (buffer.length < 8) { + return undefined; + } + + // 判断字节序 + const isLittleEndian = buffer[0] === 0x49; // 'I' + + const readUInt16 = isLittleEndian + ? (offset: number) => buffer.readUInt16LE(offset) + : (offset: number) => buffer.readUInt16BE(offset); + + const readUInt32 = isLittleEndian + ? (offset: number) => buffer.readUInt32LE(offset) + : (offset: number) => buffer.readUInt32BE(offset); + + // 获取第一个 IFD 的偏移量 + const ifdOffset = readUInt32(4); + if (ifdOffset + 2 > buffer.length) { + return undefined; + } + + // 读取 IFD 条目数量 + const numEntries = readUInt16(ifdOffset); + let width: number | undefined; + let height: number | undefined; + + // TIFF 标签 + const TAG_IMAGE_WIDTH = 0x0100; + const TAG_IMAGE_HEIGHT = 0x0101; + + // 遍历 IFD 条目 + for (let i = 0; i < numEntries; i++) { + const entryOffset = ifdOffset + 2 + i * 12; + if (entryOffset + 12 > buffer.length) { + break; + } + + const tag = readUInt16(entryOffset); + const type = readUInt16(entryOffset + 2); + // const count = readUInt32(entryOffset + 4); + + // 根据类型读取值 + let value: number; + if (type === 3) { + // SHORT (2 bytes) + value = readUInt16(entryOffset + 8); + } else if (type === 4) { + // LONG (4 bytes) + value = readUInt32(entryOffset + 8); + } else { + continue; + } + + if (tag === TAG_IMAGE_WIDTH) { + width = value; + } else if (tag === TAG_IMAGE_HEIGHT) { + height = value; + } + + if (width !== undefined && height !== undefined) { + return { width, height }; + } + } + + if (width !== undefined && height !== undefined) { + return { width, height }; + } + + return undefined; + } +} diff --git a/packages/snowcat-image-size/src/parser/WebpParser.ts b/packages/snowcat-image-size/src/parser/WebpParser.ts new file mode 100644 index 0000000..526dbec --- /dev/null +++ b/packages/snowcat-image-size/src/parser/WebpParser.ts @@ -0,0 +1,90 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/snowcat-image-size/src'; +import { ReadStream } from 'fs'; + +// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式 +export class WebpParser implements ImageParser { + readonly type = ImageType.WEBP; + // WEBP RIFF 头:52 49 46 46 (RIFF) + private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46]; + // WEBP 魔术头:57 45 42 50 (WEBP) + private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50]; + + // WEBP 块头 + private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 " + private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L" + private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X" + + canParse (buffer: Buffer): boolean { + return ( + buffer.length >= 12 && + matchMagic(buffer, this.RIFF_SIGNATURE, 0) && + matchMagic(buffer, this.WEBP_SIGNATURE, 8) + ); + } + + isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean { + return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + // 需要读取足够的字节来检测所有三种格式 + const MAX_HEADER_SIZE = 32; + let totalBytes = 0; + let buffer = Buffer.alloc(0); + + stream.on('error', reject); + + stream.on('data', (chunk: Buffer | string) => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + buffer = Buffer.concat([buffer, chunkBuffer]); + totalBytes += chunk.length; + + // 检查是否有足够的字节进行格式检测 + if (totalBytes >= MAX_HEADER_SIZE) { + stream.destroy(); + + // 检查基本的WEBP签名 + if (!this.canParse(buffer)) { + return resolve(undefined); + } + + // 检查chunk头部,位于字节12-15 + if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) { + // VP8格式 - 标准WebP + // 宽度和高度在帧头中 + const width = buffer.readUInt16LE(26) & 0x3FFF; + const height = buffer.readUInt16LE(28) & 0x3FFF; + return resolve({ width, height }); + } else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) { + // VP8L格式 - 无损WebP + // 1字节标记后是14位宽度和14位高度 + const bits = buffer.readUInt32LE(21); + const width = 1 + (bits & 0x3FFF); + const height = 1 + ((bits >> 14) & 0x3FFF); + return resolve({ width, height }); + } else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) { + // VP8X格式 - 扩展WebP + // 24位宽度和高度(减去1) + if (buffer.length < 30) { + return resolve(undefined); + } + const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF); + const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF); + return resolve({ width, height }); + } else { + // 未知的WebP子格式 + return resolve(undefined); + } + } + }); + + stream.on('end', () => { + // 如果没有读到足够的字节 + if (totalBytes < MAX_HEADER_SIZE) { + resolve(undefined); + } + }); + }); + } +} diff --git a/packages/snowcat-image-size/tsconfig.json b/packages/snowcat-image-size/tsconfig.json new file mode 100644 index 0000000..473a385 --- /dev/null +++ b/packages/snowcat-image-size/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-napi-loader/napiLoader-debug.bat b/packages/snowcat-napi-loader/napiLoader-debug.bat new file mode 100644 index 0000000..89f66bd --- /dev/null +++ b/packages/snowcat-napi-loader/napiLoader-debug.bat @@ -0,0 +1,30 @@ +@echo off +chcp 65001 +set NAPCAT_INJECT_PATH=%cd%\napiloader.dll +set NAPCAT_LAUNCHER_PATH=%cd%\napimain.exe +set NAPCAT_MAIN_PATH=%cd%\nativeLoader.cjs +set NAPCAT_DEBUG_CONSOLE=1 +:loop_read +for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( + set "RetString=%%~b" + goto :napcat_boot +) + +:napcat_boot +for %%a in ("%RetString%") do ( + set "pathWithoutUninstall=%%~dpa" +) + +set "QQPath=%pathWithoutUninstall%QQ.exe" + +if not exist "%QQpath%" ( + echo provided QQ path is invalid + pause + exit /b +) + +set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% + +"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" "%NAPCAT_MAIN_PATH%" + +pause \ No newline at end of file diff --git a/packages/snowcat-napi-loader/napiLoader.bat b/packages/snowcat-napi-loader/napiLoader.bat new file mode 100644 index 0000000..7fecb17 --- /dev/null +++ b/packages/snowcat-napi-loader/napiLoader.bat @@ -0,0 +1,27 @@ +@echo off +chcp 65001 +set NAPCAT_INJECT_PATH=%cd%\napiloader.dll +set NAPCAT_LAUNCHER_PATH=%cd%\napimain.exe +set NAPCAT_MAIN_PATH=%cd%\nativeLoader.cjs +:loop_read +for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( + set "RetString=%%~b" + goto :napcat_boot +) + +:napcat_boot +for %%a in ("%RetString%") do ( + set "pathWithoutUninstall=%%~dpa" +) + +set "QQPath=%pathWithoutUninstall%QQ.exe" + +if not exist "%QQpath%" ( + echo provided QQ path is invalid + pause + exit /b +) + +set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% + +start "" "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" "%NAPCAT_MAIN_PATH%" \ No newline at end of file diff --git a/packages/snowcat-napi-loader/napiloader.dll b/packages/snowcat-napi-loader/napiloader.dll new file mode 100644 index 0000000..31dbe43 Binary files /dev/null and b/packages/snowcat-napi-loader/napiloader.dll differ diff --git a/packages/snowcat-native/dpapi/win32-arm64/@primno+dpapi.node b/packages/snowcat-native/dpapi/win32-arm64/@primno+dpapi.node new file mode 100644 index 0000000..8edda4f Binary files /dev/null and b/packages/snowcat-native/dpapi/win32-arm64/@primno+dpapi.node differ diff --git a/packages/snowcat-native/dpapi/win32-x64/@primno+dpapi.node b/packages/snowcat-native/dpapi/win32-x64/@primno+dpapi.node new file mode 100644 index 0000000..b9c3c50 Binary files /dev/null and b/packages/snowcat-native/dpapi/win32-x64/@primno+dpapi.node differ diff --git a/packages/snowcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node b/packages/snowcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node new file mode 100644 index 0000000..86df801 Binary files /dev/null and b/packages/snowcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node differ diff --git a/packages/snowcat-native/ffmpeg/ffmpegAddon.linux.arm64.node b/packages/snowcat-native/ffmpeg/ffmpegAddon.linux.arm64.node new file mode 100644 index 0000000..2a7d07a Binary files /dev/null and b/packages/snowcat-native/ffmpeg/ffmpegAddon.linux.arm64.node differ diff --git a/packages/snowcat-native/ffmpeg/ffmpegAddon.linux.x64.node b/packages/snowcat-native/ffmpeg/ffmpegAddon.linux.x64.node new file mode 100644 index 0000000..f08ec0c Binary files /dev/null and b/packages/snowcat-native/ffmpeg/ffmpegAddon.linux.x64.node differ diff --git a/packages/snowcat-native/ffmpeg/ffmpegAddon.win32.x64.node b/packages/snowcat-native/ffmpeg/ffmpegAddon.win32.x64.node new file mode 100644 index 0000000..b9b5c92 Binary files /dev/null and b/packages/snowcat-native/ffmpeg/ffmpegAddon.win32.x64.node differ diff --git a/packages/snowcat-native/napi2native/ffmpeg.dll b/packages/snowcat-native/napi2native/ffmpeg.dll new file mode 100644 index 0000000..1840944 Binary files /dev/null and b/packages/snowcat-native/napi2native/ffmpeg.dll differ diff --git a/packages/snowcat-native/napi2native/napi2native.darwin.arm64.node b/packages/snowcat-native/napi2native/napi2native.darwin.arm64.node new file mode 100644 index 0000000..4807861 Binary files /dev/null and b/packages/snowcat-native/napi2native/napi2native.darwin.arm64.node differ diff --git a/packages/snowcat-native/napi2native/napi2native.linux.arm64.node b/packages/snowcat-native/napi2native/napi2native.linux.arm64.node new file mode 100644 index 0000000..887e336 Binary files /dev/null and b/packages/snowcat-native/napi2native/napi2native.linux.arm64.node differ diff --git a/packages/snowcat-native/napi2native/napi2native.linux.x64.node b/packages/snowcat-native/napi2native/napi2native.linux.x64.node new file mode 100644 index 0000000..ddc20c9 Binary files /dev/null and b/packages/snowcat-native/napi2native/napi2native.linux.x64.node differ diff --git a/packages/snowcat-native/napi2native/napi2native.win32.x64.node b/packages/snowcat-native/napi2native/napi2native.win32.x64.node new file mode 100644 index 0000000..d276a61 Binary files /dev/null and b/packages/snowcat-native/napi2native/napi2native.win32.x64.node differ diff --git a/packages/snowcat-native/packet/MoeHoo.darwin.arm64.node b/packages/snowcat-native/packet/MoeHoo.darwin.arm64.node new file mode 100644 index 0000000..15fad35 Binary files /dev/null and b/packages/snowcat-native/packet/MoeHoo.darwin.arm64.node differ diff --git a/packages/snowcat-native/packet/MoeHoo.linux.arm64.node b/packages/snowcat-native/packet/MoeHoo.linux.arm64.node new file mode 100644 index 0000000..d83d35a Binary files /dev/null and b/packages/snowcat-native/packet/MoeHoo.linux.arm64.node differ diff --git a/packages/snowcat-native/packet/MoeHoo.linux.x64.node b/packages/snowcat-native/packet/MoeHoo.linux.x64.node new file mode 100644 index 0000000..e007d78 Binary files /dev/null and b/packages/snowcat-native/packet/MoeHoo.linux.x64.node differ diff --git a/packages/snowcat-native/packet/MoeHoo.win32.x64.node b/packages/snowcat-native/packet/MoeHoo.win32.x64.node new file mode 100644 index 0000000..2073340 Binary files /dev/null and b/packages/snowcat-native/packet/MoeHoo.win32.x64.node differ diff --git a/packages/snowcat-native/pty/linux.arm64/pty.node b/packages/snowcat-native/pty/linux.arm64/pty.node new file mode 100644 index 0000000..0dcd051 Binary files /dev/null and b/packages/snowcat-native/pty/linux.arm64/pty.node differ diff --git a/packages/snowcat-native/pty/linux.x64/pty.node b/packages/snowcat-native/pty/linux.x64/pty.node new file mode 100644 index 0000000..08961b7 Binary files /dev/null and b/packages/snowcat-native/pty/linux.x64/pty.node differ diff --git a/packages/snowcat-native/pty/win32.x64/conpty.node b/packages/snowcat-native/pty/win32.x64/conpty.node new file mode 100644 index 0000000..1722686 Binary files /dev/null and b/packages/snowcat-native/pty/win32.x64/conpty.node differ diff --git a/packages/snowcat-native/pty/win32.x64/conpty_console_list.node b/packages/snowcat-native/pty/win32.x64/conpty_console_list.node new file mode 100644 index 0000000..f7cba59 Binary files /dev/null and b/packages/snowcat-native/pty/win32.x64/conpty_console_list.node differ diff --git a/packages/snowcat-native/pty/win32.x64/pty.node b/packages/snowcat-native/pty/win32.x64/pty.node new file mode 100644 index 0000000..519db1c Binary files /dev/null and b/packages/snowcat-native/pty/win32.x64/pty.node differ diff --git a/packages/snowcat-native/pty/win32.x64/winpty.dll b/packages/snowcat-native/pty/win32.x64/winpty.dll new file mode 100644 index 0000000..55b4a14 Binary files /dev/null and b/packages/snowcat-native/pty/win32.x64/winpty.dll differ diff --git a/packages/snowcat-onebot/action/OneBotAction.ts b/packages/snowcat-onebot/action/OneBotAction.ts new file mode 100644 index 0000000..6705670 --- /dev/null +++ b/packages/snowcat-onebot/action/OneBotAction.ts @@ -0,0 +1,119 @@ +import { ActionName, BaseCheckResult } from './router'; +import { TypeCompiler, TypeCheck } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; +import { SnowCatCore } from 'snowcat-core'; +import { NapCatOneBot11Adapter, OB11Return } from '@/snowcat-onebot/index'; +import { NetworkAdapterConfig } from '../config/config'; +import { TSchema } from '@sinclair/typebox'; +import { StreamPacket, StreamPacketBasic, StreamStatus } from './stream/StreamBasic'; +export const ActionExamples = { + Common: { + errors: [ + { code: 1400, description: '请求参数错误或业务逻辑执行失败' }, + { code: 1401, description: '权限不足' }, + { code: 1404, description: '资源不存在' }, + ], + }, +}; + +export class OB11Response { + private static createResponse (data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return { + return { + status, + retcode, + data, + message, + wording: message, + echo, + stream: useStream ? 'stream-action' : 'normal-action', + }; + } + + static res (data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return { + return this.createResponse(data, status, retcode, message, echo, useStream); + } + + static ok (data: T, echo: unknown = null, useStream: boolean = false): OB11Return { + return this.createResponse(data, 'ok', 0, '', echo, useStream); + } + + static error (err: string, retcode: number, echo: unknown = null, useStream: boolean = false): OB11Return { + return this.createResponse(useStream ? { type: StreamStatus.Error, data_type: 'error' } : null, 'failed', retcode, err, echo, useStream); + } +} +export abstract class OneBotRequestToolkit { + abstract send (packet: StreamPacket): Promise; +} +export abstract class OneBotAction { + actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown; + core: SnowCatCore; + private validate?: TypeCheck = undefined; + payloadSchema?: TSchema = undefined; + returnSchema?: TSchema = undefined; + payloadExample?: unknown = undefined; + returnExample?: unknown = undefined; + actionSummary: string = ''; + actionDescription: string = ''; + actionTags: string[] = []; + obContext: NapCatOneBot11Adapter; + useStream: boolean = false; + errorExamples: Array<{ code: number, description: string; }> = ActionExamples.Common.errors; + + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + protected async check (payload: PayloadType): Promise { + if (!this.payloadSchema) return { valid: true }; + try { + this.validate = TypeCompiler.Compile(this.payloadSchema); + let data = payload; + data = Value.Parse(this.payloadSchema, data) as PayloadType; + if (typeof payload === 'object' && payload !== null && data !== null) { + Object.assign(payload, data); + } + } catch (e: any) { + return { valid: false, message: `Schema compilation error: ${e.message}` }; + } + if (this.validate && !this.validate.Check(payload)) { + const errors = [...this.validate.Errors(payload)]; + const errorMessages = errors.map(e => `Key: ${e.path.split('/').slice(1).join('.')}, Message: ${e.message}`); + return { + valid: false, + message: errorMessages.length > 0 ? errorMessages.join('\n') : '未知错误', + }; + } + return { valid: true }; + } + + public async handle (payload: PayloadType, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit = { send: async () => { } }, echo?: string): Promise> { + const result = await this.check(payload); + if (!result.valid) { + return OB11Response.error(result.message, 400); + } + try { + const resData = await this._handle(payload, adaptername, config, req); + return OB11Response.ok(resData, echo, this.useStream); + } catch (e: unknown) { + this.core.context.logger.logError('发生错误', e); + return OB11Response.error((e as Error).message.toString() || (e as Error)?.stack?.toString() || '未知错误,可能操作超时', 200, echo, this.useStream); + } + } + + public async websocketHandle (payload: PayloadType, echo: unknown, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit = { send: async () => { } }): Promise> { + const result = await this.check(payload); + if (!result.valid) { + return OB11Response.error(result.message, 1400, echo, this.useStream); + } + try { + const resData = await this._handle(payload, adaptername, config, req); + return OB11Response.ok(resData, echo, this.useStream); + } catch (e: unknown) { + this.core.context.logger.logError('发生错误', e); + return OB11Response.error(((e as Error).message.toString() || (e as Error).stack?.toString()) ?? 'Error', 1200, echo, this.useStream); + } + } + + abstract _handle (payload: PayloadType, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise; +} diff --git a/packages/snowcat-onebot/action/auto-register.ts b/packages/snowcat-onebot/action/auto-register.ts new file mode 100644 index 0000000..705ca02 --- /dev/null +++ b/packages/snowcat-onebot/action/auto-register.ts @@ -0,0 +1,6 @@ +import { OneBotAction } from './OneBotAction'; +export const AutoRegisterRouter: Array OneBotAction> = []; + +export function ActionHandler (target: new (...args: any[]) => OneBotAction) { + AutoRegisterRouter.push(target); +} diff --git a/packages/snowcat-onebot/action/example/ExtendsActionsExamples.ts b/packages/snowcat-onebot/action/example/ExtendsActionsExamples.ts new file mode 100644 index 0000000..d5300ff --- /dev/null +++ b/packages/snowcat-onebot/action/example/ExtendsActionsExamples.ts @@ -0,0 +1,53 @@ +export const ExtendsActionsExamples = { + OCRImage: { + payload: { image: 'image_id_123' }, + response: { texts: [{ text: '识别内容', coordinates: [] }] }, + }, + GetAiCharacters: { + payload: { group_id: '123456' }, + response: [ + { + type: 'string', + characters: [ + { character_id: 'id', character_name: 'name', preview_url: 'url' }, + ], + }, + ], + }, + GetClientkey: { + payload: {}, + response: { clientkey: 'abcdef123456' }, + }, + SetQQAvatar: { + payload: { file: 'base64://...' }, + response: null, + }, + SetGroupKickMembers: { + payload: { group_id: '123456', user_id: ['123456789'], reject_add_request: false }, + response: null, + }, + TranslateEnWordToZn: { + payload: { words: ['hello'] }, + response: { words: ['你好'] }, + }, + GetRkey: { + payload: {}, + response: { rkey: '...' }, + }, + SetLongNick: { + payload: { longNick: '个性签名' }, + response: null, + }, + SetSpecialTitle: { + payload: { group_id: '123456', user_id: '123456789', special_title: '头衔' }, + response: null, + }, + SendQzoneMsg: { + payload: { content: '今天天气不错', images: [], ugc_right: 1, target_uins: [] }, + response: { tid: '1234567890abcdef1234567890' }, + }, + DeleteQzoneMsg: { + payload: { tid: '1234567890abcdef1234567890' }, + response: null, + }, +}; diff --git a/packages/snowcat-onebot/action/example/FileActionsExamples.ts b/packages/snowcat-onebot/action/example/FileActionsExamples.ts new file mode 100644 index 0000000..9db7e97 --- /dev/null +++ b/packages/snowcat-onebot/action/example/FileActionsExamples.ts @@ -0,0 +1,22 @@ +export const FileActionsExamples = { + GetFile: { + payload: { file: 'file_id_123' }, + response: { file: '/path/to/file', url: 'http://...', file_size: 1024, file_name: 'test.jpg' }, + }, + GetGroupFileUrl: { + payload: { group_id: '123456', file_id: 'file_id_123', busid: 102 }, + response: { url: 'http://...' }, + }, + GetImage: { + payload: { file: 'image_id_123' }, + response: { file: '/path/to/image', url: 'http://...' }, + }, + GetPrivateFileUrl: { + payload: { user_id: '123456789', file_id: 'file_id_123' }, + response: { url: 'http://...' }, + }, + GetRecord: { + payload: { file: 'record_id_123', out_format: 'mp3' }, + response: { file: '/path/to/record', url: 'http://...' }, + }, +}; diff --git a/packages/snowcat-onebot/action/example/GoCQHTTPActionsExamples.ts b/packages/snowcat-onebot/action/example/GoCQHTTPActionsExamples.ts new file mode 100644 index 0000000..32c1960 --- /dev/null +++ b/packages/snowcat-onebot/action/example/GoCQHTTPActionsExamples.ts @@ -0,0 +1,102 @@ +export const GoCQHTTPActionsExamples = { + GetStrangerInfo: { + payload: { user_id: '123456789' }, + response: { user_id: 123456789, nickname: '昵称', sex: 'unknown' }, + }, + GetGroupHonorInfo: { + payload: { group_id: '123456', type: 'all' }, + response: { group_id: 123456, current_talkative: {}, talkative_list: [] }, + }, + GetForwardMsg: { + payload: { message_id: '123456' }, + response: { messages: [] }, + }, + SendForwardMsg: { + payload: { group_id: '123456', messages: [] }, + response: { message_id: 123456 }, + }, + GetGroupAtAllRemain: { + payload: { group_id: '123456' }, + response: { can_at_all: true, remain_at_all_count_for_group: 10, remain_at_all_count_for_self: 10 }, + }, + CreateGroupFileFolder: { + payload: { group_id: '123456', name: '测试目录' }, + response: { result: {}, groupItem: {} }, + }, + DeleteGroupFile: { + payload: { group_id: '123456', file_id: 'file_uuid_123' }, + response: {}, + }, + DeleteGroupFileFolder: { + payload: { group_id: '123456', folder_id: 'folder_uuid_123' }, + response: {}, + }, + DownloadFile: { + payload: { url: 'https://example.com/file.png', thread_count: 1, headers: 'User-Agent: NapCat' }, + response: { file: '/path/to/downloaded/file' }, + }, + GetFriendMsgHistory: { + payload: { user_id: '123456789', message_seq: 0, count: 20 }, + response: { messages: [] }, + }, + GetGroupFilesByFolder: { + payload: { group_id: '123456', folder_id: 'folder_id' }, + response: { files: [], folders: [] }, + }, + GetGroupFileSystemInfo: { + payload: { group_id: '123456' }, + response: { file_count: 10, limit_count: 10000, used_space: 1024, total_space: 10737418240 }, + }, + GetGroupMsgHistory: { + payload: { group_id: '123456', message_seq: 0, count: 20 }, + response: { messages: [] }, + }, + GetGroupRootFiles: { + payload: { group_id: '123456' }, + response: { files: [], folders: [] }, + }, + GetOnlineClient: { + payload: { no_cache: false }, + response: [], + }, + GoCQHTTPCheckUrlSafely: { + payload: { url: 'https://example.com' }, + response: { level: 1 }, + }, + GoCQHTTPDeleteFriend: { + payload: { user_id: '123456789' }, + response: {}, + }, + GoCQHTTPGetModelShow: { + payload: { model: 'iPhone 13' }, + response: { variants: [] }, + }, + GoCQHTTPSetModelShow: { + payload: { model: 'iPhone 13', model_show: 'iPhone 13' }, + response: {}, + }, + QuickAction: { + payload: { context: {}, operation: {} }, + response: {}, + }, + SendGroupNotice: { + payload: { group_id: '123456', content: '公告内容', image: 'base64://...' }, + response: {}, + }, + SetGroupPortrait: { + payload: { group_id: '123456', file: 'base64://...' }, + response: { result: 0, errMsg: '' }, + }, + SetQQProfile: { + payload: { nickname: '新昵称', personal_note: '个性签名' }, + response: {}, + }, + UploadGroupFile: { + payload: { group_id: '123456', file: '/path/to/file', name: 'test.txt' }, + response: { file_id: 'file_uuid_123' }, + }, + UploadPrivateFile: { + payload: { user_id: '123456789', file: '/path/to/file', name: 'test.txt' }, + response: { file_id: 'file_uuid_123' }, + }, +}; diff --git a/packages/snowcat-onebot/action/example/GroupActionsExamples.ts b/packages/snowcat-onebot/action/example/GroupActionsExamples.ts new file mode 100644 index 0000000..689313b --- /dev/null +++ b/packages/snowcat-onebot/action/example/GroupActionsExamples.ts @@ -0,0 +1,96 @@ +export const GroupActionsExamples = { + SetGroupMemberInvitePolicy: { + payload: { group_id: '123456', policy: 'require_approval' }, + response: null, + }, + SetGroupMemberPermissions: { + payload: { + group_id: '123456', + allow_member_upload_album: true, + allow_member_temporary_session: true, + allow_member_create_group: true, + }, + response: null, + }, + SetGroupNewMemberHistoryVisibility: { + payload: { group_id: '123456', visible: true }, + response: null, + }, + DelEssenceMsg: { + payload: { message_id: 123456 }, + response: null, + }, + DelGroupNotice: { + payload: { group_id: '123456', notice_id: 'notice_123' }, + response: null, + }, + GetGroupDetailInfo: { + payload: { group_id: '123456' }, + response: { group_id: 123456, group_name: '测试群', member_count: 100, max_member_count: 500 }, + }, + GetGroupEssence: { + payload: { group_id: '123456' }, + response: [{ message_id: 123456, sender_id: 123456, sender_nick: '昵称', operator_id: 123456, operator_nick: '昵称', operator_time: 1710000000, content: '精华内容' }], + }, + GetGroupInfo: { + payload: { group_id: '123456' }, + response: { group_id: 123456, group_name: '测试群', member_count: 100, max_member_count: 500 }, + }, + GetGroupList: { + payload: {}, + response: [{ group_id: 123456, group_name: '测试群', member_count: 100, max_member_count: 500 }], + }, + GetGroupMemberInfo: { + payload: { group_id: '123456', user_id: '123456789' }, + response: { group_id: 123456, user_id: 123456789, nickname: '昵称', card: '名片', role: 'member' }, + }, + GetGroupMemberList: { + payload: { group_id: '123456' }, + response: [{ group_id: 123456, user_id: 123456789, nickname: '昵称', card: '名片', role: 'member' }], + }, + GetGroupNotice: { + payload: { group_id: '123456' }, + response: [{ notice_id: 'notice_123', sender_id: 123456, publish_time: 1710000000, message: { text: '公告内容', image: [] } }], + }, + SendGroupMsg: { + payload: { group_id: '123456', message: 'hello' }, + response: { message_id: 123456 }, + }, + SetEssenceMsg: { + payload: { message_id: 123456 }, + response: null, + }, + SetGroupAddRequest: { + payload: { flag: 'flag_123', sub_type: 'add', approve: true }, + response: null, + }, + SetGroupAdmin: { + payload: { group_id: '123456', user_id: '123456789', enable: true }, + response: null, + }, + SetGroupBan: { + payload: { group_id: '123456', user_id: '123456789', duration: 1800 }, + response: null, + }, + SetGroupCard: { + payload: { group_id: '123456', user_id: '123456789', card: '新名片' }, + response: null, + }, + SetGroupKick: { + payload: { group_id: '123456', user_id: '123456789', reject_add_request: false }, + response: null, + }, + SetGroupLeave: { + payload: { group_id: '123456', is_dismiss: false }, + response: null, + }, + SetGroupName: { + payload: { group_id: '123456', group_name: '新群名' }, + response: null, + }, + SetGroupWholeBan: { + payload: { group_id: '123456', enable: true }, + response: null, + }, + +}; diff --git a/packages/snowcat-onebot/action/example/GuildActionsExamples.ts b/packages/snowcat-onebot/action/example/GuildActionsExamples.ts new file mode 100644 index 0000000..2e0a1fa --- /dev/null +++ b/packages/snowcat-onebot/action/example/GuildActionsExamples.ts @@ -0,0 +1,10 @@ +export const GuildActionsExamples = { + GetGuildList: { + payload: {}, + response: [{ guild_id: '123456', guild_name: '测试频道' }], + }, + GetGuildProfile: { + payload: { guild_id: '123456' }, + response: { guild_id: '123456', guild_name: '测试频道', guild_display_id: '123' }, + }, +}; diff --git a/packages/snowcat-onebot/action/example/NewActionsExamples.ts b/packages/snowcat-onebot/action/example/NewActionsExamples.ts new file mode 100644 index 0000000..6d21879 --- /dev/null +++ b/packages/snowcat-onebot/action/example/NewActionsExamples.ts @@ -0,0 +1,10 @@ +export const NewActionsExamples = { + GetDoubtFriendsAddRequest: { + payload: { count: 10 }, + response: [{ user_id: 123456789, nickname: '昵称', age: 20, sex: 'male', reason: '申请理由', flag: 'flag_123' }], + }, + SetDoubtFriendsAddRequest: { + payload: { flag: 'flag_123', approve: true }, + response: {}, + }, +}; diff --git a/packages/snowcat-onebot/action/example/PacketActionsExamples.ts b/packages/snowcat-onebot/action/example/PacketActionsExamples.ts new file mode 100644 index 0000000..c933c7d --- /dev/null +++ b/packages/snowcat-onebot/action/example/PacketActionsExamples.ts @@ -0,0 +1,22 @@ +export const PacketActionsExamples = { + GetPacketStatus: { + payload: {}, + response: { status: 'ok' }, + }, + SendPoke: { + payload: { user_id: '123456789' }, + response: {}, + }, + SetGroupTodo: { + payload: { group_id: '123456', message_id: '123456789' }, + response: {}, + }, + CompleteGroupTodo: { + payload: { group_id: '123456', message_id: '123456789' }, + response: {}, + }, + CancelGroupTodo: { + payload: { group_id: '123456', message_id: '123456789' }, + response: {}, + }, +}; diff --git a/packages/snowcat-onebot/action/example/SystemActionsExamples.ts b/packages/snowcat-onebot/action/example/SystemActionsExamples.ts new file mode 100644 index 0000000..cc5636e --- /dev/null +++ b/packages/snowcat-onebot/action/example/SystemActionsExamples.ts @@ -0,0 +1,42 @@ +export const SystemActionsExamples = { + CanSendImage: { + payload: {}, + response: { yes: true }, + }, + CanSendRecord: { + payload: {}, + response: { yes: true }, + }, + CleanCache: { + payload: {}, + response: {}, + }, + GetCredentials: { + payload: {}, + response: { cookies: '...', csrf_token: 123456789 }, + }, + GetCSRF: { + payload: {}, + response: { token: 123456789 }, + }, + GetLoginInfo: { + payload: {}, + response: { user_id: 123456789, nickname: '机器人' }, + }, + GetStatus: { + payload: {}, + response: { online: true, good: true }, + }, + GetSystemMsg: { + payload: {}, + response: { invited_requests: [], join_requests: [] }, + }, + GetVersionInfo: { + payload: {}, + response: { app_name: 'SnowCatQQ', app_version: '1.0.0', protocol_version: 'v11' }, + }, + SetRestart: { + payload: { delay: 0 }, + response: {}, + }, +}; diff --git a/packages/snowcat-onebot/action/example/UserActionsExamples.ts b/packages/snowcat-onebot/action/example/UserActionsExamples.ts new file mode 100644 index 0000000..558637a --- /dev/null +++ b/packages/snowcat-onebot/action/example/UserActionsExamples.ts @@ -0,0 +1,38 @@ +export const UserActionsExamples = { + GetCookies: { + payload: { domain: 'qun.qq.com' }, + response: { cookies: 'p_skey=xxx; p_uin=o0123456789;' }, + }, + GetFriendList: { + payload: {}, + response: [{ user_id: 123456789, nickname: '昵称', remark: '备注' }], + }, + GetRecentContact: { + payload: { count: 10 }, + response: [ + { + lastestMsg: 'hello', + peerUin: '123456789', + remark: 'remark', + msgTime: '1710000000', + chatType: 1, + msgId: '12345', + sendNickName: 'nick', + sendMemberName: 'card', + peerName: 'name', + }, + ], + }, + SendLike: { + payload: { user_id: '123456789', times: 10 }, + response: {}, + }, + SetFriendAddRequest: { + payload: { flag: 'flag_123', approve: true, remark: '好友' }, + response: {}, + }, + SetFriendRemark: { + payload: { user_id: '123456789', remark: '新备注' }, + response: {}, + }, +}; diff --git a/packages/snowcat-onebot/action/extends/BotExit.ts b/packages/snowcat-onebot/action/extends/BotExit.ts new file mode 100644 index 0000000..9a30fe2 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/BotExit.ts @@ -0,0 +1,17 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '../OneBotAction'; +import { Type } from '@sinclair/typebox'; + +export class BotExit extends OneBotAction { + override actionName = ActionName.Exit; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Object({}); + override actionSummary = '退出登录'; + override actionTags = ['系统扩展']; + override payloadExample = {}; + override returnExample = null; + + async _handle () { + process.exit(0); + } +} diff --git a/packages/snowcat-onebot/action/extends/ClickInlineKeyboardButton.ts b/packages/snowcat-onebot/action/extends/ClickInlineKeyboardButton.ts new file mode 100644 index 0000000..5ea4591 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/ClickInlineKeyboardButton.ts @@ -0,0 +1,47 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '../OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + bot_appid: Type.String({ description: '机器人AppID' }), + button_id: Type.String({ default: '', description: '按钮ID' }), + callback_data: Type.String({ default: '', description: '回调数据' }), + msg_seq: Type.String({ default: '10086', description: '消息序列号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '点击结果' }); + +type ReturnType = Static; + +export class ClickInlineKeyboardButton extends OneBotAction { + override actionName = ActionName.ClickInlineKeyboardButton; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '点击内联键盘按钮'; + override actionTags = ['消息扩展']; + override payloadExample = { + group_id: '123456', + bot_appid: '1234567890', + button_id: 'btn_1', + callback_data: '', + msg_seq: '10086', + }; + + override returnExample = { + }; + + async _handle (payload: PayloadType) { + return await this.core.apis.MsgApi.clickInlineKeyboardButton({ + buttonId: payload.button_id, + peerId: payload.group_id.toString(), + botAppid: payload.bot_appid, + msgSeq: payload.msg_seq, + callback_data: payload.callback_data, + dmFlag: 0, + chatType: 2, + }); + } +} diff --git a/packages/snowcat-onebot/action/extends/CreateCollection.ts b/packages/snowcat-onebot/action/extends/CreateCollection.ts new file mode 100644 index 0000000..0bed91b --- /dev/null +++ b/packages/snowcat-onebot/action/extends/CreateCollection.ts @@ -0,0 +1,40 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + rawData: Type.String({ description: '原始数据' }), + brief: Type.String({ description: '简要描述' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '创建结果' }); + +type ReturnType = Static; + +export class CreateCollection extends OneBotAction { + override actionName = ActionName.CreateCollection; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '创建收藏'; + override actionTags = ['扩展接口']; + override payloadExample = { + rawData: '收藏内容', + brief: '收藏标题', + }; + + override returnExample = { + result: 0, + errMsg: '', + }; + + async _handle (payload: PayloadType) { + return await this.core.apis.CollectionApi.createCollection( + this.core.selfInfo.uin, + this.core.selfInfo.uid, + this.core.selfInfo.nick, + payload.brief, payload.rawData + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/CustomFace.ts b/packages/snowcat-onebot/action/extends/CustomFace.ts new file mode 100644 index 0000000..76f9028 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/CustomFace.ts @@ -0,0 +1,162 @@ +import { createHash } from 'node:crypto'; +import { stat, readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { Type, Static } from '@sinclair/typebox'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; + +const FetchCustomFaceDetailPayloadSchema = Type.Object({ + count: Type.Union([Type.Number(), Type.String()], { default: 48, description: '获取数量' }), +}); + +type FetchCustomFaceDetailPayloadType = Static; + +const FetchCustomFaceDetailReturnSchema = Type.Any({ description: '自定义表情详情列表' }); + +type FetchCustomFaceDetailReturnType = Static; + +export class FetchCustomFaceDetail extends OneBotAction { + override actionName = ActionName.FetchCustomFaceDetail; + override payloadSchema = FetchCustomFaceDetailPayloadSchema; + override returnSchema = FetchCustomFaceDetailReturnSchema; + override actionSummary = '获取自定义表情详情'; + override actionTags = ['系统扩展']; + override payloadExample = { + count: 10, + }; + + override returnExample = null; + + async _handle (payload: FetchCustomFaceDetailPayloadType) { + const ret = await this.core.apis.MsgApi.fetchFavEmojiList(+payload.count); + return ret.emojiInfoList; + } +} + +const AddCustomFacePayloadSchema = Type.Object({ + file: Type.String({ description: '本地表情文件路径' }), + emoji_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: '表情ID,未提供时传空字符串' })), + package_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: '表情包ID,未提供时传0' })), + file_name: Type.Optional(Type.String({ description: '文件名,未提供时从file路径取basename' })), + file_size: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: '文件大小,未提供时读取本地文件' })), + md5: Type.Optional(Type.String({ description: '文件MD5,未提供时读取本地文件计算' })), + is_mark_face: Type.Optional(Type.Boolean({ description: '是否商城表情' })), + is_origin: Type.Optional(Type.Boolean({ description: '是否原图' })), +}); + +type AddCustomFacePayloadType = Static; + +const AddCustomFaceReturnSchema = Type.Any({ description: '添加结果' }); + +type AddCustomFaceReturnType = Static; + +export class AddCustomFace extends OneBotAction { + override actionName = ActionName.AddCustomFace; + override payloadSchema = AddCustomFacePayloadSchema; + override returnSchema = AddCustomFaceReturnSchema; + override actionSummary = '添加自定义表情'; + override actionTags = ['系统扩展']; + override payloadExample = { + file: 'C:\\Users\\Public\\face.png', + is_origin: true, + }; + + override returnExample = null; + + async _handle (payload: AddCustomFacePayloadType) { + const fileStat = await stat(payload.file); + const md5 = payload.md5 ?? createHash('md5').update(await readFile(payload.file)).digest('hex'); + return await this.core.apis.MsgApi.addFavEmoji({ + emojiId: payload.emoji_id?.toString() ?? '', + packageId: payload.package_id === undefined ? 0 : Number(payload.package_id), + emojiPath: payload.file, + fileSize: payload.file_size?.toString() ?? fileStat.size.toString(), + fileName: payload.file_name ?? basename(payload.file), + md5, + isMarkFace: payload.is_mark_face ?? false, + isOrigin: payload.is_origin ?? true, + }); + } +} + +const DeleteCustomFacePayloadSchema = Type.Object({ + res_id: Type.Optional(Type.Union([ + Type.String({ description: 'fetch_custom_face_detail返回的resId' }), + Type.Array(Type.String({ description: 'fetch_custom_face_detail返回的resId' })), + ])), + id: Type.Optional(Type.Union([ + Type.String({ description: 'native deleteFavEmoji字符串ID,通常为resId' }), + Type.Array(Type.String({ description: 'native deleteFavEmoji字符串ID,通常为resId' })), + ])), + ids: Type.Optional(Type.Array(Type.String({ description: 'native deleteFavEmoji字符串ID列表,通常为resId列表' }))), + md5: Type.Optional(Type.Union([ + Type.String({ description: '表情MD5,不能直接删除,请先通过fetch_custom_face_detail获取resId' }), + Type.Array(Type.String({ description: '表情MD5,不能直接删除,请先通过fetch_custom_face_detail获取resId' })), + ])), +}); + +type DeleteCustomFacePayloadType = Static; + +const DeleteCustomFaceReturnSchema = Type.Any({ description: '删除结果' }); + +type DeleteCustomFaceReturnType = Static; + +export class DeleteCustomFace extends OneBotAction { + override actionName = ActionName.DeleteCustomFace; + override payloadSchema = DeleteCustomFacePayloadSchema; + override returnSchema = DeleteCustomFaceReturnSchema; + override actionSummary = '删除自定义表情'; + override actionTags = ['系统扩展']; + override payloadExample = { + res_id: '2707600964_0_0_0_D8EAA70984B402EE10B0F33DB88F4173_0_0', + }; + + override returnExample = null; + + async _handle (payload: DeleteCustomFacePayloadType) { + const ids = payload.ids ?? + (Array.isArray(payload.res_id) ? payload.res_id : payload.res_id ? [payload.res_id] : undefined) ?? + (Array.isArray(payload.id) ? payload.id : payload.id ? [payload.id] : undefined) ?? + []; + if (ids.length === 0) throw new Error('res_id or ids is required'); + return await this.core.apis.MsgApi.deleteFavEmoji(ids); + } +} + +const SetCustomFaceDescPayloadSchema = Type.Object({ + emoji_id: Type.Union([Type.Number(), Type.String()], { description: '表情ID' }), + res_id: Type.String({ description: '资源ID' }), + md5: Type.String({ description: '表情MD5' }), + desc: Type.String({ description: '新的表情描述' }), +}); + +type SetCustomFaceDescPayloadType = Static; + +const SetCustomFaceDescReturnSchema = Type.Any({ description: '修改结果' }); + +type SetCustomFaceDescReturnType = Static; + +export class SetCustomFaceDesc extends OneBotAction { + override actionName = ActionName.SetCustomFaceDesc; + override payloadSchema = SetCustomFaceDescPayloadSchema; + override returnSchema = SetCustomFaceDescReturnSchema; + override actionSummary = '修改自定义表情描述'; + override actionTags = ['系统扩展']; + override payloadExample = { + emoji_id: 1, + res_id: 'resource-id', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + desc: '新的描述', + }; + + override returnExample = null; + + async _handle (payload: SetCustomFaceDescPayloadType) { + return await this.core.apis.MsgApi.modifyFavEmojiDesc([{ + emojiId: Number(payload.emoji_id), + resId: payload.res_id, + md5: payload.md5, + desc: payload.desc, + }]); + } +} diff --git a/packages/snowcat-onebot/action/extends/DelGroupAlbumMedia.ts b/packages/snowcat-onebot/action/extends/DelGroupAlbumMedia.ts new file mode 100644 index 0000000..f4d517d --- /dev/null +++ b/packages/snowcat-onebot/action/extends/DelGroupAlbumMedia.ts @@ -0,0 +1,41 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + album_id: Type.String({ description: '相册ID' }), + lloc: Type.String({ description: '媒体ID (lloc)' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '删除结果' }); + +type ReturnType = Static; + +export class DelGroupAlbumMedia extends OneBotAction { + override actionName = ActionName.DelGroupAlbumMedia; + override actionSummary = '删除群相册媒体'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + album_id: 'album_id_1', + lloc: 'media_id_1', + }; + + override returnExample = { + result: {}, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + return await this.core.apis.WebApi.deleteAlbumMediaByNTQQ( + payload.group_id, + payload.album_id, + payload.lloc + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/DeleteQzoneMsg.ts b/packages/snowcat-onebot/action/extends/DeleteQzoneMsg.ts new file mode 100644 index 0000000..9d09814 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/DeleteQzoneMsg.ts @@ -0,0 +1,26 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + tid: Type.String({ description: '说说 tid (来自 get_qzone_msg_list / send_qzone_msg)' }), +}); + +type PayloadType = Static; + +export class DeleteQzoneMsg extends OneBotAction { + override actionName = ActionName.DeleteQzoneMsg; + override actionSummary = '删除QQ空间说说'; + override actionDescription = '删除QQ空间(Qzone)的一条说说, 按 tid 删除'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.DeleteQzoneMsg.payload; + override returnExample = ExtendsActionsExamples.DeleteQzoneMsg.response; + + override payloadSchema = PayloadSchema; + override returnSchema = Type.Null(); + + async _handle (payload: PayloadType): Promise { + await this.core.apis.WebApi.deleteQzoneMsg(payload.tid); + } +} diff --git a/packages/snowcat-onebot/action/extends/DoGroupAlbumComment.ts b/packages/snowcat-onebot/action/extends/DoGroupAlbumComment.ts new file mode 100644 index 0000000..cd8a5fb --- /dev/null +++ b/packages/snowcat-onebot/action/extends/DoGroupAlbumComment.ts @@ -0,0 +1,40 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const DoGroupAlbumCommentPayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + album_id: Type.String({ description: '相册 ID' }), + lloc: Type.String({ description: '图片 ID' }), + content: Type.String({ description: '评论内容' }), +}); + +export type DoGroupAlbumCommentPayload = Static; + +export class DoGroupAlbumComment extends OneBotAction { + override actionName = ActionName.DoGroupAlbumComment; + override actionSummary = '发表群相册评论'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + album_id: 'album_id_1', + lloc: 'media_id_1', + content: '很有意思', + }; + + override returnExample = { + result: {}, + }; + + override payloadSchema = DoGroupAlbumCommentPayloadSchema; + override returnSchema = Type.Any({ description: '评论结果' }); + + async _handle (payload: DoGroupAlbumCommentPayload) { + return await this.core.apis.WebApi.doAlbumMediaPlainCommentByNTQQ( + payload.group_id, + payload.album_id, + payload.lloc, + payload.content + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/FetchCustomFace.ts b/packages/snowcat-onebot/action/extends/FetchCustomFace.ts new file mode 100644 index 0000000..7c74755 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/FetchCustomFace.ts @@ -0,0 +1,33 @@ +import { Type, Static } from '@sinclair/typebox'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; + +const PayloadSchema = Type.Object({ + count: Type.Union([Type.Number(), Type.String()], { default: 48, description: '获取数量' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(Type.String(), { description: '表情URL列表' }); + +type ReturnType = Static; + +export class FetchCustomFace extends OneBotAction { + override actionName = ActionName.FetchCustomFace; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取自定义表情'; + override actionTags = ['系统扩展']; + override payloadExample = { + count: 10, + }; + + override returnExample = [ + 'http://example.com/face1.png', + ]; + + async _handle (payload: PayloadType) { + const ret = await this.core.apis.MsgApi.fetchFavEmojiList(+payload.count); + return ret.emojiInfoList.map(e => e.url); + } +} diff --git a/packages/snowcat-onebot/action/extends/FetchEmojiLike.ts b/packages/snowcat-onebot/action/extends/FetchEmojiLike.ts new file mode 100644 index 0000000..0e19a3d --- /dev/null +++ b/packages/snowcat-onebot/action/extends/FetchEmojiLike.ts @@ -0,0 +1,71 @@ +import { Type, Static } from '@sinclair/typebox'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), + emojiId: Type.Union([Type.Number(), Type.String()], { description: '表情ID' }), + emojiType: Type.Union([Type.Number(), Type.String()], { description: '表情类型' }), + count: Type.Union([Type.Number(), Type.String()], { default: 20, description: '获取数量' }), + cookie: Type.String({ default: '', description: '分页Cookie' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + emojiLikesList: Type.Array(Type.Object({ + tinyId: Type.String({ description: 'TinyID' }), + nickName: Type.String({ description: '昵称' }), + headUrl: Type.String({ description: '头像URL' }), + }), { description: '表情回应列表' }), + cookie: Type.String({ description: '分页Cookie' }), + isLastPage: Type.Boolean({ description: '是否最后一页' }), + isFirstPage: Type.Boolean({ description: '是否第一页' }), + result: Type.Number({ description: '结果状态码' }), + errMsg: Type.String({ description: '错 误信息' }), +}, { description: '表情回应详情' }); + +type ReturnType = Static; + +export class FetchEmojiLike extends OneBotAction { + override actionName = ActionName.FetchEmojiLike; + override actionSummary = '获取表情点赞详情'; + override actionTags = ['消息扩展']; + override payloadExample = { + message_id: 12345, + emojiId: '123', + emojiType: 1, + count: 10, + cookie: '', + }; + + override returnExample = { + emojiLikesList: [ + { + tinyId: '123456', + nickName: '测试用户', + headUrl: 'http://example.com/avatar.png', + }, + ], + cookie: '', + isLastPage: true, + isFirstPage: true, + result: 0, + errMsg: '', + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType): Promise { + const msgIdPeer = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); + if (!msgIdPeer) throw new Error('消息不存在'); + const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(msgIdPeer.Peer, [msgIdPeer.MsgId])).msgList[0]; + if (!msg) throw new Error('消息不存在'); + const res = await this.core.apis.MsgApi.getMsgEmojiLikesList( + msgIdPeer.Peer, msg.msgSeq, payload.emojiId.toString(), payload.emojiType.toString(), payload.cookie, +payload.count + ); + return res; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetAiCharacters.ts b/packages/snowcat-onebot/action/extends/GetAiCharacters.ts new file mode 100644 index 0000000..a2d52dc --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetAiCharacters.ts @@ -0,0 +1,53 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Type, Static } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + chat_type: Type.Union([Type.Number(), Type.String()], { default: 1, description: '聊天类型' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array( + Type.Object({ + type: Type.String({ description: '角色类型' }), + characters: Type.Array( + Type.Object({ + character_id: Type.String({ description: '角色ID' }), + character_name: Type.String({ description: '角色名称' }), + preview_url: Type.String({ description: '预览URL' }), + }), + { description: '角色列表' } + ), + }), + { description: 'AI角色列表' } +); + +type ReturnType = Static; + +export class GetAiCharacters extends GetPacketStatusDepends { + override actionName = ActionName.GetAiCharacters; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取AI角色列表'; + override actionDescription = '获取群聊中的AI角色列表'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.GetAiCharacters.payload; + override returnExample = ExtendsActionsExamples.GetAiCharacters.response; + + async _handle (payload: PayloadType) { + const chatTypeNum = Number(payload.chat_type); + const rawList = await this.core.apis.PacketApi.pkt.operation.FetchAiVoiceList(+payload.group_id, chatTypeNum); + return rawList?.map((item) => ({ + type: item.category, + characters: item.voices.map((voice) => ({ + character_id: voice.voiceId, + character_name: voice.voiceDisplayName, + preview_url: voice.voiceExampleUrl, + })), + })) ?? []; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetClientkey.ts b/packages/snowcat-onebot/action/extends/GetClientkey.ts new file mode 100644 index 0000000..d7b2aec --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetClientkey.ts @@ -0,0 +1,26 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '../OneBotAction'; +import { Type, Static } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const ReturnSchema = Type.Object({ + clientkey: Type.Optional(Type.String({ description: '客户端Key' })), +}, { description: '获取ClientKey结果' }); + +type ReturnType = Static; + +export class GetClientkey extends OneBotAction { + override actionName = ActionName.GetClientkey; + override payloadSchema = Type.Object({}); + override returnSchema = ReturnSchema; + override actionSummary = '获取ClientKey'; + override actionDescription = '获取当前登录帐号的ClientKey'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.GetClientkey.payload; + override returnExample = ExtendsActionsExamples.GetClientkey.response; + + async _handle () { + return { clientkey: (await this.core.apis.UserApi.forceFetchClientKey()).clientKey }; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetCollectionList.ts b/packages/snowcat-onebot/action/extends/GetCollectionList.ts new file mode 100644 index 0000000..0b41ac6 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetCollectionList.ts @@ -0,0 +1,82 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + category: Type.String({ description: '分类ID' }), + count: Type.String({ default: '50', description: '获取数量' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '收藏列表' }); + +type ReturnType = Static; + +export class GetCollectionList extends OneBotAction { + override actionName = ActionName.GetCollectionList; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取收藏列表'; + override actionTags = ['系统扩展']; + override payloadExample = { + category: '0', + count: '50', + }; + + override returnExample = { + errCode: 0, + errMsg: '', + collectionSearchList: { + collectionItemList: [ + { + cid: '123456', + type: 8, + status: 1, + author: { + type: 2, + numId: '123456', + strId: '昵称', + groupId: '123456', + groupName: '群名', + uid: '123456', + }, + bid: 1, + category: 1, + createTime: '1769169157000', + collectTime: '1769413477691', + modifyTime: '1769413477691', + sequence: '1769413476735', + shareUrl: '', + customGroupId: 0, + securityBeat: false, + summary: { + textSummary: null, + linkSummary: null, + gallerySummary: null, + audioSummary: null, + videoSummary: null, + fileSummary: null, + locationSummary: null, + richMediaSummary: { + title: '', + subTitle: '', + brief: 'text', + picList: [], + contentType: 1, + originalUri: '', + publisher: '', + richMediaVersion: 0, + }, + }, + }, + ], + hasMore: false, + bottomTimeStamp: '1769413477691', + }, + }; + + async _handle (payload: PayloadType) { + return await this.core.apis.CollectionApi.getAllCollection(+payload.category, +payload.count); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetEmojiLikes.ts b/packages/snowcat-onebot/action/extends/GetEmojiLikes.ts new file mode 100644 index 0000000..0ad483b --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetEmojiLikes.ts @@ -0,0 +1,90 @@ +import { Type, Static } from '@sinclair/typebox'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Peer, ChatType } from '@/snowcat-core'; + +const PayloadSchema = Type.Object({ + group_id: Type.Optional(Type.String({ description: '群号,短ID可不传' })), + message_id: Type.String({ description: '消息ID,可以传递长ID或短ID' }), + emoji_id: Type.String({ description: '表情ID' }), + emoji_type: Type.Optional(Type.String({ description: '表情类型' })), + count: Type.Number({ default: 0, description: '数量,0代表全部' }), +}); +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + emoji_like_list: Type.Array( + Type.Object({ + user_id: Type.String({ description: '点击者QQ号' }), + nick_name: Type.String({ description: '昵称?' }), + }), + { description: '表情回应列表' } + ), +}); +type ReturnType = Static; + +export class GetEmojiLikes extends OneBotAction { + override actionName = ActionName.GetEmojiLikes; + override actionSummary = '获取消息表情点赞列表'; + override actionTags = ['消息扩展']; + override payloadExample = { + message_id: '12345', + emoji_id: '123', + }; + + override returnExample = { + emoji_like_list: [ + { + user_id: '654321', + nick_name: '测试用户', + }, + ], + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + let peer: Peer; + let msgId: string; + + if (MessageUnique.isShortId(payload.message_id)) { + const msgIdPeer = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); + if (!msgIdPeer) throw new Error('消息不存在'); + peer = msgIdPeer.Peer; + msgId = msgIdPeer.MsgId; + } else { + if (!payload.group_id) throw new Error('长ID模式下必须提供群号'); + peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id }; + msgId = payload.message_id; + } + + const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId])).msgList[0]; + if (!msg) throw new Error('消息不存在'); + + const emojiType = payload.emoji_type ?? (payload.emoji_id.length > 3 ? '2' : '1'); + const emojiLikeList: Array<{ user_id: string; nick_name: string; }> = []; + let cookie = ''; + const needFetchCount = payload.count === 0 ? 200 : Math.ceil(payload.count / 15); + for (let page = 0; page < needFetchCount; page++) { + const res = await this.core.apis.MsgApi.getMsgEmojiLikesList( + peer, msg.msgSeq, payload.emoji_id.toString(), emojiType, cookie, 15 + ); + + if (Array.isArray(res.emojiLikesList)) { + for (const like of res.emojiLikesList) { + emojiLikeList.push({ user_id: like.tinyId, nick_name: like.nickName }); + } + } + + if (res.isLastPage || !res.cookie) break; + cookie = res.cookie; + } + // 切断多余部分 + if (payload.count > 0) { + emojiLikeList.splice(payload.count); + } + return { emoji_like_list: emojiLikeList }; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetFriendWithCategory.ts b/packages/snowcat-onebot/action/extends/GetFriendWithCategory.ts new file mode 100644 index 0000000..f5fea54 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetFriendWithCategory.ts @@ -0,0 +1,42 @@ +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; +import { OB11UserSchema } from '../schemas'; + +const ReturnSchema = Type.Array( + Type.Object({ + categoryId: Type.Number({ description: '分组ID' }), + categoryName: Type.String({ description: '分组名称' }), + categoryMbCount: Type.Number({ description: '分组内好友数量' }), + buddyList: Type.Array(OB11UserSchema, { description: '好友列表' }), + }), + { description: '带分组的好友列表' } +); + +type ReturnType = Static; + +export class GetFriendWithCategory extends OneBotAction { + override actionName = ActionName.GetFriendsWithCategory; + override payloadSchema = Type.Object({}); + override returnSchema = ReturnSchema; + override actionSummary = '获取带分组的好友列表'; + override actionTags = ['用户扩展']; + override payloadExample = {}; + override returnExample = [ + { + categoryId: 1, + categoryName: '我的好友', + categoryMbCount: 1, + buddyList: [], + }, + ]; + + async _handle () { + const categories = await this.core.apis.FriendApi.getBuddyV2ExWithCate(); + return categories.map(category => ({ + ...category, + buddyList: OB11Construct.friends(category.buddyList), + })); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetGroupAddRequest.ts b/packages/snowcat-onebot/action/extends/GetGroupAddRequest.ts new file mode 100644 index 0000000..da04c5f --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetGroupAddRequest.ts @@ -0,0 +1,72 @@ +import { GroupNotifyMsgStatus } from 'snowcat-core'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const ReturnSchema = Type.Array( + Type.Object({ + request_id: Type.Number({ description: '请求ID' }), + invitor_uin: Type.Number({ description: '邀请者QQ' }), + invitor_nick: Type.Optional(Type.String({ description: '邀请者昵称' })), + group_id: Type.Number({ description: '群号' }), + message: Type.Optional(Type.String({ description: '验证信息' })), + group_name: Type.Optional(Type.String({ description: '群名称' })), + checked: Type.Boolean({ description: '是否已处理' }), + actor: Type.Number({ description: '处理者QQ' }), + requester_nick: Type.Optional(Type.String({ description: '请求者昵称' })), + }), + { description: '群通知列表' } +); + +type ReturnType = Static; + +export default class GetGroupAddRequest extends OneBotAction { + override actionName = ActionName.GetGroupIgnoreAddRequest; + override payloadSchema = Type.Object({}); + override returnSchema = ReturnSchema; + override actionSummary = '获取群被忽略的加群请求'; + override actionTags = ['群组接口']; + override payloadExample = {}; + override returnExample = [ + { + request_id: 12345, + invitor_uin: 123456789, + invitor_nick: '邀请者', + group_id: 123456789, + message: '加群请求', + group_name: '群名称', + checked: false, + actor: 0, + requester_nick: '请求者', + }, + ]; + + async _handle (): Promise { + const NTQQUserApi = this.core.apis.UserApi; + const NTQQGroupApi = this.core.apis.GroupApi; + const ignoredNotifies = await NTQQGroupApi.getSingleScreenNotifies(true, 10); + const retData: ReturnType = []; + + const notifyPromises = ignoredNotifies + .filter(notify => notify.type === 7) + .map(async SSNotify => { + const invitorUin = SSNotify.user1?.uid ? +await NTQQUserApi.getUinByUidV2(SSNotify.user1.uid) : 0; + const actorUin = SSNotify.user2?.uid ? +await NTQQUserApi.getUinByUidV2(SSNotify.user2.uid) : 0; + retData.push({ + request_id: +SSNotify.seq, + invitor_uin: invitorUin, + invitor_nick: SSNotify.user1?.nickName, + group_id: +SSNotify.group?.groupCode, + message: SSNotify?.postscript, + group_name: SSNotify.group?.groupName, + checked: SSNotify.status !== GroupNotifyMsgStatus.KUNHANDLE, + actor: actorUin, + requester_nick: SSNotify.user1?.nickName, + }); + }); + + await Promise.all(notifyPromises); + + return retData; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetGroupAlbumMediaList.ts b/packages/snowcat-onebot/action/extends/GetGroupAlbumMediaList.ts new file mode 100644 index 0000000..33a3968 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetGroupAlbumMediaList.ts @@ -0,0 +1,42 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + album_id: Type.String({ description: '相册ID' }), + attach_info: Type.Optional(Type.String({ default: '', description: '附加信息(用于分页)' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '相册媒体列表' }); + +type ReturnType = Static; + +export class GetGroupAlbumMediaList extends OneBotAction { + override actionName = ActionName.GetGroupAlbumMediaList; + override actionSummary = '获取群相册媒体列表'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + album_id: 'album_id_1', + }; + + override returnExample = { + media_list: [ + { media_id: 'media_id_1', url: 'http://example.com/1.jpg' }, + ], + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + return await this.core.apis.WebApi.getAlbumMediaListByNTQQ( + payload.group_id, + payload.album_id, + payload.attach_info + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetGroupInfoEx.ts b/packages/snowcat-onebot/action/extends/GetGroupInfoEx.ts new file mode 100644 index 0000000..5ab6547 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetGroupInfoEx.ts @@ -0,0 +1,32 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '群扩展信息' }); + +type ReturnType = Static; + +export class GetGroupInfoEx extends OneBotAction { + override actionName = ActionName.GetGroupInfoEx; + override actionSummary = '获取群详细信息 (扩展)'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + }; + + override returnExample = { + + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + return (await this.core.apis.GroupApi.getGroupExtFE0Info([payload.group_id.toString()])).result.groupExtInfos.get(payload.group_id.toString()); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetGroupShareLink.ts b/packages/snowcat-onebot/action/extends/GetGroupShareLink.ts new file mode 100644 index 0000000..96c230f --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetGroupShareLink.ts @@ -0,0 +1,51 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()], { description: '群号' }), + need_short_url: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { default: true, description: '是否返回短链 (qm.qq.com/q/xxx),false 返回完整 universal-share 链接' })), + src_id: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 73, description: '分享来源 ID' })), + additional_param: Type.Optional(Type.String({ default: '', description: '附加参数' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + url: Type.String({ description: '群分享链接' }), +}, { description: '群分享链接' }); + +type ReturnType = Static; + +export class GetGroupShareLink extends OneBotAction { + override actionName = ActionName.GetGroupShareLink; + override actionSummary = '获取群分享链接'; + override actionDescription = '通过 NTQQ 原生 getJoinGroupLink 获取群的 H5 分享/加群链接 (qm.qq.com/q/xxx 或 qun.qq.com/universal-share/share?...)'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + need_short_url: true, + }; + + override returnExample = { + url: 'https://qm.qq.com/q/xxxxxxxxxx', + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const needShortUrl = payload.need_short_url === undefined ? true : payload.need_short_url.toString() === 'true'; + const srcId = payload.src_id === undefined ? 73 : Number(payload.src_id); + const ret = await this.core.apis.GroupApi.getJoinGroupLink( + payload.group_id.toString(), + needShortUrl, + srcId, + payload.additional_param ?? '' + ); + if (!ret?.url) { + throw new Error(`获取群分享链接失败: ${ret?.errMsg || JSON.stringify(ret)}`); + } + return { url: ret.url }; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetGroupSignedList.ts b/packages/snowcat-onebot/action/extends/GetGroupSignedList.ts new file mode 100644 index 0000000..4212a4e --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetGroupSignedList.ts @@ -0,0 +1,48 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.Union([Type.String(), Type.Number()], { description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array( + Type.Object({ + user_id: Type.Number({ description: '打卡者QQ' }), + nick: Type.String({ description: '打卡者昵称' }), + time: Type.Number({ description: '打卡时间' }), + rank: Type.Number({ description: '打卡排名' }), + }), + { description: '群组今日打卡列表' } +); + +type ReturnType = Static; + +export class GetGroupSignedList extends OneBotAction { + override actionName = ActionName.GetGroupSignedList; + override actionSummary = '获取群组今日打卡列表'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + }; + + override returnExample = { + + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const data = await this.core.apis.WebApi.getDaySignedList(payload.group_id.toString()); + if (!data.response.page) throw new Error('无法获取该群组打卡列表'); + return data.response.page[0]?.infos?.map(info => ({ + user_id: +info.uid, + nick: info.uidGroupNick, + time: +info.signedTimeStamp, + rank: (info.signInRank - 1) / 2 + 1, + })) ?? []; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetMiniAppArk.ts b/packages/snowcat-onebot/action/extends/GetMiniAppArk.ts new file mode 100644 index 0000000..6a2e4d3 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetMiniAppArk.ts @@ -0,0 +1,102 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { MiniAppInfo, MiniAppInfoHelper } from 'snowcat-core/packet/utils/helper/miniAppHelper'; +import { MiniAppReqCustomParams, MiniAppReqParams } from 'snowcat-core/packet/entities/miniApp'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Union([ + Type.Object({ + type: Type.Union([Type.Literal('bili'), Type.Literal('weibo')], { description: '模板类型' }), + title: Type.String({ description: '标题' }), + desc: Type.String({ description: '描述' }), + picUrl: Type.String({ description: '图片URL' }), + jumpUrl: Type.String({ description: '跳转URL' }), + webUrl: Type.Optional(Type.String({ description: '网页URL' })), + rawArkData: Type.Optional(Type.Union([Type.String()], { description: '是否返回原始Ark数据' })), + }), + Type.Object({ + title: Type.String({ description: '标题' }), + desc: Type.String({ description: '描述' }), + picUrl: Type.String({ description: '图片URL' }), + jumpUrl: Type.String({ description: '跳转URL' }), + iconUrl: Type.String({ description: '图标URL' }), + webUrl: Type.Optional(Type.String({ description: '网页URL' })), + appId: Type.String({ description: '小程序AppID' }), + scene: Type.String({ description: '场景ID' }), + templateType: Type.String({ description: '模板类型' }), + businessType: Type.String({ description: '业务类型' }), + verType: Type.String({ description: '版本类型' }), + shareType: Type.String({ description: '分享类型' }), + versionId: Type.String({ description: '版本ID' }), + sdkId: Type.String({ description: 'SDK ID' }), + withShareTicket: Type.String({ description: '是否携带分享票据' }), + rawArkData: Type.Optional(Type.String({ description: '是否返回原始Ark数据' })), + }), +], { description: '小程序Ark参数' }); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + data: Type.Any({ description: 'Ark数据' }), +}, { description: '获取小程序Ark结果' }); + +type ReturnType = Static; + +export class GetMiniAppArk extends GetPacketStatusDepends { + override actionName = ActionName.GetMiniAppArk; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; override actionSummary = '获取小程序 Ark'; + override actionTags = ['系统扩展']; + override payloadExample = { + type: 'bili', + title: '测试标题', + desc: '测试描述', + picUrl: 'http://example.com/pic.jpg', + jumpUrl: 'http://example.com', + }; + + override returnExample = { + data: { + ark: 'ark_content', + }, + }; + + async _handle (payload: PayloadType) { + let reqParam: MiniAppReqParams; + const customParams: MiniAppReqCustomParams = { + title: payload.title, + desc: payload.desc, + picUrl: payload.picUrl, + jumpUrl: payload.jumpUrl, + webUrl: payload.webUrl ?? '', + }; + if ('type' in payload) { + const template = MiniAppInfo.get(payload.type)?.template; + if (!template) { + throw new Error('未知的模板类型'); + } + reqParam = MiniAppInfoHelper.generateReq(customParams, template); + } else { + const { appId, scene, iconUrl, templateType, businessType, verType, shareType, versionId, withShareTicket } = payload; + reqParam = MiniAppInfoHelper.generateReq( + customParams, + { + sdkId: payload.sdkId ?? MiniAppInfo.sdkId, + appId, + scene: +scene, + iconUrl, + templateType: +templateType, + businessType: +businessType, + verType: +verType, + shareType: +shareType, + versionId, + withShareTicket: +withShareTicket, + } + ); + } + const arkData = await this.core.apis.PacketApi.pkt.operation.GetMiniAppAdaptShareInfo(reqParam); + return { + data: payload.rawArkData === 'true' ? arkData : MiniAppInfoHelper.RawToSend(arkData), + }; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetProfileLike.ts b/packages/snowcat-onebot/action/extends/GetProfileLike.ts new file mode 100644 index 0000000..83eb5b3 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetProfileLike.ts @@ -0,0 +1,80 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + user_id: Type.Optional(Type.String({ description: 'QQ号' })), + start: Type.Union([Type.Number(), Type.String()], { default: 0, description: '起始位置' }), + count: Type.Union([Type.Number(), Type.String()], { default: 10, description: '获取数量' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + uid: Type.String({ description: '用户UID' }), + time: Type.String({ description: '时间' }), + favoriteInfo: Type.Object({ + userInfos: Type.Array(Type.Any(), { description: '点赞用户信息' }), + total_count: Type.Number({ description: '总点赞数' }), + last_time: Type.Number({ description: '最后点赞时间' }), + today_count: Type.Number({ description: '今日点赞数' }), + }), + voteInfo: Type.Object({ + total_count: Type.Number({ description: '总点赞数' }), + new_count: Type.Number({ description: '新增点赞数' }), + new_nearby_count: Type.Number({ description: '新增附近点赞数' }), + last_visit_time: Type.Number({ description: '最后访问时间' }), + userInfos: Type.Array(Type.Any(), { description: '点赞用户信息' }), + }), +}, { description: '点赞详情' }); + +type ReturnType = Static; + +export class GetProfileLike extends OneBotAction { + override actionName = ActionName.GetProfileLike; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取资料点赞'; + override actionTags = ['用户扩展']; + override payloadExample = { + user_id: '123456789', + start: 0, + count: 10, + }; + + override returnExample = { + uid: 'u_123', + time: '1734567890', + favoriteInfo: { + userInfos: [], + total_count: 10, + last_time: 1734567890, + today_count: 5, + }, + voteInfo: { + total_count: 100, + new_count: 2, + new_nearby_count: 0, + last_visit_time: 1734567890, + userInfos: [], + }, + }; + + async _handle (payload: PayloadType): Promise { + const isSelf = this.core.selfInfo.uin === payload.user_id || !payload.user_id; + const userUid = isSelf || !payload.user_id ? this.core.selfInfo.uid : await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + const type = isSelf ? 2 : 1; + const ret = await this.core.apis.UserApi.getProfileLike(userUid ?? this.core.selfInfo.uid, +payload.start, +payload.count, type); + const data = ret.info.userLikeInfos[0]; + if (!data) { + throw new Error('get info error'); + } + for (const item of data.voteInfo.userInfos) { + item.uin = +((await this.core.apis.UserApi.getUinByUidV2(item.uid)) ?? ''); + } + for (const item of data.favoriteInfo.userInfos) { + item.uin = +((await this.core.apis.UserApi.getUinByUidV2(item.uid)) ?? ''); + } + return data; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetQunAlbumList.ts b/packages/snowcat-onebot/action/extends/GetQunAlbumList.ts new file mode 100644 index 0000000..7f072be --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetQunAlbumList.ts @@ -0,0 +1,53 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + attach_info: Type.Optional(Type.String({ default: '', description: '附加信息(用于分页,从上一次返回结果中获取)' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + album_list: Type.Array(Type.Any(), { description: '群相册列表' }), + attach_info: Type.String({ description: '分页附加信息,传入下一次请求以获取更多数据' }), + has_more: Type.Boolean({ description: '是否有更多数据' }), +}, { description: '群相册列表响应' }); + +type GetQunAlbumListReturn = Static; + +export class GetQunAlbumList extends OneBotAction { + override actionName = ActionName.GetQunAlbumList; + override actionSummary = '获取群相册列表'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + attach_info: '', + }; + + override returnExample = { + album_list: [ + { + album_id: 'album_1', + album_name: '测试相册', + cover_url: 'http://example.com/cover.jpg', + create_time: 1734567890, + }, + ], + attach_info: '', + has_more: false, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType): Promise { + const resp = (await this.core.apis.WebApi.getAlbumListByNTQQ(payload.group_id, payload.attach_info)).response; + return { + album_list: resp.album_list, + attach_info: resp.attach_info, + has_more: resp.has_more, + }; + } +} diff --git a/packages/snowcat-onebot/action/extends/GetRkey.ts b/packages/snowcat-onebot/action/extends/GetRkey.ts new file mode 100644 index 0000000..153ff8b --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetRkey.ts @@ -0,0 +1,26 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Type, Static } from '@sinclair/typebox'; + +const ReturnSchema = Type.Array(Type.Any(), { description: 'Rkey列表' }); + +type ReturnType = Static; + +export class GetRkey extends GetPacketStatusDepends { + override actionName = ActionName.GetRkey; + override payloadSchema = Type.Object({}); + override returnSchema = ReturnSchema; + override actionSummary = '获取 RKey'; + override actionTags = ['系统扩展']; + override payloadExample = {}; + override returnExample = [ + { + key: 'rkey_value', + expired: 1734567890, + }, + ]; + + async _handle () { + return await this.core.apis.PacketApi.pkt.operation.FetchRkey(); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetRobotUinRange.ts b/packages/snowcat-onebot/action/extends/GetRobotUinRange.ts new file mode 100644 index 0000000..81e804b --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetRobotUinRange.ts @@ -0,0 +1,24 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +const ReturnSchema = Type.Array(Type.Any(), { description: '机器人Uin范围列表' }); + +type ReturnType = Static; + +export class GetRobotUinRange extends OneBotAction { + override actionName = ActionName.GetRobotUinRange; + override actionSummary = '获取机器人 UIN 范围'; + override actionTags = ['系统扩展']; + override payloadExample = {}; + override returnExample = [ + { minUin: '12345678', maxUin: '87654321' }, + ]; + + override payloadSchema = Type.Object({}); + override returnSchema = ReturnSchema; + + async _handle () { + return await this.core.apis.UserApi.getRobotUinRange(); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetUnidirectionalFriendList.ts b/packages/snowcat-onebot/action/extends/GetUnidirectionalFriendList.ts new file mode 100644 index 0000000..893d7f9 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetUnidirectionalFriendList.ts @@ -0,0 +1,75 @@ +import { PacketBuf } from 'snowcat-core/packet/transformer/base'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ProtoBuf, ProtoBufBase, PBUint32, PBString } from 'napcat.protobuf'; +import { Type, Static } from '@sinclair/typebox'; + +const ReturnSchema = Type.Array( + Type.Object({ + uin: Type.Number({ description: 'QQ号' }), + uid: Type.String({ description: '用户UID' }), + nick_name: Type.String({ description: '昵称' }), + age: Type.Number({ description: '年龄' }), + source: Type.String({ description: '来源' }), + }), + { description: '单向好友列表' } +); + +type ReturnType = Static; + +export class GetUnidirectionalFriendList extends OneBotAction { + override actionName = ActionName.GetUnidirectionalFriendList; + override payloadSchema = Type.Object({}); + override returnSchema = ReturnSchema; + override actionSummary = '获取单向好友列表'; + override actionTags = ['用户扩展']; + override payloadExample = {}; + override returnExample = [ + { + uin: 123456789, + uid: 'u_123', + nick_name: '单向好友', + age: 20, + source: '来源', + }, + ]; + + async pack_data (data: string): Promise { + return ProtoBuf(class extends ProtoBufBase { + type = PBUint32(2, false, 0); + data = PBString(3, false, data); + }).encode(); + } + + async _handle (): Promise { + const self_id = this.core.selfInfo.uin; + const req_json = { + uint64_uin: self_id, + uint64_top: 0, + uint32_req_num: 99, + bytes_cookies: '', + }; + const packed_data = await this.pack_data(JSON.stringify(req_json)); + const data = Buffer.from(packed_data); + const rsq = { cmd: 'MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0', data: data as unknown as PacketBuf }; + const rsp_data = await this.core.apis.PacketApi.pkt.operation.sendPacket(rsq, true); + const block_json = ProtoBuf(class extends ProtoBufBase { data = PBString(4); }).decode(rsp_data); + interface BlockItem { + uint64_uin: number; + str_uid: string; + bytes_nick: string; + uint32_age: number; + bytes_source: string; + } + const block_data: { rpt_block_list: BlockItem[]; } = JSON.parse(block_json.data); + const block_list = block_data.rpt_block_list; + + return block_list.map((block) => ({ + uin: block.uint64_uin, + uid: block.str_uid, + nick_name: Buffer.from(block.bytes_nick, 'base64').toString(), + age: block.uint32_age, + source: Buffer.from(block.bytes_source, 'base64').toString(), + })); + } +} diff --git a/packages/snowcat-onebot/action/extends/GetUserStatus.ts b/packages/snowcat-onebot/action/extends/GetUserStatus.ts new file mode 100644 index 0000000..2522e82 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/GetUserStatus.ts @@ -0,0 +1,40 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + user_id: Type.String({ description: 'QQ号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + status: Type.Number({ description: '在线状态' }), + ext_status: Type.Number({ description: '扩展状态' }), +}, { description: '用户状态' }); + +type ReturnType = Static; + +export class GetUserStatus extends GetPacketStatusDepends { + override actionName = ActionName.GetUserStatus; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取用户在线状态'; + override actionTags = ['系统扩展']; + override payloadExample = { + user_id: '123456789', + }; + + override returnExample = { + status: 10, + ext_status: 0, + }; + + async _handle (payload: PayloadType) { + const res = await this.core.apis.PacketApi.pkt.operation.GetStrangerStatus(+payload.user_id); + if (!res) { + throw new Error('无法获取用户状态'); + } + return res; + } +} diff --git a/packages/snowcat-onebot/action/extends/MoveGroupFile.ts b/packages/snowcat-onebot/action/extends/MoveGroupFile.ts new file mode 100644 index 0000000..2214faf --- /dev/null +++ b/packages/snowcat-onebot/action/extends/MoveGroupFile.ts @@ -0,0 +1,49 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file_id: Type.String({ description: '文件ID' }), + current_parent_directory: Type.String({ description: '当前父目录' }), + target_parent_directory: Type.String({ description: '目标父目录' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + ok: Type.Boolean({ description: '是否成功' }), +}, { description: '移动文件结果' }); + +type ReturnType = Static; + +export class MoveGroupFile extends GetPacketStatusDepends { + override actionName = ActionName.MoveGroupFile; + override actionSummary = '移动群文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + group_id: '123456', + file_id: '/file_id', + current_parent_directory: '/current_folder_id', + target_parent_directory: '/target_folder_id', + }; + + override returnExample = { + ok: true, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + await this.core.apis.PacketApi.pkt.operation.MoveGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.target_parent_directory); + return { + ok: true, + }; + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/packages/snowcat-onebot/action/extends/OCRImage.ts b/packages/snowcat-onebot/action/extends/OCRImage.ts new file mode 100644 index 0000000..b1fd852 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/OCRImage.ts @@ -0,0 +1,57 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { checkFileExist, uriToLocalFile } from 'snowcat-common/src/file'; +import fs from 'fs'; +import { Static, Type } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + image: Type.String({ description: '图片路径、URL或Base64' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: 'OCR结果' }); + +type ReturnType = Static; + +class OCRImageBase extends OneBotAction { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '图片 OCR 识别'; + override actionDescription = '识别图片中的文字内容(仅Windows端支持)'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.OCRImage.payload; + override returnExample = ExtendsActionsExamples.OCRImage.response; + + async _handle (payload: PayloadType): Promise { + const { path, success, isLocal } = await uriToLocalFile(this.core.SnowCatTempPath, payload.image); + if (!success) { + throw new Error(`OCR ${payload.image}失败, image字段可能格式不正确`); + } + if (path) { + try { + await checkFileExist(path, 5000); // 避免崩溃 + const ret = await this.core.apis.SystemApi.ocrImage(path); + if (!ret) { + throw new Error(`OCR ${payload.image}失败`); + } + return ret.result; + } finally { + if (!isLocal) fs.unlink(path, () => { }); + } + } + throw new Error(`OCR ${payload.image}失败, 文件可能不存在`); + } +} + +export class OCRImage extends OCRImageBase { + override actionName = ActionName.OCRImage; + override actionSummary = '图片 OCR 识别'; +} + +export class IOCRImage extends OCRImageBase { + override actionName = ActionName.IOCRImage; + override actionSummary = '图片 OCR 识别 (内部)'; +} diff --git a/packages/snowcat-onebot/action/extends/RenameGroupFile.ts b/packages/snowcat-onebot/action/extends/RenameGroupFile.ts new file mode 100644 index 0000000..75ed955 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/RenameGroupFile.ts @@ -0,0 +1,49 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file_id: Type.String({ description: '文件ID' }), + current_parent_directory: Type.String({ description: '当前父目录' }), + new_name: Type.String({ description: '新文件名' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + ok: Type.Boolean({ description: '是否成功' }), +}, { description: '重命名文件结果' }); + +type ReturnType = Static; + +export class RenameGroupFile extends GetPacketStatusDepends { + override actionName = ActionName.RenameGroupFile; + override actionSummary = '重命名群文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + group_id: '123456', + file_id: '/file_id', + current_parent_directory: '/', + new_name: 'new_name.jpg', + }; + + override returnExample = { + ok: true, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + await this.core.apis.PacketApi.pkt.operation.RenameGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.new_name); + return { + ok: true, + }; + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/packages/snowcat-onebot/action/extends/SendPacket.ts b/packages/snowcat-onebot/action/extends/SendPacket.ts new file mode 100644 index 0000000..e5cebd7 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SendPacket.ts @@ -0,0 +1,38 @@ +import { PacketBuf } from 'snowcat-core/packet/transformer/base'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + cmd: Type.String({ description: '命令字' }), + data: Type.String({ description: '十六进制数据' }), + rsp: Type.Union([Type.String(), Type.Boolean()], { default: true, description: '是否等待响应' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Union([Type.String({ description: '响应十六进制数据' }), Type.Undefined()], { description: '发包结果' }); + +type ReturnType = Static; + +export class SendPacket extends GetPacketStatusDepends { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionName = ActionName.SendPacket; + override actionSummary = '发送原始数据包'; + override actionTags = ['系统扩展']; + override payloadExample = { + cmd: 'Example.Cmd', + data: '123456', + rsp: true, + }; + + override returnExample = '123456'; + + async _handle (payload: PayloadType) { + const rsp = typeof payload.rsp === 'boolean' ? payload.rsp : payload.rsp === 'true'; + const packetData = Buffer.from(payload.data, 'hex') as unknown as PacketBuf; + const data = await this.core.apis.PacketApi.pkt.operation.sendPacket({ cmd: payload.cmd, data: packetData }, rsp); + return typeof data === 'object' ? data.toString('hex') : undefined; + } +} diff --git a/packages/snowcat-onebot/action/extends/SendQzoneMsg.ts b/packages/snowcat-onebot/action/extends/SendQzoneMsg.ts new file mode 100644 index 0000000..d7e76db --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SendQzoneMsg.ts @@ -0,0 +1,73 @@ +import { uriToLocalFile } from 'snowcat-common/src/file'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { existsSync, readFileSync } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const ValidUgcRights = [1, 4, 16, 64, 128] as const; + +const PayloadSchema = Type.Object({ + content: Type.String({ description: '说说正文' }), + images: Type.Optional(Type.Array(Type.String(), { description: '图片数组, 支持 file:// http(s):// base64:// , 自动上传' })), + ugc_right: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 1, description: '查看权限: 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见' })), + target_uins: Type.Optional(Type.Array(Type.Union([Type.Number(), Type.String()]), { description: 'ugc_right为16/128时, 权限作用的QQ号数组' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + tid: Type.String({ description: '说说Tid' }), +}, { description: '发说说结果' }); + +type ReturnType = Static; + +export class SendQzoneMsg extends OneBotAction { + override actionName = ActionName.SendQzoneMsg; + override actionSummary = '发表QQ空间说说'; + override actionDescription = '在QQ空间(Qzone)发表说说, 支持纯文字或带(多)图, 可设置查看权限'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.SendQzoneMsg.payload; + override returnExample = ExtendsActionsExamples.SendQzoneMsg.response; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType): Promise { + const ugcRight = payload.ugc_right !== undefined ? +payload.ugc_right : 1; + if (!ValidUgcRights.includes(ugcRight as typeof ValidUgcRights[number])) { + throw new Error(`ugc_right 参数不合法, 允许的值为: ${ValidUgcRights.join(', ')}`); + } + const targetUins = payload.target_uins?.map((uin) => +uin).filter((uin) => !Number.isNaN(uin)); + if ((ugcRight === 16 || ugcRight === 128) && (!targetUins || targetUins.length === 0)) { + throw new Error('ugc_right 为 16 或 128 时, target_uins 不能为空'); + } + + const richvals: string[] = []; + const cleanupPaths: string[] = []; + try { + for (const image of payload.images ?? []) { + const downloadResult = await uriToLocalFile(this.core.SnowCatTempPath, image); + if (!downloadResult.success) { + throw new Error(`图片${image}处理失败, ${downloadResult.errMsg}`); + } + if (!downloadResult.isLocal) { + cleanupPaths.push(downloadResult.path); + } + const base64 = readFileSync(downloadResult.path).toString('base64'); + const richval = await this.core.apis.WebApi.uploadImageToQzone(base64); + richvals.push(richval); + } + + const result = await this.core.apis.WebApi.publishQzoneMsg(payload.content, richvals, ugcRight, targetUins); + return result; + } finally { + for (const p of cleanupPaths) { + if (existsSync(p)) { + await unlink(p).catch(() => { }); + } + } + } + } +} diff --git a/packages/snowcat-onebot/action/extends/SetDiyOnlineStatus.ts b/packages/snowcat-onebot/action/extends/SetDiyOnlineStatus.ts new file mode 100644 index 0000000..eac5d96 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetDiyOnlineStatus.ts @@ -0,0 +1,41 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + face_id: Type.Union([Type.Number(), Type.String()], { description: '图标ID' }), // 参考 face_config.json 的 QSid + face_type: Type.Union([Type.Number(), Type.String()], { default: '1', description: '图标类型' }), + wording: Type.String({ default: ' ', description: '状态文字内容' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.String({ description: '错误信息(如果有)' }); + +type ReturnType = Static; + +export class SetDiyOnlineStatus extends OneBotAction { + override actionName = ActionName.SetDiyOnlineStatus; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; override actionSummary = '设置自定义在线状态'; + override actionDescription = '设置自定义在线状态'; + override actionTags = ['用户扩展']; + override payloadExample = { + face_id: '123', + face_type: '1', + wording: '自定义状态', + }; + + override returnExample = ''; + async _handle (payload: PayloadType) { + const ret = await this.core.apis.UserApi.setDiySelfOnlineStatus( + payload.face_id.toString(), + payload.wording, + payload.face_type.toString() + ); + if (ret.result !== 0) { + throw new Error('设置在线状态失败'); + } + return ret.errMsg; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupAddOption.ts b/packages/snowcat-onebot/action/extends/SetGroupAddOption.ts new file mode 100644 index 0000000..84b8e37 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupAddOption.ts @@ -0,0 +1,41 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + add_type: Type.Number({ description: '加群方式' }), + group_question: Type.Optional(Type.String({ description: '加群问题' })), + group_answer: Type.Optional(Type.String({ description: '加群答案' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '返回结果' }); + +type ReturnType = Static; + +export default class SetGroupAddOption extends OneBotAction { + override actionName = ActionName.SetGroupAddOption; + override actionSummary = '设置群加群选项'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + add_type: 1, + }; + + override returnExample = null; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + async _handle (payload: PayloadType): Promise { + const ret = await this.core.apis.GroupApi.setGroupAddOption(payload.group_id, { + addOption: payload.add_type, + groupQuestion: payload.group_question, + groupAnswer: payload.group_answer, + }); + if (ret.result !== 0) { + throw new Error(`设置群添加选项失败, ${ret.result}:${ret.errMsg}`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupAlbumMediaLike.ts b/packages/snowcat-onebot/action/extends/SetGroupAlbumMediaLike.ts new file mode 100644 index 0000000..1d3874b --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupAlbumMediaLike.ts @@ -0,0 +1,74 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + album_id: Type.String({ description: '相册ID' }), + batch_id: Type.String({ description: 'batch_id' }), + lloc: Type.Optional(Type.String({ description: 'lloc,若对整个上传操作则不填' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '操作结果' }); + +type ReturnType = Static; + +export class SetGroupAlbumMediaLike extends OneBotAction { + override actionName = ActionName.SetGroupAlbumMediaLike; + override actionSummary = '点赞群相册媒体'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + album_id: 'album_id_123', + batch_id: '112233', + lloc: 'aabbcc12213123', + }; + + override returnExample = { + result: {}, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + return await this.core.apis.WebApi.doAlbumMediaLikeByNTQQ( + payload.group_id, + payload.album_id, + payload.batch_id, + payload.lloc, + true // isLike = true + ); + } +} + +export class CancelGroupAlbumMediaLike extends OneBotAction { + override actionName = ActionName.CancelGroupAlbumMediaLike; // 注意:需要在 router/index.ts 的 ActionName 枚举中补充该定义 + override actionSummary = '取消点赞群相册媒体'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + album_id: 'album_id', + batch_id: '112233', + lloc: 'aabbcc', + }; + + override returnExample = { + result: {}, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + return await this.core.apis.WebApi.doAlbumMediaLikeByNTQQ( + payload.group_id, + payload.album_id, + payload.batch_id, + payload.lloc, + false + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupKickMembers.ts b/packages/snowcat-onebot/action/extends/SetGroupKickMembers.ts new file mode 100644 index 0000000..3490516 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupKickMembers.ts @@ -0,0 +1,35 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.Array(Type.String(), { description: 'QQ号列表' }), + reject_add_request: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否拒绝加群请求' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '返回结果' }); + +type ReturnType = Static; + +export default class SetGroupKickMembers extends OneBotAction { + override actionName = ActionName.SetGroupKickMembers; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '批量踢出群成员'; + override actionDescription = '从指定群聊中批量踢出多个成员'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.SetGroupKickMembers.payload; + override returnExample = ExtendsActionsExamples.SetGroupKickMembers.response; + + async _handle (payload: PayloadType): Promise { + const rejectReq = payload.reject_add_request?.toString() === 'true'; + const uids: string[] = await Promise.all(payload.user_id.map(async uin => await this.core.apis.UserApi.getUidByUinV2(uin))); + await this.core.apis.GroupApi.kickMember(payload.group_id.toString(), uids.filter(uid => !!uid), rejectReq); + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupRemark.ts b/packages/snowcat-onebot/action/extends/SetGroupRemark.ts new file mode 100644 index 0000000..13f54eb --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupRemark.ts @@ -0,0 +1,37 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + remark: Type.String({ description: '备注' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '返回结果' }); + +type ReturnType = Static; + +export default class SetGroupRemark extends OneBotAction { + override actionName = ActionName.SetGroupRemark; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群备注'; + override actionDescription = '设置群备注'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + remark: '测试群备注', + }; + + override returnExample = null; + + async _handle (payload: PayloadType): Promise { + const ret = await this.core.apis.GroupApi.setGroupRemark(payload.group_id, payload.remark); + if (ret.result !== 0) { + throw new Error(`设置群备注失败, ${ret.result}:${ret.errMsg}`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupRobotAddOption.ts b/packages/snowcat-onebot/action/extends/SetGroupRobotAddOption.ts new file mode 100644 index 0000000..21c96f8 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupRobotAddOption.ts @@ -0,0 +1,39 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + robot_member_switch: Type.Optional(Type.Number({ description: '机器人成员开关' })), + robot_member_examine: Type.Optional(Type.Number({ description: '机器人成员审核' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '返回结果' }); + +type ReturnType = Static; + +export default class SetGroupRobotAddOption extends OneBotAction { + override actionName = ActionName.SetGroupRobotAddOption; + override actionSummary = '设置群机器人加群选项'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + }; + + override returnExample = null; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + async _handle (payload: PayloadType): Promise { + const ret = await this.core.apis.GroupApi.setGroupRobotAddOption( + payload.group_id, + payload.robot_member_switch, + payload.robot_member_examine + ); + if (ret.result !== 0) { + throw new Error(`设置群机器人添加选项失败, ${ret.result}:${ret.errMsg}`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupSearch.ts b/packages/snowcat-onebot/action/extends/SetGroupSearch.ts new file mode 100644 index 0000000..f8668f4 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupSearch.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + no_code_finger_open: Type.Optional(Type.Number({ description: '未知' })), + no_finger_open: Type.Optional(Type.Number({ description: '未知' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '返回结果' }); + +type ReturnType = Static; + +export default class SetGroupSearch extends OneBotAction { + override actionName = ActionName.SetGroupSearch; + override actionSummary = '设置群搜索选项'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + }; + + override returnExample = null; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + async _handle (payload: PayloadType): Promise { + const ret = await this.core.apis.GroupApi.setGroupSearch(payload.group_id, { + noCodeFingerOpenFlag: payload.no_code_finger_open, + noFingerOpenFlag: payload.no_finger_open, + }); + if (ret.result !== 0) { + throw new Error(`设置群搜索失败, ${ret.result}:${ret.errMsg}`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetGroupSign.ts b/packages/snowcat-onebot/action/extends/SetGroupSign.ts new file mode 100644 index 0000000..74ae006 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetGroupSign.ts @@ -0,0 +1,37 @@ +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Void({ description: '打卡结果' }); + +type ReturnType = Static; + +class SetGroupSignBase extends GetPacketStatusDepends { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '群打卡'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456789', + }; + + override returnExample = null; + + async _handle (payload: PayloadType) { + return await this.core.apis.PacketApi.pkt.operation.GroupSign(+payload.group_id); + } +} + +export class SetGroupSign extends SetGroupSignBase { + override actionName = ActionName.SetGroupSign; +} + +export class SendGroupSign extends SetGroupSignBase { + override actionName = ActionName.SendGroupSign; +} diff --git a/packages/snowcat-onebot/action/extends/SetInputStatus.ts b/packages/snowcat-onebot/action/extends/SetInputStatus.ts new file mode 100644 index 0000000..ca70b37 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetInputStatus.ts @@ -0,0 +1,39 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ChatType } from 'snowcat-core'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + user_id: Type.String({ description: 'QQ号' }), + event_type: Type.Number({ description: '事件类型' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '设置结果' }); + +type ReturnType = Static; + +export class SetInputStatus extends OneBotAction { + override actionName = ActionName.SetInputStatus; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置输入状态'; + override actionTags = ['系统扩展']; + override payloadExample = { + user_id: '123456789', + event_type: 1, + }; + + override returnExample = null; + + async _handle (payload: PayloadType) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('uid is empty'); + const peer = { + chatType: ChatType.KCHATTYPEC2C, + peerUid: uid, + }; + return await this.core.apis.MsgApi.sendShowInputStatusReq(peer, payload.event_type); + } +} diff --git a/packages/snowcat-onebot/action/extends/SetLongNick.ts b/packages/snowcat-onebot/action/extends/SetLongNick.ts new file mode 100644 index 0000000..1915966 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetLongNick.ts @@ -0,0 +1,30 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + longNick: Type.String({ description: '签名内容' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '设置结果' }); + +type ReturnType = Static; + +export class SetLongNick extends OneBotAction { + override actionName = ActionName.SetLongNick; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置个性签名'; + override actionDescription = '修改当前登录帐号的个性签名'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.SetLongNick.payload; + override returnExample = ExtendsActionsExamples.SetLongNick.response; + + async _handle (payload: PayloadType) { + return await this.core.apis.UserApi.setLongNick(payload.longNick); + } +} diff --git a/packages/snowcat-onebot/action/extends/SetOnlineStatus.ts b/packages/snowcat-onebot/action/extends/SetOnlineStatus.ts new file mode 100644 index 0000000..54f95f7 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetOnlineStatus.ts @@ -0,0 +1,256 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + status: Type.Union([Type.Number(), Type.String()], { description: '在线状态' }), + ext_status: Type.Union([Type.Number(), Type.String()], { description: '扩展状态' }), + battery_status: Type.Union([Type.Number(), Type.String()], { description: '电量状态' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '设置结果' }); + +type ReturnType = Static; + +export class SetOnlineStatus extends OneBotAction { + override actionName = ActionName.SetOnlineStatus; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置在线状态'; + override actionDescription = statusText; + override actionTags = ['系统扩展']; + override payloadExample = { + status: 11, + ext_status: 0, + battery_status: 100, + }; + + override returnExample = null; + + async _handle (payload: PayloadType) { + const ret = await this.core.apis.UserApi.setSelfOnlineStatus( + +payload.status, + +payload.ext_status, + +payload.battery_status + ); + if (ret.result !== 0) { + throw new Error('设置在线状态失败'); + } + return null; + } +} + +const statusText = ` +## 状态列表 + +### 在线 +\`\`\`json5; +{ "status": 10, "ext_status": 0, "battery_status": 0; } +\`\`\` + +### Q我吧 +\`\`\`json5; +{ "status": 60, "ext_status": 0, "battery_status": 0; } +\`\`\` + +### 离开 +\`\`\`json5; +{ "status": 30, "ext_status": 0, "battery_status": 0; } +\`\`\` + +### 忙碌 +\`\`\`json5; +{ "status": 50, "ext_status": 0, "battery_status": 0; } +\`\`\` + +### 请勿打扰 +\`\`\`json5; +{ "status": 70, "ext_status": 0, "battery_status": 0; } +\`\`\` + +### 隐身 +\`\`\`json5; +{ "status": 40, "ext_status": 0, "battery_status": 0; } +\`\`\` + +### 听歌中 +\`\`\`json5; +{ "status": 10, "ext_status": 1028, "battery_status": 0; } +\`\`\` + +### 春日限定 +\`\`\`json5; +{ "status": 10, "ext_status": 2037, "battery_status": 0; } +\`\`\` + +### 一起元梦 +\`\`\`json5; +{ "status": 10, "ext_status": 2025, "battery_status": 0; } +\`\`\` + +### 求星搭子 +\`\`\`json5; +{ "status": 10, "ext_status": 2026, "battery_status": 0; } +\`\`\` + +### 被掏空 +\`\`\`json5; +{ "status": 10, "ext_status": 2014, "battery_status": 0; } +\`\`\` + +### 今日天气 +\`\`\`json5; +{ "status": 10, "ext_status": 1030, "battery_status": 0; } +\`\`\` + +### 我crash了 +\`\`\`json5; +{ "status": 10, "ext_status": 2019, "battery_status": 0; } +\`\`\` + +### 爱你 +\`\`\`json5; +{ "status": 10, "ext_status": 2006, "battery_status": 0; } +\`\`\` + +### 恋爱中 +\`\`\`json5; +{ "status": 10, "ext_status": 1051, "battery_status": 0; } +\`\`\` + +### 好运锦鲤 +\`\`\`json5; +{ "status": 10, "ext_status": 1071, "battery_status": 0; } +\`\`\` + +### 水逆退散 +\`\`\`json5; +{ "status": 10, "ext_status": 1201, "battery_status": 0; } +\`\`\` + +### 嗨到飞起 +\`\`\`json5; +{ "status": 10, "ext_status": 1056, "battery_status": 0; } +\`\`\` + +### 元气满满 +\`\`\`json5; +{ "status": 10, "ext_status": 1058, "battery_status": 0; } +\`\`\` + +### 宝宝认证 +\`\`\`json5; +{ "status": 10, "ext_status": 1070, "battery_status": 0; } +\`\`\` + +### 一言难尽 +\`\`\`json5; +{ "status": 10, "ext_status": 1063, "battery_status": 0; } +\`\`\` + +### 难得糊涂 +\`\`\`json5; +{ "status": 10, "ext_status": 2001, "battery_status": 0; } +\`\`\` + +### emo中 +\`\`\`json5; +{ "status": 10, "ext_status": 1401, "battery_status": 0; } +\`\`\` + +### 我太难了 +\`\`\`json5; +{ "status": 10, "ext_status": 1062, "battery_status": 0; } +\`\`\` + +### 我想开了 +\`\`\`json5; +{ "status": 10, "ext_status": 2013, "battery_status": 0; } +\`\`\` + +### 我没事 +\`\`\`json5; +{ "status": 10, "ext_status": 1052, "battery_status": 0; } +\`\`\` + +### 想静静 +\`\`\`json5; +{ "status": 10, "ext_status": 1061, "battery_status": 0; } +\`\`\` + +### 悠哉哉 +\`\`\`json5; +{ "status": 10, "ext_status": 1059, "battery_status": 0; } +\`\`\` + +### 去旅行 +\`\`\`json5; +{ "status": 10, "ext_status": 2015, "battery_status": 0; } +\`\`\` + +### 信号弱 +\`\`\`json5; +{ "status": 10, "ext_status": 1011, "battery_status": 0; } +\`\`\` + +### 出去浪 +\`\`\`json5; +{ "status": 10, "ext_status": 2003, "battery_status": 0; } +\`\`\` + +### 肝作业 +\`\`\`json5; +{ "status": 10, "ext_status": 2012, "battery_status": 0; } +\`\`\` + +### 学习中 +\`\`\`json5; +{ "status": 10, "ext_status": 1018, "battery_status": 0; } +\`\`\` + +### 搬砖中 +\`\`\`json5; +{ "status": 10, "ext_status": 2023, "battery_status": 0; } +\`\`\` + +### 摸鱼中 +\`\`\`json5; +{ "status": 10, "ext_status": 1300, "battery_status": 0; } +\`\`\` + +### 无聊中 +\`\`\`json5; +{ "status": 10, "ext_status": 1060, "battery_status": 0; } +\`\`\` + +### timi中 +\`\`\`json5; +{ "status": 10, "ext_status": 1027, "battery_status": 0; } +\`\`\` + +### 睡觉中 +\`\`\`json5; +{ "status": 10, "ext_status": 1016, "battery_status": 0; } +\`\`\` + +### 熬夜中 +\`\`\`json5; +{ "status": 10, "ext_status": 1032, "battery_status": 0; } +\`\`\` + +### 追剧中 +\`\`\`json5; +{ "status": 10, "ext_status": 1021, "battery_status": 0; } +\`\`\` + +### 我的电量 +\`\`\`json5; +{ + "status": 10, + "ext_status": 1000, + "battery_status": 0; +} +\`\`\` +`; diff --git a/packages/snowcat-onebot/action/extends/SetQQAvatar.ts b/packages/snowcat-onebot/action/extends/SetQQAvatar.ts new file mode 100644 index 0000000..dfe0292 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetQQAvatar.ts @@ -0,0 +1,52 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import fs from 'node:fs/promises'; +import { checkFileExist, uriToLocalFile } from 'snowcat-common/src/file'; +import { Static, Type } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + file: Type.String({ description: '图片路径、URL或Base64' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '设置结果' }); + +type ReturnType = Static; + +export default class SetAvatar extends OneBotAction { + override actionName = ActionName.SetQQAvatar; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置QQ头像'; + override actionDescription = '修改当前账号的QQ头像'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.SetQQAvatar.payload; + override returnExample = ExtendsActionsExamples.SetQQAvatar.response; + + async _handle (payload: PayloadType): Promise { + const { path, success, isLocal } = (await uriToLocalFile(this.core.SnowCatTempPath, payload.file)); + if (!success) { + throw new Error(`头像${payload.file}设置失败,file字段可能格式不正确`); + } + if (path) { + await checkFileExist(path, 5000);// 避免崩溃 + const ret = await this.core.apis.UserApi.setQQAvatar(path); + if (!isLocal) fs.unlink(path).catch(() => { }); + if (!ret) { + throw new Error(`头像${payload.file}设置失败,api无返回`); + } + // log(`头像设置返回:${JSON.stringify(ret)}`) + if (Number(ret.result) === 1004022) { + throw new Error(`头像${payload.file}设置失败,文件可能不是图片格式`); + } else if (ret.result !== 0) { + throw new Error(`头像${payload.file}设置失败,未知的错误,${ret.result}:${ret.errMsg}`); + } + } else { + throw new Error(`头像${payload.file}设置失败,无法获取头像,文件可能不存在`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/SetSpecialTitle.ts b/packages/snowcat-onebot/action/extends/SetSpecialTitle.ts new file mode 100644 index 0000000..243b4c8 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/SetSpecialTitle.ts @@ -0,0 +1,34 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.String({ description: 'QQ号' }), + special_title: Type.String({ default: '', description: '专属头衔' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Void({ description: '设置结果' }); + +type ReturnType = Static; + +export class SetSpecialTitle extends GetPacketStatusDepends { + override actionName = ActionName.SetSpecialTitle; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置专属头衔'; + override actionDescription = '设置群聊中指定成员的专属头衔'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.SetSpecialTitle.payload; + override returnExample = ExtendsActionsExamples.SetSpecialTitle.response; + + async _handle (payload: PayloadType) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + await this.core.apis.PacketApi.pkt.operation.SetGroupSpecialTitle(+payload.group_id, uid, payload.special_title); + } +} diff --git a/packages/snowcat-onebot/action/extends/ShareContact.ts b/packages/snowcat-onebot/action/extends/ShareContact.ts new file mode 100644 index 0000000..8d7a2a3 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/ShareContact.ts @@ -0,0 +1,79 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + user_id: Type.Optional(Type.String({ description: 'QQ号' })), + group_id: Type.Optional(Type.String({ description: '群号' })), + phone_number: Type.String({ default: '', description: '手机号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '分享结果' }); + +type ReturnType = Static; + +export class SharePeerBase extends OneBotAction { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '分享用户 (Ark)'; + override actionDescription = '获取用户推荐的 Ark 内容'; + override actionTags = ['消息扩展']; + override payloadExample = { + user_id: '123456', + phone_number: '', + }; + + override returnExample = { + ark: '...', + }; + + async _handle (payload: PayloadType) { + if (payload.group_id) { + return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString()); + } else if (payload.user_id) { + return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phone_number); + } + throw new Error('group_id or user_id is required'); + } +} + +const PayloadSchemaGroupEx = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); +export class SharePeer extends SharePeerBase { + override actionName = ActionName.SharePeer; +} +type PayloadTypeGroupEx = Static; + +const ReturnSchemaGroupEx = Type.String({ description: 'Ark Json内容' }); + +type ReturnTypeGroupEx = Static; + +export class ShareGroupExBase extends OneBotAction { + override payloadSchema = PayloadSchemaGroupEx; + override returnSchema = ReturnSchemaGroupEx; + override actionSummary = '分享群 (Ark)'; + override actionDescription = '获取群分享的 Ark 内容'; + override actionTags = ['消息扩展']; + override payloadExample = { + group_id: '123456', + }; + + override returnExample = '{"app": "com.tencent.structmsg", ...}'; + + async _handle (payload: PayloadTypeGroupEx) { + return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString()); + } +} +export class ShareGroupEx extends ShareGroupExBase { + override actionName = ActionName.ShareGroupEx; +} +export class SendGroupArkShare extends ShareGroupExBase { + override actionName = ActionName.SendGroupArkShare; +} + +export class SendArkShare extends SharePeerBase { + override actionName = ActionName.SendArkShare; +} diff --git a/packages/snowcat-onebot/action/extends/TransGroupFile.ts b/packages/snowcat-onebot/action/extends/TransGroupFile.ts new file mode 100644 index 0000000..0f589ff --- /dev/null +++ b/packages/snowcat-onebot/action/extends/TransGroupFile.ts @@ -0,0 +1,48 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file_id: Type.String({ description: '文件ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + ok: Type.Boolean({ description: '是否成功' }), +}, { description: '转发文件结果' }); + +type ReturnType = Static; + +export class TransGroupFile extends GetPacketStatusDepends { + override actionName = ActionName.TransGroupFile; + override actionSummary = '传输群文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + group_id: '123456', + file_id: '/file_id', + }; + + override returnExample = { + ok: true, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + const result = await this.core.apis.GroupApi.transGroupFile(payload.group_id.toString(), contextMsgFile.fileUUID); + if (result.transGroupFileResult.result.retCode === 0) { + return { + ok: true, + }; + } + throw new Error(result.transGroupFileResult.result.retMsg); + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/packages/snowcat-onebot/action/extends/TranslateEnWordToZn.ts b/packages/snowcat-onebot/action/extends/TranslateEnWordToZn.ts new file mode 100644 index 0000000..7497546 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/TranslateEnWordToZn.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples'; + +const PayloadSchema = Type.Object({ + words: Type.Array(Type.String(), { description: '待翻译单词列表' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + words: Type.Array(Type.String(), { description: '翻译结果列表' }), +}, { description: '翻译结果' }); + +type ReturnType = Static; + +export class TranslateEnWordToZn extends OneBotAction { + override actionName = ActionName.TranslateEnWordToZn; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '英文单词翻译'; + override actionDescription = '将英文单词列表翻译为中文'; + override actionTags = ['扩展接口']; + override payloadExample = ExtendsActionsExamples.TranslateEnWordToZn.payload; + override returnExample = ExtendsActionsExamples.TranslateEnWordToZn.response; + + async _handle (payload: PayloadType): Promise { + const ret = await this.core.apis.SystemApi.translateEnWordToZn(payload.words); + if (ret.result !== 0) { + throw new Error('翻译失败'); + } + return { + words: ret.words, + }; + } +} diff --git a/packages/snowcat-onebot/action/extends/UploadImageToQunAlbum.ts b/packages/snowcat-onebot/action/extends/UploadImageToQunAlbum.ts new file mode 100644 index 0000000..d1735ff --- /dev/null +++ b/packages/snowcat-onebot/action/extends/UploadImageToQunAlbum.ts @@ -0,0 +1,49 @@ +import { uriToLocalFile } from 'snowcat-common/src/file'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { existsSync } from 'node:fs'; +import { unlink } from 'node:fs/promises'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + album_id: Type.String({ description: '相册ID' }), + album_name: Type.String({ description: '相册名称' }), + file: Type.String({ description: '图片路径、URL或Base64' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '上传结果' }); + +type ReturnType = Static; + +export class UploadImageToQunAlbum extends OneBotAction { + override actionName = ActionName.UploadImageToQunAlbum; + override actionSummary = '上传图片到群相册'; + override actionTags = ['群组扩展']; + override payloadExample = { + group_id: '123456', + album_id: 'album_id_1', + album_name: '相册1', + file: '/path/to/image.jpg', + }; + + override returnExample = { + result: null, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const downloadResult = await uriToLocalFile(this.core.SnowCatTempPath, payload.file); + try { + return await this.core.apis.WebApi.uploadImageToQunAlbum(payload.group_id, payload.album_id, payload.album_name, downloadResult.path); + } finally { + if (!downloadResult.isLocal && downloadResult.path && existsSync(downloadResult.path)) { + await unlink(downloadResult.path); + } + } + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/CommentQzoneMsg.ts b/packages/snowcat-onebot/action/extends/qzone/CommentQzoneMsg.ts new file mode 100644 index 0000000..641415e --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/CommentQzoneMsg.ts @@ -0,0 +1,32 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + host_uin: Type.String({ description: '说说主人的QQ号' }), + tid: Type.String({ description: '说说tid' }), + content: Type.String({ description: '评论正文' }), + richval: Type.Optional(Type.String({ description: '图片评论时传图片直链(与发说说的richval串不同)' })), +}); + +type PayloadType = Static; + +export class CommentQzoneMsg extends OneBotAction { + override actionName = ActionName.CommentQzoneMsg; + override actionSummary = '评论说说'; + override actionDescription = '给一条说说发评论(emotion_cgi_re_feeds)'; + override actionTags = ['扩展接口']; + override payloadExample = { host_uin: '123456789', tid: 'abc123', content: '说得好' }; + override returnExample = { comment_id: '999' }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ comment_id: Type.String() }); + + async _handle (payload: PayloadType) { + return this.core.apis.WebApi.commentQzoneMsg( + payload.host_uin, + payload.tid, + payload.content, + payload.richval, + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/GetQzoneFeeds.ts b/packages/snowcat-onebot/action/extends/qzone/GetQzoneFeeds.ts new file mode 100644 index 0000000..7419a90 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/GetQzoneFeeds.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + page_num: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 1, description: '页码(无cursor时生效)' })), + count: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 10, description: '每页条数(1-50)' })), + cursor: Type.Optional(Type.String({ description: '上次返回的 next_cursor,续翻用' })), +}); + +type PayloadType = Static; + +export class GetQzoneFeeds extends OneBotAction { + override actionName = ActionName.GetQzoneFeeds; + override actionSummary = '获取空间好友动态流'; + override actionDescription = '读取QQ空间好友动态(HTML载荷数据级解析,不执行远程内容);cursor 续翻'; + override actionTags = ['扩展接口']; + override payloadExample = { page_num: 1, count: 10 }; + override returnExample = { + feeds: [{ uin: 123456789, nickname: '张三', tid: 'abc', content: '动态正文', images: [], like_count: 5, comment_count: 2, appid: 311, key: 'k', time: 1760000000, html: '', content_complete: true }], + has_more: true, + next_cursor: 'eyJ...', + }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ + feeds: Type.Array(Type.Any()), + has_more: Type.Boolean(), + next_cursor: Type.Union([Type.String(), Type.Null()]), + }); + + async _handle (payload: PayloadType) { + return this.core.apis.WebApi.getQzoneFeeds( + payload.page_num !== undefined ? +payload.page_num : 1, + payload.count !== undefined ? +payload.count : 10, + payload.cursor, + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/GetQzoneMsgDetail.ts b/packages/snowcat-onebot/action/extends/qzone/GetQzoneMsgDetail.ts new file mode 100644 index 0000000..8d52103 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/GetQzoneMsgDetail.ts @@ -0,0 +1,42 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + target_uin: Type.String({ description: '说说主人的QQ号' }), + tid: Type.String({ description: '说说tid' }), + pos: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 0, description: '评论起始偏移' })), + num: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 20, description: '评论每页数量' })), +}); + +type PayloadType = Static; + +export class GetQzoneMsgDetail extends OneBotAction { + override actionName = ActionName.GetQzoneMsgDetail; + override actionSummary = '获取说说详情'; + override actionDescription = '读取一条说说正文与一页评论(含内联回复)'; + override actionTags = ['扩展接口']; + override payloadExample = { target_uin: '123456789', tid: 'abc123' }; + override returnExample = { + tid: 'abc123', + content: '正文', + comment_num: 2, + comments: [], + }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ + tid: Type.String(), + content: Type.String(), + comment_num: Type.Number(), + comments: Type.Array(Type.Any()), + }); + + async _handle (payload: PayloadType) { + return this.core.apis.WebApi.getQzoneMsgDetail( + payload.target_uin, + payload.tid, + payload.pos !== undefined ? +payload.pos : 0, + payload.num !== undefined ? +payload.num : 20, + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/GetQzoneMsgList.ts b/packages/snowcat-onebot/action/extends/qzone/GetQzoneMsgList.ts new file mode 100644 index 0000000..2ae051d --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/GetQzoneMsgList.ts @@ -0,0 +1,53 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +// SnowCat · qzone Web 线动作组(SnowLuma 移植) +// 全部走 core.apis.WebApi 的 cookie 管道(getQzoneAuth),无需本文件再做凭证处理。 + +const PayloadSchema = Type.Object({ + target_uin: Type.Optional(Type.String({ description: '目标QQ号,省略=机器人自己' })), + pos: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 0, description: '起始偏移' })), + num: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 20, description: '每页数量' })), +}); + +type PayloadType = Static; + +export class GetQzoneMsgList extends OneBotAction { + override actionName = ActionName.GetQzoneMsgList; + override actionSummary = '获取说说列表'; + override actionDescription = '获取QQ空间说说列表(默认自己空间,可查有权限的他人空间);-10000限频自动切备路'; + override actionTags = ['扩展接口']; + override payloadExample = { target_uin: '123456789', pos: 0, num: 20 }; + override returnExample = { + total: 100, + msglist: [{ + tid: 'abc123', + content: '今天天气不错', + time: 1760000000, + comment_num: 2, + is_private: false, + images: ['https://...'], + }], + }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ + total: Type.Number(), + msglist: Type.Array(Type.Object({ + tid: Type.String(), + content: Type.String(), + time: Type.Number(), + comment_num: Type.Number(), + is_private: Type.Boolean(), + images: Type.Array(Type.String()), + })), + }); + + async _handle (payload: PayloadType) { + return this.core.apis.WebApi.getQzoneMsgList( + payload.target_uin, + payload.pos !== undefined ? +payload.pos : 0, + payload.num !== undefined ? +payload.num : 20, + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/GetQzoneProfile.ts b/packages/snowcat-onebot/action/extends/qzone/GetQzoneProfile.ts new file mode 100644 index 0000000..36ac80e --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/GetQzoneProfile.ts @@ -0,0 +1,27 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + target_uin: Type.Optional(Type.String({ description: '目标QQ号,省略=机器人自己' })), +}); + +type PayloadType = Static; + +export class GetQzoneProfile extends OneBotAction { + override actionName = ActionName.GetQzoneProfile; + override actionSummary = '获取空间名称和说明'; + override actionDescription = '获取QQ空间名称和说明(默认当前账号,可查有权限的他人空间)'; + override actionTags = ['扩展接口']; + override payloadExample = { target_uin: '123456789' }; + override returnExample = { name: '我的空间', description: '记录日常' }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ + name: Type.String(), + description: Type.String(), + }); + + async _handle (payload: PayloadType) { + return this.core.apis.WebApi.getQzoneProfile(payload.target_uin); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/ReplyQzoneComment.ts b/packages/snowcat-onebot/action/extends/qzone/ReplyQzoneComment.ts new file mode 100644 index 0000000..b023951 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/ReplyQzoneComment.ts @@ -0,0 +1,42 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + host_uin: Type.String({ description: '说说主人的QQ号' }), + tid: Type.String({ description: '说说tid' }), + content: Type.String({ description: '回复正文' }), + comment_id: Type.String({ description: '父评论id(顶层评论,回复楼中楼也传顶层)' }), + comment_uin: Type.String({ description: '父评论作者QQ号' }), + reply_to_uin: Type.Optional(Type.String({ description: '楼中楼点名作者的QQ号' })), + reply_to_name: Type.Optional(Type.String({ description: '楼中楼点名作者的昵称' })), +}); + +type PayloadType = Static; + +export class ReplyQzoneComment extends OneBotAction { + override actionName = ActionName.ReplyQzoneComment; + override actionSummary = '回复说说评论'; + override actionDescription = '在说说的评论线程里回复(独立端点;点名作者自动转@提及)'; + override actionTags = ['扩展接口']; + override payloadExample = { host_uin: '123456789', tid: 'abc123', content: '同意', comment_id: '999', comment_uin: '987654321' }; + override returnExample = { comment_id: '1001' }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ comment_id: Type.String() }); + + async _handle (payload: PayloadType) { + const replyTo = payload.reply_to_uin && payload.reply_to_name + ? { uin: payload.reply_to_uin, name: payload.reply_to_name } + : undefined; + return this.core.apis.WebApi.replyQzoneComment( + payload.host_uin, + payload.tid, + payload.content, + { + commentId: payload.comment_id, + commentUin: payload.comment_uin, + ...(replyTo ? { replyTo } : {}), + }, + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/SendQzoneMsgFull.ts b/packages/snowcat-onebot/action/extends/qzone/SendQzoneMsgFull.ts new file mode 100644 index 0000000..02ede44 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/SendQzoneMsgFull.ts @@ -0,0 +1,46 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +// SnowCat 富返回版发说说:与官方风格 send_qzone_msg 并存,多返回 time。 + +const ValidRights = [1, 4, 16, 64, 128] as const; + +const PayloadSchema = Type.Object({ + content: Type.String({ description: '说说正文' }), + richvals: Type.Optional(Type.Array(Type.String(), { description: 'richval 数组(来自 uploadImageToQzone 或 get_qzone_msg_list 的图片机制),多图 \t 自动拼接' })), + ugc_right: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 1, description: '查看权限: 1所有人 4好友 16部分好友 64仅自己 128部分好友不可见' })), + target_uins: Type.Optional(Type.Array(Type.String(), { description: 'ugc_right为16/128时权限作用的QQ号数组' })), +}); + +type PayloadType = Static; + +export class SendQzoneMsgFull extends OneBotAction { + override actionName = ActionName.SendQzoneMsgFull; + override actionSummary = '发表说说(富返回)'; + override actionDescription = '发表QQ空间说说,返回 tid + 发布时间(send_qzone_msg 的富参版)'; + override actionTags = ['扩展接口']; + override payloadExample = { content: '今天天气不错' }; + override returnExample = { tid: 'abc123', time: 1760000000 }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ + tid: Type.String(), + time: Type.Number(), + }); + + async _handle (payload: PayloadType) { + const ugcRight = payload.ugc_right !== undefined ? +payload.ugc_right : 1; + if (!ValidRights.includes(ugcRight as typeof ValidRights[number])) { + throw new Error(`ugc_right 参数不合法, 允许的值为: ${ValidRights.join(', ')}`); + } + if ((ugcRight === 16 || ugcRight === 128) && (!payload.target_uins || payload.target_uins.length === 0)) { + throw new Error('ugc_right 为 16 或 128 时, target_uins 不能为空'); + } + return this.core.apis.WebApi.publishQzoneMsgFull( + payload.content, + payload.richvals ?? [], + ugcRight, + payload.target_uins, + ); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/SetQzoneBlack.ts b/packages/snowcat-onebot/action/extends/qzone/SetQzoneBlack.ts new file mode 100644 index 0000000..7dc3cef --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/SetQzoneBlack.ts @@ -0,0 +1,26 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + target_uin: Type.String({ description: '目标QQ号' }), + ban: Type.Boolean({ default: true, description: 'true=拉黑 false=解拉黑' }), +}); + +type PayloadType = Static; + +export class SetQzoneBlack extends OneBotAction { + override actionName = ActionName.SetQzoneBlack; + override actionSummary = '空间拉黑/解拉黑'; + override actionDescription = '把一个用户加入/移出机器人QQ空间的黑名单'; + override actionTags = ['扩展接口']; + override payloadExample = { target_uin: '123456789', ban: true }; + override returnExample = null; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Null(); + + async _handle (payload: PayloadType): Promise { + await this.core.apis.WebApi.setQzoneBlack(payload.target_uin, payload.ban); + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/SetQzoneLike.ts b/packages/snowcat-onebot/action/extends/qzone/SetQzoneLike.ts new file mode 100644 index 0000000..bfb9d16 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/SetQzoneLike.ts @@ -0,0 +1,33 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + target_uin: Type.String({ description: '说说主人的QQ号' }), + tid: Type.String({ description: '说说tid' }), + like: Type.Boolean({ default: true, description: 'true=点赞 false=取消赞' }), + abstime: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 0, description: '目标帖发帖时间(unix秒),传真实值更稳' })), +}); + +type PayloadType = Static; + +export class SetQzoneLike extends OneBotAction { + override actionName = ActionName.SetQzoneLike; + override actionSummary = '说说点赞/取消赞'; + override actionDescription = '对一条说说点赞或取消赞(appid 311 mood)'; + override actionTags = ['扩展接口']; + override payloadExample = { target_uin: '123456789', tid: 'abc123', like: true }; + override returnExample = null; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Null(); + + async _handle (payload: PayloadType): Promise { + await this.core.apis.WebApi.setQzoneLike( + payload.target_uin, + payload.tid, + payload.like, + payload.abstime !== undefined ? +payload.abstime : 0, + ); + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/SetQzoneMsgRight.ts b/packages/snowcat-onebot/action/extends/qzone/SetQzoneMsgRight.ts new file mode 100644 index 0000000..bbd0acb --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/SetQzoneMsgRight.ts @@ -0,0 +1,35 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const ValidRights = [1, 4, 16, 64, 128] as const; + +const PayloadSchema = Type.Object({ + tid: Type.String({ description: '说说tid(自己的说说)' }), + ugc_right: Type.Union([Type.Number(), Type.String()], { description: '查看权限: 1所有人 4好友 16部分好友 64仅自己 128部分好友不可见' }), + target_uins: Type.Optional(Type.Array(Type.String(), { description: 'ugc_right为16/128时权限作用的QQ号数组' })), +}); + +type PayloadType = Static; + +export class SetQzoneMsgRight extends OneBotAction { + override actionName = ActionName.SetQzoneMsgRight; + override actionSummary = '修改说说查看权限'; + override actionDescription = '改一条已有说说的查看权限(内部走 msgdetail→update 两步全量重提)'; + override actionTags = ['扩展接口']; + override payloadExample = { tid: 'abc123', ugc_right: 4 }; + override returnExample = { ugc_right: 4 }; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Object({ ugc_right: Type.Number() }); + + async _handle (payload: PayloadType) { + const right = +payload.ugc_right; + if (!ValidRights.includes(right as typeof ValidRights[number])) { + throw new Error(`ugc_right 参数不合法, 允许的值为: ${ValidRights.join(', ')}`); + } + if ((right === 16 || right === 128) && (!payload.target_uins || payload.target_uins.length === 0)) { + throw new Error('ugc_right 为 16 或 128 时, target_uins 不能为空'); + } + return this.core.apis.WebApi.setQzoneMsgRight(payload.tid, right, payload.target_uins); + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/SetQzoneProfile.ts b/packages/snowcat-onebot/action/extends/qzone/SetQzoneProfile.ts new file mode 100644 index 0000000..903fcae --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/SetQzoneProfile.ts @@ -0,0 +1,29 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + name: Type.Optional(Type.String({ description: '空间名称,省略=不动' })), + description: Type.Optional(Type.String({ description: '空间说明,省略=不动;空串=清空' })), +}); + +type PayloadType = Static; + +export class SetQzoneProfile extends OneBotAction { + override actionName = ActionName.SetQzoneProfile; + override actionSummary = '修改空间名称和说明'; + override actionDescription = '修改当前账号的空间名称/说明,只动传了的字段,空串=清空'; + override actionTags = ['扩展接口']; + override payloadExample = { name: '我的空间', description: '记录日常' }; + override returnExample = null; + override payloadSchema = PayloadSchema; + override returnSchema = Type.Null(); + + async _handle (payload: PayloadType): Promise { + await this.core.apis.WebApi.setQzoneProfile({ + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.description !== undefined ? { description: payload.description } : {}), + }); + return null; + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/batch3.ts b/packages/snowcat-onebot/action/extends/qzone/batch3.ts new file mode 100644 index 0000000..7b9732c --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/batch3.ts @@ -0,0 +1,138 @@ +/** + * SnowCat · 第三批动作组(SnowLuma 移植):群相册补/群管补/自定义表情/杂项 + * + * 底层取舍(对账定谳 2026-11-11): + * - 群相册 get_group_album_list:走 NapCat 现役 Web 线 getAlbumList(qun_list_album_v2,已实装) + * - 群相册 Web 线媒体列表:getAlbumMediaListByNTQQ(NT AlbumService,已实装) + * - get_msg_emoji_likes:NT getMsgEmojiLikesList(已实装,补 OB11 动作壳) + * - get_friend_dress:NT getUserDetailInfo(已实装,补动作壳) + * - fetch_sys_faces:NapCat 无目录表,返回空集占位(SnowLuma 侧是协议自编目录,待后续独立移植) + * - get_group_todo_list/get_group_tags/set_group_reaction 等 OIDB 线:NapCat PacketApi 只透传裸包 + * (send_packet 动作已在),SDK 级 OIDB 编解码层为后续独立批次,此批不重复造。 + */ + +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +// ─────────────── 群相册补 ─────────────── + +const AlbumListPayload = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()], { description: '群号' }), +}); +export class GetGroupAlbumList extends OneBotAction, unknown> { + override actionName = ActionName.GetGroupAlbumList; + override actionSummary = '获取群相册列表'; + override actionDescription = '获取群相册列表(qzone Web 线 qun_list_album_v2)'; + override actionTags = ['扩展接口']; + override payloadExample = { group_id: 123456789 }; + override returnExample = [{ id: '1', title: '相册一' }]; + override payloadSchema = AlbumListPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + return this.core.apis.WebApi.getAlbumList(String(payload.group_id)); + } +} + +const AlbumWebMediaPayload = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()], { description: '群号' }), + album_id: Type.String({ description: '相册id(来自 get_group_album_list)' }), +}); +export class GetGroupAlbumMediaListWeb extends OneBotAction, unknown> { + override actionName = ActionName.GetGroupAlbumMediaListOnly; + override actionSummary = '获取群相册媒体列表(Web线)'; + override actionDescription = '经 NT AlbumService 读取群相册媒体列表(与 get_group_album_media_list 分页线并存)'; + override actionTags = ['扩展接口']; + override payloadExample = { group_id: 123456789, album_id: '1' }; + override returnExample = []; + override payloadSchema = AlbumWebMediaPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + return this.core.apis.WebApi.getAlbumMediaListByNTQQ(String(payload.group_id), payload.album_id); + } +} + +// ─────────────── 群管补 ─────────────── + +const MsgEmojiLikesPayload = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()], { description: '群号' }), + message_seq: Type.Union([Type.Number(), Type.String()], { description: '消息 seq' }), + emoji_id: Type.String({ description: '表情id' }), + emoji_type: Type.Optional(Type.String({ description: '表情类型(1-2,省略按 id 长度推断)' })), + cookie: Type.Optional(Type.String({ description: '翻页 cookie,来自上一页返回' })), + count: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 20, description: '每页数量' })), +}); +export class GetMsgEmojiLikes extends OneBotAction, unknown> { + override actionName = ActionName.GetMsgEmojiLikes; + override actionSummary = '查询消息表情回应列表'; + override actionDescription = '查询一条群消息的表情回应(谁点了什么表情),可翻页'; + override actionTags = ['扩展接口']; + override payloadExample = { group_id: 123456789, message_seq: 12345, emoji_id: '128077' }; + override returnExample = { cookie: '', likes: [] }; + override payloadSchema = MsgEmojiLikesPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const peer = { + chatType: 1, + peerUin: String(payload.group_id), + peerUid: '', + guildId: '', + }; + return this.core.apis.MsgApi.getMsgEmojiLikesList( + peer, + String(payload.message_seq), + payload.emoji_id, + payload.emoji_type ?? (payload.emoji_id.length > 3 ? '2' : '1'), + payload.cookie ?? '', + payload.count !== undefined ? +payload.count : 20, + ); + } +} + +// ─────────────── 自定义表情/装扮 ─────────────── + +const FriendDressPayload = Type.Object({ + user_id: Type.Optional(Type.String({ description: '目标QQ号,省略=自己' })), +}); +export class GetFriendDress extends OneBotAction, unknown> { + override actionName = ActionName.GetFriendDress; + override actionSummary = '获取用户装扮'; + override actionDescription = '获取好友/自己的装扮信息(挂件/气泡等,getUserDetailInfo)'; + override actionTags = ['扩展接口']; + override payloadExample = { user_id: '123456789' }; + override returnExample = { dress: {} }; + override payloadSchema = FriendDressPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const uin = payload.user_id ?? this.core.selfInfo.uin; + const uid = uin === this.core.selfInfo.uin + ? this.core.selfInfo.uid + : await this.core.apis.UserApi.getUidByUinV2(uin); + if (!uid) throw new Error('用户不存在'); + const info = await this.core.apis.UserApi.getUserDetailInfo(uid); + return { dress: info as unknown as Record }; + } +} + +const FetchSysFacesPayload = Type.Object({ + count: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 50, description: '取回数量' })), +}); +export class FetchSysFaces extends OneBotAction, unknown> { + override actionName = ActionName.FetchSysFaces; + override actionSummary = '获取系统表情目录'; + override actionDescription = '获取 QQ 系统表情目录(占位:协议自编目录待后续批次移植,当前返回空集)'; + override actionTags = ['扩展接口']; + override payloadExample = { count: 50 }; + override returnExample = { items: [], note: 'sys face catalog 移植批次待排' }; + override payloadSchema = FetchSysFacesPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + void payload; + return { items: [], note: 'sys face catalog 移植批次待排' }; + } +} diff --git a/packages/snowcat-onebot/action/extends/qzone/qzone-public.ts b/packages/snowcat-onebot/action/extends/qzone/qzone-public.ts new file mode 100644 index 0000000..51c02c2 --- /dev/null +++ b/packages/snowcat-onebot/action/extends/qzone/qzone-public.ts @@ -0,0 +1,322 @@ +/** + * SnowCat · QQ空间公共空间(空友推荐)13 动作 + * + * 底层:snowcat-core/apis/qzone-public-web.ts(SnowLuma qzone-public.ts 移植) + * 动作约定:feed/comment/reply 等复杂对象由调用方从 get_qzone_public_feeds / + * get_qzone_public_feed_detail 的返回中原样回传(透明通道,不做二次建模)。 + */ + +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const FeedObject = Type.Any({ description: '完整动态对象(来自 get_qzone_public_feeds/feed_detail,原样回传)' }); +const CommentObject = Type.Any({ description: '评论对象(原样回传)' }); +const ReplyObject = Type.Optional(Type.Any({ description: '回复对象(原样回传)' })); +const UserObject = Type.Any({ description: '用户身份对象(详情/列表响应里的 user 形态)' }); + +/** 公共动作共用的 WebApi 快捷取凭证。 */ +function publicApi (action: OneBotAction) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (action as any).core.apis.WebApi; +} + +// 1. get_qzone_public_capabilities —— 查询账号空友入口白名单 +const CapPayload = Type.Object({}); +export class GetQzonePublicCapabilities extends OneBotAction, unknown> { + override actionName = ActionName.GetQzonePublicCapabilities; + override actionSummary = '查询空友入口能力'; + override actionDescription = '查询当前账号在 QQ 空间公共空间(空友)各入口的白名单能力'; + override actionTags = ['扩展接口']; + override payloadExample = {}; + override returnExample = { entrance1: true, entrance15: false }; + override payloadSchema = CapPayload; + override returnSchema = Type.Any(); + + async _handle () { + const api = publicApi(this); + const { uin, g_tk, cookie } = await api.getQzoneAuth(); + return api.getQzonePublicCapabilities(cookie, g_tk, uin); + } +} + +// 2. get_qzone_public_feeds —— 空友推荐动态流 +const FeedsPayload = Type.Object({ + cursor: Type.Optional(Type.String({ description: '上次返回的 next_cursor,续翻用' })), +}); +export class GetQzonePublicFeeds extends OneBotAction, unknown> { + override actionName = ActionName.GetQzonePublicFeeds; + override actionSummary = '获取空友推荐动态'; + override actionDescription = '读取QQ空间公共空间(空友)推荐动态流,cursor 续翻'; + override actionTags = ['扩展接口']; + override payloadExample = {}; + override returnExample = { feeds: [], has_more: false, next_cursor: null }; + override payloadSchema = FeedsPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { uin, g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).getQzonePublicFeeds(cookie, g_tk, uin, payload.cursor); + } +} + +// 3. get_qzone_public_feed_detail —— 空友单条动态详情 +const DetailPayload = Type.Object({ feed: FeedObject }); +export class GetQzonePublicFeedDetail extends OneBotAction, unknown> { + override actionName = ActionName.GetQzonePublicFeedDetail; + override actionSummary = '获取空友动态详情'; + override actionDescription = '获取一条空友推荐动态的完整详情(含评论首屏)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {} }; + override returnExample = {}; + override payloadSchema = DetailPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).getQzonePublicFeedDetail(cookie, g_tk, payload.feed); + } +} + +// 4. get_qzone_public_comments —— 空友评论分页 +const CommentsPayload = Type.Object({ + feed: FeedObject, + cursor: Type.String({ description: '详情/上页返回的评论 attach_info' }), +}); +export class GetQzonePublicComments extends OneBotAction, unknown> { + override actionName = ActionName.GetQzonePublicComments; + override actionSummary = '获取空友评论分页'; + override actionDescription = '分页读取一条空友动态的评论'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, cursor: '' }; + override returnExample = {}; + override payloadSchema = CommentsPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).getQzonePublicComments(cookie, g_tk, payload.feed, payload.cursor); + } +} + +// 5. get_qzone_public_replies —— 空友楼中楼分页 +const RepliesPayload = Type.Object({ + feed: FeedObject, + comment: CommentObject, + cursor: Type.Optional(Type.String({ description: '回复 attach_info,省略用 comment 自带' })), + page_size: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 20, description: '每页条数' })), +}); +export class GetQzonePublicReplies extends OneBotAction, unknown> { + override actionName = ActionName.GetQzonePublicReplies; + override actionSummary = '获取空友楼中楼分页'; + override actionDescription = '分页读取一条空友评论的楼中楼回复'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, comment: {}, cursor: '', page_size: 20 }; + override returnExample = {}; + override payloadSchema = RepliesPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).getQzonePublicReplies( + cookie, g_tk, payload.feed, payload.comment, + payload.cursor ?? '', payload.page_size !== undefined ? +payload.page_size : 20, + ); + } +} + +// 6. set_qzone_public_upvote —— 空友动态点赞/取消(UFO Upvote) +const UpvotePayload = Type.Object({ feed: FeedObject, enable: Type.Boolean({ default: true }) }); +export class SetQzonePublicUpvote extends OneBotAction, unknown> { + override actionName = ActionName.SetQzonePublicUpvote; + override actionSummary = '空友动态点赞/取消'; + override actionDescription = '对一条空友推荐动态点赞或取消点赞(UFO Upvote/Downvote)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, enable: true }; + override returnExample = {}; + override payloadSchema = UpvotePayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).setQzonePublicUpvote(cookie, g_tk, payload.feed, payload.enable); + } +} + +// 7. set_qzone_public_like —— 空友动态点赞(DoLike 系) +const LikePayload = Type.Object({ feed: FeedObject, enable: Type.Boolean({ default: true }) }); +export class SetQzonePublicLike extends OneBotAction, unknown> { + override actionName = ActionName.SetQzonePublicLike; + override actionSummary = '空友动态点赞(DoLike)'; + override actionDescription = '对一条空友动态点赞/取消赞(DoLike/UnLike,key 取 cur_like_key)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, enable: true }; + override returnExample = {}; + override payloadSchema = LikePayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).setQzonePublicLike(cookie, g_tk, payload.feed, payload.enable); + } +} + +// 8. set_qzone_public_comment_like —— 空友评论/回复点赞 +const CommentLikePayload = Type.Object({ + feed: FeedObject, + comment: CommentObject, + reply: ReplyObject, + enable: Type.Boolean({ default: true }), +}); +export class SetQzonePublicCommentLike extends OneBotAction, unknown> { + override actionName = ActionName.SetQzonePublicCommentLike; + override actionSummary = '空友评论/回复点赞'; + override actionDescription = '给空友评论点赞(like_type=2)或给楼中楼回复点赞(like_type=3)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, comment: {}, enable: true }; + override returnExample = {}; + override payloadSchema = CommentLikePayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).setQzonePublicCommentLike( + cookie, g_tk, payload.feed, payload.comment, payload.enable, + payload.reply as Record | undefined, + ); + } +} + +// 9. comment_qzone_public_feed —— 空友发评论 +const CommentPayload = Type.Object({ + feed: FeedObject, + user: UserObject, + content: Type.Union([Type.String(), Type.Array(Type.Any())], { description: '评论内容:纯文本或富内容数组' }), +}); +export class CommentQzonePublicFeed extends OneBotAction, unknown> { + override actionName = ActionName.CommentQzonePublicFeed; + override actionSummary = '空友发评论'; + override actionDescription = '给一条空友推荐动态发评论'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, user: {}, content: '说得好' }; + override returnExample = {}; + override payloadSchema = CommentPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).commentQzonePublicFeed( + cookie, g_tk, payload.feed, + payload.user as Record, + payload.content as string | Record[], + ); + } +} + +// 10. reply_qzone_public_feed —— 空友回复评论/楼中楼 +const ReplyPayload = Type.Object({ + feed: FeedObject, + user: UserObject, + comment: CommentObject, + content: Type.Union([Type.String(), Type.Array(Type.Any())], { description: '回复内容' }), + reply: ReplyObject, +}); +export class ReplyQzonePublicFeed extends OneBotAction, unknown> { + override actionName = ActionName.ReplyQzonePublicFeed; + override actionSummary = '空友回复评论'; + override actionDescription = '在空友动态的评论线程里回复(可点名楼中楼目标)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, user: {}, comment: {}, content: '同意' }; + override returnExample = {}; + override payloadSchema = ReplyPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).replyQzonePublicFeed( + cookie, g_tk, payload.feed, + payload.user as Record, + payload.comment as Record, + payload.content as string | Record[], + payload.reply as Record | undefined, + ); + } +} + +// 11. delete_qzone_public_comment —— 删空友评论 +const DelCommentPayload = Type.Object({ feed: FeedObject, comment: CommentObject }); +export class DeleteQzonePublicComment extends OneBotAction, unknown> { + override actionName = ActionName.DeleteQzonePublicComment; + override actionSummary = '删空友评论'; + override actionDescription = '删除一条空友动态的评论(自己的评论)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, comment: {} }; + override returnExample = {}; + override payloadSchema = DelCommentPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).deleteQzonePublicComment( + cookie, g_tk, payload.feed, payload.comment as Record, + ); + } +} + +// 12. delete_qzone_public_reply —— 删空友楼中楼 +const DelReplyPayload = Type.Object({ feed: FeedObject, comment: CommentObject, reply: Type.Any({ description: '回复对象(原样回传)' }) }); +export class DeleteQzonePublicReply extends OneBotAction, unknown> { + override actionName = ActionName.DeleteQzonePublicReply; + override actionSummary = '删空友楼中楼'; + override actionDescription = '删除一条空友评论的楼中楼回复(自己的回复)'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, comment: {}, reply: {} }; + override returnExample = {}; + override payloadSchema = DelReplyPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).deleteQzonePublicReply( + cookie, g_tk, payload.feed, + payload.comment as Record, + payload.reply as Record, + ); + } +} + +// 13. set_qzone_public_follow —— 空友关注/取关 +const FollowPayload = Type.Object({ feed: FeedObject, enable: Type.Boolean({ default: true }) }); +export class SetQzonePublicFollow extends OneBotAction, unknown> { + override actionName = ActionName.SetQzonePublicFollow; + override actionSummary = '空友关注/取关'; + override actionDescription = '关注/取关一条空友动态的作者'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, enable: true }; + override returnExample = {}; + override payloadSchema = FollowPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).setQzonePublicFollow(cookie, g_tk, payload.feed, payload.enable); + } +} + +// 14. forward_qzone_public_feed —— 转发空友动态 +const ForwardPayload = Type.Object({ feed: FeedObject, reason: Type.String({ description: '转发理由/附言' }) }); +export class ForwardQzonePublicFeed extends OneBotAction, unknown> { + override actionName = ActionName.ForwardQzonePublicFeed; + override actionSummary = '转发空友动态'; + override actionDescription = '转发一条空友推荐动态到自己的空间'; + override actionTags = ['扩展接口']; + override payloadExample = { feed: {}, reason: '转发' }; + override returnExample = {}; + override payloadSchema = ForwardPayload; + override returnSchema = Type.Any(); + + async _handle (payload: Static) { + const { g_tk, cookie } = await publicApi(this).getQzoneAuth(); + return publicApi(this).forwardQzonePublicFeed(cookie, g_tk, payload.feed, payload.reason); + } +} diff --git a/packages/snowcat-onebot/action/file/GetFile.ts b/packages/snowcat-onebot/action/file/GetFile.ts new file mode 100644 index 0000000..515d2c0 --- /dev/null +++ b/packages/snowcat-onebot/action/file/GetFile.ts @@ -0,0 +1,131 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import fs from 'fs/promises'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11MessageImage, OB11MessageVideo } from '@/snowcat-onebot/types'; +import { Static, Type } from '@sinclair/typebox'; +import { calculateTimeout } from '@/snowcat-onebot/config/config'; + +import { FileActionsExamples } from '../example/FileActionsExamples'; + +export const GetFilePayloadSchema = Type.Object({ + file: Type.Optional(Type.String({ description: '文件路径、URL或Base64' })), + file_id: Type.Optional(Type.String({ description: '文件ID' })), +}); + +export type GetFilePayload = Static; + +export const GetFileReturnSchema = Type.Object({ + file: Type.Optional(Type.String({ description: '本地路径' })), + url: Type.Optional(Type.String({ description: '下载URL' })), + file_size: Type.Optional(Type.String({ description: '文件大小' })), + file_name: Type.Optional(Type.String({ description: '文件名' })), + base64: Type.Optional(Type.String({ description: 'Base64编码' })), +}, { description: '文件信息' }); + +export type GetFileResponse = Static; + +export class GetFileBase extends OneBotAction { + override payloadSchema = GetFilePayloadSchema; + override returnSchema = GetFileReturnSchema; + override actionTags = ['文件接口']; + + async _handle (payload: GetFilePayload): Promise { + payload.file ||= payload.file_id || ''; + // 接收消息标记模式 + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file); + if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) { + const { peer, msgId, elementId } = contextMsgFile; + const downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', ''); + const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList + .find(msg => msg.msgId === msgId); + const mixElement = rawMessage?.elements.find(e => e.elementId === elementId); + const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement; + if (!mixElementInner) throw new Error('element not found'); + const fileSize = mixElementInner.fileSize?.toString() ?? ''; + const fileName = mixElementInner.fileName ?? ''; + let url = ''; + if (mixElement?.picElement && rawMessage) { + const tempData = + await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: true }) as OB11MessageImage | undefined; + url = tempData?.data.url ?? ''; + } + if (mixElement?.videoElement && rawMessage) { + const tempData = + await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: true }) as OB11MessageVideo | undefined; + url = tempData?.data.url ?? ''; + } + const res: GetFileResponse = { + file: downloadPath, + url: url !== '' ? url : downloadPath, + file_size: fileSize, + file_name: fileName, + }; + + if (this.obContext.configLoader.configData.enableLocalFile2Url && downloadPath) { + try { + res.base64 = await fs.readFile(downloadPath, 'base64'); + } catch (e) { + throw new Error('文件下载失败. ' + e); + } + } + return res; + } + + // 群文件模式 + const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(payload.file); + if (contextModelIdFile && contextModelIdFile.modelId) { + const { peer, modelId } = contextModelIdFile; + const downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, ''); + const res: GetFileResponse = { + file: downloadPath, + url: downloadPath, + file_size: '', + file_name: '', + }; + + if (this.obContext.configLoader.configData.enableLocalFile2Url && downloadPath) { + try { + res.base64 = await fs.readFile(downloadPath, 'base64'); + } catch (e) { + throw new Error('文件下载失败. ' + e); + } + } + return res; + } + + // 搜索名字模式 + const searchResult = (await this.core.apis.FileApi.searchForFile([payload.file])); + if (searchResult) { + const fileSize = parseInt(searchResult.fileSize); + const timeoutConfig = this.obContext.configLoader.configData.timeout; + const estimatedTime = calculateTimeout(timeoutConfig, fileSize, timeoutConfig.downloadSpeedKBps); + const downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, fileSize, estimatedTime); + const res: GetFileResponse = { + file: downloadPath, + url: downloadPath, + file_size: searchResult.fileSize.toString(), + file_name: searchResult.fileName, + }; + if (this.obContext.configLoader.configData.enableLocalFile2Url && downloadPath) { + try { + res.base64 = await fs.readFile(downloadPath, 'base64'); + } catch (e) { + throw new Error('文件下载失败. ' + e); + } + } + return res; + } + + throw new Error('file not found'); + } +} + +export default class GetFile extends GetFileBase { + override actionName = ActionName.GetFile; + override actionSummary = '获取文件'; + override actionDescription = '获取指定文件的详细信息及下载路径'; + override actionTags = ['文件接口']; + override payloadExample = FileActionsExamples.GetFile.payload; + override returnExample = FileActionsExamples.GetFile.response; +} diff --git a/packages/snowcat-onebot/action/file/GetGroupFileUrl.ts b/packages/snowcat-onebot/action/file/GetGroupFileUrl.ts new file mode 100644 index 0000000..f587108 --- /dev/null +++ b/packages/snowcat-onebot/action/file/GetGroupFileUrl.ts @@ -0,0 +1,40 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +import { FileActionsExamples } from '../example/FileActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file_id: Type.String({ description: '文件ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + url: Type.Optional(Type.String({ description: '文件下载链接' })), +}, { description: '群文件URL信息' }); + +type ReturnType = Static; + +export class GetGroupFileUrl extends GetPacketStatusDepends { + override actionName = ActionName.GOCQHTTP_GetGroupFileUrl; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群文件URL'; + override actionDescription = '获取指定群文件的下载链接'; + override actionTags = ['文件接口']; + override payloadExample = FileActionsExamples.GetGroupFileUrl.payload; + override returnExample = FileActionsExamples.GetGroupFileUrl.response; + + async _handle (payload: PayloadType) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + return { + url: await this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+payload.group_id, contextMsgFile.fileUUID), + }; + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/packages/snowcat-onebot/action/file/GetImage.ts b/packages/snowcat-onebot/action/file/GetImage.ts new file mode 100644 index 0000000..c5a14c8 --- /dev/null +++ b/packages/snowcat-onebot/action/file/GetImage.ts @@ -0,0 +1,13 @@ +import { GetFileBase } from './GetFile'; +import { ActionName } from '@/snowcat-onebot/action/router'; + +import { FileActionsExamples } from '../example/FileActionsExamples'; + +export default class GetImage extends GetFileBase { + override actionName = ActionName.GetImage; + override actionSummary = '获取图片'; + override actionDescription = '获取指定图片的信息及路径'; + override actionTags = ['文件接口']; + override payloadExample = FileActionsExamples.GetImage.payload; + override returnExample = FileActionsExamples.GetImage.response; +} diff --git a/packages/snowcat-onebot/action/file/GetPrivateFileUrl.ts b/packages/snowcat-onebot/action/file/GetPrivateFileUrl.ts new file mode 100644 index 0000000..9e836b1 --- /dev/null +++ b/packages/snowcat-onebot/action/file/GetPrivateFileUrl.ts @@ -0,0 +1,45 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +import { FileActionsExamples } from '../example/FileActionsExamples'; + +const PayloadSchema = Type.Object({ + file_id: Type.String({ description: '文件ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + url: Type.Optional(Type.String({ description: '文件下载链接' })), +}, { description: '私聊文件URL信息' }); + +type ReturnType = Static; + +export class GetPrivateFileUrl extends GetPacketStatusDepends { + override actionName = ActionName.NapCat_GetPrivateFileUrl; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取私聊文件URL'; + override actionDescription = '获取指定私聊文件的下载链接'; + override actionTags = ['文件接口']; + override payloadExample = FileActionsExamples.GetPrivateFileUrl.payload; + override returnExample = FileActionsExamples.GetPrivateFileUrl.response; + + async _handle (payload: PayloadType) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id); + + if (contextMsgFile?.fileUUID && contextMsgFile.msgId) { + const msg = await this.core.apis.MsgApi.getMsgsByMsgId(contextMsgFile.peer, [contextMsgFile.msgId]); + const self_id = this.core.selfInfo.uid; + const file_hash = msg.msgList[0]?.elements.map(ele => ele.fileElement?.file10MMd5)[0]; + if (file_hash) { + return { + url: await this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(self_id, contextMsgFile.fileUUID, file_hash), + }; + } + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/packages/snowcat-onebot/action/file/GetRecord.ts b/packages/snowcat-onebot/action/file/GetRecord.ts new file mode 100644 index 0000000..6e0be67 --- /dev/null +++ b/packages/snowcat-onebot/action/file/GetRecord.ts @@ -0,0 +1,55 @@ +import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { promises as fs } from 'fs'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; +import { Static, Type } from '@sinclair/typebox'; + +import { FileActionsExamples } from '../example/FileActionsExamples'; + +const out_format_list = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac']; + +const PayloadSchema = Type.Object({ + file: Type.Optional(Type.String({ description: '文件路径、URL或Base64' })), + file_id: Type.Optional(Type.String({ description: '文件ID' })), + out_format: Type.String({ description: '输出格式' }), +}); + +type PayloadType = Static; + +export default class GetRecord extends GetFileBase { + override actionName = ActionName.GetRecord; + override payloadSchema = PayloadSchema; + override actionSummary = '获取语音'; + override actionDescription = '获取指定语音文件的信息,并支持格式转换'; + override actionTags = ['文件接口']; + override payloadExample = FileActionsExamples.GetRecord.payload; + override returnExample = FileActionsExamples.GetRecord.response; + + override async _handle (payload: PayloadType): Promise { + const res = await super._handle(payload as GetFilePayload); + if (payload.out_format && typeof payload.out_format === 'string') { + const inputFile = res.file; + if (!inputFile) throw new Error('file not found'); + if (!out_format_list.includes(payload.out_format)) { + throw new Error('转换失败 out_format 字段可能格式不正确'); + } + const outputFile = `${inputFile}.${payload.out_format}`; + try { + await fs.access(inputFile); + try { + await fs.access(outputFile); + } catch { + await FFmpegService.convertAudioFmt(inputFile, outputFile, payload.out_format); + } + const base64Data = await fs.readFile(outputFile, { encoding: 'base64' }); + res.file = outputFile; + res.url = outputFile; + res.base64 = base64Data; + } catch (error) { + console.error('Error processing file:', error); + throw error; // 重新抛出错误以便调用者可以处理 + } + } + return res; + } +} diff --git a/packages/snowcat-onebot/action/file/flash/CreateFlashTask.ts b/packages/snowcat-onebot/action/file/flash/CreateFlashTask.ts new file mode 100644 index 0000000..49a6706 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/CreateFlashTask.ts @@ -0,0 +1,72 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import path from 'node:path'; + +const richMediaList = [ + '.mp4', '.mov', '.avi', '.wmv', '.mpeg', '.mpg', '.flv', '.mkv', + '.png', '.gif', '.jpg', '.jpeg', '.webp', '.bmp', +]; + +export const CreateFlashTaskPayloadSchema = Type.Object({ + files: Type.Union([ + Type.Array(Type.String()), + Type.String(), + ], { description: '文件列表或单个文件路径' }), + name: Type.Optional(Type.String({ description: '任务名称' })), + thumb_path: Type.Optional(Type.String({ description: '缩略图路径' })), +}); +export type CreateFlashTaskPayload = Static; + +export class CreateFlashTask extends OneBotAction { + override actionName = ActionName.CreateFlashTask; + override payloadSchema = CreateFlashTaskPayloadSchema; + override returnSchema = Type.Any({ description: '任务创建结果' }); + override actionSummary = '创建闪传任务'; + override actionTags = ['文件扩展']; + override payloadExample = { + files: 'C:\\test.jpg', + name: 'test_task', + }; + + override returnExample = { + task_id: 'task_123', + }; + + async _handle (payload: CreateFlashTaskPayload) { + const fileList = Array.isArray(payload.files) ? payload.files : [payload.files]; + let thumbPath: string = ''; + + if (fileList.length === 1) { + // 我是真没hook到那种合并的缩略图是哪个方法产生的,暂时不实现(怀疑是js直接canvas渲染的!!) // 确认了猜想 + const filePath = fileList[0]; + if (filePath === undefined) { + return {}; + } + const ext = path.extname(filePath).toLowerCase(); + + if (richMediaList.includes(ext)) { + try { + const res = await this.core.apis.FlashApi.createFileThumbnail(filePath); + if (res && typeof res === 'object' && 'result' in res && res.result === 0) { + thumbPath = res.targetPath as string; + } + } catch (_e) { + } + } + } + + function toPlatformPath (inputPath: string) { + const unifiedPath = inputPath.replace(/[\\/]/g, path.sep); + return path.normalize(unifiedPath); + } + + let normalPath: string; + if (payload.thumb_path !== undefined) { + normalPath = path.normalize(payload.thumb_path); + } else { + normalPath = toPlatformPath(thumbPath); + } + return await this.core.apis.FlashApi.createFlashTransferUploadTask(fileList, normalPath, payload.name || ''); + } +} diff --git a/packages/snowcat-onebot/action/file/flash/DownloadFileset.ts b/packages/snowcat-onebot/action/file/flash/DownloadFileset.ts new file mode 100644 index 0000000..91a7ec0 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/DownloadFileset.ts @@ -0,0 +1,27 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const DownloadFilesetPayloadSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), +}); + +export type DownloadFilesetPayload = Static; + +export class DownloadFileset extends OneBotAction { + override actionName = ActionName.DownloadFileset; + override payloadSchema = DownloadFilesetPayloadSchema; + override returnSchema = Type.Any({ description: '下载结果' }); + override actionSummary = '下载文件集'; + override actionTags = ['文件扩展']; + override payloadExample = { + fileset_id: 'set_123', + }; + + override returnExample = null; + + async _handle (payload: DownloadFilesetPayload) { + // 默认路径 / fileset_id /为下载路径 + return await this.core.apis.FlashApi.downloadFileSetBySetId(payload.fileset_id); + } +} diff --git a/packages/snowcat-onebot/action/file/flash/GetFilesetIdByCode.ts b/packages/snowcat-onebot/action/file/flash/GetFilesetIdByCode.ts new file mode 100644 index 0000000..ff581e0 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/GetFilesetIdByCode.ts @@ -0,0 +1,34 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetFilesetIdPayloadSchema = Type.Object({ + share_code: Type.String({ description: '分享码或分享链接' }), +}); + +export type GetFilesetIdPayload = Static; + +export class GetFilesetId extends OneBotAction { + override actionName = ActionName.GetFilesetId; + override payloadSchema = GetFilesetIdPayloadSchema; + override returnSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), + }); + + override actionSummary = '获取文件集 ID'; + override actionTags = ['文件扩展']; + override payloadExample = { + share_code: '123456', + }; + + override returnExample = { + fileset_id: 'set_123', + }; + + async _handle (payload: GetFilesetIdPayload) { + // 适配share_link 防止被传 Link无法解析 + const code = payload.share_code.includes('=') ? payload.share_code.split('=').slice(1).join('=') : payload.share_code; + const result = await this.core.apis.FlashApi.fromShareLinkFindSetId(code); + return { fileset_id: result.fileSetId }; + } +} diff --git a/packages/snowcat-onebot/action/file/flash/GetFilesetInfo.ts b/packages/snowcat-onebot/action/file/flash/GetFilesetInfo.ts new file mode 100644 index 0000000..c1b0920 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/GetFilesetInfo.ts @@ -0,0 +1,29 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetFilesetInfoPayloadSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), +}); + +export type GetFilesetInfoPayload = Static; + +export class GetFilesetInfo extends OneBotAction { + override actionName = ActionName.GetFilesetInfo; + override payloadSchema = GetFilesetInfoPayloadSchema; + override returnSchema = Type.Any({ description: '文件集信息' }); + override actionSummary = '获取文件集信息'; + override actionTags = ['文件扩展']; + override payloadExample = { + fileset_id: 'set_123', + }; + + override returnExample = { + fileset_id: 'set_123', + file_list: [], + }; + + async _handle (payload: GetFilesetInfoPayload) { + return await this.core.apis.FlashApi.getFileSetIndoBySetId(payload.fileset_id); + } +} diff --git a/packages/snowcat-onebot/action/file/flash/GetFlashFileList.ts b/packages/snowcat-onebot/action/file/flash/GetFlashFileList.ts new file mode 100644 index 0000000..d462860 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/GetFlashFileList.ts @@ -0,0 +1,31 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetFlashFileListPayloadSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), +}); + +export type GetFlashFileListPayload = Static; + +export class GetFlashFileList extends OneBotAction { + override actionName = ActionName.GetFlashFileList; + override payloadSchema = GetFlashFileListPayloadSchema; + override returnSchema = Type.Any({ description: '文件列表' }); + override actionSummary = '获取闪传文件列表'; + override actionTags = ['文件扩展']; + override payloadExample = { + fileset_id: 'set_123', + }; + + override returnExample = [ + { + file_name: 'test.jpg', + size: 1024, + }, + ]; + + async _handle (payload: GetFlashFileListPayload) { + return await this.core.apis.FlashApi.getFileListBySetId(payload.fileset_id); + } +} diff --git a/packages/snowcat-onebot/action/file/flash/GetFlashFileUrl.ts b/packages/snowcat-onebot/action/file/flash/GetFlashFileUrl.ts new file mode 100644 index 0000000..f8a7b4e --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/GetFlashFileUrl.ts @@ -0,0 +1,34 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetFlashFileUrlPayloadSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), + file_name: Type.Optional(Type.String({ description: '文件名' })), + file_index: Type.Optional(Type.Number({ description: '文件索引' })), +}); + +export type GetFlashFileUrlPayload = Static; + +export class GetFlashFileUrl extends OneBotAction { + override actionName = ActionName.GetFlashFileUrl; + override payloadSchema = GetFlashFileUrlPayloadSchema; + override returnSchema = Type.Any({ description: '文件下载链接' }); + override actionSummary = '获取闪传文件链接'; + override actionTags = ['文件扩展']; + override payloadExample = { + fileset_id: 'set_123', + }; + + override returnExample = { + url: 'http://example.com/flash.jpg', + }; + + async _handle (payload: GetFlashFileUrlPayload) { + // 文件的索引依旧从0开始 + return await this.core.apis.FlashApi.getFileTransUrl(payload.fileset_id, { + fileName: payload.file_name, + fileIndex: payload.file_index, + }); + } +} diff --git a/packages/snowcat-onebot/action/file/flash/GetShareLink.ts b/packages/snowcat-onebot/action/file/flash/GetShareLink.ts new file mode 100644 index 0000000..9a258e6 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/GetShareLink.ts @@ -0,0 +1,26 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetShareLinkPayloadSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), +}); + +export type GetShareLinkPayload = Static; + +export class GetShareLink extends OneBotAction { + override actionName = ActionName.GetShareLink; + override payloadSchema = GetShareLinkPayloadSchema; + override returnSchema = Type.Any({ description: '分享链接' }); + override actionSummary = '获取文件分享链接'; + override actionTags = ['文件扩展']; + override payloadExample = { + fileset_id: 'set_123', + }; + + override returnExample = 'http://example.com/share'; + + async _handle (payload: GetShareLinkPayload) { + return await this.core.apis.FlashApi.getShareLinkBySetId(payload.fileset_id); + } +} diff --git a/packages/snowcat-onebot/action/file/flash/SendFlashMsg.ts b/packages/snowcat-onebot/action/file/flash/SendFlashMsg.ts new file mode 100644 index 0000000..a510ae7 --- /dev/null +++ b/packages/snowcat-onebot/action/file/flash/SendFlashMsg.ts @@ -0,0 +1,50 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType, Peer } from 'snowcat-core/types'; + +export const SendFlashMsgPayloadSchema = Type.Object({ + fileset_id: Type.String({ description: '文件集 ID' }), + user_id: Type.Optional(Type.String({ description: '用户 QQ' })), + group_id: Type.Optional(Type.String({ description: '群号' })), +}); + +export type SendFlashMsgPayload = Static; + +export class SendFlashMsg extends OneBotAction { + override actionName = ActionName.SendFlashMsg; + override payloadSchema = SendFlashMsgPayloadSchema; + override returnSchema = Type.Any({ description: '发送结果' }); + override actionSummary = '发送闪传消息'; + override actionTags = ['文件扩展']; + override payloadExample = { + fileset_id: 'set_123', + user_id: '123456789', + }; + + override returnExample = { + message_id: 123456, + }; + + async _handle (payload: SendFlashMsgPayload) { + let peer: Peer; + + if (payload.group_id) { + peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() }; + } else if (payload.user_id) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + // 可能需要更严格的判断 + const isBuddy = await this.core.apis.FriendApi.isBuddy(uid); + peer = { + chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, + peerUid: uid, + }; + } else { + throw new Error('user_id or group_id is required'); + } + + return await this.core.apis.FlashApi.sendFlashMessage(payload.fileset_id, peer); + } +} diff --git a/packages/snowcat-onebot/action/file/online/CancelOnlineFile.ts b/packages/snowcat-onebot/action/file/online/CancelOnlineFile.ts new file mode 100644 index 0000000..f2fb75c --- /dev/null +++ b/packages/snowcat-onebot/action/file/online/CancelOnlineFile.ts @@ -0,0 +1,35 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType } from 'snowcat-core/types'; + +export const CancelOnlineFilePayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), + msg_id: Type.String({ description: '消息 ID' }), +}); + +export type CancelOnlineFilePayload = Static; + +export class CancelOnlineFile extends OneBotAction { + override actionName = ActionName.CancelOnlineFile; + override payloadSchema = CancelOnlineFilePayloadSchema; + override returnSchema = Type.Any({ description: '取消结果' }); + override actionSummary = '取消在线文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + user_id: '123456789', + msg_id: '123', + }; + + override returnExample = null; + + async _handle (payload: CancelOnlineFilePayload) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + // 仅私聊 + const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid }; + + return await this.core.apis.OnlineApi.cancelMyOnlineFileMsg(peer, payload.msg_id); + } +} diff --git a/packages/snowcat-onebot/action/file/online/GetOnlineFileMessages.ts b/packages/snowcat-onebot/action/file/online/GetOnlineFileMessages.ts new file mode 100644 index 0000000..4e24e50 --- /dev/null +++ b/packages/snowcat-onebot/action/file/online/GetOnlineFileMessages.ts @@ -0,0 +1,33 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType } from 'snowcat-core/types'; + +export const GetOnlineFileMessagesPayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), +}); + +export type GetOnlineFileMessagesPayload = Static; + +export class GetOnlineFileMessages extends OneBotAction { + override actionName = ActionName.GetOnlineFileMessages; + override payloadSchema = GetOnlineFileMessagesPayloadSchema; + override returnSchema = Type.Any({ description: '在线文件消息列表' }); + override actionSummary = '获取在线文件消息'; + override actionTags = ['文件扩展']; + override payloadExample = { + user_id: '123456789', + }; + + override returnExample = []; + + async _handle (payload: GetOnlineFileMessagesPayload) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + // 仅私聊 + const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid }; + + return await this.core.apis.OnlineApi.getOnlineFileMsg(peer); + } +} diff --git a/packages/snowcat-onebot/action/file/online/ReceiveOnlineFile.ts b/packages/snowcat-onebot/action/file/online/ReceiveOnlineFile.ts new file mode 100644 index 0000000..7a6913d --- /dev/null +++ b/packages/snowcat-onebot/action/file/online/ReceiveOnlineFile.ts @@ -0,0 +1,37 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType } from 'snowcat-core/types'; + +export const ReceiveOnlineFilePayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), + msg_id: Type.String({ description: '消息 ID' }), + element_id: Type.String({ description: '元素 ID' }), +}); + +export type ReceiveOnlineFilePayload = Static; + +export class ReceiveOnlineFile extends OneBotAction { + override actionName = ActionName.ReceiveOnlineFile; + override payloadSchema = ReceiveOnlineFilePayloadSchema; + override returnSchema = Type.Any({ description: '接收结果' }); + override actionSummary = '接收在线文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + user_id: '123456789', + msg_id: '123', + save_path: 'C:\\save', + }; + + override returnExample = null; + + async _handle (payload: ReceiveOnlineFilePayload) { + // 默认下载路径 + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid }; + + return await this.core.apis.OnlineApi.receiveOnlineFileOrFolder(peer, payload.msg_id, payload.element_id); + } +} diff --git a/packages/snowcat-onebot/action/file/online/RefuseOnlineFile.ts b/packages/snowcat-onebot/action/file/online/RefuseOnlineFile.ts new file mode 100644 index 0000000..4e48f52 --- /dev/null +++ b/packages/snowcat-onebot/action/file/online/RefuseOnlineFile.ts @@ -0,0 +1,35 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType } from 'snowcat-core/types'; + +export const RefuseOnlineFilePayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), + msg_id: Type.String({ description: '消息 ID' }), + element_id: Type.String({ description: '元素 ID' }), +}); + +export type RefuseOnlineFilePayload = Static; + +export class RefuseOnlineFile extends OneBotAction { + override actionName = ActionName.RefuseOnlineFile; + override payloadSchema = RefuseOnlineFilePayloadSchema; + override returnSchema = Type.Any({ description: '拒绝结果' }); + override actionSummary = '拒绝在线文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + user_id: '123456789', + msg_id: '123', + }; + + override returnExample = null; + + async _handle (payload: RefuseOnlineFilePayload) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid }; + + return await this.core.apis.OnlineApi.refuseOnlineFileMsg(peer, payload.msg_id, payload.element_id); + } +} diff --git a/packages/snowcat-onebot/action/file/online/SendOnlineFile.ts b/packages/snowcat-onebot/action/file/online/SendOnlineFile.ts new file mode 100644 index 0000000..5f06a37 --- /dev/null +++ b/packages/snowcat-onebot/action/file/online/SendOnlineFile.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType } from 'snowcat-core/types'; + +export const SendOnlineFilePayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), + file_path: Type.String({ description: '本地文件路径' }), + file_name: Type.Optional(Type.String({ description: '文件名 (可选)' })), +}); + +export type SendOnlineFilePayload = Static; + +export class SendOnlineFile extends OneBotAction { + override actionName = ActionName.SendOnlineFile; + override payloadSchema = SendOnlineFilePayloadSchema; + override returnSchema = Type.Any({ description: '发送结果' }); + override actionSummary = '发送在线文件'; + override actionTags = ['文件扩展']; + override payloadExample = { + user_id: '123456789', + file_path: 'C:\\path\\to\\file.txt', + file_name: 'test.txt', + }; + + override returnExample = null; + + async _handle (payload: SendOnlineFilePayload) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + // 仅私聊 + const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid }; + const fileName = payload.file_name || ''; + + return await this.core.apis.OnlineApi.sendOnlineFile(peer, payload.file_path, fileName); + } +} diff --git a/packages/snowcat-onebot/action/file/online/SendOnlineFolder.ts b/packages/snowcat-onebot/action/file/online/SendOnlineFolder.ts new file mode 100644 index 0000000..63555a6 --- /dev/null +++ b/packages/snowcat-onebot/action/file/online/SendOnlineFolder.ts @@ -0,0 +1,35 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType } from 'snowcat-core/types'; + +export const SendOnlineFolderPayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), + folder_path: Type.String({ description: '本地文件夹路径' }), + folder_name: Type.Optional(Type.String({ description: '文件夹名称 (可选)' })), +}); + +export type SendOnlineFolderPayload = Static; + +export class SendOnlineFolder extends OneBotAction { + override actionName = ActionName.SendOnlineFolder; + override payloadSchema = SendOnlineFolderPayloadSchema; + override returnSchema = Type.Any({ description: '发送结果' }); + override actionSummary = '发送在线文件夹'; + override actionTags = ['文件扩展']; + override payloadExample = { + user_id: '123456789', + folder_path: 'C:\\path\\to\\folder', + }; + + override returnExample = null; + + async _handle (payload: SendOnlineFolderPayload) { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('User not found'); + + const peer = { chatType: ChatType.KCHATTYPEC2C, peerUid: uid }; + + return await this.core.apis.OnlineApi.sendOnlineFolder(peer, payload.folder_path, payload.folder_name); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/CreateGroupFileFolder.ts b/packages/snowcat-onebot/action/go-cqhttp/CreateGroupFileFolder.ts new file mode 100644 index 0000000..57cbf2a --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/CreateGroupFileFolder.ts @@ -0,0 +1,43 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + // 兼容gocq 与name二选一 + folder_name: Type.Optional(Type.String({ description: '文件夹名称' })), + // 兼容gocq 与folder_name二选一 + name: Type.Optional(Type.String({ description: '文件夹名称' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + result: Type.Any({ description: '操作结果' }), + groupItem: Type.Any({ description: '群项信息' }), +}, { description: '创建文件夹结果' }); + +type ReturnType = Static; + +export class CreateGroupFileFolder extends OneBotAction { + override actionName = ActionName.GoCQHTTP_CreateGroupFileFolder; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '创建群文件目录'; + override actionDescription = '在群文件系统中创建新的文件夹'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = { + group_id: '123456789', + folder_name: '新建文件夹', + }; + + override returnExample = { + result: {}, + groupItem: {}, + }; + + async _handle (payload: PayloadType) { + const folderName = payload.folder_name || payload.name; + return (await this.core.apis.GroupApi.creatGroupFileFolder(payload.group_id.toString(), folderName!)).resultWithGroupItem; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/DeleteGroupFile.ts b/packages/snowcat-onebot/action/go-cqhttp/DeleteGroupFile.ts new file mode 100644 index 0000000..74401db --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/DeleteGroupFile.ts @@ -0,0 +1,33 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file_id: Type.String({ description: '文件ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '删除结果' }); + +type ReturnType = Static; + +export class DeleteGroupFile extends OneBotAction { + override actionName = ActionName.GOCQHTTP_DeleteGroupFile; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '删除群文件'; + override actionDescription = '在群文件系统中删除指定的文件'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.DeleteGroupFile.payload; + override returnExample = GoCQHTTPActionsExamples.DeleteGroupFile.response; + + async _handle (payload: PayloadType) { + const data = FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (!data || !data.fileId) throw new Error('Invalid file_id'); + return await this.core.apis.GroupApi.delGroupFile(payload.group_id.toString(), [data.fileId]); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/DeleteGroupFileFolder.ts b/packages/snowcat-onebot/action/go-cqhttp/DeleteGroupFileFolder.ts new file mode 100644 index 0000000..c74f818 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/DeleteGroupFileFolder.ts @@ -0,0 +1,32 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + folder_id: Type.Optional(Type.String({ description: '文件夹ID' })), + folder: Type.Optional(Type.String({ description: '文件夹ID' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '删除结果' }); + +type ReturnType = Static; + +export class DeleteGroupFileFolder extends OneBotAction { + override actionName = ActionName.GoCQHTTP_DeleteGroupFileFolder; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '删除群文件目录'; + override actionDescription = '在群文件系统中删除指定的文件夹'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.DeleteGroupFileFolder.payload; + override returnExample = GoCQHTTPActionsExamples.DeleteGroupFileFolder.response; + + async _handle (payload: PayloadType) { + return (await this.core.apis.GroupApi.delGroupFileFolder( + payload.group_id.toString(), payload.folder ?? payload.folder_id ?? '')).groupFileCommonResult; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/DownloadFile.ts b/packages/snowcat-onebot/action/go-cqhttp/DownloadFile.ts new file mode 100644 index 0000000..e108d76 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/DownloadFile.ts @@ -0,0 +1,87 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import fs from 'fs'; +import { join as joinPath } from 'node:path'; +import { calculateFileMD5, uriToLocalFile } from 'snowcat-common/src/file'; +import { randomUUID } from 'crypto'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + url: Type.Optional(Type.String({ description: '下载链接' })), + base64: Type.Optional(Type.String({ description: 'base64数据' })), + name: Type.Optional(Type.String({ description: '文件名' })), + headers: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: '请求头' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + file: Type.String({ description: '文件路径' }), +}, { description: '下载结果' }); + +type ReturnType = Static; + +export default class GoCQHTTPDownloadFile extends OneBotAction { + override actionName = ActionName.GoCQHTTP_DownloadFile; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '下载文件'; + override actionDescription = '下载网络文件到本地临时目录'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.DownloadFile.payload; + override returnExample = GoCQHTTPActionsExamples.DownloadFile.response; + + async _handle (payload: PayloadType): Promise { + const isRandomName = !payload.name; + const name = payload.name || randomUUID(); + let result: Awaited>; + + if (payload.base64) { + result = await uriToLocalFile(this.core.SnowCatTempPath, `base64://${payload.base64}`, name); + } else if (payload.url) { + const headers = this.getHeaders(payload.headers); + result = await uriToLocalFile(this.core.SnowCatTempPath, payload.url, name, headers); + } else { + throw new Error('不存在任何文件, 无法下载'); + } + if (!result.success) { + throw new Error(result.errMsg); + } + const filePath = result.path; + if (fs.existsSync(filePath)) { + if (isRandomName) { + // 默认实现要名称未填写时文件名为文件 md5 + const md5 = await calculateFileMD5(filePath); + const newPath = joinPath(this.core.SnowCatTempPath, md5); + fs.renameSync(filePath, newPath); + return { file: newPath }; + } + return { file: filePath }; + } else { + throw new Error('文件写入失败, 检查权限'); + } + } + + getHeaders (headersIn?: string | string[]): Record { + const headers: Record = {}; + if (typeof headersIn === 'string') { + headersIn = headersIn.split('[\\r\\n]'); + } + if (Array.isArray(headersIn)) { + for (const headerItem of headersIn) { + const spilt = headerItem.indexOf('='); + if (spilt < 0) { + headers[headerItem] = ''; + } else { + const key = headerItem.substring(0, spilt); + headers[key] = headerItem.substring(spilt + 1); + } + } + } + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/octet-stream'; + } + return headers; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts b/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts new file mode 100644 index 0000000..4f81c99 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts @@ -0,0 +1,170 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { OB11Message, OB11MessageData, OB11MessageDataType, OB11MessageForward, OB11MessageNodePlain as OB11MessageNode } from '@/snowcat-onebot/index'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; +import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from 'snowcat-core'; +import { isNumeric } from 'snowcat-common/src/helper'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + message_id: Type.Optional(Type.String({ description: '消息ID' })), + id: Type.Optional(Type.String({ description: '消息ID' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + messages: Type.Optional(Type.Array(Type.Unknown(), { description: '消息列表' })), +}, { description: '合并转发消息' }); + +type ReturnType = Static; + +function isForward (msg: OB11MessageData | string): msg is OB11MessageForward { + return typeof msg !== 'string' && msg.type === OB11MessageDataType.forward; +} + +export class GoCQHTTPGetForwardMsgAction extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetForwardMsg; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取合并转发消息'; + override actionDescription = '获取合并转发消息的具体内容'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetForwardMsg.payload; + override returnExample = GoCQHTTPActionsExamples.GetForwardMsg.response; + + private createTemplateNode (message: OB11Message): OB11MessageNode { + return { + type: OB11MessageDataType.node, + data: { + user_id: message.user_id, + nickname: message.sender.nickname, + message: [], + content: [], + }, + }; + } + + async parseForward (messages: OB11Message[]): Promise { + const retMsg: OB11MessageNode[] = []; + + for (const message of messages) { + const templateNode = this.createTemplateNode(message); + + for (const msgdata of message.message) { + if (isForward(msgdata)) { + const newNode = this.createTemplateNode(message); + newNode.data.message = await this.parseForward(msgdata.data.content ?? []); + + templateNode.data.message.push(newNode); + } else { + if (typeof msgdata === 'string') { + const textMsg: OB11MessageData = { type: OB11MessageDataType.text, data: { text: msgdata } }; + templateNode.data.message.push(textMsg); + } else { + templateNode.data.message.push(msgdata); + } + } + } + retMsg.push(templateNode); + } + + return retMsg; + } + + async _handle (payload: PayloadType) { + // 1. 检查消息ID是否存在 + const msgId = payload.message_id || payload.id; + if (!msgId) { + throw new Error('message_id is required'); + } + + // 2. 定义辅助函数 - 创建伪转发消息对象 + const createFakeForwardMsg = (resId: string): RawMessage => { + const fakeMsg: RawMessage = { + chatType: ChatType.KCHATTYPEGROUP, + elements: [{ + elementType: ElementType.MULTIFORWARD, + elementId: '', + multiForwardMsgElement: { + resId, + fileName: '', + xmlContent: '', + }, + }], + guildId: '', + isOnlineMsg: false, + msgId: '', // TODO: no necessary + msgRandom: '0', + msgSeq: '', + msgTime: '', + msgType: NTMsgType.KMSGTYPEMIX, + parentMsgIdList: [], + parentMsgPeer: { + chatType: ChatType.KCHATTYPEGROUP, + peerUid: '', + }, + peerName: '', + peerUid: '284840486', + peerUin: '284840486', + recallTime: '0', + records: [], + sendNickName: '', + sendRemarkName: '', + senderUid: '', + senderUin: '1094950020', + sourceType: MsgSourceType.K_DOWN_SOURCETYPE_UNKNOWN, + subMsgType: 1, + }; + return fakeMsg; + }; + + // 3. 定义协议回退逻辑函数 + const protocolFallbackLogic = async (resId: string) => { + const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId), true))?.arrayMsg; + const firstMsg = ob?.message?.[0]; + if (firstMsg && isForward(firstMsg)) { + return { + messages: firstMsg.data.content, + }; + } + throw new Error('protocolFallbackLogic: 找不到相关的聊天记录'); + }; + // 4. 尝试通过正常渠道获取消息 + // 如果是数字ID,优先使用getMsgsByMsgId获取 + if (!isNumeric(msgId)) { + const ret = await protocolFallbackLogic(msgId); + if (ret.messages) { + return ret; + } + throw new Error('ResId无效: 找不到相关的聊天记录'); + } + const rootMsgId = MessageUnique.getShortIdByMsgId(msgId.toString()); + const rootMsg = MessageUnique.getMsgIdAndPeerByShortId(rootMsgId ?? +msgId); + + if (rootMsg) { + // 5. 获取消息内容 + const data = await this.core.apis.MsgApi.getMsgHistory(rootMsg.Peer, rootMsg.MsgId, 1);// getMsgsIncludeSelf + + if (data && data.result === 0 && data.msgList.length > 0) { + const singleMsg = data.msgList[0]; + if (!singleMsg) { + throw new Error('消息不存在或已过期'); + } + // 6. 解析消息内容 + const resMsg = (await this.obContext.apis.MsgApi.parseMessage(singleMsg, 'array', true)); + + const firstMsg = resMsg?.message?.[0]; + if (firstMsg && isForward(firstMsg)) { + const forwardContent = firstMsg.data.content; + if (forwardContent) { + return { messages: forwardContent }; + } + } + } + } + // 说明消息已过期或者为内层消息 NapCat 一次返回不处理内层消息 + throw new Error('消息已过期或者为内层消息,无法获取转发消息'); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetFriendMsgHistory.ts b/packages/snowcat-onebot/action/go-cqhttp/GetFriendMsgHistory.ts new file mode 100644 index 0000000..54de1e6 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetFriendMsgHistory.ts @@ -0,0 +1,63 @@ +import type { MessageFormat } from '@/snowcat-onebot/config/config'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { OB11Message } from '@/snowcat-onebot/index'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ChatType } from 'snowcat-core/types'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; + +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户QQ' }), + message_seq: Type.Optional(Type.String({ description: '起始消息序号' })), + count: Type.Number({ default: 20, description: '获取消息数量' }), + reverse_order: Type.Boolean({ default: false, description: '是否反向排序' }), + disable_get_url: Type.Boolean({ default: false, description: '是否禁用获取URL' }), + parse_mult_msg: Type.Boolean({ default: true, description: '是否解析合并消息' }), + quick_reply: Type.Boolean({ default: false, description: '是否快速回复' }), + reverseOrder: Type.Boolean({ default: false, description: '是否反向排序(旧版本兼容)' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + messages: Type.Array(Type.Any(), { description: '消息列表' }), +}, { description: '好友历史消息' }); + +type ReturnType = Static; + +export default class GetFriendMsgHistory extends OneBotAction { + override actionName = ActionName.GetFriendMsgHistory; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取好友历史消息'; + override actionDescription = '获取指定好友的历史聊天记录'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetFriendMsgHistory.payload; + override returnExample = GoCQHTTPActionsExamples.GetFriendMsgHistory.response; + + async _handle (payload: PayloadType, _adapter: string, config: NetworkAdapterConfig): Promise { + // 处理参数 + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error(`记录${payload.user_id}不存在`); + const friend = await this.core.apis.FriendApi.isBuddy(uid); + const peer = { chatType: friend ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid: uid }; + const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); + const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; + const msgList = hasMessageSeq + ? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList + : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; + if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); + // 转换序号 + await Promise.all(msgList.map(async msg => { + msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId); + })); + // 烘焙消息 + const ob11MsgList = (await Promise.all( + msgList.map(msg => this.obContext.apis.MsgApi.parseMessage(msg, config['messagePostFormat'] as MessageFormat | undefined ?? 'array', payload.parse_mult_msg, payload.disable_get_url))) + ).filter((msg): msg is OB11Message => msg !== undefined); + return { messages: ob11MsgList }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetGroupAtAllRemain.ts b/packages/snowcat-onebot/action/go-cqhttp/GetGroupAtAllRemain.ts new file mode 100644 index 0000000..7cb3a64 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetGroupAtAllRemain.ts @@ -0,0 +1,39 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + can_at_all: Type.Boolean({ description: '是否可以艾特全体' }), + remain_at_all_count_for_group: Type.Number({ description: '群艾特全体剩余次数' }), + remain_at_all_count_for_uin: Type.Number({ description: '个人艾特全体剩余次数' }), +}, { description: '群艾特全体剩余次数' }); + +type ReturnType = Static; + +export class GoCQHTTPGetGroupAtAllRemain extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetGroupAtAllRemain; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群艾特全体剩余次数'; + override actionDescription = '获取指定群聊中艾特全体成员的剩余次数'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetGroupAtAllRemain.payload; + override returnExample = GoCQHTTPActionsExamples.GetGroupAtAllRemain.response; + + async _handle (payload: PayloadType) { + const ret = await this.core.apis.GroupApi.getGroupRemainAtTimes(payload.group_id.toString()); + const data = { + can_at_all: ret.atInfo.canAtAll, + remain_at_all_count_for_group: ret.atInfo.RemainAtAllCountForGroup, + remain_at_all_count_for_uin: ret.atInfo.RemainAtAllCountForUin, + }; + return data; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetGroupFileSystemInfo.ts b/packages/snowcat-onebot/action/go-cqhttp/GetGroupFileSystemInfo.ts new file mode 100644 index 0000000..ec0908b --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetGroupFileSystemInfo.ts @@ -0,0 +1,50 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + file_count: Type.Number({ description: '文件总数' }), + limit_count: Type.Number({ description: '文件上限' }), + used_space: Type.Number({ description: '已使用空间' }), + total_space: Type.Number({ description: '总空间' }), +}, { description: '群文件系统信息' }); + +type ReturnType = Static; + +export class GetGroupFileSystemInfo extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetGroupFileSystemInfo; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群文件系统信息'; + override actionDescription = '获取群聊文件系统的空间及状态信息'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetGroupFileSystemInfo.payload; + override returnExample = GoCQHTTPActionsExamples.GetGroupFileSystemInfo.response; + + async _handle (payload: PayloadType) { + const groupCode = payload.group_id.toString(); + const [countResult, space] = await Promise.all([ + this.core.apis.GroupApi.getGroupFileCount([groupCode]), + this.core.apis.GroupApi.getGroupFileSpace(groupCode), + ]); + const groupFileCount = countResult.groupFileCounts[0]; + if (groupFileCount === undefined) { + throw new Error('Group not found'); + } + return { + file_count: groupFileCount, + limit_count: 10000, + // Real values from the kernel's groupSpaceResult; fall back to the old + // placeholders if the field is unexpectedly absent. + used_space: space?.usedSpace ?? 0, + total_space: space?.totalSpace ?? 10 * 1024 * 1024 * 1024, + }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetGroupFilesByFolder.ts b/packages/snowcat-onebot/action/go-cqhttp/GetGroupFilesByFolder.ts new file mode 100644 index 0000000..818a5e4 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetGroupFilesByFolder.ts @@ -0,0 +1,49 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + folder_id: Type.Optional(Type.String({ description: '文件夹ID' })), + folder: Type.Optional(Type.String({ description: '文件夹ID' })), + file_count: Type.Union([Type.Number(), Type.String()], { default: 50, description: '文件数量' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + files: Type.Array(Type.Unknown(), { description: '文件列表' }), + folders: Type.Array(Type.Unknown(), { description: '文件夹列表' }), +}, { description: '群文件夹文件列表' }); + +type ReturnType = Static; + +export class GetGroupFilesByFolder extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetGroupFilesByFolder; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群文件夹文件列表'; + override actionDescription = '获取指定群文件夹下的文件及子文件夹列表'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetGroupFilesByFolder.payload; + override returnExample = GoCQHTTPActionsExamples.GetGroupFilesByFolder.response; + + async _handle (payload: PayloadType): Promise { + const retRaw = await this.core.apis.MsgApi.getGroupFileList(payload.group_id.toString(), { + sortType: 1, + fileCount: +payload.file_count, + startIndex: 0, + sortOrder: 2, + showOnlinedocFolder: 0, + folderId: payload.folder ?? payload.folder_id ?? '', + }); + const ret = Array.isArray(retRaw) ? retRaw : []; + return { + files: ret.filter(item => item.fileInfo) + .map(item => OB11Construct.file(item.peerId, item.fileInfo!)), + folders: [], + }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetGroupHonorInfo.ts b/packages/snowcat-onebot/action/go-cqhttp/GetGroupHonorInfo.ts new file mode 100644 index 0000000..19bb3f6 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetGroupHonorInfo.ts @@ -0,0 +1,42 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { WebHonorType } from 'snowcat-core/types'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + type: Type.Optional(Type.Enum(WebHonorType, { description: '荣誉类型' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + group_id: Type.Number({ description: '群号' }), + current_talkative: Type.Record(Type.String(), Type.Unknown(), { description: '当前龙王' }), + talkative_list: Type.Array(Type.Unknown(), { description: '龙王列表' }), + performer_list: Type.Array(Type.Unknown(), { description: '群聊之火列表' }), + legend_list: Type.Array(Type.Unknown(), { description: '群聊炽热列表' }), + emotion_list: Type.Array(Type.Unknown(), { description: '快乐源泉列表' }), + strong_newbie_list: Type.Array(Type.Unknown(), { description: '冒尖小春笋列表' }), +}, { description: '群荣誉信息' }); + +type ReturnType = Static; + +export class GetGroupHonorInfo extends OneBotAction { + override actionName = ActionName.GetGroupHonorInfo; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群荣誉信息'; + override actionDescription = '获取指定群聊的荣誉信息,如龙王等'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetGroupHonorInfo.payload; + override returnExample = GoCQHTTPActionsExamples.GetGroupHonorInfo.response; + + async _handle (payload: PayloadType): Promise { + if (!payload.type) { + payload.type = WebHonorType.ALL; + } + return await this.core.apis.WebApi.getGroupHonorInfo(payload.group_id.toString(), payload.type); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetGroupMsgHistory.ts b/packages/snowcat-onebot/action/go-cqhttp/GetGroupMsgHistory.ts new file mode 100644 index 0000000..ab87000 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetGroupMsgHistory.ts @@ -0,0 +1,59 @@ +import type { MessageFormat } from '@/snowcat-onebot/config/config'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { OB11Message } from '@/snowcat-onebot/index'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ChatType, Peer } from 'snowcat-core/types'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + message_seq: Type.Optional(Type.String({ description: '起始消息序号' })), + count: Type.Number({ default: 20, description: '获取消息数量' }), + reverse_order: Type.Boolean({ default: false, description: '是否反向排序' }), + disable_get_url: Type.Boolean({ default: false, description: '是否禁用获取URL' }), + parse_mult_msg: Type.Boolean({ default: true, description: '是否解析合并消息' }), + quick_reply: Type.Boolean({ default: false, description: '是否快速回复' }), + reverseOrder: Type.Boolean({ default: false, description: '是否反向排序(旧版本兼容)' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + messages: Type.Array(Type.Any(), { description: '消息列表' }), +}, { description: '群历史消息' }); + +type ReturnType = Static; + +export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetGroupMsgHistory; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群历史消息'; + override actionDescription = '获取指定群聊的历史聊天记录'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetGroupMsgHistory.payload; + override returnExample = GoCQHTTPActionsExamples.GetGroupMsgHistory.response; + + async _handle (payload: PayloadType, _adapter: string, config: NetworkAdapterConfig): Promise { + const peer: Peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() }; + const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); + // 拉取消息 + const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; + const msgList = hasMessageSeq + ? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList + : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; + if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); + // 转换序号 + await Promise.all(msgList.map(async msg => { + msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId); + })); + // 烘焙消息 + const ob11MsgList = (await Promise.all( + msgList.map(msg => this.obContext.apis.MsgApi.parseMessage(msg, config['messagePostFormat'] as MessageFormat | undefined ?? 'array', payload.parse_mult_msg, payload.disable_get_url, payload.quick_reply))) + ).filter((msg): msg is OB11Message => msg !== undefined); + return { messages: ob11MsgList }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetGroupRootFiles.ts b/packages/snowcat-onebot/action/go-cqhttp/GetGroupRootFiles.ts new file mode 100644 index 0000000..7a756fd --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetGroupRootFiles.ts @@ -0,0 +1,47 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file_count: Type.Union([Type.Number(), Type.String()], { default: 50, description: '文件数量' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + files: Type.Array(Type.Any(), { description: '文件列表' }), + folders: Type.Array(Type.Any(), { description: '文件夹列表' }), +}, { description: '群根目录文件列表' }); + +type ReturnType = Static; + +export class GetGroupRootFiles extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetGroupRootFiles; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群根目录文件列表'; + override actionDescription = '获取群文件根目录下的所有文件和文件夹'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetGroupRootFiles.payload; + override returnExample = GoCQHTTPActionsExamples.GetGroupRootFiles.response; + + async _handle (payload: PayloadType) { + const ret = await this.core.apis.MsgApi.getGroupFileList(payload.group_id.toString(), { + sortType: 1, + fileCount: +payload.file_count, + startIndex: 0, + sortOrder: 2, + showOnlinedocFolder: 0, + }); + + return { + files: ret.filter(item => item.fileInfo) + .map(item => OB11Construct.file(item.peerId, item.fileInfo!)), + folders: ret.filter(item => item.folderInfo) + .map(item => OB11Construct.folder(item.peerId, item.folderInfo!)), + }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetOnlineClient.ts b/packages/snowcat-onebot/action/go-cqhttp/GetOnlineClient.ts new file mode 100644 index 0000000..3e98cd2 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetOnlineClient.ts @@ -0,0 +1,32 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { sleep } from 'snowcat-common/src/helper'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({}, { description: '在线客户端负载' }); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(Type.Any(), { description: '在线客户端列表' }); + +type ReturnType = Static; + +export class GetOnlineClient extends OneBotAction { + override actionName = ActionName.GetOnlineClient; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取在线客户端'; + override actionDescription = '获取当前登录账号的在线客户端列表'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetOnlineClient.payload; + override returnExample = GoCQHTTPActionsExamples.GetOnlineClient.response; + + async _handle () { + // 注册监听 + this.core.apis.SystemApi.getOnlineDev(); + await sleep(500); + + return []; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetStrangerInfo.ts b/packages/snowcat-onebot/action/go-cqhttp/GetStrangerInfo.ts new file mode 100644 index 0000000..13e3272 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GetStrangerInfo.ts @@ -0,0 +1,76 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { OB11UserSex } from '@/snowcat-onebot/index'; +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { calcQQLevel } from 'snowcat-common/src/helper'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +const PayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户QQ' }), + no_cache: Type.Union([Type.Boolean(), Type.String()], { default: false, description: '是否不使用缓存' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + user_id: Type.Number({ description: '用户QQ' }), + uid: Type.String({ description: 'UID' }), + nickname: Type.String({ description: '昵称' }), + age: Type.Number({ description: '年龄' }), + qid: Type.String({ description: 'QID' }), + qqLevel: Type.Number({ description: 'QQ等级' }), + sex: Type.String({ description: '性别' }), + long_nick: Type.String({ description: '个性签名' }), + reg_time: Type.Number({ description: '注册时间' }), + is_vip: Type.Boolean({ description: '是否VIP' }), + is_years_vip: Type.Boolean({ description: '是否年费VIP' }), + vip_level: Type.Number({ description: 'VIP等级' }), + remark: Type.String({ description: '备注' }), + status: Type.Number({ description: '状态' }), + login_days: Type.Number({ description: '登录天数' }), +}, { description: '陌生人信息' }); + +type ReturnType = Static; + +export default class GoCQHTTPGetStrangerInfo extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetStrangerInfo; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取陌生人信息'; + override actionDescription = '获取指定非好友用户的信息'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GetStrangerInfo.payload; + override returnExample = GoCQHTTPActionsExamples.GetStrangerInfo.response; + + async _handle (payload: PayloadType): Promise { + const user_id = payload.user_id.toString(); + const isNocache = typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache; + const extendData = await this.core.apis.UserApi.getUserDetailInfoByUin(user_id); + let uid = (await this.core.apis.UserApi.getUidByUinV2(user_id)); + if (!uid) uid = extendData.detail.uid; + const info = (await this.core.apis.UserApi.getUserDetailInfo(uid, isNocache)); + return { + ...extendData.detail.simpleInfo.coreInfo, + ...extendData.detail.commonExt ?? {}, + ...extendData.detail.simpleInfo.baseInfo, + ...extendData.detail.simpleInfo.relationFlags ?? {}, + ...extendData.detail.simpleInfo.status ?? {}, + user_id: parseInt(extendData.detail.uin) ?? 0, + uid: info.uid ?? uid, + nickname: extendData.detail.simpleInfo.coreInfo.nick ?? '', + age: extendData.detail.simpleInfo.baseInfo.age ?? info.age, + qid: extendData.detail.simpleInfo.baseInfo.qid, + qqLevel: calcQQLevel(extendData.detail.commonExt?.qqLevel ?? info.qqLevel), + sex: OB11Construct.sex(extendData.detail.simpleInfo.baseInfo.sex) ?? OB11UserSex.unknown, + long_nick: extendData.detail.simpleInfo.baseInfo.longNick ?? info.longNick, + reg_time: extendData.detail.commonExt?.regTime ?? info.regTime, + is_vip: extendData.detail.simpleInfo.vasInfo?.svipFlag ?? false, + is_years_vip: extendData.detail.simpleInfo.vasInfo?.yearVipFlag ?? false, + vip_level: extendData.detail.simpleInfo.vasInfo?.vipLevel ?? 0, + remark: extendData.detail.simpleInfo.coreInfo.remark ?? info.remark ?? '', + status: extendData.detail.simpleInfo.status?.status ?? info.status ?? 0, + login_days: 0, // 失效 + }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPCheckUrlSafely.ts b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPCheckUrlSafely.ts new file mode 100644 index 0000000..5ece76d --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPCheckUrlSafely.ts @@ -0,0 +1,31 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const GoCQHTTPCheckUrlSafelyPayloadSchema = Type.Object({ + url: Type.String({ description: '要检查的 URL' }), +}); + +export type GoCQHTTPCheckUrlSafelyPayload = Static; + +export const GoCQHTTPCheckUrlSafelyReturnSchema = Type.Object({ + level: Type.Number({ description: '安全等级 (1: 安全, 2: 未知, 3: 危险)' }), +}); + +export type GoCQHTTPCheckUrlSafelyReturn = Static; + +export class GoCQHTTPCheckUrlSafely extends OneBotAction { + override actionName = ActionName.GoCQHTTP_CheckUrlSafely; + override payloadSchema = GoCQHTTPCheckUrlSafelyPayloadSchema; + override returnSchema = GoCQHTTPCheckUrlSafelyReturnSchema; + override actionSummary = '检查URL安全性'; + override actionDescription = '检查指定URL的安全等级'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GoCQHTTPCheckUrlSafely.payload; + override returnExample = GoCQHTTPActionsExamples.GoCQHTTPCheckUrlSafely.response; + + async _handle () { + return { level: 1 }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPDeleteFriend.ts b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPDeleteFriend.ts new file mode 100644 index 0000000..8cdf25d --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPDeleteFriend.ts @@ -0,0 +1,44 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const GoCQHTTPDeleteFriendPayloadSchema = Type.Object({ + friend_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: '好友 QQ 号' })), + user_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: '用户 QQ 号' })), + temp_block: Type.Optional(Type.Boolean({ description: '是否加入黑名单' })), + temp_both_del: Type.Optional(Type.Boolean({ description: '是否双向删除' })), +}); + +export type GoCQHTTPDeleteFriendPayload = Static; + +export class GoCQHTTPDeleteFriend extends OneBotAction { + override actionName = ActionName.GoCQHTTP_DeleteFriend; + override payloadSchema = GoCQHTTPDeleteFriendPayloadSchema; + override returnSchema = Type.Any(); + override actionSummary = '删除好友'; + override actionDescription = '从好友列表中删除指定用户'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GoCQHTTPDeleteFriend.payload; + override returnExample = GoCQHTTPActionsExamples.GoCQHTTPDeleteFriend.response; + + async _handle (payload: GoCQHTTPDeleteFriendPayload) { + const uin = payload.friend_id ?? payload.user_id ?? ''; + const uid = await this.core.apis.UserApi.getUidByUinV2(uin.toString()); + + if (!uid) { + return { + valid: false, + message: '好友不存在', + }; + } + const isBuddy = await this.core.apis.FriendApi.isBuddy(uid); + if (!isBuddy) { + return { + valid: false, + message: '不是好友', + }; + } + return await this.core.apis.FriendApi.delBuddy(uid, payload.temp_block, payload.temp_both_del); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPGetModelShow.ts b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPGetModelShow.ts new file mode 100644 index 0000000..31c6214 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPGetModelShow.ts @@ -0,0 +1,42 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const GoCQHTTPGetModelShowPayloadSchema = Type.Object({ + model: Type.Optional(Type.String({ description: '模型名称' })), +}); + +export type GoCQHTTPGetModelShowPayload = Static; + +export const GoCQHTTPGetModelShowReturnSchema = Type.Array(Type.Object({ + variants: Type.Object({ + model_show: Type.String({ description: '显示名称' }), + need_pay: Type.Boolean({ description: '是否需要付费' }), + }), +}), { description: '机型显示列表' }); + +export type GoCQHTTPGetModelShowReturn = Static; + +export class GoCQHTTPGetModelShow extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetModelShow; + override payloadSchema = GoCQHTTPGetModelShowPayloadSchema; + override returnSchema = GoCQHTTPGetModelShowReturnSchema; + override actionSummary = '获取机型显示'; + override actionDescription = '获取当前账号可用的设备机型显示名称列表'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GoCQHTTPGetModelShow.payload; + override returnExample = GoCQHTTPActionsExamples.GoCQHTTPGetModelShow.response; + + async _handle (payload: GoCQHTTPGetModelShowPayload) { + if (!payload.model) { + payload.model = 'napcat'; + } + return [{ + variants: { + model_show: 'napcat', + need_pay: false, + }, + }]; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPSetModelShow.ts b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPSetModelShow.ts new file mode 100644 index 0000000..de11d58 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/GoCQHTTPSetModelShow.ts @@ -0,0 +1,20 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +// 兼容性代码 +export class GoCQHTTPSetModelShow extends OneBotAction { + override actionName = ActionName.GoCQHTTP_SetModelShow; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + override actionSummary = '设置机型'; + override actionDescription = '设置当前账号的设备机型名称'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.GoCQHTTPSetModelShow.payload; + override returnExample = GoCQHTTPActionsExamples.GoCQHTTPSetModelShow.response; + + async _handle () { + + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/QuickAction.ts b/packages/snowcat-onebot/action/go-cqhttp/QuickAction.ts new file mode 100644 index 0000000..fc9e2b3 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/QuickAction.ts @@ -0,0 +1,78 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { QuickAction, QuickActionEvent } from '@/snowcat-onebot/types'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; +import { OB11MessageMixTypeSchema } from '@/snowcat-onebot/types/message'; + +const SenderSchema = Type.Object({ + user_id: Type.String({ description: '用户ID' }), + nickname: Type.String({ description: '昵称' }), + sex: Type.Optional(Type.String({ description: '性别' })), + age: Type.Optional(Type.Number({ description: '年龄' })), + card: Type.Optional(Type.String({ description: '群名片' })), + level: Type.Optional(Type.String({ description: '群等级' })), + role: Type.Optional(Type.String({ description: '群角色' })), +}); + +// 定义 QuickAction 的详细 Schema +const QuickActionSchema = Type.Object({ + reply: Type.Optional(OB11MessageMixTypeSchema), + auto_escape: Type.Optional(Type.Boolean({ description: '是否作为纯文本发送' })), + at_sender: Type.Optional(Type.Boolean({ description: '是否 @ 发送者' })), + delete: Type.Optional(Type.Boolean({ description: '是否撤回该消息' })), + kick: Type.Optional(Type.Boolean({ description: '是否踢出发送者' })), + ban: Type.Optional(Type.Boolean({ description: '是否禁言发送者' })), + ban_duration: Type.Optional(Type.Number({ description: '禁言时长' })), + approve: Type.Optional(Type.Boolean({ description: '是否同意请求/加群' })), + remark: Type.Optional(Type.String({ description: '好友备注' })), + reason: Type.Optional(Type.String({ description: '拒绝理由' })), +}, { description: '快速操作内容' }); + +// 定义 QuickActionEvent 的详细 Schema +const QuickActionEventSchema = Type.Object({ + time: Type.Number({ description: '事件发生时间' }), + self_id: Type.Number({ description: '收到事件的机器人 QQ 号' }), + post_type: Type.String({ description: '上报类型' }), + message_type: Type.Optional(Type.String({ description: '消息类型' })), + sub_type: Type.Optional(Type.String({ description: '消息子类型' })), + user_id: Type.String({ description: '发送者 QQ 号' }), + group_id: Type.Optional(Type.String({ description: '群号' })), + message_id: Type.Optional(Type.Number({ description: '消息 ID' })), + message_seq: Type.Optional(Type.Number({ description: '消息序列号' })), + real_id: Type.Optional(Type.Number({ description: '真实消息 ID' })), + sender: Type.Optional(SenderSchema), + message: Type.Optional(Type.Any({ description: '消息内容' })), + message_format: Type.Optional(Type.String({ description: '消息格式' })), + raw_message: Type.Optional(Type.String({ description: '原始消息内容' })), + font: Type.Optional(Type.Number({ description: '字体' })), + notice_type: Type.Optional(Type.String({ description: '通知类型' })), + meta_event_type: Type.Optional(Type.String({ description: '元事件类型' })), +}, { description: '事件上下文' }); + +export const GoCQHTTPHandleQuickActionPayloadSchema = Type.Object({ + context: QuickActionEventSchema, + operation: QuickActionSchema, +}); + +export type GoCQHTTPHandleQuickActionPayload = { + context: QuickActionEvent; + operation: QuickAction; +} & Static; + +export class GoCQHTTPHandleQuickAction extends OneBotAction { + override actionName = ActionName.GoCQHTTP_HandleQuickAction; + override payloadSchema = GoCQHTTPHandleQuickActionPayloadSchema; + override returnSchema = Type.Null(); + override actionSummary = '处理快速操作'; + override actionDescription = '处理来自事件上报的快速操作请求'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.QuickAction.payload; + override returnExample = GoCQHTTPActionsExamples.QuickAction.response; + + async _handle (payload: GoCQHTTPHandleQuickActionPayload): Promise { + this.obContext.apis.QuickActionApi + .handleQuickOperation(payload.context, payload.operation) + .catch(e => this.core.context.logger.logError(e)); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/SendForwardMsg.ts b/packages/snowcat-onebot/action/go-cqhttp/SendForwardMsg.ts new file mode 100644 index 0000000..00e5da5 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/SendForwardMsg.ts @@ -0,0 +1,67 @@ +import { OB11MessageMixType } from '@/snowcat-onebot/types'; +import { ContextMode, normalize, ReturnDataType, SendMsgBase, SendMsgPayload } from '@/snowcat-onebot/action/msg/SendMsg'; +import { ActionName } from '@/snowcat-onebot/action/router'; + +// 未验证 +type GoCQHTTPSendForwardMsgPayload = SendMsgPayload & { messages?: OB11MessageMixType; }; + +export class GoCQHTTPSendForwardMsgBase extends SendMsgBase { + protected override async check (payload: GoCQHTTPSendForwardMsgPayload) { + if (payload.messages) payload.message = normalize(payload.messages); + return super.check(payload); + } +} +export class GoCQHTTPSendForwardMsg extends GoCQHTTPSendForwardMsgBase { + override actionName = ActionName.GoCQHTTP_SendForwardMsg; + override actionSummary = '发送合并转发消息'; + override actionDescription = '发送合并转发消息'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = { + group_id: '123456789', + messages: [], + }; + + override returnExample = { + message_id: 123456, + }; + + protected override async check (payload: GoCQHTTPSendForwardMsgPayload) { + if (payload.messages) payload.message = normalize(payload.messages); + return super.check(payload); + } +} +export class GoCQHTTPSendPrivateForwardMsg extends GoCQHTTPSendForwardMsgBase { + override actionName = ActionName.GoCQHTTP_SendPrivateForwardMsg; + override actionSummary = '发送私聊合并转发消息'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = { + user_id: '123456789', + messages: [], + }; + + override returnExample = { + message_id: 123456, + }; + + override async _handle (payload: GoCQHTTPSendForwardMsgPayload): Promise { + return this.base_handle(payload, ContextMode.Private); + } +} + +export class GoCQHTTPSendGroupForwardMsg extends GoCQHTTPSendForwardMsgBase { + override actionName = ActionName.GoCQHTTP_SendGroupForwardMsg; + override actionSummary = '发送群合并转发消息'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = { + group_id: '123456789', + messages: [], + }; + + override returnExample = { + message_id: 123456, + }; + + override async _handle (payload: GoCQHTTPSendForwardMsgPayload): Promise { + return this.base_handle(payload, ContextMode.Group); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/SendGroupNotice.ts b/packages/snowcat-onebot/action/go-cqhttp/SendGroupNotice.ts new file mode 100644 index 0000000..56dca4f --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/SendGroupNotice.ts @@ -0,0 +1,72 @@ +import { checkFileExist, uriToLocalFile } from 'snowcat-common/src/file'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { unlink } from 'node:fs/promises'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const SendGroupNoticePayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + content: Type.String({ description: '公告内容' }), + image: Type.Optional(Type.String({ description: '公告图片路径或 URL' })), + pinned: Type.Union([Type.Number(), Type.String()], { default: 0, description: '是否置顶 (0/1)' }), + type: Type.Union([Type.Number(), Type.String()], { default: 1, description: '类型 (默认为 1)' }), + confirm_required: Type.Union([Type.Number(), Type.String()], { default: 1, description: '是否需要确认 (0/1)' }), + is_show_edit_card: Type.Union([Type.Number(), Type.String()], { default: 0, description: '是否显示修改群名片引导 (0/1)' }), + tip_window_type: Type.Union([Type.Number(), Type.String()], { default: 0, description: '弹窗类型 (默认为 0)' }), +}); + +export type SendGroupNoticePayload = Static; + +export class SendGroupNotice extends OneBotAction { + override actionName = ActionName.GoCQHTTP_SendGroupNotice; + override payloadSchema = SendGroupNoticePayloadSchema; + override returnSchema = Type.Null(); + override actionSummary = '发送群公告'; + override actionDescription = '在指定群聊中发布新的公告'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.SendGroupNotice.payload; + override returnExample = GoCQHTTPActionsExamples.SendGroupNotice.response; + + async _handle (payload: SendGroupNoticePayload) { + let UploadImage: { id: string, width: number, height: number; } | undefined; + if (payload.image) { + // 公告图逻辑 + const { + path, + success, + isLocal, + } = (await uriToLocalFile(this.core.SnowCatTempPath, payload.image)); + if (!success) { + throw new Error(`群公告${payload.image}设置失败,image字段可能格式不正确`); + } + if (!path) { + throw new Error(`群公告${payload.image}设置失败,获取资源失败`); + } + await checkFileExist(path, 5000); + const ImageUploadResult = await this.core.apis.GroupApi.uploadGroupBulletinPic(payload.group_id.toString(), path); + if (ImageUploadResult.errCode !== 0) { + throw new Error(`群公告${payload.image}设置失败,图片上传失败 , 错误信息:${ImageUploadResult.errMsg}`); + } + + if (!isLocal) unlink(path).catch(() => { }); + + UploadImage = ImageUploadResult.picInfo; + } + const publishGroupBulletinResult = await this.core.apis.WebApi.setGroupNotice( + payload.group_id.toString(), + payload.content, + +payload.pinned, + +payload.type, + +payload.is_show_edit_card, + +payload.tip_window_type, + +payload.confirm_required, + UploadImage?.id, + UploadImage?.width, + UploadImage?.height + ); + if (!publishGroupBulletinResult || publishGroupBulletinResult.ec !== 0) { + throw new Error(`设置群公告失败,错误信息:${publishGroupBulletinResult?.em}`); + } + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/SetGroupPortrait.ts b/packages/snowcat-onebot/action/go-cqhttp/SetGroupPortrait.ts new file mode 100644 index 0000000..ada5529 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/SetGroupPortrait.ts @@ -0,0 +1,57 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { checkFileExistV2, uriToLocalFile } from 'snowcat-common/src/file'; +import { Static, Type } from '@sinclair/typebox'; +import fs from 'node:fs/promises'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const SetGroupPortraitPayloadSchema = Type.Object({ + file: Type.String({ description: '头像文件路径或 URL' }), + group_id: Type.String({ description: '群号' }), +}); + +export type SetGroupPortraitPayload = Static; + +const ReturnSchema = Type.Object({ + result: Type.Number(), + errMsg: Type.String(), +}, { description: '设置结果' }); + +export type ReturnType = Static; + +export default class SetGroupPortrait extends OneBotAction { + override actionName = ActionName.SetGroupPortrait; + override payloadSchema = SetGroupPortraitPayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群头像'; + override actionDescription = '修改指定群聊的头像'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.SetGroupPortrait.payload; + override returnExample = GoCQHTTPActionsExamples.SetGroupPortrait.response; + + async _handle (payload: SetGroupPortraitPayload): Promise { + const { path, success, isLocal } = (await uriToLocalFile(this.core.SnowCatTempPath, payload.file)); + if (!success) { + throw new Error(`头像${payload.file}设置失败,file字段可能格式不正确`); + } + if (path) { + await checkFileExistV2(path, 5000); // 文件不存在QQ会崩溃,需要提前判断 + const ret = await this.core.apis.GroupApi.setGroupAvatar(payload.group_id.toString(), path); + if (!isLocal) fs.unlink(path).catch(() => { }); + if (!ret) { + throw new Error(`头像${payload.file}设置失败,api无返回`); + } + if (Number(ret.result) === 1004022) { + throw new Error(`头像${payload.file}设置失败,文件可能不是图片格式或权限不足`); + } else if (Number(ret.result) !== 0) { + throw new Error(`头像${payload.file}设置失败,未知的错误,${ret.result}:${ret.errMsg}`); + } + return { + result: Number(ret.result), + errMsg: ret.errMsg, + }; + } else { + throw new Error(`头像${payload.file}设置失败,无法获取头像,文件可能不存在`); + } + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/SetQQProfile.ts b/packages/snowcat-onebot/action/go-cqhttp/SetQQProfile.ts new file mode 100644 index 0000000..93cb38f --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/SetQQProfile.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const SetQQProfilePayloadSchema = Type.Object({ + nickname: Type.String({ description: '昵称' }), + personal_note: Type.Optional(Type.String({ description: '个性签名' })), + sex: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '性别 (0: 未知, 1: 男, 2: 女)' })), // 传Sex值?建议传0 +}); + +export type SetQQProfilePayload = Static; +export class SetQQProfile extends OneBotAction { + override actionName = ActionName.SetQQProfile; + override payloadSchema = SetQQProfilePayloadSchema; + override returnSchema = Type.Any({ description: '设置结果' }); + override actionSummary = '设置QQ资料'; + override actionDescription = '修改当前账号的昵称、个性签名等资料'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.SetQQProfile.payload; + override returnExample = GoCQHTTPActionsExamples.SetQQProfile.response; + + async _handle (payload: SetQQProfilePayload) { + const self = this.core.selfInfo; + const OldProfile = await this.core.apis.UserApi.getUserDetailInfo(self.uid); + return await this.core.apis.UserApi.modifySelfProfile({ + nick: payload.nickname, + longNick: (payload?.personal_note ?? OldProfile?.longNick) || '', + sex: parseInt(payload?.sex ? payload?.sex.toString() : OldProfile?.sex!.toString()), + birthday: { + birthday_year: OldProfile?.birthday_year!.toString(), + birthday_month: OldProfile?.birthday_month!.toString(), + birthday_day: OldProfile?.birthday_day!.toString(), + }, + location: undefined, + }); + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/UploadGroupFile.ts b/packages/snowcat-onebot/action/go-cqhttp/UploadGroupFile.ts new file mode 100644 index 0000000..6f7e356 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/UploadGroupFile.ts @@ -0,0 +1,65 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ChatType, Peer, ElementType } from 'snowcat-core/types'; +import fs from 'fs'; +import { uriToLocalFile } from 'snowcat-common/src/file'; +import { SendMessageContext } from '@/snowcat-onebot/api'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const GoCQHTTPUploadGroupFilePayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + file: Type.String({ description: '资源路径或URL' }), + name: Type.String({ description: '文件名' }), + folder: Type.Optional(Type.String({ description: '父目录 ID' })), + folder_id: Type.Optional(Type.String({ description: '父目录 ID (兼容性字段)' })), // 临时扩展 + upload_file: Type.Boolean({ default: true, description: '是否执行上传' }), +}); + +export type GoCQHTTPUploadGroupFilePayload = Static; + +export const GoCQHTTPUploadGroupFileReturnSchema = Type.Object({ + file_id: Type.Union([Type.String(), Type.Null()], { description: '文件 ID' }), +}); + +export type GoCQHTTPUploadGroupFileResponse = Static; + +export default class GoCQHTTPUploadGroupFile extends OneBotAction { + override actionName = ActionName.GoCQHTTP_UploadGroupFile; + override payloadSchema = GoCQHTTPUploadGroupFilePayloadSchema; + override returnSchema = GoCQHTTPUploadGroupFileReturnSchema; + override actionSummary = '上传群文件'; + override actionDescription = '上传资源路径或URL指定的文件到指定群聊的文件系统中'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.UploadGroupFile.payload; + override returnExample = GoCQHTTPActionsExamples.UploadGroupFile.response; + + async _handle (payload: GoCQHTTPUploadGroupFilePayload): Promise { + let file = payload.file; + if (fs.existsSync(file)) { + file = `file://${file}`; + } + const downloadResult = await uriToLocalFile(this.core.SnowCatTempPath, file); + const peer: Peer = { + chatType: ChatType.KCHATTYPEGROUP, + peerUid: payload.group_id.toString(), + }; + if (!downloadResult.success) { + throw new Error(downloadResult.errMsg); + } + const msgContext: SendMessageContext = { + peer, + deleteAfterSentFiles: [], + }; + const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id, payload.upload_file, downloadResult.isLocal); + if (!downloadResult.isLocal) { + msgContext.deleteAfterSentFiles.push(downloadResult.path); + } + const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles); + + const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE); + return { + file_id: fileElement?.fileElement?.fileUuid || null, + }; + } +} diff --git a/packages/snowcat-onebot/action/go-cqhttp/UploadPrivateFile.ts b/packages/snowcat-onebot/action/go-cqhttp/UploadPrivateFile.ts new file mode 100644 index 0000000..3dd8732 --- /dev/null +++ b/packages/snowcat-onebot/action/go-cqhttp/UploadPrivateFile.ts @@ -0,0 +1,75 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ChatType, Peer, SendFileElement, ElementType } from 'snowcat-core/types'; +import fs from 'fs'; +import { uriToLocalFile } from 'snowcat-common/src/file'; +import { SendMessageContext } from '@/snowcat-onebot/api'; +import { ContextMode, createContext } from '@/snowcat-onebot/action/msg/SendMsg'; +import { Static, Type } from '@sinclair/typebox'; +import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +export const GoCQHTTPUploadPrivateFilePayloadSchema = Type.Object({ + user_id: Type.String({ description: '用户 QQ' }), + file: Type.String({ description: '资源路径或URL' }), + name: Type.String({ description: '文件名' }), + upload_file: Type.Boolean({ default: true, description: '是否执行上传' }), +}); + +export type GoCQHTTPUploadPrivateFilePayload = Static; + +export const GoCQHTTPUploadPrivateFileReturnSchema = Type.Object({ + file_id: Type.Union([Type.String(), Type.Null()], { description: '文件 ID' }), +}); + +export type GoCQHTTPUploadPrivateFileResponse = Static; + +export default class GoCQHTTPUploadPrivateFile extends OneBotAction { + override actionName = ActionName.GOCQHTTP_UploadPrivateFile; + override payloadSchema = GoCQHTTPUploadPrivateFilePayloadSchema; + override returnSchema = GoCQHTTPUploadPrivateFileReturnSchema; + override actionSummary = '上传私聊文件'; + override actionDescription = '上传本地文件到指定私聊会话中'; + override actionTags = ['Go-CQHTTP']; + override payloadExample = GoCQHTTPActionsExamples.UploadPrivateFile.payload; + override returnExample = GoCQHTTPActionsExamples.UploadPrivateFile.response; + + async getPeer (payload: GoCQHTTPUploadPrivateFilePayload): Promise { + if (payload.user_id) { + const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!peerUid) { + throw new Error(`私聊${payload.user_id}不存在`); + } + const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid); + return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid }; + } + throw new Error('缺少参数 user_id'); + } + + async _handle (payload: GoCQHTTPUploadPrivateFilePayload): Promise { + let file = payload.file; + if (fs.existsSync(file)) { + file = `file://${file}`; + } + const downloadResult = await uriToLocalFile(this.core.SnowCatTempPath, file); + if (!downloadResult.success) { + throw new Error(downloadResult.errMsg); + } + + const msgContext: SendMessageContext = { + peer: await createContext(this.core, { + user_id: payload.user_id.toString(), + }, ContextMode.Private), + deleteAfterSentFiles: [], + }; + const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, '', payload.upload_file, downloadResult.isLocal); + if (!downloadResult.isLocal) { + msgContext.deleteAfterSentFiles.push(downloadResult.path); + } + const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles); + + const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE); + return { + file_id: fileElement?.fileElement?.fileUuid || null, + }; + } +} diff --git a/packages/snowcat-onebot/action/group/DelEssenceMsg.ts b/packages/snowcat-onebot/action/group/DelEssenceMsg.ts new file mode 100644 index 0000000..a4f9c96 --- /dev/null +++ b/packages/snowcat-onebot/action/group/DelEssenceMsg.ts @@ -0,0 +1,58 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + message_id: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '消息ID' })), + msg_seq: Type.Optional(Type.String({ description: '消息序号' })), + msg_random: Type.Optional(Type.String({ description: '消息随机数' })), + group_id: Type.Optional(Type.String({ description: '群号' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '操作结果' }); + +type ReturnType = Static; + +export default class DelEssenceMsg extends OneBotAction { + override actionName = ActionName.DelEssenceMsg; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '移出精华消息'; + override actionDescription = '将一条消息从群精华消息列表中移出'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.DelEssenceMsg.payload; + override returnExample = GroupActionsExamples.DelEssenceMsg.response; + + async _handle (payload: PayloadType): Promise { + // 如果直接提供了 msg_seq, msg_random, group_id,优先使用 + if (payload.msg_seq && payload.msg_random && payload.group_id) { + return await this.core.apis.GroupApi.removeGroupEssenceBySeq( + payload.group_id, + payload.msg_random, + payload.msg_seq + ); + } + + // 如果没有 message_id,则必须提供 msg_seq, msg_random, group_id + if (!payload.message_id) { + throw new Error('必须提供 message_id 或者同时提供 msg_seq, msg_random, group_id'); + } + + const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); + if (!msg) { + const data = this.core.apis.GroupApi.essenceLRU.getValue(+payload.message_id); + if (!data) throw new Error('消息不存在'); + const { msg_seq, msg_random, group_id } = JSON.parse(data) as { msg_seq: string, msg_random: string, group_id: string; }; + return await this.core.apis.GroupApi.removeGroupEssenceBySeq(group_id, msg_seq, msg_random); + } + return await this.core.apis.GroupApi.removeGroupEssence( + msg.Peer.peerUid, + msg.MsgId + ); + } +} diff --git a/packages/snowcat-onebot/action/group/DelGroupNotice.ts b/packages/snowcat-onebot/action/group/DelGroupNotice.ts new file mode 100644 index 0000000..ec6494d --- /dev/null +++ b/packages/snowcat-onebot/action/group/DelGroupNotice.ts @@ -0,0 +1,33 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + notice_id: Type.String({ description: '公告ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '操作结果' }); + +type ReturnType = Static; + +export class DelGroupNotice extends OneBotAction { + override actionName = ActionName.DelGroupNotice; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '删除群公告'; + override actionDescription = '删除群聊中的公告'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.DelGroupNotice.payload; + override returnExample = GroupActionsExamples.DelGroupNotice.response; + + async _handle (payload: PayloadType) { + const group = payload.group_id.toString(); + const noticeId = payload.notice_id; + return await this.core.apis.GroupApi.deleteGroupBulletin(group, noticeId); + } +} diff --git a/packages/snowcat-onebot/action/group/GetAiRecord.ts b/packages/snowcat-onebot/action/group/GetAiRecord.ts new file mode 100644 index 0000000..3fbc867 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetAiRecord.ts @@ -0,0 +1,40 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { AIVoiceChatType } from 'snowcat-core/packet/entities/aiChat'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + character: Type.String({ description: '角色ID' }), + group_id: Type.String({ description: '群号' }), + text: Type.String({ description: '语音文本内容' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.String({ description: '语音URL' }); + +type ReturnType = Static; + +export class GetAiRecord extends GetPacketStatusDepends { + override actionName = ActionName.GetAiRecord; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取 AI 语音'; + override actionDescription = '通过 AI 语音引擎获取指定文本的语音 URL'; + override actionTags = ['AI 扩展']; + override payloadExample = { + character: 'ai_char_1', + group_id: '123456', + text: '你好', + }; + + override returnExample = 'http://example.com/ai_voice.silk'; + + async _handle (payload: PayloadType) { + const rawRsp = await this.core.apis.PacketApi.pkt.operation.GetAiVoice(+payload.group_id, payload.character, payload.text, AIVoiceChatType.Sound); + if (!rawRsp.msgInfoBody[0]) { + throw new Error('No voice data'); + } + return await this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+payload.group_id, rawRsp.msgInfoBody[0].index); + } +} diff --git a/packages/snowcat-onebot/action/group/GetGroupDetailInfo.ts b/packages/snowcat-onebot/action/group/GetGroupDetailInfo.ts new file mode 100644 index 0000000..15fb2f4 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupDetailInfo.ts @@ -0,0 +1,46 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + group_id: Type.Number({ description: '群号' }), + group_name: Type.String({ description: '群名称' }), + member_count: Type.Number({ description: '成员数量' }), + max_member_count: Type.Number({ description: '最大成员数量' }), + group_all_shut: Type.Number({ description: '全员禁言状态' }), + group_remark: Type.String({ description: '群备注' }), +}, { description: '群详细信息' }); + +type ReturnType = Static; + +export class GetGroupDetailInfo extends OneBotAction { + override actionName = ActionName.GetGroupDetailInfo; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群详细信息'; + override actionDescription = '获取群聊的详细信息,包括成员数、最大成员数等'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupDetailInfo.payload; + override returnExample = GroupActionsExamples.GetGroupDetailInfo.response; + + async _handle (payload: PayloadType): Promise { + const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString()); + return { + ...data, + group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0, + group_remark: '', + group_id: +payload.group_id, + group_name: data.groupName, + member_count: data.memberNum, + max_member_count: data.maxMemberNum, + }; + } +} diff --git a/packages/snowcat-onebot/action/group/GetGroupEssence.ts b/packages/snowcat-onebot/action/group/GetGroupEssence.ts new file mode 100644 index 0000000..a50b4ae --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupEssence.ts @@ -0,0 +1,133 @@ +import type { MessageFormat } from '@/snowcat-onebot/config/config'; +import { ChatType, Peer } from 'snowcat-core'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import crypto from 'crypto'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { OB11MessageData, OB11MessageDataType } from '@/snowcat-onebot/types'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(Type.Object({ + msg_seq: Type.Number({ description: '消息序号' }), + msg_random: Type.Number({ description: '消息随机数' }), + sender_id: Type.Number({ description: '发送者QQ' }), + sender_nick: Type.String({ description: '发送者昵称' }), + operator_id: Type.Number({ description: '操作者QQ' }), + operator_nick: Type.String({ description: '操作者昵称' }), + message_id: Type.Number({ description: '消息ID' }), + operator_time: Type.Number({ description: '操作时间' }), + content: Type.Array(Type.Any(), { description: '消息内容' }), +}), { description: '精华消息列表' }); + +type ReturnType = Static; + +export class GetGroupEssence extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetEssenceMsg; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群精华消息'; + override actionDescription = '获取指定群聊中的精华消息列表'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupEssence.payload; + override returnExample = GroupActionsExamples.GetGroupEssence.response; + + private async msgSeqToMsgId (peer: Peer, msgSeq: string, msgRandom: string) { + const replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(peer, msgSeq, 1, true, true)).msgList.find((msg) => msg.msgSeq === msgSeq && msg.msgRandom === msgRandom); + if (!replyMsgList) { + return undefined; + } + return { + id: MessageUnique.createUniqueMsgId(peer, replyMsgList.msgId), + msg: replyMsgList, + }; + } + + async _handle (payload: PayloadType, _adapter: string, config: NetworkAdapterConfig): Promise { + const msglist = (await this.core.apis.WebApi.getGroupEssenceMsgAll(payload.group_id.toString())) + .flatMap((e) => e?.data?.msg_list) + // 在群精华回空的时候会出现[null]的情况~ https://github.com/DandanLLab/SnowCatQQ/issues/1334 + .filter(Boolean); + if (!msglist) { + throw new Error('获取失败'); + } + return await Promise.all(msglist.map(async (msg) => { + const msgOriginData = await this.msgSeqToMsgId({ + chatType: ChatType.KCHATTYPEGROUP, + peerUid: payload.group_id.toString(), + }, msg.msg_seq.toString(), msg.msg_random.toString()); + if (msgOriginData) { + const { id: message_id, msg: rawMessage } = msgOriginData; + const parsed = await this.obContext.apis.MsgApi.parseMessage(rawMessage, config['messagePostFormat'] as MessageFormat | undefined ?? 'array'); + let content: OB11MessageData[] = []; + if (parsed) { + if (Array.isArray(parsed.message)) { + content = parsed.message; + } else { + content = [{ type: OB11MessageDataType.text, data: { text: parsed.message } }]; + } + } + return { + msg_seq: msg.msg_seq, + msg_random: msg.msg_random, + sender_id: +msg.sender_uin, + sender_nick: msg.sender_nick, + operator_id: +msg.add_digest_uin, + operator_nick: msg.add_digest_nick, + message_id, + operator_time: msg.add_digest_time, + content, + }; + } + const msgTempData = JSON.stringify({ + msg_seq: msg.msg_seq.toString(), + msg_random: msg.msg_random.toString(), + group_id: payload.group_id.toString(), + }); + const hash = crypto.createHash('md5').update(msgTempData).digest(); + // 设置第一个bit为0 保证shortId为正数 + if (hash[0]) { + hash[0] &= 0x7f; + } + const shortId = hash.readInt32BE(0); + this.core.apis.GroupApi.essenceLRU.set(shortId, msgTempData); + return { + msg_seq: msg.msg_seq, + msg_random: msg.msg_random, + sender_id: +msg.sender_uin, + sender_nick: msg.sender_nick, + operator_id: +msg.add_digest_uin, + operator_nick: msg.add_digest_nick, + message_id: shortId, + operator_time: msg.add_digest_time, + content: msg.msg_content.map((msg): OB11MessageData | undefined => { + if (msg.msg_type === 1) { + return { + type: OB11MessageDataType.text, + data: { + text: msg?.text ?? '', + }, + }; + } else if (msg.msg_type === 3) { + return { + type: OB11MessageDataType.image, + data: { + file: '', + url: msg?.image_url, + }, + }; + } + return undefined; + }).filter((e): e is OB11MessageData => e !== undefined), + }; + })); + } +} diff --git a/packages/snowcat-onebot/action/group/GetGroupIgnoredNotifies.ts b/packages/snowcat-onebot/action/group/GetGroupIgnoredNotifies.ts new file mode 100644 index 0000000..45efc1b --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupIgnoredNotifies.ts @@ -0,0 +1,62 @@ +import { GroupNotifyMsgStatus } from 'snowcat-core'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({}, { description: '群忽略通知负载' }); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + invited_requests: Type.Array(Type.Any(), { description: '邀请请求列表' }), + InvitedRequest: Type.Array(Type.Any(), { description: '邀请请求列表' }), + join_requests: Type.Array(Type.Any(), { description: '加入请求列表' }), +}, { description: '群忽略通知结果' }); + +type ReturnType = Static; + +export class GetGroupIgnoredNotifies extends OneBotAction { + override actionName = ActionName.GetGroupIgnoredNotifies; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群忽略通知'; + override actionDescription = '获取被忽略的入群申请和邀请通知'; + override actionTags = ['群组接口']; + override payloadExample = {}; + override returnExample = { + invited_requests: [], + InvitedRequest: [], + join_requests: [], + }; + + async _handle (): Promise { + const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, 50); + const retData: ReturnType = { invited_requests: [], InvitedRequest: [], join_requests: [] }; + + const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => { + const invitorUin = SSNotify.user1?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user1.uid) : 0; + const actorUin = SSNotify.user2?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user2.uid) : 0; + const commonData = { + request_id: +SSNotify.seq, + invitor_uin: invitorUin, + invitor_nick: SSNotify.user1?.nickName, + group_id: +SSNotify.group?.groupCode, + message: SSNotify?.postscript, + group_name: SSNotify.group?.groupName, + checked: SSNotify.status !== GroupNotifyMsgStatus.KUNHANDLE, + actor: actorUin, + requester_nick: SSNotify.user1?.nickName, + }; + + if (SSNotify.type === 1) { + retData.InvitedRequest.push(commonData); + } else if (SSNotify.type === 7) { + retData.join_requests.push(commonData); + } + }); + + await Promise.all(notifyPromises); + retData.invited_requests = retData.InvitedRequest; + return retData; + } +} diff --git a/packages/snowcat-onebot/action/group/GetGroupInfo.ts b/packages/snowcat-onebot/action/group/GetGroupInfo.ts new file mode 100644 index 0000000..6ae4735 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupInfo.ts @@ -0,0 +1,50 @@ +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { OB11GroupSchema } from '../schemas'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = OB11GroupSchema; + +type ReturnType = Static; + +class GetGroupInfo extends OneBotAction { + override actionName = ActionName.GetGroupInfo; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群信息'; + override actionDescription = '获取群聊的基本信息'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupInfo.payload; + override returnExample = GroupActionsExamples.GetGroupInfo.response; + + async _handle (payload: PayloadType) { + const group = (await this.core.apis.GroupApi.getGroups()).find(e => e.groupCode === payload.group_id.toString()); + if (!group) { + const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString()); + if (data.ownerUid && data.ownerUin === '0') { + data.ownerUin = await this.core.apis.UserApi.getUinByUidV2(data.ownerUid); + } + return { + ...data, + group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0, + group_remark: '', + group_id: +payload.group_id, + group_name: data.groupName, + member_count: data.memberNum, + max_member_count: data.maxMemberNum, + }; + } + return OB11Construct.group(group); + } +} + +export default GetGroupInfo; diff --git a/packages/snowcat-onebot/action/group/GetGroupList.ts b/packages/snowcat-onebot/action/group/GetGroupList.ts new file mode 100644 index 0000000..4ae92b5 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupList.ts @@ -0,0 +1,35 @@ +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { OB11GroupSchema } from '../schemas'; +import { GroupActionsExamples } from '@/snowcat-onebot/action/example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + no_cache: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否不使用缓存' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(OB11GroupSchema, { description: '群列表' }); + +type ReturnType = Static; + +class GetGroupList extends OneBotAction { + override actionName = ActionName.GetGroupList; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群列表'; + override actionDescription = '获取当前帐号的群聊列表'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupList.payload; + override returnExample = GroupActionsExamples.GetGroupList.response; + + async _handle (payload: PayloadType) { + return OB11Construct.groups( + await this.core.apis.GroupApi.getGroups( + typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache)); + } +} + +export default GetGroupList; diff --git a/packages/snowcat-onebot/action/group/GetGroupMemberInfo.ts b/packages/snowcat-onebot/action/group/GetGroupMemberInfo.ts new file mode 100644 index 0000000..3e37355 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupMemberInfo.ts @@ -0,0 +1,68 @@ +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { OB11GroupMemberSchema } from '../schemas'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.String({ description: 'QQ号' }), + no_cache: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否不使用缓存' })), +}); + +type PayloadType = Static; + +const ReturnSchema = OB11GroupMemberSchema; + +type ReturnType = Static; + +class GetGroupMemberInfo extends OneBotAction { + override actionName = ActionName.GetGroupMemberInfo; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群成员信息'; + override actionDescription = '获取群聊中指定成员的信息'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupMemberInfo.payload; + override returnExample = GroupActionsExamples.GetGroupMemberInfo.response; + + private parseBoolean (value: boolean | string): boolean { + return typeof value === 'string' ? value === 'true' : value; + } + + private async getUid (userId: string | number): Promise { + const uid = await this.core.apis.UserApi.getUidByUinV2(userId.toString()); + if (!uid) throw new Error(`Uin2Uid Error: 用户ID ${userId} 不存在`); + return uid; + } + + private async getGroupMemberInfo (payload: PayloadType, uid: string, isNocache: boolean) { + const groupMemberCache = this.core.apis.GroupApi.groupMemberCache.get(payload.group_id.toString()); + const groupMember = groupMemberCache?.get(uid); + + const [member, info] = await Promise.all([ + this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, isNocache), + this.core.apis.UserApi.getUserDetailInfo(uid, isNocache), + ]); + + if (!member || !groupMember) throw new Error(`群(${payload.group_id})成员${payload.user_id}不存在`); + + return info ? { ...groupMember, ...member, ...info } : member; + } + + async _handle (payload: PayloadType) { + const isNocache = this.parseBoolean(payload.no_cache ?? true); + const uid = await this.getUid(payload.user_id); + const member = await this.getGroupMemberInfo(payload, uid, isNocache); + + if (!member) { + this.core.context.logger.logDebug('获取群成员详细信息失败, 只能返回基础信息'); + } + + return OB11Construct.groupMember(payload.group_id.toString(), member); + } +} + +export default GetGroupMemberInfo; diff --git a/packages/snowcat-onebot/action/group/GetGroupMemberList.ts b/packages/snowcat-onebot/action/group/GetGroupMemberList.ts new file mode 100644 index 0000000..21d48b7 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupMemberList.ts @@ -0,0 +1,59 @@ +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GroupMember } from 'snowcat-core'; +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + no_cache: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否不使用缓存' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(Type.Any(), { description: '群成员列表' }); + +type ReturnType = Static; + +export class GetGroupMemberList extends OneBotAction { + override actionName = ActionName.GetGroupMemberList; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群成员列表'; + override actionDescription = '获取群聊中的所有成员列表'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupMemberList.payload; + override returnExample = GroupActionsExamples.GetGroupMemberList.response; + + async _handle (payload: PayloadType) { + const groupIdStr = payload.group_id.toString(); + const noCache = this.parseBoolean(payload.no_cache ?? false); + const groupMembers = await this.getGroupMembers(groupIdStr, noCache); + const _groupMembers = await Promise.all( + Array.from(groupMembers.values()).map(item => + OB11Construct.groupMember(groupIdStr, item) + ) + ); + return Array.from(new Map(_groupMembers.map(member => [member.user_id, member])).values()); + } + + private parseBoolean (value: boolean | string): boolean { + return typeof value === 'string' ? value === 'true' : value; + } + + private async getGroupMembers (groupIdStr: string, noCache: boolean): Promise> { + const memberCache = this.core.apis.GroupApi.groupMemberCache; + let groupMembers = memberCache.get(groupIdStr); + + if (noCache || !groupMembers) { + const data = this.core.apis.GroupApi.refreshGroupMemberCache(groupIdStr, true).then().catch(); + groupMembers = memberCache.get(groupIdStr) || (await data); + if (!groupMembers) { + throw new Error(`Failed to get group member list for group ${groupIdStr}`); + } + } + + return groupMembers; + } +} diff --git a/packages/snowcat-onebot/action/group/GetGroupNotice.ts b/packages/snowcat-onebot/action/group/GetGroupNotice.ts new file mode 100644 index 0000000..880aa31 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupNotice.ts @@ -0,0 +1,73 @@ +import { WebApiGroupNoticeFeed } from 'snowcat-core'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(Type.Object({ + sender_id: Type.Number({ description: '发送者QQ' }), + publish_time: Type.Number({ description: '发布时间' }), + notice_id: Type.String({ description: '公告ID' }), + message: Type.Object({ + text: Type.String({ description: '文本内容' }), + image: Type.Array(Type.Any(), { description: '图片列表' }), + images: Type.Array(Type.Any(), { description: '图片列表' }), + }, { description: '公告内容' }), + settings: Type.Optional(Type.Any({ description: '设置项' })), + read_num: Type.Optional(Type.Number({ description: '阅读数' })), +}), { description: '群公告列表' }); + +type ReturnType = Static; + +export type ApiGroupNotice = ReturnType[number] & WebApiGroupNoticeFeed; + +export class GetGroupNotice extends OneBotAction { + override actionName = ActionName.GoCQHTTP_GetGroupNotice; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群公告'; + override actionDescription = '获取指定群聊中的公告列表'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.GetGroupNotice.payload; + override returnExample = GroupActionsExamples.GetGroupNotice.response; + + async _handle (payload: PayloadType) { + const group = payload.group_id.toString(); + const ret = await this.core.apis.WebApi.getGroupNotice(group); + if (!ret) { + throw new Error('获取公告失败'); + } + const retNotices: ReturnType = []; + for (const key in ret.feeds) { + if (!ret.feeds[key]) { + continue; + } + const retApiNotice: WebApiGroupNoticeFeed = ret.feeds[key]; + const image = retApiNotice.msg.pics?.map((pic) => { + return { id: pic.id, height: pic.h, width: pic.w }; + }) || []; + + const retNotice: ReturnType[number] = { + notice_id: retApiNotice.fid, + sender_id: retApiNotice.u, + publish_time: retApiNotice.pubt, + message: { + text: retApiNotice.msg.text, + image, + images: image, + }, + settings: retApiNotice.settings, + read_num: retApiNotice.read_num, + }; + retNotices.push(retNotice); + } + + return retNotices; + } +} diff --git a/packages/snowcat-onebot/action/group/GetGroupShutList.ts b/packages/snowcat-onebot/action/group/GetGroupShutList.ts new file mode 100644 index 0000000..f24fbf6 --- /dev/null +++ b/packages/snowcat-onebot/action/group/GetGroupShutList.ts @@ -0,0 +1,36 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(Type.Any(), { description: '禁言成员列表' }); + +type ReturnType = Static; + +export class GetGroupShutList extends OneBotAction { + override actionName = ActionName.GetGroupShutList; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取群禁言列表'; + override actionTags = ['群组接口']; + override payloadExample = { + group_id: '123456789', + }; + + override returnExample = [ + { + user_id: 123456789, + nickname: '禁言用户', + shut_up_time: 1734567890, + }, + ]; + + async _handle (payload: PayloadType) { + return await this.core.apis.GroupApi.getGroupShutUpMemberList(payload.group_id.toString()); + } +} diff --git a/packages/snowcat-onebot/action/group/GroupOperationHelpers.ts b/packages/snowcat-onebot/action/group/GroupOperationHelpers.ts new file mode 100644 index 0000000..42ba0fa --- /dev/null +++ b/packages/snowcat-onebot/action/group/GroupOperationHelpers.ts @@ -0,0 +1,13 @@ +import type { GeneralCallResult } from 'snowcat-core'; + +export function assertGroupManagementResults ( + operation: string, + results: Array<{ setting: string, result: GeneralCallResult; }> +): void { + const failure = results.find(item => item.result.result !== 0); + if (!failure) return; + throw new Error( + `${operation}失败 Setting: ${failure.setting} ErrCode: ${failure.result.result} ` + + `ErrMsg: ${failure.result.errMsg || '未知错误'}` + ); +} diff --git a/packages/snowcat-onebot/action/group/SendGroupAiRecord.ts b/packages/snowcat-onebot/action/group/SendGroupAiRecord.ts new file mode 100644 index 0000000..2f40033 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SendGroupAiRecord.ts @@ -0,0 +1,41 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { AIVoiceChatType } from 'snowcat-core/packet/entities/aiChat'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + character: Type.String({ description: '角色ID' }), + group_id: Type.String({ description: '群号' }), + text: Type.String({ description: '语音文本内容' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + message_id: Type.Number({ description: '消息ID' }), +}, { description: '发送结果' }); + +type ReturnType = Static; + +export class SendGroupAiRecord extends GetPacketStatusDepends { + override actionName = ActionName.SendGroupAiRecord; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '发送群 AI 语音'; + override actionDescription = '发送 AI 生成的语音到指定群聊'; + override actionTags = ['AI 扩展']; + override payloadExample = { + character: 'ai_char_1', + group_id: '123456', + text: '你好', + }; + + override returnExample = {}; + + async _handle (payload: PayloadType) { + await this.core.apis.PacketApi.pkt.operation.GetAiVoice(+payload.group_id, payload.character, payload.text, AIVoiceChatType.Sound); + return { + message_id: 0, // can't get message_id from GetAiVoice + }; + } +} diff --git a/packages/snowcat-onebot/action/group/SendGroupMsg.ts b/packages/snowcat-onebot/action/group/SendGroupMsg.ts new file mode 100644 index 0000000..a2cfb09 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SendGroupMsg.ts @@ -0,0 +1,29 @@ +import { ContextMode, ReturnDataType, SendMsgBase, SendMsgPayload } from '@/snowcat-onebot/action/msg/SendMsg'; +import { ActionName, BaseCheckResult } from '@/snowcat-onebot/action/router'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; +import { snowCatStable } from '@/snowcat-onebot/stable-line'; + +// 未检测参数 +class SendGroupMsg extends SendMsgBase { + override actionName = ActionName.SendGroupMsg; + override actionSummary = '发送群消息'; + override actionDescription = '发送群消息'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SendGroupMsg.payload; + override returnExample = GroupActionsExamples.SendGroupMsg.response; + + protected override async check (payload: SendMsgPayload): Promise { + delete payload.user_id; + payload.message_type = 'group'; + return super.check(payload); + } + + override async _handle (payload: SendMsgPayload): Promise { + // SnowCat 稳定线:真人抖动+账号预算+熔断 + await snowCatStable.stableDelay(); + return this.base_handle(payload, ContextMode.Group); + } +} + +export default SendGroupMsg; diff --git a/packages/snowcat-onebot/action/group/SetEssenceMsg.ts b/packages/snowcat-onebot/action/group/SetEssenceMsg.ts new file mode 100644 index 0000000..190090e --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetEssenceMsg.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetEssenceMsg extends OneBotAction { + override actionName = ActionName.SetEssenceMsg; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置精华消息'; + override actionDescription = '将一条消息设置为群精华消息'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetEssenceMsg.payload; + override returnExample = GroupActionsExamples.SetEssenceMsg.response; + + async _handle (payload: PayloadType) { + const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); + if (!msg) { + throw new Error('msg not found'); + } + return await this.core.apis.GroupApi.addGroupEssence( + msg.Peer.peerUid, + msg.MsgId + ); + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupAddRequest.ts b/packages/snowcat-onebot/action/group/SetGroupAddRequest.ts new file mode 100644 index 0000000..006ba84 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupAddRequest.ts @@ -0,0 +1,66 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { GroupNotify, NTGroupRequestOperateTypes } from 'snowcat-core/types'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + flag: Type.String({ description: '请求flag' }), + approve: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否同意' })), + reason: Type.Optional(Type.Union([Type.String({ default: ' ' }), Type.Null()], { description: '拒绝理由' })), + count: Type.Optional(Type.Number({ default: 100, description: '搜索通知数量' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupAddRequest extends OneBotAction { + override actionName = ActionName.SetGroupAddRequest; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '处理加群请求'; + override actionDescription = '同意或拒绝加群请求或邀请'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupAddRequest.payload; + override returnExample = GroupActionsExamples.SetGroupAddRequest.response; + + async _handle (payload: PayloadType): Promise { + const flag = payload.flag.toString(); + const approve = payload.approve?.toString() !== 'false'; + const reason = payload.reason ?? ' '; + const count = payload.count; + const invite_notify = this.obContext.apis.MsgApi.notifyGroupInvite.get(flag); + const { doubt, notify } = invite_notify + ? { + doubt: false, + notify: invite_notify, + } + : await this.findNotify(flag, count); + if (!notify) { + throw new Error('No such request'); + } + await this.core.apis.GroupApi.handleGroupRequest( + doubt, + notify, + approve ? NTGroupRequestOperateTypes.KAGREE : NTGroupRequestOperateTypes.KREFUSE, + reason + ); + return null; + } + + private async findNotify (flag: string, count: number = 100): Promise<{ + doubt: boolean, + notify: GroupNotify | undefined; + }> { + let notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(false, count)).find(e => e.seq === flag); + if (!notify) { + notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(true, count)).find(e => e.seq === flag); + return { doubt: true, notify }; + } + return { doubt: false, notify }; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupAdmin.ts b/packages/snowcat-onebot/action/group/SetGroupAdmin.ts new file mode 100644 index 0000000..3191433 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupAdmin.ts @@ -0,0 +1,37 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { NTGroupMemberRole } from 'snowcat-core/types'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.String({ description: '用户QQ' }), + enable: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否设置为管理员' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupAdmin extends OneBotAction { + override actionName = ActionName.SetGroupAdmin; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群管理员'; + override actionDescription = '设置或取消群聊中的管理员'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupAdmin.payload; + override returnExample = GroupActionsExamples.SetGroupAdmin.response; + + async _handle (payload: PayloadType): Promise { + const enable = typeof payload.enable === 'string' ? payload.enable === 'true' : !!payload.enable; + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('get Uid Error'); + await this.core.apis.GroupApi.setMemberRole(payload.group_id.toString(), uid, enable ? NTGroupMemberRole.KADMIN : NTGroupMemberRole.KMEMBER); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupBan.ts b/packages/snowcat-onebot/action/group/SetGroupBan.ts new file mode 100644 index 0000000..0a4e6be --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupBan.ts @@ -0,0 +1,47 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; +import { NTGroupMemberRole } from '@/snowcat-core'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.String({ description: '用户QQ' }), + duration: Type.Union([Type.Number(), Type.String()], { default: 0, description: '禁言时长(秒)' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupBan extends OneBotAction { + override actionName = ActionName.SetGroupBan; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '群组禁言'; + override actionDescription = '禁言群聊中的指定成员'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupBan.payload; + override returnExample = GroupActionsExamples.SetGroupBan.response; + + async _handle (payload: PayloadType): Promise { + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('uid error'); + const member_role = (await this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, true))?.role; + if (member_role === undefined) throw new Error('user not in group'); + if (member_role === NTGroupMemberRole.KOWNER) throw new Error('cannot ban owner'); + if (member_role === NTGroupMemberRole.KADMIN) { + const self_role = (await this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), this.core.selfInfo.uid, true))?.role; + if (self_role === undefined) throw new Error('failed to fetch bot role'); + if (self_role !== NTGroupMemberRole.KOWNER) throw new Error('cannot ban admin'); + } + // 例如无管理员权限时 result为 120101005 errMsg为 'ERR_NOT_GROUP_ADMIN' + const ret = await this.core.apis.GroupApi.banMember(payload.group_id.toString(), + [{ uid, timeStamp: +payload.duration }]); + if (ret.result !== 0) throw new Error(ret.errMsg); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupCard.ts b/packages/snowcat-onebot/action/group/SetGroupCard.ts new file mode 100644 index 0000000..0432705 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupCard.ts @@ -0,0 +1,51 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { sleep } from '@/snowcat-common/src/helper'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.String({ description: '用户QQ' }), + card: Type.Optional(Type.String({ description: '群名片' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupCard extends OneBotAction { + override actionName = ActionName.SetGroupCard; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群名片'; + override actionDescription = '设置群聊中指定成员的群名片'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupCard.payload; + override returnExample = GroupActionsExamples.SetGroupCard.response; + + async _handle (payload: PayloadType): Promise { + const groupId = payload.group_id.toString(); + const userId = payload.user_id.toString(); + const card = payload.card || ''; + + const member = await this.core.apis.GroupApi.getGroupMember(groupId, userId); + if (!member) { + throw new Error(`群(${groupId})成员${userId}不存在`); + } + + await this.core.apis.GroupApi.setMemberCard(groupId, member.uid, card); + + for (let i = 0; i < 3; i++) { + await sleep(1000); + const updatedMember = await this.core.apis.GroupApi.refreshGroupMemberCachePartial(groupId, member.uid); + if (updatedMember?.cardName === card) { + return null; + } + } + throw new Error('设置群名片未生效'); + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupKick.ts b/packages/snowcat-onebot/action/group/SetGroupKick.ts new file mode 100644 index 0000000..f1eb645 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupKick.ts @@ -0,0 +1,36 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + user_id: Type.String({ description: '用户QQ' }), + reject_add_request: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否拒绝加群请求' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupKick extends OneBotAction { + override actionName = ActionName.SetGroupKick; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '群组踢人'; + override actionDescription = '将指定成员踢出群聊'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupKick.payload; + override returnExample = GroupActionsExamples.SetGroupKick.response; + + async _handle (payload: PayloadType): Promise { + const rejectReq = payload.reject_add_request?.toString() === 'true'; + const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!uid) throw new Error('get Uid Error'); + await this.core.apis.GroupApi.kickMember(payload.group_id.toString(), [uid], rejectReq); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupLeave.ts b/packages/snowcat-onebot/action/group/SetGroupLeave.ts new file mode 100644 index 0000000..b103d68 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupLeave.ts @@ -0,0 +1,32 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + is_dismiss: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否解散' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupLeave extends OneBotAction { + override actionName = ActionName.SetGroupLeave; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '退出群组'; + override actionDescription = '退出或解散指定群聊'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupLeave.payload; + override returnExample = GroupActionsExamples.SetGroupLeave.response; + + async _handle (payload: PayloadType): Promise { + await this.core.apis.GroupApi.quitGroup(payload.group_id.toString()); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupMemberInvitePolicy.ts b/packages/snowcat-onebot/action/group/SetGroupMemberInvitePolicy.ts new file mode 100644 index 0000000..7208e1a --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupMemberInvitePolicy.ts @@ -0,0 +1,41 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; +import { assertGroupManagementResults } from './GroupOperationHelpers'; + +const MemberInvitePolicySchema = Type.Union([ + Type.Literal('disabled'), + Type.Literal('require_approval'), + Type.Literal('no_approval'), + Type.Literal('no_approval_under_100'), +], { + description: '成员邀请策略:禁止、需要管理员审核、无需审核、群成员少于100人时无需审核', +}); + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + policy: MemberInvitePolicySchema, +}); +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); +type ReturnType = Static; + +export default class SetGroupMemberInvitePolicy extends OneBotAction { + override actionName = ActionName.SetGroupMemberInvitePolicy; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群成员邀请策略'; + override actionDescription = '设置是否允许群成员邀请好友进群,以及邀请是否需要管理员审核'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupMemberInvitePolicy.payload; + override returnExample = GroupActionsExamples.SetGroupMemberInvitePolicy.response; + + async _handle (payload: PayloadType): Promise { + const results = await this.core.apis.GroupApi.setGroupMemberInvitePolicy(payload.group_id, payload.policy); + assertGroupManagementResults('设置群成员邀请策略', results); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupMemberPermissions.ts b/packages/snowcat-onebot/action/group/SetGroupMemberPermissions.ts new file mode 100644 index 0000000..14ef5e1 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupMemberPermissions.ts @@ -0,0 +1,43 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; +import { assertGroupManagementResults } from './GroupOperationHelpers'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + allow_member_upload_album: Type.Optional(Type.Boolean({ description: '允许成员上传群相册' })), + allow_member_temporary_session: Type.Optional(Type.Boolean({ description: '允许成员发起临时会话' })), + allow_member_create_group: Type.Optional(Type.Boolean({ description: '允许成员发起新的群聊' })), +}); +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); +type ReturnType = Static; + +export default class SetGroupMemberPermissions extends OneBotAction { + override actionName = ActionName.SetGroupMemberPermissions; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群成员功能权限'; + override actionDescription = '设置群成员上传相册、发起临时会话和发起新群聊的权限;未传入的项目保持不变'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupMemberPermissions.payload; + override returnExample = GroupActionsExamples.SetGroupMemberPermissions.response; + + async _handle (payload: PayloadType): Promise { + const permissions = { + allowMemberUploadAlbum: payload.allow_member_upload_album, + allowMemberTemporarySession: payload.allow_member_temporary_session, + allowMemberCreateGroup: payload.allow_member_create_group, + }; + if (Object.values(permissions).every(value => value === undefined)) { + throw new Error('至少需要提供一个群成员功能权限'); + } + + const results = await this.core.apis.GroupApi.setGroupMemberPermissions(payload.group_id, permissions); + assertGroupManagementResults('设置群成员功能权限', results); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupName.ts b/packages/snowcat-onebot/action/group/SetGroupName.ts new file mode 100644 index 0000000..66e8284 --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupName.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + group_name: Type.String({ description: '群名称' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupName extends OneBotAction { + override actionName = ActionName.SetGroupName; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置群名称'; + override actionDescription = '修改指定群聊的名称'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupName.payload; + override returnExample = GroupActionsExamples.SetGroupName.response; + + async _handle (payload: PayloadType): Promise { + let ret = await this.core.apis.GroupApi.setGroupName(payload.group_id.toString(), payload.group_name); + if (ret.result.toString() === '1287') { + ret = await this.core.apis.GroupApi.setGroupName(payload.group_id.toString(), payload.group_name, true); + } + if (ret.result !== 0) { + throw new Error(`设置群名称失败 ErrCode: ${ret.result} ErrMsg: ${ret.errMsg}`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupNewMemberHistoryVisibility.ts b/packages/snowcat-onebot/action/group/SetGroupNewMemberHistoryVisibility.ts new file mode 100644 index 0000000..bd3f3be --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupNewMemberHistoryVisibility.ts @@ -0,0 +1,35 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; +import { assertGroupManagementResults } from './GroupOperationHelpers'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + visible: Type.Boolean({ description: '新成员默认可见最近聊天记录' }), +}); +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); +type ReturnType = Static; + +export default class SetGroupNewMemberHistoryVisibility extends OneBotAction { + override actionName = ActionName.SetGroupNewMemberHistoryVisibility; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '设置新成员历史消息可见性'; + override actionDescription = '设置新入群成员默认是否可以查看最近聊天记录'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupNewMemberHistoryVisibility.payload; + override returnExample = GroupActionsExamples.SetGroupNewMemberHistoryVisibility.response; + + async _handle (payload: PayloadType): Promise { + const results = await this.core.apis.GroupApi.setGroupNewMemberHistoryVisibility( + payload.group_id, + payload.visible + ); + assertGroupManagementResults('设置新成员历史消息可见性', results); + return null; + } +} diff --git a/packages/snowcat-onebot/action/group/SetGroupWholeBan.ts b/packages/snowcat-onebot/action/group/SetGroupWholeBan.ts new file mode 100644 index 0000000..371ec8d --- /dev/null +++ b/packages/snowcat-onebot/action/group/SetGroupWholeBan.ts @@ -0,0 +1,36 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +import { GroupActionsExamples } from '../example/GroupActionsExamples'; + +const PayloadSchema = Type.Object({ + group_id: Type.String({ description: '群号' }), + enable: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否开启全员禁言' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +export default class SetGroupWholeBan extends OneBotAction { + override actionName = ActionName.SetGroupWholeBan; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '全员禁言'; + override actionDescription = '开启或关闭指定群聊的全员禁言'; + override actionTags = ['群组接口']; + override payloadExample = GroupActionsExamples.SetGroupWholeBan.payload; + override returnExample = GroupActionsExamples.SetGroupWholeBan.response; + + async _handle (payload: PayloadType): Promise { + const enable = payload.enable?.toString() !== 'false'; + const res = await this.core.apis.GroupApi.banGroup(payload.group_id.toString(), enable); + if (res.result !== 0) { + throw new Error(`SetGroupWholeBan failed: ${res.errMsg} ${res.result}`); + } + return null; + } +} diff --git a/packages/snowcat-onebot/action/guild/GetGuildList.ts b/packages/snowcat-onebot/action/guild/GetGuildList.ts new file mode 100644 index 0000000..51ce45d --- /dev/null +++ b/packages/snowcat-onebot/action/guild/GetGuildList.ts @@ -0,0 +1,20 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; + +import { Type } from '@sinclair/typebox'; +import { GuildActionsExamples } from '../example/GuildActionsExamples'; + +export class GetGuildList extends OneBotAction { + override actionName = ActionName.GetGuildList; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + override actionSummary = '获取频道列表'; + override actionDescription = '获取当前帐号已加入的频道列表'; + override actionTags = ['频道接口']; + override payloadExample = GuildActionsExamples.GetGuildList.payload; + override returnExample = GuildActionsExamples.GetGuildList.response; + + async _handle (): Promise { + + } +} diff --git a/packages/snowcat-onebot/action/guild/GetGuildProfile.ts b/packages/snowcat-onebot/action/guild/GetGuildProfile.ts new file mode 100644 index 0000000..be8e5cf --- /dev/null +++ b/packages/snowcat-onebot/action/guild/GetGuildProfile.ts @@ -0,0 +1,20 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; + +import { Type } from '@sinclair/typebox'; +import { GuildActionsExamples } from '../example/GuildActionsExamples'; + +export class GetGuildProfile extends OneBotAction { + override actionName = ActionName.GetGuildProfile; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + override actionSummary = '获取频道个人信息'; + override actionDescription = '获取当前帐号在频道中的个人资料'; + override actionTags = ['频道接口']; + override payloadExample = GuildActionsExamples.GetGuildProfile.payload; + override returnExample = GuildActionsExamples.GetGuildProfile.response; + + async _handle (): Promise { + + } +} diff --git a/packages/snowcat-onebot/action/index.ts b/packages/snowcat-onebot/action/index.ts new file mode 100644 index 0000000..7b2f1ff --- /dev/null +++ b/packages/snowcat-onebot/action/index.ts @@ -0,0 +1,482 @@ +import GetMsg from './msg/GetMsg'; +import GetLoginInfo from './system/GetLoginInfo'; +import GetFriendList from './user/GetFriendList'; +import GetGroupList from './group/GetGroupList'; +import GetGroupInfo from './group/GetGroupInfo'; +import GetGroupMemberInfo from './group/GetGroupMemberInfo'; +import SendGroupMsg from './group/SendGroupMsg'; +import SendPrivateMsg from './msg/SendPrivateMsg'; +import SendMsg from './msg/SendMsg'; +import DeleteMsg from './msg/DeleteMsg'; +import GetVersionInfo from './system/GetVersionInfo'; +import CanSendRecord from './system/CanSendRecord'; +import CanSendImage from './system/CanSendImage'; +import GetStatus from './system/GetStatus'; +import { + GoCQHTTPSendForwardMsg, + GoCQHTTPSendGroupForwardMsg, + GoCQHTTPSendPrivateForwardMsg, +} from './go-cqhttp/SendForwardMsg'; +import GoCQHTTPGetStrangerInfo from './go-cqhttp/GetStrangerInfo'; +import SendLike from './user/SendLike'; +import SetGroupAddRequest from './group/SetGroupAddRequest'; +import SetGroupLeave from './group/SetGroupLeave'; +import SetFriendAddRequest from './user/SetFriendAddRequest'; +import SetGroupWholeBan from './group/SetGroupWholeBan'; +import SetGroupName from './group/SetGroupName'; +import SetGroupBan from './group/SetGroupBan'; +import SetGroupKick from './group/SetGroupKick'; +import SetGroupAdmin from './group/SetGroupAdmin'; +import SetGroupCard from './group/SetGroupCard'; +import GetImage from './file/GetImage'; +import GetRecord from './file/GetRecord'; +import { GoCQHTTPMarkMsgAsRead, MarkAllMsgAsRead, MarkGroupMsgAsRead, MarkPrivateMsgAsRead } from './msg/MarkMsgAsRead'; +import GoCQHTTPUploadGroupFile from './go-cqhttp/UploadGroupFile'; +import SetQQAvatar from '@/snowcat-onebot/action/extends/SetQQAvatar'; +import GoCQHTTPDownloadFile from './go-cqhttp/DownloadFile'; +import GoCQHTTPGetGroupMsgHistory from './go-cqhttp/GetGroupMsgHistory'; +import GetFile from './file/GetFile'; +import { GoCQHTTPGetForwardMsgAction } from './go-cqhttp/GetForwardMsg'; +import GetFriendMsgHistory from './go-cqhttp/GetFriendMsgHistory'; +import { GetCookies } from './user/GetCookies'; +import { SetMsgEmojiLike } from '@/snowcat-onebot/action/msg/SetMsgEmojiLike'; +import { GetRobotUinRange } from './extends/GetRobotUinRange'; +import { SetOnlineStatus } from './extends/SetOnlineStatus'; +import { GetGroupNotice } from './group/GetGroupNotice'; +import { GetGroupEssence } from './group/GetGroupEssence'; +import { ForwardFriendSingleMsg, ForwardGroupSingleMsg } from '@/snowcat-onebot/action/msg/ForwardSingleMsg'; +import { GetFriendWithCategory } from './extends/GetFriendWithCategory'; +import { SendGroupNotice } from './go-cqhttp/SendGroupNotice'; +import { GetGroupHonorInfo } from './go-cqhttp/GetGroupHonorInfo'; +import { GoCQHTTPHandleQuickAction } from './go-cqhttp/QuickAction'; +import { GetGroupIgnoredNotifies } from './group/GetGroupIgnoredNotifies'; +import { GetOnlineClient } from './go-cqhttp/GetOnlineClient'; +import { IOCRImage, OCRImage } from './extends/OCRImage'; +import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn'; +import { SetQQProfile } from './go-cqhttp/SetQQProfile'; +import { SendArkShare, SendGroupArkShare, ShareGroupEx, SharePeer } from './extends/ShareContact'; +import { CreateCollection } from './extends/CreateCollection'; +import { SetLongNick } from './extends/SetLongNick'; +import DelEssenceMsg from './group/DelEssenceMsg'; +import SetEssenceMsg from './group/SetEssenceMsg'; +import GetRecentContact from './user/GetRecentContact'; +import { GetProfileLike } from './extends/GetProfileLike'; +import SetGroupPortrait from './go-cqhttp/SetGroupPortrait'; +import { FetchCustomFace } from './extends/FetchCustomFace'; +import { AddCustomFace, DeleteCustomFace, FetchCustomFaceDetail, SetCustomFaceDesc } from './extends/CustomFace'; +import GoCQHTTPUploadPrivateFile from './go-cqhttp/UploadPrivateFile'; +import { FetchEmojiLike } from './extends/FetchEmojiLike'; +import { GetEmojiLikes } from './extends/GetEmojiLikes'; +import { SnowCatCore } from 'snowcat-core'; +import type { NetworkAdapterConfig } from '../config/config'; +import { OneBotAction } from './OneBotAction'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot'; +import { SetInputStatus } from './extends/SetInputStatus'; +import { GetCSRF } from './system/GetCSRF'; +import { DelGroupNotice } from './group/DelGroupNotice'; +import { GetGroupInfoEx } from './extends/GetGroupInfoEx'; +import { GetGroupShareLink } from './extends/GetGroupShareLink'; +import { DeleteGroupFile } from '@/snowcat-onebot/action/go-cqhttp/DeleteGroupFile'; +import { CreateGroupFileFolder } from '@/snowcat-onebot/action/go-cqhttp/CreateGroupFileFolder'; +import { DeleteGroupFileFolder } from '@/snowcat-onebot/action/go-cqhttp/DeleteGroupFileFolder'; +import { GetGroupFileSystemInfo } from '@/snowcat-onebot/action/go-cqhttp/GetGroupFileSystemInfo'; +import { GetGroupRootFiles } from '@/snowcat-onebot/action/go-cqhttp/GetGroupRootFiles'; +import { GetGroupFilesByFolder } from '@/snowcat-onebot/action/go-cqhttp/GetGroupFilesByFolder'; +import { GetGroupSystemMsg } from './system/GetSystemMsg'; +import { GetUserStatus } from './extends/GetUserStatus'; +import { GetRkey } from './extends/GetRkey'; +import { SetSpecialTitle } from './extends/SetSpecialTitle'; +import { GetGroupShutList } from './group/GetGroupShutList'; +import { GetGroupMemberList } from './group/GetGroupMemberList'; +import { GetGroupFileUrl } from '@/snowcat-onebot/action/file/GetGroupFileUrl'; +import { GetPacketStatus } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { GetCredentials } from './system/GetCredentials'; +import { SetRestart } from './system/SetRestart'; +import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign'; +import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain'; +import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely'; +import { GoCQHTTPGetModelShow } from './go-cqhttp/GoCQHTTPGetModelShow'; +import { GoCQHTTPSetModelShow } from './go-cqhttp/GoCQHTTPSetModelShow'; +import { GoCQHTTPDeleteFriend } from './go-cqhttp/GoCQHTTPDeleteFriend'; +import { GetMiniAppArk } from '@/snowcat-onebot/action/extends/GetMiniAppArk'; +import { GetAiRecord } from '@/snowcat-onebot/action/group/GetAiRecord'; +import { SendGroupAiRecord } from '@/snowcat-onebot/action/group/SendGroupAiRecord'; +import { GetAiCharacters } from '@/snowcat-onebot/action/extends/GetAiCharacters'; +import { GetGuildList } from './guild/GetGuildList'; +import { GetGuildProfile } from './guild/GetGuildProfile'; +import { GetClientkey } from './extends/GetClientkey'; +import { SendPacket } from './extends/SendPacket'; +import { FriendPoke, GroupPoke, SendPoke } from '@/snowcat-onebot/action/packet/SendPoke'; +import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus'; +import { BotExit } from './extends/BotExit'; +import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton'; +import { GetPrivateFileUrl } from './file/GetPrivateFileUrl'; +import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList'; +import SetGroupRemark from './extends/SetGroupRemark'; +import { MoveGroupFile } from './extends/MoveGroupFile'; +import { TransGroupFile } from './extends/TransGroupFile'; +import { RenameGroupFile } from './extends/RenameGroupFile'; +import { GetRkeyServer } from './packet/GetRkeyServer'; +import { GetRkeyEx } from './packet/GetRkeyEx'; +import { CleanCache } from './system/CleanCache'; +import SetFriendRemark from './user/SetFriendRemark'; +import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest'; +import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest'; +import SetGroupAddOption from './extends/SetGroupAddOption'; +import SetGroupSearch from './extends/SetGroupSearch'; +import SetGroupRobotAddOption from './extends/SetGroupRobotAddOption'; +import SetGroupKickMembers from './extends/SetGroupKickMembers'; +import { GetGroupDetailInfo } from './group/GetGroupDetailInfo'; +import GetGroupAddRequest from './extends/GetGroupAddRequest'; +import { GetCollectionList } from './extends/GetCollectionList'; +import { CompleteGroupTodo } from './packet/CompleteGroupTodo'; +import { CancelGroupTodo } from './packet/CancelGroupTodo'; +import { SetGroupTodo } from './packet/SetGroupTodo'; +import { GetQunAlbumList } from './extends/GetQunAlbumList'; +import { UploadImageToQunAlbum } from './extends/UploadImageToQunAlbum'; +import { DoGroupAlbumComment } from './extends/DoGroupAlbumComment'; +import { GetGroupAlbumMediaList } from './extends/GetGroupAlbumMediaList'; +import { SetGroupAlbumMediaLike, CancelGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike'; +import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia'; +import { CleanStreamTempFile } from './stream/CleanStreamTempFile'; +import { DownloadFileStream } from './stream/DownloadFileStream'; +import { DownloadFileRecordStream } from './stream/DownloadFileRecordStream'; +import { DownloadFileImageStream } from './stream/DownloadFileImageStream'; +import { TestDownloadStream } from './stream/TestStreamDownload'; +import { UploadFileStream } from './stream/UploadFileStream'; +import { AutoRegisterRouter } from './auto-register'; +import { CreateFlashTask } from './file/flash/CreateFlashTask'; +import { SendFlashMsg } from './file/flash/SendFlashMsg'; +import { GetFlashFileList } from './file/flash/GetFlashFileList'; +import { GetFlashFileUrl } from './file/flash/GetFlashFileUrl'; +import { GetShareLink } from './file/flash/GetShareLink'; +import { GetFilesetInfo } from './file/flash/GetFilesetInfo'; +import { DownloadFileset } from './file/flash/DownloadFileset'; +import { GetOnlineFileMessages } from './file/online/GetOnlineFileMessages'; +import { SendOnlineFile } from './file/online/SendOnlineFile'; +import { SendOnlineFolder } from './file/online/SendOnlineFolder'; +import { CancelOnlineFile } from './file/online/CancelOnlineFile'; +import { ReceiveOnlineFile } from './file/online/ReceiveOnlineFile'; +import { RefuseOnlineFile } from './file/online/RefuseOnlineFile'; +import { GetFilesetId } from './file/flash/GetFilesetIdByCode'; +import { FetchPttText } from '@/snowcat-onebot/action/msg/FetchPttText'; +import { GetGroupSignedList } from './extends/GetGroupSignedList'; +import { SendQzoneMsg } from './extends/SendQzoneMsg'; +import { DeleteQzoneMsg } from './extends/DeleteQzoneMsg'; +// SnowCat · qzone Web 线动作组(SnowLuma 移植) +import { GetQzoneMsgList } from './extends/qzone/GetQzoneMsgList'; +import { GetQzoneMsgDetail } from './extends/qzone/GetQzoneMsgDetail'; +import { SetQzoneLike } from './extends/qzone/SetQzoneLike'; +import { CommentQzoneMsg } from './extends/qzone/CommentQzoneMsg'; +import { ReplyQzoneComment } from './extends/qzone/ReplyQzoneComment'; +import { GetQzoneProfile } from './extends/qzone/GetQzoneProfile'; +import { SetQzoneProfile } from './extends/qzone/SetQzoneProfile'; +import { SetQzoneBlack } from './extends/qzone/SetQzoneBlack'; +import { SetQzoneMsgRight } from './extends/qzone/SetQzoneMsgRight'; +import { SendQzoneMsgFull } from './extends/qzone/SendQzoneMsgFull'; +import { GetQzoneFeeds } from './extends/qzone/GetQzoneFeeds'; +import { + GetQzonePublicCapabilities, + GetQzonePublicFeeds, + GetQzonePublicFeedDetail, + GetQzonePublicComments, + GetQzonePublicReplies, + SetQzonePublicUpvote, + SetQzonePublicLike, + SetQzonePublicCommentLike, + CommentQzonePublicFeed, + ReplyQzonePublicFeed, + DeleteQzonePublicComment, + DeleteQzonePublicReply, + SetQzonePublicFollow, + ForwardQzonePublicFeed, +} from './extends/qzone/qzone-public'; +// SnowCat · 第三批(SnowLuma 移植):群相册补/群管补/自定义表情/杂项 +import { + GetGroupAlbumList, + GetGroupAlbumMediaListWeb, + GetMsgEmojiLikes, + FetchSysFaces, + GetFriendDress, +} from './extends/qzone/batch3'; +import SetGroupMemberInvitePolicy from './group/SetGroupMemberInvitePolicy'; +import SetGroupMemberPermissions from './group/SetGroupMemberPermissions'; +import SetGroupNewMemberHistoryVisibility from './group/SetGroupNewMemberHistoryVisibility'; + +export function getAllHandlers (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + const actionHandlers = [ + new CleanStreamTempFile(obContext, core), + new DownloadFileStream(obContext, core), + new DownloadFileRecordStream(obContext, core), + new DownloadFileImageStream(obContext, core), + new TestDownloadStream(obContext, core), + new UploadFileStream(obContext, core), + new DelGroupAlbumMedia(obContext, core), + new SetGroupAlbumMediaLike(obContext, core), + new CancelGroupAlbumMediaLike(obContext, core), + new DoGroupAlbumComment(obContext, core), + new GetGroupAlbumMediaList(obContext, core), + new GetQunAlbumList(obContext, core), + new UploadImageToQunAlbum(obContext, core), + new SetGroupTodo(obContext, core), + new CompleteGroupTodo(obContext, core), + new CancelGroupTodo(obContext, core), + new GetGroupDetailInfo(obContext, core), + new SetGroupKickMembers(obContext, core), + new SetGroupAddOption(obContext, core), + new SetGroupRobotAddOption(obContext, core), + new SetGroupSearch(obContext, core), + new SetDoubtFriendsAddRequest(obContext, core), + new GetDoubtFriendsAddRequest(obContext, core), + new SetFriendRemark(obContext, core), + new GetRkeyEx(obContext, core), + new GetRkeyServer(obContext, core), + new SetGroupRemark(obContext, core), + new GetGroupInfoEx(obContext, core), + new GetGroupShareLink(obContext, core), + new FetchEmojiLike(obContext, core), + new GetEmojiLikes(obContext, core), + new FetchPttText(obContext, core), + new GetFile(obContext, core), + new SetQQProfile(obContext, core), + new ShareGroupEx(obContext, core), + new SharePeer(obContext, core), + new SendGroupArkShare(obContext, core), + new SendArkShare(obContext, core), + new CreateCollection(obContext, core), + new SetLongNick(obContext, core), + new ForwardFriendSingleMsg(obContext, core), + new ForwardGroupSingleMsg(obContext, core), + new MarkGroupMsgAsRead(obContext, core), + new MarkPrivateMsgAsRead(obContext, core), + new SetQQAvatar(obContext, core), + new TranslateEnWordToZn(obContext, core), + new GetGroupRootFiles(obContext, core), + new SetGroupSign(obContext, core), + new SendGroupSign(obContext, core), + new GetClientkey(obContext, core), + new MoveGroupFile(obContext, core), + new RenameGroupFile(obContext, core), + new TransGroupFile(obContext, core), + new GetGroupSignedList(obContext, core), + new SendQzoneMsg(obContext, core), + new DeleteQzoneMsg(obContext, core), + new SetGroupMemberInvitePolicy(obContext, core), + new SetGroupMemberPermissions(obContext, core), + new SetGroupNewMemberHistoryVisibility(obContext, core), + // onebot11 + new SendLike(obContext, core), + new GetMsg(obContext, core), + new GetLoginInfo(obContext, core), + new GetFriendList(obContext, core), + new GetGroupList(obContext, core), + new GetGroupInfo(obContext, core), + new GetGroupMemberList(obContext, core), + new GetGroupMemberInfo(obContext, core), + new SendGroupMsg(obContext, core), + new SendPrivateMsg(obContext, core), + new SendMsg(obContext, core), + new DeleteMsg(obContext, core), + new SetGroupAddRequest(obContext, core), + new SetFriendAddRequest(obContext, core), + new SetGroupLeave(obContext, core), + new GetVersionInfo(obContext, core), + new CanSendRecord(obContext, core), + new CanSendImage(obContext, core), + new GetStatus(obContext, core), + new SetGroupWholeBan(obContext, core), + new SetGroupBan(obContext, core), + new SetGroupKick(obContext, core), + new SetGroupAdmin(obContext, core), + new SetGroupName(obContext, core), + new SetGroupCard(obContext, core), + new GetImage(obContext, core), + new GetRecord(obContext, core), + new SetMsgEmojiLike(obContext, core), + new GetCookies(obContext, core), + new SetOnlineStatus(obContext, core), + new GetRobotUinRange(obContext, core), + new GetFriendWithCategory(obContext, core), + // 以下为go-cqhttp api + new GoCQHTTPDeleteFriend(obContext, core), + new GoCQHTTPCheckUrlSafely(obContext, core), + new GetOnlineClient(obContext, core), + new OCRImage(obContext, core), + new IOCRImage(obContext, core), + new GetGroupHonorInfo(obContext, core), + new SendGroupNotice(obContext, core), + new GetGroupNotice(obContext, core), + new GetGroupEssence(obContext, core), + new GoCQHTTPGetGroupAtAllRemain(obContext, core), + new GoCQHTTPSendForwardMsg(obContext, core), + new GoCQHTTPSendGroupForwardMsg(obContext, core), + new GoCQHTTPSendPrivateForwardMsg(obContext, core), + new GoCQHTTPGetStrangerInfo(obContext, core), + new GoCQHTTPDownloadFile(obContext, core), + new GetGuildList(obContext, core), + new GoCQHTTPMarkMsgAsRead(obContext, core), + new GoCQHTTPUploadGroupFile(obContext, core), + new GoCQHTTPGetGroupMsgHistory(obContext, core), + new GoCQHTTPGetForwardMsgAction(obContext, core), + new GetFriendMsgHistory(obContext, core), + new GoCQHTTPHandleQuickAction(obContext, core), + new GetGroupIgnoredNotifies(obContext, core), + new DelEssenceMsg(obContext, core), + new SetEssenceMsg(obContext, core), + new GetRecentContact(obContext, core), + new MarkAllMsgAsRead(obContext, core), + new GetProfileLike(obContext, core), + new SetGroupPortrait(obContext, core), + new FetchCustomFace(obContext, core), + new FetchCustomFaceDetail(obContext, core), + new AddCustomFace(obContext, core), + new DeleteCustomFace(obContext, core), + new SetCustomFaceDesc(obContext, core), + new GoCQHTTPUploadPrivateFile(obContext, core), + new GetGuildProfile(obContext, core), + new GoCQHTTPGetModelShow(obContext, core), + new GoCQHTTPSetModelShow(obContext, core), + new GoCQHTTPCheckUrlSafely(obContext, core), + new SetInputStatus(obContext, core), + new GetCSRF(obContext, core), + new GetCredentials(obContext, core), + new DelGroupNotice(obContext, core), + new DeleteGroupFile(obContext, core), + new CreateGroupFileFolder(obContext, core), + new DeleteGroupFileFolder(obContext, core), + new GetGroupFileSystemInfo(obContext, core), + new GetGroupFilesByFolder(obContext, core), + new GetPacketStatus(obContext, core), + new SetRestart(obContext, core), + new GroupPoke(obContext, core), + new FriendPoke(obContext, core), + new GetUserStatus(obContext, core), + new GetRkey(obContext, core), + new SetSpecialTitle(obContext, core), + new SetDiyOnlineStatus(obContext, core), + // new UploadForwardMsg(obContext, core), + new GetGroupShutList(obContext, core), + new GetGroupFileUrl(obContext, core), + new GetMiniAppArk(obContext, core), + new GetAiRecord(obContext, core), + new SendGroupAiRecord(obContext, core), + new GetAiCharacters(obContext, core), + new SendPacket(obContext, core), + new SendPoke(obContext, core), + new GetGroupSystemMsg(obContext, core), + new BotExit(obContext, core), + new ClickInlineKeyboardButton(obContext, core), + new GetPrivateFileUrl(obContext, core), + new GetUnidirectionalFriendList(obContext, core), + new CleanCache(obContext, core), + new GetGroupAddRequest(obContext, core), + new GetCollectionList(obContext, core), + new CreateFlashTask(obContext, core), + new GetFlashFileList(obContext, core), + new GetFlashFileUrl(obContext, core), + new SendFlashMsg(obContext, core), + new GetShareLink(obContext, core), + new GetFilesetInfo(obContext, core), + new GetOnlineFileMessages(obContext, core), + new SendOnlineFile(obContext, core), + new SendOnlineFolder(obContext, core), + new ReceiveOnlineFile(obContext, core), + new RefuseOnlineFile(obContext, core), + new CancelOnlineFile(obContext, core), + new DownloadFileset(obContext, core), + new GetFilesetId(obContext, core), + // SnowCat · qzone Web 线动作组(SnowLuma 移植) + new GetQzoneMsgList(obContext, core), + new GetQzoneMsgDetail(obContext, core), + new SetQzoneLike(obContext, core), + new CommentQzoneMsg(obContext, core), + new ReplyQzoneComment(obContext, core), + new GetQzoneProfile(obContext, core), + new SetQzoneProfile(obContext, core), + new SetQzoneBlack(obContext, core), + new SetQzoneMsgRight(obContext, core), + new SendQzoneMsgFull(obContext, core), + new GetQzoneFeeds(obContext, core), + // SnowCat · qzone 公共空间(空友)动作组(SnowLuma 移植) + new GetQzonePublicCapabilities(obContext, core), + new GetQzonePublicFeeds(obContext, core), + new GetQzonePublicFeedDetail(obContext, core), + new GetQzonePublicComments(obContext, core), + new GetQzonePublicReplies(obContext, core), + new SetQzonePublicUpvote(obContext, core), + new SetQzonePublicLike(obContext, core), + new SetQzonePublicCommentLike(obContext, core), + new CommentQzonePublicFeed(obContext, core), + new ReplyQzonePublicFeed(obContext, core), + new DeleteQzonePublicComment(obContext, core), + new DeleteQzonePublicReply(obContext, core), + new SetQzonePublicFollow(obContext, core), + new ForwardQzonePublicFeed(obContext, core), + // SnowCat · 第三批动作组(SnowLuma 移植) + new GetGroupAlbumList(obContext, core), + new GetGroupAlbumMediaListWeb(obContext, core), + new GetMsgEmojiLikes(obContext, core), + new FetchSysFaces(obContext, core), + new GetFriendDress(obContext, core), + ]; + return actionHandlers; +} + +export function createActionMap (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + const actionHandlers = getAllHandlers(obContext, core); + type HandlerUnion = typeof actionHandlers[number]; + type MapType = { + [H in HandlerUnion as H['actionName']]: H; + } & { + [H in HandlerUnion as `${H['actionName']}_async`]: H; + } & { + [H in HandlerUnion as `${H['actionName']}_rate_limited`]: H; + }; + + const _map = new Map(); + + actionHandlers.forEach(h => { + _map.set(h.actionName as keyof MapType, h); + _map.set(`${h.actionName}_async` as keyof MapType, h); + _map.set(`${h.actionName}_rate_limited` as keyof MapType, h); + }); + AutoRegisterRouter.forEach((ActionClass) => { + const handlerInstance = new ActionClass(obContext, core); // 延迟实例化 + const actionName = handlerInstance.actionName; // 获取 actionName + _map.set(actionName as keyof MapType, handlerInstance as HandlerUnion); + _map.set(`${actionName}_async` as keyof MapType, handlerInstance as HandlerUnion); + _map.set(`${actionName}_rate_limited` as keyof MapType, handlerInstance as HandlerUnion); + }); + // function get(key: K): MapType[K]; + // function get(key: K): null; + // function get(key: K): HandlerUnion | null | MapType[K] + function get (key: K): MapType[K] | undefined { + return _map.get(key as keyof MapType) as MapType[K] | undefined; + } + + /** + * 类型安全的 action 调用辅助函数 + * 根据 action 名称自动推导返回类型 + */ + async function call ( + actionName: K, + params: unknown, + adapter: string, + config: NetworkAdapterConfig + ): Promise ? R : never> { + const action = _map.get(actionName); + if (!action) { + throw new Error(`Action ${String(actionName)} not found`); + } + + const result = await (action as any).handle(params, adapter, config); + if (result.status !== 'ok' || !result.data) { + throw new Error(`Action ${String(actionName)} failed: ${result.message || 'No data returned'}`); + } + + return result.data; + } + + return { get, call }; +} +export type ActionMap = ReturnType; diff --git a/packages/snowcat-onebot/action/msg/DeleteMsg.ts b/packages/snowcat-onebot/action/msg/DeleteMsg.ts new file mode 100644 index 0000000..90564b9 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/DeleteMsg.ts @@ -0,0 +1,43 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +class DeleteMsg extends OneBotAction { + override actionName = ActionName.DeleteMsg; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '撤回消息'; + override actionDescription = '撤回已发送的消息'; + override actionTags = ['消息接口']; + override payloadExample = { + message_id: 12345, + }; + + override returnExample = null; + + async _handle (payload: PayloadType) { + const msg = MessageUnique.getMsgIdAndPeerByShortId(Number(payload.message_id)); + if (msg) { + this.obContext.recallEventCache.set(msg.MsgId, setTimeout(() => { + this.obContext.recallEventCache.delete(msg.MsgId); + }, 5000)); + await this.core.apis.MsgApi.recallMsg(msg.Peer, msg.MsgId); + return null; + } else { + throw new Error('Recall failed'); + } + } +} + +export default DeleteMsg; diff --git a/packages/snowcat-onebot/action/msg/FetchPttText.ts b/packages/snowcat-onebot/action/msg/FetchPttText.ts new file mode 100644 index 0000000..eabc423 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/FetchPttText.ts @@ -0,0 +1,87 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; +import { PromiseTimer } from '@/snowcat-common/src/helper'; +import { ElementType } from '@/snowcat-core'; +import { MsgActionsExamples } from '@/snowcat-onebot/action/msg/examples'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ text: Type.String({ description: '得到的文本' }) }); + +type ReturnType = Static; + +export class FetchPttText extends OneBotAction { + override actionName = ActionName.FetchPttText; + override actionSummary = '获取语音转文字结果'; + override actionTags = ['消息扩展']; + override payloadExample = MsgActionsExamples.FetchPttText.payload; + override returnExample = MsgActionsExamples.FetchPttText.response; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + if (!payload.message_id) { + throw Error('参数message_id不能为空'); + } + const MsgShortId = MessageUnique.getShortIdByMsgId(payload.message_id.toString()); + const msgIdWithPeer = MessageUnique.getMsgIdAndPeerByShortId(MsgShortId ?? +payload.message_id); + if (!msgIdWithPeer) { + throw new Error('消息不存在'); + } + const peer = { guildId: '', peerUid: msgIdWithPeer?.Peer.peerUid, chatType: msgIdWithPeer.Peer.chatType }; + const getMsgTimeout = this.obContext.configLoader.configData.timeout.baseTimeout; + const msgRes = await PromiseTimer( + this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgIdWithPeer?.MsgId || payload.message_id.toString()]), + getMsgTimeout + ).catch((e: unknown) => { + if (e instanceof Error && e.message.startsWith('PromiseTimer:')) { + throw new Error(`消息不存在或已被撤回: ${e.message || String(e)}`); + } + throw e; + }); + const msg = msgRes.msgList[0]; + if (!msg) throw Error('消息不存在或已被撤回'); + const pttSegment = msg.elements.find((s) => + s.elementType === ElementType.PTT + ); + if (!pttSegment) { + throw Error('消息中不包含语音'); + } + const getPttTextTimeout = this.obContext.configLoader.configData.timeout.baseTimeout; + await PromiseTimer( + this.core.context.session.getMsgService().translatePtt2Text(msgIdWithPeer.MsgId, msgIdWithPeer.Peer, pttSegment), + getPttTextTimeout + ).catch((e: unknown) => { + if (e instanceof Error && e.message.startsWith('PromiseTimer:')) { + throw new Error(`获取语音转文字结果失败: ${e.message || String(e)}`); + } + throw e; + }); + const msgRes2 = await PromiseTimer( + this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgIdWithPeer?.MsgId || payload.message_id.toString()]), + getMsgTimeout + ).catch((e: unknown) => { + if (e instanceof Error && e.message.startsWith('PromiseTimer:')) { + throw new Error(`消息不存在或已被撤回: ${e.message || String(e)}`); + } + throw e; + }); + const msg2 = msgRes2.msgList[0]; + if (!msg2) throw Error('消息不存在或已被撤回'); + for (const s of msg2.elements) { + if (s.elementType === ElementType.PTT) { + if (s.pttElement?.text) { + return { text: s.pttElement?.text }; + } + } + } + throw new Error('获取语音转文字结果失败'); + } +} diff --git a/packages/snowcat-onebot/action/msg/ForwardSingleMsg.ts b/packages/snowcat-onebot/action/msg/ForwardSingleMsg.ts new file mode 100644 index 0000000..20cc167 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/ForwardSingleMsg.ts @@ -0,0 +1,68 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ChatType, Peer } from 'snowcat-core/types'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), + group_id: Type.Optional(Type.String({ description: '目标群号' })), + user_id: Type.Optional(Type.String({ description: '目标用户QQ' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +class ForwardSingleMsg extends OneBotAction { + override actionSummary = '转发单条消息'; + override actionDescription = '转发单条消息'; + override actionTags = ['消息接口']; + override payloadExample = { + message_id: 12345, + group_id: '123456', + }; + + override returnExample = null; + + protected async getTargetPeer (payload: PayloadType): Promise { + if (payload.user_id) { + const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!peerUid) { + throw new Error(`无法找到私聊对象${payload.user_id}`); + } + return { chatType: ChatType.KCHATTYPEC2C, peerUid }; + } + return { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id!.toString() }; + } + + async _handle (payload: PayloadType): Promise { + const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); + if (!msg) { + throw new Error(`无法找到消息${payload.message_id}`); + } + const peer = await this.getTargetPeer(payload); + const ret = await this.core.apis.MsgApi.forwardMsg(msg.Peer, + peer, + [msg.MsgId] + ); + if (ret.result !== 0) { + throw new Error(`转发消息失败 ${ret.errMsg}`); + } + return null; + } +} + +export class ForwardFriendSingleMsg extends ForwardSingleMsg { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionName = ActionName.ForwardFriendSingleMsg; +} + +export class ForwardGroupSingleMsg extends ForwardSingleMsg { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionName = ActionName.ForwardGroupSingleMsg; +} diff --git a/packages/snowcat-onebot/action/msg/GetMsg.ts b/packages/snowcat-onebot/action/msg/GetMsg.ts new file mode 100644 index 0000000..e67aa00 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/GetMsg.ts @@ -0,0 +1,87 @@ +import type { MessageFormat } from '@/snowcat-onebot/config/config'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { PromiseTimer } from 'snowcat-common/src/helper'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; + +import { MsgActionsExamples } from './examples'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Object({ + time: Type.Number({ description: '发送时间' }), + message_type: Type.String({ description: '消息类型' }), + message_id: Type.Number({ description: '消息ID' }), + real_id: Type.Number({ description: '真实ID' }), + message_seq: Type.Number({ description: '消息序号' }), + sender: Type.Any({ description: '发送者' }), + message: Type.Any({ description: '消息内容' }), + raw_message: Type.String({ description: '原始消息内容' }), + font: Type.Number({ description: '字体' }), + group_id: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '群号' })), + user_id: Type.Union([Type.Number(), Type.String()], { description: '发送者QQ号' }), + emoji_likes_list: Type.Optional(Type.Array(Type.Any(), { description: '表情回应列表' })), +}, { description: 'OneBot 11 消息' }); + +type ReturnType = Static; + +class GetMsg extends OneBotAction { + override actionName = ActionName.GetMsg; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取消息'; + override actionDescription = '根据消息 ID 获取消息详细信息'; + override actionTags = ['消息接口']; + override payloadExample = MsgActionsExamples.GetMsg.payload; + override returnExample = MsgActionsExamples.GetMsg.response; + + async _handle (payload: PayloadType, _adapter: string, config: NetworkAdapterConfig) { + if (!payload.message_id) { + throw Error('参数message_id不能为空'); + } + const MsgShortId = MessageUnique.getShortIdByMsgId(payload.message_id.toString()); + const msgIdWithPeer = MessageUnique.getMsgIdAndPeerByShortId(MsgShortId ?? +payload.message_id); + if (!msgIdWithPeer) { + throw new Error('消息不存在'); + } + const peer = { guildId: '', peerUid: msgIdWithPeer?.Peer.peerUid, chatType: msgIdWithPeer.Peer.chatType }; + const getMsgTimeout = this.obContext.configLoader.configData.timeout.baseTimeout; + const msgRes = await PromiseTimer( + this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgIdWithPeer?.MsgId || payload.message_id.toString()]), + getMsgTimeout + ).catch((e: unknown) => { + if (e instanceof Error && e.message.startsWith('PromiseTimer:')) { + throw new Error(`消息不存在或已被撤回: ${e.message || String(e)}`); + } + throw e; + }); + const msg = msgRes.msgList[0]; + if (!msg) throw Error('消息不存在或已被撤回'); + const retMsg = await this.obContext.apis.MsgApi.parseMessage(msg, config['messagePostFormat'] as MessageFormat | undefined ?? 'array'); + if (!retMsg) throw Error('消息为空'); + retMsg.emoji_likes_list = []; + msg.emojiLikesList?.forEach(emoji => { + retMsg.emoji_likes_list?.push({ + emoji_id: emoji.emojiId, + emoji_type: emoji.emojiType, + likes_cnt: emoji.likesCnt, + }); + }); + try { + retMsg.message_id = MessageUnique.createUniqueMsgId(peer, msg.msgId)!; + retMsg.message_seq = retMsg.message_id; + retMsg.real_id = retMsg.message_id; + } catch { + // ignored + } + return retMsg; + } +} + +export default GetMsg; diff --git a/packages/snowcat-onebot/action/msg/MarkMsgAsRead.ts b/packages/snowcat-onebot/action/msg/MarkMsgAsRead.ts new file mode 100644 index 0000000..b451f63 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/MarkMsgAsRead.ts @@ -0,0 +1,96 @@ +import { ChatType, Peer } from 'snowcat-core/types'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + user_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: '用户QQ' })), + group_id: Type.Optional(Type.String({ description: '群号' })), + message_id: Type.Optional(Type.String({ description: '消息ID' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Null({ description: '操作结果' }); + +type ReturnType = Static; + +class MarkMsgAsRead extends OneBotAction { + override actionSummary = '标记消息已读'; + override actionDescription = '标记指定渠道的消息为已读'; + override actionTags = ['消息接口']; + override payloadExample = { + message_id: 12345, + }; + + override returnExample = null; + + async getPeer (payload: PayloadType): Promise { + if (payload.message_id) { + const s_peer = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id)?.Peer; + if (s_peer) { + return s_peer; + } + const l_peer = MessageUnique.getPeerByMsgId(payload.message_id.toString())?.Peer; + if (l_peer) { + return l_peer; + } + } + if (payload.user_id) { + const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!peerUid) { + throw new Error(`私聊${payload.user_id}不存在`); + } + const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid); + return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid }; + } + if (!payload.group_id) { + throw new Error('缺少参数 group_id 或 user_id'); + } + return { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() }; + } + + async _handle (payload: PayloadType): Promise { + const ret = await this.core.apis.MsgApi.setMsgRead(await this.getPeer(payload)); + if (ret.result !== 0) { + throw new Error('设置已读失败,' + ret.errMsg); + } + return null; + } +} + +// 以下为非标准实现 +export class MarkPrivateMsgAsRead extends MarkMsgAsRead { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionName = ActionName.MarkPrivateMsgAsRead; + override actionSummary = '标记私聊已读'; +} + +export class MarkGroupMsgAsRead extends MarkMsgAsRead { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionName = ActionName.MarkGroupMsgAsRead; + override actionSummary = '标记群聊已读'; +} + +export class GoCQHTTPMarkMsgAsRead extends MarkMsgAsRead { + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionName = ActionName.GoCQHTTP_MarkMsgAsRead; + override actionSummary = '标记消息已读 (Go-CQHTTP)'; +} + +export class MarkAllMsgAsRead extends OneBotAction { + override actionName = ActionName._MarkAllMsgAsRead; + override actionSummary = '标记所有消息已读'; + override actionTags = ['消息接口']; + override payloadExample = {}; + override returnExample = null; + + async _handle (): Promise { + await this.core.apis.MsgApi.markAllMsgAsRead(); + return null; + } +} diff --git a/packages/snowcat-onebot/action/msg/SendMsg.ts b/packages/snowcat-onebot/action/msg/SendMsg.ts new file mode 100644 index 0000000..3937200 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/SendMsg.ts @@ -0,0 +1,517 @@ +import { + OB11MessageData, + OB11MessageDataType, + OB11MessageMixType, + OB11MessageNode, + OB11PostContext, +} from '@/snowcat-onebot/types'; +import { ActionName, BaseCheckResult } from '@/snowcat-onebot/action/router'; +import { decodeCQCode } from '@/snowcat-onebot/helper/cqcode'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { ChatType, ElementType, SnowCatCore, Peer, RawMessage, SendArkElement, SendMessageElement } from 'snowcat-core'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { snowCatStable } from '@/snowcat-onebot/stable-line'; +import { ForwardMsgBuilder } from '@/snowcat-core/helper/forward-msg-builder'; +import { stringifyWithBigInt } from 'snowcat-common/src/helper'; +import { PacketMsg } from 'snowcat-core/packet/message/message'; +import { rawMsgWithSendMsg } from 'snowcat-core/packet/message/converter'; +import { Static, Type } from '@sinclair/typebox'; +import { MsgActionsExamples } from '@/snowcat-onebot/action/msg/examples'; +import { OB11MessageMixTypeSchema } from '@/snowcat-onebot/types/message'; +import { UploadForwardMsgParams } from '@/snowcat-core/packet/transformer/message/UploadForwardMsgV2'; + +export const SendMsgPayloadSchema = Type.Object({ + message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })), + user_id: Type.Optional(Type.String({ description: '用户QQ' })), + group_id: Type.Optional(Type.String({ description: '群号' })), + message: OB11MessageMixTypeSchema, + auto_escape: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否作为纯文本发送' })), + // 以下为扩展字段 + source: Type.Optional(Type.String({ description: '合并转发来源' })), + news: Type.Optional(Type.Array(Type.Object({ text: Type.String() }), { description: '合并转发新闻' })), + summary: Type.Optional(Type.String({ description: '合并转发摘要' })), + prompt: Type.Optional(Type.String({ description: '合并转发提示' })), + timeout: Type.Optional(Type.Number({ description: '自定义发送超时(毫秒),覆盖自动计算值' })), +}); + +export type SendMsgPayload = Static; + +export const SendMsgReturnSchema = Type.Object({ + message_id: Type.Number({ description: '消息ID' }), + res_id: Type.Optional(Type.String({ description: '转发消息的 res_id' })), + forward_id: Type.Optional(Type.String({ description: '转发消息的 forward_id' })), +}); + +export type ReturnDataType = Static; + +export enum ContextMode { + Normal = 0, + Private = 1, + Group = 2, +} + +// Normalizes a mixed type (CQCode/a single segment/segment array) into a segment array. +export function normalize (message: OB11MessageMixType, autoEscape = false): OB11MessageData[] { + return typeof message === 'string' + ? ( + autoEscape + ? [{ type: OB11MessageDataType.text, data: { text: message } }] + : decodeCQCode(message) + ) + : Array.isArray(message) ? message : [message]; +} + +export async function createContext (core: SnowCatCore, payload: OB11PostContext | undefined, contextMode: ContextMode = ContextMode.Normal): Promise { + if (!payload) { + throw new Error('请传递请求内容'); + } + if ((contextMode === ContextMode.Group || contextMode === ContextMode.Normal) && payload.group_id) { + return { + chatType: ChatType.KCHATTYPEGROUP, + peerUid: payload.group_id.toString(), + }; + } + if ((contextMode === ContextMode.Private || contextMode === ContextMode.Normal) && payload.user_id) { + const Uid = await core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + if (!Uid) { + if (payload.group_id) { + return { + chatType: ChatType.KCHATTYPEGROUP, + peerUid: payload.group_id.toString(), + guildId: '', + }; + } + throw new Error('无法获取用户信息'); + } + const isBuddy = await core.apis.FriendApi.isBuddy(Uid); + if (!isBuddy) { + if (payload.group_id) { + return { + chatType: ChatType.KCHATTYPETEMPC2CFROMGROUP, + peerUid: Uid, + guildId: payload.group_id.toString(), + }; + } + const ret = await core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, Uid); + if (ret.tmpChatInfo?.groupCode) { + return { + chatType: ChatType.KCHATTYPETEMPC2CFROMGROUP, + peerUid: Uid, + guildId: '', + }; + } + return { + chatType: ChatType.KCHATTYPEC2C, + peerUid: Uid, + guildId: '', + }; + } + return { + chatType: ChatType.KCHATTYPEC2C, + peerUid: Uid, + guildId: '', + }; + } + if (contextMode === ContextMode.Private && payload.group_id) { + throw new Error('当前私聊发送,请指定 user_id 而不是 group_id'); + } + if (contextMode === ContextMode.Group && payload.user_id) { + throw new Error('当前群聊发送,请指定 group_id 而不是 user_id'); + } + throw new Error('请指定正确的 group_id 或 user_id'); +} + +function getSpecialMsgNum (messages: OB11MessageData[], msgType: OB11MessageDataType): number { + return messages.filter(msg => msg.type === msgType).length; +} + +function isNode (msg: OB11MessageData): msg is OB11MessageNode { + return msg.type === OB11MessageDataType.node; +} + +export class SendMsgBase extends OneBotAction { + override payloadSchema = SendMsgPayloadSchema; + override returnSchema = SendMsgReturnSchema; + override actionTags = ['消息接口']; + + protected override async check (payload: SendMsgPayload): Promise { + const messages = normalize(payload.message); + const nodeElementLength = getSpecialMsgNum(messages, OB11MessageDataType.node); + if (nodeElementLength > 0 && nodeElementLength !== messages.length) { + return { + valid: false, + message: '转发消息不能和普通消息混在一起发送,转发需要保证message只有type为node的元素', + }; + } + return { valid: true }; + } + + async _handle (payload: SendMsgPayload): Promise { + // SnowCat 稳定线:主动发言前过真人抖动+账号预算+熔断(被动回复不受预算惩罚) + await snowCatStable.stableDelay(); + return this.base_handle(payload); + } + + async base_handle (payload: SendMsgPayload, contextMode: ContextMode = ContextMode.Normal): Promise { + if (payload.message_type === 'group') contextMode = ContextMode.Group; + if (payload.message_type === 'private') contextMode = ContextMode.Private; + const peer = await createContext(this.core, { + message_type: payload.message_type, + user_id: payload.user_id?.toString(), + group_id: payload.group_id?.toString(), + }, contextMode); + + const messages = normalize( + payload.message, + typeof payload.auto_escape === 'string' ? payload.auto_escape === 'true' : !!payload.auto_escape + ); + + const nodeMessages = messages.filter(isNode); + if (nodeMessages.length > 0) { + const packetMode = this.core.apis.PacketApi.packetStatus; + let returnMsgAndResId: { message: RawMessage | null, res_id?: string; } | null; + try { + returnMsgAndResId = packetMode + ? await this.handleForwardedNodesPacket(peer, nodeMessages, payload.source, payload.news, payload.summary, payload.prompt) + : await this.handleForwardedNodes(peer, nodeMessages); + } catch (e: unknown) { + throw Error(packetMode ? `发送伪造合并转发消息失败: ${(e as Error)?.stack}` : `发送合并转发消息失败: ${(e as Error)?.stack}`); + } + if (!returnMsgAndResId) { + throw Error('发送合并转发消息失败:returnMsgAndResId 为空!'); + } + if (returnMsgAndResId.message) { + const msgShortId = MessageUnique.createUniqueMsgId({ + guildId: '', + peerUid: peer.peerUid, + chatType: peer.chatType, + }, (returnMsgAndResId.message).msgId); + + // 对gocq的forward_id进行兼容 + const resId = returnMsgAndResId.res_id!; + return { message_id: msgShortId!, res_id: resId, forward_id: resId }; + } else if (returnMsgAndResId.res_id && !returnMsgAndResId.message) { + throw Error(`发送转发消息(res_id:${returnMsgAndResId.res_id} 失败`); + } + } else { + // if (getSpecialMsgNum(payload, OB11MessageDataType.music)) { + // const music: OB11MessageCustomMusic = messages[0] as OB11MessageCustomMusic; + // if (music) { + // } + // } + } + // log("send msg:", peer, sendElements) + + const { sendElements, deleteAfterSentFiles } = await this.obContext.apis.MsgApi + .createSendElements(messages, peer); + const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, sendElements, deleteAfterSentFiles, payload.timeout); + return { message_id: returnMsg.id! }; + } + + private async uploadForwardedNodesPacket (msgPeer: Peer, messageNodes: OB11MessageNode[], source?: string, news?: { + text: string; + }[], summary?: string, prompt?: string, parentMeta?: { + user_id: string, + nickname: string, + }, dp: number = 0): Promise<{ + finallySendElements: SendArkElement, + res_id?: string, + uuid?: string, + packetMsg: PacketMsg[], + deleteAfterSentFiles: string[], + innerPacketMsg?: Array<{ uuid: string, packetMsg?: PacketMsg[], actionMsgBody?: UploadForwardMsgParams['actionMsgBody']; }>; + } | null> { + const packetMsg: PacketMsg[] = []; + const delFiles: string[] = []; + const innerMsg: Array<{ uuid: string, packetMsg?: PacketMsg[], actionMsgBody?: UploadForwardMsgParams['actionMsgBody']; }> = []; + for (const node of messageNodes) { + if (dp >= 3) { + this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!'); + break; + } + if (!node.data.id) { + const OB11Data = normalize(node.type === OB11MessageDataType.node ? node.data.content : node); + let sendElements: SendMessageElement[]; + + const subNodeMessages = OB11Data.filter(isNode); + if (subNodeMessages.length > 0) { + const uploadReturnData = await this.uploadForwardedNodesPacket(msgPeer, subNodeMessages, node.data.source, node.data.news, node.data.summary, node.data.prompt, { + user_id: (node.data.user_id ?? node.data.uin)?.toString() ?? parentMeta?.user_id ?? this.core.selfInfo.uin, + nickname: (node.data.nickname || node.data.name) ?? parentMeta?.nickname ?? 'QQ用户', + }, dp + 1); + sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : []; + delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || [])); + if (uploadReturnData?.uuid) { + innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg }); + uploadReturnData.innerPacketMsg?.forEach(m => { + innerMsg.push(m); + }); + } + } else { + const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements( + OB11Data, + msgPeer, + [], + { usePacketVideoMetadata: true } + ); + sendElements = sendElementsCreateReturn.sendElements; + delFiles.push(...sendElementsCreateReturn.deleteAfterSentFiles); + } + + const packetMsgElements: rawMsgWithSendMsg = { + senderUin: Number((node.data.user_id ?? node.data.uin) ?? parentMeta?.user_id) || +this.core.selfInfo.uin, + senderName: (node.data.nickname || node.data.name) ?? parentMeta?.nickname ?? 'QQ用户', + groupId: msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0, + time: Number(node.data.time) || Date.now(), + msg: sendElements, + }; + this.core.context.logger.logDebug(`handleForwardedNodesPacket[SendRaw] 开始转换 ${stringifyWithBigInt(packetMsgElements)}`); + const transformedMsg = this.core.apis.PacketApi.pkt.msgConverter.rawMsgWithSendMsgToPacketMsg(packetMsgElements); + this.core.context.logger.logDebug(`handleForwardedNodesPacket[SendRaw] 转换为 ${stringifyWithBigInt(transformedMsg)}`); + packetMsg.push(transformedMsg); + } else if (node.data.id) { + const id = node.data.id; + const nodeMsg = MessageUnique.getMsgIdAndPeerByShortId(+id) || MessageUnique.getPeerByMsgId(id); + if (!nodeMsg) { + this.core.context.logger.logError('转发消息失败,未找到消息', id); + continue; + } + const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(nodeMsg.Peer, [nodeMsg.MsgId])).msgList[0]; + this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 开始转换 ${stringifyWithBigInt(msg)}`); + if (msg) { + const msgCache = await this.core.apis.FileApi.downloadRawMsgMedia([msg]); + delFiles.push(...msgCache); + const transformedMsg = this.core.apis.PacketApi.pkt.msgConverter.rawMsgToPacketMsg(msg, msgPeer); + // 检测嵌套合并转发消息,拉取内层protobuf数据 + for (const element of msg.elements) { + let resId: string | undefined; + let uuid: string | undefined; + if (element.multiForwardMsgElement?.resId) { + resId = element.multiForwardMsgElement.resId; + uuid = element.multiForwardMsgElement.fileName; + } else if (element.arkElement?.bytesData) { + try { + const json = JSON.parse(element.arkElement.bytesData); + if (json.app === 'com.tencent.multimsg') { + resId = json.meta?.detail?.resid; + uuid = json.meta?.detail?.uniseq || json.extra?.filename; + } + } catch { /* not a valid json */ } + } + if (resId && uuid) { + try { + const rawActions = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsgRaw(resId); + for (const action of rawActions) { + if (action.actionCommand === 'MultiMsg') { + innerMsg.push({ uuid, actionMsgBody: action.actionData.msgBody }); + } else { + innerMsg.push({ uuid: action.actionCommand, actionMsgBody: action.actionData.msgBody }); + } + } + } catch (e) { + this.core.context.logger.logError(`获取合并转发内层消息失败: ${(e as Error)?.stack}`); + } + } + } + this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 转换为 ${stringifyWithBigInt(transformedMsg)}`); + packetMsg.push(transformedMsg); + } + } else { + this.core.context.logger.logDebug(`handleForwardedNodesPacket 跳过元素 ${stringifyWithBigInt(node)}`); + } + } + if (packetMsg.length === 0) { + this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!'); + return null; + } + const uploadMsgData: UploadForwardMsgParams[] = [{ + actionCommand: 'MultiMsg', + actionMsg: packetMsg, + }]; + innerMsg.forEach(({ uuid, packetMsg: msg, actionMsgBody: raw }) => { + uploadMsgData.push({ + actionCommand: uuid, + actionMsg: msg, + actionMsgBody: raw, + }); + }); + const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0); + const uuid = crypto.randomUUID(); + const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid); + return { + deleteAfterSentFiles: delFiles, + finallySendElements: { + elementType: ElementType.ARK, + elementId: '', + arkElement: { + bytesData: JSON.stringify(forwardJson), + }, + } as SendArkElement, + res_id: resid, + uuid, + packetMsg, + innerPacketMsg: innerMsg, + }; + } + + private async handleForwardedNodesPacket (msgPeer: Peer, messageNodes: OB11MessageNode[], source?: string, news?: { + text: string; + }[], summary?: string, prompt?: string): Promise<{ + message: RawMessage | null, + res_id?: string; + }> { + const uploadReturnData = await this.uploadForwardedNodesPacket(msgPeer, messageNodes, source, news, summary, prompt); + const res_id = uploadReturnData?.res_id; + const finallySendElements = uploadReturnData?.finallySendElements; + if (!finallySendElements) throw Error('转发消息失败,生成节点为空'); + const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(msgPeer, [finallySendElements], uploadReturnData.deleteAfterSentFiles || []).catch(() => undefined); + return { message: returnMsg ?? null, res_id: res_id! }; + } + + private async handleForwardedNodes (destPeer: Peer, messageNodes: OB11MessageNode[]): Promise<{ + message: RawMessage | null, + res_id?: string; + }> { + const selfPeer = { + chatType: ChatType.KCHATTYPEC2C, + peerUid: this.core.selfInfo.uid, + }; + let nodeMsgIds: string[] = []; + for (const messageNode of messageNodes) { + const nodeId = messageNode.data.id; + if (nodeId) { + // 对Msgid和OB11ID混用情况兜底 + const nodeMsg = MessageUnique.getMsgIdAndPeerByShortId(parseInt(nodeId)) || MessageUnique.getPeerByMsgId(nodeId); + if (!nodeMsg) { + this.core.context.logger.logError('转发消息失败,未找到消息', nodeId); + continue; + } + nodeMsgIds.push(nodeMsg.MsgId); + } else { + // 自定义的消息 + try { + const OB11Data = normalize(messageNode.data.content); + // 筛选node消息 + const subNodeMessages = OB11Data.filter(isNode); + if (subNodeMessages.length !== 0) { + if (subNodeMessages.length !== OB11Data.length) { + this.core.context.logger.logError('子消息中包含非node消息 跳过不合法部分'); + continue; + } + const nodeMsg = await this.handleForwardedNodes(selfPeer, subNodeMessages); + if (nodeMsg) { + nodeMsgIds.push(nodeMsg.message!.msgId); + MessageUnique.createUniqueMsgId(selfPeer, nodeMsg.message!.msgId); + } + // 完成子卡片生成跳过后续 + continue; + } + const { sendElements } = await this.obContext.apis.MsgApi + .createSendElements(OB11Data, destPeer); + + // 拆分消息 + + const MixElement = sendElements.filter( + element => + element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK && element.elementType !== ElementType.PTT + ); + const SingleElement = sendElements.filter( + element => + element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK || element.elementType === ElementType.PTT + ).map(e => [e]); + + const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0); + const MsgNodeList: Promise[] = []; + for (const sendElementsSplitElement of AllElement) { + MsgNodeList.push(this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(selfPeer, sendElementsSplitElement, []).catch(() => undefined)); + } + (await Promise.allSettled(MsgNodeList)).map((result) => { + if (result.status === 'fulfilled' && result.value) { + nodeMsgIds.push(result.value.msgId); + MessageUnique.createUniqueMsgId(selfPeer, result.value.msgId); + } + return result; + }); + } catch (e: unknown) { + this.core.context.logger.logDebug('生成转发消息节点失败', (e as Error).stack); + } + } + } + const nodeMsgArray: Array = []; + let srcPeer: Peer | undefined; + let needSendSelf = false; + // 检测是否处于同一个Peer 不在同一个peer则全部消息由自身发送 + for (const msgId of nodeMsgIds) { + const nodeMsgPeer = MessageUnique.getPeerByMsgId(msgId); + if (!nodeMsgPeer) { + this.core.context.logger.logError('转发消息失败,未找到消息', msgId); + continue; + } + const nodeMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(nodeMsgPeer.Peer, [msgId])).msgList[0]; + if (nodeMsg) { + srcPeer = srcPeer ?? { chatType: nodeMsg.chatType, peerUid: nodeMsg.peerUid }; + if (srcPeer.peerUid !== nodeMsg.peerUid) { + needSendSelf = true; + } + nodeMsgArray.push(nodeMsg); + } + } + nodeMsgIds = nodeMsgArray.map(msg => msg.msgId); + let retMsgIds: string[] = []; + if (needSendSelf) { + for (const [, msg] of nodeMsgArray.entries()) { + if (msg.peerUid === this.core.selfInfo.uid) { + retMsgIds.push(msg.msgId); + continue; + } + const ClonedMsg = await this.cloneMsg(msg); + if (ClonedMsg) retMsgIds.push(ClonedMsg.msgId); + } + } else { + retMsgIds = nodeMsgIds; + } + if (retMsgIds.length === 0) throw Error('转发消息失败,生成节点为空'); + try { + this.core.context.logger.logDebug('开发转发', srcPeer, destPeer, retMsgIds); + return { + message: await this.core.apis.MsgApi.multiForwardMsg(srcPeer!, destPeer, retMsgIds), + }; + } catch (e: unknown) { + this.core.context.logger.logError('forward failed', (e as Error)?.stack); + return { + message: null, + }; + } + } + + async cloneMsg (msg: RawMessage): Promise { + const selfPeer = { + chatType: ChatType.KCHATTYPEC2C, + peerUid: this.core.selfInfo.uid, + }; + // msg 为待克隆消息 + const sendElements: SendMessageElement[] = []; + + for (const element of msg.elements) { + sendElements.push(element as SendMessageElement); + } + + if (sendElements.length === 0) { + this.core.context.logger.logDebug('需要clone的消息无法解析,将会忽略掉', msg); + } + try { + return await this.core.apis.MsgApi.sendMsg(selfPeer, sendElements); + } catch (e: unknown) { + this.core.context.logger.logError((e as Error)?.stack, '克隆转发消息失败,将忽略本条消息', msg); + return undefined; + } + } +} +export default class SendMsg extends SendMsgBase { + override actionName = ActionName.SendMsg; + override actionSummary = '发送消息'; + override actionDescription = '发送私聊或群聊消息'; + override actionTags = ['消息接口']; + override payloadExample = MsgActionsExamples.SendMsg.payload; + override returnExample = MsgActionsExamples.SendMsg.response; +} diff --git a/packages/snowcat-onebot/action/msg/SendPrivateMsg.ts b/packages/snowcat-onebot/action/msg/SendPrivateMsg.ts new file mode 100644 index 0000000..f7c6d3d --- /dev/null +++ b/packages/snowcat-onebot/action/msg/SendPrivateMsg.ts @@ -0,0 +1,32 @@ +import { ContextMode, ReturnDataType, SendMsgBase, SendMsgPayload } from './SendMsg'; +import { ActionName, BaseCheckResult } from '@/snowcat-onebot/action/router'; +import { snowCatStable } from '@/snowcat-onebot/stable-line'; + +// 未检测参数 +class SendPrivateMsg extends SendMsgBase { + override actionName = ActionName.SendPrivateMsg; + override actionSummary = '发送私聊消息'; + override actionDescription = '发送私聊消息'; + override actionTags = ['消息接口']; + override payloadExample = { + user_id: '123456789', + message: 'hello', + }; + + override returnExample = { + message_id: 123456, + }; + + protected override async check (payload: SendMsgPayload): Promise { + payload.message_type = 'private'; + return super.check(payload); + } + + override async _handle (payload: SendMsgPayload): Promise { + // SnowCat 稳定线:真人抖动+账号预算+熔断 + await snowCatStable.stableDelay(); + return this.base_handle(payload, ContextMode.Private); + } +} + +export default SendPrivateMsg; diff --git a/packages/snowcat-onebot/action/msg/SetMsgEmojiLike.ts b/packages/snowcat-onebot/action/msg/SetMsgEmojiLike.ts new file mode 100644 index 0000000..8f5b469 --- /dev/null +++ b/packages/snowcat-onebot/action/msg/SetMsgEmojiLike.ts @@ -0,0 +1,57 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { Static, Type } from '@sinclair/typebox'; + +const PayloadSchema = Type.Object({ + message_id: Type.Union([Type.Number(), Type.String()], { description: '消息ID' }), + emoji_id: Type.Union([Type.Number(), Type.String()], { description: '表情ID' }), + set: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否设置' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Any({ description: '操作结果' }); + +type ReturnType = Static; + +export class SetMsgEmojiLike extends OneBotAction { + override actionName = ActionName.SetMsgEmojiLike; + override actionSummary = '设置消息表情点赞'; + override actionTags = ['消息扩展']; + override payloadExample = { + message_id: 12345, + emoji_id: '123', + set: true, + }; + + override returnExample = { + result: true, + }; + + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + + async _handle (payload: PayloadType) { + const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); + if (!msg) { + throw new Error('msg not found'); + } + if (!payload.emoji_id) { + throw new Error('emojiId not found'); + } + payload.set = payload.set ?? true; + + const msgData = (await this.core.apis.MsgApi.getMsgsByMsgId(msg.Peer, [msg.MsgId])).msgList; + if (!msgData || msgData.length === 0 || !msgData[0]?.msgSeq) { + throw new Error('find msg by msgid error'); + } + + return await this.core.apis.MsgApi.setEmojiLike( + msg.Peer, + msgData[0].msgSeq, + payload.emoji_id.toString(), + typeof payload.set === 'string' ? payload.set === 'true' : !!payload.set + ); + } +} diff --git a/packages/snowcat-onebot/action/msg/examples.ts b/packages/snowcat-onebot/action/msg/examples.ts new file mode 100644 index 0000000..212d22b --- /dev/null +++ b/packages/snowcat-onebot/action/msg/examples.ts @@ -0,0 +1,39 @@ +export const MsgActionsExamples = { + DeleteMsg: { + payload: { message_id: 123456 }, + response: {}, + }, + GetMsg: { + payload: { message_id: 123456 }, + response: { + time: 1710000000, + message_type: 'group', + message_id: 123456, + real_id: 123456, + sender: { user_id: 123456789, nickname: '昵称' }, + message: 'hello', + }, + }, + FetchPttText: { + payload: { message_id: 123456 }, + response: { + text: 'hello', + }, + }, + MarkMsgAsRead: { + payload: { group_id: '123456' }, + response: {}, + }, + SendMsg: { + payload: { message_type: 'group', group_id: '123456', message: 'hello' }, + response: { message_id: 123456 }, + }, + SendPrivateMsg: { + payload: { user_id: '123456789', message: 'hello' }, + response: { message_id: 123456 }, + }, + SetMsgEmojiLike: { + payload: { message_id: 123456, emoji_id: '12345' }, + response: {}, + }, +}; diff --git a/packages/snowcat-onebot/action/new/GetDoubtFriendsAddRequest.ts b/packages/snowcat-onebot/action/new/GetDoubtFriendsAddRequest.ts new file mode 100644 index 0000000..15dd81f --- /dev/null +++ b/packages/snowcat-onebot/action/new/GetDoubtFriendsAddRequest.ts @@ -0,0 +1,25 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { NewActionsExamples } from '../example/NewActionsExamples'; + +export const GetDoubtFriendsAddRequestPayloadSchema = Type.Object({ + count: Type.Number({ default: 50, description: '获取数量' }), +}); + +export type GetDoubtFriendsAddRequestPayload = Static; + +export class GetDoubtFriendsAddRequest extends OneBotAction { + override actionName = ActionName.GetDoubtFriendsAddRequest; + override payloadSchema = GetDoubtFriendsAddRequestPayloadSchema; + override returnSchema = Type.Any({ description: '可疑好友申请列表' }); + override actionSummary = '获取可疑好友申请'; + override actionDescription = '获取系统的可疑好友申请列表'; + override actionTags = ['系统接口']; + override payloadExample = NewActionsExamples.GetDoubtFriendsAddRequest.payload; + override returnExample = NewActionsExamples.GetDoubtFriendsAddRequest.response; + + async _handle (payload: GetDoubtFriendsAddRequestPayload) { + return await this.core.apis.FriendApi.getDoubtFriendRequest(payload.count); + } +} diff --git a/packages/snowcat-onebot/action/new/SetDoubtFriendsAddRequest.ts b/packages/snowcat-onebot/action/new/SetDoubtFriendsAddRequest.ts new file mode 100644 index 0000000..7d29d1e --- /dev/null +++ b/packages/snowcat-onebot/action/new/SetDoubtFriendsAddRequest.ts @@ -0,0 +1,31 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const SetDoubtFriendsAddRequestPayloadSchema = Type.Object({ + flag: Type.String({ description: '请求 flag' }), + // 注意强制String 非isNumeric 不遵守则不符合设计 + approve: Type.Boolean({ default: true, description: '是否同意 (强制为 true)' }), + // 该字段没有语义 仅做保留 强制为True +}); + +export type SetDoubtFriendsAddRequestPayload = Static; + +export class SetDoubtFriendsAddRequest extends OneBotAction { + override actionName = ActionName.SetDoubtFriendsAddRequest; + override payloadSchema = SetDoubtFriendsAddRequestPayloadSchema; + override returnSchema = Type.Any(); + override actionSummary = '处理可疑好友申请'; + override actionDescription = '同意或拒绝系统的可疑好友申请'; + override actionTags = ['系统接口']; + override payloadExample = { + flag: '12345', + approve: true, + }; + + override returnExample = null; + + async _handle (payload: SetDoubtFriendsAddRequestPayload) { + return await this.core.apis.FriendApi.handleDoubtFriendRequest(payload.flag); + } +} diff --git a/packages/snowcat-onebot/action/packet/BaseGroupTodoAction.ts b/packages/snowcat-onebot/action/packet/BaseGroupTodoAction.ts new file mode 100644 index 0000000..a0ef83b --- /dev/null +++ b/packages/snowcat-onebot/action/packet/BaseGroupTodoAction.ts @@ -0,0 +1,40 @@ +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { ChatType, Peer } from 'snowcat-core'; +import { Static, Type } from '@sinclair/typebox'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; + +export const GroupTodoPayloadSchema = Type.Object({ + group_id: Type.Union([Type.String(), Type.Number()], { description: '群号' }), + message_id: Type.Optional(Type.String({ description: '消息ID' })), + message_seq: Type.Optional(Type.String({ description: '消息Seq (可选)' })), +}); + +export type GroupTodoPayload = Static; + +export abstract class BaseGroupTodoAction extends GetPacketStatusDepends { + override payloadSchema = GroupTodoPayloadSchema; + override returnSchema = Type.Null(); + override actionTags = ['核心接口']; + + protected abstract handleGroupTodo (groupId: number, msgSeq: string): Promise; + + async _handle (payload: GroupTodoPayload) { + const groupId = +payload.group_id; + if (payload.message_seq) { + return await this.handleGroupTodo(groupId, payload.message_seq.toString()); + } + if (!payload.message_id) { + throw new Error('缺少参数 message_id 或 message_seq'); + } + const peer: Peer = { + chatType: ChatType.KCHATTYPEGROUP, + peerUid: payload.group_id.toString(), + }; + const { MsgId, Peer } = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id) ?? { Peer: peer, MsgId: payload.message_id.toString() }; + const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(Peer, [MsgId])).msgList[0]; + if (!msg) { + throw new Error('消息不存在'); + } + await this.handleGroupTodo(groupId, msg.msgSeq); + } +} diff --git a/packages/snowcat-onebot/action/packet/CancelGroupTodo.ts b/packages/snowcat-onebot/action/packet/CancelGroupTodo.ts new file mode 100644 index 0000000..56134be --- /dev/null +++ b/packages/snowcat-onebot/action/packet/CancelGroupTodo.ts @@ -0,0 +1,15 @@ +import { ActionName } from '../router'; +import { PacketActionsExamples } from '../example/PacketActionsExamples'; +import { BaseGroupTodoAction } from './BaseGroupTodoAction'; + +export class CancelGroupTodo extends BaseGroupTodoAction { + override actionName = ActionName.CancelGroupTodo; + override actionSummary = '取消群待办'; + override actionDescription = '将指定消息对应的群待办取消'; + override payloadExample = PacketActionsExamples.CancelGroupTodo.payload; + override returnExample = PacketActionsExamples.CancelGroupTodo.response; + + protected override async handleGroupTodo (groupId: number, msgSeq: string) { + await this.core.apis.PacketApi.pkt.operation.CancelGroupTodo(groupId, msgSeq); + } +} diff --git a/packages/snowcat-onebot/action/packet/CompleteGroupTodo.ts b/packages/snowcat-onebot/action/packet/CompleteGroupTodo.ts new file mode 100644 index 0000000..42503a9 --- /dev/null +++ b/packages/snowcat-onebot/action/packet/CompleteGroupTodo.ts @@ -0,0 +1,15 @@ +import { ActionName } from '../router'; +import { PacketActionsExamples } from '../example/PacketActionsExamples'; +import { BaseGroupTodoAction } from './BaseGroupTodoAction'; + +export class CompleteGroupTodo extends BaseGroupTodoAction { + override actionName = ActionName.CompleteGroupTodo; + override actionSummary = '完成群待办'; + override actionDescription = '将指定消息对应的群待办标记为已完成'; + override payloadExample = PacketActionsExamples.CompleteGroupTodo.payload; + override returnExample = PacketActionsExamples.CompleteGroupTodo.response; + + protected override async handleGroupTodo (groupId: number, msgSeq: string) { + await this.core.apis.PacketApi.pkt.operation.CompleteGroupTodo(groupId, msgSeq); + } +} diff --git a/packages/snowcat-onebot/action/packet/GetPacketStatus.ts b/packages/snowcat-onebot/action/packet/GetPacketStatus.ts new file mode 100644 index 0000000..76cc873 --- /dev/null +++ b/packages/snowcat-onebot/action/packet/GetPacketStatus.ts @@ -0,0 +1,48 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName, BaseCheckResult } from '@/snowcat-onebot/action/router'; +import { Type } from '@sinclair/typebox'; + +export abstract class GetPacketStatusDepends extends OneBotAction { + protected override async check (payload: PT): Promise { + const schemaResult = await super.check(payload); + if (!schemaResult.valid) return schemaResult; + if (!this.core.apis.PacketApi.packetStatus) { + return { + valid: false, + message: 'packetBackend不可用,请参照文档 https://napneko.github.io/config/advanced 和启动日志检查packetBackend状态或进行配置!' + + '错误堆栈信息:' + this.core.apis.PacketApi.clientLogStack, + }; + } + return { valid: true }; + } +} + +export abstract class PacketSendAvailableDepends extends OneBotAction { + protected override async check (payload: PT): Promise { + const schemaResult = await super.check(payload); + if (!schemaResult.valid) return schemaResult; + if (!this.core.apis.PacketApi.available) { + return { + valid: false, + message: 'packetBackend发包能力不可用,请参照文档 https://napneko.github.io/config/advanced 和启动日志检查packetBackend状态或进行配置!' + + '错误堆栈信息:' + this.core.apis.PacketApi.clientLogStack, + }; + } + return { valid: true }; + } +} + +export class GetPacketStatus extends GetPacketStatusDepends { + override actionName = ActionName.GetPacketStatus; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + override actionSummary = '获取Packet状态'; + override actionDescription = '获取底层Packet服务的运行状态'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = null; + + async _handle () { + + } +} diff --git a/packages/snowcat-onebot/action/packet/GetRkeyEx.ts b/packages/snowcat-onebot/action/packet/GetRkeyEx.ts new file mode 100644 index 0000000..b7bdcd7 --- /dev/null +++ b/packages/snowcat-onebot/action/packet/GetRkeyEx.ts @@ -0,0 +1,41 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Type, Static } from '@sinclair/typebox'; + +export const GetRkeyExReturnSchema = Type.Array(Type.Object({ + type: Type.String({ description: '类型 (private/group)' }), + rkey: Type.String({ description: 'RKey' }), + created_at: Type.Number({ description: '创建时间' }), + ttl: Type.Number({ description: '有效期' }), +}), { description: 'RKey 列表' }); + +export type GetRkeyExReturn = Static; + +export class GetRkeyEx extends GetPacketStatusDepends { + override actionName = ActionName.GetRkeyEx; + override payloadSchema = Type.Object({}); + override returnSchema = GetRkeyExReturnSchema; + override actionSummary = '获取扩展 RKey'; + override actionTags = ['系统扩展']; + override payloadExample = {}; + override returnExample = [ + { + type: 'private', + rkey: 'rkey_123', + created_at: 1734567890, + ttl: 3600, + }, + ]; + + async _handle () { + const rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey(); + return rkeys.map(rkey => { + return { + type: rkey.type === 10 ? 'private' : 'group', + rkey: rkey.rkey, + created_at: Number(rkey.time), + ttl: Number(rkey.ttl), + }; + }); + } +} diff --git a/packages/snowcat-onebot/action/packet/GetRkeyServer.ts b/packages/snowcat-onebot/action/packet/GetRkeyServer.ts new file mode 100644 index 0000000..a70e0b3 --- /dev/null +++ b/packages/snowcat-onebot/action/packet/GetRkeyServer.ts @@ -0,0 +1,56 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { GetPacketStatusDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Type, Static } from '@sinclair/typebox'; + +export const GetRkeyServerReturnSchema = Type.Object({ + private_rkey: Type.Optional(Type.String({ description: '私聊 RKey' })), + group_rkey: Type.Optional(Type.String({ description: '群聊 RKey' })), + expired_time: Type.Optional(Type.Number({ description: '过期时间' })), + name: Type.String({ description: '名称' }), +}); + +export type GetRkeyServerReturn = Static; + +export class GetRkeyServer extends GetPacketStatusDepends { + override actionName = ActionName.GetRkeyServer; + override actionSummary = '获取 RKey 服务器'; + override actionTags = ['系统扩展']; + override payloadExample = {}; + override returnExample = { + private_rkey: '&rkey=123456789', + group_rkey: '&rkey=123456789', + expired_time: 1694560000, + name: 'SnowCat 4', + }; + + override payloadSchema = Type.Object({}); + override returnSchema = GetRkeyServerReturnSchema; + + private rkeyCache: GetRkeyServerReturn | null = null; + + private expiryTime: number | null = null; + + async _handle () { + // 检查缓存是否有效 + if (this.expiryTime && this.expiryTime > Math.floor(Date.now() / 1000) && this.rkeyCache) { + return this.rkeyCache; + } + + // 获取新的 Rkey + const rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey(); + const privateRkeyItem = rkeys.filter(rkey => rkey.type === 10)[0]; + const groupRkeyItem = rkeys.filter(rkey => rkey.type === 20)[0]; + + this.expiryTime = Math.floor(Date.now() / 1000) + Math.min(+groupRkeyItem!.ttl.toString(), +privateRkeyItem!.ttl.toString()); + + // 更新缓存 + this.rkeyCache = { + private_rkey: privateRkeyItem ? privateRkeyItem.rkey : undefined, + group_rkey: groupRkeyItem ? groupRkeyItem.rkey : undefined, + expired_time: this.expiryTime, + name: 'SnowCat 4', + }; + + return this.rkeyCache; + } +} diff --git a/packages/snowcat-onebot/action/packet/SendPoke.ts b/packages/snowcat-onebot/action/packet/SendPoke.ts new file mode 100644 index 0000000..5a9a6f1 --- /dev/null +++ b/packages/snowcat-onebot/action/packet/SendPoke.ts @@ -0,0 +1,45 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { PacketSendAvailableDepends } from '@/snowcat-onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +import { PacketActionsExamples } from '../example/PacketActionsExamples'; + +export const SendPokePayloadSchema = Type.Object({ + group_id: Type.Optional(Type.String({ description: '群号' })), + user_id: Type.String({ description: '用户QQ' }), + target_id: Type.Optional(Type.String({ description: '目标QQ' })), +}); + +export type SendPokePayload = Static; +export class SendPokeBase extends PacketSendAvailableDepends { + override payloadSchema = SendPokePayloadSchema; + override returnSchema = Type.Null(); + override actionSummary = '发送戳一戳'; + override actionDescription = '在群聊或私聊中发送戳一戳动作'; + override actionTags = ['核心接口']; + override payloadExample = PacketActionsExamples.SendPoke.payload; + override returnExample = PacketActionsExamples.SendPoke.response; + + async _handle (payload: SendPokePayload) { + // 这里的 !! 可以传入空字符串 忽略这些数据有利用接口统一接口 + const target_id = payload.target_id?.toString() || payload.user_id?.toString(); + const peer_id = payload.group_id?.toString() || payload.user_id?.toString(); + + const is_group = !!payload.group_id; + if (!target_id || !peer_id) { + throw new Error('请检查参数,缺少 user_id 或 group_id'); + } + + await this.core.apis.PacketApi.pkt.operation.SendPoke(is_group, +peer_id, +target_id); + } +} + +export class SendPoke extends SendPokeBase { + override actionName = ActionName.SendPoke; +} +export class GroupPoke extends SendPokeBase { + override actionName = ActionName.GroupPoke; +} +export class FriendPoke extends SendPokeBase { + override actionName = ActionName.FriendPoke; +} diff --git a/packages/snowcat-onebot/action/packet/SetGroupTodo.ts b/packages/snowcat-onebot/action/packet/SetGroupTodo.ts new file mode 100644 index 0000000..09c5761 --- /dev/null +++ b/packages/snowcat-onebot/action/packet/SetGroupTodo.ts @@ -0,0 +1,14 @@ +import { ActionName } from '../router'; +import { PacketActionsExamples } from '../example/PacketActionsExamples'; +import { BaseGroupTodoAction } from './BaseGroupTodoAction'; +export class SetGroupTodo extends BaseGroupTodoAction { + override actionName = ActionName.SetGroupTodo; + override actionSummary = '设置群待办'; + override actionDescription = '将指定消息设置为群待办'; + override payloadExample = PacketActionsExamples.SetGroupTodo.payload; + override returnExample = PacketActionsExamples.SetGroupTodo.response; + + protected override async handleGroupTodo (groupId: number, msgSeq: string) { + await this.core.apis.PacketApi.pkt.operation.SetGroupTodo(groupId, msgSeq); + } +} diff --git a/packages/snowcat-onebot/action/router.ts b/packages/snowcat-onebot/action/router.ts new file mode 100644 index 0000000..3bfb866 --- /dev/null +++ b/packages/snowcat-onebot/action/router.ts @@ -0,0 +1,261 @@ +export type BaseCheckResult = ValidCheckResult | InvalidCheckResult; + +export interface ValidCheckResult { + valid: true; +} + +export interface InvalidCheckResult { + valid: false; + message: string; +} + +export const ActionName = { + TestAutoRegister01: 'test_auto_register_01', + TestAutoRegister02: 'test_auto_register_02', + // 所有 Normal Stream Api 表示并未流传输 表示与流传输有关 + CleanStreamTempFile: 'clean_stream_temp_file', + + // 所有 Upload/Download Stream Api 应当 _stream 结尾 + TestDownloadStream: 'test_download_stream', + UploadFileStream: 'upload_file_stream', + DownloadFileStream: 'download_file_stream', + DownloadFileRecordStream: 'download_file_record_stream', + DownloadFileImageStream: 'download_file_image_stream', + + DelGroupAlbumMedia: 'del_group_album_media', + SetGroupAlbumMediaLike: 'set_group_album_media_like', + CancelGroupAlbumMediaLike: 'cancel_group_album_media_like', + DoGroupAlbumComment: 'do_group_album_comment', + GetGroupAlbumMediaList: 'get_group_album_media_list', + UploadImageToQunAlbum: 'upload_image_to_qun_album', + GetQunAlbumList: 'get_qun_album_list', + SetGroupTodo: 'set_group_todo', + CompleteGroupTodo: 'complete_group_todo', + CancelGroupTodo: 'cancel_group_todo', + SetGroupKickMembers: 'set_group_kick_members', + SetGroupRobotAddOption: 'set_group_robot_add_option', + SetGroupAddOption: 'set_group_add_option', + SetGroupSearch: 'set_group_search', + // new extends 完全差异OneBot类别 + GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request', + SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request', + // napcat + GetRkeyEx: 'get_rkey', + GetRkeyServer: 'get_rkey_server', + SetGroupRemark: 'set_group_remark', + SetGroupMemberInvitePolicy: 'set_group_member_invite_policy', + SetGroupMemberPermissions: 'set_group_member_permissions', + SetGroupNewMemberHistoryVisibility: 'set_group_new_member_history_visibility', + NapCat_GetPrivateFileUrl: 'get_private_file_url', + ClickInlineKeyboardButton: 'click_inline_keyboard_button', + GetUnidirectionalFriendList: 'get_unidirectional_friend_list', + // onebot 11 + SendPrivateMsg: 'send_private_msg', + SendGroupMsg: 'send_group_msg', + SendMsg: 'send_msg', + DeleteMsg: 'delete_msg', + GetMsg: 'get_msg', + GoCQHTTP_GetForwardMsg: 'get_forward_msg', + SendLike: 'send_like', + SetGroupKick: 'set_group_kick', + SetGroupBan: 'set_group_ban', + // SetGroupAnoymousBan : 'set_group_anonymous_ban', + SetGroupWholeBan: 'set_group_whole_ban', + SetGroupAdmin: 'set_group_admin', + // SetGroupAnoymous : 'set_group_anonymous', + SetGroupCard: 'set_group_card', + SetGroupName: 'set_group_name', + SetGroupLeave: 'set_group_leave', + SetSpecialTitle: 'set_group_special_title', + SetFriendAddRequest: 'set_friend_add_request', + SetFriendRemark: 'set_friend_remark', + SetGroupAddRequest: 'set_group_add_request', + GetLoginInfo: 'get_login_info', + GoCQHTTP_GetStrangerInfo: 'get_stranger_info', + GetFriendList: 'get_friend_list', + GetGroupInfo: 'get_group_info', + GetGroupList: 'get_group_list', + GetGroupMemberInfo: 'get_group_member_info', + GetGroupMemberList: 'get_group_member_list', + GetGroupHonorInfo: 'get_group_honor_info', + GetCookies: 'get_cookies', + GetCSRF: 'get_csrf_token', + GetCredentials: 'get_credentials', + GetRecord: 'get_record', + GetImage: 'get_image', + CanSendImage: 'can_send_image', + CanSendRecord: 'can_send_record', + GetStatus: 'get_status', + GetVersionInfo: 'get_version_info', + Reboot: 'set_restart', + CleanCache: 'clean_cache', + Exit: 'bot_exit', + // go-cqhttp + SetQQProfile: 'set_qq_profile', + // QidianGetAccountInfo : 'qidian_get_account_info', + GoCQHTTP_GetModelShow: '_get_model_show', + GoCQHTTP_SetModelShow: '_set_model_show', + GetOnlineClient: 'get_online_clients', + // GetUnidirectionalFriendList : 'get_unidirectional_friend_list', + GoCQHTTP_DeleteFriend: 'delete_friend', + // DeleteUnidirectionalFriendList : 'delete_unidirectional_friend', + GoCQHTTP_MarkMsgAsRead: 'mark_msg_as_read', + GoCQHTTP_SendGroupForwardMsg: 'send_group_forward_msg', + GoCQHTTP_SendPrivateForwardMsg: 'send_private_forward_msg', + GoCQHTTP_GetGroupMsgHistory: 'get_group_msg_history', + OCRImage: 'ocr_image', + IOCRImage: '.ocr_image', + GetGroupSystemMsg: 'get_group_system_msg', + GoCQHTTP_GetEssenceMsg: 'get_essence_msg_list', + GoCQHTTP_GetGroupAtAllRemain: 'get_group_at_all_remain', + SetGroupPortrait: 'set_group_portrait', + SetEssenceMsg: 'set_essence_msg', + DelEssenceMsg: 'delete_essence_msg', + GoCQHTTP_SendGroupNotice: '_send_group_notice', + GoCQHTTP_GetGroupNotice: '_get_group_notice', + GoCQHTTP_UploadGroupFile: 'upload_group_file', + GOCQHTTP_DeleteGroupFile: 'delete_group_file', + GoCQHTTP_CreateGroupFileFolder: 'create_group_file_folder', + GoCQHTTP_DeleteGroupFileFolder: 'delete_group_folder', + GoCQHTTP_GetGroupFileSystemInfo: 'get_group_file_system_info', + GoCQHTTP_GetGroupRootFiles: 'get_group_root_files', + GoCQHTTP_GetGroupFilesByFolder: 'get_group_files_by_folder', + GOCQHTTP_GetGroupFileUrl: 'get_group_file_url', + GOCQHTTP_UploadPrivateFile: 'upload_private_file', + // GOCQHTTP_ReloadEventFilter : 'reload_event_filter', + GoCQHTTP_DownloadFile: 'download_file', + GoCQHTTP_CheckUrlSafely: 'check_url_safely', + GoCQHTTP_GetWordSlices: '.get_word_slices', + GoCQHTTP_HandleQuickAction: '.handle_quick_operation', + + // 以下为扩展napcat扩展 + Unknown: 'unknown', + SetDiyOnlineStatus: 'set_diy_online_status', + SharePeer: 'ArkSharePeer', // @deprecated + ShareGroupEx: 'ArkShareGroup', // @deprecated + // 标准化接口 + SendGroupArkShare: 'send_group_ark_share', + SendArkShare: 'send_ark_share', + // RebootNormal : 'reboot_normal', //无快速登录重新启动 + GetRobotUinRange: 'get_robot_uin_range', + SetOnlineStatus: 'set_online_status', + GetFriendsWithCategory: 'get_friends_with_category', + SetQQAvatar: 'set_qq_avatar', + GetFile: 'get_file', + ForwardFriendSingleMsg: 'forward_friend_single_msg', + ForwardGroupSingleMsg: 'forward_group_single_msg', + TranslateEnWordToZn: 'translate_en2zh', + FetchPttText: 'fetch_ptt_text', + SetMsgEmojiLike: 'set_msg_emoji_like', + GoCQHTTP_SendForwardMsg: 'send_forward_msg', + MarkPrivateMsgAsRead: 'mark_private_msg_as_read', + MarkGroupMsgAsRead: 'mark_group_msg_as_read', + GetFriendMsgHistory: 'get_friend_msg_history', + CreateCollection: 'create_collection', + GetCollectionList: 'get_collection_list', + SetLongNick: 'set_self_longnick', + GetRecentContact: 'get_recent_contact', + _MarkAllMsgAsRead: '_mark_all_as_read', + GetProfileLike: 'get_profile_like', + FetchCustomFace: 'fetch_custom_face', + FetchCustomFaceDetail: 'fetch_custom_face_detail', + AddCustomFace: 'add_custom_face', + DeleteCustomFace: 'delete_custom_face', + SetCustomFaceDesc: 'set_custom_face_desc', + FetchEmojiLike: 'fetch_emoji_like', + GetEmojiLikes: 'get_emoji_likes', + SetInputStatus: 'set_input_status', + GetGroupInfoEx: 'get_group_info_ex', + GetGroupShareLink: 'get_group_share_link', + GetGroupDetailInfo: 'get_group_detail_info', + GetGroupIgnoreAddRequest: 'get_group_ignore_add_request', + DelGroupNotice: '_del_group_notice', + FriendPoke: 'friend_poke', + GroupPoke: 'group_poke', + GetPacketStatus: 'nc_get_packet_status', + GetUserStatus: 'nc_get_user_status', + GetRkey: 'nc_get_rkey', + GetGroupShutList: 'get_group_shut_list', + + MoveGroupFile: 'move_group_file', + TransGroupFile: 'trans_group_file', + RenameGroupFile: 'rename_group_file', + + GetGuildList: 'get_guild_list', + GetGuildProfile: 'get_guild_service_profile', + + GetGroupIgnoredNotifies: 'get_group_ignored_notifies', + + SetGroupSign: 'set_group_sign', + SendGroupSign: 'send_group_sign', + SendPacket: 'send_packet', + GetMiniAppArk: 'get_mini_app_ark', + // UploadForwardMsg : "upload_forward_msg", + GetAiRecord: 'get_ai_record', + GetAiCharacters: 'get_ai_characters', + SendGroupAiRecord: 'send_group_ai_record', + + GetClientkey: 'get_clientkey', + + SendPoke: 'send_poke', + + // Flash (闪传) 扩展 + CreateFlashTask: 'create_flash_task', + SendFlashMsg: 'send_flash_msg', // 因为不可能手动构造element,所以不走sendMsg + GetShareLink: 'get_share_link', + DownloadFileset: 'download_fileset', + GetFilesetInfo: 'get_fileset_info', + GetFlashFileList: 'get_flash_file_list', + GetFlashFileUrl: 'get_flash_file_url', + GetFilesetId: 'get_fileset_id', + + // Online File (在线文件) 扩展 + SendOnlineFile: 'send_online_file', + SendOnlineFolder: 'send_online_folder', + GetOnlineFileMessages: 'get_online_file_msg', + ReceiveOnlineFile: 'receive_online_file', + RefuseOnlineFile: 'refuse_online_file', + CancelOnlineFile: 'cancel_online_file', + + GetGroupSignedList: 'get_group_signed_list', + + // QQ 空间扩展 + SendQzoneMsg: 'send_qzone_msg', + DeleteQzoneMsg: 'delete_qzone_msg', + + // SnowCat · qzone Web 线扩展(SnowLuma 移植) + GetQzoneMsgList: 'get_qzone_msg_list', + GetQzoneMsgDetail: 'get_qzone_msg_detail', + SetQzoneLike: 'set_qzone_like', + CommentQzoneMsg: 'comment_qzone', + ReplyQzoneComment: 'reply_qzone_comment', + GetQzoneProfile: 'get_qzone_profile', + SetQzoneProfile: 'set_qzone_profile', + SetQzoneBlack: 'set_qzone_ban', + SetQzoneMsgRight: 'set_qzone_msg_right', + SendQzoneMsgFull: 'send_qzone_msg_full', + GetQzoneFeeds: 'get_qzone_feeds', + + // SnowCat · qzone 公共空间(空友)扩展(SnowLuma 移植) + GetQzonePublicCapabilities: 'get_qzone_public_capabilities', + GetQzonePublicFeeds: 'get_qzone_public_feeds', + GetQzonePublicFeedDetail: 'get_qzone_public_feed_detail', + GetQzonePublicComments: 'get_qzone_public_comments', + GetQzonePublicReplies: 'get_qzone_public_replies', + SetQzonePublicUpvote: 'set_qzone_public_upvote', + SetQzonePublicLike: 'set_qzone_public_like', + SetQzonePublicCommentLike: 'set_qzone_public_comment_like', + CommentQzonePublicFeed: 'comment_qzone_public_feed', + ReplyQzonePublicFeed: 'reply_qzone_public_feed', + DeleteQzonePublicComment: 'delete_qzone_public_comment', + DeleteQzonePublicReply: 'delete_qzone_public_reply', + SetQzonePublicFollow: 'set_qzone_public_follow', + ForwardQzonePublicFeed: 'forward_qzone_public_feed', + + // SnowCat · 第三批(SnowLuma 移植):群相册补/群管补/自定义表情/杂项 + GetGroupAlbumList: 'get_group_album_list', + GetGroupAlbumMediaListOnly: 'get_group_album_media_list_web', + GetMsgEmojiLikes: 'get_msg_emoji_likes', + FetchSysFaces: 'fetch_sys_faces', + GetFriendDress: 'get_friend_dress', +} as const; diff --git a/packages/snowcat-onebot/action/schemas.ts b/packages/snowcat-onebot/action/schemas.ts new file mode 100644 index 0000000..24f91f6 --- /dev/null +++ b/packages/snowcat-onebot/action/schemas.ts @@ -0,0 +1,98 @@ +import { Type } from '@sinclair/typebox'; + +export const OB11UserSchema = Type.Object({ + birthday_year: Type.Optional(Type.Number({ description: '出生年份' })), + birthday_month: Type.Optional(Type.Number({ description: '出生月份' })), + birthday_day: Type.Optional(Type.Number({ description: '出生日期' })), + phone_num: Type.Optional(Type.String({ description: '手机号' })), + email: Type.Optional(Type.String({ description: '邮箱' })), + category_id: Type.Optional(Type.Number({ description: '分组ID' })), + user_id: Type.Number({ description: 'QQ号' }), + nickname: Type.String({ description: '昵称' }), + remark: Type.Optional(Type.String({ description: '备注' })), + sex: Type.Optional(Type.String({ description: '性别' })), + level: Type.Optional(Type.Number({ description: '等级' })), + age: Type.Optional(Type.Number({ description: '年龄' })), + qid: Type.Optional(Type.String({ description: 'QID' })), + login_days: Type.Optional(Type.Number({ description: '登录天数' })), + categoryName: Type.Optional(Type.String({ description: '分组名称' })), + categoryId: Type.Optional(Type.Number({ description: '分组ID' })), +}, { $id: 'OB11User', description: 'OneBot 11 用户信息' }); + +export const OB11GroupSchema = Type.Object({ + group_all_shut: Type.Number({ description: '是否全员禁言' }), + group_remark: Type.String({ description: '群备注' }), + group_id: Type.Number({ description: '群号' }), + group_name: Type.String({ description: '群名称' }), + member_count: Type.Optional(Type.Number({ description: '成员人数' })), + max_member_count: Type.Optional(Type.Number({ description: '最大成员人数' })), +}, { $id: 'OB11Group', description: 'OneBot 11 群信息' }); + +export const OB11GroupMemberSchema = Type.Object({ + group_id: Type.Number({ description: '群号' }), + user_id: Type.Number({ description: 'QQ号' }), + nickname: Type.String({ description: '昵称' }), + card: Type.Optional(Type.String({ description: '名片' })), + sex: Type.Optional(Type.String({ description: '性别' })), + age: Type.Optional(Type.Number({ description: '年龄' })), + join_time: Type.Optional(Type.Number({ description: '入群时间戳' })), + last_sent_time: Type.Optional(Type.Number({ description: '最后发言时间戳' })), + level: Type.Optional(Type.String({ description: '等级' })), + qq_level: Type.Optional(Type.Number({ description: 'QQ等级' })), + role: Type.Optional(Type.String({ description: '角色 (owner/admin/member)' })), + title: Type.Optional(Type.String({ description: '头衔' })), + area: Type.Optional(Type.String({ description: '地区' })), + unfriendly: Type.Optional(Type.Boolean({ description: '是否不良记录' })), + title_expire_time: Type.Optional(Type.Number({ description: '头衔过期时间' })), + card_changeable: Type.Optional(Type.Boolean({ description: '是否允许修改名片' })), + shut_up_timestamp: Type.Optional(Type.Number({ description: '禁言截止时间戳' })), + is_robot: Type.Optional(Type.Boolean({ description: '是否为机器人' })), + qage: Type.Optional(Type.Number({ description: 'Q龄' })), +}, { $id: 'OB11GroupMember', description: 'OneBot 11 群成员信息' }); + +export const OB11NotifySchema = Type.Object({ + request_id: Type.Number({ description: '请求ID' }), + invitor_uin: Type.Number({ description: '邀请者QQ' }), + invitor_nick: Type.String({ description: '邀请者昵称' }), + group_id: Type.Number({ description: '群号' }), + group_name: Type.String({ description: '群名称' }), + message: Type.String({ description: '附言' }), + checked: Type.Boolean({ description: '是否已处理' }), + actor: Type.Number({ description: '操作者QQ' }), + requester_nick: Type.String({ description: '申请者昵称' }), +}, { $id: 'OB11Notify', description: 'OneBot 11 通知信息' }); + +export const lastestMessageSchema = Type.Object({ + self_id: Type.Number({ description: '发送者QQ号' }), + user_id: Type.Number({ description: '接收者QQ号' }), + time: Type.Number({ description: '时间戳' }), + real_seq: Type.String({ description: '消息序号' }), + message_type: Type.String({ description: '消息类型' }), + sender: Type.Object({ + user_id: Type.Number({ description: '用户QQ号' }), + nickname: Type.String({ description: '用户昵称' }), + card: Type.Optional(Type.String({ description: '用户名片' })), + role: Type.Optional(Type.String({ description: '用户角色' })), + }), + raw_message: Type.String({ description: '原始消息' }), + font: Type.Number({ description: '字体大小' }), + sub_type: Type.String({ description: '子类型' }), + message: Type.Unknown({ description: '消息内容' }), + message_format: Type.String({ description: '消息格式' }), + post_type: Type.String({ description: '发布类型' }), + group_id: Type.Number({ description: '群号' }), + group_name: Type.String({ description: '群名称' }), +}, { $id: 'OB11LatestMessage', description: '最后一条消息' }); + +export const OB11MessageSchema = Type.Intersect([ + Type.Omit(lastestMessageSchema, []), + Type.Object({ + message_id: Type.Number({ description: '消息ID' }), + message_seq: Type.Number({ description: '消息序列号' }), + emoji_likes_list: Type.Array(Type.Object({ + emoji_id: Type.String({ description: '表情符号ID' }), + emoji_type: Type.String({ description: '表情符号类型' }), + likes_cnt: Type.String({ description: '点赞数' }), + })), + }, { description: 'OneBot 11 消息信息' }), +], { $id: 'OB11ActionMessage', description: 'OneBot 11 消息信息' }); diff --git a/packages/snowcat-onebot/action/stream/BaseDownloadStream.ts b/packages/snowcat-onebot/action/stream/BaseDownloadStream.ts new file mode 100644 index 0000000..501a7a5 --- /dev/null +++ b/packages/snowcat-onebot/action/stream/BaseDownloadStream.ts @@ -0,0 +1,102 @@ +import { OneBotAction, OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { calculateTimeout } from '@/snowcat-onebot/config/config'; + +export interface ResolvedFileInfo { + downloadPath: string; + fileName: string; + fileSize: number; +} + +export interface DownloadResult { + // 文件信息 + file_name?: string; + file_size?: number; + chunk_size?: number; + + // 分片数据 + index?: number; + data?: string; + size?: number; + progress?: number; + base64_size?: number; + + // 完成信息 + total_chunks?: number; + total_bytes?: number; + message?: string; + data_type?: 'file_info' | 'file_chunk' | 'file_complete'; + + // 可选扩展字段 + width?: number; + height?: number; + out_format?: string; +} + +export abstract class BaseDownloadStream extends OneBotAction> { + protected async resolveDownload (file?: string): Promise { + const target = file || ''; + let downloadPath = ''; + let fileName = ''; + let fileSize = 0; + + const contextMsgFile = FileNapCatOneBotUUID.decode(target); + if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) { + const { peer, msgId, elementId } = contextMsgFile; + downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', ''); + const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList + .find(msg => msg.msgId === msgId); + const mixElement = rawMessage?.elements.find(e => e.elementId === elementId); + const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement; + if (!mixElementInner) throw new Error('element not found'); + fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0'); + fileName = mixElementInner.fileName ?? ''; + return { downloadPath, fileName, fileSize }; + } + + const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(target); + if (contextModelIdFile && contextModelIdFile.modelId) { + const { peer, modelId } = contextModelIdFile; + downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, ''); + return { downloadPath, fileName, fileSize }; + } + + const searchResult = (await this.core.apis.FileApi.searchForFile([target])); + if (searchResult) { + const fileSizeNum = parseInt(searchResult.fileSize); + const timeoutConfig = this.obContext.configLoader.configData.timeout; + const estimatedTime = calculateTimeout(timeoutConfig, fileSizeNum, timeoutConfig.downloadSpeedKBps); + downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, fileSizeNum, estimatedTime); + fileSize = fileSizeNum; + fileName = searchResult.fileName; + return { downloadPath, fileName, fileSize }; + } + + throw new Error('file not found'); + } + + protected async streamFileChunks (req: OneBotRequestToolkit, streamPath: string, chunkSize: number, chunkDataType: string): Promise<{ totalChunks: number; totalBytes: number }> { + const stats = await fs.promises.stat(streamPath); + const totalSize = stats.size; + const readStream = fs.createReadStream(streamPath, { highWaterMark: chunkSize }); + let chunkIndex = 0; + let bytesRead = 0; + for await (const chunk of readStream) { + const base64Chunk = (chunk as Buffer).toString('base64'); + bytesRead += (chunk as Buffer).length; + await req.send({ + type: StreamStatus.Stream, + data_type: chunkDataType, + index: chunkIndex, + data: base64Chunk, + size: (chunk as Buffer).length, + progress: Math.round((bytesRead / totalSize) * 100), + base64_size: base64Chunk.length, + } as unknown as StreamPacket); + chunkIndex++; + } + return { totalChunks: chunkIndex, totalBytes: bytesRead }; + } +} diff --git a/packages/snowcat-onebot/action/stream/CleanStreamTempFile.ts b/packages/snowcat-onebot/action/stream/CleanStreamTempFile.ts new file mode 100644 index 0000000..ea067bd --- /dev/null +++ b/packages/snowcat-onebot/action/stream/CleanStreamTempFile.ts @@ -0,0 +1,42 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { join } from 'node:path'; +import { readdir, unlink } from 'node:fs/promises'; +import { Type } from '@sinclair/typebox'; + +export class CleanStreamTempFile extends OneBotAction { + override actionName = ActionName.CleanStreamTempFile; + override actionSummary = '清理流式传输临时文件'; + override actionTags = ['流式传输扩展']; + override payloadExample = {}; + override returnExample = { + message: 'success', + }; + + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + + async _handle (_payload: void): Promise { + try { + // 获取临时文件夹路径 + const tempPath = this.core.SnowCatTempPath; + + // 读取文件夹中的所有文件 + const files = await readdir(tempPath); + + // 删除每个文件 + const deletePromises = files.map(async (file) => { + const filePath = join(tempPath, file); + try { + await unlink(filePath); + this.core.context.logger.log(`已删除文件: ${filePath}`); + } catch (err: unknown) { + this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`); + } + }); + await Promise.all(deletePromises); + } catch (err: unknown) { + this.core.context.logger.log(`清理流临时文件失败: ${(err as Error).message}`); + } + } +} diff --git a/packages/snowcat-onebot/action/stream/DownloadFileImageStream.ts b/packages/snowcat-onebot/action/stream/DownloadFileImageStream.ts new file mode 100644 index 0000000..f6d3757 --- /dev/null +++ b/packages/snowcat-onebot/action/stream/DownloadFileImageStream.ts @@ -0,0 +1,70 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { imageSizeFallBack } from 'snowcat-image-size'; +import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream'; + +export const DownloadFileImageStreamPayloadSchema = Type.Object({ + file: Type.Optional(Type.String({ description: '文件路径或 URL' })), + file_id: Type.Optional(Type.String({ description: '文件 ID' })), + chunk_size: Type.Optional(Type.Number({ default: 64 * 1024, description: '分块大小 (字节)' })), // 默认64KB分块 +}); + +export type DownloadFileImageStreamPayload = Static; + +export class DownloadFileImageStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileImageStream; + override actionSummary = '下载图片文件流'; + override actionTags = ['流式传输扩展']; + override payloadExample = { + file: 'image_file_id', + }; + + override returnExample = { + file: 'temp_image_path', + }; + + override payloadSchema = DownloadFileImageStreamPayloadSchema; + override returnSchema = Type.Any({ description: '下载结果 (流式)' }); + override useStream = true; + + async _handle (payload: DownloadFileImageStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + try { + payload.file ||= payload.file_id || ''; + const chunkSize = payload.chunk_size || 64 * 1024; + + const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file); + + const stats = await fs.promises.stat(downloadPath); + const totalSize = fileSize || stats.size; + const { width, height } = await imageSizeFallBack(downloadPath); + + // 发送文件信息(与 DownloadFileStream 对齐,但包含宽高) + await req.send({ + type: StreamStatus.Stream, + data_type: 'file_info', + file_name: fileName, + file_size: totalSize, + chunk_size: chunkSize, + width, + height, + }); + + const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk'); + + // 返回完成状态(与 DownloadFileStream 对齐) + return { + type: StreamStatus.Response, + data_type: 'file_complete', + total_chunks: totalChunks, + total_bytes: totalBytes, + message: 'Download completed', + }; + } catch (error) { + throw new Error(`Download failed: ${(error as Error).message}`); + } + } +} diff --git a/packages/snowcat-onebot/action/stream/DownloadFileRecordStream.ts b/packages/snowcat-onebot/action/stream/DownloadFileRecordStream.ts new file mode 100644 index 0000000..23c67bc --- /dev/null +++ b/packages/snowcat-onebot/action/stream/DownloadFileRecordStream.ts @@ -0,0 +1,89 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; +import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream'; + +const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac']; + +export const DownloadFileRecordStreamPayloadSchema = Type.Object({ + file: Type.Optional(Type.String({ description: '文件路径或 URL' })), + file_id: Type.Optional(Type.String({ description: '文件 ID' })), + chunk_size: Type.Optional(Type.Number({ default: 64 * 1024, description: '分块大小 (字节)' })), // 默认64KB分块 + out_format: Type.Optional(Type.String({ description: '输出格式' })), +}); + +export type DownloadFileRecordStreamPayload = Static; + +export class DownloadFileRecordStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileRecordStream; + override actionSummary = '下载语音文件流'; + override actionTags = ['流式传输扩展']; + override payloadExample = { + file: 'record_file_id', + }; + + override returnExample = { + file: 'temp_record_path', + }; + + override payloadSchema = DownloadFileRecordStreamPayloadSchema; + override returnSchema = Type.Any({ description: '下载结果 (流式)' }); + override useStream = true; + + async _handle (payload: DownloadFileRecordStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + try { + payload.file ||= payload.file_id || ''; + const chunkSize = payload.chunk_size || 64 * 1024; + + const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file); + + // 处理输出格式转换 + let streamPath = downloadPath; + if (payload.out_format && typeof payload.out_format === 'string') { + if (!out_format.includes(payload.out_format)) { + throw new Error('转换失败 out_format 字段可能格式不正确'); + } + + const outputFile = `${downloadPath}.${payload.out_format}`; + + try { + // 如果已存在目标文件则跳过转换 + await fs.promises.access(outputFile); + streamPath = outputFile; + } catch { + // 尝试解码 amr 到 out format直接 ffmpeg 转换 + await FFmpegService.convertAudioFmt(downloadPath, outputFile, payload.out_format); + streamPath = outputFile; + } + } + + const stats = await fs.promises.stat(streamPath); + const totalSize = fileSize || stats.size; + + await req.send({ + type: StreamStatus.Stream, + data_type: 'file_info', + file_name: fileName, + file_size: totalSize, + chunk_size: chunkSize, + out_format: payload.out_format, + }); + + const { totalChunks, totalBytes } = await this.streamFileChunks(req, streamPath, chunkSize, 'file_chunk'); + + return { + type: StreamStatus.Response, + data_type: 'file_complete', + total_chunks: totalChunks, + total_bytes: totalBytes, + message: 'Download completed', + }; + } catch (error) { + throw new Error(`Download failed: ${(error as Error).message}`); + } + } +} diff --git a/packages/snowcat-onebot/action/stream/DownloadFileStream.ts b/packages/snowcat-onebot/action/stream/DownloadFileStream.ts new file mode 100644 index 0000000..a97567c --- /dev/null +++ b/packages/snowcat-onebot/action/stream/DownloadFileStream.ts @@ -0,0 +1,67 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream'; +const DownloadFileStreamPayloadSchema = Type.Object({ + file: Type.Optional(Type.String({ description: '文件路径或 URL' })), + file_id: Type.Optional(Type.String({ description: '文件 ID' })), + chunk_size: Type.Optional(Type.Number({ default: 64 * 1024, description: '分块大小 (字节)' })), // 默认64KB分块 +}); + +export type DownloadFileStreamPayload = Static; + +export class DownloadFileStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileStream; + override payloadSchema = DownloadFileStreamPayloadSchema; + override returnSchema = Type.Any({ description: '下载结果 (流式)' }); + override actionSummary = '下载文件流'; + override actionDescription = '以流式方式从网络或本地下载文件'; + override actionTags = ['流式接口']; + override payloadExample = { + file: 'http://example.com/file.png', + }; + + override returnExample = { + type: 'stream', + data_type: 'file_info', + file_name: 'file.png', + file_size: 1024, + }; + + override useStream = true; + + async _handle (payload: DownloadFileStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + try { + payload.file ||= payload.file_id || ''; + const chunkSize = payload.chunk_size || 64 * 1024; + + const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file); + + const stats = await fs.promises.stat(downloadPath); + const totalSize = fileSize || stats.size; + + await req.send({ + type: StreamStatus.Stream, + data_type: 'file_info', + file_name: fileName, + file_size: totalSize, + chunk_size: chunkSize, + }); + + const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk'); + + return { + type: StreamStatus.Response, + data_type: 'file_complete', + total_chunks: totalChunks, + total_bytes: totalBytes, + message: 'Download completed', + }; + } catch (error) { + throw new Error(`Download failed: ${(error as Error).message}`); + } + } +} diff --git a/packages/snowcat-onebot/action/stream/Readme.txt b/packages/snowcat-onebot/action/stream/Readme.txt new file mode 100644 index 0000000..e6c5930 --- /dev/null +++ b/packages/snowcat-onebot/action/stream/Readme.txt @@ -0,0 +1,3 @@ +# Stream-Api + +## 流式接口 \ No newline at end of file diff --git a/packages/snowcat-onebot/action/stream/StreamBasic.ts b/packages/snowcat-onebot/action/stream/StreamBasic.ts new file mode 100644 index 0000000..5010993 --- /dev/null +++ b/packages/snowcat-onebot/action/stream/StreamBasic.ts @@ -0,0 +1,16 @@ +import { OneBotAction, OneBotRequestToolkit } from '../OneBotAction'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +export type StreamPacketBasic = { + type: StreamStatus; + data_type?: string; +}; +export type StreamPacket = T & StreamPacketBasic; +export enum StreamStatus { + Stream = 'stream', // 分片流数据包 + Response = 'response', // 流最终响应 + Reset = 'reset', // 重置流 + Error = 'error', // 流错误 +} +export abstract class BasicStream extends OneBotAction> { + abstract override _handle (_payload: T, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise>; +} diff --git a/packages/snowcat-onebot/action/stream/TestStreamDownload.ts b/packages/snowcat-onebot/action/stream/TestStreamDownload.ts new file mode 100644 index 0000000..6cff4f6 --- /dev/null +++ b/packages/snowcat-onebot/action/stream/TestStreamDownload.ts @@ -0,0 +1,43 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction, OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; + +export const TestDownloadStreamPayloadSchema = Type.Object({ + error: Type.Optional(Type.Boolean({ default: false, description: '是否触发测试错误' })), +}); + +export type TestDownloadStreamPayload = Static; + +export class TestDownloadStream extends OneBotAction> { + override actionName = ActionName.TestDownloadStream; + override actionSummary = '测试下载流'; + override actionTags = ['流式传输扩展']; + override payloadExample = { + url: 'http://example.com/file', + }; + + override returnExample = { + success: true, + }; + + override payloadSchema = TestDownloadStreamPayloadSchema; + override returnSchema = Type.Any({ description: '测试流数据' }); + override useStream = true; + + async _handle (_payload: TestDownloadStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit) { + for (let i = 0; i < 10; i++) { + await req.send({ type: StreamStatus.Stream, data: `Index-> ${i + 1}`, data_type: 'data_chunk' }); + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (_payload.error) { + throw new Error('This is a test error'); + } + return { + type: StreamStatus.Response, + data_type: 'data_complete', + data: 'Stream transmission complete', + }; + } +} diff --git a/packages/snowcat-onebot/action/stream/UploadFileStream.ts b/packages/snowcat-onebot/action/stream/UploadFileStream.ts new file mode 100644 index 0000000..b2c11cf --- /dev/null +++ b/packages/snowcat-onebot/action/stream/UploadFileStream.ts @@ -0,0 +1,363 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { join as joinPath } from 'node:path'; +import { randomUUID, createHash } from 'crypto'; + +import { unlink } from 'node:fs'; + +// 简化配置 +const CONFIG = { + TIMEOUT: 10 * 60 * 1000, // 10分钟超时 + MEMORY_THRESHOLD: 10 * 1024 * 1024, // 10MB,超过使用磁盘 + MEMORY_LIMIT: 100 * 1024 * 1024, // 100MB内存总限制 +} as const; + +export const UploadFileStreamPayloadSchema = Type.Object({ + stream_id: Type.String({ description: '流 ID' }), + chunk_data: Type.Optional(Type.String({ description: '分块数据 (Base64)' })), + chunk_index: Type.Optional(Type.Number({ description: '分块索引' })), + total_chunks: Type.Optional(Type.Number({ description: '总分块数' })), + file_size: Type.Optional(Type.Number({ description: '文件总大小' })), + expected_sha256: Type.Optional(Type.String({ description: '期望的 SHA256' })), + is_complete: Type.Optional(Type.Boolean({ description: '是否完成' })), + filename: Type.Optional(Type.String({ description: '文件名' })), + reset: Type.Optional(Type.Boolean({ description: '是否重置' })), + verify_only: Type.Optional(Type.Boolean({ description: '是否仅验证' })), + file_retention: Type.Number({ default: 5 * 60 * 1000, description: '文件保留时间 (毫秒)' }), // 默认5分钟 回收 不设置或0为不回收 +}); + +export type UploadFileStreamPayload = Static; + +// 简化流状态接口 +interface StreamState { + id: string; + filename: string; + totalChunks: number; + receivedChunks: number; + missingChunks: Set; + + // 可选属性 + fileSize?: number; + expectedSha256?: string; + + // 存储策略 + useMemory: boolean; + memoryChunks?: Map; + tempDir?: string; + finalPath?: string; + fileRetention?: number; + + // 管理 + createdAt: number; + timeoutId: NodeJS.Timeout; +} + +interface StreamResult { + stream_id: string; + status: 'file_created' | 'chunk_received' | 'file_complete'; + received_chunks: number; + total_chunks: number; + file_path?: string; + file_size?: number; + sha256?: string; +} + +export class UploadFileStream extends OneBotAction> { + override actionName = ActionName.UploadFileStream; + override payloadSchema = UploadFileStreamPayloadSchema; + override returnSchema = Type.Any({ description: '上传结果 (流式)' }); + override actionSummary = '上传文件流'; + override actionDescription = '以流式方式上传文件数据到机器人'; + override actionTags = ['流式接口']; + override payloadExample = { + stream_id: 'uuid-1234-5678', + chunk_data: 'SGVsbG8gV29ybGQ=', + chunk_index: 0, + total_chunks: 1, + file_size: 11, + }; + + override returnExample = { + type: 'stream', + stream_id: 'uuid-1234-5678', + status: 'chunk_received', + received_chunks: 1, + total_chunks: 1, + }; + + override useStream = true; + + private static streams = new Map(); + private static memoryUsage = 0; + + async _handle (payload: UploadFileStreamPayload, _adaptername: string, _config: NetworkAdapterConfig): Promise> { + const { stream_id, reset, verify_only } = payload; + + if (reset) { + this.cleanupStream(stream_id); + throw new Error('Stream reset completed'); + } + + if (verify_only) { + const stream = UploadFileStream.streams.get(stream_id); + if (!stream) throw new Error('Stream not found'); + return this.getStreamStatus(stream); + } + + const stream = this.getOrCreateStream(payload); + + if (payload.chunk_data && payload.chunk_index !== undefined) { + return await this.processChunk(stream, payload.chunk_data, payload.chunk_index); + } + + if (payload.is_complete || stream.receivedChunks === stream.totalChunks) { + return await this.completeStream(stream); + } + + return this.getStreamStatus(stream); + } + + private getOrCreateStream (payload: UploadFileStreamPayload): StreamState { + let stream = UploadFileStream.streams.get(payload.stream_id); + + if (!stream) { + if (!payload.total_chunks) { + throw new Error('total_chunks required for new stream'); + } + stream = this.createStream(payload); + } + + return stream; + } + + private createStream (payload: UploadFileStreamPayload): StreamState { + const { stream_id, total_chunks, file_size, filename, expected_sha256 } = payload; + + const useMemory = this.shouldUseMemory(file_size); + if (useMemory && file_size && (UploadFileStream.memoryUsage + file_size) > CONFIG.MEMORY_LIMIT) { + throw new Error('Memory limit exceeded'); + } + + const stream: StreamState = { + id: stream_id, + filename: filename || `upload_${randomUUID()}`, + totalChunks: total_chunks!, + receivedChunks: 0, + missingChunks: new Set(Array.from({ length: total_chunks! }, (_, i) => i)), + fileSize: file_size, + expectedSha256: expected_sha256, + useMemory, + createdAt: Date.now(), + timeoutId: this.setupTimeout(stream_id), + fileRetention: payload.file_retention, + }; + try { + if (useMemory) { + stream.memoryChunks = new Map(); + if (file_size) UploadFileStream.memoryUsage += file_size; + } else { + this.setupDiskStorage(stream); + } + + UploadFileStream.streams.set(stream_id, stream); + return stream; + } catch (error) { + // 如果设置存储失败,清理已创建的资源 + clearTimeout(stream.timeoutId); + if (stream.tempDir && fs.existsSync(stream.tempDir)) { + try { + fs.rmSync(stream.tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + console.error('Failed to cleanup temp dir during creation error:', cleanupError); + } + } + throw error; + } + } + + private shouldUseMemory (fileSize?: number): boolean { + return fileSize !== undefined && fileSize <= CONFIG.MEMORY_THRESHOLD; + } + + private setupDiskStorage (stream: StreamState): void { + const tempDir = joinPath(this.core.SnowCatTempPath, `upload_${stream.id}`); + const finalPath = joinPath(this.core.SnowCatTempPath, stream.filename); + + fs.mkdirSync(tempDir, { recursive: true }); + + stream.tempDir = tempDir; + stream.finalPath = finalPath; + } + + private setupTimeout (streamId: string): NodeJS.Timeout { + return setTimeout(() => { + console.log(`Stream ${streamId} timeout`); + this.cleanupStream(streamId); + }, CONFIG.TIMEOUT); + } + + private async processChunk (stream: StreamState, chunkData: string, chunkIndex: number): Promise> { + // 验证索引 + if (chunkIndex < 0 || chunkIndex >= stream.totalChunks) { + throw new Error(`Invalid chunk index: ${chunkIndex}`); + } + + // 检查重复 + if (!stream.missingChunks.has(chunkIndex)) { + return this.getStreamStatus(stream); + } + + const buffer = Buffer.from(chunkData, 'base64'); + + // 存储分片 + if (stream.useMemory) { + stream.memoryChunks!.set(chunkIndex, buffer); + } else { + const chunkPath = joinPath(stream.tempDir!, `${chunkIndex}.chunk`); + await fs.promises.writeFile(chunkPath, buffer); + } + + // 更新状态 + stream.missingChunks.delete(chunkIndex); + stream.receivedChunks++; + this.refreshTimeout(stream); + + return { + type: StreamStatus.Stream, + stream_id: stream.id, + status: 'chunk_received', + received_chunks: stream.receivedChunks, + total_chunks: stream.totalChunks, + }; + } + + private refreshTimeout (stream: StreamState): void { + clearTimeout(stream.timeoutId); + stream.timeoutId = this.setupTimeout(stream.id); + } + + private getStreamStatus (stream: StreamState): StreamPacket { + return { + type: StreamStatus.Stream, + stream_id: stream.id, + status: 'file_created', + received_chunks: stream.receivedChunks, + total_chunks: stream.totalChunks, + }; + } + + private async completeStream (stream: StreamState): Promise> { + // 合并分片 + const finalBuffer = stream.useMemory + ? await this.mergeMemoryChunks(stream) + : await this.mergeDiskChunks(stream); + + // 验证SHA256 + const sha256 = this.validateSha256(stream, finalBuffer); + + // 保存文件 + const finalPath = stream.finalPath || joinPath(this.core.SnowCatTempPath, stream.filename); + await fs.promises.writeFile(finalPath, finalBuffer); + + // 清理资源但保留文件 + this.cleanupStream(stream.id, false); + if (stream.fileRetention && stream.fileRetention > 0) { + setTimeout(() => { + unlink(finalPath, err => { + if (err) this.core.context.logger.logError(`Failed to delete retained file ${finalPath}:`, err); + }); + }, stream.fileRetention); + } + return { + type: StreamStatus.Response, + stream_id: stream.id, + status: 'file_complete', + received_chunks: stream.receivedChunks, + total_chunks: stream.totalChunks, + file_path: finalPath, + file_size: finalBuffer.length, + sha256, + }; + } + + private async mergeMemoryChunks (stream: StreamState): Promise { + const chunks: Buffer[] = []; + for (let i = 0; i < stream.totalChunks; i++) { + const chunk = stream.memoryChunks!.get(i); + if (!chunk) throw new Error(`Missing memory chunk ${i}`); + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + + private async mergeDiskChunks (stream: StreamState): Promise { + const chunks: Buffer[] = []; + for (let i = 0; i < stream.totalChunks; i++) { + const chunkPath = joinPath(stream.tempDir!, `${i}.chunk`); + if (!fs.existsSync(chunkPath)) throw new Error(`Missing chunk file ${i}`); + chunks.push(await fs.promises.readFile(chunkPath)); + } + return Buffer.concat(chunks); + } + + private validateSha256 (stream: StreamState, buffer: Buffer): string | undefined { + if (!stream.expectedSha256) return undefined; + + const actualSha256 = createHash('sha256').update(buffer).digest('hex'); + if (actualSha256 !== stream.expectedSha256) { + throw new Error(`SHA256 mismatch. Expected: ${stream.expectedSha256}, Got: ${actualSha256}`); + } + return actualSha256; + } + + private cleanupStream (streamId: string, deleteFinalFile = true): void { + const stream = UploadFileStream.streams.get(streamId); + if (!stream) return; + + try { + // 清理超时 + clearTimeout(stream.timeoutId); + + // 清理内存 + if (stream.useMemory) { + if (stream.fileSize) { + UploadFileStream.memoryUsage = Math.max(0, UploadFileStream.memoryUsage - stream.fileSize); + } + stream.memoryChunks?.clear(); + } + + // 清理临时文件夹及其所有内容 + if (stream.tempDir) { + try { + if (fs.existsSync(stream.tempDir)) { + fs.rmSync(stream.tempDir, { recursive: true, force: true }); + console.log(`Cleaned up temp directory: ${stream.tempDir}`); + } + } catch (error) { + console.error(`Failed to cleanup temp directory ${stream.tempDir}:`, error); + } + } + + // 删除最终文件(如果需要) + if (deleteFinalFile && stream.finalPath) { + try { + if (fs.existsSync(stream.finalPath)) { + fs.unlinkSync(stream.finalPath); + console.log(`Deleted final file: ${stream.finalPath}`); + } + } catch (error) { + console.error(`Failed to delete final file ${stream.finalPath}:`, error); + } + } + } catch (error) { + console.error(`Cleanup error for stream ${streamId}:`, error); + } finally { + UploadFileStream.streams.delete(streamId); + console.log(`Stream ${streamId} cleaned up`); + } + } +} diff --git a/packages/snowcat-onebot/action/stream/test_upload_stream.py b/packages/snowcat-onebot/action/stream/test_upload_stream.py new file mode 100644 index 0000000..3aa98d7 --- /dev/null +++ b/packages/snowcat-onebot/action/stream/test_upload_stream.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +NapCat OneBot WebSocket 文件流上传测试脚本 +用于测试 UploadFileStream 接口的一次性分片上传功能 +""" + +import asyncio +import json +import base64 +import hashlib +import os +import uuid +from typing import List, Optional +import websockets +import argparse +from pathlib import Path + +class OneBotUploadTester: + def __init__(self, ws_url: str = "ws://localhost:3001", access_token: Optional[str] = None): + self.ws_url = ws_url + self.access_token = access_token + self.websocket = None + + async def connect(self): + """连接到 OneBot WebSocket""" + headers = {} + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + + print(f"连接到 {self.ws_url}") + self.websocket = await websockets.connect(self.ws_url, additional_headers=headers) + print("WebSocket 连接成功") + + async def disconnect(self): + """断开 WebSocket 连接""" + if self.websocket: + await self.websocket.close() + print("WebSocket 连接已断开") + + def calculate_file_chunks(self, file_path: str, chunk_size: int = 64) -> tuple[List[bytes], str, int]: + """ + 计算文件分片和 SHA256 + + Args: + file_path: 文件路径 + chunk_size: 分片大小(默认64KB) + + Returns: + (chunks, sha256_hash, total_size) + """ + chunks = [] + hasher = hashlib.sha256() + total_size = 0 + + with open(file_path, 'rb') as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + chunks.append(chunk) + hasher.update(chunk) + total_size += len(chunk) + + sha256_hash = hasher.hexdigest() + print(f"文件分析完成:") + print(f" - 文件大小: {total_size} 字节") + print(f" - 分片数量: {len(chunks)}") + print(f" - SHA256: {sha256_hash}") + + return chunks, sha256_hash, total_size + + async def send_action(self, action: str, params: dict, echo: str = None) -> dict: + """发送 OneBot 动作请求""" + if not echo: + echo = str(uuid.uuid4()) + + message = { + "action": action, + "params": params, + "echo": echo + } + + print(f"发送请求: {action}") + await self.websocket.send(json.dumps(message)) + + # 等待响应 + while True: + response = await self.websocket.recv() + data = json.loads(response) + + # 检查是否是我们的响应 + if data.get("echo") == echo: + return data + else: + # 可能是其他消息,继续等待 + print(f"收到其他消息: {data}") + continue + + async def upload_file_stream_batch(self, file_path: str, chunk_size: int = 64 ) -> str: + """ + 一次性批量上传文件流 + + Args: + file_path: 要上传的文件路径 + chunk_size: 分片大小 + + Returns: + 上传完成后的文件路径 + """ + file_path = Path(file_path) + if not file_path.exists(): + raise FileNotFoundError(f"文件不存在: {file_path}") + + # 分析文件 + chunks, sha256_hash, total_size = self.calculate_file_chunks(str(file_path), chunk_size) + stream_id = str(uuid.uuid4()) + + print(f"\n开始上传文件: {file_path.name}") + print(f"流ID: {stream_id}") + + # 一次性发送所有分片 + total_chunks = len(chunks) + + for chunk_index, chunk_data in enumerate(chunks): + # 将分片数据编码为 base64 + chunk_base64 = base64.b64encode(chunk_data).decode('utf-8') + + # 构建参数 + params = { + "stream_id": stream_id, + "chunk_data": chunk_base64, + "chunk_index": chunk_index, + "total_chunks": total_chunks, + "file_size": total_size, + "expected_sha256": sha256_hash, + "filename": file_path.name, + "file_retention": 30 * 1000 + } + + # 发送分片 + response = await self.send_action("upload_file_stream", params) + + if response.get("status") != "ok": + raise Exception(f"上传分片 {chunk_index} 失败: {response}") + + # 解析流响应 + stream_data = response.get("data", {}) + print(f"分片 {chunk_index + 1}/{total_chunks} 上传成功 " + f"(接收: {stream_data.get('received_chunks', 0)}/{stream_data.get('total_chunks', 0)})") + + # 发送完成信号 + print(f"\n所有分片发送完成,请求文件合并...") + complete_params = { + "stream_id": stream_id, + "is_complete": True + } + + response = await self.send_action("upload_file_stream", complete_params) + + if response.get("status") != "ok": + raise Exception(f"文件合并失败: {response}") + + result = response.get("data", {}) + + if result.get("status") == "file_complete": + print(f"✅ 文件上传成功!") + print(f" - 文件路径: {result.get('file_path')}") + print(f" - 文件大小: {result.get('file_size')} 字节") + print(f" - SHA256: {result.get('sha256')}") + return result.get('file_path') + else: + raise Exception(f"文件状态异常: {result}") + + async def test_upload(self, file_path: str, chunk_size: int = 64 ): + """测试文件上传""" + try: + await self.connect() + + # 执行上传 + uploaded_path = await self.upload_file_stream_batch(file_path, chunk_size) + + print(f"\n🎉 测试完成! 上传后的文件路径: {uploaded_path}") + + except Exception as e: + print(f"❌ 测试失败: {e}") + raise + finally: + await self.disconnect() + +def create_test_file(file_path: str, size_mb: float = 1): + """创建测试文件""" + size_bytes = int(size_mb * 1024 * 1024) + + with open(file_path, 'wb') as f: + # 写入一些有意义的测试数据 + test_data = b"NapCat Upload Test Data - " * 100 + written = 0 + while written < size_bytes: + write_size = min(len(test_data), size_bytes - written) + f.write(test_data[:write_size]) + written += write_size + + print(f"创建测试文件: {file_path} ({size_mb}MB)") + +async def main(): + parser = argparse.ArgumentParser(description="NapCat OneBot 文件流上传测试") + parser.add_argument("--url", default="ws://localhost:3001", help="WebSocket URL") + parser.add_argument("--token", help="访问令牌") + parser.add_argument("--file", help="要上传的文件路径") + parser.add_argument("--chunk-size", type=int, default=64*1024, help="分片大小(字节)") + parser.add_argument("--create-test", type=float, help="创建测试文件(MB)") + + args = parser.parse_args() + + # 创建测试文件 + if args.create_test: + test_file = "test_upload_file.bin" + create_test_file(test_file, args.create_test) + if not args.file: + args.file = test_file + + if not args.file: + print("请指定要上传的文件路径,或使用 --create-test 创建测试文件") + return + + # 创建测试器并运行 + tester = OneBotUploadTester(args.url, args.token) + await tester.test_upload(args.file, args.chunk_size) + +if __name__ == "__main__": + # 安装依赖提示 + try: + import websockets + except ImportError: + print("请先安装依赖: pip install websockets") + exit(1) + + asyncio.run(main()) \ No newline at end of file diff --git a/packages/snowcat-onebot/action/system/CanSendImage.ts b/packages/snowcat-onebot/action/system/CanSendImage.ts new file mode 100644 index 0000000..7fd0d55 --- /dev/null +++ b/packages/snowcat-onebot/action/system/CanSendImage.ts @@ -0,0 +1,11 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { CanSend } from './CanSendRecord'; + +export default class CanSendImage extends CanSend { + override actionName = ActionName.CanSendImage; + override actionSummary = '是否可以发送图片'; + override actionDescription = '检查是否可以发送图片'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = { yes: true }; +} diff --git a/packages/snowcat-onebot/action/system/CanSendRecord.ts b/packages/snowcat-onebot/action/system/CanSendRecord.ts new file mode 100644 index 0000000..ff8e52f --- /dev/null +++ b/packages/snowcat-onebot/action/system/CanSendRecord.ts @@ -0,0 +1,30 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +export const CanSendReturnSchema = Type.Object({ + yes: Type.Boolean({ description: '是否可以发送' }), +}); + +export type CanSendReturnType = Static; + +export class CanSend extends OneBotAction { + override payloadSchema = Type.Object({}); + override returnSchema = CanSendReturnSchema; + override actionTags = ['系统接口']; + + async _handle (): Promise { + return { + yes: true, + }; + } +} + +export default class CanSendRecord extends CanSend { + override actionName = ActionName.CanSendRecord; + override actionSummary = '是否可以发送语音'; + override actionDescription = '检查是否可以发送语音'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = { yes: true }; +} diff --git a/packages/snowcat-onebot/action/system/CleanCache.ts b/packages/snowcat-onebot/action/system/CleanCache.ts new file mode 100644 index 0000000..438fe8c --- /dev/null +++ b/packages/snowcat-onebot/action/system/CleanCache.ts @@ -0,0 +1,75 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { unlink, readdir } from 'fs/promises'; +import path, { join } from 'path'; +import { Type } from '@sinclair/typebox'; + +export class CleanCache extends OneBotAction { + override actionName = ActionName.CleanCache; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + override actionSummary = '清理缓存'; + override actionDescription = '清理缓存'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = null; + + async _handle () { + try { + // 获取临时文件夹路径 + const tempPath = this.core.SnowCatTempPath; + + // 读取文件夹中的所有文件 + const files = await readdir(tempPath); + + // 删除每个文件 + const deletePromises = files.map(async (file) => { + const filePath = join(tempPath, file); + try { + await unlink(filePath); + this.core.context.logger.log(`已删除文件: ${filePath}`); + } catch (err: unknown) { + this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`); + } + }); + + // 等待所有删除操作完成 + await Promise.all(deletePromises); + const basic_path = path.join(this.core.dataPath, this.core.selfInfo.uin || '10001', 'nt_qq', 'nt_data'); + + // 需要清理的目录列表 + const dirsToClean = ['Pic', 'Ptt', 'Video', 'File', 'log']; + + // 清理每个指定目录 + for (const dir of dirsToClean) { + const dirPath = path.join(basic_path, dir); + try { + // 检查目录是否存在 + const files = await readdir(dirPath).catch(() => null); + if (files) { + // 删除目录下的所有文件 + const dirDeletePromises = files.map(async (file) => { + const filePath = path.join(dirPath, file); + try { + await unlink(filePath); + this.core.context.logger.log(`已删除文件: ${filePath}`); + } catch (err: unknown) { + this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`); + } + }); + + await Promise.all(dirDeletePromises); + this.core.context.logger.log(`目录清理完成: ${dirPath}`); + } + } catch (err: unknown) { + this.core.context.logger.log(`清理目录 ${dirPath} 失败: ${(err as Error).message}`); + } + } + + this.core.context.logger.log(`临时文件夹清理完成: ${tempPath}`); + } catch (err: unknown) { + this.core.context.logger.log(`清理缓存失败: ${(err as Error).message}`); + throw err; + } + } +} diff --git a/packages/snowcat-onebot/action/system/GetCSRF.ts b/packages/snowcat-onebot/action/system/GetCSRF.ts new file mode 100644 index 0000000..92e98e0 --- /dev/null +++ b/packages/snowcat-onebot/action/system/GetCSRF.ts @@ -0,0 +1,32 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +export const GetCSRFReturnSchema = Type.Object({ + token: Type.Number({ description: 'CSRF Token' }), +}); + +export type GetCSRFReturnType = Static; + +export class GetCSRF extends OneBotAction { + override actionName = ActionName.GetCSRF; + override payloadSchema = Type.Object({}); + override returnSchema = GetCSRFReturnSchema; + override actionSummary = '获取 CSRF Token'; + override actionDescription = '获取 CSRF Token'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = { + token: 123456789, + }; + + async _handle () { + const sKey = await this.core.apis.UserApi.getSKey(); + if (!sKey) { + throw new Error('SKey is undefined'); + } + return { + token: +this.core.apis.WebApi.getBknFromSKey(sKey), + }; + } +} diff --git a/packages/snowcat-onebot/action/system/GetCredentials.ts b/packages/snowcat-onebot/action/system/GetCredentials.ts new file mode 100644 index 0000000..06bb854 --- /dev/null +++ b/packages/snowcat-onebot/action/system/GetCredentials.ts @@ -0,0 +1,41 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetCredentialsPayloadSchema = Type.Object({ + domain: Type.String({ description: '需要获取 cookies 的域名' }), +}); + +export type GetCredentialsPayload = Static; + +export const GetCredentialsReturnSchema = Type.Object({ + cookies: Type.String({ description: 'Cookies' }), + token: Type.Number({ description: 'CSRF Token' }), +}); + +export type GetCredentialsResponse = Static; + +export class GetCredentials extends OneBotAction { + override actionName = ActionName.GetCredentials; + override payloadSchema = GetCredentialsPayloadSchema; + override returnSchema = GetCredentialsReturnSchema; + override actionSummary = '获取登录凭证'; + override actionDescription = '获取登录凭证'; + override actionTags = ['系统接口']; + override payloadExample = { + domain: 'qun.qq.com', + }; + + override returnExample = { + cookies: 'uin=o123456789; skey=@abc12345;', + token: 123456789, + }; + + async _handle (payload: GetCredentialsPayload) { + const cookiesObject = await this.core.apis.UserApi.getCookies(payload.domain); + // 把获取到的cookiesObject转换成 k=v; 格式字符串拼接在一起 + const cookies = Object.entries(cookiesObject).map(([key, value]) => `${key}=${value}`).join('; '); + const bkn = cookiesObject?.['skey'] ? this.core.apis.WebApi.getBknFromCookie(cookiesObject) : ''; + return { cookies, token: +bkn }; + } +} diff --git a/packages/snowcat-onebot/action/system/GetLoginInfo.ts b/packages/snowcat-onebot/action/system/GetLoginInfo.ts new file mode 100644 index 0000000..a9782b8 --- /dev/null +++ b/packages/snowcat-onebot/action/system/GetLoginInfo.ts @@ -0,0 +1,24 @@ +import { OB11User } from '@/snowcat-onebot/index'; +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11UserSchema } from '../schemas'; +import { Type } from '@sinclair/typebox'; +import { SystemActionsExamples } from '@/snowcat-onebot/action/example/SystemActionsExamples'; + +class GetLoginInfo extends OneBotAction { + override actionName = ActionName.GetLoginInfo; + override payloadSchema = Type.Object({}); + override returnSchema = OB11UserSchema; + override actionSummary = '获取登录号信息'; + override actionDescription = '获取当前登录帐号的信息'; + override actionTags = ['系统接口']; + override payloadExample = SystemActionsExamples.GetLoginInfo.payload; + override returnExample = SystemActionsExamples.GetLoginInfo.response; + + async _handle () { + return OB11Construct.selfInfo(this.core.selfInfo); + } +} + +export default GetLoginInfo; diff --git a/packages/snowcat-onebot/action/system/GetStatus.ts b/packages/snowcat-onebot/action/system/GetStatus.ts new file mode 100644 index 0000000..68d4d69 --- /dev/null +++ b/packages/snowcat-onebot/action/system/GetStatus.ts @@ -0,0 +1,34 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Type, Static } from '@sinclair/typebox'; + +export const GetStatusReturnSchema = Type.Object({ + online: Type.Boolean({ description: '是否在线' }), + good: Type.Boolean({ description: '状态是否良好' }), + stat: Type.Unknown({ description: '统计信息' }), +}); + +export type GetStatusReturnType = Static; + +export default class GetStatus extends OneBotAction { + override actionName = ActionName.GetStatus; + override payloadSchema = Type.Object({}); + override returnSchema = GetStatusReturnSchema; + override actionSummary = '获取运行状态'; + override actionDescription = '获取运行状态'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = { + online: true, + good: true, + stat: {}, + }; + + async _handle (): Promise { + return { + online: !!this.core.selfInfo.online, + good: true, + stat: {}, + }; + } +} diff --git a/packages/snowcat-onebot/action/system/GetSystemMsg.ts b/packages/snowcat-onebot/action/system/GetSystemMsg.ts new file mode 100644 index 0000000..71cd9f0 --- /dev/null +++ b/packages/snowcat-onebot/action/system/GetSystemMsg.ts @@ -0,0 +1,69 @@ +import { GroupNotifyMsgStatus } from 'snowcat-core'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { OB11NotifySchema } from '../schemas'; + +export const GetGroupSystemMsgPayloadSchema = Type.Object({ + count: Type.Union([Type.Number(), Type.String()], { default: 50, description: '获取的消息数量' }), +}); + +export type GetGroupSystemMsgPayload = Static; + +export const GetGroupSystemMsgReturnSchema = Type.Object({ + invited_requests: Type.Array(OB11NotifySchema, { description: '进群邀请列表' }), + InvitedRequest: Type.Array(OB11NotifySchema, { description: '进群邀请列表 (兼容)' }), + join_requests: Type.Array(OB11NotifySchema, { description: '进群申请列表' }), +}); + +export type GetGroupSystemMsgReturn = Static; + +export class GetGroupSystemMsg extends OneBotAction { + override actionName = ActionName.GetGroupSystemMsg; + override payloadSchema = GetGroupSystemMsgPayloadSchema; + override returnSchema = GetGroupSystemMsgReturnSchema; + override actionSummary = '获取群系统消息'; + override actionDescription = '获取群系统消息'; + override actionTags = ['系统接口']; + override payloadExample = { + count: 50, + }; + + override returnExample = { + invited_requests: [], + InvitedRequest: [], + join_requests: [], + }; + + async _handle (params: GetGroupSystemMsgPayload): Promise { + const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, +params.count); + const retData: GetGroupSystemMsgReturn = { invited_requests: [], InvitedRequest: [], join_requests: [] }; + + const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => { + const invitorUin = SSNotify.user1?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user1.uid) : 0; + const actorUin = SSNotify.user2?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user2.uid) : 0; + const commonData = { + request_id: +SSNotify.seq, + invitor_uin: invitorUin, + invitor_nick: SSNotify.user1?.nickName || '', + group_id: +SSNotify.group?.groupCode, + message: SSNotify?.postscript || '', + group_name: SSNotify.group?.groupName || '', + checked: SSNotify.status !== GroupNotifyMsgStatus.KUNHANDLE, + actor: actorUin, + requester_nick: SSNotify.user1?.nickName || '', + }; + + if (SSNotify.type === 1) { + retData.InvitedRequest.push(commonData); + } else if (SSNotify.type === 7) { + retData.join_requests.push(commonData); + } + }); + + await Promise.all(notifyPromises); + + retData.invited_requests = retData.InvitedRequest; + return retData; + } +} diff --git a/packages/snowcat-onebot/action/system/GetVersionInfo.ts b/packages/snowcat-onebot/action/system/GetVersionInfo.ts new file mode 100644 index 0000000..52c386a --- /dev/null +++ b/packages/snowcat-onebot/action/system/GetVersionInfo.ts @@ -0,0 +1,34 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { snowCatVersion } from 'snowcat-common/src/version'; +import { Type, Static } from '@sinclair/typebox'; + +const ReturnSchema = Type.Object({ + app_name: Type.String({ description: '应用名称' }), + protocol_version: Type.String({ description: '协议版本' }), + app_version: Type.String({ description: '应用版本' }), +}, { description: '版本信息' }); + +type ReturnType = Static; + +export default class GetVersionInfo extends OneBotAction { + override actionName = ActionName.GetVersionInfo; + override returnSchema = ReturnSchema; + override actionSummary = '获取版本信息'; + override actionDescription = '获取版本信息'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = { + app_name: 'SnowCat.Onebot', + protocol_version: 'v11', + app_version: '1.0.0', + }; + + async _handle (): Promise { + return { + app_name: 'SnowCat.Onebot', + protocol_version: 'v11', + app_version: snowCatVersion, + }; + } +} diff --git a/packages/snowcat-onebot/action/system/SetRestart.ts b/packages/snowcat-onebot/action/system/SetRestart.ts new file mode 100644 index 0000000..690c866 --- /dev/null +++ b/packages/snowcat-onebot/action/system/SetRestart.ts @@ -0,0 +1,22 @@ +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '../OneBotAction'; +import { WebUiDataRuntime } from 'snowcat-webui-backend/src/helper/Data'; +import { Type } from '@sinclair/typebox'; + +export class SetRestart extends OneBotAction { + override actionName = ActionName.Reboot; + override payloadSchema = Type.Object({}); + override returnSchema = Type.Null(); + override actionSummary = '重启服务'; + override actionDescription = '重启服务'; + override actionTags = ['系统接口']; + override payloadExample = {}; + override returnExample = null; + + async _handle () { + const result = await WebUiDataRuntime.requestRestartProcess(); + if (!result.result) { + throw new Error(result.message || '进程重启失败'); + } + } +} diff --git a/packages/snowcat-onebot/action/test/Readme.md b/packages/snowcat-onebot/action/test/Readme.md new file mode 100644 index 0000000..fb6cf9b --- /dev/null +++ b/packages/snowcat-onebot/action/test/Readme.md @@ -0,0 +1,17 @@ +# 自动注册路由测试 + +## 参与工具 +vite-auto-include 自动化收集所有文件并引入 +@vitejs/plugin-react-swc 包含对装饰器的支持 + +## 支持示例 +```typescript +@ActionHandler +export default class TestAutoRegister02 extends OneBotAction { + override actionName = ActionName.TestAutoRegister02; + + async _handle (_payload: void): Promise { + return 'AutoRegister Router Test'; + } +} +``` \ No newline at end of file diff --git a/packages/snowcat-onebot/action/test/TestAutoRegister01.ts b/packages/snowcat-onebot/action/test/TestAutoRegister01.ts new file mode 100644 index 0000000..25b8c69 --- /dev/null +++ b/packages/snowcat-onebot/action/test/TestAutoRegister01.ts @@ -0,0 +1,15 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ActionHandler } from '../auto-register'; +import { Type } from '@sinclair/typebox'; + +@ActionHandler +export default class TestAutoRegister01 extends OneBotAction { + override actionName = ActionName.TestAutoRegister01; + override payloadSchema = Type.Object({}); + override returnSchema = Type.String({ description: '测试返回内容' }); + + async _handle (_payload: void): Promise { + return 'AutoRegister Router Test'; + } +} diff --git a/packages/snowcat-onebot/action/test/TestAutoRegister02.ts b/packages/snowcat-onebot/action/test/TestAutoRegister02.ts new file mode 100644 index 0000000..6fc6f1e --- /dev/null +++ b/packages/snowcat-onebot/action/test/TestAutoRegister02.ts @@ -0,0 +1,15 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { ActionHandler } from '../auto-register'; +import { Type } from '@sinclair/typebox'; + +@ActionHandler +export default class TestAutoRegister02 extends OneBotAction { + override actionName = ActionName.TestAutoRegister02; + override payloadSchema = Type.Object({}); + override returnSchema = Type.String({ description: '测试返回内容' }); + + async _handle (_payload: void): Promise { + return 'AutoRegister Router Test'; + } +} diff --git a/packages/snowcat-onebot/action/user/GetCookies.ts b/packages/snowcat-onebot/action/user/GetCookies.ts new file mode 100644 index 0000000..01141b7 --- /dev/null +++ b/packages/snowcat-onebot/action/user/GetCookies.ts @@ -0,0 +1,50 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetCookiesPayloadSchema = Type.Object({ + domain: Type.String({ description: '需要获取 cookies 的域名' }), +}); + +export type GetCookiesPayload = Static; + +export const GetCookiesReturnSchema = Type.Object({ + cookies: Type.String({ description: 'Cookies' }), + bkn: Type.String({ description: 'CSRF Token' }), +}); + +export type GetCookiesResponse = Static; + +export class GetCookies extends OneBotAction { + override actionName = ActionName.GetCookies; + override payloadSchema = GetCookiesPayloadSchema; + override returnSchema = GetCookiesReturnSchema; + override actionSummary = '获取 Cookies'; + override actionDescription = '获取指定域名的 Cookies'; + override actionTags = ['用户接口']; + override payloadExample = { + domain: 'qun.qq.com', + }; + + override returnExample = { + cookies: 'uin=o123456789; skey=@abc12345;', + bkn: '123456789', + }; + + async _handle (payload: GetCookiesPayload) { + const cookiesObject = await this.core.apis.UserApi.getCookies(payload.domain); + // 把获取到的cookiesObject转换成 k=v; 格式字符串拼接在一起 + const cookies = Object.entries(cookiesObject).map(([key, value]) => `${key}=${value}`).join('; '); + let bkn = ''; + if (payload.domain.includes('qzone.qq.com')) { + if (cookiesObject?.['p_skey']) { + bkn = this.core.apis.WebApi.getBknFromPSKey(cookiesObject['p_skey']); + } else { + bkn = cookiesObject?.['skey'] ? this.core.apis.WebApi.getBknFromCookie(cookiesObject) : ''; + } + } else { + bkn = cookiesObject?.['skey'] ? this.core.apis.WebApi.getBknFromCookie(cookiesObject) : ''; + } + return { cookies, bkn }; + } +} diff --git a/packages/snowcat-onebot/action/user/GetFriendList.ts b/packages/snowcat-onebot/action/user/GetFriendList.ts new file mode 100644 index 0000000..2ee9bba --- /dev/null +++ b/packages/snowcat-onebot/action/user/GetFriendList.ts @@ -0,0 +1,46 @@ +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +import { OB11UserSchema } from '../schemas'; +import { UserActionsExamples } from '@/snowcat-onebot/action/example/UserActionsExamples'; + +const PayloadSchema = Type.Object({ + no_cache: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否不使用缓存' })), +}); + +type PayloadType = Static; + +const ReturnSchema = Type.Array(OB11UserSchema, { description: '好友列表' }); + +type ReturnType = Static; + +export default class GetFriendList extends OneBotAction { + override actionName = ActionName.GetFriendList; + override payloadSchema = PayloadSchema; + override returnSchema = ReturnSchema; + override actionSummary = '获取好友列表'; + override actionDescription = '获取当前帐号的好友列表'; + override actionTags = ['用户接口']; + override payloadExample = UserActionsExamples.GetFriendList.payload; + override returnExample = UserActionsExamples.GetFriendList.response; + + async _handle (_payload: PayloadType) { + const buddyMap = await this.core.apis.FriendApi.getBuddyV2SimpleInfoMap(); + const isNocache = typeof _payload.no_cache === 'string' ? _payload.no_cache === 'true' : !!_payload.no_cache; + await Promise.all( + Array.from(buddyMap.values()).map(async (buddyInfo) => { + try { + const userDetail = await this.core.apis.UserApi.getUserDetailInfo(buddyInfo.coreInfo.uid, isNocache); + const data = buddyMap.get(buddyInfo.coreInfo.uid); + if (data) { + data.qqLevel = userDetail.qqLevel; + } + } catch (error) { + this.core.context.logger.logError('获取好友详细信息失败', error); + } + }) + ); + return OB11Construct.friends(Array.from(buddyMap.values())); + } +} diff --git a/packages/snowcat-onebot/action/user/GetRecentContact.ts b/packages/snowcat-onebot/action/user/GetRecentContact.ts new file mode 100644 index 0000000..364932c --- /dev/null +++ b/packages/snowcat-onebot/action/user/GetRecentContact.ts @@ -0,0 +1,82 @@ +import type { MessageFormat } from '@/snowcat-onebot/config/config'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { Static, Type } from '@sinclair/typebox'; + +export const GetRecentContactPayloadSchema = Type.Object({ + count: Type.Union([Type.Number(), Type.String()], { default: 10, description: '获取的数量' }), +}); + +export type GetRecentContactPayload = Static; + +export const GetRecentContactReturnSchema = Type.Array(Type.Object({ + lastestMsg: Type.Any({ description: '最后一条消息' }), + peerUin: Type.String({ description: '对象QQ' }), + remark: Type.String({ description: '备注' }), + msgTime: Type.String({ description: '消息时间' }), + chatType: Type.Number({ description: '聊天类型' }), + msgId: Type.String({ description: '消息ID' }), + sendNickName: Type.String({ description: '发送者昵称' }), + sendMemberName: Type.String({ description: '发送者群名片' }), + peerName: Type.String({ description: '对象名称' }), +}), { description: '最近会话列表' }); + +export type GetRecentContactReturn = Static; + +export default class GetRecentContact extends OneBotAction { + override actionName = ActionName.GetRecentContact; + override payloadSchema = GetRecentContactPayloadSchema; + override returnSchema = GetRecentContactReturnSchema; + override actionSummary = '获取最近会话'; + override actionDescription = '获取最近会话'; + override actionTags = ['用户接口']; + override payloadExample = { + count: 10, + }; + + override returnExample = [ + { + peerUin: '123456', + peerName: '测试', + msgTime: '1734567890', + msgId: '12345', + lastestMsg: {}, + }, + ]; + + async _handle (payload: GetRecentContactPayload, _adapter: string, config: NetworkAdapterConfig): Promise { + const ret = await this.core.apis.UserApi.getRecentContactListSnapShot(+payload.count); + // 烘焙消息 + const results = await Promise.all(ret.info.changedList.map(async (t) => { + const FastMsg = await this.core.apis.MsgApi.getMsgsByMsgId({ chatType: t.chatType, peerUid: t.peerUid }, [t.msgId]); + if (FastMsg.msgList.length > 0 && FastMsg.msgList[0]) { + // 扩展ret.info.changedList + const lastestMsg = await this.obContext.apis.MsgApi.parseMessage(FastMsg.msgList[0], config['messagePostFormat'] as MessageFormat | undefined ?? 'array'); + return { + lastestMsg, + peerUin: t.peerUin, + remark: String(t.remark ?? ''), + msgTime: t.msgTime, + chatType: t.chatType, + msgId: t.msgId, + sendNickName: String(t.sendNickName ?? ''), + sendMemberName: String(t.sendMemberName ?? ''), + peerName: String(t.peerName ?? ''), + }; + } + return { + lastestMsg: undefined, + peerUin: t.peerUin, + remark: String(t.remark ?? ''), + msgTime: t.msgTime, + chatType: t.chatType, + msgId: t.msgId, + sendNickName: String(t.sendNickName ?? ''), + sendMemberName: String(t.sendMemberName ?? ''), + peerName: String(t.peerName ?? ''), + }; + })); + return results; + } +} diff --git a/packages/snowcat-onebot/action/user/SendLike.ts b/packages/snowcat-onebot/action/user/SendLike.ts new file mode 100644 index 0000000..b3ce4a3 --- /dev/null +++ b/packages/snowcat-onebot/action/user/SendLike.ts @@ -0,0 +1,36 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; +export const SendLikePayloadSchema = Type.Object({ + user_id: Type.String({ description: '对方 QQ 号' }), + times: Type.Union([Type.Number(), Type.String()], { default: 1, description: '点赞次数' }), +}); + +export type SendLikePayload = Static; + +export default class SendLike extends OneBotAction { + override actionName = ActionName.SendLike; + override payloadSchema = SendLikePayloadSchema; + override returnSchema = Type.Null(); + override actionSummary = '点赞'; + override actionDescription = '给指定用户点赞'; + override actionTags = ['用户接口']; + override payloadExample = { + user_id: '123456', + times: 10, + }; + + override returnExample = {}; + override errorExamples = [ + { code: 1400, description: '点赞失败(频率过快或用户不存在)' }, + ]; + + async _handle (payload: SendLikePayload): Promise { + const qq = payload.user_id.toString(); + const uid: string = await this.core.apis.UserApi.getUidByUinV2(qq) ?? ''; + const result = await this.core.apis.UserApi.like(uid, +payload.times); + if (result.result !== 0) { + throw new Error(`点赞失败 ${result.errMsg}`); + } + } +} diff --git a/packages/snowcat-onebot/action/user/SetFriendAddRequest.ts b/packages/snowcat-onebot/action/user/SetFriendAddRequest.ts new file mode 100644 index 0000000..7e7ce83 --- /dev/null +++ b/packages/snowcat-onebot/action/user/SetFriendAddRequest.ts @@ -0,0 +1,39 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const SetFriendAddRequestPayloadSchema = Type.Object({ + flag: Type.String({ description: '加好友请求的 flag (需从上报中获取)' }), + approve: Type.Optional(Type.Union([Type.String(), Type.Boolean()], { description: '是否同意请求' })), + remark: Type.Optional(Type.String({ description: '添加后的好友备注' })), +}); + +export type SetFriendAddRequestPayload = Static; + +export default class SetFriendAddRequest extends OneBotAction { + override actionName = ActionName.SetFriendAddRequest; + override payloadSchema = SetFriendAddRequestPayloadSchema; + override returnSchema = Type.Null(); + override actionSummary = '处理加好友请求'; + override actionDescription = '同意或拒绝加好友请求'; + override actionTags = ['用户接口']; + override payloadExample = { + flag: 'flag_12345', + approve: true, + remark: '新朋友', + }; + + override returnExample = {}; + + async _handle (payload: SetFriendAddRequestPayload): Promise { + const approve = payload.approve?.toString() !== 'false'; + const notify = (await this.core.apis.FriendApi.getBuddyReq()).buddyReqs.find(e => e.reqTime === payload.flag.toString()); + if (!notify) { + throw new Error('No such request'); + } + await this.core.apis.FriendApi.handleFriendRequest(notify, approve); + if (payload.remark) { + await this.core.apis.FriendApi.setBuddyRemark(notify.friendUid, payload.remark); + } + } +} diff --git a/packages/snowcat-onebot/action/user/SetFriendRemark.ts b/packages/snowcat-onebot/action/user/SetFriendRemark.ts new file mode 100644 index 0000000..531067b --- /dev/null +++ b/packages/snowcat-onebot/action/user/SetFriendRemark.ts @@ -0,0 +1,37 @@ +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +export const SetFriendRemarkPayloadSchema = Type.Object({ + user_id: Type.String({ description: '对方 QQ 号' }), + remark: Type.String({ description: '备注内容' }), +}); + +export type SetFriendRemarkPayload = Static; + +export default class SetFriendRemark extends OneBotAction { + override actionName = ActionName.SetFriendRemark; + override payloadSchema = SetFriendRemarkPayloadSchema; + override returnSchema = Type.Null(); + override actionSummary = '设置好友备注'; + override actionDescription = '设置好友备注'; + override actionTags = ['用户接口']; + override payloadExample = { + user_id: '123456', + remark: '测试备注', + }; + + override returnExample = null; + override errorExamples = [ + { code: 1400, description: '备注设置失败(好友不存在或非法输入)' }, + ]; + + async _handle (payload: SetFriendRemarkPayload): Promise { + const friendUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); + const is_friend = await this.core.apis.FriendApi.isBuddy(friendUid); + if (!is_friend) { + throw new Error(`用户 ${payload.user_id} 不是好友`); + } + await this.core.apis.FriendApi.setBuddyRemark(friendUid, payload.remark); + } +} diff --git a/packages/snowcat-onebot/api/file.ts b/packages/snowcat-onebot/api/file.ts new file mode 100644 index 0000000..1a656b1 --- /dev/null +++ b/packages/snowcat-onebot/api/file.ts @@ -0,0 +1,228 @@ +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { encodeSilk } from '@/snowcat-core/helper/audio'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; +import { calculateFileMD5 } from 'snowcat-common/src/file'; +import { ElementType, SnowCatCore, NTVideoType, PicElement, PicSubType, SendFileElement, SendPicElement, SendPttElement, SendVideoElement } from 'snowcat-core'; +import { getFileTypeForSendType } from 'snowcat-core/helper/msg'; +import { imageSizeFallBack } from 'snowcat-image-size'; +import { SendMessageContext } from './msg'; +import { fileTypeFromFile } from 'file-type'; +import pathLib from 'node:path'; +import fsPromises from 'fs/promises'; +import fs from 'fs'; +import { randomUUID } from 'crypto'; +import { defaultVideoThumbB64 } from '@/snowcat-core/helper/ffmpeg/video'; + +const videoFormatByExtension: Partial> = { + afs: NTVideoType.VIDEO_FORMAT_AFS, + avi: NTVideoType.VIDEO_FORMAT_AVI, + mkv: NTVideoType.VIDEO_FORMAT_MKV, + mod: NTVideoType.VIDEO_FORMAT_MOD, + mov: NTVideoType.VIDEO_FORMAT_MOV, + mp4: NTVideoType.VIDEO_FORMAT_MP4, + mts: NTVideoType.VIDEO_FORMAT_MTS, + rm: NTVideoType.VIDEO_FORMAT_RM, + rmvb: NTVideoType.VIDEO_FORMAT_RMVB, + ts: NTVideoType.VIDEO_FORMAT_TS, + wmv: NTVideoType.VIDEO_FORMAT_WMV, +}; + +export class OneBotFileApi { + obContext: NapCatOneBot11Adapter; + core: SnowCatCore; + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false, _isLocal: boolean = false): Promise { + const { + fileName: _fileName, + path, + fileSize, + } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE, 0, uploadGroupFile); + if (fileSize === 0) { + throw new Error('文件异常,大小为0'); + } + if (uploadGroupFile) { + context.deleteAfterSentFiles.push(path); + } + return { + elementType: ElementType.FILE, + elementId: '', + fileElement: { + fileName: fileName || _fileName, + folderId, + filePath: path, + fileSize: fileSize.toString(), + }, + }; + } + + async createValidSendPicElement (context: SendMessageContext, picPath: string, summary: string = '', subType: PicSubType = 0, _isLocal: boolean = false): Promise { + const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(picPath, ElementType.PIC, subType); + if (fileSize === 0) { + throw new Error('文件异常,大小为0'); + } + const imageSize = await imageSizeFallBack(picPath); + context.deleteAfterSentFiles.push(path); + return { + elementType: ElementType.PIC, + elementId: '', + picElement: { + md5HexStr: md5, + fileSize: fileSize.toString(), + picWidth: imageSize.width, + picHeight: imageSize.height, + fileName, + sourcePath: path, + original: true, + picType: await getFileTypeForSendType(picPath), + picSubType: subType, + fileUuid: '', + fileSubId: '', + thumbFileSize: 0, + summary, + } as PicElement, + }; + } + + async createValidSendVideoElement (context: SendMessageContext, filePath: string, fileName: string = '', _diyThumbPath: string = '', isLocal: boolean = false): Promise { + let videoInfo = { + width: 1920, + height: 1080, + time: 15, + format: 'mp4', + size: 0, + filePath, + }; + let fileExt = 'mp4'; + try { + const tempExt = (await fileTypeFromFile(filePath))?.ext; + if (tempExt) fileExt = tempExt; + } catch (e) { + this.core.context.logger.logError('获取文件类型失败', e); + } + const currentExt = pathLib.extname(filePath).slice(1).toLowerCase(); + const needsExtCopy = currentExt !== fileExt.toLowerCase(); + if (needsExtCopy) { + if (isLocal) { + // 本地文件缺少正确扩展名:拷贝到临时目录,避免污染用户目录 + const tempFilePath = pathLib.join(this.core.SnowCatTempPath, `${randomUUID()}.${fileExt}`); + fs.copyFileSync(filePath, tempFilePath); + context.deleteAfterSentFiles.push(tempFilePath); + filePath = tempFilePath; + } else { + // 临时文件缺少正确扩展名:在原位置加扩展名 + const newFilePath = `${filePath}.${fileExt}`; + fs.copyFileSync(filePath, newFilePath); + context.deleteAfterSentFiles.push(newFilePath); + filePath = newFilePath; + } + } + // 扩展名已匹配时:本地文件直接用原始路径(零拷贝),临时文件也无需额外操作 + + const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO); + context.deleteAfterSentFiles.push(path); + if (fileSize === 0) { + throw new Error('文件异常,大小为0'); + } + const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`); + fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true }); + const usePacketVideoMetadata = context.usePacketVideoMetadata === true; + const thumbExtension = usePacketVideoMetadata ? 'jpg' : 'png'; + const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.${thumbExtension}`); + try { + if (usePacketVideoMetadata) { + videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath); + } else { + videoInfo = await FFmpegService.getVideoInfo(filePath, ''); + await FFmpegService.extractLegacyVideoThumbnail(filePath, thumbPath); + } + if (!fs.existsSync(thumbPath)) { + this.core.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在')); + throw new Error('获取视频缩略图失败'); + } + } catch (e) { + this.core.context.logger.logError('获取视频信息失败', e); + fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64')); + } + if (_diyThumbPath) { + try { + await this.core.apis.FileApi.copyFile(_diyThumbPath, thumbPath); + } catch (e) { + this.core.context.logger.logError('复制自定义缩略图失败', e); + } + } + context.deleteAfterSentFiles.push(thumbPath); + const thumbSize = (await fsPromises.stat(thumbPath)).size; + const thumbMd5 = await calculateFileMD5(thumbPath); + + const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`; + const videoMetadata = usePacketVideoMetadata + ? { + fileTime: Math.max(0, Math.round(videoInfo.time)), + fileFormat: videoFormatByExtension[fileExt.toLowerCase()] ?? NTVideoType.VIDEO_FORMAT_MP4, + fileWidth: videoInfo.width, + fileHeight: videoInfo.height, + } + : { + fileTime: videoInfo.time, + }; + return { + elementType: ElementType.VIDEO, + elementId: '', + videoElement: { + fileName: uploadName, + filePath: path, + videoMd5: md5, + thumbMd5, + ...videoMetadata, + thumbPath: new Map([[0, thumbPath]]), + thumbSize, + thumbWidth: videoInfo.width, + thumbHeight: videoInfo.height, + fileSize: fileSize.toString(), + }, + }; + } + + async createValidSendPttElement (_context: SendMessageContext, pttPath: string): Promise { + const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.SnowCatTempPath, this.core.context.logger); + if (!silkPath) { + throw new Error('语音转换失败, 请检查语音文件是否正常'); + } + const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(silkPath, ElementType.PTT); + if (fileSize === 0) { + throw new Error('文件异常,大小为0'); + } + if (converted) { + fsPromises.unlink(silkPath).then().catch((e) => this.core.context.logger.logError('删除临时文件失败', e)); + } + return { + elementType: ElementType.PTT, + elementId: '', + pttElement: { + fileName, + filePath: path, + md5HexStr: md5, + fileSize: fileSize.toString(), + duration: duration ?? 1, + formatType: 1, + voiceType: 1, + voiceChangeType: 0, + canConvert2Text: true, + waveAmplitudes: [ + 0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17, + ], + fileSubId: '', + playState: 1, + autoConvertText: 0, + storeID: 0, + otherBusinessInfo: { + aiVoiceType: 0, + }, + }, + }; + } +} diff --git a/packages/snowcat-onebot/api/friend.ts b/packages/snowcat-onebot/api/friend.ts new file mode 100644 index 0000000..6e79749 --- /dev/null +++ b/packages/snowcat-onebot/api/friend.ts @@ -0,0 +1,30 @@ +import { GrayTipElement, SnowCatCore } from 'snowcat-core'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11FriendPokeEvent } from '@/snowcat-onebot/event/notice/OB11PokeEvent'; + +export class OneBotFriendApi { + obContext: NapCatOneBot11Adapter; + core: SnowCatCore; + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + // 使用前预先判断 busiId 1061 + async parsePrivatePokeEvent (grayTipElement: GrayTipElement, uin: number) { + const json = JSON.parse(grayTipElement.jsonGrayTipElement.jsonStr); + const pokedetail: Array<{ uid: string; }> = json.items; + // 筛选item带有uid的元素 + const poke_uid = pokedetail.filter(item => item.uid); + if (poke_uid.length === 2 && poke_uid[0]?.uid && poke_uid[1]?.uid) { + return new OB11FriendPokeEvent( + this.core, + uin, + parseInt((await this.core.apis.UserApi.getUinByUidV2(poke_uid[0].uid))), + parseInt((await this.core.apis.UserApi.getUinByUidV2(poke_uid[1].uid))), + pokedetail + ); + } + return undefined; + } +} diff --git a/packages/snowcat-onebot/api/group.ts b/packages/snowcat-onebot/api/group.ts new file mode 100644 index 0000000..e49191c --- /dev/null +++ b/packages/snowcat-onebot/api/group.ts @@ -0,0 +1,416 @@ +import { + ChatType, + FileElement, + GrayTipElement, + InstanceContext, + JsonGrayBusiId, + MessageElement, + SnowCatCore, + NTGrayTipElementSubTypeV2, + RawMessage, + TipGroupElement, + TipGroupElementType, +} from 'snowcat-core'; +import { OB11GroupBanEvent } from '@/snowcat-onebot/event/notice/OB11GroupBanEvent'; +import fastXmlParser from 'fast-xml-parser'; +import { OB11GroupMsgEmojiLikeEvent } from '@/snowcat-onebot/event/notice/OB11MsgEmojiLikeEvent'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { OB11GroupCardEvent } from '@/snowcat-onebot/event/notice/OB11GroupCardEvent'; +import { OB11GroupPokeEvent } from '@/snowcat-onebot/event/notice/OB11PokeEvent'; +import { OB11GroupEssenceEvent } from '@/snowcat-onebot/event/notice/OB11GroupEssenceEvent'; +import { OB11GroupTitleEvent } from '@/snowcat-onebot/event/notice/OB11GroupTitleEvent'; +import { OB11GroupGrayTipEvent } from '@/snowcat-onebot/event/notice/OB11GroupGrayTipEvent'; +import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent'; +import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { GroupReactNotify, PushMsg } from 'snowcat-core/packet/transformer/proto'; +import { NapCatOneBot11Adapter } from '..'; + +export class OneBotGroupApi { + obContext: NapCatOneBot11Adapter; + core: SnowCatCore; + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + async parseGroupBanEvent (GroupCode: string, grayTipElement: GrayTipElement) { + const groupElement = grayTipElement?.groupElement; + if (!groupElement?.shutUp) return undefined; + const memberUid = groupElement.shutUp.member.uid; + const adminUid = groupElement.shutUp.admin.uid; + let memberUin: string; + let duration = parseInt(groupElement.shutUp.duration); + const subType: 'ban' | 'lift_ban' = duration > 0 ? 'ban' : 'lift_ban'; + if (memberUid) { + memberUin = (await this.core.apis.GroupApi.getGroupMember(GroupCode, memberUid))?.uin ?? ''; + } else { + memberUin = '0'; // 0表示全员禁言 + if (duration > 0) { + duration = -1; + } + } + await this.core.apis.GroupApi.refreshGroupMemberCachePartial(GroupCode, memberUid); + const adminUin = (await this.core.apis.GroupApi.getGroupMember(GroupCode, adminUid))?.uin; + if (memberUin && adminUin) { + return new OB11GroupBanEvent( + this.core, + parseInt(GroupCode), + parseInt(memberUin), + parseInt(adminUin), + duration, + subType + ); + } + return undefined; + } + + async parseGroupEmojiLikeEventByGrayTip ( + groupCode: string, + grayTipElement: GrayTipElement + ) { + if (this.core.apis.PacketApi.packetStatus === true) { + return; + // Raw包解析支持时禁用NT解析 + } + const emojiLikeData = new fastXmlParser.XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + }).parse(grayTipElement.xmlElement.content); + this.core.context.logger.logDebug('收到表情回应我的消息', emojiLikeData); + const senderUin = emojiLikeData.gtip.qq.jp; + const msgSeq = emojiLikeData.gtip.url.msgseq; + const emojiId = emojiLikeData.gtip.face.id; + return await this.createGroupEmojiLikeEvent(groupCode, senderUin, msgSeq, emojiId, true, 1); + } + + async createGroupEmojiLikeEvent ( + groupCode: string, + senderUin: string, + msgSeq: string, + emojiId: string, + isAdd: boolean = true, + count: number = 1 + ) { + const peer = { + chatType: ChatType.KCHATTYPEGROUP, + guildId: '', + peerUid: groupCode, + }; + const replyMsgList = (await this.core.apis.MsgApi.queryFirstMsgBySeq(peer, msgSeq)).msgList; + if (replyMsgList.length < 1) { + return; + } + const replyMsg = replyMsgList[0]; + if (!replyMsg) { + this.core.context.logger.logError('解析表情回应消息失败: 未找到回应消息'); + return undefined; + } + return new OB11GroupMsgEmojiLikeEvent( + this.core, + parseInt(groupCode), + parseInt(senderUin), + MessageUnique.createUniqueMsgId({ chatType: ChatType.KCHATTYPEGROUP, guildId: '', peerUid: groupCode }, replyMsg.msgId), + [{ + emoji_id: emojiId, + count, + }], + isAdd, + msgSeq + ); + } + + async parseCardChangedEvent (msg: RawMessage) { + if (msg.senderUin && msg.senderUin !== '0') { + const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin); + if (member && member.cardName !== msg.sendMemberName) { + const newCardName = msg.sendMemberName ?? ''; + const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName); + member.cardName = newCardName; + return event; + } + if (member && member.nick !== msg.sendNickName) { + await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid); + } + } + return undefined; + } + + async registerParseGroupReactEvent () { + this.obContext.core.context.packetHandler.onCmd('trpc.msg.olpush.OlPushService.MsgPush', async (packet) => { + const data = new NapProtoMsg(PushMsg).decode(Buffer.from(packet.hex_data, 'hex')); + if (data.message.contentHead.type === 732 && data.message.contentHead.subType === 16) { + const pbNotify = data.message.body?.msgContent?.slice(7); + if (!pbNotify) { + return; + } + // 开始解析Notify + const notify = new NapProtoMsg(GroupReactNotify).decode(pbNotify); + if ((notify.field13 ?? 0) === 35) { + // Group React Notify + const groupCode = notify.groupUin?.toString() ?? ''; + const operatorUid = notify.groupReactionData?.data?.data?.groupReactionDataContent?.operatorUid ?? ''; + const type = notify.groupReactionData?.data?.data?.groupReactionDataContent?.type ?? 0; + const seq = notify.groupReactionData?.data?.data?.groupReactionTarget?.seq?.toString() ?? ''; + const code = notify.groupReactionData?.data?.data?.groupReactionDataContent?.code ?? ''; + // const count = notify.groupReactionData?.data?.data?.groupReactionDataContent?.count ?? 0; + const senderUin = await this.core.apis.UserApi.getUinByUidV2(operatorUid); + const event = await this.createGroupEmojiLikeEvent( + groupCode, + senderUin, + seq, + code, + type === 1, + 1 + ); + if (event) { + this.obContext.networkManager.emitEvent(event); + } + } + } + }); + } + + async registerParseGroupReactEventByCore () { + this.core.event.on('event:emoji_like', async (data) => { + const event = await this.createGroupEmojiLikeEvent( + data.groupId, + data.senderUin, + data.msgSeq, + data.emojiId, + data.isAdd, + data.count + ); + if (event) { + this.obContext.networkManager.emitEvent(event); + } + }); + } + + async parsePaiYiPai (msg: RawMessage, jsonStr: string) { + const json = JSON.parse(jsonStr); + // 判断业务类型 + // Poke事件 + const pokedetail: Array<{ uid: string; }> = json.items; + // 筛选item带有uid的元素 + const poke_uid = pokedetail.filter(item => item.uid); + if (poke_uid.length === 2 && poke_uid[0]?.uid && poke_uid[1]?.uid) { + return new OB11GroupPokeEvent( + this.core, + parseInt(msg.peerUid), + +await this.core.apis.UserApi.getUinByUidV2(poke_uid[0].uid), + +await this.core.apis.UserApi.getUinByUidV2(poke_uid[1].uid), + pokedetail + ); + } + return undefined; + } + + async parseOtherJsonEvent (msg: RawMessage, jsonGrayTipElement: GrayTipElement['jsonGrayTipElement'], context: InstanceContext) { + let json: { items?: { txt?: string; param?: string[] }[] }; + try { + json = JSON.parse(jsonGrayTipElement.jsonStr); + } catch (e) { + context.logger.logWarn('灰条消息JSON解析失败', jsonGrayTipElement.jsonStr, e); + return undefined; + } + const type = json.items?.[json.items.length - 1]?.txt; + await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid); + if (type === '头衔') { + const memberUin = json.items?.[1]?.param?.[0]; + const title = json.items?.[3]?.txt; + context.logger.logDebug('收到群成员新头衔消息', json); + if (memberUin == null || title == null) { + context.logger.logWarn('收到格式异常的群成员新头衔灰条消息', json); + return undefined; + } + return new OB11GroupTitleEvent( + this.core, + +msg.peerUid, + +memberUin, + title + ); + } else if (type === '移出') { + context.logger.logDebug('收到机器人被踢消息', json); + } else { + context.logger.logWarn('收到未知的灰条消息', json); + + // 如果有真实发送者(非0),生成事件上报,可用于检测和撤回伪造灰条 + const senderUin = Number(msg.senderUin) || 0; + if (senderUin !== 0) { + const peer = { chatType: ChatType.KCHATTYPEGROUP, guildId: '', peerUid: msg.peerUid }; + const messageId = MessageUnique.createUniqueMsgId(peer, msg.msgId); + return new OB11GroupGrayTipEvent( + this.core, + +msg.peerUin, + senderUin, + messageId, + jsonGrayTipElement.busiId, + jsonGrayTipElement.jsonStr, + { + msgSeq: msg.msgSeq, + msgTime: msg.msgTime, + msgId: msg.msgId, + json, + } + ); + } + } + return undefined; + } + + async parseEssenceMsg (msg: RawMessage, jsonStr: string) { + const json = JSON.parse(jsonStr); + const searchParams = new URL(json.items[0].jp).searchParams; + const msgSeq = searchParams.get('msgSeq')!; + const Group = searchParams.get('groupCode'); + if (!Group) return undefined; + // const businessId = searchParams.get('businessid'); + const Peer = { + guildId: '', + chatType: ChatType.KCHATTYPEGROUP, + peerUid: Group, + }; + const msgData = await this.core.apis.MsgApi.getMsgsBySeqAndCount(Peer, msgSeq.toString(), 1, true, true); + const msgList = (await this.core.apis.WebApi.getGroupEssenceMsgAll(Group)).flatMap((e) => e.data.msg_list); + const realMsg = msgList.find((e) => e.msg_seq.toString() === msgSeq); + if (msgData.msgList[0]) { + return new OB11GroupEssenceEvent( + this.core, + parseInt(msg.peerUid), + MessageUnique.getShortIdByMsgId(msgData.msgList[0].msgId)!, + parseInt(msgData.msgList[0].senderUin ?? await this.core.apis.UserApi.getUinByUidV2(msgData.msgList[0].senderUid)), + parseInt(realMsg?.add_digest_uin ?? '0') + ); + } + return undefined; + // 获取MsgSeq+Peer可获取具体消息 + } + + async parseGroupUploadFileEvene (msg: RawMessage, element: FileElement, elementWrapper: MessageElement) { + return new OB11GroupUploadNoticeEvent( + this.core, + parseInt(msg.peerUid), parseInt(msg.senderUin || ''), + { + id: FileNapCatOneBotUUID.encode({ + chatType: ChatType.KCHATTYPEGROUP, + peerUid: msg.peerUid, + }, msg.msgId, elementWrapper.elementId, elementWrapper?.fileElement?.fileUuid, element.fileMd5 ?? element.fileUuid), + name: element.fileName, + size: parseInt(element.fileSize), + busid: element.fileBizId ?? 0, + } + ); + } + + async parseGroupElement (msg: RawMessage, element: TipGroupElement, elementWrapper: GrayTipElement) { + if (element.type === TipGroupElementType.KGROUPNAMEMODIFIED) { + this.core.context.logger.logDebug('收到群名称变更事件', element); + return new OB11GroupNameEvent( + this.core, + parseInt(msg.peerUid), + parseInt(await this.core.apis.UserApi.getUinByUidV2(element.memberUid)), + element.groupName + ); + } else if (element.type === TipGroupElementType.KSHUTUP) { + const event = await this.parseGroupBanEvent(msg.peerUid, elementWrapper); + return event; + } else if (element.type === TipGroupElementType.KMEMBERADD) { + // 自己的通知 协议推送为type->85 在这里实现为了避免邀请出现问题 + if (element.memberUid === this.core.selfInfo.uid) { + await this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid, true); + return new OB11GroupIncreaseEvent( + this.core, + parseInt(msg.peerUid), + +this.core.selfInfo.uin, + element.adminUid ? +await this.core.apis.UserApi.getUinByUidV2(element.adminUid) : 0, + 'approve' + ); + } + } + return undefined; + } + + async parseSelfInviteEvent (msg: RawMessage, inviterUin: string, inviteeUin: string) { + return new OB11GroupIncreaseEvent( + this.core, + parseInt(msg.peerUid), + +inviteeUin, + +inviterUin, + 'invite' + ); + } + + async parse51TypeEvent (msg: RawMessage, grayTipElement: GrayTipElement) { + // 神经腾讯 没了妈妈想出来的 + // Warn 下面存在高并发危险 + if (grayTipElement.jsonGrayTipElement.jsonStr) { + const json: { + align: string, + items: Array<{ txt: string, type: string; }>; + } = JSON.parse(grayTipElement.jsonGrayTipElement.jsonStr); + if (json.items.length === 1 && json.items[0]?.txt.endsWith('加入群')) { + const old_members = structuredClone(this.core.apis.GroupApi.groupMemberCache.get(msg.peerUid)); + if (!old_members) return; + const new_members_map = await this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid, true); + if (!new_members_map) return; + const new_members = Array.from(new_members_map.values()); + // 对比members查找新成员 + const new_member = new_members.find((member) => old_members.get(member.uid) === undefined); + if (!new_member) return; + return new OB11GroupIncreaseEvent( + this.core, + +msg.peerUid, + +new_member.uin, + 0, + 'invite' + ); + } + } + return undefined; + } + + async parseGrayTipElement (msg: RawMessage, grayTipElement: GrayTipElement) { + if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) { + // 解析群组事件 由sysmsg解析 + return await this.parseGroupElement(msg, grayTipElement.groupElement, grayTipElement); + } else if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_XMLMSG) { + // 筛选自身入群情况 + // if (grayTipElement.xmlElement.busiId === '10145') { + // const inviteData = new fastXmlParser.XMLParser({ + // ignoreAttributes: false, + // attributeNamePrefix: '', + // }).parse(grayTipElement.xmlElement.content); + + // const inviterUin: string = inviteData.gtip.qq[0].jp; + // const inviteeUin: string = inviteData.gtip.qq[1].jp; + // //刷新群缓存 + // if (inviteeUin === this.core.selfInfo.uin) { + // this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid).then().catch(); + // return this.parseSelfInviteEvent(msg, inviterUin, inviteeUin); + // } + // } else + if (grayTipElement.xmlElement?.templId === '10382') { + return await this.obContext.apis.GroupApi.parseGroupEmojiLikeEventByGrayTip(msg.peerUid, grayTipElement); + } else { + // return await this.obContext.apis.GroupApi.parseGroupIncreaseEvent(msg.peerUid, grayTipElement); + } + } else if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) { + // 解析json事件 busiId好像是string类型的? + if (grayTipElement.jsonGrayTipElement.busiId === '1061') { + return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr); + } else if (grayTipElement.jsonGrayTipElement.busiId === JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP.toString()) { + return await this.parseEssenceMsg(msg, grayTipElement.jsonGrayTipElement.jsonStr); + } else if (grayTipElement.jsonGrayTipElement.busiId === '51') { + // 51是什么?{"align":"center","items":[{"txt":"下一秒起床通过王者荣耀加入群","type":"nor"}] + return await this.parse51TypeEvent(msg, grayTipElement); + } else { + console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement)); + return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement, this.core.context); + } + } + return undefined; + } +} diff --git a/packages/snowcat-onebot/api/index.ts b/packages/snowcat-onebot/api/index.ts new file mode 100644 index 0000000..760416b --- /dev/null +++ b/packages/snowcat-onebot/api/index.ts @@ -0,0 +1,6 @@ +export * from './friend'; +export * from './group'; +export * from './user'; +export * from './msg'; +export * from './quick-action'; +export * from './file'; diff --git a/packages/snowcat-onebot/api/msg.ts b/packages/snowcat-onebot/api/msg.ts new file mode 100644 index 0000000..efa3f37 --- /dev/null +++ b/packages/snowcat-onebot/api/msg.ts @@ -0,0 +1,1663 @@ +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { + ChatType, + CustomMusicSignPostData, + ElementType, + FaceIndex, + FaceType, + GrayTipElement, + GroupNotify, + IdMusicSignPostData, + MessageElement, + SnowCatCore, + NTGrayTipElementSubTypeV2, + NTMsgAtType, + Peer, + RawMessage, + SendMessageElement, + SendTextElement, +} from 'snowcat-core'; +import faceConfig from 'snowcat-core/external/face_config.json'; +import { + NapCatOneBot11Adapter, + OB11Message, + OB11MessageData, + OB11MessageDataType, + OB11MessageFileBase, + OB11MessageForward, + OB11MessageImage, + OB11MessageVideo, +} from '@/snowcat-onebot/index'; +import { OB11Construct } from '@/snowcat-onebot/helper/data'; +import { EventType } from '@/snowcat-onebot/event/OneBotEvent'; +import { encodeCQCode } from '@/snowcat-onebot/helper/cqcode'; +import { uriToLocalFile } from 'snowcat-common/src/file'; +import { RequestUtil } from 'snowcat-common/src/request'; +import { OB11FriendAddNoticeEvent } from '@/snowcat-onebot/event/notice/OB11FriendAddNoticeEvent'; +import { ForwardMsgBuilder } from '@/snowcat-core/helper/forward-msg-builder'; +import { calculateTimeout, capTimeout } from '@/snowcat-onebot/config/config'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent'; +import { GroupDecreaseSubType, OB11GroupDecreaseEvent } from '../event/notice/OB11GroupDecreaseEvent'; +import { GroupAdmin } from 'snowcat-core/packet/transformer/proto/message/groupAdmin'; +import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeEvent'; +import { + GroupChange, + GroupChangeInfo, + GroupInvite, + PushMsgBody, +} from 'snowcat-core/packet/transformer/proto'; +import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest'; +import { LRUCache } from 'snowcat-common/src/lru-cache'; +import { cleanTaskQueue } from 'snowcat-common/src/clean-task'; +import { registerResource } from 'snowcat-common/src/health'; +import { OB11OnlineFileReceiveEvent } from '@/snowcat-onebot/event/notice/OB11OnlineFileReceiveEvent'; +import { OB11OnlineFileSendEvent } from '@/snowcat-onebot/event/notice/OB11OnlineFileSendEvent'; + +type RawToOb11Converters = { + [Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: ( + element: Exclude, + msg: RawMessage, + elementWrapper: MessageElement, + context: RecvMessageContext + ) => Promise +}; + +type Ob11ToRawConverters = { + [Key in OB11MessageDataType]: ( + sendMsg: Extract, + context: SendMessageContext, + ) => Promise +}; + +export type SendMessageContext = { + deleteAfterSentFiles: string[], + peer: Peer; + usePacketVideoMetadata?: boolean; +}; + +export type RecvMessageContext = { + parseMultMsg: boolean, + disableGetUrl: boolean, + quick_reply: boolean; +}; + +function keyCanBeParsed (key: string, parser: RawToOb11Converters): key is keyof RawToOb11Converters { + return key in parser; +} + +export class OneBotMsgApi { + obContext: NapCatOneBot11Adapter; + core: SnowCatCore; + notifyGroupInvite: LRUCache = new LRUCache(50); + // seq -> notify + rawToOb11Converters: RawToOb11Converters = { + textElement: async element => { + if (element.atType === NTMsgAtType.ATTYPEUNKNOWN) { + let text = element.content; + // 兼容 9.7.x 换行符 + if (text.indexOf('\n') === -1 && text.indexOf('\r\n') === -1) { + text = text.replace(/\r/g, '\n'); + } + return { + type: OB11MessageDataType.text, + data: { text }, + }; + } else { + let qq: string = 'all'; + if (element.atType !== NTMsgAtType.ATTYPEALL) { + const { atNtUid, atUid } = element; + qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : String(Number(atUid) >>> 0); + } + return { + type: OB11MessageDataType.at, + data: { + qq, + // name: content.slice(1); + }, + }; + } + }, + + picElement: async (element, msg, elementWrapper, { disableGetUrl }) => { + try { + const peer = { + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '', + }; + FileNapCatOneBotUUID.encode( + peer, + msg.msgId, + elementWrapper.elementId, + element.fileUuid, + element.fileName + ); + return { + type: OB11MessageDataType.image, + data: { + summary: element.summary, + file: element.fileName, + sub_type: element.picSubType, + url: disableGetUrl ? (element.filePath ?? '') : await this.core.apis.FileApi.getImageUrl(element), + file_size: element.fileSize, + }, + }; + } catch (e) { + this.core.context.logger.logError('获取图片url失败', (e as Error).stack); + return null; + } + }, + + fileElement: async (element, msg, elementWrapper, { disableGetUrl }) => { + // 让在线文件/文件夹的消息单独出去(否则无法正确处理UUID!!!) + if (+elementWrapper.elementType === 23 || +elementWrapper.elementType === 30) { + // 判断为在线文件/文件夹 + return { + type: OB11MessageDataType.onlinefile, + data: { + msgId: msg.msgId, + elementId: elementWrapper.elementId, + fileName: element.fileName, + fileSize: element.fileSize, + isDir: (elementWrapper.elementType === 30), + }, + }; + } + + const peer = { + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '', + }; + FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid); + FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName); + if (this.core.apis.PacketApi.packetStatus && !disableGetUrl) { + let url; + try { + // url = await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5, 1500) + url = await registerResource( + 'file-url-get', + { + resourceFn: async () => { + return await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5, 1500); + }, + healthCheckFn: async () => { + return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false); + }, + testArgs: [], + healthCheckInterval: 30000, + maxHealthCheckFailures: 3, + } + ); + } catch (_error) { + url = ''; + } + if (url) { + return { + type: OB11MessageDataType.file, + data: { + file: element.fileName, + file_id: element.fileUuid, + file_size: element.fileSize, + url, + }, + }; + } + } + return { + type: OB11MessageDataType.file, + data: { + file: element.fileName, + file_id: element.fileUuid, + file_size: element.fileSize, + }, + }; + }, + + faceElement: async element => { + const faceIndex = element.faceIndex; + if (element.faceType === FaceType.Poke) { + return { + type: OB11MessageDataType.poke, + data: { + type: element?.pokeType?.toString() ?? '0', + id: faceIndex.toString(), + }, + }; + } + + if (faceIndex === FaceIndex.DICE) { + return { + type: OB11MessageDataType.dice, + data: { + result: element.resultId!, + }, + }; + } else if (faceIndex === FaceIndex.RPS) { + return { + type: OB11MessageDataType.rps, + data: { + result: element.resultId!, + }, + }; + } else { + return { + type: OB11MessageDataType.face, + data: { + id: element.faceIndex.toString(), + raw: element, + resultId: element.resultId, + chainCount: element.chainCount, + }, + }; + } + }, + + marketFaceElement: async (_, msg, elementWrapper) => { + const peer = { + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '', + }; + const { emojiId } = _; + const dir = emojiId.substring(0, 2); + const url = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`; + const filename = `${dir}-${emojiId}.gif`; + FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', filename); + return { + type: OB11MessageDataType.image, + data: { + summary: _.faceName, // 商城表情名称 + file: filename, + url, + key: _.key, + emoji_id: _.emojiId, + emoji_package_id: _.emojiPackageId, + }, + }; + }, + + replyElement: async (element, msg, _, { quick_reply }) => { + const peer = { + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '', + }; + + // 创建回复数据的通用方法 + const createReplyData = (msgId: string): OB11MessageData => ({ + type: OB11MessageDataType.reply, + data: { + id: MessageUnique.createUniqueMsgId(peer, msgId).toString(), + }, + }); + + // 查找记录 + const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords); + + // 特定账号的特殊处理 + if (records && (records.peerUin === '284840486' || records.peerUin === '1094950020')) { + return createReplyData(records.msgId); + } + + // 获取消息的通用方法组 + const tryFetchMethods = async (msgSeq: string, senderUid?: string, msgTime?: string, msgRandom?: string): Promise => { + try { + // 方法1:通过序号和时间筛选 + if (senderUid && msgTime) { + const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV2( + peer, msgSeq, msgTime, [senderUid] + )).msgList; + + const replyMsg = replyMsgList.find(msg => + (msgRandom && msg.msgRandom === msgRandom) || msg.msgSeq === msgSeq + ); + + if (replyMsg) return replyMsg; + if (quick_reply) { + this.core.context.logger.logWarn(`快速回复,跳过方法1查询,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`); + return undefined; + } + this.core.context.logger.logWarn(`方法1查询失败,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`); + } + + // 方法2:直接通过序号获取 + const replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount( + peer, msgSeq, 1, true, true + )).msgList; + + const replyMsg = replyMsgList.find(msg => + (msgRandom && msg.msgRandom === msgRandom) || msg.msgSeq === msgSeq + ); + + if (replyMsg) return replyMsg; + + this.core.context.logger.logWarn(`方法2查询失败,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`); + + // 方法3:另一种筛选方式 + if (senderUid) { + const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV3( + peer, msgSeq, [senderUid] + )).msgList; + + const replyMsg = replyMsgList.find(msg => + (msgRandom && msg.msgRandom === msgRandom) || msg.msgSeq === msgSeq + ); + + if (replyMsg) return replyMsg; + + this.core.context.logger.logWarn(`方法3查询失败,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`); + } + + return undefined; + } catch (error) { + this.core.context.logger.logError('查询回复消息出错', error); + return undefined; + } + }; + + // 有记录情况下,使用完整信息查询 + if (records && element.replyMsgTime && element.senderUidStr) { + const replyMsg = await tryFetchMethods( + element.replayMsgSeq, + element.senderUidStr, + records.msgTime, + records.msgRandom + ); + + if (replyMsg) { + return createReplyData(replyMsg.msgId); + } + + this.core.context.logger.logError('所有查找方法均失败,获取不到带记录的引用消息', element.replayMsgSeq); + } else { + // 旧版客户端或不完整记录的情况,也尝试使用相同流程 + this.core.context.logger.logWarn('似乎是旧版客户端,尝试仅通过序号获取引用消息', element.replayMsgSeq); + + const replyMsg = await tryFetchMethods(element.replayMsgSeq); + + if (replyMsg) { + return createReplyData(replyMsg.msgId); + } + + this.core.context.logger.logError('所有查找方法均失败,获取不到旧客户端的引用消息', element.replayMsgSeq); + } + + // 协议兜底:尝试通过协议直接获取消息 + if (this.core.apis.PacketApi.packetStatus) { + try { + const msgSeq = +element.replayMsgSeq; + const fetchedMsgs = msg.chatType === ChatType.KCHATTYPEGROUP + ? await this.core.apis.PacketApi.pkt.operation.FetchGroupMessage(+msg.peerUin, msgSeq, msgSeq) + : await this.core.apis.PacketApi.pkt.operation.FetchC2CMessage(msg.peerUid, msgSeq, msgSeq); + + const targetMsg = fetchedMsgs.find(m => (m.contentHead.sequence ?? 0) === msgSeq); + if (targetMsg?.contentHead?.newId) { + const msgId = String(targetMsg.contentHead.newId); + this.core.context.logger.logWarn('通过协议兜底成功获取引用消息', msgId); + return createReplyData(msgId); + } + this.core.context.logger.logWarn('协议兜底未找到匹配的引用消息'); + } catch (e) { + this.core.context.logger.logError('协议兜底获取引用消息失败', (e as Error).stack); + } + } + + return null; + }, + videoElement: async (element, msg, elementWrapper, { disableGetUrl }) => { + const peer = { + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '', + }; + // 读取视频链接并兜底 + let videoUrlWrappers: Awaited> | undefined; + + if (msg.peerUin === '284840486' || msg.peerUin === '1094950020') { + try { + videoUrlWrappers = await this.core.apis.FileApi.getVideoUrl({ + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '0', + }, msg.parentMsgIdList[0] ?? msg.msgId, elementWrapper.elementId); + } catch { + this.core.context.logger.logWarn('合并获取视频 URL 失败'); + } + } else { + try { + videoUrlWrappers = await this.core.apis.FileApi.getVideoUrl({ + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '0', + }, msg.msgId, elementWrapper.elementId); + } catch { + this.core.context.logger.logWarn('获取视频 URL 失败'); + } + } + + // 读取在线URL + let videoDownUrl: string | undefined; + + if (videoUrlWrappers) { + const videoDownUrlTemp = videoUrlWrappers.find((urlWrapper) => { + return !!(urlWrapper.url); + }); + if (videoDownUrlTemp) { + videoDownUrl = videoDownUrlTemp.url; + } + } + + // 开始兜底 + if (!videoDownUrl && !disableGetUrl) { + if (this.core.apis.PacketApi.packetStatus) { + try { + // videoDownUrl = await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid, 1500); + videoDownUrl = await registerResource( + 'video-url-get', + { + resourceFn: async () => { + return await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid, 1500); + }, + healthCheckFn: async () => { + return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false); + }, + testArgs: [], + healthCheckInterval: 30000, + maxHealthCheckFailures: 3, + } + ); + } catch (e) { + this.core.context.logger.logError('获取视频url失败', (e as Error).stack); + videoDownUrl = element.filePath; + } + } else { + videoDownUrl = element.filePath; + } + } + const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName); + return { + type: OB11MessageDataType.video, + data: { + file: fileCode, + url: videoDownUrl, + file_size: element.fileSize, + }, + }; + }, + + pttElement: async (element, msg, elementWrapper, { disableGetUrl }) => { + const peer = { + chatType: msg.chatType, + peerUid: msg.peerUid, + guildId: '', + }; + const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', element.fileName); + let pttUrl = ''; + if (this.core.apis.PacketApi.packetStatus && !disableGetUrl) { + try { + pttUrl = await registerResource( + 'ptt-url-get', + { + resourceFn: async () => { + return await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid, 1500); + }, + healthCheckFn: async () => { + return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false); + }, + testArgs: [], + healthCheckInterval: 30000, + maxHealthCheckFailures: 3, + } + ); + // pttUrl = await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid, 1500); + } catch (e) { + this.core.context.logger.logError('获取语音url失败', (e as Error).stack); + pttUrl = element.filePath; + } + } else { + pttUrl = element.filePath; + } + if (pttUrl) { + return { + type: OB11MessageDataType.voice, + data: { + file: fileCode, + path: element.filePath, + url: pttUrl, + file_size: element.fileSize, + }, + }; + } + return { + type: OB11MessageDataType.voice, + data: { + file: fileCode, + file_size: element.fileSize, + path: element.filePath, + }, + }; + }, + + multiForwardMsgElement: async (element, msg, _wrapper, context) => { + const parentMsgPeer = msg.parentMsgPeer ?? { + chatType: msg.chatType, + guildId: '', + peerUid: msg.peerUid, + }; + let multiMsgs = await this.getMultiMessages(msg, parentMsgPeer); + // 拉取失败则跳过 + if (!multiMsgs || multiMsgs.length === 0) { + try { + multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId); + } catch (e) { + this.core.context.logger.logError(`Protocol FetchForwardMsg fallback failed! + element = ${JSON.stringify(element)} , error=${e})`); + return null; + } + } + const forward: OB11MessageForward = { + type: OB11MessageDataType.forward, + data: { id: msg.msgId }, + }; + if (!context.parseMultMsg) return forward; + forward.data.content = await this.parseMultiMessageContent( + multiMsgs, + parentMsgPeer, + msg.parentMsgIdList + ); + return forward; + }, + + arkElement: async (element) => { + return { + type: OB11MessageDataType.json, + data: { + data: element.bytesData, + }, + }; + }, + + markdownElement: async (element) => { + // 让QQ闪传消息独立出去 + if (element?.mdExtInfo?.flashTransferInfo?.filesetId) { + return { + type: OB11MessageDataType.flashtransfer, + data: { + fileSetId: element.mdExtInfo.flashTransferInfo.filesetId, + }, + }; + } else { + return { + type: OB11MessageDataType.markdown, + data: { + content: element.content, + }, + }; + } + }, + }; + + ob11ToRawConverters: Ob11ToRawConverters = { + [OB11MessageDataType.text]: async ({ data: { text } }) => ({ + elementType: ElementType.TEXT, + elementId: '', + textElement: { + content: text, + atType: NTMsgAtType.ATTYPEUNKNOWN, + atUid: '', + atTinyId: '', + atNtUid: '', + }, + }), + + [OB11MessageDataType.at]: async ({ data: { qq: atQQ } }, context) => { + function at (atUid: string, atNtUid: string, atType: NTMsgAtType, atName: string): SendTextElement { + return { + elementType: ElementType.TEXT, + elementId: '', + textElement: { + content: `@${atName}`, + atType, + atUid, + atTinyId: '', + atNtUid, + }, + }; + } + + if (!context.peer || !atQQ || context.peer.chatType === ChatType.KCHATTYPEC2C) return undefined; // 过滤掉空atQQ + if (atQQ === 'all') return at(atQQ, atQQ, NTMsgAtType.ATTYPEALL, '全体成员'); + const atMember = await this.core.apis.GroupApi.getGroupMember(context.peer.peerUid, atQQ); + if (atMember) { + return at(atQQ, atMember.uid, NTMsgAtType.ATTYPEONE, atMember.nick || atMember.cardName); + } + const uid = await this.core.apis.UserApi.getUidByUinV2(`${atQQ}`); + if (!uid) throw new Error('Get Uid Error'); + const info = await this.core.apis.UserApi.getUserDetailInfo(uid); + return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || ''); + }, + + [OB11MessageDataType.reply]: async ({ data: { id, seq } }, context) => { + let replyMsg: RawMessage | undefined; + let replyMsgPeer: Peer | undefined; + + // 优先使用 seq + if (seq) { + const msgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount( + context.peer, seq.toString(), 1, true, true + )).msgList; + replyMsg = msgList[0]; + replyMsgPeer = context.peer; + } else if (id) { + // 降级使用 id + const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id)); + if (!replyMsgM) { + this.core.context.logger.logWarn('回复消息不存在', id); + return undefined; + } + replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId( + replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0]; + replyMsgPeer = replyMsgM.Peer; + } else { + this.core.context.logger.logWarn('回复消息缺少id或seq参数'); + return undefined; + } + + return replyMsg && replyMsgPeer + ? { + elementType: ElementType.REPLY, + elementId: '', + replyElement: { + replayMsgSeq: replyMsg.msgSeq, // raw.msgSeq + replayMsgId: replyMsg.msgId, // raw.msgId + senderUin: replyMsg.senderUin, + senderUinStr: replyMsg.senderUin, + replyMsgClientSeq: replyMsg.clientSeq, + _replyMsgPeer: replyMsgPeer, + }, + } + : undefined; + }, + + [OB11MessageDataType.face]: async ({ data: { id, resultId, chainCount } }) => { + const parsedFaceId = +id; + // 从face_config.json中获取表情名称 + const sysFaces = faceConfig.sysface; + const face: { + QSid?: string, + QDes?: string, + AniStickerId?: string, + AniStickerType?: number, + AniStickerPackId?: string, + } | undefined = sysFaces.find((systemFace) => systemFace.QSid === parsedFaceId.toString()); + if (!face) { + this.core.context.logger.logError('不支持的ID', id); + return undefined; + } + let faceType = 1; + if (parsedFaceId >= 222) { + faceType = 2; + } + if (face.AniStickerType) { + faceType = 3; + } + return { + elementType: ElementType.FACE, + elementId: '', + faceElement: { + faceIndex: parsedFaceId, + faceType, + faceText: face.QDes, + stickerId: face.AniStickerId, + stickerType: face.AniStickerType, + packId: face.AniStickerPackId, + sourceType: 1, + resultId: resultId?.toString(), + chainCount, + }, + }; + }, + [OB11MessageDataType.mface]: async ({ + data: { + emoji_package_id, emoji_id, key, summary, + }, + }) => ({ + elementType: ElementType.MFACE, + elementId: '', + marketFaceElement: { + emojiPackageId: emoji_package_id, + emojiId: emoji_id, + key, + faceName: summary || '[商城表情]', + }, + }), + + // File service + [OB11MessageDataType.image]: async (sendMsg, context) => { + const result = await this.handleOb11FileLikeMessage(sendMsg, context); + return await this.obContext.apis.FileApi.createValidSendPicElement( + context, + result.path, + sendMsg.data.summary, + sendMsg.data.sub_type, + result.isLocal + ); + }, + + [OB11MessageDataType.file]: async (sendMsg, context) => { + const { path, fileName, isLocal } = await this.handleOb11FileLikeMessage(sendMsg, context); + return await this.obContext.apis.FileApi.createValidSendFileElement(context, path, fileName, '', false, isLocal); + }, + + [OB11MessageDataType.video]: async (sendMsg, context) => { + const { path, fileName, isLocal } = await this.handleOb11FileLikeMessage(sendMsg, context); + + let thumb = sendMsg.data.thumb; + if (thumb) { + const uri2LocalRes = await uriToLocalFile(this.core.SnowCatTempPath, thumb); + if (uri2LocalRes.success) { + thumb = uri2LocalRes.path; + if (!uri2LocalRes.isLocal) { + context.deleteAfterSentFiles.push(thumb); + } + } + } + + return await this.obContext.apis.FileApi.createValidSendVideoElement(context, path, fileName, thumb, isLocal); + }, + + [OB11MessageDataType.voice]: async (sendMsg, context) => + this.obContext.apis.FileApi.createValidSendPttElement(context, + (await this.handleOb11FileLikeMessage(sendMsg, context)).path), + + [OB11MessageDataType.json]: async ({ data: { data } }) => ({ + elementType: ElementType.ARK, + elementId: '', + arkElement: { + bytesData: typeof data === 'string' ? data : JSON.stringify(data), + linkInfo: null, + subElementType: null, + }, + }), + + [OB11MessageDataType.dice]: async () => ({ + elementType: ElementType.FACE, + elementId: '', + faceElement: { + faceIndex: FaceIndex.DICE, + faceType: FaceType.AniSticke, + faceText: '[骰子]', + packId: '1', + stickerId: '33', + sourceType: 1, + stickerType: 2, + surpriseId: '', + // "randomType": 1, + }, + }), + + [OB11MessageDataType.rps]: async () => ({ + elementType: ElementType.FACE, + elementId: '', + faceElement: { + faceIndex: FaceIndex.RPS, + faceText: '[包剪锤]', + faceType: FaceType.AniSticke, + packId: '1', + stickerId: '34', + sourceType: 1, + stickerType: 2, + surpriseId: '', + // "randomType": 1, + }, + }), + + // Need signing + [OB11MessageDataType.markdown]: async ({ data: { content } }) => ({ + elementType: ElementType.MARKDOWN, + elementId: '', + markdownElement: { content }, + }), + + [OB11MessageDataType.music]: async ({ data }, context) => { + // 保留, 直到...找到更好的解决方案 + const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu']; + const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom']; + + // 验证音乐类型 + if (data.id !== undefined) { + if (!supportedPlatforms.includes(data.type)) { + this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`); + return undefined; + } + } else { + if (!supportedPlatformsWithCustom.includes(data.type)) { + this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`); + return undefined; + } + if (!data.url) { + this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: url'); + return undefined; + } + if (!data.image) { + this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: image'); + return undefined; + } + } + + // 构建请求数据 + let postData: IdMusicSignPostData | CustomMusicSignPostData; + if (data.id === undefined && data.content) { + const { content, ...others } = data; + postData = { singer: content, ...others }; + } else { + postData = data; + } + + // 获取签名服务地址 + let signUrl = this.obContext.configLoader.configData.musicSignUrl; + if (!signUrl) { + signUrl = 'http://106.55.0.102:10087/'; // 首选,yibai音卡签名,已更换稳定签名服务器 + } + + // 请求签名服务 + try { + let musicJson: string; + try { + musicJson = await RequestUtil.HttpGetJson(signUrl, 'POST', postData); + } catch { + // 首选失败,降级到备选 + this.core.context.logger.logError('[音乐卡片签名] 首选地址失败,尝试备选地址...'); + musicJson = await RequestUtil.HttpGetJson( + 'https://ss.xingzhige.com/music_card/card', // 思思的音卡签名 + 'POST', + postData + ); + } + return this.ob11ToRawConverters.json({ + data: { data: musicJson }, + type: OB11MessageDataType.json, + }, context); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.core.context.logger.logError( + '[音乐卡片签名失败] 签名服务请求出错!\n' + + ` ├─ 音乐类型: ${data.type}\n` + + ` ├─ 音乐ID: ${data.id ?? '自定义'}\n` + + ` ├─ 错误信息: ${errorMessage}\n` + + ' └─ 提示: 请检查网络连接,或尝试在配置中更换其他音乐签名服务地址(musicSignUrl)' + ); + return undefined; + } + }, + + [OB11MessageDataType.node]: async () => undefined, + + [OB11MessageDataType.forward]: async ({ data }, context) => { + // let id = data.id.toString(); + // let peer: Peer | undefined = context.peer; + // if (isNumeric(id)) { + // let msgid = ''; + // if (BigInt(data.id) > 2147483647n) { + // peer = MessageUnique.getPeerByMsgId(id)?.Peer; + // msgid = id; + // } else { + // let data = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id)); + // msgid = data?.MsgId ?? ''; + // peer = data?.Peer; + // } + // } + const jsonData = ForwardMsgBuilder.fromResId(data.id); + return this.ob11ToRawConverters.json({ + data: { data: JSON.stringify(jsonData) }, + type: OB11MessageDataType.json, + }, context); + }, + + [OB11MessageDataType.xml]: async () => undefined, + + [OB11MessageDataType.poke]: async () => undefined, + + [OB11MessageDataType.location]: async () => ({ + elementType: ElementType.SHARELOCATION, + elementId: '', + shareLocationElement: { + text: '测试', + ext: '', + }, + }), + + [OB11MessageDataType.miniapp]: async () => undefined, + + [OB11MessageDataType.contact]: async ({ data: { type = 'qq', id } }, context) => { + if (type === 'qq') { + const arkJson = await this.core.apis.UserApi.getBuddyRecommendContactArkJson(id.toString(), ''); + return this.ob11ToRawConverters.json({ + data: { data: arkJson.arkMsg }, + type: OB11MessageDataType.json, + }, context); + } else if (type === 'group') { + const arkJson = await this.core.apis.GroupApi.getGroupRecommendContactArkJson(id.toString()); + return this.ob11ToRawConverters.json({ + data: { data: arkJson.arkJson }, + type: OB11MessageDataType.json, + }, context); + } + return undefined; + }, + // 不需要支持发送 + [OB11MessageDataType.onlinefile]: async () => undefined, + + [OB11MessageDataType.flashtransfer]: async () => undefined, + }; + + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + /** + * 解析带有JSON标记的文本 + * @param text 要解析的文本 + * @returns 解析后的结果数组,每个元素包含类型(text或json)和内容 + */ + parseTextWithJson (text: string) { + // 匹配<{...}>格式的JSON + const regex = /<(\{.*?\})>/g; + const parts: Array<{ type: 'text' | 'json', content: string | object; }> = []; + let lastIndex = 0; + let match; + + // 查找所有匹配项 + while ((match = regex.exec(text)) !== null) { + // 添加匹配前的文本 + if (match.index > lastIndex) { + parts.push({ + type: 'text', + content: text.substring(lastIndex, match.index), + }); + } + + // 添加JSON部分 + try { + const jsonContent = JSON.parse(match[1] ?? ''); + parts.push({ + type: 'json', + content: jsonContent, + }); + } catch (_e) { + // 如果JSON解析失败,作为普通文本处理 + parts.push({ + type: 'text', + content: match[0], + }); + } + + lastIndex = regex.lastIndex; + } + + // 添加最后一部分文本 + if (lastIndex < text.length) { + parts.push({ + type: 'text', + content: text.substring(lastIndex), + }); + } + + return parts; + } + + async parsePrivateMsgEvent (msg: RawMessage, grayTipElement: GrayTipElement) { + if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) { + if (grayTipElement.jsonGrayTipElement.busiId.toString() === '1061') { + const PokeEvent = await this.obContext.apis.FriendApi.parsePrivatePokeEvent(grayTipElement, Number(await this.core.apis.UserApi.getUinByUidV2(msg.peerUid))); + if (PokeEvent) { + return PokeEvent; + } + } else if (grayTipElement.jsonGrayTipElement.busiId.toString() === '19324' && msg.peerUid !== '') { + return new OB11FriendAddNoticeEvent(this.core, Number(await this.core.apis.UserApi.getUinByUidV2(msg.peerUid))); + } + } + return undefined; + } + + private async getMultiMessages (msg: RawMessage, parentMsgPeer: Peer) { + // 判断是否在合并消息内 + msg.parentMsgIdList = msg.parentMsgIdList ?? []; + // 首次列表不存在则开始创建 + msg.parentMsgIdList.push(msg.msgId); + // 拉取下级消息 + if (msg.parentMsgIdList[0]) { + return (await this.core.apis.MsgApi.getMultiMsg( + parentMsgPeer, + msg.parentMsgIdList[0], + msg.msgId + ))?.msgList; + } + return undefined; + } + + private async parseMultiMessageContent ( + multiMsgs: RawMessage[], + parentMsgPeer: Peer, + parentMsgIdList: string[] + ) { + const parsed = await Promise.all(multiMsgs.map(async msg => { + msg.parentMsgPeer = parentMsgPeer; + msg.parentMsgIdList = parentMsgIdList; + msg.id = MessageUnique.createUniqueMsgId(parentMsgPeer, msg.msgId); + // 该ID仅用查看 无法调用 + return await this.parseMessage(msg, 'array', true); + })); + return parsed.filter(item => item !== undefined); + } + + async parseMessage ( + msg: RawMessage, + messagePostFormat: string, + parseMultMsg: boolean = true, + disableGetUrl: boolean = false, + quick_reply: boolean = false + ) { + if (messagePostFormat === 'string') { + return (await this.parseMessageV2(msg, parseMultMsg, disableGetUrl, quick_reply))?.stringMsg; + } + return (await this.parseMessageV2(msg, parseMultMsg, disableGetUrl, quick_reply))?.arrayMsg; + } + + async parseMessageV2 ( + msg: RawMessage, + parseMultMsg: boolean = true, + disableGetUrl: boolean = false, + quick_reply: boolean = false + ) { + if ((msg.senderUin === '0' || msg.senderUin === '')) { + if (msg.senderUid && msg.senderUid !== '' && msg.senderUid !== '0') { + msg.senderUin = await this.core.apis.UserApi.getUinByUidV2(msg.senderUid); + } else { + return undefined; + } + } + if (msg.peerUin === '0' || msg.peerUin === '') { + if (msg.peerUid && msg.peerUid !== '' && msg.peerUid !== '0') { + msg.peerUin = await this.core.apis.UserApi.getUinByUidV2(msg.peerUid); + } else { + return undefined; + } + } + + const resMsg = this.initializeMessage(msg); + + if (this.core.selfInfo.uin === msg.senderUin) { + resMsg.message_sent_type = 'self'; + } + + if (msg.chatType === ChatType.KCHATTYPEGROUP) { + await this.handleGroupMessage(resMsg, msg); + } else if (msg.chatType === ChatType.KCHATTYPEC2C) { + await this.handlePrivateMessage(resMsg, msg); + } else if (msg.chatType === ChatType.KCHATTYPETEMPC2CFROMGROUP) { + await this.handleTempGroupMessage(resMsg, msg); + } else { + return undefined; + } + + const validSegments = await this.parseMessageSegments(msg, parseMultMsg, disableGetUrl, quick_reply); + resMsg.message = validSegments; + resMsg.raw_message = validSegments.map(msg => encodeCQCode(msg)).join('').trim(); + + const stringMsg = await this.convertArrayToStringMessage(resMsg); + return { stringMsg, arrayMsg: resMsg }; + } + + private initializeMessage (msg: RawMessage): OB11Message { + return { + self_id: parseInt(this.core.selfInfo.uin), + user_id: parseInt(msg.senderUin), + time: parseInt(msg.msgTime) || Date.now(), + message_id: msg.id!, + message_seq: msg.id!, + real_id: msg.id!, + real_seq: msg.msgSeq, + message_type: msg.chatType === ChatType.KCHATTYPEGROUP ? 'group' : 'private', + sender: { + user_id: +(msg.senderUin ?? 0), + nickname: msg.sendNickName, + card: msg.sendMemberName ?? '', + }, + raw_message: '', + font: 14, + sub_type: 'friend', + message: [], + message_format: 'array', + post_type: this.core.selfInfo.uin === msg.senderUin ? EventType.MESSAGE_SENT : EventType.MESSAGE, + }; + } + + private async handleGroupMessage (resMsg: OB11Message, msg: RawMessage) { + resMsg.sub_type = 'normal'; + resMsg.group_id = parseInt(msg.peerUin); + resMsg.group_name = msg.peerName; + let member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin); + if (!member) member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin); + if (member) { + resMsg.sender.role = OB11Construct.groupMemberRole(member.role); + resMsg.sender.nickname = member.nick; + } + } + + private async handlePrivateMessage (resMsg: OB11Message, msg: RawMessage) { + resMsg.sub_type = 'friend'; + if (await this.core.apis.FriendApi.isBuddy(msg.senderUid)) { + const nickname = (await this.core.apis.UserApi.getCoreAndBaseInfo([msg.senderUid])).get(msg.senderUid)?.coreInfo.nick; + if (nickname) { + resMsg.sender.nickname = nickname; + return; + } + } + resMsg.sender.nickname = (await this.core.apis.UserApi.getUserDetailInfo(msg.senderUid)).nick; + } + + private async handleTempGroupMessage (resMsg: OB11Message, msg: RawMessage) { + resMsg.sub_type = 'group'; + const ret = await this.core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, msg.senderUid); + if (ret.result === 0) { + // 避免uin:'' uid非空,uid一般不空 + const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, await this.core.apis.UserApi.getUinByUidV2(msg.senderUid)); + resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode); + resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话'; + resMsg.temp_source = 0; + } else { + resMsg.group_id = 284840486; + resMsg.temp_source = 0; + resMsg.sender.nickname = '临时会话'; + } + } + + private async parseMessageSegments (msg: RawMessage, parseMultMsg: boolean, disableGetUrl: boolean = false, quick_reply: boolean = false): Promise { + const msgSegments = await Promise.allSettled(msg.elements.map( + async (element): Promise => { + for (const key in element) { + if (keyCanBeParsed(key, this.rawToOb11Converters) && element[key]) { + const converters = this.rawToOb11Converters[key] as ( + element: Exclude, + msg: RawMessage, + elementWrapper: MessageElement, + context: RecvMessageContext + ) => Promise; + const parsedElement = await converters?.( + element[key], + msg, + element, + { parseMultMsg, disableGetUrl, quick_reply } + ); + if (key === 'faceElement' && !parsedElement) { + return null; + } + return parsedElement; + } + } + return null; + } + )); + + return msgSegments.filter(entry => { + if (entry.status === 'fulfilled') { + return !!entry.value; + } else { + this.core.context.logger.logError('消息段解析失败', entry.reason); + return false; + } + }).map((entry) => (>entry).value).filter(value => value != null); + } + + private async convertArrayToStringMessage (originMsg: OB11Message): Promise { + const msg = structuredClone(originMsg); + msg.message_format = 'string'; + msg.message = msg.raw_message; + return msg; + } + + async importArrayTostringMsg (originMsg: OB11Message) { + const msg = structuredClone(originMsg); + msg.message_format = 'string'; + msg.message = msg.raw_message; + return msg; + } + + async createSendElements ( + messageData: OB11MessageData[], + peer: Peer, + ignoreTypes: OB11MessageDataType[] = [], + options: { + usePacketVideoMetadata?: boolean; + } = {} + ) { + const deleteAfterSentFiles: string[] = []; + const callResultList: Array> = []; + for (const sendMsg of messageData) { + if (ignoreTypes.includes(sendMsg.type)) { + continue; + } + const converter = this.ob11ToRawConverters[sendMsg.type] as (( + sendMsg: Extract, + context: SendMessageContext, + ) => Promise) | undefined; + if (converter === undefined) { + throw new Error('未知的消息类型:' + sendMsg.type); + } + const callResult = converter( + sendMsg, + { + peer, + deleteAfterSentFiles, + usePacketVideoMetadata: options.usePacketVideoMetadata, + } + )?.catch(undefined); + callResultList.push(callResult); + } + const ret = await Promise.all(callResultList); + const sendElements: SendMessageElement[] = ret.filter(ele => !!ele); + return { sendElements, deleteAfterSentFiles }; + } + + async sendMsgWithOb11UniqueId (peer: Peer, sendElements: SendMessageElement[], deleteAfterSentFiles: string[], timeoutOverride?: number) { + if (!sendElements.length) { + throw new Error('消息体无法解析, 请检查是否发送了不支持的消息类型'); + } + + const timeoutConfig = this.obContext.configLoader.configData.timeout; + + let timeout: number; + if (timeoutOverride !== undefined && timeoutOverride > 0) { + timeout = capTimeout(timeoutConfig, timeoutOverride); + } else { + const totalSize = sendElements.reduce((total, element) => { + switch (element.elementType) { + case ElementType.PTT: + return total + (+(element.pttElement.fileSize || '3000')); + case ElementType.FILE: + return total + (+(element.fileElement.fileSize || '3000')); + case ElementType.VIDEO: + return total + (+(element.videoElement.fileSize || '3000')); + case ElementType.PIC: + return total + (+(element.picElement.fileSize || '3000')); + default: + return total; + } + }, 0); + + timeout = calculateTimeout(timeoutConfig, totalSize, timeoutConfig.uploadSpeedKBps); + } + try { + const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout); + if (!returnMsg) throw new Error('发送消息失败'); + returnMsg.id = MessageUnique.createUniqueMsgId({ + chatType: peer.chatType, + guildId: '', + peerUid: peer.peerUid, + }, returnMsg.msgId); + return returnMsg; + } finally { + cleanTaskQueue.addFiles(deleteAfterSentFiles, timeout); + // setTimeout(async () => { + // const deletePromises = deleteAfterSentFiles.map(async file => { + // try { + // if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) { + // await fsPromise.unlink(file); + // } + // } catch (e) { + // this.core.context.logger.logError('发送消息删除文件失败', e); + // } + // }); + // await Promise.all(deletePromises); + // }, 60000); + } + } + + private async handleOb11FileLikeMessage ( + { data: inputdata }: OB11MessageFileBase, + { deleteAfterSentFiles }: SendMessageContext + ) { + let realUri = [inputdata.url, inputdata.file, inputdata.path].find(uri => uri && uri.trim()) ?? ''; + if (!realUri) { + this.core.context.logger.logError('文件消息缺少参数', inputdata); + throw new Error('文件消息缺少参数'); + } + realUri = await this.handleObfuckName(realUri) ?? realUri; + try { + const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined; + const { path, fileName, errMsg, success, isLocal } = await uriToLocalFile(this.core.SnowCatTempPath, realUri, undefined, undefined, proxy); + if (!success) { + this.core.context.logger.logError('文件处理失败', errMsg); + throw new Error('文件处理失败: ' + errMsg); + } + if (!isLocal) { + deleteAfterSentFiles.push(path); + } + return { path, fileName: inputdata.name ?? fileName, isLocal }; + } catch (e: unknown) { + throw new Error((e as Error).message); + } + } + + async handleObfuckName (name: string) { + const contextMsgFile = FileNapCatOneBotUUID.decode(name); + if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) { + const { peer, msgId, elementId } = contextMsgFile; + const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList.find(msg => msg.msgId === msgId); + const mixElement = rawMessage?.elements.find(e => e.elementId === elementId); + const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement; + if (!mixElementInner) throw new Error('element not found'); + let url = ''; + if (mixElement?.picElement && rawMessage) { + const tempData = + await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: false }) as OB11MessageImage | undefined; + url = tempData?.data.url ?? ''; + } + if (mixElement?.videoElement && rawMessage) { + const tempData = + await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: false }) as OB11MessageVideo | undefined; + url = tempData?.data.url ?? ''; + } + return url !== '' ? url : await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', ''); + } + return undefined; + } + + groupChangDecreseType2String (type: number): GroupDecreaseSubType { + switch (type) { + case 130: + return 'leave'; + case 131: + return 'kick'; + case 3: + return 'kick_me'; + case 129: + return 'disband'; + default: + return 'kick'; + } + } + + async waitGroupNotify (groupUin: string, memberUid?: string, operatorUid?: string) { + const groupRole = this.core.apis.GroupApi.groupMemberCache.get(groupUin)?.get(this.core.selfInfo.uid.toString())?.role; + const isAdminOrOwner = groupRole === 3 || groupRole === 4; + if (isAdminOrOwner && !operatorUid) { + let dataNotify: GroupNotify | undefined; + await this.core.eventWrapper.registerListen('NodeIKernelGroupListener/onGroupNotifiesUpdated', + (_doubt, notifies) => { + for (const notify of notifies) { + if (notify.group.groupCode === groupUin && notify.user1.uid === memberUid) { + dataNotify = notify; + return true; + } + } + return false; + }, 1, 1000).catch(() => undefined); + if (dataNotify) { + return !dataNotify.actionUser.uid ? dataNotify.user2.uid : dataNotify.actionUser.uid; + } + } + + return operatorUid; + } + + async parseSysMessage (msg: number[]) { + const SysMessage = new NapProtoMsg(PushMsgBody).decode(Uint8Array.from(msg)); + // 邀请需要解grayTipElement + // console.log(SysMessage.body?.msgContent); + if (SysMessage.contentHead.type === 33 && SysMessage.body?.msgContent) { + const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent); + await this.core.apis.GroupApi.refreshGroupMemberCache(groupChange.groupUin.toString(), true); + const operatorUid = await this.waitGroupNotify( + groupChange.groupUin.toString(), + groupChange.memberUid, + groupChange.operatorInfo ? new TextDecoder('utf-8').decode(groupChange.operatorInfo) : undefined + ); + return new OB11GroupIncreaseEvent( + this.core, + groupChange.groupUin, + groupChange.memberUid ? +await this.core.apis.UserApi.getUinByUidV2(groupChange.memberUid) : 0, + operatorUid ? +await this.core.apis.UserApi.getUinByUidV2(operatorUid) : 0, + groupChange.decreaseType === 131 ? 'invite' : 'approve' + ); + } else if (SysMessage.contentHead.type === 34 && SysMessage.body?.msgContent) { + const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent); + + let operator_uid_parse: string | undefined; + if (groupChange.operatorInfo) { + // 先判断是否可能是protobuf(自身被踢出或以0a开头) + if (groupChange.decreaseType === 3 || Buffer.from(groupChange.operatorInfo).toString('hex').startsWith('0a')) { + // 可能是protobuf,尝试解析 + try { + operator_uid_parse = new NapProtoMsg(GroupChangeInfo).decode(groupChange.operatorInfo).operator?.operatorUid; + } catch (_error) { + // protobuf解析失败,fallback到字符串解析 + try { + const decoded = new TextDecoder('utf-8').decode(groupChange.operatorInfo); + // 检查是否包含非ASCII字符,如果包含则丢弃 + const isAsciiOnly = [...decoded].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126); + operator_uid_parse = isAsciiOnly ? decoded : ''; + } catch (_e2) { + operator_uid_parse = ''; + } + } + } else { + // 直接进行字符串解析 + try { + const decoded = new TextDecoder('utf-8').decode(groupChange.operatorInfo); + // 检查是否包含非ASCII字符,如果包含则丢弃 + const isAsciiOnly = [...decoded].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126); + operator_uid_parse = isAsciiOnly ? decoded : ''; + } catch (_e) { + operator_uid_parse = ''; + } + } + } + + const operatorUid = await this.waitGroupNotify( + groupChange.groupUin.toString(), + groupChange.memberUid, + operator_uid_parse + ); + if (groupChange.memberUid === this.core.selfInfo.uid) { + setTimeout(() => { + this.core.apis.GroupApi.groupMemberCache.delete(groupChange.groupUin.toString()); + }, 5000); + // 自己被踢了 5S后回收 + } else { + await this.core.apis.GroupApi.refreshGroupMemberCache(groupChange.groupUin.toString(), true); + } + return new OB11GroupDecreaseEvent( + this.core, + groupChange.groupUin, + groupChange.memberUid ? +await this.core.apis.UserApi.getUinByUidV2(groupChange.memberUid) : 0, + operatorUid ? +await this.core.apis.UserApi.getUinByUidV2(operatorUid) : 0, + this.groupChangDecreseType2String(groupChange.decreaseType) + ); + } else if (SysMessage.contentHead.type === 44 && SysMessage.body?.msgContent) { + const groupAmin = new NapProtoMsg(GroupAdmin).decode(SysMessage.body.msgContent); + await this.core.apis.GroupApi.refreshGroupMemberCache(groupAmin.groupUin.toString(), true); + let enabled = false; + let uid = ''; + if (groupAmin.body.extraEnable != null) { + uid = groupAmin.body.extraEnable.adminUid; + enabled = true; + } else if (groupAmin.body.extraDisable != null) { + uid = groupAmin.body.extraDisable.adminUid; + enabled = false; + } + return new OB11GroupAdminNoticeEvent( + this.core, + groupAmin.groupUin, + +await this.core.apis.UserApi.getUinByUidV2(uid), + enabled ? 'set' : 'unset' + ); + } else if (SysMessage.contentHead.type === 87 && SysMessage.body?.msgContent) { + const groupInvite = new NapProtoMsg(GroupInvite).decode(SysMessage.body.msgContent); + let request_seq = ''; + try { + await this.core.eventWrapper.registerListen('NodeIKernelMsgListener/onRecvMsg', (msgs) => { + for (const msg of msgs) { + if (msg.senderUid === groupInvite.invitorUid && msg.msgType === 11) { + const jumpUrl = JSON.parse(msg.elements.find(e => e.elementType === 10)?.arkElement?.bytesData ?? '').meta?.news?.jumpUrl; + const jumpUrlParams = new URLSearchParams(jumpUrl); + const groupcode = jumpUrlParams.get('groupcode'); + const receiveruin = jumpUrlParams.get('receiveruin'); + const msgseq = jumpUrlParams.get('msgseq'); + request_seq = msgseq ?? ''; + if (groupcode === groupInvite.groupUin.toString() && receiveruin === this.core.selfInfo.uin) { + return true; + } + } + } + return false; + }, 1, 1000); + } catch { + request_seq = ''; + } + // 未拉取到seq + if (request_seq === '') { + return; + } + // 创建个假的 + this.notifyGroupInvite.put(request_seq, { + seq: request_seq, + type: 1, + group: { + groupCode: groupInvite.groupUin.toString(), + groupName: '', + }, + user1: { + uid: groupInvite.invitorUid, + nickName: '', + }, + user2: { + uid: this.core.selfInfo.uid, + nickName: '', + }, + actionUser: { + uid: groupInvite.invitorUid, + nickName: '', + }, + actionTime: Date.now().toString(), + postscript: '', + repeatSeqs: [], + warningTips: '', + invitationExt: { + srcType: 1, + groupCode: groupInvite.groupUin.toString(), + waitStatus: 1, + }, + status: 1, + }); + return new OB11GroupRequestEvent( + this.core, + +groupInvite.groupUin, + +await this.core.apis.UserApi.getUinByUidV2(groupInvite.invitorUid), + 'invite', + '', + request_seq + ); + } else if (SysMessage.contentHead.type === 528 && SysMessage.contentHead.subType === 39 && SysMessage.body?.msgContent) { + return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent); + } else if (SysMessage.contentHead.type === 166 && SysMessage.contentHead.c2CCmd === 133 && SysMessage.body?.msgContent) { + this.core.context.logger.logDebug('在线文件通道断开'); + // 可能原因: 对方取消 对方拒绝 对方转离线 + // body不是proto,只能手动提取,可能是错的!! + // console.log(SysMessage.body?.msgContent); + const mainCmd = SysMessage.body.msgContent[15]; + const subCmd = SysMessage.body.msgContent[17]; + if (mainCmd === 101) { + // 在线文件 + if (subCmd === 225) { + // 对方取消或转离线 + this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}取消了在线文件的传输(或转离线)`); + return new OB11OnlineFileReceiveEvent( + this.core, + +SysMessage.responseHead.fromUin + ); + } else if (subCmd === 230) { + // 对方拒绝接收 + this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}拒绝了你的在线文件传输`); + return new OB11OnlineFileSendEvent( + this.core, + +SysMessage.responseHead.fromUin, + 'refuse' + ); + } + } else if (mainCmd === 136) { + if (subCmd === 225) { + // 对方取消或转离线 + this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}取消了在线文件夹的传输(或转离线)`); + return new OB11OnlineFileReceiveEvent( + this.core, + +SysMessage.responseHead.fromUin + ); + } else if (subCmd === 230) { + // 对方拒绝接收 + this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}拒绝了你的在线文件夹传输`); + return new OB11OnlineFileSendEvent( + this.core, + +SysMessage.responseHead.fromUin, + 'refuse' + ); + } + } + this.core.context.logger.logDebug('未知的系统消息事件:', mainCmd, subCmd); + return undefined; + } else if (SysMessage.contentHead.type === 166 && SysMessage.contentHead.c2CCmd === 131 && SysMessage.body?.msgContent) { + const mainCmd = SysMessage.body.msgContent[15]; + if (mainCmd === 101) { + this.core.context.logger.log('在线文件传输成功!'); + } else if (mainCmd === 136) { + this.core.context.logger.log('在线文件夹传输成功!'); + } + return new OB11OnlineFileSendEvent( + this.core, + +SysMessage.responseHead.fromUin, + 'receive' + ); + } + // else if (SysMessage.contentHead.type == 732 && SysMessage.contentHead.subType == 16 && SysMessage.body?.msgContent) { + // let data_wrap = PBString(2); + // let user_wrap = PBUint64(5); + // let group_wrap = PBUint64(4); + + // ProtoBuf(class extends ProtoBufBase { + // group = group_wrap; + // content = ProtoBufIn(5, { data: data_wrap, user: user_wrap }); + // }).decode(SysMessage.body?.msgContent.slice(7)); + // let xml_data = UnWrap(data_wrap); + // let group = UnWrap(group_wrap).toString(); + // //let user = UnWrap(user_wrap).toString(); + // const parsedParts = this.parseTextWithJson(xml_data); + // //解析JSON + // if (parsedParts[1] && parsedParts[3]) { + // let set_user_id: string = (parsedParts[1].content as { data: string }).data; + // let uid = await this.core.apis.UserApi.getUidByUinV2(set_user_id); + // let new_title: string = (parsedParts[3].content as { text: string }).text; + // console.log(this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle, new_title) + // if (this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle == new_title) { + // return; + // } + // await this.core.apis.GroupApi.refreshGroupMemberCachePartial(group, uid); + // //let json_data_1_url_search = new URL((parsedParts[3].content as { url: string }).url).searchParams; + // //let is_new: boolean = json_data_1_url_search.get('isnew') === '1'; + + // //console.log(group, set_user_id, is_new, new_title); + // return new GroupMemberTitle( + // this.core, + // +group, + // +set_user_id, + // new_title + // ); + // } + // } + return undefined; + } +} diff --git a/packages/snowcat-onebot/api/quick-action.ts b/packages/snowcat-onebot/api/quick-action.ts new file mode 100644 index 0000000..146dcd2 --- /dev/null +++ b/packages/snowcat-onebot/api/quick-action.ts @@ -0,0 +1,113 @@ +import type { + NapCatOneBot11Adapter, + OB11Message, + OB11MessageAt, + OB11MessageData, + OB11MessageReply, + QuickAction, + QuickActionEvent, + QuickActionFriendRequest, + QuickActionGroupMessage, + QuickActionGroupRequest, +} from '@/snowcat-onebot/index'; +import { NTGroupRequestOperateTypes, type SnowCatCore, type Peer } from 'snowcat-core'; +import type { OB11FriendRequestEvent } from '@/snowcat-onebot/event/request/OB11FriendRequest'; +import type { OB11GroupRequestEvent } from '@/snowcat-onebot/event/request/OB11GroupRequest'; + +import { ContextMode, createContext, normalize } from '@/snowcat-onebot/action/msg/SendMsg'; +import { isNull } from 'snowcat-common/src/helper'; + +export class OneBotQuickActionApi { + obContext: NapCatOneBot11Adapter; + core: SnowCatCore; + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + async handleQuickOperation (eventContext: QuickActionEvent, quickAction: QuickAction) { + if (eventContext.post_type === 'message') { + await this.handleMsg(eventContext as OB11Message, quickAction) + .catch(e => this.core.context.logger.logError(e)); + } + if (eventContext.post_type === 'request') { + const friendRequest = eventContext as unknown as OB11FriendRequestEvent; + const groupRequest = eventContext as unknown as OB11GroupRequestEvent; + if ((friendRequest).request_type === 'friend') { + await this.handleFriendRequest(friendRequest, quickAction) + .catch(e => this.core.context.logger.logError(e)); + } else if (groupRequest.request_type === 'group') { + await this.handleGroupRequest(groupRequest, quickAction) + .catch(e => this.core.context.logger.logError(e)); + } + } + } + + async handleMsg (msg: OB11Message, quickAction: QuickAction) { + const reply = quickAction.reply; + const peerContextMode = msg.message_type === 'private' ? ContextMode.Private : ContextMode.Group; + const peer: Peer = await createContext(this.core, { + group_id: msg.group_id?.toString(), + user_id: msg.user_id?.toString(), + }, peerContextMode); + + if (reply) { + // let group: Group | undefined; + let replyMessage: OB11MessageData[] = []; + + if (msg.message_type === 'group') { + // group = await core.apis.GroupApi.getGroup(msg.group_id!.toString()); + replyMessage.push({ + type: 'reply', + data: { + id: msg.message_id.toString(), + }, + } as OB11MessageReply); + if ((quickAction as QuickActionGroupMessage).at_sender) { + replyMessage.push({ + type: 'at', + data: { + qq: msg.user_id.toString(), + }, + } as OB11MessageAt); + } + } + replyMessage = replyMessage.concat(normalize(reply, quickAction.auto_escape)); + const { + sendElements, + deleteAfterSentFiles, + } = await this.obContext.apis.MsgApi.createSendElements(replyMessage, peer); + this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, sendElements, deleteAfterSentFiles).then().catch(e => this.core.context.logger.logError(e)); + } + } + + async findNotify (flag: string) { + let notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(false, 100)).find(e => e.seq === flag); + if (!notify) { + notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100)).find(e => e.seq === flag); + return { doubt: true, notify }; + } + return { doubt: false, notify }; + } + + async handleGroupRequest (request: OB11GroupRequestEvent, quickAction: QuickActionGroupRequest) { + const invite_notify = this.obContext.apis.MsgApi.notifyGroupInvite.get(request.flag); + const { doubt, notify } = invite_notify ? { doubt: false, notify: invite_notify } : await this.findNotify(request.flag); + + if (!isNull(quickAction.approve) && notify) { + this.core.apis.GroupApi.handleGroupRequest( + doubt, + notify, + quickAction.approve ? NTGroupRequestOperateTypes.KAGREE : NTGroupRequestOperateTypes.KREFUSE, + quickAction.reason + ).catch(e => this.core.context.logger.logError(e)); + } + } + + async handleFriendRequest (request: OB11FriendRequestEvent, quickAction: QuickActionFriendRequest) { + const notify = (await this.core.apis.FriendApi.getBuddyReq()).buddyReqs.find(e => e.reqTime === request.flag.toString()); + if (!isNull(quickAction.approve) && notify) { + this.core.apis.FriendApi.handleFriendRequest(notify, !!quickAction.approve).then().catch(e => this.core.context.logger.logError(e)); + } + } +} diff --git a/packages/snowcat-onebot/api/user.ts b/packages/snowcat-onebot/api/user.ts new file mode 100644 index 0000000..75e41bf --- /dev/null +++ b/packages/snowcat-onebot/api/user.ts @@ -0,0 +1,32 @@ +import { SnowCatCore } from 'snowcat-core'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11ProfileLikeEvent } from '@/snowcat-onebot/event/notice/OB11ProfileLikeEvent'; +import { decodeProfileLikeTip } from 'snowcat-core/helper/adaptDecoder'; + +export class OneBotUserApi { + obContext: NapCatOneBot11Adapter; + core: SnowCatCore; + + constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) { + this.obContext = obContext; + this.core = core; + } + + async parseLikeEvent (wrappedBody: Uint8Array): Promise { + const likeTip = decodeProfileLikeTip(wrappedBody); + if (likeTip?.msgType !== 0 || likeTip?.subType !== 203) return; + this.core.context.logger.logDebug('收到点赞通知消息'); + const likeMsg = likeTip.content.msg; + if (!likeMsg) return; + const detail = likeMsg.detail; + if (!detail) return; + const times = detail.txt.match(/\d+/) ?? '0'; + return new OB11ProfileLikeEvent( + this.core, + Number(detail.uin), + detail.nickname, + parseInt(times[0], 10), + likeMsg.time + ); + } +} diff --git a/packages/snowcat-onebot/config/config.ts b/packages/snowcat-onebot/config/config.ts new file mode 100644 index 0000000..1e7fe06 --- /dev/null +++ b/packages/snowcat-onebot/config/config.ts @@ -0,0 +1,150 @@ +import { Type, Static } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; +export type MessageFormat = 'array' | 'string'; + +const HttpServerConfigSchema = Type.Object({ + name: Type.String({ default: 'http-server' }), + enable: Type.Boolean({ default: false }), + port: Type.Number({ default: 3000 }), + host: Type.String({ default: '127.0.0.1' }), + enableCors: Type.Boolean({ default: true }), + enableWebsocket: Type.Boolean({ default: false }), + messagePostFormat: Type.String({ default: 'array' }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), +}); + +const HttpSseServerConfigSchema = Type.Object({ + name: Type.String({ default: 'http-sse-server' }), + enable: Type.Boolean({ default: false }), + port: Type.Number({ default: 3000 }), + host: Type.String({ default: '127.0.0.1' }), + enableCors: Type.Boolean({ default: true }), + enableWebsocket: Type.Boolean({ default: false }), + messagePostFormat: Type.String({ default: 'array' }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), + reportSelfMessage: Type.Boolean({ default: false }), +}); + +const HttpClientConfigSchema = Type.Object({ + name: Type.String({ default: 'http-client' }), + enable: Type.Boolean({ default: false }), + url: Type.String({ default: 'http://localhost:8080' }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), +}); + +const WebsocketServerConfigSchema = Type.Object({ + name: Type.String({ default: 'websocket-server' }), + enable: Type.Boolean({ default: false }), + host: Type.String({ default: '127.0.0.1' }), + port: Type.Number({ default: 3001 }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + token: Type.String({ default: '' }), + enableForcePushEvent: Type.Boolean({ default: true }), + debug: Type.Boolean({ default: false }), + heartInterval: Type.Number({ default: 30000 }), +}); + +const WebsocketClientConfigSchema = Type.Object({ + name: Type.String({ default: 'websocket-client' }), + enable: Type.Boolean({ default: false }), + url: Type.String({ default: 'ws://localhost:8082' }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + reconnectInterval: Type.Number({ default: 5000 }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), + heartInterval: Type.Number({ default: 30000 }), + verifyCertificate: Type.Optional(Type.Boolean({ default: true })), +}); + +const PluginConfigSchema = Type.Object({ + name: Type.String({ default: 'plugin' }), + enable: Type.Boolean({ default: false }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + debug: Type.Boolean({ default: false }), +}); + +const McpServerConfigSchema = Type.Object({ + name: Type.String({ default: 'mcp-server' }), + enable: Type.Boolean({ default: false }), + host: Type.String({ default: '127.0.0.1' }), + port: Type.Number({ default: 3002 }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), +}); + +const NetworkConfigSchema = Type.Object({ + httpServers: Type.Array(HttpServerConfigSchema, { default: [] }), + httpSseServers: Type.Array(HttpSseServerConfigSchema, { default: [] }), + httpClients: Type.Array(HttpClientConfigSchema, { default: [] }), + websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }), + websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }), + mcpServers: Type.Optional(Type.Array(McpServerConfigSchema, { default: [] })), + plugins: Type.Array(PluginConfigSchema, { default: [] }), +}, { default: {} }); + +const TimeoutConfigSchema = Type.Object({ + baseTimeout: Type.Number({ default: 10000 }), + uploadSpeedKBps: Type.Number({ default: 256 }), + downloadSpeedKBps: Type.Number({ default: 256 }), + maxTimeout: Type.Number({ default: 1800000 }), +}, { default: {} }); + +export const OneBotConfigSchema = Type.Object({ + network: NetworkConfigSchema, + musicSignUrl: Type.String({ default: '' }), + enableLocalFile2Url: Type.Boolean({ default: false }), + parseMultMsg: Type.Boolean({ default: false }), + imageDownloadProxy: Type.String({ default: '' }), + timeout: TimeoutConfigSchema, +}); + +export type OneBotConfig = Static; +export type HttpServerConfig = Static; +export type HttpSseServerConfig = Static; +export type HttpClientConfig = Static; +export type WebsocketServerConfig = Static; +export type WebsocketClientConfig = Static; +export type PluginConfig = Static; +export type McpServerConfig = Static; + +export type NetworkAdapterConfig = HttpServerConfig | HttpSseServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | McpServerConfig | PluginConfig; +export type NetworkConfigKey = keyof OneBotConfig['network']; + +export function loadConfig (config: Partial): OneBotConfig { + let data = config; + data = Value.Parse(OneBotConfigSchema, data) as Partial; + const validate = TypeCompiler.Compile(OneBotConfigSchema); + const valid = validate.Check(data); + if (!valid) { + const errorMsg = [...validate.Errors(data)].map(e => e.message).join(', '); + throw new Error(errorMsg); + } + return data as OneBotConfig; +} + +export function calculateTimeout ( + config: OneBotConfig['timeout'], + dataSizeBytes: number, + speedKBps: number +): number { + return Math.min( + config.baseTimeout + (dataSizeBytes / 1024 / Math.max(speedKBps, 1) * 1000), + config.maxTimeout + ); +} + +export function capTimeout ( + config: OneBotConfig['timeout'], + timeoutMs: number +): number { + return Math.min(timeoutMs, config.maxTimeout); +} diff --git a/packages/snowcat-onebot/config/index.ts b/packages/snowcat-onebot/config/index.ts new file mode 100644 index 0000000..3276479 --- /dev/null +++ b/packages/snowcat-onebot/config/index.ts @@ -0,0 +1,12 @@ +import { ConfigBase } from 'snowcat-core/helper/config-base'; +import type { SnowCatCore } from 'snowcat-core'; +import { OneBotConfig } from './config'; +import { TSchema } from '@sinclair/typebox'; + +export * from './config'; + +export class OB11ConfigLoader extends ConfigBase { + constructor (core: SnowCatCore, configPath: string, schema: TSchema) { + super('onebot11', core, configPath, schema); + } +} diff --git a/packages/snowcat-onebot/event/OneBotEvent.ts b/packages/snowcat-onebot/event/OneBotEvent.ts new file mode 100644 index 0000000..b9436bd --- /dev/null +++ b/packages/snowcat-onebot/event/OneBotEvent.ts @@ -0,0 +1,19 @@ +import { SnowCatCore } from 'snowcat-core'; + +export enum EventType { + META = 'meta_event', + REQUEST = 'request', + NOTICE = 'notice', + MESSAGE = 'message', + MESSAGE_SENT = 'message_sent', +} + +export abstract class OneBotEvent { + time = Math.floor(Date.now() / 1000); + self_id: number; + abstract post_type: EventType; + + constructor (core: SnowCatCore) { + this.self_id = parseInt(core.selfInfo.uin); + } +} diff --git a/packages/snowcat-onebot/event/index.ts b/packages/snowcat-onebot/event/index.ts new file mode 100644 index 0000000..cefa45d --- /dev/null +++ b/packages/snowcat-onebot/event/index.ts @@ -0,0 +1,5 @@ +export * from './message'; +export * from './meta'; +export * from './notice'; +export * from './request'; +export * from './OneBotEvent'; diff --git a/packages/snowcat-onebot/event/message/OB11BaseMessageEvent.ts b/packages/snowcat-onebot/event/message/OB11BaseMessageEvent.ts new file mode 100644 index 0000000..fedf077 --- /dev/null +++ b/packages/snowcat-onebot/event/message/OB11BaseMessageEvent.ts @@ -0,0 +1,5 @@ +import { EventType, OneBotEvent } from '@/snowcat-onebot/event/OneBotEvent'; + +export abstract class OB11BaseMessageEvent extends OneBotEvent { + post_type = EventType.MESSAGE; +} diff --git a/packages/snowcat-onebot/event/message/index.ts b/packages/snowcat-onebot/event/message/index.ts new file mode 100644 index 0000000..e60fecc --- /dev/null +++ b/packages/snowcat-onebot/event/message/index.ts @@ -0,0 +1 @@ +export * from './OB11BaseMessageEvent'; diff --git a/packages/snowcat-onebot/event/meta/OB11BaseMetaEvent.ts b/packages/snowcat-onebot/event/meta/OB11BaseMetaEvent.ts new file mode 100644 index 0000000..0e2fd7f --- /dev/null +++ b/packages/snowcat-onebot/event/meta/OB11BaseMetaEvent.ts @@ -0,0 +1,6 @@ +import { EventType, OneBotEvent } from '@/snowcat-onebot/event/OneBotEvent'; + +export abstract class OB11BaseMetaEvent extends OneBotEvent { + post_type = EventType.META; + abstract meta_event_type: string; +} diff --git a/packages/snowcat-onebot/event/meta/OB11HeartbeatEvent.ts b/packages/snowcat-onebot/event/meta/OB11HeartbeatEvent.ts new file mode 100644 index 0000000..05188e7 --- /dev/null +++ b/packages/snowcat-onebot/event/meta/OB11HeartbeatEvent.ts @@ -0,0 +1,22 @@ +import { OB11BaseMetaEvent } from './OB11BaseMetaEvent'; +import { SnowCatCore } from 'snowcat-core'; + +interface HeartbeatStatus { + online: boolean | undefined, + good: boolean +} + +export class OB11HeartbeatEvent extends OB11BaseMetaEvent { + meta_event_type = 'heartbeat'; + status: HeartbeatStatus; + interval: number; + + public constructor (core: SnowCatCore, interval: number, isOnline: boolean, isGood: boolean) { + super(core); + this.interval = interval; + this.status = { + online: isOnline, + good: isGood, + }; + } +} diff --git a/packages/snowcat-onebot/event/meta/OB11LifeCycleEvent.ts b/packages/snowcat-onebot/event/meta/OB11LifeCycleEvent.ts new file mode 100644 index 0000000..281ac5e --- /dev/null +++ b/packages/snowcat-onebot/event/meta/OB11LifeCycleEvent.ts @@ -0,0 +1,18 @@ +import { OB11BaseMetaEvent } from './OB11BaseMetaEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export enum LifeCycleSubType { + ENABLE = 'enable', + DISABLE = 'disable', + CONNECT = 'connect', +} + +export class OB11LifeCycleEvent extends OB11BaseMetaEvent { + meta_event_type = 'lifecycle'; + sub_type: LifeCycleSubType; + + public constructor (core: SnowCatCore, subType: LifeCycleSubType) { + super(core); + this.sub_type = subType; + } +} diff --git a/packages/snowcat-onebot/event/meta/index.ts b/packages/snowcat-onebot/event/meta/index.ts new file mode 100644 index 0000000..b44b8ed --- /dev/null +++ b/packages/snowcat-onebot/event/meta/index.ts @@ -0,0 +1,3 @@ +export * from './OB11BaseMetaEvent'; +export * from './OB11HeartbeatEvent'; +export * from './OB11LifeCycleEvent'; diff --git a/packages/snowcat-onebot/event/notice/BotOfflineEvent.ts b/packages/snowcat-onebot/event/notice/BotOfflineEvent.ts new file mode 100644 index 0000000..b6075c4 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/BotOfflineEvent.ts @@ -0,0 +1,16 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class BotOfflineEvent extends OB11BaseNoticeEvent { + notice_type = 'bot_offline'; + user_id: number; + tag: string = 'BotOfflineEvent'; + message: string = 'BotOfflineEvent'; + + public constructor (core: SnowCatCore, tag: string, message: string) { + super(core); + this.user_id = +core.selfInfo.uin; + this.tag = tag; + this.message = message; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11BaseNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11BaseNoticeEvent.ts new file mode 100644 index 0000000..4a0243a --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11BaseNoticeEvent.ts @@ -0,0 +1,6 @@ +import { EventType, OneBotEvent } from '@/snowcat-onebot/event/OneBotEvent'; + +export abstract class OB11BaseNoticeEvent extends OneBotEvent { + post_type = EventType.NOTICE; + abstract notice_type: string; +} diff --git a/packages/snowcat-onebot/event/notice/OB11FriendAddNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11FriendAddNoticeEvent.ts new file mode 100644 index 0000000..e717ef9 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11FriendAddNoticeEvent.ts @@ -0,0 +1,12 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11FriendAddNoticeEvent extends OB11BaseNoticeEvent { + notice_type = 'friend_add'; + user_id: number; + + public constructor (core: SnowCatCore, userId: number) { + super(core); + this.user_id = userId; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11FriendRecallNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11FriendRecallNoticeEvent.ts new file mode 100644 index 0000000..e22eaae --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11FriendRecallNoticeEvent.ts @@ -0,0 +1,14 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11FriendRecallNoticeEvent extends OB11BaseNoticeEvent { + notice_type = 'friend_recall'; + user_id: number; + message_id: number; + + public constructor (core: SnowCatCore, userId: number, messageId: number) { + super(core); + this.user_id = userId; + this.message_id = messageId; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupAdminNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupAdminNoticeEvent.ts new file mode 100644 index 0000000..33213f6 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupAdminNoticeEvent.ts @@ -0,0 +1,12 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupAdminNoticeEvent extends OB11GroupNoticeEvent { + notice_type = 'group_admin'; + sub_type: 'set' | 'unset'; + + constructor (core: SnowCatCore, group_id: number, user_id: number, sub_type: 'set' | 'unset') { + super(core, group_id, user_id); + this.sub_type = sub_type; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupBanEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupBanEvent.ts new file mode 100644 index 0000000..401273e --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupBanEvent.ts @@ -0,0 +1,18 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupBanEvent extends OB11GroupNoticeEvent { + notice_type = 'group_ban'; + operator_id: number; + duration: number; + sub_type: 'ban' | 'lift_ban'; + + constructor (core: SnowCatCore, groupId: number, userId: number, operatorId: number, duration: number, sub_type: 'ban' | 'lift_ban') { + super(core, groupId, userId); + this.group_id = groupId; + this.operator_id = operatorId; + this.user_id = userId; + this.duration = duration; + this.sub_type = sub_type; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupCardEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupCardEvent.ts new file mode 100644 index 0000000..ac9e8aa --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupCardEvent.ts @@ -0,0 +1,16 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupCardEvent extends OB11GroupNoticeEvent { + notice_type = 'group_card'; + card_new: string; + card_old: string; + + constructor (core: SnowCatCore, groupId: number, userId: number, cardNew: string, cardOld: string) { + super(core, groupId, userId); + this.group_id = groupId; + this.user_id = userId; + this.card_new = cardNew; + this.card_old = cardOld; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupDecreaseEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupDecreaseEvent.ts new file mode 100644 index 0000000..837814b --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupDecreaseEvent.ts @@ -0,0 +1,18 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me' | 'disband'; + +export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent { + notice_type = 'group_decrease'; + sub_type: GroupDecreaseSubType = 'leave'; + operator_id: number; + + constructor (core: SnowCatCore, groupId: number, userId: number, operatorId: number, subType: GroupDecreaseSubType = 'leave') { + super(core, groupId, userId); + this.group_id = groupId; + this.operator_id = operatorId; + this.user_id = userId; + this.sub_type = subType; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupEssenceEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupEssenceEvent.ts new file mode 100644 index 0000000..6f28e6c --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupEssenceEvent.ts @@ -0,0 +1,18 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupEssenceEvent extends OB11GroupNoticeEvent { + notice_type = 'essence'; + message_id: number; + sender_id: number; + operator_id: number; + sub_type: 'add' | 'delete' = 'add'; + + constructor (core: SnowCatCore, groupId: number, message_id: number, sender_id: number, operator_id: number) { + super(core, groupId, sender_id); + this.group_id = groupId; + this.operator_id = operator_id; + this.message_id = message_id; + this.sender_id = sender_id; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupGrayTipEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupGrayTipEvent.ts new file mode 100644 index 0000000..a65c1e1 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupGrayTipEvent.ts @@ -0,0 +1,35 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +/** + * 群灰条消息事件 + * 用于上报未知类型的灰条消息,便于下游检测和处理伪造灰条攻击 + */ +export class OB11GroupGrayTipEvent extends OB11BaseNoticeEvent { + notice_type = 'notify'; + sub_type = 'gray_tip'; + group_id: number; + user_id: number; // 真实发送者QQ(如果是伪造的灰条,这就是攻击者) + message_id: number; // 消息ID,可用于撤回 + busi_id: string; // 业务ID + content: string; // 灰条内容(JSON字符串) + raw_info: unknown; // 原始信息 + + constructor ( + core: SnowCatCore, + groupId: number, + userId: number, + messageId: number, + busiId: string, + content: string, + rawInfo: unknown + ) { + super(core); + this.group_id = groupId; + this.user_id = userId; + this.message_id = messageId; + this.busi_id = busiId; + this.content = content; + this.raw_info = rawInfo; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupIncreaseEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupIncreaseEvent.ts new file mode 100644 index 0000000..6c09d2d --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupIncreaseEvent.ts @@ -0,0 +1,18 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +type GroupIncreaseSubType = 'approve' | 'invite'; + +export class OB11GroupIncreaseEvent extends OB11GroupNoticeEvent { + notice_type = 'group_increase'; + operator_id: number; + sub_type: GroupIncreaseSubType; + + constructor (core: SnowCatCore, groupId: number, userId: number, operatorId: number, subType: GroupIncreaseSubType = 'approve') { + super(core, groupId, userId); + this.group_id = groupId; + this.operator_id = operatorId; + this.user_id = userId; + this.sub_type = subType; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupNameEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupNameEvent.ts new file mode 100644 index 0000000..f901980 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupNameEvent.ts @@ -0,0 +1,13 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupNameEvent extends OB11GroupNoticeEvent { + notice_type = 'notify'; + sub_type = 'group_name'; + name_new: string; + + constructor (core: SnowCatCore, groupId: number, userId: number, nameNew: string) { + super(core, groupId, userId); + this.name_new = nameNew; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupNoticeEvent.ts new file mode 100644 index 0000000..b10c157 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupNoticeEvent.ts @@ -0,0 +1,13 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export abstract class OB11GroupNoticeEvent extends OB11BaseNoticeEvent { + group_id: number; + user_id: number; + + constructor (core: SnowCatCore, group_id: number, user_id: number) { + super(core); + this.group_id = group_id; + this.user_id = user_id; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupRecallNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupRecallNoticeEvent.ts new file mode 100644 index 0000000..c2f429e --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupRecallNoticeEvent.ts @@ -0,0 +1,16 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupRecallNoticeEvent extends OB11GroupNoticeEvent { + notice_type = 'group_recall'; + operator_id: number; + message_id: number; + + constructor (core: SnowCatCore, groupId: number, userId: number, operatorId: number, messageId: number) { + super(core, groupId, userId); + this.group_id = groupId; + this.user_id = userId; + this.operator_id = operatorId; + this.message_id = messageId; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupTitleEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupTitleEvent.ts new file mode 100644 index 0000000..9b81073 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupTitleEvent.ts @@ -0,0 +1,15 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11GroupTitleEvent extends OB11GroupNoticeEvent { + notice_type = 'notify'; + sub_type = 'title'; + title: string; + + constructor (core: SnowCatCore, groupId: number, userId: number, title: string) { + super(core, groupId, userId); + this.group_id = groupId; + this.user_id = userId; + this.title = title; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11GroupUploadNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11GroupUploadNoticeEvent.ts new file mode 100644 index 0000000..68f3dc3 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11GroupUploadNoticeEvent.ts @@ -0,0 +1,21 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export interface GroupUploadFile { + id: string, + name: string, + size: number, + busid: number, +} + +export class OB11GroupUploadNoticeEvent extends OB11GroupNoticeEvent { + notice_type = 'group_upload'; + file: GroupUploadFile; + + constructor (core: SnowCatCore, groupId: number, userId: number, file: GroupUploadFile) { + super(core, groupId, userId); + this.group_id = groupId; + this.user_id = userId; + this.file = file; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11InputStatusEvent.ts b/packages/snowcat-onebot/event/notice/OB11InputStatusEvent.ts new file mode 100644 index 0000000..faeb124 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11InputStatusEvent.ts @@ -0,0 +1,19 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +// TODO: 输入状态事件 初步完成 Mlikiowa 需要做一些过滤 +export class OB11InputStatusEvent extends OB11BaseNoticeEvent { + notice_type = 'notify'; + sub_type = 'input_status'; + status_text = '对方正在输入...'; + event_type = 1; + user_id = 0; + group_id = 0; + + constructor (core: SnowCatCore, user_id: number, eventType: number, status_text: string) { + super(core); + this.user_id = user_id; + this.event_type = eventType; + this.status_text = status_text; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11MsgEmojiLikeEvent.ts b/packages/snowcat-onebot/event/notice/OB11MsgEmojiLikeEvent.ts new file mode 100644 index 0000000..aab85de --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11MsgEmojiLikeEvent.ts @@ -0,0 +1,24 @@ +import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export interface MsgEmojiLike { + emoji_id: string, + count: number; +} + +export class OB11GroupMsgEmojiLikeEvent extends OB11GroupNoticeEvent { + notice_type = 'group_msg_emoji_like'; + message_id: number; + likes: MsgEmojiLike[]; + is_add: boolean; + message_seq?: string; + + constructor (core: SnowCatCore, groupId: number, userId: number, messageId: number, likes: MsgEmojiLike[], isAdd: boolean, _messageSeq?: string) { + super(core, groupId, userId); + this.group_id = groupId; + this.user_id = userId; // 可为空,表示是对别人的消息操作,如果是对bot自己的消息则不为空 + this.message_id = messageId; + this.likes = likes; + this.is_add = isAdd; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11OnlineFileNoticeEvent.ts b/packages/snowcat-onebot/event/notice/OB11OnlineFileNoticeEvent.ts new file mode 100644 index 0000000..f1993ab --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11OnlineFileNoticeEvent.ts @@ -0,0 +1,11 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export abstract class OB11OnlineFileNoticeEvent extends OB11BaseNoticeEvent { + peer_id: number; + + protected constructor (core: SnowCatCore, peer_id: number) { + super(core); + this.peer_id = peer_id; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11OnlineFileReceiveEvent.ts b/packages/snowcat-onebot/event/notice/OB11OnlineFileReceiveEvent.ts new file mode 100644 index 0000000..2e61be2 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11OnlineFileReceiveEvent.ts @@ -0,0 +1,13 @@ +import { OB11OnlineFileNoticeEvent } from './OB11OnlineFileNoticeEvent'; +import { SnowCatCore } from '@/snowcat-core'; + +export class OB11OnlineFileReceiveEvent extends OB11OnlineFileNoticeEvent { + notice_type: string; + sub_type: string; + + constructor (core: SnowCatCore, peer_id: number) { + super(core, peer_id); + this.notice_type = 'online_file_receive'; + this.sub_type = 'cancel'; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11OnlineFileSendEvent.ts b/packages/snowcat-onebot/event/notice/OB11OnlineFileSendEvent.ts new file mode 100644 index 0000000..779655a --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11OnlineFileSendEvent.ts @@ -0,0 +1,12 @@ +import { OB11OnlineFileNoticeEvent } from './OB11OnlineFileNoticeEvent'; +import { SnowCatCore } from '@/snowcat-core'; + +export class OB11OnlineFileSendEvent extends OB11OnlineFileNoticeEvent { + notice_type = 'online_file_send'; + sub_type: 'receive' | 'refuse'; + + constructor (core: SnowCatCore, peer_id: number, sub_type: 'receive' | 'refuse') { + super(core, peer_id); + this.sub_type = sub_type; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11PokeEvent.ts b/packages/snowcat-onebot/event/notice/OB11PokeEvent.ts new file mode 100644 index 0000000..0fea19f --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11PokeEvent.ts @@ -0,0 +1,37 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +class OB11PokeEvent extends OB11BaseNoticeEvent { + notice_type = 'notify'; + sub_type = 'poke'; + target_id = 0; + user_id = 0; +} + +export class OB11FriendPokeEvent extends OB11PokeEvent { + raw_info: unknown; + sender_id: number; + + // raw_message nb等框架标准为string + constructor (core: SnowCatCore, user_id: number, sender_id: number, target_id: number, raw_message: unknown) { + super(core); + this.target_id = target_id; + this.user_id = user_id; + this.sender_id = sender_id; + this.raw_info = raw_message; + } +} + +export class OB11GroupPokeEvent extends OB11PokeEvent { + group_id: number; + raw_info: unknown; + + // raw_message nb等框架标准为string + constructor (core: SnowCatCore, group_id: number, user_id: number, target_id: number, raw_message: unknown) { + super(core); + this.group_id = group_id; + this.target_id = target_id; + this.user_id = user_id; + this.raw_info = raw_message; + } +} diff --git a/packages/snowcat-onebot/event/notice/OB11ProfileLikeEvent.ts b/packages/snowcat-onebot/event/notice/OB11ProfileLikeEvent.ts new file mode 100644 index 0000000..4e0f243 --- /dev/null +++ b/packages/snowcat-onebot/event/notice/OB11ProfileLikeEvent.ts @@ -0,0 +1,19 @@ +import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent'; +import { SnowCatCore } from 'snowcat-core'; + +export class OB11ProfileLikeEvent extends OB11BaseNoticeEvent { + notice_type = 'notify'; + sub_type = 'profile_like'; + operator_id: number; + operator_nick: string; + times: number; + override time: number; + + constructor (core: SnowCatCore, operatorId: number, operatorNick: string, times: number, time: number) { + super(core); + this.operator_id = operatorId; + this.operator_nick = operatorNick; + this.times = times; + this.time = time; + } +} diff --git a/packages/snowcat-onebot/event/notice/index.ts b/packages/snowcat-onebot/event/notice/index.ts new file mode 100644 index 0000000..2868dec --- /dev/null +++ b/packages/snowcat-onebot/event/notice/index.ts @@ -0,0 +1,23 @@ +export * from './BotOfflineEvent'; +export * from './OB11BaseNoticeEvent'; +export * from './OB11FriendAddNoticeEvent'; +export * from './OB11FriendRecallNoticeEvent'; +export * from './OB11GroupAdminNoticeEvent'; +export * from './OB11GroupBanEvent'; +export * from './OB11GroupCardEvent'; +export * from './OB11GroupDecreaseEvent'; +export * from './OB11GroupEssenceEvent'; +export * from './OB11GroupGrayTipEvent'; +export * from './OB11GroupIncreaseEvent'; +export * from './OB11GroupNameEvent'; +export * from './OB11GroupNoticeEvent'; +export * from './OB11GroupRecallNoticeEvent'; +export * from './OB11GroupTitleEvent'; +export * from './OB11GroupUploadNoticeEvent'; +export * from './OB11InputStatusEvent'; +export * from './OB11MsgEmojiLikeEvent'; +export * from './OB11OnlineFileNoticeEvent'; +export * from './OB11OnlineFileReceiveEvent'; +export * from './OB11OnlineFileSendEvent'; +export * from './OB11PokeEvent'; +export * from './OB11ProfileLikeEvent'; diff --git a/packages/snowcat-onebot/event/request/OB11BaseRequestEvent.ts b/packages/snowcat-onebot/event/request/OB11BaseRequestEvent.ts new file mode 100644 index 0000000..3b78f7b --- /dev/null +++ b/packages/snowcat-onebot/event/request/OB11BaseRequestEvent.ts @@ -0,0 +1,6 @@ +import { EventType, OneBotEvent } from '@/snowcat-onebot/event/OneBotEvent'; + +export abstract class OB11BaseRequestEvent extends OneBotEvent { + readonly post_type = EventType.REQUEST; + abstract request_type: string; +} diff --git a/packages/snowcat-onebot/event/request/OB11FriendRequest.ts b/packages/snowcat-onebot/event/request/OB11FriendRequest.ts new file mode 100644 index 0000000..47774e3 --- /dev/null +++ b/packages/snowcat-onebot/event/request/OB11FriendRequest.ts @@ -0,0 +1,17 @@ +import { SnowCatCore } from 'snowcat-core'; +import { OB11BaseRequestEvent } from './OB11BaseRequestEvent'; + +export class OB11FriendRequestEvent extends OB11BaseRequestEvent { + override request_type = 'friend'; + + user_id: number; + comment: string; + flag: string; + + constructor (core: SnowCatCore, user_id: number, comment: string, flag: string) { + super(core); + this.user_id = user_id; + this.comment = comment; + this.flag = flag; + } +} diff --git a/packages/snowcat-onebot/event/request/OB11GroupRequest.ts b/packages/snowcat-onebot/event/request/OB11GroupRequest.ts new file mode 100644 index 0000000..0ca0dac --- /dev/null +++ b/packages/snowcat-onebot/event/request/OB11GroupRequest.ts @@ -0,0 +1,21 @@ +import { SnowCatCore } from 'snowcat-core'; +import { OB11BaseRequestEvent } from './OB11BaseRequestEvent'; + +export class OB11GroupRequestEvent extends OB11BaseRequestEvent { + override readonly request_type = 'group' as const; + + group_id: number; + user_id: number; + comment: string; + flag: string; + sub_type: string; + + constructor (core: SnowCatCore, groupId: number, userId: number, sub_type: string, comment: string, flag: string) { + super(core); + this.group_id = groupId; + this.user_id = userId; + this.sub_type = sub_type; + this.comment = comment; + this.flag = flag; + } +} diff --git a/packages/snowcat-onebot/event/request/index.ts b/packages/snowcat-onebot/event/request/index.ts new file mode 100644 index 0000000..1951f71 --- /dev/null +++ b/packages/snowcat-onebot/event/request/index.ts @@ -0,0 +1,3 @@ +export * from './OB11BaseRequestEvent'; +export * from './OB11FriendRequest'; +export * from './OB11GroupRequest'; diff --git a/packages/snowcat-onebot/helper/cqcode.ts b/packages/snowcat-onebot/helper/cqcode.ts new file mode 100644 index 0000000..f6f6998 --- /dev/null +++ b/packages/snowcat-onebot/helper/cqcode.ts @@ -0,0 +1,91 @@ +import { OB11MessageData } from '@/snowcat-onebot/types'; + +const pattern = /\[CQ:(\w+)((,\w+=[^,\]]*)*)]/; + +function unescape (source: string) { + return String(source) + .replace(/[/g, '[') + .replace(/]/g, ']') + .replace(/,/g, ',') + .replace(/&/g, '&'); +} + +function from (source: string) { + const capture = pattern.exec(source); + if (!capture) return null; + const [, type, attrs] = capture; + const data: Record = {}; + if (attrs) { + attrs.slice(1).split(',').forEach((str) => { + const index = str.indexOf('='); + data[str.slice(0, index)] = unescape(str.slice(index + 1)); + }); + } + return { type, data, capture }; +} + +function convert (type: string, data: unknown) { + return { + type, + data, + }; +} + +export function decodeCQCode (source: string): OB11MessageData[] { + const elements: Array = []; + let result: ReturnType; + while ((result = from(source))) { + const { type, data, capture } = result; + if (type) { + if (capture.index) { + elements.push(convert('text', { text: unescape(source.slice(0, capture.index)) })); + } + elements.push(convert(type, data)); + source = source.slice(capture.index + capture[0].length); + } + } + if (source) elements.push(convert('text', { text: unescape(source) })); + return elements as OB11MessageData[]; +} + +function CQCodeEscapeText (text: string) { + return text.replace(/&/g, '&') + .replace(/\[/g, '[') + .replace(/]/g, ']'); +} + +function CQCodeEscape (text: string) { + return text.replace(/&/g, '&') + .replace(/\[/g, '[') + .replace(/]/g, ']') + .replace(/,/g, ','); +} + +export function encodeCQCode (data: OB11MessageData) { + if (data.type === 'text') { + return CQCodeEscapeText(data.data.text); + } + + let result = '[CQ:' + data.type; + for (const name in data.data) { + const value = (data.data as Record)[name]; + if (value === undefined) { + continue; + } + try { + let text: string | undefined; + if (typeof value === 'object' && value?.toString === Object.prototype.toString) { + text = JSON.stringify(value); + } else { + text = value?.toString(); + } + if (text) { + result += `,${name}=${CQCodeEscape(text)}`; + } + } catch (error) { + console.error(`Error encoding CQCode for ${name}:`, error); + } + } + result += ']'; + return result; +} diff --git a/packages/snowcat-onebot/helper/data.ts b/packages/snowcat-onebot/helper/data.ts new file mode 100644 index 0000000..9012bf8 --- /dev/null +++ b/packages/snowcat-onebot/helper/data.ts @@ -0,0 +1,140 @@ +import { calcQQLevel } from 'snowcat-common/src/helper'; +import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid'; +import { FriendV2, Group, GroupFileInfoUpdateParamType, GroupMember, SelfInfo, NTSex } from 'snowcat-core'; +import { + OB11Group, + OB11GroupFile, + OB11GroupFileFolder, + OB11GroupMember, + OB11GroupMemberRole, + OB11User, + OB11UserSex, +} from '@/snowcat-onebot/types'; +export class OB11Construct { + static selfInfo (selfInfo: SelfInfo): OB11User { + return { + user_id: +selfInfo.uin, + nickname: selfInfo.nick, + }; + } + + static friends (friends: FriendV2[]): OB11User[] { + return friends.map(rawFriend => ({ + birthday_year: rawFriend.baseInfo.birthday_year, + birthday_month: rawFriend.baseInfo.birthday_month, + birthday_day: rawFriend.baseInfo.birthday_day, + user_id: parseInt(rawFriend.coreInfo.uin), + age: rawFriend.baseInfo.age, + phone_num: rawFriend.baseInfo.phoneNum, + email: rawFriend.baseInfo.eMail, + category_id: rawFriend.baseInfo.categoryId, + nickname: rawFriend.coreInfo.nick ?? '', + remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick, + sex: this.sex(rawFriend.baseInfo.sex), + level: (rawFriend.qqLevel && calcQQLevel(rawFriend.qqLevel)) || 0, + })); + } + + static friend (friends: FriendV2): OB11User { + return { + birthday_year: friends.baseInfo.birthday_year, + birthday_month: friends.baseInfo.birthday_month, + birthday_day: friends.baseInfo.birthday_day, + user_id: parseInt(friends.coreInfo.uin), + age: friends.baseInfo.age, + phone_num: friends.baseInfo.phoneNum, + email: friends.baseInfo.eMail, + category_id: friends.baseInfo.categoryId, + nickname: friends.coreInfo.nick ?? '', + remark: friends.coreInfo.remark ?? friends.coreInfo.nick, + sex: this.sex(friends.baseInfo.sex), + level: 0, + }; + } + + static groupMemberRole (role: number): OB11GroupMemberRole | undefined { + return { + 4: OB11GroupMemberRole.owner, + 3: OB11GroupMemberRole.admin, + 2: OB11GroupMemberRole.member, + }[role]; + } + + static sex (sex?: NTSex): OB11UserSex { + if (!sex) return OB11UserSex.unknown; + return { + [NTSex.GENDER_MALE]: OB11UserSex.male, + [NTSex.GENDER_FEMALE]: OB11UserSex.female, + [NTSex.GENDER_UNKOWN]: OB11UserSex.unknown, + [NTSex.GENDER_PRIVACY]: OB11UserSex.unknown, + }[sex] || OB11UserSex.unknown; + } + + static groupMember (group_id: string, member: GroupMember): OB11GroupMember { + return { + group_id: +group_id, + user_id: +member.uin, + nickname: member.nick, + card: member.cardName, + sex: this.sex(member.sex), + age: member.age ?? 0, + area: '', + level: member.memberRealLevel?.toString() ?? '0', + qq_level: (member.qqLevel && calcQQLevel(member.qqLevel)) || 0, + join_time: +member.joinTime, + last_sent_time: +member.lastSpeakTime, + title_expire_time: 0, + unfriendly: false, + card_changeable: true, + is_robot: member.isRobot, + shut_up_timestamp: member.shutUpTime, + role: this.groupMemberRole(member.role), + title: member.memberSpecialTitle ?? '', + }; + } + + static group (group: Group): OB11Group { + return { + group_all_shut: (+group.groupShutupExpireTime > 0) ? -1 : 0, + group_remark: group.remarkName, + group_id: +group.groupCode, + group_name: group.groupName, + member_count: group.memberCount, + max_member_count: group.maxMember, + }; + } + + static groups (groups: Group[]): OB11Group[] { + return groups.map(this.group); + } + + static file (peerId: string, file: Exclude): OB11GroupFile { + return { + group_id: +peerId, + file_id: FileNapCatOneBotUUID.encodeModelId({ chatType: 2, peerUid: peerId }, file.fileModelId, file.fileId, file.fileId ?? ''), + file_name: file.fileName, + busid: file.busId, + size: +file.fileSize, + file_size: +file.fileSize, + upload_time: file.uploadTime, + dead_time: file.deadTime, + modify_time: file.modifyTime, + download_times: file.downloadTimes, + uploader: +file.uploaderUin, + uploader_name: file.uploaderName, + }; + } + + static folder (peerId: string, folder: Exclude): OB11GroupFileFolder { + return { + group_id: +peerId, + folder_id: folder.folderId, + folder: folder.folderId, + folder_name: folder.folderName, + create_time: folder.createTime, + creator: +folder.createUin, + creator_name: folder.creatorName, + total_file_count: folder.totalFileCount, + }; + } +} diff --git a/packages/snowcat-onebot/index.ts b/packages/snowcat-onebot/index.ts new file mode 100644 index 0000000..f76ceee --- /dev/null +++ b/packages/snowcat-onebot/index.ts @@ -0,0 +1,792 @@ +import { + BuddyReqType, + ChatType, + GroupNotifyMsgStatus, + GroupNotifyMsgType, + InstanceContext, + SnowCatCore, + NodeIKernelBuddyListener, + NodeIKernelGroupListener, + NodeIKernelMsgListener, + Peer, + RawMessage, + SendStatusType, + NTMsgType, + MessageElement, + ElementType, + NTMsgAtType, +} from 'snowcat-core'; +import { OB11ConfigLoader } from '@/snowcat-onebot/config'; +import { pendingTokenToSend } from 'snowcat-webui-backend/index'; +import { + OB11HttpClientAdapter, + OB11WebSocketClientAdapter, + OB11NetworkManager, + OB11NetworkReloadType, + OB11HttpServerAdapter, + OB11WebSocketServerAdapter, +} from '@/snowcat-onebot/network'; +import { SnowCatPathWrapper } from 'snowcat-common/src/path'; +import { + OneBotFriendApi, + OneBotGroupApi, + OneBotMsgApi, + OneBotQuickActionApi, + OneBotUserApi, +} from '@/snowcat-onebot/api'; +import { ActionMap, createActionMap } from '@/snowcat-onebot/action'; +import { WebUiDataRuntime } from 'snowcat-webui-backend/src/helper/Data'; +import { OB11InputStatusEvent } from '@/snowcat-onebot/event/notice/OB11InputStatusEvent'; +import { MessageUnique } from 'snowcat-common/src/message-unique'; +import { OB11FriendRequestEvent } from '@/snowcat-onebot/event/request/OB11FriendRequest'; +import { OB11GroupRequestEvent } from '@/snowcat-onebot/event/request/OB11GroupRequest'; +import { OB11FriendRecallNoticeEvent } from '@/snowcat-onebot/event/notice/OB11FriendRecallNoticeEvent'; +import { OB11GroupRecallNoticeEvent } from '@/snowcat-onebot/event/notice/OB11GroupRecallNoticeEvent'; +import { BotOfflineEvent } from './event/notice/BotOfflineEvent'; +import { + NetworkAdapterConfig, + OneBotConfig, + OneBotConfigSchema, +} from './config/config'; +import { OB11Message } from './types'; +import { existsSync } from 'node:fs'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import { OB11HttpSSEServerAdapter } from './network/http-server-sse'; +import { OB11McpServerAdapter } from './network/mcp-server'; +import { OB11PluginMangerAdapter } from './network/plugin-manger'; + +import { proxiedListenerOf } from '@/snowcat-core/helper/proxy-handler'; +import { OneBotFileApi } from './api/file'; + +interface ApiListType { + GroupApi: OneBotGroupApi; + UserApi: OneBotUserApi; + FriendApi: OneBotFriendApi; + MsgApi: OneBotMsgApi; + QuickActionApi: OneBotQuickActionApi; + FileApi: OneBotFileApi; +} + +const RECALL_EVENT_DEDUP_MAX_SIZE = 1_000; +const RECALL_UPDATE_CLOCK_SKEW_SECONDS = 30; + +// OneBot实现类 +export class NapCatOneBot11Adapter { + readonly core: SnowCatCore; + readonly context: InstanceContext; + + configLoader: OB11ConfigLoader; + public apis: ApiListType; + networkManager: OB11NetworkManager; + actions: ActionMap; + private readonly bootTime = Date.now() / 1000; + recallEventCache = new Map(); + private readonly reportedRecallEventCache = new Map(); + private readonly processingRecallEvents = new Map>(); + constructor (core: SnowCatCore, context: InstanceContext, pathWrapper: SnowCatPathWrapper) { + this.core = core; + this.context = context; + this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema); + this.apis = { + GroupApi: new OneBotGroupApi(this, core), + UserApi: new OneBotUserApi(this, core), + FriendApi: new OneBotFriendApi(this, core), + MsgApi: new OneBotMsgApi(this, core), + QuickActionApi: new OneBotQuickActionApi(this, core), + FileApi: new OneBotFileApi(this, core), + } as const; + this.actions = createActionMap(this, core); + this.networkManager = new OB11NetworkManager(); + } + + async creatOneBotLog (ob11Config: OneBotConfig) { + let log = '[network] 配置加载\n'; + for (const key of ob11Config.network.httpServers) { + log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of ob11Config.network.httpSseServers) { + log += `HTTP-SSE服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of ob11Config.network.httpClients) { + log += `HTTP上报服务: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of ob11Config.network.websocketServers) { + log += `WebSocket服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of ob11Config.network.websocketClients) { + log += `WebSocket反向服务: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of ob11Config.network.mcpServers ?? []) { + log += `MCP服务: ${key.host}:${key.port}/mcp, : ${key.enable ? '已启动' : '未启动'}\n`; + } + return log; + } + + async InitOneBot () { + const selfInfo = this.core.selfInfo; + const ob11Config = this.configLoader.configData; + this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false) + .then(async (user) => { + selfInfo.nick = user.nick; + this.context.logger.setLogSelfInfo(selfInfo); + + // 检查是否有待发送的token + if (pendingTokenToSend) { + this.context.logger.log('[SnowCat] [OneBot] 🔐 检测到待发送的WebUI Token,开始发送'); + try { + await this.core.apis.MsgApi.sendMsg( + { chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' }, + [{ + elementType: ElementType.TEXT, + elementId: '', + textElement: { + content: + '[SnowCat] 温馨提示:\n' + + 'WebUI密码为默认密码,已进行强制修改\n' + + '新密码: ' + pendingTokenToSend, + atType: NTMsgAtType.ATTYPEUNKNOWN, + atUid: '', + atTinyId: '', + atNtUid: '', + }, + }], + 5000 + ); + this.context.logger.log('[SnowCat] [OneBot] ✅ WebUI Token 消息发送成功'); + } catch (error) { + this.context.logger.logError('[SnowCat] [OneBot] ❌ WebUI Token 消息发送失败:', error); + } + } + + WebUiDataRuntime.getQQLoginCallback()(true); + }) + .catch(e => this.context.logger.logError(e)); + + const serviceInfo = await this.creatOneBotLog(ob11Config); + this.context.logger.log(`[Notice] [OneBot11] ${serviceInfo}`); + + // 创建NetWork服务 + + // 检查插件目录是否存在,不存在则不加载插件管理器 + if (existsSync(this.context.pathWrapper.pluginPath)) { + this.context.logger.log('[Plugins] 插件目录存在,开始加载插件'); + this.networkManager.registerAdapter( + new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions) + ); + } + for (const key of ob11Config.network.httpServers) { + if (key.enable) { + this.networkManager.registerAdapter( + new OB11HttpServerAdapter(key.name, key, this.core, this, this.actions) + ); + } + } + for (const key of ob11Config.network.httpSseServers) { + if (key.enable) { + this.networkManager.registerAdapter( + new OB11HttpSSEServerAdapter(key.name, key, this.core, this, this.actions) + ); + } + } + for (const key of ob11Config.network.httpClients) { + if (key.enable) { + this.networkManager.registerAdapter( + new OB11HttpClientAdapter(key.name, key, this.core, this, this.actions) + ); + } + } + for (const key of ob11Config.network.websocketServers) { + if (key.enable) { + this.networkManager.registerAdapter( + new OB11WebSocketServerAdapter( + key.name, + key, + this.core, + this, + this.actions + ) + ); + } + } + for (const key of ob11Config.network.websocketClients) { + if (key.enable) { + this.networkManager.registerAdapter( + new OB11WebSocketClientAdapter( + key.name, + key, + this.core, + this, + this.actions + ) + ); + } + } + await this.networkManager.openAllAdapters(); + + this.initMsgListener(); + this.initBuddyListener(); + this.initGroupListener(); + + WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVersion()); + WebUiDataRuntime.setQQLoginInfo(selfInfo); + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => { + const prev = this.configLoader.configData; + this.configLoader.save(newConfig); + // this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`); + await this.reloadNetwork(prev, newConfig); + }); + this.apis.GroupApi.registerParseGroupReactEventByCore().catch(e => + this.context.logger.logError('注册群消息反应表情失败', e) + ); + } + + private async reloadNetwork (prev: OneBotConfig, now: OneBotConfig): Promise { + const prevLog = await this.creatOneBotLog(prev); + const newLog = await this.creatOneBotLog(now); + this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`); + this.context.logger.log(`[Notice] [OneBot11] 配置变更后:\n${newLog}`); + + await this.handleConfigChange(prev.network.httpServers, now.network.httpServers, OB11HttpServerAdapter); + await this.handleConfigChange(prev.network.httpClients, now.network.httpClients, OB11HttpClientAdapter); + await this.handleConfigChange(prev.network.httpSseServers, now.network.httpSseServers, OB11HttpSSEServerAdapter); + await this.handleConfigChange(prev.network.websocketServers, now.network.websocketServers, OB11WebSocketServerAdapter); + await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter); + await this.handleConfigChange(prev.network.mcpServers ?? [], now.network.mcpServers ?? [], OB11McpServerAdapter); + } + + private async handleConfigChange ( + prevConfig: NetworkAdapterConfig[], + nowConfig: NetworkAdapterConfig[], + adapterClass: new ( + ...args: ConstructorParameters> + ) => IOB11NetworkAdapter + ): Promise { + // 比较旧的在新的找不到的回收 + for (const adapterConfig of prevConfig) { + const existingAdapter = nowConfig.find((e) => e.name === adapterConfig.name); + if (!existingAdapter) { + const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name); + if (existingAdapter) { + await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]); + } + } + } + // 通知新配置重载 删除关闭的 加入新开的 + for (const adapterConfig of nowConfig) { + const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name); + if (existingAdapter) { + const networkChange = await existingAdapter.reload(adapterConfig); + if (networkChange === OB11NetworkReloadType.NetWorkClose) { + await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]); + } + } else if (adapterConfig.enable) { + // eslint-disable-next-line new-cap + const newAdapter = new adapterClass(adapterConfig.name, adapterConfig as CT, this.core, this, this.actions); + await this.networkManager.registerAdapterAndOpen(newAdapter); + } + } + } + + private initMsgListener () { + const msgListener = new NodeIKernelMsgListener(); + msgListener.onRecvSysMsg = (msg) => { + this.apis.MsgApi.parseSysMessage(msg) + .then((event) => { + if (event) this.networkManager.emitEvent(event); + }) + .catch((e) => + this.context.logger.logError( + 'constructSysMessage error: ', + e, + '\n Parse Hex:', + Buffer.from(msg).toString('hex') + ) + ); + }; + + msgListener.onInputStatusPush = async (data) => { + const uin = await this.core.apis.UserApi.getUinByUidV2(data.fromUin); + this.context.logger.log(`[Notice] [输入状态] ${uin} ${data.statusText}`); + await this.networkManager.emitEvent( + new OB11InputStatusEvent(this.core, parseInt(uin), data.eventType, data.statusText) + ); + }; + + msgListener.onRecvMsg = async (msg) => { + if (!this.networkManager.hasActiveAdapters()) { + return; + } + for (const m of msg) { + if (this.bootTime > parseInt(m.msgTime)) { + this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`); + continue; + } + m.id = MessageUnique.createUniqueMsgId( + { + chatType: m.chatType, + peerUid: m.peerUid, + guildId: '', + }, + m.msgId + ); + await this.emitMsg(m).catch((e) => + this.context.logger.logError('处理消息失败', e) + ); + } + }; + + /** + * 加入在线文件的listener + */ + msgListener.onRecvOnlineFileMsg = async (msg: RawMessage[]) => { + if (!this.networkManager.hasActiveAdapters()) { + return; + } + + for (const m of msg) { + // this.context.logger.logMessage(m, this.core.selfInfo); + + if (this.bootTime > parseInt(m.msgTime)) { + this.context.logger.logDebug(`在线文件消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`); + continue; + } + + m.id = MessageUnique.createUniqueMsgId( + { + chatType: m.chatType, + peerUid: m.peerUid, + guildId: '', + }, + m.msgId + ); + + await this.emitMsg(m).catch((e) => + this.context.logger.logError('处理在线文件消息失败', e) + ); + } + }; + + msgListener.onAddSendMsg = async (msg) => { + try { + if (msg.sendStatus === SendStatusType.KSEND_STATUS_SENDING) { + const [updatemsgs] = await this.core.eventWrapper.registerListen('NodeIKernelMsgListener/onMsgInfoListUpdate', (msgList: RawMessage[]) => { + const report = msgList.find((e) => + e.senderUin === this.core.selfInfo.uin && e.sendStatus !== SendStatusType.KSEND_STATUS_SENDING && e.msgId === msg.msgId + ); + return !!report; + }, 1, 10 * 60 * 1000); + // 10分钟 超时 + const updatemsg = updatemsgs.find((e) => e.msgId === msg.msgId); + // updatemsg?.sendStatus == SendStatusType.KSEND_STATUS_SUCCESS_NOSEQ NOSEQ一般是服务器未下发SEQ 这意味着这条消息不应该推送network + if (updatemsg?.sendStatus === SendStatusType.KSEND_STATUS_SUCCESS) { + updatemsg.id = MessageUnique.createUniqueMsgId( + { + chatType: updatemsg.chatType, + peerUid: updatemsg.peerUid, + guildId: '', + }, + updatemsg.msgId + ); + this.emitMsg(updatemsg); + } + } + } catch (error) { + this.context.logger.logError('处理发送消息失败', error); + } + }; + + msgListener.onMsgInfoListUpdate = async (msgList: RawMessage[]) => { + for (const msg of msgList) { + const element = this.getRecallElement(msg); + if (element && this.isCurrentRecallUpdate(msg)) { + // The gray-tip update is authoritative; the recall callback's sequence query can return a newer message. + await this.handleRecallMessage(msg, element); + } + } + }; + + msgListener.onMsgRecall = async (chatType: ChatType, uid: string, msgSeq: string) => { + const peer: Peer = { + chatType, + peerUid: uid, + guildId: '', + }; + try { + const msg = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeq(peer, msgSeq)).msgList + .find(message => this.getRecallElement(message)); + const element = msg && this.getRecallElement(msg); + if (msg && element) { + await this.handleRecallMessage(msg, element); + } else { + this.context.logger.logDebug(`撤回回调暂未查询到灰条消息,等待消息更新: ${chatType}/${uid}/${msgSeq}`); + } + } catch (e) { + this.context.logger.logError('查询撤回消息失败', e); + } + }; + msgListener.onKickedOffLine = async (kick) => { + WebUiDataRuntime.setQQLoginStatus(false); + const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc); + this.networkManager + .emitEvent(event) + .catch((e) => this.context.logger.logError('处理Bot掉线失败', e)); + }; + this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger)); + } + + private initBuddyListener () { + const buddyListener = new NodeIKernelBuddyListener(); + + buddyListener.onBuddyReqChange = async (reqs) => { + this.core.apis.FriendApi.clearBuddyReqUnreadCnt(); + for (let i = 0; i < reqs.unreadNums; i++) { + const req = reqs.buddyReqs[i]; + if (!req) continue; + if (!!req.isInitiator || (req.isDecide && req.reqType !== BuddyReqType.KMEINITIATORWAITPEERCONFIRM) || !req.isUnread) { + continue; + } + try { + const requesterUin = await this.core.apis.UserApi.getUinByUidV2(req.friendUid); + await this.networkManager.emitEvent( + new OB11FriendRequestEvent( + this.core, + +requesterUin, + req.extWords, + req.reqTime + ) + ); + } catch (e) { + this.context.logger.logDebug('获取加好友者QQ号失败', e); + } + } + }; + this.context.session + .getBuddyService() + .addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger)); + } + + private initGroupListener () { + const groupListener = new NodeIKernelGroupListener(); + + groupListener.onGroupNotifiesUpdated = async (_, notifies) => { + await this.core.apis.GroupApi.clearGroupNotifiesUnreadCount(false); + if (!notifies[0]?.type) return; + if ( + ![ + GroupNotifyMsgType.SET_ADMIN, + GroupNotifyMsgType.CANCEL_ADMIN_NOTIFY_CANCELED, + GroupNotifyMsgType.CANCEL_ADMIN_NOTIFY_ADMIN, + ].includes(notifies[0]?.type) + ) { + for (const notify of notifies) { + const notifyTime = parseInt(notify.seq) / 1000 / 1000; + // log(`群通知时间${notifyTime}`, `启动时间${this.bootTime}`); + if (notifyTime < this.bootTime) { + continue; + } + const flag = notify.seq; + this.context.logger.logDebug('收到群通知', notify); + if ( + [GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS].includes(notify.type) && + notify.status === GroupNotifyMsgStatus.KUNHANDLE + ) { + this.context.logger.logDebug('有加群请求'); + try { + const requestUin = await this.core.apis.UserApi.getUinByUidV2(notify.user1.uid); + const groupRequestEvent = new OB11GroupRequestEvent( + this.core, + parseInt(notify.group.groupCode), + parseInt(requestUin), + 'add', + notify.postscript, + flag + ); + this.networkManager + .emitEvent(groupRequestEvent) + .catch((e) => + this.context.logger.logError('处理加群请求失败', e) + ); + } catch (e) { + this.context.logger.logError( + '获取加群人QQ号失败 Uid:', + notify.user1.uid, + e + ); + } + } else if ( + notify.type === GroupNotifyMsgType.INVITED_BY_MEMBER && + notify.status === GroupNotifyMsgStatus.KUNHANDLE + ) { + this.context.logger.logDebug(`收到邀请我加群通知:${notify}`); + const groupInviteEvent = new OB11GroupRequestEvent( + this.core, + +notify.group.groupCode, + +await this.core.apis.UserApi.getUinByUidV2(notify.user2.uid), + 'invite', + notify.postscript, + flag + ); + this.networkManager + .emitEvent(groupInviteEvent) + .catch((e) => + this.context.logger.logError('处理邀请本人加群失败', e) + ); + } else if ( + notify.type === GroupNotifyMsgType.INVITED_NEED_ADMINI_STRATOR_PASS && + notify.status === GroupNotifyMsgStatus.KUNHANDLE + ) { + this.context.logger.logDebug(`收到群员邀请加群通知:${notify}`); + const groupInviteEvent = new OB11GroupRequestEvent( + this.core, + +notify.group.groupCode, + +await this.core.apis.UserApi.getUinByUidV2(notify.user1.uid), + 'add', + notify.postscript, + flag + ); + this.networkManager + .emitEvent(groupInviteEvent) + .catch((e) => + this.context.logger.logError('处理邀请本人加群失败', e) + ); + } + } + } + }; + this.context.session + .getGroupService() + .addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger)); + } + + private async emitMsg (message: RawMessage) { + this.context.logger.logDebug('收到新消息 RawMessage', message); + await Promise.allSettled([ + this.handleMsg(message), + message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message), + ]); + } + + private async handleMsg (message: RawMessage) { + // 过滤无效消息 + if (message.msgType === NTMsgType.KMSGTYPENULL) { + return; + } + try { + const ob11Msg = await this.apis.MsgApi.parseMessageV2(message, this.configLoader.configData.parseMultMsg); + if (ob11Msg) { + const isSelfMsg = this.isSelfMessage(ob11Msg); + this.context.logger.logDebug('转化为 OB11Message', ob11Msg); + if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) { + const targetId = parseInt(message.peerUin); + ob11Msg.stringMsg.target_id = targetId; + ob11Msg.arrayMsg.target_id = targetId; + } + + const msgMap = new Map(); + + for (const adapter of this.networkManager.adapters.values()) { + if (!adapter.isActive) continue; + const config = adapter.config; + if (isSelfMsg) { + if (!('reportSelfMessage' in config) || !config.reportSelfMessage) { + continue; + } + } + const msgData = config['messagePostFormat'] === 'string' ? ob11Msg.stringMsg : ob11Msg.arrayMsg; + if (config.debug) { + const clone = structuredClone(msgData); + clone.raw = message; + msgMap.set(adapter.name, clone); + } else { + msgMap.set(adapter.name, msgData); + } + } + if (msgMap.size > 0) { + this.networkManager.emitEventByNames(msgMap); + } else if (this.networkManager.hasActiveAdapters()) { + this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message); + } + } + } catch (e) { + this.context.logger.logError('constructMessage error: ', e); + } + } + + private isSelfMessage (ob11Msg: { + stringMsg: OB11Message; + arrayMsg: OB11Message; + }): boolean { + return ob11Msg.stringMsg.user_id.toString() === this.core.selfInfo.uin || + ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin; + } + + private async handleGroupEvent (message: RawMessage) { + try { + // 群名片修改事件解析 任何都该判断 + if (message.senderUin && message.senderUin !== '0') { + const cardChangedEvent = await this.apis.GroupApi.parseCardChangedEvent(message); + if (cardChangedEvent) { + await this.networkManager.emitEvent(cardChangedEvent); + } + } + if (message.msgType === NTMsgType.KMSGTYPEFILE) { + // 文件为单元素消息 + const elementWrapper = message.elements.find(e => !!e.fileElement); + if (elementWrapper?.fileElement) { + const uploadGroupFileEvent = await this.apis.GroupApi.parseGroupUploadFileEvene(message, elementWrapper.fileElement, elementWrapper); + if (uploadGroupFileEvent) { + await this.networkManager.emitEvent(uploadGroupFileEvent); + } + } + } else if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) { + // 灰条为单元素消息 + const grayTipElement = message.elements[0]?.grayTipElement; + if (grayTipElement) { + const event = await this.apis.GroupApi.parseGrayTipElement(message, grayTipElement); + if (event) { + await this.networkManager.emitEvent(event); + } + } + } + } catch (e) { + this.context.logger.logError('constructGroupEvent error: ', e); + } + } + + private async handlePrivateMsgEvent (message: RawMessage) { + try { + if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) { + // 灰条为单元素消息 + const grayTipElement = message.elements[0]?.grayTipElement; + if (grayTipElement) { + const event = await this.apis.MsgApi.parsePrivateMsgEvent(message, grayTipElement); + if (event) { + await this.networkManager.emitEvent(event); + } + } + } + } catch (e) { + this.context.logger.logError('constructPrivateEvent error: ', e); + } + } + + private getRecallElement (message: RawMessage): MessageElement | undefined { + if (message.msgType !== NTMsgType.KMSGTYPEGRAYTIPS) { + return undefined; + } + return message.elements.find(element => !!element.grayTipElement?.revokeElement); + } + + private isCurrentRecallUpdate (message: RawMessage): boolean { + const recallTime = Number(message.recallTime); + if (!Number.isFinite(recallTime) || recallTime <= 0) { + return false; + } + const recallTimeInSeconds = recallTime > 10_000_000_000 ? recallTime / 1000 : recallTime; + return recallTimeInSeconds >= this.bootTime - RECALL_UPDATE_CLOCK_SKEW_SECONDS; + } + + private getRecallEventKey (message: RawMessage): string { + return `${message.chatType}/${message.peerUid}/${message.msgId}`; + } + + private hasReportedRecallEvent (key: string): boolean { + return this.reportedRecallEventCache.has(key); + } + + private markRecallEventReported (key: string): void { + this.reportedRecallEventCache.delete(key); + this.reportedRecallEventCache.set(key, true); + while (this.reportedRecallEventCache.size > RECALL_EVENT_DEDUP_MAX_SIZE) { + const oldestKey = this.reportedRecallEventCache.keys().next().value; + if (oldestKey === undefined) break; + this.reportedRecallEventCache.delete(oldestKey); + } + } + + private async handleRecallMessage (message: RawMessage, element: MessageElement): Promise { + const revokeElement = element.grayTipElement?.revokeElement; + if (!revokeElement) return; + + if (revokeElement.isSelfOperate) { + const selfRecallTimeout = this.recallEventCache.get(message.msgId); + if (selfRecallTimeout) { + clearTimeout(selfRecallTimeout); + this.recallEventCache.delete(message.msgId); + this.context.logger.logDebug('自操作消息撤回事件'); + } + } + + const key = this.getRecallEventKey(message); + while (!this.hasReportedRecallEvent(key)) { + const processing = this.processingRecallEvents.get(key); + if (processing) { + // A duplicate callback becomes the retry path if the in-flight report fails. + await processing; + continue; + } + + const task = (async () => { + try { + const recallEvent = await this.emitRecallMsg(message, element); + if (recallEvent) { + await this.networkManager.emitEvent(recallEvent); + this.markRecallEventReported(key); + } + } catch (e) { + this.context.logger.logError('处理消息撤回失败', e); + } + })(); + + this.processingRecallEvents.set(key, task); + try { + await task; + } finally { + if (this.processingRecallEvents.get(key) === task) { + this.processingRecallEvents.delete(key); + } + } + return; + } + } + + private async emitRecallMsg (message: RawMessage, element: MessageElement) { + const peer: Peer = { chatType: message.chatType, peerUid: message.peerUid, guildId: '' }; + const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId); + if (message.chatType === ChatType.KCHATTYPEC2C) { + return await this.emitFriendRecallMsg(message, oriMessageId, element); + } else if (message.chatType === ChatType.KCHATTYPEGROUP) { + return await this.emitGroupRecallMsg(message, oriMessageId, element); + } + return undefined; + } + + private async emitFriendRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) { + const operatorUid = element.grayTipElement?.revokeElement.operatorUid; + if (!operatorUid) return undefined; + return new OB11FriendRecallNoticeEvent( + this.core, + +message.senderUin, + oriMessageId + ); + } + + private async emitGroupRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) { + const operatorUid = element.grayTipElement?.revokeElement.operatorUid; + if (!operatorUid) return undefined; + const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid); + return new OB11GroupRecallNoticeEvent( + this.core, + +message.peerUin, + +message.senderUin, + +operatorId, + oriMessageId + ); + } +} + +export * from './types/index'; +export * from './api/index'; +export * from './event/index'; +export * from './config/index'; diff --git a/packages/snowcat-onebot/network/adapter.ts b/packages/snowcat-onebot/network/adapter.ts new file mode 100644 index 0000000..7f221ae --- /dev/null +++ b/packages/snowcat-onebot/network/adapter.ts @@ -0,0 +1,37 @@ +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { LogWrapper } from 'snowcat-core/helper/log'; +import { SnowCatCore } from 'snowcat-core'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { ActionMap } from '@/snowcat-onebot/action'; +import { OB11EmitEventContent, OB11NetworkReloadType } from '@/snowcat-onebot/network/index'; + +export abstract class IOB11NetworkAdapter { + name: string; + isEnable: boolean = false; + config: CT; + readonly logger: LogWrapper; + readonly core: SnowCatCore; + readonly obContext: NapCatOneBot11Adapter; + readonly actions: ActionMap; + + constructor (name: string, config: CT, core: SnowCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) { + this.name = name; + this.config = structuredClone(config); + this.core = core; + this.obContext = obContext; + this.actions = actions; + this.logger = core.context.logger; + } + + abstract onEvent (event: T): Promise; + + abstract open (): void | Promise; + + abstract close (): void | Promise; + + abstract reload (config: unknown): OB11NetworkReloadType | Promise; + + get isActive (): boolean { + return this.isEnable; + } +} diff --git a/packages/snowcat-onebot/network/http-client.ts b/packages/snowcat-onebot/network/http-client.ts new file mode 100644 index 0000000..58ed3de --- /dev/null +++ b/packages/snowcat-onebot/network/http-client.ts @@ -0,0 +1,61 @@ +import { OB11EmitEventContent, OB11NetworkReloadType } from '@/snowcat-onebot/network/index'; +import { createHmac } from 'crypto'; +import { QuickAction, QuickActionEvent } from '@/snowcat-onebot/types'; +import { RequestUtil } from 'snowcat-common/src/request'; +import { HttpClientConfig } from '@/snowcat-onebot/config/config'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import json5 from 'json5'; + +export class OB11HttpClientAdapter extends IOB11NetworkAdapter { + async onEvent (event: T) { + this.emitEventAsync(event).catch(e => this.logger.logError('[OneBot] [Http Client] 新消息事件HTTP上报返回快速操作失败', e)); + } + + async emitEventAsync (event: T) { + if (!this.isEnable) return; + const headers: Record = { + 'Content-Type': 'application/json', + 'x-self-id': this.core.selfInfo.uin, + }; + + const msgStr = JSON.stringify(event); + if (this.config.token) { + const hmac = createHmac('sha1', this.config.token); + hmac.update(msgStr); + headers['x-signature'] = 'sha1=' + hmac.digest('hex'); + } + + const data = await RequestUtil.HttpGetText(this.config.url, 'POST', msgStr, headers); + const resJson: QuickAction = data ? json5.parse(data) : {}; + + await this.obContext.apis.QuickActionApi.handleQuickOperation(event as QuickActionEvent, resJson); + } + + open () { + this.isEnable = true; + } + + close () { + this.isEnable = false; + } + + async reload (newConfig: HttpClientConfig) { + const wasEnabled = this.isEnable; + const oldUrl = this.config.url; + this.config = newConfig; + + if (newConfig.enable && !wasEnabled) { + this.open(); + return OB11NetworkReloadType.NetWorkOpen; + } else if (!newConfig.enable && wasEnabled) { + this.close(); + return OB11NetworkReloadType.NetWorkClose; + } + + if (oldUrl !== newConfig.url) { + return OB11NetworkReloadType.NetWorkReload; + } + + return OB11NetworkReloadType.Normal; + } +} diff --git a/packages/snowcat-onebot/network/http-server-sse.ts b/packages/snowcat-onebot/network/http-server-sse.ts new file mode 100644 index 0000000..db5d5aa --- /dev/null +++ b/packages/snowcat-onebot/network/http-server-sse.ts @@ -0,0 +1,48 @@ +import { OB11EmitEventContent } from './index'; +import { Request, Response } from 'express'; +import { OB11HttpServerAdapter } from './http-server'; + +export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter { + private sseClients: Response[] = []; + + override get isActive (): boolean { + return this.isEnable && (this.sseClients.length > 0 || super.isActive); + } + + override async handleRequest (req: Request, res: Response) { + if (req.path === '/_events') { + this.createSseSupport(req, res); + } else { + super.httpApiRequest(req, res, true); + } + } + + private async createSseSupport (req: Request, res: Response) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + this.sseClients.push(res); + req.on('close', () => { + this.sseClients = this.sseClients.filter((client) => client !== res); + }); + } + + override async onEvent (event: T) { + super.onEvent(event); + const promises: Promise[] = []; + this.sseClients.forEach((res) => { + promises.push(new Promise((resolve, reject) => { + res.write(`data: ${JSON.stringify(event)}\n\n`, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + })); + }); + await Promise.allSettled(promises); + } +} diff --git a/packages/snowcat-onebot/network/http-server.ts b/packages/snowcat-onebot/network/http-server.ts new file mode 100644 index 0000000..d4d2382 --- /dev/null +++ b/packages/snowcat-onebot/network/http-server.ts @@ -0,0 +1,332 @@ +import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; +import express, { Express, NextFunction, Request, Response } from 'express'; +import http, { IncomingMessage } from 'http'; +import { OB11Response } from '@/snowcat-onebot/action/OneBotAction'; +import cors from 'cors'; +import { HttpServerConfig } from '@/snowcat-onebot/config/config'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import json5 from 'json5'; +import { isFinished } from 'on-finished'; +import typeis from 'type-is'; +import { WebSocket, WebSocketServer, RawData } from 'ws'; +import { URL } from 'url'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11HeartbeatEvent } from '@/snowcat-onebot/event/meta/OB11HeartbeatEvent'; +import { OB11LifeCycleEvent, LifeCycleSubType } from '@/snowcat-onebot/event/meta/OB11LifeCycleEvent'; + +export class OB11HttpServerAdapter extends IOB11NetworkAdapter { + private app: Express | undefined; + private server: http.Server | undefined; + private wsServer?: WebSocketServer; + private wsClients: WebSocket[] = []; + private heartbeatIntervalId: NodeJS.Timeout | null = null; + private wsClientWithEvent: WebSocket[] = []; + + override get isActive (): boolean { + return this.isEnable && this.wsClientWithEvent.length > 0; + } + + override async onEvent (event: T) { + // http server is passive, no need to emit event + const promises = this.wsClientWithEvent.map((wsClient) => { + return new Promise((resolve, reject) => { + if (wsClient.readyState === WebSocket.OPEN) { + wsClient.send(JSON.stringify(event)); + resolve(); + } else { + reject(new Error('WebSocket is not open')); + } + }); + }); + await Promise.allSettled(promises); + } + + open () { + try { + if (this.isEnable) { + this.core.context.logger.logError('Cannot open a closed HTTP server'); + return; + } + if (!this.isEnable) { + this.initializeServer(); + this.isEnable = true; + } + } catch (e) { + this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] Boot Error: ${e}`); + } + } + + async close () { + this.isEnable = false; + this.server?.close(); + this.app = undefined; + this.stopHeartbeat(); + this.wsClients.forEach((wsClient) => wsClient.close()); + this.wsClients = []; + this.wsClientWithEvent = []; + this.wsServer?.close(); + } + + private initializeServer () { + this.app = express(); + this.server = http.createServer(this.app); + if (this.config.enableWebsocket) { + this.wsServer = new WebSocketServer({ server: this.server }); + this.createWSServer(this.wsServer); + } + + this.app.use(cors()); + this.app.use(express.urlencoded({ extended: true, limit: '5000mb' })); + + this.app.use((req, res, next) => { + if (isFinished(req)) { + next(); + return; + } + if (!typeis.hasBody(req)) { + next(); + return; + } + // 兼容处理没有带content-type的请求 + req.headers['content-type'] = 'application/json'; + let rawData = ''; + req.on('data', (chunk) => { + rawData += chunk; + }); + req.on('end', () => { + try { + req.body = { ...json5.parse(rawData || '{}'), ...req.body }; + next(); + } catch { + res.status(400).send('Invalid JSON'); + } + }); + req.on('error', () => { + return res.status(400).send('Invalid JSON'); + }); + }); + this.app.use((req, res, next) => this.authorize(this.config.token, req, res, next)); + this.app.use(async (req, res) => { + await this.handleRequest(req, res); + }); + this.server.listen(this.config.port, this.config.host, () => { + this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] Start On ${this.config.host}:${this.config.port}`); + }); + } + + private authorize (token: string | undefined, req: Request, res: Response, next: NextFunction) { + if (!token || token.length === 0) return next();// 客户端未设置密钥 + const HeaderClientToken = req.headers.authorization?.split('Bearer ').pop() || ''; + const QueryClientToken = req.query['access_token']; + const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken; + if (ClientToken === token) { + return next(); + } else { + return res.status(403).send(JSON.stringify({ message: 'token verify failed!' })); + } + } + + createWSServer (newServer: WebSocketServer) { + newServer.on('connection', async (wsClient, wsReq) => { + if (!this.isEnable) { + wsClient.close(); + return; + } + if (!this.authorizeWS(this.config.token, wsClient, wsReq)) { + return; + } + const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url; + const isApiConnect = paramUrl === '/api' || paramUrl === '/api/'; + if (!isApiConnect) { + this.connectEvent(this.core, wsClient); + } + + wsClient.on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Client Error:', err.message)); + wsClient.on('message', (message) => { + this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e)); + }); + wsClient.on('pong', () => { + // this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received'); + }); + wsClient.once('close', () => { + const NormolIndex = this.wsClients.indexOf(wsClient); + if (NormolIndex !== -1) { + this.wsClients.splice(NormolIndex, 1); + } + const EventIndex = this.wsClientWithEvent.indexOf(wsClient); + if (EventIndex !== -1) { + this.wsClientWithEvent.splice(EventIndex, 1); + } + if (this.wsClientWithEvent.length === 0) { + this.stopHeartbeat(); + } + }); + if (!isApiConnect) { + this.wsClientWithEvent.push(wsClient); + } + this.wsClients.push(wsClient); + if (this.wsClientWithEvent.length > 0) { + this.startHeartbeat(); + } + }).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message)); + } + + connectEvent (core: any, wsClient: WebSocket) { + try { + this.checkStateAndReply(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e)); + } catch (e) { + this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e); + } + } + + private startHeartbeat () { + if (this.heartbeatIntervalId) return; + this.heartbeatIntervalId = setInterval(() => { + this.wsClientWithEvent.forEach((wsClient) => { + if (wsClient.readyState === WebSocket.OPEN) { + wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true))); + } + }); + }, 30000); + } + + private stopHeartbeat () { + if (this.heartbeatIntervalId) { + clearInterval(this.heartbeatIntervalId); + this.heartbeatIntervalId = null; + } + } + + private authorizeWS (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) { + if (!token || token.length === 0) return true; + const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`); + const QueryClientToken = url.searchParams.get('access_token'); + const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || ''; + const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken; + if (ClientToken === token) { + return true; + } + wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败'))); + wsClient.close(); + return false; + } + + private async checkStateAndReply (data: T, wsClient: WebSocket) { + return await new Promise((resolve, reject) => { + if (wsClient.readyState === WebSocket.OPEN) { + wsClient.send(JSON.stringify(data)); + resolve(); + } else { + reject(new Error('WebSocket is not open')); + } + }); + } + + private async handleWSMessage (wsClient: WebSocket, message: RawData) { + let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} }; + let echo; + try { + receiveData = json5.parse(message.toString()); + echo = receiveData.echo; + } catch { + await this.checkStateAndReply(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient); + return; + } + receiveData.params = (receiveData?.params) ? receiveData.params : {}; + + const action = this.actions.get(receiveData.action as any); + if (!action) { + this.logger.logError('[OneBot] [HTTP WebSocket] 发生错误', '不支持的API ' + receiveData.action); + await this.checkStateAndReply(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient); + return; + } + const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, { + send: async (data: object) => { + await this.checkStateAndReply({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient); + }, + }); + await this.checkStateAndReply({ ...retdata }, wsClient); + } + + async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) { + let payload = req.body; + if (req.method === 'GET') { + payload = req.query; + } else if (req.query) { + payload = { ...req.body, ...req.query }; + } + if (req.path === '' || req.path === '/') { + const hello = OB11Response.ok({}); + hello.message = 'NapCat4 Is Running'; + return res.json(hello); + } + const actionName = req.path.split('/')[1]; + const payload_echo = payload['echo']; + const real_echo = payload_echo ?? Math.random().toString(36).substring(2, 15); + + const action = this.actions.get(actionName as any); + if (action) { + const useStream = action.useStream; + try { + const result = await action.handle(payload, this.name, this.config, { + send: request_sse + ? async (data: object) => { + await this.onEvent({ ...OB11Response.ok(data, real_echo, true) } as unknown as OB11EmitEventContent); + } + : async (data: object) => { + const newPromise = new Promise((resolve, _reject) => { + res.write(JSON.stringify({ ...OB11Response.ok(data, real_echo, true) }) + '\r\n\r\n', () => { + resolve(); + }); + }); + return newPromise; + }, + }, real_echo); + if (useStream) { + res.write(JSON.stringify({ ...result }) + '\r\n\r\n'); + return res.end(); + } + return res.json(result); + } catch (error: unknown) { + return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200, real_echo)); + } + } else { + return res.json(OB11Response.error('不支持的Api ' + actionName, 200, real_echo)); + } + } + + async handleRequest (req: Request, res: Response) { + if (!this.isEnable) { + this.core.context.logger.log('[OneBot] [HTTP Server Adapter] Server is closed'); + res.json(OB11Response.error('Server is closed', 200)); + return; + } + this.httpApiRequest(req, res); + } + + async reload (newConfig: HttpServerConfig) { + const wasEnabled = this.isEnable; + const oldPort = this.config.port; + const oldHost = this.config.host; + const oldEnableWebsocket = this.config.enableWebsocket; + this.config = newConfig; + + if (newConfig.enable && !wasEnabled) { + this.open(); + return OB11NetworkReloadType.NetWorkOpen; + } else if (!newConfig.enable && wasEnabled) { + this.close(); + return OB11NetworkReloadType.NetWorkClose; + } + + if (oldPort !== newConfig.port || oldHost !== newConfig.host || oldEnableWebsocket !== newConfig.enableWebsocket) { + this.close(); + if (newConfig.enable) { + this.open(); + } + return OB11NetworkReloadType.NetWorkReload; + } + + return OB11NetworkReloadType.Normal; + } +} diff --git a/packages/snowcat-onebot/network/index.ts b/packages/snowcat-onebot/network/index.ts new file mode 100644 index 0000000..e36c826 --- /dev/null +++ b/packages/snowcat-onebot/network/index.ts @@ -0,0 +1,114 @@ +import { OneBotEvent } from '@/snowcat-onebot/event/OneBotEvent'; +import { OB11Message } from '@/snowcat-onebot/index'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; + +export type OB11EmitEventContent = OneBotEvent | OB11Message; +export enum OB11NetworkReloadType { + Normal = 0, + ConfigChange = 1, + NetWorkReload = 2, + NetWorkClose = 3, + NetWorkOpen = 4, +} + +export class OB11NetworkManager { + adapters: Map> = new Map(); + + async openAllAdapters () { + return Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.open())); + } + + async emitEvent (event: OB11EmitEventContent) { + return Promise.all(Array.from(this.adapters.values()).map(async adapter => { + if (adapter.isActive) { + return await adapter.onEvent(event); + } + })); + } + + async emitEvents (events: OB11EmitEventContent[]) { + return Promise.all(events.map(event => this.emitEvent(event))); + } + + async emitEventByName (names: string[], event: OB11EmitEventContent) { + return Promise.all(names.map(async name => { + const adapter = this.adapters.get(name); + if (adapter && adapter.isActive) { + return await adapter.onEvent(event); + } + })); + } + + async emitEventByNames (map: Map) { + return Promise.all(Array.from(map.entries()).map(async ([name, event]) => { + const adapter = this.adapters.get(name); + if (adapter && adapter.isActive) { + return await adapter.onEvent(event); + } + })); + } + + registerAdapter (adapter: IOB11NetworkAdapter) { + this.adapters.set(adapter.name, adapter); + } + + async registerAdapterAndOpen (adapter: IOB11NetworkAdapter) { + this.registerAdapter(adapter); + await adapter.open(); + } + + async closeSomeAdapters (adaptersToClose: IOB11NetworkAdapter[]) { + for (const adapter of adaptersToClose) { + this.adapters.delete(adapter.name); + await adapter.close(); + } + } + + async closeSomeAdaterWhenOpen (adaptersToClose: IOB11NetworkAdapter[]) { + for (const adapter of adaptersToClose) { + this.adapters.delete(adapter.name); + if (adapter.isEnable) { + await adapter.close(); + } + } + } + + findSomeAdapter (name: string) { + return this.adapters.get(name); + } + + async closeAdapterByPredicate (closeFilter: (adapter: IOB11NetworkAdapter) => boolean) { + const adaptersToClose = Array.from(this.adapters.values()).filter(closeFilter); + await this.closeSomeAdapters(adaptersToClose); + } + + async closeAllAdapters () { + await Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.close())); + this.adapters.clear(); + } + + async readloadAdapter (name: string, config: T) { + const adapter = this.adapters.get(name); + if (adapter) { + await adapter.reload(config); + } + } + + async readloadSomeAdapters (configMap: Map) { + await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config))); + } + + hasActiveAdapters (): boolean { + return Array.from(this.adapters.values()).some(adapter => adapter.isActive); + } + + async getAllConfig () { + return Array.from(this.adapters.values()).map(adapter => adapter.config); + } +} + +export * from './http-client'; +export * from './websocket-client'; +export * from './http-server'; +export * from './websocket-server'; diff --git a/packages/snowcat-onebot/network/mcp-server.ts b/packages/snowcat-onebot/network/mcp-server.ts new file mode 100644 index 0000000..4b97955 --- /dev/null +++ b/packages/snowcat-onebot/network/mcp-server.ts @@ -0,0 +1,216 @@ +import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; +import express, { Express, Request, Response } from 'express'; +import http from 'http'; +import cors from 'cors'; +import { McpServerConfig } from '@/snowcat-onebot/config/config'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; + +/** + * SnowCat MCP 服务器适配器 + * + * 把 OneBot v11 全部动作以 MCP(Model Context Protocol)工具形式暴露, + * AI 宿主(Claude / dsh / 任何 MCP 客户端)直连调用。 + * + * 传输:Streamable HTTP(POST /mcp 单次 JSON-RPC;GET /mcp 返回 SSE 事件流)。 + * 鉴权:Bearer token(与网络配置 token 一致;为空则不鉴权)。 + */ +export class OB11McpServerAdapter extends IOB11NetworkAdapter { + private app: Express | undefined; + private server: http.Server | undefined; + private sseClients: Response[] = []; + + override get isActive (): boolean { + return this.isEnable; + } + + override async onEvent (event: T) { + // MCP 侧事件推给已建立 SSE 流的客户端(notifications 可选通道) + const payload = `event: ob11_event\ndata: ${JSON.stringify(event)}\n\n`; + const promises = this.sseClients.map((res) => new Promise((resolve) => { + res.write(payload, () => resolve()); + })); + await Promise.allSettled(promises); + } + + open () { + try { + if (this.isEnable) { + this.core.context.logger.logError('Cannot open a closed MCP server'); + return; + } + this.initializeServer(); + this.isEnable = true; + } catch (e) { + this.core.context.logger.logError(`[OneBot] [MCP Server Adapter] Boot Error: ${e}`); + } + } + + async close () { + this.isEnable = false; + this.sseClients.forEach((res) => res.end()); + this.sseClients = []; + this.server?.close(); + this.app = undefined; + } + + async reload (newConfig: McpServerConfig) { + const wasEnabled = this.isEnable; + const oldPort = this.config.port; + const oldHost = this.config.host; + const oldToken = this.config.token; + this.config = newConfig; + + if (newConfig.enable && !wasEnabled) { + this.open(); + return OB11NetworkReloadType.NetWorkOpen; + } else if (!newConfig.enable && wasEnabled) { + await this.close(); + return OB11NetworkReloadType.NetWorkClose; + } + if (oldPort !== newConfig.port || oldHost !== newConfig.host || oldToken !== newConfig.token) { + await this.close(); + if (newConfig.enable) { + this.open(); + return OB11NetworkReloadType.NetWorkOpen; + } + return OB11NetworkReloadType.NetWorkClose; + } + return OB11NetworkReloadType.Normal; + } + + private authOk (req: Request): boolean { + const token = this.config.token; + if (!token) return true; + const auth = req.headers.authorization ?? ''; + return auth === `Bearer ${token}`; + } + + private jsonRpcError (res: Response, id: unknown, code: number, message: string) { + res.json({ jsonrpc: '2.0', id, error: { code, message } }); + } + + private toolNameFor (actionName: string): string { + // action 名已是 PascalCase(如 SendGroupMsg),MCP 工具名转 snake_case 更顺手 + return actionName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); + } + + private initializeServer () { + this.app = express(); + this.server = http.createServer(this.app); + this.app.use(cors()); + this.app.use(express.json({ limit: '50mb' })); + + // 鉴权 + this.app.use((req, res, next) => { + if (!this.authOk(req)) { + res.status(401).json({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Unauthorized' } }); + return; + } + next(); + }); + + // GET /mcp → SSE 事件流(可选) + this.app.get('/mcp', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + res.write(': stream open\n\n'); + this.sseClients.push(res); + req.on('close', () => { + this.sseClients = this.sseClients.filter((c) => c !== res); + }); + }); + + // POST /mcp → JSON-RPC + this.app.post('/mcp', async (req, res) => { + const body = req.body ?? {}; + const { id, method, params } = body as { id?: unknown; method?: string; params?: Record }; + try { + switch (method) { + case 'initialize': + res.json({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: '2025-03-26', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'snowcat-onebot', version: '1.0.0' }, + }, + }); + return; + case 'notifications/initialized': + res.status(202).end(); + return; + case 'ping': + res.json({ jsonrpc: '2.0', id, result: {} }); + return; + case 'tools/list': { + const tools = (Object.values(ActionName) as string[]).filter(n => n !== ActionName.Unknown).map((name) => { + const action = this.actions.get(name as never) as OneBotAction | undefined; + if (!action) return null; + return { + name: this.toolNameFor(name), + description: `[OneBot:${name}] ${action.actionDescription || name}`, + inputSchema: this.actionInputSchema(action), + }; + }).filter(Boolean); + res.json({ jsonrpc: '2.0', id, result: { tools } }); + return; + } + case 'tools/call': { + const toolName = String(params?.['name'] ?? ''); + const args = (params?.['arguments'] ?? {}) as Record; + // 工具名反查动作名(snake_case → PascalCase) + const pascal = toolName.split('_').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(''); + const actionName = (ActionName as Record)[pascal] ?? pascal; + const action = this.actions.get(actionName as never) as OneBotAction | undefined; + if (!action) { + this.jsonRpcError(res, id, -32602, `Unknown tool: ${toolName}`); + return; + } + const result = await action.handle(args, this.name, this.config, { send: async () => {} }); + // OB11Return: { status, retcode, data, message, ... } → MCP content + const ret = result as { data?: unknown; message?: string; retcode?: number }; + const ok = ret.retcode === 0; + res.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: JSON.stringify(ret.data ?? ret, null, 2) }], + isError: !ok, + }, + }); + return; + } + case 'resources/list': + res.json({ jsonrpc: '2.0', id, result: { resources: [] } }); + return; + case 'prompts/list': + res.json({ jsonrpc: '2.0', id, result: { prompts: [] } }); + return; + default: + this.jsonRpcError(res, id, -32601, `Method not found: ${method}`); + } + } catch (e) { + this.core.context.logger.logError(`[OneBot] [MCP] handler error: ${e}`); + this.jsonRpcError(res, id, -32603, `Internal error: ${(e as Error).message}`); + } + }); + + this.server.listen(this.config.port, this.config.host, () => { + this.core.context.logger.log(`[OneBot] [MCP Server] SnowCat MCP 已启动: http://${this.config.host}:${this.config.port}/mcp(OneBot v11 全量动作)`); + }); + } + + private actionInputSchema (action: OneBotAction): Record { + // 有 typebox payloadSchema 的动作直接透传 JSON Schema;没有的收任意对象 + const ps = action.payloadSchema as unknown as Record | undefined; + if (ps && typeof ps === 'object' && ps['type'] === 'object') { + return ps; + } + return { type: 'object', properties: {}, additionalProperties: true }; + } +} diff --git a/packages/snowcat-onebot/network/plugin-manger.ts b/packages/snowcat-onebot/network/plugin-manger.ts new file mode 100644 index 0000000..0b6ca48 --- /dev/null +++ b/packages/snowcat-onebot/network/plugin-manger.ts @@ -0,0 +1,551 @@ +import fs from 'fs'; +import path from 'path'; +import { ActionMap } from '@/snowcat-onebot/action'; +import { SnowCatCore } from 'snowcat-core'; +import { NapCatOneBot11Adapter, OB11Message } from '@/snowcat-onebot/index'; +import { OB11EmitEventContent, OB11NetworkReloadType } from '@/snowcat-onebot/network/index'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import { PluginConfig } from '@/snowcat-onebot/config/config'; +import { SnowCatConfig } from './plugin/config'; +import { PluginLoader } from './plugin/loader'; +import { + PluginEntry, + PluginLogger, + PluginStatusConfig, + SnowCatPluginContext, + IPluginManager, +} from './plugin/types'; +import { PluginRouterRegistryImpl } from './plugin/router-registry'; + +export { PluginPackageJson } from './plugin/types'; +export { PluginConfigItem } from './plugin/types'; +export { PluginConfigUIController } from './plugin/types'; +export { SnowCatConfig } from './plugin/config'; +export { PluginConfigSchema } from './plugin/types'; +export { PluginLogger } from './plugin/types'; +export { SnowCatPluginContext } from './plugin/types'; +export { PluginModule } from './plugin/types'; +export { PluginStatusConfig } from './plugin/types'; +export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types'; +export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types'; +export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types'; +export { PluginRouterRegistryImpl } from './plugin/router-registry'; +export class OB11PluginMangerAdapter extends IOB11NetworkAdapter implements IPluginManager { + private readonly pluginPath: string; + private readonly configPath: string; + private readonly loader: PluginLoader; + + /** 插件注册表: ID -> 插件条目 */ + private plugins: Map = new Map(); + + /** 插件路由注册表: ID -> 路由注册器 */ + private pluginRouters: Map = new Map(); + + declare config: PluginConfig; + public SnowCatConfig = SnowCatConfig; + + override get isActive (): boolean { + return this.isEnable && this.getLoadedPlugins().length > 0; + } + + constructor ( + name: string, + core: SnowCatCore, + obContext: NapCatOneBot11Adapter, + actions: ActionMap + ) { + const config = { + name, + messagePostFormat: 'array', + reportSelfMessage: true, + enable: true, + debug: true, + }; + super(name, config, core, obContext, actions); + this.pluginPath = this.core.context.pathWrapper.pluginPath; + this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json'); + this.loader = new PluginLoader(this.pluginPath, this.configPath, this.logger); + } + + // ==================== 插件状态配置 ==================== + + public getPluginConfig (): PluginStatusConfig { + return this.loader.loadPluginStatusConfig(); + } + + private savePluginConfig (config: PluginStatusConfig): void { + this.loader.savePluginStatusConfig(config); + } + + // ==================== 插件扫描与加载 ==================== + + /** + * 扫描并加载所有插件 + */ + private async scanAndLoadPlugins (): Promise { + // 扫描所有插件目录 + const entries = await this.loader.scanPlugins(); + + // 清空现有注册表 + this.plugins.clear(); + + // 注册所有插件条目 + for (const entry of entries) { + this.plugins.set(entry.id, entry); + } + + this.logger.log(`[PluginManager] Scanned ${this.plugins.size} plugins`); + + // 加载启用的插件 + for (const entry of this.plugins.values()) { + if (entry.enable && entry.runtime.status !== 'error') { + await this.loadPlugin(entry); + } + } + + const loadedCount = this.getLoadedPlugins().length; + this.logger.log(`[PluginManager] Loaded ${loadedCount} plugins`); + } + + /** + * 加载单个插件 + */ + private async loadPlugin (entry: PluginEntry): Promise { + if (entry.loaded) { + return true; + } + + if (entry.runtime.status === 'error') { + return false; + } + + // 加载模块 + const module = await this.loader.loadPluginModule(entry); + if (!module) { + return false; + } + + // 创建上下文 + const context = this.createPluginContext(entry); + + // 初始化插件 + try { + await module.plugin_init(context); + + entry.loaded = true; + entry.runtime = { + status: 'loaded', + module, + context, + }; + + this.logger.log(`[PluginManager] Initialized plugin: ${entry.id}${entry.version ? ` v${entry.version}` : ''}`); + return true; + } catch (error: any) { + entry.loaded = false; + entry.runtime = { + status: 'error', + error: error.message || 'Initialization failed', + }; + + this.logger.logError(`[PluginManager] Error initializing plugin ${entry.id}:`, error); + return false; + } + } + + /** + * 卸载单个插件 + */ + private async unloadPlugin (entry: PluginEntry): Promise { + if (!entry.loaded || entry.runtime.status !== 'loaded') { + return; + } + + const { module, context } = entry.runtime; + + // 调用清理方法 + if (module && context && typeof module.plugin_cleanup === 'function') { + try { + await module.plugin_cleanup(context); + this.logger.log(`[PluginManager] Cleaned up plugin: ${entry.id}`); + } catch (error) { + this.logger.logError(`[PluginManager] Error cleaning up plugin ${entry.id}:`, error); + } + } + + // 清理插件路由 + const routerRegistry = this.pluginRouters.get(entry.id); + if (routerRegistry) { + routerRegistry.clear(); + this.pluginRouters.delete(entry.id); + } + + // 清理模块缓存 + this.loader.clearCache(entry.pluginPath); + + // 重置状态 + entry.loaded = false; + entry.runtime = { + status: 'unloaded', + }; + + this.logger.log(`[PluginManager] Unloaded plugin: ${entry.id}`); + } + + /** + * 创建插件上下文 + */ + private createPluginContext (entry: PluginEntry): SnowCatPluginContext { + const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id); + const configPath = path.join(dataPath, 'config.json'); + + // 确保插件配置目录存在 + if (!fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); + } + + // 创建插件专用日志器 + const pluginPrefix = `[Plugin: ${entry.id}]`; + const coreLogger = this.logger; + const pluginLogger: PluginLogger = { + log: (...args: any[]) => coreLogger.log(pluginPrefix, ...args), + debug: (...args: any[]) => coreLogger.logDebug(pluginPrefix, ...args), + info: (...args: any[]) => coreLogger.log(pluginPrefix, ...args), + warn: (...args: any[]) => coreLogger.logWarn(pluginPrefix, ...args), + error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args), + }; + + // 创建插件路由注册器 + const routerRegistry = new PluginRouterRegistryImpl(entry.id, entry.pluginPath); + // 保存到路由注册表 + this.pluginRouters.set(entry.id, routerRegistry); + + // 创建获取其他插件导出的方法 + const getPluginExports = (pluginId: string): T | undefined => { + const targetEntry = this.plugins.get(pluginId); + if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') { + return undefined; + } + return targetEntry.runtime.module as T; + }; + + return { + core: this.core, + oneBot: this.obContext, + actions: this.actions, + pluginName: entry.id, + pluginPath: entry.pluginPath, + dataPath, + configPath, + SnowCatConfig, + adapterName: this.name, + pluginManager: this, + logger: pluginLogger, + router: routerRegistry, + getPluginExports, + }; + } + + // ==================== 公共 API ==================== + + /** + * 获取插件目录路径 + */ + public getPluginPath (): string { + return this.pluginPath; + } + + /** + * 获取所有插件条目 + */ + public getAllPlugins (): PluginEntry[] { + return Array.from(this.plugins.values()); + } + + /** + * 获取已加载的插件列表 + */ + public getLoadedPlugins (): PluginEntry[] { + return Array.from(this.plugins.values()).filter(p => p.loaded); + } + + /** + * 通过 ID 获取插件信息 + */ + public getPluginInfo (pluginId: string): PluginEntry | undefined { + return this.plugins.get(pluginId); + } + + /** + * 获取插件路由注册器 + */ + public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined { + return this.pluginRouters.get(pluginId); + } + + /** + * 获取所有插件路由注册器 + */ + public getAllPluginRouters (): Map { + return this.pluginRouters; + } + + /** + * 设置插件状态(启用/禁用) + */ + public async setPluginStatus (pluginId: string, enable: boolean): Promise { + const config = this.getPluginConfig(); + config[pluginId] = enable; + this.savePluginConfig(config); + + const entry = this.plugins.get(pluginId); + if (entry) { + entry.enable = enable; + + if (enable && !entry.loaded) { + // 启用插件 + await this.loadPlugin(entry); + } else if (!enable && entry.loaded) { + // 禁用插件 + await this.unloadPlugin(entry); + } + } + } + + /** + * 通过 ID 加载插件 + */ + public async loadPluginById (pluginId: string): Promise { + let entry = this.plugins.get(pluginId); + + if (!entry) { + // 尝试查找并扫描 + const dirname = this.loader.findPluginDirById(pluginId); + if (!dirname) { + this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found in filesystem`); + return false; + } + + const newEntry = this.loader.rescanPlugin(dirname); + if (!newEntry) { + return false; + } + + this.plugins.set(newEntry.id, newEntry); + entry = newEntry; + } + + if (!entry.enable) { + this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`); + return false; + } + + return await this.loadPlugin(entry); + } + + /** + * 卸载插件(仅从内存卸载) + */ + public async unregisterPlugin (pluginId: string): Promise { + const entry = this.plugins.get(pluginId); + if (entry) { + await this.unloadPlugin(entry); + } + } + + /** + * 卸载并删除插件 + */ + public async uninstallPlugin (pluginId: string, cleanData: boolean = false): Promise { + const entry = this.plugins.get(pluginId); + if (!entry) { + throw new Error(`Plugin ${pluginId} not found`); + } + + const pluginPath = entry.pluginPath; + const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId); + + if (entry.loaded) { + await this.unloadPlugin(entry); + } + + // 从注册表移除 + this.plugins.delete(pluginId); + + // 删除插件目录 + if (fs.existsSync(pluginPath)) { + fs.rmSync(pluginPath, { recursive: true, force: true }); + } + + // 清理插件配置数据 + if (cleanData && fs.existsSync(dataPath)) { + fs.rmSync(dataPath, { recursive: true, force: true }); + } + } + + /** + * 重载指定插件 + */ + public async reloadPlugin (pluginId: string): Promise { + const entry = this.plugins.get(pluginId); + if (!entry) { + this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found`); + return false; + } + + try { + // 卸载插件 + await this.unloadPlugin(entry); + + // 重新扫描 + const newEntry = this.loader.rescanPlugin(entry.fileId); + if (!newEntry) { + return false; + } + + // 更新注册表 + this.plugins.set(newEntry.id, newEntry); + + // 重新加载 + if (newEntry.enable) { + await this.loadPlugin(newEntry); + } + + this.logger.log(`[PluginManager] Plugin ${pluginId} reloaded successfully`); + return true; + } catch (error) { + this.logger.logError(`[PluginManager] Error reloading plugin ${pluginId}:`, error); + return false; + } + } + + /** + * 加载目录插件(用于新安装的插件) + */ + public async loadDirectoryPlugin (dirname: string): Promise { + const entry = this.loader.rescanPlugin(dirname); + if (!entry) { + return; + } + + // 检查是否已存在 + if (this.plugins.has(entry.id)) { + this.logger.logWarn(`[PluginManager] Plugin ${entry.id} already exists`); + return; + } + + this.plugins.set(entry.id, entry); + + if (entry.enable && entry.runtime.status !== 'error') { + await this.loadPlugin(entry); + } + } + + /** + * 获取插件数据目录路径 + */ + public getPluginDataPath (pluginId: string): string { + return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId); + } + + /** + * 获取插件配置文件路径 + */ + public getPluginConfigPath (pluginId: string): string { + return path.join(this.getPluginDataPath(pluginId), 'config.json'); + } + + // ==================== 事件处理 ==================== + + async onEvent (event: T): Promise { + if (!this.isEnable) { + return; + } + + try { + await Promise.allSettled( + this.getLoadedPlugins().map((entry) => + this.callPluginEventHandler(entry, event) + ) + ); + } catch (error) { + this.logger.logError('[PluginManager] Error handling event:', error); + } + } + + /** + * 调用插件的事件处理方法 + */ + private async callPluginEventHandler ( + entry: PluginEntry, + event: OB11EmitEventContent + ): Promise { + if (entry.runtime.status !== 'loaded' || !entry.runtime.module || !entry.runtime.context) { + return; + } + + const { module, context } = entry.runtime; + + try { + // 优先使用 plugin_onevent 方法 + if (typeof module.plugin_onevent === 'function') { + await module.plugin_onevent(context, event); + } + + // 如果是消息事件并且插件有 plugin_onmessage 方法,也调用 + if ( + (event as any).message_type && + typeof module.plugin_onmessage === 'function' + ) { + await module.plugin_onmessage(context, event as OB11Message); + } + } catch (error) { + this.logger.logError(`[PluginManager] Error calling plugin ${entry.id} event handler:`, error); + } + } + + // ==================== 生命周期 ==================== + + async open (): Promise { + if (this.isEnable) { + return; + } + + this.logger.log('[PluginManager] Opening plugin manager...'); + this.isEnable = true; + + // 扫描并加载所有插件 + await this.scanAndLoadPlugins(); + + this.logger.log(`[PluginManager] Plugin manager opened with ${this.getLoadedPlugins().length} plugins loaded`); + } + + async close (): Promise { + if (!this.isEnable) { + return; + } + + this.logger.log('[PluginManager] Closing plugin manager...'); + this.isEnable = false; + + // 卸载所有已加载的插件 + for (const entry of this.plugins.values()) { + if (entry.loaded) { + await this.unloadPlugin(entry); + } + } + + this.logger.log('[PluginManager] Plugin manager closed'); + } + + async reload (): Promise { + this.logger.log('[PluginManager] Reloading plugin manager...'); + + // 先关闭然后重新打开 + await this.close(); + await this.open(); + + this.logger.log('[PluginManager] Plugin manager reloaded'); + return OB11NetworkReloadType.Normal; + } +} diff --git a/packages/snowcat-onebot/network/plugin/config.ts b/packages/snowcat-onebot/network/plugin/config.ts new file mode 100644 index 0000000..44c8ad9 --- /dev/null +++ b/packages/snowcat-onebot/network/plugin/config.ts @@ -0,0 +1,39 @@ +import { PluginConfigItem, PluginConfigSchema } from './types'; + +/** + * NapCat 插件配置构建器 + * 提供便捷的配置项创建方法 + */ +export class SnowCatConfig { + static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem { + return { key, type: 'string', label, default: defaultValue, description, reactive }; + } + + static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem { + return { key, type: 'number', label, default: defaultValue, description, reactive }; + } + + static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem { + return { key, type: 'boolean', label, default: defaultValue, description, reactive }; + } + + static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem { + return { key, type: 'select', label, options, default: defaultValue, description, reactive }; + } + + static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem { + return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive }; + } + + static html (content: string): PluginConfigItem { + return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content }; + } + + static plainText (content: string): PluginConfigItem { + return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content }; + } + + static combine (...items: PluginConfigItem[]): PluginConfigSchema { + return items; + } +} diff --git a/packages/snowcat-onebot/network/plugin/index.ts b/packages/snowcat-onebot/network/plugin/index.ts new file mode 100644 index 0000000..2965d3f --- /dev/null +++ b/packages/snowcat-onebot/network/plugin/index.ts @@ -0,0 +1,23 @@ +// 导出类型 +export type { + PluginPackageJson, + PluginConfigItem, + PluginConfigSchema, + INapCatConfigStatic, + SnowCatConfigClass, + IPluginManager, + PluginConfigUIController, + PluginLogger, + SnowCatPluginContext, + PluginModule, + PluginRuntimeStatus, + PluginRuntime, + PluginEntry, + PluginStatusConfig, +} from './types'; + +// 导出配置构建器 +export { SnowCatConfig } from './config'; + +// 导出加载器 +export { PluginLoader } from './loader'; diff --git a/packages/snowcat-onebot/network/plugin/loader.ts b/packages/snowcat-onebot/network/plugin/loader.ts new file mode 100644 index 0000000..1dd630c --- /dev/null +++ b/packages/snowcat-onebot/network/plugin/loader.ts @@ -0,0 +1,429 @@ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { LogWrapper } from 'snowcat-core/helper/log'; +import { + PluginPackageJson, + PluginModule, + PluginEntry, + PluginStatusConfig, +} from './types'; +const require = createRequire(import.meta.url); + +/** 允许加载的官方插件 ID 白名单。不在表内的插件一律拒绝加载。 */ +const OFFICIAL_PLUGIN_IDS = new Set([ + 'snowcat-plugin-builtin', + 'snowcat-plugin-cleaner', + 'snowcat-plugin-ssqq', + 'snowcat-plugin-qce', +]); + +/** + * 敏感词表:插件源码命中(黑灰产 / 支付发卡相关)即拒绝加载。 + * 仅作启发式威慑,混淆/编码可绕过,并非唯一防线。 + */ +const SENSITIVE_KEYWORDS: string[] = [ + '发卡', '卡密', '卡网', '自动发卡', '寄售', '代刷', '刷单', '跑分', + '四件套', '洗钱', '黑产', '灰产', '博彩', '赌博', '菠菜', '涉黄', + '色情', '收款码', '代付', '担保交易', '第三方支付', 'USDT', +]; + +/** 纳入敏感词检测的源码扩展名 */ +const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts']); +/** 跳过敏感词扫描的目录名 */ +const SCAN_SKIP_DIRS = new Set(['data', 'node_modules', '.git']); +/** 单文件扫描大小上限,避免超大文件拖慢启动 */ +const SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024; + +/** + * 插件加载器 + * 负责扫描、加载和导入插件模块 + */ +export class PluginLoader { + constructor ( + private readonly pluginPath: string, + private readonly configPath: string, + private readonly logger: LogWrapper + ) { } + + /** + * 加载插件状态配置 + */ + loadPluginStatusConfig (): PluginStatusConfig { + if (fs.existsSync(this.configPath)) { + try { + return JSON.parse(fs.readFileSync(this.configPath, 'utf-8')); + } catch (e) { + this.logger.logWarn('[PluginLoader] Error parsing plugins.json', e); + } + } + return {}; + } + + /** + * 保存插件状态配置 + */ + savePluginStatusConfig (config: PluginStatusConfig): void { + try { + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8'); + } catch (e) { + this.logger.logError('[PluginLoader] Error saving plugins.json', e); + } + } + + /** + * 扫描插件目录,收集所有有效插件条目(异步版本,验证模块有效性) + * 只有包含有效 plugin_init 函数的插件才会被收集 + */ + async scanPlugins (): Promise { + const entries: PluginEntry[] = []; + + // 确保插件目录存在 + if (!fs.existsSync(this.pluginPath)) { + this.logger.logWarn(`[PluginLoader] Plugin directory does not exist: ${this.pluginPath}`); + fs.mkdirSync(this.pluginPath, { recursive: true }); + return entries; + } + + const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); + const statusConfig = this.loadPluginStatusConfig(); + + for (const item of items) { + if (!item.isDirectory()) { + continue; + } + + const entry = this.scanDirectoryPlugin(item.name, statusConfig); + if (!entry) { + continue; + } + + // 如果没有入口文件,跳过 + if (!entry.entryPath) { + this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: no entry file found`); + continue; + } + + // 如果插件被禁用,跳过模块验证,直接添加到列表 + if (!entry.enable) { + entries.push(entry); + continue; + } + + // 验证模块有效性(仅对启用的插件) + const validation = await this.validatePluginEntry(entry.entryPath); + if (!validation.valid) { + this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: ${validation.error}`); + continue; + } + + entries.push(entry); + } + + return entries; + } + + /** + * 扫描单个目录插件 + */ + private scanDirectoryPlugin (dirname: string, statusConfig: PluginStatusConfig): PluginEntry | null { + const pluginDir = path.join(this.pluginPath, dirname); + + try { + // 尝试读取 package.json + let packageJson: PluginPackageJson | undefined; + const packageJsonPath = path.join(pluginDir, 'package.json'); + + if (fs.existsSync(packageJsonPath)) { + try { + const packageContent = fs.readFileSync(packageJsonPath, 'utf-8'); + packageJson = JSON.parse(packageContent); + } catch (error) { + this.logger.logWarn(`[PluginLoader] Invalid package.json in ${dirname}:`, error); + } + } + + // 获取插件 ID(包名或目录名) + const pluginId = packageJson?.name || dirname; + + // 官方白名单 + 敏感词校验:被拒绝的插件视为不可见, + // 不入列表、不加载、更不会被动态导入执行。 + // 该检查置于此处统一拦截 scanPlugins 与 rescanPlugin(安装/重载)两条路径。 + const rejectReason = this.getRejectReason(pluginId, pluginDir); + if (rejectReason) { + this.logger.logWarn(`[PluginLoader] Rejected ${pluginId} (${dirname}): ${rejectReason}`); + return null; + } + + // 确定入口文件 + const entryFile = this.findEntryFile(pluginDir, packageJson); + const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined; + + // 获取启用状态(默认禁用,内置插件除外) + const enable = statusConfig[pluginId] ?? (pluginId === 'snowcat-plugin-builtin'); + + // 创建插件条目 + const entry: PluginEntry = { + id: pluginId, + fileId: dirname, + name: packageJson?.name, + version: packageJson?.version, + description: packageJson?.description, + author: packageJson?.author, + pluginPath: pluginDir, + entryPath, + packageJson, + enable, + loaded: false, + runtime: { + status: 'unloaded', + }, + }; + + // 如果没有入口文件,标记为错误 + if (!entryPath) { + entry.runtime = { + status: 'error', + error: `No valid entry file found for plugin directory: ${dirname}`, + }; + } + + return entry; + } catch (error: any) { + // 创建错误条目 + return { + id: dirname, // 使用目录名作为 ID + fileId: dirname, + pluginPath: path.join(this.pluginPath, dirname), + enable: statusConfig[dirname] ?? (dirname === 'snowcat-plugin-builtin'), + loaded: false, + runtime: { + status: 'error', + error: error.message || 'Unknown error during scan', + }, + }; + } + } + + /** + * 查找插件目录的入口文件 + */ + private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null { + const possibleEntries = [ + packageJson?.main, + 'index.mjs', + 'index.js', + 'main.mjs', + 'main.js', + ].filter(Boolean) as string[]; + + for (const entry of possibleEntries) { + const root = path.resolve(pluginDir); + const entryPath = path.resolve(pluginDir, entry); + // 拒绝 package.json main 指向插件目录之外(防路径穿越) + if (entryPath !== root && !entryPath.startsWith(root + path.sep)) { + continue; + } + if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) { + return entry; + } + } + + return null; + } + + /** 是否为白名单内的官方插件 */ + isOfficialPlugin (pluginId: string): boolean { + return OFFICIAL_PLUGIN_IDS.has(pluginId); + } + + /** + * 判断插件是否应被拒绝(非官方 / 命中敏感词)。 + * @returns 拒绝原因;通过校验返回 null + */ + private getRejectReason (pluginId: string, pluginDir: string): string | null { + const hit = this.scanSensitiveWords(pluginDir); + if (this.isOfficialPlugin(pluginId)) { + return null; + } + if (hit) { + return `sensitive keyword "${hit}"`; + } + return 'not in official plugin whitelist'; + } + + /** + * 递归扫描插件目录内源码是否命中敏感词(黑灰产 / 支付发卡)。 + * @returns 命中的敏感词;未命中返回 null + */ + scanSensitiveWords (pluginDir: string): string | null { + const visit = (dir: string): string | null => { + let items: fs.Dirent[]; + try { + items = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + for (const item of items) { + if (item.isDirectory()) { + if (SCAN_SKIP_DIRS.has(item.name)) { + continue; + } + const hit = visit(path.join(dir, item.name)); + if (hit) { + return hit; + } + continue; + } + if (!item.isFile()) { + continue; + } + if (!SCAN_EXTS.has(path.extname(item.name).toLowerCase())) { + continue; + } + const filePath = path.join(dir, item.name); + try { + if (fs.statSync(filePath).size > SCAN_MAX_FILE_BYTES) { + continue; + } + const content = fs.readFileSync(filePath, 'utf-8'); + for (const word of SENSITIVE_KEYWORDS) { + if (content.includes(word)) { + return word; + } + } + } catch { + // 读取失败忽略该文件 + } + } + return null; + }; + return visit(pluginDir); + } + + /** + * 动态导入模块 + */ + async importModule (filePath: string): Promise { + const fileUrl = `file://${filePath.replace(/\\/g, '/')}`; + const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`; + return await import(fileUrlWithQuery); + } + + /** + * 加载插件模块 + */ + async loadPluginModule (entry: PluginEntry): Promise { + if (!entry.entryPath) { + entry.runtime = { + status: 'error', + error: 'No entry path specified', + }; + return null; + } + + try { + const module = await this.importModule(entry.entryPath); + + if (!this.isValidPluginModule(module)) { + entry.runtime = { + status: 'error', + error: 'Invalid plugin module: missing plugin_init function', + }; + return null; + } + + return module; + } catch (error: any) { + entry.runtime = { + status: 'error', + error: error.message || 'Failed to import module', + }; + return null; + } + } + + /** + * 检查模块是否为有效的插件模块 + */ + isValidPluginModule (module: any): module is PluginModule { + return module && typeof module.plugin_init === 'function'; + } + + /** + * 验证插件入口文件是否包含有效的 plugin_init 函数 + * 用于扫描阶段快速验证 + */ + async validatePluginEntry (entryPath: string): Promise<{ valid: boolean; error?: string; }> { + try { + const module = await this.importModule(entryPath); + if (this.isValidPluginModule(module)) { + return { valid: true }; + } + return { valid: false, error: 'Missing plugin_init function' }; + } catch (error: any) { + return { valid: false, error: error.message || 'Failed to import module' }; + } + } + + /** + * 重新扫描单个插件 + */ + rescanPlugin (dirname: string): PluginEntry | null { + const statusConfig = this.loadPluginStatusConfig(); + return this.scanDirectoryPlugin(dirname, statusConfig); + } + + /** + * 通过 ID 查找插件目录名 + */ + findPluginDirById (pluginId: string): string | null { + if (!fs.existsSync(this.pluginPath)) { + return null; + } + + const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); + + for (const item of items) { + if (!item.isDirectory()) continue; + + const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + if (pkg.name === pluginId) { + return item.name; + } + } catch (_e) { } + } + + // 如果目录名就是 ID + if (item.name === pluginId) { + return item.name; + } + } + + return null; + } + + /** + * 清除插件文件的 require 缓存 + * 用于确保卸载插件时清理 CJS 模块缓存 + */ + clearCache (pluginPath: string): void { + try { + // 规范化路径以确保匹配正确 + const normalizedPluginPath = path.resolve(pluginPath); + + // 遍历缓存并删除属于该插件目录的模块 + Object.keys(require.cache).forEach((id) => { + if (id.startsWith(normalizedPluginPath)) { + delete require.cache[id]; + this.logger.logDebug(`[PluginLoader] Cleared cache for: ${id}`); + } + }); + } catch (e) { + this.logger.logError('[PluginLoader] Error clearing module cache:', e); + } + } +} diff --git a/packages/snowcat-onebot/network/plugin/router-registry.ts b/packages/snowcat-onebot/network/plugin/router-registry.ts new file mode 100644 index 0000000..69395c3 --- /dev/null +++ b/packages/snowcat-onebot/network/plugin/router-registry.ts @@ -0,0 +1,315 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import path from 'path'; +import { + PluginRouterRegistry, + PluginRequestHandler, + PluginApiRouteDefinition, + PluginPageDefinition, + PluginHttpRequest, + PluginHttpResponse, + HttpMethod, + MemoryStaticFile, +} from './types'; + +/** + * 包装 Express Request 为 PluginHttpRequest + */ +function wrapRequest (req: Request): PluginHttpRequest { + return { + path: req.path, + method: req.method, + query: req.query as Record, + body: req.body, + headers: req.headers as Record, + params: req.params, + raw: req, + }; +} + +/** + * 包装 Express Response 为 PluginHttpResponse + */ +function wrapResponse (res: Response): PluginHttpResponse { + const wrapped: PluginHttpResponse = { + status (code: number) { + res.status(code); + return wrapped; + }, + json (data: unknown) { + res.json(data); + }, + send (data: string | Buffer) { + res.send(data); + }, + setHeader (name: string, value: string) { + res.setHeader(name, value); + return wrapped; + }, + sendFile (filePath: string) { + res.sendFile(filePath); + }, + redirect (url: string) { + res.redirect(url); + }, + raw: res, + }; + return wrapped; +} + +/** + * 插件路由注册器实现 + * 为每个插件创建独立的路由注册器,收集路由定义 + */ +/** 内存静态路由定义 */ +interface MemoryStaticRoute { + urlPath: string; + files: MemoryStaticFile[]; +} + +export class PluginRouterRegistryImpl implements PluginRouterRegistry { + private apiRoutes: PluginApiRouteDefinition[] = []; + private apiNoAuthRoutes: PluginApiRouteDefinition[] = []; + private pageDefinitions: PluginPageDefinition[] = []; + private staticRoutes: Array<{ urlPath: string; localPath: string; }> = []; + private memoryStaticRoutes: MemoryStaticRoute[] = []; + + constructor ( + private readonly pluginId: string, + private readonly pluginPath: string + ) { } + + // ==================== API 路由注册 ==================== + + api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void { + this.apiRoutes.push({ method, path: routePath, handler }); + } + + get (routePath: string, handler: PluginRequestHandler): void { + this.api('get', routePath, handler); + } + + post (routePath: string, handler: PluginRequestHandler): void { + this.api('post', routePath, handler); + } + + put (routePath: string, handler: PluginRequestHandler): void { + this.api('put', routePath, handler); + } + + delete (routePath: string, handler: PluginRequestHandler): void { + this.api('delete', routePath, handler); + } + + // ==================== 无认证 API 路由注册 ==================== + + apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void { + this.apiNoAuthRoutes.push({ method, path: routePath, handler }); + } + + getNoAuth (routePath: string, handler: PluginRequestHandler): void { + this.apiNoAuth('get', routePath, handler); + } + + postNoAuth (routePath: string, handler: PluginRequestHandler): void { + this.apiNoAuth('post', routePath, handler); + } + + putNoAuth (routePath: string, handler: PluginRequestHandler): void { + this.apiNoAuth('put', routePath, handler); + } + + deleteNoAuth (routePath: string, handler: PluginRequestHandler): void { + this.apiNoAuth('delete', routePath, handler); + } + + // ==================== 页面注册 ==================== + + page (pageDef: PluginPageDefinition): void { + this.pageDefinitions.push(pageDef); + } + + pages (pageDefs: PluginPageDefinition[]): void { + this.pageDefinitions.push(...pageDefs); + } + + // ==================== 静态资源 ==================== + + static (urlPath: string, localPath: string): void { + // 如果是相对路径,则相对于插件目录 + const absolutePath = path.isAbsolute(localPath) + ? localPath + : path.join(this.pluginPath, localPath); + this.staticRoutes.push({ urlPath, localPath: absolutePath }); + } + + staticOnMem (urlPath: string, files: MemoryStaticFile[]): void { + this.memoryStaticRoutes.push({ urlPath, files }); + } + + // ==================== 构建路由 ==================== + + /** + * 构建 Express Router(用于 API 路由) + * 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理 + */ + buildApiRouter (): Router { + const router = Router(); + + // 注册 API 路由 + for (const route of this.apiRoutes) { + const handler = this.wrapHandler(route.handler); + switch (route.method) { + case 'get': + router.get(route.path, handler); + break; + case 'post': + router.post(route.path, handler); + break; + case 'put': + router.put(route.path, handler); + break; + case 'delete': + router.delete(route.path, handler); + break; + case 'patch': + router.patch(route.path, handler); + break; + case 'all': + router.all(route.path, handler); + break; + } + } + + return router; + } + + /** + * 包装处理器,添加错误处理和请求/响应包装 + */ + private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const wrappedReq = wrapRequest(req); + const wrappedRes = wrapResponse(res); + await handler(wrappedReq, wrappedRes, next); + } catch (error: any) { + console.error(`[Plugin: ${this.pluginId}] Route error:`, error); + if (!res.headersSent) { + res.status(500).json({ + code: -1, + message: `Plugin error: ${error.message || 'Unknown error'}`, + }); + } + } + }; + } + + // ==================== 查询方法 ==================== + + /** + * 检查是否有注册的 API 路由(需要认证) + */ + hasApiRoutes (): boolean { + return this.apiRoutes.length > 0; + } + + /** + * 检查是否有注册的无认证 API 路由 + */ + hasApiNoAuthRoutes (): boolean { + return this.apiNoAuthRoutes.length > 0; + } + + /** + * 构建无认证 Express Router(用于 /plugin/{pluginId}/api/ 路径) + */ + buildApiNoAuthRouter (): Router { + const router = Router(); + + for (const route of this.apiNoAuthRoutes) { + const handler = this.wrapHandler(route.handler); + switch (route.method) { + case 'get': + router.get(route.path, handler); + break; + case 'post': + router.post(route.path, handler); + break; + case 'put': + router.put(route.path, handler); + break; + case 'delete': + router.delete(route.path, handler); + break; + case 'patch': + router.patch(route.path, handler); + break; + case 'all': + router.all(route.path, handler); + break; + } + } + + return router; + } + + /** + * 检查是否有注册的静态资源路由 + */ + hasStaticRoutes (): boolean { + return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0; + } + + /** + * 检查是否有注册的页面 + */ + hasPages (): boolean { + return this.pageDefinitions.length > 0; + } + + /** + * 获取所有注册的页面定义 + */ + getPages (): PluginPageDefinition[] { + return [...this.pageDefinitions]; + } + + /** + * 获取插件 ID + */ + getPluginId (): string { + return this.pluginId; + } + + /** + * 获取插件路径 + */ + getPluginPath (): string { + return this.pluginPath; + } + + /** + * 获取所有注册的静态路由 + */ + getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> { + return [...this.staticRoutes]; + } + + /** + * 获取所有注册的内存静态路由 + */ + getMemoryStaticRoutes (): MemoryStaticRoute[] { + return [...this.memoryStaticRoutes]; + } + + /** + * 清空路由(用于插件卸载) + */ + clear (): void { + this.apiRoutes = []; + this.apiNoAuthRoutes = []; + this.pageDefinitions = []; + this.staticRoutes = []; + this.memoryStaticRoutes = []; + } +} diff --git a/packages/snowcat-onebot/network/plugin/types.ts b/packages/snowcat-onebot/network/plugin/types.ts new file mode 100644 index 0000000..5820499 --- /dev/null +++ b/packages/snowcat-onebot/network/plugin/types.ts @@ -0,0 +1,395 @@ +import { SnowCatCore } from 'snowcat-core'; +import { NapCatOneBot11Adapter, OB11Message } from '@/snowcat-onebot/index'; +import { ActionMap } from '@/snowcat-onebot/action'; +import { OB11EmitEventContent } from '@/snowcat-onebot/network/index'; +import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config'; + +// ==================== 插件包信息 ==================== + +export interface PluginPackageJson { + name?: string; + plugin?: string; + version?: string; + main?: string; + description?: string; + author?: string; + homepage?: string; + repository?: string | { type: string; url: string; }; + icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png" +} + +// ==================== 插件配置 Schema ==================== + +export interface PluginConfigItem { + key: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text'; + label: string; + description?: string; + default?: unknown; + options?: { label: string; value: string | number; }[]; + placeholder?: string; + /** 标记此字段为响应式:值变化时触发 schema 刷新 */ + reactive?: boolean; + /** 是否隐藏此字段 */ + hidden?: boolean; +} + +export type PluginConfigSchema = PluginConfigItem[]; + +// ==================== SnowCatConfig 静态接口 ==================== + +/** SnowCatConfig 类的静态方法接口(用于 typeof SnowCatConfig) */ +export interface INapCatConfigStatic { + text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem; + number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem; + boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem; + select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem; + multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem; + html (content: string): PluginConfigItem; + plainText (content: string): PluginConfigItem; + combine (...items: PluginConfigItem[]): PluginConfigSchema; +} + +/** SnowCatConfig 类型(包含静态方法) */ +export type SnowCatConfigClass = INapCatConfigStatic; + +// ==================== 插件路由相关类型(包装层,不直接依赖 express) ==================== + +/** HTTP 请求对象(包装类型) */ +export interface PluginHttpRequest { + /** 请求路径 */ + path: string; + /** 请求方法 */ + method: string; + /** 查询参数 */ + query: Record; + /** 请求体 */ + body: unknown; + /** 请求头 */ + headers: Record; + /** 路由参数 */ + params: Record; + /** 原始请求对象(用于高级用法) */ + raw: unknown; +} + +/** HTTP 响应对象(包装类型) */ +export interface PluginHttpResponse { + /** 设置状态码 */ + status (code: number): PluginHttpResponse; + /** 发送 JSON 响应 */ + json (data: unknown): void; + /** 发送文本响应 */ + send (data: string | Buffer): void; + /** 设置响应头 */ + setHeader (name: string, value: string): PluginHttpResponse; + /** 发送文件 */ + sendFile (filePath: string): void; + /** 重定向 */ + redirect (url: string): void; + /** 原始响应对象(用于高级用法) */ + raw: unknown; +} + +/** 下一步函数类型 */ +export type PluginNextFunction = (err?: unknown) => void; + +/** 插件请求处理器类型 */ +export type PluginRequestHandler = ( + req: PluginHttpRequest, + res: PluginHttpResponse, + next: PluginNextFunction +) => void | Promise; + +/** HTTP 方法类型 */ +export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'all'; + +/** 插件 API 路由定义 */ +export interface PluginApiRouteDefinition { + /** HTTP 方法 */ + method: HttpMethod; + /** 路由路径(相对于插件路由前缀) */ + path: string; + /** 请求处理器 */ + handler: PluginRequestHandler; +} + +/** 插件页面定义 */ +export interface PluginPageDefinition { + /** 页面路径(用于路由,如 'settings') */ + path: string; + /** 页面标题(显示在 Tab 上) */ + title: string; + /** 页面图标(可选,支持 emoji 或图标名) */ + icon?: string; + /** 页面 HTML 文件路径(相对于插件目录) */ + htmlFile: string; + /** 页面描述 */ + description?: string; +} + +/** 内存文件生成器 - 用于动态生成静态文件内容 */ +export type MemoryFileGenerator = () => string | Buffer | Promise; + +/** 内存静态文件定义 */ +export interface MemoryStaticFile { + /** 文件路径(相对于 urlPath) */ + path: string; + /** 文件内容或生成器 */ + content: string | Buffer | MemoryFileGenerator; + /** 可选的 MIME 类型 */ + contentType?: string; +} + +/** 插件路由注册器 */ +export interface PluginRouterRegistry { + // ==================== API 路由注册(需要认证) ==================== + + /** + * 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/) + * @param method HTTP 方法 + * @param path 路由路径 + * @param handler 请求处理器 + */ + api (method: HttpMethod, path: string, handler: PluginRequestHandler): void; + /** 注册 GET API(需要认证) */ + get (path: string, handler: PluginRequestHandler): void; + /** 注册 POST API(需要认证) */ + post (path: string, handler: PluginRequestHandler): void; + /** 注册 PUT API(需要认证) */ + put (path: string, handler: PluginRequestHandler): void; + /** 注册 DELETE API(需要认证) */ + delete (path: string, handler: PluginRequestHandler): void; + + // ==================== 无认证 API 路由注册 ==================== + + /** + * 注册单个无认证 API 路由(挂载到 /plugin/{pluginId}/api/) + * @param method HTTP 方法 + * @param path 路由路径 + * @param handler 请求处理器 + */ + apiNoAuth (method: HttpMethod, path: string, handler: PluginRequestHandler): void; + /** 注册 GET API(无认证) */ + getNoAuth (path: string, handler: PluginRequestHandler): void; + /** 注册 POST API(无认证) */ + postNoAuth (path: string, handler: PluginRequestHandler): void; + /** 注册 PUT API(无认证) */ + putNoAuth (path: string, handler: PluginRequestHandler): void; + /** 注册 DELETE API(无认证) */ + deleteNoAuth (path: string, handler: PluginRequestHandler): void; + + // ==================== 页面注册 ==================== + + /** + * 注册插件页面 + * @param page 页面定义 + */ + page (page: PluginPageDefinition): void; + + /** + * 注册多个插件页面 + * @param pages 页面定义数组 + */ + pages (pages: PluginPageDefinition[]): void; + + // ==================== 静态资源 ==================== + + /** + * 提供静态文件服务 + * @param urlPath URL 路径 + * @param localPath 本地文件夹路径(相对于插件目录或绝对路径) + */ + static (urlPath: string, localPath: string): void; + + /** + * 提供内存生成的静态文件服务 + * @param urlPath URL 路径 + * @param files 内存文件列表 + */ + staticOnMem (urlPath: string, files: MemoryStaticFile[]): void; +} + +// ==================== 插件管理器接口 ==================== + +/** 插件管理器公共接口 */ +export interface IPluginManager { + readonly config: NetworkAdapterConfig; + getPluginPath (): string; + getPluginConfig (): PluginStatusConfig; + getAllPlugins (): PluginEntry[]; + getLoadedPlugins (): PluginEntry[]; + getPluginInfo (pluginId: string): PluginEntry | undefined; + setPluginStatus (pluginId: string, enable: boolean): Promise; + loadPluginById (pluginId: string): Promise; + unregisterPlugin (pluginId: string): Promise; + uninstallPlugin (pluginId: string, cleanData?: boolean): Promise; + reloadPlugin (pluginId: string): Promise; + loadDirectoryPlugin (dirname: string): Promise; + getPluginDataPath (pluginId: string): string; + getPluginConfigPath (pluginId: string): string; +} + +// ==================== 插件配置 UI 控制器 ==================== + +/** 插件配置 UI 控制器 - 用于动态控制配置界面 */ +export interface PluginConfigUIController { + /** 更新整个 schema */ + updateSchema: (schema: PluginConfigSchema) => void; + /** 更新单个字段 */ + updateField: (key: string, field: Partial) => void; + /** 移除字段 */ + removeField: (key: string) => void; + /** 添加字段 */ + addField: (field: PluginConfigItem, afterKey?: string) => void; + /** 显示字段 */ + showField: (key: string) => void; + /** 隐藏字段 */ + hideField: (key: string) => void; + /** 获取当前配置值 */ + getCurrentConfig: () => Record; +} + +// ==================== 插件日志接口 ==================== + +/** + * 插件日志接口 - 简化的日志 API + */ +export interface PluginLogger { + /** 普通日志 */ + log (...args: unknown[]): void; + /** 调试日志 */ + debug (...args: unknown[]): void; + /** 信息日志 */ + info (...args: unknown[]): void; + /** 警告日志 */ + warn (...args: unknown[]): void; + /** 错误日志 */ + error (...args: unknown[]): void; +} + +// ==================== 插件上下文 ==================== + +export interface SnowCatPluginContext { + core: SnowCatCore; + oneBot: NapCatOneBot11Adapter; + actions: ActionMap; + pluginName: string; + pluginPath: string; + configPath: string; + dataPath: string; + /** SnowCatConfig 配置构建器 */ + SnowCatConfig: SnowCatConfigClass; + adapterName: string; + /** 插件管理器实例 */ + pluginManager: IPluginManager; + /** 插件日志器 - 自动添加插件名称前缀 */ + logger: PluginLogger; + /** + * WebUI 路由注册器 + * 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/ + * 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/ + */ + router: PluginRouterRegistry; + /** + * 获取其他插件的导出模块 + * @param pluginId 目标插件 ID + * @returns 插件导出的模块,如果插件未加载则返回 undefined + */ + getPluginExports: (pluginId: string) => T | undefined; +} + +// ==================== 插件模块接口 ==================== + +export interface PluginModule { + plugin_init: (ctx: SnowCatPluginContext) => void | Promise; + plugin_onmessage?: ( + ctx: SnowCatPluginContext, + event: OB11Message, + ) => void | Promise; + plugin_onevent?: ( + ctx: SnowCatPluginContext, + event: T, + ) => void | Promise; + plugin_cleanup?: ( + ctx: SnowCatPluginContext + ) => void | Promise; + plugin_config_schema?: PluginConfigSchema; + plugin_config_ui?: PluginConfigSchema; + plugin_get_config?: (ctx: SnowCatPluginContext) => C | Promise; + plugin_set_config?: (ctx: SnowCatPluginContext, config: C) => void | Promise; + /** + * 配置界面控制器 - 当配置界面打开时调用 + * 返回清理函数,在界面关闭时调用 + */ + plugin_config_controller?: ( + ctx: SnowCatPluginContext, + ui: PluginConfigUIController, + initialConfig: Record + ) => void | (() => void) | Promise void)>; + /** + * 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用 + */ + plugin_on_config_change?: ( + ctx: SnowCatPluginContext, + ui: PluginConfigUIController, + key: string, + value: unknown, + currentConfig: Record + ) => void | Promise; +} + +// ==================== 插件运行时状态 ==================== + +export type PluginRuntimeStatus = 'loaded' | 'error' | 'unloaded'; + +export interface PluginRuntime { + /** 运行时状态 */ + status: PluginRuntimeStatus; + /** 错误信息(当 status 为 'error' 时) */ + error?: string; + /** 插件模块(当 status 为 'loaded' 时) */ + module?: PluginModule; + /** 插件上下文(当 status 为 'loaded' 时) */ + context?: SnowCatPluginContext; +} + +// ==================== 插件条目(统一管理所有插件) ==================== + +export interface PluginEntry { + // ===== 基础信息 ===== + /** 插件 ID(包名或目录名) */ + id: string; + /** 文件系统目录名 */ + fileId: string; + /** 显示名称 */ + name?: string; + /** 版本号 */ + version?: string; + /** 描述 */ + description?: string; + /** 作者 */ + author?: string; + /** 插件目录路径 */ + pluginPath: string; + /** 入口文件路径 */ + entryPath?: string; + /** package.json 内容 */ + packageJson?: PluginPackageJson; + + // ===== 状态 ===== + /** 是否启用(用户配置) */ + enable: boolean; + /** 运行时是否已加载 */ + loaded: boolean; + + // ===== 运行时 ===== + /** 运行时信息 */ + runtime: PluginRuntime; +} + +// ==================== 插件状态配置(持久化) ==================== + +export interface PluginStatusConfig { + [key: string]: boolean; // key: pluginId, value: enabled +} diff --git a/packages/snowcat-onebot/network/websocket-client.ts b/packages/snowcat-onebot/network/websocket-client.ts new file mode 100644 index 0000000..511e5f0 --- /dev/null +++ b/packages/snowcat-onebot/network/websocket-client.ts @@ -0,0 +1,212 @@ +import { OB11EmitEventContent, OB11NetworkReloadType } from '@/snowcat-onebot/network/index'; +import { RawData, WebSocket } from 'ws'; +import { OB11HeartbeatEvent } from '@/snowcat-onebot/event/meta/OB11HeartbeatEvent'; +import { SnowCatCore } from 'snowcat-core'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11Response } from '@/snowcat-onebot/action/OneBotAction'; +import { LifeCycleSubType, OB11LifeCycleEvent } from '@/snowcat-onebot/event/meta/OB11LifeCycleEvent'; +import { WebsocketClientConfig } from '@/snowcat-onebot/config/config'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import json5 from 'json5'; + +export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter { + private connection: WebSocket | null = null; + private heartbeatRef: NodeJS.Timeout | null = null; + private firstConnectDelayed: boolean = false; + + override get isActive (): boolean { + return this.isEnable && !!this.connection && this.connection.readyState === WebSocket.OPEN; + } + + async onEvent (event: T) { + if (this.connection && this.connection.readyState === WebSocket.OPEN) { + this.connection.send(JSON.stringify(event)); + } + } + + async open () { + if (this.connection) { + return; + } + if (this.config.heartInterval > 0) { + this.heartbeatRef = setInterval(() => { + if (this.connection && this.connection.readyState === WebSocket.OPEN) { + this.connection.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true))); + } + }, this.config.heartInterval); + } + this.isEnable = true; + try { + await this.tryConnect(); + } catch (error) { + this.logger.logError('[OneBot] [WebSocket Client] TryConnect Error , info -> ', error); + } + } + + close () { + if (!this.isEnable) { + this.logger.logDebug('Cannot close a closed WebSocket connection'); + return; + } + this.isEnable = false; + if (this.connection) { + this.connection.close(); + this.connection = null; + } + if (this.heartbeatRef) { + clearInterval(this.heartbeatRef); + this.heartbeatRef = null; + } + } + + private async checkStateAndReply (data: T) { + return new Promise((resolve, reject) => { + if (this.connection && this.connection.readyState === WebSocket.OPEN) { + this.connection.send(JSON.stringify(data)); + resolve(); + } else { + reject(new Error('WebSocket is not open')); + } + }); + } + + private async tryConnect () { + if (!this.connection && this.isEnable) { + let isClosedByError = false; + // 启动首发连接延后 3 秒:QQ 刚拉起时 dsh 侧 8082 服务可能还没完成监听, + // 立刻连会撞握手超时产生 2~3 条 ERROR 噪音,延后首发即可静默闭合 + if (!this.firstConnectDelayed) { + this.firstConnectDelayed = true; + await new Promise(resolve => setTimeout(resolve, 3000)); + if (!this.isEnable) return; + } + + const wsOptions: WebSocket.ClientOptions = { + maxPayload: 50 * 1024 * 1024, // 50 MB + handshakeTimeout: 2000, + perMessageDeflate: false, + headers: { + 'X-Self-ID': this.core.selfInfo.uin, + Authorization: `Bearer ${this.config.token}`, + 'x-client-role': 'Universal', // 为koishi adpter适配 + 'User-Agent': 'OneBot/11', + }, + }; + + // 如果配置了 verifyCertificate,设置 rejectUnauthorized + if (this.config.verifyCertificate !== undefined) { + wsOptions.rejectUnauthorized = this.config.verifyCertificate; + } + + this.connection = new WebSocket(this.config.url, wsOptions); + this.connection.on('pong', () => { + // this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong'); + }); + this.connection.on('open', () => { + try { + this.connectEvent(this.core).catch(e => this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e)); + } catch (e) { + this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e); + } + }); + this.connection.on('message', (data) => { + this.handleMessage(data); + }); + this.connection.once('close', () => { + if (!isClosedByError) { + this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接意外关闭`); + this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`); + if (this.isEnable) { + this.connection = null; + setTimeout(() => this.tryConnect(), this.config.reconnectInterval); + } + } + }); + this.connection.on('error', (err) => { + isClosedByError = true; + this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接错误`, err); + this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`); + if (this.isEnable) { + this.connection = null; + setTimeout(() => this.tryConnect(), this.config.reconnectInterval); + } + }); + } + } + + async connectEvent (core: SnowCatCore) { + try { + await this.checkStateAndReply(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT)); + } catch (e) { + this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e); + } + } + + private async handleMessage (message: RawData) { + let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} }; + let echo; + + try { + receiveData = json5.parse(message.toString()); + echo = receiveData.echo; + this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData); + } catch { + await this.checkStateAndReply(OB11Response.error('json解析失败,请检查数据格式', 1400, echo)); + return; + } + receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证 + + const action = this.actions.get(receiveData.action as any); + if (!action) { + this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的Api ' + receiveData.action); + await this.checkStateAndReply(OB11Response.error('不支持的Api ' + receiveData.action, 1404, echo)); + return; + } + const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, { + send: async (data: object) => { + await this.checkStateAndReply({ ...OB11Response.ok(data, echo ?? '', true) }); + }, + }); + await this.checkStateAndReply({ ...retdata }); + } + + async reload (newConfig: WebsocketClientConfig) { + const wasEnabled = this.isEnable; + const oldUrl = this.config.url; + const oldHeartInterval = this.config.heartInterval; + this.config = newConfig; + + if (newConfig.enable && !wasEnabled) { + this.open(); + return OB11NetworkReloadType.NetWorkOpen; + } else if (!newConfig.enable && wasEnabled) { + this.close(); + return OB11NetworkReloadType.NetWorkClose; + } + + if (oldUrl !== newConfig.url) { + this.close(); + if (newConfig.enable) { + this.open(); + } + return OB11NetworkReloadType.NetWorkReload; + } + + if (oldHeartInterval !== newConfig.heartInterval) { + if (this.heartbeatRef) { + clearInterval(this.heartbeatRef); + this.heartbeatRef = null; + } + if (newConfig.heartInterval > 0 && this.isEnable) { + this.heartbeatRef = setInterval(() => { + if (this.connection && this.connection.readyState === WebSocket.OPEN) { + this.connection.send(JSON.stringify(new OB11HeartbeatEvent(this.core, newConfig.heartInterval, this.core.selfInfo.online ?? true, true))); + } + }, newConfig.heartInterval); + } + return OB11NetworkReloadType.NetWorkReload; + } + + return OB11NetworkReloadType.Normal; + } +} diff --git a/packages/snowcat-onebot/network/websocket-server.ts b/packages/snowcat-onebot/network/websocket-server.ts new file mode 100644 index 0000000..c8992f2 --- /dev/null +++ b/packages/snowcat-onebot/network/websocket-server.ts @@ -0,0 +1,242 @@ +import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; +import { URL } from 'url'; +import { RawData, WebSocket, WebSocketServer } from 'ws'; +import { OB11Response } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { SnowCatCore } from 'snowcat-core'; +import { OB11HeartbeatEvent } from '@/snowcat-onebot/event/meta/OB11HeartbeatEvent'; +import { IncomingMessage } from 'http'; +import { ActionMap } from '@/snowcat-onebot/action'; +import { LifeCycleSubType, OB11LifeCycleEvent } from '@/snowcat-onebot/event/meta/OB11LifeCycleEvent'; +import { WebsocketServerConfig } from '@/snowcat-onebot/config/config'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import json5 from 'json5'; + +export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter { + wsServer?: WebSocketServer; + wsClients: WebSocket[] = []; + private heartbeatIntervalId: NodeJS.Timeout | null = null; + wsClientWithEvent: WebSocket[] = []; + + override get isActive (): boolean { + return this.isEnable && this.wsClientWithEvent.length > 0; + } + + constructor ( + name: string, config: WebsocketServerConfig, core: SnowCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap + ) { + super(name, config, core, obContext, actions); + this.wsServer = new WebSocketServer({ + port: this.config.port, + host: this.config.host === '0.0.0.0' ? '' : this.config.host, + maxPayload: 50 * 1024 * 1024, // 50 MB + }); + this.createServer(this.wsServer); + } + + createServer (newServer: WebSocketServer) { + newServer.on('connection', async (wsClient, wsReq) => { + if (!this.isEnable) { + wsClient.close(); + return; + } + // 鉴权 close 不会立刻销毁 当前返回可避免挂载message事件 close 并未立刻关闭 而是存在timer操作后关闭 + // 引发高危漏洞 + if (!this.authorize(this.config.token, wsClient, wsReq)) { + return; + } + const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url; + const isApiConnect = paramUrl === '/api' || paramUrl === '/api/'; + if (!isApiConnect) { + this.connectEvent(this.core, wsClient); + } + + wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message)); + wsClient.on('message', (message) => { + this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e)); + }); + wsClient.on('pong', () => { + // this.logger.logDebug('[OneBot] [WebSocket Server] Pong received'); + }); + wsClient.once('close', () => { + const NormolIndex = this.wsClients.indexOf(wsClient); + if (NormolIndex !== -1) { + this.wsClients.splice(NormolIndex, 1); + } + const EventIndex = this.wsClientWithEvent.indexOf(wsClient); + if (EventIndex !== -1) { + this.wsClientWithEvent.splice(EventIndex, 1); + } + if (this.wsClientWithEvent.length === 0) { + this.stopHeartbeat(); + } + }); + if (!isApiConnect) { + this.wsClientWithEvent.push(wsClient); + } + this.wsClients.push(wsClient); + if (this.wsClientWithEvent.length > 0) { + this.startHeartbeat(); + } + }).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message)); + } + + connectEvent (core: SnowCatCore, wsClient: WebSocket) { + try { + this.checkStateAndReply(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e)); + } catch (e) { + this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e); + } + } + + async onEvent (event: T) { + const promises = this.wsClientWithEvent.map((wsClient) => { + return new Promise((resolve, reject) => { + if (wsClient.readyState === WebSocket.OPEN) { + wsClient.send(JSON.stringify(event)); + resolve(); + } else { + reject(new Error('WebSocket is not open')); + } + }); + }); + await Promise.allSettled(promises); + } + + open () { + if (this.isEnable) { + this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server'); + return; + } + const addressInfo = this.wsServer?.address(); + this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port); + + this.isEnable = true; + } + + async close () { + this.isEnable = false; + this.wsServer?.close((err) => { + if (err) { + this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message); + } else { + this.logger.log('[OneBot] [WebSocket Server] Server Closed'); + } + }); + this.stopHeartbeat(); + this.wsClients.forEach((wsClient) => wsClient.close()); + this.wsClients = []; + this.wsClientWithEvent = []; + } + + private startHeartbeat () { + if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return; + this.heartbeatIntervalId = setInterval(() => { + this.wsClientWithEvent.forEach((wsClient) => { + if (wsClient.readyState === WebSocket.OPEN) { + wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true))); + } + }); + }, this.config.heartInterval); + } + + private stopHeartbeat () { + if (this.heartbeatIntervalId) { + clearInterval(this.heartbeatIntervalId); + this.heartbeatIntervalId = null; + } + } + + private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) { + if (!token || token.length === 0) return true;// 客户端未设置密钥 + const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`); + const QueryClientToken = url.searchParams.get('access_token'); + const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || ''; + const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken; + if (ClientToken === token) { + return true; + } + wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败'))); + wsClient.close(); + return false; + } + + private async checkStateAndReply (data: T, wsClient: WebSocket) { + return await new Promise((resolve, reject) => { + if (wsClient.readyState === WebSocket.OPEN) { + wsClient.send(JSON.stringify(data)); + resolve(); + } else { + reject(new Error('WebSocket is not open')); + } + }); + } + + private async handleMessage (wsClient: WebSocket, message: RawData) { + let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} }; + let echo; + try { + receiveData = json5.parse(message.toString()); + echo = receiveData.echo; + // this.logger.logDebug('收到正向Websocket消息', receiveData); + } catch { + await this.checkStateAndReply(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient); + return; + } + receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证 不然类型校验爆炸 + + const action = this.actions.get(receiveData.action as any); + if (!action) { + this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action); + await this.checkStateAndReply(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient); + return; + } + const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, { + send: async (data: object) => { + await this.checkStateAndReply({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient); + }, + }); + await this.checkStateAndReply({ ...retdata }, wsClient); + } + + async reload (newConfig: WebsocketServerConfig) { + const wasEnabled = this.isEnable; + const oldPort = this.config.port; + const oldHost = this.config.host; + const oldHeartbeatInterval = this.config.heartInterval; + this.config = newConfig; + + if (newConfig.enable && !wasEnabled) { + this.open(); + return OB11NetworkReloadType.NetWorkOpen; + } else if (!newConfig.enable && wasEnabled) { + this.close(); + return OB11NetworkReloadType.NetWorkClose; + } + + if (oldPort !== newConfig.port || oldHost !== newConfig.host) { + this.close(); + this.wsServer = new WebSocketServer({ + port: newConfig.port, + host: newConfig.host === '0.0.0.0' ? '' : newConfig.host, + maxPayload: 50 * 1024 * 1024, // 50 MB + }); + this.createServer(this.wsServer); + if (newConfig.enable) { + this.open(); + } + return OB11NetworkReloadType.NetWorkReload; + } + + if (oldHeartbeatInterval !== newConfig.heartInterval) { + this.stopHeartbeat(); + if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) { + this.startHeartbeat(); + } + return OB11NetworkReloadType.NetWorkReload; + } + + return OB11NetworkReloadType.Normal; + } +} diff --git a/packages/snowcat-onebot/package.json b/packages/snowcat-onebot/package.json new file mode 100644 index 0000000..ec5b927 --- /dev/null +++ b/packages/snowcat-onebot/package.json @@ -0,0 +1,45 @@ +{ + "name": "snowcat-onebot", + "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": { + "@sinclair/typebox": "^0.34.38", + "cors": "^2.8.5", + "express": "^5.0.0", + "fast-xml-parser": "^4.3.6", + "file-type": "^21.0.0", + "json5": "^2.2.3", + "snowcat-common": "workspace:*", + "snowcat-core": "workspace:*", + "snowcat-image-size": "workspace:*", + "snowcat-protobuf": "workspace:*", + "snowcat-webui-backend": "workspace:*", + "napcat.protobuf": "^1.1.4", + "on-finished": "^2.3.5", + "type-is": "^1.6.7", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.0.1", + "@types/on-finished": "^2.3.4", + "@types/type-is": "^1.6.7" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-onebot/stable-line.ts b/packages/snowcat-onebot/stable-line.ts new file mode 100644 index 0000000..f8be798 --- /dev/null +++ b/packages/snowcat-onebot/stable-line.ts @@ -0,0 +1,157 @@ +/** + * SnowCat · 稳定性工程线(StableLine) + * + * 三条稳定线(师父令 2026-11-11): + * 1. 连接线:QQ 掉线自愈——登录态监听 + 指数退避重连 + 存活心跳 + * 2. 发言线:真人化节奏——发送前抖动延迟 + 突发限流 + 深夜低频 + * 3. 账号线:防风控节奏——每小时发送预算 + 连发熔断 + 指数冷却 + * + * 全部为可选软层(默认温和值),不干预任何 OneBot 动作语义; + * dsh 侧已有自己的节奏学(去模板化六条/语言学规则),此处只做传输层护栏。 + */ + +import { SnowCatCore } from 'snowcat-core'; + +export interface SnowCatStableConfig { + /** 连接线:掉线后重连基础等待(毫秒) */ + reconnectBaseMs: number; + /** 连接线:重连指数退避上限(毫秒) */ + reconnectCeilingMs: number; + /** 发言线:发送前随机抖动下限(毫秒) */ + sendJitterMinMs: number; + /** 发言线:发送前随机抖动上限(毫秒) */ + sendJitterMaxMs: number; + /** 发言线:深夜时段(小时,本地时区)发送抖动倍率 */ + nightJitterMultiplier: number; + /** 账号线:每小时允许的最大主动发言数(0=不限) */ + hourlySendBudget: number; + /** 账号线:连发熔断阈值——连续 N 条无间隔发送后强制冷却 */ + burstBreakThreshold: number; + /** 账号线:熔断冷却时长(毫秒) */ + burstCooldownMs: number; +} + +export const DEFAULT_STABLE_CONFIG: SnowCatStableConfig = { + reconnectBaseMs: 5_000, + reconnectCeilingMs: 300_000, + sendJitterMinMs: 600, + sendJitterMaxMs: 2_400, + nightJitterMultiplier: 1.8, + hourlySendBudget: 120, + burstBreakThreshold: 8, + burstCooldownMs: 90_000, +}; + +export class SnowCatStableLine { + private cfg: SnowCatStableConfig; + private core: SnowCatCore | null = null; + + // 账号线状态 + private hourStamp = 0; + private hourCount = 0; + private burstCount = 0; + private burstUntil = 0; + private lastSendAt = 0; + + // 连接线状态 + private reconnectAttempt = 0; + private online = true; + + constructor (cfg: Partial = {}) { + this.cfg = { ...DEFAULT_STABLE_CONFIG, ...cfg }; + } + + attach (core: SnowCatCore): void { + this.core = core; + } + + get config (): SnowCatStableConfig { + return this.cfg; + } + + // ─────────────── 连接线 ─────────────── + + /** 掉线时调用:登记并算下一次重连等待(指数退避)。 */ + markOffline (): number { + this.online = false; + this.reconnectAttempt += 1; + const backoff = Math.min( + this.cfg.reconnectBaseMs * 2 ** Math.min(this.reconnectAttempt, 6), + this.cfg.reconnectCeilingMs, + ); + return backoff; + } + + /** 重连成功时调用:归零退避。 */ + markOnline (): void { + this.online = true; + this.reconnectAttempt = 0; + } + + get isOnline (): boolean { + return this.online; + } + + // ─────────────── 发言线 + 账号线 ─────────────── + + /** + * 主动发言前调用:返回本次应等待的毫秒数(0=立即)。 + * 组合三层:账号预算 → 熔断冷却 → 真人抖动(深夜加倍)。 + */ + async beforeSend (): Promise { + const now = Date.now(); + + // 账号线:小时预算 + const hour = Math.floor(now / 3_600_000); + if (this.hourStamp !== hour) { + this.hourStamp = hour; + this.hourCount = 0; + } + if (this.cfg.hourlySendBudget > 0 && this.hourCount >= this.cfg.hourlySendBudget) { + // 本小时预算用尽,睡到下一小时边界(附带随机分钟,别整点齐刷刷) + const nextHour = (hour + 1) * 3_600_000; + return nextHour - now + Math.floor(Math.random() * 60_000); + } + + // 账号线:连发熔断 + if (now < this.burstUntil) { + return this.burstUntil - now; + } + + // 发言线:真人抖动(深夜 0-7 点加倍) + const nightHours = [0, 1, 2, 3, 4, 5, 6, 7]; + const h = new Date().getHours(); + const mult = nightHours.includes(h) ? this.cfg.nightJitterMultiplier : 1; + let wait = this.cfg.sendJitterMinMs + + Math.random() * (this.cfg.sendJitterMaxMs - this.cfg.sendJitterMinMs); + wait *= mult; + + // 账号线:距上条太近计一次连发,超阈值熔断 + if (now - this.lastSendAt < 2_000) { + this.burstCount += 1; + if (this.burstCount >= this.cfg.burstBreakThreshold) { + this.burstUntil = now + this.cfg.burstCooldownMs; + this.burstCount = 0; + this.core?.context.logger.logWarn( + `[SnowCat] 连发 ${this.cfg.burstBreakThreshold} 条触发熔断,冷却 ${this.cfg.burstCooldownMs / 1000}s`, + ); + return this.cfg.burstCooldownMs; + } + } else { + this.burstCount = 0; + } + + this.hourCount += 1; + this.lastSendAt = now; + return Math.floor(wait); + } + + /** 便捷等待:beforeSend + sleep。 */ + async stableDelay (): Promise { + const ms = await this.beforeSend(); + if (ms > 0) await new Promise((r) => setTimeout(r, ms)); + } +} + +// 全局单例(NapCat 侧 wire 进启动链;dsh 侧动作层经 actions 引用) +export const snowCatStable = new SnowCatStableLine(); diff --git a/packages/snowcat-onebot/tsconfig.json b/packages/snowcat-onebot/tsconfig.json new file mode 100644 index 0000000..ccb8729 --- /dev/null +++ b/packages/snowcat-onebot/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "../../packages" + }, + "include": [ + "*.ts", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-onebot/types/data.ts b/packages/snowcat-onebot/types/data.ts new file mode 100644 index 0000000..d8e3daa --- /dev/null +++ b/packages/snowcat-onebot/types/data.ts @@ -0,0 +1,108 @@ +export interface OB11User { + birthday_year?: number; // 生日 + birthday_month?: number; // 生日 + birthday_day?: number; // 生日 + phone_num?: string; // 手机号 + email?: string; // 邮箱 + category_id?: number; // 分组ID + user_id: number; // 用户ID + nickname: string; // 昵称 + remark?: string; // 备注 + sex?: OB11UserSex; // 性别 + level?: number; // 等级 + age?: number; // 年龄 + qid?: string; // QID + login_days?: number; // 登录天数 + categoryName?: string; // 分组名称 + categoryId?: number; // 分组ID 999为特别关心 +} +export interface Notify { + request_id: number; + invitor_uin: number; + invitor_nick?: string; + group_id?: number; + group_name?: string; + message?: string; + checked: boolean; + actor: number; + requester_nick?: string; +} + +export enum OB11UserSex { + male = 'male', // 男性 + female = 'female', // 女性 + unknown = 'unknown', // 未知 +} + +export enum OB11GroupMemberRole { + owner = 'owner', // 群主 + admin = 'admin', // 管理员 + member = 'member', // 成员 +} + +export interface OB11GroupMember { + group_id: number; // 群ID + user_id: number; // 用户ID + nickname: string; // 昵称 + card?: string; // 群名片 + sex?: OB11UserSex; // 性别 + age?: number; // 年龄 + join_time?: number; // 加入时间 + last_sent_time?: number; // 最后发言时间 + level?: string; // 群等级 + qq_level?: number; // QQ等级 + role?: OB11GroupMemberRole; // 群角色 + title?: string; // 头衔 + area?: string; // 地区 + unfriendly?: boolean; // 是否不友好 + title_expire_time?: number; // 头衔过期时间 + card_changeable?: boolean; // 名片是否可修改 + shut_up_timestamp?: number; // 禁言时间戳 + is_robot?: boolean; // 是否机器人 + qage?: number; // QQ年龄 +} + +export interface OB11Group { + group_all_shut: number; // 群全员禁言 + group_remark: string; // 群备注 + group_id: number; // 群ID + group_name: string; // 群名称 + member_count?: number; // 成员数量 + max_member_count?: number; // 最大成员数量 +} + +export interface OB11Sender { + user_id: number; // 用户ID + nickname: string; // 昵称 + sex?: OB11UserSex; // 性别 + age?: number; // 年龄 + card?: string; // 群名片 + level?: string; // 群等级 + role?: OB11GroupMemberRole; // 群角色 +} + +export interface OB11GroupFile { + file_size: number; // 文件大小 GOCQHTTP 群文件Api扩展 + group_id: number; // 群ID + file_id: string; // 文件ID + file_name: string; // 文件名称 + busid: number; // 业务ID + size: number; // 文件大小 + upload_time: number; // 上传时间 + dead_time: number; // 过期时间 + modify_time: number; // 修改时间 + download_times: number; // 下载次数 + uploader: number; // 上传者ID + uploader_name: string; // 上传者名称 +} + +export interface OB11GroupFileFolder { + group_id: number; // 群ID + folder_id: string; // 文件夹ID + folder: string; // 文件夹路径 + folder_name: string; // 文件夹名称 + create_time: number; // 创建时间 + creator: number; // 创建者ID + creator_name: string; // 创建者名称 + total_file_count: number; // 文件总数 +} diff --git a/packages/snowcat-onebot/types/index.ts b/packages/snowcat-onebot/types/index.ts new file mode 100644 index 0000000..780354e --- /dev/null +++ b/packages/snowcat-onebot/types/index.ts @@ -0,0 +1,3 @@ +export * from './data'; +export * from './message'; +export * from './quick'; diff --git a/packages/snowcat-onebot/types/message.ts b/packages/snowcat-onebot/types/message.ts new file mode 100644 index 0000000..c4b047a --- /dev/null +++ b/packages/snowcat-onebot/types/message.ts @@ -0,0 +1,482 @@ +import { Type, Static } from '@sinclair/typebox'; + +export enum OB11MessageDataType { + text = 'text', + image = 'image', + music = 'music', + video = 'video', + voice = 'record', + file = 'file', + at = 'at', + reply = 'reply', + json = 'json', + face = 'face', + mface = 'mface', // 商城表情 + markdown = 'markdown', + node = 'node', // 合并转发消息节点 + forward = 'forward', // 合并转发消息,用于上报 + xml = 'xml', + poke = 'poke', + dice = 'dice', + rps = 'rps', + miniapp = 'miniapp', // json类 + contact = 'contact', + location = 'location', + onlinefile = 'onlinefile', // 在线文件/文件夹 + flashtransfer = 'flashtransfer', // QQ闪传 +} +// ==================== 基础消息段类型 ==================== + +// 纯文本消息段 +export const OB11MessageTextSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.text), + data: Type.Object({ + text: Type.String({ description: '纯文本内容' }), + }), +}, { $id: 'OB11MessageText', description: '纯文本消息段' }); + +// 表情消息段 +export const OB11MessageFaceSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.face), + data: Type.Object({ + id: Type.String({ description: '表情ID' }), + resultId: Type.Optional(Type.String({ description: '结果ID' })), + chainCount: Type.Optional(Type.Number({ description: '连击数' })), + }), +}, { $id: 'OB11MessageFace', description: 'QQ表情消息段' }); + +// 商城表情消息段 +export const OB11MessageMFaceSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.mface), + data: Type.Object({ + emoji_package_id: Type.Number({ description: '表情包ID' }), + emoji_id: Type.String({ description: '表情ID' }), + key: Type.String({ description: '表情key' }), + summary: Type.String({ description: '表情摘要' }), + }), +}, { $id: 'OB11MessageMFace', description: '商城表情消息段' }); + +// @消息段 +export const OB11MessageAtSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.at), + data: Type.Object({ + qq: Type.String({ description: 'QQ号或all' }), + name: Type.Optional(Type.String({ description: '显示名称' })), + }), +}, { $id: 'OB11MessageAt', description: '@消息段' }); + +// 回复消息段 +export const OB11MessageReplySchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.reply), + data: Type.Object({ + id: Type.Optional(Type.String({ description: '消息ID的短ID映射' })), + seq: Type.Optional(Type.Number({ description: '消息序列号,优先使用' })), + }), +}, { $id: 'OB11MessageReply', description: '回复消息段' }); + +// ==================== 文件类消息段 ==================== + +// 文件消息段基础数据 Schema +export const FileBaseDataSchema = Type.Object({ + file: Type.String({ description: '文件路径/URL/file:///' }), + path: Type.Optional(Type.String({ description: '文件路径' })), + url: Type.Optional(Type.String({ description: '文件URL' })), + name: Type.Optional(Type.String({ description: '文件名' })), + thumb: Type.Optional(Type.String({ description: '缩略图' })), +}, { $id: 'FileBaseData', description: '文件消息段基础数据' }); + +// 文件消息基础接口 Schema +export const OB11MessageFileBaseSchema = Type.Object({ + data: FileBaseDataSchema, +}, { $id: 'OB11MessageFileBase', description: '文件消息基础接口' }); + +// 图片消息段 +export const OB11MessageImageSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.image), + data: Type.Intersect([ + Type.Omit(FileBaseDataSchema, []), + Type.Object({ + summary: Type.Optional(Type.String({ description: '图片摘要' })), + sub_type: Type.Optional(Type.Number({ description: '图片子类型' })), + }), + ]), +}, { $id: 'OB11MessageImage', description: '图片消息段' }); + +// 语音消息段 +export const OB11MessageRecordSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.voice), + data: FileBaseDataSchema, +}, { $id: 'OB11MessageRecord', description: '语音消息段' }); + +// 视频消息段 +export const OB11MessageVideoSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.video), + data: FileBaseDataSchema, +}, { $id: 'OB11MessageVideo', description: '视频消息段' }); + +// 文件消息段 +export const OB11MessageFileSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.file), + data: FileBaseDataSchema, +}, { $id: 'OB11MessageFile', description: '文件消息段' }); + +// ==================== 音乐消息段 ==================== + +// ID音乐消息段数据 +const IdMusicDataSchema = Type.Object({ + type: Type.Union([ + Type.Literal('qq'), + Type.Literal('163'), + Type.Literal('kugou'), + Type.Literal('migu'), + Type.Literal('kuwo'), + ], { description: '音乐平台类型' }), + id: Type.Union([Type.String(), Type.Number()], { description: '音乐ID' }), +}); + +// 自定义音乐消息段数据 +const CustomMusicDataSchema = Type.Object({ + type: Type.Union([ + Type.Literal('qq'), + Type.Literal('163'), + Type.Literal('kugou'), + Type.Literal('migu'), + Type.Literal('kuwo'), + Type.Literal('custom'), + ], { description: '音乐平台类型' }), + id: Type.Undefined(), + url: Type.String({ description: '点击后跳转URL' }), + audio: Type.Optional(Type.String({ description: '音频URL' })), + title: Type.Optional(Type.String({ description: '音乐标题' })), + image: Type.String({ description: '封面图片URL' }), + content: Type.Optional(Type.String({ description: '音乐简介' })), +}); + +// ID音乐消息段 +export const OB11MessageIdMusicSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.music), + data: IdMusicDataSchema, +}, { $id: 'OB11MessageIdMusic', description: 'ID音乐消息段' }); + +// 自定义音乐消息段 +export const OB11MessageCustomMusicSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.music), + data: CustomMusicDataSchema, +}, { $id: 'OB11MessageCustomMusic', description: '自定义音乐消息段' }); + +// ==================== 特殊消息段 ==================== + +// 戳一戳消息段 +export const OB11MessagePokeSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.poke), + data: Type.Object({ + type: Type.String({ description: '戳一戳类型' }), + id: Type.String({ description: '戳一戳ID' }), + }), +}, { $id: 'OB11MessagePoke', description: '戳一戳消息段' }); + +// 骰子消息段 +export const OB11MessageDiceSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.dice), + data: Type.Object({ + result: Type.Union([Type.Number(), Type.String()], { description: '骰子结果' }), + }), +}, { $id: 'OB11MessageDice', description: '骰子消息段' }); + +// 猜拳消息段 +export const OB11MessageRPSSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.rps), + data: Type.Object({ + result: Type.Union([Type.Number(), Type.String()], { description: '猜拳结果' }), + }), +}, { $id: 'OB11MessageRPS', description: '猜拳消息段' }); + +// 联系人消息段 +export const OB11MessageContactSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.contact), + data: Type.Object({ + type: Type.Union([Type.Literal('qq'), Type.Literal('group')], { description: '联系人类型' }), + id: Type.String({ description: '联系人ID' }), + }), +}, { $id: 'OB11MessageContact', description: '联系人消息段' }); + +// 位置消息段 +export const OB11MessageLocationSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.location), + data: Type.Object({ + lat: Type.Union([Type.String(), Type.Number()], { description: '纬度' }), + lon: Type.Union([Type.String(), Type.Number()], { description: '经度' }), + title: Type.Optional(Type.String({ description: '标题' })), + content: Type.Optional(Type.String({ description: '内容' })), + }), +}, { $id: 'OB11MessageLocation', description: '位置消息段' }); + +// ==================== 富文本消息段 ==================== + +// JSON消息段 +export const OB11MessageJsonSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.json), + data: Type.Object({ + data: Type.Union([Type.String(), Type.Object({})], { description: 'JSON数据' }), + config: Type.Optional(Type.Object({ + token: Type.String({ description: 'token' }), + })), + }), +}, { $id: 'OB11MessageJson', description: 'JSON消息段' }); + +// XML消息段 +export const OB11MessageXmlSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.xml), + data: Type.Object({ + data: Type.String({ description: 'XML数据' }), + }), +}, { $id: 'OB11MessageXml', description: 'XML消息段' }); + +// Markdown消息段 +export const OB11MessageMarkdownSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.markdown), + data: Type.Object({ + content: Type.String({ description: 'Markdown内容' }), + }), +}, { $id: 'OB11MessageMarkdown', description: 'Markdown消息段' }); + +// 小程序消息段 +export const OB11MessageMiniAppSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.miniapp), + data: Type.Object({ + data: Type.String({ description: '小程序数据' }), + }), +}, { $id: 'OB11MessageMiniApp', description: '小程序消息段' }); + +// ==================== 在线文件消息段 ==================== + +// 在线文件消息段 +export const OB11MessageOnlineFileSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.onlinefile), + data: Type.Object({ + msgId: Type.String({ description: '消息ID' }), + elementId: Type.String({ description: '元素ID' }), + fileName: Type.String({ description: '文件名' }), + fileSize: Type.String({ description: '文件大小' }), + isDir: Type.Boolean({ description: '是否为目录' }), + }), +}, { $id: 'OB11MessageOnlineFile', description: '在线文件消息段' }); + +// 闪传消息段 +export const OB11MessageFlashTransferSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.flashtransfer), + data: Type.Object({ + fileSetId: Type.String({ description: '文件集ID' }), + }), +}, { $id: 'OB11MessageFlashTransfer', description: 'QQ闪传消息段' }); + +// ==================== 合并转发消息段(递归类型)==================== + +// 由于 TypeBox 的递归类型限制,我们需要使用 Type.Recursive +// 但为了与原始类型完全兼容,我们使用 Type.Unknown 来表示递归部分 + +// 合并转发消息节点 +export const OB11MessageNodeSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.node), + data: Type.Object({ + id: Type.Optional(Type.String({ description: '转发消息ID' })), + user_id: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '发送者QQ号' })), + uin: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '发送者QQ号(兼容go-cqhttp)' })), + nickname: Type.String({ description: '发送者昵称' }), + name: Type.Optional(Type.String({ description: '发送者昵称(兼容go-cqhttp)' })), + // content 使用 Any 以支持循环引用,实际类型是 OB11MessageMixType + content: Type.Any({ description: '消息内容 (OB11MessageMixType)' }), + source: Type.Optional(Type.String({ description: '消息来源' })), + news: Type.Optional(Type.Array(Type.Object({ + text: Type.String({ description: '新闻文本' }), + }))), + summary: Type.Optional(Type.String({ description: '摘要' })), + prompt: Type.Optional(Type.String({ description: '提示' })), + time: Type.Optional(Type.String({ description: '时间' })), + }), +}, { $id: 'OB11MessageNode', description: '合并转发消息节点' }); + +// 合并转发消息段 +export const OB11MessageForwardSchema = Type.Object({ + type: Type.Literal(OB11MessageDataType.forward), + data: Type.Object({ + id: Type.String({ description: '合并转发ID' }), + // content 使用 Any 以支持类型兼容,实际类型是 OB11Message[] + content: Type.Optional(Type.Any({ description: '消息内容 (OB11Message[])' })), + }), +}, { $id: 'OB11MessageForward', description: '合并转发消息段' }); + +// ==================== 消息段联合类型 ==================== + +// 所有消息段的联合类型(与原始 OB11MessageData 完全一致) +export const OB11MessageDataSchema = Type.Union([ + OB11MessageTextSchema, + OB11MessageFaceSchema, + OB11MessageMFaceSchema, + OB11MessageAtSchema, + OB11MessageReplySchema, + OB11MessageImageSchema, + OB11MessageRecordSchema, + OB11MessageVideoSchema, + OB11MessageFileSchema, + OB11MessageIdMusicSchema, + OB11MessageCustomMusicSchema, + OB11MessagePokeSchema, + OB11MessageDiceSchema, + OB11MessageRPSSchema, + OB11MessageContactSchema, + OB11MessageLocationSchema, + OB11MessageJsonSchema, + OB11MessageXmlSchema, + OB11MessageMarkdownSchema, + OB11MessageMiniAppSchema, + OB11MessageNodeSchema, + OB11MessageForwardSchema, + OB11MessageOnlineFileSchema, + OB11MessageFlashTransferSchema, +], { $id: 'OB11MessageData', description: 'OneBot 11 消息段' }); + +// 消息混合类型(数组、字符串或单个消息段) +export const OB11MessageMixTypeSchema = Type.Union([ + Type.Array(OB11MessageDataSchema), + Type.String(), + OB11MessageDataSchema, +], { $id: 'OB11MessageMixType', description: 'OneBot 11 消息混合类型' }); + +// ==================== 发送消息请求 ==================== + +// 发送消息请求 +export const OB11PostSendMsgSchema = Type.Object({ + message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型' })), + user_id: Type.Optional(Type.String({ description: '用户QQ号' })), + group_id: Type.Optional(Type.String({ description: '群号' })), + message: OB11MessageMixTypeSchema, + messages: Type.Optional(OB11MessageMixTypeSchema), + auto_escape: Type.Optional(Type.Union([Type.Boolean(), Type.String()], { description: '是否作为纯文本发送' })), + source: Type.Optional(Type.String({ description: '消息来源' })), + news: Type.Optional(Type.Array(Type.Object({ + text: Type.String({ description: '文本' }), + }))), + summary: Type.Optional(Type.String({ description: '摘要' })), + prompt: Type.Optional(Type.String({ description: '提示' })), + time: Type.Optional(Type.String({ description: '时间' })), +}, { $id: 'OB11PostSendMsg', description: 'OneBot 11 发送消息请求' }); + +// ==================== 完整消息对象 ==================== + +// 发送者信息 Schema(注意:OB11Sender 类型已在 data.ts 中定义,这里只提供 Schema) +export const OB11SenderSchema = Type.Object({ + user_id: Type.Union([Type.Number(), Type.String()], { description: '发送者QQ号' }), + nickname: Type.String({ description: '发送者昵称' }), + card: Type.Optional(Type.String({ description: '群名片' })), + role: Type.Optional(Type.String({ description: '角色' })), + sex: Type.Optional(Type.String({ description: '性别' })), + age: Type.Optional(Type.Number({ description: '年龄' })), + area: Type.Optional(Type.String({ description: '地区' })), + level: Type.Optional(Type.String({ description: '等级' })), + title: Type.Optional(Type.String({ description: '头衔' })), +}, { $id: 'OB11Sender', description: 'OneBot 11 发送者信息' }); + +// 完整消息对象 +export const OB11MessageSchema = Type.Object({ + real_seq: Type.Optional(Type.String({ description: '真实序列号' })), + temp_source: Type.Optional(Type.Number({ description: '临时会话来源' })), + message_sent_type: Type.Optional(Type.String({ description: '消息发送类型' })), + target_id: Type.Optional(Type.Number({ description: '目标ID' })), + self_id: Type.Optional(Type.Number({ description: '机器人QQ号' })), + time: Type.Number({ description: '消息时间戳' }), + message_id: Type.Number({ description: '消息ID' }), + message_seq: Type.Number({ description: '消息序列号' }), + real_id: Type.Number({ description: '真实ID' }), + user_id: Type.Union([Type.Number(), Type.String()], { description: '发送者QQ号' }), + group_id: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '群号' })), + group_name: Type.Optional(Type.String({ description: '群名称' })), + message_type: Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型' }), + sub_type: Type.Optional(Type.Union([ + Type.Literal('friend'), + Type.Literal('group'), + Type.Literal('normal'), + ], { description: '消息子类型' })), + sender: OB11SenderSchema, + message: Type.Union([Type.Array(OB11MessageDataSchema), Type.String()], { description: '消息内容' }), + message_format: Type.Union([Type.Literal('array'), Type.Literal('string')], { description: '消息格式' }), + raw_message: Type.String({ description: '原始消息' }), + font: Type.Number({ description: '字体' }), + post_type: Type.Optional(Type.String({ description: '上报类型' })), + raw: Type.Optional(Type.Unknown({ description: '原始消息对象' })), + emoji_likes_list: Type.Optional(Type.Array(Type.Object({ + emoji_id: Type.String({ description: '表情ID' }), + emoji_type: Type.String({ description: '表情类型' }), + likes_cnt: Type.String({ description: '点赞数' }), + }), { description: '表情点赞列表' })), +}, { $id: 'OB11Message', description: 'OneBot 11 完整消息对象' }); + +// ==================== TypeScript 类型导出 ==================== + +export type OB11MessageText = Static; +export type OB11MessageFace = Static; +export type OB11MessageMFace = Static; +export type OB11MessageAt = Static; +export type OB11MessageReply = Static; +export type OB11MessageFileBase = Static; +export type OB11MessageImage = Static; +export type OB11MessageRecord = Static; +export type OB11MessageVideo = Static; +export type OB11MessageFile = Static; +export type OB11MessageIdMusic = Static; +export type OB11MessageCustomMusic = Static; +export type OB11MessagePoke = Static; +export type OB11MessageDice = Static; +export type OB11MessageRPS = Static; +export type OB11MessageContact = Static; +export type OB11MessageLocation = Static; +export type OB11MessageJson = Static; +export type OB11MessageXml = Static; +export type OB11MessageMarkdown = Static; +export type OB11MessageMiniApp = Static; +export type OB11MessageNode = Static; +export type OB11MessageForward = Static; +export type OB11MessageOnlineFile = Static; +export type OB11MessageFlashTransfer = Static; +export type OB11MessageData = Static; +export type OB11MessageMixType = Static; +export type OB11PostSendMsg = Static; +// 注意:OB11Sender 类型已在 data.ts 中定义,避免重复导出 +// export type OB11Sender = Static; +export type OB11Message = Static; + +// 合并转发消息节点纯文本接口定义 +export type OB11MessageNodePlain = OB11MessageNode & { + data: { + content?: Array; + message: Array; + }; +}; + +// 返回数据接口定义 +export interface OB11Return { + status: string; + retcode: number; + data: DataType; + message: string; + echo?: unknown; // ws调用api才有此字段 + wording?: string; // go-cqhttp字段,错误信息 + stream?: 'stream-action' | 'normal-action'; // 流式返回标记 +} + +// 合并转发消息接口定义 +export interface OB11ForwardMessage extends OB11Message { + content: OB11MessageData[] | string; +} + +// 消息类型枚举 +export enum OB11MessageType { + private = 'private', + group = 'group', +} + +export interface OB11PostContext { + message_type?: 'private' | 'group'; + user_id?: string; + group_id?: string; +} diff --git a/packages/snowcat-onebot/types/quick.ts b/packages/snowcat-onebot/types/quick.ts new file mode 100644 index 0000000..56d761b --- /dev/null +++ b/packages/snowcat-onebot/types/quick.ts @@ -0,0 +1,36 @@ +import { OB11BaseMetaEvent } from '@/snowcat-onebot/event/meta/OB11BaseMetaEvent'; +import { OB11BaseNoticeEvent } from '@/snowcat-onebot/event/notice/OB11BaseNoticeEvent'; +import { OB11Message, OB11MessageMixType } from '@/snowcat-onebot/types/message'; + +export type QuickActionEvent = OB11Message | OB11BaseMetaEvent | OB11BaseNoticeEvent; +export type PostEventType = OB11Message | OB11BaseMetaEvent | OB11BaseNoticeEvent; + +export interface QuickActionPrivateMessage { + reply?: OB11MessageMixType; + auto_escape?: boolean; +} + +export interface QuickActionGroupMessage extends QuickActionPrivateMessage { + // 回复群消息 + at_sender?: boolean; + delete?: boolean; + kick?: boolean; + ban?: boolean; + ban_duration?: number; +} + +export interface QuickActionFriendRequest { + approve?: boolean; + remark?: string; +} + +export interface QuickActionGroupRequest { + approve?: boolean; + reason?: string; +} + +export type QuickAction = + QuickActionPrivateMessage + & QuickActionGroupMessage + & QuickActionFriendRequest + & QuickActionGroupRequest; diff --git a/packages/snowcat-plugin-builtin/index.ts b/packages/snowcat-plugin-builtin/index.ts new file mode 100644 index 0000000..316a6da --- /dev/null +++ b/packages/snowcat-plugin-builtin/index.ts @@ -0,0 +1,378 @@ +import type { ActionMap } from 'snowcat-types/snowcat-onebot/action/index'; +import { EventType } from 'snowcat-types/snowcat-onebot/event/index'; +import type { PluginModule, PluginLogger, PluginConfigSchema, PluginConfigUIController } from 'snowcat-types/snowcat-onebot/network/plugin-manger'; +import type { OB11Message, OB11PostSendMsg } from 'snowcat-types/snowcat-onebot/types/index'; +import fs from 'fs'; +import path from 'path'; +import { NetworkAdapterConfig } from 'snowcat-types/snowcat-onebot/config/config'; + +const startTime: number = Date.now(); +let logger: PluginLogger | null = null; + +interface BuiltinPluginConfig { + prefix: string; + enableReply: boolean; + description: string; + theme?: string; + features?: string[]; + apiUrl?: string; + apiEndpoints?: string[]; + [key: string]: unknown; +} + +let currentConfig: BuiltinPluginConfig = { + prefix: '#napcat', + enableReply: true, + description: '这是一个内置插件的配置示例', +}; + +export let plugin_config_ui: PluginConfigSchema = []; + +const plugin_init: PluginModule['plugin_init'] = async (ctx) => { + logger = ctx.logger; + logger.info('SnowCat 内置插件已初始化'); + plugin_config_ui = ctx.SnowCatConfig.combine( + ctx.SnowCatConfig.html('

👋 Welcome to NapCat Builtin Plugin

This is a demonstration of the plugin configuration interface with reactive fields.

'), + ctx.SnowCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'), + ctx.SnowCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'), + // 代表监听 apiUrl 字段的变化 + ctx.SnowCatConfig.text('apiUrl', 'API URL', '', 'Enter an API URL to load available endpoints', true), + ctx.SnowCatConfig.select('theme', 'Theme Selection', [ + { label: 'Light Mode', value: 'light' }, + { label: 'Dark Mode', value: 'dark' }, + { label: 'Auto', value: 'auto' }, + ], 'light', 'Select a theme for the response (Demo purpose only)'), + ctx.SnowCatConfig.multiSelect('features', 'Enabled Features', [ + { label: 'Version Info', value: 'version' }, + { label: 'Status Report', value: 'status' }, + { label: 'Debug Log', value: 'debug' }, + ], ['version'], 'Select features to enable'), + ctx.SnowCatConfig.text('description', 'Description', '这是一个内置插件的配置示例', 'A multi-line text area for notes') + ); + + // Try to load config + try { + // Use ctx.configPath + if (fs.existsSync(ctx.configPath)) { + const savedConfig = JSON.parse(fs.readFileSync(ctx.configPath, 'utf-8')); + Object.assign(currentConfig, savedConfig); + } + } catch (e) { + logger?.warn('Failed to load config', e); + } + + // ==================== 注册 WebUI 路由示例 ==================== + + // 注册静态资源目录 + // 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权) + ctx.router.static('/static', 'webui'); + + // 注册内存生成的静态资源(无需鉴权) + // 可通过 /plugin/{pluginId}/mem/dynamic/info.json 访问 + ctx.router.staticOnMem('/dynamic', [ + { + path: '/info.json', + contentType: 'application/json', + // 使用生成器函数动态生成内容 + content: () => JSON.stringify({ + pluginName: ctx.pluginName, + generatedAt: new Date().toISOString(), + uptime: Date.now() - startTime, + config: currentConfig, + }, null, 2), + }, + { + path: '/readme.txt', + contentType: 'text/plain', + content: `SnowCat Builtin Plugin\n=====================\nThis is a demonstration of the staticOnMem feature.\nPlugin: ${ctx.pluginName}\nPath: ${ctx.pluginPath}`, + }, + ]); + + // 注册 API 路由(需要鉴权,挂载到 /api/Plugin/ext/{pluginId}/) + ctx.router.get('/status', (_req, res) => { + const uptime = Date.now() - startTime; + res.json({ + code: 0, + data: { + pluginName: ctx.pluginName, + uptime, + uptimeFormatted: formatUptime(uptime), + config: currentConfig, + platform: process.platform, + arch: process.arch, + }, + }); + }); + + ctx.router.get('/config', (_req, res) => { + res.json({ + code: 0, + data: currentConfig, + }); + }); + + ctx.router.post('/config', (req, res) => { + try { + const newConfig = req.body as Partial; + Object.assign(currentConfig, newConfig); + // 保存配置 + const configDir = path.dirname(ctx.configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(ctx.configPath, JSON.stringify(currentConfig, null, 2), 'utf-8'); + res.json({ code: 0, message: 'Config saved successfully' }); + } catch (e: any) { + res.status(500).json({ code: -1, message: e.message }); + } + }); + + // ==================== 无认证 API 路由示例 ==================== + // 路由挂载到 /plugin/{pluginId}/api/,无需 WebUI 登录即可访问 + + // 获取插件公开信息(无需鉴权) + ctx.router.getNoAuth('/public/info', (_req, res) => { + const uptime = Date.now() - startTime; + res.json({ + code: 0, + data: { + pluginName: ctx.pluginName, + uptime, + uptimeFormatted: formatUptime(uptime), + platform: process.platform, + }, + }); + }); + + // 健康检查接口(无需鉴权) + ctx.router.getNoAuth('/health', (_req, res) => { + res.json({ + code: 0, + data: { + status: 'ok', + timestamp: new Date().toISOString(), + }, + }); + }); + + // ==================== 插件互调用示例 ==================== + // 演示如何调用其他插件的导出方法 + ctx.router.get('/call-plugin/:pluginId', (req, res) => { + const { pluginId } = req.params; + + if (!pluginId) { + res.status(400).json({ + code: -1, + message: 'Plugin ID is required', + }); + return; + } + + // 使用 getPluginExports 获取其他插件的导出模块 + const targetPlugin = ctx.getPluginExports(pluginId); + + if (!targetPlugin) { + res.status(404).json({ + code: -1, + message: `Plugin '${pluginId}' not found or not loaded`, + }); + return; + } + + // 返回目标插件的信息 + res.json({ + code: 0, + data: { + pluginId, + hasInit: typeof targetPlugin.plugin_init === 'function', + hasOnMessage: typeof targetPlugin.plugin_onmessage === 'function', + hasOnEvent: typeof targetPlugin.plugin_onevent === 'function', + hasCleanup: typeof targetPlugin.plugin_cleanup === 'function', + hasConfigSchema: Array.isArray(targetPlugin.plugin_config_schema), + hasConfigUI: Array.isArray(targetPlugin.plugin_config_ui), + }, + }); + }); + + // 注册扩展页面(无需鉴权,可通过 /plugin/{pluginId}/page/dashboard 访问) + ctx.router.page({ + path: 'dashboard', + title: '插件仪表盘', + icon: '📊', + htmlFile: 'webui/dashboard.html', + description: '查看内置插件的运行状态和配置', + }); + + logger.info('WebUI 路由已注册:'); + logger.info(' - API 路由(需认证): /api/Plugin/ext/' + ctx.pluginName + '/'); + logger.info(' - API 路由(无认证): /plugin/' + ctx.pluginName + '/api/'); + logger.info(' - 扩展页面: /plugin/' + ctx.pluginName + '/page/dashboard'); + logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/'); + logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/'); +}; + +export const plugin_get_config: PluginModule['plugin_get_config'] = async () => { + return currentConfig; +}; + +export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config) => { + currentConfig = config as BuiltinPluginConfig; + if (ctx && ctx.configPath) { + try { + const configPath = ctx.configPath; + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + } catch (e) { + logger?.error('Failed to save config', e); + throw e; + } + } +}; + +/** + * 响应式配置控制器 - 当插件配置界面打开时调用 + * 用于初始化动态 UI 控制 + */ +export const plugin_config_controller: PluginModule['plugin_config_controller'] = async (_ctx, ui, initialConfig) => { + logger?.info('配置控制器已初始化', initialConfig); + + // 如果初始配置中有 apiUrl,立即加载端点 + if (initialConfig['apiUrl']) { + await loadEndpointsForUrl(ui, initialConfig['apiUrl'] as string); + } + + // 返回清理函数 + return () => { + logger?.info('配置控制器已清理'); + }; +}; + +/** + * 响应式字段变化处理 - 当标记为 reactive 的字段值变化时调用 + */ +export const plugin_on_config_change: PluginModule['plugin_on_config_change'] = async (_ctx, ui, key, value, _currentConfig: Partial) => { + logger?.info(`配置字段变化: ${key} = ${value}`); + + if (key === 'apiUrl') { + await loadEndpointsForUrl(ui, value as string); + } +}; + +/** + * 根据 API URL 动态加载端点列表 + */ +async function loadEndpointsForUrl (ui: PluginConfigUIController, apiUrl: string) { + if (!apiUrl) { + // URL 为空时,移除端点选择字段 + ui.removeField('apiEndpoints'); + return; + } + + // 模拟从 API 获取端点列表(实际使用时可以 fetch 真实 API) + const mockEndpoints = [ + { label: `${apiUrl}/users`, value: '/users' }, + { label: `${apiUrl}/posts`, value: '/posts' }, + { label: `${apiUrl}/comments`, value: '/comments' }, + { label: `${apiUrl}/albums`, value: '/albums' }, + ]; + + // 动态添加或更新端点选择字段 + const currentSchema = ui.getCurrentConfig(); + if ('apiEndpoints' in currentSchema) { + // 更新现有字段的选项 + ui.updateField('apiEndpoints', { + options: mockEndpoints, + description: `从 ${apiUrl} 加载的端点`, + }); + } else { + // 添加新字段 + ui.addField({ + key: 'apiEndpoints', + type: 'multi-select', + label: 'API Endpoints', + description: `从 ${apiUrl} 加载的端点`, + options: mockEndpoints, + default: [], + }, 'apiUrl'); + } +} + +const plugin_onmessage: PluginModule['plugin_onmessage'] = async (_ctx, event) => { + if (currentConfig.enableReply === false) { + return; + } + const prefix = currentConfig.prefix || '#napcat'; + if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith(prefix)) { + return; + } + + try { + const versionInfo = await getVersionInfo(_ctx.actions, _ctx.adapterName, _ctx.pluginManager.config); + if (!versionInfo) return; + + const message = formatVersionMessage(versionInfo); + await sendMessage(_ctx.actions, event, message, _ctx.adapterName, _ctx.pluginManager.config); + + logger?.info('已回复版本信息'); + } catch (error) { + logger?.error('处理消息时发生错误:', error); + } +}; + +async function getVersionInfo (actions: ActionMap, adapter: string, config: NetworkAdapterConfig) { + if (!actions) return null; + + try { + const data = await actions.call('get_version_info', undefined, adapter, config); + return { + appName: data.app_name, + appVersion: data.app_version, + protocolVersion: data.protocol_version, + }; + } catch (error) { + logger?.error('获取版本信息失败:', error); + return null; + } +} + +function formatUptime (ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}天 ${hours % 24}小时 ${minutes % 60}分钟`; + } else if (hours > 0) { + return `${hours}小时 ${minutes % 60}分钟`; + } else if (minutes > 0) { + return `${minutes}分钟 ${seconds % 60}秒`; + } else { + return `${seconds}秒`; + } +} + +function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) { + const uptime = Date.now() - startTime; + return `SnowCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}\n运行时间: ${formatUptime(uptime)}`; +} + +async function sendMessage (actions: ActionMap, event: OB11Message, message: string, adapter: string, config: NetworkAdapterConfig) { + const params: OB11PostSendMsg = { + message, + message_type: event.message_type, + ...(event.message_type === 'group' && event.group_id ? { group_id: String(event.group_id) } : {}), + ...(event.message_type === 'private' && event.user_id ? { user_id: String(event.user_id) } : {}), + }; + + try { + await actions.call('send_msg', params, adapter, config); + } catch (error) { + logger?.error('发送消息失败:', error); + } +} + +export { plugin_init, plugin_onmessage }; diff --git a/packages/snowcat-plugin-builtin/package.json b/packages/snowcat-plugin-builtin/package.json new file mode 100644 index 0000000..acf0a9e --- /dev/null +++ b/packages/snowcat-plugin-builtin/package.json @@ -0,0 +1,18 @@ +{ + "name": "snowcat-plugin-builtin", + "plugin": "内置插件", + "version": "1.0.0", + "type": "module", + "main": "index.mjs", + "description": "NapCat 内置插件", + "author": "NapNeko", + "dependencies": { + "snowcat-types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/packages/snowcat-plugin-builtin/tsconfig.json b/packages/snowcat-plugin-builtin/tsconfig.json new file mode 100644 index 0000000..48d3a03 --- /dev/null +++ b/packages/snowcat-plugin-builtin/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-plugin-builtin/vite.config.ts b/packages/snowcat-plugin-builtin/vite.config.ts new file mode 100644 index 0000000..57c0893 --- /dev/null +++ b/packages/snowcat-plugin-builtin/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import { builtinModules } from 'module'; + +const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); +// 依赖排除 +const external = []; + +export default defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/snowcat-core': resolve(__dirname, '../snowcat-core'), + '@': resolve(__dirname, '../'), + }, + }, + build: { + sourcemap: false, + target: 'esnext', + minify: false, + lib: { + entry: 'index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + external: [...nodeModules, ...external], + }, + }, + plugins: [nodeResolve()], +}); diff --git a/packages/snowcat-plugin-builtin/webui/dashboard.html b/packages/snowcat-plugin-builtin/webui/dashboard.html new file mode 100644 index 0000000..88fc754 --- /dev/null +++ b/packages/snowcat-plugin-builtin/webui/dashboard.html @@ -0,0 +1,447 @@ + + + + + + + 内置插件仪表盘 + + + + +
+
+
+

NapCat 内置插件仪表盘

+
+ +
+
加载中
+
+
+ +
+
+

当前配置

+
+
+
加载中
+
+
+ +
+
+

静态资源测试

+
+
+

+ 测试插件静态资源服务是否正常工作(不需要鉴权) +

+
+ + +
+
+
+
+ + +
+ + + + + \ No newline at end of file diff --git a/packages/snowcat-plugin-builtin/webui/test.txt b/packages/snowcat-plugin-builtin/webui/test.txt new file mode 100644 index 0000000..2d51280 --- /dev/null +++ b/packages/snowcat-plugin-builtin/webui/test.txt @@ -0,0 +1,6 @@ +Hello from NapCat Builtin Plugin! + +这是一个静态资源测试文件。 +如果你能看到这段文字,说明插件的静态资源服务正常工作。 + +时间戳: 2026-01-30 diff --git a/packages/snowcat-protobuf/NapProto.ts b/packages/snowcat-protobuf/NapProto.ts new file mode 100644 index 0000000..09e47c3 --- /dev/null +++ b/packages/snowcat-protobuf/NapProto.ts @@ -0,0 +1,202 @@ +// @ts-nocheck +import { MessageType, PartialMessage, RepeatType, ScalarType } from '@protobuf-ts/runtime'; +import { PartialFieldInfo } from '@protobuf-ts/runtime/build/types/reflection-info'; + +export type LowerCamelCase = CamelCaseHelper; + +export type CamelCaseHelper< + S extends string, + CapNext extends boolean, + IsFirstChar extends boolean +> = S extends `${infer F}${infer R}` + ? F extends '_' + ? CamelCaseHelper + : F extends `${number}` + ? `${F}${CamelCaseHelper}` + : CapNext extends true + ? `${Uppercase}${CamelCaseHelper}` + : IsFirstChar extends true + ? `${Lowercase}${CamelCaseHelper}` + : `${F}${CamelCaseHelper}` + : ''; + +export type ScalarTypeToTsType = T extends + | ScalarType.DOUBLE + | ScalarType.FLOAT + | ScalarType.INT32 + | ScalarType.FIXED32 + | ScalarType.UINT32 + | ScalarType.SFIXED32 + | ScalarType.SINT32 + ? number + : T extends ScalarType.INT64 | ScalarType.UINT64 | ScalarType.FIXED64 | ScalarType.SFIXED64 | ScalarType.SINT64 + ? bigint + : T extends ScalarType.BOOL + ? boolean + : T extends ScalarType.STRING + ? string + : T extends ScalarType.BYTES + ? Uint8Array + : never; + +export interface BaseProtoFieldType { + kind: 'scalar' | 'message'; + no: number; + type: T; + optional: O; + repeat: R; +} + +export interface ScalarProtoFieldType + extends BaseProtoFieldType { + kind: 'scalar'; +} + +export interface MessageProtoFieldType< + T extends () => ProtoMessageType, + O extends boolean, + R extends O extends true ? false : boolean +> extends BaseProtoFieldType { + kind: 'message'; +} + +export type ProtoFieldType = + | ScalarProtoFieldType + | MessageProtoFieldType<() => ProtoMessageType, boolean, boolean>; + +export type ProtoMessageType = { + [key: string]: ProtoFieldType; +}; + +export function ProtoField< + T extends ScalarType, + O extends boolean = false, + R extends O extends true ? false : boolean = false +> (no: number, type: T, optional?: O, repeat?: R): ScalarProtoFieldType; +export function ProtoField< + T extends () => ProtoMessageType, + O extends boolean = false, + R extends O extends true ? false : boolean = false +> (no: number, type: T, optional?: O, repeat?: R): MessageProtoFieldType; +export function ProtoField ( + no: number, + type: ScalarType | (() => ProtoMessageType), + optional?: boolean, + repeat?: boolean +): ProtoFieldType { + if (typeof type === 'function') { + return { kind: 'message', no, type, optional: optional ?? false, repeat: repeat ?? false }; + } else { + return { kind: 'scalar', no, type, optional: optional ?? false, repeat: repeat ?? false }; + } +} + +export type ProtoFieldReturnType = + NonNullable extends ScalarProtoFieldType + ? ScalarTypeToTsType + : T extends NonNullable> + ? NonNullable, E>> + : never; + +export type RequiredFieldsBaseType = { + [K in keyof T as T[K] extends { optional: true; } ? never : LowerCamelCase]: T[K] extends { + repeat: true; + } + ? ProtoFieldReturnType[] + : ProtoFieldReturnType; +}; + +export type OptionalFieldsBaseType = { + [K in keyof T as T[K] extends { optional: true; } ? LowerCamelCase : never]?: T[K] extends { + repeat: true; + } + ? ProtoFieldReturnType[] + : ProtoFieldReturnType; +}; + +export type RequiredFieldsType = E extends true + ? Partial> + : RequiredFieldsBaseType; + +export type OptionalFieldsType = E extends true + ? Partial> + : OptionalFieldsBaseType; + +export type NapProtoStructType = RequiredFieldsType & OptionalFieldsType; + +export type NapProtoEncodeStructType = NapProtoStructType; + +export type NapProtoDecodeStructType = NapProtoStructType; + +class NapProtoRealMsg { + private readonly _field: PartialFieldInfo[]; + private readonly _proto_msg: MessageType>; + private static cache = new WeakMap>(); + + private constructor (fields: T) { + this._field = Object.keys(fields).map((key) => { + const field = fields[key]; + if (field.kind === 'scalar') { + const repeatType = field.repeat + ? [ScalarType.STRING, ScalarType.BYTES].includes(field.type) + ? RepeatType.UNPACKED + : RepeatType.PACKED + : RepeatType.NO; + return { + no: field.no, + name: key, + kind: 'scalar', + T: field.type, + opt: field.optional, + repeat: repeatType, + }; + } else if (field.kind === 'message') { + return { + no: field.no, + name: key, + kind: 'message', + repeat: field.repeat ? RepeatType.PACKED : RepeatType.NO, + T: () => NapProtoRealMsg.getInstance(field.type())._proto_msg, + }; + } else { + throw new Error(`Unknown field kind: ${field.kind}`); + } + }) as PartialFieldInfo[]; + this._proto_msg = new MessageType>('nya', this._field); + } + + static getInstance (fields: T): NapProtoRealMsg { + let instance = this.cache.get(fields); + if (!instance) { + instance = new NapProtoRealMsg(fields); + this.cache.set(fields, instance); + } + return instance; + } + + encode (data: NapProtoEncodeStructType): Uint8Array { + return this._proto_msg.toBinary(this._proto_msg.create(data as PartialMessage>)); + } + + decode (data: Uint8Array): NapProtoDecodeStructType { + return this._proto_msg.fromBinary(data) as NapProtoDecodeStructType; + } +} + +export class NapProtoMsg { + private realMsg: NapProtoRealMsg; + + constructor (fields: T) { + this.realMsg = NapProtoRealMsg.getInstance(fields); + } + + encode (data: NapProtoEncodeStructType): Uint8Array { + return this.realMsg.encode(data); + } + + decode (data: Uint8Array): NapProtoDecodeStructType { + return this.realMsg.decode(data); + } +} + +export { ScalarType } from '@protobuf-ts/runtime'; diff --git a/packages/snowcat-protobuf/package.json b/packages/snowcat-protobuf/package.json new file mode 100644 index 0000000..4eee857 --- /dev/null +++ b/packages/snowcat-protobuf/package.json @@ -0,0 +1,17 @@ +{ + "name": "snowcat-protobuf", + "type": "module", + "version": "0.0.4", + "description": "A lightweight, elegant, and efficient protobuf-ts solution", + "main": "NapProto.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "prepack": "tsc" + }, + "dependencies": { + "@protobuf-ts/runtime": "^2.9.4" + }, + "devDependencies": { + "typescript": "^5.6" + } +} diff --git a/packages/snowcat-protobuf/tsconfig.json b/packages/snowcat-protobuf/tsconfig.json new file mode 100644 index 0000000..74a6186 --- /dev/null +++ b/packages/snowcat-protobuf/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": true, + "declarationDir": "dist", + "sourceMap": true + }, + "include": [ + "*.ts" + ] +} \ No newline at end of file diff --git a/packages/snowcat-protocol/action/index.ts b/packages/snowcat-protocol/action/index.ts new file mode 100644 index 0000000..1891a77 --- /dev/null +++ b/packages/snowcat-protocol/action/index.ts @@ -0,0 +1,51 @@ +import { SnowCatCore } from 'snowcat-core'; +import { SnowCatProtocolResponse } from '@/snowcat-protocol/types'; + +// 前向声明类型,避免循环依赖 +import type { SnowCatProtocolAdapter } from '@/snowcat-protocol/index'; + +// Action 基类 +export abstract class BaseAction { + abstract actionName: string; + protected core: SnowCatCore; + protected adapter: SnowCatProtocolAdapter; + + constructor (adapter: SnowCatProtocolAdapter, core: SnowCatCore) { + this.adapter = adapter; + this.core = core; + } + + protected abstract _handle (payload: PayloadType): Promise; + + async handle (payload: PayloadType): Promise> { + try { + const result = await this._handle(payload); + return { + status: 'ok', + retcode: 0, + data: result, + }; + } catch (e) { + return { + status: 'failed', + retcode: -1, + data: null, + message: e instanceof Error ? e.message : String(e), + }; + } + } +} + +// Action 映射类型 +export type ActionMap = Map>; + +// 创建 Action 映射 + +export function createActionMap (_adapter: SnowCatProtocolAdapter, _core: SnowCatCore): ActionMap { + const actionMap = new Map>(); + + // 这里可以注册各种 Action + // 例如: actionMap.set('send_msg', new SendMsgAction(adapter, core)); + + return actionMap; +} diff --git a/packages/snowcat-protocol/api/index.ts b/packages/snowcat-protocol/api/index.ts new file mode 100644 index 0000000..9cb1088 --- /dev/null +++ b/packages/snowcat-protocol/api/index.ts @@ -0,0 +1,33 @@ +import { SnowCatCore } from 'snowcat-core'; +import { SnowCatProtocolAdapter } from '@/snowcat-protocol/index'; + +// NapCat Protocol API 基类 +export abstract class SnowCatProtocolApiBase { + protected adapter: SnowCatProtocolAdapter; + protected core: SnowCatCore; + + constructor (adapter: SnowCatProtocolAdapter, core: SnowCatCore) { + this.adapter = adapter; + this.core = core; + } +} + +// 消息 API +export class SnowCatProtocolMsgApi extends SnowCatProtocolApiBase { + // 消息相关 API 方法可以在这里实现 +} + +// 用户 API +export class SnowCatProtocolUserApi extends SnowCatProtocolApiBase { + // 用户相关 API 方法可以在这里实现 +} + +// 群组 API +export class SnowCatProtocolGroupApi extends SnowCatProtocolApiBase { + // 群组相关 API 方法可以在这里实现 +} + +// 好友 API +export class SnowCatProtocolFriendApi extends SnowCatProtocolApiBase { + // 好友相关 API 方法可以在这里实现 +} diff --git a/packages/snowcat-protocol/config/config.ts b/packages/snowcat-protocol/config/config.ts new file mode 100644 index 0000000..434879b --- /dev/null +++ b/packages/snowcat-protocol/config/config.ts @@ -0,0 +1,69 @@ +import { Type, Static } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; + +// WebSocket 服务端配置 +const WebsocketServerConfigSchema = Type.Object({ + name: Type.String({ default: 'snowcat-ws-server' }), + enable: Type.Boolean({ default: false }), + host: Type.String({ default: '127.0.0.1' }), + port: Type.Number({ default: 6700 }), + token: Type.String({ default: '' }), + heartInterval: Type.Number({ default: 30000 }), + debug: Type.Boolean({ default: false }), +}); + +// WebSocket 客户端配置 +const WebsocketClientConfigSchema = Type.Object({ + name: Type.String({ default: 'snowcat-ws-client' }), + enable: Type.Boolean({ default: false }), + url: Type.String({ default: 'ws://localhost:6701' }), + token: Type.String({ default: '' }), + reconnectInterval: Type.Number({ default: 5000 }), + heartInterval: Type.Number({ default: 30000 }), + debug: Type.Boolean({ default: false }), +}); + +// HTTP 服务端配置 +const HttpServerConfigSchema = Type.Object({ + name: Type.String({ default: 'snowcat-http-server' }), + enable: Type.Boolean({ default: false }), + host: Type.String({ default: '127.0.0.1' }), + port: Type.Number({ default: 6702 }), + token: Type.String({ default: '' }), + enableCors: Type.Boolean({ default: true }), + debug: Type.Boolean({ default: false }), +}); + +// 网络配置 +const NetworkConfigSchema = Type.Object({ + httpServers: Type.Array(HttpServerConfigSchema, { default: [] }), + websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }), + websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }), +}, { default: {} }); + +// NapCat Protocol 主配置 - 默认关闭 +export const SnowCatProtocolConfigSchema = Type.Object({ + enable: Type.Boolean({ default: false }), // 默认关闭 + network: NetworkConfigSchema, +}); + +export type SnowCatProtocolConfig = Static; +export type HttpServerConfig = Static; +export type WebsocketServerConfig = Static; +export type WebsocketClientConfig = Static; + +export type NetworkAdapterConfig = HttpServerConfig | WebsocketServerConfig | WebsocketClientConfig; +export type NetworkConfigKey = keyof SnowCatProtocolConfig['network']; + +export function loadConfig (config: Partial): SnowCatProtocolConfig { + let data = config; + data = Value.Parse(SnowCatProtocolConfigSchema, data) as Partial; + const validate = TypeCompiler.Compile(SnowCatProtocolConfigSchema); + const valid = validate.Check(data); + if (!valid) { + const errorMsg = [...validate.Errors(data)].map(e => e.message).join(', '); + throw new Error(errorMsg); + } + return data as SnowCatProtocolConfig; +} diff --git a/packages/snowcat-protocol/config/index.ts b/packages/snowcat-protocol/config/index.ts new file mode 100644 index 0000000..a7b929b --- /dev/null +++ b/packages/snowcat-protocol/config/index.ts @@ -0,0 +1,11 @@ +import { ConfigBase, SnowCatCore } from 'snowcat-core'; + +import { SnowCatProtocolConfig, SnowCatProtocolConfigSchema } from './config'; + +export class SnowCatProtocolConfigLoader extends ConfigBase { + constructor (core: SnowCatCore, configPath: string) { + super('napcat_protocol', core, configPath, SnowCatProtocolConfigSchema); + } +} + +export * from './config'; diff --git a/packages/snowcat-protocol/event/SnowCatProtocolEvent.ts b/packages/snowcat-protocol/event/SnowCatProtocolEvent.ts new file mode 100644 index 0000000..904affc --- /dev/null +++ b/packages/snowcat-protocol/event/SnowCatProtocolEvent.ts @@ -0,0 +1,66 @@ +import { SnowCatCore } from 'snowcat-core'; + +// NapCat Protocol 事件基类 +export abstract class SnowCatProtocolEvent { + protected core: SnowCatCore; + public time: number; + public self_id: number; + public post_type: string; + + constructor (core: SnowCatCore) { + this.core = core; + this.time = Math.floor(Date.now() / 1000); + this.self_id = parseInt(core.selfInfo.uin); + this.post_type = 'event'; + } + + abstract toJSON (): Record; +} + +// 消息事件基类 +export abstract class SnowCatProtocolMessageEvent extends SnowCatProtocolEvent { + public message_type: 'private' | 'group'; + public message_id: number; + public user_id: number; + + constructor (core: SnowCatCore, messageType: 'private' | 'group', messageId: number, userId: number) { + super(core); + this.post_type = 'message'; + this.message_type = messageType; + this.message_id = messageId; + this.user_id = userId; + } +} + +// 通知事件基类 +export abstract class SnowCatProtocolNoticeEvent extends SnowCatProtocolEvent { + public notice_type: string; + + constructor (core: SnowCatCore, noticeType: string) { + super(core); + this.post_type = 'notice'; + this.notice_type = noticeType; + } +} + +// 请求事件基类 +export abstract class SnowCatProtocolRequestEvent extends SnowCatProtocolEvent { + public request_type: string; + + constructor (core: SnowCatCore, requestType: string) { + super(core); + this.post_type = 'request'; + this.request_type = requestType; + } +} + +// 元事件基类 +export abstract class SnowCatProtocolMetaEvent extends SnowCatProtocolEvent { + public meta_event_type: string; + + constructor (core: SnowCatCore, metaEventType: string) { + super(core); + this.post_type = 'meta_event'; + this.meta_event_type = metaEventType; + } +} diff --git a/packages/snowcat-protocol/event/index.ts b/packages/snowcat-protocol/event/index.ts new file mode 100644 index 0000000..cb2b4dd --- /dev/null +++ b/packages/snowcat-protocol/event/index.ts @@ -0,0 +1 @@ +export * from './SnowCatProtocolEvent'; diff --git a/packages/snowcat-protocol/index.ts b/packages/snowcat-protocol/index.ts new file mode 100644 index 0000000..77ba34f --- /dev/null +++ b/packages/snowcat-protocol/index.ts @@ -0,0 +1,104 @@ +import { + InstanceContext, + SnowCatCore, +} from 'snowcat-core'; +import { SnowCatProtocolConfigLoader, SnowCatProtocolConfig } from '@/snowcat-protocol/config'; +import { SnowCatPathWrapper } from 'snowcat-common/src/path'; +import { + SnowCatProtocolNetworkManager, +} from '@/snowcat-protocol/network'; +import { + SnowCatProtocolMsgApi, + SnowCatProtocolUserApi, + SnowCatProtocolGroupApi, + SnowCatProtocolFriendApi, +} from '@/snowcat-protocol/api'; +import { ActionMap, createActionMap } from '@/snowcat-protocol/action'; + +interface ApiListType { + MsgApi: SnowCatProtocolMsgApi; + UserApi: SnowCatProtocolUserApi; + GroupApi: SnowCatProtocolGroupApi; + FriendApi: SnowCatProtocolFriendApi; +} + +// NapCat Protocol 适配器 - NapCat 私有 Bot 协议实现 +export class SnowCatProtocolAdapter { + readonly core: SnowCatCore; + readonly context: InstanceContext; + + configLoader: SnowCatProtocolConfigLoader; + public apis: ApiListType; + networkManager: SnowCatProtocolNetworkManager; + actions: ActionMap; + + constructor (core: SnowCatCore, context: InstanceContext, pathWrapper: SnowCatPathWrapper) { + this.core = core; + this.context = context; + this.configLoader = new SnowCatProtocolConfigLoader(core, pathWrapper.configPath); + this.apis = { + MsgApi: new SnowCatProtocolMsgApi(this, core), + UserApi: new SnowCatProtocolUserApi(this, core), + GroupApi: new SnowCatProtocolGroupApi(this, core), + FriendApi: new SnowCatProtocolFriendApi(this, core), + } as const; + this.actions = createActionMap(this, core); + this.networkManager = new SnowCatProtocolNetworkManager(); + } + + // 检查协议是否启用 + isEnabled (): boolean { + return this.configLoader.configData.enable; + } + + async createProtocolLog (config: SnowCatProtocolConfig) { + let log = '[SnowCat Protocol] 配置加载\n'; + log += `协议状态: ${config.enable ? '已启用' : '已禁用'}\n`; + + if (config.enable) { + for (const key of config.network.httpServers) { + log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of config.network.websocketServers) { + log += `WebSocket服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + for (const key of config.network.websocketClients) { + log += `WebSocket客户端: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`; + } + } + return log; + } + + async initProtocol () { + const config = this.configLoader.configData; + + // 如果协议未启用,直接返回 + if (!config.enable) { + this.context.logger.log('[SnowCat Protocol] 协议未启用,跳过初始化'); + return; + } + + const selfInfo = this.core.selfInfo; + const serviceInfo = await this.createProtocolLog(config); + this.context.logger.log(`[Notice] ${serviceInfo}`); + + // 注册网络适配器 + // 这里可以根据配置注册不同的网络适配器 + // 例如: WebSocket Server, WebSocket Client, HTTP Server 等 + + await this.networkManager.openAllAdapters(); + + this.context.logger.log(`[SnowCat Protocol] 初始化完成,Bot: ${selfInfo.uin}`); + } + + async close () { + await this.networkManager.closeAllAdapters(); + this.context.logger.log('[SnowCat Protocol] 已关闭所有网络适配器'); + } +} + +export * from './types/index'; +export * from './api/index'; +export * from './event/index'; +export * from './config/index'; +export * from './network/index'; diff --git a/packages/snowcat-protocol/network/adapter.ts b/packages/snowcat-protocol/network/adapter.ts new file mode 100644 index 0000000..e3fb6c6 --- /dev/null +++ b/packages/snowcat-protocol/network/adapter.ts @@ -0,0 +1,37 @@ +import { NetworkAdapterConfig } from '@/snowcat-protocol/config/config'; +import { LogWrapper } from 'snowcat-core/helper/log'; +import { SnowCatCore } from 'snowcat-core'; +import { SnowCatProtocolAdapter } from '@/snowcat-protocol/index'; +import { ActionMap } from '@/snowcat-protocol/action'; +import { SnowCatProtocolEmitEventContent, SnowCatProtocolNetworkReloadType } from '@/snowcat-protocol/network/index'; + +export abstract class INapCatProtocolNetworkAdapter { + name: string; + isEnable: boolean = false; + config: CT; + readonly logger: LogWrapper; + readonly core: SnowCatCore; + readonly protocolContext: SnowCatProtocolAdapter; + readonly actions: ActionMap; + + constructor (name: string, config: CT, core: SnowCatCore, protocolContext: SnowCatProtocolAdapter, actions: ActionMap) { + this.name = name; + this.config = structuredClone(config); + this.core = core; + this.protocolContext = protocolContext; + this.actions = actions; + this.logger = core.context.logger; + } + + abstract onEvent (event: T): Promise; + + abstract open (): void | Promise; + + abstract close (): void | Promise; + + abstract reload (config: unknown): SnowCatProtocolNetworkReloadType | Promise; + + get isActive (): boolean { + return this.isEnable; + } +} diff --git a/packages/snowcat-protocol/network/index.ts b/packages/snowcat-protocol/network/index.ts new file mode 100644 index 0000000..d93c1e5 --- /dev/null +++ b/packages/snowcat-protocol/network/index.ts @@ -0,0 +1,112 @@ +import { SnowCatProtocolEvent } from '@/snowcat-protocol/event/SnowCatProtocolEvent'; +import { SnowCatProtocolMessage } from '@/snowcat-protocol/types'; +import { NetworkAdapterConfig } from '@/snowcat-protocol/config/config'; +import { INapCatProtocolNetworkAdapter } from '@/snowcat-protocol/network/adapter'; + +export type SnowCatProtocolEmitEventContent = SnowCatProtocolEvent | SnowCatProtocolMessage; + +export enum SnowCatProtocolNetworkReloadType { + Normal = 0, + ConfigChange = 1, + NetWorkReload = 2, + NetWorkClose = 3, + NetWorkOpen = 4, +} + +export class SnowCatProtocolNetworkManager { + adapters: Map> = new Map(); + + async openAllAdapters () { + return Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.open())); + } + + async emitEvent (event: SnowCatProtocolEmitEventContent) { + return Promise.all(Array.from(this.adapters.values()).map(async adapter => { + if (adapter.isActive) { + return await adapter.onEvent(event); + } + })); + } + + async emitEvents (events: SnowCatProtocolEmitEventContent[]) { + return Promise.all(events.map(event => this.emitEvent(event))); + } + + async emitEventByName (names: string[], event: SnowCatProtocolEmitEventContent) { + return Promise.all(names.map(async name => { + const adapter = this.adapters.get(name); + if (adapter && adapter.isActive) { + return await adapter.onEvent(event); + } + })); + } + + async emitEventByNames (map: Map) { + return Promise.all(Array.from(map.entries()).map(async ([name, event]) => { + const adapter = this.adapters.get(name); + if (adapter && adapter.isActive) { + return await adapter.onEvent(event); + } + })); + } + + registerAdapter (adapter: INapCatProtocolNetworkAdapter) { + this.adapters.set(adapter.name, adapter); + } + + async registerAdapterAndOpen (adapter: INapCatProtocolNetworkAdapter) { + this.registerAdapter(adapter); + await adapter.open(); + } + + async closeSomeAdapters (adaptersToClose: INapCatProtocolNetworkAdapter[]) { + for (const adapter of adaptersToClose) { + this.adapters.delete(adapter.name); + await adapter.close(); + } + } + + async closeSomeAdapterWhenOpen (adaptersToClose: INapCatProtocolNetworkAdapter[]) { + for (const adapter of adaptersToClose) { + this.adapters.delete(adapter.name); + if (adapter.isEnable) { + await adapter.close(); + } + } + } + + findSomeAdapter (name: string) { + return this.adapters.get(name); + } + + async closeAdapterByPredicate (closeFilter: (adapter: INapCatProtocolNetworkAdapter) => boolean) { + const adaptersToClose = Array.from(this.adapters.values()).filter(closeFilter); + await this.closeSomeAdapters(adaptersToClose); + } + + async closeAllAdapters () { + await Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.close())); + this.adapters.clear(); + } + + async reloadAdapter (name: string, config: T) { + const adapter = this.adapters.get(name); + if (adapter) { + await adapter.reload(config); + } + } + + async reloadSomeAdapters (configMap: Map) { + await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.reloadAdapter(name, config))); + } + + hasActiveAdapters (): boolean { + return Array.from(this.adapters.values()).some(adapter => adapter.isActive); + } + + async getAllConfig () { + return Array.from(this.adapters.values()).map(adapter => adapter.config); + } +} + +export * from './adapter'; diff --git a/packages/snowcat-protocol/package.json b/packages/snowcat-protocol/package.json new file mode 100644 index 0000000..d236a73 --- /dev/null +++ b/packages/snowcat-protocol/package.json @@ -0,0 +1,35 @@ +{ + "name": "snowcat-protocol", + "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": { + "@sinclair/typebox": "^0.34.38", + "cors": "^2.8.5", + "express": "^5.0.0", + "json5": "^2.2.3", + "snowcat-common": "workspace:*", + "snowcat-core": "workspace:*", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-protocol/tsconfig.json b/packages/snowcat-protocol/tsconfig.json new file mode 100644 index 0000000..48d3a03 --- /dev/null +++ b/packages/snowcat-protocol/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-protocol/types/index.ts b/packages/snowcat-protocol/types/index.ts new file mode 100644 index 0000000..9bf060f --- /dev/null +++ b/packages/snowcat-protocol/types/index.ts @@ -0,0 +1,70 @@ +// NapCat Protocol 消息类型定义 + +export interface SnowCatProtocolMessage { + post_type: 'message' | 'notice' | 'request' | 'meta_event'; + time: number; + self_id: number; + message_type?: 'private' | 'group'; + sub_type?: string; + message_id?: number; + user_id?: number; + group_id?: number; + message?: SnowCatProtocolMessageSegment[] | string; + raw_message?: string; + sender?: SnowCatProtocolSender; +} + +export interface SnowCatProtocolMessageSegment { + type: string; + data: Record; +} + +export interface SnowCatProtocolSender { + user_id: number; + nickname: string; + card?: string; + sex?: 'male' | 'female' | 'unknown'; + age?: number; + area?: string; + level?: string; + role?: 'owner' | 'admin' | 'member'; + title?: string; +} + +// API 请求类型 +export interface SnowCatProtocolRequest { + action: string; + params?: Record; + echo?: string | number; +} + +// API 响应类型 +export interface SnowCatProtocolResponse { + status: 'ok' | 'failed'; + retcode: number; + data: T | null; + message?: string; + echo?: string | number; +} + +// 心跳事件 +export interface SnowCatProtocolHeartbeat { + post_type: 'meta_event'; + meta_event_type: 'heartbeat'; + time: number; + self_id: number; + status: { + online: boolean; + good: boolean; + }; + interval: number; +} + +// 生命周期事件 +export interface SnowCatProtocolLifecycle { + post_type: 'meta_event'; + meta_event_type: 'lifecycle'; + time: number; + self_id: number; + sub_type: 'connect' | 'enable' | 'disable'; +} diff --git a/packages/snowcat-pty/index.ts b/packages/snowcat-pty/index.ts new file mode 100644 index 0000000..847e3ef --- /dev/null +++ b/packages/snowcat-pty/index.ts @@ -0,0 +1,31 @@ +import type { ITerminal, IPtyOpenOptions, IPtyForkOptions, IWindowsPtyForkOptions } from '@homebridge/node-pty-prebuilt-multiarch/src/interfaces'; +import type { ArgvOrCommandLine } from './node_modules/@homebridge/node-pty-prebuilt-multiarch/src/types'; +// import type { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { WindowsTerminal } from './windowsTerminal'; +import { UnixTerminal } from './unixTerminal'; +import { fileURLToPath } from 'node:url'; +import path, { dirname } from 'node:path'; + +let terminalCtor: typeof WindowsTerminal | typeof UnixTerminal; + +if (process.platform === 'win32') { + terminalCtor = WindowsTerminal; +} else { + terminalCtor = UnixTerminal; +} + +export function spawn (file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal { + // eslint-disable-next-line new-cap + return new terminalCtor(file, args, opt); +} + +export function open (options: IPtyOpenOptions): ITerminal { + return terminalCtor.open(options) as ITerminal; +} +export function require_dlopen (modulename: string) { + const module = { exports: {} }; + const import__dirname = dirname(fileURLToPath(import.meta.url)); + process.dlopen(module, path.join(import__dirname, modulename)); + + return module.exports as any; +} diff --git a/packages/snowcat-pty/native.d.ts b/packages/snowcat-pty/native.d.ts new file mode 100644 index 0000000..973aca5 --- /dev/null +++ b/packages/snowcat-pty/native.d.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +interface IConptyNative { + startProcess(file: string, cols: number, rows: number, debug: boolean, pipeName: string, conptyInheritCursor: boolean, useConptyDll: boolean): IConptyProcess; + connect(ptyId: number, commandLine: string, cwd: string, env: string[], onExitCallback: (exitCode: number) => void): { pid: number }; + resize(ptyId: number, cols: number, rows: number, useConptyDll: boolean): void; + clear(ptyId: number, useConptyDll: boolean): void; + kill(ptyId: number, useConptyDll: boolean): void; +} + +interface IWinptyNative { + startProcess(file: string, commandLine: string, env: string[], cwd: string, cols: number, rows: number, debug: boolean): IWinptyProcess; + resize(pid: number, cols: number, rows: number): void; + kill(pid: number, innerPid: number): void; + getProcessList(pid: number): number[]; + getExitCode(innerPid: number): number; +} + +interface IUnixNative { + fork(file: string, args: string[], parsedEnv: string[], cwd: string, cols: number, rows: number, uid: number, gid: number, useUtf8: boolean, helperPath: string, onExitCallback: (code: number, signal: number) => void): IUnixProcess; + open(cols: number, rows: number): IUnixOpenProcess; + process(fd: number, pty?: string): string; + resize(fd: number, cols: number, rows: number): void; +} + +interface IConptyProcess { + pty: number; + fd: number; + conin: string; + conout: string; +} + +interface IWinptyProcess { + pty: number; + fd: number; + conin: string; + conout: string; + pid: number; + innerPid: number; +} + +interface IUnixProcess { + fd: number; + pid: number; + pty: string; +} + +interface IUnixOpenProcess { + master: number; + slave: number; + pty: string; +} diff --git a/packages/snowcat-pty/node-pty.d.ts b/packages/snowcat-pty/node-pty.d.ts new file mode 100644 index 0000000..729941e --- /dev/null +++ b/packages/snowcat-pty/node-pty.d.ts @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +declare module 'snowcat-pty' { + /** + * Forks a process as a pseudoterminal. + * @param file The file to launch. + * @param args The file's arguments as argv (string[]) or in a pre-escaped CommandLine format + * (string). Note that the CommandLine option is only available on Windows and is expected to be + * escaped properly. + * @param options The options of the terminal. + * @see CommandLineToArgvW https://msdn.microsoft.com/en-us/library/windows/desktop/bb776391(v=vs.85).aspx + * @see Parsing C++ Comamnd-Line Arguments https://msdn.microsoft.com/en-us/library/17w5ykft.aspx + * @see GetCommandLine https://msdn.microsoft.com/en-us/library/windows/desktop/ms683156.aspx + */ + export function spawn (file: string, args: string[] | string, options: IPtyForkOptions | IWindowsPtyForkOptions): IPty; + + export interface IBasePtyForkOptions { + + /** + * Name of the terminal to be set in environment ($TERM variable). + */ + name?: string; + + /** + * Number of intial cols of the pty. + */ + cols?: number; + + /** + * Number of initial rows of the pty. + */ + rows?: number; + + /** + * Working directory to be set for the child program. + */ + cwd?: string; + + /** + * Environment to be set for the child program. + */ + env?: { [key: string]: string | undefined }; + + /** + * String encoding of the underlying pty. + * If set, incoming data will be decoded to strings and outgoing strings to bytes applying this encoding. + * If unset, incoming data will be delivered as raw bytes (Buffer type). + * By default 'utf8' is assumed, to unset it explicitly set it to `null`. + */ + encoding?: string | null; + + /** + * (EXPERIMENTAL) + * Whether to enable flow control handling (false by default). If enabled a message of `flowControlPause` + * will pause the socket and thus blocking the child program execution due to buffer back pressure. + * A message of `flowControlResume` will resume the socket into flow mode. + * For performance reasons only a single message as a whole will match (no message part matching). + * If flow control is enabled the `flowControlPause` and `flowControlResume` messages are not forwarded to + * the underlying pseudoterminal. + */ + handleFlowControl?: boolean; + + /** + * (EXPERIMENTAL) + * The string that should pause the pty when `handleFlowControl` is true. Default is XOFF ('\x13'). + */ + flowControlPause?: string; + + /** + * (EXPERIMENTAL) + * The string that should resume the pty when `handleFlowControl` is true. Default is XON ('\x11'). + */ + flowControlResume?: string; + } + + export interface IPtyForkOptions extends IBasePtyForkOptions { + /** + * Security warning: use this option with great caution, + * as opened file descriptors with higher privileges might leak to the child program. + */ + uid?: number; + gid?: number; + } + + export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { + /** + * Whether to use the ConPTY system on Windows. When this is not set, ConPTY will be used when + * the Windows build number is >= 18309 (instead of winpty). Note that ConPTY is available from + * build 17134 but is too unstable to enable by default. + * + * This setting does nothing on non-Windows. + */ + useConpty?: boolean; + + /** + * (EXPERIMENTAL) + * + * Whether to use the conpty.dll shipped with the node-pty package instead of the one built into + * Windows. Defaults to false. + */ + useConptyDll?: boolean; + + /** + * Whether to use PSEUDOCONSOLE_INHERIT_CURSOR in conpty. + * @see https://docs.microsoft.com/en-us/windows/console/createpseudoconsole + */ + conptyInheritCursor?: boolean; + } + + /** + * An interface representing a pseudoterminal, on Windows this is emulated via the winpty library. + */ + export interface IPty { + /** + * The process ID of the outer process. + */ + readonly pid: number; + + /** + * The column size in characters. + */ + readonly cols: number; + + /** + * The row size in characters. + */ + readonly rows: number; + + /** + * The title of the active process. + */ + readonly process: string; + + /** + * (EXPERIMENTAL) + * Whether to handle flow control. Useful to disable/re-enable flow control during runtime. + * Use this for binary data that is likely to contain the `flowControlPause` string by accident. + */ + handleFlowControl: boolean; + + /** + * Adds an event listener for when a data event fires. This happens when data is returned from + * the pty. + * @returns an `IDisposable` to stop listening. + */ + readonly onData: IEvent; + + /** + * Adds an event listener for when an exit event fires. This happens when the pty exits. + * @returns an `IDisposable` to stop listening. + */ + readonly onExit: IEvent<{ exitCode: number, signal?: number }>; + + /** + * Resizes the dimensions of the pty. + * @param columns The number of columns to use. + * @param rows The number of rows to use. + */ + resize(columns: number, rows: number): void; + + // Re-added this interface as homebridge-config-ui-x leverages it https://github.com/microsoft/node-pty/issues/282 + + /** + * Adds a listener to the data event, fired when data is returned from the pty. + * @param event The name of the event. + * @param listener The callback function. + * @deprecated Use IPty.onData + */ + on(event: 'data', listener: (data: string) => void): void; + + /** + * Adds a listener to the exit event, fired when the pty exits. + * @param event The name of the event. + * @param listener The callback function, exitCode is the exit code of the process and signal is + * the signal that triggered the exit. signal is not supported on Windows. + * @deprecated Use IPty.onExit + */ + on(event: 'exit', listener: (exitCode: number, signal?: number) => void): void; + + /** + * Clears the pty's internal representation of its buffer. This is a no-op + * unless on Windows/ConPTY. This is useful if the buffer is cleared on the + * frontend in order to synchronize state with the backend to avoid ConPTY + * possibly reprinting the screen. + */ + clear(): void; + + /** + * Writes data to the pty. + * @param data The data to write. + */ + write(data: string): void; + + /** + * Kills the pty. + * @param signal The signal to use, defaults to SIGHUP. This parameter is not supported on + * Windows. + * @throws Will throw when signal is used on Windows. + */ + kill(signal?: string): void; + + /** + * Pauses the pty for customizable flow control. + */ + pause(): void; + + /** + * Resumes the pty for customizable flow control. + */ + resume(): void; + } + + /** + * An object that can be disposed via a dispose function. + */ + export interface IDisposable { + dispose(): void; + } + + /** + * An event that can be listened to. + * @returns an `IDisposable` to stop listening. + */ + export interface IEvent { + + (listener: (e: T) => any): IDisposable; + } +} diff --git a/packages/snowcat-pty/package.json b/packages/snowcat-pty/package.json new file mode 100644 index 0000000..7b3594e --- /dev/null +++ b/packages/snowcat-pty/package.json @@ -0,0 +1,26 @@ +{ + "name": "snowcat-pty", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.ts", + "types": "node-pty.d.ts", + "exports": { + ".": { + "import": "./index.ts" + }, + "./src/*": { + "import": "./*" + } + }, + "dependencies": { + "@homebridge/node-pty-prebuilt-multiarch":"^0.12.0" + + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-pty/prebuild-loader.ts b/packages/snowcat-pty/prebuild-loader.ts new file mode 100644 index 0000000..d39d31e --- /dev/null +++ b/packages/snowcat-pty/prebuild-loader.ts @@ -0,0 +1,10 @@ +import { require_dlopen } from './index'; +export function pty_loader () { + let pty: any; + try { + pty = require_dlopen('./native/pty/' + process.platform + '.' + process.arch + '/pty.node'); + } catch { + pty = undefined; + } + return pty; +} diff --git a/packages/snowcat-pty/tsconfig.json b/packages/snowcat-pty/tsconfig.json new file mode 100644 index 0000000..26b76ad --- /dev/null +++ b/packages/snowcat-pty/tsconfig.json @@ -0,0 +1,50 @@ +{ + "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, + "skipLibCheck": true, + "skipDefaultLibCheck": true + }, + "include": [ + "*.ts", + "*.d.ts", + "**/*.ts", + "**/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-pty/unixTerminal.ts b/packages/snowcat-pty/unixTerminal.ts new file mode 100644 index 0000000..481fa00 --- /dev/null +++ b/packages/snowcat-pty/unixTerminal.ts @@ -0,0 +1,307 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +import * as net from 'net'; +import * as path from 'path'; +import * as tty from 'tty'; +import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from '@homebridge/node-pty-prebuilt-multiarch/src/terminal'; +import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from '@homebridge/node-pty-prebuilt-multiarch/src/interfaces'; +import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils'; +import { pty_loader } from './prebuild-loader'; +import { fileURLToPath } from 'url'; + +// 懒加载pty,避免在模块导入时立即执行pty_loader() +let _pty: any; +export const pty: any = new Proxy({}, { + get (_target, prop) { + if (!_pty) { + _pty = pty_loader(); + } + return _pty[prop]; + }, +}); + +let helperPath: string; +helperPath = '../build/Release/spawn-helper'; +const import__dirname = path.dirname(fileURLToPath(import.meta.url)); +helperPath = path.resolve(import__dirname, helperPath); +helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); + +const DEFAULT_FILE = 'sh'; +const DEFAULT_NAME = 'xterm'; +const DESTROY_SOCKET_TIMEOUT_MS = 200; + +export class UnixTerminal extends Terminal { + protected override _fd: number; + protected override _pty: string; + + protected override _file: string; + protected override _name: string; + + protected override _readable: boolean; + protected override _writable: boolean; + + private _boundClose: boolean = false; + private _emittedClose: boolean = false; + private _master: net.Socket | undefined; + private _slave: net.Socket | undefined; + + public get master (): net.Socket | undefined { return this._master; } + public get slave (): net.Socket | undefined { return this._slave; } + + constructor (file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions) { + super(opt); + + if (typeof args === 'string') { + throw new Error('args as a string is not supported on unix.'); + } + + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + + this._cols = opt.cols || DEFAULT_COLS; + this._rows = opt.rows || DEFAULT_ROWS; + const uid = opt.uid ?? -1; + const gid = opt.gid ?? -1; + const env: IProcessEnv = assign({}, opt.env); + + if (opt.env === process.env) { + this._sanitizeEnv(env); + } + + const cwd = opt.cwd || process.cwd(); + env['PWD'] = cwd; + const name = opt.name || env['TERM'] || DEFAULT_NAME; + env['TERM'] = name; + const parsedEnv = this._parseEnv(env); + + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + + const onexit = (code: number, signal: number): void => { + // XXX Sometimes a data event is emitted after exit. Wait til socket is + // destroyed. + if (!this._emittedClose) { + if (this._boundClose) { + return; + } + this._boundClose = true; + // From macOS High Sierra 10.13.2 sometimes the socket never gets + // closed. A timeout is applied here to avoid the terminal never being + // destroyed when this occurs. + let timeout: NodeJS.Timeout | null = setTimeout(() => { + timeout = null; + // Destroying the socket now will cause the close event to fire + this._socket.destroy(); + }, DESTROY_SOCKET_TIMEOUT_MS); + this.once('close', () => { + if (timeout !== null) { + clearTimeout(timeout); + } + this.emit('exit', code, signal); + }); + return; + } + this.emit('exit', code, signal); + }; + + // fork + const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); + + this._socket = new tty.ReadStream(term.fd); + if (encoding !== null) { + this._socket.setEncoding(encoding as BufferEncoding); + } + + // setup + this._socket.on('error', (err: any) => { + // NOTE: fs.ReadStream gets EAGAIN twice at first: + if (err.code) { + if (~err.code.indexOf('EAGAIN')) { + return; + } + } + + // close + this._close(); + // EIO on exit from fs.ReadStream: + if (!this._emittedClose) { + this._emittedClose = true; + this.emit('close'); + } + + // EIO, happens when someone closes our child process: the only process in + // the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if (err.code) { + if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) { + return; + } + } + + // throw anything else + if (this.listeners('error').length < 2) { + throw err; + } + }); + + this._pid = term.pid; + this._fd = term.fd; + this._pty = term.pty; + + this._file = file; + this._name = name; + + this._readable = true; + this._writable = true; + + this._socket.on('close', () => { + if (this._emittedClose) { + return; + } + this._emittedClose = true; + this._close(); + this.emit('close'); + }); + + this._forwardEvents(); + } + + protected _write (data: string): void { + this._socket.write(data); + } + + /* Accessors */ + get fd (): number { return this._fd; } + get ptsName (): string { return this._pty; } + + /** + * openpty + */ + + public static open (opt: IPtyOpenOptions): UnixTerminal { + const self: UnixTerminal = Object.create(UnixTerminal.prototype); + opt = opt || {}; + + if (arguments.length > 1) { + opt = { + cols: arguments[1], + rows: arguments[2], + }; + } + + const cols = opt.cols || DEFAULT_COLS; + const rows = opt.rows || DEFAULT_ROWS; + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + + // open + const term: IUnixOpenProcess = pty.open(cols, rows); + + self._master = new tty.ReadStream(term.master); + if (encoding !== null) { + self._master.setEncoding(encoding as BufferEncoding); + } + self._master.resume(); + + self._slave = new tty.ReadStream(term.slave); + if (encoding !== null) { + self._slave.setEncoding(encoding as BufferEncoding); + } + self._slave.resume(); + + self._socket = self._master; + self._pid = -1; + self._fd = term.master; + self._pty = term.pty; + + self._file = process.argv[0] || 'node'; + self._name = process.env['TERM'] || ''; + + self._readable = true; + self._writable = true; + + self._socket.on('error', err => { + self._close(); + if (self.listeners('error').length < 2) { + throw err; + } + }); + + self._socket.on('close', () => { + self._close(); + }); + + return self; + } + + public destroy (): void { + this._close(); + + // Need to close the read stream so node stops reading a dead file + // descriptor. Then we can safely SIGHUP the shell. + this._socket.once('close', () => { + this.kill('SIGHUP'); + }); + + this._socket.destroy(); + } + + public kill (signal?: string): void { + try { + process.kill(this.pid, signal || 'SIGHUP'); + } catch { /* swallow */ } + } + + /** + * Gets the name of the process. + */ + public get process (): string { + if (process.platform === 'darwin') { + const title = pty.process(this._fd); + return (title !== 'kernel_task') ? title : this._file; + } + + return pty.process(this._fd, this._pty) || this._file; + } + + /** + * TTY + */ + + public resize (cols: number, rows: number): void { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + pty.resize(this._fd, cols, rows); + this._cols = cols; + this._rows = rows; + } + + public clear (): void { + + } + + private _sanitizeEnv (env: IProcessEnv): void { + // Make sure we didn't start our server from inside tmux. + delete env['TMUX']; + delete env['TMUX_PANE']; + + // Make sure we didn't start our server from inside screen. + // http://web.mit.edu/gnu/doc/html/screen_20.html + delete env['STY']; + delete env['WINDOW']; + + // Delete some variables that might confuse our terminal. + delete env['WINDOWID']; + delete env['TERMCAP']; + delete env['COLUMNS']; + delete env['LINES']; + } +} diff --git a/packages/snowcat-pty/windowsConoutConnection.ts b/packages/snowcat-pty/windowsConoutConnection.ts new file mode 100644 index 0000000..911883b --- /dev/null +++ b/packages/snowcat-pty/windowsConoutConnection.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +import { Worker } from 'worker_threads'; +import { Socket } from 'net'; +import { IDisposable } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { IWorkerData, ConoutWorkerMessage, getWorkerPipeName } from '@homebridge/node-pty-prebuilt-multiarch/src/shared/conout'; +import { dirname, join } from 'path'; +import { IEvent, EventEmitter2 } from '@homebridge/node-pty-prebuilt-multiarch/src/eventEmitter2'; +import { fileURLToPath } from 'node:url'; + +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the worker and sockets. The timer will be reset if a new data event comes in after + * the timer has started. + */ +const FLUSH_DATA_INTERVAL = 1000; + +/** + * Connects to and manages the lifecycle of the conout socket. This socket must be drained on + * another thread in order to avoid deadlocks where Conpty waits for the out socket to drain + * when `ClosePseudoConsole` is called. This happens when data is being written to the terminal when + * the pty is closed. + * + * See also: + * - https://github.com/microsoft/node-pty/issues/375 + * - https://github.com/microsoft/vscode/issues/76548 + * - https://github.com/microsoft/terminal/issues/1810 + * - https://docs.microsoft.com/en-us/windows/console/closepseudoconsole + */ +export class ConoutConnection implements IDisposable { + private _worker: Worker; + private _drainTimeout: NodeJS.Timeout | undefined; + private _isDisposed: boolean = false; + + private _onReady = new EventEmitter2(); + public get onReady (): IEvent { return this._onReady.event; } + + constructor ( + private _conoutPipeName: string + ) { + const workerData: IWorkerData = { conoutPipeName: _conoutPipeName }; + const scriptPath = dirname(fileURLToPath(import.meta.url)); + this._worker = new Worker(join(scriptPath, 'worker/conoutSocketWorker.mjs'), { workerData }); + this._worker.on('message', (message: ConoutWorkerMessage) => { + switch (message) { + case ConoutWorkerMessage.READY: + this._onReady.fire(); + return; + default: + console.warn('Unexpected ConoutWorkerMessage', message); + } + }); + } + + dispose (): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + // Drain all data from the socket before closing + this._drainDataAndClose(); + } + + connectSocket (socket: Socket): void { + socket.connect(getWorkerPipeName(this._conoutPipeName)); + } + + private _drainDataAndClose (): void { + if (this._drainTimeout) { + clearTimeout(this._drainTimeout); + } + this._drainTimeout = setTimeout(() => this._destroySocket(), FLUSH_DATA_INTERVAL); + } + + private async _destroySocket (): Promise { + await this._worker.terminate(); + } +} diff --git a/packages/snowcat-pty/windowsPtyAgent.ts b/packages/snowcat-pty/windowsPtyAgent.ts new file mode 100644 index 0000000..b2e13e1 --- /dev/null +++ b/packages/snowcat-pty/windowsPtyAgent.ts @@ -0,0 +1,315 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Socket } from 'net'; +import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { fork } from 'child_process'; +import { ConoutConnection } from './windowsConoutConnection'; +import { require_dlopen } from './index'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +let conptyNative: IConptyNative; +let winptyNative: IWinptyNative; + +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the socket. The timer will be reset if a new data event comes in after the timer + * has started. + */ +const FLUSH_DATA_INTERVAL = 1000; + +/** + * This agent sits between the WindowsTerminal class and provides a common interface for both conpty + * and winpty. + */ +export class WindowsPtyAgent { + private _inSocket: Socket; + private _outSocket: Socket; + private _pid: number = 0; + private _innerPid: number = 0; + private _closeTimeout: NodeJS.Timer | undefined; + private _exitCode: number | undefined; + private _conoutSocketWorker: ConoutConnection; + + private _fd: any; + private _pty: number; + private _ptyNative: IConptyNative | IWinptyNative; + + public get inSocket (): Socket { return this._inSocket; } + public get outSocket (): Socket { return this._outSocket; } + public get fd (): any { return this._fd; } + public get innerPid (): number { return this._innerPid; } + public get pty (): number { return this._pty; } + + constructor ( + file: string, + args: ArgvOrCommandLine, + env: string[], + cwd: string, + cols: number, + rows: number, + debug: boolean, + private _useConpty: boolean | undefined, + private _useConptyDll: boolean = false, + conptyInheritCursor: boolean = false + ) { + if (this._useConpty === undefined || this._useConpty === true) { + this._useConpty = this._getWindowsBuildNumber() >= 18309; + } + if (this._useConpty) { + if (!conptyNative) { + conptyNative = require_dlopen('./native/pty/' + process.platform + '.' + process.arch + '/conpty.node'); + } + } else { + if (!winptyNative) { + winptyNative = require_dlopen('./native/pty/' + process.platform + '.' + process.arch + '/pty.node'); + } + } + this._ptyNative = this._useConpty ? conptyNative : winptyNative; + + // Sanitize input variable. + cwd = path.resolve(cwd); + + // Compose command line + const commandLine = argsToCommandLine(file, args); + + // Open pty session. + let term: IConptyProcess | IWinptyProcess; + if (this._useConpty) { + term = (this._ptyNative as IConptyNative).startProcess(file, cols, rows, debug, this._generatePipeName(), conptyInheritCursor, this._useConptyDll); + } else { + term = (this._ptyNative as IWinptyNative).startProcess(file, commandLine, env, cwd, cols, rows, debug); + this._pid = (term as IWinptyProcess).pid; + this._innerPid = (term as IWinptyProcess).innerPid; + } + + // Not available on windows. + this._fd = term.fd; + + // Generated incremental number that has no real purpose besides using it + // as a terminal id. + this._pty = term.pty; + + // Create terminal pipe IPC channel and forward to a local unix socket. + this._outSocket = new Socket(); + this._outSocket.setEncoding('utf8'); + // The conout socket must be ready out on another thread to avoid deadlocks + this._conoutSocketWorker = new ConoutConnection(term.conout); + this._conoutSocketWorker.onReady(() => { + this._conoutSocketWorker.connectSocket(this._outSocket); + }); + this._outSocket.on('connect', () => { + this._outSocket.emit('ready_datapipe'); + }); + + const inSocketFD = fs.openSync(term.conin, 'w'); + this._inSocket = new Socket({ + fd: inSocketFD, + readable: false, + writable: true, + }); + this._inSocket.setEncoding('utf8'); + + if (this._useConpty) { + const connect = (this._ptyNative as IConptyNative).connect(this._pty, commandLine, cwd, env, c => this._$onProcessExit(c)); + this._innerPid = connect.pid; + } + } + + public resize (cols: number, rows: number): void { + if (this._useConpty) { + if (this._exitCode !== undefined) { + throw new Error('Cannot resize a pty that has already exited'); + } + (this._ptyNative as IConptyNative).resize(this._pty, cols, rows, this._useConptyDll); + return; + } + (this._ptyNative as IWinptyNative).resize(this._pid, cols, rows); + } + + public clear (): void { + if (this._useConpty) { + (this._ptyNative as IConptyNative).clear(this._pty, this._useConptyDll); + } + } + + public kill (): void { + this._inSocket.readable = false; + this._outSocket.readable = false; + // Tell the agent to kill the pty, this releases handles to the process + if (this._useConpty) { + this._getConsoleProcessList().then(consoleProcessList => { + consoleProcessList.forEach((pid: number) => { + try { + process.kill(pid); + } catch { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); + }); + } else { + // Because pty.kill closes the handle, it will kill most processes by itself. + // Process IDs can be reused as soon as all handles to them are + // dropped, so we want to immediately kill the entire console process list. + // If we do not force kill all processes here, node servers in particular + // seem to become detached and remain running (see + // Microsoft/vscode#26807). + const processList: number[] = (this._ptyNative as IWinptyNative).getProcessList(this._pid); + (this._ptyNative as IWinptyNative).kill(this._pid, this._innerPid); + processList.forEach(pid => { + try { + process.kill(pid); + } catch { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + } + this._conoutSocketWorker.dispose(); + } + + private _getConsoleProcessList (): Promise { + const import__dirname = dirname(fileURLToPath(import.meta.url)); + return new Promise(resolve => { + const agent = fork(path.join(import__dirname, 'conpty_console_list_agent'), [this._innerPid.toString()]); + agent.on('message', message => { + clearTimeout(timeout); + // @ts-expect-error no need to check if it is null + resolve(message.consoleProcessList); + }); + const timeout = setTimeout(() => { + // Something went wrong, just send back the shell PID + agent.kill(); + resolve([this._innerPid]); + }, 5000); + }); + } + + public get exitCode (): number | undefined { + if (this._useConpty) { + return this._exitCode; + } + const winptyExitCode = (this._ptyNative as IWinptyNative).getExitCode(this._innerPid); + return winptyExitCode === -1 ? undefined : winptyExitCode; + } + + private _getWindowsBuildNumber (): number { + const release = os.release(); + // Limit input length to prevent potential DoS attacks + if (release.length > 50) { + return 0; + } + // Use non-global regex with more specific pattern to prevent backtracking + const osVersion = /^(\d{1,5})\.(\d{1,5})\.(\d{1,10})/.exec(release); + let buildNumber: number = 0; + if (osVersion && osVersion.length === 4) { + buildNumber = parseInt(osVersion[3]!); + } + return buildNumber; + } + + private _generatePipeName (): string { + return `conpty-${Math.random() * 10000000}`; + } + + /** + * Triggered from the native side when a contpy process exits. + */ + private _$onProcessExit (exitCode: number): void { + this._exitCode = exitCode; + this._flushDataAndCleanUp(); + this._outSocket.on('data', () => this._flushDataAndCleanUp()); + } + + private _flushDataAndCleanUp (): void { + if (this._closeTimeout) { + // @ts-expect-error no need to check if it is null + clearTimeout(this._closeTimeout); + } + this._closeTimeout = setTimeout(() => this._cleanUpProcess(), FLUSH_DATA_INTERVAL); + } + + private _cleanUpProcess (): void { + this._inSocket.readable = false; + this._outSocket.readable = false; + this._outSocket.destroy(); + } +} + +// Convert argc/argv into a Win32 command-line following the escaping convention +// documented on MSDN (e.g. see CommandLineToArgvW documentation). Copied from +// winpty project. +export function argsToCommandLine (file: string, args: ArgvOrCommandLine): string { + if (isCommandLine(args)) { + if (args.length === 0) { + return file; + } + return `${argsToCommandLine(file, [])} ${args}`; + } + const argv = [file]; + Array.prototype.push.apply(argv, args); + let result = ''; + for (let argIndex = 0; argIndex < argv.length; argIndex++) { + if (argIndex > 0) { + result += ' '; + } + const arg = argv[argIndex]; + // if it is empty or it contains whitespace and is not already quoted + const hasLopsidedEnclosingQuote = xOr((arg![0] !== '"'), (arg![arg!.length - 1] !== '"')); + const hasNoEnclosingQuotes = ((arg![0] !== '"') && (arg![arg!.length - 1] !== '"')); + const quote = + arg === '' || + ((arg!.indexOf(' ') !== -1 || + arg!.indexOf('\t') !== -1) && + ((arg!.length > 1) && + (hasLopsidedEnclosingQuote || hasNoEnclosingQuotes))); + if (quote) { + result += '"'; + } + let bsCount = 0; + for (let i = 0; i < arg!.length; i++) { + const p = arg![i]; + if (p === '\\') { + bsCount++; + } else if (p === '"') { + result += repeatText('\\', bsCount * 2 + 1); + result += '"'; + bsCount = 0; + } else { + result += repeatText('\\', bsCount); + bsCount = 0; + result += p; + } + } + if (quote) { + result += repeatText('\\', bsCount * 2); + result += '"'; + } else { + result += repeatText('\\', bsCount); + } + } + return result; +} + +function isCommandLine (args: ArgvOrCommandLine): args is string { + return typeof args === 'string'; +} + +function repeatText (text: string, count: number): string { + let result = ''; + for (let i = 0; i < count; i++) { + result += text; + } + return result; +} + +function xOr (arg1: boolean, arg2: boolean): boolean { + return ((arg1 && !arg2) || (!arg1 && arg2)); +} diff --git a/packages/snowcat-pty/windowsTerminal.ts b/packages/snowcat-pty/windowsTerminal.ts new file mode 100644 index 0000000..d2c48d4 --- /dev/null +++ b/packages/snowcat-pty/windowsTerminal.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import { Socket } from 'net'; +// @ts-check +import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from '@homebridge/node-pty-prebuilt-multiarch/src/terminal'; +import { WindowsPtyAgent } from './windowsPtyAgent'; +import { IPtyOpenOptions, IWindowsPtyForkOptions } from '@homebridge/node-pty-prebuilt-multiarch/src/interfaces'; +import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils'; + +const DEFAULT_FILE = 'cmd.exe'; +const DEFAULT_NAME = 'Windows Shell'; + +export class WindowsTerminal extends Terminal { + private _isReady: boolean; + private _deferreds: any[]; + private _agent: WindowsPtyAgent; + + constructor (file?: string, args?: ArgvOrCommandLine, opt?: IWindowsPtyForkOptions) { + super(opt); + + this._checkType('args', args, 'string', true); + + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + + if (opt.encoding) { + console.warn('Setting encoding on Windows is not supported'); + } + + const env = assign({}, opt.env); + this._cols = opt.cols || DEFAULT_COLS; + this._rows = opt.rows || DEFAULT_ROWS; + const cwd = opt.cwd || process.cwd(); + const name = opt.name || env.TERM || DEFAULT_NAME; + const parsedEnv = this._parseEnv(env); + + // If the terminal is ready + this._isReady = false; + + // Functions that need to run after `ready` event is emitted. + this._deferreds = []; + + // Create new termal. + this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); + this._socket = this._agent.outSocket; + + // Not available until `ready` event emitted. + this._pid = this._agent.innerPid; + this._fd = this._agent.fd; + this._pty = this._agent.pty; + + // The forked windows terminal is not available until `ready` event is + // emitted. + this._socket.on('ready_datapipe', () => { + // These events needs to be forwarded. + ['connect', 'data', 'end', 'timeout', 'drain'].forEach(event => { + this._socket.on(event, () => { + // Wait until the first data event is fired then we can run deferreds. + if (!this._isReady && event === 'data') { + // Terminal is now ready and we can avoid having to defer method + // calls. + this._isReady = true; + + // Execute all deferred methods + this._deferreds.forEach(fn => { + // NB! In order to ensure that `this` has all its references + // updated any variable that need to be available in `this` before + // the deferred is run has to be declared above this forEach + // statement. + fn.run(); + }); + + // Reset + this._deferreds = []; + } + }); + }); + + // Shutdown if `error` event is emitted. + this._socket.on('error', err => { + // Close terminal session. + this._close(); + + // EIO, happens when someone closes our child process: the only process + // in the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if ((err).code) { + if (~(err).code.indexOf('errno 5') || ~(err).code.indexOf('EIO')) return; + } + + // Throw anything else. + if (this.listeners('error').length < 2) { + throw err; + } + }); + + // Cleanup after the socket is closed. + this._socket.on('close', () => { + this.emit('exit', this._agent.exitCode); + this._close(); + }); + }); + + this._file = file; + this._name = name; + + this._readable = true; + this._writable = true; + + this._forwardEvents(); + } + + protected _write (data: string): void { + this._defer(this._doWrite, data); + } + + private _doWrite (data: string): void { + this._agent.inSocket.write(data); + } + + /** + * openpty + */ + + public static open (_options?: IPtyOpenOptions): void { + throw new Error('open() not supported on windows, use Fork() instead.'); + } + + /** + * TTY + */ + + public resize (cols: number, rows: number): void { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + this._deferNoArgs(() => { + this._agent.resize(cols, rows); + this._cols = cols; + this._rows = rows; + }); + } + + public clear (): void { + this._deferNoArgs(() => { + this._agent.clear(); + }); + } + + public destroy (): void { + this._deferNoArgs(() => { + this.kill(); + }); + } + + public kill (signal?: string): void { + this._deferNoArgs(() => { + if (signal) { + throw new Error('Signals not supported on windows.'); + } + this._close(); + this._agent.kill(); + }); + } + + // @ts-expect-error - This is a private method that is not part of the public API. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private _deferNoArgs (deferredFn: () => void): void { + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this); + return; + } + + // Queue until terminal is ready. + this._deferreds.push({ + run: () => deferredFn.call(this), + }); + } + + private _defer (deferredFn: (arg: A) => void, arg: A): void { + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this, arg); + return; + } + + // Queue until terminal is ready. + this._deferreds.push({ + run: () => deferredFn.call(this, arg), + }); + } + + public get process (): string { return this._name; } + public get master (): Socket { throw new Error('master is not supported on Windows'); } + public get slave (): Socket { throw new Error('slave is not supported on Windows'); } +} diff --git a/packages/snowcat-pty/worker/conoutSocketWorker.ts b/packages/snowcat-pty/worker/conoutSocketWorker.ts new file mode 100644 index 0000000..cee739a --- /dev/null +++ b/packages/snowcat-pty/worker/conoutSocketWorker.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +import { parentPort, workerData } from 'worker_threads'; +import { Socket, createServer } from 'net'; +import { ConoutWorkerMessage, IWorkerData, getWorkerPipeName } from '@homebridge/node-pty-prebuilt-multiarch/src/shared/conout'; + +const conoutPipeName = (workerData as IWorkerData).conoutPipeName; +const conoutSocket = new Socket(); +conoutSocket.setEncoding('utf8'); +conoutSocket.connect(conoutPipeName, () => { + const server = createServer(workerSocket => { + conoutSocket.pipe(workerSocket); + }); + server.listen(getWorkerPipeName(conoutPipeName)); + + if (!parentPort) { + throw new Error('worker_threads parentPort is null'); + } + parentPort.postMessage(ConoutWorkerMessage.READY); +}); diff --git a/packages/snowcat-qrcode/README.md b/packages/snowcat-qrcode/README.md new file mode 100644 index 0000000..f5c830f --- /dev/null +++ b/packages/snowcat-qrcode/README.md @@ -0,0 +1,82 @@ +# QRCode Terminal Edition [![Build Status][travis-ci-img]][travis-ci-url] + +> Going where no QRCode has gone before. + +![Basic Example][basic-example-img] + +# Node Library + +## Install + +Can be installed with: + + $ npm install qrcode-terminal + +and used: + + var qrcode = require('qrcode-terminal'); + +## Usage + +To display some data to the terminal just call: + + qrcode.generate('This will be a QRCode, eh!'); + +You can even specify the error level (default is 'L'): + + qrcode.setErrorLevel('Q'); + qrcode.generate('This will be a QRCode with error level Q!'); + +If you don't want to display to the terminal but just want to string you can provide a callback: + + qrcode.generate('http://github.com', function (qrcode) { + console.log(qrcode); + }); + +If you want to display small output, provide `opts` with `small`: + + qrcode.generate('This will be a small QRCode, eh!', {small: true}); + + qrcode.generate('This will be a small QRCode, eh!', {small: true}, function (qrcode) { + console.log(qrcode) + }); + +# Command-Line + +## Install + + $ npm install -g qrcode-terminal + +## Usage + + $ qrcode-terminal --help + $ qrcode-terminal 'http://github.com' + $ echo 'http://github.com' | qrcode-terminal + +# Support + +- OS X +- Linux +- Windows + +# Server-side + +[node-qrcode][node-qrcode-url] is a popular server-side QRCode generator that +renders to a `canvas` object. + +# Developing + +To setup the development envrionment run `npm install` + +To run tests run `npm test` + +# Contributers + + Gord Tanner + Micheal Brooks + +[travis-ci-img]: https://travis-ci.org/gtanner/qrcode-terminal.png +[travis-ci-url]: https://travis-ci.org/gtanner/qrcode-terminal +[basic-example-img]: https://raw.github.com/gtanner/qrcode-terminal/master/example/basic.png +[node-qrcode-url]: https://github.com/soldair/node-qrcode + diff --git a/packages/snowcat-qrcode/lib/main.ts b/packages/snowcat-qrcode/lib/main.ts new file mode 100644 index 0000000..b2e499d --- /dev/null +++ b/packages/snowcat-qrcode/lib/main.ts @@ -0,0 +1,110 @@ +import QRCode from '../vendor/QRCode'; +import { QRErrorCorrectLevel } from '../vendor/QRCode/QRErrorCorrectLevel'; + +const black = '\x1b[40m \x1b[0m'; +const white = '\x1b[47m \x1b[0m'; + +const toCell = (value: boolean | null): string => { + return value ? black : white; +}; + +const repeat = (color: string) => { + return { + times: (count: number): string => { + return new Array(count + 1).join(color); + }, + }; +}; + +const fill = (length: number, value: boolean): boolean[] => { + const arr = new Array(length); + for (let i = 0; i < length; i++) { + arr[i] = value; + } + return arr; +}; + +interface GenerateOptions { + small?: boolean; +} + +type Callback = (output: string) => void; + +export default { + error: QRErrorCorrectLevel.L, + + generate (input: string, opts: GenerateOptions | Callback, cb?: Callback): void { + if (typeof opts === 'function') { + cb = opts; + opts = {}; + } + + const qrcode = new QRCode(-1, this.error); + qrcode.addData(input); + qrcode.make(); + + let output = ''; + if (opts && (opts as GenerateOptions).small) { + const BLACK = true; const WHITE = false; + const moduleCount = qrcode.getModuleCount(); + const moduleData = qrcode.modules ? qrcode.modules.slice() : []; + + const oddRow = moduleCount % 2 === 1; + if (oddRow) { + moduleData.push(fill(moduleCount, WHITE)); + } + + const platte = { + WHITE_ALL: '\u2588', + WHITE_BLACK: '\u2580', + BLACK_WHITE: '\u2584', + BLACK_ALL: ' ', + }; + + const borderTop = repeat(platte.BLACK_WHITE).times(moduleCount + 2); // 修改这里 + const borderBottom = repeat(platte.WHITE_BLACK).times(moduleCount + 2); // 修改这里 + output += borderTop + '\n'; + + for (let row = 0; row < moduleCount; row += 2) { + output += platte.WHITE_ALL; + + for (let col = 0; col < moduleCount; col++) { + if (moduleData[row]?.[col] === WHITE && moduleData[row + 1]?.[col] === WHITE) { + output += platte.WHITE_ALL; + } else if (moduleData[row]?.[col] === WHITE && moduleData[row + 1]?.[col] === BLACK) { + output += platte.WHITE_BLACK; + } else if (moduleData[row]?.[col] === BLACK && moduleData[row + 1]?.[col] === WHITE) { + output += platte.BLACK_WHITE; + } else { + output += platte.BLACK_ALL; + } + } + + output += platte.WHITE_ALL + '\n'; + } + + if (!oddRow) { + output += borderBottom; + } + } else { + const border = repeat(white).times(qrcode.getModuleCount() + 2); // 修改这里 + + output += border + '\n'; + if (qrcode.modules) { + qrcode.modules.forEach((row: (boolean | null)[]) => { + output += white; + output += row.map(toCell).join(''); + output += white + '\n'; + }); + } + output += border; + } + + if (cb) cb(output); + else console.log(output); + }, + + setErrorLevel (error: keyof typeof QRErrorCorrectLevel): void { + this.error = QRErrorCorrectLevel[error] || this.error; + }, +}; diff --git a/packages/snowcat-qrcode/package.json b/packages/snowcat-qrcode/package.json new file mode 100644 index 0000000..fa3d9db --- /dev/null +++ b/packages/snowcat-qrcode/package.json @@ -0,0 +1,21 @@ +{ + "name": "snowcat-qrcode", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.ts", + "exports": { + ".": { + "import": "./index.ts" + }, + "./*": { + "import": "./*" + } + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-qrcode/tsconfig.json b/packages/snowcat-qrcode/tsconfig.json new file mode 100644 index 0000000..48d3a03 --- /dev/null +++ b/packages/snowcat-qrcode/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-qrcode/vendor/QRCode/QR8bitByte.ts b/packages/snowcat-qrcode/vendor/QRCode/QR8bitByte.ts new file mode 100644 index 0000000..607b33d --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QR8bitByte.ts @@ -0,0 +1,24 @@ +import { MODE_8BIT_BYTE } from './QRMode'; + +class QR8bitByte { + mode: number; + data: string; + + constructor (data: string) { + this.mode = MODE_8BIT_BYTE; + this.data = data; + } + + getLength (): number { + return this.data.length; + } + + write (buffer: { put: (arg0: number, arg1: number) => void }): void { + for (let i = 0; i < this.data.length; i++) { + // not JIS ... + buffer.put(this.data.charCodeAt(i), 8); + } + } +} + +export default QR8bitByte; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRBitBuffer.ts b/packages/snowcat-qrcode/vendor/QRCode/QRBitBuffer.ts new file mode 100644 index 0000000..08d95e2 --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRBitBuffer.ts @@ -0,0 +1,39 @@ +class QRBitBuffer { + buffer: number[]; + length: number; + + constructor () { + this.buffer = []; + this.length = 0; + } + + get (index: number): boolean { + const bufIndex = Math.floor(index / 8); + return ((this.buffer[bufIndex]! >>> (7 - index % 8)) & 1) === 1; + } + + put (num: number, length: number): void { + for (let i = 0; i < length; i++) { + this.putBit(((num >>> (length - i - 1)) & 1) === 1); + } + } + + getLengthInBits (): number { + return this.length; + } + + putBit (bit: boolean): void { + const bufIndex = Math.floor(this.length / 8); + if (this.buffer.length <= bufIndex) { + this.buffer.push(0); + } + + if (bit) { + this.buffer[bufIndex]! |= (0x80 >>> (this.length % 8)); + } + + this.length++; + } +} + +export default QRBitBuffer; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRErrorCorrectLevel.ts b/packages/snowcat-qrcode/vendor/QRCode/QRErrorCorrectLevel.ts new file mode 100644 index 0000000..0dfd90a --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRErrorCorrectLevel.ts @@ -0,0 +1,8 @@ +const QRErrorCorrectLevel = { + L: 1, + M: 0, + Q: 3, + H: 2, +}; + +export { QRErrorCorrectLevel }; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRMaskPattern.ts b/packages/snowcat-qrcode/vendor/QRCode/QRMaskPattern.ts new file mode 100644 index 0000000..d81972b --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRMaskPattern.ts @@ -0,0 +1,12 @@ +const QRMaskPattern = { + PATTERN000: 0, + PATTERN001: 1, + PATTERN010: 2, + PATTERN011: 3, + PATTERN100: 4, + PATTERN101: 5, + PATTERN110: 6, + PATTERN111: 7, +}; + +export default QRMaskPattern; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRMath.ts b/packages/snowcat-qrcode/vendor/QRCode/QRMath.ts new file mode 100644 index 0000000..7837392 --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRMath.ts @@ -0,0 +1,37 @@ +const QRMath = { + glog (n: number): number { + if (n < 1) { + throw new Error('glog(' + n + ')'); + } + return QRMath.LOG_TABLE[n]!; + }, + + gexp (n: number): number { + while (n < 0) { + n += 255; + } + while (n >= 256) { + n -= 255; + } + return QRMath.EXP_TABLE[n]!; + }, + + EXP_TABLE: new Array(256).fill(0), + + LOG_TABLE: new Array(256).fill(0), +}; + +for (let i = 0; i < 8; i++) { + QRMath.EXP_TABLE[i] = 1 << i; +} +for (let i = 8; i < 256; i++) { + QRMath.EXP_TABLE[i] = QRMath.EXP_TABLE[i - 4]! ^ + QRMath.EXP_TABLE[i - 5]! ^ + QRMath.EXP_TABLE[i - 6]! ^ + QRMath.EXP_TABLE[i - 8]!; +} +for (let i = 0; i < 255; i++) { + QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]!] = i; +} + +export default QRMath; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRMode.ts b/packages/snowcat-qrcode/vendor/QRCode/QRMode.ts new file mode 100644 index 0000000..0a5cdc0 --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRMode.ts @@ -0,0 +1,11 @@ +export const MODE_NUMBER = 1 << 0; +export const MODE_ALPHA_NUM = 1 << 1; +export const MODE_8BIT_BYTE = 1 << 2; +export const MODE_KANJI = 1 << 3; +export const QRMode = { + MODE_NUMBER, + MODE_ALPHA_NUM, + MODE_8BIT_BYTE, + MODE_KANJI, +}; +export default QRMode; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRPolynomial.ts b/packages/snowcat-qrcode/vendor/QRCode/QRPolynomial.ts new file mode 100644 index 0000000..69ca180 --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRPolynomial.ts @@ -0,0 +1,65 @@ +import QRMath from './QRMath'; + +class QRPolynomial { + private num: number[]; + + constructor (num: number[], shift: number) { + if (num.length === undefined) { + throw new Error(num.length + '/' + shift); + } + + let offset = 0; + + while (offset < num.length && num[offset] === 0) { + offset++; + } + + this.num = new Array(num.length - offset + shift); + for (let i = 0; i < num.length - offset; i++) { + this.num[i] = num[i + offset]!; + } + } + + get (index: number): number { + return this.num[index]!; + } + + getLength (): number { + return this.num.length; + } + + multiply (e: QRPolynomial): QRPolynomial { + const num = new Array(this.getLength() + e.getLength() - 1); + + for (let i = 0; i < this.getLength(); i++) { + for (let j = 0; j < e.getLength(); j++) { + num[i + j] ^= QRMath.gexp(QRMath.glog(this.get(i)) + QRMath.glog(e.get(j))); + } + } + + return new QRPolynomial(num, 0); + } + + mod (e: QRPolynomial): QRPolynomial { + if (this.getLength() - e.getLength() < 0) { + return this; + } + + const ratio = QRMath.glog(this.get(0)) - QRMath.glog(e.get(0)); + + const num = new Array(this.getLength()); + + for (let i = 0; i < this.getLength(); i++) { + num[i] = this.get(i); + } + + for (let x = 0; x < e.getLength(); x++) { + num[x] ^= QRMath.gexp(QRMath.glog(e.get(x)) + ratio); + } + + // recursive call + return new QRPolynomial(num, 0).mod(e); + } +} + +export default QRPolynomial; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRRSBlock.ts b/packages/snowcat-qrcode/vendor/QRCode/QRRSBlock.ts new file mode 100644 index 0000000..5c93c15 --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRRSBlock.ts @@ -0,0 +1,302 @@ +import { QRErrorCorrectLevel } from './QRErrorCorrectLevel'; + +class QRRSBlock { + totalCount: number; + dataCount: number; + + constructor (totalCount: number, dataCount: number) { + this.totalCount = totalCount; + this.dataCount = dataCount; + } + + static RS_BLOCK_TABLE: number[][] = [ + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16], + ]; +} + +function getRSBlocks (typeNumber: number, errorCorrectLevel: number): QRRSBlock[] { + const rsBlock = getRsBlockTable(typeNumber, errorCorrectLevel); + + if (rsBlock === undefined) { + throw new Error(`bad rs block @ typeNumber: ${typeNumber} / errorCorrectLevel: ${errorCorrectLevel}`); + } + + const length = rsBlock.length / 3; + const list: QRRSBlock[] = []; + + for (let i = 0; i < length; i++) { + const count = rsBlock[i * 3 + 0]; + const totalCount = rsBlock[i * 3 + 1]!; + const dataCount = rsBlock[i * 3 + 2]!; + + if (count === undefined) { + throw new Error(`count is undefined for typeNumber: ${typeNumber} / errorCorrectLevel: ${errorCorrectLevel}`); + } + + for (let j = 0; j < count; j++) { + list.push(new QRRSBlock(totalCount, dataCount)); + } + } + + return list; +} + +function getRsBlockTable (typeNumber: number, errorCorrectLevel: number): number[] | undefined { + switch (errorCorrectLevel) { + case QRErrorCorrectLevel.L: + return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectLevel.M: + return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectLevel.Q: + return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectLevel.H: + return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default: + return undefined; + } +} + +export { QRRSBlock, getRSBlocks, getRsBlockTable }; diff --git a/packages/snowcat-qrcode/vendor/QRCode/QRUtil.ts b/packages/snowcat-qrcode/vendor/QRCode/QRUtil.ts new file mode 100644 index 0000000..ece399a --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/QRUtil.ts @@ -0,0 +1,210 @@ +import QRPolynomial from './QRPolynomial'; +import QRMath from './QRMath'; +import QRMaskPattern from './QRMaskPattern'; +import QRCode from '.'; +import QRMode from './QRMode'; + +export const PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170], +]; +export const G15: number = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); +export const G18: number = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); +export const G15_MASK: number = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + +export function getBCHTypeInfo (data: number): number { + let d: number = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15))); + } + return ((data << 10) | d) ^ G15_MASK; +} + +export function getBCHTypeNumber (data: number): number { + let d: number = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18))); + } + return (data << 12) | d; +} + +export function getBCHDigit (data: number): number { + let digit: number = 0; + while (data !== 0) { + digit++; + data >>>= 1; + } + return digit; +} + +export function getPatternPosition (typeNumber: number): number[] { + return PATTERN_POSITION_TABLE[typeNumber - 1]!; +} + +export function getMask (maskPattern: number, i: number, j: number): boolean { + switch (maskPattern) { + case QRMaskPattern.PATTERN000: return (i + j) % 2 === 0; + case QRMaskPattern.PATTERN001: return i % 2 === 0; + case QRMaskPattern.PATTERN010: return j % 3 === 0; + case QRMaskPattern.PATTERN011: return (i + j) % 3 === 0; + case QRMaskPattern.PATTERN100: return (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0; + case QRMaskPattern.PATTERN101: return (i * j) % 2 + (i * j) % 3 === 0; + case QRMaskPattern.PATTERN110: return ((i * j) % 2 + (i * j) % 3) % 2 === 0; + case QRMaskPattern.PATTERN111: return ((i * j) % 3 + (i + j) % 2) % 2 === 0; + default: throw new Error('bad maskPattern:' + maskPattern); + } +} + +export function getErrorCorrectPolynomial (errorCorrectLength: number): QRPolynomial { + let a: QRPolynomial = new QRPolynomial([1], 0); + for (let i = 0; i < errorCorrectLength; i++) { + a = a.multiply(new QRPolynomial([1, QRMath.gexp(i)], 0)); + } + return a; +} + +export function getLengthInBits (mode: number, type: number): number { + if (type >= 1 && type < 10) { + // 1 - 9 + switch (mode) { + case QRMode.MODE_NUMBER: return 10; + case QRMode.MODE_ALPHA_NUM: return 9; + case QRMode.MODE_8BIT_BYTE: return 8; + case QRMode.MODE_KANJI: return 8; + default: throw new Error('mode:' + mode); + } + } else if (type < 27) { + // 10 - 26 + switch (mode) { + case QRMode.MODE_NUMBER: return 12; + case QRMode.MODE_ALPHA_NUM: return 11; + case QRMode.MODE_8BIT_BYTE: return 16; + case QRMode.MODE_KANJI: return 10; + default: throw new Error('mode:' + mode); + } + } else if (type < 41) { + // 27 - 40 + switch (mode) { + case QRMode.MODE_NUMBER: return 14; + case QRMode.MODE_ALPHA_NUM: return 13; + case QRMode.MODE_8BIT_BYTE: return 16; + case QRMode.MODE_KANJI: return 12; + default: throw new Error('mode:' + mode); + } + } else { + throw new Error('type:' + type); + } +} + +export function getLostPoint (qrCode: QRCode): number { + const moduleCount: number = qrCode.getModuleCount(); + let lostPoint: number = 0; + + // LEVEL1 + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + let sameCount: number = 0; + const dark: boolean = qrCode.isDark(row, col); + for (let r = -1; r <= 1; r++) { + if (row + r < 0 || moduleCount <= row + r) continue; + for (let c = -1; c <= 1; c++) { + if (col + c < 0 || moduleCount <= col + c) continue; + if (r === 0 && c === 0) continue; + if (dark === qrCode.isDark(row + r, col + c)) sameCount++; + } + } + if (sameCount > 5) lostPoint += (3 + sameCount - 5); + } + } + + // LEVEL2 + for (let row = 0; row < moduleCount - 1; row++) { + for (let col = 0; col < moduleCount - 1; col++) { + let count: number = 0; + if (qrCode.isDark(row, col)) count++; + if (qrCode.isDark(row + 1, col)) count++; + if (qrCode.isDark(row, col + 1)) count++; + if (qrCode.isDark(row + 1, col + 1)) count++; + if (count === 0 || count === 4) lostPoint += 3; + } + } + + // LEVEL3 + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount - 6; col++) { + if (qrCode.isDark(row, col) && + !qrCode.isDark(row, col + 1) && + qrCode.isDark(row, col + 2) && + qrCode.isDark(row, col + 3) && + qrCode.isDark(row, col + 4) && + !qrCode.isDark(row, col + 5) && + qrCode.isDark(row, col + 6)) { + lostPoint += 40; + } + } + } + + for (let col = 0; col < moduleCount; col++) { + for (let row = 0; row < moduleCount - 6; row++) { + if (qrCode.isDark(row, col) && + !qrCode.isDark(row + 1, col) && + qrCode.isDark(row + 2, col) && + qrCode.isDark(row + 3, col) && + qrCode.isDark(row + 4, col) && + !qrCode.isDark(row + 5, col) && + qrCode.isDark(row + 6, col)) { + lostPoint += 40; + } + } + } + + // LEVEL4 + let darkCount: number = 0; + for (let col = 0; col < moduleCount; col++) { + for (let row = 0; row < moduleCount; row++) { + if (qrCode.isDark(row, col)) darkCount++; + } + } + const ratio: number = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; +} diff --git a/packages/snowcat-qrcode/vendor/QRCode/index.ts b/packages/snowcat-qrcode/vendor/QRCode/index.ts new file mode 100644 index 0000000..669b01f --- /dev/null +++ b/packages/snowcat-qrcode/vendor/QRCode/index.ts @@ -0,0 +1,412 @@ +// Copyright (c) 2009 Kazuhiko Arase +import QR8bitByte from './QR8bitByte'; +import { getLengthInBits, getLostPoint, getPatternPosition, getBCHTypeNumber, getBCHTypeInfo, getMask, getErrorCorrectPolynomial } from './QRUtil'; +import QRPolynomial from './QRPolynomial'; +import { getRSBlocks, QRRSBlock as RSBlock } from './QRRSBlock'; +import QRBitBuffer from './QRBitBuffer'; + +class QRCode { + typeNumber: number; + errorCorrectLevel: number; + modules: (boolean | null)[][] | null; + moduleCount: number; + dataCache: number[] | null; + dataList: QR8bitByte[]; + + static PAD0 = 0xEC; + static PAD1 = 0x11; + + constructor (typeNumber: number, errorCorrectLevel: number) { + this.typeNumber = typeNumber; + this.errorCorrectLevel = errorCorrectLevel; + this.modules = null; + this.moduleCount = 0; + this.dataCache = null; + this.dataList = []; + } + + addData (data: string): void { + const newData = new QR8bitByte(data); + this.dataList.push(newData); + this.dataCache = null; + } + + isDark (row: number, col: number): boolean { + if (row < 0 || this.moduleCount <= row || col < 0 || this.moduleCount <= col) { + throw new Error(`${row},${col}`); + } + if (this.modules === null || this.modules[row] === null || this.modules[row]![col] === null) { + throw new Error(`Module at (${row},${col}) is null`); + } + return this.modules[row]![col]!; + } + + getModuleCount (): number { + return this.moduleCount; + } + + make (): void { + if (this.typeNumber < 1) { + let typeNumber = 1; + for (typeNumber = 1; typeNumber < 40; typeNumber++) { + const rsBlocks = getRSBlocks(typeNumber, this.errorCorrectLevel); + + const buffer = new QRBitBuffer(); + let totalDataCount = 0; + for (let i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i]!.dataCount; + } + + for (let x = 0; x < this.dataList.length; x++) { + const data = this.dataList[x]; + buffer.put(data!.mode, 4); + buffer.put(data!.getLength(), getLengthInBits(data!.mode, typeNumber)); + data!.write(buffer); + } + if (buffer.getLengthInBits() <= totalDataCount * 8) { break; } + } + this.typeNumber = typeNumber; + } + this.makeImpl(false, this.getBestMaskPattern()); + } + + makeImpl (test: boolean, maskPattern: number): void { + this.moduleCount = this.typeNumber * 4 + 17; + this.modules = new Array(this.moduleCount); + + for (let row = 0; row < this.moduleCount; row++) { + this.modules[row] = new Array(this.moduleCount); + for (let col = 0; col < this.moduleCount; col++) { + this.modules[row]![col] = null; + } + } + + this.setupPositionProbePattern(0, 0); + this.setupPositionProbePattern(this.moduleCount - 7, 0); + this.setupPositionProbePattern(0, this.moduleCount - 7); + this.setupPositionAdjustPattern(); + this.setupTimingPattern(); + this.setupTypeInfo(test, maskPattern); + + if (this.typeNumber >= 7) { + this.setupTypeNumber(test); + } + + if (this.dataCache === null) { + this.dataCache = QRCode.createData(this.typeNumber, this.errorCorrectLevel, this.dataList); + } + + this.mapData(this.dataCache, maskPattern); + } + + setupPositionProbePattern (row: number, col: number): void { + for (let r = -1; r <= 7; r++) { + if (row + r <= -1 || this.moduleCount <= row + r) continue; + + for (let c = -1; c <= 7; c++) { + if (col + c <= -1 || this.moduleCount <= col + c) continue; + + if ((r >= 0 && r <= 6 && (c === 0 || c === 6)) || + (c >= 0 && c <= 6 && (r === 0 || r === 6)) || + (r >= 2 && r <= 4 && c >= 2 && c <= 4)) { + this.modules![row + r]![col + c] = true; + } else { + this.modules![row + r]![col + c] = false; + } + } + } + } + + getBestMaskPattern (): number { + let minLostPoint = 0; + let pattern = 0; + + for (let i = 0; i < 8; i++) { + this.makeImpl(true, i); + const lostPoint = getLostPoint(this); + + if (i === 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + } + + createMovieClip (target_mc: { createEmptyMovieClip: (name: string, depth: number) => { beginFill: (color: number, alpha: number) => void; moveTo: (x: number, y: number) => void; lineTo: (x: number, y: number) => void; endFill: () => void; }; }, instance_name: string, depth: number): { beginFill: (color: number, alpha: number) => void; moveTo: (x: number, y: number) => void; lineTo: (x: number, y: number) => void; endFill: () => void; } { + const qr_mc = target_mc.createEmptyMovieClip(instance_name, depth); + const cs = 1; + + this.make(); + + for (let row = 0; row < this.modules!.length; row++) { + const y = row * cs; + + for (let col = 0; col < this.modules![row]!.length; col++) { + const x = col * cs; + const dark = this.modules![row]![col]; + + if (dark) { + qr_mc.beginFill(0, 100); + qr_mc.moveTo(x, y); + qr_mc.lineTo(x + cs, y); + qr_mc.lineTo(x + cs, y + cs); + qr_mc.lineTo(x, y + cs); + qr_mc.endFill(); + } + } + } + + return qr_mc; + } + + setupTimingPattern (): void { + for (let r = 8; r < this.moduleCount - 8; r++) { + if (this.modules![r]![6] !== null) { + continue; + } + this.modules![r]![6] = (r % 2 === 0); + } + + for (let c = 8; c < this.moduleCount - 8; c++) { + if (this.modules![6]![c] !== null) { + continue; + } + this.modules![6]![c] = (c % 2 === 0); + } + } + + setupPositionAdjustPattern (): void { + const pos = getPatternPosition(this.typeNumber); + + for (let i = 0; i < pos.length; i++) { + for (let j = 0; j < pos.length; j++) { + const row = pos[i]; + const col = pos[j]; + + if (this.modules![row!]![col!] !== null) { + continue; + } + + for (let r = -2; r <= 2; r++) { + for (let c = -2; c <= 2; c++) { + if (Math.abs(r) === 2 || + Math.abs(c) === 2 || + (r === 0 && c === 0)) { + this.modules![row! + r]![col! + c] = true; + } else { + this.modules![row! + r]![col! + c] = false; + } + } + } + } + } + } + + setupTypeNumber (test: boolean): void { + const bits = getBCHTypeNumber(this.typeNumber); + let mod: boolean; + + for (let i = 0; i < 18; i++) { + mod = (!test && ((bits >> i) & 1) === 1); + this.modules![Math.floor(i / 3)]![i % 3 + this.moduleCount - 8 - 3] = mod; + } + + for (let x = 0; x < 18; x++) { + mod = (!test && ((bits >> x) & 1) === 1); + this.modules![x % 3 + this.moduleCount - 8 - 3]![Math.floor(x / 3)] = mod; + } + } + + setupTypeInfo (test: boolean, maskPattern: number): void { + const data = (this.errorCorrectLevel << 3) | maskPattern; + const bits = getBCHTypeInfo(data); + let mod: boolean; + + // vertical + for (let v = 0; v < 15; v++) { + mod = (!test && ((bits >> v) & 1) === 1); + + if (v < 6) { + this.modules![v]![8] = mod; + } else if (v < 8) { + this.modules![v + 1]![8] = mod; + } else { + this.modules![this.moduleCount - 15 + v]![8] = mod; + } + } + + // horizontal + for (let h = 0; h < 15; h++) { + mod = (!test && ((bits >> h) & 1) === 1); + + if (h < 8) { + this.modules![8]![this.moduleCount - h - 1] = mod; + } else if (h < 9) { + this.modules![8]![15 - h - 1 + 1] = mod; + } else { + this.modules![8]![15 - h - 1] = mod; + } + } + + // fixed module + this.modules![this.moduleCount - 8]![8] = (!test); + } + + mapData (data: number[], maskPattern: number): void { + let inc = -1; + let row = this.moduleCount - 1; + let bitIndex = 7; + let byteIndex = 0; + + for (let col = this.moduleCount - 1; col > 0; col -= 2) { + if (col === 6) col--; + + while (true) { + for (let c = 0; c < 2; c++) { + if (this.modules![row]![col - c] === null) { + let dark = false; + + if (byteIndex < data.length) { + dark = (((data[byteIndex] ?? 0) >>> bitIndex) & 1) === 1; + } + + const mask = getMask(maskPattern, row, col - c); + + if (mask) { + dark = !dark; + } + + this.modules![row]![col - c] = dark; + bitIndex--; + + if (bitIndex === -1) { + byteIndex++; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || this.moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + } + + static createData (typeNumber: number, errorCorrectLevel: number, dataList: QR8bitByte[]): number[] { + const rsBlocks = getRSBlocks(typeNumber, errorCorrectLevel); + + const buffer = new QRBitBuffer(); + + for (let i = 0; i < dataList.length; i++) { + const data = dataList[i]; + buffer.put(data!.mode, 4); + buffer.put(data!.getLength(), getLengthInBits(data!.mode, typeNumber)); + data!.write(buffer); + } + + // calc num max data. + let totalDataCount = 0; + for (let x = 0; x < rsBlocks.length; x++) { + totalDataCount += rsBlocks[x]!.dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw new Error(`code length overflow. (${buffer.getLengthInBits()} > ${totalDataCount * 8})`); + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 !== 0) { + buffer.putBit(false); + } + + // padding + while (true) { + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(QRCode.PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(QRCode.PAD1, 8); + } + + return QRCode.createBytes(buffer, rsBlocks); + } + + static createBytes (buffer: QRBitBuffer, rsBlocks: RSBlock[]): number[] { + let offset = 0; + + let maxDcCount = 0; + let maxEcCount = 0; + + const dcdata: number[][] = new Array(rsBlocks.length); + const ecdata: number[][] = new Array(rsBlocks.length); + + for (let r = 0; r < rsBlocks.length; r++) { + const dcCount = rsBlocks[r]!.dataCount; + const ecCount = rsBlocks[r]!.totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (let i = 0; i < dcdata[r]!.length; i++) { + dcdata[r]![i] = 0xff & buffer.buffer[i + offset]!; + } + offset += dcCount; + + const rsPoly = getErrorCorrectPolynomial(ecCount); + const rawPoly = new QRPolynomial(dcdata[r]!, rsPoly.getLength() - 1); + + const modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (let x = 0; x < ecdata[r]!.length; x++) { + const modIndex = x + modPoly.getLength() - ecdata[r]!.length; + ecdata[r]![x] = (modIndex >= 0) ? modPoly.get(modIndex) : 0; + } + } + + let totalCodeCount = 0; + for (let y = 0; y < rsBlocks.length; y++) { + totalCodeCount += rsBlocks[y]!.totalCount; + } + + const data: number[] = new Array(totalCodeCount); + let index = 0; + + for (let z = 0; z < maxDcCount; z++) { + for (let s = 0; s < rsBlocks.length; s++) { + if (z < dcdata[s]!.length) { + data[index++] = dcdata[s]![z]!; + } + } + } + + for (let xx = 0; xx < maxEcCount; xx++) { + for (let t = 0; t < rsBlocks.length; t++) { + if (xx < ecdata[t]!.length) { + data[index++] = ecdata[t]![xx]!; + } + } + } + + return data; + } +} + +export default QRCode; diff --git a/packages/snowcat-rpc/package.json b/packages/snowcat-rpc/package.json new file mode 100644 index 0000000..74c6c8e --- /dev/null +++ b/packages/snowcat-rpc/package.json @@ -0,0 +1,25 @@ +{ + "name": "snowcat-rpc", + "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": {}, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-rpc/src/client.ts b/packages/snowcat-rpc/src/client.ts new file mode 100644 index 0000000..9cb22e5 --- /dev/null +++ b/packages/snowcat-rpc/src/client.ts @@ -0,0 +1,365 @@ +import { + type DeepProxyOptions, + type ProxyMeta, + RpcOperationType, + PROXY_META, + type RpcRequest, +} from './types.js'; +import { + serialize, + deserialize, + SimpleCallbackRegistry, + extractCallbackIds, +} from './serializer.js'; + +/** + * 生成唯一请求 ID + */ +function generateRequestId (): string { + return `req_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +/** + * 创建深层 RPC 代理 + * + * 将所有属性访问、方法调用等操作转换为 RPC 请求 + */ +export function createDeepProxy (options: DeepProxyOptions): T { + const { + transport, + rootPath = [], + refId: rootRefId, + // callbackTimeout 可供未来扩展使用 + } = options; + + const callbackRegistry = new SimpleCallbackRegistry(); + + // 注册回调处理器 + if (transport.onCallback) { + transport.onCallback(async (callbackId, serializedArgs) => { + const callback = callbackRegistry.get(callbackId); + if (!callback) { + throw new Error(`Callback not found: ${callbackId}`); + } + const args = serializedArgs.map(arg => deserialize(arg, { + callbackResolver: (id) => { + const cb = callbackRegistry.get(id); + if (!cb) throw new Error(`Nested callback not found: ${id}`); + return cb; + }, + proxyCreator: (path, proxyRefId, cachedProps) => createProxyAtPath(path, proxyRefId, cachedProps), + })); + const result = await callback(...args); + return serialize(result, { callbackRegistry }); + }); + } + + /** + * 在指定路径创建代理 + * @param path 路径 + * @param refId 远程对象引用 ID + * @param cachedProps 缓存的属性值(避免属性访问需要 RPC) + */ + function createProxyAtPath (path: PropertyKey[], refId?: string, cachedProps?: Record): unknown { + const proxyMeta: ProxyMeta = { + path: [...path], + isProxy: true, + refId, + }; + + // 创建一个函数目标,以支持 apply 和 construct + const target = function () { } as unknown as Record; + + return new Proxy(target, { + get (_target, prop) { + // 返回代理元数据 + if (prop === PROXY_META) { + return proxyMeta; + } + + // then 方法特殊处理,使代理可以被 await + if (prop === 'then') { + return undefined; + } + + // 检查缓存属性(仅顶层代理,即 path 为空时) + if (path.length === 0 && cachedProps && typeof prop === 'string' && prop in cachedProps) { + return cachedProps[prop]; + } + + // 返回新的子路径代理(继承 refId,不继承 cachedProps) + return createProxyAtPath([...path, prop], refId); + }, + + set (_target, prop, value) { + const request: RpcRequest = { + id: generateRequestId(), + type: RpcOperationType.SET, + path: [...path, prop], + args: [serialize(value, { callbackRegistry })], + refId, + }; + + // 同步返回,但实际是异步操作 + transport.send(request).catch(() => { /* ignore */ }); + return true; + }, + + apply (_target, _thisArg, args) { + const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry })); + const callbackIds = extractCallbackIds(serializedArgs); + + const request: RpcRequest = { + id: generateRequestId(), + type: RpcOperationType.APPLY, + path, + args: serializedArgs, + callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined, + refId, + }; + + return createAsyncResultProxy(request); + }, + + construct (_target, args): object { + const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry })); + const callbackIds = extractCallbackIds(serializedArgs); + + const request: RpcRequest = { + id: generateRequestId(), + type: RpcOperationType.CONSTRUCT, + path, + args: serializedArgs, + callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined, + refId, + }; + + return createAsyncResultProxy(request) as object; + }, + + has (_target, prop) { + // 检查是否为代理元数据符号 + if (prop === PROXY_META) { + return true; + } + // 同步返回 true,实际检查通过异步完成 + return true; + }, + + ownKeys () { + // 返回空数组,实际键需要通过异步获取 + return []; + }, + + getOwnPropertyDescriptor (_target, _prop) { + return { + configurable: true, + enumerable: true, + writable: true, + }; + }, + + deleteProperty (_target, prop) { + const request: RpcRequest = { + id: generateRequestId(), + type: RpcOperationType.DELETE, + path: [...path, prop], + refId, + }; + + transport.send(request).catch(() => { /* ignore */ }); + return true; + }, + + getPrototypeOf () { + return Object.prototype; + }, + }); + } + + /** + * 创建异步结果代理 + * 返回一个 Promise-like 对象,可以被 await, + * 同时也可以继续链式访问属性 + */ + function createAsyncResultProxy (request: RpcRequest): unknown { + let resultPromise: Promise | null = null; + + const getResult = async (): Promise => { + if (!resultPromise) { + resultPromise = (async () => { + const response = await transport.send(request); + + if (!response.success) { + const error = new Error(response.error ?? 'RPC call failed'); + if (response.stack) { + error.stack = response.stack; + } + throw error; + } + + if (response.result === undefined) { + return undefined; + } + + // 如果结果是可代理对象,返回代理 + if (response.isProxyable && response.result) { + const deserialized = deserialize(response.result, { + callbackResolver: (id) => { + const cb = callbackRegistry.get(id); + if (!cb) throw new Error(`Callback not found: ${id}`); + return cb; + }, + proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps), + }); + return deserialized; + } + + return deserialize(response.result, { + callbackResolver: (id) => { + const cb = callbackRegistry.get(id); + if (!cb) throw new Error(`Callback not found: ${id}`); + return cb; + }, + proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps), + }); + })(); + } + return resultPromise; + }; + + // 创建一个可链式访问的代理 + const target = function () { } as unknown as Record; + + return new Proxy(target, { + get (_target, prop) { + if (prop === 'then') { + return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => { + getResult().then(resolve, reject); + }; + } + + if (prop === 'catch') { + return (reject: (error: unknown) => void) => { + getResult().catch(reject); + }; + } + + if (prop === 'finally') { + return (callback: () => void) => { + getResult().finally(callback); + }; + } + + if (prop === PROXY_META) { + return undefined; + } + + // 链式访问:等待结果后访问其属性 + return createChainedProxy(getResult(), [prop]); + }, + + apply (_target, _thisArg, args) { + // 等待结果后调用 + return getResult().then(result => { + if (typeof result === 'function') { + return result(...args); + } + throw new Error('Result is not callable'); + }); + }, + }); + } + + /** + * 创建链式代理 + * 用于处理 await result.prop.method() 这样的链式调用 + */ + function createChainedProxy (parentPromise: Promise, path: PropertyKey[]): unknown { + const target = function () { } as unknown as Record; + + return new Proxy(target, { + get (_target, prop) { + if (prop === 'then') { + return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => { + parentPromise + .then(parent => { + let value: unknown = parent; + for (const key of path) { + if (value === null || value === undefined) { + return undefined; + } + value = (value as Record)[key]; + } + resolve(value); + }) + .catch(reject); + }; + } + + if (prop === 'catch') { + return (reject: (error: unknown) => void) => { + parentPromise.catch(reject); + }; + } + + if (prop === 'finally') { + return (callback: () => void) => { + parentPromise.finally(callback); + }; + } + + return createChainedProxy(parentPromise, [...path, prop]); + }, + + apply (_target, _thisArg, args) { + return parentPromise.then(parent => { + let value: unknown = parent; + const pathToMethod = path.slice(0, -1); + const methodName = path[path.length - 1]; + + for (const key of pathToMethod) { + if (value === null || value === undefined) { + throw new Error(`Cannot access property '${String(key)}' of ${value}`); + } + value = (value as Record)[key]; + } + + const method = (value as Record)[methodName!]; + if (typeof method !== 'function') { + throw new Error(`${String(methodName)} is not a function`); + } + + return method.call(value, ...args); + }); + }, + }); + } + + return createProxyAtPath(rootPath, rootRefId) as T; +} + +/** + * 获取代理的元数据 + */ +export function getProxyMeta (proxy: unknown): ProxyMeta | undefined { + if (proxy != null && (typeof proxy === 'object' || typeof proxy === 'function')) { + try { + // 直接访问 Symbol 属性,代理的 get 陷阱会返回元数据 + const meta = (proxy as Record)[PROXY_META]; + if (meta && meta.isProxy === true) { + return meta; + } + } catch { + // 忽略访问错误 + } + } + return undefined; +} + +/** + * 检查是否为 RPC 代理 + */ +export function isRpcProxy (value: unknown): boolean { + return getProxyMeta(value) !== undefined; +} diff --git a/packages/snowcat-rpc/src/easy.ts b/packages/snowcat-rpc/src/easy.ts new file mode 100644 index 0000000..06b75a7 --- /dev/null +++ b/packages/snowcat-rpc/src/easy.ts @@ -0,0 +1,130 @@ +/** + * 简化版 RPC API + * + * 提供一键创建完全隔离的 client/server 对 + * 在 client 端操作就像直接操作 server 端的变量一样 + */ + +import { LocalTransport } from './transport.js'; +import { createDeepProxy, getProxyMeta, isRpcProxy } from './client.js'; +import { RpcServer } from './server.js'; +import type { ProxyMeta } from './types.js'; + +/** + * RPC 配对结果 + */ +export interface RpcPair { + /** 客户端代理 - 在这里操作就像直接操作服务端的变量 */ + client: T; + /** 服务端原始对象 */ + server: T; + /** 关闭连接 */ + close (): void; +} + +/** + * 创建 RPC 配对 + * + * 快速创建完全隔离的 client/server 对,client 端的所有操作都会通过 RPC 传递到 server 端执行 + * + * @example + * ```ts + * const { client, server } = createRpcPair({ + * name: 'test', + * greet: (msg: string) => `Hello, ${msg}!`, + * register: (handlers: { onSuccess: Function, onError: Function }) => { + * handlers.onSuccess('done'); + * } + * }); + * + * // 在 client 端操作,就像直接操作 server 端的变量 + * await client.greet('world'); // 返回 'Hello, world!' + * + * // 支持包含多个回调的对象 + * await client.register({ + * onSuccess: (result) => console.log(result), + * onError: (err) => console.error(err) + * }); + * ``` + */ +export function createRpcPair (target: T): RpcPair { + const transport = new LocalTransport(target); + const client = createDeepProxy({ transport }); + + return { + client, + server: target, + close: () => transport.close(), + }; +} + +/** + * 模拟远程变量 + * + * 将一个本地变量包装成"看起来像远程变量"的代理,所有操作都通过 RPC 隔离 + * + * @example + * ```ts + * const remoteApi = mockRemote({ + * counter: 0, + * increment() { return ++this.counter; }, + * async fetchData(id: number) { return { id, data: 'test' }; } + * }); + * + * // 所有操作都是异步的,通过 RPC 隔离 + * await remoteApi.increment(); // 1 + * await remoteApi.fetchData(123); // { id: 123, data: 'test' } + * ``` + */ +export function mockRemote (target: T): T { + return createRpcPair(target).client; +} + +/** + * 创建 RPC 服务端 + * + * @example + * ```ts + * const server = createServer({ + * users: new Map(), + * addUser(id: string, name: string) { + * this.users.set(id, { name }); + * return true; + * } + * }); + * + * // 获取传输层供客户端连接 + * const transport = server.getTransport(); + * ``` + */ +export function createServer (target: T): { + target: T; + handler: RpcServer; + getTransport (): LocalTransport; +} { + const handler = new RpcServer({ target }); + return { + target, + handler, + getTransport: () => new LocalTransport(target), + }; +} + +/** + * 创建指向服务端的客户端 + * + * @example + * ```ts + * const server = createServer(myApi); + * const client = createClient(server.getTransport()); + * + * await client.someMethod(); + * ``` + */ +export function createClient (transport: LocalTransport): T { + return createDeepProxy({ transport }); +} + +// 重新导出常用工具 +export { getProxyMeta, isRpcProxy }; +export type { ProxyMeta }; diff --git a/packages/snowcat-rpc/src/index.ts b/packages/snowcat-rpc/src/index.ts new file mode 100644 index 0000000..4b2cd18 --- /dev/null +++ b/packages/snowcat-rpc/src/index.ts @@ -0,0 +1,60 @@ +/** + * snowcat-rpc + * + * 深层 RPC 代理库 - 将对象的所有层级操作转换为 RPC 调用 + */ + +// 简化 API(推荐使用) +export { + createRpcPair, + mockRemote, + createServer, + createClient, +} from './easy.js'; + +// 类型导出 +export { + RpcOperationType, + SerializedValueType, + PROXY_META, + type RpcRequest, + type RpcResponse, + type SerializedValue, + type RpcTransport, + type RpcServerHandler, + type RpcServerOptions, + type DeepProxyOptions, + type ProxyMeta, +} from './types.js'; + +// 序列化工具 +export { + serialize, + deserialize, + extractCallbackIds, + SimpleCallbackRegistry, + type CallbackRegistry, + type SerializeContext, + type DeserializeContext, +} from './serializer.js'; + +// 客户端代理 +export { + createDeepProxy, + getProxyMeta, + isRpcProxy, +} from './client.js'; + +// 服务端 +export { + RpcServer, + createRpcServer, +} from './server.js'; + +// 传输层 +export { + LocalTransport, + MessageTransport, + createMessageServerHandler, + type MessageTransportOptions, +} from './transport.js'; diff --git a/packages/snowcat-rpc/src/serializer.ts b/packages/snowcat-rpc/src/serializer.ts new file mode 100644 index 0000000..4fda321 --- /dev/null +++ b/packages/snowcat-rpc/src/serializer.ts @@ -0,0 +1,410 @@ +import { + SerializedValue, + SerializedValueType, + PROXY_META, + type ProxyMeta, +} from './types.js'; + +/** + * 回调注册器接口 + */ +export interface CallbackRegistry { + register (fn: Function): string; + get (id: string): Function | undefined; + remove (id: string): void; +} + +/** + * 简单的回调注册器实现 + */ +export class SimpleCallbackRegistry implements CallbackRegistry { + private callbacks = new Map(); + private counter = 0; + + register (fn: Function): string { + const id = `cb_${++this.counter}_${Date.now()}`; + this.callbacks.set(id, fn); + return id; + } + + get (id: string): Function | undefined { + return this.callbacks.get(id); + } + + remove (id: string): void { + this.callbacks.delete(id); + } + + clear (): void { + this.callbacks.clear(); + } +} + +/** + * 序列化上下文 + */ +export interface SerializeContext { + /** 回调注册器 */ + callbackRegistry?: CallbackRegistry; + /** 已序列化对象映射(用于循环引用检测) */ + seen?: WeakMap; + /** 深度限制 */ + maxDepth?: number; + /** 当前深度 */ + currentDepth?: number; +} + +/** + * 反序列化上下文 + */ +export interface DeserializeContext { + /** 回调解析器 */ + callbackResolver?: (id: string) => Function; + /** 代理创建器 */ + proxyCreator?: (path: PropertyKey[], refId?: string, cachedProps?: Record) => unknown; + /** 对象引用解析器 */ + refResolver?: (refId: string) => unknown; +} + +/** + * 将值序列化为可传输格式 + */ +export function serialize (value: unknown, context: SerializeContext = {}): SerializedValue { + const { + callbackRegistry, + seen = new WeakMap(), + maxDepth = 50, + currentDepth = 0, + } = context; + + // 深度检查 + if (currentDepth > maxDepth) { + return { type: SerializedValueType.STRING, value: '[Max depth exceeded]' }; + } + + // 基本类型处理 + if (value === undefined) { + return { type: SerializedValueType.UNDEFINED }; + } + + if (value === null) { + return { type: SerializedValueType.NULL }; + } + + const valueType = typeof value; + + if (valueType === 'boolean') { + return { type: SerializedValueType.BOOLEAN, value }; + } + + if (valueType === 'number') { + const numValue = value as number; + if (Number.isNaN(numValue)) { + return { type: SerializedValueType.NUMBER, value: 'NaN' }; + } + if (!Number.isFinite(numValue)) { + return { type: SerializedValueType.NUMBER, value: numValue > 0 ? 'Infinity' : '-Infinity' }; + } + return { type: SerializedValueType.NUMBER, value }; + } + + if (valueType === 'bigint') { + return { type: SerializedValueType.BIGINT, value: (value as bigint).toString() }; + } + + if (valueType === 'string') { + return { type: SerializedValueType.STRING, value }; + } + + if (valueType === 'symbol') { + return { + type: SerializedValueType.SYMBOL, + value: (value as symbol).description ?? '', + }; + } + + if (valueType === 'function') { + const fn = value as Function; + if (callbackRegistry) { + const callbackId = callbackRegistry.register(fn); + return { + type: SerializedValueType.FUNCTION, + callbackId, + className: fn.name || 'anonymous', + }; + } + return { + type: SerializedValueType.FUNCTION, + className: fn.name || 'anonymous', + }; + } + + // 对象类型处理 + const obj = value as object; + + // 检查是否为代理对象 + if (PROXY_META in obj) { + const meta = (obj as Record)[PROXY_META]; + if (meta) { + return { + type: SerializedValueType.PROXY_REF, + proxyPath: meta.path, + }; + } + } + + // 循环引用检测 + if (seen.has(obj)) { + return seen.get(obj)!; + } + + // Date + if (obj instanceof Date) { + return { type: SerializedValueType.DATE, value: obj.toISOString() }; + } + + // RegExp + if (obj instanceof RegExp) { + return { + type: SerializedValueType.REGEXP, + value: { source: obj.source, flags: obj.flags }, + }; + } + + // Error + if (obj instanceof Error) { + return { + type: SerializedValueType.ERROR, + value: obj.message, + className: obj.constructor.name, + properties: { + stack: serialize(obj.stack, { ...context, seen, currentDepth: currentDepth + 1 }), + }, + }; + } + + // Buffer / Uint8Array + if (obj instanceof Uint8Array) { + return { + type: SerializedValueType.BUFFER, + value: Array.from(obj as Uint8Array), + }; + } + + // Node.js Buffer + if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) { + const BufferClass = (globalThis as unknown as { Buffer: { isBuffer (obj: unknown): boolean; }; }).Buffer; + if (BufferClass.isBuffer(obj)) { + return { + type: SerializedValueType.BUFFER, + value: Array.from(obj as unknown as Uint8Array), + }; + } + } + + // Map + if (obj instanceof Map) { + const entries: SerializedValue[] = []; + const nextContext = { ...context, seen, currentDepth: currentDepth + 1 }; + for (const [k, v] of obj) { + entries.push(serialize([k, v], nextContext)); + } + return { + type: SerializedValueType.MAP, + elements: entries, + }; + } + + // Set + if (obj instanceof Set) { + const elements: SerializedValue[] = []; + const nextContext = { ...context, seen, currentDepth: currentDepth + 1 }; + for (const v of obj) { + elements.push(serialize(v, nextContext)); + } + return { + type: SerializedValueType.SET, + elements, + }; + } + + // Promise + if (obj instanceof Promise) { + return { type: SerializedValueType.PROMISE }; + } + + // Array + if (Array.isArray(obj)) { + const result: SerializedValue = { + type: SerializedValueType.ARRAY, + elements: [], + }; + seen.set(obj, result); + const nextContext = { ...context, seen, currentDepth: currentDepth + 1 }; + result.elements = obj.map(item => serialize(item, nextContext)); + return result; + } + + // 普通对象 + const result: SerializedValue = { + type: SerializedValueType.OBJECT, + className: obj.constructor?.name ?? 'Object', + properties: {}, + }; + seen.set(obj, result); + + const nextContext = { ...context, seen, currentDepth: currentDepth + 1 }; + for (const key of Object.keys(obj)) { + result.properties![key] = serialize((obj as Record)[key], nextContext); + } + + return result; +} + +/** + * 将序列化数据还原为值 + */ +export function deserialize (data: SerializedValue, context: DeserializeContext = {}): unknown { + const { callbackResolver, proxyCreator, refResolver } = context; + + switch (data.type) { + case SerializedValueType.UNDEFINED: + return undefined; + + case SerializedValueType.NULL: + return null; + + case SerializedValueType.BOOLEAN: + return data.value; + + case SerializedValueType.NUMBER: + if (data.value === 'NaN') return NaN; + if (data.value === 'Infinity') return Infinity; + if (data.value === '-Infinity') return -Infinity; + return data.value; + + case SerializedValueType.BIGINT: + return BigInt(data.value as string); + + case SerializedValueType.STRING: + return data.value; + + case SerializedValueType.SYMBOL: + return Symbol(data.value as string); + + case SerializedValueType.FUNCTION: + if (data.callbackId && callbackResolver) { + return callbackResolver(data.callbackId); + } + // 返回一个占位函数 + return function placeholder () { + throw new Error('Remote function cannot be called without callback resolver'); + }; + + case SerializedValueType.DATE: + return new Date(data.value as string); + + case SerializedValueType.REGEXP: { + const { source, flags } = data.value as { source: string; flags: string; }; + return new RegExp(source, flags); + } + + case SerializedValueType.ERROR: { + const error = new Error(data.value as string); + if (data.properties?.['stack']) { + error.stack = deserialize(data.properties['stack'], context) as string; + } + return error; + } + + case SerializedValueType.BUFFER: { + const arr = data.value as number[]; + if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) { + const BufferClass = (globalThis as unknown as { Buffer: { from (arr: number[]): Uint8Array; }; }).Buffer; + return BufferClass.from(arr); + } + return new Uint8Array(arr); + } + + case SerializedValueType.MAP: { + const map = new Map(); + if (data.elements) { + for (const element of data.elements) { + const [k, v] = deserialize(element, context) as [unknown, unknown]; + map.set(k, v); + } + } + return map; + } + + case SerializedValueType.SET: { + const set = new Set(); + if (data.elements) { + for (const element of data.elements) { + set.add(deserialize(element, context)); + } + } + return set; + } + + case SerializedValueType.PROMISE: + return Promise.resolve(undefined); + + case SerializedValueType.ARRAY: + return (data.elements ?? []).map(elem => deserialize(elem, context)); + + case SerializedValueType.PROXY_REF: + if (data.proxyPath && proxyCreator) { + return proxyCreator(data.proxyPath); + } + return {}; + + case SerializedValueType.OBJECT_REF: + // 对象引用:在客户端创建代理,在服务端解析为实际对象 + if (data.refId) { + // 优先使用 refResolver(服务端场景) + if (refResolver) { + return refResolver(data.refId); + } + // 否则创建代理(客户端场景) + if (proxyCreator) { + // 反序列化缓存的属性 + let cachedValues: Record | undefined; + if (data.cachedProps) { + cachedValues = {}; + for (const [key, val] of Object.entries(data.cachedProps)) { + cachedValues[key] = deserialize(val, context); + } + } + return proxyCreator([], data.refId, cachedValues); + } + } + return {}; + + case SerializedValueType.OBJECT: { + const obj: Record = {}; + if (data.properties) { + for (const [key, val] of Object.entries(data.properties)) { + obj[key] = deserialize(val, context); + } + } + return obj; + } + + default: + return undefined; + } +} + +/** + * 提取序列化参数中的回调ID映射 + */ +export function extractCallbackIds (args: SerializedValue[]): Record { + const result: Record = {}; + args.forEach((arg, index) => { + if (arg.type === SerializedValueType.FUNCTION && arg.callbackId) { + result[index] = arg.callbackId; + } + }); + return result; +} diff --git a/packages/snowcat-rpc/src/server.ts b/packages/snowcat-rpc/src/server.ts new file mode 100644 index 0000000..9d9c8bf --- /dev/null +++ b/packages/snowcat-rpc/src/server.ts @@ -0,0 +1,577 @@ +import { + type RpcRequest, + type RpcResponse, + type RpcServerOptions, + type SerializedValue, + RpcOperationType, + SerializedValueType, +} from './types.js'; +import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js'; + +/** + * 生成唯一引用 ID + */ +function generateRefId (): string { + return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +/** + * 默认的代理判断函数 + * 判断返回值是否应该保持代理引用(而非完全序列化) + * 策略:class 实例和有方法的对象保持代理,普通对象直接序列化 + */ +function defaultShouldProxyResult (value: unknown): boolean { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== 'object' && typeof value !== 'function') { + return false; + } + // 函数保持代理 + if (typeof value === 'function') { + return true; + } + // 可安全序列化的内置类型不代理 + if (value instanceof Date || value instanceof RegExp || value instanceof Error) { + return false; + } + if (value instanceof Map || value instanceof Set) { + return false; + } + if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) { + return false; + } + // 数组不代理 + if (Array.isArray(value)) { + return false; + } + // 检查对象原型是否为 Object.prototype(普通对象) + const proto = Object.getPrototypeOf(value); + if (proto === Object.prototype || proto === null) { + // 普通对象检查是否有方法 + const hasMethod = Object.values(value as object).some(v => typeof v === 'function'); + return hasMethod; + } + // 非普通对象(class 实例)- 保持代理 + return true; +} + +/** + * RPC 服务端 + * + * 处理来自客户端的 RPC 请求,在目标对象上执行操作 + */ +export class RpcServer { + private target: unknown; + private callbackInvoker?: (callbackId: string, args: unknown[]) => Promise; + private localCallbacks = new SimpleCallbackRegistry(); + /** 对象引用存储 */ + private objectRefs = new Map(); + /** 代理判断函数 */ + private shouldProxyResult: (value: unknown) => boolean; + + constructor (options: RpcServerOptions) { + this.target = options.target; + this.callbackInvoker = options.callbackInvoker; + this.shouldProxyResult = options.shouldProxyResult ?? defaultShouldProxyResult; + } + + /** + * 处理 RPC 请求 + */ + async handleRequest (request: RpcRequest): Promise { + try { + switch (request.type) { + case RpcOperationType.GET: + return this.handleGet(request); + + case RpcOperationType.SET: + return this.handleSet(request); + + case RpcOperationType.APPLY: + return await this.handleApply(request); + + case RpcOperationType.CONSTRUCT: + return await this.handleConstruct(request); + + case RpcOperationType.HAS: + return this.handleHas(request); + + case RpcOperationType.OWNKEYS: + return this.handleOwnKeys(request); + + case RpcOperationType.DELETE: + return this.handleDelete(request); + + case RpcOperationType.GET_DESCRIPTOR: + return this.handleGetDescriptor(request); + + case RpcOperationType.GET_PROTOTYPE: + return this.handleGetPrototype(request); + + case RpcOperationType.RELEASE: + return this.handleRelease(request); + + default: + return { + id: request.id, + success: false, + error: `Unknown operation type: ${request.type}`, + }; + } + } catch (error) { + return this.createErrorResponse(request.id, error); + } + } + + /** + * 解析路径获取目标值,支持 refId + */ + private resolvePath (path: PropertyKey[], refId?: string): { parent: unknown; key: PropertyKey | undefined; value: unknown; } { + // 如果有 refId,从引用存储中获取根对象 + let current = refId ? this.objectRefs.get(refId) : this.target; + + if (refId && current === undefined) { + throw new Error(`Object reference not found: ${refId}`); + } + + let parent: unknown = null; + let key: PropertyKey | undefined; + + for (let i = 0; i < path.length; i++) { + parent = current; + key = path[i]; + if (key === undefined) { + throw new Error('Path contains undefined key'); + } + if (current === null || current === undefined) { + throw new Error(`Cannot access property '${String(key)}' of ${current}`); + } + current = (current as Record)[key]; + } + + return { parent, key, value: current }; + } + + /** + * 存储对象引用并返回序列化的引用 + * 同时序列化可序列化的属性值,避免属性访问需要额外 RPC + */ + private storeObjectRef (value: unknown): SerializedValue { + const refId = generateRefId(); + this.objectRefs.set(refId, value); + const className = value?.constructor?.name; + + // 序列化非函数属性 + const cachedProps: Record = {}; + if (value && typeof value === 'object') { + for (const key of Object.keys(value)) { + const propValue = (value as Record)[key]; + // 跳过函数(方法需要远程调用) + if (typeof propValue === 'function') { + continue; + } + // 序列化属性值 + try { + cachedProps[key] = serialize(propValue, { callbackRegistry: this.localCallbacks }); + } catch { + // 序列化失败的属性跳过,让客户端通过 RPC 获取 + } + } + } + + return { + type: SerializedValueType.OBJECT_REF, + refId, + className: className !== 'Object' ? className : undefined, + cachedProps: Object.keys(cachedProps).length > 0 ? cachedProps : undefined, + }; + } + + /** + * 序列化结果值,如果需要代理则存储引用 + */ + private serializeResult (value: unknown): { result: SerializedValue; isProxyable: boolean; refId?: string; } { + const shouldProxy = this.shouldProxyResult(value); + + if (shouldProxy) { + const ref = this.storeObjectRef(value); + return { + result: ref, + isProxyable: true, + refId: ref.refId, + }; + } + + return { + result: serialize(value, { callbackRegistry: this.localCallbacks }), + isProxyable: false, + }; + } + + /** + * 处理 GET 操作 + */ + private handleGet (request: RpcRequest): RpcResponse { + const { value } = this.resolvePath(request.path, request.refId); + const { result, isProxyable, refId } = this.serializeResult(value); + + return { + id: request.id, + success: true, + result, + isProxyable, + refId, + }; + } + + /** + * 处理 SET 操作 + */ + private handleSet (request: RpcRequest): RpcResponse { + const path = request.path; + if (path.length === 0 && !request.refId) { + throw new Error('Cannot set root object'); + } + + const parentPath = path.slice(0, -1); + const key = path[path.length - 1]!; + const { value: parent } = this.resolvePath(parentPath, request.refId); + + if (parent === null || parent === undefined) { + throw new Error(`Cannot set property '${String(key)}' of ${parent}`); + } + + const newValue = request.args?.[0] + ? deserialize(request.args[0], { + callbackResolver: this.createCallbackResolver(request), + refResolver: (refId) => this.objectRefs.get(refId), + }) + : undefined; + + (parent as Record)[key] = newValue; + + return { + id: request.id, + success: true, + }; + } + + /** + * 处理 APPLY 操作 + */ + private async handleApply (request: RpcRequest): Promise { + const path = request.path; + + // 如果有 refId 且 path 为空,说明引用对象本身是函数 + if (path.length === 0 && request.refId) { + const func = this.objectRefs.get(request.refId); + if (typeof func !== 'function') { + throw new Error('Referenced object is not callable'); + } + + const args = (request.args ?? []).map(arg => + deserialize(arg, { + callbackResolver: this.createCallbackResolver(request), + refResolver: (refId) => this.objectRefs.get(refId), + }) + ); + + let result = func(...args); + if (result instanceof Promise) { + result = await result; + } + + const { result: serializedResult, isProxyable, refId } = this.serializeResult(result); + return { + id: request.id, + success: true, + result: serializedResult, + isProxyable, + refId, + }; + } + + if (path.length === 0) { + throw new Error('Cannot call root object'); + } + + const methodPath = path.slice(0, -1); + const methodName = path[path.length - 1]!; + const { value: parent } = this.resolvePath(methodPath, request.refId); + + if (parent === null || parent === undefined) { + throw new Error(`Cannot call method on ${parent}`); + } + + const method = (parent as Record)[methodName]; + if (typeof method !== 'function') { + throw new Error(`${String(methodName)} is not a function`); + } + + const args = (request.args ?? []).map(arg => + deserialize(arg, { + callbackResolver: this.createCallbackResolver(request), + refResolver: (refId) => this.objectRefs.get(refId), + }) + ); + + let result = method.call(parent, ...args); + + // 处理 Promise + if (result instanceof Promise) { + result = await result; + } + + const { result: serializedResult, isProxyable, refId } = this.serializeResult(result); + + return { + id: request.id, + success: true, + result: serializedResult, + isProxyable, + refId, + }; + } + + /** + * 处理 CONSTRUCT 操作 + */ + private async handleConstruct (request: RpcRequest): Promise { + const { value: Constructor } = this.resolvePath(request.path, request.refId); + + if (typeof Constructor !== 'function') { + throw new Error('Target is not a constructor'); + } + + const args = (request.args ?? []).map(arg => + deserialize(arg, { + callbackResolver: this.createCallbackResolver(request), + refResolver: (refId) => this.objectRefs.get(refId), + }) + ); + + const instance = new (Constructor as new (...args: unknown[]) => unknown)(...args); + const { result, isProxyable, refId } = this.serializeResult(instance); + + return { + id: request.id, + success: true, + result, + isProxyable, + refId, + }; + } + + /** + * 处理 HAS 操作 + */ + private handleHas (request: RpcRequest): RpcResponse { + const path = request.path; + if (path.length === 0) { + return { + id: request.id, + success: true, + result: serialize(true), + }; + } + + const parentPath = path.slice(0, -1); + const key = path[path.length - 1]!; + const { value: parent } = this.resolvePath(parentPath, request.refId); + + const has = parent !== null && parent !== undefined && key in (parent as object); + + return { + id: request.id, + success: true, + result: serialize(has), + }; + } + + /** + * 处理 OWNKEYS 操作 + */ + private handleOwnKeys (request: RpcRequest): RpcResponse { + const { value } = this.resolvePath(request.path, request.refId); + + if (value === null || value === undefined) { + return { + id: request.id, + success: true, + result: serialize([]), + }; + } + + const keys = Reflect.ownKeys(value as object); + + return { + id: request.id, + success: true, + result: serialize(keys.map(k => (typeof k === 'symbol' ? k.description ?? '' : String(k)))), + }; + } + + /** + * 处理 DELETE 操作 + */ + private handleDelete (request: RpcRequest): RpcResponse { + const path = request.path; + if (path.length === 0 && !request.refId) { + throw new Error('Cannot delete root object'); + } + + const parentPath = path.slice(0, -1); + const key = path[path.length - 1]!; + const { value: parent } = this.resolvePath(parentPath, request.refId); + + if (parent === null || parent === undefined) { + throw new Error(`Cannot delete property from ${parent}`); + } + + const deleted = delete (parent as Record)[key]; + + return { + id: request.id, + success: true, + result: serialize(deleted), + }; + } + + /** + * 处理 GET_DESCRIPTOR 操作 + */ + private handleGetDescriptor (request: RpcRequest): RpcResponse { + const path = request.path; + if (path.length === 0) { + return { + id: request.id, + success: true, + result: serialize(undefined), + }; + } + + const parentPath = path.slice(0, -1); + const key = path[path.length - 1]!; + const { value: parent } = this.resolvePath(parentPath, request.refId); + + if (parent === null || parent === undefined) { + return { + id: request.id, + success: true, + result: serialize(undefined), + }; + } + + const descriptor = Object.getOwnPropertyDescriptor(parent as object, key); + + if (!descriptor) { + return { + id: request.id, + success: true, + result: serialize(undefined), + }; + } + + // 序列化描述符(排除 value 和 get/set 函数) + return { + id: request.id, + success: true, + result: serialize({ + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: descriptor.writable, + }), + }; + } + + /** + * 处理 GET_PROTOTYPE 操作 + */ + private handleGetPrototype (request: RpcRequest): RpcResponse { + const { value } = this.resolvePath(request.path, request.refId); + + if (value === null || value === undefined) { + return { + id: request.id, + success: true, + result: serialize(null), + }; + } + + const proto = Object.getPrototypeOf(value); + const name = proto?.constructor?.name ?? 'Object'; + + return { + id: request.id, + success: true, + result: serialize({ name }), + }; + } + + /** + * 处理 RELEASE 操作 + */ + private handleRelease (request: RpcRequest): RpcResponse { + // 如果有 refId,释放该引用 + if (request.refId) { + this.objectRefs.delete(request.refId); + } + return { + id: request.id, + success: true, + }; + } + + /** + * 创建回调解析器 + */ + private createCallbackResolver (_request: RpcRequest): (id: string) => Function { + return (callbackId: string) => { + // 创建一个代理函数,调用时会通过 callbackInvoker 发送回客户端 + return async (...args: unknown[]) => { + if (!this.callbackInvoker) { + throw new Error('Callback invoker not configured'); + } + return this.callbackInvoker(callbackId, args); + }; + }; + } + + /** + * 创建错误响应 + */ + private createErrorResponse (requestId: string, error: unknown): RpcResponse { + if (error instanceof Error) { + return { + id: requestId, + success: false, + error: error.message, + stack: error.stack, + }; + } + return { + id: requestId, + success: false, + error: String(error), + }; + } + + /** + * 调用客户端回调 + */ + async invokeCallback (callbackId: string, args: unknown[]): Promise { + if (!this.callbackInvoker) { + throw new Error('Callback invoker not configured'); + } + const result = await this.callbackInvoker(callbackId, args); + return serialize(result, { callbackRegistry: this.localCallbacks }); + } +} + +/** + * 创建 RPC 服务端 + */ +export function createRpcServer (options: RpcServerOptions): RpcServer { + return new RpcServer(options); +} diff --git a/packages/snowcat-rpc/src/transport.ts b/packages/snowcat-rpc/src/transport.ts new file mode 100644 index 0000000..e71fa24 --- /dev/null +++ b/packages/snowcat-rpc/src/transport.ts @@ -0,0 +1,215 @@ +import { + type RpcTransport, + type RpcRequest, + type RpcResponse, + type SerializedValue, +} from './types.js'; +import { RpcServer } from './server.js'; +import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js'; + +/** + * 本地传输层 + * + * 用于在同一进程内进行 RPC 调用,主要用于测试 + */ +export class LocalTransport implements RpcTransport { + private server: RpcServer; + private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise; + private clientCallbacks = new SimpleCallbackRegistry(); + + constructor (target: unknown) { + this.server = new RpcServer({ + target, + callbackInvoker: async (callbackId, args) => { + if (!this.callbackHandler) { + throw new Error('Callback handler not registered'); + } + const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry: this.clientCallbacks })); + const result = await this.callbackHandler(callbackId, serializedArgs); + return deserialize(result); + }, + }); + } + + async send (request: RpcRequest): Promise { + // 模拟网络延迟(可选) + // await new Promise(resolve => setTimeout(resolve, 0)); + + return this.server.handleRequest(request); + } + + onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise): void { + this.callbackHandler = handler; + } + + close (): void { + this.clientCallbacks.clear(); + } +} + +/** + * 消息传输层接口 + */ +export interface MessageTransportOptions { + /** 发送消息 */ + sendMessage: (message: string) => void | Promise; + /** 接收消息时的回调 */ + onMessage: (handler: (message: string) => void) => void; +} + +/** + * 基于消息的传输层 + * + * 可用于跨进程/网络通信 + */ +export class MessageTransport implements RpcTransport { + private pendingRequests = new Map void; + reject: (error: Error) => void; + }>(); + + private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise; + private sendMessage: (message: string) => void | Promise; + + constructor (options: MessageTransportOptions) { + this.sendMessage = options.sendMessage; + + options.onMessage(async (message) => { + const data = JSON.parse(message) as { + type: 'response' | 'callback' | 'callback_response'; + id: string; + response?: RpcResponse; + callbackId?: string; + args?: SerializedValue[]; + result?: SerializedValue; + error?: string; + }; + + if (data.type === 'response') { + const pending = this.pendingRequests.get(data.id); + if (pending && data.response) { + this.pendingRequests.delete(data.id); + pending.resolve(data.response); + } + } else if (data.type === 'callback') { + // 处理来自服务端的回调调用 + if (this.callbackHandler && data.callbackId && data.args) { + try { + const result = await this.callbackHandler(data.callbackId, data.args); + await this.sendMessage(JSON.stringify({ + type: 'callback_response', + id: data.id, + result, + })); + } catch (error) { + await this.sendMessage(JSON.stringify({ + type: 'callback_response', + id: data.id, + error: error instanceof Error ? error.message : String(error), + })); + } + } + } + }); + } + + async send (request: RpcRequest): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, _reject) => { + const id = request.id; + const reject = (error: any) => { + this.pendingRequests.delete(id); + _reject(error); + }; + this.pendingRequests.set(id, { resolve, reject }); + + const message = JSON.stringify({ + type: 'request', + request, + }); + + try { + await this.sendMessage(message); + } catch (error) { + reject(error); + } + }); + } + + onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise): void { + this.callbackHandler = handler; + } + + close (): void { + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error('Transport closed')); + } + this.pendingRequests.clear(); + } +} + +/** + * 创建消息传输层的服务端处理器 + */ +export function createMessageServerHandler (target: unknown, options: { + sendMessage: (message: string) => void | Promise; + onMessage: (handler: (message: string) => void) => void; +}): void { + const pendingCallbacks = new Map void; + reject: (error: Error) => void; + }>(); + + let callbackIdCounter = 0; + + const server = new RpcServer({ + target, + callbackInvoker: async (callbackId, args) => { + const id = `cb_call_${++callbackIdCounter}`; + const serializedArgs = args.map(arg => serialize(arg)); + + return new Promise((resolve, reject) => { + pendingCallbacks.set(id, { + resolve: (result) => resolve(deserialize(result)), + reject, + }); + + options.sendMessage(JSON.stringify({ + type: 'callback', + id, + callbackId, + args: serializedArgs, + })); + }); + }, + }); + + options.onMessage(async (message) => { + const data = JSON.parse(message) as { + type: 'request' | 'callback_response'; + id: string; + request?: RpcRequest; + result?: SerializedValue; + error?: string; + }; + + if (data.type === 'request' && data.request) { + const response = await server.handleRequest(data.request); + await options.sendMessage(JSON.stringify({ + type: 'response', + id: data.request.id, + response, + })); + } else if (data.type === 'callback_response') { + const pending = pendingCallbacks.get(data.id); + if (pending) { + pendingCallbacks.delete(data.id); + if (data.error) { + pending.reject(new Error(data.error)); + } else if (data.result) { + pending.resolve(data.result); + } + } + } + }); +} diff --git a/packages/snowcat-rpc/src/types.ts b/packages/snowcat-rpc/src/types.ts new file mode 100644 index 0000000..d700d84 --- /dev/null +++ b/packages/snowcat-rpc/src/types.ts @@ -0,0 +1,195 @@ +/** + * RPC 操作类型 + */ +export enum RpcOperationType { + /** 获取属性 */ + GET = 'get', + /** 设置属性 */ + SET = 'set', + /** 调用方法 */ + APPLY = 'apply', + /** 构造函数调用 */ + CONSTRUCT = 'construct', + /** 检查属性是否存在 */ + HAS = 'has', + /** 获取所有键 */ + OWNKEYS = 'ownKeys', + /** 删除属性 */ + DELETE = 'deleteProperty', + /** 获取属性描述符 */ + GET_DESCRIPTOR = 'getOwnPropertyDescriptor', + /** 获取原型 */ + GET_PROTOTYPE = 'getPrototypeOf', + /** 回调调用 */ + CALLBACK = 'callback', + /** 释放资源 */ + RELEASE = 'release', +} + +/** + * RPC 请求消息 + */ +export interface RpcRequest { + /** 请求 ID */ + id: string; + /** 操作类型 */ + type: RpcOperationType; + /** 访问路径 (从根对象开始) */ + path: PropertyKey[]; + /** 参数 (用于 set, apply, construct) */ + args?: SerializedValue[]; + /** 回调 ID 映射 (参数索引 -> 回调 ID) */ + callbackIds?: Record; + /** 远程对象引用 ID(用于对引用对象的操作) */ + refId?: string; +} + +/** + * RPC 响应消息 + */ +export interface RpcResponse { + /** 请求 ID */ + id: string; + /** 是否成功 */ + success: boolean; + /** 返回值 */ + result?: SerializedValue; + /** 错误信息 */ + error?: string; + /** 错误堆栈 */ + stack?: string; + /** 结果是否为可代理对象 */ + isProxyable?: boolean; + /** 远程对象引用 ID(用于深层对象代理) */ + refId?: string; +} + +/** + * 序列化后的值 + */ +export interface SerializedValue { + /** 值类型 */ + type: SerializedValueType; + /** 原始值(用于基本类型) */ + value?: unknown; + /** 对象类型名称 */ + className?: string; + /** 回调 ID(用于函数) */ + callbackId?: string; + /** 代理路径(用于可代理对象) */ + proxyPath?: PropertyKey[]; + /** 数组元素或对象属性 */ + properties?: Record; + /** 数组元素 */ + elements?: SerializedValue[]; + /** 远程对象引用 ID(用于保持代理能力) */ + refId?: string; + /** 缓存的属性值(OBJECT_REF 时使用,避免属性访问需要 RPC) */ + cachedProps?: Record; +} + +/** + * 序列化值类型 + */ +export enum SerializedValueType { + UNDEFINED = 'undefined', + NULL = 'null', + BOOLEAN = 'boolean', + NUMBER = 'number', + BIGINT = 'bigint', + STRING = 'string', + SYMBOL = 'symbol', + FUNCTION = 'function', + OBJECT = 'object', + ARRAY = 'array', + DATE = 'date', + REGEXP = 'regexp', + ERROR = 'error', + PROMISE = 'promise', + PROXY_REF = 'proxyRef', + BUFFER = 'buffer', + MAP = 'map', + SET = 'set', + /** 远程对象引用 - 保持代理能力 */ + OBJECT_REF = 'objectRef', +} + +/** + * 对象引用信息 + */ +export interface ObjectRef { + /** 引用 ID */ + refId: string; + /** 对象类型名称 */ + className?: string; +} + +/** + * RPC 传输层接口 + */ +export interface RpcTransport { + /** 发送请求并等待响应 */ + send (request: RpcRequest): Promise; + /** 注册回调处理器 */ + onCallback?(handler: (callbackId: string, args: SerializedValue[]) => Promise): void; + /** 关闭连接 */ + close?(): void; +} + +/** + * RPC 服务端处理器接口 + */ +export interface RpcServerHandler { + /** 处理请求 */ + handleRequest (request: RpcRequest): Promise; + /** 调用客户端回调 */ + invokeCallback?(callbackId: string, args: unknown[]): Promise; +} + +/** + * 深层代理选项 + */ +export interface DeepProxyOptions { + /** 传输层 */ + transport: RpcTransport; + /** 根路径 */ + rootPath?: PropertyKey[]; + /** 是否缓存属性 */ + cacheProperties?: boolean; + /** 回调超时时间 (ms) */ + callbackTimeout?: number; + /** 远程对象引用 ID(用于引用对象的代理) */ + refId?: string; +} + +/** + * RPC 服务端选项 + */ +export interface RpcServerOptions { + /** 目标对象 */ + target: unknown; + /** 回调调用器 */ + callbackInvoker?: (callbackId: string, args: unknown[]) => Promise; + /** + * 判断返回值是否应保持代理引用(而非完全序列化) + * 默认对 class 实例和包含方法的对象返回 true + */ + shouldProxyResult?: (value: unknown) => boolean; +} + +/** + * 代理元数据符号 + */ +export const PROXY_META = Symbol('PROXY_META'); + +/** + * 代理元数据 + */ +export interface ProxyMeta { + /** 访问路径 */ + path: PropertyKey[]; + /** 是否为代理 */ + isProxy: true; + /** 远程对象引用 ID */ + refId?: string; +} diff --git a/packages/snowcat-rpc/tsconfig.json b/packages/snowcat-rpc/tsconfig.json new file mode 100644 index 0000000..e4975a2 --- /dev/null +++ b/packages/snowcat-rpc/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "paths": { + "@/*": [ + "../../packages/*" + ] + } + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-schema/index.ts b/packages/snowcat-schema/index.ts new file mode 100644 index 0000000..f24e2d6 --- /dev/null +++ b/packages/snowcat-schema/index.ts @@ -0,0 +1,747 @@ +import { getAllHandlers } from '@/snowcat-onebot/action/index'; +import { AutoRegisterRouter } from '@/snowcat-onebot/action/auto-register'; +import { writeFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TSchema } from '@sinclair/typebox'; +import { OneBotAction, ActionExamples } from '@/snowcat-onebot/action/OneBotAction'; +import { snowCatVersion } from 'snowcat-common/src/version'; +import * as MessageSchemas from '@/snowcat-onebot/types/message'; +import * as ActionSchemas from '@/snowcat-onebot/action/schemas'; + +/* -------------------------------------------------------------------------- */ +/* 基础类型 */ +/* -------------------------------------------------------------------------- */ + +interface ActionSchemaInfo { + payload?: TSchema; + return?: TSchema; + summary?: string; + description?: string; + tags?: string[]; + payloadExample?: unknown; + returnExample?: unknown; + errorExamples?: Array<{ code: number, description: string; }>; +} + +type JsonObject = { [key: string]: unknown; }; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const OPENAPI_OUTPUT_PATH = resolve(__dirname, 'openapi.json'); +const MISSING_REPORT_PATH = resolve(__dirname, 'missing_props.log'); + +export const actionSchemas: Record = {}; + +/* -------------------------------------------------------------------------- */ +/* 日志工具 */ +/* -------------------------------------------------------------------------- */ + +/** + * 统一日志前缀,方便在构建日志中快速检索。 + */ +const LOG_SCOPE = '[snowcat-schema]'; + +function logSection (title: string) { + console.log(`\n${LOG_SCOPE} ── ${title}`); +} + +function logInfo (message: string) { + console.log(`${LOG_SCOPE} ℹ ${message}`); +} + +function logWarn (message: string) { + console.warn(`${LOG_SCOPE} ⚠ ${message}`); +} + +function logSuccess (message: string) { + console.log(`${LOG_SCOPE} ✅ ${message}`); +} + +/* -------------------------------------------------------------------------- */ +/* OpenAPI 基础组件(固定部分) */ +/* -------------------------------------------------------------------------- */ + +const BaseResponseSchema: JsonObject = { + type: 'object', + 'x-schema-id': 'BaseResponse', + properties: { + status: { type: 'string', description: '状态 (ok/failed)' }, + retcode: { type: 'number', description: '返回码' }, + data: { description: '业务数据(具体结构由各接口定义)' }, + message: { type: 'string', description: '消息' }, + wording: { type: 'string', description: '提示' }, + stream: { + type: 'string', + description: '流式响应', + enum: ['stream-action', 'normal-action'], + }, + }, + required: ['status', 'retcode'], +}; + +const EmptyDataSchema: JsonObject = { + description: '无数据', + type: 'null', +}; + +const DEFAULT_SUCCESS_EXAMPLE_VALUE = { + status: 'ok', + retcode: 0, + data: {}, + message: '', + wording: '', + stream: 'normal-action', +} as const; + +const DEFAULT_ERROR_EXAMPLE_DEFINITIONS = ActionExamples.Common.errors; + +const SUCCESS_DEFAULT_EXAMPLE_KEY = 'Success_Default'; + +function isObjectRecord (value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isEmptyObject (value: unknown): value is Record { + return isObjectRecord(value) && Object.keys(value).length === 0; +} + +function isEmptyArray (value: unknown): value is [] { + return Array.isArray(value) && value.length === 0; +} + +function isMeaninglessSuccessExampleData (value: unknown): boolean { + return value === null || isEmptyObject(value) || isEmptyArray(value); +} + +function resolveCommonErrorExampleKey (error: { code: number, description: string; }): string | null { + const matched = DEFAULT_ERROR_EXAMPLE_DEFINITIONS.find( + item => item.code === error.code && item.description === error.description + ); + return matched ? `Error_${matched.code}` : null; +} + +/* -------------------------------------------------------------------------- */ +/* 通用工具函数 */ +/* -------------------------------------------------------------------------- */ + +/** + * 深拷贝 schema,优先使用 structuredClone,失败时回落到 JSON 序列化。 + */ +function cloneSchema (schema: T): T { + if (typeof globalThis.structuredClone === 'function') { + try { + return globalThis.structuredClone(schema); + } catch { + // fallback + } + } + return JSON.parse(JSON.stringify(schema)) as T; +} + +/** + * 在 anyOf/oneOf 中,将“多个单值 enum 分支 + 可选 nullable 分支”压缩为单个 enum。 + * + * 例: + * - anyOf: [{ type:'string', enum:['a'] }, { type:'string', enum:['b'] }] + * -> { type:'string', enum:['a','b'] } + */ +function collapseSingleValueEnumCombinator (items: unknown[]): Record | null { + const enumValues: unknown[] = []; + let type: string | undefined; + let nullable = false; + + for (const item of items) { + if (!item || typeof item !== 'object') { + return null; + } + + const branch = item as Record; + + // 兼容仅有 nullable 的分支 + if (branch['nullable'] === true && Object.keys(branch).length === 1) { + nullable = true; + continue; + } + + const branchEnum = branch['enum']; + if (!Array.isArray(branchEnum) || branchEnum.length !== 1) { + return null; + } + + enumValues.push(branchEnum[0]); + + if (typeof branch['type'] === 'string') { + if (!type) { + type = branch['type']; + } else if (type !== branch['type']) { + return null; + } + } + } + + if (enumValues.length === 0) { + return null; + } + + const merged: Record = { enum: [...new Set(enumValues)] }; + if (type) { + merged['type'] = type; + } + if (nullable) { + merged['nullable'] = true; + } + return merged; +} + +/** + * 将 TypeBox/JSON-Schema 映射为 OpenAPI 3.1 兼容结构。 + * + * 关键规则: + * - $id -> x-schema-id(保留标识用于后续 $ref 替换) + * - const -> enum:[const] + * - type:'void' / type:'undefined' -> type:'null' + * - nullable:true -> type 包含 'null' + * - anyOf/oneOf 的简单 enum 分支做压缩 + */ +function sanitizeSchemaForOpenAPI (schema: T): T { + const walk = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(walk); + } + + if (value && typeof value === 'object') { + const obj = value as Record; + const next: Record = {}; + + for (const [key, child] of Object.entries(obj)) { + // 特殊处理 properties 容器:只遍历每个属性的 schema,避免将容器对象误判为 schema 元对象 + if (key === 'properties' && child && typeof child === 'object' && !Array.isArray(child)) { + const cleanProps: Record = {}; + for (const [propName, propSchema] of Object.entries(child as Record)) { + cleanProps[propName] = walk(propSchema); + } + next[key] = cleanProps; + continue; + } + + if (key === '$id') { + if (typeof child === 'string' && child.length > 0) { + next['x-schema-id'] = child; + } + continue; + } + + if (key === 'const') { + next['enum'] = [child]; + continue; + } + + if (key === 'type' && typeof child === 'string') { + if (child === 'void' || child === 'undefined') { + next['type'] = 'null'; + } else { + next['type'] = child; + } + continue; + } + + if (key === 'type' && Array.isArray(child)) { + const types = child + .filter((t): t is string => typeof t === 'string') + .map(t => (t === 'void' || t === 'undefined') ? 'null' : t); + + const normalizedTypes = [...new Set(types)]; + if (normalizedTypes.length === 0) { + next['type'] = 'null'; + } else if (normalizedTypes.length === 1) { + next['type'] = normalizedTypes[0]; + } else { + next['type'] = normalizedTypes; + } + continue; + } + + if ((key === 'anyOf' || key === 'oneOf') && Array.isArray(child)) { + const normalized = child.map(walk); + const mergedEnum = collapseSingleValueEnumCombinator(normalized); + if (mergedEnum) { + Object.assign(next, mergedEnum); + } else { + next[key] = normalized; + } + continue; + } + + next[key] = walk(child); + } + + // OpenAPI 3.1:将 nullable 归一到 type 包含 null + if (next['nullable'] === true) { + const currentType = next['type']; + if (typeof currentType === 'string') { + next['type'] = currentType === 'null' ? 'null' : [currentType, 'null']; + } else if (Array.isArray(currentType)) { + const normalizedTypes = [ + ...new Set(currentType + .filter((t): t is string => typeof t === 'string') + .map(t => (t === 'void' || t === 'undefined') ? 'null' : t) + .concat('null')), + ]; + next['type'] = normalizedTypes.length === 1 ? normalizedTypes[0] : normalizedTypes; + } else if (!('anyOf' in next) && !('oneOf' in next) && !('allOf' in next) && !('$ref' in next)) { + next['type'] = 'null'; + } + delete next['nullable']; + } + + // 兜底:仅有描述/元信息但缺少 type 时,补 object,避免严格校验失败 + if ( + !('type' in next) && + !('$ref' in next) && + !('anyOf' in next) && + !('oneOf' in next) && + !('allOf' in next) && + !('enum' in next) && + !('properties' in next) && + !('items' in next) + ) { + const schemaMetaKeys = [ + 'description', 'title', 'default', 'examples', 'example', + 'deprecated', 'readOnly', 'writeOnly', 'x-schema-id', + ]; + + if (schemaMetaKeys.some(key => key in next)) { + next['type'] = 'object'; + } + } + + return next; + } + + return value; + }; + + return walk(schema) as T; +} + +/* -------------------------------------------------------------------------- */ +/* Schema 注册 & 引用替换逻辑 */ +/* -------------------------------------------------------------------------- */ + +/** + * 将模块中所有“含 $id 的导出 schema”注册到 components.schemas。 + */ +function registerSchemasFromModule ( + openapi: JsonObject, + source: Record, + sourceName: string +) { + const components = (openapi['components'] as JsonObject)['schemas'] as JsonObject; + let registeredCount = 0; + let duplicatedCount = 0; + + for (const exportedValue of Object.values(source)) { + if (!exportedValue || typeof exportedValue !== 'object') { + continue; + } + + const schema = cloneSchema(exportedValue) as JsonObject; + const schemaId = typeof schema['$id'] === 'string' && (schema['$id'] as string).length > 0 + ? schema['$id'] as string + : ''; + + if (!schemaId) { + continue; + } + + if (components[schemaId]) { + duplicatedCount += 1; + logWarn(`发现重复 schema id(${sourceName}):${schemaId},将覆盖旧定义`); + } + + components[schemaId] = sanitizeSchemaForOpenAPI(schema); + registeredCount += 1; + } + + logInfo(`${sourceName} 注册完成:${registeredCount} 个 schema,重复 ${duplicatedCount} 个`); +} + +/** + * 对 components.schemas 做去内联: + * - 若子节点含 x-schema-id 且在 components.schemas 可命中 + * - 则替换为 $ref + * + * 注意:组件根节点不会替换为自身,避免根级自引用。 + */ +function replaceComponentInlineSchemasWithRefs (openapi: JsonObject) { + const components = openapi['components'] as JsonObject | undefined; + const schemas = components?.['schemas'] as JsonObject | undefined; + + if (!schemas || typeof schemas !== 'object') { + return; + } + + const availableSchemaIds = new Set(Object.keys(schemas)); + let replacedCount = 0; + + const walk = (value: unknown, ownerSchemaId: string): unknown => { + if (Array.isArray(value)) { + return value.map(item => walk(item, ownerSchemaId)); + } + + if (value && typeof value === 'object') { + const obj = value as JsonObject; + const schemaId = obj['x-schema-id']; + + if ( + typeof schemaId === 'string' && + schemaId !== ownerSchemaId && + availableSchemaIds.has(schemaId) + ) { + replacedCount += 1; + return { $ref: `#/components/schemas/${schemaId}` }; + } + + const next: JsonObject = {}; + for (const [key, child] of Object.entries(obj)) { + next[key] = walk(child, ownerSchemaId); + } + return next; + } + + return value; + }; + + for (const [schemaId, schema] of Object.entries(schemas)) { + schemas[schemaId] = walk(schema, schemaId); + } + + logInfo(`components 内联替换完成:${replacedCount} 处`); +} + +/** + * 对 paths 做去内联: + * - 若节点含 x-schema-id 且在 components.schemas 可命中 + * - 则替换为 $ref + */ +function replacePathInlineSchemasWithRefs (openapi: JsonObject) { + const paths = openapi['paths']; + const components = openapi['components'] as JsonObject | undefined; + const schemas = components?.['schemas'] as JsonObject | undefined; + + if (!paths || typeof paths !== 'object' || !schemas || typeof schemas !== 'object') { + return; + } + + const availableSchemaIds = new Set(Object.keys(schemas)); + let replacedCount = 0; + + const walk = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(walk); + } + + if (value && typeof value === 'object') { + const obj = value as JsonObject; + const schemaId = obj['x-schema-id']; + + if (typeof schemaId === 'string' && availableSchemaIds.has(schemaId)) { + replacedCount += 1; + return { $ref: `#/components/schemas/${schemaId}` }; + } + + const next: JsonObject = {}; + for (const [key, child] of Object.entries(obj)) { + next[key] = walk(child); + } + return next; + } + + return value; + }; + + openapi['paths'] = walk(paths) as JsonObject; + logInfo(`paths 内联替换完成:${replacedCount} 处`); +} + +/* -------------------------------------------------------------------------- */ +/* Action 收集逻辑 */ +/* -------------------------------------------------------------------------- */ + +/** + * 收集全部 action schema 信息。 + */ +export function initSchemas () { + const handlers = getAllHandlers(null as any, null as any); + + handlers.forEach(handler => { + if (!handler.actionName || (handler.actionName as string) === 'unknown') { + return; + } + + const action = handler as OneBotAction; + actionSchemas[handler.actionName] = { + payload: action.payloadSchema, + return: action.returnSchema, + summary: action.actionSummary, + description: action.actionDescription, + tags: action.actionTags, + payloadExample: action.payloadExample, + returnExample: action.returnExample, + errorExamples: action.errorExamples, + }; + }); + + AutoRegisterRouter.forEach(ActionClass => { + const handler = new ActionClass(null as any, null as any); + if (!handler.actionName || (handler.actionName as string) === 'unknown') { + return; + } + + const action = handler as OneBotAction; + actionSchemas[handler.actionName] = { + payload: action.payloadSchema, + return: action.returnSchema, + summary: action.actionSummary, + description: action.actionDescription, + tags: action.actionTags, + payloadExample: action.payloadExample, + returnExample: action.returnExample, + errorExamples: action.errorExamples, + }; + }); +} + +/* -------------------------------------------------------------------------- */ +/* OpenAPI 构建主流程 */ +/* -------------------------------------------------------------------------- */ + +function createOpenAPIDocument (): Record { + const componentExamples: Record = { + [SUCCESS_DEFAULT_EXAMPLE_KEY]: { + summary: '成功响应', + value: DEFAULT_SUCCESS_EXAMPLE_VALUE, + }, + }; + + DEFAULT_ERROR_EXAMPLE_DEFINITIONS.forEach(error => { + componentExamples[`Error_${error.code}`] = { + summary: error.description, + value: { + status: 'failed', + retcode: error.code, + data: null, + message: error.description, + wording: error.description, + stream: 'normal-action', + }, + }; + }); + + return { + openapi: '3.1.0', + info: { + title: 'SnowCat OneBot 11 HTTP API', + description: 'NapCatOneBot11 HTTP POST 接口文档', + version: snowCatVersion, + }, + tags: [ + { name: '消息接口', description: '发送、删除、获取消息相关接口' }, + { name: '群组接口', description: '群组管理、成员管理相关接口' }, + { name: '用户接口', description: '好友管理、个人信息相关接口' }, + { name: '系统接口', description: '状态获取、重启、缓存清理相关接口' }, + { name: '文件接口', description: '文件上传下载、预览相关接口' }, + ], + paths: {} as Record, + components: { + schemas: { + BaseResponse: BaseResponseSchema, + EmptyData: EmptyDataSchema, + }, + examples: componentExamples, + responses: {}, + securitySchemes: {}, + }, + servers: [], + security: [], + }; +} + +function buildResponseExamples (schemas: ActionSchemaInfo): Record { + const successData = schemas.returnExample ?? {}; + const examples: Record = { + Success: isMeaninglessSuccessExampleData(successData) + ? { $ref: `#/components/examples/${SUCCESS_DEFAULT_EXAMPLE_KEY}` } + : { + summary: '成功响应', + value: { + status: 'ok', + retcode: 0, + data: successData, + message: '', + wording: '', + stream: 'normal-action', + }, + }, + }; + + if (schemas.errorExamples) { + schemas.errorExamples.forEach(error => { + const commonErrorKey = resolveCommonErrorExampleKey(error); + examples[`Error_${error.code}`] = commonErrorKey + ? { $ref: `#/components/examples/${commonErrorKey}` } + : { + summary: error.description, + value: { + status: 'failed', + retcode: error.code, + data: null, + message: error.description, + wording: error.description, + stream: 'normal-action', + }, + }; + }); + return examples; + } + + examples['Generic_Error'] = { + $ref: '#/components/examples/Error_1400', + }; + + return examples; +} + +function appendActionPaths (openapi: Record) { + const paths = openapi['paths'] as Record; + + for (const [actionName, schemas] of Object.entries(actionSchemas)) { + if (!schemas.payload && !schemas.summary) { + continue; + } + + const path = `/${actionName}`; + const cleanPayload = schemas.payload + ? sanitizeSchemaForOpenAPI(cloneSchema(schemas.payload)) + : { type: 'object', properties: {} }; + const cleanReturn = schemas.return + ? sanitizeSchemaForOpenAPI(cloneSchema(schemas.return)) + : { $ref: '#/components/schemas/EmptyData' }; + + paths[path] = { + post: { + summary: schemas.summary || actionName, + deprecated: false, + description: schemas.description || '', + tags: schemas.tags || [], + parameters: [], + requestBody: { + description: 'API 参数', + content: { + 'application/json': { + schema: cleanPayload, + examples: { + Default: { + summary: '默认请求示例', + value: schemas.payloadExample || {}, + }, + }, + }, + }, + }, + responses: { + 200: { + description: '业务响应', + content: { + 'application/json': { + schema: { + allOf: [ + { $ref: '#/components/schemas/BaseResponse' }, + { + type: 'object', + required: ['data'], + properties: { + data: { + ...(typeof cleanReturn === 'object' && cleanReturn ? cleanReturn : {}), + description: '业务数据', + }, + }, + }, + ], + }, + examples: buildResponseExamples(schemas), + }, + }, + }, + }, + security: [], + }, + }; + } +} + +export function generateOpenAPI () { + logSection('开始生成 OpenAPI 文档'); + + try { + initSchemas(); + logInfo(`已收集 action: ${Object.keys(actionSchemas).length} 个`); + } catch { + logWarn('初始化 schema 过程中出现部分失败,将继续使用已收集的数据'); + } + + const openapi = createOpenAPIDocument(); + + logSection('注册组件 schema'); + registerSchemasFromModule(openapi as JsonObject, MessageSchemas, 'types/message.ts'); + registerSchemasFromModule(openapi as JsonObject, ActionSchemas, 'action/schemas.ts'); + + logSection('处理组件内联引用'); + replaceComponentInlineSchemasWithRefs(openapi as JsonObject); + + logSection('构建 paths'); + appendActionPaths(openapi); + + logSection('处理 paths 内联引用'); + replacePathInlineSchemasWithRefs(openapi as JsonObject); + + writeFileSync(OPENAPI_OUTPUT_PATH, JSON.stringify(openapi, null, 2)); + logSuccess(`OpenAPI 生成完成:${OPENAPI_OUTPUT_PATH}`); + + generateMissingReport(); +} + +/* -------------------------------------------------------------------------- */ +/* 元数据缺失报告 */ +/* -------------------------------------------------------------------------- */ + +function generateMissingReport () { + const missingReport: string[] = []; + + for (const [actionName, schemas] of Object.entries(actionSchemas)) { + const missing: string[] = []; + if (!schemas.summary) missing.push('actionSummary'); + if (!schemas.tags || schemas.tags.length === 0) missing.push('actionTags'); + if (schemas.payloadExample === undefined && schemas.payload) missing.push('payloadExample'); + if (schemas.returnExample === undefined) missing.push('returnExample'); + + if (missing.length > 0) { + missingReport.push(`[${actionName}] 缺失属性: ${missing.join(', ')}`); + } + } + + if (missingReport.length > 0) { + writeFileSync(MISSING_REPORT_PATH, missingReport.join('\n')); + logWarn(`检查到 ${missingReport.length} 个接口元数据缺失,报告已写入:${MISSING_REPORT_PATH}`); + return; + } + + if (existsSync(MISSING_REPORT_PATH)) { + writeFileSync(MISSING_REPORT_PATH, ''); + } + logSuccess('所有接口元数据完整'); +} + +generateOpenAPI(); diff --git a/packages/snowcat-schema/package.json b/packages/snowcat-schema/package.json new file mode 100644 index 0000000..b7bf6dd --- /dev/null +++ b/packages/snowcat-schema/package.json @@ -0,0 +1,20 @@ +{ + "name": "snowcat-schema", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.ts", + "scripts": { + "build:openapi": "vite build && node ./dist/schemas.mjs" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.38", + "snowcat-onebot": "workspace:*", + "snowcat-common": "workspace:*", + "snowcat-vite": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.7.1", + "vite": "^6.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-schema/tsconfig.json b/packages/snowcat-schema/tsconfig.json new file mode 100644 index 0000000..2101dac --- /dev/null +++ b/packages/snowcat-schema/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-schema/vite.config.ts b/packages/snowcat-schema/vite.config.ts new file mode 100644 index 0000000..17d8d46 --- /dev/null +++ b/packages/snowcat-schema/vite.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from 'vite'; +import path, { resolve } from 'path'; +import { builtinModules } from 'module'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import napcatVersion from 'snowcat-vite/vite-plugin-version'; +// 依赖排除 +const external = [ + 'ws', + 'express', + 'electron', +]; +const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`), 'node:sqlite'].flat(); + +export default defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/snowcat-rpc': resolve(__dirname, '../snowcat-rpc'), + '@/snowcat-onebot': resolve(__dirname, '../snowcat-onebot'), + '@/snowcat-common': resolve(__dirname, '../snowcat-common'), + '@/snowcat-schema': resolve(__dirname, './src'), + '@/snowcat-core': resolve(__dirname, '../snowcat-core'), + '@/snowcat-webui-backend': resolve(__dirname, '../snowcat-webui-backend'), + '@/snowcat-image-size': resolve(__dirname, '../snowcat-image-size'), + }, + }, + plugins: [ + nodeResolve(), + napcatVersion(), + ], + build: { + target: 'esnext', + minify: false, + emptyOutDir: true, + outDir: 'dist', + lib: { + entry: path.resolve(__dirname, './index.ts'), + formats: ['es'], + fileName: () => 'schemas.mjs', + }, + rollupOptions: { + external: [ + ...nodeModules, + ...external, + ], + }, + }, +}); diff --git a/packages/snowcat-shell-loader/KillQQ.bat b/packages/snowcat-shell-loader/KillQQ.bat new file mode 100644 index 0000000..c0b4cf4 --- /dev/null +++ b/packages/snowcat-shell-loader/KillQQ.bat @@ -0,0 +1,2 @@ +@echo off +taskkill /f /im QQ.exe \ No newline at end of file diff --git a/packages/snowcat-shell-loader/NapCatWinBootHook.dll b/packages/snowcat-shell-loader/NapCatWinBootHook.dll new file mode 100644 index 0000000..ee74325 Binary files /dev/null and b/packages/snowcat-shell-loader/NapCatWinBootHook.dll differ diff --git a/packages/snowcat-shell-loader/launcher-user.bat b/packages/snowcat-shell-loader/launcher-user.bat new file mode 100644 index 0000000..f6ed9d2 --- /dev/null +++ b/packages/snowcat-shell-loader/launcher-user.bat @@ -0,0 +1,32 @@ +@echo off +chcp 65001 >nul +set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json +set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js +set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll +set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe +set NAPCAT_MAIN_PATH=%cd%\napcat.mjs +:loop_read +for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( + set "RetString=%%~b" + goto :napcat_boot +) + +:napcat_boot +for %%a in ("%RetString%") do ( + set "pathWithoutUninstall=%%~dpa" +) + +set "QQPath=%pathWithoutUninstall%QQ.exe" + +if not exist "%QQpath%" ( + echo provided QQ path is invalid + pause + exit /b +) + +set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% +echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" + +"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* + +pause diff --git a/packages/snowcat-shell-loader/launcher-win10-user.bat b/packages/snowcat-shell-loader/launcher-win10-user.bat new file mode 100644 index 0000000..6c615d1 --- /dev/null +++ b/packages/snowcat-shell-loader/launcher-win10-user.bat @@ -0,0 +1,34 @@ +@echo off +chcp 65001 >nul +set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json +set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js +set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll +set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe +set NAPCAT_MAIN_PATH=%cd%\napcat.mjs +:loop_read +for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( + set "RetString=%%~b" + goto :napcat_boot +) + +:napcat_boot +for %%a in ("%RetString%") do ( + set "pathWithoutUninstall=%%~dpa" +) + +set "QQPath=%pathWithoutUninstall%QQ.exe" + +if not exist "%QQpath%" ( + echo provided QQ path is invalid + pause + exit /b +) +set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% +echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" + +"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* + +REM Optional: -q for quick login, omit for QR code login +REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456 + +pause diff --git a/packages/snowcat-shell-loader/launcher-win10.bat b/packages/snowcat-shell-loader/launcher-win10.bat new file mode 100644 index 0000000..271c611 --- /dev/null +++ b/packages/snowcat-shell-loader/launcher-win10.bat @@ -0,0 +1,41 @@ +@echo off +chcp 65001 >nul +net session >nul 2>&1 +if %ERRORLEVEL% == 0 ( + echo Administrator mode detected. +) else ( + echo Please run this script in administrator mode. + powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs" + exit +) + +set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json +set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js +set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll +set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe +set NAPCAT_MAIN_PATH=%cd%\napcat.mjs +:loop_read +for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( + set "RetString=%%~b" + goto :napcat_boot +) + +:napcat_boot +for %%a in ("%RetString%") do ( + set "pathWithoutUninstall=%%~dpa" +) + +set "QQPath=%pathWithoutUninstall%QQ.exe" + +if not exist "%QQPath%" ( + echo provided QQ path is invalid + pause + exit /b +) +set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% +echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" + +"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* + +REM Optional: -q for quick login, omit for QR code login +REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456 diff --git a/packages/snowcat-shell-loader/launcher.bat b/packages/snowcat-shell-loader/launcher.bat new file mode 100644 index 0000000..be9dda0 --- /dev/null +++ b/packages/snowcat-shell-loader/launcher.bat @@ -0,0 +1,39 @@ +@echo off +chcp 65001 >nul +net session >nul 2>&1 +if %ERRORLEVEL% == 0 ( + echo Administrator mode detected. +) else ( + echo Please run this script in administrator mode. + powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs" + exit +) + +set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json +set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js +set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll +set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe +set NAPCAT_MAIN_PATH=%cd%\napcat.mjs +:loop_read +for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( + set "RetString=%%~b" + goto :napcat_boot +) + +:napcat_boot +for %%a in ("%RetString%") do ( + set "pathWithoutUninstall=%%~dpa" +) + +set "QQPath=%pathWithoutUninstall%QQ.exe" + +if not exist "%QQPath%" ( + echo provided QQ path is invalid + pause + exit /b +) + +set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/% +echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%" + +"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* diff --git a/packages/snowcat-shell-loader/loadNapCat.js b/packages/snowcat-shell-loader/loadNapCat.js new file mode 100644 index 0000000..25e1ed1 --- /dev/null +++ b/packages/snowcat-shell-loader/loadNapCat.js @@ -0,0 +1,5 @@ +const path = require('path'); +const CurrentPath = path.dirname(__filename); +(async () => { + await import('file://' + path.join(CurrentPath, './napcat/napcat.mjs')); +})(); diff --git a/packages/snowcat-shell-loader/qqnt.json b/packages/snowcat-shell-loader/qqnt.json new file mode 100644 index 0000000..0c5ba4a --- /dev/null +++ b/packages/snowcat-shell-loader/qqnt.json @@ -0,0 +1,25 @@ +{ + "name": "qq-chat", + "verHash": "2c9d3f6c", + "version": "9.9.22-40990", + "linuxVersion": "3.2.20-40990", + "linuxVerHash": "ec800879", + "private": true, + "description": "QQ", + "productName": "QQ", + "author": { + "name": "Tencent", + "email": "QQ-Team@tencent.com" + }, + "homepage": "https://im.qq.com", + "sideEffects": true, + "bin": { + "qd": "externals/devtools/cli/index.js" + }, + "main": "./loadNapCat.js", + "buildVersion": "40990", + "isPureShell": true, + "isByteCodeShell": true, + "platform": "win32", + "eleArch": "x64" +} \ No newline at end of file diff --git a/packages/snowcat-shell-loader/quickLoginExample.bat b/packages/snowcat-shell-loader/quickLoginExample.bat new file mode 100644 index 0000000..95c401a --- /dev/null +++ b/packages/snowcat-shell-loader/quickLoginExample.bat @@ -0,0 +1,7 @@ +@echo off +REM 快速登录示例脚本 +REM -q 参数是可选的,不传则使用二维码登录 +REM +REM 使用方法(删掉对应系统那行的 REM): +REM ./launcher-user.bat 123456 +REM ./launcher-win10-user.bat 123456 diff --git a/packages/snowcat-shell/base.ts b/packages/snowcat-shell/base.ts new file mode 100644 index 0000000..7b4399c --- /dev/null +++ b/packages/snowcat-shell/base.ts @@ -0,0 +1,885 @@ +import type { SelfInfo } from 'snowcat-core/index'; + +import { NodeIKernelLoginListener, NodeIKernelSessionListener } from 'snowcat-core/listeners'; +import { NodeIDependsAdapter, NodeIDispatcherAdapter, NodeIGlobalAdapter } from 'snowcat-core/adapters'; +import { SnowCatPathWrapper } from 'snowcat-common/src/path'; +import { + genSessionConfig, + InstanceContext, + loadQQWrapper, + SnowCatCore, + SnowCatCoreWorkingEnv, + NodeIQQNTStartupSessionWrapper, + NodeIQQNTWrapperEngine, + NodeIQQNTWrapperSession, + PlatformType, + WrapperNodeApi, + WrapperSessionInitConfig, +} from 'snowcat-core'; +import { hostname, systemVersion } from 'snowcat-common/src/system'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { createHash } from 'node:crypto'; +import { LoginListItem, NodeIKernelLoginService } from 'snowcat-core/services'; +import qrcode from 'snowcat-qrcode/lib/main'; +import { SnowCatAdapterManager } from 'snowcat-adapter'; +import { InitWebUi } from 'snowcat-webui-backend/index'; +import { WebUiDataRuntime } from 'snowcat-webui-backend/src/helper/Data'; +import { snowCatVersion } from 'snowcat-common/src/version'; +import { NodeIO3MiscListener } from 'snowcat-core/listeners/NodeIO3MiscListener'; +import { sleep } from 'snowcat-common/src/helper'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; +import { NativePacketHandler } from 'snowcat-core/packet/handler/client'; +import { Napi2NativeLoader } from 'snowcat-core/packet/handler/napi2nativeLoader'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbSvcTrpcTcpBase, OidbSvcTrpcTcp0XCDE_2RespBody } from '@/snowcat-core/packet/transformer/proto'; +import { loadSnowCatConfig } from '@/snowcat-core/helper/config'; +import { logSubscription, LogWrapper } from '@/snowcat-core/helper/log'; +import { proxiedListenerOf } from '@/snowcat-core/helper/proxy-handler'; +import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info'; +import { statusHelperSubscription } from '@/snowcat-core/helper/status'; +import { applyPendingUpdates } from '@/snowcat-webui-backend/src/api/UpdateNapCat'; +import { connectToNamedPipe } from './pipe'; + +// NapCat Shell App ES 入口文件 +async function handleUncaughtExceptions (logger: LogWrapper) { + process.on('uncaughtException', (err) => { + logger.logError('[SnowCat] [Error] Unhandled Exception:', err.message); + }); + process.on('unhandledRejection', (reason) => { + logger.logError('[SnowCat] [Error] unhandledRejection:', reason); + }); +} + +function getDataPaths (wrapper: WrapperNodeApi): [string, string] { + if (os.platform() === 'darwin') { + const userPath = os.homedir(); + const appDataPath = path.resolve(userPath, './Library/Application Support/QQ'); + return [appDataPath, path.join(appDataPath, 'global')]; + } + let dataPath = wrapper.NodeQQNTWrapperUtil.getNTUserDataInfoConfig(); + if (!dataPath) { + dataPath = path.resolve(os.homedir(), './.config/QQ'); + fs.mkdirSync(dataPath, { recursive: true }); + } + const dataPathGlobal = path.resolve(dataPath, './nt_qq/global'); + return [dataPath, dataPathGlobal]; +} + +function getPlatformType (): PlatformType { + const platformMapping: Partial> = { + win32: PlatformType.KWINDOWS, + darwin: PlatformType.KMAC, + linux: PlatformType.KLINUX, + }; + return platformMapping[os.platform()] ?? PlatformType.KWINDOWS; +} + +async function initializeEngine ( + engine: NodeIQQNTWrapperEngine, + basicInfoWrapper: QQBasicInfoWrapper, + dataPathGlobal: string, + systemPlatform: PlatformType, + systemVersion: string +) { + engine.initWithDeskTopConfig( + { + base_path_prefix: '', + platform_type: systemPlatform, + app_type: 4, + app_version: basicInfoWrapper.getFullQQVersion(), + os_version: systemVersion, + use_xlog: false, + qua: basicInfoWrapper.QQVersionQua ?? '', + global_path_config: { + desktopGlobalPath: dataPathGlobal, + }, + thumb_config: { maxSide: 324, minSide: 48, longLimit: 6, density: 2 }, + }, + new NodeIGlobalAdapter() + ); +} + +async function initializeLoginService ( + loginService: NodeIKernelLoginService, + basicInfoWrapper: QQBasicInfoWrapper, + dataPathGlobal: string, + systemVersion: string, + hostname: string +) { + loginService.initConfig({ + machineId: '', + appid: basicInfoWrapper.QQVersionAppid ?? '', + platVer: systemVersion, + commonPath: dataPathGlobal, + clientVer: basicInfoWrapper.getFullQQVersion(), + hostName: hostname, + externalVersion: false, + }); +} + +async function handleLogin ( + loginService: NodeIKernelLoginService, + logger: LogWrapper, + pathWrapper: SnowCatPathWrapper, + quickLoginUin: string | undefined, + historyLoginList: LoginListItem[] +): Promise { + const context = { isLogined: false, loginFlowInitialized: false }; + let inner_resolve: (value: SelfInfo) => void; + const selfInfo: Promise = new Promise((resolve) => { + inner_resolve = resolve; + }); + // 连接服务 + + const loginListener = new NodeIKernelLoginListener(); + loginListener.onUserLoggedIn = (userid: string) => { + const tips = `当前账号(${userid})已登录,无法重复登录`; + logger.logError(tips); + WebUiDataRuntime.setQQLoginError(tips); + }; + loginListener.onQRCodeLoginSucceed = async (loginResult) => { + context.isLogined = true; + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + if (oneBotContext?.core?.selfInfo) { + oneBotContext.core.selfInfo.online = true; + WebUiDataRuntime.setQQLoginPhase('ready'); + } else { + WebUiDataRuntime.setQQLoginPhase('initializing'); + } + inner_resolve({ + uid: loginResult.uid, + uin: loginResult.uin, + nick: '', + online: true, + }); + }; + loginListener.onLoginDisConnected = () => { + context.isLogined = false; + WebUiDataRuntime.setQQLoginStatus(false); + WebUiDataRuntime.setQQLoginPhase('offline'); + logger.logWarn('[Core] [Login] 登录服务已断开,将允许重新获取二维码'); + }; + loginListener.onLogoutSucceed = () => { + context.isLogined = false; + WebUiDataRuntime.setQQLoginStatus(false); + WebUiDataRuntime.setQQLoginPhase('offline'); + }; + loginListener.onLoginConnected = () => { + waitForNetworkConnection(loginService, logger).then(async () => { + if (!context.loginFlowInitialized) { + context.loginFlowInitialized = true; + await handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList); + return; + } + if (!context.isLogined) { + WebUiDataRuntime.setQQLoginPhase('generating_qrcode'); + const requested = loginService.getQRCodePicture(); + logger.logWarn('[Core] [Login] 登录服务重连完成,正在获取二维码', { requested, msfStatus: loginService.getMsfStatus() }); + } + }).catch(e => logger.logError(e)); + }; + loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => { + WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl); + WebUiDataRuntime.setQQLoginError(''); + WebUiDataRuntime.setQQLoginPhase('waiting_qrcode'); + + const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(realBase64, 'base64'); + logger.logWarn('请扫描下面的二维码,然后在手Q上授权登录:'); + const qrcodePath = path.join(pathWrapper.cachePath, 'qrcode.png'); + qrcode.generate(qrcodeUrl, { small: true }, (res) => { + logger.logWarn([ + '\n', + res, + '二维码解码URL: ' + qrcodeUrl, + '如果控制台二维码无法扫码,可以复制解码url到二维码生成网站生成二维码再扫码,也可以打开下方的二维码路径图片进行扫码。', + ].join('\n')); + fs.writeFile(qrcodePath, buffer, {}, () => { + logger.logWarn('二维码已保存到', qrcodePath); + }); + }); + }; + + loginListener.onQRCodeSessionUserScaned = () => { + WebUiDataRuntime.setQQLoginPhase('qrcode_scanned'); + logger.log('[Core] [Login] 二维码已被扫描,等待确认...'); + }; + + loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => { + if (!context.isLogined) { + logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode); + if (errType === 1 && errCode === 3) { + // 仅二维码过期时自动请求下一张二维码。账号被踢下线等其他错误 + // 需要由用户触发刷新,避免每两秒重复创建原生登录会话。 + WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新'); + loginService.getQRCodePicture(); + } + } + }; + + loginListener.onLoginFailed = (...args) => { + const errInfo = JSON.stringify(args); + logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo); + WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`); + }; + + loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger)); + loginService.connect(); + return await selfInfo; +} +async function handleLoginInner (context: { isLogined: boolean; loginFlowInitialized: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) { + const resolveQuickPasswordMd5 = (): string | undefined => { + const quickPasswordMd5 = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim(); + if (quickPasswordMd5) { + if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5)) { + return quickPasswordMd5.toLowerCase(); + } + logger.logError('NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)'); + } + + const quickPassword = process.env['NAPCAT_QUICK_PASSWORD']; + if (typeof quickPassword === 'string' && quickPassword.length > 0) { + logger.log('检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录'); + return createHash('md5').update(quickPassword, 'utf8').digest('hex'); + } + + return undefined; + }; + + // 注册刷新二维码回调 + WebUiDataRuntime.setRefreshQRCodeCallback(async () => { + context.isLogined = false; + const msfStatus = loginService.getMsfStatus(); + if (msfStatus === 2) { + // KickedOffLine 会在后台负责重启 Worker。这里不能重启:该回调由 + // RefreshQRcode HTTP 请求触发,重启会中断正在等待响应的请求。 + WebUiDataRuntime.setQQLoginPhase('reconnecting'); + throw new Error('QQ_LOGIN_SERVICE_RESTARTING'); + } + const requested = loginService.getQRCodePicture(); + logger.logWarn('[Core] [Login] 请求刷新二维码', { requested, msfStatus }); + if (!requested) { + throw new Error('QQ 未接受二维码刷新请求'); + } + }); + + WebUiDataRuntime.setQuickLoginCall(async (uin: string) => { + return await new Promise((resolve) => { + if (uin) { + logger.log('正在快速登录 ', uin); + loginService.quickLoginWithUin(uin).then(res => { + const quickLoginSuccess = res.result === '0' && !res.loginErrorInfo?.errMsg; + if (!quickLoginSuccess) { + const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`; + WebUiDataRuntime.setQQLoginError(errMsg); + loginService.getQRCodePicture(); + resolve({ result: false, message: errMsg }); + } else { + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError(e); + WebUiDataRuntime.setQQLoginError('快速登录发生错误'); + loginService.getQRCodePicture(); + resolve({ result: false, message: '快速登录发生错误' }); + }); + } else { + resolve({ result: false, message: '快速登录失败' }); + } + }); + }); + + // 注册密码登录回调 + WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => { + return await new Promise((resolve) => { + if (uin && passwordMd5) { + logger.log('正在密码登录 ', uin); + loginService.passwordLogin({ + uin, + passwordMd5, + step: 0, + newDeviceLoginSig: new Uint8Array(), + proofWaterSig: new Uint8Array(), + proofWaterRand: new Uint8Array(), + proofWaterSid: new Uint8Array(), + unusualDeviceCheckSig: new Uint8Array(), + }).then(res => { + if (res.result === '140022008') { + const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || ''; + logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl); + resolve({ + result: false, + message: '需要验证码', + needCaptcha: true, + proofWaterUrl, + }); + } else if (res.result === '140022010') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl); + resolve({ + result: false, + message: '新设备需要扫码验证', + needNewDevice: true, + jumpUrl, + newDevicePullQrCodeSig, + }); + } else if (res.result === '140022011') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + logger.log('异常设备需要验证, jumpUrl: ', jumpUrl); + resolve({ + result: false, + message: '异常设备需要验证', + needNewDevice: true, + jumpUrl, + newDevicePullQrCodeSig, + }); + } else if (res.result !== '0') { + const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败'; + WebUiDataRuntime.setQQLoginError(errMsg); + loginService.getQRCodePicture(); + resolve({ result: false, message: errMsg }); + } else { + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError(e); + WebUiDataRuntime.setQQLoginError('密码登录发生错误'); + loginService.getQRCodePicture(); + resolve({ result: false, message: '密码登录发生错误' }); + }); + } else { + resolve({ result: false, message: '密码登录失败:参数不完整' }); + } + }); + }); + const tryPasswordFallbackLogin = async (uin: string): Promise<{ success: boolean, attempted: boolean; }> => { + const quickPasswordMd5 = resolveQuickPasswordMd5(); + if (!quickPasswordMd5) { + logger.log(`QQ ${uin} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),将使用二维码登录方式`); + return { success: false, attempted: false }; + } + + logger.log('正在尝试密码回退登录 ', uin); + const fallbackResult = await WebUiDataRuntime.requestPasswordLogin(uin, quickPasswordMd5); + if (fallbackResult.result) { + logger.log('密码回退登录成功 ', uin); + return { success: true, attempted: true }; + } + if (fallbackResult.needCaptcha) { + const captchaTip = fallbackResult.proofWaterUrl + ? `密码回退需要验证码,请在 WebUi 中继续完成验证:${fallbackResult.proofWaterUrl}` + : '密码回退需要验证码,请在 WebUi 中继续完成验证'; + logger.logWarn(captchaTip); + WebUiDataRuntime.setQQLoginError('密码回退需要验证码,请在 WebUi 中继续完成验证'); + return { success: false, attempted: true }; + } + if (fallbackResult.needNewDevice) { + const newDeviceTip = fallbackResult.jumpUrl + ? `密码回退需要新设备验证,请在 WebUi 中继续完成验证:${fallbackResult.jumpUrl}` + : '密码回退需要新设备验证,请在 WebUi 中继续完成验证'; + logger.logWarn(newDeviceTip); + WebUiDataRuntime.setQQLoginError('密码回退需要新设备验证,请在 WebUi 中继续完成验证'); + return { success: false, attempted: true }; + } + logger.logError('密码回退登录失败:', fallbackResult.message); + return { success: false, attempted: true }; + }; + + // 注册验证码登录回调(密码登录需要验证码时的第二步) + WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => { + return await new Promise((resolve) => { + if (uin && passwordMd5 && ticket) { + logger.log('正在验证码登录 ', uin); + loginService.passwordLogin({ + uin, + passwordMd5, + step: 1, + newDeviceLoginSig: new Uint8Array(), + proofWaterSig: new TextEncoder().encode(ticket), + proofWaterRand: new TextEncoder().encode(randstr), + proofWaterSid: new TextEncoder().encode(sid), + unusualDeviceCheckSig: new Uint8Array(), + }).then(res => { + console.log('验证码登录结果: ', res); + if (res.result === '140022010') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl); + resolve({ + result: false, + message: '新设备需要扫码验证', + needNewDevice: true, + jumpUrl, + newDevicePullQrCodeSig, + }); + } else if (res.result === '140022011') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl); + resolve({ + result: false, + message: '异常设备需要验证', + needNewDevice: true, + jumpUrl, + newDevicePullQrCodeSig, + }); + } else if (res.result !== '0') { + const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败'; + WebUiDataRuntime.setQQLoginError(errMsg); + loginService.getQRCodePicture(); + resolve({ result: false, message: errMsg }); + } else { + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError(e); + WebUiDataRuntime.setQQLoginError('验证码登录发生错误'); + loginService.getQRCodePicture(); + resolve({ result: false, message: '验证码登录发生错误' }); + }); + } else { + resolve({ result: false, message: '验证码登录失败:参数不完整' }); + } + }); + }); + + // 注册新设备登录回调(密码登录需要新设备验证时的第二步) + WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => { + return await new Promise((resolve) => { + if (uin && passwordMd5 && newDevicePullQrCodeSig) { + logger.log('正在新设备验证登录 ', uin); + loginService.passwordLogin({ + uin, + passwordMd5, + step: 2, + newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig), + proofWaterSig: new Uint8Array(), + proofWaterRand: new Uint8Array(), + proofWaterSid: new Uint8Array(), + unusualDeviceCheckSig: new Uint8Array(), + }).then(res => { + if (res.result === '140022011') { + const jumpUrl = res.loginErrorInfo?.jumpUrl || ''; + const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || ''; + logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl); + resolve({ + result: false, + message: '异常设备需要验证', + needNewDevice: true, + jumpUrl, + newDevicePullQrCodeSig, + }); + } else if (res.result !== '0') { + const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败'; + WebUiDataRuntime.setQQLoginError(errMsg); + loginService.getQRCodePicture(); + resolve({ result: false, message: errMsg }); + } else { + WebUiDataRuntime.setQQLoginStatus(true); + WebUiDataRuntime.setQQLoginError(''); + resolve({ result: true, message: '' }); + } + }).catch((e) => { + logger.logError(e); + WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误'); + loginService.getQRCodePicture(); + resolve({ result: false, message: '新设备验证登录发生错误' }); + }); + } else { + resolve({ result: false, message: '新设备验证登录失败:参数不完整' }); + } + }); + }); + if (quickLoginUin) { + if (historyLoginList.some(u => u.uin === quickLoginUin)) { + logger.log('正在快速登录 ', quickLoginUin); + loginService.quickLoginWithUin(quickLoginUin) + .then(async result => { + const quickLoginSuccess = result.result === '0' && !result.loginErrorInfo?.errMsg; + if (!quickLoginSuccess) { + const errMsg = result.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${result.result}`; + logger.logError('快速登录错误:', errMsg); + WebUiDataRuntime.setQQLoginError(errMsg); + const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin); + if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture(); + } + }) + .catch(async (error) => { + logger.logError('快速登录异常:', error); + WebUiDataRuntime.setQQLoginError('快速登录发生错误'); + const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin); + if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture(); + }); + } else { + logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将尝试密码回退登录'); + const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin); + if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture(); + } + } else { + logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式'); + if (historyLoginList.length > 0) { + logger.log(`可用于快速登录 of QQ:\n${historyLoginList + .map((u, index) => `${index + 1}. ${u.uin} ${u.nickName}`) + .join('\n') + }`); + } + let hasAttemptedFallback = false; + try { + // 检查是否配置了自动登录账号,并尝试 WebUi 快速/密码回退登录。 + // 这可以避免在已经有自动登录配置的情况下过早输出二维码。 + const quickLoginList = WebUiDataRuntime.getQQQuickLoginList() || []; + const hasAutoLogin = process.env['NAPCAT_QUICK_ACCOUNT'] || (quickLoginList.length > 0); // 假如有历史登录列表 + if (hasAutoLogin || process.env['NAPCAT_QUICK_PASSWORD'] || process.env['NAPCAT_QUICK_PASSWORD_MD5']) { + hasAttemptedFallback = true; + } + await WebUiDataRuntime.runWebUiConfigQuickFunction(); + } catch (error) { + logger.logError('WebUi 快速登录失败 执行失败', error); + } + if (!hasAttemptedFallback && !context.isLogined) { + loginService.getQRCodePicture(); + } + } + + loginService.getLoginList().then((res) => { + // 遍历 res.LocalLoginInfoList[x].isQuickLogin是否可以 res.LocalLoginInfoList[x].uin 转为string 加入string[] 最后遍历完成调用WebUiDataRuntime.setQQQuickLoginList + const list = res.LocalLoginInfoList.filter((item) => item.isQuickLogin); + WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString())); + WebUiDataRuntime.setQQNewLoginList(list); + }); +} + +async function initializeSession ( + session: NodeIQQNTWrapperSession, + sessionConfig: WrapperSessionInitConfig, + startupSession: NodeIQQNTStartupSessionWrapper | null +) { + return new Promise((resolve, reject) => { + const sessionListener = new NodeIKernelSessionListener(); + sessionListener.onOpentelemetryInit = (info) => { + if (info.is_init) { + resolve(); + } else { + reject(new Error('opentelemetry init failed')); + } + }; + session.init( + sessionConfig, + new NodeIDependsAdapter(), + new NodeIDispatcherAdapter(), + sessionListener + ); + if (startupSession) { + startupSession.start(); + } else { + try { + session.startNT(0); + } catch { + try { + session.startNT(); + } catch (e: unknown) { + reject(new Error('init failed ' + (e as Error).message)); + } + } + } + }); +} +async function handleProxy (session: NodeIQQNTWrapperSession, logger: LogWrapper) { + if (process.env['NAPCAT_PROXY_PORT']) { + session.getMSFService().setNetworkProxy({ + userName: '', + userPwd: '', + address: process.env['NAPCAT_PROXY_ADDRESS'] || '127.0.0.1', + port: +process.env['NAPCAT_PROXY_PORT'], + proxyType: 2, + domain: '', + isSocket: true, + }); + logger.logWarn('已设置代理', process.env['NAPCAT_PROXY_ADDRESS'], process.env['NAPCAT_PROXY_PORT']); + } else if (process.env['NAPCAT_PROXY_CLOSE']) { + session.getMSFService().setNetworkProxy({ + userName: '', + userPwd: '', + address: '', + port: 0, + proxyType: 0, + domain: '', + isSocket: false, + }); + } +} + +async function waitForNetworkConnection (loginService: NodeIKernelLoginService, logger: LogWrapper) { + let network_ok = false; + let _tryCount = 0; + while (!network_ok) { + network_ok = loginService.getMsfStatus() !== 3;// win 11 0连接 1未连接 + logger.log('等待网络连接...'); + await sleep(500); + _tryCount++; + } + logger.log('网络已连接'); + return network_ok; +} + +export async function NCoreInitShell () { + console.log('SnowCat Shell App Loading...'); + const pathWrapper = new SnowCatPathWrapper(); + const logger = new LogWrapper(pathWrapper.logsPath); + if (process.env['NAPCAT_WORKER_PROCESS'] !== '1' && process.env['NAPCAT_DISABLE_MULTI_PROCESS'] !== '1' && process.env['NAPCAT_DISABLE_MULTIPROCESSING'] !== '1') { + logger.setFileLogEnabled(false); + } + handleUncaughtExceptions(logger); + await applyPendingUpdates(pathWrapper, logger); + + // 提前初始化 Native 模块(在登录前加载) + const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); + const nativePacketHandler = new NativePacketHandler({ logger }); + const napi2nativeLoader = new Napi2NativeLoader({ logger }); + + // 初始化 FFmpeg 服务 + await FFmpegService.init(pathWrapper.binaryPath, logger); + + if (!(process.env['NAPCAT_DISABLE_PIPE'] === '1' || process.env['NAPCAT_WORKER_PROCESS'] === '1')) { + await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); + } + const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion()); + // wrapper.node 加载后再初始化 hook,按 schema 读取配置 + const snowcatConfig = loadSnowCatConfig(pathWrapper.configPath); + await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), snowcatConfig.o3HookMode === 1); + + // 登录前监听 OidbSvcTrpcTcp.0xcde_2 数据包,获取数据库 passphrase + let dbPassphrase: string | undefined; + nativePacketHandler.onCmd('OidbSvcTrpcTcp.0xcde_2', ({ type, hex_data }) => { + if (type !== 1) return; // 仅处理接收方向 + try { + const raw = Buffer.from(hex_data, 'hex'); + const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(raw); + if (base.body && base.body.length > 0) { + const body = new NapProtoMsg(OidbSvcTrpcTcp0XCDE_2RespBody).decode(base.body); + if (body.inner?.value) { + dbPassphrase = body.inner.value; + logger.log('[SnowCat] 已启用数据库辅助支持能力'); + } + } + } catch (e) { + logger.logError('[SnowCat] [0xCDE_2] 解析失败:', e); + } + }); + + if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') { + napi2nativeLoader.nativeExports.setVerbose?.(true); + } + // wrapper.node 加载后立刻启用 Bypass(可通过环境变量禁用) + if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') { + const bypassOptions = snowcatConfig.bypass ?? {}; + napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions); + } else { + logger.log('[SnowCat] Napi2NativeLoader: Bypass已通过环境变量禁用'); + } + + const o3Service = wrapper.NodeIO3MiscService.get(); + o3Service.addO3MiscListener(new NodeIO3MiscListener()); + + logger.log('[SnowCat] [Core] SnowCat.Core Version: ' + snowCatVersion); + WebUiDataRuntime.setWorkingEnv(SnowCatCoreWorkingEnv.Shell); + InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e)); + const engine = wrapper.NodeIQQNTWrapperEngine.get(); + const loginService = wrapper.NodeIKernelLoginService.get(); + let session: NodeIQQNTWrapperSession; + let startupSession: NodeIQQNTStartupSessionWrapper | null = null; + try { + startupSession = wrapper.NodeIQQNTStartupSessionWrapper.create(); + session = wrapper.NodeIQQNTWrapperSession.getNTWrapperSession('nt_1'); + } catch (e: unknown) { + try { + session = wrapper.NodeIQQNTWrapperSession.create(); + } catch (error) { + logger.logError('创建 StartupSession 失败', e); + logger.logError('创建 Session 失败', error); + throw error; + } + } + const [dataPath, dataPathGlobal] = getDataPaths(wrapper); + WebUiDataRuntime.setQQDataPath(dataPath); + const systemPlatform = getPlatformType(); + + if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined'); + + await initializeEngine(engine, basicInfoWrapper, dataPathGlobal, systemPlatform, systemVersion); + await initializeLoginService(loginService, basicInfoWrapper, dataPathGlobal, systemVersion, hostname); + handleProxy(session, logger); + + let quickLoginUin: string | undefined; + try { + const args = process.argv; + const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq'); + if (qIndex !== -1 && qIndex + 1 < args.length) { + quickLoginUin = args[qIndex + 1]; + } + } catch (error) { + logger.logWarn('解析命令行参数失败,无法使用快速登录功能', error); + } + + const historyLoginList = (await loginService.getLoginList()).LocalLoginInfoList; + + const dataTimestape = new Date().getTime().toString(); + o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']); + + const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList); + + // 登录成功后通知 Master 进程(用于切换崩溃重试策略) + if (typeof process.send === 'function') { + process.send({ type: 'login-success' }); + logger.log('[SnowCat] 已通知主进程登录成功'); + } + + const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129'; + o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex'))); + + let guid = loginService.getMachineGuid(); + guid = guid.slice(0, 8) + '-' + guid.slice(8, 12) + '-' + guid.slice(12, 16) + '-' + guid.slice(16, 20) + '-' + guid.slice(20); + o3Service.reportAmgomWeather('login', 'a6', [dataTimestape, '184', '329']); + + const sessionConfig = await genSessionConfig( + guid, + basicInfoWrapper.QQVersionAppid, + basicInfoWrapper.getFullQQVersion(), + selfInfo.uin, + selfInfo.uid, + dataPath + ); + + await initializeSession(session, sessionConfig, startupSession); + + const accountDataPath = path.resolve(dataPath, './NapCat/data'); + // 判断dataPath是否为根目录 或者 D:/ 之类的盘目录 + if (dataPath !== '/' && /^[a-zA-Z]:\\$/.test(dataPath) === false) { + try { + fs.mkdirSync(accountDataPath, { recursive: true }); + } catch (error) { + logger.logError('创建accountDataPath失败', error); + } + } + + logger.logDebug('本账号数据/缓存目录:', accountDataPath); + + const shell = new SnowCatShell( + wrapper, + session, + logger, + selfInfo, + basicInfoWrapper, + pathWrapper, + nativePacketHandler, + napi2nativeLoader + ); + // 将捕获的 passphrase 设置到 core 上,供 DatabaseApi 使用 + if (dbPassphrase) { + shell.core.dbPassphrase = dbPassphrase; + } + await shell.InitNapCat(); +} + +export class SnowCatShell { + readonly core: SnowCatCore; + readonly context: InstanceContext; + + constructor ( + wrapper: WrapperNodeApi, + session: NodeIQQNTWrapperSession, + logger: LogWrapper, + selfInfo: SelfInfo, + basicInfoWrapper: QQBasicInfoWrapper, + pathWrapper: SnowCatPathWrapper, + packetHandler: NativePacketHandler, + napi2nativeLoader: Napi2NativeLoader + ) { + this.context = { + packetHandler, + napi2nativeLoader, + workingEnv: SnowCatCoreWorkingEnv.Shell, + wrapper, + session, + logger, + basicInfoWrapper, + pathWrapper, + }; + this.core = new SnowCatCore(this.context, selfInfo); + } + + async InitNapCat () { + await this.core.initCore(); + + // // 测试 DatabaseApi + // if (this.core.dbPassphrase) { + // const dbApi = this.core.apis.DatabaseApi; + // const sqliteOk = await dbApi.isSqliteAvailable(); + // if (sqliteOk) { + // try { + // const result = dbApi.readDatabase('nt_msg.db'); + // if (result) { + // this.context.logger.log('[SnowCat] [Database] nt_msg.db:\n' + dbApi.formatResults([result])); + // } + // } catch (e) { + // this.context.logger.logError('[SnowCat] [Database] 读取失败:', e); + // } + // } + // } + + // 监听下线通知并同步到 WebUI + let restartRequested = false; + this.core.event.on('KickedOffLine', (tips: string) => { + WebUiDataRuntime.setQQLoginStatus(false); + // 被踢后的二维码已失效,绝不能继续交给页面展示或扫码。 + WebUiDataRuntime.setQQLoginQrcodeURL(''); + WebUiDataRuntime.setQQLoginError(tips); + WebUiDataRuntime.setQQLoginPhase('reconnecting'); + + if (restartRequested) return; + restartRequested = true; + // 不从 RefreshQRcode 请求中重启 Worker;否则请求会在返回前被中断, + // 浏览器只能得到 ERR_EMPTY_RESPONSE。KickedOffLine 是独立事件, + // 在此异步调度可让前端先观察到“二维码已清空、正在恢复”的状态。 + setTimeout(() => { + WebUiDataRuntime.requestRestartProcess().then((restartResult) => { + this.context.logger.logWarn('[Core] [Login] 账号被踢下线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult); + if (!restartResult.result) { + restartRequested = false; + WebUiDataRuntime.setQQLoginPhase('offline'); + WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${restartResult.message}`); + } + }).catch((error) => { + restartRequested = false; + WebUiDataRuntime.setQQLoginPhase('offline'); + WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${(error as Error).message}`); + }); + // 常规状态轮询为 3 秒;留出一个轮询周期,确保浏览器先清除旧二维码。 + }, 3_500); + }); + // 使用 SnowCatAdapterManager 统一管理协议适配器 + const adapterManager = new SnowCatAdapterManager(this.core, this.context, this.context.pathWrapper); + await adapterManager.initAdapters() + .catch(e => this.context.logger.logError('初始化协议适配器失败', e)); + // 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用 + const oneBotAdapter = adapterManager.getOneBotAdapter(); + if (oneBotAdapter) { + WebUiDataRuntime.setOneBotContext(oneBotAdapter); + if (WebUiDataRuntime.getQQLoginStatus()) { + WebUiDataRuntime.setQQLoginPhase('ready'); + } + } + } +} diff --git a/packages/snowcat-shell/package.json b/packages/snowcat-shell/package.json new file mode 100644 index 0000000..958ecab --- /dev/null +++ b/packages/snowcat-shell/package.json @@ -0,0 +1,37 @@ +{ + "name": "snowcat-shell", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.ts", + "scripts": { + "build": "vite build", + "build:dev": "vite build --mode development", + "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" + }, + "exports": { + ".": { + "import": "./index.ts" + }, + "./*": { + "import": "./*" + } + }, + "dependencies": { + "snowcat-core": "workspace:*", + "snowcat-common": "workspace:*", + "snowcat-adapter": "workspace:*", + "snowcat-webui-backend": "workspace:*", + "snowcat-qrcode": "workspace:*", + "snowcat-protobuf": "workspace:*", + "snowcat-database": "workspace:*" + }, + "devDependencies": { + "json5": "^2.2.3", + "@types/node": "^22.0.1", + "snowcat-vite": "workspace:*" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-shell/pipe.ts b/packages/snowcat-shell/pipe.ts new file mode 100644 index 0000000..ab3b460 --- /dev/null +++ b/packages/snowcat-shell/pipe.ts @@ -0,0 +1,122 @@ +import { LogWrapper } from 'snowcat-core/helper/log'; +import * as net from 'net'; +import * as process from 'process'; +import { Writable } from 'stream'; + +/** + * 连接到命名管道并重定向stdout + * @param logger 日志记录器 + * @param timeoutMs 连接超时时间(毫秒),默认5000ms + * @returns Promise,连接成功时resolve,失败时reject + */ +export function connectToNamedPipe (logger: LogWrapper, timeoutMs: number = 5000): Promise<{ disconnect: () => void }> { + return new Promise((resolve, reject) => { + if (process.platform !== 'win32') { + // 非Windows平台不reject,而是返回一个空的disconnect函数 + return resolve({ disconnect: () => { } }); + } + + const pid = process.pid; + const pipePath = `\\\\.\\pipe\\NapCat_${pid}`; + + // 设置连接超时 + const timeoutId = setTimeout(() => { + reject(new Error(`连接命名管道超时: ${pipePath}`)); + }, timeoutMs); + + try { + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const pipeSocket = net.connect(pipePath, () => { + // 清除超时 + clearTimeout(timeoutId); + + // 优化网络性能设置 + pipeSocket.setNoDelay(true); // 减少延迟 + + // 设置更高的高水位线,允许更多数据缓冲 + + logger.log(`[StdOut] 已重定向到命名管道: ${pipePath}`); + + // 创建拥有更优雅背压处理的 Writable 流 + const pipeWritable = new Writable({ + highWaterMark: 1024 * 64, // 64KB 高水位线 + write (chunk, encoding, callback) { + if (!pipeSocket.writable) { + // 如果管道不可写,退回到原始stdout + logger.log('[StdOut] 管道不可写,回退到控制台输出'); + return originalStdoutWrite(chunk, encoding, callback); + } + + // 尝试写入数据到管道 + const canContinue = pipeSocket.write(chunk, encoding, () => { + // 数据已被发送或放入内部缓冲区 + }); + + if (canContinue) { + // 如果返回true,表示可以继续写入更多数据 + // 立即通知写入流可以继续 + process.nextTick(callback); + } else { + // 如果返回false,表示内部缓冲区已满 + // 等待drain事件再恢复写入 + pipeSocket.once('drain', () => { + callback(); + }); + } + // 明确返回true,表示写入已处理 + return true; + }, + }); + + // 重定向stdout + process.stdout.write = ( + chunk: any, + encoding?: BufferEncoding | (() => void), + cb?: () => void + ): boolean => { + if (typeof encoding === 'function') { + cb = encoding; + encoding = undefined; + } + + // 使用优化的writable流处理写入 + return pipeWritable.write(chunk, encoding as BufferEncoding, cb as () => void); + }; + + // 提供断开连接的方法 + const disconnect = () => { + process.stdout.write = originalStdoutWrite; + pipeSocket.end(); + logger.log(`已手动断开命名管道连接: ${pipePath}`); + }; + + // 返回成功和断开连接的方法 + resolve({ disconnect }); + }); + + // 管道错误处理 + pipeSocket.on('error', (err) => { + clearTimeout(timeoutId); + process.stdout.write = originalStdoutWrite; + logger.log(`连接命名管道 ${pipePath} 时出错:`, err); + reject(err); + }); + + // 管道关闭处理 + pipeSocket.on('end', () => { + process.stdout.write = originalStdoutWrite; + logger.log('命名管道连接已关闭'); + }); + + // 确保在连接意外关闭时恢复stdout + pipeSocket.on('close', () => { + process.stdout.write = originalStdoutWrite; + logger.log('命名管道连接已关闭'); + }); + } catch (error) { + clearTimeout(timeoutId); + logger.log(`尝试连接命名管道 ${pipePath} 时发生异常:`, error); + reject(error); + } + }); +} diff --git a/packages/snowcat-shell/process-api.ts b/packages/snowcat-shell/process-api.ts new file mode 100644 index 0000000..2da5da0 --- /dev/null +++ b/packages/snowcat-shell/process-api.ts @@ -0,0 +1,191 @@ +import type { Readable } from 'stream'; +import type { fork as forkType } from 'child_process'; + +// 扩展 Process 类型以支持 parentPort +declare global { + namespace NodeJS { + interface Process { + parentPort?: { + on (event: 'message', listener: (e: { data: unknown; }) => void): void; + postMessage (message: unknown): void; + }; + } + } +} + +/** + * 统一的进程接口 + */ +export interface IWorkerProcess { + readonly pid: number | undefined; + readonly stdout: Readable | null; + readonly stderr: Readable | null; + + postMessage (message: unknown): void; + kill (): boolean; + on (event: string, listener: (...args: unknown[]) => void): void; + once (event: string, listener: (...args: unknown[]) => void): void; + off (event: string, listener: (...args: unknown[]) => void): void; +} + +/** + * 进程创建选项 + */ +export interface ProcessOptions { + env: NodeJS.ProcessEnv; + stdio: 'pipe' | 'ignore' | 'inherit' | Array<'pipe' | 'ignore' | 'inherit' | 'ipc'>; + execArgv?: string[]; +} + +/** + * 进程管理器接口 + */ +export interface IProcessManager { + createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess; + onParentMessage (handler: (message: unknown) => void): void; + sendToParent (message: unknown): boolean; +} + +/** + * Electron utilityProcess 包装器 + */ +class ElectronProcessManager implements IProcessManager { + private utilityProcess: { + fork (modulePath: string, args: string[], options: unknown): unknown; + }; + + constructor (utilityProcess: { fork (modulePath: string, args: string[], options: unknown): unknown; }) { + this.utilityProcess = utilityProcess; + } + + createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess { + const child: any = this.utilityProcess.fork(modulePath, args, options); + + return { + get pid () { return child.pid as number | undefined; }, + stdout: child.stdout as Readable | null, + stderr: child.stderr as Readable | null, + + postMessage (message: unknown): void { + child.postMessage(message); + }, + + kill (): boolean { + return child.kill() as boolean; + }, + + on (event: string, listener: (...args: unknown[]) => void): void { + child.on(event, listener); + }, + + once (event: string, listener: (...args: unknown[]) => void): void { + child.once(event, listener); + }, + + off (event: string, listener: (...args: unknown[]) => void): void { + child.off(event, listener); + }, + }; + } + + onParentMessage (handler: (message: unknown) => void): void { + if (process.parentPort) { + process.parentPort.on('message', (e: { data: unknown; }) => { + handler(e.data); + }); + } + } + + sendToParent (message: unknown): boolean { + if (process.parentPort) { + process.parentPort.postMessage(message); + return true; + } + return false; + } +} + +/** + * Node.js child_process 包装器 + */ +class NodeProcessManager implements IProcessManager { + private forkFn: typeof forkType; + + constructor (forkFn: typeof forkType) { + this.forkFn = forkFn; + } + + createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess { + const child = this.forkFn(modulePath, args, options as any); + + return { + get pid () { return child.pid; }, + stdout: child.stdout, + stderr: child.stderr, + + postMessage (message: unknown): void { + if (child.send) { + child.send(message as any); + } + }, + + kill (): boolean { + return child.kill(); + }, + + on (event: string, listener: (...args: unknown[]) => void): void { + child.on(event, listener); + }, + + once (event: string, listener: (...args: unknown[]) => void): void { + child.once(event, listener); + }, + + off (event: string, listener: (...args: unknown[]) => void): void { + child.off(event, listener); + }, + }; + } + + onParentMessage (handler: (message: unknown) => void): void { + process.on('message', (message: unknown) => { + handler(message); + }); + } + + sendToParent (message: unknown): boolean { + if (process.send) { + process.send(message as any); + return true; + } + return false; + } +} + +/** + * 检测运行环境并创建对应的进程管理器 + */ +export async function createProcessManager (): Promise<{ + manager: IProcessManager; + isElectron: boolean; +}> { + const isElectron = process.env['NAPCAT_FORCE_NODE_PROCESS'] !== '1' && typeof process.versions['electron'] !== 'undefined'; + + if (isElectron) { + // @ts-ignore - electron 运行时存在但类型声明可能缺失 + const electron = await import('electron'); + if (electron.app && !electron.app.isReady()) { + await electron.app.whenReady(); + } + return { + manager: new ElectronProcessManager(electron.utilityProcess), + isElectron: true, + }; + } else { + const { fork } = await import('child_process'); + return { + manager: new NodeProcessManager(fork), + isElectron: false, + }; + } +} diff --git a/packages/snowcat-shell/rollup-single.mjs b/packages/snowcat-shell/rollup-single.mjs new file mode 100644 index 0000000..988b90c --- /dev/null +++ b/packages/snowcat-shell/rollup-single.mjs @@ -0,0 +1,15 @@ +// SnowCat: 把 vite 分块产物合成单文件 napcat.mjs(QQ utilityProcess 对多块 ESM 兼容差) +import { rollup } from 'rollup'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import fs from 'node:fs'; + +const dist = './dist'; +const input = `${dist}/napcat.mjs`; +const bundle = await rollup({ + input, + external: (id) => /^(node:|fs|path|os|url|http|https|net|crypto|zlib|stream|events|util|child_process|worker_threads|readline|assert|buffer|querystring|string_decoder|timers|tls|dns|dgram|cluster|v8|vm|inspector|module|perf_hooks|process|tty|constants)/.test(id) || id.startsWith('.') === false && !id.startsWith('/'), + plugins: [nodeResolve()], +}); +const { output } = await bundle.generate({ format: 'es', inlineDynamicImports: true }); +fs.writeFileSync(`${dist}/napcat.single.mjs`, output[0].code); +console.log('single bundle:', output[0].code.length, 'bytes'); diff --git a/packages/snowcat-shell/snowcat.ts b/packages/snowcat-shell/snowcat.ts new file mode 100644 index 0000000..5bc4764 --- /dev/null +++ b/packages/snowcat-shell/snowcat.ts @@ -0,0 +1,456 @@ +import { NCoreInitShell } from './base'; +import { SnowCatPathWrapper } from '@/snowcat-common/src/path'; +import { LogWrapper } from '@/snowcat-core/helper/log'; +import { connectToNamedPipe } from './pipe'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { AuthHelper } from '@/snowcat-webui-backend/src/helper/SignToken'; +import { webUiRuntimePort } from '@/snowcat-webui-backend/index'; +import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const pathWrapper = new SnowCatPathWrapper(); +const envPath = path.join(__dirname, 'config', '.env'); +if (fs.existsSync(envPath)) { + try { + const data = fs.readFileSync(envPath, 'utf8'); + data.split(/\r?\n/).forEach(line => { + line = line.trim(); + if (line && !line.startsWith('#')) { + const parts = line.split('='); + const key = parts[0]?.trim(); + const value = parts.slice(1).join('=').trim(); + if (key && value) { + process.env[key] = value; + } + } + }); + } catch (e) { + console.log('[SnowCat] Failed to load .env file:', e); + } +} + +// 环境变量配置 +const ENV = { + isWorkerProcess: process.env['NAPCAT_WORKER_PROCESS'] === '1', + isMultiProcessDisabled: process.env['NAPCAT_DISABLE_MULTI_PROCESS'] === '1' || process.env['NAPCAT_DISABLE_MULTIPROCESSING'] === '1', + isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1', +} as const; + +// Worker 消息类型 +interface WorkerMessage { + type: 'restart' | 'restart-prepare' | 'shutdown' | 'login-success'; + secretKey?: string; + port?: number; +} + +const logger = new LogWrapper(pathWrapper.logsPath); +if (!ENV.isWorkerProcess && !ENV.isMultiProcessDisabled) { + logger.setFileLogEnabled(false); +} + +// 进程管理器和当前 Worker 进程引用 +let processManager: IProcessManager | null = null; +let currentWorker: IWorkerProcess | null = null; +let isElectron = false; +let isRestarting = false; +let isShuttingDown = false; + +// 进程崩溃保护:记录最近的异常退出时间戳 +const recentCrashTimestamps: number[] = []; +const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口 +const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数 + +/** + * 获取进程类型名称(用于日志) + */ +function getProcessTypeName (): string { + return isElectron ? 'UtilityProcess' : 'Fork'; +} + +/** + * 获取 Worker 脚本路径 + */ +function getWorkerScriptPath (): string { + return __filename.endsWith('.mjs') + ? path.join(__dirname, 'napcat.mjs') + : path.join(__dirname, 'napcat.js'); +} + +/** + * 检查进程是否存在 + */ +function isProcessAlive (pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * 强制终止进程 + */ +function forceKillProcess (pid: number): void { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + // SIGKILL 失败,在 Windows 上使用 taskkill 兜底 + if (process.platform === 'win32') { + try { + require('child_process').execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); + } catch { + logger.logError(`[SnowCat] [Process] 强制终止进程失败: PID ${pid}`); + } + } else { + logger.logError('[SnowCat] [Process] 强制终止进程失败:', error); + } + } +} + +/** + * 清理进程树中的残留子进程(Electron 模式专用) + * 排除当前主进程和新 worker 进程 + */ +async function cleanupOrphanedProcesses (excludePids: number[]): Promise { + if (!isElectron) return; + + try { + // 使用 Electron 的 app.getAppMetrics() 获取所有相关进程 + // @ts-ignore - electron 运行时存在但类型声明可能缺失 + const electron = await import('electron'); + if (electron.app && typeof electron.app.getAppMetrics === 'function') { + const metrics = electron.app.getAppMetrics(); + const mainPid = process.pid; + + for (const metric of metrics) { + const pid = metric.pid; + // 排除主进程、新 worker 进程和明确排除的 PID + if (pid === mainPid || excludePids.includes(pid)) { + continue; + } + // 尝试终止残留进程 + try { + process.kill(pid, 'SIGTERM'); + logger.log(`[SnowCat] [Process] 已清理残留进程: PID ${pid} (${metric.type})`); + } catch { + // 进程可能已经不存在,忽略错误 + } + } + } + } catch (error) { + // Electron API 不可用或出错,静默忽略 + logger.logDebug?.('[SnowCat] [Process] 清理残留进程时出错:', error); + } +} + +/** + * 重启 Worker 进程 + */ +export async function restartWorker (secretKey?: string, port?: number): Promise { + isRestarting = true; + + if (!currentWorker) { + logger.logWarn('[SnowCat] [Process] 没有运行中的Worker进程'); + await startWorker(false); + isRestarting = false; + return; + } + + const workerPid = currentWorker.pid; + + // 1. 通知旧进程准备重启(旧进程会自行退出) + currentWorker.postMessage({ type: 'restart-prepare' }); + + // 2. 等待进程退出(最多 5 秒,给更多时间让进程自行清理) + await new Promise((resolve) => { + const timeout = setTimeout(() => { + logger.logWarn('[SnowCat] [Process] Worker进程未在 5 秒内退出,尝试发送强制关闭信号'); + currentWorker?.postMessage({ type: 'shutdown' }); + + // 再等待 2 秒 + setTimeout(() => { + logger.logWarn('[SnowCat] [Process] Worker进程仍未退出,尝试 kill'); + currentWorker?.kill(); + resolve(); + }, 2000); + }, 5000); + + currentWorker?.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + + // 3. 二次确认进程是否真的被终止(兜底检查) + if (workerPid && isProcessAlive(workerPid)) { + logger.logWarn(`[SnowCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉`); + forceKillProcess(workerPid); + await new Promise(resolve => setTimeout(resolve, 1000)); + if (isProcessAlive(workerPid)) { + logger.logError(`[SnowCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`); + } + } + + // 4. 等待后启动新进程 + await new Promise(resolve => setTimeout(resolve, 3000)); + + // 5. 启动新进程(重启模式不传递快速登录参数,传递密钥和端口) + await startWorker(false, secretKey, port); + + // 6. Electron 模式下清理可能残留的子进程 + if (isElectron && currentWorker?.pid) { + const excludePids = [process.pid, currentWorker.pid]; + await cleanupOrphanedProcesses(excludePids); + } + + isRestarting = false; +} + +/** + * 启动 Worker 进程 + * @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false + * @param secretKey WebUI JWT 密钥 + * @param preferredPort 优先使用的 WebUI 端口 + */ +async function startWorker (passQuickLogin: boolean = true, secretKey?: string, preferredPort?: number): Promise { + if (!processManager) { + throw new Error('进程管理器未初始化'); + } + + const workerScript = getWorkerScriptPath(); + const processType = getProcessTypeName(); + + // 只在首次启动时传递 -q 或 --qq 参数给 worker 进程 + const workerArgs: string[] = []; + if (passQuickLogin) { + const args = process.argv.slice(2); + const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq'); + if (qIndex !== -1 && qIndex + 1 < args.length) { + const qFlag = args[qIndex]; + const qValue = args[qIndex + 1]; + if (qFlag && qValue) { + workerArgs.push(qFlag, qValue); + } + } + } + + const child = processManager.createWorker(workerScript, workerArgs, { + env: { + ...process.env, + NAPCAT_WORKER_PROCESS: '1', + ...(secretKey ? { NAPCAT_WEBUI_JWT_SECRET_KEY: secretKey } : {}), + ...(preferredPort ? { NAPCAT_WEBUI_PREFERRED_PORT: String(preferredPort) } : {}), + }, + stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'], + // --no-sandbox 是 Chromium 开关,仅当子进程完整引导 Electron(未设 ELECTRON_RUN_AS_NODE)时 + // 才会被解析;纯 Node / Electron node 模式子进程会因 bad option 以退出码 9 拒绝启动 + // (ELECTRON_RUN_AS_NODE 变量存在即触发 node 模式,与取值无关) + ...(!isElectron && + process.versions['electron'] && + process.env['ELECTRON_RUN_AS_NODE'] === undefined + ? { execArgv: ['--no-sandbox'] } + : {}), + }); + + currentWorker = child; + + // 监听标准输出(直接转发) + if (child.stdout) { + child.stdout.on('data', (data: Buffer) => { + process.stdout.write(data); + }); + } + + // 监听标准错误(直接转发) + if (child.stderr) { + child.stderr.on('data', (data: Buffer) => { + process.stderr.write(data); + }); + } + + // 监听子进程消息 + child.on('message', (msg: unknown) => { + // 处理重启请求 + if (typeof msg === 'object' && msg !== null && 'type' in msg) { + const message = msg as WorkerMessage; + if (message.type === 'restart') { + restartWorker(message.secretKey, message.port).catch(e => { + logger.logError(`[SnowCat] [${processType}] 重启Worker进程失败:`, e); + }); + } else if (message.type === 'login-success') { + logger.log(`[SnowCat] [${processType}] Worker进程已登录成功,切换到正常重试策略`); + } + } + }); + + // 监听子进程退出 + child.on('exit', (code: unknown) => { + const exitCode = typeof code === 'number' ? code : 0; + if (exitCode !== 0) { + logger.logError(`[SnowCat] [${processType}] Worker进程退出,退出码: ${exitCode}`); + } + // 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起 + if (!isRestarting && !isShuttingDown) { + const now = Date.now(); + + // 清理超出时间窗口的崩溃记录 + while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) { + recentCrashTimestamps.shift(); + } + + // 记录本次崩溃 + recentCrashTimestamps.push(now); + + if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) { + logger.logError(`[SnowCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`); + process.exit(1); + } + + logger.logWarn(`[SnowCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`); + + startWorker(true).catch(e => { + logger.logError(`[SnowCat] [${processType}] 重新拉起Worker进程失败:`, e); + }); + } + }); + + // 等待进程成功 spawn + await new Promise((resolve, reject) => { + const onSpawn = () => { + child.off('error', onError); + resolve(); + }; + const onError = (...args: unknown[]) => { + const err = args[0] as Error; + logger.logError(`[SnowCat] [${processType}] Worker进程启动失败:`, err); + child.off('spawn', onSpawn); + reject(err); + }; + child.once('spawn', onSpawn); + child.once('error', onError); + }); +} + +/** + * 启动 Master 进程 + */ +async function startMasterProcess (): Promise { + // 连接命名管道(可通过环境变量禁用) + if (!ENV.isPipeDisabled) { + await connectToNamedPipe(logger).catch(e => + logger.logError('命名管道连接失败', e) + ); + } + + // 启动 Worker 进程 + await startWorker(); + + // 优雅关闭处理 + const shutdown = () => { + isShuttingDown = true; + if (currentWorker) { + currentWorker.postMessage({ type: 'shutdown' }); + setTimeout(() => { + currentWorker?.kill(); + process.exit(0); + }, 1000); + } else { + process.exit(0); + } + }; + + process.on('SIGINT', () => shutdown()); + process.on('SIGTERM', () => shutdown()); +} + +/** + * 启动 Worker 进程(子进程入口) + */ +async function startWorkerProcess (): Promise { + if (!processManager) { + throw new Error('进程管理器未初始化'); + } + + // 预加载 Node Addon(如果设置了环境变量) + const preloadAddonPath = process.env['NAPCAT_PRELOAD_NODE_ADDON_PATH']; + if (preloadAddonPath) { + try { + const os = await import('os'); + process.dlopen({ exports: {} }, preloadAddonPath, os.constants.dlopen.RTLD_NOW | os.constants.dlopen.RTLD_GLOBAL); + logger.log(`[SnowCat] [Worker] 已预加载 Node Addon: ${preloadAddonPath}`); + } catch (error) { + logger.logError(`[SnowCat] [Worker] 预加载 Node Addon 失败: ${preloadAddonPath}`, error); + } + } + + // 监听来自父进程的消息 + processManager.onParentMessage((msg: unknown) => { + if (typeof msg === 'object' && msg !== null && 'type' in msg) { + if (msg.type === 'restart-prepare' || msg.type === 'shutdown') { + setTimeout(() => { + process.exit(0); + }, 100); + } + } + }); + + // 注册重启进程函数到 WebUI + WebUiDataRuntime.setRestartProcessCall(async () => { + try { + const success = processManager!.sendToParent({ + type: 'restart', + secretKey: AuthHelper.getSecretKey(), + port: webUiRuntimePort, + }); + + if (success) { + return { result: true, message: '进程重启请求已发送' }; + } else { + return { result: false, message: '无法与主进程通信' }; + } + } catch (e) { + logger.logError('[SnowCat] [Process] 发送重启请求失败:', e); + return { + result: false, + message: '发送重启请求失败: ' + (e as Error).message, + }; + } + }); + + // 启动 NapCat 核心 + await NCoreInitShell(); +} + +/** + * 主入口 + */ +async function main (): Promise { + // 单进程模式:直接启动核心 + if (ENV.isMultiProcessDisabled) { + await NCoreInitShell(); + return; + } + + // 多进程模式:初始化进程管理器 + const result = await createProcessManager(); + processManager = result.manager; + isElectron = result.isElectron; + + // 根据进程类型启动 + if (ENV.isWorkerProcess) { + await startWorkerProcess(); + } else { + await startMasterProcess(); + } +} + +// 启动应用 +main().catch((e: Error) => { + logger.logError('[SnowCat] [Process] 启动失败:', e); + process.exit(1); +}); diff --git a/packages/snowcat-shell/tsconfig.json b/packages/snowcat-shell/tsconfig.json new file mode 100644 index 0000000..fd28287 --- /dev/null +++ b/packages/snowcat-shell/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": [ + "*.ts", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-shell/vite.config.ts b/packages/snowcat-shell/vite.config.ts new file mode 100644 index 0000000..846a709 --- /dev/null +++ b/packages/snowcat-shell/vite.config.ts @@ -0,0 +1,81 @@ +import cp from 'vite-plugin-cp'; +import { defineConfig, PluginOption, UserConfig } from 'vite'; +import path, { resolve } from 'path'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import { builtinModules } from 'module'; +import napcatVersion from 'snowcat-vite/vite-plugin-version.js'; +import { autoIncludeTSPlugin } from 'snowcat-vite/vite-auto-include.js'; +import react from '@vitejs/plugin-react-swc'; + +// 依赖排除 +const external = [ + 'ws', + 'express', + 'electron', +]; + +const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`), 'node:sqlite'].flat(); +const ShellBaseConfigPlugin: PluginOption[] = [ + react({ tsDecorators: true }), + autoIncludeTSPlugin({ + entries: [ + { entry: 'snowcat.ts', dir: path.resolve(__dirname, '../snowcat-core/protocol') }, + { entry: 'snowcat.ts', dir: path.resolve(__dirname, '../snowcat-onebot/action/test') }, + ], + }), + cp({ + targets: [ + { src: '../snowcat-native/', dest: 'dist/native', flatten: false }, + { src: '../snowcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false }, + { src: '../snowcat-webui-backend/src/assets/sw_template.js', dest: 'dist/static/' }, + { src: '../snowcat-core/external/napcat.json', dest: 'dist/config/' }, + { src: '../../package.json', dest: 'dist' }, + { src: '../snowcat-shell-loader', dest: 'dist' }, + ], + }), + nodeResolve(), + napcatVersion(), +]; +const ShellBaseConfig = (source_map: boolean = false) => + defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/snowcat-core': resolve(__dirname, '../snowcat-core'), + '@/snowcat-common': resolve(__dirname, '../snowcat-common'), + '@/snowcat-onebot': resolve(__dirname, '../snowcat-onebot'), + '@/snowcat-pty': resolve(__dirname, '../snowcat-pty'), + '@/snowcat-dpapi': resolve(__dirname, '../snowcat-dpapi'), + '@/snowcat-webui-backend': resolve(__dirname, '../snowcat-webui-backend'), + '@/snowcat-image-size': resolve(__dirname, '../snowcat-image-size'), + '@/snowcat-protocol': resolve(__dirname, '../snowcat-protocol'), + }, + }, + build: { + sourcemap: source_map, + target: 'esnext', + minify: source_map ? false : 'esbuild', + lib: { + // SnowCat:单文件 napcat.mjs(QQ utilityProcess 对分块 ESM 相对导入兼容差) + entry: { napcat: path.resolve(__dirname, 'snowcat.ts') }, + formats: ['es'], + fileName: () => 'napcat.mjs', + }, + rollupOptions: { + external: [...nodeModules, ...external], + output: { inlineDynamicImports: true }, + }, + }, + }); +export default defineConfig(({ mode }): UserConfig => { + if (mode === 'development') { + return { + ...ShellBaseConfig(true), + plugins: [...ShellBaseConfigPlugin], + }; + } + return { + ...ShellBaseConfig(), + plugins: [...ShellBaseConfigPlugin], + }; +}); diff --git a/packages/snowcat-test/Readme.md b/packages/snowcat-test/Readme.md new file mode 100644 index 0000000..5231bd5 --- /dev/null +++ b/packages/snowcat-test/Readme.md @@ -0,0 +1,3 @@ +# NapCat-Test +## 测试方向 +测试过程不涉及账号登录 \ No newline at end of file diff --git a/packages/snowcat-test/groupOperations.test.ts b/packages/snowcat-test/groupOperations.test.ts new file mode 100644 index 0000000..99e5ea1 --- /dev/null +++ b/packages/snowcat-test/groupOperations.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'vitest'; +import { + applyGroupManagementSettings, + createGroupDetailInfoV2Param, + createGroupManagementRequests, +} from '../snowcat-core/data/group'; +import { assertGroupManagementResults } from '../snowcat-onebot/action/group/GroupOperationHelpers'; + +describe('NTQQ group operations', () => { + test('reports the exact native setting that failed', () => { + expect(() => assertGroupManagementResults('设置群成员功能权限', [{ + setting: 'allow_member_create_group', + result: { result: 1006, errMsg: '未知错误' }, + }])).toThrow(/allow_member_create_group.*1006.*未知错误/); + expect(() => assertGroupManagementResults('设置群成员功能权限', [])) + .not.toThrow(); + }); + + test('builds the NTQQ permission masks without changing unrelated flags', async () => { + const request = createGroupDetailInfoV2Param('123456'); + expect(applyGroupManagementSettings(request, { + memberInvite: 'no_approval', + allowMemberUploadAlbum: false, + allowMemberTemporarySession: true, + allowMemberCreateGroup: false, + newMembersSeeRecentHistory: true, + })).toBe(true); + expect(request.groupCode).toBe('123456'); + expect(request.filter).toMatchObject({ + allowMemberInvite: 1, + appPrivilegeFlag: 1, + appPrivilegeMask: 1, + groupFlagExt4: 1, + groupFlagExt4Mask: 1, + }); + expect(request.modifyInfo).toMatchObject({ + allowMemberInvite: 1, + appPrivilegeFlag: 0x00108001, + appPrivilegeMask: 0x06118001, + groupFlagExt3: 0, + groupFlagExt3Mask: 0, + groupFlagExt4: 0x4, + groupFlagExt4Mask: 0x4, + }); + }); + + test('maps every member invite policy to the native app privilege value', async () => { + const expected = { + disabled: 0x04000000, + require_approval: 0, + no_approval: 0x00100000, + no_approval_under_100: 0x02000000, + } as const; + + for (const [memberInvite, privilegeFlag] of Object.entries(expected)) { + const request = createGroupDetailInfoV2Param('123456'); + applyGroupManagementSettings(request, { memberInvite: memberInvite as keyof typeof expected }); + expect(request.modifyInfo.allowMemberInvite).toBe(memberInvite === 'disabled' ? 0 : 1); + expect(request.modifyInfo.appPrivilegeFlag).toBe(privilegeFlag); + expect(request.modifyInfo.appPrivilegeMask).toBe(0x06100000); + } + }); + + test('uses NTQQ privilege operation type 8 for album, temporary-session, and create-group flags', () => { + const requests = createGroupManagementRequests('123456', { + memberInvite: 'require_approval', + allowMemberUploadAlbum: true, + allowMemberTemporarySession: true, + allowMemberCreateGroup: true, + }, 0x18001); + + expect(requests.map(request => request.operationType)).toEqual([0, 8, 8, 8]); + expect(requests.map(request => request.setting)).toEqual([ + 'member_invite', + 'allow_member_upload_album', + 'allow_member_temporary_session', + 'allow_member_create_group', + ]); + expect(requests.slice(1).map(request => request.param.modifyInfo.appPrivilegeMask)) + .toEqual([0x1, 0x10000, 0x8000]); + expect(requests[1]?.param.modifyInfo).toMatchObject({ + appPrivilegeFlag: 0x18000, + appPrivilegeMask: 0x1, + groupFlagExt3: 0, + groupFlagExt3Mask: 0, + }); + expect(requests[2]?.param.modifyInfo).toMatchObject({ + appPrivilegeFlag: 0x8000, + appPrivilegeMask: 0x10000, + groupFlagExt3: 0, + groupFlagExt3Mask: 0, + }); + expect(requests[3]?.param.modifyInfo).toMatchObject({ + appPrivilegeFlag: 0, + appPrivilegeMask: 0x8000, + groupFlagExt3: 0, + groupFlagExt3Mask: 0, + }); + }); + + test('preserves the complete privilege value when clearing Linux NTQQ permission bits', () => { + const requests = createGroupManagementRequests('123456', { + allowMemberUploadAlbum: false, + allowMemberTemporarySession: false, + allowMemberCreateGroup: false, + }, 0); + + expect(requests.map(request => request.param.modifyInfo.appPrivilegeFlag)) + .toEqual([0x1, 0x10001, 0x18001]); + expect(requests.map(request => request.param.modifyInfo.appPrivilegeMask)) + .toEqual([0x1, 0x10000, 0x8000]); + }); + + test('skips privilege requests whose effective value is already current', () => { + const requests = createGroupManagementRequests('123456', { + allowMemberUploadAlbum: true, + allowMemberTemporarySession: true, + allowMemberCreateGroup: true, + newMembersSeeRecentHistory: true, + }, 0x00100000, 0x4); + + expect(requests).toEqual([]); + }); +}); diff --git a/packages/snowcat-test/groupTodoTransformer.test.ts b/packages/snowcat-test/groupTodoTransformer.test.ts new file mode 100644 index 0000000..951ece7 --- /dev/null +++ b/packages/snowcat-test/groupTodoTransformer.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test, vi } from 'vitest'; +import { NapProtoMsg } from 'snowcat-protobuf'; +import { OidbSvcTrpcTcpBase } from '@/snowcat-core/packet/transformer/proto/oidb/OidbBase'; +import { OidbSvcTrpcTcp0XF90 } from '@/snowcat-core/packet/transformer/proto/oidb/Oidb.0xf90'; +import SetGroupTodo from '@/snowcat-core/packet/transformer/action/SetGroupTodo'; +import CompleteGroupTodo from '@/snowcat-core/packet/transformer/action/CompleteGroupTodo'; +import CancelGroupTodo from '@/snowcat-core/packet/transformer/action/CancelGroupTodo'; + +vi.mock('@/snowcat-core/packet/transformer/base', () => ({ + PacketTransformer: class PacketTransformer { + protected msgBuilder: unknown; + + constructor () { + this.msgBuilder = undefined; + } + }, + PacketBufBuilder: (str: Uint8Array) => Buffer.from(str), +})); + +const decodePacket = (data: Buffer) => { + const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(data); + const body = new NapProtoMsg(OidbSvcTrpcTcp0XF90).decode(base.body); + return { base, body }; +}; + +describe('Group todo packet builder', () => { + test('builds OIDB 0xF90 subCommand 1 for set', () => { + const packet = SetGroupTodo.build(123456, '789'); + const { base, body } = decodePacket(packet.data); + + expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_1'); + expect(base.command).toBe(0xF90); + expect(base.subCommand).toBe(1); + expect(body.groupUin).toBe(123456); + expect(body.msgSeq).toBe('789'); + }); + + test('builds OIDB 0xF90 subCommand 2 for complete', () => { + const packet = CompleteGroupTodo.build(123456, '789'); + const { base, body } = decodePacket(packet.data); + + expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_2'); + expect(base.command).toBe(0xF90); + expect(base.subCommand).toBe(2); + expect(body.groupUin).toBe(123456); + expect(body.msgSeq).toBe('789'); + }); + + test('builds OIDB 0xF90 subCommand 3 for cancel', () => { + const packet = CancelGroupTodo.build(123456, '789'); + const { base, body } = decodePacket(packet.data); + + expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_3'); + expect(base.command).toBe(0xF90); + expect(base.subCommand).toBe(3); + expect(body.groupUin).toBe(123456); + expect(body.msgSeq).toBe('789'); + }); +}); diff --git a/packages/snowcat-test/imageSize.test.ts b/packages/snowcat-test/imageSize.test.ts new file mode 100644 index 0000000..47c92dc --- /dev/null +++ b/packages/snowcat-test/imageSize.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + detectImageTypeFromBuffer, + imageSizeFromBuffer, + imageSizeFromBufferFallBack, + imageSizeFromFile, + matchMagic, + ImageType, +} from '@/snowcat-image-size/src'; + +// resource 目录路径 +const resourceDir = path.resolve(__dirname, '../snowcat-image-size/resource'); + +// 测试用的 Buffer 数据 +const testBuffers = { + // PNG 测试图片 (100x200) + png: Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]), + + // JPEG 测试图片 (320x240) + jpeg: Buffer.from([ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, + 0x4A, 0x46, 0x49, 0x46, 0x00, + 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0xFF, 0xC0, 0x00, 0x0B, 0x08, + 0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00, + ]), + + // BMP 测试图片 (640x480) + bmp: (() => { + const buf = Buffer.alloc(54); + buf.write('BM', 0); + buf.writeUInt32LE(54, 2); + buf.writeUInt32LE(0, 6); + buf.writeUInt32LE(54, 10); + buf.writeUInt32LE(40, 14); + buf.writeUInt32LE(640, 18); + buf.writeUInt32LE(480, 22); + buf.writeUInt16LE(1, 26); + buf.writeUInt16LE(24, 28); + return buf; + })(), + + // GIF87a 测试图片 (800x600) + gif87a: Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x37, 0x61, + 0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00, + ]), + + // GIF89a 测试图片 (1024x768) + gif89a: Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00, + ]), + + // WebP VP8 测试图片 (1920x1080) + webpVP8: (() => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0); + buf.writeUInt32LE(24, 4); + buf.write('WEBP', 8); + buf.write('VP8 ', 12); + buf.writeUInt32LE(14, 16); + buf.writeUInt8(0x9D, 20); + buf.writeUInt8(0x01, 21); + buf.writeUInt8(0x2A, 22); + buf.writeUInt16LE(1920 & 0x3FFF, 26); + buf.writeUInt16LE(1080 & 0x3FFF, 28); + return buf; + })(), + + // WebP VP8L 测试图片 (256x128) + webpVP8L: (() => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0); + buf.writeUInt32LE(24, 4); + buf.write('WEBP', 8); + buf.write('VP8L', 12); + buf.writeUInt32LE(10, 16); + buf.writeUInt8(0x2F, 20); + const vp8lBits = (256 - 1) | ((128 - 1) << 14); + buf.writeUInt32LE(vp8lBits, 21); + return buf; + })(), + + // WebP VP8X 测试图片 (512x384) + webpVP8X: (() => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0); + buf.writeUInt32LE(24, 4); + buf.write('WEBP', 8); + buf.write('VP8X', 12); + buf.writeUInt32LE(10, 16); + buf.writeUInt8((512 - 1) & 0xFF, 24); + buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25); + buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26); + buf.writeUInt8((384 - 1) & 0xFF, 27); + buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28); + buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29); + return buf; + })(), + + // TIFF Little Endian 测试图片 + tiffLE: Buffer.from([ + 0x49, 0x49, 0x2A, 0x00, // II + magic + 0x08, 0x00, 0x00, 0x00, // IFD offset = 8 + 0x02, 0x00, // 2 entries + // Entry 1: ImageWidth = 100 + 0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, + // Entry 2: ImageHeight = 200 + 0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00, + ]), + + // TIFF Big Endian 测试图片 + tiffBE: Buffer.from([ + 0x4D, 0x4D, 0x00, 0x2A, // MM + magic + 0x00, 0x00, 0x00, 0x08, // IFD offset = 8 + 0x00, 0x02, // 2 entries + // Entry 1: ImageWidth = 100 + 0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00, + // Entry 2: ImageHeight = 200 + 0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00, + ]), + + invalid: Buffer.from('This is not an image file'), + empty: Buffer.alloc(0), +}; + +describe('snowcat-image-size', () => { + describe('matchMagic', () => { + it('should match magic bytes at the beginning', () => { + const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true); + }); + + it('should match magic bytes at offset', () => { + const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true); + }); + + it('should return false for non-matching magic', () => { + const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false); + }); + + it('should return false for buffer too short', () => { + const buffer = Buffer.from([0x89, 0x50]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false); + }); + + it('should return false for offset beyond buffer', () => { + const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]); + expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false); + }); + }); + + describe('detectImageTypeFromBuffer', () => { + it('should detect PNG image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG); + }); + + it('should detect JPEG image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG); + }); + + it('should detect BMP image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP); + }); + + it('should detect GIF87a image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF); + }); + + it('should detect GIF89a image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF); + }); + + it('should detect WebP VP8 image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP); + }); + + it('should detect WebP VP8L image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP); + }); + + it('should detect WebP VP8X image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP); + }); + + it('should detect TIFF Little Endian image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF); + }); + + it('should detect TIFF Big Endian image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF); + }); + + it('should return UNKNOWN for invalid data', () => { + expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN); + }); + + it('should return UNKNOWN for empty buffer', () => { + expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN); + }); + }); + + describe('imageSizeFromBuffer', () => { + it('should parse PNG image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 }); + }); + + it('should parse JPEG image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 }); + }); + + it('should parse BMP image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 }); + }); + + it('should parse GIF87a image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 }); + }); + + it('should parse GIF89a image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 }); + }); + + it('should parse WebP VP8 image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 }); + }); + + it('should parse WebP VP8L image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 }); + }); + + it('should parse WebP VP8X image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 }); + }); + + it('should parse TIFF Little Endian image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 }); + }); + + it('should parse TIFF Big Endian image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 }); + }); + + it('should return undefined for invalid data', async () => { + expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined(); + }); + + it('should return undefined for empty buffer', async () => { + expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined(); + }); + }); + + describe('imageSizeFromBufferFallBack', () => { + it('should return actual size for valid image', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 }); + }); + + it('should return default fallback for invalid data', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 }); + }); + + it('should return custom fallback for invalid data', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 }); + }); + + it('should return default fallback for empty buffer', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 }); + }); + + it('should return custom fallback for empty buffer', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 }); + }); + }); + + describe('ImageType enum', () => { + it('should have correct enum values', () => { + expect(ImageType.JPEG).toBe('jpeg'); + expect(ImageType.PNG).toBe('png'); + expect(ImageType.BMP).toBe('bmp'); + expect(ImageType.GIF).toBe('gif'); + expect(ImageType.WEBP).toBe('webp'); + expect(ImageType.TIFF).toBe('tiff'); + expect(ImageType.UNKNOWN).toBe('unknown'); + }); + }); + + describe('Real image files from resource directory', () => { + it('should detect and parse test-20x20.jpg', async () => { + const filePath = path.join(resourceDir, 'test-20x20.jpg'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-20x20.png', async () => { + const filePath = path.join(resourceDir, 'test-20x20.png'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-20x20.tiff', async () => { + const filePath = path.join(resourceDir, 'test-20x20.tiff'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-20x20.webp', async () => { + const filePath = path.join(resourceDir, 'test-20x20.webp'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-490x498.gif', async () => { + const filePath = path.join(resourceDir, 'test-490x498.gif'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 490, height: 498 }); + }); + + it('should parse real images using imageSizeFromFile', async () => { + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 }); + }); + }); +}); diff --git a/packages/snowcat-test/package.json b/packages/snowcat-test/package.json new file mode 100644 index 0000000..af40feb --- /dev/null +++ b/packages/snowcat-test/package.json @@ -0,0 +1,21 @@ +{ + "name": "snowcat-test", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "vitest", + "test:ui": "vitest --ui" + }, + "devDependencies": { + "vitest": "^4.0.9" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.41", + "snowcat-core": "workspace:*", + "snowcat-image-size": "workspace:*", + "snowcat-onebot": "workspace:*", + "snowcat-rpc": "workspace:*", + "snowcat-protobuf": "workspace:*" + } +} \ No newline at end of file diff --git a/packages/snowcat-test/qqBasicInfo.test.ts b/packages/snowcat-test/qqBasicInfo.test.ts new file mode 100644 index 0000000..ced2f76 --- /dev/null +++ b/packages/snowcat-test/qqBasicInfo.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info'; + +vi.mock('@/snowcat-core/index', () => ({ getMajorPath: vi.fn() })); + +describe('QQ AppID lookup', () => { + let info: QQBasicInfoWrapper; + const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')!; + + beforeEach(() => { + Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'x64' }); + info = Object.create(QQBasicInfoWrapper.prototype); + info.context = { logger: { log: vi.fn() } } as QQBasicInfoWrapper['context']; + vi.spyOn(info, 'getFullQQVersion'); + vi.spyOn(info, 'getAppidV2ByMajor').mockReturnValue('537999999'); + }); + + afterEach(() => { + Object.defineProperty(process, 'arch', archDescriptor); + vi.restoreAllMocks(); + }); + + it.each([ + ['3.2.30-50969', 'x64', 537376344], + ['3.2.30-50969', 'arm64', 537376345], + ['3.2.32-52194', 'x64', 537379447], + ['3.2.32-52194', 'arm64', 537379448], + ['3.2.33-52892', 'x64', 537382855], + ['3.2.33-52892', 'arm64', 537382856], + ] as const)('uses the scanned AppID for Linux %s %s', (version, arch, appid) => { + Object.defineProperty(process, 'arch', { ...archDescriptor, value: arch }); + vi.mocked(info.getFullQQVersion).mockReturnValue(version); + + expect(info.getAppidV2()).toEqual({ + appid, + qua: `V1_LNX_NQ_${version.replace('-', '_')}_GW_B`, + }); + expect(info.getAppidV2ByMajor).not.toHaveBeenCalled(); + }); + + it.each([ + ['9.9.33-52230', 537379411], + ['9.9.35-52892', 537382819], + ] as const)('finds Windows %s by its actual package version', (version, appid) => { + vi.mocked(info.getFullQQVersion).mockReturnValue(version); + + expect(info.getAppidV2()).toEqual({ + appid, + qua: `V1_WIN_NQ_${version.replace('-', '_')}_GW_B`, + }); + expect(info.getAppidV2ByMajor).not.toHaveBeenCalled(); + }); + + it('preserves version-only entries when an architecture override is absent', () => { + Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'arm64' }); + vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.29-49738'); + + expect(info.getAppidV2()).toEqual({ + appid: 537355867, + qua: 'V1_LNX_NQ_3.2.29_49738_GW_B', + }); + expect(info.getAppidV2ByMajor).not.toHaveBeenCalled(); + }); + + it('still scans major.node for an unknown version', () => { + vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.99-99999'); + vi.spyOn(info, 'getQUAFallback').mockReturnValue('fallback-qua'); + + expect(info.getAppidV2()).toEqual({ appid: '537999999', qua: 'fallback-qua' }); + expect(info.getAppidV2ByMajor).toHaveBeenCalledWith('3.2.99-99999'); + }); +}); diff --git a/packages/snowcat-test/qzone.test.ts b/packages/snowcat-test/qzone.test.ts new file mode 100644 index 0000000..1bde1c8 --- /dev/null +++ b/packages/snowcat-test/qzone.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest'; +import { + buildQzoneDeleteBody, + buildQzonePublishBody, + buildQzoneUploadImageBody, + parseQzoneDeleteResponse, + parseQzonePublishResponse, + parseQzoneUploadImageResponseText, + toQzoneRichval, +} from '@/snowcat-core/data/qzone'; + +describe('Qzone data helpers', () => { + test('buildQzoneUploadImageBody encodes required fields', () => { + const body = buildQzoneUploadImageBody({ + uin: '123456', + skey: 'sk', + pskey: 'pk', + g_tk: '999', + base64: 'AAAA', + }); + const params = new URLSearchParams(body); + expect(params.get('uin')).toBe('123456'); + expect(params.get('skey')).toBe('sk'); + expect(params.get('p_skey')).toBe('pk'); + expect(params.get('picfile')).toBe('AAAA'); + expect(params.get('uploadtype')).toBe('1'); + expect(params.get('albumtype')).toBe('7'); + expect(params.get('base64')).toBe('1'); + expect(params.get('qzreferrer')).toContain('123456'); + }); + + test('parseQzoneUploadImageResponseText parses valid callback wrapper', () => { + const raw = 'frameElement.callback({"code":0,"data":{"albumid":"a1","lloc":"l1","type":"22","height":"100","width":"200"}});'; + const parsed = parseQzoneUploadImageResponseText(raw); + expect(parsed.code).toBe(0); + expect(parsed.data?.albumid).toBe('a1'); + expect(parsed.data?.lloc).toBe('l1'); + }); + + test('parseQzoneUploadImageResponseText throws on malformed response', () => { + expect(() => parseQzoneUploadImageResponseText('not a valid response')).toThrow(); + }); + + test('toQzoneRichval builds expected format', () => { + const richval = toQzoneRichval({ albumid: 'a1', lloc: 'l1', type: '22', height: '100', width: '200' }); + expect(richval).toBe(',a1,l1,l1,22,100,200,,100,200'); + }); + + test('buildQzonePublishBody without images omits richval/richtype', () => { + const body = buildQzonePublishBody({ + hostuin: '123456', + content: 'hello world', + ugcRight: 1, + }); + const params = new URLSearchParams(body); + expect(params.get('con')).toBe('hello world'); + expect(params.get('ugc_right')).toBe('1'); + expect(params.has('richval')).toBe(false); + expect(params.has('richtype')).toBe(false); + expect(params.has('allow_uins')).toBe(false); + }); + + test('buildQzonePublishBody with images and allow_uins sets richtype and who', () => { + const body = buildQzonePublishBody({ + hostuin: '123456', + content: 'with pic', + richval: ',a1,l1,l1,22,100,200,,100,200\t,a2,l2,l2,22,100,200,,100,200', + ugcRight: 16, + allowUins: '10001|10002', + }); + const params = new URLSearchParams(body); + expect(params.get('richtype')).toBe('1'); + expect(params.get('richval')).toContain('\t'); + expect(params.get('ugc_right')).toBe('16'); + expect(params.get('allow_uins')).toBe('10001|10002'); + expect(params.get('who')).toBe('1'); + }); + + test('parseQzonePublishResponse extracts tid on success', () => { + const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123' }); + expect(result.tid).toBe('tid123'); + }); + + test('parseQzonePublishResponse prefers t1_tid over tid', () => { + const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123', t1_tid: 't1tid456' }); + expect(result.tid).toBe('t1tid456'); + }); + + test('parseQzonePublishResponse throws when subcode indicates failure', () => { + expect(() => parseQzonePublishResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足'); + }); + + test('parseQzonePublishResponse throws when tid missing', () => { + expect(() => parseQzonePublishResponse({ subcode: 0 })).toThrow(); + }); + + test('buildQzoneDeleteBody encodes required fields', () => { + const body = buildQzoneDeleteBody({ hostuin: '123456', tid: 'tid123' }); + const params = new URLSearchParams(body); + expect(params.get('hostuin')).toBe('123456'); + expect(params.get('tid')).toBe('tid123'); + expect(params.get('t1_source')).toBe('1'); + expect(params.get('code_version')).toBe('1'); + expect(params.get('format')).toBe('json'); + expect(params.get('qzreferrer')).toContain('123456'); + }); + + test('parseQzoneDeleteResponse succeeds when subcode is 0', () => { + const result = parseQzoneDeleteResponse({ subcode: 0 }); + expect(result.success).toBe(true); + }); + + test('parseQzoneDeleteResponse throws when subcode indicates failure', () => { + expect(() => parseQzoneDeleteResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足'); + }); + + test('parseQzoneDeleteResponse throws with default message when subcode indicates failure without message', () => { + expect(() => parseQzoneDeleteResponse({ subcode: -200 })).toThrow('subcode=-200'); + }); +}); diff --git a/packages/snowcat-test/recallEvent.test.ts b/packages/snowcat-test/recallEvent.test.ts new file mode 100644 index 0000000..3caf17c --- /dev/null +++ b/packages/snowcat-test/recallEvent.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test, vi } from 'vitest'; +import { ChatType, NTMsgType, type RawMessage } from 'snowcat-core'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; + +vi.mock('../snowcat-webui-backend/index', () => ({ + pendingTokenToSend: undefined, +})); + +vi.mock('../snowcat-webui-backend/src/helper/Data', () => ({ + WebUiDataRuntime: {}, +})); + +vi.mock('@/snowcat-onebot/config', () => ({ + OB11ConfigLoader: class {}, +})); + +vi.mock('@/snowcat-onebot/network', () => ({ + OB11HttpClientAdapter: class {}, + OB11WebSocketClientAdapter: class {}, + OB11NetworkManager: class {}, + OB11NetworkReloadType: {}, + OB11HttpServerAdapter: class {}, + OB11WebSocketServerAdapter: class {}, +})); + +vi.mock('@/snowcat-onebot/api', () => ({ + OneBotFriendApi: class {}, + OneBotGroupApi: class {}, + OneBotMsgApi: class {}, + OneBotQuickActionApi: class {}, + OneBotUserApi: class {}, +})); + +vi.mock('@/snowcat-onebot/action', () => ({ + createActionMap: vi.fn(), +})); + +vi.mock('@/snowcat-onebot/network/adapter', () => ({ + IOB11NetworkAdapter: class {}, +})); + +vi.mock('@/snowcat-onebot/network/http-server-sse', () => ({ + OB11HttpSSEServerAdapter: class {}, +})); + +vi.mock('@/snowcat-onebot/network/plugin-manger', () => ({ + OB11PluginMangerAdapter: class {}, +})); + +vi.mock('@/snowcat-onebot/api/file', () => ({ + OneBotFileApi: class {}, +})); + +interface TestMsgListener { + onMsgRecall (chatType: ChatType, uid: string, msgSeq: string): Promise | void; + onMsgInfoListUpdate (messages: RawMessage[]): Promise | void; +} + +const friendUid = 'u_friend'; +const friendUin = '123456'; +const recalledMsgId = 'recalled-message-id'; +const recalledMsgSeq = '100'; +const botUin = '999999'; + +function createMessage (overrides: Partial): RawMessage { + return { + msgId: 'message-id', + msgRandom: '1', + msgSeq: '101', + cntSeq: '101', + chatType: ChatType.KCHATTYPEC2C, + msgType: NTMsgType.KMSGTYPEUNKNOWN, + subMsgType: 0, + sendStatus: 2, + senderUid: friendUid, + senderUin: friendUin, + peerUid: friendUid, + peerUin: friendUin, + peerName: 'friend', + sendNickName: 'friend', + sendRemarkName: 'friend', + msgTime: '1', + recallTime: '0', + elements: [], + records: [], + sourceType: 0, + isOnlineMsg: true, + ...overrides, + } as RawMessage; +} + +function createRecallGrayTip (): RawMessage { + return createMessage({ + msgId: recalledMsgId, + msgSeq: recalledMsgSeq, + msgType: NTMsgType.KMSGTYPEGRAYTIPS, + recallTime: Math.floor(Date.now() / 1000).toString(), + elements: [{ + elementType: 8, + elementId: 'recall-element', + grayTipElement: { + revokeElement: { + operatorRole: '0', + operatorUid: friendUid, + operatorNick: 'friend', + operatorRemark: '', + isSelfOperate: false, + wording: '', + }, + }, + } as unknown as RawMessage['elements'][number]], + }); +} + +function createHarness (queryResult: RawMessage[], operatorUin = '654321') { + let listener: TestMsgListener | undefined; + const emitEvent = vi.fn(async (_event: unknown): Promise => undefined); + const queryMsgsWithFilterExWithSeq = vi.fn(async () => ({ msgList: queryResult })); + const registerListen = vi.fn(); + const logger = { + logDebug: vi.fn(), + logError: vi.fn(), + logMessage: vi.fn(), + }; + const context = { + logger, + session: { + getMsgService: () => ({ + addKernelMsgListener: (registeredListener: TestMsgListener) => { + listener = registeredListener; + }, + }), + }, + }; + const core = { + selfInfo: { uin: botUin }, + apis: { + MsgApi: { queryMsgsWithFilterExWithSeq }, + UserApi: { getUinByUidV2: vi.fn(async () => operatorUin) }, + }, + eventWrapper: { registerListen }, + }; + const adapter = Object.create(NapCatOneBot11Adapter.prototype) as NapCatOneBot11Adapter; + + Object.assign(adapter, { + core, + context, + networkManager: { emitEvent }, + bootTime: Date.now() / 1000, + recallEventCache: new Map(), + reportedRecallEventCache: new Map(), + processingRecallEvents: new Map(), + }); + + (adapter as unknown as { initMsgListener (): void }).initMsgListener(); + + if (!listener) { + throw new Error('message listener was not registered'); + } + + return { adapter, listener, emitEvent, logger, queryMsgsWithFilterExWithSeq, registerListen }; +} + +describe('friend recall reporting', () => { + test('reports a recalled non-latest private message from its gray-tip update', async () => { + const botReply = createMessage({ + msgId: 'bot-reply-id', + msgSeq: '101', + senderUid: 'u_bot', + senderUin: botUin, + }); + const grayTip = createRecallGrayTip(); + const { listener, emitEvent } = createHarness([botReply]); + + await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq); + await listener.onMsgInfoListUpdate([grayTip]); + + expect(emitEvent).toHaveBeenCalledTimes(1); + expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({ + notice_type: 'friend_recall', + user_id: Number(friendUin), + })); + }); + + test('deduplicates the same recall from the gray-tip update and recall callback', async () => { + const grayTip = createRecallGrayTip(); + const { listener, emitEvent } = createHarness([grayTip]); + + await listener.onMsgInfoListUpdate([grayTip]); + await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq); + await listener.onMsgInfoListUpdate([grayTip]); + + expect(emitEvent).toHaveBeenCalledTimes(1); + }); + + test('deduplicates concurrent update and callback processing', async () => { + const grayTip = createRecallGrayTip(); + const { listener, emitEvent } = createHarness([grayTip]); + let finishEmit: (() => void) | undefined; + emitEvent.mockImplementationOnce(() => new Promise((resolve) => { + finishEmit = resolve; + })); + + const updateTask = listener.onMsgInfoListUpdate([grayTip]); + await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1)); + const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq); + + finishEmit?.(); + await Promise.all([updateTask, callbackTask]); + + expect(emitEvent).toHaveBeenCalledTimes(1); + }); + + test('uses a concurrent duplicate callback to retry a failed report', async () => { + const grayTip = createRecallGrayTip(); + const { listener, emitEvent, logger } = createHarness([grayTip]); + let failFirstEmit: (() => void) | undefined; + emitEvent + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + failFirstEmit = () => reject(new Error('network unavailable')); + })) + .mockResolvedValueOnce(undefined); + + const updateTask = listener.onMsgInfoListUpdate([grayTip]); + await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1)); + const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq); + + failFirstEmit?.(); + await Promise.all([updateTask, callbackTask]); + + expect(emitEvent).toHaveBeenCalledTimes(2); + expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error)); + }); + + test('reports one self recall without waiting for a duplicate callback', async () => { + const grayTip = createRecallGrayTip(); + const revokeElement = grayTip.elements[0]?.grayTipElement?.revokeElement; + if (!revokeElement) throw new Error('missing revoke element'); + revokeElement.isSelfOperate = true; + + const { adapter, listener, emitEvent, registerListen } = createHarness([grayTip]); + const originTimeout = setTimeout(() => undefined, 60_000); + adapter.recallEventCache.set(recalledMsgId, originTimeout); + + await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq); + await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq); + + expect(emitEvent).toHaveBeenCalledTimes(1); + expect(adapter.recallEventCache.has(recalledMsgId)).toBe(false); + expect(registerListen).not.toHaveBeenCalled(); + }); + + test('retries reporting when the first network emission fails', async () => { + const grayTip = createRecallGrayTip(); + const { listener, emitEvent, logger } = createHarness([grayTip]); + emitEvent + .mockRejectedValueOnce(new Error('network unavailable')) + .mockResolvedValueOnce(undefined); + + await listener.onMsgInfoListUpdate([grayTip]); + await listener.onMsgInfoListUpdate([grayTip]); + + expect(emitEvent).toHaveBeenCalledTimes(2); + expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error)); + }); + + test('ignores historical recall rows replayed during startup sync', async () => { + const grayTip = createRecallGrayTip(); + grayTip.recallTime = Math.floor(Date.now() / 1000 - 120).toString(); + const { listener, emitEvent } = createHarness([]); + + await listener.onMsgInfoListUpdate([grayTip]); + + expect(emitEvent).not.toHaveBeenCalled(); + }); + + test('ignores ordinary message updates', async () => { + const ordinaryMessage = createMessage({ recallTime: Math.floor(Date.now() / 1000).toString() }); + const { listener, emitEvent } = createHarness([]); + + await listener.onMsgInfoListUpdate([ordinaryMessage]); + + expect(emitEvent).not.toHaveBeenCalled(); + }); +}); + +describe('group recall reporting', () => { + test('preserves group, sender and operator fields', async () => { + const grayTip = createRecallGrayTip(); + const groupUin = '112233'; + const senderUin = '223344'; + grayTip.chatType = ChatType.KCHATTYPEGROUP; + grayTip.peerUid = groupUin; + grayTip.peerUin = groupUin; + grayTip.senderUin = senderUin; + + const { listener, emitEvent } = createHarness([], '334455'); + + await listener.onMsgInfoListUpdate([grayTip]); + + expect(emitEvent).toHaveBeenCalledTimes(1); + expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({ + notice_type: 'group_recall', + group_id: Number(groupUin), + user_id: Number(senderUin), + operator_id: 334455, + })); + }); +}); diff --git a/packages/snowcat-test/rpc.test.ts b/packages/snowcat-test/rpc.test.ts new file mode 100644 index 0000000..cfe20da --- /dev/null +++ b/packages/snowcat-test/rpc.test.ts @@ -0,0 +1,1470 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + // 简化 API + createRpcPair, + mockRemote, + createServer, + createClient, + // 底层 API + createDeepProxy, + LocalTransport, + serialize, + deserialize, + SerializedValueType, + isRpcProxy, + getProxyMeta, + RpcServer, + MessageTransport, + createMessageServerHandler, +} from '@/snowcat-rpc/src'; + +// 测试用目标对象 +interface TestObject { + name: string; + count: number; + nested: { + value: string; + deep: { + level: number; + getData (): { info: string; }; + }; + }; + items: string[]; + greet (name: string): string; + asyncGreet (name: string): Promise; + add (a: number, b: number): number; + multiply (a: number, b: number): number; + withCallback (fn: (x: number) => number): number; + asyncWithCallback (fn: (x: number) => Promise): Promise; + multiCallback ( + onSuccess: (result: string) => void, + onError: (error: Error) => void + ): void; + getObject (): { id: number; name: string; }; + createInstance: new (name: string) => { name: string; greet (): string; getName (): string; }; + getData (): Map; + getSet (): Set; + getDate (): Date; + getBuffer (): Uint8Array; + throwError (): never; + asyncThrowError (): Promise; +} + +function createTestObject (): TestObject { + return { + name: 'test', + count: 42, + nested: { + value: 'nested-value', + deep: { + level: 3, + getData () { + return { info: 'deep-info' }; + }, + }, + }, + items: ['a', 'b', 'c'], + greet (name: string) { + return `Hello, ${name}!`; + }, + async asyncGreet (name: string) { + await Promise.resolve(); + return `Async Hello, ${name}!`; + }, + add (a: number, b: number) { + return a + b; + }, + multiply (a: number, b: number) { + return a * b; + }, + withCallback (fn: (x: number) => number) { + return fn(10) * 2; + }, + async asyncWithCallback (fn: (x: number) => Promise) { + const result = await fn(5); + return result + 100; + }, + multiCallback ( + onSuccess: (result: string) => void, + onError: (error: Error) => void + ) { + try { + onSuccess('Operation completed'); + } catch (e) { + onError(e as Error); + } + }, + getObject () { + return { id: 1, name: 'object-name' }; + }, + createInstance: class TestClass { + constructor (public name: string) { } + greet () { + return `Instance: ${this.name}`; + } + + getName () { + return this.name; + } + } as unknown as new (name: string) => { name: string; greet (): string; getName (): string; }, + getData () { + return new Map([['a', 1], ['b', 2]]); + }, + getSet () { + return new Set(['x', 'y', 'z']); + }, + getDate () { + return new Date('2024-01-15T10:30:00.000Z'); + }, + getBuffer () { + return new Uint8Array([1, 2, 3, 4, 5]); + }, + throwError () { + throw new Error('Test error'); + }, + async asyncThrowError () { + await Promise.resolve(); + throw new Error('Async test error'); + }, + }; +} + +describe('snowcat-rpc RPC', () => { + describe('serialize / deserialize', () => { + it('should serialize and deserialize primitive types', () => { + // undefined + expect(deserialize(serialize(undefined))).toBeUndefined(); + + // null + expect(deserialize(serialize(null))).toBeNull(); + + // boolean + expect(deserialize(serialize(true))).toBe(true); + expect(deserialize(serialize(false))).toBe(false); + + // number + expect(deserialize(serialize(42))).toBe(42); + expect(deserialize(serialize(3.14))).toBe(3.14); + expect(deserialize(serialize(NaN))).toBeNaN(); + expect(deserialize(serialize(Infinity))).toBe(Infinity); + expect(deserialize(serialize(-Infinity))).toBe(-Infinity); + + // bigint + expect(deserialize(serialize(BigInt('9007199254740993')))).toBe(BigInt('9007199254740993')); + + // string + expect(deserialize(serialize('hello'))).toBe('hello'); + expect(deserialize(serialize(''))).toBe(''); + }); + + it('should serialize and deserialize Date', () => { + const date = new Date('2024-01-15T10:30:00.000Z'); + const result = deserialize(serialize(date)) as Date; + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toBe(date.toISOString()); + }); + + it('should serialize and deserialize RegExp', () => { + const regex = /test\d+/gi; + const result = deserialize(serialize(regex)) as RegExp; + expect(result).toBeInstanceOf(RegExp); + expect(result.source).toBe(regex.source); + expect(result.flags).toBe(regex.flags); + }); + + it('should serialize and deserialize Error', () => { + const error = new Error('test error'); + const result = deserialize(serialize(error)) as Error; + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('test error'); + }); + + it('should serialize and deserialize arrays', () => { + const arr = [1, 'two', { three: 3 }, [4, 5]]; + const result = deserialize(serialize(arr)); + expect(result).toEqual(arr); + }); + + it('should serialize and deserialize objects', () => { + const obj = { + name: 'test', + count: 42, + nested: { + value: 'nested', + }, + }; + const result = deserialize(serialize(obj)); + expect(result).toEqual(obj); + }); + + it('should serialize and deserialize Map', () => { + const map = new Map([['a', 1], ['b', 2]]); + const result = deserialize(serialize(map)) as Map; + expect(result).toBeInstanceOf(Map); + expect(result.get('a')).toBe(1); + expect(result.get('b')).toBe(2); + }); + + it('should serialize and deserialize Set', () => { + const set = new Set(['x', 'y', 'z']); + const result = deserialize(serialize(set)) as Set; + expect(result).toBeInstanceOf(Set); + expect(result.has('x')).toBe(true); + expect(result.has('y')).toBe(true); + expect(result.has('z')).toBe(true); + }); + + it('should serialize and deserialize Uint8Array', () => { + const buffer = new Uint8Array([1, 2, 3, 4, 5]); + const result = deserialize(serialize(buffer)) as Uint8Array; + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]); + }); + + it('should handle circular references gracefully', () => { + const obj: { self?: unknown; } = {}; + obj.self = obj; + // 应该不抛出错误 + const serialized = serialize(obj); + expect(serialized.type).toBe(SerializedValueType.OBJECT); + }); + }); + + describe('LocalTransport + createDeepProxy', () => { + let target: TestObject; + let transport: LocalTransport; + let proxy: TestObject; + + beforeEach(() => { + target = createTestObject(); + transport = new LocalTransport(target); + proxy = createDeepProxy({ transport }); + }); + + it('should identify RPC proxy', () => { + expect(isRpcProxy(proxy)).toBe(true); + expect(isRpcProxy({})).toBe(false); + expect(isRpcProxy(null)).toBe(false); + }); + + it('should get proxy metadata', () => { + const meta = getProxyMeta(proxy); + expect(meta).toBeDefined(); + expect(meta?.isProxy).toBe(true); + expect(meta?.path).toEqual([]); + + const nestedMeta = getProxyMeta(proxy.nested); + expect(nestedMeta?.path).toEqual(['nested']); + }); + + describe('property access', () => { + it('should access nested properties through method call', async () => { + // 通过方法调用获取嵌套属性 + const result = await proxy.nested.deep.getData(); + expect(result).toEqual({ info: 'deep-info' }); + }); + }); + + describe('method calls', () => { + it('should call synchronous methods', async () => { + const result = await proxy.greet('World'); + expect(result).toBe('Hello, World!'); + }); + + it('should call methods with multiple arguments', async () => { + const sum = await proxy.add(5, 3); + expect(sum).toBe(8); + + const product = await proxy.multiply(4, 7); + expect(product).toBe(28); + }); + + it('should call async methods', async () => { + const result = await proxy.asyncGreet('Async'); + expect(result).toBe('Async Hello, Async!'); + }); + + it('should call deeply nested methods', async () => { + const result = await proxy.nested.deep.getData(); + expect(result).toEqual({ info: 'deep-info' }); + }); + }); + + describe('callback proxying', () => { + it('should proxy callbacks to remote (async pattern)', async () => { + // RPC 回调本质上是异步的,因此服务端必须 await 回调结果 + // 测试 asyncWithCallback 场景(服务端已经 await 回调) + const callback = vi.fn(async (x: number) => x * 3); + const result = await proxy.asyncWithCallback(callback); + + expect(callback).toHaveBeenCalledWith(5); + expect(result).toBe(115); // (5 * 3) + 100 + }); + + it('should proxy async callbacks', async () => { + const callback = vi.fn(async (x: number) => { + await Promise.resolve(); + return x * 4; + }); + const result = await proxy.asyncWithCallback(callback); + + expect(callback).toHaveBeenCalledWith(5); + expect(result).toBe(120); // (5 * 4) + 100 + }); + + it('should handle multiple callbacks', async () => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + + await proxy.multiCallback(onSuccess, onError); + + expect(onSuccess).toHaveBeenCalledWith('Operation completed'); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('return value types', () => { + it('should handle object return values', async () => { + const result = await proxy.getObject(); + expect(result).toEqual({ id: 1, name: 'object-name' }); + }); + + it('should handle Map return values', async () => { + const result = await proxy.getData(); + expect(result).toBeInstanceOf(Map); + expect((result as Map).get('a')).toBe(1); + }); + + it('should handle Set return values', async () => { + const result = await proxy.getSet(); + expect(result).toBeInstanceOf(Set); + expect((result as Set).has('x')).toBe(true); + }); + + it('should handle Date return values', async () => { + const result = await proxy.getDate(); + expect(result).toBeInstanceOf(Date); + expect((result as Date).toISOString()).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should handle Uint8Array return values', async () => { + const result = await proxy.getBuffer(); + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result as Uint8Array)).toEqual([1, 2, 3, 4, 5]); + }); + }); + + describe('error handling', () => { + it('should propagate sync errors', async () => { + await expect(proxy.throwError()).rejects.toThrow('Test error'); + }); + + it('should propagate async errors', async () => { + await expect(proxy.asyncThrowError()).rejects.toThrow('Async test error'); + }); + }); + + describe('constructor proxying', () => { + it('should proxy constructor calls', async () => { + const Constructor = proxy.createInstance; + const instance = await new Constructor('TestInstance'); + // 返回的是代理对象,需要 await 获取属性值 + expect(await instance.getName()).toBe('TestInstance'); + }); + }); + + describe('property modification', () => { + it('should set properties through proxy', async () => { + proxy.count = 100; + // 等待一下让异步操作完成 + await new Promise(resolve => setTimeout(resolve, 10)); + expect(target.count).toBe(100); + }); + + it('should set nested properties', async () => { + proxy.nested.value = 'modified'; + await new Promise(resolve => setTimeout(resolve, 10)); + expect(target.nested.value).toBe('modified'); + }); + }); + + describe('delete property', () => { + it('should delete properties through proxy', async () => { + (target as { deletable?: string; }).deletable = 'will be deleted'; + delete (proxy as { deletable?: string; }).deletable; + await new Promise(resolve => setTimeout(resolve, 10)); + expect((target as { deletable?: string; }).deletable).toBeUndefined(); + }); + }); + }); + + describe('RpcServer', () => { + it('should handle GET requests', async () => { + const target = { name: 'test', nested: { value: 123 } }; + const server = new RpcServer({ target }); + + const response = await server.handleRequest({ + id: 'req1', + type: 'get' as never, + path: ['name'], + }); + + expect(response.success).toBe(true); + expect(deserialize(response.result!)).toBe('test'); + }); + + it('should handle SET requests', async () => { + const target = { name: 'test' }; + const server = new RpcServer({ target }); + + const response = await server.handleRequest({ + id: 'req1', + type: 'set' as never, + path: ['name'], + args: [serialize('modified')], + }); + + expect(response.success).toBe(true); + expect(target.name).toBe('modified'); + }); + + it('should handle APPLY requests', async () => { + const target = { + add: (a: number, b: number) => a + b, + }; + const server = new RpcServer({ target }); + + const response = await server.handleRequest({ + id: 'req1', + type: 'apply' as never, + path: ['add'], + args: [serialize(3), serialize(4)], + }); + + expect(response.success).toBe(true); + expect(deserialize(response.result!)).toBe(7); + }); + + it('should handle errors gracefully', async () => { + const target = { + fail: () => { + throw new Error('Intentional failure'); + }, + }; + const server = new RpcServer({ target }); + + const response = await server.handleRequest({ + id: 'req1', + type: 'apply' as never, + path: ['fail'], + args: [], + }); + + expect(response.success).toBe(false); + expect(response.error).toBe('Intentional failure'); + }); + }); + + describe('MessageTransport', () => { + it('should communicate via message channel', async () => { + const target = { + echo: (msg: string) => `Echo: ${msg}`, + add: (a: number, b: number) => a + b, + }; + + // 模拟消息通道 + type MessageHandler = (message: string) => void; + let clientHandler: MessageHandler = () => { }; + let serverHandler: MessageHandler = () => { }; + + // 创建服务端处理器 + createMessageServerHandler(target, { + sendMessage: (msg) => { + setTimeout(() => clientHandler(msg), 0); + }, + onMessage: (handler) => { + serverHandler = handler; + }, + }); + + // 创建客户端传输层 + const transport = new MessageTransport({ + sendMessage: (msg) => { + setTimeout(() => serverHandler(msg), 0); + }, + onMessage: (handler) => { + clientHandler = handler; + }, + }); + + const proxy = createDeepProxy({ transport }); + + // 测试调用 + const echoResult = await proxy.echo('Hello'); + expect(echoResult).toBe('Echo: Hello'); + + const addResult = await proxy.add(10, 20); + expect(addResult).toBe(30); + + transport.close(); + }); + }); + + describe('complex scenarios', () => { + it('should handle chained method calls with await', async () => { + const target = { + calculator: { + add: (a: number, b: number) => a + b, + multiply: (a: number, b: number) => a * b, + }, + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + // RPC 场景中,可以通过嵌套路径直接调用方法 + const sum = await proxy.calculator.add(5, 3); + expect(sum).toBe(8); + + const product = await proxy.calculator.multiply(4, 5); + expect(product).toBe(20); + }); + + it('should handle arrays with objects', async () => { + const target = { + getUsers: () => [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + const users = await proxy.getUsers(); + expect(users).toHaveLength(2); + expect(users[0]).toEqual({ id: 1, name: 'Alice' }); + expect(users[1]).toEqual({ id: 2, name: 'Bob' }); + }); + + it('should handle async callbacks that return objects', async () => { + const target = { + transformAsync: async (items: number[], fn: (item: number) => Promise<{ doubled: number; }>) => { + const results: { doubled: number; }[] = []; + for (const item of items) { + results.push(await fn(item)); + } + return results; + }, + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + const callback = vi.fn(async (x: number) => ({ doubled: x * 2 })); + const result = await proxy.transformAsync([1, 2, 3], callback); + + expect(callback).toHaveBeenCalledTimes(3); + expect(result).toEqual([ + { doubled: 2 }, + { doubled: 4 }, + { doubled: 6 }, + ]); + }); + + it('should handle nested callback with Promise', async () => { + const target = { + processWithCallback: async ( + value: number, + processor: (x: number) => Promise + ): Promise => { + const result = await processor(value); + return result * 2; + }, + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + const callback = vi.fn(async (x: number) => x + 10); + const result = await proxy.processWithCallback(5, callback); + + expect(callback).toHaveBeenCalledWith(5); + expect(result).toBe(30); // (5 + 10) * 2 + }); + + it('should handle Promise-returning callbacks', async () => { + const target = { + processAsync: async ( + data: number[], + processor: (item: number) => Promise + ): Promise => { + const results: number[] = []; + for (const item of data) { + results.push(await processor(item)); + } + return results; + }, + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + const processor = vi.fn(async (x: number) => { + await new Promise(resolve => setTimeout(resolve, 1)); + return x * 10; + }); + + const result = await proxy.processAsync([1, 2, 3], processor); + // 数组返回代理,需 await 获取元素 + expect(await result[0]).toBe(10); + expect(await result[1]).toBe(20); + expect(await result[2]).toBe(30); + expect(processor).toHaveBeenCalledTimes(3); + }); + + it('should maintain this context in methods', async () => { + const target = { + value: 100, + getValue () { + return this.value; + }, + double () { + return this.value * 2; + }, + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + expect(await proxy.getValue()).toBe(100); + expect(await proxy.double()).toBe(200); + }); + + it('should handle Symbol properties', async () => { + const sym = Symbol('test'); + const target = { + [sym]: 'symbol-value', + getSymbolValue () { + return this[sym]; + }, + }; + + const transport = new LocalTransport(target); + const proxy = createDeepProxy({ transport }); + + const result = await proxy.getSymbolValue(); + expect(result).toBe('symbol-value'); + }); + }); + + // ========== 简化 API 测试 ========== + describe('Easy API - createRpcPair', () => { + it('should create isolated client/server pair', async () => { + const { client, server } = createRpcPair({ + counter: 0, + increment () { + return ++this.counter; + }, + getCounter () { + return this.counter; + }, + }); + + // client 端操作会影响 server 端 + expect(await client.increment()).toBe(1); + expect(await client.increment()).toBe(2); + expect(server.counter).toBe(2); + expect(await client.getCounter()).toBe(2); + }); + + it('should support nested objects', async () => { + const { client } = createRpcPair({ + user: { + profile: { + name: 'Alice', + age: 25, + getInfo () { + return `${this.name}, ${this.age}`; + }, + }, + }, + }); + + const info = await client.user.profile.getInfo(); + expect(info).toBe('Alice, 25'); + }); + + it('should support .register({ cb1, cb2 }) pattern', async () => { + const results: string[] = []; + + const { client } = createRpcPair({ + async register (handlers: { + onConnect: () => Promise; + onMessage: (msg: string) => Promise; + onDisconnect: (reason: string) => Promise; + }) { + await handlers.onConnect(); + await handlers.onMessage('Hello'); + await handlers.onMessage('World'); + await handlers.onDisconnect('bye'); + return 'registered'; + }, + }); + + const result = await client.register({ + onConnect: async () => { + results.push('connected'); + }, + onMessage: async (msg) => { + results.push(`msg:${msg}`); + }, + onDisconnect: async (reason) => { + results.push(`disconnected:${reason}`); + }, + }); + + expect(result).toBe('registered'); + expect(results).toEqual([ + 'connected', + 'msg:Hello', + 'msg:World', + 'disconnected:bye', + ]); + }); + + it('should support complex callback objects with return values', async () => { + const { client } = createRpcPair({ + async process (handlers: { + transform: (x: number) => Promise; + validate: (x: number) => Promise; + format: (x: number) => Promise; + }) { + const values: string[] = []; + for (const num of [1, 2, 3, 4, 5]) { + const transformed = await handlers.transform(num); + if (await handlers.validate(transformed)) { + values.push(await handlers.format(transformed)); + } + } + return values; + }, + }); + + const result = await client.process({ + transform: async (x) => x * 2, + validate: async (x) => x > 5, + format: async (x) => `value:${x}`, + }); + + // 数组返回代理 + expect(await result[0]).toBe('value:6'); + expect(await result[1]).toBe('value:8'); + expect(await result[2]).toBe('value:10'); + }); + }); + + describe('Easy API - mockRemote', () => { + it('should create remote-like proxy easily', async () => { + const api = mockRemote({ + add: (a: number, b: number) => a + b, + multiply: (a: number, b: number) => a * b, + }); + + expect(await api.add(2, 3)).toBe(5); + expect(await api.multiply(4, 5)).toBe(20); + }); + + it('should handle async methods', async () => { + const api = mockRemote({ + async fetchUser (id: number) { + return { id, name: `User${id}` }; + }, + async delay (ms: number) { + await new Promise(resolve => setTimeout(resolve, ms)); + return 'done'; + }, + }); + + const user = await api.fetchUser(123); + // 对象返回代理,属性已缓存,可直接访问 + expect(user.id).toBe(123); + expect(user.name).toBe('User123'); + }); + }); + + describe('Easy API - createServer/createClient', () => { + it('should create server and connect clients', async () => { + const server = createServer({ + data: [] as string[], + add (item: string) { + this.data.push(item); + return this.data.length; + }, + getAll () { + return [...this.data]; + }, + }); + + const client1 = createClient(server.getTransport()); + const client2 = createClient(server.getTransport()); + + await client1.add('from-client1'); + await client2.add('from-client2'); + + // 数组返回代理 + const list1 = await client1.getAll(); + const list2 = await client2.getAll(); + expect(await list1[0]).toBe('from-client1'); + expect(await list1[1]).toBe('from-client2'); + expect(await list2[0]).toBe('from-client1'); + expect(await list2[1]).toBe('from-client2'); + }); + }); + + // ========== 多层调用场景 ========== + describe('Multi-level deep proxy operations', () => { + it('should handle 5+ levels of nesting', async () => { + const { client } = createRpcPair({ + level1: { + level2: { + level3: { + level4: { + level5: { + getValue () { + return 'deep-value'; + }, + async getAsyncValue () { + return 'async-deep-value'; + }, + }, + }, + }, + }, + }, + }); + + expect(await client.level1.level2.level3.level4.level5.getValue()).toBe('deep-value'); + expect(await client.level1.level2.level3.level4.level5.getAsyncValue()).toBe('async-deep-value'); + }); + + it('should handle array of objects with data', async () => { + const { client } = createRpcPair({ + getItems () { + return [ + { id: 1, name: 'Item1', active: true }, + { id: 2, name: 'Item2', active: false }, + { id: 3, name: 'Item3', active: true }, + ]; + }, + getItemById (id: number) { + const items = [ + { id: 1, name: 'Item1' }, + { id: 2, name: 'Item2' }, + ]; + return items.find(item => item.id === id); + }, + }); + + const items = await client.getItems(); + // 数组直接序列化,元素可直接访问 + expect(items[0]!.id).toBe(1); + expect(items[0]!.name).toBe('Item1'); + expect(items[1]!.id).toBe(2); + + const item = await client.getItemById(2); + // 普通对象(无方法)直接序列化 + expect(item!.id).toBe(2); + expect(item!.name).toBe('Item2'); + }); + + it('should handle Map and Set in nested structures', async () => { + const { client } = createRpcPair({ + storage: { + getMap () { + return new Map([ + ['key1', { value: 1 }], + ['key2', { value: 2 }], + ]); + }, + getSet () { + return new Set([1, 2, 3, 4, 5]); + }, + }, + }); + + const map = await client.storage.getMap(); + expect(map).toBeInstanceOf(Map); + expect(map.get('key1')).toEqual({ value: 1 }); + + const set = await client.storage.getSet(); + expect(set).toBeInstanceOf(Set); + expect(set.has(3)).toBe(true); + }); + + it('should handle event emitter pattern', async () => { + const events: string[] = []; + + const { client } = createRpcPair({ + eventEmitter: { + async on (event: string, handler: (data: string) => Promise) { + // 模拟事件触发 + await handler(`${event}:data1`); + await handler(`${event}:data2`); + return () => { /* unsubscribe */ }; + }, + }, + }); + + await client.eventEmitter.on('message', async (data) => { + events.push(data); + }); + + expect(events).toEqual(['message:data1', 'message:data2']); + }); + + it('should handle Promise chain in callbacks', async () => { + const { client } = createRpcPair({ + async pipeline ( + input: number, + stages: { + step1: (x: number) => Promise; + step2: (x: number) => Promise; + step3: (x: number) => Promise; + } + ) { + const r1 = await stages.step1(input); + const r2 = await stages.step2(r1); + const r3 = await stages.step3(r2); + return r3; + }, + }); + + const result = await client.pipeline(5, { + step1: async (x) => x * 2, // 10 + step2: async (x) => x + 3, // 13 + step3: async (x) => `result:${x}`, // 'result:13' + }); + + expect(result).toBe('result:13'); + }); + + it('should handle mixed sync/async handlers in object', async () => { + const logs: string[] = []; + + const { client } = createRpcPair({ + async execute (handlers: { + beforeStart: () => Promise; + onProgress: (percent: number) => Promise; + afterComplete: () => Promise; + }) { + await handlers.beforeStart(); + await handlers.onProgress(25); + await handlers.onProgress(50); + await handlers.onProgress(75); + await handlers.onProgress(100); + return await handlers.afterComplete(); + }, + }); + + const result = await client.execute({ + beforeStart: async () => { + logs.push('started'); + }, + onProgress: async (percent) => { + logs.push(`progress:${percent}%`); + }, + afterComplete: async () => { + logs.push('completed'); + return 'success'; + }, + }); + + expect(result).toBe('success'); + expect(logs).toEqual([ + 'started', + 'progress:25%', + 'progress:50%', + 'progress:75%', + 'progress:100%', + 'completed', + ]); + }); + }); + + // ========== 边界情况 ========== + describe('Edge cases', () => { + it('should handle undefined and null in callbacks', async () => { + const { client } = createRpcPair({ + async process (handler: (val: unknown) => Promise) { + const results: unknown[] = []; + results.push(await handler(undefined)); + results.push(await handler(null)); + results.push(await handler(0)); + results.push(await handler('')); + results.push(await handler(false)); + return results; + }, + }); + + const result = await client.process(async (val) => { + if (val === undefined) return 'undefined'; + if (val === null) return 'null'; + if (val === 0) return 'zero'; + if (val === '') return 'empty'; + if (val === false) return 'false'; + return 'other'; + }); + + // 数组返回代理 + expect(await result[0]).toBe('undefined'); + expect(await result[1]).toBe('null'); + expect(await result[2]).toBe('zero'); + expect(await result[3]).toBe('empty'); + expect(await result[4]).toBe('false'); + }); + + it('should handle errors in callbacks', async () => { + const { client } = createRpcPair({ + async executeWithErrorHandler ( + action: () => Promise, + onError: (err: Error) => Promise + ) { + try { + return await action(); + } catch (e) { + return await onError(e as Error); + } + }, + }); + + const result = await client.executeWithErrorHandler( + async () => { + throw new Error('action failed'); + }, + async (err) => { + return `caught: ${err.message}`; + } + ); + + expect(result).toBe('caught: action failed'); + }); + + it('should handle large data transfer', async () => { + const { client } = createRpcPair({ + processLargeArray (arr: number[]) { + return arr.reduce((a, b) => a + b, 0); + }, + }); + + const largeArray = Array.from({ length: 10000 }, (_, i) => i); + const sum = await client.processLargeArray(largeArray); + expect(sum).toBe(49995000); // sum of 0 to 9999 + }); + }); + + describe('Deep return value proxying (class instances)', () => { + it('should keep class instance as proxy and call methods remotely', async () => { + class User { + constructor (public name: string, public age: number) { } + + greet () { + return `Hi, I am ${this.name}`; + } + + getInfo () { + return { name: this.name, age: this.age }; + } + + updateAge (newAge: number) { + this.age = newAge; + return this.age; + } + } + + const { client } = createRpcPair({ + createUser (name: string, age: number) { + return new User(name, age); + }, + }); + + const user = await client.createUser('Alice', 25); + + // user 应该是代理,调用方法时发送 RPC 请求 + const greeting = await user.greet(); + expect(greeting).toBe('Hi, I am Alice'); + + const info = await user.getInfo(); + // getInfo 返回普通对象,直接序列化,属性可直接访问 + expect(info.name).toBe('Alice'); + expect(info.age).toBe(25); + + // 更新远程对象状态 + const newAge = await user.updateAge(30); + expect(newAge).toBe(30); + + // 验证状态被更新 + const updatedInfo = await user.getInfo(); + expect(updatedInfo.age).toBe(30); + }); + + it('should support chained method calls on returned class instance', async () => { + class Counter { + value = 0; + + increment () { + this.value++; + return this; + } + + decrement () { + this.value--; + return this; + } + + getValue () { + return this.value; + } + } + + const { client } = createRpcPair({ + createCounter () { + return new Counter(); + }, + }); + + const counter = await client.createCounter(); + + // 链式调用 + await counter.increment(); + await counter.increment(); + await counter.increment(); + await counter.decrement(); + + const value = await counter.getValue(); + expect(value).toBe(2); + }); + + it('should handle nested class instances', async () => { + class Address { + constructor (public city: string, public country: string) { } + + getFullAddress () { + return `${this.city}, ${this.country}`; + } + } + + class Person { + constructor (public name: string, public address: Address) { } + + getAddress () { + return this.address; + } + + getFormattedInfo () { + return `${this.name} lives in ${this.address.city}`; + } + } + + const { client } = createRpcPair({ + createPerson (name: string, city: string, country: string) { + return new Person(name, new Address(city, country)); + }, + }); + + const person = await client.createPerson('Bob', 'Tokyo', 'Japan'); + + const info = await person.getFormattedInfo(); + expect(info).toBe('Bob lives in Tokyo'); + + // 获取嵌套对象(如果 Address 也是类实例,也应该是代理) + const address = await person.getAddress(); + const fullAddress = await address.getFullAddress(); + expect(fullAddress).toBe('Tokyo, Japan'); + }); + + it('should handle objects with methods (not class instances)', async () => { + // 这种对象有方法,应该也返回代理 + const { client } = createRpcPair({ + createApi () { + let data = 'initial'; + return { + getData () { + return data; + }, + setData (newData: string) { + data = newData; + }, + processData (transformer: (d: string) => string) { + data = transformer(data); + return data; + }, + }; + }, + }); + + const api = await client.createApi(); + + const initial = await api.getData(); + expect(initial).toBe('initial'); + + await api.setData('updated'); + const updated = await api.getData(); + expect(updated).toBe('updated'); + + // 测试回调 + const result = await api.processData((d: string) => d.toUpperCase()); + expect(result).toBe('UPDATED'); + }); + + it('should proxy simple objects and allow property access', async () => { + // 简单对象(无方法)直接序列化,属性可直接访问 + const { client } = createRpcPair({ + getSimpleData () { + return { id: 1, name: 'test', active: true }; + }, + }); + + const data = await client.getSimpleData(); + // 普通对象直接序列化,属性可直接访问 + expect(data.id).toBe(1); + expect(data.name).toBe('test'); + expect(data.active).toBe(true); + }); + + it('should handle async methods in returned class', async () => { + class AsyncService { + private value = 0; + + async fetchAndAdd (amount: number) { + await new Promise(resolve => setTimeout(resolve, 10)); + this.value += amount; + return this.value; + } + + async getValue () { + await new Promise(resolve => setTimeout(resolve, 5)); + return this.value; + } + } + + const { client } = createRpcPair({ + createService () { + return new AsyncService(); + }, + }); + + const service = await client.createService(); + + const first = await service.fetchAndAdd(10); + expect(first).toBe(10); + + const second = await service.fetchAndAdd(5); + expect(second).toBe(15); + + const current = await service.getValue(); + expect(current).toBe(15); + }); + + it('should handle constructor on returned proxy', async () => { + class Factory { + create (name: string): { name: string; } { + return { name }; + } + + Widget = class Widget { + constructor (public id: number) { } + getId () { + return this.id; + } + }; + } + + const { client } = createRpcPair({ + getFactory () { + return new Factory(); + }, + }); + + const factory = await client.getFactory(); + + // 调用返回代理上的方法,返回带方法的对象被代理 + const obj = await factory.create('widget1'); + // 属性已缓存,可直接访问 + expect(obj.name).toBe('widget1'); + + // 在返回代理上调用构造函数 + const Widget = await factory.Widget; + const widget = await new Widget(123); + const id = await widget.getId(); + expect(id).toBe(123); + }); + + it('should support getService().method({fn: callback}) pattern', async () => { + // 核心场景:链式调用 + 回调 + const { client } = createRpcPair({ + getService () { + return { + execute (options: { onProgress: (p: number) => void; onComplete: (r: string) => void; }) { + options.onProgress(25); + options.onProgress(50); + options.onProgress(100); + options.onComplete('done'); + return 'success'; + }, + process (data: { transformer: (x: number) => number; }) { + return data.transformer(10); + }, + }; + }, + }); + + const service = await client.getService(); + + const progressValues: number[] = []; + let completedWith = ''; + + const result = await service.execute({ + onProgress: (p: number) => { + progressValues.push(p); + }, + onComplete: (r: string) => { + completedWith = r; + }, + }); + + expect(result).toBe('success'); + expect(progressValues).toEqual([25, 50, 100]); + expect(completedWith).toBe('done'); + + // 测试返回值的回调 + const transformed = await service.process({ + transformer: (x: number) => x * 3, + }); + expect(transformed).toBe(30); + }); + + it('should support deep chain call with callbacks at any level', async () => { + const { client } = createRpcPair({ + getApi () { + return { + getModule () { + return { + getHandler () { + return { + handle (input: number, callbacks: { onSuccess: (r: number) => void; onError: (e: string) => void; }) { + if (input > 0) { + callbacks.onSuccess(input * 2); + } else { + callbacks.onError('negative input'); + } + return input > 0; + }, + }; + }, + }; + }, + }; + }, + }); + + const api = await client.getApi(); + const module = await api.getModule(); + const handler = await module.getHandler(); + + let successResult = 0; + const isSuccess = await handler.handle(5, { + onSuccess: (r: number) => { successResult = r; }, + onError: () => { }, + }); + + expect(isSuccess).toBe(true); + expect(successResult).toBe(10); + + let errorMsg = ''; + const isFailed = await handler.handle(-1, { + onSuccess: () => { }, + onError: (e: string) => { errorMsg = e; }, + }); + + expect(isFailed).toBe(false); + expect(errorMsg).toBe('negative input'); + }); + + it('should support register pattern with multiple callbacks', async () => { + const { client } = createRpcPair({ + createEventEmitter () { + const listeners: Map void>> = new Map(); + return { + register (events: Record void>) { + for (const [name, handler] of Object.entries(events)) { + if (!listeners.has(name)) listeners.set(name, []); + listeners.get(name)!.push(handler); + } + }, + emit (name: string, ...args: unknown[]) { + const handlers = listeners.get(name) ?? []; + handlers.forEach(h => h(...args)); + }, + }; + }, + }); + + const emitter = await client.createEventEmitter(); + + const events: Array<{ type: string; data: unknown; }> = []; + + await emitter.register({ + data: (value: unknown) => events.push({ type: 'data', data: value }), + error: (err: unknown) => events.push({ type: 'error', data: err }), + complete: () => events.push({ type: 'complete', data: null }), + }); + + await emitter.emit('data', 'hello'); + await emitter.emit('data', 'world'); + await emitter.emit('error', 'oops'); + await emitter.emit('complete'); + + expect(events).toEqual([ + { type: 'data', data: 'hello' }, + { type: 'data', data: 'world' }, + { type: 'error', data: 'oops' }, + { type: 'complete', data: null }, + ]); + }); + + it('should handle array return values with proxy', async () => { + // 通过 getService 返回的类实例来测试数组代理 + class ItemService { + getItems () { + return [ + { id: 1, name: 'item1' }, + { id: 2, name: 'item2' }, + ]; + } + + getItem (index: number) { + return { id: index, getValue: () => `item${index}` }; + } + } + const { client } = createRpcPair({ + getItemService () { + return new ItemService(); + }, + }); + + const service = await client.getItemService(); + + // 通过代理的 service 调用方法获取数组 + const items = await service.getItems(); + expect(items).toEqual([ + { id: 1, name: 'item1' }, + { id: 2, name: 'item2' }, + ]); + + // 获取单个 item 作为代理(因为它有方法) + const item = await service.getItem(3); + expect(await item.getValue()).toBe('item3'); + }); + }); +}); diff --git a/packages/snowcat-test/schema.test.ts b/packages/snowcat-test/schema.test.ts new file mode 100644 index 0000000..1af0f6e --- /dev/null +++ b/packages/snowcat-test/schema.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'vitest'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; +import { Type } from '@sinclair/typebox'; + +import { OB11MessageDataSchema, OB11MessageSchema, OB11PostSendMsgSchema } from '../snowcat-onebot/types/message'; +import { OB11MessageSchema as OB11ActionMessageSchema } from '../snowcat-onebot/action/schemas'; +import { OneBotConfigSchema, loadConfig as loadOneBotConfig, OneBotConfig } from '../snowcat-onebot/config'; +import { SnowCatConfigSchema } from '../snowcat-core/helper/config'; + +describe('SnowCat Schemas Compilation', () => { + test('should compile OB11MessageDataSchema without duplicate id error', () => { + expect(() => TypeCompiler.Compile(OB11MessageDataSchema)).not.toThrow(); + }); + + test('should compile OB11MessageSchema without duplicate id error', () => { + expect(() => TypeCompiler.Compile(OB11MessageSchema)).not.toThrow(); + }); + + test('should compile OB11ActionMessageSchema without duplicate id error', () => { + expect(() => TypeCompiler.Compile(OB11ActionMessageSchema)).not.toThrow(); + }); + + test('should compile OB11PostSendMsgSchema without error', () => { + expect(() => TypeCompiler.Compile(OB11PostSendMsgSchema)).not.toThrow(); + }); + + test('should compile QuickAction hybrid schema without error', () => { + const QASchema = Type.Object({ + message: OB11PostSendMsgSchema, + quick_action: OB11ActionMessageSchema, + }); + expect(() => TypeCompiler.Compile(QASchema)).not.toThrow(); + }); +}); + +describe('SnowCat Schemas Validation & Coercion', () => { + test('should coerce numeric user_id to string in OB11PostSendMsgSchema', () => { + const payload = { + message_type: 'private', + user_id: 123456, + message: [{ type: 'text', data: { text: 'hello' } }], + }; + + let data: unknown = structuredClone(payload); + data = Value.Parse(OB11PostSendMsgSchema, data); + + const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema); + expect(compiler.Check(data)).toBe(true); + expect((data as Record)['user_id']).toBe('123456'); + }); + + test('should validate complex mixed messages correctly', () => { + const payload = { + message_type: 'group', + group_id: '654321', + message: 'this is a string message', + }; + + let data: unknown = structuredClone(payload); + data = Value.Parse(OB11PostSendMsgSchema, data); + + const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema); + expect(compiler.Check(data)).toBe(true); + }); +}); + +describe('SnowCat Configuration Loaders', () => { + test('OneBotConfig schemas should compile', () => { + expect(() => TypeCompiler.Compile(OneBotConfigSchema)).not.toThrow(); + }); + + test('OneBotConfig should load and apply defaults correctly', () => { + const partialConfig: Partial = { + network: { + httpServers: [{ + port: 3000, + enable: true, + name: 'test', + host: '127.0.0.1', + enableCors: true, + enableWebsocket: false, + messagePostFormat: 'array', + token: '', + debug: false, + }], + httpSseServers: [], + httpClients: [], + websocketServers: [], + websocketClients: [], + plugins: [], + }, + }; + const loaded = loadOneBotConfig(partialConfig); + expect(loaded.network.httpServers[0]?.host).toBe('127.0.0.1'); + expect(loaded.network.httpServers[0]?.enableCors).toBe(true); + expect(loaded.musicSignUrl).toBe(''); + }); + + test('OneBotConfig websocketClients should apply verifyCertificate default', () => { + const partialConfig: Partial = { + network: { + httpServers: [], + httpSseServers: [], + httpClients: [], + websocketServers: [], + websocketClients: [{ + name: 'test-ws-client', + enable: true, + url: 'wss://example.com', + messagePostFormat: 'array', + reportSelfMessage: false, + reconnectInterval: 5000, + token: '', + debug: false, + heartInterval: 30000, + }], + plugins: [], + }, + }; + const loaded = loadOneBotConfig(partialConfig); + expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(true); + }); + + test('OneBotConfig websocketClients should preserve explicit verifyCertificate false', () => { + const partialConfig: Partial = { + network: { + httpServers: [], + httpSseServers: [], + httpClients: [], + websocketServers: [], + websocketClients: [{ + name: 'test-ws-client', + enable: true, + url: 'wss://example.com', + messagePostFormat: 'array', + reportSelfMessage: false, + reconnectInterval: 5000, + token: '', + debug: false, + heartInterval: 30000, + verifyCertificate: false, + }], + plugins: [], + }, + }; + const loaded = loadOneBotConfig(partialConfig); + expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(false); + }); + + test('NapcatConfig should compile and apply defaults', () => { + let compiled: ReturnType | undefined; + expect(() => { + compiled = TypeCompiler.Compile(SnowCatConfigSchema); + }).not.toThrow(); + + let data: unknown = {}; + data = Value.Parse(SnowCatConfigSchema, data); + + expect(compiled?.Check(data)).toBe(true); + const resolved = data as Record; + expect(resolved['consoleLog']).toBe(true); + expect(resolved['consoleLogLevel']).toBe('info'); + }); +}); diff --git a/packages/snowcat-test/sha1Stream.test.ts b/packages/snowcat-test/sha1Stream.test.ts new file mode 100644 index 0000000..2aa7255 --- /dev/null +++ b/packages/snowcat-test/sha1Stream.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'crypto'; +import { Sha1Stream } from 'snowcat-core/packet/utils/crypto/sha1Stream'; + +describe('Sha1Stream', () => { + it('should compute correct SHA-1 hash for empty string', () => { + const sha1Stream = new Sha1Stream(); + const hash = sha1Stream.final(); + const expected = crypto.createHash('sha1').update('').digest(); + expect(hash).toEqual(expected); + }); + + it('should compute correct SHA-1 hash for simple string', () => { + const testData = 'Hello, World!'; + const sha1Stream = new Sha1Stream(); + sha1Stream.update(Buffer.from(testData)); + const hash = sha1Stream.final(); + const expected = crypto.createHash('sha1').update(testData).digest(); + expect(hash).toEqual(expected); + }); + + it('should compute correct SHA-1 hash for binary data', () => { + const testData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + const sha1Stream = new Sha1Stream(); + sha1Stream.update(testData); + const hash = sha1Stream.final(); + const expected = crypto.createHash('sha1').update(testData).digest(); + expect(hash).toEqual(expected); + }); + + it('should handle multiple update calls', () => { + const part1 = 'Hello'; + const part2 = ', World!'; + const sha1Stream = new Sha1Stream(); + sha1Stream.update(Buffer.from(part1)); + sha1Stream.update(Buffer.from(part2)); + const hash = sha1Stream.final(); + const expected = crypto.createHash('sha1').update(part1 + part2).digest(); + expect(hash).toEqual(expected); + }); + + it('should handle large data correctly', () => { + const testData = crypto.randomBytes(1024 * 10); // 10KB random data + const sha1Stream = new Sha1Stream(); + sha1Stream.update(testData); + const hash = sha1Stream.final(); + const expected = crypto.createHash('sha1').update(testData).digest(); + expect(hash).toEqual(expected); + }); + + it('should produce consistent results for same input', () => { + const testData = 'Consistent test data'; + + const sha1Stream1 = new Sha1Stream(); + sha1Stream1.update(Buffer.from(testData)); + const hash1 = sha1Stream1.final(); + + const sha1Stream2 = new Sha1Stream(); + sha1Stream2.update(Buffer.from(testData)); + const hash2 = sha1Stream2.final(); + + expect(hash1).toEqual(hash2); + }); + + it('should handle edge case with exact block size', () => { + // SHA-1 block size is 64 bytes + const testData = 'a'.repeat(64); + const sha1Stream = new Sha1Stream(); + sha1Stream.update(Buffer.from(testData)); + const hash = sha1Stream.final(); + const expected = crypto.createHash('sha1').update(testData).digest(); + expect(hash).toEqual(expected); + }); + + it('should handle random data correctly', () => { + // Run multiple random tests (reduced from 100000 to 100 for performance) + for (let i = 0; i < 100; i++) { + const randomLength = Math.floor(Math.random() * 1024); + const randomData = crypto.randomBytes(randomLength); + const sha1Stream = new Sha1Stream(); + sha1Stream.update(randomData); + const hash = sha1Stream.final(); + const expectedDigest = crypto.createHash('sha1').update(randomData).digest(); + expect(hash).toEqual(expectedDigest); + } + }); +}); diff --git a/packages/snowcat-test/tsconfig.json b/packages/snowcat-test/tsconfig.json new file mode 100644 index 0000000..a6c373b --- /dev/null +++ b/packages/snowcat-test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": [ + "*.ts", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/snowcat-test/videoPacket.test.ts b/packages/snowcat-test/videoPacket.test.ts new file mode 100644 index 0000000..b7605d0 --- /dev/null +++ b/packages/snowcat-test/videoPacket.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf'; +import { ElementType, NTVideoType } from '@/snowcat-core/types/msg'; +import { SendVideoElement } from '@/snowcat-core/types/element'; +import { + PacketMsgPicElement, + PacketMsgVideoElement, +} from '@/snowcat-core/packet/message/element'; +import { PacketMsgConverter } from '@/snowcat-core/packet/message/converter'; +import { assertUploadResults } from '@/snowcat-core/packet/context/uploadResult'; +import { Elem, MsgInfo } from '@/snowcat-core/packet/transformer/proto'; +import { NTV2RichMediaReq } from '@/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq'; +import UploadGroupVideo from '@/snowcat-core/packet/transformer/highway/UploadGroupVideo'; +import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase'; + +function createVideoMsgInfo (): NapProtoEncodeStructType { + return { + msgInfoBody: [ + { + index: { + info: { + fileSize: 1024, + fileHash: '00112233445566778899aabbccddeeff', + fileSha1: '00112233445566778899aabbccddeeff00112233', + fileName: 'clip.mp4', + type: { + type: 2, + videoFormat: NTVideoType.VIDEO_FORMAT_MP4, + }, + width: 1920, + height: 1080, + time: 12, + }, + fileUuid: 'video-uuid', + }, + }, + { + index: { + info: { + fileSize: 2048, + fileHash: 'ffeeddccbbaa99887766554433221100', + fileSha1: 'ffeeddccbbaa99887766554433221100ffeeddcc', + fileName: 'thumb.jpg', + type: { + type: 1, + picFormat: 0, + }, + width: 640, + height: 360, + }, + fileUuid: 'thumb-uuid', + }, + }, + ], + }; +} + +function createSendVideoElement (): SendVideoElement { + return { + elementType: ElementType.VIDEO, + elementId: '', + videoElement: { + filePath: 'clip.mp4', + fileName: 'clip.mp4', + videoMd5: '00112233445566778899aabbccddeeff', + thumbMd5: 'ffeeddccbbaa99887766554433221100', + fileTime: 12, + fileFormat: NTVideoType.VIDEO_FORMAT_MP4, + fileSize: '1024', + fileWidth: 1920, + fileHeight: 1080, + thumbWidth: 640, + thumbHeight: 360, + thumbSize: 2048, + thumbPath: new Map([[0, 'thumb.jpg']]), + }, + }; +} + +function decodeElem ( + elem: NapProtoEncodeStructType +): NapProtoDecodeStructType { + const codec = new NapProtoMsg(Elem); + return codec.decode(codec.encode(elem)); +} + +function createCommonVideoElem (businessType: 11 | 21): NapProtoDecodeStructType { + return decodeElem({ + commonElem: { + serviceType: 48, + businessType, + pbElem: new NapProtoMsg(MsgInfo).encode(createVideoMsgInfo()), + }, + }); +} + +describe('packet video forwarding', () => { + it('emits only the common rich-media element so mobile rendering is not blocked', () => { + const video = new PacketMsgVideoElement(createSendVideoElement()); + video.msgInfo = createVideoMsgInfo(); + video.businessType = 11; + + const elems = video.buildElement(); + + expect(elems).toHaveLength(1); + expect(elems[0]?.videoFile).toBeUndefined(); + expect(elems[0]?.commonElem?.serviceType).toBe(48); + expect(elems[0]?.commonElem?.businessType).toBe(11); + expect(video.valid).toBe(true); + }); + + it('parses common business type 11/21 as video instead of image', () => { + for (const businessType of [11, 21] as const) { + const commonElem = createCommonVideoElem(businessType); + expect(PacketMsgPicElement.parseElement(commonElem)).toBeUndefined(); + + const parsed = new PacketMsgConverter().packetMsgToRaw([commonElem]); + + expect(parsed).toHaveLength(1); + expect(parsed[0]?.[0].elementType).toBe(ElementType.VIDEO); + expect(parsed[0]?.[0].picElement).toBeUndefined(); + expect(parsed[0]?.[0].videoElement).toMatchObject({ + fileName: 'clip.mp4', + fileTime: 12, + fileWidth: 1920, + fileHeight: 1080, + thumbWidth: 640, + thumbHeight: 360, + fileUuid: 'video-uuid', + }); + } + }); + + it('preserves video metadata in the rich-media upload request', () => { + const video = new PacketMsgVideoElement(createSendVideoElement()); + video.fileSha1 = '00112233445566778899aabbccddeeff00112233'; + video.thumbSha1 = 'ffeeddccbbaa99887766554433221100ffeeddcc'; + + const packet = UploadGroupVideo.build(123456, video); + const envelope = OidbBase.parse(packet.data); + const request = new NapProtoMsg(NTV2RichMediaReq).decode(envelope.body); + const videoInfo = request.upload?.uploadInfo[0]?.fileInfo; + const thumbInfo = request.upload?.uploadInfo[1]?.fileInfo; + + expect(videoInfo).toMatchObject({ + fileName: 'clip.mp4', + fileSize: 1024, + width: 1920, + height: 1080, + time: 12, + }); + expect(videoInfo?.type.videoFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4); + expect(thumbInfo?.fileName).toBe('ffeeddccbbaa99887766554433221100.jpg'); + }); + + it('propagates resource upload failures instead of silently continuing', () => { + const uploadError = new Error('thumb upload failed'); + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + + expect(() => assertUploadResults([ + { status: 'fulfilled', value: undefined }, + { status: 'rejected', reason: uploadError }, + ], logger)).toThrow('上传转发消息资源失败'); + expect(logger.warn).toHaveBeenCalledWith('上传资源2个,失败1个'); + expect(logger.error).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/snowcat-test/videoSendMode.test.ts b/packages/snowcat-test/videoSendMode.test.ts new file mode 100644 index 0000000..9b8a17a --- /dev/null +++ b/packages/snowcat-test/videoSendMode.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { OneBotFileApi } from '@/snowcat-onebot/api/file'; +import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg'; +import { NTVideoType } from '@/snowcat-core/types/msg'; + +const MP4_HEADER = Buffer.from([ + 0x00, 0x00, 0x00, 0x18, + 0x66, 0x74, 0x79, 0x70, + 0x69, 0x73, 0x6f, 0x6d, + 0x00, 0x00, 0x00, 0x00, + 0x69, 0x73, 0x6f, 0x6d, +]); +const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + +describe('OneBot video send modes', () => { + let tempDir: string; + let sourceVideoPath: string; + let uploadedVideoPath: string; + let fileApi: OneBotFileApi; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'snowcat-video-send-mode-')); + sourceVideoPath = path.join(tempDir, 'clip.mp4'); + uploadedVideoPath = path.join(tempDir, 'cache', 'Ori', 'clip.mp4'); + await mkdir(path.dirname(uploadedVideoPath), { recursive: true }); + await Promise.all([ + writeFile(sourceVideoPath, MP4_HEADER), + writeFile(uploadedVideoPath, MP4_HEADER), + ]); + + const core = { + SnowCatTempPath: tempDir, + context: { + logger: { + logError: vi.fn(), + }, + }, + apis: { + FileApi: { + uploadFile: vi.fn().mockResolvedValue({ + fileName: 'clip.mp4', + path: uploadedVideoPath, + fileSize: MP4_HEADER.length, + md5: '00112233445566778899aabbccddeeff', + }), + copyFile: vi.fn(), + }, + }, + } as unknown as ConstructorParameters[1]; + + fileApi = new OneBotFileApi( + {} as ConstructorParameters[0], + core + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('uses a PNG thumbnail and legacy minimal metadata for direct kernel sends', async () => { + const getVideoInfo = vi.spyOn(FFmpegService, 'getVideoInfo').mockResolvedValue({ + width: 480, + height: 852, + time: 3.6, + format: 'jpg', + size: MP4_HEADER.length, + filePath: sourceVideoPath, + }); + const extractLegacyThumbnail = vi + .spyOn(FFmpegService, 'extractLegacyVideoThumbnail') + .mockImplementation(async (_videoPath, thumbnailPath) => { + await writeFile(thumbnailPath, PNG_HEADER); + }); + + const context = { + deleteAfterSentFiles: [], + peer: {}, + } as unknown as Parameters[0]; + const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath); + const thumbnailPath = element.videoElement.thumbPath?.get(0) as string; + + expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, ''); + expect(extractLegacyThumbnail).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath); + expect(path.extname(thumbnailPath)).toBe('.png'); + expect((await readFile(thumbnailPath)).subarray(0, PNG_HEADER.length)).toEqual(PNG_HEADER); + expect(element.videoElement.fileTime).toBe(3.6); + expect('fileFormat' in element.videoElement).toBe(false); + expect('fileWidth' in element.videoElement).toBe(false); + expect('fileHeight' in element.videoElement).toBe(false); + }); + + it('keeps JPEG thumbnails and complete metadata for packet forward uploads', async () => { + const getVideoInfo = vi + .spyOn(FFmpegService, 'getVideoInfo') + .mockImplementation(async (videoPath, thumbnailPath) => { + await writeFile(thumbnailPath, JPEG_HEADER); + return { + width: 480, + height: 852, + time: 3.6, + format: 'jpg', + size: MP4_HEADER.length, + filePath: videoPath, + }; + }); + const extractLegacyThumbnail = vi.spyOn(FFmpegService, 'extractLegacyVideoThumbnail'); + + const context = { + deleteAfterSentFiles: [], + peer: {}, + usePacketVideoMetadata: true, + } as unknown as Parameters[0]; + const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath); + const thumbnailPath = element.videoElement.thumbPath?.get(0) as string; + + expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath); + expect(extractLegacyThumbnail).not.toHaveBeenCalled(); + expect(path.extname(thumbnailPath)).toBe('.jpg'); + expect((await readFile(thumbnailPath)).subarray(0, JPEG_HEADER.length)).toEqual(JPEG_HEADER); + expect(element.videoElement.fileTime).toBe(4); + expect(element.videoElement.fileFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4); + expect(element.videoElement.fileWidth).toBe(480); + expect(element.videoElement.fileHeight).toBe(852); + }); +}); diff --git a/packages/snowcat-test/vitest.config.ts b/packages/snowcat-test/vitest.config.ts new file mode 100644 index 0000000..7630c1b --- /dev/null +++ b/packages/snowcat-test/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, + resolve: { + alias: { + '@/snowcat-rpc': resolve(__dirname, '../snowcat-rpc'), + '@/snowcat-image-size': resolve(__dirname, '../snowcat-image-size'), + '@/snowcat-test': resolve(__dirname, '.'), + '@/snowcat-common': resolve(__dirname, '../snowcat-common'), + '@/snowcat-core': resolve(__dirname, '../snowcat-core'), + '@/snowcat-onebot': resolve(__dirname, '../snowcat-onebot'), + 'snowcat-protobuf': resolve(__dirname, '../snowcat-protobuf/NapProto.ts'), + }, + }, +}); diff --git a/packages/snowcat-types/README.md b/packages/snowcat-types/README.md new file mode 100644 index 0000000..2205c1e --- /dev/null +++ b/packages/snowcat-types/README.md @@ -0,0 +1,3 @@ +# NapCat-Types + + NapCat 类型定义包,包含 NapCat 及其插件开发所需的所有类型定义。 \ No newline at end of file diff --git a/packages/snowcat-types/external-shims.d.ts b/packages/snowcat-types/external-shims.d.ts new file mode 100644 index 0000000..48bc699 --- /dev/null +++ b/packages/snowcat-types/external-shims.d.ts @@ -0,0 +1,104 @@ +// 外部模块 shim,提供最小的可运行值和类型,避免类型包依赖外部环境 +// node:* 模块移除 mock,使用系统自带类型 + +// (ws/express/winston) now provided by real type deps (@types/ws, @types/express, winston) + +declare module 'ffmpeg-static' { + const _ffmpeg_static_default: any; + export default _ffmpeg_static_default; +} + +declare module 'fluent-ffmpeg' { + const _fluent_ffmpeg_default: any; + export default _fluent_ffmpeg_default; +} + +declare module 'sharp' { + const _sharp_default: any; + export default _sharp_default; +} + +declare module 'uuid' { + export function v4 (...args: any[]): string; +} + +declare module 'axios' { + const _axios_default: any; + export default _axios_default; +} + +declare module 'body-parser' { + const _body_parser_default: any; + export default _body_parser_default; +} + +declare module 'cors' { + const _cors_default: any; + export default _cors_default; +} + +declare module 'file-type' { + export function fileTypeFromFile (path: string): Promise; +} + +declare module 'image-size' { + const _image_size_default: any; + export default _image_size_default; +} + +declare module 'jimp' { + const _jimp_default: any; + export default _jimp_default; +} + +declare module 'qrcode' { + const _qrcode_default: any; + export default _qrcode_default; +} + +declare module 'yaml' { + export const parse: (...args: any[]) => any; + export const stringify: (...args: any[]) => any; +} + +declare module 'snowcat-protobuf' { + export class NapProtoMsg { + constructor (schema: any); + decode (buffer: any): T; + encode (value: any): Uint8Array; + } + export function ProtoField (...args: any[]): any; + export type NapProtoEncodeStructType<_T> = any; + export type NapProtoDecodeStructType<_T> = any; + export type ScalarProtoFieldType<_T> = any; + export type MessageProtoFieldType<_T> = any; + export const ScalarType: { + STRING: any; + INT64: any; + INT32: any; + UINT32: any; + UINT64: any; + BYTES: any; + BOOL: any; + [key: string]: any; + }; +} + +declare module 'inversify' { + export class Container { + bind: (...args: any[]) => any; + get: (id: any) => T; + } + export function injectable (...args: any[]): any; + export function inject (...args: any[]): any; + export interface ServiceIdentifier<_T = any> { } + const _inversify_default: any; + export default _inversify_default; +} + +declare module 'ip' { + export function toBuffer (ip: any, buffer?: Buffer, offset?: number): Buffer; + export function toString (buffer: any, offset?: number, length?: number): string; + const _ip_default: any; + export default _ip_default; +} diff --git a/packages/snowcat-types/index.ts b/packages/snowcat-types/index.ts new file mode 100644 index 0000000..0740900 --- /dev/null +++ b/packages/snowcat-types/index.ts @@ -0,0 +1,8 @@ +// 聚合导出核心库的所有内容(包括枚举、类和类型) +export * from '../snowcat-core/index'; + +// 聚合导出 OneBot 的所有内容 +export * from '../snowcat-onebot/index'; + +// Ensure the shims file exists next to the emitted JS as well. +export type { }; diff --git a/packages/snowcat-types/package.json b/packages/snowcat-types/package.json new file mode 100644 index 0000000..872c49e --- /dev/null +++ b/packages/snowcat-types/package.json @@ -0,0 +1,24 @@ +{ + "name": "snowcat-types", + "version": "0.0.1", + "private": false, + "type": "module", + "types": "./dist/snowcat-types/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsc --project tsconfig.json && tsc-alias --project tsconfig.json --outDir dist && node ./scripts/post-build.mjs && node ./scripts/copy-dist.mjs", + "test": "pnpm -s exec tsc --project tsconfig.json --noEmit", + "publish": "cd dist && npm publish" + }, + "dependencies": { + "@types/node": "^22.10.7", + "@sinclair/typebox": "^0.34.38" + }, + "devDependencies": { + "snowcat-core": "workspace:*", + "snowcat-onebot": "workspace:*", + "tsc-alias": "^1.8.16" + } +} \ No newline at end of file diff --git a/packages/snowcat-types/package.public.json b/packages/snowcat-types/package.public.json new file mode 100644 index 0000000..172da5b --- /dev/null +++ b/packages/snowcat-types/package.public.json @@ -0,0 +1,19 @@ +{ + "name": "snowcat-types", + "version": "0.0.17", + "private": false, + "type": "module", + "types": "./snowcat-types/index.d.ts", + "files": [ + "./**/*" + ], + "dependencies": { + "@types/node": "^22.10.7", + "@sinclair/typebox": "^0.34.38" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} \ No newline at end of file diff --git a/packages/snowcat-types/scripts/copy-dist.mjs b/packages/snowcat-types/scripts/copy-dist.mjs new file mode 100644 index 0000000..0f7be1a --- /dev/null +++ b/packages/snowcat-types/scripts/copy-dist.mjs @@ -0,0 +1,13 @@ +// 复制 cp README.md dist/ && cp package.public.json dist/package.json +import { copyFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const __dirname = fileURLToPath(new URL('../', import.meta.url)); +await copyFile( + join(__dirname, 'package.public.json'), + join(__dirname, 'dist', 'package.json') +); +await copyFile( + join(__dirname, 'README.md'), + join(__dirname, 'dist', 'README.md') +); diff --git a/packages/snowcat-types/scripts/post-build.mjs b/packages/snowcat-types/scripts/post-build.mjs new file mode 100644 index 0000000..52af6f3 --- /dev/null +++ b/packages/snowcat-types/scripts/post-build.mjs @@ -0,0 +1,118 @@ +import { readdir, readFile, writeFile, rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('../', import.meta.url)); +const distDir = join(__dirname, 'dist'); + +// 允许保留的包(白名单) +const ALLOWED_PACKAGES = [ + '@sinclair/typebox', + 'node:', // node: 前缀的内置模块 +]; + +function isAllowedImport (importPath) { + return ALLOWED_PACKAGES.some(pkg => importPath.startsWith(pkg)); +} + +function removeExternalImports (content) { + const lines = content.split('\n'); + const resultLines = []; + + for (const line of lines) { + // 匹配 import 语句 + const importMatch = line.match(/^import\s+.*\s+from\s+['"]([^'"]+)['"]/); + if (importMatch) { + const importPath = importMatch[1]; + // 如果是相对路径或白名单包,保留 + if (importPath.startsWith('.') || importPath.startsWith('/') || isAllowedImport(importPath)) { + resultLines.push(line); + } + // 否则移除该 import + continue; + } + resultLines.push(line); + } + + return resultLines.join('\n'); +} + +function replaceExternalTypes (content) { + let result = content; + + // 替换带泛型的类型(先处理复杂的) + result = result.replace(/NapProtoDecodeStructType<[^>]+>/g, 'any'); + result = result.replace(/NapProtoEncodeStructType<[^>]+>/g, 'any'); + result = result.replace(/ValidateFunction<[^>]+>/g, 'any'); + + // 替换 winston.Logger 等带命名空间的类型 + result = result.replace(/winston\.Logger/g, 'any'); + result = result.replace(/winston\.transport/g, 'any'); + result = result.replace(/express\.Express/g, 'any'); + result = result.replace(/express\.Application/g, 'any'); + result = result.replace(/express\.Router/g, 'any'); + + // 替换独立的类型名(需要小心不要替换变量名) + // 使用类型上下文的模式匹配 + const typeContextPatterns = [ + // : Type + /:\s*(WebSocket|WebSocketServer|RawData|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]}|&]|$)/g, + // + /<(WebSocket|WebSocketServer|RawData|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g, + // Type[] + /(WebSocket|WebSocketServer|RawData|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g, + // extends Type + /extends\s+(WebSocket|WebSocketServer|RawData|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g, + // implements Type + /implements\s+(WebSocket|WebSocketServer|RawData|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g, + ]; + + for (const pattern of typeContextPatterns) { + result = result.replace(pattern, (match, typeName) => { + return match.replace(typeName, 'any'); + }); + } + + return result; +} + +async function traverseDirectory (dir) { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + await traverseDirectory(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.d.ts')) { + await processFile(fullPath); + } + } +} + +async function processFile (filePath) { + // Read file content + let content = await readFile(filePath, 'utf-8'); + + // 1. 移除外部包的 import + content = removeExternalImports(content); + + // 2. 替换外部类型为 any + content = replaceExternalTypes(content); + + // 3. Replace "export declare enum" with "export enum" + content = content.replace(/export declare enum/g, 'export enum'); + + // Write back the modified content + await writeFile(filePath, content, 'utf-8'); + + // Rename .d.ts to .ts + const newPath = filePath.replace(/\.d\.ts$/, '.ts'); + await rename(filePath, newPath); + + // console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`); +} + +console.log('Starting post-build processing...'); +await traverseDirectory(distDir); +console.log('Post-build processing completed!'); diff --git a/packages/snowcat-types/test-dist.ts b/packages/snowcat-types/test-dist.ts new file mode 100644 index 0000000..b62b302 --- /dev/null +++ b/packages/snowcat-types/test-dist.ts @@ -0,0 +1,36 @@ +import { + ChatType, + ElementType, + SnowCatCore, + NTQQMsgApi, + NapCatOneBot11Adapter, + OB11Message, + OB11BaseMessageEvent, + OB11BaseMetaEvent, +} from './dist/snowcat-types/index'; + +console.log('--- SnowCat Comprehensive Type Test ---'); + +// 1. 测试枚举 (Core) +console.log('ChatType.KCHATTYPEGROUP:', ChatType.KCHATTYPEGROUP); // 应输出 2 +console.log('ElementType.TEXT:', ElementType.TEXT); // 应输出 1 + +// 2. 测试类型 (Core) +const _coreStub = {} as SnowCatCore; +const _apiStub = {} as NTQQMsgApi; +console.log('Core types access check: OK'); + +// 3. 测试类和类型 (OneBot) +const _obAdapterStub = {} as NapCatOneBot11Adapter; +const _obMsgStub = {} as OB11Message; +const _baseMessageEventStub = {} as OB11BaseMessageEvent; +const _baseMetaEventStub = {} as OB11BaseMetaEvent; +console.log('OneBot types and events access check: OK'); + +// 4. 验证导出完整性 +if (ChatType.KCHATTYPEGROUP === 2 && ElementType.TEXT === 1) { + console.log('\n✅ ALL TESTS PASSED: Types, Enums and Events are correctly exported and accessible.'); +} else { + console.error('\n❌ TESTS FAILED: Enum value mismatch.'); + throw new Error('Test Failed'); +} diff --git a/packages/snowcat-types/test-export.ts b/packages/snowcat-types/test-export.ts new file mode 100644 index 0000000..0c8fe51 --- /dev/null +++ b/packages/snowcat-types/test-export.ts @@ -0,0 +1,20 @@ +import { ChatType, ElementType, SnowCatCore, NTQQMsgApi } from './index'; + +console.log('--- SnowCat Types Manual Test ---'); + +// 测试枚举值 (snowcat-core enums) +console.log('ChatType.KCHATTYPEGROUP:', ChatType.KCHATTYPEGROUP); +console.log('ElementType.TEXT:', ElementType.TEXT); + +// 测试 snowcat-core 的类型和类 +const coreStub = {} as SnowCatCore; +const apiStub = {} as NTQQMsgApi; + +console.log('SnowCatCore type check:', !!coreStub); +console.log('NTQQMsgApi type check:', !!apiStub); + +if (ChatType.KCHATTYPEGROUP === 2 && ElementType.TEXT === 1) { + console.log('Test Passed: core enums and types are correctly exported.'); +} else { + throw new Error('Test Failed: Enum values do not match expected values.'); +} diff --git a/packages/snowcat-types/tsconfig.json b/packages/snowcat-types/tsconfig.json new file mode 100644 index 0000000..9b11a87 --- /dev/null +++ b/packages/snowcat-types/tsconfig.json @@ -0,0 +1,51 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "..", + "declaration": true, + "emitDeclarationOnly": true, + "skipLibCheck": true, + "stripInternal": true, + "noEmitOnError": false, + "paths": { + "snowcat-core": [ + "../../packages/snowcat-core/index.ts" + ], + "snowcat-onebot": [ + "../../packages/snowcat-onebot/index.ts" + ], + "snowcat-common/src/*": [ + "../../packages/snowcat-common/src/*" + ], + "snowcat-image-size/src/*": [ + "../../packages/snowcat-image-size/src/*" + ], + "@/snowcat-core/*": [ + "../../packages/snowcat-core/*" + ], + "@/snowcat-onebot/*": [ + "../../packages/snowcat-onebot/*" + ], + "@/snowcat-common/*": [ + "../../packages/snowcat-common/*" + ], + "@/snowcat-webui-backend/*": [ + "../../packages/snowcat-webui-backend/*" + ], + "@/*": [ + "../../packages/*" + ] + } + }, + "include": [ + "./index.ts", + "../snowcat-core/**/*.ts", + "../snowcat-onebot/**/*.ts", + "../snowcat-common/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-universal/package.json b/packages/snowcat-universal/package.json new file mode 100644 index 0000000..f626648 --- /dev/null +++ b/packages/snowcat-universal/package.json @@ -0,0 +1,16 @@ +{ + "name": "snowcat-universal", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "snowcat-shell": "workspace:*", + "snowcat-framework": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-universal/snowcat.ts b/packages/snowcat-universal/snowcat.ts new file mode 100644 index 0000000..06082ab --- /dev/null +++ b/packages/snowcat-universal/snowcat.ts @@ -0,0 +1,2 @@ +export * from 'snowcat-framework/SnowCat'; +export * from 'snowcat-shell/base'; diff --git a/packages/snowcat-universal/tsconfig.json b/packages/snowcat-universal/tsconfig.json new file mode 100644 index 0000000..48d3a03 --- /dev/null +++ b/packages/snowcat-universal/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-vite/package.json b/packages/snowcat-vite/package.json new file mode 100644 index 0000000..b850342 --- /dev/null +++ b/packages/snowcat-vite/package.json @@ -0,0 +1,24 @@ +{ + "name": "snowcat-vite", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.ts", + "scripts": { + "_build": "vite build" + }, + "exports": { + ".": { + "import": "./index.ts" + }, + "./*": { + "import": "./*" + } + }, + "devDependencies": { + "@types/node": "^22.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-vite/tsconfig.json b/packages/snowcat-vite/tsconfig.json new file mode 100644 index 0000000..fce4a04 --- /dev/null +++ b/packages/snowcat-vite/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true + }, + "include": [ + "*.js", + "**/*.js" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-vite/vite-auto-include.js b/packages/snowcat-vite/vite-auto-include.js new file mode 100644 index 0000000..ba8fa9f --- /dev/null +++ b/packages/snowcat-vite/vite-auto-include.js @@ -0,0 +1,78 @@ +import path from 'path'; +import fs from 'fs'; + +export function autoIncludeTSPlugin (options) { + // options: { entries: [{ entry: 'napcat.ts', dir: './utils' }, ...] } + const { entries } = options; + let tsFilesMap = {}; + + return { + name: 'vite-auto-include', + + async buildStart () { + tsFilesMap = {}; + for (const { entry, dir } of entries) { + if (!tsFilesMap[entry]) { + tsFilesMap[entry] = []; + } + const fullDir = path.resolve(dir); + const allTsFiles = await findTSFiles(fullDir); + + const validFiles = []; + allTsFiles.forEach((filePath) => { + try { + const source = fs.readFileSync(filePath, 'utf-8'); + if (source && source.trim() !== '') { + validFiles.push(filePath); + } else { + // Skipping empty file: ${filePath} + } + } catch (error) { + console.error(`Error reading file: ${filePath}`, error); + } + }); + + tsFilesMap[entry].push(...validFiles); + } + }, + + transform (code, id) { + for (const [entry, tsFiles] of Object.entries(tsFilesMap)) { + const isMatch = id.endsWith(entry) || id.includes(entry); + if (isMatch && tsFiles.length > 0) { + const imports = tsFiles.map(filePath => { + const relativePath = path.relative(path.dirname(id), filePath).replace(/\\/g, '/'); + return `import './${relativePath}';`; + }).join('\n'); + + const transformedCode = imports + '\n' + code; + + return { + code: transformedCode, + map: { mappings: '' }, // 空映射即可,VSCode 可以在 TS 上断点 + }; + } + } + + return null; + }, + + }; +} + +// 辅助函数:查找所有 .ts 文件 +async function findTSFiles (dir) { + const files = []; + const items = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(dir, item.name); + if (item.isDirectory()) { + files.push(...await findTSFiles(fullPath)); // 递归查找子目录 + } else if (item.isFile() && fullPath.endsWith('.ts')) { + files.push(fullPath); // 收集 .ts 文件 + } + } + + return files; +} diff --git a/packages/snowcat-vite/vite-plugin-version.js b/packages/snowcat-vite/vite-plugin-version.js new file mode 100644 index 0000000..a8fae6f --- /dev/null +++ b/packages/snowcat-vite/vite-plugin-version.js @@ -0,0 +1,180 @@ +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * SemVer 2.0 正则表达式 + * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息] + * 参考: https://semver.org/lang/zh-CN/ + */ +const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** + * Validate version format according to SemVer 2.0 specification + * @param {string} version - The version string to validate (with or without 'v' prefix) + * @returns {{ valid: boolean, normalized: string, major: number, minor: number, patch: number, prerelease: string|null, buildmetadata: string|null }} + */ +function validateVersion (version) { + if (!version || typeof version !== 'string') { + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; + } + + const match = version.trim().match(SEMVER_REGEX); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3], 10); + const prerelease = match[4] || null; + const buildmetadata = match[5] || null; + + // 构建标准化版本号(不带 v 前缀) + let normalized = `${major}.${minor}.${patch}`; + if (prerelease) normalized += `-${prerelease}`; + if (buildmetadata) normalized += `+${buildmetadata}`; + + return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata }; + } + return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null }; +} + +/** + * NapCat Vite Plugin: fetches latest GitHub tag (not release) and injects into import.meta.env + * + * 版本号来源优先级: + * 1. 环境变量 NAPCAT_VERSION (用于 CI 构建) + * 2. 缓存的 GitHub tag + * 3. 从 GitHub API 获取最新公开 tag(不携带 token) + * 4. 兜底版本号: 1.0.0-dev + */ +export default function vitePluginNapcatVersion () { + const pluginDir = path.resolve(__dirname, 'dist'); + const cacheFile = path.join(pluginDir, '.snowcat-version.json'); + const owner = 'NapNeko'; + const repo = 'NapCatQQ'; + const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day + // CI 构建时可通过环境变量直接指定版本号 + const envVersion = process.env.NAPCAT_VERSION; + const fallbackVersion = '1.0.0-dev'; + + fs.mkdirSync(pluginDir, { recursive: true }); + + function readCache () { + try { + const stat = fs.statSync(cacheFile); + if (Date.now() - stat.mtimeMs < maxAgeMs) { + const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + if (data?.tag) return data.tag; + } + } catch { } + return null; + } + + function writeCache (tag) { + try { + fs.writeFileSync( + cacheFile, + JSON.stringify({ tag, time: new Date().toISOString() }, null, 2) + ); + } catch { } + } + + async function fetchLatestTag () { + const url = `https://api.github.com/repos/${owner}/${repo}/tags`; + return new Promise((resolve, reject) => { + const req = https.get( + url, + { + headers: { + 'User-Agent': 'vite-plugin-snowcat-version', + Accept: 'application/vnd.github.v3+json', + }, + }, + (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + try { + const json = JSON.parse(data); + if (Array.isArray(json) && json[0]?.name) { + const tagName = json[0].name; + const { valid, normalized } = validateVersion(tagName); + if (valid) { + resolve(normalized); + } else { + console.warn(`[vite-plugin-snowcat-version] Invalid tag format: ${tagName}, expected vX.X.X`); + reject(new Error(`Invalid tag format: ${tagName}, expected vX.X.X`)); + } + } else reject(new Error('Invalid GitHub tag response')); + } catch (e) { + reject(e); + } + }); + } + ); + req.on('error', reject); + }); + } + + async function getVersion () { + // 优先使用环境变量指定的版本号 (CI 构建) + if (envVersion) { + const { valid, normalized } = validateVersion(envVersion); + if (valid) { + console.log(`[vite-plugin-snowcat-version] Using version from NAPCAT_VERSION env: ${normalized}`); + return normalized; + } else { + console.warn(`[vite-plugin-snowcat-version] Invalid NAPCAT_VERSION format: ${envVersion}, falling back to fetch`); + } + } + + const cached = readCache(); + if (cached) return cached; + try { + const tag = await fetchLatestTag(); + writeCache(tag); + return tag; + } catch (e) { + console.warn('[vite-plugin-snowcat-version] Failed to fetch tag:', e.message); + return cached ?? fallbackVersion; + } + } + + let lastTag = null; + + return { + name: 'vite-plugin-snowcat-version', + enforce: 'pre', + + async config (userConfig) { + const tag = await getVersion(); + console.log(`[vite-plugin-snowcat-version] Using version: ${tag}`); + lastTag = tag; + return { + define: { + ...(userConfig.define || {}), + 'import.meta.env.VITE_NAPCAT_VERSION': JSON.stringify(tag), + }, + }; + }, + + handleHotUpdate (ctx) { + if (path.resolve(ctx.file) === cacheFile) { + try { + const json = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + const tag = json?.tag; + if (tag && tag !== lastTag) { + lastTag = tag; + ctx.server?.ws.send({ type: 'full-reload' }); + } + } catch { } + } + }, + }; +} + +// Export validateVersion for external use +export { validateVersion }; diff --git a/packages/snowcat-webui-backend/Readme.md b/packages/snowcat-webui-backend/Readme.md new file mode 100644 index 0000000..5cc8040 --- /dev/null +++ b/packages/snowcat-webui-backend/Readme.md @@ -0,0 +1,3 @@ +# The Path of NapCatQQ + +Tiny WebUi Backend for NapCatQQ diff --git a/packages/snowcat-webui-backend/index.ts b/packages/snowcat-webui-backend/index.ts new file mode 100644 index 0000000..eda0444 --- /dev/null +++ b/packages/snowcat-webui-backend/index.ts @@ -0,0 +1,583 @@ +/** + * @file WebUI服务入口文件 + */ + +import express from 'express'; +import type { WebUiConfigType } from './src/types'; +import { createServer } from 'http'; +import { createHash, randomUUID } from 'node:crypto'; +import { createServer as createHttpsServer } from 'https'; +import { SnowCatPathWrapper } from 'snowcat-common/src/path'; +import { WebUiConfigWrapper } from '@/snowcat-webui-backend/src/helper/config'; +import { ALLRouter } from '@/snowcat-webui-backend/src/router'; +import { cors } from '@/snowcat-webui-backend/src/middleware/cors'; +import { createUrl, getRandomToken } from '@/snowcat-webui-backend/src/utils/url'; +import { sendError } from '@/snowcat-webui-backend/src/utils/response'; +import { join, dirname, resolve } from 'node:path'; +import { terminalManager } from '@/snowcat-webui-backend/src/terminal/terminal_manager'; +import multer from 'multer'; +import * as net from 'node:net'; +import { WebUiDataRuntime } from './src/helper/Data'; +import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误捕获 +import { ILogWrapper } from 'snowcat-common/src/log-interface'; +import { ISubscription } from 'snowcat-common/src/subscription-interface'; +import { IStatusHelperSubscription } from '@/snowcat-common/src/status-interface'; +import { handleDebugWebSocket } from '@/snowcat-webui-backend/src/api/Debug'; +import compression from 'compression'; +import { snowCatVersion } from 'snowcat-common/src/version'; +import { fileURLToPath } from 'node:url'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/snowcat-onebot/network/plugin-manger'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +// 实例化Express +const app = express(); +/** + * 初始化并启动WebUI服务。 + * 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。 + * 无需参数。 + * @returns {Promise} 无返回值。 + */ +export let WebUiConfig: WebUiConfigWrapper; +export let webUiPathWrapper: SnowCatPathWrapper; +export let logSubscription: ISubscription; +export let statusHelperSubscription: IStatusHelperSubscription; +export let webUiLogger: ILogWrapper | null = null; +const MAX_PORT_TRY = 100; + +export let webUiRuntimePort = 6099; +// 全局变量:存储需要在QQ登录成功后发送的新token +export let pendingTokenToSend: string | null = null; + +/** + * 存储WebUI启动时的初始token,用于鉴权 + * - 无论是否在运行时修改密码,都应该使用此token进行鉴权 + * - 运行时手动修改的密码将会在下次napcat重启后生效 + * - 如果需要在运行时修改密码并立即生效,则需要在前端调用路由进行修改 + */ +let initialWebUiToken: string = ''; + +export function setInitialWebUiToken (token: string) { + initialWebUiToken = token; +} + +export function getInitialWebUiToken (): string { + return initialWebUiToken; +} + +export function setPendingTokenToSend (token: string | null) { + pendingTokenToSend = token; +} + +export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> { + try { + await tryUseHost(parsedConfig.host); + const preferredPort = parseInt(process.env['NAPCAT_WEBUI_PREFERRED_PORT'] || '', 10); + + let port: number; + if (preferredPort > 0) { + try { + port = await tryUsePort(preferredPort, parsedConfig.host, 0, true); + } catch { + port = await tryUsePort(parsedConfig.port, parsedConfig.host); + } + } else { + port = await tryUsePort(parsedConfig.port, parsedConfig.host); + } + + return [parsedConfig.host, port, parsedConfig.token]; + } catch (error) { + console.log('host或port不可用', error); + return ['', 0, randomUUID()]; + } +} + +async function checkCertificates (logger: ILogWrapper): Promise<{ key: string, cert: string; } | null> { + try { + const certPath = join(webUiPathWrapper.configPath, 'cert.pem'); + const keyPath = join(webUiPathWrapper.configPath, 'key.pem'); + + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf8'); + const key = readFileSync(keyPath, 'utf8'); + logger.log('[SnowCat] [WebUi] 找到SSL证书,将启用HTTPS模式'); + return { cert, key }; + } + return null; + } catch (error) { + logger.log('[SnowCat] [WebUi] 检查SSL证书时出错: ' + error); + return null; + } +} +export async function InitWebUi (logger: ILogWrapper, pathWrapper: SnowCatPathWrapper, Subscription: ISubscription, statusSubscription: IStatusHelperSubscription) { + webUiPathWrapper = pathWrapper; + logSubscription = Subscription; + statusHelperSubscription = statusSubscription; + webUiLogger = logger; + WebUiConfig = new WebUiConfigWrapper(); + let config = await WebUiConfig.GetWebUIConfig(); + + // 检查是否禁用WebUI(若禁用则不进行密码检测) + if (config.disableWebUI) { + logger.log('[SnowCat] [WebUi] WebUI is disabled by configuration.'); + return; + } + + // 优先使用环境变量覆盖 Token + if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) { + await WebUiConfig.UpdateWebUIConfig({ token: process.env['NAPCAT_WEBUI_SECRET_KEY'] }); + logger.log(`[SnowCat] [WebUi] 检测到环境变量配置,已更新 WebUI Token 为 ${process.env['NAPCAT_WEBUI_SECRET_KEY']}`); + config = await WebUiConfig.GetWebUIConfig(); + } else if (config.token === 'napcat' || !config.token) { + // 只有没设置环境变量,且是默认密码时,才生成随机密码 + const randomToken = getRandomToken(8); + await WebUiConfig.UpdateWebUIConfig({ token: randomToken }); + logger.log('[SnowCat] [WebUi] 检测到默认密码,已自动更新为安全密码'); + + // 存储token到全局变量,等待QQ登录成功后发送 + setPendingTokenToSend(randomToken); + logger.log('[SnowCat] [WebUi] 新密码将在QQ登录成功后发送给用户'); + + // 重新获取更新后的配置 + config = await WebUiConfig.GetWebUIConfig(); + } + + // 存储启动时的初始token用于鉴权 + setInitialWebUiToken(config.token); + + const [host, port, token] = await InitPort(config); + webUiRuntimePort = port; + if (port === 0) { + logger.log('[SnowCat] [WebUi] Current WebUi is not run.'); + return; + } + WebUiDataRuntime.setWebUiConfigQuickFunction( + async () => { + const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount(); + const resolveQuickPasswordMd5 = (): string | undefined => { + const quickPasswordMd5FromEnv = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim(); + if (quickPasswordMd5FromEnv) { + if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5FromEnv)) { + return quickPasswordMd5FromEnv.toLowerCase(); + } + console.log('[SnowCat] [WebUi] NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)'); + } + + const quickPassword = process.env['NAPCAT_QUICK_PASSWORD']; + if (typeof quickPassword === 'string' && quickPassword.length > 0) { + console.log('[SnowCat] [WebUi] 检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录'); + return createHash('md5').update(quickPassword, 'utf8').digest('hex'); + } + return undefined; + }; + if (!autoLoginAccount) { + return; + } + const quickPasswordMd5 = resolveQuickPasswordMd5(); + + try { + const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount); + if (result) { + console.log(`[SnowCat] [WebUi] 自动快速登录成功: ${autoLoginAccount}`); + return; + } + console.log(`[SnowCat] [WebUi] 自动快速登录失败: ${message || '未知错误'}`); + } catch (error) { + console.log('[SnowCat] [WebUi] 自动快速登录异常:' + error); + } + + if (!quickPasswordMd5) { + console.log(`[SnowCat] [WebUi] QQ ${autoLoginAccount} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),保持二维码登录兜底`); + return; + } + + try { + const { result, message, needCaptcha, needNewDevice } = await WebUiDataRuntime.requestPasswordLogin(autoLoginAccount, quickPasswordMd5); + if (result) { + console.log(`[SnowCat] [WebUi] 自动密码回退登录成功: ${autoLoginAccount}`); + return; + } + if (needCaptcha) { + console.log(`[SnowCat] [WebUi] 自动密码回退登录需要验证码,请在登录页面继续完成: ${autoLoginAccount}`); + return; + } + if (needNewDevice) { + console.log(`[SnowCat] [WebUi] 自动密码回退登录需要新设备验证,请在登录页面继续完成: ${autoLoginAccount}`); + return; + } + console.log(`[SnowCat] [WebUi] 自动密码回退登录失败: ${message || '未知错误'}`); + } catch (error) { + console.log('[SnowCat] [WebUi] 自动密码回退登录异常:' + error); + } + }); + // ------------注册中间件------------ + // 使用express的json中间件 + app.use(express.json()); + // 启用gzip压缩(对所有响应启用,阈值1KB) + app.use(compression({ + level: 6, // 压缩级别 1-9,6 是性能和压缩率的平衡点 + threshold: 1024, // 只压缩大于 1KB 的响应 + filter: (req, res) => { + // 不压缩 SSE 和 WebSocket 升级请求 + if (req.headers['accept'] === 'text/event-stream') { + return false; + } + // 使用默认过滤器 + return compression.filter(req, res); + }, + })); + + // CORS中间件 + // TODO: + app.use(cors); + + // 自定义字体文件路由 - 返回用户上传的字体文件 + app.use('/webui/fonts/CustomFont.woff', async (_req, res) => { + const fontPath = await WebUiConfig.GetWebUIFontPath(); + if (fontPath) { + res.sendFile(fontPath); + } else { + res.status(404).send('Custom font not found'); + } + }); + + // 如果是自定义色彩,构建一个css文件 + app.use('/files/theme.css', async (_req, res) => { + const theme = await WebUiConfig.GetTheme(); + const fontMode = theme.fontMode || 'system'; + + let css = ''; + + // 生成字体 @font-face + if (fontMode === 'aacute') { + css += ` +@font-face { + font-family: 'Aa偷吃可爱长大的'; + src: url('/webui/fonts/AaCute.woff') format('woff'); + font-display: swap; +} +`; + } else if (fontMode === 'custom') { + css += ` +@font-face { + font-family: 'CustomFont'; + src: url('/webui/fonts/CustomFont.woff') format('woff'); + font-display: swap; +} +`; + } + + // 生成颜色主题和字体变量 + css += ':root, .light, [data-theme="light"] {'; + for (const key in theme.light) { + css += `${key}: ${theme.light[key]};`; + } + // 添加字体变量 + if (fontMode === 'aacute') { + css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;"; + css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;"; + } else if (fontMode === 'custom') { + css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;"; + css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;"; + } else { + css += '--font-family-base: var(--font-family-fallbacks) !important;'; + css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;'; + } + css += '}'; + + css += '.dark, [data-theme="dark"] {'; + for (const key in theme.dark) { + css += `${key}: ${theme.dark[key]};`; + } + // 添加字体变量 + if (fontMode === 'aacute') { + css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;"; + css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;"; + } else if (fontMode === 'custom') { + css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;"; + css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;"; + } else { + css += '--font-family-base: var(--font-family-fallbacks) !important;'; + css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;'; + } + css += '}'; + + res.send(css); + }); + + // 动态生成 sw.js + app.get('/webui/sw.js', async (_req, res) => { + try { + // 读取模板文件 + let templatePath = resolve(__dirname, 'static', 'sw_template.js'); + if (!existsSync(templatePath)) { + templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js'); + } + + let swContent = readFileSync(templatePath, 'utf-8'); + + // 替换版本号 + // 使用 snowCatVersion,如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用 + // 用户要求控制 sw.js 版本,snowCatVersion 是核心控制点 + swContent = swContent.replace('{{VERSION}}', snowCatVersion); + + res.header('Content-Type', 'application/javascript'); + res.header('Service-Worker-Allowed', '/webui/'); + res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.send(swContent); + } catch (error) { + console.error('[SnowCat] [WebUi] Error generating sw.js', error); + res.status(500).send('Error generating service worker'); + } + }); + + // ------------中间件结束------------ + + // ------------挂载路由------------ + // 挂载静态路由(前端),路径为 /webui + app.use('/webui', express.static(pathWrapper.staticPath, { + maxAge: '1d', + })); + + // 插件内存静态资源路由(不需要鉴权) + // 路径格式: /plugin/:pluginId/mem/:urlPath/* + app.use('/plugin/:pluginId/mem', async (req, res) => { + const { pluginId } = req.params; + if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null; + if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' }); + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined; + if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + + const routerRegistry = pluginManager.getPluginRouter(pluginId); + const memoryRoutes = routerRegistry?.getMemoryStaticRoutes() || []; + + for (const { urlPath, files } of memoryRoutes) { + const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath; + if (req.path.startsWith(prefix)) { + const filePath = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || ''); + const memFile = files.find(f => ('/' + f.path.replace(/^\//, '')) === filePath); + if (memFile) { + try { + const content = typeof memFile.content === 'function' ? await memFile.content() : memFile.content; + res.setHeader('Content-Type', memFile.contentType || 'application/octet-stream'); + return res.send(content); + } catch (err) { + console.error(`[Plugin: ${pluginId}] Error serving memory file:`, err); + return res.status(500).json({ code: -1, message: 'Error serving memory file' }); + } + } + } + } + return res.status(404).json({ code: -1, message: 'Memory file not found' }); + }); + + // 插件无认证 API 路由(不需要鉴权) + // 路径格式: /plugin/:pluginId/api/* + app.use('/plugin/:pluginId/api', (req, res, next) => { + const { pluginId } = req.params; + if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null; + if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' }); + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined; + if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + + const routerRegistry = pluginManager.getPluginRouter(pluginId); + if (!routerRegistry || !routerRegistry.hasApiNoAuthRoutes()) { + return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered no-auth API routes` }); + } + + // 构建并执行插件无认证 API 路由 + const pluginRouter = routerRegistry.buildApiNoAuthRouter(); + return pluginRouter(req, res, next); + }); + + // 插件页面路由(不需要鉴权) + // 路径格式: /plugin/:pluginId/page/:pagePath + app.get('/plugin/:pluginId/page/:pagePath', (req, res) => { + const { pluginId, pagePath } = req.params; + if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null; + if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' }); + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined; + if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + + const routerRegistry = pluginManager.getPluginRouter(pluginId); + if (!routerRegistry || !routerRegistry.hasPages()) { + return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` }); + } + + const pages = routerRegistry.getPages(); + const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath); + if (!page) { + return res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` }); + } + + const pluginPath = routerRegistry.getPluginPath(); + if (!pluginPath) { + return res.status(500).json({ code: -1, message: 'Plugin path not available' }); + } + + const htmlFilePath = join(pluginPath, page.htmlFile); + if (!existsSync(htmlFilePath)) { + return res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` }); + } + + return res.sendFile(htmlFilePath); + }); + + // 插件文件系统静态资源路由(不需要鉴权) + // 路径格式: /plugin/:pluginId/files/* + app.use('/plugin/:pluginId/files', (req, res, next) => { + const { pluginId } = req.params; + if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null; + if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' }); + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined; + if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + + const routerRegistry = pluginManager.getPluginRouter(pluginId); + const staticRoutes = routerRegistry?.getStaticRoutes() || []; + + for (const { urlPath, localPath } of staticRoutes) { + const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath; + if (req.path.startsWith(prefix) || req.path === prefix.slice(0, -1)) { + const staticMiddleware = express.static(localPath, { maxAge: '1d' }); + const originalUrl = req.url; + req.url = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || ''); + return staticMiddleware(req, res, (err) => { + req.url = originalUrl; + err ? next(err) : next(); + }); + } + } + res.status(404).json({ code: -1, message: 'Static resource not found' }); + }); + + // 初始化WebSocket服务器 + const sslCerts = await checkCertificates(logger); + const isHttps = !!sslCerts; + const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app); + server.on('upgrade', (request, socket, head) => { + const url = new URL(request.url || '', `http://${request.headers.host}`); + + // 检查是否是调试 WebSocket 连接 + if (url.pathname.startsWith('/api/Debug/ws')) { + handleDebugWebSocket(request, socket, head); + } else { + // 默认为终端 WebSocket + terminalManager.initialize(request, socket, head, logger); + } + }); + // 挂载API接口 + app.use('/api', ALLRouter); + // 所有剩下的请求都转到静态页面 + const indexFile = join(pathWrapper.staticPath, 'index.html'); + + app.all(/\/webui\/(.*)/, (_req, res) => { + res.sendFile(indexFile); + }); + + // 初始服务(先放个首页) + app.all('/', (_req, res) => { + res.status(301).header('Location', '/webui').send(); + }); + + // 错误处理中间件,捕获multer的错误 + app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => { + if (err instanceof multer.MulterError) { + return sendError(res, err.message, true); + } + next(err); + }); + + // 全局错误处理中间件(非multer错误) + app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => { + sendError(res, 'An unknown error occurred.', true); + }); + + // ------------启动服务------------ + server.listen(port, host, async () => { + const searchParams = { token }; + logger.log(`[SnowCat] [WebUi] WebUi Token: ${token}`); + logger.log( + `[SnowCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}` + ); + if (host !== '') { + logger.log( + `[SnowCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}` + ); + } + }); + + // ------------Over!------------ +} + +async function tryUseHost (host: string): Promise { + return new Promise((resolve, reject) => { + try { + const server = net.createServer(); + server.on('listening', () => { + server.close(); + resolve(host); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRNOTAVAIL') { + reject(new Error('主机地址验证失败,可能为非本机地址')); + } else { + reject(new Error(`遇到错误: ${err.code}`)); + } + }); + + // 尝试监听 让系统随机分配一个端口 + server.listen(0, host); + } catch (error) { + // 这里捕获到的错误应该是启动服务器时的同步错误 + reject(new Error(`服务器启动时发生错误: ${error}`)); + } + }); +} + +async function tryUsePort (port: number, host: string, tryCount: number = 0, singleTry: boolean = false): Promise { + return new Promise((resolve, reject) => { + try { + const server = net.createServer(); + server.on('listening', () => { + server.close(); + resolve(port); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + if (singleTry) { + // 只尝试一次,端口被占用则直接失败 + reject(new Error(`端口 ${port} 已被占用`)); + } else if (tryCount < MAX_PORT_TRY) { + // 递归尝试下一个端口 + resolve(tryUsePort(port + 1, host, tryCount + 1, false)); + } else { + reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`)); + } + } else { + reject(new Error(`遇到错误: ${err.code}`)); + } + }); + + // 尝试监听端口 + server.listen(port, host); + } catch (error) { + // 这里捕获到的错误应该是启动服务器时的同步错误 + reject(new Error(`服务器启动时发生错误: ${error}`)); + } + }); +} diff --git a/packages/snowcat-webui-backend/package.json b/packages/snowcat-webui-backend/package.json new file mode 100644 index 0000000..ad48922 --- /dev/null +++ b/packages/snowcat-webui-backend/package.json @@ -0,0 +1,42 @@ +{ + "name": "snowcat-webui-backend", + "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": { + "@simplewebauthn/server": "^13.2.2", + "@sinclair/typebox": "^0.34.38", + "compressing": "^1.10.3", + "compression": "^1.8.1", + "express": "^5.0.0", + "express-rate-limit": "^7.5.0", + "json5": "^2.2.3", + "multer": "^2.0.1", + "snowcat-common": "workspace:*", + "snowcat-dpapi": "workspace:*", + "snowcat-pty": "workspace:*", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/compression": "^1.8.1", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^22.0.1", + "@types/ws": "^8.5.12" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/packages/snowcat-webui-backend/src/api/Auth.ts b/packages/snowcat-webui-backend/src/api/Auth.ts new file mode 100644 index 0000000..cc8f395 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Auth.ts @@ -0,0 +1,366 @@ +import { RequestHandler } from 'express'; +import { AuthHelper } from '@/snowcat-webui-backend/src/helper/SignToken'; +import { PasskeyHelper } from '@/snowcat-webui-backend/src/helper/PasskeyHelper'; +import { TotpHelper } from '@/snowcat-webui-backend/src/helper/TotpHelper'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { sendSuccess, sendError } from '@/snowcat-webui-backend/src/utils/response'; +import { isEmpty } from '@/snowcat-webui-backend/src/utils/check'; +import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/snowcat-webui-backend/index'; + +// 登录 - 支持2FA验证 +export const LoginHandler: RequestHandler = async (req, res) => { + // 获取WebUI配置 + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + // 获取请求体中的hash和totpCode + const { hash, totpCode } = req.body; + // 获取客户端IP + const clientIP = req.ip || req.socket.remoteAddress || ''; + + // 如果token为空,返回错误信息 + if (isEmpty(hash)) { + return sendError(res, 'token is empty'); + } + // 检查登录频率 + if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) { + return sendError(res, 'login rate limit'); + } + // 使用启动时缓存的token进行验证,而不是动态读取配置文件 + const initialToken = getInitialWebUiToken(); + if (!initialToken) { + return sendError(res, 'Server token not initialized'); + } + // 验证初始token hash是否等于提交的token hash + if (!AuthHelper.comparePasswordHash(initialToken, hash)) { + return sendError(res, 'token is invalid'); + } + + // 检查是否启用了2FA + if (WebUiConfigData.enable2FA && WebUiConfigData.totpSecret) { + // 如果没有提供验证码,返回要求验证码 + if (!totpCode) { + return sendSuccess(res, { + require2FA: true, + message: 'Please enter your authenticator code', + }); + } + + // 验证TOTP验证码 + if (!TotpHelper.verifyTotp(WebUiConfigData.totpSecret, totpCode)) { + return sendError(res, 'Invalid or expired code'); + } + + // 2FA验证成功,签发凭证 + const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(hash))).toString('base64'); + return sendSuccess(res, { + Credential: signCredential, + require2FA: false, + }); + } + + // 未启用2FA,直接签发凭证 + const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(hash))).toString('base64'); + return sendSuccess(res, { + Credential: signCredential, + }); +}; + +// 退出登录 +export const LogoutHandler: RequestHandler = async (req, res) => { + const authorization = req.headers.authorization; + try { + const CredentialBase64: string = authorization?.split(' ')[1] as string; + const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString()); + AuthHelper.revokeCredential(Credential); + return sendSuccess(res, 'Logged out successfully'); + } catch (_e) { + return sendError(res, 'Logout failed'); + } +}; + +// 检查登录状态 +export const checkHandler: RequestHandler = async (req, res) => { + // 获取请求头中的Authorization + const authorization = req.headers.authorization; + // 检查凭证 + try { + // 从Authorization中获取凭证 + const CredentialBase64: string = authorization?.split(' ')[1] as string; + // 解析凭证 + const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString()); + + // 检查凭证是否已被注销 + if (AuthHelper.isCredentialRevoked(Credential)) { + return sendError(res, 'Token has been revoked'); + } + + // 使用启动时缓存的token进行验证 + const initialToken = getInitialWebUiToken(); + if (!initialToken) { + return sendError(res, 'Server token not initialized'); + } + // 验证凭证是否在一小时内有效 + const valid = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential); + // 返回成功信息 + if (valid) return sendSuccess(res, null); + // 返回错误信息 + return sendError(res, 'Authorization Failed'); + } catch (_e) { + // 返回错误信息 + return sendError(res, 'Authorization Failed'); + } +}; + +// 修改密码(token) +export const UpdateTokenHandler: RequestHandler = async (req, res) => { + const { oldToken, newToken } = req.body; + const authorization = req.headers.authorization; + + if (isEmpty(newToken)) { + return sendError(res, 'newToken is empty'); + } + + // 强制要求旧密码 + if (isEmpty(oldToken)) { + return sendError(res, 'oldToken is required'); + } + + // 检查新旧密码是否相同 + if (oldToken === newToken) { + return sendError(res, '新密码不能与旧密码相同'); + } + + // 检查新密码强度 + if (newToken.length < 6) { + return sendError(res, '新密码至少需要6个字符'); + } + + // 检查是否包含字母 + if (!/[a-zA-Z]/.test(newToken)) { + return sendError(res, '新密码必须包含字母'); + } + + // 检查是否包含数字 + if (!/[0-9]/.test(newToken)) { + return sendError(res, '新密码必须包含数字'); + } + + try { + // 注销当前的Token + if (authorization) { + const CredentialBase64: string = authorization.split(' ')[1] as string; + const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString()); + AuthHelper.revokeCredential(Credential); + } + + // 使用启动时缓存的token进行验证 + const initialToken = getInitialWebUiToken(); + if (!initialToken) { + return sendError(res, 'Server token not initialized'); + } + if (initialToken !== oldToken) { + return sendError(res, '旧 token 不匹配'); + } + // 直接更新配置文件中的token,不需要通过WebUiConfig.UpdateToken方法 + await WebUiConfig.UpdateWebUIConfig({ token: newToken }); + // 更新内存中的缓存token,使新密码立即生效 + setInitialWebUiToken(newToken); + + return sendSuccess(res, 'Token updated successfully'); + } catch (e: any) { + return sendError(res, `Failed to update token: ${e.message}`); + } +}; + +// 生成Passkey注册选项 +export const GeneratePasskeyRegistrationOptionsHandler: RequestHandler = async (_req, res) => { + try { + // 使用固定用户ID,因为WebUI只有一个用户 + const userId = 'snowcat-user'; + const userName = 'SnowCat User'; + + // 从请求头获取host来确定RP_ID + const host = _req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + + const options = await PasskeyHelper.generateRegistrationOptions(userId, userName, rpId); + return sendSuccess(res, options); + } catch (error) { + return sendError(res, `Failed to generate registration options: ${(error as Error).message}`); + } +}; + +// 验证Passkey注册 +export const VerifyPasskeyRegistrationHandler: RequestHandler = async (req, res) => { + try { + const { response } = req.body; + if (!response) { + return sendError(res, 'Response is required'); + } + + const origin = req.get('origin') || req.protocol + '://' + req.get('host'); + const host = req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + const userId = 'snowcat-user'; + const verification = await PasskeyHelper.verifyRegistration(userId, response, origin, rpId); + + if (verification.verified) { + return sendSuccess(res, { verified: true }); + } else { + return sendError(res, 'Registration failed'); + } + } catch (error) { + return sendError(res, `Registration verification failed: ${(error as Error).message}`); + } +}; + +// 生成Passkey认证选项 +export const GeneratePasskeyAuthenticationOptionsHandler: RequestHandler = async (_req, res) => { + try { + const userId = 'snowcat-user'; + + if (!(await PasskeyHelper.hasPasskeys(userId))) { + return sendError(res, 'No passkeys registered'); + } + + // 从请求头获取host来确定RP_ID + const host = _req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + + const options = await PasskeyHelper.generateAuthenticationOptions(userId, rpId); + return sendSuccess(res, options); + } catch (error) { + return sendError(res, `Failed to generate authentication options: ${(error as Error).message}`); + } +}; + +// 验证Passkey认证 +export const VerifyPasskeyAuthenticationHandler: RequestHandler = async (req, res) => { + try { + const { response } = req.body; + if (!response) { + return sendError(res, 'Response is required'); + } + + // 获取WebUI配置用于限速检查 + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + // 获取客户端IP + const clientIP = req.ip || req.socket.remoteAddress || ''; + + // 检查登录频率 + if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) { + return sendError(res, 'login rate limit'); + } + + const origin = req.get('origin') || req.protocol + '://' + req.get('host'); + const host = req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + const userId = 'snowcat-user'; + const verification = await PasskeyHelper.verifyAuthentication(userId, response, origin, rpId); + + if (verification.verified) { + // 使用与普通登录相同的凭证签发 + const initialToken = getInitialWebUiToken(); + if (!initialToken) { + return sendError(res, 'Server token not initialized'); + } + const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(AuthHelper.generatePasswordHash(initialToken)))).toString('base64'); + return sendSuccess(res, { + Credential: signCredential, + }); + } else { + return sendError(res, 'Authentication failed'); + } + } catch (error) { + return sendError(res, `Authentication verification failed: ${(error as Error).message}`); + } +}; + +// 获取2FA状态 +export const Get2FAStatusHandler: RequestHandler = async (_req, res) => { + try { + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + return sendSuccess(res, { + enable2FA: WebUiConfigData.enable2FA || false, + hasSecret: !!WebUiConfigData.totpSecret, + }); + } catch (error) { + return sendError(res, `获取2FA状态失败: ${(error as Error).message}`); + } +}; + +// 生成2FA密钥 +export const Generate2FASecretHandler: RequestHandler = async (_req, res) => { + try { + const secret = TotpHelper.generateSecret(); + const qrCodeUrl = TotpHelper.generateQrCodeUrl(secret, 'SnowCat WebUI', 'SnowCat'); + + return sendSuccess(res, { + secret, + qrCodeUrl, + }); + } catch (error) { + return sendError(res, `Failed to generate 2FA secret: ${(error as Error).message}`); + } +}; + +// 启用2FA +export const Enable2FAHandler: RequestHandler = async (req, res) => { + try { + const { secret, totpCode } = req.body; + + if (!secret || !totpCode) { + return sendError(res, 'secret and totpCode are required'); + } + + if (!TotpHelper.verifyTotp(secret, totpCode)) { + return sendError(res, 'Invalid code'); + } + + await WebUiConfig.UpdateWebUIConfig({ + enable2FA: true, + totpSecret: secret, + }); + + return sendSuccess(res, { message: '2FA enabled' }); + } catch (error) { + return sendError(res, `Failed to enable 2FA: ${(error as Error).message}`); + } +}; + +// 禁用2FA - 需要验证当前TOTP代码 +export const Disable2FAHandler: RequestHandler = async (req, res) => { + try { + const { totpCode } = req.body; + + if (!totpCode) { + return sendError(res, 'totpCode is required to disable 2FA'); + } + + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + if (!WebUiConfigData.totpSecret) { + return sendError(res, '2FA is not enabled'); + } + + // 验证当前TOTP代码 + if (!TotpHelper.verifyTotp(WebUiConfigData.totpSecret, totpCode)) { + return sendError(res, 'Invalid code'); + } + + await WebUiConfig.UpdateWebUIConfig({ + enable2FA: false, + totpSecret: '', + }); + + return sendSuccess(res, { message: '2FA disabled' }); + } catch (error) { + return sendError(res, `Failed to disable 2FA: ${(error as Error).message}`); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/BackupConfig.ts b/packages/snowcat-webui-backend/src/api/BackupConfig.ts new file mode 100644 index 0000000..296fa1a --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/BackupConfig.ts @@ -0,0 +1,149 @@ +import { RequestHandler } from 'express'; +import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join, normalize } from 'node:path'; +import { webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import compressing from 'compressing'; +import { Readable } from 'node:stream'; + +// 使用compressing库进行流式压缩导出 +export const BackupExportConfigHandler: RequestHandler = async (_req, res) => { + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (!isLogin) { + return sendError(res, 'Not Login'); + } + + try { + const configPath = webUiPathWrapper.configPath; + + if (!existsSync(configPath)) { + return sendError(res, '配置目录不存在'); + } + + const formatDate = (date: Date) => { + return date.toISOString().replace(/[:.]/g, '-'); + }; + const zipFileName = `config_backup_${formatDate(new Date())}.zip`; + + // 设置响应头 + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`); + + // 使用compressing的Stream API进行流式压缩 + const stream = new compressing.zip.Stream(); + + // 添加目录下的所有文件到压缩流(单层平坦结构) + const entries = readdirSync(configPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + const entryPath = join(configPath, entry.name); + stream.addEntry(entryPath, { relativePath: entry.name }); + } + } + + // 管道传输到响应 + stream.pipe(res); + + // 处理流错误 + stream.on('error', (err) => { + console.error('压缩流错误:', err); + if (!res.headersSent) { + sendError(res, '流式压缩失败'); + } + }); + } catch (error) { + const msg = (error as Error).message; + console.error('导出配置失败:', error); + if (!res.headersSent) { + return sendError(res, `导出配置失败: ${msg}`); + } + } +}; + +// 从内存Buffer流式解压,返回文件名和内容的映射 +async function extractZipToMemory (buffer: Buffer): Promise> { + return new Promise((resolve, reject) => { + const files = new Map(); + const bufferStream = Readable.from(buffer); + const uncompressStream = new compressing.zip.UncompressStream(); + + uncompressStream.on('entry', (header, stream, next) => { + // 只处理文件,忽略目录 + if (header.type === 'file') { + const chunks: Buffer[] = []; + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + stream.on('end', () => { + // 取文件名(忽略路径中的目录部分) + const fileName = header.name.split('/').pop() || header.name; + files.set(fileName, Buffer.concat(chunks)); + next(); + }); + stream.on('error', (err) => { + console.error(`读取文件失败: ${header.name}`, err); + next(); + }); + } else { + stream.resume(); + next(); + } + }); + + uncompressStream.on('finish', () => resolve(files)); + uncompressStream.on('error', reject); + + bufferStream.pipe(uncompressStream); + }); +} + +// 导入配置 - 流式处理,完全在内存中解压 +export const BackupImportConfigHandler: RequestHandler = async (req, res) => { + // 检查是否有文件上传(multer memoryStorage 模式下文件在 req.file.buffer) + if (!req.file || !req.file.buffer) { + return sendError(res, '请选择要导入的配置文件'); + } + + try { + const configPath = webUiPathWrapper.configPath; + + // 从内存中解压zip + const extractedFiles = await extractZipToMemory(req.file.buffer); + + if (extractedFiles.size === 0) { + return sendError(res, '配置文件为空或格式不正确'); + } + + // 备份当前配置到内存 + const backupFiles = new Map(); + if (existsSync(configPath)) { + const currentFiles = readdirSync(configPath, { withFileTypes: true }); + for (const entry of currentFiles) { + if (entry.isFile()) { + const filePath = join(configPath, entry.name); + backupFiles.set(entry.name, readFileSync(filePath)); + } + } + } + + // 写入新的配置文件 + for (const [fileName, content] of extractedFiles) { + // 防止路径穿越攻击 + const destPath = join(configPath, fileName); + const normalizedPath = normalize(destPath); + if (!normalizedPath.startsWith(normalize(configPath))) { + continue; + } + writeFileSync(destPath, content); + } + + return sendSuccess(res, { + message: '配置导入成功,重启后生效~', + filesImported: extractedFiles.size, + filesBackedUp: backupFiles.size, + }); + } catch (error) { + console.error('导入配置失败:', error); + const msg = (error as Error).message; + return sendError(res, `导入配置失败: ${msg}`); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/BaseInfo.ts b/packages/snowcat-webui-backend/src/api/BaseInfo.ts new file mode 100644 index 0000000..927d484 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/BaseInfo.ts @@ -0,0 +1,201 @@ +import { RequestHandler } from 'express'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; + +import { sendSuccess, sendError } from '@/snowcat-webui-backend/src/utils/response'; +import { WebUiConfig, webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import { getLatestTag, getAllTags, compareSemVer } from 'snowcat-common/src/helper'; +import { getLatestActionArtifacts, getMirrorConfig } from '@/snowcat-common/src/mirror'; +import { SnowCatCoreWorkingEnv } from '@/snowcat-webui-backend/src/types'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +export const GetNapCatVersion: RequestHandler = (_, res) => { + const data = WebUiDataRuntime.GetNapCatVersion(); + sendSuccess(res, { version: data }); +}; + +export const getLatestTagHandler: RequestHandler = async (_, res) => { + try { + const latestTag = await getLatestTag(); + sendSuccess(res, latestTag); + } catch { + // GitHub 不可达 / 被限流 / 无 tag 时不应把接口打成 500: + // 返回 200 + null,前端按“版本未知”降级展示,也不污染控制台。 + sendSuccess(res, null, 'latest tag unavailable'); + } +}; + +/** + * 版本信息接口 + */ +export interface VersionInfo { + tag: string; + type: 'release' | 'prerelease' | 'action'; + /** Action artifact 专用字段 */ + artifactId?: number; + artifactName?: string; + createdAt?: string; + expiresAt?: string; + size?: number; + workflowRunId?: number; + headSha?: string; + workflowTitle?: string; +} + +/** + * 获取所有可用的版本(release + action artifacts) + * 支持分页,懒加载:根据 type 参数只获取需要的版本类型 + */ +export const getAllReleasesHandler: RequestHandler = async (req, res) => { + try { + const page = parseInt(req.query['page'] as string) || 1; + const pageSize = parseInt(req.query['pageSize'] as string) || 20; + const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all' + const searchQuery = (req.query['search'] as string || '').toLowerCase().trim(); + const mirror = req.query['mirror'] as string | undefined; + + let versions: VersionInfo[] = []; + let actionVersions: VersionInfo[] = []; + let usedMirror = ''; + + // If mirror is specified, report it as used (will be confirmed by actual fetching response) + if (mirror) { + usedMirror = mirror; + } + + // 懒加载:只获取需要的版本类型 + const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release'; + const needActions = typeFilter === 'action' || typeFilter === 'all'; + + // 获取正式版本(仅当需要时) + if (needReleases) { + try { + const result = await getAllTags(mirror); + // 如果没有指定镜像,使用实际上使用的镜像 + if (!mirror) { + usedMirror = result.mirror; + } + + versions = result.tags.map(tag => { + const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag); + return { + tag, + type: isPrerelease ? 'prerelease' : 'release', + } as VersionInfo; + }); + + // 使用语义化版本排序(最新的在前) + versions.sort((a, b) => -compareSemVer(a.tag, b.tag)); + } catch { + // 如果获取 tags 失败,返回空列表而不是抛出错误 + versions = []; + } + } + + // 获取 Action Artifacts(仅当需要时) + if (needActions) { + try { + const { artifacts, mirror: actionMirror } = await getLatestActionArtifacts('DandanLLab', 'SnowCatQQ', 'build.yml', 'main', 10, mirror); + + // 根据当前工作环境自动过滤对应的 artifact 类型 + const isFramework = WebUiDataRuntime.getWorkingEnv() === SnowCatCoreWorkingEnv.Framework; + const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell'; + + // 如果没有指定镜像,且 action 实际上用了一个镜像(自动选择的),更新 usedMirror + if (!mirror && actionMirror) { + usedMirror = actionMirror; + } + + actionVersions = artifacts + .filter(a => a && a.name === targetArtifactName) + .map(a => ({ + tag: `action-${a.id}`, + type: 'action' as const, + artifactId: a.id, + artifactName: a.name, + createdAt: a.created_at, + expiresAt: a.expires_at, + size: a.size_in_bytes, + workflowRunId: a.workflow_run_id, + headSha: a.head_sha, + workflowTitle: a.workflow_title, + })); + } catch { + // 获取失败时返回空列表 + actionVersions = []; + } + } + + // 合并版本列表(action 在最前面) + let allVersions = [...actionVersions, ...versions]; + + // 搜索过滤 + if (searchQuery) { + allVersions = allVersions.filter(v => { + const tagMatch = v.tag.toLowerCase().includes(searchQuery); + const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery); + const titleMatch = v.workflowTitle?.toLowerCase().includes(searchQuery); + const shaMatch = v.headSha?.toLowerCase().includes(searchQuery); + return tagMatch || nameMatch || titleMatch || shaMatch; + }); + } + + // 分页 + const total = allVersions.length; + const totalPages = Math.ceil(total / pageSize); + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedVersions = allVersions.slice(start, end); + + sendSuccess(res, { + versions: paginatedVersions, + pagination: { + page, + pageSize, + total, + totalPages, + }, + mirror: usedMirror, + }); + } catch (_error) { + res.status(500).json({ error: 'Failed to fetch releases' }); + } +}; + +export const QQVersionHandler: RequestHandler = (_, res) => { + const data = WebUiDataRuntime.getQQVersion(); + sendSuccess(res, data); +}; + +export const GetThemeConfigHandler: RequestHandler = async (_, res) => { + const data = await WebUiConfig.GetTheme(); + sendSuccess(res, data); +}; + +export const SetThemeConfigHandler: RequestHandler = async (req, res) => { + const { theme } = req.body; + await WebUiConfig.UpdateTheme(theme); + sendSuccess(res, { message: '更新成功' }); +}; + +export const GetMirrorsHandler: RequestHandler = (_, res) => { + const config = getMirrorConfig(); + sendSuccess(res, { mirrors: config.fileMirrors }); +}; + +export const GetNapCatFileHashHandler: RequestHandler = (_, res) => { + try { + const filePath = path.join(webUiPathWrapper.binaryPath, 'napcat.mjs'); + if (!fs.existsSync(filePath)) { + // 便携版 / 开发环境下可能不存在该产物,属于预期情况,不应报错 + sendSuccess(res, { hash: null, file: 'napcat.mjs', algorithm: 'sha512', available: false }); + return; + } + const fileBuffer = fs.readFileSync(filePath); + const hash = crypto.createHash('sha512').update(fileBuffer).digest('hex'); + sendSuccess(res, { hash, file: 'napcat.mjs', algorithm: 'sha512', available: true }); + } catch (error) { + sendError(res, `无法计算 napcat.mjs 的 hash:${(error as Error).message}`); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/Debug.ts b/packages/snowcat-webui-backend/src/api/Debug.ts new file mode 100644 index 0000000..e85416a --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Debug.ts @@ -0,0 +1,452 @@ +import { Router, Request, Response } from 'express'; +import { WebSocket, WebSocketServer, RawData } from 'ws'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { IncomingMessage } from 'http'; +import { OB11Response, OneBotAction } from '@/snowcat-onebot/action/OneBotAction'; +import { ActionName } from '@/snowcat-onebot/action/router'; +import { OB11LifeCycleEvent, LifeCycleSubType } from '@/snowcat-onebot/event/meta/OB11LifeCycleEvent'; +import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter'; +import { WebsocketServerConfig } from '@/snowcat-onebot/config/config'; +import { ActionMap } from '@/snowcat-onebot/action'; +import { SnowCatCore } from '@/snowcat-core/index'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11EmitEventContent, OB11NetworkReloadType } from '@/snowcat-onebot/network/index'; + +import json5 from 'json5'; + +type ActionNameType = typeof ActionName[keyof typeof ActionName]; + +const router: Router = Router(); +const DEFAULT_ADAPTER_NAME = 'debug-primary'; + +/** + * 获取所有 Action 的 Schema 信息 + */ +router.get('/schemas', async (_req: Request, res: Response) => { + try { + const obContext = WebUiDataRuntime.getOneBotContext(); + if (!obContext) { + return sendError(res, 'OneBot 未初始化'); + } + const schemas: Record = {}; + + // 遍历 ActionName 中定义的所有路由 + for (const key in ActionName) { + const actionName = (ActionName as any)[key]; + if (actionName === ActionName.Unknown) continue; + + const handler = obContext.actions.get(actionName); + if (handler) { + const action = handler as OneBotAction; + schemas[actionName] = { + description: action.actionSummary || action.actionDescription, + payload: action.payloadSchema, + response: action.returnSchema, + payloadExample: action.payloadExample, + tags: action.actionTags, + }; + } + } + + sendSuccess(res, schemas); + } catch (error: unknown) { + sendError(res, (error as Error).message); + } +}); + +/** + * 统一的调试适配器 + * 用于注入到 OneBot NetworkManager,接收所有事件并转发给 WebSocket 客户端 + */ +class DebugAdapter extends IOB11NetworkAdapter { + readonly token: string; + wsClients: WebSocket[] = []; + wsClientWithEvent: WebSocket[] = []; + lastActivityTime: number = Date.now(); + inactivityTimer: NodeJS.Timeout | null = null; + readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃 + + override get isActive (): boolean { + return this.isEnable && this.wsClientWithEvent.length > 0; + } + + constructor (sessionId: string, core: SnowCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) { + const config: WebsocketServerConfig = { + enable: true, + name: `debug-${sessionId}`, + host: '127.0.0.1', + port: 0, + messagePostFormat: 'array', + reportSelfMessage: true, + token: '', + enableForcePushEvent: true, + debug: true, + heartInterval: 0, + }; + + super(`debug-${sessionId}`, config, core, obContext, actions); + this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2); + this.isEnable = false; + this.startInactivityCheck(); + } + + async open (): Promise { + if (this.isEnable) { + this.logger.logError('[Debug] Cannot open an already opened adapter'); + return; + } + this.logger.log('[Debug] Adapter opened:', this.name); + this.isEnable = true; + } + + async close (): Promise { + if (!this.isEnable) { + return; + } + this.logger.log('[Debug] Adapter closing:', this.name); + this.isEnable = false; + + // 停止不活跃检查定时器 + if (this.inactivityTimer) { + clearInterval(this.inactivityTimer); + this.inactivityTimer = null; + } + + // 关闭所有 WebSocket 连接并移除事件监听器 + this.wsClients.forEach((client) => { + try { + client.removeAllListeners(); + client.close(); + } catch (error) { + this.logger.logError('[Debug] 关闭 WebSocket 失败:', error); + } + }); + this.wsClients = []; + this.wsClientWithEvent = []; + } + + async reload (_config: unknown): Promise { + return OB11NetworkReloadType.NetWorkReload; + } + + async onEvent (event: T): Promise { + this.updateActivity(); + + const payload = JSON.stringify(event); + this.wsClientWithEvent.forEach((wsClient) => { + if (wsClient.readyState === WebSocket.OPEN) { + try { + wsClient.send(payload); + } catch (error) { + this.logger.logError('[Debug] 发送事件失败:', error); + } + } + }); + } + + async callApi (actionName: ActionNameType, params: Record): Promise { + this.updateActivity(); + + const action = this.actions.get(actionName as Parameters[0]); + if (!action) { + throw new Error(`不支持的 API: ${actionName}`); + } + + type ActionHandler = { handle: (params: unknown, ...args: unknown[]) => Promise; }; + return await (action as ActionHandler).handle(params, this.name, this.config); + } + + private async handleMessage (wsClient: WebSocket, message: RawData): Promise { + this.updateActivity(); + let receiveData: { action: ActionNameType, params?: Record, echo?: unknown; } = { + action: ActionName.Unknown, + params: {}, + }; + let echo: unknown; + + try { + receiveData = json5.parse(message.toString()); + echo = receiveData.echo; + } catch { + this.sendToClient(wsClient, OB11Response.error('json解析失败,请检查数据格式', 1400, echo)); + return; + } + + receiveData.params = receiveData?.params || {}; + + const action = this.actions.get(receiveData.action as Parameters[0]); + if (!action) { + this.logger.logError('[Debug] 不支持的API:', receiveData.action); + this.sendToClient(wsClient, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo)); + return; + } + + try { + type ActionHandler = { websocketHandle: (params: unknown, ...args: unknown[]) => Promise; }; + const retdata = await (action as ActionHandler).websocketHandle(receiveData.params, echo ?? '', this.name, this.config, { + send: async (data: object) => { + this.sendToClient(wsClient, OB11Response.ok(data, echo ?? '', true)); + }, + }); + this.sendToClient(wsClient, retdata); + } catch (e: unknown) { + const error = e as Error; + this.logger.logError('[Debug] 处理消息失败:', error); + this.sendToClient(wsClient, OB11Response.error(error.message || '内部错误', 1200, echo)); + } + } + + private sendToClient (wsClient: WebSocket, data: unknown): void { + if (wsClient.readyState === WebSocket.OPEN) { + try { + wsClient.send(JSON.stringify(data)); + } catch (error) { + this.logger.logError('[Debug] 发送消息失败:', error); + } + } + } + + async addWsClient (ws: WebSocket): Promise { + this.wsClientWithEvent.push(ws); + this.wsClients.push(ws); + this.updateActivity(); + + // 发送连接事件 + this.sendToClient(ws, new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT)); + + ws.on('error', (err) => this.logger.log('[Debug] WebSocket Error:', err.message)); + ws.on('message', (message) => { + this.handleMessage(ws, message).catch((e: unknown) => { + this.logger.logError('[Debug] handleMessage error:', e); + }); + }); + ws.on('ping', () => ws.pong()); + ws.once('close', () => this.removeWsClient(ws)); + } + + private removeWsClient (ws: WebSocket): void { + const normalIndex = this.wsClients.indexOf(ws); + if (normalIndex !== -1) { + this.wsClients.splice(normalIndex, 1); + } + const eventIndex = this.wsClientWithEvent.indexOf(ws); + if (eventIndex !== -1) { + this.wsClientWithEvent.splice(eventIndex, 1); + } + } + + updateActivity (): void { + this.lastActivityTime = Date.now(); + } + + startInactivityCheck (): void { + this.inactivityTimer = setInterval(() => { + const inactive = Date.now() - this.lastActivityTime; + if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.length === 0) { + this.logger.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`); + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + if (oneBotContext) { + // 先从管理器移除,避免重复销毁 + debugAdapterManager.removeAdapter(this.name); + // 使用 NetworkManager 标准流程关闭 + oneBotContext.networkManager.closeSomeAdapters([this]).catch((e: unknown) => { + this.logger.logError('[Debug] 自动关闭适配器失败:', e); + }); + } + } + }, 30000); + } + + validateToken (inputToken: string): boolean { + return this.token === inputToken; + } +} + +/** + * 调试适配器管理器(单例管理) + */ +class DebugAdapterManager { + private currentAdapter: DebugAdapter | null = null; + + getOrCreateAdapter (): DebugAdapter { + // 如果已存在且活跃,直接返回 + if (this.currentAdapter) { + this.currentAdapter.updateActivity(); + return this.currentAdapter; + } + + // 获取 OneBot 上下文 + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + if (!oneBotContext) { + throw new Error('OneBot 未初始化,无法创建调试适配器'); + } + + // 创建新实例 + const adapter = new DebugAdapter('primary', oneBotContext.core, oneBotContext, oneBotContext.actions); + this.currentAdapter = adapter; + + // 使用 NetworkManager 标准流程注册并打开适配器 + oneBotContext.networkManager.registerAdapterAndOpen(adapter).catch((e: unknown) => { + console.error('[Debug] 注册适配器失败:', e); + }); + + return adapter; + } + + getAdapter (name: string): DebugAdapter | undefined { + if (this.currentAdapter && this.currentAdapter.name === name) { + return this.currentAdapter; + } + return undefined; + } + + removeAdapter (name: string) { + if (this.currentAdapter && this.currentAdapter.name === name) { + this.currentAdapter = null; + } + } +} + +const debugAdapterManager = new DebugAdapterManager(); + +/** + * 获取或创建调试会话 + */ +router.post('/create', async (_req: Request, res: Response) => { + try { + const adapter = debugAdapterManager.getOrCreateAdapter(); + sendSuccess(res, { + adapterName: adapter.name, + token: adapter.token, + message: '调试适配器已就绪', + }); + } catch (error: unknown) { + const err = error as Error; + sendError(res, err.message); + } +}); + +/** + * HTTP 调用 OneBot API (支持默认 adapter) + */ +const handleCallApi = async (req: Request, res: Response) => { + try { + const adapterName = req.params['adapterName'] || req.body.adapterName || DEFAULT_ADAPTER_NAME; + + let adapter = debugAdapterManager.getAdapter(adapterName); + + // 如果是默认 adapter 且不存在,尝试创建 + if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) { + adapter = debugAdapterManager.getOrCreateAdapter(); + } + + if (!adapter) { + return sendError(res, '调试适配器不存在'); + } + + const { action, params } = req.body; + const result = await adapter.callApi(action as ActionNameType, params || {}); + sendSuccess(res, result); + } catch (error: unknown) { + const err = error as Error; + sendError(res, err.message); + } +}; + +router.post('/call/:adapterName', handleCallApi); +router.post('/call', handleCallApi); + +/** + * 关闭调试适配器 + */ +router.post('/close/:adapterName', async (req: Request, res: Response) => { + try { + const { adapterName } = req.params; + if (!adapterName) { + return sendError(res, '缺少 adapterName 参数'); + } + + const adapter = debugAdapterManager.getAdapter(adapterName); + if (!adapter) { + return sendError(res, '调试适配器不存在'); + } + + // 先从管理器移除,避免重复销毁 + debugAdapterManager.removeAdapter(adapterName); + + // 使用 NetworkManager 标准流程关闭适配器 + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + if (oneBotContext) { + await oneBotContext.networkManager.closeSomeAdapters([adapter]); + } + + sendSuccess(res, { message: '调试适配器已关闭' }); + } catch (error: unknown) { + const err = error as Error; + sendError(res, err.message); + } +}); + +/** + * WebSocket 连接处理 + * 路径: /api/Debug/ws?adapterName=xxx&token=xxx + */ +export function handleDebugWebSocket (request: IncomingMessage, socket: unknown, head: unknown) { + const url = new URL(request.url || '', `http://${request.headers.host}`); + let adapterName = url.searchParams.get('adapterName'); + const token = url.searchParams.get('token') || url.searchParams.get('access_token'); + + // 默认 adapterName + if (!adapterName) { + adapterName = DEFAULT_ADAPTER_NAME; + } + + // Debug session should provide token + if (!token) { + console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token'); + (socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + (socket as { destroy: () => void; }).destroy(); + return; + } + + let adapter = debugAdapterManager.getAdapter(adapterName); + + // 如果是默认 adapter 且不存在,尝试创建 + if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) { + try { + adapter = debugAdapterManager.getOrCreateAdapter(); + } catch (error) { + console.log('[Debug] WebSocket 连接被拒绝: 无法创建适配器', error); + (socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); + (socket as { destroy: () => void; }).destroy(); + return; + } + } + + if (!adapter) { + console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在'); + (socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 404 Not Found\r\n\r\n'); + (socket as { destroy: () => void; }).destroy(); + return; + } + + if (!adapter.validateToken(token)) { + console.log('[Debug] WebSocket 连接被拒绝: Token 无效'); + (socket as { write: (data: string) => void; destroy: () => void; }).write('HTTP/1.1 403 Forbidden\r\n\r\n'); + (socket as { destroy: () => void; }).destroy(); + return; + } + + // 创建 WebSocket 服务器 + const wsServer = new WebSocketServer({ noServer: true }); + + wsServer.handleUpgrade(request, socket as never, head as Buffer, (ws) => { + adapter.addWsClient(ws).catch((e: unknown) => { + console.error('[Debug] 添加 WebSocket 客户端失败:', e); + ws.close(); + }); + }); +} + +export default router; diff --git a/packages/snowcat-webui-backend/src/api/File.ts b/packages/snowcat-webui-backend/src/api/File.ts new file mode 100644 index 0000000..d74b961 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/File.ts @@ -0,0 +1,665 @@ +import type { RequestHandler } from 'express'; +import { sendError, sendSuccess } from '../utils/response'; +import fsProm from 'fs/promises'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import compressing from 'compressing'; +import { PassThrough } from 'stream'; +import multer from 'multer'; +import webUIFontUploader from '../uploader/webui_font'; +import diskUploader from '../uploader/disk'; +import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/snowcat-webui-backend/index'; + +const isWindows = os.platform() === 'win32'; + +// 安全地从查询参数中提取字符串值,防止类型混淆 +const getQueryStringParam = (param: any): string => { + if (Array.isArray(param)) { + return String(param[0] || ''); + } + return String(param || ''); +}; + +// 获取系统根目录列表(Windows返回盘符列表,其他系统返回['/']) +const getRootDirs = async (): Promise => { + if (!isWindows) return ['/']; + + // Windows 驱动器字母 (A-Z) + const drives: string[] = []; + for (let i = 65; i <= 90; i++) { + const driveLetter = String.fromCharCode(i); + try { + await fsProm.access(`${driveLetter}:\\`); + drives.push(`${driveLetter}:`); + } catch { + // 如果驱动器不存在或无法访问,跳过 + continue; + } + } + return drives.length > 0 ? drives : ['C:']; +}; + +// 规范化路径并进行安全验证 +const normalizePath = (inputPath: string): string => { + if (!inputPath) { + // 对于空路径,Windows返回用户主目录,其他系统返回根目录 + return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/'; + } + + // 对输入路径进行清理,移除潜在的危险字符 + // eslint-disable-next-line no-control-regex + const cleanedPath = inputPath.replace(/[\x01-\x1f\x7f]/g, ''); // 移除控制字符 + + // 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\" + if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) { + return cleanedPath.slice(0, 2) + '\\'; + } + + // 安全验证:检查是否包含危险的路径遍历模式(在规范化之前) + if (containsPathTraversal(cleanedPath)) { + throw new Error('Invalid path: path traversal detected'); + } + + // 进行路径规范化 + const normalized = path.resolve(cleanedPath); + + // 再次检查规范化后的路径,确保没有绕过安全检查 + if (containsPathTraversal(normalized)) { + throw new Error('Invalid path: path traversal detected after normalization'); + } + + // 额外安全检查:确保规范化后的路径不包含连续的路径分隔符 + const finalPath = normalized.replace(/[\\/]+/g, path.sep); + + return finalPath; +}; + +// 检查路径是否包含路径遍历攻击模式 +const containsPathTraversal = (inputPath: string): boolean => { + // 对输入进行URL解码,防止编码绕过 + let decodedPath = inputPath; + try { + decodedPath = decodeURIComponent(inputPath); + } catch { + // 如果解码失败,使用原始路径 + } + + // 将路径统一为正斜杠格式进行检查 + const normalizedForCheck = decodedPath.replace(/\\/g, '/'); + + // 检查危险模式 - 更全面的路径遍历检测 + const dangerousPatterns = [ + /\.\.\//, // ../ 模式 + /\/\.\./, // /.. 模式 + /^\.\./, // 以.. 开头 + /\.\.$/, // 以.. 结尾 + /\.\.\\/, // ..\ 模式(Windows) + /\\\.\./, // \.. 模式(Windows) + /%2e%2e/i, // URL编码的.. + /%252e%252e/i, // 双重URL编码的.. + // eslint-disable-next-line no-control-regex + /\.\.\x00/, // null字节攻击 + /\0/, // null字节 + ]; + + return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck)); +}; + +interface FileInfo { + name: string; + isDirectory: boolean; + size: number; + mtime: Date; +} + +// 添加系统文件黑名单 +const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']); + +// 检查是否为WebUI配置文件 +const isWebUIConfigFile = (filePath: string): boolean => { + // 先用字符串快速筛选 + if (!filePath.includes('webui.json')) { + return false; + } + + // 进入更严格的路径判断 - 统一路径分隔符为 / + const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/'); + const targetPath = path.resolve(filePath).replace(/\\/g, '/'); + + // 统一分隔符后进行路径比较 + return targetPath === webUIConfigPath; +}; + +// WebUI配置文件脱敏处理 +const sanitizeWebUIConfig = (content: string): string => { + try { + const config = JSON.parse(content); + if (config.token) { + config.token = '******'; + } + return JSON.stringify(config, null, 4); + } catch { + // 如果解析失败,返回原内容 + return content; + } +}; + +// 检查同类型的文件或目录是否存在 +const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise => { + try { + const stat = await fsProm.stat(pathToCheck); + // 只有当类型相同时才认为是冲突 + return stat.isDirectory() === isDirectory; + } catch { + return false; + } +}; + +// 获取目录内容 +export const ListFilesHandler: RequestHandler = async (req, res) => { + try { + const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/'); + + let normalizedPath: string; + try { + normalizedPath = normalizePath(requestPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + const onlyDirectory = req.query['onlyDirectory'] === 'true'; + + // 如果是根路径且在Windows系统上,返回盘符列表 + if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) { + const drives = await getRootDirs(); + const driveInfos: FileInfo[] = await Promise.all( + drives.map(async (drive) => { + try { + const stat = await fsProm.stat(`${drive}\\`); + return { + name: drive, + isDirectory: true, + size: 0, + mtime: stat.mtime, + }; + } catch { + return { + name: drive, + isDirectory: true, + size: 0, + mtime: new Date(), + }; + } + }) + ); + return sendSuccess(res, driveInfos); + } + + const files = await fsProm.readdir(normalizedPath); + let fileInfos: FileInfo[] = []; + + for (const file of files) { + // 跳过系统文件 + if (SYSTEM_FILES.has(file)) continue; + + try { + const fullPath = path.join(normalizedPath, file); + const stat = await fsProm.stat(fullPath); + fileInfos.push({ + name: file, + isDirectory: stat.isDirectory(), + size: stat.size, + mtime: stat.mtime, + }); + } catch (_error) { + // 忽略无法访问的文件 + // console.warn(`无法访问文件 ${file}:`, error); + continue; + } + } + + // 如果请求参数 onlyDirectory 为 true,则只返回目录信息 + if (onlyDirectory) { + fileInfos = fileInfos.filter((info) => info.isDirectory); + } + + return sendSuccess(res, fileInfos); + } catch (_error) { + console.error('读取目录失败:', _error); + return sendError(res, '读取目录失败'); + } +}; + +// 创建目录 +export const CreateDirHandler: RequestHandler = async (req, res) => { + try { + const { path: dirPath } = req.body; + + let normalizedPath: string; + try { + normalizedPath = normalizePath(dirPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + // 检查是否已存在同类型(目录) + if (await checkSameTypeExists(normalizedPath, true)) { + return sendError(res, '同名目录已存在'); + } + + await fsProm.mkdir(normalizedPath, { recursive: true }); + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '创建目录失败'); + } +}; + +// 删除文件/目录 +export const DeleteHandler: RequestHandler = async (req, res) => { + try { + const { path: targetPath } = req.body; + + let normalizedPath: string; + try { + normalizedPath = normalizePath(targetPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + const stat = await fsProm.stat(normalizedPath); + if (stat.isDirectory()) { + await fsProm.rm(normalizedPath, { recursive: true }); + } else { + await fsProm.unlink(normalizedPath); + } + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '删除失败'); + } +}; + +// 批量删除文件/目录 +export const BatchDeleteHandler: RequestHandler = async (req, res) => { + try { + const { paths } = req.body; + for (const targetPath of paths) { + let normalizedPath: string; + try { + normalizedPath = normalizePath(targetPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + const stat = await fsProm.stat(normalizedPath); + if (stat.isDirectory()) { + await fsProm.rm(normalizedPath, { recursive: true }); + } else { + await fsProm.unlink(normalizedPath); + } + } + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '批量删除失败'); + } +}; + +// 读取文件内容 +export const ReadFileHandler: RequestHandler = async (req, res) => { + try { + let filePath: string; + try { + filePath = normalizePath(getQueryStringParam(req.query['path'])); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(filePath)) { + return sendError(res, '路径必须是绝对路径'); + } + + let content = await fsProm.readFile(filePath, 'utf-8'); + + // 如果是WebUI配置文件,对token进行脱敏处理 + if (isWebUIConfigFile(filePath)) { + content = sanitizeWebUIConfig(content); + } + + return sendSuccess(res, content); + } catch (_error) { + return sendError(res, '读取文件失败'); + } +}; + +// 写入文件内容 +export const WriteFileHandler: RequestHandler = async (req, res) => { + try { + const { path: filePath, content } = req.body; + + // 安全的路径规范化,如果检测到路径遍历攻击会抛出异常 + let normalizedPath: string; + try { + normalizedPath = normalizePath(filePath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + let finalContent = content; + + // 检查是否为WebUI配置文件 + if (isWebUIConfigFile(normalizedPath)) { + try { + // 解析要写入的配置 + const configToWrite = JSON.parse(content); + // 获取内存中的token,覆盖前端传来的token + const memoryToken = getInitialWebUiToken(); + if (memoryToken) { + configToWrite.token = memoryToken; + finalContent = JSON.stringify(configToWrite, null, 4); + } + } catch (_e) { + // 如果解析失败 说明不符合json格式 不允许写入 + return sendError(res, '写入的WebUI配置文件内容格式错误,必须是合法的JSON'); + } + } + + await fsProm.writeFile(normalizedPath, finalContent, 'utf-8'); + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '写入文件失败'); + } +}; + +// 创建新文件 +export const CreateFileHandler: RequestHandler = async (req, res) => { + try { + const { path: filePath } = req.body; + + let normalizedPath: string; + try { + normalizedPath = normalizePath(filePath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + // 检查是否已存在同类型(文件) + if (await checkSameTypeExists(normalizedPath, false)) { + return sendError(res, '同名文件已存在'); + } + + await fsProm.writeFile(normalizedPath, '', 'utf-8'); + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '创建文件失败'); + } +}; + +// 重命名文件/目录 +export const RenameHandler: RequestHandler = async (req, res) => { + try { + const { oldPath, newPath } = req.body; + + let normalizedOldPath: string; + let normalizedNewPath: string; + try { + normalizedOldPath = normalizePath(oldPath); + normalizedNewPath = normalizePath(newPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + await fsProm.rename(normalizedOldPath, normalizedNewPath); + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '重命名失败'); + } +}; + +// 移动文件/目录 +export const MoveHandler: RequestHandler = async (req, res) => { + try { + const { sourcePath, targetPath } = req.body; + + let normalizedSourcePath: string; + let normalizedTargetPath: string; + try { + normalizedSourcePath = normalizePath(sourcePath); + normalizedTargetPath = normalizePath(targetPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + await fsProm.rename(normalizedSourcePath, normalizedTargetPath); + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '移动失败'); + } +}; + +// 批量移动 +export const BatchMoveHandler: RequestHandler = async (req, res) => { + try { + const { items } = req.body; + for (const { sourcePath, targetPath } of items) { + let normalizedSourcePath: string; + let normalizedTargetPath: string; + try { + normalizedSourcePath = normalizePath(sourcePath); + normalizedTargetPath = normalizePath(targetPath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + await fsProm.rename(normalizedSourcePath, normalizedTargetPath); + } + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '批量移动失败'); + } +}; + +// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存) +export const DownloadHandler: RequestHandler = async (req, res) => { + try { + let filePath: string; + try { + filePath = normalizePath(getQueryStringParam(req.query['path'])); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + if (!filePath) { + return sendError(res, '参数错误'); + } + + // 额外安全检查:确保路径是绝对路径 + if (!path.isAbsolute(filePath)) { + return sendError(res, '路径必须是绝对路径'); + } + + const stat = await fsProm.stat(filePath); + + res.setHeader('Content-Type', 'application/octet-stream'); + let filename = path.basename(filePath); + if (stat.isDirectory()) { + filename = path.basename(filePath) + '.zip'; + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`); + const zipStream = new PassThrough(); + compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => { + console.error('压缩目录失败:', err); + res.end(); + }); + zipStream.pipe(res); + return; + } + res.setHeader('Content-Length', stat.size); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`); + const stream = fs.createReadStream(filePath); + stream.pipe(res); + } catch (_error) { + return sendError(res, '下载失败'); + } +}; + +// 批量下载:将多个文件/目录打包为 zip 文件下载 +export const BatchDownloadHandler: RequestHandler = async (req, res) => { + try { + const { paths } = req.body as { paths: string[]; }; + if (!paths || !Array.isArray(paths) || paths.length === 0) { + return sendError(res, '参数错误'); + } + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', 'attachment; filename=files.zip'); + + const zipStream = new compressing.zip.Stream(); + // 修改:根据文件类型设置 relativePath + for (const filePath of paths) { + let normalizedPath: string; + try { + normalizedPath = normalizePath(filePath); + } catch (_pathError) { + return sendError(res, '无效的文件路径'); + } + + // 额外安全检查:确保规范化后的路径是绝对路径 + if (!path.isAbsolute(normalizedPath)) { + return sendError(res, '路径必须是绝对路径'); + } + + const stat = await fsProm.stat(normalizedPath); + if (stat.isDirectory()) { + zipStream.addEntry(normalizedPath, { relativePath: '' }); + } else { + // 确保相对路径只使用文件名,防止路径遍历 + const safeName = path.basename(normalizedPath); + zipStream.addEntry(normalizedPath, { relativePath: safeName }); + } + } + zipStream.pipe(res); + res.on('finish', () => { + zipStream.destroy(); + }); + } catch (_error) { + return sendError(res, '下载失败'); + } +}; + +// 修改上传处理方法 +export const UploadHandler: RequestHandler = async (req, res) => { + try { + await diskUploader(req, res); + return sendSuccess(res, true, '文件上传成功', true); + } catch (_error) { + let errorMessage = '文件上传失败'; + + if (_error instanceof multer.MulterError) { + switch (_error.code) { + case 'LIMIT_FILE_SIZE': + errorMessage = '文件大小超过限制(40MB)'; + break; + case 'LIMIT_UNEXPECTED_FILE': + errorMessage = '无效的文件上传字段'; + break; + default: + errorMessage = `上传错误: ${_error.message}`; + } + } else if (_error instanceof Error) { + errorMessage = _error.message; + } + return sendError(res, errorMessage, true); + } +}; + +// 上传WebUI字体文件处理方法 +export const UploadWebUIFontHandler: RequestHandler = async (req, res) => { + try { + await webUIFontUploader(req, res); + return sendSuccess(res, true, '字体文件上传成功', true); + } catch (error) { + let errorMessage = '字体文件上传失败'; + + if (error instanceof multer.MulterError) { + switch (error.code) { + case 'LIMIT_FILE_SIZE': + errorMessage = '字体文件大小超过限制(40MB)'; + break; + case 'LIMIT_UNEXPECTED_FILE': + errorMessage = '无效的文件上传字段'; + break; + default: + errorMessage = `上传错误: ${error.message}`; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } + return sendError(res, errorMessage, true); + } +}; + +// 删除WebUI字体文件处理方法 +export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => { + try { + const fontPath = await WebUiConfig.GetWebUIFontPath(); + const exists = await WebUiConfig.CheckWebUIFontExist(); + + if (!exists || !fontPath) { + return sendSuccess(res, true); + } + + await fsProm.unlink(fontPath); + return sendSuccess(res, true); + } catch (_error) { + return sendError(res, '删除字体文件失败'); + } +}; + +// 检查WebUI字体文件是否存在 +export const CheckWebUIFontExistHandler: RequestHandler = async (_req, res) => { + try { + const exists = await WebUiConfig.CheckWebUIFontExist(); + return sendSuccess(res, exists); + } catch (_error) { + return sendError(res, '检查字体文件失败'); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/Log.ts b/packages/snowcat-webui-backend/src/api/Log.ts new file mode 100644 index 0000000..40db251 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Log.ts @@ -0,0 +1,79 @@ +import type { RequestHandler } from 'express'; +import { sendError, sendSuccess } from '../utils/response'; +import { terminalManager } from '../terminal/terminal_manager'; +import { logSubscription, WebUiConfig } from '@/snowcat-webui-backend/index'; +// 判断是否是 macos +const isMacOS = process.platform === 'darwin'; + +// 日志脱敏函数 +const sanitizeLog = (log: string): string => { + // 脱敏 token 参数,将 token=xxx 替换为 token=*** + return log.replace(/token=[\w\d]+/gi, 'token=***'); +}; +// 日志记录 +export const LogHandler: RequestHandler = async (req, res) => { + const filename = req.query['id']; + if (!filename || typeof filename !== 'string') { + return sendError(res, 'ID不能为空'); + } + + if (filename.includes('..')) { + return sendError(res, 'ID不合法'); + } + const logContent = await WebUiConfig.GetLogContent(filename); + const sanitizedLogContent = sanitizeLog(logContent); + return sendSuccess(res, sanitizedLogContent); +}; + +// 日志列表 +export const LogListHandler: RequestHandler = async (_, res) => { + const logList = await WebUiConfig.GetLogsList(); + return sendSuccess(res, logList); +}; + +// 实时日志(SSE) +export const LogRealTimeHandler: RequestHandler = async (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Connection', 'keep-alive'); + const listener = (log: string) => { + try { + const sanitizedLog = sanitizeLog(log); + res.write(`data: ${sanitizedLog}\n\n`); + } catch (error) { + console.error('向客户端写入日志数据时出错:', error); + } + }; + logSubscription.subscribe(listener); + req.on('close', () => { + logSubscription.unsubscribe(listener); + }); +}; + +// 终端相关处理器 +export const CreateTerminalHandler: RequestHandler = async (req, res) => { + if (isMacOS) { + return sendError(res, 'MacOS不支持终端'); + } + try { + const { cols, rows } = req.body; + const { id } = terminalManager.createTerminal(cols, rows); + return sendSuccess(res, { id }); + } catch (error) { + console.error('Failed to create terminal:', error); + return sendError(res, '创建终端失败'); + } +}; + +export const GetTerminalListHandler: RequestHandler = (_, res) => { + const list = terminalManager.getTerminalList(); + return sendSuccess(res, list); +}; + +export const CloseTerminalHandler: RequestHandler = (req, res) => { + const id = req.params['id']; + if (!id) { + return sendError(res, 'ID不能为空'); + } + terminalManager.closeTerminal(id); + return sendSuccess(res, {}); +}; diff --git a/packages/snowcat-webui-backend/src/api/Mirror.ts b/packages/snowcat-webui-backend/src/api/Mirror.ts new file mode 100644 index 0000000..10d081b --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Mirror.ts @@ -0,0 +1,230 @@ +import { RequestHandler } from 'express'; +import { sendSuccess, sendError } from '@/snowcat-webui-backend/src/utils/response'; +import { + GITHUB_FILE_MIRRORS, + GITHUB_RAW_MIRRORS, + buildMirrorUrl, + getMirrorConfig, + setCustomMirror, + clearMirrorCache, +} from 'snowcat-common/src/mirror'; +import https from 'https'; +import http from 'http'; + +export interface MirrorTestResult { + mirror: string; + latency: number; + success: boolean; + error?: string; +} + +/** + * 测试单个镜像的延迟 + */ +async function testMirrorLatency (mirror: string, testUrl: string, timeout: number = 5000): Promise { + const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl; + 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; + const isValid = statusCode >= 200 && statusCode < 400; + resolve({ + mirror: mirror || 'https://github.com', + latency: Date.now() - start, + success: isValid, + }); + }); + + req.on('error', (e) => { + resolve({ + mirror: mirror || 'https://github.com', + latency: Date.now() - start, + success: false, + error: e.message, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + mirror: mirror || 'https://github.com', + latency: timeout, + success: false, + error: 'Timeout', + }); + }); + + req.end(); + } catch (e: any) { + resolve({ + mirror: mirror || 'https://github.com', + latency: Date.now() - start, + success: false, + error: e.message, + }); + } + }); +} + +/** + * 获取所有可用的镜像列表 + */ +export const GetMirrorListHandler: RequestHandler = async (_req, res) => { + try { + const config = getMirrorConfig(); + return sendSuccess(res, { + fileMirrors: GITHUB_FILE_MIRRORS.filter(m => m), + rawMirrors: GITHUB_RAW_MIRRORS, + customMirror: config.customMirror, + timeout: config.timeout, + }); + } catch (e: any) { + return sendError(res, e.message); + } +}; + +/** + * 设置自定义镜像 + */ +export const SetCustomMirrorHandler: RequestHandler = async (req, res) => { + try { + const { mirror } = req.body; + setCustomMirror(mirror || ''); + clearMirrorCache(); + return sendSuccess(res, { message: 'Mirror set successfully' }); + } catch (e: any) { + return sendError(res, e.message); + } +}; + +/** + * SSE 实时测速所有镜像 + */ +export const TestMirrorsSSEHandler: RequestHandler = async (req, res) => { + const { type = 'file' } = req.query; + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const sendProgress = (data: any) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + try { + // 选择镜像列表 + let mirrors: string[]; + let testUrl: string; + + if (type === 'raw') { + mirrors = GITHUB_RAW_MIRRORS; + testUrl = 'https://raw.githubusercontent.com/DandanLLab/SnowCatQQ/main/README.md'; + } else { + mirrors = GITHUB_FILE_MIRRORS.filter(m => m); + testUrl = 'https://github.com/DandanLLab/SnowCatQQ/releases/latest'; + } + + // 添加原始 URL 测试 + if (!mirrors.includes('')) { + mirrors = ['', ...mirrors]; + } + + sendProgress({ + type: 'start', + total: mirrors.length, + message: `开始测试 ${mirrors.length} 个镜像源...`, + }); + + const results: MirrorTestResult[] = []; + const timeout = 5000; + + // 逐个测试并实时推送结果 + for (let i = 0; i < mirrors.length; i++) { + const mirror = mirrors[i] ?? ''; + const displayName = mirror || 'https://github.com (原始)'; + + sendProgress({ + type: 'testing', + index: i, + total: mirrors.length, + mirror: displayName, + message: `正在测试: ${displayName}`, + }); + + const result = await testMirrorLatency(mirror, testUrl, timeout); + results.push(result); + + sendProgress({ + type: 'result', + index: i, + total: mirrors.length, + result: { + ...result, + mirror: result.mirror || 'https://github.com (原始)', + }, + }); + } + + // 按延迟排序 + const sortedResults = results + .filter(r => r.success) + .sort((a, b) => a.latency - b.latency); + + const failedResults = results.filter(r => !r.success); + + sendProgress({ + type: 'complete', + results: sortedResults, + failed: failedResults, + fastest: sortedResults[0] || null, + message: `测试完成!${sortedResults.length} 个可用,${failedResults.length} 个失败`, + }); + + res.end(); + } catch (e: any) { + sendProgress({ + type: 'error', + error: e.message, + }); + res.end(); + } +}; + +/** + * 快速测试单个镜像 + */ +export const TestSingleMirrorHandler: RequestHandler = async (req, res) => { + try { + const { mirror, type = 'file' } = req.body; + + let testUrl: string; + if (type === 'raw') { + testUrl = 'https://raw.githubusercontent.com/DandanLLab/SnowCatQQ/main/README.md'; + } else { + testUrl = 'https://github.com/DandanLLab/SnowCatQQ/releases/latest'; + } + + const result = await testMirrorLatency(mirror || '', testUrl, 5000); + + return sendSuccess(res, result); + } catch (e: any) { + return sendError(res, e.message); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/OB11Config.ts b/packages/snowcat-webui-backend/src/api/OB11Config.ts new file mode 100644 index 0000000..6712654 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/OB11Config.ts @@ -0,0 +1,60 @@ +import { RequestHandler } from 'express'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { loadConfig, OneBotConfig } from '@/snowcat-webui-backend/src/onebot/config'; +import { webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import { isEmpty } from '@/snowcat-webui-backend/src/utils/check'; +import json5 from 'json5'; + +// 获取OneBot11配置 +export const OB11GetConfigHandler: RequestHandler = (_, res) => { + // 获取QQ登录状态 + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + // 如果未登录,返回错误 + if (!isLogin) { + return sendError(res, 'Not Login'); + } + // 获取登录的QQ号 + const uin = WebUiDataRuntime.getQQLoginUin(); + // 读取配置文件路径 + const configFilePath = resolve(webUiPathWrapper.configPath, `./onebot11_${uin}.json`); + // 尝试解析配置文件 + try { + // 读取配置文件内容 + const configFileContent = existsSync(configFilePath) + ? readFileSync(configFilePath).toString() + : readFileSync(resolve(webUiPathWrapper.configPath, './onebot11.json')).toString(); + // 解析配置文件并加载配置 + const data = loadConfig(json5.parse(configFileContent)) as OneBotConfig; + // 返回配置文件 + return sendSuccess(res, data); + } catch (_e) { + return sendError(res, 'Config Get Error'); + } +}; + +// 写入OneBot11配置 +export const OB11SetConfigHandler: RequestHandler = async (req, res) => { + // 获取QQ登录状态 + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + // 如果未登录,返回错误 + if (!isLogin) { + return sendError(res, 'Not Login'); + } + // 如果配置为空,返回错误 + if (isEmpty(req.body.config)) { + return sendError(res, 'config is empty'); + } + // 写入配置 + try { + // 解析并加载配置 + const config = loadConfig(json5.parse(req.body.config)) as OneBotConfig; + // 写入配置 + await WebUiDataRuntime.setOB11Config(config); + return sendSuccess(res, null); + } catch (e) { + return sendError(res, 'Error: ' + e); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/Plugin.ts b/packages/snowcat-webui-backend/src/api/Plugin.ts new file mode 100644 index 0000000..b0e7728 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Plugin.ts @@ -0,0 +1,667 @@ +import { RequestHandler } from 'express'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/snowcat-onebot/network/plugin-manger'; +import { webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import path from 'path'; +import fs from 'fs'; +import compressing from 'compressing'; + +/** + * 获取插件图标 URL + * 优先使用 package.json 中的 icon 字段,否则检查缓存的图标文件 + */ +function getPluginIconUrl (pluginId: string, pluginPath: string, iconField?: string): string | undefined { + // 1. 检查 package.json 中指定的 icon 文件 + if (iconField) { + const iconPath = path.join(pluginPath, iconField); + if (fs.existsSync(iconPath)) { + return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`; + } + } + + // 2. 检查 config 目录中缓存的图标 (固定 icon.png) + const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png'); + if (fs.existsSync(cachedIcon)) { + return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`; + } + + return undefined; +} + +/** + * 查找插件图标文件的实际路径 + */ +function findPluginIconPath (pluginId: string, pluginPath: string, iconField?: string): string | undefined { + // 1. 优先使用 package.json 中指定的 icon + if (iconField) { + const iconPath = path.join(pluginPath, iconField); + if (fs.existsSync(iconPath)) { + return iconPath; + } + } + + // 2. 检查 config 目录中缓存的图标 (固定 icon.png) + const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png'); + if (fs.existsSync(cachedIcon)) { + return cachedIcon; + } + + return undefined; +} + +// Helper to get the plugin manager adapter +const getPluginManager = (): OB11PluginMangerAdapter | null => { + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; + if (!ob11) return null; + return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter; +}; + +// Helper to get OneBot context +const getOneBotContext = (): NapCatOneBot11Adapter | null => { + return WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; +}; + +/** + * 手动注册插件管理器到 NetworkManager + */ +export const RegisterPluginManagerHandler: RequestHandler = async (_req, res) => { + const ob11 = getOneBotContext(); + if (!ob11) { + return sendError(res, 'OneBot context not found'); + } + + // 检查是否已经注册 + const existingManager = ob11.networkManager.findSomeAdapter('plugin_manager'); + if (existingManager) { + return sendError(res, '插件管理器已经注册'); + } + + try { + // 确保插件目录存在 + const pluginPath = webUiPathWrapper.pluginPath; + if (!fs.existsSync(pluginPath)) { + fs.mkdirSync(pluginPath, { recursive: true }); + } + + // 创建并注册插件管理器 + const pluginManager = new OB11PluginMangerAdapter( + 'plugin_manager', + ob11.core, + ob11, + ob11.actions + ); + + await ob11.networkManager.registerAdapterAndOpen(pluginManager); + + return sendSuccess(res, { message: '插件管理器注册成功' }); + } catch (e: any) { + return sendError(res, '注册插件管理器失败: ' + e.message); + } +}; + +export const GetPluginListHandler: RequestHandler = async (_req, res) => { + const pluginManager = getPluginManager(); + if (!pluginManager) { + // 返回成功但带特殊标记 + return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] }); + } + + const loadedPlugins = pluginManager.getAllPlugins(); + const AllPlugins: Array<{ + name: string; + id: string; + version: string; + description: string; + author: string; + status: string; + hasConfig: boolean; + hasPages: boolean; + homepage?: string; + repository?: string; + icon?: string; + }> = []; + + // 收集所有插件的扩展页面 + const extensionPages: Array<{ + pluginId: string; + pluginName: string; + path: string; + title: string; + icon?: string; + description?: string; + }> = []; + + // 1. 整理已加载的插件 + for (const p of loadedPlugins) { + // 根据插件状态确定 status + let status: string; + if (!p.enable) { + status = 'disabled'; + } else if (p.loaded) { + status = 'active'; + } else { + status = 'stopped'; // 启用但未加载(可能加载失败) + } + + // 检查插件是否有注册页面 + const pluginRouter = pluginManager.getPluginRouter(p.id); + const hasPages = pluginRouter?.hasPages() ?? false; + + AllPlugins.push({ + name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段 + id: p.id, // 包名,用于 API 操作 + version: p.version || '0.0.0', + description: p.packageJson?.description || '', + author: p.packageJson?.author || '', + status, + hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui), + hasPages, + homepage: p.packageJson?.homepage, + repository: typeof p.packageJson?.repository === 'string' + ? p.packageJson.repository + : p.packageJson?.repository?.url, + icon: getPluginIconUrl(p.id, p.pluginPath, p.packageJson?.icon), + }); + + // 收集插件的扩展页面 + if (hasPages && pluginRouter) { + const pages = pluginRouter.getPages(); + for (const page of pages) { + extensionPages.push({ + pluginId: p.id, + pluginName: p.packageJson?.plugin || p.name || p.id, + path: page.path, + title: page.title, + icon: page.icon, + description: page.description, + }); + } + } + } + + return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false, extensionPages }); +}; + +export const SetPluginStatusHandler: RequestHandler = async (req, res) => { + const { enable, id } = req.body; + + if (!id) return sendError(res, 'Plugin id is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) { + return sendError(res, 'Plugin Manager not found'); + } + + try { + // 设置插件状态(需要 await,因为内部会加载/卸载插件) + await pluginManager.setPluginStatus(id, enable); + + // 如果启用,检查插件是否加载成功 + if (enable) { + const plugin = pluginManager.getPluginInfo(id); + if (!plugin || !plugin.loaded) { + return sendError(res, 'Plugin load failed: ' + id); + } + } + + return sendSuccess(res, { message: 'Status updated successfully' }); + } catch (e: any) { + return sendError(res, 'Failed to update status: ' + e.message); + } +}; + +export const UninstallPluginHandler: RequestHandler = async (req, res) => { + const { id, cleanData } = req.body; + + if (!id) return sendError(res, 'Plugin id is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) { + return sendError(res, 'Plugin Manager not found'); + } + + try { + await pluginManager.uninstallPlugin(id, cleanData); + return sendSuccess(res, { message: 'Uninstalled successfully' }); + } catch (e: any) { + return sendError(res, 'Failed to uninstall: ' + e.message); + } +}; + +export const GetPluginConfigHandler: RequestHandler = async (req, res) => { + const id = req.query['id'] as string; + + if (!id) return sendError(res, 'Plugin id is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(id); + if (!plugin) return sendError(res, 'Plugin not loaded'); + + // 获取配置值 + let config: unknown = {}; + if (plugin.runtime.module?.plugin_get_config && plugin.runtime.context) { + try { + config = await plugin.runtime.module?.plugin_get_config(plugin.runtime.context); + } catch (_e) { } + } else { + // Default behavior: read from default config path + try { + const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id); + if (fs.existsSync(configPath)) { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch (_e) { } + } + + // 获取静态 schema + const schema = plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || []; + + // 检查是否支持动态控制 + const supportReactive = !!(plugin.runtime.module?.plugin_config_controller || plugin.runtime.module?.plugin_on_config_change); + + return sendSuccess(res, { schema, config, supportReactive }); +}; + +/** 活跃的 SSE 连接 */ +const activeConfigSessions = new Map void; + currentConfig: Record; +}>(); + +/** + * 插件配置 SSE 连接 - 用于动态更新配置界面 + */ +export const PluginConfigSSEHandler: RequestHandler = (req, res): void => { + const id = req.query['id'] as string; + const initialConfigStr = req.query['config'] as string; + + if (!id) { + res.status(400).json({ error: 'Plugin id is required' }); + return; + } + + const pluginManager = getPluginManager(); + if (!pluginManager) { + res.status(400).json({ error: 'Plugin Manager not found' }); + return; + } + + const plugin = pluginManager.getPluginInfo(id); + if (!plugin) { + res.status(400).json({ error: 'Plugin not loaded' }); + return; + } + + // 设置 SSE 头 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + // 生成会话 ID + const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + // 解析初始配置 + let currentConfig: Record = {}; + if (initialConfigStr) { + try { + currentConfig = JSON.parse(initialConfigStr); + } catch (_e) { } + } + + // 发送 SSE 消息的辅助函数 + const sendSSE = (event: string, data: any) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 创建 UI 控制器 + const uiController = { + updateSchema: (schema: any[]) => { + sendSSE('schema', { type: 'full', schema }); + }, + updateField: (key: string, field: any) => { + sendSSE('schema', { type: 'updateField', key, field }); + }, + removeField: (key: string) => { + sendSSE('schema', { type: 'removeField', key }); + }, + addField: (field: any, afterKey?: string) => { + sendSSE('schema', { type: 'addField', field, afterKey }); + }, + showField: (key: string) => { + sendSSE('schema', { type: 'showField', key }); + }, + hideField: (key: string) => { + sendSSE('schema', { type: 'hideField', key }); + }, + getCurrentConfig: () => currentConfig, + }; + + // 存储会话 + activeConfigSessions.set(sessionId, { res, currentConfig }); + + // 发送连接成功消息 + sendSSE('connected', { sessionId }); + + // 调用插件的控制器初始化(异步处理) + (async () => { + let cleanup: (() => void) | undefined; + if (plugin.runtime.module?.plugin_config_controller && plugin.runtime.context) { + try { + const result = await plugin.runtime.module.plugin_config_controller( + plugin.runtime.context, + uiController, + currentConfig + ); + if (typeof result === 'function') { + cleanup = result; + } + } catch (e: any) { + sendSSE('error', { message: e.message }); + } + } + + // 更新会话的 cleanup + const session = activeConfigSessions.get(sessionId); + if (session) { + session.cleanup = cleanup; + } + })(); + + // 心跳保持连接 + const heartbeat = setInterval(() => { + sendSSE('ping', { time: Date.now() }); + }, 30000); + + // 连接关闭时清理 + req.on('close', () => { + clearInterval(heartbeat); + const session = activeConfigSessions.get(sessionId); + if (session?.cleanup) { + try { + session.cleanup(); + } catch (_e) { } + } + activeConfigSessions.delete(sessionId); + }); +}; + +/** + * 插件配置字段变化通知 + */ +export const PluginConfigChangeHandler: RequestHandler = async (req, res) => { + const { id, sessionId, key, value, currentConfig } = req.body; + + if (!id || !sessionId || !key) { + return sendError(res, 'Missing required parameters'); + } + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(id); + if (!plugin) return sendError(res, 'Plugin not loaded'); + + // 获取会话 + const session = activeConfigSessions.get(sessionId); + if (!session) { + return sendError(res, 'Session not found'); + } + + // 更新会话中的当前配置 + session.currentConfig = currentConfig || {}; + + // 如果插件有响应式处理器,调用它 + if (plugin.runtime.module?.plugin_on_config_change) { + const uiController = { + updateSchema: (schema: any[]) => { + session.res.write('event: schema\n'); + session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`); + }, + updateField: (fieldKey: string, field: any) => { + session.res.write('event: schema\n'); + session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`); + }, + removeField: (fieldKey: string) => { + session.res.write('event: schema\n'); + session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`); + }, + addField: (field: any, afterKey?: string) => { + session.res.write('event: schema\n'); + session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`); + }, + showField: (fieldKey: string) => { + session.res.write('event: schema\n'); + session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`); + }, + hideField: (fieldKey: string) => { + session.res.write('event: schema\n'); + session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`); + }, + getCurrentConfig: () => session.currentConfig, + }; + + try { + if (plugin.runtime.context) { + await plugin.runtime.module.plugin_on_config_change( + plugin.runtime.context, + uiController, + key, + value, + currentConfig || {} + ); + } + } catch (e: any) { + session.res.write('event: error\n'); + session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`); + } + } + + return sendSuccess(res, { message: 'Change processed' }); +}; + +export const SetPluginConfigHandler: RequestHandler = async (req, res) => { + const { id, config } = req.body; + if (!id || !config) return sendError(res, 'Plugin id and config required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(id); + if (!plugin) return sendError(res, 'Plugin not loaded'); + + if (plugin.runtime.module?.plugin_set_config && plugin.runtime.context) { + try { + await plugin.runtime.module.plugin_set_config(plugin.runtime.context, config); + return sendSuccess(res, { message: 'Config updated' }); + } catch (e: any) { + return sendError(res, 'Error updating config: ' + e.message); + } + } else if (plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || plugin.runtime.module?.plugin_config_controller) { + // Default behavior: write to default config path + try { + const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id); + + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + + // Auto-Reload plugin to apply changes + await pluginManager.reloadPlugin(id); + + return sendSuccess(res, { message: 'Config saved and plugin reloaded' }); + } catch (e: any) { + return sendError(res, 'Error saving config: ' + e.message); + } + } else { + return sendError(res, 'Plugin does not support config update'); + } +}; + +/** + * 导入本地插件包(支持 .zip 文件) + */ +export const ImportLocalPluginHandler: RequestHandler = async (req, res) => { + const pluginManager = getPluginManager(); + if (!pluginManager) { + return sendError(res, 'Plugin Manager not found'); + } + + // multer 会将文件信息放在 req.file 中 + const file = req.file; + if (!file) { + return sendError(res, 'No file uploaded'); + } + + const PLUGINS_DIR = webUiPathWrapper.pluginPath; + + // 确保插件目录存在 + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + } + + const tempZipPath = file.path; + + try { + // 创建临时解压目录 + const tempExtractDir = path.join(PLUGINS_DIR, `_temp_extract_${Date.now()}`); + fs.mkdirSync(tempExtractDir, { recursive: true }); + + // 解压到临时目录 + await compressing.zip.uncompress(tempZipPath, tempExtractDir); + + // 检查解压后的内容 + const extractedItems = fs.readdirSync(tempExtractDir); + + let pluginSourceDir: string; + let pluginId: string; + + // 判断解压结构:可能是直接的插件文件,或者包含一个子目录 + const hasPackageJson = extractedItems.includes('package.json'); + const hasIndexFile = extractedItems.some(item => + ['index.js', 'index.mjs', 'main.js', 'main.mjs'].includes(item) + ); + + if (hasPackageJson || hasIndexFile) { + // 直接是插件文件 + pluginSourceDir = tempExtractDir; + + // 尝试从 package.json 获取插件 ID + const packageJsonPath = path.join(tempExtractDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + pluginId = pkg.name || path.basename(file.originalname, '.zip'); + } catch { + pluginId = path.basename(file.originalname, '.zip'); + } + } else { + pluginId = path.basename(file.originalname, '.zip'); + } + } else if (extractedItems.length === 1 && fs.statSync(path.join(tempExtractDir, extractedItems[0]!)).isDirectory()) { + // 包含一个子目录 + const subDir = extractedItems[0]!; + pluginSourceDir = path.join(tempExtractDir, subDir); + + // 尝试从子目录的 package.json 获取插件 ID + const packageJsonPath = path.join(pluginSourceDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + pluginId = pkg.name || subDir; + } catch { + pluginId = subDir; + } + } else { + pluginId = subDir; + } + } else { + // 清理临时文件 + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + fs.unlinkSync(tempZipPath); + return sendError(res, 'Invalid plugin package structure'); + } + + // 目标插件目录 + const targetPluginDir = path.join(PLUGINS_DIR, pluginId); + + // 如果目标目录已存在,先删除 + if (fs.existsSync(targetPluginDir)) { + // 先卸载已存在的插件 + const existingPlugin = pluginManager.getPluginInfo(pluginId); + if (existingPlugin && existingPlugin.loaded) { + await pluginManager.unregisterPlugin(pluginId); + } + fs.rmSync(targetPluginDir, { recursive: true, force: true }); + } + + // 移动插件文件到目标目录 + if (pluginSourceDir === tempExtractDir) { + // 直接重命名临时目录 + fs.renameSync(tempExtractDir, targetPluginDir); + } else { + // 移动子目录内容 + fs.renameSync(pluginSourceDir, targetPluginDir); + // 清理临时目录 + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + } + + // 删除上传的临时文件 + if (fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + } + + // 加载插件 + const loaded = await pluginManager.loadPluginById(pluginId); + + if (loaded) { + return sendSuccess(res, { + message: 'Plugin imported and loaded successfully', + pluginId, + installPath: targetPluginDir, + }); + } else { + return sendSuccess(res, { + message: 'Plugin imported but failed to load (check plugin structure)', + pluginId, + installPath: targetPluginDir, + }); + } + } catch (e: any) { + // 清理临时文件 + if (fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + } + return sendError(res, 'Failed to import plugin: ' + e.message); + } +}; + +/** + * 获取插件图标 + */ +export const GetPluginIconHandler: RequestHandler = async (req, res) => { + const pluginId = req.params['pluginId']; + if (!pluginId) return sendError(res, 'Plugin ID is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(pluginId); + if (!plugin) return sendError(res, 'Plugin not found'); + + const iconPath = findPluginIconPath(pluginId, plugin.pluginPath, plugin.packageJson?.icon); + if (!iconPath) { + return res.status(404).json({ code: -1, message: 'Icon not found' }); + } + + return res.sendFile(iconPath); +}; diff --git a/packages/snowcat-webui-backend/src/api/PluginStore.ts b/packages/snowcat-webui-backend/src/api/PluginStore.ts new file mode 100644 index 0000000..110bde2 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/PluginStore.ts @@ -0,0 +1,623 @@ +import { RequestHandler } from 'express'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import { PluginStoreList } from '@/snowcat-webui-backend/src/types/PluginStore'; +import * as fs from 'fs'; +import * as path from 'path'; +import { pipeline } from 'stream/promises'; +import { createWriteStream } from 'fs'; +import compressing from 'compressing'; +import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'snowcat-common/src/mirror'; +import { webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/snowcat-onebot/network/plugin-manger'; + +// Helper to get the plugin manager adapter +const getPluginManager = (): OB11PluginMangerAdapter | null => { + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; + if (!ob11) return null; + return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter; +}; + +// 插件商店源配置 +const PLUGIN_STORE_SOURCES = [ + 'https://raw.githubusercontent.com/DandanLLab/snowcat-plugin-index/main/plugins.v4.json', +]; + +// 插件目录 - 使用 pathWrapper +const getPluginsDir = () => webUiPathWrapper.pluginPath; + +/** + * 验证插件 ID,防止路径注入攻击 + */ +function validatePluginId (id: any): string { + if (typeof id !== 'string') { + throw new Error('Invalid plugin ID'); + } + // 仅允许字母、数字、点、下划线、连字符,禁止路径遍历字符 + // 通过 path.basename 进一步确保不包含路径分隔符 + const safeId = path.basename(id); + if (!/^[a-zA-Z0-9._-]+$/.test(safeId) || safeId !== id) { + throw new Error('Invalid plugin ID format'); + } + return safeId; +} + +// 插件列表缓存 +let pluginListCache: PluginStoreList | null = null; +let cacheTimestamp: number = 0; +const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存 + +/** + * 从多个源获取插件列表,使用镜像系统 + * 带10分钟缓存,支持强制刷新 + */ +async function fetchPluginList (forceRefresh: boolean = false): Promise { + // 检查缓存(如果不是强制刷新) + const now = Date.now(); + if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) { + // console.log('Using cached plugin list'); + return pluginListCache; + } + + const errors: string[] = []; + + for (const source of PLUGIN_STORE_SOURCES) { + // 使用镜像系统的 raw 镜像列表 + for (const mirror of GITHUB_RAW_MIRRORS) { + try { + const url = mirror ? `${mirror}/${source.replace('https://raw.githubusercontent.com/', '')}` : source; + + const response = await fetch(url, { + headers: { + 'User-Agent': 'NapCat-WebUI', + }, + signal: AbortSignal.timeout(10000), // 10秒超时 + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + // console.log(`Successfully fetched plugin list from: ${url}`); + + // 更新缓存 + pluginListCache = data as PluginStoreList; + cacheTimestamp = now; + + return pluginListCache; + } catch (e: any) { + const errorMsg = `Failed to fetch from ${source} via mirror: ${e.message}`; + console.warn(errorMsg); + errors.push(errorMsg); + } + } + } + + throw new Error(`All plugin sources failed:\n${errors.join('\n')}`); +} + +/** + * 下载文件,使用镜像系统 + * 自动识别 GitHub Release URL 并使用镜像加速 + */ +async function downloadFile ( + url: string, + destPath: string, + customMirror?: string, + onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void, + timeout: number = 120000 // 默认120秒超时 +): Promise { + try { + let downloadUrl: string; + + // 判断是否是 GitHub Release URL + // 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename} + const githubReleasePattern = /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//; + + if (githubReleasePattern.test(url)) { + // 使用镜像系统查找可用的下载 URL(支持 GitHub Release 镜像) + console.log(`Detected GitHub Release URL: ${url}`); + console.log(`Custom mirror: ${customMirror || 'auto'}`); + + downloadUrl = await findAvailableDownloadUrl(url, { + validateContent: false, // 不验证内容,只检查状态码和 Content-Type + timeout: 5000, // 每个镜像测试5秒超时 + useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞) + customMirror: customMirror || undefined, // 使用用户选择的镜像 + }); + + console.log(`Selected download URL: ${downloadUrl}`); + } else { + // 其他URL直接下载 + console.log(`Direct download URL: ${url}`); + downloadUrl = url; + } + + console.log(`Starting download from: ${downloadUrl}`); + + // 确保目标目录存在 + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + console.log(`Created directory: ${destDir}`); + } + + const response = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'NapCat-WebUI', + }, + signal: AbortSignal.timeout(timeout), // 使用传入的超时时间 + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + const totalLength = Number(response.headers.get('content-length')) || 0; + + // 初始进度通知 + if (onProgress) { + onProgress(0, 0, totalLength, 0); + } + + let downloaded = 0; + let lastTime = Date.now(); + let lastDownloaded = 0; + + // 进度监控流 + // eslint-disable-next-line @stylistic/generator-star-spacing + const progressMonitor = async function* (source: any) { + for await (const chunk of source) { + downloaded += chunk.length; + const now = Date.now(); + const elapsedSinceLast = now - lastTime; + + // 每隔 500ms 或完成时计算一次速度并更新进度 + if (elapsedSinceLast >= 500 || (totalLength && downloaded === totalLength)) { + const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0; + const speed = (downloaded - lastDownloaded) / (elapsedSinceLast / 1000); // bytes/s + + if (onProgress) { + onProgress(percent, downloaded, totalLength, speed); + } + + lastTime = now; + lastDownloaded = downloaded; + } + + yield chunk; + } + }; + + // 写入文件 + const fileStream = createWriteStream(destPath); + await pipeline(progressMonitor(response.body), fileStream); + + console.log(`Successfully downloaded to: ${destPath}`); + } catch (e: any) { + // 删除可能的不完整文件 + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + throw new Error(`Download failed: ${e.message}`); + } +} + +/** + * 解压插件到指定目录 + */ +async function extractPlugin (zipPath: string, pluginId: string): Promise { + // 验证 pluginId 确保安全 + const safeId = validatePluginId(pluginId); + const PLUGINS_DIR = getPluginsDir(); + const pluginDir = path.join(PLUGINS_DIR, safeId); + const dataDir = path.join(pluginDir, 'data'); + const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`); + + console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`); + console.log(`[extractPlugin] pluginId: ${safeId}`); + console.log(`[extractPlugin] Target directory: ${pluginDir}`); + console.log(`[extractPlugin] Zip file: ${zipPath}`); + + // 确保插件根目录存在 + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`); + } + + // 如果目录已存在,先备份 data 文件夹,再删除 + let hasDataBackup = false; + if (fs.existsSync(pluginDir)) { + // 备份 data 文件夹 + if (fs.existsSync(dataDir)) { + console.log(`[extractPlugin] Backing up data directory: ${dataDir}`); + if (fs.existsSync(tempDataDir)) { + fs.rmSync(tempDataDir, { recursive: true, force: true }); + } + fs.renameSync(dataDir, tempDataDir); + hasDataBackup = true; + } + + console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`); + fs.rmSync(pluginDir, { recursive: true, force: true }); + } + + // 创建插件目录 + fs.mkdirSync(pluginDir, { recursive: true }); + console.log(`[extractPlugin] Created directory: ${pluginDir}`); + + try { + // 解压 + await compressing.zip.uncompress(zipPath, pluginDir); + + console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`); + + // 恢复 data 文件夹 + if (hasDataBackup && fs.existsSync(tempDataDir)) { + // 如果新版本也有 data 文件夹,先删除 + if (fs.existsSync(dataDir)) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + console.log(`[extractPlugin] Restoring data directory: ${dataDir}`); + fs.renameSync(tempDataDir, dataDir); + } + } catch (e) { + // 解压失败时,尝试恢复 data 文件夹 + if (hasDataBackup && fs.existsSync(tempDataDir)) { + console.log('[extractPlugin] Extract failed, restoring data directory'); + if (!fs.existsSync(pluginDir)) { + fs.mkdirSync(pluginDir, { recursive: true }); + } + if (fs.existsSync(dataDir)) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + fs.renameSync(tempDataDir, dataDir); + } + throw e; + } + + // 列出解压后的文件 + const files = fs.readdirSync(pluginDir); + console.log('[extractPlugin] Extracted files:', files); +} + +/** + * 安装后尝试缓存插件图标 + * 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录 + */ +async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise { + const PLUGINS_DIR = getPluginsDir(); + const pluginDir = path.join(PLUGINS_DIR, pluginId); + const configDir = path.join(webUiPathWrapper.configPath, 'plugins', pluginId); + + // 检查 package.json 是否已有 icon 字段 + const packageJsonPath = path.join(pluginDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + if (pkg.icon) { + const iconPath = path.join(pluginDir, pkg.icon); + if (fs.existsSync(iconPath)) { + return; // 已有 icon,无需缓存 + } + } + } catch { + // 忽略解析错误 + } + } + + // 检查是否已有缓存的图标 (固定 icon.png) + if (fs.existsSync(path.join(configDir, 'icon.png'))) { + return; // 已有缓存图标 + } + + // 尝试从 GitHub 获取头像 + let avatarUrl: string | undefined; + + // 从 downloadUrl 提取 GitHub 用户名 + if (storePlugin.downloadUrl) { + try { + const url = new URL(storePlugin.downloadUrl); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + avatarUrl = `https://github.com/${parts[0]}.png?size=128`; + } + } + } catch { + // 忽略 + } + } + + // 从 homepage 提取 + if (!avatarUrl && storePlugin.homepage) { + try { + const url = new URL(storePlugin.homepage); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + avatarUrl = `https://github.com/${parts[0]}.png?size=128`; + } + } + } catch { + // 忽略 + } + } + + if (!avatarUrl) return; + + try { + // 确保 config 目录存在 + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const response = await fetch(avatarUrl, { + headers: { 'User-Agent': 'NapCat-WebUI' }, + signal: AbortSignal.timeout(15000), + redirect: 'follow', + }); + + if (!response.ok || !response.body) return; + + const iconPath = path.join(configDir, 'icon.png'); + const fileStream = createWriteStream(iconPath); + await pipeline(response.body as any, fileStream); + + console.log(`[cachePluginIcon] Cached icon for ${pluginId} at ${iconPath}`); + } catch (e: any) { + console.warn(`[cachePluginIcon] Failed to cache icon for ${pluginId}:`, e.message); + } +} + +/** + * 获取插件商店列表 + */ +export const GetPluginStoreListHandler: RequestHandler = async (req, res) => { + try { + // 支持 forceRefresh 查询参数强制刷新缓存 + const forceRefresh = req.query['forceRefresh'] === 'true'; + const data = await fetchPluginList(forceRefresh); + return sendSuccess(res, data); + } catch (e: any) { + return sendError(res, 'Failed to fetch plugin store list: ' + e.message); + } +}; + +/** + * 获取单个插件详情 + */ +export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => { + try { + const id = validatePluginId(req.params['id']); + const data = await fetchPluginList(); + const plugin = data.plugins.find(p => p.id === id); + + if (!plugin) { + return sendError(res, 'Plugin not found'); + } + + return sendSuccess(res, plugin); + } catch (e: any) { + return sendError(res, 'Failed to fetch plugin detail: ' + e.message); + } +}; + +/** + * 安装插件(从商店)- 普通 POST 接口 + */ +export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => { + try { + const { id: rawId, mirror } = req.body; + + if (!rawId) { + return sendError(res, 'Plugin ID is required'); + } + + const id = validatePluginId(rawId); + + // 获取插件信息 + const data = await fetchPluginList(); + const plugin = data.plugins.find(p => p.id === id); + + if (!plugin) { + return sendError(res, 'Plugin not found in store'); + } + + // 检查是否已安装相同版本 + const pm = getPluginManager(); + if (pm) { + const installedInfo = pm.getPluginInfo(id); + if (installedInfo && installedInfo.version === plugin.version) { + return sendError(res, '该插件已安装且版本相同,无需重复安装'); + } + } + + // 下载插件 + const PLUGINS_DIR = getPluginsDir(); + const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); + + try { + await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000); + + // 解压插件 + await extractPlugin(tempZipPath, id); + + // 删除临时文件 + fs.unlinkSync(tempZipPath); + + // 如果 pluginManager 存在,立即注册或重载插件 + const pluginManager = getPluginManager(); + if (pluginManager) { + // 如果插件已存在,则重载以刷新版本信息;否则注册新插件 + if (pluginManager.getPluginInfo(id)) { + await pluginManager.reloadPlugin(id); + } else { + await pluginManager.loadPluginById(id); + } + } + + // 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段),失败可跳过 + try { + await cachePluginIcon(id, plugin); + } catch (e: any) { + console.warn(`[InstallPlugin] Failed to cache icon for ${id}, skipping:`, e.message); + } + + return sendSuccess(res, { + message: 'Plugin installed successfully', + plugin, + installPath: path.join(PLUGINS_DIR, id), + }); + } catch (downloadError: any) { + // 清理临时文件 + if (fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + } + throw downloadError; + } + } catch (e: any) { + return sendError(res, 'Failed to install plugin: ' + e.message); + } +}; + +/** + * 安装插件(从商店)- SSE 版本,实时推送进度 + */ +export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => { + const { id: rawId, mirror } = req.query; + + if (!rawId || typeof rawId !== 'string') { + res.status(400).json({ error: 'Plugin ID is required' }); + return; + } + + let id: string; + try { + id = validatePluginId(rawId); + } catch (err: any) { + res.status(400).json({ error: err.message }); + return; + } + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const sendProgress = (message: string, progress?: number, detail?: any) => { + res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`); + }; + + try { + sendProgress('正在获取插件信息...', 10); + + // 获取插件信息 + const data = await fetchPluginList(); + const plugin = data.plugins.find(p => p.id === id); + + if (!plugin) { + sendProgress('错误: 插件不存在', 0); + res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`); + res.end(); + return; + } + + // 检查是否已安装相同版本 + const pm = getPluginManager(); + if (pm) { + const installedInfo = pm.getPluginInfo(id); + if (installedInfo && installedInfo.version === plugin.version) { + sendProgress('错误: 该插件已安装且版本相同', 0); + res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`); + res.end(); + return; + } + } + + sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20); + sendProgress(`下载地址: ${plugin.downloadUrl}`, 25); + + if (mirror && typeof mirror === 'string') { + sendProgress(`使用镜像: ${mirror}`, 28); + } + + // 下载插件 + const PLUGINS_DIR = getPluginsDir(); + const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); + + try { + sendProgress('正在下载插件...', 30); + await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => { + const overallProgress = 30 + Math.round(percent * 0.5); + const downloadedMb = (downloaded / 1024 / 1024).toFixed(1); + const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?'; + const speedMb = (speed / 1024 / 1024).toFixed(2); + const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1; + + sendProgress(`正在下载插件... ${percent}%`, overallProgress, { + downloaded, + total, + speed, + eta, + downloadedStr: `${downloadedMb}MB`, + totalStr: `${totalMb}MB`, + speedStr: `${speedMb}MB/s`, + }); + }, 300000); + + sendProgress('下载完成,正在解压...', 85); + await extractPlugin(tempZipPath, id); + + sendProgress('解压完成,正在清理...', 95); + fs.unlinkSync(tempZipPath); + + // 如果 pluginManager 存在,立即注册或重载插件 + const pluginManager = getPluginManager(); + if (pluginManager) { + // 如果插件已存在,则重载以刷新版本信息;否则注册新插件 + if (pluginManager.getPluginInfo(id)) { + sendProgress('正在刷新插件信息...', 95); + await pluginManager.reloadPlugin(id); + } else { + sendProgress('正在注册插件...', 95); + await pluginManager.loadPluginById(id); + } + } + + sendProgress('安装成功!', 100); + + // 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段) + cachePluginIcon(id, plugin).catch(e => { + console.warn(`[cachePluginIcon] Failed to cache icon for ${id}:`, e.message); + }); + + res.write(`data: ${JSON.stringify({ + success: true, + message: 'Plugin installed successfully', + plugin, + installPath: path.join(PLUGINS_DIR, id), + })}\n\n`); + res.end(); + } catch (downloadError: any) { + // 清理临时文件 + if (fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + } + sendProgress(`错误: ${downloadError.message}`, 0); + res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`); + res.end(); + } + } catch (e: any) { + sendProgress(`错误: ${e.message}`, 0); + res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`); + res.end(); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/Process.ts b/packages/snowcat-webui-backend/src/api/Process.ts new file mode 100644 index 0000000..9a66070 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Process.ts @@ -0,0 +1,21 @@ +import type { Request, Response } from 'express'; +import { WebUiDataRuntime } from '../helper/Data'; +import { sendError, sendSuccess } from '../utils/response'; + +/** + * 重启进程处理器 + * POST /api/Process/Restart + */ +export async function RestartProcessHandler (_req: Request, res: Response) { + try { + const result = await WebUiDataRuntime.requestRestartProcess(); + + if (result.result) { + return sendSuccess(res, { message: result.message || '进程重启请求已发送' }); + } else { + return sendError(res, result.message || '进程重启失败'); + } + } catch (e) { + return sendError(res, '重启进程时发生错误: ' + (e as Error).message); + } +} diff --git a/packages/snowcat-webui-backend/src/api/Proxy.ts b/packages/snowcat-webui-backend/src/api/Proxy.ts new file mode 100644 index 0000000..fdbe0a4 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Proxy.ts @@ -0,0 +1,14 @@ +import { RequestHandler } from 'express'; +import { RequestUtil } from 'snowcat-common/src/request'; +import { sendError, sendSuccess } from '../utils/response'; + +export const GetProxyHandler: RequestHandler = async (req, res) => { + let { url } = req.query; + if (url && typeof url === 'string') { + url = decodeURIComponent(url); + const responseText = await RequestUtil.HttpGetText(url); + return sendSuccess(res, responseText); + } else { + return sendError(res, 'url参数不合法'); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/QQLogin.ts b/packages/snowcat-webui-backend/src/api/QQLogin.ts new file mode 100644 index 0000000..d4d166f --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/QQLogin.ts @@ -0,0 +1,577 @@ +import { RequestHandler } from 'express'; +import https from 'https'; + +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { WebUiConfig } from '@/snowcat-webui-backend/index'; +import { isEmpty } from '@/snowcat-webui-backend/src/utils/check'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import { Registry20Utils, MachineInfoUtils } from '@/snowcat-webui-backend/src/utils/guid'; +import os from 'node:os'; + +// oidb 新设备验证请求辅助函数 +function oidbRequest (uid: string, body: Record): Promise> { + return new Promise((resolve, reject) => { + const postData = JSON.stringify(body); + const req = https.request({ + hostname: 'oidb.tim.qq.com', + path: `/v3/oidbinterface/oidb_0xc9e_8?uid=${encodeURIComponent(uid)}&getqrcode=1&sdkappid=39998&actype=2`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36', + Accept: 'application/json, text/plain, */*', + }, + }, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error('Failed to parse oidb response')); + } + }); + }); + req.on('error', reject); + req.write(postData); + req.end(); + }); +} + +// 获取 Registry20 路径的辅助函数 +const getRegistryPath = () => { + // 优先从 WebUiDataRuntime 获取早期设置的 dataPath + let dataPath = WebUiDataRuntime.getQQDataPath(); + if (!dataPath) { + // 回退: 从 OneBotContext 获取 + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + dataPath = oneBotContext?.core?.dataPath; + } + if (!dataPath) { + throw new Error('QQ data path not available yet'); + } + return Registry20Utils.getRegistryPath(dataPath); +}; + +// 获取 machine-info 路径的辅助函数 (Linux) +const getMachineInfoPath = () => { + let dataPath = WebUiDataRuntime.getQQDataPath(); + if (!dataPath) { + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + dataPath = oneBotContext?.core?.dataPath; + } + if (!dataPath) { + throw new Error('QQ data path not available yet'); + } + return MachineInfoUtils.getMachineInfoPath(dataPath); +}; + +// 获取QQ登录二维码 +export const QQGetQRcodeHandler: RequestHandler = async (_, res) => { + // 判断是否已经登录 + if (WebUiDataRuntime.getQQLoginStatus()) { + // 已经登录 + return sendError(res, 'QQ Is Logined'); + } + // 获取二维码 + const qrcodeUrl = WebUiDataRuntime.getQQLoginQrcodeURL(); + // 判断二维码是否为空 + if (isEmpty(qrcodeUrl)) { + return sendError(res, 'QRCode Get Error'); + } + // 返回二维码URL + const data = { + qrcode: qrcodeUrl, + }; + return sendSuccess(res, data); +}; + +// 获取QQ登录状态 +export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => { + // 从 OneBot 上下文获取实时的 selfInfo.online 状态 + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + const selfInfo = oneBotContext?.core?.selfInfo; + const isOnline = selfInfo?.online; + const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus(); + const loginPhase = WebUiDataRuntime.getQQLoginPhase(); + // 必须同时满足:已登录且在线(online 必须明确为 true) + // 登录状态以 QQ 登录成功和 Core 明确在线为准。loginPhase 用于展示进度, + // 不能作为跳转的额外门槛,否则 phase 事件延迟时会卡在扫码页。 + const isLogin = qqLoginStatus && isOnline === true; + // 检测掉线状态:已登录但不在线 + const isOffline = qqLoginStatus && isOnline === false; + const data = { + isLogin, + isOffline, + loginPhase, + qrLoginAccepted: qqLoginStatus && (loginPhase === 'qrcode_scanned' || loginPhase === 'initializing'), + coreReady: qqLoginStatus && isOnline === true, + qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(), + loginError: WebUiDataRuntime.getQQLoginError(), + }; + return sendSuccess(res, data); +}; + +// 快速登录 +export const QQSetQuickLoginHandler: RequestHandler = async (req, res) => { + // 获取QQ号 + const { uin } = req.body; + // 判断是否已经登录 + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (isLogin) { + return sendError(res, 'QQ Is Logined'); + } + // 判断QQ号是否为空 + if (isEmpty(uin)) { + return sendError(res, 'uin is empty'); + } + + // 获取快速登录状态 + const { result, message } = await WebUiDataRuntime.requestQuickLogin(uin); + if (!result) { + return sendError(res, message); + } + // 本来应该验证 但是http不宜这么搞 建议前端验证 + // isLogin = WebUiDataRuntime.getQQLoginStatus(); + return sendSuccess(res, null); +}; + +// 获取快速登录列表 +export const QQGetQuickLoginListHandler: RequestHandler = async (_, res) => { + const quickLoginList = WebUiDataRuntime.getQQQuickLoginList(); + return sendSuccess(res, quickLoginList); +}; + +// 获取快速登录列表(新) +export const QQGetLoginListNewHandler: RequestHandler = async (_, res) => { + const newLoginList = WebUiDataRuntime.getQQNewLoginList(); + return sendSuccess(res, newLoginList); +}; + +// 获取登录的QQ的信息 +export const getQQLoginInfoHandler: RequestHandler = async (_, res) => { + const basicInfo = WebUiDataRuntime.getQQLoginInfo(); + // 从 OneBot 上下文获取实时的 selfInfo.online 状态 + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + const selfInfo = oneBotContext?.core?.selfInfo; + const online = selfInfo?.online ?? undefined; + // selfInfo.avatarUrl 内核从未赋值,兜底用 qlogo 头像(浏览器端直连) + const avatarUrl = selfInfo?.avatarUrl + ?? (selfInfo?.uin + ? `https://q1.qlogo.cn/g?b=qq&nk=${selfInfo.uin}&s=100` + : undefined); + const data = { + ...basicInfo, + online, + avatarUrl, + }; + return sendSuccess(res, data); +}; + +// 获取自动登录QQ账号 +export const getAutoLoginAccountHandler: RequestHandler = async (_, res) => { + const data = WebUiConfig.getAutoLoginAccount(); + return sendSuccess(res, data); +}; + +// 设置自动登录QQ账号 +export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => { + const { uin } = req.body; + await WebUiConfig.UpdateAutoLoginAccount(uin); + return sendSuccess(res, null); +}; + +// 刷新QQ登录二维码 +export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => { + // 与 CheckLoginStatus 保持一致:运行时登录标记可能在账号下线后 + // 尚未同步,但 core.selfInfo.online 已能反映实际在线状态。 + const oneBotContext = WebUiDataRuntime.getOneBotContext(); + const isOnline = oneBotContext?.core?.selfInfo?.online === true; + if (WebUiDataRuntime.getQQLoginStatus() && isOnline) { + // 已经登录 + return sendError(res, 'QQ Is Logined'); + } + // 被踢下线后,后台会从 KickedOffLine 事件异步重启 Worker。不要在这个 + // HTTP 请求里再次触发刷新/重启,否则 Worker 退出会打断该响应。 + if (WebUiDataRuntime.getQQLoginPhase() === 'reconnecting') { + return sendSuccess(res, { qrcodeurl: '', restarting: true }); + } + try { + const qrcodeurl = await WebUiDataRuntime.refreshQRCode(); + return sendSuccess(res, { qrcodeurl }); + } catch (error) { + return sendError(res, `QRCode Refresh Error: ${(error as Error).message}`); + } +}; + +// 密码登录 +export const QQPasswordLoginHandler: RequestHandler = async (req, res) => { + // 获取QQ号和密码MD5 + const { uin, passwordMd5 } = req.body; + // 判断是否已经登录 + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (isLogin) { + return sendError(res, 'QQ Is Logined'); + } + // 判断QQ号是否为空 + if (isEmpty(uin)) { + return sendError(res, 'uin is empty'); + } + // 判断密码MD5是否为空 + if (isEmpty(passwordMd5)) { + return sendError(res, 'passwordMd5 is empty'); + } + + // 执行密码登录 + const { result, message, needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5); + if (!result) { + if (needCaptcha && proofWaterUrl) { + return sendSuccess(res, { needCaptcha: true, proofWaterUrl }); + } + if (needNewDevice && jumpUrl) { + return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig }); + } + return sendError(res, message); + } + return sendSuccess(res, null); +}; + +// 验证码登录(密码登录需要验证码时的第二步) +export const QQCaptchaLoginHandler: RequestHandler = async (req, res) => { + const { uin, passwordMd5, ticket, randstr, sid } = req.body; + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (isLogin) { + return sendError(res, 'QQ Is Logined'); + } + if (isEmpty(uin) || isEmpty(passwordMd5)) { + return sendError(res, 'uin or passwordMd5 is empty'); + } + if (isEmpty(ticket) || isEmpty(randstr)) { + return sendError(res, 'captcha ticket or randstr is empty'); + } + + const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestCaptchaLogin(uin, passwordMd5, ticket, randstr, sid || ''); + if (!result) { + if (needNewDevice && jumpUrl) { + return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig }); + } + return sendError(res, message); + } + return sendSuccess(res, null); +}; + +// 新设备验证登录(密码登录需要新设备验证时的第二步) +export const QQNewDeviceLoginHandler: RequestHandler = async (req, res) => { + const { uin, passwordMd5, newDevicePullQrCodeSig } = req.body; + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (isLogin) { + return sendError(res, 'QQ Is Logined'); + } + if (isEmpty(uin) || isEmpty(passwordMd5)) { + return sendError(res, 'uin or passwordMd5 is empty'); + } + if (isEmpty(newDevicePullQrCodeSig)) { + return sendError(res, 'newDevicePullQrCodeSig is empty'); + } + + const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestNewDeviceLogin(uin, passwordMd5, newDevicePullQrCodeSig); + if (!result) { + if (needNewDevice && jumpUrl) { + return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig }); + } + return sendError(res, message); + } + return sendSuccess(res, null); +}; + +// 重置设备信息 +export const QQResetDeviceIDHandler: RequestHandler = async (_, res) => { + try { + const registryPath = getRegistryPath(); + // 自动备份 + try { + await Registry20Utils.backup(registryPath); + } catch (_e) { + // 忽略备份错误(例如文件不存在) + } + + await Registry20Utils.delete(registryPath); + return sendSuccess(res, { message: 'Device ID reset successfully (Registry20 deleted)' }); + } catch (e) { + return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`); + } +}; + +// 获取设备 GUID +export const QQGetDeviceGUIDHandler: RequestHandler = async (_, res) => { + try { + const registryPath = getRegistryPath(); + const guid = await Registry20Utils.readGuid(registryPath); + return sendSuccess(res, { guid }); + } catch (e) { + // 可能是文件不存在,或者非 Windows 平台,或者解密失败 + return sendError(res, `Failed to get GUID: ${(e as Error).message}`); + } +}; + +// 设置设备 GUID +export const QQSetDeviceGUIDHandler: RequestHandler = async (req, res) => { + const { guid } = req.body; + if (!guid || typeof guid !== 'string' || guid.length !== 32) { + return sendError(res, 'Invalid GUID format, must be 32 hex characters'); + } + try { + const registryPath = getRegistryPath(); + // 自动备份 + try { + await Registry20Utils.backup(registryPath); + } catch { } + + await Registry20Utils.writeGuid(registryPath, guid); + return sendSuccess(res, { message: 'GUID set successfully' }); + } catch (e) { + return sendError(res, `Failed to set GUID: ${(e as Error).message}`); + } +}; + +// 获取备份列表 +export const QQGetGUIDBackupsHandler: RequestHandler = async (_, res) => { + try { + const registryPath = getRegistryPath(); + const backups = Registry20Utils.getBackups(registryPath); + return sendSuccess(res, backups); + } catch (e) { + return sendError(res, `Failed to get backups: ${(e as Error).message}`); + } +}; + +// 恢复备份 +export const QQRestoreGUIDBackupHandler: RequestHandler = async (req, res) => { + const { backupName } = req.body; + if (!backupName) { + return sendError(res, 'Backup name is required'); + } + try { + const registryPath = getRegistryPath(); + await Registry20Utils.restore(registryPath, backupName); + return sendSuccess(res, { message: 'Restored successfully' }); + } catch (e) { + return sendError(res, `Failed to restore: ${(e as Error).message}`); + } +}; + +// 创建备份 +export const QQCreateGUIDBackupHandler: RequestHandler = async (_, res) => { + try { + const registryPath = getRegistryPath(); + const backupPath = await Registry20Utils.backup(registryPath); + return sendSuccess(res, { message: 'Backup created', path: backupPath }); + } catch (e) { + return sendError(res, `Failed to backup: ${(e as Error).message}`); + } +}; + +// 重启NapCat +export const QQRestartNapCatHandler: RequestHandler = async (_, res) => { + try { + const result = await WebUiDataRuntime.requestRestartProcess(); + if (result.result) { + return sendSuccess(res, { message: result.message || 'Restart initiated' }); + } else { + return sendError(res, result.message || 'Restart failed'); + } + } catch (e) { + return sendError(res, `Restart error: ${(e as Error).message}`); + } +}; + +// ============================================================ +// 平台信息 & Linux GUID 管理 +// ============================================================ + +// 获取平台信息 +export const QQGetPlatformInfoHandler: RequestHandler = async (_, res) => { + return sendSuccess(res, { platform: os.platform() }); +}; + +// 获取 Linux MAC 地址 (从 machine-info 文件读取) +export const QQGetLinuxMACHandler: RequestHandler = async (_, res) => { + try { + const machineInfoPath = getMachineInfoPath(); + const mac = MachineInfoUtils.readMac(machineInfoPath); + return sendSuccess(res, { mac }); + } catch (e) { + return sendError(res, `Failed to get MAC: ${(e as Error).message}`); + } +}; + +// 设置 Linux MAC 地址 (写入 machine-info 文件) +export const QQSetLinuxMACHandler: RequestHandler = async (req, res) => { + const { mac } = req.body; + if (!mac || typeof mac !== 'string') { + return sendError(res, 'MAC address is required'); + } + try { + const machineInfoPath = getMachineInfoPath(); + // 自动备份 + try { + MachineInfoUtils.backup(machineInfoPath); + } catch { } + + MachineInfoUtils.writeMac(machineInfoPath, mac); + return sendSuccess(res, { message: 'MAC set successfully' }); + } catch (e) { + return sendError(res, `Failed to set MAC: ${(e as Error).message}`); + } +}; + +// 获取 Linux machine-id +export const QQGetLinuxMachineIdHandler: RequestHandler = async (_, res) => { + try { + const machineId = MachineInfoUtils.readMachineId(); + return sendSuccess(res, { machineId }); + } catch (e) { + return sendError(res, `Failed to read machine-id: ${(e as Error).message}`); + } +}; + +// 计算 Linux GUID (用于前端实时预览) +export const QQComputeLinuxGUIDHandler: RequestHandler = async (req, res) => { + const { mac, machineId } = req.body; + try { + // 如果没传 machineId,从 /etc/machine-id 读取 + let mid = machineId; + if (!mid || typeof mid !== 'string') { + try { + mid = MachineInfoUtils.readMachineId(); + } catch { + mid = ''; + } + } + // 如果没传 mac,从 machine-info 文件读取 + let macStr = mac; + if (!macStr || typeof macStr !== 'string') { + try { + const machineInfoPath = getMachineInfoPath(); + macStr = MachineInfoUtils.readMac(machineInfoPath); + } catch { + macStr = ''; + } + } + const guid = MachineInfoUtils.computeGuid(mid, macStr); + return sendSuccess(res, { guid, machineId: mid, mac: macStr }); + } catch (e) { + return sendError(res, `Failed to compute GUID: ${(e as Error).message}`); + } +}; + +// 获取 Linux machine-info 备份列表 +export const QQGetLinuxMachineInfoBackupsHandler: RequestHandler = async (_, res) => { + try { + const machineInfoPath = getMachineInfoPath(); + const backups = MachineInfoUtils.getBackups(machineInfoPath); + return sendSuccess(res, backups); + } catch (e) { + return sendError(res, `Failed to get backups: ${(e as Error).message}`); + } +}; + +// 创建 Linux machine-info 备份 +export const QQCreateLinuxMachineInfoBackupHandler: RequestHandler = async (_, res) => { + try { + const machineInfoPath = getMachineInfoPath(); + const backupPath = MachineInfoUtils.backup(machineInfoPath); + return sendSuccess(res, { message: 'Backup created', path: backupPath }); + } catch (e) { + return sendError(res, `Failed to backup: ${(e as Error).message}`); + } +}; + +// 恢复 Linux machine-info 备份 +export const QQRestoreLinuxMachineInfoBackupHandler: RequestHandler = async (req, res) => { + const { backupName } = req.body; + if (!backupName) { + return sendError(res, 'Backup name is required'); + } + try { + const machineInfoPath = getMachineInfoPath(); + MachineInfoUtils.restore(machineInfoPath, backupName); + return sendSuccess(res, { message: 'Restored successfully' }); + } catch (e) { + return sendError(res, `Failed to restore: ${(e as Error).message}`); + } +}; + +// 重置 Linux 设备信息 (删除 machine-info) +export const QQResetLinuxDeviceIDHandler: RequestHandler = async (_, res) => { + try { + const machineInfoPath = getMachineInfoPath(); + // 自动备份 + try { + MachineInfoUtils.backup(machineInfoPath); + } catch { } + + MachineInfoUtils.delete(machineInfoPath); + return sendSuccess(res, { message: 'Device ID reset successfully (machine-info deleted)' }); + } catch (e) { + return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`); + } +}; + +// ============================================================ +// OIDB 新设备 QR 验证 +// ============================================================ + +// 获取新设备验证二维码 (通过 OIDB 接口) +export const QQGetNewDeviceQRCodeHandler: RequestHandler = async (req, res) => { + const { uin, jumpUrl } = req.body; + if (!uin || !jumpUrl) { + return sendError(res, 'uin and jumpUrl are required'); + } + + // 从 jumpUrl 中提取参数 + // jumpUrl 格式: https://accounts.qq.com/safe/verify?...&uin-token=xxx&sig=yyy + // sig -> str_dev_auth_token, uin-token -> str_uin_token + const url = new URL(jumpUrl); + const strDevAuthToken = url.searchParams.get('sig') || ''; + const strUinToken = url.searchParams.get('uin-token') || ''; + + if (!strDevAuthToken || !strUinToken) { + return sendError(res, 'Failed to get new device QR code: unable to extract sig/uin-token from jumpUrl'); + } + + const body = { + str_dev_auth_token: strDevAuthToken, + uint32_flag: 1, + uint32_url_type: 0, + str_uin_token: strUinToken, + str_dev_type: 'Windows', + str_dev_name: os.hostname() || 'DESKTOP-NAPCAT', + }; + + const result = await oidbRequest(uin, body); + return sendSuccess(res, result); +}; + +// 轮询新设备验证二维码状态 +export const QQPollNewDeviceQRHandler: RequestHandler = async (req, res) => { + const { uin, bytesToken } = req.body; + if (!uin || !bytesToken) { + return sendError(res, 'uin and bytesToken are required'); + } + + try { + const body = { + uint32_flag: 0, + bytes_token: bytesToken, // base64 编码的 token + }; + + const result = await oidbRequest(uin, body); + // result 应包含 uint32_guarantee_status: + // 0 = 等待扫码, 3 = 已扫码, 1 = 已确认 (包含 str_nt_succ_token) + return sendSuccess(res, result); + } catch (e) { + return sendError(res, `Failed to poll QR status: ${(e as Error).message}`); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/SnowCatConfig.ts b/packages/snowcat-webui-backend/src/api/SnowCatConfig.ts new file mode 100644 index 0000000..0c37605 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/SnowCatConfig.ts @@ -0,0 +1,145 @@ +import { RequestHandler } from 'express'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import json5 from 'json5'; + +import { Value } from '@sinclair/typebox/value'; +import { SnowCatConfigSchema } from '@/snowcat-core/helper/config'; + +// 动态获取 NapCat 配置默认值 +function getDefaultNapcatConfig (): Record { + const data = {}; + return Value.Parse(SnowCatConfigSchema, data) as Record; +} + +/** + * 获取 napcat 配置文件路径 + */ +function getNapcatConfigPath (): string { + return resolve(webUiPathWrapper.configPath, './napcat.json'); +} + +/** + * 读取 napcat 配置 + */ +function readNapcatConfig (): Record { + const configPath = getNapcatConfigPath(); + try { + if (existsSync(configPath)) { + const content = readFileSync(configPath, 'utf-8'); + return { ...getDefaultNapcatConfig(), ...json5.parse(content) }; + } + } catch (_e) { + // 读取失败,使用默认值 + } + return { ...getDefaultNapcatConfig() }; +} + +/** + * 写入 napcat 配置 + */ +function writeNapcatConfig (config: Record): void { + const configPath = resolve(webUiPathWrapper.configPath, './napcat.json'); + mkdirSync(webUiPathWrapper.configPath, { recursive: true }); + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); +} + +// 获取 NapCat 配置 +export const SnowCatGetConfigHandler: RequestHandler = (_, res) => { + try { + const config = readNapcatConfig(); + return sendSuccess(res, config); + } catch (e) { + return sendError(res, 'Config Get Error: ' + (e as Error).message); + } +}; + +// 设置 NapCat 配置 +export const SnowCatSetConfigHandler: RequestHandler = (req, res) => { + try { + const newConfig = req.body; + if (!newConfig || typeof newConfig !== 'object') { + return sendError(res, 'config is empty or invalid'); + } + + // 读取当前配置并合并 + const currentConfig = readNapcatConfig(); + const mergedConfig = { ...currentConfig, ...newConfig }; + + // 验证 bypass 字段 + if (mergedConfig.bypass && typeof mergedConfig.bypass === 'object') { + const bypass = mergedConfig.bypass as Record; + const validKeys = ['hook', 'window', 'module', 'process', 'container', 'js']; + for (const key of validKeys) { + if (key in bypass && typeof bypass[key] !== 'boolean') { + return sendError(res, `bypass.${key} must be boolean`); + } + } + } + + writeNapcatConfig(mergedConfig); + return sendSuccess(res, null); + } catch (e) { + return sendError(res, 'Config Set Error: ' + (e as Error).message); + } +}; + +// ============================================================ +// Per-UIN 配置(napcat_{uin}.json)—— 登录后配置 +// ============================================================ + +function readUinConfig (uin: string): Record { + const uinPath = resolve(webUiPathWrapper.configPath, `./napcat_${uin}.json`); + const fallbackPath = resolve(webUiPathWrapper.configPath, './napcat.json'); + const configPath = existsSync(uinPath) ? uinPath : fallbackPath; + try { + if (existsSync(configPath)) { + const content = readFileSync(configPath, 'utf-8'); + return { ...getDefaultNapcatConfig(), ...json5.parse(content) }; + } + } catch (_e) { + // ignore + } + return { ...getDefaultNapcatConfig() }; +} + +function writeUinConfig (uin: string, config: Record): void { + const configPath = resolve(webUiPathWrapper.configPath, `./napcat_${uin}.json`); + mkdirSync(webUiPathWrapper.configPath, { recursive: true }); + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); +} + +// 获取 per-uin NapCat 配置 +export const SnowCatGetUinConfigHandler: RequestHandler = (_, res) => { + try { + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (!isLogin) return sendError(res, 'Not Login'); + const uin = WebUiDataRuntime.getQQLoginUin(); + return sendSuccess(res, readUinConfig(uin)); + } catch (e) { + return sendError(res, 'Config Get Error: ' + (e as Error).message); + } +}; + +// 设置 per-uin NapCat 配置 +export const SnowCatSetUinConfigHandler: RequestHandler = (req, res) => { + try { + const isLogin = WebUiDataRuntime.getQQLoginStatus(); + if (!isLogin) return sendError(res, 'Not Login'); + const uin = WebUiDataRuntime.getQQLoginUin(); + + const newConfig = req.body; + if (!newConfig || typeof newConfig !== 'object') { + return sendError(res, 'config is empty or invalid'); + } + const currentConfig = readUinConfig(uin); + const mergedConfig = { ...currentConfig, ...newConfig }; + writeUinConfig(uin, mergedConfig); + return sendSuccess(res, null); + } catch (e) { + return sendError(res, 'Config Set Error: ' + (e as Error).message); + } +}; diff --git a/packages/snowcat-webui-backend/src/api/Status.ts b/packages/snowcat-webui-backend/src/api/Status.ts new file mode 100644 index 0000000..247bc87 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/Status.ts @@ -0,0 +1,19 @@ +import { statusHelperSubscription } from '@/snowcat-webui-backend'; +import { RequestHandler } from 'express'; +import { SystemStatus } from 'snowcat-common/src/status-interface'; +export const StatusRealTimeHandler: RequestHandler = async (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Connection', 'keep-alive'); + const sendStatus = (status: SystemStatus) => { + try { + res.write(`data: ${JSON.stringify(status)}\n\n`); + } catch (e) { + console.error(`An error occurred when writing sendStatus data to client: ${e}`); + } + }; + statusHelperSubscription.on('statusUpdate', sendStatus); + req.on('close', () => { + statusHelperSubscription.off('statusUpdate', sendStatus); + res.end(); + }); +}; diff --git a/packages/snowcat-webui-backend/src/api/UpdateNapCat.ts b/packages/snowcat-webui-backend/src/api/UpdateNapCat.ts new file mode 100644 index 0000000..1da89cd --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/UpdateNapCat.ts @@ -0,0 +1,422 @@ +import { RequestHandler } from 'express'; +import { sendSuccess, sendError } from '@/snowcat-webui-backend/src/utils/response'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; +import compressing from 'compressing'; +import { webUiPathWrapper, webUiLogger } from '../../index'; +import { SnowCatPathWrapper } from '@/snowcat-common/src/path'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { SnowCatCoreWorkingEnv } from '@/snowcat-webui-backend/src/types'; +import { + getGitHubRelease, + findAvailableDownloadUrl, +} from '@/snowcat-common/src/mirror'; +import { ILogWrapper } from '@/snowcat-common/src/log-interface'; + +// 更新请求体接口 +interface UpdateRequestBody { + /** 要更新到的版本 tag,如 "v4.9.9",不传则更新到最新版本 */ + targetVersion?: string; + /** 是否强制更新(即使是降级也更新) */ + force?: boolean; + /** 指定使用的镜像 */ + mirror?: string; +} + +// 更新配置文件接口 +interface UpdateConfig { + version: string; + updateTime: string; + files: Array<{ + sourcePath: string; + targetPath: string; + backupPath?: string; + }>; + changelog?: string; +} + +// 需要跳过更新的文件 +const SKIP_UPDATE_FILES = [ + 'NapCatWinBootMain.exe', + 'NapCatWinBootHook.dll', + 'quickLoginExample.bat', +]; + +// 更新时若文件已存在则保留(不覆盖)的用户配置文件(使用相对路径精确匹配) +// 这些文件保存了用户的自定义设置,更新时应予以保留; +// 新增的配置项将在运行时通过 TypeBox schema 默认值自动填充,用户不会错过新配置选项。 +const PRESERVE_USER_CONFIG_RELATIVE_PATHS = new Set([ + path.normalize('config/napcat.json'), +]); + +/** + * 递归扫描目录中的所有文件 + */ +function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Array<{ + sourcePath: string; + relativePath: string; +}> { + const files: Array<{ + sourcePath: string; + relativePath: string; + }> = []; + + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const fullPath = path.join(dirPath, item); + const relativePath = path.relative(basePath, fullPath); + const stat = fs.lstatSync(fullPath); + + if (stat.isSymbolicLink()) { + // 跳过符号链接,避免潜在的路径穿越风险 + continue; + } else if (stat.isDirectory()) { + // 递归扫描子目录 + files.push(...scanFilesRecursively(fullPath, basePath)); + } else if (stat.isFile()) { + files.push({ + sourcePath: fullPath, + relativePath, + }); + } + } + + return files; +} + +// 注:镜像配置已迁移到 @/snowcat-common/src/mirror 模块统一管理 + +/** + * 下载文件(带进度和重试) + */ +async function downloadFile (url: string, dest: string): Promise { + webUiLogger?.log('[SnowCat Update] Starting download from:', url); + const file = fs.createWriteStream(dest); + + return new Promise((resolve, reject) => { + const request = https.get(url, { + headers: { 'User-Agent': 'NapCat-WebUI' }, + }, (res) => { + webUiLogger?.log('[SnowCat Update] Response status:', res.statusCode); + webUiLogger?.log('[SnowCat Update] Content-Type:', res.headers['content-type']); + + if (res.statusCode === 302 || res.statusCode === 301) { + webUiLogger?.log('[SnowCat Update] Following redirect to:', res.headers.location); + file.close(); + fs.unlinkSync(dest); + downloadFile(res.headers.location!, dest).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + file.close(); + fs.unlinkSync(dest); + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + res.pipe(file); + file.on('finish', () => { + file.close(); + webUiLogger?.log('[SnowCat Update] Download completed'); + resolve(); + }); + }); + + request.on('error', (err) => { + webUiLogger?.logError('[SnowCat Update] Download error:', err); + file.close(); + fs.unlink(dest, () => { }); + reject(err); + }); + }); +} + +export const UpdateNapCatHandler: RequestHandler = async (req, res) => { + try { + // 从请求体获取目标版本(可选) + const { targetVersion, force, mirror } = req.body as UpdateRequestBody; + + // 确定要下载的文件名 + const ReleaseName = WebUiDataRuntime.getWorkingEnv() === SnowCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip'; + + // 确定目标版本 tag + // 如果指定了版本,使用指定版本;否则使用 'latest' + const targetTag = targetVersion || 'latest'; + webUiLogger?.log(`[SnowCat Update] Target version: ${targetTag}`); + + // 检查是否是 action 临时版本 + const isActionVersion = targetTag.startsWith('action-'); + let downloadUrl: string; + let actualVersion: string; + + if (isActionVersion) { + // 处理 action 临时版本 + const runId = parseInt(targetTag.replace('action-', '')); + if (isNaN(runId)) { + throw new Error(`Invalid action version format: ${targetTag}`); + } + + webUiLogger?.log(`[SnowCat Update] Downloading action artifact from run: ${runId}`); + + // 根据当前工作环境确定 artifact 名称 + const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell + + // Action artifacts 通过 nightly.link 下载 + // 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip + const baseUrl = `https://nightly.link/DandanLLab/SnowCatQQ/actions/runs/${runId}/${artifactName}.zip`; + actualVersion = targetTag; + + webUiLogger?.log(`[SnowCat Update] Action artifact URL: ${baseUrl}`); + + // 使用 mirror 模块查找可用的 nightly.link 镜像 + try { + downloadUrl = await findAvailableDownloadUrl(baseUrl, { + validateContent: true, + minFileSize: 1024 * 1024, + timeout: 10000, + customMirror: mirror, + }); + webUiLogger?.log(`[SnowCat Update] Using download URL: ${downloadUrl}`); + } catch (_error) { + // 如果镜像都不可用,直接使用原始 URL + webUiLogger?.logWarn('[SnowCat Update] All nightly.link mirrors failed, using original URL'); + downloadUrl = baseUrl; + } + } else { + // 处理标准 release 版本 + // 使用 mirror 模块获取 release 信息(不依赖 API) + // 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API + const release = await getGitHubRelease('DandanLLab', 'SnowCatQQ', targetTag, { + assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'], + fetchChangelog: false, // 不需要 changelog,避免 API 调用 + mirror, + }); + + const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName); + if (!shellZipAsset) { + throw new Error(`未找到${ReleaseName}文件`); + } + + actualVersion = release.tag_name; + + // 使用 mirror 模块查找可用的下载 URL + // 启用内容验证,确保返回的是有效文件而非错误页面 + downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, { + validateContent: true, // 验证 Content-Type 和状态码 + minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面 + timeout: 10000, // 10秒超时 + customMirror: mirror, + }); + } + + // 检查是否需要强制更新(降级警告) + const currentVersion = WebUiDataRuntime.GetNapCatVersion(); + webUiLogger?.log(`[SnowCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`); + + if (!force && currentVersion && !isActionVersion) { + // 简单的版本比较(可选的降级保护) + const parseVersion = (v: string): [number, number, number] => { + const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')]; + }; + const [currMajor, currMinor, currPatch] = parseVersion(currentVersion); + const [targetMajor, targetMinor, targetPatch] = parseVersion(actualVersion); + + const isDowngrade = + targetMajor < currMajor || + (targetMajor === currMajor && targetMinor < currMinor) || + (targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch); + + if (isDowngrade) { + webUiLogger?.log(`[SnowCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`); + // 不阻止降级,只是记录日志 + } + } + + webUiLogger?.log(`[SnowCat Update] Updating to version: ${actualVersion}`); + + // 创建临时目录 + const tempDir = path.join(webUiPathWrapper.binaryPath, './temp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + webUiLogger?.log(`[SnowCat Update] Using download URL: ${downloadUrl}`); + + // 下载zip + const zipPath = path.join(tempDir, 'snowcat-latest.zip'); + webUiLogger?.log('[SnowCat Update] Saving to:', zipPath); + await downloadFile(downloadUrl, zipPath); + + // 检查文件大小 + const stats = fs.statSync(zipPath); + webUiLogger?.log('[SnowCat Update] Downloaded file size:', stats.size, 'bytes'); + + // 解压到临时目录 + const extractPath = path.join(tempDir, 'snowcat-extract'); + webUiLogger?.log('[SnowCat Update] Extracting to:', extractPath); + await compressing.zip.uncompress(zipPath, extractPath); + + // 获取解压后的实际内容目录(NapCat.Shell.zip直接包含文件,无额外根目录) + const sourcePath = extractPath; + + // 执行更新操作 + try { + // 扫描需要更新的文件 + const allFiles = scanFilesRecursively(sourcePath); + const failedFiles: Array<{ + sourcePath: string; + targetPath: string; + }> = []; + + // 先尝试直接替换文件 + const resolvedBinaryPath = path.resolve(webUiPathWrapper.binaryPath); + for (const fileInfo of allFiles) { + // 防止路径穿越攻击:确保目标路径严格位于 binaryPath 子目录内 + const targetFilePath = path.resolve(webUiPathWrapper.binaryPath, fileInfo.relativePath); + if (!targetFilePath.startsWith(resolvedBinaryPath + path.sep)) { + webUiLogger?.logWarn(`[SnowCat Update] Skipping suspicious path: ${fileInfo.relativePath}`); + continue; + } + + const normalizedRelativePath = path.normalize(fileInfo.relativePath); + + // 跳过指定的文件 + if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) { + webUiLogger?.log(`[SnowCat Update] Skipping update for ${fileInfo.relativePath}`); + continue; + } + + // 保留已存在的用户配置文件,避免覆盖用户设置 + if (PRESERVE_USER_CONFIG_RELATIVE_PATHS.has(normalizedRelativePath) && fs.existsSync(targetFilePath)) { + webUiLogger?.log(`[SnowCat Update] Preserving existing user config: ${fileInfo.relativePath}`); + continue; + } + + try { + // 确保目标目录存在 + const targetDir = path.dirname(targetFilePath); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + // 尝试直接替换文件 + if (fs.existsSync(targetFilePath)) { + fs.unlinkSync(targetFilePath); // 删除旧文件 + } + fs.copyFileSync(fileInfo.sourcePath, targetFilePath); + } catch (error) { + // 如果替换失败,添加到失败列表 + webUiLogger?.logError(`[SnowCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error); + failedFiles.push({ + sourcePath: fileInfo.sourcePath, + targetPath: targetFilePath, + }); + } + } + + // 如果有替换失败的文件,创建更新配置文件 + if (failedFiles.length > 0) { + const updateConfig: UpdateConfig = { + version: actualVersion, + updateTime: new Date().toISOString(), + files: failedFiles, + changelog: '', + }; + + // 保存更新配置文件 + const configPath = path.join(webUiPathWrapper.configPath, 'snowcat-update.json'); + fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2)); + webUiLogger?.log(`[SnowCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`); + } + + // 发送成功响应 + const message = failedFiles.length > 0 + ? `更新完成,重启应用以应用剩余${failedFiles.length}个文件的更新` + : '更新完成'; + sendSuccess(res, { + status: 'completed', + message, + newVersion: actualVersion, + failedFilesCount: failedFiles.length, + }); + } catch (error) { + webUiLogger?.logError('[SnowCat Update] 更新失败:', error); + sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误')); + } + } catch (error: any) { + webUiLogger?.logError('[SnowCat Update] 更新失败:', error); + sendError(res, '更新失败: ' + error.message); + } +}; + +// 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease + +/** + * 应用待处理的更新(在应用启动时调用) + */ +export async function applyPendingUpdates (webUiPathWrapper: SnowCatPathWrapper, logger: ILogWrapper): Promise { + const configPath = path.join(webUiPathWrapper.configPath, 'snowcat-update.json'); + + if (!fs.existsSync(configPath)) { + // logger.log('[SnowCat Update] No pending updates found'); + return; + } + + try { + logger.log('[SnowCat Update] Applying pending updates...'); + const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + const remainingFiles: Array<{ + sourcePath: string; + targetPath: string; + }> = []; + + for (const file of updateConfig.files) { + try { + // 检查源文件是否存在 + if (!fs.existsSync(file.sourcePath)) { + logger.logWarn(`[SnowCat Update] Source file not found: ${file.sourcePath}`); + continue; + } + + // 确保目标目录存在 + const targetDir = path.dirname(file.targetPath); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + // 尝试替换文件 + if (fs.existsSync(file.targetPath)) { + fs.unlinkSync(file.targetPath); // 删除旧文件 + } + fs.copyFileSync(file.sourcePath, file.targetPath); + logger.log(`[SnowCat Update] Updated ${path.basename(file.targetPath)} on startup`); + } catch (error) { + logger.logError(`[SnowCat Update] Failed to update ${file.targetPath} on startup:`, error); + // 如果仍然失败,保留在列表中 + remainingFiles.push(file); + } + } + + // 如果还有失败的文件,更新配置文件 + if (remainingFiles.length > 0) { + const updatedConfig: UpdateConfig = { + ...updateConfig, + files: remainingFiles, + }; + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + logger.log(`[SnowCat Update] ${remainingFiles.length} files still pending update`); + } else { + // 所有文件都成功更新,删除配置文件 + fs.unlinkSync(configPath); + logger.log('[SnowCat Update] All pending updates applied successfully'); + } + } catch (error) { + logger.logError('[SnowCat Update] Failed to apply pending updates:', error); + } +} diff --git a/packages/snowcat-webui-backend/src/api/WebUIConfig.ts b/packages/snowcat-webui-backend/src/api/WebUIConfig.ts new file mode 100644 index 0000000..2944609 --- /dev/null +++ b/packages/snowcat-webui-backend/src/api/WebUIConfig.ts @@ -0,0 +1,245 @@ +import { RequestHandler } from 'express'; +import { WebUiConfig, webUiPathWrapper } from '@/snowcat-webui-backend/index'; +import { sendError, sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; +import { isEmpty } from '@/snowcat-webui-backend/src/utils/check'; +import { existsSync, promises as fsProm } from 'node:fs'; +import { join } from 'node:path'; + +// 获取WebUI基础配置 +export const GetWebUIConfigHandler: RequestHandler = async (_, res) => { + try { + const config = await WebUiConfig.GetWebUIConfig(); + return sendSuccess(res, { + host: config.host, + port: config.port, + loginRate: config.loginRate, + disableWebUI: config.disableWebUI, + accessControlMode: config.accessControlMode || 'none', + ipWhitelist: config.ipWhitelist || [], + ipBlacklist: config.ipBlacklist || [], + enableXForwardedFor: config.enableXForwardedFor || false, + }); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取WebUI配置失败: ${msg}`); + } +}; + +// 获取是否禁用WebUI +export const GetDisableWebUIHandler: RequestHandler = async (_, res) => { + try { + const disable = await WebUiConfig.GetDisableWebUI(); + return sendSuccess(res, disable); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取WebUI禁用状态失败: ${msg}`); + } +}; + +// 更新是否禁用WebUI +export const UpdateDisableWebUIHandler: RequestHandler = async (req, res) => { + try { + const { disable } = req.body; + + if (typeof disable !== 'boolean') { + return sendError(res, 'disable参数必须是布尔值'); + } + + await WebUiConfig.UpdateDisableWebUI(disable); + return sendSuccess(res, null); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `更新WebUI禁用状态失败: ${msg}`); + } +}; + +// 获取当前客户端IP +export const GetClientIPHandler: RequestHandler = async (req, res) => { + try { + const config = await WebUiConfig.GetWebUIConfig(); + + // 根据配置决定如何获取客户端IP(与 CORS 中间件逻辑一致) + let clientIP: string; + if (config.enableXForwardedFor) { + const forwardedFor = req.headers['x-forwarded-for']; + if (typeof forwardedFor === 'string') { + clientIP = forwardedFor.split(',')[0]?.trim() || ''; + } else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) { + clientIP = forwardedFor[0] || ''; + } else { + clientIP = req.ip || req.socket.remoteAddress || ''; + } + } else { + clientIP = req.ip || req.socket.remoteAddress || ''; + } + + // 标准化 IP(移除 IPv4-mapped IPv6 前缀,但保留纯 IPv6) + let normalizedIP = clientIP; + if (clientIP.startsWith('::ffff:')) { + const ipv4 = clientIP.substring(7); + // 检查是否是有效的 IPv4 + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipv4)) { + normalizedIP = ipv4; + } + } + + return sendSuccess(res, { ip: normalizedIP }); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取客户端IP失败: ${msg}`); + } +}; + +// 更新WebUI基础配置 +export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => { + try { + const { host, port, loginRate, disableWebUI, accessControlMode, ipWhitelist, ipBlacklist, enableXForwardedFor } = req.body; + + const updateConfig: any = {}; + + if (host !== undefined) { + if (isEmpty(host)) { + return sendError(res, 'host不能为空'); + } + updateConfig.host = host; + } + + if (port !== undefined) { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return sendError(res, 'port必须是1-65535之间的整数'); + } + updateConfig.port = port; + } + + if (loginRate !== undefined) { + if (!Number.isInteger(loginRate) || loginRate < 1) { + return sendError(res, 'loginRate必须是大于0的整数'); + } + updateConfig.loginRate = loginRate; + } + + if (disableWebUI !== undefined) { + if (typeof disableWebUI !== 'boolean') { + return sendError(res, 'disableWebUI必须是布尔值'); + } + updateConfig.disableWebUI = disableWebUI; + } + + if (accessControlMode !== undefined) { + if (!['none', 'whitelist', 'blacklist'].includes(accessControlMode)) { + return sendError(res, 'accessControlMode必须是none、whitelist或blacklist'); + } + updateConfig.accessControlMode = accessControlMode; + } + + if (ipWhitelist !== undefined) { + if (!Array.isArray(ipWhitelist)) { + return sendError(res, 'ipWhitelist必须是数组'); + } + updateConfig.ipWhitelist = ipWhitelist; + } + + if (ipBlacklist !== undefined) { + if (!Array.isArray(ipBlacklist)) { + return sendError(res, 'ipBlacklist必须是数组'); + } + updateConfig.ipBlacklist = ipBlacklist; + } + + if (enableXForwardedFor !== undefined) { + if (typeof enableXForwardedFor !== 'boolean') { + return sendError(res, 'enableXForwardedFor必须是布尔值'); + } + updateConfig.enableXForwardedFor = enableXForwardedFor; + } + + await WebUiConfig.UpdateWebUIConfig(updateConfig); + return sendSuccess(res, null); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `更新WebUI配置失败: ${msg}`); + } +}; + +// 获取SSL证书状态 +export const GetSSLStatusHandler: RequestHandler = async (_, res) => { + try { + const certPath = join(webUiPathWrapper.configPath, 'cert.pem'); + const keyPath = join(webUiPathWrapper.configPath, 'key.pem'); + + const certExists = existsSync(certPath); + const keyExists = existsSync(keyPath); + + let certContent = ''; + let keyContent = ''; + + if (certExists) { + certContent = await fsProm.readFile(certPath, 'utf-8'); + } + if (keyExists) { + keyContent = await fsProm.readFile(keyPath, 'utf-8'); + } + + return sendSuccess(res, { + enabled: certExists && keyExists, + certExists, + keyExists, + certContent, + keyContent, + }); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `获取SSL状态失败: ${msg}`); + } +}; + +// 保存SSL证书(通过文本内容) +export const UploadSSLCertHandler: RequestHandler = async (req, res) => { + try { + const { cert, key } = req.body; + + if (isEmpty(cert) || isEmpty(key)) { + return sendError(res, 'cert和key内容不能为空'); + } + + // 简单验证证书格式 + if (!cert.includes('-----BEGIN CERTIFICATE-----') || !cert.includes('-----END CERTIFICATE-----')) { + return sendError(res, 'cert格式不正确,应为PEM格式的证书'); + } + + if (!key.includes('-----BEGIN') || !key.includes('KEY-----')) { + return sendError(res, 'key格式不正确,应为PEM格式的私钥'); + } + + const certPath = join(webUiPathWrapper.configPath, 'cert.pem'); + const keyPath = join(webUiPathWrapper.configPath, 'key.pem'); + + await fsProm.writeFile(certPath, cert, 'utf-8'); + await fsProm.writeFile(keyPath, key, 'utf-8'); + + return sendSuccess(res, { message: 'SSL证书保存成功,重启后生效' }); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `保存SSL证书失败: ${msg}`); + } +}; + +// 删除SSL证书 +export const DeleteSSLCertHandler: RequestHandler = async (_, res) => { + try { + const certPath = join(webUiPathWrapper.configPath, 'cert.pem'); + const keyPath = join(webUiPathWrapper.configPath, 'key.pem'); + + if (existsSync(certPath)) { + await fsProm.unlink(certPath); + } + if (existsSync(keyPath)) { + await fsProm.unlink(keyPath); + } + + return sendSuccess(res, { message: 'SSL证书已删除,重启后生效' }); + } catch (error) { + const msg = (error as Error).message; + return sendError(res, `删除SSL证书失败: ${msg}`); + } +}; diff --git a/packages/snowcat-webui-backend/src/assets/sw_template.js b/packages/snowcat-webui-backend/src/assets/sw_template.js new file mode 100644 index 0000000..a175776 --- /dev/null +++ b/packages/snowcat-webui-backend/src/assets/sw_template.js @@ -0,0 +1,337 @@ +/* eslint-disable no-undef */ +/** + * NapCat WebUI Service Worker + * + * 路由缓存策略设计: + * + * 【永不缓存 - Network Only】 + * - /api/* WebUI API + * - /plugin/:id/api/* 插件 API + * - /files/theme.css 动态主题 CSS + * - /webui/fonts/CustomFont.woff 用户自定义字体 + * - WebSocket / SSE 连接 + * + * 【强缓存 - Cache First】 + * - /webui/assets/* 前端静态资源(带 hash) + * - /webui/fonts/* 内置字体(排除 CustomFont) + * - q1.qlogo.cn QQ 头像 + * + * 【网络优先 - Network First】 + * - /webui/* (HTML 导航) SPA 页面 + * - /plugin/:id/page/* 插件页面 + * - /plugin/:id/files/* 插件文件系统静态资源 + * + * 【后台更新 - Stale-While-Revalidate】 + * - /plugin/:id/mem/* 插件内存静态资源 + */ + +const CACHE_NAME = 'snowcat-webui-v{{VERSION}}'; + +// 缓存配置 +const CACHE_CONFIG = { + // 静态资源缓存最大条目数 + MAX_STATIC_ENTRIES: 200, + // QQ 头像缓存最大条目数 + MAX_AVATAR_ENTRIES: 100, + // 插件资源缓存最大条目数 + MAX_PLUGIN_ENTRIES: 50, +}; + +// ============ 路由匹配辅助函数 ============ + +/** + * 检查是否为永不缓存的请求 + */ +function isNeverCache (url, request) { + // WebUI API + if (url.pathname.startsWith('/api/')) return true; + + // 插件 API: /plugin/:id/api/* + if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true; + + // 动态主题 CSS + if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true; + + // 用户自定义字体 + if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true; + + // WebSocket 升级请求 + if (request.headers.get('Upgrade') === 'websocket') return true; + + // SSE 请求 + if (request.headers.get('Accept') === 'text/event-stream') return true; + + // Socket 相关 + if (url.pathname.includes('/socket')) return true; + + return false; +} + +/** + * 检查是否为 WebUI 静态资源(强缓存) + */ +function isWebUIStaticAsset (url) { + // /webui/assets/* - 前端构建产物(带 hash) + if (url.pathname.startsWith('/webui/assets/')) return true; + + // /webui/fonts/* - 内置字体(排除 CustomFont) + if (url.pathname.startsWith('/webui/fonts/') && + !url.pathname.includes('CustomFont.woff')) return true; + + return false; +} + +/** + * 检查是否为外部头像(强缓存) + */ +function isQLogoAvatar (url) { + return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn'; +} + +/** + * 检查是否为插件文件系统静态资源(网络优先) + */ +function isPluginStaticFiles (url) { + // /plugin/:id/files/* + return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname); +} + +/** + * 检查是否为插件内存静态资源(Stale-While-Revalidate) + */ +function isPluginMemoryAsset (url) { + // /plugin/:id/mem/* + return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname); +} + +/** + * 检查是否为插件页面(Network First) + */ +function isPluginPage (url) { + // /plugin/:id/page/* + return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname); +} + +// ============ 缓存管理函数 ============ + +/** + * 限制缓存条目数量 + */ +async function trimCache (cacheName, maxEntries) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + if (keys.length > maxEntries) { + // 删除最早的条目 + const deleteCount = keys.length - maxEntries; + for (let i = 0; i < deleteCount; i++) { + await cache.delete(keys[i]); + } + console.log(`[SW] Trimmed ${deleteCount} entries from cache`); + } +} + +/** + * 按类型获取缓存限制 + */ +function getCacheLimitForRequest (url) { + if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES; + if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES; + return CACHE_CONFIG.MAX_STATIC_ENTRIES; +} + +// ============ Service Worker 生命周期 ============ + +self.addEventListener('install', (event) => { + console.log('[SW] Installing new version:', CACHE_NAME); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + console.log('[SW] Activating new version:', CACHE_NAME); + event.waitUntil( + (async () => { + // 清理所有旧版本缓存 + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map((cacheName) => { + if (cacheName.startsWith('snowcat-webui-') && cacheName !== CACHE_NAME) { + console.log('[SW] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + return undefined; + }) + ); + // 立即接管所有客户端 + await self.clients.claim(); + })() + ); +}); + +// ============ 请求拦截 ============ + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + const request = event.request; + + // 1. 永不缓存的请求 - Network Only + if (isNeverCache(url, request)) { + // 不调用 respondWith,让请求直接穿透到网络 + return; + } + + // 2. WebUI 静态资源 - Cache First + if (isWebUIStaticAsset(url)) { + event.respondWith(cacheFirst(request, url)); + return; + } + + // 3. QQ 头像 - Cache First(支持 opaque response) + if (isQLogoAvatar(url)) { + event.respondWith(cacheFirstWithOpaque(request, url)); + return; + } + + // 4. 插件文件系统静态资源 - Network First + if (isPluginStaticFiles(url)) { + event.respondWith(networkFirst(request)); + return; + } + + // 5. 插件内存静态资源 - Stale-While-Revalidate + if (isPluginMemoryAsset(url)) { + event.respondWith(staleWhileRevalidate(request, url)); + return; + } + + // 6. 插件页面 - Network First + if (isPluginPage(url)) { + event.respondWith(networkFirst(request)); + return; + } + + // 7. HTML 导航请求 - Network First + if (request.mode === 'navigate') { + event.respondWith(networkFirst(request)); + return; + } + + // 8. 其他同源请求 - Network Only(避免意外缓存) + if (url.origin === self.location.origin) { + // 不缓存,直接穿透 + + } + + // 9. 其他外部请求 - Network Only +}); + +// ============ 缓存策略实现 ============ + +/** + * Cache First 策略 + * 优先从缓存返回,缓存未命中则从网络获取并缓存 + */ +async function cacheFirst (request, url) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + if (networkResponse && networkResponse.status === 200) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + // 异步清理缓存 + trimCache(CACHE_NAME, getCacheLimitForRequest(url)); + } + return networkResponse; + } catch (error) { + console.error('[SW] Cache First fetch failed:', error); + return new Response('Network error', { status: 503 }); + } +} + +/** + * Cache First 策略(支持 opaque response,用于跨域头像) + */ +async function cacheFirstWithOpaque (request, url) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + // opaque response 的 status 是 0,但仍可缓存 + const isValidResponse = networkResponse && ( + networkResponse.status === 200 || + networkResponse.type === 'opaque' + ); + + if (isValidResponse) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + trimCache(CACHE_NAME, getCacheLimitForRequest(url)); + } + return networkResponse; + } catch (error) { + console.error('[SW] Cache First (opaque) fetch failed:', error); + return new Response('Network error', { status: 503 }); + } +} + +/** + * Network First 策略 + * 优先从网络获取,网络失败则返回缓存 + */ +async function networkFirst (request) { + try { + const networkResponse = await fetch(request); + if (networkResponse && networkResponse.status === 200) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + console.log('[SW] Network First: network failed, trying cache'); + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + return new Response('Offline', { status: 503 }); + } +} + +/** + * Stale-While-Revalidate 策略 + * 立即返回缓存(如果有),同时后台更新缓存 + */ +async function staleWhileRevalidate (request, url) { + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(request); + + // 后台刷新缓存 + const fetchPromise = fetch(request).then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + cache.put(request, networkResponse.clone()); + trimCache(CACHE_NAME, getCacheLimitForRequest(url)); + } + return networkResponse; + }).catch((error) => { + console.log('[SW] SWR background fetch failed:', error); + return null; + }); + + // 如果有缓存,立即返回缓存 + if (cachedResponse) { + return cachedResponse; + } + + // 没有缓存,等待网络 + const networkResponse = await fetchPromise; + if (networkResponse) { + return networkResponse; + } + + return new Response('Network error', { status: 503 }); +} diff --git a/packages/snowcat-webui-backend/src/const/status.ts b/packages/snowcat-webui-backend/src/const/status.ts new file mode 100644 index 0000000..c65e938 --- /dev/null +++ b/packages/snowcat-webui-backend/src/const/status.ts @@ -0,0 +1,13 @@ +export enum HttpStatusCode { + OK = 200, + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + InternalServerError = 500, +} + +export enum ResponseCode { + Success = 0, + Error = -1, +} diff --git a/packages/snowcat-webui-backend/src/helper/Data.ts b/packages/snowcat-webui-backend/src/helper/Data.ts new file mode 100644 index 0000000..837299a --- /dev/null +++ b/packages/snowcat-webui-backend/src/helper/Data.ts @@ -0,0 +1,269 @@ +import store from 'snowcat-common/src/store'; +import { snowCatVersion } from 'snowcat-common/src/version'; +import { SnowCatCoreWorkingEnv, type LoginRuntimeType, type QQLoginPhase } from '../types'; + +const LoginRuntime: LoginRuntimeType = { + workingEnv: SnowCatCoreWorkingEnv.Unknown, + LoginCurrentTime: Date.now(), + LoginCurrentRate: 0, + QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新 + QQLoginPhase: 'waiting_qrcode', + QQQRCodeURL: '', + QQLoginUin: '', + QQLoginInfo: { + uid: '', + uin: '', + nick: '', + }, + QQLoginError: '', + QQVersion: 'unknown', + QQDataPath: '', + OneBotContext: null, + onQQLoginStatusChange: async (status: boolean) => { + LoginRuntime.QQLoginStatus = status; + }, + onWebUiTokenChange: async (_token: string) => { + + }, + onRefreshQRCode: async () => { + // 默认空实现,由 shell 注册真实回调 + }, + SnowCatHelper: { + onOB11ConfigChanged: async () => { + + }, + onQuickLoginRequested: async () => { + return { result: false, message: '' }; + }, + onPasswordLoginRequested: async () => { + return { result: false, message: '密码登录功能未初始化' }; + }, + onCaptchaLoginRequested: async () => { + return { result: false, message: '验证码登录功能未初始化' }; + }, + onNewDeviceLoginRequested: async () => { + return { result: false, message: '新设备登录功能未初始化' }; + }, + onRestartProcessRequested: async () => { + return { result: false, message: '重启功能未初始化' }; + }, + QQLoginList: [], + NewQQLoginList: [], + }, + SnowCatVersion: snowCatVersion, + WebUiConfigQuickFunction: async () => { + + }, +}; +export const WebUiDataRuntime = { + setWorkingEnv (env: SnowCatCoreWorkingEnv): void { + LoginRuntime.workingEnv = env; + }, + getWorkingEnv (): SnowCatCoreWorkingEnv { + return LoginRuntime.workingEnv; + }, + setWebUiTokenChangeCallback (func: (token: string) => Promise): void { + LoginRuntime.onWebUiTokenChange = func; + }, + getWebUiTokenChangeCallback (): (token: string) => Promise { + return LoginRuntime.onWebUiTokenChange; + }, + checkLoginRate (ip: string, RateLimit: number): boolean { + const key = `login_rate:${ip}`; + const count = store.get(key) || 0; + + if (count === 0) { + // 第一次访问,设置计数器为1,并设置60秒过期 + store.set(key, 1, 60); + return true; + } + + if (count >= RateLimit) { + return false; + } + + store.set(key, count + 1); + return true; + }, + + getQQLoginStatus (): LoginRuntimeType['QQLoginStatus'] { + return LoginRuntime.QQLoginStatus; + }, + + setQQLoginCallback (func: (status: boolean) => Promise): void { + LoginRuntime.onQQLoginStatusChange = func; + }, + + getQQLoginCallback (): (status: boolean) => Promise { + return LoginRuntime.onQQLoginStatusChange; + }, + + setQQLoginStatus (status: LoginRuntimeType['QQLoginStatus']): void { + LoginRuntime.QQLoginStatus = status; + }, + + setQQLoginPhase (phase: QQLoginPhase): void { + LoginRuntime.QQLoginPhase = phase; + }, + + getQQLoginPhase (): QQLoginPhase { + return LoginRuntime.QQLoginPhase; + }, + + setQQLoginQrcodeURL (url: LoginRuntimeType['QQQRCodeURL']): void { + LoginRuntime.QQQRCodeURL = url; + }, + + getQQLoginQrcodeURL (): LoginRuntimeType['QQQRCodeURL'] { + return LoginRuntime.QQQRCodeURL; + }, + + setQQLoginInfo (info: LoginRuntimeType['QQLoginInfo']): void { + LoginRuntime.QQLoginInfo = info; + LoginRuntime.QQLoginUin = info.uin.toString(); + }, + + getQQLoginInfo (): LoginRuntimeType['QQLoginInfo'] { + return LoginRuntime.QQLoginInfo; + }, + + getQQLoginUin (): LoginRuntimeType['QQLoginUin'] { + return LoginRuntime.QQLoginUin; + }, + + getQQQuickLoginList (): LoginRuntimeType['SnowCatHelper']['QQLoginList'] { + return LoginRuntime.SnowCatHelper.QQLoginList; + }, + + setQQQuickLoginList (list: LoginRuntimeType['SnowCatHelper']['QQLoginList']): void { + LoginRuntime.SnowCatHelper.QQLoginList = list; + }, + + getQQNewLoginList (): LoginRuntimeType['SnowCatHelper']['NewQQLoginList'] { + return LoginRuntime.SnowCatHelper.NewQQLoginList; + }, + + setQQNewLoginList (list: LoginRuntimeType['SnowCatHelper']['NewQQLoginList']): void { + LoginRuntime.SnowCatHelper.NewQQLoginList = list; + }, + + setQuickLoginCall (func: LoginRuntimeType['SnowCatHelper']['onQuickLoginRequested']): void { + LoginRuntime.SnowCatHelper.onQuickLoginRequested = func; + }, + + requestQuickLogin: function (uin) { + return LoginRuntime.SnowCatHelper.onQuickLoginRequested(uin); + } as LoginRuntimeType['SnowCatHelper']['onQuickLoginRequested'], + + setPasswordLoginCall (func: LoginRuntimeType['SnowCatHelper']['onPasswordLoginRequested']): void { + LoginRuntime.SnowCatHelper.onPasswordLoginRequested = func; + }, + + requestPasswordLogin: function (uin: string, passwordMd5: string) { + return LoginRuntime.SnowCatHelper.onPasswordLoginRequested(uin, passwordMd5); + } as LoginRuntimeType['SnowCatHelper']['onPasswordLoginRequested'], + + setCaptchaLoginCall (func: LoginRuntimeType['SnowCatHelper']['onCaptchaLoginRequested']): void { + LoginRuntime.SnowCatHelper.onCaptchaLoginRequested = func; + }, + + requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) { + return LoginRuntime.SnowCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid); + } as LoginRuntimeType['SnowCatHelper']['onCaptchaLoginRequested'], + + setNewDeviceLoginCall (func: LoginRuntimeType['SnowCatHelper']['onNewDeviceLoginRequested']): void { + LoginRuntime.SnowCatHelper.onNewDeviceLoginRequested = func; + }, + + requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) { + return LoginRuntime.SnowCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig); + } as LoginRuntimeType['SnowCatHelper']['onNewDeviceLoginRequested'], + + setOnOB11ConfigChanged (func: LoginRuntimeType['SnowCatHelper']['onOB11ConfigChanged']): void { + LoginRuntime.SnowCatHelper.onOB11ConfigChanged = func; + }, + + setOB11Config: function (ob11) { + return LoginRuntime.SnowCatHelper.onOB11ConfigChanged(ob11); + } as LoginRuntimeType['SnowCatHelper']['onOB11ConfigChanged'], + + GetNapCatVersion () { + return LoginRuntime.SnowCatVersion; + }, + + setQQVersion (version: string) { + LoginRuntime.QQVersion = version; + }, + + getQQVersion () { + return LoginRuntime.QQVersion; + }, + + setQQDataPath (dataPath: string) { + LoginRuntime.QQDataPath = dataPath; + }, + + getQQDataPath (): string { + return LoginRuntime.QQDataPath; + }, + + setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void { + LoginRuntime.WebUiConfigQuickFunction = func; + }, + runWebUiConfigQuickFunction: async function () { + await LoginRuntime.WebUiConfigQuickFunction(); + }, + + setOneBotContext (context: any): void { + LoginRuntime.OneBotContext = context; + }, + + getOneBotContext (): any | null { + return LoginRuntime.OneBotContext; + }, + + setRestartProcessCall (func: () => Promise<{ result: boolean; message: string; }>): void { + LoginRuntime.SnowCatHelper.onRestartProcessRequested = func; + }, + + requestRestartProcess: async function () { + return await LoginRuntime.SnowCatHelper.onRestartProcessRequested(); + }, + + setQQLoginError (error: string): void { + LoginRuntime.QQLoginError = error; + }, + + getQQLoginError (): string { + return LoginRuntime.QQLoginError; + }, + + setRefreshQRCodeCallback (func: () => Promise): void { + LoginRuntime.onRefreshQRCode = func; + }, + + getRefreshQRCodeCallback (): () => Promise { + return LoginRuntime.onRefreshQRCode; + }, + + refreshQRCode: async function () { + const previousQRCodeUrl = LoginRuntime.QQQRCodeURL; + LoginRuntime.QQLoginError = ''; + LoginRuntime.QQLoginPhase = 'generating_qrcode'; + await LoginRuntime.onRefreshQRCode(); + + const refreshTimeout = 10_000; + const deadline = Date.now() + refreshTimeout; + while (Date.now() < deadline) { + const qrcodeUrl = LoginRuntime.QQQRCodeURL; + if (qrcodeUrl && qrcodeUrl !== previousQRCodeUrl) { + LoginRuntime.QQLoginPhase = 'waiting_qrcode'; + return qrcodeUrl; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + + LoginRuntime.QQLoginPhase = previousQRCodeUrl ? 'waiting_qrcode' : 'offline'; + throw new Error('二维码刷新超时:QQ 登录服务未返回新的二维码'); + }, +}; diff --git a/packages/snowcat-webui-backend/src/helper/PasskeyHelper.ts b/packages/snowcat-webui-backend/src/helper/PasskeyHelper.ts new file mode 100644 index 0000000..46fac63 --- /dev/null +++ b/packages/snowcat-webui-backend/src/helper/PasskeyHelper.ts @@ -0,0 +1,207 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import { isoBase64URL } from '@simplewebauthn/server/helpers'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { webUiPathWrapper } from '../../index'; + +interface PasskeyCredential { + id: string; + publicKey: string; + counter: number; + transports?: AuthenticatorTransportFuture[]; +} + +const RP_NAME = 'SnowCat WebUI'; + +export class PasskeyHelper { + private static getPasskeyFilePath (): string { + return path.join(webUiPathWrapper.configPath, 'passkey.json'); + } + + // 内存中存储临时挑战数据 + private static challenges: Map = new Map(); + private static async ensurePasskeyFile (): Promise { + try { + // 确保配置文件目录存在 + const passkeyFile = this.getPasskeyFilePath(); + await fs.mkdir(path.dirname(passkeyFile), { recursive: true }); + // 检查文件是否存在,如果不存在创建空文件 + try { + await fs.access(passkeyFile); + } catch { + await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2)); + } + } catch (_error) { + // Directory or file already exists or other error + } + } + + private static async getAllPasskeys (): Promise> { + await this.ensurePasskeyFile(); + try { + const passkeyFile = this.getPasskeyFilePath(); + const data = await fs.readFile(passkeyFile, 'utf-8'); + const passkeys = JSON.parse(data); + return typeof passkeys === 'object' && passkeys !== null ? passkeys : {}; + } catch (_error) { + console.error('Failed to read passkey file:', _error); + return {}; + } + } + + private static async saveAllPasskeys (allPasskeys: Record): Promise { + await this.ensurePasskeyFile(); + const passkeyFile = this.getPasskeyFilePath(); + await fs.writeFile(passkeyFile, JSON.stringify(allPasskeys, null, 2)); + } + + private static async getUserPasskeys (userId: string): Promise { + const allPasskeys = await this.getAllPasskeys(); + return allPasskeys[userId] || []; + } + + // 持久性存储用户的passkey到统一配置文件 + private static async setUserPasskeys (userId: string, passkeys: PasskeyCredential[]): Promise { + const allPasskeys = await this.getAllPasskeys(); + if (passkeys.length > 0) { + allPasskeys[userId] = passkeys; + } else { + delete allPasskeys[userId]; + } + await this.saveAllPasskeys(allPasskeys); + } + + static async generateRegistrationOptions (userId: string, userName: string, rpId: string) { + const userPasskeys = await this.getUserPasskeys(userId); + + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: rpId, + userID: new TextEncoder().encode(userId) as Uint8Array, + userName, + attestationType: 'none', + excludeCredentials: userPasskeys.map(passkey => ({ + id: passkey.id, + type: 'public-key' as const, + transports: passkey.transports, + })), + // Temporarily simplify authenticatorSelection - remove residentKey to avoid conflicts + authenticatorSelection: { + userVerification: 'preferred', + }, + }); + + // Store challenge temporarily in memory + this.challenges.set(`reg_${userId}`, options.challenge); + // Auto cleanup after 5 minutes + setTimeout(() => { + this.challenges.delete(`reg_${userId}`); + }, 300000); + + return options; + } + + static async verifyRegistration (userId: string, response: any, origin: string, rpId: string) { + const expectedChallenge = this.challenges.get(`reg_${userId}`); + if (!expectedChallenge) { + throw new Error('Challenge not found or expired'); + } + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + }); + + if (verification.verified && verification.registrationInfo) { + const { registrationInfo } = verification; + + const newPasskey: PasskeyCredential = { + id: registrationInfo.credential.id, + publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey), + counter: registrationInfo.credential.counter || 0, + transports: response.response.transports, + }; + + const userPasskeys = await this.getUserPasskeys(userId); + userPasskeys.push(newPasskey); + await this.setUserPasskeys(userId, userPasskeys); + + // Clean up challenge + this.challenges.delete(`reg_${userId}`); + } + + return verification; + } + + static async generateAuthenticationOptions (userId: string, rpId: string) { + const userPasskeys = await this.getUserPasskeys(userId); + + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials: userPasskeys.map(passkey => ({ + id: passkey.id, + type: 'public-key' as const, + transports: passkey.transports, + })), + userVerification: 'preferred', + }); + + // Store challenge temporarily in memory + this.challenges.set(`auth_${userId}`, options.challenge); + // Auto cleanup after 5 minutes + setTimeout(() => { + this.challenges.delete(`auth_${userId}`); + }, 300000); + + return options; + } + + static async verifyAuthentication (userId: string, response: any, origin: string, rpId: string) { + const expectedChallenge = this.challenges.get(`auth_${userId}`); + if (!expectedChallenge) { + throw new Error('Challenge not found or expired'); + } + + const userPasskeys = await this.getUserPasskeys(userId); + const passkey = userPasskeys.find(p => p.id === response.id); + if (!passkey) { + throw new Error('Passkey not found'); + } + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + credential: { + id: passkey.id, + publicKey: isoBase64URL.toBuffer(passkey.publicKey), + counter: passkey.counter, + }, + }); + + if (verification.verified && verification.authenticationInfo) { + // Update counter + passkey.counter = verification.authenticationInfo.newCounter; + await this.setUserPasskeys(userId, userPasskeys); + + // Clean up challenge + this.challenges.delete(`auth_${userId}`); + } + + return verification; + } + + static async hasPasskeys (userId: string): Promise { + const userPasskeys = await this.getUserPasskeys(userId); + return userPasskeys.length > 0; + } +} diff --git a/packages/snowcat-webui-backend/src/helper/SignToken.ts b/packages/snowcat-webui-backend/src/helper/SignToken.ts new file mode 100644 index 0000000..6888ad8 --- /dev/null +++ b/packages/snowcat-webui-backend/src/helper/SignToken.ts @@ -0,0 +1,114 @@ +import crypto from 'crypto'; +import store from 'snowcat-common/src/store'; +import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/snowcat-webui-backend/src/types'; +export class AuthHelper { + private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2); + private static readonly MAX_CREDENTIAL_VALID_SECONDS = 3600; + + public static getSecretKey (): string { + return AuthHelper.secretKey; + } + + /** + * 签名凭证方法。 + * @param hash 待签名的凭证字符串。 + * @returns 签名后的凭证对象。 + */ + public static signCredential (hash: string): WebUiCredentialJson { + const innerJson: WebUiCredentialInnerJson = { + // 统一使用秒级时间戳,避免与校验逻辑出现单位不一致 + CreatedTime: Math.floor(Date.now() / 1000), + HashEncoded: hash, + }; + const jsonString = JSON.stringify(innerJson); + const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex'); + return { Data: innerJson, Hmac: hmac }; + } + + /** + * 检查凭证是否被篡改的方法。 + * @param credentialJson 凭证的JSON对象。 + * @returns 布尔值,表示凭证是否有效。 + */ + public static checkCredential (credentialJson: WebUiCredentialJson): boolean { + try { + const jsonString = JSON.stringify(credentialJson.Data); + const calculatedHmac = crypto + .createHmac('sha256', AuthHelper.secretKey) + .update(jsonString, 'utf8') + .digest('hex'); + return calculatedHmac === credentialJson.Hmac; + } catch (_error) { + return false; + } + } + + /** + * 验证凭证在1小时内有效且token与原始token相同。 + * @param token 待验证的原始token。 + * @param credentialJson 已签名的凭证JSON对象。 + * @returns 布尔值,表示凭证是否有效且token匹配。 + */ + public static validateCredentialWithinOneHour (token: string, credentialJson: WebUiCredentialJson): boolean { + // 首先检查凭证是否被篡改 + const isValid = AuthHelper.checkCredential(credentialJson); + if (!isValid) { + return false; + } + + // 检查凭证是否在黑名单中 + if (AuthHelper.isCredentialRevoked(credentialJson)) { + return false; + } + + const currentTimeSeconds = Math.floor(Date.now() / 1000); + const createdTimeSeconds = Math.floor(Number(credentialJson.Data.CreatedTime)); + const timeDifferenceSeconds = currentTimeSeconds - createdTimeSeconds; + const isWithinOneHour = timeDifferenceSeconds >= 0 && timeDifferenceSeconds <= AuthHelper.MAX_CREDENTIAL_VALID_SECONDS; + return isWithinOneHour && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token); + } + + /** + * 注销指定的Token凭证 + * @param credentialJson 凭证JSON对象 + * @returns void + */ + public static revokeCredential (credentialJson: WebUiCredentialJson): void { + const jsonString = JSON.stringify(credentialJson.Data); + const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex'); + + // 将已注销的凭证添加到黑名单中,有效期1小时 + store.set(`revoked:${hmac}`, true, 3600); + } + + /** + * 检查凭证是否已被注销 + * @param credentialJson 凭证JSON对象 + * @returns 布尔值,表示凭证是否已被注销 + */ + public static isCredentialRevoked (credentialJson: WebUiCredentialJson): boolean { + const jsonString = JSON.stringify(credentialJson.Data); + const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex'); + + return store.exists(`revoked:${hmac}`) > 0; + } + + /** + * 生成密码Hash + * @param password 密码 + * @returns 生成的Hash值 + */ + public static generatePasswordHash (password: string): string { + return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex'); + } + + /** + * 对比密码和Hash值 + * @param password 密码 + * @param hash Hash值 + * @returns 布尔值,表示密码是否匹配Hash值 + */ + public static comparePasswordHash (password: string, hash: string): boolean { + return this.generatePasswordHash(password) === hash; + } +} diff --git a/packages/snowcat-webui-backend/src/helper/TotpHelper.ts b/packages/snowcat-webui-backend/src/helper/TotpHelper.ts new file mode 100644 index 0000000..309f410 --- /dev/null +++ b/packages/snowcat-webui-backend/src/helper/TotpHelper.ts @@ -0,0 +1,164 @@ +import crypto from 'crypto'; + +/** + * TOTP (Time-based One-Time Password) Helper + * 用于生成和验证基于时间的一次性密码 + */ +export class TotpHelper { + /** + * 生成随机密钥(Base32格式) + * @param length 密钥长度(字节),默认16字节 + * @returns Base32编码的密钥 + */ + static generateSecret (length: number = 16): string { + const buffer = crypto.randomBytes(length); + return this.toBase32(buffer); + } + + /** + * 验证一次性密码 + * @param secret Base32编码的密钥 + * @param code 用户输入的验证码 + * @param timeStep 时间步长(秒),默认30秒 + * @param window 允许的时间窗口(前后各多少个时间步长),默认1 + * @returns 是否验证通过 + */ + static verifyTotp ( + secret: string, + code: string, + timeStep: number = 30, + window: number = 1 + ): boolean { + const time = Math.floor(Date.now() / 1000 / timeStep); + + // 检查前后 window 个时间步长 + for (let i = -window; i <= window; i++) { + const candidateCode = this.generateHotp(secret, time + i); + if (candidateCode === code) { + return true; + } + } + return false; + } + + /** + * 生成 HOTP (HMAC-based One-Time Password) + * @param secret Base32编码的密钥 + * @param counter 计数器 + * @returns 6位数字验证码 + */ + private static generateHotp (secret: string, counter: number): string { + // 将 Base32 密钥转换为字节 + const key = this.base32ToBytes(secret); + + // 将计数器转换为 8 字节大端序 + const counterBytes = Buffer.alloc(8); + let tempCounter = counter; + for (let i = 7; i >= 0; i--) { + counterBytes[i] = tempCounter & 0xff; + tempCounter = Math.floor(tempCounter / 256); + } + + // 计算 HMAC-SHA1 + const hmac = crypto.createHmac('sha1', key); + hmac.update(counterBytes); + const hash = hmac.digest(); + + // 动态截断 + const hashLength = hash.length; + const lastByte = hash[hashLength - 1]; + if (lastByte === undefined) { + return '000000'; + } + const offset = lastByte & 0x0f; + const byte0 = hash[offset] ?? 0; + const byte1 = hash[offset + 1] ?? 0; + const byte2 = hash[offset + 2] ?? 0; + const byte3 = hash[offset + 3] ?? 0; + const binary = + ((byte0 & 0x7f) << 24) | + ((byte1 & 0xff) << 16) | + ((byte2 & 0xff) << 8) | + (byte3 & 0xff); + + // 生成 6 位数字 + const otp = binary % 1000000; + return otp.toString().padStart(6, '0'); + } + + /** + * 生成 QR Code URL + * @param secret Base32编码的密钥 + * @param account 账户名称 + * @param issuer 发行方名称 + * @returns otpauth:// URL + */ + static generateQrCodeUrl ( + secret: string, + account: string, + issuer: string + ): string { + const encodedIssuer = encodeURIComponent(issuer); + const encodedAccount = encodeURIComponent(`${issuer}:${account}`); + return `otpauth://totp/${encodedAccount}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`; + } + + /** + * 将字节数组转换为 Base32 编码 + * @param buffer 字节数组 + * @returns Base32编码字符串 + */ + private static toBase32 (buffer: Buffer): string { + const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let output = ''; + + for (let i = 0; i < buffer.length; i += 5) { + const b0 = buffer[i] ?? 0; + const b1 = buffer[i + 1] ?? 0; + const b2 = buffer[i + 2] ?? 0; + const b3 = buffer[i + 3] ?? 0; + const b4 = buffer[i + 4] ?? 0; + + output += base32Alphabet[(b0 >> 3) & 0x1f]; + output += base32Alphabet[((b0 << 2) | (b1 >> 6)) & 0x1f]; + output += base32Alphabet[(b1 >> 1) & 0x1f]; + output += base32Alphabet[((b1 << 4) | (b2 >> 4)) & 0x1f]; + output += base32Alphabet[((b2 << 1) | (b3 >> 7)) & 0x1f]; + output += base32Alphabet[(b3 >> 2) & 0x1f]; + output += base32Alphabet[((b3 << 3) | (b4 >> 5)) & 0x1f]; + output += base32Alphabet[b4 & 0x1f]; + } + + // 移除填充的等号 + return output.replace(/=+$/, ''); + } + + /** + * 将 Base32 字符串转换为字节数组 + * @param base32 Base32编码字符串 + * @returns 字节数组 + */ + private static base32ToBytes (base32: string): Buffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const base32Clean = base32.toUpperCase().replace(/=+$/, ''); + + const bytes: number[] = []; + let buffer = 0; + let bitsLeft = 0; + + for (const char of base32Clean) { + const value = alphabet.indexOf(char); + if (value === -1) continue; + + buffer = (buffer << 5) | value; + bitsLeft += 5; + + if (bitsLeft >= 8) { + bytes.push((buffer >> (bitsLeft - 8)) & 0xff); + bitsLeft -= 8; + } + } + + return Buffer.from(bytes); + } +} diff --git a/packages/snowcat-webui-backend/src/helper/config.ts b/packages/snowcat-webui-backend/src/helper/config.ts new file mode 100644 index 0000000..7befb4e --- /dev/null +++ b/packages/snowcat-webui-backend/src/helper/config.ts @@ -0,0 +1,311 @@ +import { webUiPathWrapper, getInitialWebUiToken } from '@/snowcat-webui-backend/index'; +import { Type, Static } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; +import fs, { constants } from 'node:fs/promises'; + +import { resolve } from 'node:path'; + +import { deepMerge } from '../utils/object'; +import { themeType } from '../types/theme'; +import { getRandomToken } from '../utils/url'; + +// 限制尝试端口的次数,避免死循环 +// 定义配置的类型 +const WebUiConfigSchema = Type.Object({ + host: Type.String({ default: '::' }), + port: Type.Number({ default: 6099 }), + token: Type.String({ default: getRandomToken(12) }), + loginRate: Type.Number({ default: 10 }), + autoLoginAccount: Type.String({ default: '' }), + theme: themeType, + // 是否关闭WebUI + disableWebUI: Type.Boolean({ default: false }), + // 网络访问控制模式: 'none' | 'whitelist' | 'blacklist' + accessControlMode: Type.Union([ + Type.Literal('none'), + Type.Literal('whitelist'), + Type.Literal('blacklist'), + ], { default: 'none' }), + // IP白名单列表 + ipWhitelist: Type.Array(Type.String(), { default: [] }), + // IP黑名单列表 + ipBlacklist: Type.Array(Type.String(), { default: [] }), + // 是否启用 X-Forwarded-For 获取真实IP + enableXForwardedFor: Type.Boolean({ default: false }), + // 是否启用双因素认证(2FA) + enable2FA: Type.Boolean({ default: false }), + // TOTP 密钥 + totpSecret: Type.String({ default: '' }), +}); + +export type WebUiConfigType = Static; + +// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件 +export class WebUiConfigWrapper { + WebUiConfigData: WebUiConfigType | undefined = undefined; + + private validateAndApplyDefaults (config: Partial): WebUiConfigType { + let data = config; + data = Value.Parse(WebUiConfigSchema, data) as Partial; + const validate = TypeCompiler.Compile(WebUiConfigSchema); + if (!validate.Check(data)) { + // Return original if validation failed, or throw + } + return data as WebUiConfigType; + } + + private async ensureConfigFileExists (configPath: string): Promise { + const configExists = await fs + .access(configPath, constants.F_OK) + .then(() => true) + .catch(() => false); + if (!configExists) { + await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4)); + } + } + + private async readAndValidateConfig (configPath: string): Promise { + const fileContent = await fs.readFile(configPath, 'utf-8'); + return this.validateAndApplyDefaults(JSON.parse(fileContent)); + } + + private async writeConfig (configPath: string, config: WebUiConfigType): Promise { + const hasWritePermission = await fs + .access(configPath, constants.W_OK) + .then(() => true) + .catch(() => false); + if (hasWritePermission) { + await fs.writeFile(configPath, JSON.stringify(config, null, 4)); + } else { + console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`); + } + } + + async GetWebUIConfig (): Promise { + if (this.WebUiConfigData) { + return this.WebUiConfigData; + } + + try { + const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); + await this.ensureConfigFileExists(configPath); + const parsedConfig = await this.readAndValidateConfig(configPath); + // 使用内存中缓存的token进行覆盖,确保强兼容性 + this.WebUiConfigData = { + ...parsedConfig, + // 首次读取内存中是没有token的,需要进行一层兜底 + token: getInitialWebUiToken() || parsedConfig.token, + }; + return this.WebUiConfigData; + } catch (e) { + console.log('读取配置文件失败', e); + const defaultConfig = this.validateAndApplyDefaults({}); + this.WebUiConfigData = { + ...defaultConfig, + token: getInitialWebUiToken() || defaultConfig.token, + }; + return this.WebUiConfigData; + } + } + + async UpdateWebUIConfig (newConfig: Partial): Promise { + const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); + // 使用原始配置进行合并,避免内存token覆盖影响配置更新 + const currentConfig = await this.GetRawWebUIConfig(); + const mergedConfig = deepMerge({ ...currentConfig }, newConfig); + const updatedConfig = this.validateAndApplyDefaults(mergedConfig); + await this.writeConfig(configPath, updatedConfig); + this.WebUiConfigData = updatedConfig; + } + + /** + * 获取配置文件中实际存储的配置(不被内存token覆盖) + * 主要用于配置更新和特殊场景 + */ + async GetRawWebUIConfig (): Promise { + if (this.WebUiConfigData) { + return this.WebUiConfigData; + } + try { + const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); + await this.ensureConfigFileExists(configPath); + const parsedConfig = await this.readAndValidateConfig(configPath); + this.WebUiConfigData = parsedConfig; + return this.WebUiConfigData; + } catch (e) { + console.log('读取配置文件失败', e); + return this.validateAndApplyDefaults({}); + } + } + + async UpdateToken (oldToken: string, newToken: string): Promise { + // 使用内存中缓存的token进行验证,确保强兼容性 + const cachedToken = getInitialWebUiToken(); + const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token; + + if (tokenToCheck !== oldToken) { + throw new Error('旧 token 不匹配'); + } + await this.UpdateWebUIConfig({ token: newToken }); + } + + // 获取日志文件夹路径 + async GetLogsPath (): Promise { + return resolve(webUiPathWrapper.logsPath); + } + + // 获取日志列表 + async GetLogsList (): Promise { + const logsPath = resolve(webUiPathWrapper.logsPath); + const logsExist = await fs + .access(logsPath, constants.F_OK) + .then(() => true) + .catch(() => false); + if (logsExist) { + return (await fs.readdir(logsPath)) + .filter((file) => file.endsWith('.log')) + .map((file) => file.replace('.log', '')); + } + return []; + } + + // 获取指定日志文件内容 + async GetLogContent (filename: string): Promise { + const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`); + const logExists = await fs + .access(logPath, constants.R_OK) + .then(() => true) + .catch(() => false); + if (logExists) { + return await fs.readFile(logPath, 'utf-8'); + } + return ''; + } + + // 获取字体文件夹内的字体列表 + async GetFontList (): Promise { + const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); + const fontsExist = await fs + .access(fontsPath, constants.F_OK) + .then(() => true) + .catch(() => false); + if (fontsExist) { + return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf')); + } + return []; + } + + // 判断字体是否存在(支持多种格式) + async CheckWebUIFontExist (): Promise { + const fontPath = await this.GetWebUIFontPath(); + if (!fontPath) return false; + return await fs + .access(fontPath, constants.F_OK) + .then(() => true) + .catch(() => false); + } + + // 获取webui字体文件路径(支持多种格式) + async GetWebUIFontPath (): Promise { + const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); + const extensions = ['.woff', '.woff2', '.ttf', '.otf']; + for (const ext of extensions) { + const fontPath = resolve(fontsPath, `webui${ext}`); + const exists = await fs + .access(fontPath, constants.F_OK) + .then(() => true) + .catch(() => false); + if (exists) { + return fontPath; + } + } + return null; + } + + // 同步版本,用于 multer 配置 + GetWebUIFontPathSync (): string { + return resolve(webUiPathWrapper.configPath, './fonts/webui.woff'); + } + + getAutoLoginAccount (): string | undefined { + return this.WebUiConfigData?.autoLoginAccount; + } + + // 获取自动登录账号 + async GetAutoLoginAccount (): Promise { + return (await this.GetWebUIConfig()).autoLoginAccount; + } + + // 更新自动登录账号 + async UpdateAutoLoginAccount (uin: string): Promise { + await this.UpdateWebUIConfig({ autoLoginAccount: uin }); + } + + // 获取主题内容 + async GetTheme (): Promise { + const config = await this.GetWebUIConfig(); + + return config.theme; + } + + // 更新主题内容 + async UpdateTheme (theme: WebUiConfigType['theme']): Promise { + await this.UpdateWebUIConfig({ theme }); + } + + // 获取是否禁用WebUI + async GetDisableWebUI (): Promise { + const config = await this.GetWebUIConfig(); + return config.disableWebUI; + } + + // 更新是否禁用WebUI + async UpdateDisableWebUI (disable: boolean): Promise { + await this.UpdateWebUIConfig({ disableWebUI: disable }); + } + + // 获取访问控制模式 + async GetAccessControlMode (): Promise<'none' | 'whitelist' | 'blacklist'> { + const config = await this.GetWebUIConfig(); + return config.accessControlMode; + } + + // 更新访问控制模式 + async UpdateAccessControlMode (mode: 'none' | 'whitelist' | 'blacklist'): Promise { + await this.UpdateWebUIConfig({ accessControlMode: mode }); + } + + // 获取IP白名单 + async GetIpWhitelist (): Promise { + const config = await this.GetWebUIConfig(); + return config.ipWhitelist; + } + + // 更新IP白名单 + async UpdateIpWhitelist (whitelist: string[]): Promise { + await this.UpdateWebUIConfig({ ipWhitelist: whitelist }); + } + + // 获取IP黑名单 + async GetIpBlacklist (): Promise { + const config = await this.GetWebUIConfig(); + return config.ipBlacklist; + } + + // 更新IP黑名单 + async UpdateIpBlacklist (blacklist: string[]): Promise { + await this.UpdateWebUIConfig({ ipBlacklist: blacklist }); + } + + // 获取是否启用 X-Forwarded-For + async GetEnableXForwardedFor (): Promise { + const config = await this.GetWebUIConfig(); + return config.enableXForwardedFor || false; + } + + // 更新是否启用 X-Forwarded-For + async UpdateEnableXForwardedFor (enable: boolean): Promise { + await this.UpdateWebUIConfig({ enableXForwardedFor: enable }); + } +} diff --git a/packages/snowcat-webui-backend/src/middleware/auth.ts b/packages/snowcat-webui-backend/src/middleware/auth.ts new file mode 100644 index 0000000..fe8a3f7 --- /dev/null +++ b/packages/snowcat-webui-backend/src/middleware/auth.ts @@ -0,0 +1,60 @@ +import { NextFunction, Request, Response } from 'express'; + +import { getInitialWebUiToken } from '@/snowcat-webui-backend/index'; + +import { AuthHelper } from '@/snowcat-webui-backend/src/helper/SignToken'; +import { sendError } from '@/snowcat-webui-backend/src/utils/response'; +import type { WebUiCredentialJson } from '@/snowcat-webui-backend/src/types'; + +// 鉴权中间件 +export async function auth (req: Request, res: Response, next: NextFunction) { + // 判断当前url是否为/login 如果是跳过鉴权 + if (req.url === '/auth/login') { + return next(); + } + if (req.url === '/auth/passkey/generate-authentication-options' || + req.url === '/auth/passkey/verify-authentication') { + return next(); + } + let hash: string | undefined; + if (req.headers?.authorization) { + // 切割参数以获取token + const authorization = req.headers.authorization.split(' '); + // 当Bearer后面没有参数时 + if (authorization.length < 2) { + return sendError(res, 'Unauthorized'); + } + // 获取token + hash = authorization[1]; + } else if (req.query['webui_token'] && typeof req.query['webui_token'] === 'string') { + // 支持通过query参数传递token + hash = req.query['webui_token']; + } + // 判断是否有Authorization头 + if (hash) { + // if (!hash) return sendError(res, 'Unauthorized'); + // 解析token + let Credential: WebUiCredentialJson; + try { + Credential = JSON.parse(Buffer.from(hash, 'base64').toString('utf-8')); + } catch (_e) { + return sendError(res, 'Unauthorized'); + } + // 使用启动时缓存的token进行验证,而不是动态读取配置文件 因为有可能运行时手动修改了密码 + const initialToken = getInitialWebUiToken(); + if (!initialToken) { + return sendError(res, 'Server token not initialized'); + } + // 验证凭证在1小时内有效 + const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential); + if (credentialJson) { + // 通过验证 + return next(); + } + // 验证失败 + return sendError(res, 'Unauthorized'); + } + + // 没有Authorization头 + return sendError(res, 'Unauthorized'); +} diff --git a/packages/snowcat-webui-backend/src/middleware/cors.ts b/packages/snowcat-webui-backend/src/middleware/cors.ts new file mode 100644 index 0000000..3505d7c --- /dev/null +++ b/packages/snowcat-webui-backend/src/middleware/cors.ts @@ -0,0 +1,233 @@ +import type { RequestHandler } from 'express'; +import { WebUiConfig } from '@/snowcat-webui-backend/index'; + +// 标准化 IP 地址(移除 IPv6 前缀,但保留完整的 IPv6 地址) +function normalizeIP (ip: string): string { + // 移除 IPv4-mapped IPv6 前缀 + if (ip.startsWith('::ffff:')) { + const ipv4 = ip.substring(7); + // 检查是否是有效的 IPv4 + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipv4)) { + return ipv4; + } + } + return ip; +} + +// 检查是否是 IPv6 地址 +function isIPv6 (ip: string): boolean { + return ip.includes(':'); +} + +// 检查 IP 是否匹配规则(支持 IPv4 和 IPv6) +function matchIPRule (ip: string, rule: string): boolean { + const cleanIP = normalizeIP(ip); + const cleanRule = normalizeIP(rule); + + // 精确匹配 + if (cleanIP === cleanRule) return true; + + // IPv6 地址不支持通配符,只支持 CIDR + if (isIPv6(cleanIP) || isIPv6(cleanRule)) { + if (cleanRule.includes('/')) { + return matchIPv6CIDR(cleanIP, cleanRule); + } + return false; + } + + // IPv4 通配符匹配 (例如: 192.168.*.* 或 192.168.1.*) + if (cleanRule.includes('*')) { + const ruleRegex = new RegExp('^' + cleanRule.replace(/\./g, '\\.').replace(/\*/g, '\\d+') + '$'); + return ruleRegex.test(cleanIP); + } + + // IPv4 CIDR 匹配 (例如: 192.168.1.0/24) + if (cleanRule.includes('/')) { + return matchIPv4CIDR(cleanIP, cleanRule); + } + + return false; +} + +// IPv4 CIDR 匹配 +function matchIPv4CIDR (ip: string, cidr: string): boolean { + const parts = cidr.split('/'); + if (parts.length !== 2) return false; + + const range = parts[0]; + const bits = parts[1]; + if (!range || !bits) return false; + + const prefixLength = parseInt(bits); + if (isNaN(prefixLength) || prefixLength < 0 || prefixLength > 32) return false; + + const mask = prefixLength === 0 ? 0 : ~(2 ** (32 - prefixLength) - 1); + + const ipNum = ipv4ToNumber(ip); + const rangeNum = ipv4ToNumber(range); + + if (ipNum === null || rangeNum === null) return false; + + return (ipNum & mask) === (rangeNum & mask); +} + +// IPv4 转数字 +function ipv4ToNumber (ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + + const nums = parts.map(p => parseInt(p)); + if (nums.some(n => isNaN(n) || n < 0 || n > 255)) return null; + + return ((nums[0] || 0) << 24) + ((nums[1] || 0) << 16) + ((nums[2] || 0) << 8) + (nums[3] || 0); +} + +// IPv6 CIDR 匹配 +function matchIPv6CIDR (ip: string, cidr: string): boolean { + const parts = cidr.split('/'); + if (parts.length !== 2) return false; + + const range = parts[0]; + const bits = parts[1]; + if (!range || !bits) return false; + + const prefixLength = parseInt(bits); + if (isNaN(prefixLength) || prefixLength < 0 || prefixLength > 128) return false; + + try { + const ipSegments = expandIPv6(ip); + const rangeSegments = expandIPv6(range); + + if (!ipSegments || !rangeSegments) return false; + + // 按位比较 + let bitsToCompare = prefixLength; + for (let i = 0; i < 8; i++) { + if (bitsToCompare <= 0) break; + + const ipSeg = ipSegments[i] ?? 0; + const rangeSeg = rangeSegments[i] ?? 0; + + if (bitsToCompare >= 16) { + // 完整比较这个段 + if (ipSeg !== rangeSeg) return false; + bitsToCompare -= 16; + } else { + // 部分比较这个段 + const mask = (0xFFFF << (16 - bitsToCompare)) & 0xFFFF; + if ((ipSeg & mask) !== (rangeSeg & mask)) return false; + break; + } + } + + return true; + } catch (_error) { + return false; + } +} + +// 展开 IPv6 地址为 8 个 16 位段 +function expandIPv6 (ip: string): number[] | null { + try { + // 移除可能的 IPv4 映射部分 + let addr = ip; + + // 处理 IPv4-mapped IPv6 (如 ::ffff:192.168.1.1) + const ipv4Match = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (ipv4Match && ipv4Match[1]) { + const ipv4 = ipv4Match[1]; + const ipv4Parts = ipv4.split('.').map(p => parseInt(p)); + if (ipv4Parts.length === 4 && ipv4Parts.every(n => n >= 0 && n <= 255)) { + // 转换为 IPv6 格式 + const part1 = (ipv4Parts[0] ?? 0) << 8 | (ipv4Parts[1] ?? 0); + const part2 = (ipv4Parts[2] ?? 0) << 8 | (ipv4Parts[3] ?? 0); + addr = `::ffff:${part1.toString(16)}:${part2.toString(16)}`; + } + } + + // 处理 :: 缩写 + if (addr.includes('::')) { + const sides = addr.split('::'); + if (sides.length > 2) return null; + + const left = sides[0] ? sides[0].split(':') : []; + const right = sides[1] ? sides[1].split(':') : []; + const missing = 8 - left.length - right.length; + + if (missing < 0) return null; + + const segments = [ + ...left, + ...Array(missing).fill('0'), + ...right, + ]; + + return segments.map(s => parseInt(s || '0', 16)); + } + + // 标准格式 + const segments = addr.split(':'); + if (segments.length !== 8) return null; + + return segments.map(s => parseInt(s || '0', 16)); + } catch (_error) { + return null; + } +} + +// 检查是否在 IP 列表中 +function isIPInList (ip: string, ipList: string[]): boolean { + return ipList.some(rule => matchIPRule(ip, rule)); +} + +// CORS 中间件,跨域用 +export const cors: RequestHandler = async (req, res, next) => { + const config = await WebUiConfig.GetWebUIConfig(); + + // 根据配置决定如何获取客户端IP + let clientIP: string; + if (config.enableXForwardedFor) { + // 启用 X-Forwarded-For 时,优先从该头部获取真实IP + const forwardedFor = req.headers['x-forwarded-for']; + if (typeof forwardedFor === 'string') { + // X-Forwarded-For 可能包含多个IP,取第一个 + clientIP = forwardedFor.split(',')[0]?.trim() || ''; + } else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) { + clientIP = forwardedFor[0] || ''; + } else { + // 如果没有 X-Forwarded-For,回退到 socket IP + clientIP = req.ip || req.socket.remoteAddress || ''; + } + } else { + // 默认使用 socket IP + clientIP = req.ip || req.socket.remoteAddress || ''; + } + + // 检查访问控制模式 + if (config.accessControlMode === 'whitelist') { + // 白名单模式:只允许白名单中的IP访问 + if (!isIPInList(clientIP, config.ipWhitelist || [])) { + res.status(403).json({ error: '访问被拒绝:IP不在白名单中' }); + return; + } + } else if (config.accessControlMode === 'blacklist') { + // 黑名单模式:拒绝黑名单中的IP访问 + if (isIPInList(clientIP, config.ipBlacklist || [])) { + res.status(403).json({ error: '访问被拒绝:IP在黑名单中' }); + return; + } + } + // 'none' 模式:不进行任何限制 + + const origin = req.headers.origin || '*'; + res.header('Access-Control-Allow-Origin', origin); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); + res.header('Access-Control-Allow-Credentials', 'true'); + + if (req.method === 'OPTIONS') { + res.sendStatus(204); + return; + } + next(); +}; diff --git a/packages/snowcat-webui-backend/src/onebot/config.ts b/packages/snowcat-webui-backend/src/onebot/config.ts new file mode 100644 index 0000000..1a58233 --- /dev/null +++ b/packages/snowcat-webui-backend/src/onebot/config.ts @@ -0,0 +1,118 @@ +import { Type, Static } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { Value } from '@sinclair/typebox/value'; +const HttpServerConfigSchema = Type.Object({ + name: Type.String({ default: 'http-server' }), + enable: Type.Boolean({ default: false }), + port: Type.Number({ default: 3000 }), + host: Type.String({ default: '127.0.0.1' }), + enableCors: Type.Boolean({ default: true }), + enableWebsocket: Type.Boolean({ default: false }), + messagePostFormat: Type.String({ default: 'array' }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), +}); + +const HttpSseServerConfigSchema = Type.Object({ + name: Type.String({ default: 'http-sse-server' }), + enable: Type.Boolean({ default: false }), + port: Type.Number({ default: 3000 }), + host: Type.String({ default: '127.0.0.1' }), + enableCors: Type.Boolean({ default: true }), + enableWebsocket: Type.Boolean({ default: false }), + messagePostFormat: Type.String({ default: 'array' }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), + reportSelfMessage: Type.Boolean({ default: false }), +}); + +const HttpClientConfigSchema = Type.Object({ + name: Type.String({ default: 'http-client' }), + enable: Type.Boolean({ default: false }), + url: Type.String({ default: 'http://localhost:8080' }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), +}); + +const WebsocketServerConfigSchema = Type.Object({ + name: Type.String({ default: 'websocket-server' }), + enable: Type.Boolean({ default: false }), + host: Type.String({ default: '127.0.0.1' }), + port: Type.Number({ default: 3001 }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + token: Type.String({ default: '' }), + enableForcePushEvent: Type.Boolean({ default: true }), + debug: Type.Boolean({ default: false }), + heartInterval: Type.Number({ default: 30000 }), +}); + +const WebsocketClientConfigSchema = Type.Object({ + name: Type.String({ default: 'websocket-client' }), + enable: Type.Boolean({ default: false }), + url: Type.String({ default: 'ws://localhost:8082' }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + reconnectInterval: Type.Number({ default: 5000 }), + token: Type.String({ default: '' }), + debug: Type.Boolean({ default: false }), + heartInterval: Type.Number({ default: 30000 }), +}); + +const PluginConfigSchema = Type.Object({ + name: Type.String({ default: 'plugin' }), + enable: Type.Boolean({ default: false }), + messagePostFormat: Type.String({ default: 'array' }), + reportSelfMessage: Type.Boolean({ default: false }), + debug: Type.Boolean({ default: false }), +}); + +const NetworkConfigSchema = Type.Object({ + httpServers: Type.Array(HttpServerConfigSchema, { default: [] }), + httpSseServers: Type.Array(HttpSseServerConfigSchema, { default: [] }), + httpClients: Type.Array(HttpClientConfigSchema, { default: [] }), + websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }), + websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }), + plugins: Type.Array(PluginConfigSchema, { default: [] }), +}, { default: {} }); + +const TimeoutConfigSchema = Type.Object({ + baseTimeout: Type.Number({ default: 10000 }), + uploadSpeedKBps: Type.Number({ default: 256 }), + downloadSpeedKBps: Type.Number({ default: 256 }), + maxTimeout: Type.Number({ default: 1800000 }), +}, { default: {} }); + +export const OneBotConfigSchema = Type.Object({ + network: NetworkConfigSchema, + musicSignUrl: Type.String({ default: '' }), + enableLocalFile2Url: Type.Boolean({ default: false }), + parseMultMsg: Type.Boolean({ default: false }), + imageDownloadProxy: Type.String({ default: '' }), + timeout: TimeoutConfigSchema, +}); + +export type OneBotConfig = Static; +export type HttpServerConfig = Static; +export type HttpSseServerConfig = Static; +export type HttpClientConfig = Static; +export type WebsocketServerConfig = Static; +export type WebsocketClientConfig = Static; +export type PluginConfig = Static; + +export type NetworkAdapterConfig = HttpServerConfig | HttpSseServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | PluginConfig; +export type NetworkConfigKey = keyof OneBotConfig['network']; + +export function loadConfig (config: Partial): OneBotConfig { + let data = config; + data = Value.Parse(OneBotConfigSchema, data) as Partial; + const validate = TypeCompiler.Compile(OneBotConfigSchema); + const valid = validate.Check(data); + if (!valid) { + const errorMsg = [...validate.Errors(data)].map(e => e.message).join(', '); + throw new Error(errorMsg); + } + return data as OneBotConfig; +} diff --git a/packages/snowcat-webui-backend/src/router/Base.ts b/packages/snowcat-webui-backend/src/router/Base.ts new file mode 100644 index 0000000..cfe47d5 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/Base.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler, GetMirrorsHandler, GetNapCatFileHashHandler } from '../api/BaseInfo'; +import { StatusRealTimeHandler } from '@/snowcat-webui-backend/src/api/Status'; +import { GetProxyHandler } from '../api/Proxy'; + +const router: Router = Router(); +// router: 获取nc的package.json信息 +router.get('/QQVersion', QQVersionHandler); + +// 品牌命名统一为 SnowCat:新路径优先,旧 NapCat 路径保留兼容(老前端 / 外部脚本) +router.get('/GetSnowCatVersion', GetNapCatVersion); +router.get('/GetNapCatVersion', GetNapCatVersion); + +router.get('/getLatestTag', getLatestTagHandler); +router.get('/getAllReleases', getAllReleasesHandler); +router.get('/getMirrors', GetMirrorsHandler); +router.get('/GetSysStatusRealTime', StatusRealTimeHandler); +router.get('/proxy', GetProxyHandler); +router.get('/Theme', GetThemeConfigHandler); +router.post('/SetTheme', SetThemeConfigHandler); + +router.get('/GetSnowCatFileHash', GetNapCatFileHashHandler); +router.get('/GetNapCatFileHash', GetNapCatFileHashHandler); + +export { router as BaseRouter }; diff --git a/packages/snowcat-webui-backend/src/router/File.ts b/packages/snowcat-webui-backend/src/router/File.ts new file mode 100644 index 0000000..0de664f --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/File.ts @@ -0,0 +1,51 @@ +import { Router, RequestHandler } from 'express'; +import rateLimit from 'express-rate-limit'; +import { + ListFilesHandler, + CreateDirHandler, + DeleteHandler, + ReadFileHandler, + WriteFileHandler, + CreateFileHandler, + BatchDeleteHandler, // 添加这一行 + RenameHandler, + MoveHandler, + BatchMoveHandler, + DownloadHandler, + BatchDownloadHandler, // 新增下载处理方法 + UploadHandler, + UploadWebUIFontHandler, + DeleteWebUIFontHandler, // 添加上传处理器 + CheckWebUIFontExistHandler, // Add this +} from '../api/File'; + +const router: Router = Router(); + +const apiLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1分钟内 + max: 60, // 最大60个请求 + validate: { + xForwardedForHeader: false, + }, +}); + +router.use(apiLimiter as unknown as RequestHandler); + +router.get('/list', ListFilesHandler); +router.post('/mkdir', CreateDirHandler); +router.post('/delete', DeleteHandler); +router.get('/read', ReadFileHandler); +router.post('/write', WriteFileHandler); +router.post('/create', CreateFileHandler); +router.post('/batchDelete', BatchDeleteHandler); +router.post('/rename', RenameHandler); +router.post('/move', MoveHandler); +router.post('/batchMove', BatchMoveHandler); +router.post('/download', DownloadHandler); +router.post('/batchDownload', BatchDownloadHandler); +router.post('/upload', UploadHandler); + +router.post('/font/upload/webui', UploadWebUIFontHandler); +router.post('/font/delete/webui', DeleteWebUIFontHandler); +router.get('/font/exists/webui', CheckWebUIFontExistHandler); // Add this +export { router as FileRouter }; diff --git a/packages/snowcat-webui-backend/src/router/Log.ts b/packages/snowcat-webui-backend/src/router/Log.ts new file mode 100644 index 0000000..9d29d24 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/Log.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + LogHandler, + LogListHandler, + LogRealTimeHandler, + CreateTerminalHandler, + GetTerminalListHandler, + CloseTerminalHandler, +} from '../api/Log'; + +const router: Router = Router(); + +// 日志相关路由 +router.get('/GetLog', LogHandler); +router.get('/GetLogList', LogListHandler); +router.get('/GetLogRealTime', LogRealTimeHandler); + +// 终端相关路由 +router.get('/terminal/list', GetTerminalListHandler); +router.post('/terminal/create', CreateTerminalHandler); +router.post('/terminal/:id/close', CloseTerminalHandler); + +export { router as LogRouter }; diff --git a/packages/snowcat-webui-backend/src/router/Mirror.ts b/packages/snowcat-webui-backend/src/router/Mirror.ts new file mode 100644 index 0000000..df7cfab --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/Mirror.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + GetMirrorListHandler, + SetCustomMirrorHandler, + TestMirrorsSSEHandler, + TestSingleMirrorHandler, +} from '@/snowcat-webui-backend/src/api/Mirror'; + +const router: Router = Router(); + +// 获取镜像列表 +router.get('/List', GetMirrorListHandler); + +// 设置自定义镜像 +router.post('/SetCustom', SetCustomMirrorHandler); + +// SSE 实时测速 +router.get('/Test/SSE', TestMirrorsSSEHandler); + +// 测试单个镜像 +router.post('/Test', TestSingleMirrorHandler); + +export { router as MirrorRouter }; diff --git a/packages/snowcat-webui-backend/src/router/OB11Config.ts b/packages/snowcat-webui-backend/src/router/OB11Config.ts new file mode 100644 index 0000000..18ee0e5 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/OB11Config.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import multer from 'multer'; + +import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/snowcat-webui-backend/src/api/OB11Config'; +import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/snowcat-webui-backend/src/api/BackupConfig'; + +const router: Router = Router(); + +// 使用内存存储,配合流式处理 +const upload = multer({ + storage: multer.memoryStorage(), +}); + +// router:读取配置 +router.post('/GetConfig', OB11GetConfigHandler); +// router:写入配置 +router.post('/SetConfig', OB11SetConfigHandler); +// router:导出配置 +router.get('/ExportConfig', BackupExportConfigHandler); +// router:导入配置 +router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler); + +export { router as OB11ConfigRouter }; diff --git a/packages/snowcat-webui-backend/src/router/Plugin.ts b/packages/snowcat-webui-backend/src/router/Plugin.ts new file mode 100644 index 0000000..8084ad8 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/Plugin.ts @@ -0,0 +1,168 @@ +import { Router } from 'express'; +// import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { + GetPluginListHandler, + SetPluginStatusHandler, + UninstallPluginHandler, + GetPluginConfigHandler, + SetPluginConfigHandler, + RegisterPluginManagerHandler, + PluginConfigSSEHandler, + PluginConfigChangeHandler, + // ImportLocalPluginHandler, + GetPluginIconHandler, +} from '@/snowcat-webui-backend/src/api/Plugin'; +import { + GetPluginStoreListHandler, + GetPluginStoreDetailHandler, + InstallPluginFromStoreHandler, + InstallPluginFromStoreSSEHandler, +} from '@/snowcat-webui-backend/src/api/PluginStore'; +import { WebUiDataRuntime } from '@/snowcat-webui-backend/src/helper/Data'; +import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/snowcat-onebot/network/plugin-manger'; + +// 配置 multer 用于文件上传 +const uploadDir = path.join(os.tmpdir(), 'snowcat-plugin-uploads'); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +// const storage = multer.diskStorage({ +// destination: (_req, _file, cb) => { +// cb(null, uploadDir); +// }, +// filename: (_req, file, cb) => { +// const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); +// cb(null, uniqueSuffix + '-' + file.originalname); +// } +// }); + +// const upload = multer({ +// storage, +// limits: { +// fileSize: 50 * 1024 * 1024, // 50MB 限制 +// }, +// fileFilter: (_req, file, cb) => { +// // 只允许 .zip 文件 +// if (file.mimetype === 'application/zip' || +// file.mimetype === 'application/x-zip-compressed' || +// file.originalname.endsWith('.zip')) { +// cb(null, true); +// } else { +// cb(new Error('Only .zip files are allowed')); +// } +// } +// }); + +const router: Router = Router(); + +router.get('/List', GetPluginListHandler); +router.post('/SetStatus', SetPluginStatusHandler); +router.post('/Uninstall', UninstallPluginHandler); +router.get('/Config', GetPluginConfigHandler); +router.post('/Config', SetPluginConfigHandler); +router.get('/Config/SSE', PluginConfigSSEHandler); +router.post('/Config/Change', PluginConfigChangeHandler); +router.post('/RegisterManager', RegisterPluginManagerHandler); +// router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); // 禁用插件上传 +router.get('/Icon/:pluginId', GetPluginIconHandler); + +// 插件商店相关路由 +router.get('/Store/List', GetPluginStoreListHandler); +router.get('/Store/Detail/:id', GetPluginStoreDetailHandler); +router.post('/Store/Install', InstallPluginFromStoreHandler); +router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler); + +// 插件扩展路由 - 动态挂载插件注册的 API 路由 +router.use('/ext/:pluginId', (req, res, next): void => { + const { pluginId } = req.params; + + if (!pluginId) { + res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + return; + } + + // 获取插件管理器 + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; + if (!ob11) { + res.status(503).json({ code: -1, message: 'OneBot context not available' }); + return; + } + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter; + if (!pluginManager) { + res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + return; + } + + // 获取插件路由 + const routerRegistry = pluginManager.getPluginRouter(pluginId); + if (!routerRegistry || !routerRegistry.hasApiRoutes()) { + res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered API routes` }); + return; + } + + // 构建并执行插件路由 + const pluginRouter = routerRegistry.buildApiRouter(); + pluginRouter(req, res, next); +}); + +// 插件页面路由 - 服务插件注册的 HTML 页面 +router.get('/page/:pluginId/:pagePath', (req, res): void => { + const { pluginId, pagePath } = req.params; + + if (!pluginId) { + res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + return; + } + + // 获取插件管理器 + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; + if (!ob11) { + res.status(503).json({ code: -1, message: 'OneBot context not available' }); + return; + } + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter; + if (!pluginManager) { + res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + return; + } + + // 获取插件路由 + const routerRegistry = pluginManager.getPluginRouter(pluginId); + if (!routerRegistry || !routerRegistry.hasPages()) { + res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` }); + return; + } + + // 查找匹配的页面 + const pages = routerRegistry.getPages(); + const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath); + if (!page) { + res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` }); + return; + } + + // 获取插件路径 + const pluginPath = routerRegistry.getPluginPath(); + if (!pluginPath) { + res.status(500).json({ code: -1, message: 'Plugin path not available' }); + return; + } + + // 构建 HTML 文件路径并发送 + const htmlFilePath = path.join(pluginPath, page.htmlFile); + if (!fs.existsSync(htmlFilePath)) { + res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` }); + return; + } + + res.sendFile(htmlFilePath); +}); + +export { router as PluginRouter }; diff --git a/packages/snowcat-webui-backend/src/router/Process.ts b/packages/snowcat-webui-backend/src/router/Process.ts new file mode 100644 index 0000000..cde534e --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/Process.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { RestartProcessHandler } from '../api/Process'; + +const router: Router = Router(); + +// POST /api/Process/Restart - 重启进程 +router.post('/Restart', RestartProcessHandler); + +export { router as ProcessRouter }; diff --git a/packages/snowcat-webui-backend/src/router/QQLogin.ts b/packages/snowcat-webui-backend/src/router/QQLogin.ts new file mode 100644 index 0000000..a4b40f3 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/QQLogin.ts @@ -0,0 +1,103 @@ +import { Router } from 'express'; + +import { + QQCheckLoginStatusHandler, + QQGetQRcodeHandler, + QQGetQuickLoginListHandler, + QQSetQuickLoginHandler, + QQGetLoginListNewHandler, + getQQLoginInfoHandler, + getAutoLoginAccountHandler, + setAutoLoginAccountHandler, + QQRefreshQRcodeHandler, + QQPasswordLoginHandler, + QQCaptchaLoginHandler, + QQNewDeviceLoginHandler, + QQGetNewDeviceQRCodeHandler, + QQPollNewDeviceQRHandler, + QQResetDeviceIDHandler, + QQRestartNapCatHandler, + QQGetDeviceGUIDHandler, + QQSetDeviceGUIDHandler, + QQGetGUIDBackupsHandler, + QQRestoreGUIDBackupHandler, + QQCreateGUIDBackupHandler, + QQGetPlatformInfoHandler, + QQGetLinuxMACHandler, + QQSetLinuxMACHandler, + QQGetLinuxMachineIdHandler, + QQComputeLinuxGUIDHandler, + QQGetLinuxMachineInfoBackupsHandler, + QQCreateLinuxMachineInfoBackupHandler, + QQRestoreLinuxMachineInfoBackupHandler, + QQResetLinuxDeviceIDHandler, +} from '@/snowcat-webui-backend/src/api/QQLogin'; + +const router: Router = Router(); +// router:获取快速登录列表 +router.all('/GetQuickLoginList', QQGetQuickLoginListHandler); +// router:获取快速登录列表(新) +router.all('/GetQuickLoginListNew', QQGetLoginListNewHandler); +// router:检查QQ登录状态 +router.post('/CheckLoginStatus', QQCheckLoginStatusHandler); +// router:获取QQ登录二维码 +router.post('/GetQQLoginQrcode', QQGetQRcodeHandler); +// router:设置QQ快速登录 +router.post('/SetQuickLogin', QQSetQuickLoginHandler); +// router:获取QQ登录信息 +router.post('/GetQQLoginInfo', getQQLoginInfoHandler); +// router:获取快速登录QQ账号 +router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler); +// router:设置自动登录QQ账号 +router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler); +// router:刷新QQ登录二维码 +router.post('/RefreshQRcode', QQRefreshQRcodeHandler); +// router:密码登录 +router.post('/PasswordLogin', QQPasswordLoginHandler); +// router:验证码登录(密码登录需要验证码时的第二步) +router.post('/CaptchaLogin', QQCaptchaLoginHandler); +// router:新设备验证登录(密码登录需要新设备验证时的第二步) +router.post('/NewDeviceLogin', QQNewDeviceLoginHandler); +// router:获取新设备验证二维码 (OIDB) +router.post('/GetNewDeviceQRCode', QQGetNewDeviceQRCodeHandler); +// router:轮询新设备验证二维码状态 (OIDB) +router.post('/PollNewDeviceQR', QQPollNewDeviceQRHandler); +// router:重置设备信息 +router.post('/ResetDeviceID', QQResetDeviceIDHandler); +// router:重启 SnowCat(新命名),旧 /RestartNapCat 保留兼容 +router.post('/RestartSnowCat', QQRestartNapCatHandler); +router.post('/RestartNapCat', QQRestartNapCatHandler); +// router:获取设备GUID +router.post('/GetDeviceGUID', QQGetDeviceGUIDHandler); +// router:设置设备GUID +router.post('/SetDeviceGUID', QQSetDeviceGUIDHandler); +// router:获取GUID备份列表 +router.post('/GetGUIDBackups', QQGetGUIDBackupsHandler); +// router:恢复GUID备份 +router.post('/RestoreGUIDBackup', QQRestoreGUIDBackupHandler); +// router:创建GUID备份 +router.post('/CreateGUIDBackup', QQCreateGUIDBackupHandler); + +// ============================================================ +// 平台信息 & Linux GUID 管理 +// ============================================================ +// router:获取平台信息 +router.post('/GetPlatformInfo', QQGetPlatformInfoHandler); +// router:获取Linux MAC地址 +router.post('/GetLinuxMAC', QQGetLinuxMACHandler); +// router:设置Linux MAC地址 +router.post('/SetLinuxMAC', QQSetLinuxMACHandler); +// router:获取Linux machine-id +router.post('/GetLinuxMachineId', QQGetLinuxMachineIdHandler); +// router:计算Linux GUID +router.post('/ComputeLinuxGUID', QQComputeLinuxGUIDHandler); +// router:获取Linux machine-info备份列表 +router.post('/GetLinuxMachineInfoBackups', QQGetLinuxMachineInfoBackupsHandler); +// router:创建Linux machine-info备份 +router.post('/CreateLinuxMachineInfoBackup', QQCreateLinuxMachineInfoBackupHandler); +// router:恢复Linux machine-info备份 +router.post('/RestoreLinuxMachineInfoBackup', QQRestoreLinuxMachineInfoBackupHandler); +// router:重置Linux设备信息 +router.post('/ResetLinuxDeviceID', QQResetLinuxDeviceIDHandler); + +export { router as QQLoginRouter }; diff --git a/packages/snowcat-webui-backend/src/router/SnowCatConfig.ts b/packages/snowcat-webui-backend/src/router/SnowCatConfig.ts new file mode 100644 index 0000000..5f0055d --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/SnowCatConfig.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; + +import { SnowCatGetConfigHandler, SnowCatSetConfigHandler, SnowCatGetUinConfigHandler, SnowCatSetUinConfigHandler } from '@/snowcat-webui-backend/src/api/SnowCatConfig'; + +const router: Router = Router(); + +// router:获取 NapCat 配置(napcat.json 登录前配置) +router.get('/GetConfig', SnowCatGetConfigHandler); +// router:设置 NapCat 配置(napcat.json 登录前配置) +router.post('/SetConfig', SnowCatSetConfigHandler); +// router:获取 NapCat per-uin 配置(napcat_{uin}.json 登录后配置) +router.get('/GetUinConfig', SnowCatGetUinConfigHandler); +// router:设置 NapCat per-uin 配置(napcat_{uin}.json 登录后配置) +router.post('/SetUinConfig', SnowCatSetUinConfigHandler); + +export { router as SnowCatConfigRouter }; diff --git a/packages/snowcat-webui-backend/src/router/UpdateNapCat.ts b/packages/snowcat-webui-backend/src/router/UpdateNapCat.ts new file mode 100644 index 0000000..26e88ff --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/UpdateNapCat.ts @@ -0,0 +1,13 @@ +/** + * @file UpdateNapCat路由 + */ + +import { Router } from 'express'; +import { UpdateNapCatHandler } from '@/snowcat-webui-backend/src/api/UpdateNapCat'; + +const router: Router = Router(); + +// POST /api/UpdateNapCat/update - 更新NapCat +router.post('/update', UpdateNapCatHandler); + +export { router as UpdateNapCatRouter }; diff --git a/packages/snowcat-webui-backend/src/router/WebUIConfig.ts b/packages/snowcat-webui-backend/src/router/WebUIConfig.ts new file mode 100644 index 0000000..2aa12c4 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/WebUIConfig.ts @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { + GetWebUIConfigHandler, + GetDisableWebUIHandler, + UpdateDisableWebUIHandler, + UpdateWebUIConfigHandler, + GetClientIPHandler, + GetSSLStatusHandler, + UploadSSLCertHandler, + DeleteSSLCertHandler, +} from '@/snowcat-webui-backend/src/api/WebUIConfig'; + +const router: Router = Router(); + +// 获取WebUI基础配置 +router.get('/GetConfig', GetWebUIConfigHandler); + +// 更新WebUI基础配置 +router.post('/UpdateConfig', UpdateWebUIConfigHandler); + +// 获取是否禁用WebUI +router.get('/GetDisableWebUI', GetDisableWebUIHandler); + +// 更新是否禁用WebUI +router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler); + +// 获取当前客户端IP +router.get('/GetClientIP', GetClientIPHandler); + +// 获取SSL证书状态 +router.get('/GetSSLStatus', GetSSLStatusHandler); + +// 上传SSL证书 +router.post('/UploadSSLCert', UploadSSLCertHandler); + +// 删除SSL证书 +router.post('/DeleteSSLCert', DeleteSSLCertHandler); + +export { router as WebUIConfigRouter }; diff --git a/packages/snowcat-webui-backend/src/router/auth.ts b/packages/snowcat-webui-backend/src/router/auth.ts new file mode 100644 index 0000000..f3d7696 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/auth.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; + +import { + checkHandler, + LoginHandler, + LogoutHandler, + UpdateTokenHandler, + GeneratePasskeyRegistrationOptionsHandler, + VerifyPasskeyRegistrationHandler, + GeneratePasskeyAuthenticationOptionsHandler, + VerifyPasskeyAuthenticationHandler, + Get2FAStatusHandler, + Generate2FASecretHandler, + Enable2FAHandler, + Disable2FAHandler, +} from '@/snowcat-webui-backend/src/api/Auth'; + +const router: Router = Router(); +// router:登录 +router.post('/login', LoginHandler); +// router:检查登录状态 +router.post('/check', checkHandler); +// router:注销 +router.post('/logout', LogoutHandler); +// router:更新token +router.post('/update_token', UpdateTokenHandler); +// router:生成Passkey注册选项 +router.post('/passkey/generate-registration-options', GeneratePasskeyRegistrationOptionsHandler); +// router:验证Passkey注册 +router.post('/passkey/verify-registration', VerifyPasskeyRegistrationHandler); +// router:生成Passkey认证选项 +router.post('/passkey/generate-authentication-options', GeneratePasskeyAuthenticationOptionsHandler); +// router:验证Passkey认证 +router.post('/passkey/verify-authentication', VerifyPasskeyAuthenticationHandler); +// router:获取2FA状态 +router.get('/2fa/status', Get2FAStatusHandler); +// router:生成2FA密钥 +router.post('/2fa/generate-secret', Generate2FASecretHandler); +// router:启用2FA +router.post('/2fa/enable', Enable2FAHandler); +// router:禁用2FA +router.post('/2fa/disable', Disable2FAHandler); + +export { router as AuthRouter }; diff --git a/packages/snowcat-webui-backend/src/router/index.ts b/packages/snowcat-webui-backend/src/router/index.ts new file mode 100644 index 0000000..3a1eb48 --- /dev/null +++ b/packages/snowcat-webui-backend/src/router/index.ts @@ -0,0 +1,62 @@ +/** + * @file 所有路由的入口文件 + */ + +import { Router } from 'express'; + +import { OB11ConfigRouter } from '@/snowcat-webui-backend/src/router/OB11Config'; +import { auth } from '@/snowcat-webui-backend/src/middleware/auth'; +import { sendSuccess } from '@/snowcat-webui-backend/src/utils/response'; + +import { QQLoginRouter } from '@/snowcat-webui-backend/src/router/QQLogin'; +import { AuthRouter } from '@/snowcat-webui-backend/src/router/auth'; +import { LogRouter } from '@/snowcat-webui-backend/src/router/Log'; +import { BaseRouter } from '@/snowcat-webui-backend/src/router/Base'; +import { FileRouter } from './File'; +import { WebUIConfigRouter } from './WebUIConfig'; +import { UpdateNapCatRouter } from './UpdateNapCat'; +import DebugRouter from '@/snowcat-webui-backend/src/api/Debug'; +import { ProcessRouter } from './Process'; +import { PluginRouter } from './Plugin'; +import { MirrorRouter } from './Mirror'; +import { SnowCatConfigRouter } from './SnowCatConfig'; + +const router: Router = Router(); + +// 鉴权中间件 +router.use(auth); + +// router:测试用 +router.all('/test', (_, res) => { + return sendSuccess(res); +}); +// router:基础信息相关路由 +router.use('/base', BaseRouter); +// router:WebUI登录相关路由 +router.use('/auth', AuthRouter); +// router:QQ登录相关路由 +router.use('/QQLogin', QQLoginRouter); +// router:OB11配置相关路由 +router.use('/OB11Config', OB11ConfigRouter); +// router:日志相关路由 +router.use('/Log', LogRouter); +// file:文件相关路由 +router.use('/File', FileRouter); +// router:WebUI配置相关路由 +router.use('/WebUIConfig', WebUIConfigRouter); +// router:更新相关路由 +// 品牌命名统一为 SnowCat:新路径优先,旧 NapCat 路径保留兼容 +router.use('/UpdateSnowCat', UpdateNapCatRouter); +router.use('/UpdateNapCat', UpdateNapCatRouter); +// router:调试相关路由 +router.use('/Debug', DebugRouter); +// router:进程管理相关路由 +router.use('/Process', ProcessRouter); +// router:插件管理相关路由 +router.use('/Plugin', PluginRouter); +// router:镜像管理相关路由 +router.use('/Mirror', MirrorRouter); +// router:NapCat配置相关路由 +router.use('/SnowCatConfig', SnowCatConfigRouter); + +export { router as ALLRouter }; diff --git a/packages/snowcat-webui-backend/src/terminal/init-dynamic-dirname.ts b/packages/snowcat-webui-backend/src/terminal/init-dynamic-dirname.ts new file mode 100644 index 0000000..b883441 --- /dev/null +++ b/packages/snowcat-webui-backend/src/terminal/init-dynamic-dirname.ts @@ -0,0 +1,31 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +export function callsites () { + const _prepareStackTrace = Error.prepareStackTrace; + try { + let result: NodeJS.CallSite[] = []; + Error.prepareStackTrace = (_, callSites) => { + const callSitesWithoutCurrent = callSites.slice(1); + result = callSitesWithoutCurrent; + return callSitesWithoutCurrent; + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + new Error().stack; + return result; + } finally { + Error.prepareStackTrace = _prepareStackTrace; + } +} + +Object.defineProperty(global, '__dirname', { + get () { + const sites = callsites(); + const file = sites?.[1]?.getFileName(); + if (file) { + return path.dirname(fileURLToPath(file)); + } + return ''; + }, +}); diff --git a/packages/snowcat-webui-backend/src/terminal/terminal_manager.ts b/packages/snowcat-webui-backend/src/terminal/terminal_manager.ts new file mode 100644 index 0000000..1473f58 --- /dev/null +++ b/packages/snowcat-webui-backend/src/terminal/terminal_manager.ts @@ -0,0 +1,192 @@ +// import './init-dynamic-dirname'; +import { WebUiConfig } from '../../index'; +import { AuthHelper } from '../helper/SignToken'; +import type { WebUiCredentialJson } from '@/snowcat-webui-backend/src/types'; +import { WebSocket, WebSocketServer } from 'ws'; +import os from 'os'; +import { IPty, spawn as ptySpawn } from 'snowcat-pty'; +import { randomUUID } from 'crypto'; +import { ILogWrapper } from 'snowcat-common/src/log-interface'; + +interface TerminalInstance { + pty: IPty; // 改用 PTY 实例 + lastAccess: number; + sockets: Set; + // 新增标识,用于防止重复关闭 + isClosing: boolean; + // 新增:存储终端历史输出 + buffer: string; +} + +class TerminalManager { + private terminals: Map = new Map(); + private wss: WebSocketServer | null = null; + + initialize (req: any, socket: any, head: any, logger?: ILogWrapper) { + logger?.log('[SnowCat] [WebUi] terminal websocket initialized'); + this.wss = new WebSocketServer({ + noServer: true, + verifyClient: async (info, cb) => { + // 验证 token + const url = new URL(info.req.url || '', 'ws://localhost'); + const token = url.searchParams.get('token'); + const terminalId = url.searchParams.get('id'); + + if (!token || !terminalId) { + // eslint-disable-next-line n/no-callback-literal + cb(false, 401, 'Unauthorized'); + return; + } + + // 解析token + let Credential: WebUiCredentialJson; + try { + Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + } catch (_e) { + // eslint-disable-next-line n/no-callback-literal + cb(false, 401, 'Unauthorized'); + return; + } + const config = await WebUiConfig.GetWebUIConfig(); + const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); + if (!validate) { + // eslint-disable-next-line n/no-callback-literal + cb(false, 401, 'Unauthorized'); + return; + } + // eslint-disable-next-line n/no-callback-literal + cb(true); + }, + }); + this.wss.handleUpgrade(req, socket, head, (ws) => { + this.wss?.emit('connection', ws, req); + }); + this.wss.on('connection', async (ws, req) => { + logger?.log('建立终端连接'); + try { + const url = new URL(req.url || '', 'ws://localhost'); + const terminalId = url.searchParams.get('id')!; + + const instance = this.terminals.get(terminalId); + + if (!instance) { + ws.close(); + return; + } + + instance.sockets.add(ws); + instance.lastAccess = Date.now(); + + // 新增:发送当前终端内容给新连接 + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data: instance.buffer })); + } + + ws.on('message', (data) => { + if (instance) { + let text: string; + if (Array.isArray(data)) { + text = Buffer.concat(data).toString(); + } else { + text = data.toString(); + } + const result = JSON.parse(text); + if (result.type === 'input') { + instance.pty.write(result.data); + } + // 新增:处理 resize 消息 + if (result.type === 'resize') { + instance.pty.resize(result.cols, result.rows); + } + } + }); + + ws.on('close', () => { + instance.sockets.delete(ws); + if (instance.sockets.size === 0 && !instance.isClosing) { + instance.isClosing = true; + if (os.platform() === 'win32') { + process.kill(instance.pty.pid); + } else { + instance.pty.kill(); + } + } + }); + } catch (err) { + console.error('WebSocket authentication failed:', err); + ws.close(); + } + }); + } + + // 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位 + createTerminal (cols: number, rows: number) { + const id = randomUUID(); + const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; + const pty = ptySpawn(shell, [], { + name: 'xterm-256color', + cols, // 使用客户端传入的 cols + rows, // 使用客户端传入的 rows + cwd: process.cwd(), + env: { + ...process.env, + LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8', + TERM: 'xterm-256color', + }, + }); + + const instance: TerminalInstance = { + pty, + lastAccess: Date.now(), + sockets: new Set(), + isClosing: false, + buffer: '', // 初始化终端内容缓存 + }; + pty.onData((data: any) => { + // 追加数据到 buffer + instance.buffer += data; + // 发送数据给已连接的 websocket + instance.sockets.forEach((ws) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data })); + } + }); + }); + pty.onExit(() => { + this.closeTerminal(id); + }); + + this.terminals.set(id, instance); + // 返回生成的 id 及对应实例,方便后续通知客户端使用该 id + return { id, instance }; + } + + closeTerminal (id: string) { + const instance = this.terminals.get(id); + if (instance) { + if (!instance.isClosing) { + instance.isClosing = true; + if (os.platform() === 'win32') { + process.kill(instance.pty.pid); + } else { + instance.pty.kill(); + } + } + instance.sockets.forEach((ws) => ws.close()); + this.terminals.delete(id); + } + } + + getTerminal (id: string) { + return this.terminals.get(id); + } + + getTerminalList () { + return Array.from(this.terminals.keys()).map((id) => ({ + id, + lastAccess: this.terminals.get(id)!.lastAccess, + })); + } +} + +export const terminalManager = new TerminalManager(); diff --git a/packages/snowcat-webui-backend/src/types/PluginStore.ts b/packages/snowcat-webui-backend/src/types/PluginStore.ts new file mode 100644 index 0000000..4debee6 --- /dev/null +++ b/packages/snowcat-webui-backend/src/types/PluginStore.ts @@ -0,0 +1,19 @@ +// 插件商店相关类型定义 + +export interface PluginStoreItem { + id: string; // 插件唯一标识 + name: string; // 插件名称 + version: string; // 最新版本 + description: string; // 插件描述 + author: string; // 作者 + homepage?: string; // 主页链接 + downloadUrl: string; // 下载地址 + tags?: string[]; // 标签 + minVersion?: string; // 最低版本要求 +} + +export interface PluginStoreList { + version: string; // 索引版本 + updateTime: string; // 更新时间 + plugins: PluginStoreItem[]; // 插件列表 +} diff --git a/packages/snowcat-webui-backend/src/types/index.ts b/packages/snowcat-webui-backend/src/types/index.ts new file mode 100644 index 0000000..cb72ee0 --- /dev/null +++ b/packages/snowcat-webui-backend/src/types/index.ts @@ -0,0 +1,82 @@ +import type { OneBotConfig } from '@/snowcat-webui-backend/src/onebot/config'; +export interface LoginListItem { + uin: string; + uid: string; + nickName: string; + faceUrl: string; + facePath: string; + loginType: 1; // 1是二维码登录? + isQuickLogin: boolean; // 是否可以快速登录 + isAutoLogin: boolean; // 是否可以自动登录 +} + +interface SelfInfo { + uid: string; + uin: string; + nick: string; +} +export interface WebUiConfigType { + host: string; + port: number; + token: string; + loginRate: number; + accessControlMode?: 'none' | 'whitelist' | 'blacklist'; + ipWhitelist?: string[]; + ipBlacklist?: string[]; + enableXForwardedFor?: boolean; +} +export interface WebUiCredentialInnerJson { + CreatedTime: number; + HashEncoded: string; +} + +export interface WebUiCredentialJson { + Data: WebUiCredentialInnerJson; + Hmac: string; +} +export enum SnowCatCoreWorkingEnv { + Unknown = 0, + Shell = 1, + Framework = 2, +} + +export type QQLoginPhase = + | 'waiting_qrcode' + | 'generating_qrcode' + | 'qrcode_scanned' + | 'initializing' + | 'ready' + | 'offline' + | 'reconnecting'; + +export interface LoginRuntimeType { + workingEnv: SnowCatCoreWorkingEnv; + LoginCurrentTime: number; + LoginCurrentRate: number; + QQLoginStatus: boolean; + QQLoginPhase: QQLoginPhase; + QQQRCodeURL: string; + QQLoginUin: string; + QQLoginInfo: SelfInfo; + QQLoginError: string; + QQVersion: string; + QQDataPath: string; + onQQLoginStatusChange: (status: boolean) => Promise; + onWebUiTokenChange: (token: string) => Promise; + onRefreshQRCode: () => Promise; + WebUiConfigQuickFunction: () => Promise; + OneBotContext: any | null; // OneBot 上下文,用于调试功能 + SnowCatHelper: { + onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; + onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; needCaptcha?: boolean; proofWaterUrl?: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>; + onCaptchaLoginRequested: (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>; + onNewDeviceLoginRequested: (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>; + onOB11ConfigChanged: (ob11: OneBotConfig) => Promise; + onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>; + QQLoginList: string[]; + NewQQLoginList: LoginListItem[]; + }; + SnowCatVersion: string; +} + +export default {}; diff --git a/packages/snowcat-webui-backend/src/types/theme.ts b/packages/snowcat-webui-backend/src/types/theme.ts new file mode 100644 index 0000000..5084cef --- /dev/null +++ b/packages/snowcat-webui-backend/src/types/theme.ts @@ -0,0 +1,262 @@ +import { Type } from '@sinclair/typebox'; + +export const themeType = Type.Object( + { + dark: Type.Record(Type.String(), Type.String()), + light: Type.Record(Type.String(), Type.String()), + fontMode: Type.String({ default: 'system' }), + }, + { + default: { + fontMode: 'system', + dark: { + '--heroui-background': '0 0% 0%', + '--heroui-foreground-50': '240 5.88% 10%', + '--heroui-foreground-100': '240 3.7% 15.88%', + '--heroui-foreground-200': '240 5.26% 26.08%', + '--heroui-foreground-300': '240 5.2% 33.92%', + '--heroui-foreground-400': '240 3.83% 46.08%', + '--heroui-foreground-500': '240 5.03% 64.9%', + '--heroui-foreground-600': '240 4.88% 83.92%', + '--heroui-foreground-700': '240 5.88% 90%', + '--heroui-foreground-800': '240 4.76% 95.88%', + '--heroui-foreground-900': '0 0% 98.04%', + '--heroui-foreground': '210 5.56% 92.94%', + '--heroui-focus': '212.01999999999998 100% 46.67%', + '--heroui-overlay': '0 0% 0%', + '--heroui-divider': '0 0% 100%', + '--heroui-divider-opacity': '0.15', + '--heroui-content1': '240 5.88% 10%', + '--heroui-content1-foreground': '0 0% 98.04%', + '--heroui-content2': '240 3.7% 15.88%', + '--heroui-content2-foreground': '240 4.76% 95.88%', + '--heroui-content3': '240 5.26% 26.08%', + '--heroui-content3-foreground': '240 5.88% 90%', + '--heroui-content4': '240 5.2% 33.92%', + '--heroui-content4-foreground': '240 4.88% 83.92%', + '--heroui-default-50': '240 5.88% 10%', + '--heroui-default-100': '240 3.7% 15.88%', + '--heroui-default-200': '240 5.26% 26.08%', + '--heroui-default-300': '240 5.2% 33.92%', + '--heroui-default-400': '240 3.83% 46.08%', + '--heroui-default-500': '240 5.03% 64.9%', + '--heroui-default-600': '240 4.88% 83.92%', + '--heroui-default-700': '240 5.88% 90%', + '--heroui-default-800': '240 4.76% 95.88%', + '--heroui-default-900': '0 0% 98.04%', + '--heroui-default-foreground': '0 0% 100%', + '--heroui-default': '240 5.26% 26.08%', + '--heroui-danger-50': '301.89 82.61% 22.55%', + '--heroui-danger-100': '308.18 76.39% 28.24%', + '--heroui-danger-200': '313.85 70.65% 36.08%', + '--heroui-danger-300': '319.73 65.64% 44.51%', + '--heroui-danger-400': '325.82 69.62% 53.53%', + '--heroui-danger-500': '331.82 75% 65.49%', + '--heroui-danger-600': '337.84 83.46% 73.92%', + '--heroui-danger-700': '343.42 90.48% 83.53%', + '--heroui-danger-800': '350.53 90.48% 91.76%', + '--heroui-danger-900': '324 90.91% 95.69%', + '--heroui-danger-foreground': '0 0% 100%', + '--heroui-danger': '325.82 69.62% 53.53%', + '--heroui-primary-50': '340 84.91% 10.39%', + '--heroui-primary-100': '339.33 86.54% 20.39%', + '--heroui-primary-200': '339.11 85.99% 30.78%', + '--heroui-primary-300': '339 86.54% 40.78%', + '--heroui-primary-400': '339.2 90.36% 51.18%', + '--heroui-primary-500': '339 90% 60.78%', + '--heroui-primary-600': '339.11 90.6% 70.78%', + '--heroui-primary-700': '339.33 90% 80.39%', + '--heroui-primary-800': '340 91.84% 90.39%', + '--heroui-primary-900': '339.13 92% 95.1%', + '--heroui-primary-foreground': '0 0% 100%', + '--heroui-primary': '339.2 90.36% 51.18%', + '--heroui-secondary-50': '270 66.67% 9.41%', + '--heroui-secondary-100': '270 66.67% 18.82%', + '--heroui-secondary-200': '270 66.67% 28.24%', + '--heroui-secondary-300': '270 66.67% 37.65%', + '--heroui-secondary-400': '270 66.67% 47.06%', + '--heroui-secondary-500': '270 59.26% 57.65%', + '--heroui-secondary-600': '270 59.26% 68.24%', + '--heroui-secondary-700': '270 59.26% 78.82%', + '--heroui-secondary-800': '270 59.26% 89.41%', + '--heroui-secondary-900': '270 61.54% 94.9%', + '--heroui-secondary-foreground': '0 0% 100%', + '--heroui-secondary': '270 59.26% 57.65%', + '--heroui-success-50': '145.71 77.78% 8.82%', + '--heroui-success-100': '146.2 79.78% 17.45%', + '--heroui-success-200': '145.79 79.26% 26.47%', + '--heroui-success-300': '146.01 79.89% 35.1%', + '--heroui-success-400': '145.96 79.46% 43.92%', + '--heroui-success-500': '146.01 62.45% 55.1%', + '--heroui-success-600': '145.79 62.57% 66.47%', + '--heroui-success-700': '146.2 61.74% 77.45%', + '--heroui-success-800': '145.71 61.4% 88.82%', + '--heroui-success-900': '146.67 64.29% 94.51%', + '--heroui-success-foreground': '0 0% 0%', + '--heroui-success': '145.96 79.46% 43.92%', + '--heroui-warning-50': '37.14 75% 10.98%', + '--heroui-warning-100': '37.14 75% 21.96%', + '--heroui-warning-200': '36.96 73.96% 33.14%', + '--heroui-warning-300': '37.01 74.22% 44.12%', + '--heroui-warning-400': '37.03 91.27% 55.1%', + '--heroui-warning-500': '37.01 91.26% 64.12%', + '--heroui-warning-600': '36.96 91.24% 73.14%', + '--heroui-warning-700': '37.14 91.3% 81.96%', + '--heroui-warning-800': '37.14 91.3% 90.98%', + '--heroui-warning-900': '54.55 91.67% 95.29%', + '--heroui-warning-foreground': '0 0% 0%', + '--heroui-warning': '37.03 91.27% 55.1%', + '--heroui-code-background': '240 5.56% 7.06%', + '--heroui-strong': '190.14 94.67% 44.12%', + '--heroui-code-mdx': '190.14 94.67% 44.12%', + '--heroui-divider-weight': '1px', + '--heroui-disabled-opacity': '.5', + '--heroui-font-size-tiny': '0.75rem', + '--heroui-font-size-small': '0.875rem', + '--heroui-font-size-medium': '1rem', + '--heroui-font-size-large': '1.125rem', + '--heroui-line-height-tiny': '1rem', + '--heroui-line-height-small': '1.25rem', + '--heroui-line-height-medium': '1.5rem', + '--heroui-line-height-large': '1.75rem', + '--heroui-radius-small': '8px', + '--heroui-radius-medium': '12px', + '--heroui-radius-large': '14px', + '--heroui-border-width-small': '1px', + '--heroui-border-width-medium': '2px', + '--heroui-border-width-large': '3px', + '--heroui-box-shadow-small': + '0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)', + '--heroui-box-shadow-medium': + '0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)', + '--heroui-box-shadow-large': + '0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)', + '--heroui-hover-opacity': '.9', + }, + light: { + '--heroui-background': '0 0% 100%', + '--heroui-foreground-50': '240 5.88% 95%', + '--heroui-foreground-100': '240 3.7% 90%', + '--heroui-foreground-200': '240 5.26% 80%', + '--heroui-foreground-300': '240 5.2% 70%', + '--heroui-foreground-400': '240 3.83% 60%', + '--heroui-foreground-500': '240 5.03% 50%', + '--heroui-foreground-600': '240 4.88% 40%', + '--heroui-foreground-700': '240 5.88% 30%', + '--heroui-foreground-800': '240 4.76% 20%', + '--heroui-foreground-900': '0 0% 10%', + '--heroui-foreground': '210 5.56% 7.06%', + '--heroui-focus': '212.01999999999998 100% 53.33%', + '--heroui-overlay': '0 0% 100%', + '--heroui-divider': '0 0% 0%', + '--heroui-divider-opacity': '0.85', + '--heroui-content1': '240 5.88% 95%', + '--heroui-content1-foreground': '0 0% 10%', + '--heroui-content2': '240 3.7% 90%', + '--heroui-content2-foreground': '240 4.76% 20%', + '--heroui-content3': '240 5.26% 80%', + '--heroui-content3-foreground': '240 5.88% 30%', + '--heroui-content4': '240 5.2% 70%', + '--heroui-content4-foreground': '240 4.88% 40%', + '--heroui-default-50': '240 5.88% 95%', + '--heroui-default-100': '240 3.7% 90%', + '--heroui-default-200': '240 5.26% 80%', + '--heroui-default-300': '240 5.2% 70%', + '--heroui-default-400': '240 3.83% 60%', + '--heroui-default-500': '240 5.03% 50%', + '--heroui-default-600': '240 4.88% 40%', + '--heroui-default-700': '240 5.88% 30%', + '--heroui-default-800': '240 4.76% 20%', + '--heroui-default-900': '0 0% 10%', + '--heroui-default-foreground': '0 0% 0%', + '--heroui-default': '240 5.26% 80%', + '--heroui-danger-50': '324 90.91% 95.69%', + '--heroui-danger-100': '350.53 90.48% 91.76%', + '--heroui-danger-200': '343.42 90.48% 83.53%', + '--heroui-danger-300': '337.84 83.46% 73.92%', + '--heroui-danger-400': '331.82 75% 65.49%', + '--heroui-danger-500': '325.82 69.62% 53.53%', + '--heroui-danger-600': '319.73 65.64% 44.51%', + '--heroui-danger-700': '313.85 70.65% 36.08%', + '--heroui-danger-800': '308.18 76.39% 28.24%', + '--heroui-danger-900': '301.89 82.61% 22.55%', + '--heroui-danger-foreground': '0 0% 100%', + '--heroui-danger': '325.82 69.62% 53.53%', + '--heroui-primary-50': '339.13 92% 95.1%', + '--heroui-primary-100': '340 91.84% 90.39%', + '--heroui-primary-200': '339.33 90% 80.39%', + '--heroui-primary-300': '339.11 90.6% 70.78%', + '--heroui-primary-400': '339 90% 60.78%', + '--heroui-primary-500': '339.2 90.36% 51.18%', + '--heroui-primary-600': '339 86.54% 40.78%', + '--heroui-primary-700': '339.11 85.99% 30.78%', + '--heroui-primary-800': '339.33 86.54% 20.39%', + '--heroui-primary-900': '340 84.91% 10.39%', + '--heroui-primary-foreground': '0 0% 100%', + '--heroui-primary': '339.2 90.36% 51.18%', + '--heroui-secondary-50': '270 61.54% 94.9%', + '--heroui-secondary-100': '270 59.26% 89.41%', + '--heroui-secondary-200': '270 59.26% 78.82%', + '--heroui-secondary-300': '270 59.26% 68.24%', + '--heroui-secondary-400': '270 59.26% 57.65%', + '--heroui-secondary-500': '270 66.67% 47.06%', + '--heroui-secondary-600': '270 66.67% 37.65%', + '--heroui-secondary-700': '270 66.67% 28.24%', + '--heroui-secondary-800': '270 66.67% 18.82%', + '--heroui-secondary-900': '270 66.67% 9.41%', + '--heroui-secondary-foreground': '0 0% 100%', + '--heroui-secondary': '270 66.67% 47.06%', + '--heroui-success-50': '146.67 64.29% 94.51%', + '--heroui-success-100': '145.71 61.4% 88.82%', + '--heroui-success-200': '146.2 61.74% 77.45%', + '--heroui-success-300': '145.79 62.57% 66.47%', + '--heroui-success-400': '146.01 62.45% 55.1%', + '--heroui-success-500': '145.96 79.46% 43.92%', + '--heroui-success-600': '146.01 79.89% 35.1%', + '--heroui-success-700': '145.79 79.26% 26.47%', + '--heroui-success-800': '146.2 79.78% 17.45%', + '--heroui-success-900': '145.71 77.78% 8.82%', + '--heroui-success-foreground': '0 0% 0%', + '--heroui-success': '145.96 79.46% 43.92%', + '--heroui-warning-50': '54.55 91.67% 95.29%', + '--heroui-warning-100': '37.14 91.3% 90.98%', + '--heroui-warning-200': '37.14 91.3% 81.96%', + '--heroui-warning-300': '36.96 91.24% 73.14%', + '--heroui-warning-400': '37.01 91.26% 64.12%', + '--heroui-warning-500': '37.03 91.27% 55.1%', + '--heroui-warning-600': '37.01 74.22% 44.12%', + '--heroui-warning-700': '36.96 73.96% 33.14%', + '--heroui-warning-800': '37.14 75% 21.96%', + '--heroui-warning-900': '37.14 75% 10.98%', + '--heroui-warning-foreground': '0 0% 0%', + '--heroui-warning': '37.03 91.27% 55.1%', + '--heroui-code-background': '221.25 17.39% 18.04%', + '--heroui-strong': '316.95 100% 65.29%', + '--heroui-code-mdx': '316.95 100% 65.29%', + '--heroui-divider-weight': '1px', + '--heroui-disabled-opacity': '.5', + '--heroui-font-size-tiny': '0.75rem', + '--heroui-font-size-small': '0.875rem', + '--heroui-font-size-medium': '1rem', + '--heroui-font-size-large': '1.125rem', + '--heroui-line-height-tiny': '1rem', + '--heroui-line-height-small': '1.25rem', + '--heroui-line-height-medium': '1.5rem', + '--heroui-line-height-large': '1.75rem', + '--heroui-radius-small': '8px', + '--heroui-radius-medium': '12px', + '--heroui-radius-large': '14px', + '--heroui-border-width-small': '1px', + '--heroui-border-width-medium': '2px', + '--heroui-border-width-large': '3px', + '--heroui-box-shadow-small': + '0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)', + '--heroui-box-shadow-medium': + '0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)', + '--heroui-box-shadow-large': + '0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)', + '--heroui-hover-opacity': '.8', + }, + }, + } +); diff --git a/packages/snowcat-webui-backend/src/uploader/disk.ts b/packages/snowcat-webui-backend/src/uploader/disk.ts new file mode 100644 index 0000000..c357e53 --- /dev/null +++ b/packages/snowcat-webui-backend/src/uploader/disk.ts @@ -0,0 +1,105 @@ +import multer from 'multer'; +import { Request, Response, RequestHandler } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +const isWindows = process.platform === 'win32'; + +// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题 +const decodeFileName = (fileName: string): string => { + try { + return Buffer.from(fileName, 'binary').toString('utf8'); + } catch { + return fileName; + } +}; + +export const createDiskStorage = (uploadPath: string) => { + return multer.diskStorage({ + destination: ( + _: Request, + file: Express.Multer.File, + cb: (error: Error | null, destination: string) => void + ) => { + try { + const decodedName = decodeFileName(file.originalname); + + if (!uploadPath) { + return cb(new Error('上传路径不能为空'), ''); + } + + if (isWindows && uploadPath === '\\') { + return cb(new Error('根目录不允许上传文件'), ''); + } + + // 处理文件夹上传的情况 + if (decodedName.includes('/') || decodedName.includes('\\')) { + const fullPath = path.join(uploadPath, path.dirname(decodedName)); + fs.mkdirSync(fullPath, { recursive: true }); + cb(null, fullPath); + } else { + cb(null, uploadPath); + } + } catch (error) { + cb(error as Error, ''); + } + }, + filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => { + try { + const decodedName = decodeFileName(file.originalname); + const fileName = path.basename(decodedName); + + // 检查文件是否存在 + const fullPath = path.join(uploadPath, decodedName); + if (fs.existsSync(fullPath)) { + const ext = path.extname(fileName); + const name = path.basename(fileName, ext); + cb(null, `${name}-${randomUUID()}${ext}`); + } else { + cb(null, fileName); + } + } catch (error) { + cb(error as Error, ''); + } + }, + }); +}; + +export const createDiskUpload = (uploadPath: string): RequestHandler => { + const upload = multer({ + storage: createDiskStorage(uploadPath), + limits: { + fileSize: 100 * 1024 * 1024, // 100MB 文件大小限制 + files: 20, // 最多同时上传20个文件 + fieldSize: 1024 * 1024, // 1MB 字段大小限制 + fields: 10, // 最多10个字段 + }, + }).array('files'); + return upload; +}; + +const diskUploader = (req: Request, res: Response) => { + const uploadPath = (req.query['path'] || '') as string; + return new Promise((resolve, reject) => { + createDiskUpload(uploadPath)(req, res, (error) => { + if (error) { + // 错误处理 + if (error.code === 'LIMIT_FILE_SIZE') { + return reject(new Error('文件大小超过限制(最大100MB)')); + } + if (error.code === 'LIMIT_FILE_COUNT') { + return reject(new Error('文件数量超过限制(最多20个文件)')); + } + if (error.code === 'LIMIT_FIELD_VALUE') { + return reject(new Error('字段值大小超过限制')); + } + if (error.code === 'LIMIT_FIELD_COUNT') { + return reject(new Error('字段数量超过限制')); + } + return reject(error); + } + return resolve(true); + }); + }); +}; +export default diskUploader; diff --git a/packages/snowcat-webui-backend/src/uploader/webui_font.ts b/packages/snowcat-webui-backend/src/uploader/webui_font.ts new file mode 100644 index 0000000..e09795b --- /dev/null +++ b/packages/snowcat-webui-backend/src/uploader/webui_font.ts @@ -0,0 +1,86 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import type { Request, Response, RequestHandler } from 'express'; +import { WebUiConfig } from '@/snowcat-webui-backend/index'; + +// 支持的字体格式 +const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf']; + +// 清理旧的字体文件 +const cleanOldFontFiles = (fontsPath: string) => { + try { + // 确保字体目录存在 + if (!fs.existsSync(fontsPath)) { + return; + } + + // 遍历目录下所有文件 + const files = fs.readdirSync(fontsPath); + + for (const file of files) { + // 检查文件名是否以 webui 或 CustomFont 开头,且是支持的字体扩展名 + const ext = path.extname(file).toLowerCase(); + const name = path.basename(file, ext); + + if (SUPPORTED_FONT_EXTENSIONS.includes(ext) && (name === 'webui' || name === 'CustomFont')) { + try { + fs.unlinkSync(path.join(fontsPath, file)); + } catch (e) { + console.error(`Failed to delete old font file ${file}:`, e); + } + } + } + } catch (err) { + console.error('Failed to clean old font files:', err); + } +}; + +export const webUIFontStorage = multer.diskStorage({ + destination: (_, __, cb) => { + try { + const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync()); + // 确保字体目录存在 + fs.mkdirSync(fontsPath, { recursive: true }); + // 清理旧的字体文件 + cleanOldFontFiles(fontsPath); + cb(null, fontsPath); + } catch (error) { + // 确保错误信息被正确传递 + cb(new Error(`创建字体目录失败:${(error as Error).message}`), ''); + } + }, + filename: (_, file, cb) => { + // 强制文件名为 CustomFont,保留原始扩展名 + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, `CustomFont${ext}`); + }, +}); + +export const webUIFontUpload: RequestHandler = multer({ + storage: webUIFontStorage, + fileFilter: (_, file, cb) => { + // 验证文件类型 + const ext = path.extname(file.originalname).toLowerCase(); + if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) { + cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件')); + return; + } + cb(null, true); + }, + limits: { + fileSize: 40 * 1024 * 1024, // 限制40MB + }, +}).single('file'); + +const webUIFontUploader = (req: Request, res: Response) => { + return new Promise((resolve, reject) => { + webUIFontUpload(req, res, (error) => { + if (error) { + return reject(error); + } + return resolve(true); + }); + }); +}; +export default webUIFontUploader; diff --git a/packages/snowcat-webui-backend/src/utils/check.ts b/packages/snowcat-webui-backend/src/utils/check.ts new file mode 100644 index 0000000..0c532fc --- /dev/null +++ b/packages/snowcat-webui-backend/src/utils/check.ts @@ -0,0 +1 @@ +export const isEmpty = (data: T) => data === undefined || data === null || data === ''; diff --git a/packages/snowcat-webui-backend/src/utils/guid.ts b/packages/snowcat-webui-backend/src/utils/guid.ts new file mode 100644 index 0000000..ca93a3c --- /dev/null +++ b/packages/snowcat-webui-backend/src/utils/guid.ts @@ -0,0 +1,275 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { protectData, unprotectData } from 'snowcat-dpapi'; + +const GUID_HEADER = Buffer.from([0x00, 0x00, 0x00, 0x14]); +const XOR_KEY = 0x10; + +/** + * Unprotects data using Windows DPAPI via snowcat-dpapi. + */ +function dpapiUnprotect (filePath: string): Buffer { + const encrypted = fs.readFileSync(filePath); + return Buffer.from(unprotectData(encrypted, null, 'CurrentUser')); +} + +/** + * Protects data using Windows DPAPI and writes to file. + */ +function dpapiProtectAndWrite (filePath: string, data: Buffer): void { + const encrypted = protectData(data, null, 'CurrentUser'); + fs.writeFileSync(filePath, Buffer.from(encrypted)); +} + +export class Registry20Utils { + static getRegistryPath (dataPath: string): string { + return path.join(dataPath, 'nt_qq', 'global', 'nt_data', 'msf', 'Registry20'); + } + + static readGuid (registryPath: string): string { + if (!fs.existsSync(registryPath)) { + throw new Error('Registry20 file not found'); + } + if (os.platform() !== 'win32') { + throw new Error('Registry20 decryption is only supported on Windows'); + } + + const decrypted = dpapiUnprotect(registryPath); + + if (decrypted.length < 20) { + throw new Error(`Decrypted data too short (got ${decrypted.length} bytes, need 20)`); + } + + // Decode payload: header(4) + obfuscated_guid(16) + const payload = decrypted.subarray(4, 20); + const guidBuf = Buffer.alloc(16); + for (let i = 0; i < 16; i++) { + const payloadByte = payload[i] ?? 0; + guidBuf[i] = (~(payloadByte ^ XOR_KEY)) & 0xFF; + } + + return guidBuf.toString('hex'); + } + + static writeGuid (registryPath: string, guidHex: string): void { + if (guidHex.length !== 32) { + throw new Error('Invalid GUID length, must be 32 hex chars'); + } + if (os.platform() !== 'win32') { + throw new Error('Registry20 encryption is only supported on Windows'); + } + + const guidBytes = Buffer.from(guidHex, 'hex'); + const payload = Buffer.alloc(16); + for (let i = 0; i < 16; i++) { + const guidByte = guidBytes[i] ?? 0; + payload[i] = XOR_KEY ^ (~guidByte & 0xFF); + } + + const data = Buffer.concat([GUID_HEADER, payload]); + + // Create directory if not exists + const dir = path.dirname(registryPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + dpapiProtectAndWrite(registryPath, data); + } + + static getBackups (registryPath: string): string[] { + const dir = path.dirname(registryPath); + const baseName = path.basename(registryPath); + if (!fs.existsSync(dir)) return []; + + return fs.readdirSync(dir) + .filter(f => f.startsWith(`${baseName}.bak.`)) + .sort() + .reverse(); + } + + static backup (registryPath: string): string { + if (!fs.existsSync(registryPath)) { + throw new Error('Registry20 does not exist'); + } + const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14); + const backupPath = `${registryPath}.bak.${timestamp}`; + fs.copyFileSync(registryPath, backupPath); + return backupPath; + } + + static restore (registryPath: string, backupFileName: string): void { + const dir = path.dirname(registryPath); + const backupPath = path.join(dir, backupFileName); + if (!fs.existsSync(backupPath)) { + throw new Error('Backup file not found'); + } + fs.copyFileSync(backupPath, registryPath); + } + + static delete (registryPath: string): void { + if (fs.existsSync(registryPath)) { + fs.unlinkSync(registryPath); + } + } +} + +// ============================================================ +// Linux machine-info 工具类 +// ============================================================ + +/** + * ROT13 编解码 (自逆运算) + * 字母偏移13位,数字和符号不变 + */ +function rot13 (s: string): string { + return s.replace(/[a-zA-Z]/g, (c) => { + const base = c <= 'Z' ? 65 : 97; + return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base); + }); +} + +/** + * Linux 平台 machine-info 文件工具类 + * + * 文件格式 (逆向自 machine_guid_util.cc): + * [4字节 BE uint32 长度 N] [N字节 ROT13 编码的 MAC 字符串] + * - MAC 格式: xx-xx-xx-xx-xx-xx (17 字符) + * - ROT13: 字母偏移13位, 数字和 '-' 不变 + * + * GUID 生成算法: + * GUID = MD5( /etc/machine-id + MAC地址 ) + */ +export class MachineInfoUtils { + /** + * 获取 machine-info 文件路径 + */ + static getMachineInfoPath (dataPath: string): string { + return path.join(dataPath, 'nt_qq', 'global', 'nt_data', 'msf', 'machine-info'); + } + + /** + * 从 machine-info 文件读取 MAC 地址 + */ + static readMac (machineInfoPath: string): string { + if (!fs.existsSync(machineInfoPath)) { + throw new Error('machine-info file not found'); + } + + const data = fs.readFileSync(machineInfoPath); + + if (data.length < 4) { + throw new Error(`machine-info data too short: ${data.length} < 4 bytes`); + } + + const length = data.readUInt32BE(0); + + if (length >= 18) { + throw new Error(`MAC string length abnormal: ${length} >= 18`); + } + + if (data.length < 4 + length) { + throw new Error(`machine-info data incomplete: need ${4 + length} bytes, got ${data.length}`); + } + + const rot13Str = data.subarray(4, 4 + length).toString('ascii'); + return rot13(rot13Str); + } + + /** + * 将 MAC 地址写入 machine-info 文件 + */ + static writeMac (machineInfoPath: string, mac: string): void { + mac = mac.trim().toLowerCase(); + + // 验证 MAC 格式: xx-xx-xx-xx-xx-xx + if (!/^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/.test(mac)) { + throw new Error('Invalid MAC format, must be xx-xx-xx-xx-xx-xx'); + } + + const encoded = rot13(mac); + const length = encoded.length; + const buf = Buffer.alloc(4 + length); + buf.writeUInt32BE(length, 0); + buf.write(encoded, 4, 'ascii'); + + // 确保目录存在 + const dir = path.dirname(machineInfoPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(machineInfoPath, buf); + } + + /** + * 读取 /etc/machine-id + */ + static readMachineId (): string { + const machineIdPath = '/etc/machine-id'; + if (!fs.existsSync(machineIdPath)) { + throw new Error('/etc/machine-id not found'); + } + return fs.readFileSync(machineIdPath, 'utf-8').trim(); + } + + /** + * 计算 Linux GUID = MD5(machine-id + MAC) + */ + static computeGuid (machineId: string, mac: string): string { + const md5 = crypto.createHash('md5'); + md5.update(machineId, 'ascii'); + md5.update(mac, 'ascii'); + return md5.digest('hex'); + } + + /** + * 获取备份列表 + */ + static getBackups (machineInfoPath: string): string[] { + const dir = path.dirname(machineInfoPath); + const baseName = path.basename(machineInfoPath); + if (!fs.existsSync(dir)) return []; + + return fs.readdirSync(dir) + .filter(f => f.startsWith(`${baseName}.bak.`)) + .sort() + .reverse(); + } + + /** + * 创建备份 + */ + static backup (machineInfoPath: string): string { + if (!fs.existsSync(machineInfoPath)) { + throw new Error('machine-info file does not exist'); + } + const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14); + const backupPath = `${machineInfoPath}.bak.${timestamp}`; + fs.copyFileSync(machineInfoPath, backupPath); + return backupPath; + } + + /** + * 恢复备份 + */ + static restore (machineInfoPath: string, backupFileName: string): void { + const dir = path.dirname(machineInfoPath); + const backupPath = path.join(dir, backupFileName); + if (!fs.existsSync(backupPath)) { + throw new Error('Backup file not found'); + } + fs.copyFileSync(backupPath, machineInfoPath); + } + + /** + * 删除 machine-info + */ + static delete (machineInfoPath: string): void { + if (fs.existsSync(machineInfoPath)) { + fs.unlinkSync(machineInfoPath); + } + } +} diff --git a/packages/snowcat-webui-backend/src/utils/object.ts b/packages/snowcat-webui-backend/src/utils/object.ts new file mode 100644 index 0000000..2b1d284 --- /dev/null +++ b/packages/snowcat-webui-backend/src/utils/object.ts @@ -0,0 +1,22 @@ +export function deepMerge> (target: T, source: Partial): T { + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + // 如果 source[key] 为 undefined,则跳过(保留 target[key]) + if (source[key] === undefined) { + continue; + } + if ( + target[key] !== undefined && + typeof target[key] === 'object' && + !Array.isArray(target[key]) && + typeof source[key] === 'object' && + !Array.isArray(source[key]) + ) { + target[key] = deepMerge({ ...target[key] }, source[key]!) as T[Extract]; + } else { + target[key] = source[key]! as T[Extract]; + } + } + } + return target; +} diff --git a/packages/snowcat-webui-backend/src/utils/response.ts b/packages/snowcat-webui-backend/src/utils/response.ts new file mode 100644 index 0000000..adb77dc --- /dev/null +++ b/packages/snowcat-webui-backend/src/utils/response.ts @@ -0,0 +1,47 @@ +import type { Response } from 'express'; + +import { ResponseCode, HttpStatusCode } from '@/snowcat-webui-backend/src/const/status'; + +export const sendResponse = ( + res: Response, + data?: T, + code: ResponseCode = 0, + message = 'success', + useSend: boolean = false +) => { + const result = { + code, + message, + data, + }; + if (useSend) { + res.status(HttpStatusCode.OK).send(JSON.stringify(result)); + return; + } + res.status(HttpStatusCode.OK).json(result); +}; + +export const sendError = (res: Response, message = 'error', useSend: boolean = false) => { + const result = { + code: ResponseCode.Error, + message, + }; + if (useSend) { + res.status(HttpStatusCode.OK).send(JSON.stringify(result)); + return; + } + res.status(HttpStatusCode.OK).json(result); +}; + +export const sendSuccess = (res: Response, data?: T, message = 'success', useSend: boolean = false) => { + const result = { + code: ResponseCode.Success, + data, + message, + }; + if (useSend) { + res.status(HttpStatusCode.OK).send(JSON.stringify(result)); + return; + } + res.status(HttpStatusCode.OK).json(result); +}; diff --git a/packages/snowcat-webui-backend/src/utils/url.ts b/packages/snowcat-webui-backend/src/utils/url.ts new file mode 100644 index 0000000..0d37396 --- /dev/null +++ b/packages/snowcat-webui-backend/src/utils/url.ts @@ -0,0 +1,107 @@ +/** + * @file URL工具 + */ +import fs from 'node:fs'; +import { isIP } from 'node:net'; +import { randomBytes } from 'node:crypto'; + +type Protocol = 'http' | 'https'; + +let isDockerCached: boolean; + +function hasDockerEnv () { + try { + fs.statSync('/.dockerenv'); + return true; + } catch { + return false; + } +} + +function hasDockerCGroup () { + try { + return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker'); + } catch { + return false; + } +} + +const hasContainerEnv = () => { + try { + fs.statSync('/run/.containerenv'); + return true; + } catch { + return false; + } +}; + +const isDocker = () => { + if (isDockerCached === undefined) { + isDockerCached = hasContainerEnv() || hasDockerEnv() || hasDockerCGroup(); + } + + return isDockerCached; +}; + +/** + * 获取默认host地址 + * @returns 根据环境返回合适的host地址 + * @example getDefaultHost() => '127.0.0.1' // 非Docker环境 + * @example getDefaultHost() => '0.0.0.0' // Docker环境 + */ +export const getDefaultHost = (): string => { + return isDocker() ? '0.0.0.0' : '127.0.0.1'; +}; + +/** + * 将 host(主机地址) 转换为标准格式 + * @param host 主机地址 + * @returns 标准格式的IP地址 + * @example normalizeHost('10.0.3.2') => '10.0.3.2' + * @example normalizeHost('0.0.0.0') => '127.0.0.1' + * @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]' + */ +export const normalizeHost = (host: string) => { + if (isIP(host) === 6) return `[${host}]`; + return host; +}; + +/** + * 创建URL + * @param host 主机地址 + * @param port 端口 + * @param path URL路径 + * @param search URL参数 + * @returns 完整URL + * @example createUrl('127.0.0.1', '8080', '/api', { token: '123456' }) => 'http://127.0.0.1:8080/api?token=123456' + * @example createUrl('baidu.com', '80', void 0, void 0, 'https') => 'https://baidu.com:80/' + */ +export const createUrl = ( + host: string, + port: string, + path = '/', + search?: Record, + protocol: Protocol = 'http' +) => { + const url = new URL(`${protocol}://${normalizeHost(host)}`); + url.port = port; + url.pathname = path; + if (search) { + for (const key in search) { + url.searchParams.set(key, search[key]); + } + } + + /** 进行url解码 对特殊字符进行处理 */ + return decodeURIComponent(url.toString()); +}; + +/** + * 生成随机Token + * @param length Token长度 默认8 + * @returns 随机Token字符串 + * @example getRandomToken + */ +export const getRandomToken = (length = 8) => { + return randomBytes(36).toString('hex').slice(0, length); +}; diff --git a/packages/snowcat-webui-backend/tsconfig.json b/packages/snowcat-webui-backend/tsconfig.json new file mode 100644 index 0000000..e51d730 --- /dev/null +++ b/packages/snowcat-webui-backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": [ + "*.ts", + "src/**/*.ts", + "src/**/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/snowcat-webui-backend/webui.json b/packages/snowcat-webui-backend/webui.json new file mode 100644 index 0000000..5d74c5e --- /dev/null +++ b/packages/snowcat-webui-backend/webui.json @@ -0,0 +1,11 @@ +{ + "host": "0.0.0.0", + "port": 6099, + "prefix": "", + "token": "random", + "loginRate": 3, + "accessControlMode": "none", + "ipWhitelist": [], + "ipBlacklist": [], + "enableXForwardedFor": false +} \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/.env b/packages/snowcat-webui-frontend/.env new file mode 100644 index 0000000..84ce554 --- /dev/null +++ b/packages/snowcat-webui-frontend/.env @@ -0,0 +1 @@ +VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099" \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/.gitignore b/packages/snowcat-webui-frontend/.gitignore new file mode 100644 index 0000000..5a7e64b --- /dev/null +++ b/packages/snowcat-webui-frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# NPM LOCK files +package-lock.json +yarn.lock + +dist.zip \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/.npmrc b/packages/snowcat-webui-frontend/.npmrc new file mode 100644 index 0000000..7cb61a2 --- /dev/null +++ b/packages/snowcat-webui-frontend/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@heroui/* \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/LICENSE b/packages/snowcat-webui-frontend/LICENSE new file mode 100644 index 0000000..725286a --- /dev/null +++ b/packages/snowcat-webui-frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 bietiaop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/README.md b/packages/snowcat-webui-frontend/README.md new file mode 100644 index 0000000..be8c275 --- /dev/null +++ b/packages/snowcat-webui-frontend/README.md @@ -0,0 +1,32 @@ +# NapCat WebUI + +## 功能 + +- WebUI登录 +- QQ登录 +- 网络配置 +- OneBot/WebUI配置 +- 日志查看(实时日志、历史日志) +- HTTP调试 +- WS调试 +- 在线音乐播放器,支持网易云音乐歌单(大屏在页面右下角,小屏在页面下方) + +如果你有更多功能需求,欢迎在 issue 中提出。 + +# License + +[MIT](LICENSE) + +# Related Projects + +- [NapCat](https://github.com/NapNeko/NapCatQQ/) +- [Karin](https://github.com/KarinJS/Karin/) + +# Thanks to + +- [Vercel](https://vercel.com/) +- [React](https://react.dev/) +- [HeroUI](https://nextui.org/) +- and more open-source projects + +感谢群友“维拉”提供的在线音乐API。 \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/index.html b/packages/snowcat-webui-frontend/index.html new file mode 100644 index 0000000..484b054 --- /dev/null +++ b/packages/snowcat-webui-frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + SnowCat WebUI · 雪猫控制台 + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/package.json b/packages/snowcat-webui-frontend/package.json new file mode 100644 index 0000000..aebcfa5 --- /dev/null +++ b/packages/snowcat-webui-frontend/package.json @@ -0,0 +1,134 @@ +{ + "name": "snowcat-webui-frontend", + "private": true, + "version": "0.0.6", + "type": "module", + "scripts": { + "dev": "vite --host=0.0.0.0", + "build": "vite build", + "build:full": "tsc && vite build", + "fontmin": "node scripts/fontmin.cjs", + "typecheck": "tsc --noEmit", + "lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@heroui/accordion": "^2.2.8", + "@heroui/avatar": "2.2.7", + "@heroui/breadcrumbs": "2.2.7", + "@heroui/button": "2.2.10", + "@heroui/card": "2.2.10", + "@heroui/checkbox": "2.3.9", + "@heroui/chip": "2.2.7", + "@heroui/code": "2.2.7", + "@heroui/divider": "^2.2.21", + "@heroui/dropdown": "2.3.10", + "@heroui/form": "2.1.9", + "@heroui/image": "2.2.6", + "@heroui/input": "2.4.10", + "@heroui/kbd": "2.2.7", + "@heroui/link": "2.2.8", + "@heroui/listbox": "2.3.10", + "@heroui/modal": "2.2.8", + "@heroui/navbar": "2.2.9", + "@heroui/pagination": "^2.2.9", + "@heroui/popover": "2.3.10", + "@heroui/select": "2.4.10", + "@heroui/skeleton": "^2.2.6", + "@heroui/slider": "2.4.8", + "@heroui/snippet": "2.2.11", + "@heroui/spinner": "2.2.7", + "@heroui/switch": "2.2.9", + "@heroui/system": "2.4.7", + "@heroui/table": "^2.2.9", + "@heroui/tabs": "2.2.8", + "@heroui/theme": "2.4.6", + "@heroui/tooltip": "2.2.8", + "@monaco-editor/loader": "^1.4.0", + "@react-aria/visually-hidden": "^3.8.19", + "@reduxjs/toolkit": "^2.5.1", + "@sinclair/typebox": "^0.34.41", + "@uidotdev/usehooks": "^2.4.1", + "@uiw/react-codemirror": "^4.25.4", + "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "ahooks": "^3.8.4", + "axios": "^1.7.9", + "clsx": "^2.1.1", + "crypto-js": "^4.2.0", + "event-source-polyfill": "^1.0.31", + "motion": "^12.0.6", + "path-browserify": "^1.0.1", + "qface": "^1.4.1", + "qrcode.react": "^4.2.0", + "quill": "^2.0.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-dropzone": "^14.3.5", + "react-error-boundary": "^5.0.0", + "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.4.1", + "react-icons": "^5.4.0", + "react-markdown": "^9.0.3", + "react-photo-view": "^1.2.7", + "react-redux": "^9.2.0", + "react-router-dom": "^7.1.4", + "react-use-websocket": "^4.11.1", + "react-window": "^1.8.11", + "remark-gfm": "^4.0.0", + "tailwind-variants": "^0.3.0", + "tailwindcss": "^3.4.17", + "zod": "^3.24.1" + }, + "devDependencies": { + "@react-types/shared": "^3.26.0", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/crypto-js": "^4.2.2", + "@types/event-source-polyfill": "^1.0.5", + "@types/fabric": "^5.3.9", + "@types/node": "^22.12.0", + "@types/path-browserify": "^1.0.3", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@types/react-window": "^1.8.8", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.19.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "5.2.3", + "eslint-plugin-unused-imports": "^4.1.4", + "fontmin": "^0.9.9", + "glob": "^10.3.10", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "sharp": "^0.34.5", + "typescript": "^5.7.3", + "vite": "^6.0.5", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-image-optimizer": "^2.0.3", + "vite-plugin-static-copy": "^2.2.0", + "vite-tsconfig-paths": "^5.1.4" + }, + "overrides": { + "ahooks": { + "react": "$react", + "react-dom": "$react-dom" + }, + "react-window": { + "react": "$react", + "react-dom": "$react-dom" + } + } +} \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/postcss.config.js b/packages/snowcat-webui-frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/snowcat-webui-frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/snowcat-webui-frontend/public/favicon.ico b/packages/snowcat-webui-frontend/public/favicon.ico new file mode 100644 index 0000000..4d67aff Binary files /dev/null and b/packages/snowcat-webui-frontend/public/favicon.ico differ diff --git a/packages/snowcat-webui-frontend/public/fonts/AaCute.woff b/packages/snowcat-webui-frontend/public/fonts/AaCute.woff new file mode 100644 index 0000000..73c0698 Binary files /dev/null and b/packages/snowcat-webui-frontend/public/fonts/AaCute.woff differ diff --git a/packages/snowcat-webui-frontend/public/fonts/JetBrainsMono-Italic.ttf b/packages/snowcat-webui-frontend/public/fonts/JetBrainsMono-Italic.ttf new file mode 100644 index 0000000..5414835 Binary files /dev/null and b/packages/snowcat-webui-frontend/public/fonts/JetBrainsMono-Italic.ttf differ diff --git a/packages/snowcat-webui-frontend/public/fonts/JetBrainsMono.ttf b/packages/snowcat-webui-frontend/public/fonts/JetBrainsMono.ttf new file mode 100644 index 0000000..b60e77f Binary files /dev/null and b/packages/snowcat-webui-frontend/public/fonts/JetBrainsMono.ttf differ diff --git a/packages/snowcat-webui-frontend/public/robots.txt b/packages/snowcat-webui-frontend/public/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/packages/snowcat-webui-frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/public/vercel.json b/packages/snowcat-webui-frontend/public/vercel.json new file mode 100644 index 0000000..1db5d80 --- /dev/null +++ b/packages/snowcat-webui-frontend/public/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/" } + ] +} \ No newline at end of file diff --git a/packages/snowcat-webui-frontend/scripts/fontmin.cjs b/packages/snowcat-webui-frontend/scripts/fontmin.cjs new file mode 100644 index 0000000..aae2498 --- /dev/null +++ b/packages/snowcat-webui-frontend/scripts/fontmin.cjs @@ -0,0 +1,137 @@ +/** + * Fontmin Script - 动态裁剪字体 + * 扫描 src 目录中所有中文字符,生成字体子集 + */ +const Fontmin = require('fontmin'); +const fs = require('fs'); +const path = require('path'); +const glob = require('glob'); + +// 配置 +const SOURCE_FONT = path.resolve(__dirname, '../src/assets/fonts/AaCute-full.ttf'); +const SOURCE_TTF_ORIGINAL = path.resolve(__dirname, '../src/assets/fonts/AaCute.ttf'); +const OUTPUT_DIR = path.resolve(__dirname, '../public/fonts'); +const OUTPUT_NAME = 'AaCute.woff'; +const SRC_DIR = path.resolve(__dirname, '../src'); + +// 基础字符集(常用汉字 + 标点 + 数字 + 字母) +const BASE_CHARS = ` + 0123456789 + abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ + ,。!?、;:""''()【】《》…—· + ,.:;!?'"()[]<>-_+=*/\\|@#$%^&~\` + 基础信息系统版本网络配置服务器客户端终端日志调试关于设置主题 + 登录退出确定取消保存删除编辑新建刷新加载更新下载上传 + 成功失败错误警告提示信息状态在线离线连接断开 + 用户名密码账号验证码记住自动 + 文件管理打开关闭复制粘贴剪切重命名移动 + 发送消息输入内容搜索查找筛选排序 + 帮助文档教程反馈问题建议 + 开启关闭启用禁用显示隐藏展开收起 + 返回前进上一步下一步完成跳过 + 今天昨天明天时间日期年月日时分秒 + 总量使用占用剩余内存内核主频型号 + 有新版本可用当前最新立即稍后 +`; + +/** + * 从源码文件中提取所有中文字符 + */ +function extractCharsFromSource () { + const chars = new Set(BASE_CHARS.replace(/\s/g, '')); + + // 匹配所有 .tsx, .ts, .jsx, .js, .css 文件 + const files = glob.sync(`${SRC_DIR}/**/*.{tsx,ts,jsx,js,css}`, { + ignore: ['**/node_modules/**'], + }); + + // 中文字符正则 + const chineseRegex = /[\u4e00-\u9fa5]/g; + + files.forEach(file => { + try { + const content = fs.readFileSync(file, 'utf-8'); + const matches = content.match(chineseRegex); + if (matches) { + matches.forEach(char => chars.add(char)); + } + } catch (e) { + console.warn(`Warning: Could not read file ${file}`); + } + }); + + return Array.from(chars).join(''); +} + +/** + * 运行 fontmin + */ +async function run () { + console.log('🔍 Scanning source files for Chinese characters...'); + const text = extractCharsFromSource(); + console.log(`📝 Found ${text.length} unique characters`); + + // 检查源字体是否存在 + const sourceFont = SOURCE_FONT; + if (!fs.existsSync(SOURCE_FONT)) { + // 尝试查找原始 TTF 并复制(不重命名,保留原始) + if (fs.existsSync(SOURCE_TTF_ORIGINAL)) { + console.log('📦 Copying original font to AaCute-full.ttf...'); + fs.copyFileSync(SOURCE_TTF_ORIGINAL, SOURCE_FONT); + } else { + console.error(`❌ Source font not found: ${SOURCE_FONT}`); + console.log('💡 Please ensure AaCute.ttf exists in src/assets/fonts/'); + process.exit(1); + } + } + + console.log('✂️ Subsetting font...'); + + // 确保输出目录存在 + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + const fontmin = new Fontmin() + .src(sourceFont) + .use(Fontmin.glyph({ text })) + .use(Fontmin.ttf2woff()) + .dest(OUTPUT_DIR); + + return new Promise((resolve, reject) => { + fontmin.run((err, files) => { + if (err) { + console.error('❌ Fontmin error:', err); + reject(err); + } else { + // 重命名输出文件 + const generatedWoff = path.join(OUTPUT_DIR, 'AaCute-full.woff'); + const targetFile = path.join(OUTPUT_DIR, OUTPUT_NAME); + + if (fs.existsSync(generatedWoff)) { + // 如果目标文件存在,先删除 + if (fs.existsSync(targetFile)) { + fs.unlinkSync(targetFile); + } + fs.renameSync(generatedWoff, targetFile); + } + + // 清理生成的 TTF 文件 + const generatedTtf = path.join(OUTPUT_DIR, 'AaCute-full.ttf'); + if (fs.existsSync(generatedTtf)) { + fs.unlinkSync(generatedTtf); + } + + if (fs.existsSync(targetFile)) { + const stats = fs.statSync(targetFile); + const sizeKB = (stats.size / 1024).toFixed(2); + console.log(`✅ Font subset created: ${targetFile} (${sizeKB} KB)`); + } + resolve(); + } + }); + }); +} + +run().catch(console.error); diff --git a/packages/snowcat-webui-frontend/src/App.tsx b/packages/snowcat-webui-frontend/src/App.tsx new file mode 100644 index 0000000..b1a7b11 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/App.tsx @@ -0,0 +1,93 @@ +import { Suspense, lazy, useEffect } from 'react'; +import { Provider } from 'react-redux'; +import { Route, Routes, useNavigate } from 'react-router-dom'; + +import PageBackground from '@/components/page_background'; +import PageLoading from '@/components/page_loading'; +import Toaster from '@/components/toaster'; + +import DialogProvider from '@/contexts/dialog'; + +import useAuth from '@/hooks/auth'; + +import store from '@/store'; + +const WebLoginPage = lazy(() => import('@/pages/web_login')); +const IndexPage = lazy(() => import('@/pages/index')); +const QQLoginPage = lazy(() => import('@/pages/qq_login')); +const DashboardIndexPage = lazy(() => import('@/pages/dashboard')); +const AboutPage = lazy(() => import('@/pages/dashboard/about')); +const ConfigPage = lazy(() => import('@/pages/dashboard/config')); +const DebugPage = lazy(() => import('@/pages/dashboard/debug')); +const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http')); +const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket')); +const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager')); +const LogsPage = lazy(() => import('@/pages/dashboard/logs')); +const NetworkPage = lazy(() => import('@/pages/dashboard/network')); +const TerminalPage = lazy(() => import('@/pages/dashboard/terminal')); +const PluginPage = lazy(() => import('@/pages/dashboard/plugin')); +const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store')); +const ExtensionPage = lazy(() => import('@/pages/dashboard/extension')); + +function App () { + return ( + + + + + }> + + + + + + + ); +} + +function AuthChecker ({ children }: { children: React.ReactNode; }) { + const { isAuth } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isAuth) { + const search = new URLSearchParams(window.location.search); + const token = search.get('token'); + let url = '/web_login'; + + if (token) { + url += `?token=${token}`; + } + navigate(url, { replace: true }); + } + }, [isAuth, navigate]); + + return <>{children}; +} + +function AppRoutes () { + return ( + + }> + } /> + } /> + } /> + } /> + }> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + ); +} + +export default App; diff --git a/packages/snowcat-webui-frontend/src/assets/fonts/AaCute-full.ttf b/packages/snowcat-webui-frontend/src/assets/fonts/AaCute-full.ttf new file mode 100644 index 0000000..87b04a4 Binary files /dev/null and b/packages/snowcat-webui-frontend/src/assets/fonts/AaCute-full.ttf differ diff --git a/packages/snowcat-webui-frontend/src/assets/fonts/AaCute.ttf b/packages/snowcat-webui-frontend/src/assets/fonts/AaCute.ttf new file mode 100644 index 0000000..87b04a4 Binary files /dev/null and b/packages/snowcat-webui-frontend/src/assets/fonts/AaCute.ttf differ diff --git a/packages/snowcat-webui-frontend/src/assets/fonts/AaCute.woff b/packages/snowcat-webui-frontend/src/assets/fonts/AaCute.woff new file mode 100644 index 0000000..19fcf9b Binary files /dev/null and b/packages/snowcat-webui-frontend/src/assets/fonts/AaCute.woff differ diff --git a/packages/snowcat-webui-frontend/src/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png b/packages/snowcat-webui-frontend/src/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png new file mode 100644 index 0000000..8297f06 Binary files /dev/null and b/packages/snowcat-webui-frontend/src/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png differ diff --git a/packages/snowcat-webui-frontend/src/assets/images/bkg-color.png b/packages/snowcat-webui-frontend/src/assets/images/bkg-color.png new file mode 100644 index 0000000..66ef420 Binary files /dev/null and b/packages/snowcat-webui-frontend/src/assets/images/bkg-color.png differ diff --git a/packages/snowcat-webui-frontend/src/assets/images/logo.png b/packages/snowcat-webui-frontend/src/assets/images/logo.png new file mode 100644 index 0000000..a01e8dd Binary files /dev/null and b/packages/snowcat-webui-frontend/src/assets/images/logo.png differ diff --git a/packages/snowcat-webui-frontend/src/components/ColorPicker.tsx b/packages/snowcat-webui-frontend/src/components/ColorPicker.tsx new file mode 100644 index 0000000..e987487 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/ColorPicker.tsx @@ -0,0 +1,410 @@ +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import React, { useCallback, useEffect, useRef, useState, memo } from 'react'; + +interface ColorPickerProps { + color: string; + onChange: (color: string) => void; +} + +// 转换 HSL 字符串到对象 +const parseHsl = (hslStr: string) => { + const match = hslStr.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/); + if (match) { + return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) }; + } + return { h: 0, s: 0, l: 0 }; +}; + +// 转换 HEX 到 HSL +const hexToHsl = (hex: string) => { + let r = 0; let g = 0; let b = 0; + if (hex.length === 4) { + r = parseInt('0x' + hex[1] + hex[1]); + g = parseInt('0x' + hex[2] + hex[2]); + b = parseInt('0x' + hex[3] + hex[3]); + } else if (hex.length === 7) { + r = parseInt('0x' + hex[1] + hex[2]); + g = parseInt('0x' + hex[3] + hex[4]); + b = parseInt('0x' + hex[5] + hex[6]); + } + r /= 255; + g /= 255; + b /= 255; + const cmin = Math.min(r, g, b); + const cmax = Math.max(r, g, b); + const delta = cmax - cmin; + let h = 0; + let s = 0; + let l = 0; + + if (delta === 0) h = 0; + else if (cmax === r) h = ((g - b) / delta) % 6; + else if (cmax === g) h = (b - r) / delta + 2; + else h = (r - g) / delta + 4; + + h = Math.round(h * 60); + if (h < 0) h += 360; + + l = (cmax + cmin) / 2; + s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return { h, s, l }; +}; + +// 转换 HSL 到 HEX +const hslToHex = (h: number, s: number, l: number) => { + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0; let g = 0; let b = 0; + + if (h >= 0 && h < 60) { + r = c; g = x; b = 0; + } else if (h >= 60 && h < 120) { + r = x; g = c; b = 0; + } else if (h >= 120 && h < 180) { + r = 0; g = c; b = x; + } else if (h >= 180 && h < 240) { + r = 0; g = x; b = c; + } else if (h >= 240 && h < 300) { + r = x; g = 0; b = c; + } else if (h >= 300 && h < 360) { + r = c; g = 0; b = x; + } + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + const toHex = (n: number) => { + const hex = n.toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + return '#' + toHex(r) + toHex(g) + toHex(b); +}; + +interface PanelProps { + hsl: { h: number, s: number, l: number; }; + onChange: (newHsl: { h: number, s: number, l: number; }) => void; +} + +// 饱和度/亮度面板 +const SatLightPanel = memo(({ hsl, onChange }: PanelProps) => { + const panelRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const hslRef = useRef(hsl); + useEffect(() => { hslRef.current = hsl; }, [hsl]); + + const updateColor = useCallback((clientX: number, clientY: number) => { + if (!panelRef.current) return; + const rect = panelRef.current.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + + const s_hsv = x; + const v_hsv = 1 - y; + + const l_hsl = v_hsv * (1 - s_hsv / 2); + let s_hsl = 0; + if (l_hsl === 0 || l_hsl === 1) { + s_hsl = 0; + } else { + s_hsl = (v_hsv - l_hsl) / Math.min(l_hsl, 1 - l_hsl); + } + + onChange({ h: hslRef.current.h, s: s_hsl * 100, l: l_hsl * 100 }); + }, [onChange]); + + const handleStart = (clientX: number, clientY: number) => { + setIsDragging(true); + updateColor(clientX, clientY); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + handleStart(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault(); + const touch = e.touches[0]; + handleStart(touch.clientX, touch.clientY); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + e.preventDefault(); + updateColor(e.clientX, e.clientY); + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (isDragging) { + e.preventDefault(); + const touch = e.touches[0]; + updateColor(touch.clientX, touch.clientY); + } + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleEnd); + window.addEventListener('touchmove', handleTouchMove, { passive: false }); + window.addEventListener('touchend', handleEnd); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleEnd); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleEnd); + }; + }, [isDragging, updateColor]); + + const l_val = hsl.l / 100; + const s_val = hsl.s / 100; + const v_hsv = l_val + s_val * Math.min(l_val, 1 - l_val); + const s_hsv = v_hsv === 0 ? 0 : 2 * (1 - l_val / v_hsv); + + const markerX = s_hsv * 100; + const markerY = (1 - v_hsv) * 100; + + return ( +
+
+
+ ); +}); + +SatLightPanel.displayName = 'SatLightPanel'; + +const HueSlider = memo(({ hsl, onChange }: PanelProps) => { + const sliderRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const hslRef = useRef(hsl); + useEffect(() => { hslRef.current = hsl; }, [hsl]); + + const updateHue = useCallback((clientX: number) => { + if (!sliderRef.current) return; + const rect = sliderRef.current.getBoundingClientRect(); + let x = (clientX - rect.left) / rect.width; + x = Math.max(0, Math.min(1, x)); + onChange({ ...hslRef.current, h: x * 360 }); + }, [onChange]); + + const handleStart = (clientX: number) => { + setIsDragging(true); + updateHue(clientX); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + handleStart(e.clientX); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault(); + const touch = e.touches[0]; + handleStart(touch.clientX); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + e.preventDefault(); + updateHue(e.clientX); + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (isDragging) { + e.preventDefault(); + const touch = e.touches[0]; + updateHue(touch.clientX); + } + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleEnd); + window.addEventListener('touchmove', handleTouchMove, { passive: false }); + window.addEventListener('touchend', handleEnd); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleEnd); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleEnd); + }; + }, [isDragging, updateHue]); + + return ( +
+
+
+ ); +}); + +HueSlider.displayName = 'HueSlider'; + +const ColorPicker: React.FC = ({ color, onChange }) => { + const [hsl, setHsl] = useState(parseHsl(color)); + const [hex, setHex] = useState(hslToHex(hsl.h, hsl.s, hsl.l)); + const isDraggingRef = useRef(false); + + useEffect(() => { + if (isDraggingRef.current) return; + const newHsl = parseHsl(color); + if (Math.abs(newHsl.h - hsl.h) > 0.1 || Math.abs(newHsl.s - hsl.s) > 0.1 || Math.abs(newHsl.l - hsl.l) > 0.1) { + setHsl(newHsl); + setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l)); + } + }, [color]); + + const handleHslChange = useCallback((newHsl: { h: number, s: number, l: number; }) => { + setHsl(newHsl); + setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l)); + onChange('hsl(' + Math.round(newHsl.h) + ', ' + Math.round(newHsl.s) + '%, ' + Math.round(newHsl.l) + '%)'); + }, [onChange]); + + const handleHexChange = (value: string) => { + setHex(value); + if (/^#[0-9A-Fa-f]{6}$/.test(value)) { + const newHsl = hexToHsl(value); + handleHslChange(newHsl); + } + }; + + return ( + + +
+
+
+ {hex} + HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%) +
+
+ + { isDraggingRef.current = true; }} + onMouseUpCapture={() => { isDraggingRef.current = false; }} + onTouchStartCapture={() => { isDraggingRef.current = true; }} + onTouchEndCapture={() => { isDraggingRef.current = false; }} + > +
+
+ 选择颜色 +
+
+ + + + +
+ HEX + handleHexChange(e.target.value)} + className='col-span-3 font-mono' + classNames={{ + input: 'text-xs uppercase', + inputWrapper: 'h-8 min-h-8', + }} + /> +
+ +
+ HSL +
+ handleHslChange({ ...hsl, h: Number(e.target.value) })} + endContent={H} + classNames={{ input: 'text-xs', inputWrapper: 'h-8 min-h-8 px-1' }} + /> + handleHslChange({ ...hsl, s: Number(e.target.value) })} + endContent={S} + classNames={{ input: 'text-xs', inputWrapper: 'h-8 min-h-8 px-1' }} + /> + handleHslChange({ ...hsl, l: Number(e.target.value) })} + endContent={L} + classNames={{ input: 'text-xs', inputWrapper: 'h-8 min-h-8 px-1' }} + /> +
+
+ +
+ {['#006FEE', '#17C964', '#F5A524', '#F31260', '#7828C8', '#000000', '#FFFFFF'].map((c) => ( +
+
+ + + ); +}; + +export default ColorPicker; diff --git a/packages/snowcat-webui-frontend/src/components/button/add_button.tsx b/packages/snowcat-webui-frontend/src/components/button/add_button.tsx new file mode 100644 index 0000000..db19f82 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/button/add_button.tsx @@ -0,0 +1,237 @@ +import { Button } from '@heroui/button'; +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, +} from '@heroui/dropdown'; +import { Tooltip } from '@heroui/tooltip'; +import { FaRegCircleQuestion } from 'react-icons/fa6'; +import { IoAddCircleOutline, IoServerOutline } from 'react-icons/io5'; + +import { + HTTPClientIcon, + HTTPServerIcon, + PCIcon, + PlusIcon, + WebsocketIcon, +} from '../icons'; + +export interface AddButtonProps { + onOpen: (key: keyof OneBotConfig['network']) => void; +} + +const AddButton: React.FC = (props) => { + const { onOpen } = props; + + return ( + + + + + { + onOpen(key as keyof OneBotConfig['network']); + }} + > + +
+
+ +
+
新建网络配置
+
+
+ + +
+ } + > +
+ HTTP服务器 + + + +
+ + + +
+ } + > +
+ HTTP SSE服务器 + + + +
+ + + +
+ } + > +
+ HTTP客户端 + + + +
+ + + +
+ } + > +
+ Websocket服务器 + + + +
+ + + +
+ } + > +
+ Websocket客户端 + + + +
+ + + + + } + > +
+ MCP 服务器 + + + +
+
+ + + ); +}; + +export default AddButton; diff --git a/packages/snowcat-webui-frontend/src/components/button/save_buttons.tsx b/packages/snowcat-webui-frontend/src/components/button/save_buttons.tsx new file mode 100644 index 0000000..5e9aa29 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/button/save_buttons.tsx @@ -0,0 +1,63 @@ +import { Button } from '@heroui/button'; +import clsx from 'clsx'; +import toast from 'react-hot-toast'; +import { IoMdRefresh } from 'react-icons/io'; + +export interface SaveButtonsProps { + onSubmit: () => void; + reset: () => void; + refresh?: () => void; + isSubmitting: boolean; + className?: string; +} + +const SaveButtons: React.FC = ({ + onSubmit, + reset, + isSubmitting, + refresh, + className, +}) => ( +
+
+ + + {refresh && ( + + )} +
+
+); + +export default SaveButtons; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/audio_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/audio_insert.tsx new file mode 100644 index 0000000..7f8058d --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/audio_insert.tsx @@ -0,0 +1,254 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { FaMicrophone } from 'react-icons/fa6'; +import { IoMic } from 'react-icons/io5'; +import { MdEdit, MdUpload } from 'react-icons/md'; + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +import { isURI } from '@/utils/url'; + +import type { OB11Segment } from '@/types/onebot'; + +const AudioInsert = () => { + const [audioUrl, setAudioUrl] = useState(''); + const audioInputRef = useRef(null); + const showStructuredMessage = useShowStructuredMessage(); + const showAudioSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'record', + data: { + file, + }, + }, + ]; + showStructuredMessage(messages); + }; + + const [isRecording, setIsRecording] = useState(false); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const [audioPreview, setAudioPreview] = useState(null); + const [showPreview, setShowPreview] = useState(false); + const streamRef = useRef(null); + const [recordingTime, setRecordingTime] = useState(0); + const recordingIntervalRef = useRef(null); + + useEffect(() => { + if (isRecording) { + navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { + streamRef.current = stream; + const recorder = new MediaRecorder(stream); + mediaRecorderRef.current = recorder; + recorder.start(); + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data); + } + }; + recorder.onstop = () => { + if (audioChunksRef.current.length > 0) { + const audioBlob = new Blob(audioChunksRef.current, { + type: 'audio/wav', + }); + const reader = new FileReader(); + reader.readAsDataURL(audioBlob); + reader.onloadend = () => { + const base64Audio = reader.result as string; + setAudioPreview(base64Audio); + setShowPreview(true); + }; + audioChunksRef.current = []; + } + stream.getTracks().forEach((track) => track.stop()); + }; + }); + recordingIntervalRef.current = setInterval(() => { + setRecordingTime((prevTime) => prevTime + 1); + }, 1000); + } else { + mediaRecorderRef.current?.stop(); + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + } + }, [isRecording]); + + const startRecording = () => { + setAudioPreview(null); + setShowPreview(false); + setRecordingTime(0); + setIsRecording(true); + }; + + const stopRecording = () => { + setIsRecording(false); + }; + + const handleShowPreview = () => { + if (audioPreview) { + showAudioSegment(audioPreview); + } + }; + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = time % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setAudioUrl(e.target.value)} + placeholder='请输入音频地址' + /> + + +
+ + +
+ + + +
+
+ +
+ + {showPreview && audioPreview && ( + + )} +
+ {(isRecording || audioPreview) && ( +
+ + 录制时长: {formatTime(recordingTime)} +
+ )} + {showPreview && audioPreview && ( +
+
+
+
+ + { + const file = e.target.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const dataURL = event.target?.result; + showAudioSegment(dataURL as string); + e.target.value = ''; + }; + }} + /> + + ); +}; + +export default AudioInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/dice_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/dice_insert.tsx new file mode 100644 index 0000000..57728d5 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/dice_insert.tsx @@ -0,0 +1,31 @@ +import { Button } from '@heroui/button'; +import { Tooltip } from '@heroui/tooltip'; +import { BsDice3Fill } from 'react-icons/bs'; + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +const DiceInsert = () => { + const showStructuredMessage = useShowStructuredMessage(); + + return ( + + + + ); +}; + +export default DiceInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/emoji_picker.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/emoji_picker.tsx new file mode 100644 index 0000000..c6a47db --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/emoji_picker.tsx @@ -0,0 +1,83 @@ +import { Button } from '@heroui/button'; +import { Image } from '@heroui/image'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import { data, getUrl } from 'qface'; +import { useEffect, useRef, useState } from 'react'; +import { MdEmojiEmotions } from 'react-icons/md'; + +import { EmojiValue } from '../formats/emoji_blot'; + +const emojis = data.map((item) => { + return { + alt: item.QDes, + src: getUrl(item.QSid), + id: item.QSid, + } as EmojiValue; +}); + +export interface EmojiPickerProps { + onInsertEmoji: (emoji: EmojiValue) => void + onOpenChange: (open: boolean) => void +} + +const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => { + const [visibleEmojis, setVisibleEmojis] = useState([]); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const containerRef = useRef(null); + useEffect(() => { + if (isPopoverOpen) { + setVisibleEmojis([]); // Reset visible emojis + requestAnimationFrame(() => loadEmojis()); // Start loading emojis + } + }, [isPopoverOpen]); + + const loadEmojis = (index = 0, batchSize = 10) => { + if (index < emojis.length) { + setVisibleEmojis((prev) => [ + ...prev, + ...emojis.slice(index, index + batchSize), + ]); + requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize)); + } + }; + return ( +
+ { + onOpenChange(v); + setIsPopoverOpen(v); + }} + > + +
+ + + +
+
+ + {visibleEmojis.map((emoji) => ( + + ))} + +
+
+ ); +}; + +export default EmojiPicker; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/file_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/file_insert.tsx new file mode 100644 index 0000000..d7d583e --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/file_insert.tsx @@ -0,0 +1,125 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import { useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { FaFolder } from 'react-icons/fa6'; +import { LuFilePlus2 } from 'react-icons/lu'; +import { MdEdit, MdUpload } from 'react-icons/md'; + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +import { isURI } from '@/utils/url'; + +import type { OB11Segment } from '@/types/onebot'; + +const FileInsert = () => { + const [fileUrl, setFileUrl] = useState(''); + const fileInputRef = useRef(null); + const showStructuredMessage = useShowStructuredMessage(); + const showFileSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'file', + data: { + file, + }, + }, + ]; + showStructuredMessage(messages); + }; + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setFileUrl(e.target.value)} + placeholder='请输入文件地址' + /> + + +
+
+
+ + { + const file = e.target.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const dataURL = event.target?.result; + showFileSegment(dataURL as string); + e.target.value = ''; + }; + }} + /> + + ); +}; + +export default FileInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/image_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/image_insert.tsx new file mode 100644 index 0000000..344cb94 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/image_insert.tsx @@ -0,0 +1,114 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import { useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md'; + +import { isURI } from '@/utils/url'; + +export interface ImageInsertProps { + insertImage: (url: string) => void + onOpenChange: (open: boolean) => void +} + +const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => { + const [imgUrl, setImgUrl] = useState(''); + const imageInputRef = useRef(null); + + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setImgUrl(e.target.value)} + placeholder='请输入图片地址' + /> + + +
+
+
+ + { + const file = e.target.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const dataURL = event.target?.result; + insertImage(dataURL as string); + e.target.value = ''; + }; + }} + /> + + ); +}; + +export default ImageInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/music_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/music_insert.tsx new file mode 100644 index 0000000..063c037 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/music_insert.tsx @@ -0,0 +1,258 @@ +import { Button } from '@heroui/button'; +import { Form } from '@heroui/form'; +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Select, SelectItem } from '@heroui/select'; +import type { SharedSelection } from '@heroui/system'; +import { Tab, Tabs } from '@heroui/tabs'; +import { Tooltip } from '@heroui/tooltip'; +import type { Key } from '@react-types/shared'; +import { useRef, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import { IoMusicalNotes } from 'react-icons/io5'; +import { TbMusicPlus } from 'react-icons/tb'; + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +import { isURI } from '@/utils/url'; + +import type { + CustomMusicSegment, + MusicSegment, + OB11Segment, +} from '@/types/onebot'; + +type MusicData = CustomMusicSegment['data'] | MusicSegment['data']; + +const MusicInsert = () => { + const [musicId, setMusicId] = useState(''); + const [musicType, setMusicType] = useState(new Set(['163'])); + const [mode, setMode] = useState('default'); + const containerRef = useRef(null); + const { control, handleSubmit, reset } = useForm< + Omit + >({ + defaultValues: { + url: '', + audio: '', + title: '', + image: '', + content: '', + }, + }); + const showStructuredMessage = useShowStructuredMessage(); + + const showMusicSegment = (data: MusicData) => { + const messages: OB11Segment[] = []; + if (data.type === 'custom') { + messages.push({ + type: 'music', + data: { + ...data, + type: 'custom', + }, + }); + } else { + messages.push({ + type: 'music', + data, + }); + } + showStructuredMessage(messages); + }; + + const onSubmit = (data: Omit) => { + showMusicSegment({ + type: 'custom', + ...data, + }); + reset(); + }; + + return ( +
+ + +
+ + + +
+
+ + { + if (key !== null) setMode(key); + }} + > + + + setMusicId(e.target.value)} + placeholder='请输入音乐ID' + label='音乐ID' + /> + + + +
+ ( + { + return !isURI(v) ? '请输入正确的音乐URL' : null; + }} + size='sm' + placeholder='请输入音乐URL' + label='音乐URL' + /> + )} + /> + ( + { + return !isURI(v) ? '请输入正确的音频URL' : null; + }} + size='sm' + placeholder='请输入音频URL' + label='音频URL' + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + +
+
+
+
+
+ ); +}; + +export default MusicInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/reply_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/reply_insert.tsx new file mode 100644 index 0000000..f45d9a5 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/reply_insert.tsx @@ -0,0 +1,58 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import { useState } from 'react'; +import { BsChatQuoteFill } from 'react-icons/bs'; +import { MdAdd } from 'react-icons/md'; + +export interface ReplyInsertProps { + insertReply: (messageId: string) => void +} + +const ReplyInsert = ({ insertReply }: ReplyInsertProps) => { + const [replyId, setReplyId] = useState(''); + + return ( + <> + + +
+ + + +
+
+ + { + const value = e.target.value; + const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/; + if (isNumberReg.test(value)) { + setReplyId(value); + } + }} + /> + + +
+ + ); +}; + +export default ReplyInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/rps_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/rps_insert.tsx new file mode 100644 index 0000000..e8c15c1 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/rps_insert.tsx @@ -0,0 +1,31 @@ +import { Button } from '@heroui/button'; +import { Tooltip } from '@heroui/tooltip'; +import { LiaHandScissors } from 'react-icons/lia'; + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +const RPSInsert = () => { + const showStructuredMessage = useShowStructuredMessage(); + + return ( + + + + ); +}; + +export default RPSInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/show_structed_message.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/show_structed_message.tsx new file mode 100644 index 0000000..1303296 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/show_structed_message.tsx @@ -0,0 +1,33 @@ +import { Snippet } from '@heroui/snippet'; + +import { OB11Segment } from '@/types/onebot'; + +export interface ShowStructedMessageProps { + messages: OB11Segment[] +} + +const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => { + return ( + + {JSON.stringify(messages, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + ); +}; + +export default ShowStructedMessage; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/components/video_insert.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/components/video_insert.tsx new file mode 100644 index 0000000..9d58fb9 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/components/video_insert.tsx @@ -0,0 +1,126 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import { useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { IoVideocam } from 'react-icons/io5'; +import { MdEdit, MdUpload } from 'react-icons/md'; +import { TbVideoPlus } from 'react-icons/tb'; + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +import { isURI } from '@/utils/url'; + +import type { OB11Segment } from '@/types/onebot'; + +const VideoInsert = () => { + const [videoUrl, setVideoUrl] = useState(''); + const videoInputRef = useRef(null); + const showStructuredMessage = useShowStructuredMessage(); + const showVideoSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'video', + data: { + file, + }, + }, + ]; + showStructuredMessage(messages); + }; + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setVideoUrl(e.target.value)} + placeholder='请输入视频地址' + /> + + +
+
+
+ + { + const file = e.target.files?.[0]; + if (!file) { + return; + } + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const dataURL = event.target?.result; + showVideoSegment(dataURL as string); + e.target.value = ''; + }; + }} + /> + + ); +}; + +export default VideoInsert; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/formats/emoji_blot.ts b/packages/snowcat-webui-frontend/src/components/chat_input/formats/emoji_blot.ts new file mode 100644 index 0000000..68e8f66 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/formats/emoji_blot.ts @@ -0,0 +1,41 @@ +import Quill from 'quill'; + +// eslint-disable-next-line +const Embed = Quill.import('blots/embed') as any +export interface EmojiValue { + alt: string + src: string + id: string +} +class EmojiBlot extends Embed { + static blotName: string = 'emoji'; + static tagName: string = 'img'; + static classNames: string[] = ['w-6', 'h-6']; + + static create (value: HTMLImageElement) { + const node = super.create(value); + node.setAttribute('alt', value.alt); + node.setAttribute('src', value.src); + node.setAttribute('data-id', value.id); + node.classList.add(...EmojiBlot.classNames); + return node; + } + + static formats (node: HTMLImageElement): EmojiValue { + return { + alt: node.getAttribute('alt') ?? '', + src: node.getAttribute('src') ?? '', + id: node.getAttribute('data-id') ?? '', + }; + } + + static value (node: HTMLImageElement): EmojiValue { + return { + alt: node.getAttribute('alt') ?? '', + src: node.getAttribute('src') ?? '', + id: node.getAttribute('data-id') ?? '', + }; + } +} + +export default EmojiBlot; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/formats/image_blot.ts b/packages/snowcat-webui-frontend/src/components/chat_input/formats/image_blot.ts new file mode 100644 index 0000000..f428e58 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/formats/image_blot.ts @@ -0,0 +1,30 @@ +import Quill from 'quill'; + +// eslint-disable-next-line +const Embed = Quill.import('blots/embed') as any +export interface ImageValue { + alt: string + src: string +} +class ImageBlot extends Embed { + static blotName = 'image'; + static tagName = 'img'; + static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom']; + + static create (value: ImageValue) { + const node = super.create(); + node.setAttribute('alt', value.alt); + node.setAttribute('src', value.src); + node.classList.add(...ImageBlot.classNames); + return node; + } + + static value (node: HTMLImageElement): ImageValue { + return { + alt: node.getAttribute('alt') ?? '', + src: node.getAttribute('src') ?? '', + }; + } +} + +export default ImageBlot; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/formats/reply_blot.ts b/packages/snowcat-webui-frontend/src/components/chat_input/formats/reply_blot.ts new file mode 100644 index 0000000..8740e11 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/formats/reply_blot.ts @@ -0,0 +1,43 @@ +import Quill from 'quill'; + +// eslint-disable-next-line +const BlockEmbed = Quill.import('blots/block/embed') as any +export interface ReplyBlockValue { + messageId: string +} +class ReplyBlock extends BlockEmbed { + static blotName = 'reply'; + static tagName = 'div'; + static classNames = [ + 'p-2', + 'select-none', + 'bg-default-100', + 'rounded-md', + 'pointer-events-none', + ]; + + static create (value: ReplyBlockValue) { + const node = super.create(); + node.setAttribute('data-message-id', value.messageId); + node.setAttribute('contenteditable', 'false'); + node.classList.add(...ReplyBlock.classNames); + const innerDom = document.createElement('div'); + innerDom.classList.add('text-sm', 'text-default-500', 'relative'); + const svgContainer = document.createElement('div'); + svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0'); + const svg = ' '; + svgContainer.innerHTML = svg; + innerDom.innerHTML = `消息ID:${value.messageId}`; + innerDom.appendChild(svgContainer); + node.appendChild(innerDom); + return node; + } + + static value (node: HTMLElement): ReplyBlockValue { + return { + messageId: node.getAttribute('data-message-id') || '', + }; + } +} + +export default ReplyBlock; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/index.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/index.tsx new file mode 100644 index 0000000..1de7e52 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/index.tsx @@ -0,0 +1,207 @@ +import { Button } from '@heroui/button'; +import type { Range } from 'quill'; +import 'quill/dist/quill.core.css'; +import { useRef } from 'react'; +import toast from 'react-hot-toast'; + +import { useCustomQuill } from '@/hooks/use_custom_quill'; +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'; + +import { quillToMessage } from '@/utils/onebot'; + +import type { OB11Segment } from '@/types/onebot'; + +import AudioInsert from './components/audio_insert'; +import DiceInsert from './components/dice_insert'; +import EmojiPicker from './components/emoji_picker'; +import FileInsert from './components/file_insert'; +import ImageInsert from './components/image_insert'; +import MusicInsert from './components/music_insert'; +import ReplyInsert from './components/reply_insert'; +import RPSInsert from './components/rps_insert'; +import VideoInsert from './components/video_insert'; +import EmojiBlot from './formats/emoji_blot'; +import type { EmojiValue } from './formats/emoji_blot'; +import ImageBlot from './formats/image_blot'; +import ReplyBlock from './formats/reply_blot'; + +const ChatInput = () => { + const memorizedRange = useRef(null); + + const showStructuredMessage = useShowStructuredMessage(); + const formats: string[] = ['image', 'emoji', 'reply']; + const modules = { + toolbar: '#toolbar', + }; + const { quillRef, quill, Quill } = useCustomQuill({ + modules, + formats, + placeholder: '请输入消息', + }); + + if (Quill && !quill) { + Quill.register('formats/emoji', EmojiBlot); + Quill.register('formats/image', ImageBlot, true); + Quill.register('formats/reply', ReplyBlock); + } + + if (quill) { + quill.on('selection-change', (range) => { + if (range) { + const editorContent = quill.getContents(); + const firstOp = editorContent.ops[0]; + + if ( + typeof firstOp?.insert !== 'string' && + firstOp?.insert?.reply && + range.index === 0 && + range.length !== quill.getLength() + ) { + quill.setSelection(1, Quill.sources.SILENT); + } + } + }); + + quill.on('text-change', () => { + const editorContent = quill.getContents(); + const firstOp = editorContent.ops[0]; + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply && + quill.getLength() === 1 + ) { + quill.insertText(1, '\n', Quill.sources.SILENT); + } + }); + + quill.on('editor-change', (eventName: string) => { + if (eventName === 'text-change') { + const editorContent = quill.getContents(); + const firstOp = editorContent.ops[0]; + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply && + quill.getLength() === 1 + ) { + quill.insertText(1, '\n', Quill.sources.SILENT); + } + } + }); + + quill.root.addEventListener('compositionstart', () => { + const editorContent = quill.getContents(); + const firstOp = editorContent.ops[0]; + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply && + quill.getLength() === 1 + ) { + quill.insertText(1, '\n', Quill.sources.SILENT); + } + }); + } + + const onOpenChange = (open: boolean) => { + if (open) { + const selection = quill?.getSelection(); + if (selection) memorizedRange.current = selection; + } + }; + + const insertImage = (url: string) => { + const selection = memorizedRange.current || quill?.getSelection(); + quill?.deleteText(selection?.index || 0, selection?.length || 0); + quill?.insertEmbed(selection?.index || 0, 'image', { + src: url, + alt: '图片', + }); + quill?.setSelection((selection?.index || 0) + 1, 0); + }; + function insertReplyBlock (messageId: string) { + const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/; + if (!isNumberReg.test(messageId)) { + toast.error('请输入正确的消息ID'); + return; + } + const editorContent = quill?.getContents(); + const firstOp = editorContent?.ops[0]; + const currentSelection = quill?.getSelection(); + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply + ) { + const delta = quill?.getContents(); + if (delta) { + delta.ops[0] = { + insert: { reply: { messageId } }, + }; + quill?.setContents(delta, Quill.sources.USER); + } + } else { + quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER); + } + quill?.setSelection((currentSelection?.index || 0) + 1, 0); + quill?.blur(); + } + const onInsertEmoji = (emoji: EmojiValue) => { + const selection = memorizedRange.current || quill?.getSelection(); + quill?.deleteText(selection?.index || 0, selection?.length || 0); + quill?.insertEmbed(selection?.index || 0, 'emoji', { + alt: emoji.alt, + src: emoji.src, + id: emoji.id, + }); + quill?.setSelection((selection?.index || 0) + 1, 0); + }; + + const getChatMessage = () => { + const delta = quill?.getContents(); + const ops = + delta?.ops?.filter((op) => { + return op.insert !== '\n'; + }) ?? []; + const messages: OB11Segment[] = ops.map((op) => { + return quillToMessage(op); + }); + return messages; + }; + + return ( +
+
+
+ + + + + + + + + + +
+
+ ); +}; + +export default ChatInput; diff --git a/packages/snowcat-webui-frontend/src/components/chat_input/modal.tsx b/packages/snowcat-webui-frontend/src/components/chat_input/modal.tsx new file mode 100644 index 0000000..10b6411 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/chat_input/modal.tsx @@ -0,0 +1,64 @@ +import { Button } from '@heroui/button'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + useDisclosure, +} from '@heroui/modal'; + +import ChatInput from '.'; + +interface ChatInputModalProps { + children?: (onOpen: () => void) => React.ReactNode; +} + +export default function ChatInputModal ({ children }: ChatInputModalProps) { + const { isOpen, onOpen, onOpenChange } = useDisclosure(); + + return ( + <> + {children + ? children(onOpen) + : ( + + )} + + + {(onClose) => ( + <> + + 构造消息 + + +
+ +
+
+ + + + + )} +
+
+ + ); +} diff --git a/packages/snowcat-webui-frontend/src/components/code_editor.tsx b/packages/snowcat-webui-frontend/src/components/code_editor.tsx new file mode 100644 index 0000000..c242897 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/code_editor.tsx @@ -0,0 +1,160 @@ +import React, { useImperativeHandle, useEffect, useState } from 'react'; +import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { json } from '@codemirror/lang-json'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { useTheme } from '@/hooks/use-theme'; +import { EditorView } from '@codemirror/view'; +import clsx from 'clsx'; + +const getLanguageExtension = (lang?: string) => { + switch (lang) { + case 'json': return json(); + default: return []; + } +}; + +export interface CodeEditorProps { + value?: string; + defaultValue?: string; + language?: string; + defaultLanguage?: string; + onChange?: (value: string | undefined) => void; + height?: string; + options?: any; + onMount?: any; +} + +export interface CodeEditorRef { + getValue: () => string; +} + +const CodeEditor = React.forwardRef((props, ref) => { + const { isDark } = useTheme(); + const chromeless = !!props.options?.chromeless; + + const [val, setVal] = useState(props.value || props.defaultValue || ''); + const internalRef = React.useRef(null); + + useEffect(() => { + if (props.value !== undefined) { + setVal(props.value); + } + }, [props.value]); + + useImperativeHandle(ref, () => ({ + getValue: () => { + // Prefer getting dynamic value from view, fallback to state + return internalRef.current?.view?.state.doc.toString() || val; + }, + })); + + const customTheme = EditorView.theme({ + '&': { + fontSize: '14px', + height: '100% !important', + backgroundColor: 'transparent !important', + }, + '&.cm-editor': { + backgroundColor: 'transparent !important', + }, + '.cm-scroller': { + fontFamily: "var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace)", + lineHeight: '1.6', + overflow: 'auto !important', + height: '100% !important', + backgroundColor: 'transparent !important', + }, + '.cm-gutters': { + backgroundColor: 'transparent !important', + borderRight: 'none', + color: isDark + ? 'hsl(var(--heroui-foreground-500) / 0.75)' + : 'hsl(var(--heroui-foreground-500) / 0.65)', + }, + '.cm-gutterElement': { + paddingLeft: '12px', + paddingRight: '12px', + }, + '.cm-activeLineGutter': { + backgroundColor: 'transparent !important', + color: isDark + ? 'hsl(var(--heroui-foreground) / 0.9) !important' + : 'hsl(var(--heroui-foreground) / 0.8) !important', + }, + '.cm-content': { + color: 'hsl(var(--heroui-foreground) / 0.9)', + caretColor: 'hsl(var(--heroui-foreground) / 0.9)', + paddingTop: '12px', + paddingBottom: '12px', + backgroundColor: 'transparent !important', + }, + '.cm-activeLine': { + backgroundColor: isDark + ? 'hsl(var(--heroui-foreground) / 0.08)' + : 'hsl(var(--heroui-foreground) / 0.06)', + }, + '.cm-selectionMatch': { + backgroundColor: isDark + ? 'hsl(var(--heroui-foreground) / 0.16)' + : 'hsl(var(--heroui-foreground) / 0.12)', + }, + // Syntax highlighting overrides for better readability + '.ͼo': { + // JSON property names - use a softer primary color + color: isDark + ? 'hsl(var(--heroui-primary) / 0.85)' + : 'hsl(var(--heroui-primary) / 0.75)', + }, + '.ͼd': { + // Strings - softer green + color: isDark ? '#98c379cc' : '#50a14fcc', + }, + '.ͼc': { + // Numbers - softer orange + color: isDark ? '#d19a66cc' : '#c18401cc', + }, + }); + + const extensions = [ + customTheme, + getLanguageExtension(props.language || props.defaultLanguage), + props.options?.wordWrap === 'on' ? EditorView.lineWrapping : [], + props.options?.readOnly ? EditorView.editable.of(false) : [], + ].flat(); + + return ( +
+ { + setVal(value); + props.onChange?.(value); + }} + readOnly={props.options?.readOnly} + basicSetup={{ + lineNumbers: props.options?.lineNumbers !== 'off', + foldGutter: props.options?.folding !== false, + highlightActiveLine: props.options?.renderLineHighlight !== 'none', + }} + /> +
+ ); +}); + +export default CodeEditor; diff --git a/packages/snowcat-webui-frontend/src/components/command_palette.tsx b/packages/snowcat-webui-frontend/src/components/command_palette.tsx new file mode 100644 index 0000000..7d55a87 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/command_palette.tsx @@ -0,0 +1,240 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@heroui/modal'; +import clsx from 'clsx'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { TbCornerDownLeft, TbSearch } from 'react-icons/tb'; + +export type CommandPaletteCommand = { + id: string; + title: string; + subtitle?: string; + group?: string; +}; + +export type CommandPaletteExecuteMode = 'open' | 'send'; + +export interface CommandPaletteProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + commands: CommandPaletteCommand[]; + onExecute: (commandId: string, mode: CommandPaletteExecuteMode) => void; +} + +const isMobileByViewport = () => { + try { + return window.innerWidth < 768; + } catch { + return false; + } +}; + +export default function CommandPalette (props: CommandPaletteProps) { + const { isOpen, onOpenChange, commands, onExecute } = props; + const inputRef = useRef(null); + + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [mobile, setMobile] = useState(false); + + useEffect(() => { + const update = () => setMobile(isMobileByViewport()); + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + useEffect(() => { + if (!isOpen) return; + setQuery(''); + setActiveIndex(0); + // 等 Modal 动画挂载后再 focus + const t = window.setTimeout(() => inputRef.current?.focus(), 50); + return () => window.clearTimeout(t); + }, [isOpen]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const list = !q + ? commands + : commands.filter((c) => { + const hay = `${c.id} ${c.title} ${c.subtitle ?? ''} ${c.group ?? ''}`.toLowerCase(); + return hay.includes(q); + }); + + // 简单:优先 path 前缀命中 + if (!q) return list; + const starts = list.filter((c) => c.id.toLowerCase().startsWith(q)); + const rest = list.filter((c) => !c.id.toLowerCase().startsWith(q)); + return [...starts, ...rest]; + }, [commands, query]); + + useEffect(() => { + if (activeIndex >= filtered.length) setActiveIndex(0); + }, [filtered.length, activeIndex]); + + const active = filtered[activeIndex]; + + const exec = (mode: CommandPaletteExecuteMode) => { + if (!active) return; + onExecute(active.id, mode); + onOpenChange(false); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, Math.max(0, filtered.length - 1))); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + // Shift+Enter 仅打开;Enter 打开并发送 + exec(e.shiftKey ? 'open' : 'send'); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + onOpenChange(false); + } + }; + + return ( + + + {() => ( + <> + + 命令面板 + + Ctrl / ⌘ + K + + + + } + radius='lg' + variant='flat' + classNames={{ + inputWrapper: 'bg-white/80 dark:bg-white/[0.06] border border-white/70 dark:border-white/10', + input: 'text-sm', + }} + /> + +
+
+ {filtered.length === 0 && ( +
+

没有匹配的接口

+

试试更短的关键词,或直接输入接口路径

+
+ )} + {filtered.map((c, idx) => ( + + ))} +
+
+
+ {mobile && ( + + + + + + )} + + )} +
+
+ ); +} diff --git a/packages/snowcat-webui-frontend/src/components/display_card/common_card.tsx b/packages/snowcat-webui-frontend/src/components/display_card/common_card.tsx new file mode 100644 index 0000000..d034d28 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/common_card.tsx @@ -0,0 +1,208 @@ +import { Button } from '@heroui/button'; +import { Switch } from '@heroui/switch'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { CgDebug } from 'react-icons/cg'; +import { FiEdit3 } from 'react-icons/fi'; +import { MdDeleteForever } from 'react-icons/md'; + +import DisplayCardContainer from './container'; + +type NetworkType = OneBotConfig['network']; + +export type NetworkDisplayCardFields = Array<{ + label: string; + value: NetworkType[T][0][keyof NetworkType[T][0]]; + render?: ( + value: NetworkType[T][0][keyof NetworkType[T][0]] + ) => React.ReactNode; +}>; + +export interface NetworkDisplayCardProps { + data: NetworkType[T][0]; + typeLabel: string; + fields: NetworkDisplayCardFields; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; + showType?: boolean; +} + +const NetworkDisplayCard = ({ + data, + typeLabel, + fields, + onEdit, + onEnable, + onDelete, + onEnableDebug, + showType, +}: NetworkDisplayCardProps) => { + const { name, enable, debug } = data; + const [editing, setEditing] = useState(false); + + const handleEnable = () => { + setEditing(true); + onEnable().finally(() => setEditing(false)); + }; + + const handleDelete = () => { + setEditing(true); + onDelete().finally(() => setEditing(false)); + }; + + const handleEnableDebug = () => { + setEditing(true); + onEnableDebug().finally(() => setEditing(false)); + }; + + const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label); + + return ( + + + + + +
+ } + enableSwitch={ + + } + title={name} + > +
+ {(() => { + const targetFullField = fields.find(f => isFullWidthField(f.label)); + + if (targetFullField) { + // 模式1:存在全宽字段(如URL),布局为: + // Row 1: 类型 (全宽) + // Row 2: 全宽字段 (全宽) + return ( + <> +
+ 类型 +
+ {typeLabel} +
+
+
+ {targetFullField.label} +
+ {targetFullField.render + ? targetFullField.render(targetFullField.value) + : ( + + {String(targetFullField.value)} + + )} +
+
+ + ); + } else { + // 模式2:无全宽字段,布局为 4 个小块 (2行 x 2列) + // Row 1: 类型 | Field 0 + // Row 2: Field 1 | Field 2 + const displayFields = fields.slice(0, 3); + return ( + <> +
+ 类型 +
+ {typeLabel} +
+
+ {displayFields.map((field, index) => ( +
+ {field.label} +
+ {field.render + ? ( + field.render(field.value) + ) + : ( + + {String(field.value)} + + )} +
+
+ ))} + {/* 如果字段不足3个,可以补充空白块占位吗?或者是让它空着?用户说要高度一致。只要是grid,通常高度会被撑开。目前这样应该能保证最多2行。 */} + + ); + } + })()} +
+ + ); +}; + +export default NetworkDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/container.tsx b/packages/snowcat-webui-frontend/src/components/display_card/container.tsx new file mode 100644 index 0000000..625f762 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/container.tsx @@ -0,0 +1,68 @@ +import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import clsx from 'clsx'; +import key from '@/const/key'; + +export interface ContainerProps { + title: React.ReactNode; + tag?: React.ReactNode; + action: React.ReactNode; + enableSwitch: React.ReactNode; + children: React.ReactNode; + className?: string; // Add className prop +} + +export interface DisplayCardProps { + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const DisplayCardContainer: React.FC = ({ + title: _title, + action, + enableSwitch, + children, + className, +}) => { + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + return ( + + +
+
+ + + {_title} + +
+
+
{enableSwitch}
+
+ {children} + {action} +
+ ); +}; + +export default DisplayCardContainer; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/http_client.tsx b/packages/snowcat-webui-frontend/src/components/display_card/http_client.tsx new file mode 100644 index 0000000..b9f3ce2 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/http_client.tsx @@ -0,0 +1,47 @@ +import { Chip } from '@heroui/chip'; + +import NetworkDisplayCard from './common_card'; +import type { NetworkDisplayCardFields } from './common_card'; + +interface HTTPClientDisplayCardProps { + data: OneBotConfig['network']['httpClients'][0]; + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const HTTPClientDisplayCard: React.FC = (props) => { + const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; + const { url, reportSelfMessage, messagePostFormat } = data; + + const fields: NetworkDisplayCardFields<'httpClients'> = [ + { label: 'URL', value: url }, + { label: '消息格式', value: messagePostFormat }, + { + label: '上报自身消息', + value: reportSelfMessage, + render: (value) => ( + + {value ? '是' : '否'} + + ), + }, + ]; + + return ( + + ); +}; + +export default HTTPClientDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/http_server.tsx b/packages/snowcat-webui-frontend/src/components/display_card/http_server.tsx new file mode 100644 index 0000000..1ba5531 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/http_server.tsx @@ -0,0 +1,57 @@ +import { Chip } from '@heroui/chip'; + +import NetworkDisplayCard from './common_card'; +import type { NetworkDisplayCardFields } from './common_card'; + +interface HTTPServerDisplayCardProps { + data: OneBotConfig['network']['httpServers'][0]; + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const HTTPServerDisplayCard: React.FC = (props) => { + const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; + const { host, port, enableCors, enableWebsocket, messagePostFormat } = data; + + const fields: NetworkDisplayCardFields<'httpServers'> = [ + { label: '主机', value: host }, + { label: '端口', value: port }, + { label: '消息格式', value: messagePostFormat }, + { + label: 'CORS', + value: enableCors, + render: (value) => ( + + {value ? '已启用' : '未启用'} + + ), + }, + { + label: 'WS', + value: enableWebsocket, + render: (value) => ( + + {value ? '已启用' : '未启用'} + + ), + }, + ]; + + return ( + + ); +}; + +export default HTTPServerDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/http_sse_server.tsx b/packages/snowcat-webui-frontend/src/components/display_card/http_sse_server.tsx new file mode 100644 index 0000000..38e3e6b --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/http_sse_server.tsx @@ -0,0 +1,59 @@ +import { Chip } from '@heroui/chip'; + +import NetworkDisplayCard from './common_card'; +import type { NetworkDisplayCardFields } from './common_card'; + +interface HTTPSSEServerDisplayCardProps { + data: OneBotConfig['network']['httpSseServers'][0]; + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const HTTPSSEServerDisplayCard: React.FC = ( + props +) => { + const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; + const { host, port, enableCors, enableWebsocket, messagePostFormat } = data; + + const fields: NetworkDisplayCardFields<'httpServers'> = [ + { label: '主机', value: host }, + { label: '端口', value: port }, + { label: '消息格式', value: messagePostFormat }, + { + label: 'CORS', + value: enableCors, + render: (value) => ( + + {value ? '已启用' : '未启用'} + + ), + }, + { + label: 'WS', + value: enableWebsocket, + render: (value) => ( + + {value ? '已启用' : '未启用'} + + ), + }, + ]; + + return ( + + ); +}; + +export default HTTPSSEServerDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/mcp_server.tsx b/packages/snowcat-webui-frontend/src/components/display_card/mcp_server.tsx new file mode 100644 index 0000000..e33d9c9 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/mcp_server.tsx @@ -0,0 +1,50 @@ +import { Chip } from '@heroui/chip'; + +import NetworkDisplayCard from './common_card'; +import type { NetworkDisplayCardFields } from './common_card'; + +interface McpServerDisplayCardProps { + data: OneBotConfig['network']['mcpServers'][0]; + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const McpServerDisplayCard: React.FC = ( + props +) => { + const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; + const { host, port, token } = data; + + const fields: NetworkDisplayCardFields<'mcpServers'> = [ + { label: '主机', value: host }, + { label: '端口', value: port }, + { label: '端点', value: `/mcp` }, + { + label: '鉴权', + value: !!token, + render: (value) => ( + + {value ? 'Bearer Token' : '未鉴权'} + + ), + }, + ]; + + return ( + + ); +}; + +export default McpServerDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/plugin_card.tsx b/packages/snowcat-webui-frontend/src/components/display_card/plugin_card.tsx new file mode 100644 index 0000000..3bc6cb7 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/plugin_card.tsx @@ -0,0 +1,194 @@ +import { Avatar } from '@heroui/avatar'; +import { Button } from '@heroui/button'; +import { Card, CardBody, CardFooter } from '@heroui/card'; +import { Chip } from '@heroui/chip'; +import { Switch } from '@heroui/switch'; +import { Tooltip } from '@heroui/tooltip'; +import { MdDeleteForever, MdSettings } from 'react-icons/md'; +import clsx from 'clsx'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import { useState } from 'react'; + +import key from '@/const/key'; +import { PluginItem } from '@/controllers/plugin_manager'; + +function getPluginIconUrl (iconPath?: string): string | undefined { + if (!iconPath) return undefined; + try { + const raw = localStorage.getItem(key.token); + if (!raw) return iconPath; + const token = JSON.parse(raw); + const url = new URL(iconPath, window.location.origin); + url.searchParams.set('webui_token', token); + return url.pathname + url.search; + } catch { + return iconPath; + } +} + +export interface PluginDisplayCardProps { + data: PluginItem; + onToggleStatus: () => Promise; + onUninstall: () => Promise; + onConfig?: () => void; + hasConfig?: boolean; +} + +const PluginDisplayCard: React.FC = ({ + data, + onToggleStatus, + onUninstall, + onConfig, + hasConfig = false, +}) => { + const { name, version, author, description, status, icon } = data; + const isEnabled = status === 'active'; + const [processing, setProcessing] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + // 后端已处理 icon,前端只需拼接 token;无 icon 时兜底 Vercel 风格头像 + const avatarUrl = getPluginIconUrl(icon) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; + + const handleToggle = () => { + setProcessing(true); + onToggleStatus().finally(() => setProcessing(false)); + }; + + const handleUninstall = () => { + setProcessing(true); + onUninstall().finally(() => setProcessing(false)); + }; + + return ( + + + {/* Header */} +
+
+ +
+

+ {name} +

+

+ by {author || '未知'} +

+
+
+ + + {status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'} + +
+ + {/* Description */} +
setIsExpanded(!isExpanded)} + > + +

+ {description || '暂无描述'} +

+
+
+ + {/* Version Badge */} +
+ + v{version} + +
+
+ + + + + {isEnabled ? '已启用' : '已禁用'} + + + +
+ + {hasConfig && ( + + + + )} + + + + + + + ); +}; + +export default PluginDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/plugin_store_card.tsx b/packages/snowcat-webui-frontend/src/components/display_card/plugin_store_card.tsx new file mode 100644 index 0000000..d27a199 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/plugin_store_card.tsx @@ -0,0 +1,292 @@ +/* eslint-disable @stylistic/indent */ +import { Avatar } from '@heroui/avatar'; +import { Button } from '@heroui/button'; +import { Card, CardBody, CardFooter } from '@heroui/card'; +import { Chip } from '@heroui/chip'; +import { Tooltip } from '@heroui/tooltip'; +import { IoMdCheckmarkCircle } from 'react-icons/io'; +import { MdUpdate, MdOutlineGetApp } from 'react-icons/md'; +import clsx from 'clsx'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import { useState } from 'react'; + +import key from '@/const/key'; +import { PluginStoreItem } from '@/types/plugin-store'; + +export type InstallStatus = 'not-installed' | 'installed' | 'update-available'; + +/** 提取作者头像 URL */ +function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined { + // 1. 尝试从 downloadUrl 提取 GitHub 用户名 (通常是最准确的源码仓库所有者) + if (downloadUrl) { + try { + const url = new URL(downloadUrl); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + return `https://github.com/${parts[0]}.png`; + } + } + } catch { + // 忽略解析错误 + } + } + + // 2. 尝试从 homepage 提取 + if (homepage) { + try { + const url = new URL(homepage); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + return `https://github.com/${parts[0]}.png`; + } + } else { + // 如果是自定义域名,尝试获取 favicon。使用主流的镜像服务以保证国内访问速度 + return `https://api.iowen.cn/favicon/${url.hostname}.png`; + } + } catch { + // 忽略解析错误 + } + } + return undefined; +} + +export interface PluginStoreCardProps { + data: PluginStoreItem; + onInstall: () => void; + onViewDetail?: () => void; + installStatus?: InstallStatus; + installedVersion?: string; +} + +const PluginStoreCard: React.FC = ({ + data, + onInstall, + onViewDetail, + installStatus = 'not-installed', + installedVersion, +}) => { + const { name, version, author, description, tags, homepage, downloadUrl } = data; + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const [isExpanded, setIsExpanded] = useState(false); + const hasBackground = !!backgroundImage; + + // 综合尝试提取头像,最后兜底使用 Vercel 风格头像 + const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; + + // 作者链接组件 + const AuthorComponent = ( + + {author || '未知作者'} + + ); + + return ( + + + {/* Header: Avatar + Name + Author */} +
+ +
+ + + {/* 可点击的作者名称 */} +
+ by {homepage + ? ( + e.stopPropagation()} + > + {AuthorComponent} + + ) + : AuthorComponent} +
+
+
+ + {/* Description */} +
setIsExpanded(!isExpanded)} + > + +

+ {description || '暂无描述'} +

+
+
+ + {/* Tags & Version */} +
+ + v{version} + + + {/* Tags with proper truncation and hover */} + {tags?.slice(0, 2).map((tag) => ( + + {tag} + + ))} + + {tags && tags.length > 2 && ( + + {tags.map(t => ( + + {t} + + ))} +
+ } + delay={0} + closeDelay={0} + > + + +{tags.length - 2} + + + )} + + {installStatus === 'update-available' && installedVersion && ( + + 新版本 + + )} +
+ + + e.stopPropagation()} + > + {installStatus === 'installed' + ? ( + + ) + : installStatus === 'update-available' + ? ( + + ) + : ( + + )} + + + ); +}; + +export default PluginStoreCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/ws_client.tsx b/packages/snowcat-webui-frontend/src/components/display_card/ws_client.tsx new file mode 100644 index 0000000..b69c57e --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/ws_client.tsx @@ -0,0 +1,57 @@ +import { Chip } from '@heroui/chip'; + +import NetworkDisplayCard from './common_card'; +import type { NetworkDisplayCardFields } from './common_card'; + +interface WebsocketClientDisplayCardProps { + data: OneBotConfig['network']['websocketClients'][0]; + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const WebsocketClientDisplayCard: React.FC = ( + props +) => { + const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; + const { + url, + heartInterval, + reconnectInterval, + messagePostFormat, + reportSelfMessage, + } = data; + + const fields: NetworkDisplayCardFields<'websocketClients'> = [ + { label: 'URL', value: url }, + { label: '重连间隔', value: `${reconnectInterval}ms` }, + { label: '心跳间隔', value: `${heartInterval}ms` }, + { label: '消息格式', value: messagePostFormat }, + { + label: '上报自身消息', + value: reportSelfMessage, + render: (value) => ( + + {value ? '是' : '否'} + + ), + }, + ]; + + return ( + + ); +}; + +export default WebsocketClientDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_card/ws_server.tsx b/packages/snowcat-webui-frontend/src/components/display_card/ws_server.tsx new file mode 100644 index 0000000..4c345de --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_card/ws_server.tsx @@ -0,0 +1,67 @@ +import { Chip } from '@heroui/chip'; + +import NetworkDisplayCard from './common_card'; +import type { NetworkDisplayCardFields } from './common_card'; + +interface WebsocketServerDisplayCardProps { + data: OneBotConfig['network']['websocketServers'][0]; + showType?: boolean; + onEdit: () => void; + onEnable: () => Promise; + onDelete: () => Promise; + onEnableDebug: () => Promise; +} + +const WebsocketServerDisplayCard: React.FC = ( + props +) => { + const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; + const { + host, + port, + heartInterval, + messagePostFormat, + reportSelfMessage, + enableForcePushEvent, + } = data; + + const fields: NetworkDisplayCardFields<'websocketServers'> = [ + { label: '主机', value: host }, + { label: '端口', value: port }, + { label: '心跳间隔', value: `${heartInterval}ms` }, + { label: '消息格式', value: messagePostFormat }, + { + label: '上报自身消息', + value: reportSelfMessage, + render: (value) => ( + + {value ? '是' : '否'} + + ), + }, + { + label: '强制推送事件', + value: enableForcePushEvent, + render: (value) => ( + + {value ? '是' : '否'} + + ), + }, + ]; + + return ( + + ); +}; + +export default WebsocketServerDisplayCard; diff --git a/packages/snowcat-webui-frontend/src/components/display_network_item.tsx b/packages/snowcat-webui-frontend/src/components/display_network_item.tsx new file mode 100644 index 0000000..7da8db3 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/display_network_item.tsx @@ -0,0 +1,65 @@ +import { Card, CardBody } from '@heroui/card'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import clsx from 'clsx'; +import key from '@/const/key'; + +export interface NetworkItemDisplayProps { + count: number; + label: string; + size?: 'sm' | 'md'; + /** 强调卡:用于总数统计 */ + accent?: boolean; +} + +const NetworkItemDisplay: React.FC = ({ + count, + label, + size = 'md', + accent = false, +}) => { + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + return ( + + +
+ {count} +
+
+ {label} +
+
+
+ ); +}; + +export default NetworkItemDisplay; diff --git a/packages/snowcat-webui-frontend/src/components/effect_card.tsx b/packages/snowcat-webui-frontend/src/components/effect_card.tsx new file mode 100644 index 0000000..84a12c6 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/effect_card.tsx @@ -0,0 +1,109 @@ +import { Card, CardProps } from '@heroui/card'; +import clsx from 'clsx'; +import React from 'react'; + +export interface HoverEffectCardProps extends CardProps { + children: React.ReactNode + maxXRotation?: number + maxYRotation?: number + lightClassName?: string + lightStyle?: React.CSSProperties +} + +const HoverEffectCard: React.FC = (props) => { + const { + children, + maxXRotation = 5, + maxYRotation = 5, + className, + style, + lightClassName, + lightStyle, + } = props; + const cardRef = React.useRef(null); + const lightRef = React.useRef(null); + const [isShowLight, setIsShowLight] = React.useState(false); + const [pos, setPos] = React.useState({ + left: 0, + top: 0, + }); + + return ( + { + if (cardRef.current) { + cardRef.current.style.transition = 'transform 0.3s ease-out'; + } + }} + onMouseLeave={() => { + setIsShowLight(false); + if (cardRef.current) { + cardRef.current.style.transition = 'transform 0.5s'; + cardRef.current.style.transform = + 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'; + } + }} + onMouseMove={(e: React.MouseEvent) => { + if (cardRef.current) { + setIsShowLight(true); + + const { x, y } = cardRef.current.getBoundingClientRect(); + const { clientX, clientY } = e; + + const offsetX = clientX - x; + const offsetY = clientY - y; + + const lightWidth = lightStyle?.width?.toString() || '100'; + const lightHeight = lightStyle?.height?.toString() || '100'; + const lightWidthNum = parseInt(lightWidth); + const lightHeightNum = parseInt(lightHeight); + + const left = offsetX - lightWidthNum / 2; + const top = offsetY - lightHeightNum / 2; + + setPos({ + left, + top, + }); + + cardRef.current.style.transition = 'transform 0.1s'; + + const rangeX = 400 / 2; + const rangeY = 400 / 2; + + const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation; + const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation; + + cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + } + }} + > +
+ {children} + + ); +}; + +export default HoverEffectCard; diff --git a/packages/snowcat-webui-frontend/src/components/empty_state.tsx b/packages/snowcat-webui-frontend/src/components/empty_state.tsx new file mode 100644 index 0000000..a9156f2 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/empty_state.tsx @@ -0,0 +1,61 @@ +import clsx from 'clsx'; + +export interface EmptyStateProps { + icon: React.ReactNode; + title: string; + description?: string; + action?: React.ReactNode; + /** 紧凑模式:用于卡片内部的空区块 */ + compact?: boolean; + className?: string; +} + +/** + * 统一空状态:图标底座 + 标题 + 说明 + 可选操作。 + * 用虚线容器包裹,避免大片留白显得"页面坏了"。 + */ +const EmptyState: React.FC = ({ + icon, + title, + description, + action, + compact = false, + className, +}) => { + return ( +
+
+ {icon} +
+

+ {title} +

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ); +}; + +export default EmptyState; diff --git a/packages/snowcat-webui-frontend/src/components/error_fallback.tsx b/packages/snowcat-webui-frontend/src/components/error_fallback.tsx new file mode 100644 index 0000000..f65e0f2 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/error_fallback.tsx @@ -0,0 +1,30 @@ +import { Button } from '@heroui/button'; +import { Code } from '@heroui/code'; +import { MdError } from 'react-icons/md'; + +export interface ErrorFallbackProps { + error: Error + resetErrorBoundary: () => void +} +function errorFallbackRender ({ + error, + resetErrorBoundary, +}: ErrorFallbackProps) { + return ( +
+
+ +

出错了

+
+
+

错误信息

+ {error.message} +
+ +
+ ); +} + +export default errorFallbackRender; diff --git a/packages/snowcat-webui-frontend/src/components/file_icon.tsx b/packages/snowcat-webui-frontend/src/components/file_icon.tsx new file mode 100644 index 0000000..c955d92 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/file_icon.tsx @@ -0,0 +1,166 @@ +import { + FaFile, + FaFileAudio, + FaFileCode, + FaFileCsv, + FaFileExcel, + FaFileImage, + FaFileLines, + FaFilePdf, + FaFilePowerpoint, + FaFileVideo, + FaFileWord, + FaFileZipper, + FaFolderClosed, +} from 'react-icons/fa6'; + +export interface FileIconProps { + name?: string + isDirectory?: boolean +} + +const FileIcon = (props: FileIconProps) => { + const { name, isDirectory = false } = props; + if (isDirectory) { + return ; + } + + const ext = name?.split('.').pop() || ''; + if (ext) { + switch (ext.toLowerCase()) { + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'svg': + case 'bmp': + case 'ico': + case 'webp': + case 'tiff': + case 'tif': + case 'heic': + case 'heif': + case 'avif': + case 'apng': + case 'flif': + case 'ai': + case 'psd': + case 'xcf': + case 'sketch': + case 'fig': + case 'xd': + case 'svgz': + return ; + case 'pdf': + return ; + case 'doc': + case 'docx': + return ; + case 'xls': + case 'xlsx': + return ; + case 'csv': + return ; + case 'ppt': + case 'pptx': + return ; + case 'zip': + case 'rar': + case '7z': + case 'tar': + case 'gz': + case 'bz2': + case 'xz': + case 'lz': + case 'lzma': + case 'zst': + case 'zstd': + case 'z': + case 'taz': + case 'tz': + case 'tzo': + return ; + case 'txt': + return ; + case 'mp3': + case 'wav': + case 'flac': + return ; + case 'mp4': + case 'avi': + case 'mov': + case 'wmv': + return ; + case 'html': + case 'css': + case 'js': + case 'ts': + case 'jsx': + case 'tsx': + case 'json': + case 'xml': + case 'yaml': + case 'yml': + case 'md': + case 'sh': + case 'py': + case 'java': + case 'c': + case 'cpp': + case 'cs': + case 'go': + case 'php': + case 'rb': + case 'pl': + case 'swift': + case 'kt': + case 'rs': + case 'sql': + case 'r': + case 'scala': + case 'groovy': + case 'dart': + case 'lua': + case 'perl': + case 'h': + case 'm': + case 'mm': + case 'makefile': + case 'cmake': + case 'dockerfile': + case 'gradle': + case 'properties': + case 'ini': + case 'conf': + case 'env': + case 'bat': + case 'cmd': + case 'ps1': + case 'psm1': + case 'psd1': + case 'ps1xml': + case 'psc1': + case 'pssc': + case 'nuspec': + case 'resx': + case 'resw': + case 'csproj': + case 'vbproj': + case 'vcxproj': + case 'fsproj': + case 'sln': + case 'suo': + case 'user': + case 'userosscache': + case 'sln.docstates': + case 'dll': + return ; + default: + return ; + } + } + + return ; +}; + +export default FileIcon; diff --git a/packages/snowcat-webui-frontend/src/components/file_manage/create_file_modal.tsx b/packages/snowcat-webui-frontend/src/components/file_manage/create_file_modal.tsx new file mode 100644 index 0000000..89c049c --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/file_manage/create_file_modal.tsx @@ -0,0 +1,64 @@ +import { Button, ButtonGroup } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@heroui/modal'; + +interface CreateFileModalProps { + isOpen: boolean; + fileType: 'file' | 'directory'; + newFileName: string; + onTypeChange: (type: 'file' | 'directory') => void; + onNameChange: (e: React.ChangeEvent) => void; + onClose: () => void; + onCreate: () => void; +} + +export default function CreateFileModal ({ + isOpen, + fileType, + newFileName, + onTypeChange, + onNameChange, + onClose, + onCreate, +}: CreateFileModalProps) { + return ( + + + 新建 + +
+ + + + + +
+
+ + + + +
+
+ ); +} diff --git a/packages/snowcat-webui-frontend/src/components/file_manage/file_edit_modal.tsx b/packages/snowcat-webui-frontend/src/components/file_manage/file_edit_modal.tsx new file mode 100644 index 0000000..0bca7cd --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/file_manage/file_edit_modal.tsx @@ -0,0 +1,104 @@ +import { Button } from '@heroui/button'; +import { Code } from '@heroui/code'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@heroui/modal'; + +import CodeEditor from '@/components/code_editor'; + +interface FileEditModalProps { + isOpen: boolean; + file: { path: string; content: string; } | null; + onClose: () => void; + onSave: () => void; + onContentChange: (newContent?: string) => void; +} + +export default function FileEditModal ({ + isOpen, + file, + onClose, + onSave, + onContentChange, +}: FileEditModalProps) { + // 根据文件后缀返回对应语言 + const getLanguage = (filePath: string) => { + if (filePath.endsWith('.js')) return 'javascript'; + if (filePath.endsWith('.ts')) return 'typescript'; + if (filePath.endsWith('.tsx')) return 'tsx'; + if (filePath.endsWith('.jsx')) return 'jsx'; + if (filePath.endsWith('.vue')) return 'vue'; + if (filePath.endsWith('.svelte')) return 'svelte'; + if (filePath.endsWith('.json')) return 'json'; + if (filePath.endsWith('.html')) return 'html'; + if (filePath.endsWith('.css')) return 'css'; + if (filePath.endsWith('.scss')) return 'scss'; + if (filePath.endsWith('.less')) return 'less'; + if (filePath.endsWith('.md')) return 'markdown'; + if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'; + if (filePath.endsWith('.xml')) return 'xml'; + if (filePath.endsWith('.sql')) return 'sql'; + if (filePath.endsWith('.sh')) return 'shell'; + if (filePath.endsWith('.bat')) return 'bat'; + if (filePath.endsWith('.php')) return 'php'; + if (filePath.endsWith('.java')) return 'java'; + if (filePath.endsWith('.c')) return 'c'; + if (filePath.endsWith('.cpp')) return 'cpp'; + if (filePath.endsWith('.h')) return 'h'; + if (filePath.endsWith('.hpp')) return 'hpp'; + if (filePath.endsWith('.go')) return 'go'; + if (filePath.endsWith('.py')) return 'python'; + if (filePath.endsWith('.rb')) return 'ruby'; + if (filePath.endsWith('.cs')) return 'csharp'; + if (filePath.endsWith('.swift')) return 'swift'; + if (filePath.endsWith('.vb')) return 'vb'; + if (filePath.endsWith('.lua')) return 'lua'; + if (filePath.endsWith('.pl')) return 'perl'; + if (filePath.endsWith('.r')) return 'r'; + return 'plaintext'; + }; + + return ( + + + + 编辑文件 + {file?.path} +
+ 按 Ctrl/Cmd + S 保存 +
+
+ +
{ + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + onSave(); + } + }} + > + +
+
+ + + + +
+
+ ); +} diff --git a/packages/snowcat-webui-frontend/src/components/file_manage/file_preview_modal.tsx b/packages/snowcat-webui-frontend/src/components/file_manage/file_preview_modal.tsx new file mode 100644 index 0000000..81c0e9b --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/file_manage/file_preview_modal.tsx @@ -0,0 +1,92 @@ +import { Button } from '@heroui/button'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@heroui/modal'; +import { Spinner } from '@heroui/spinner'; +import { useRequest } from 'ahooks'; +import path from 'path-browserify'; +import { useEffect } from 'react'; + +import FileManager from '@/controllers/file_manager'; + +interface FilePreviewModalProps { + isOpen: boolean; + filePath: string; + onClose: () => void; +} + +export const videoExts = ['.mp4', '.webm']; +export const audioExts = ['.mp3', '.wav']; + +export const supportedPreviewExts = [...videoExts, ...audioExts]; + +export default function FilePreviewModal ({ + isOpen, + filePath, + onClose, +}: FilePreviewModalProps) { + const ext = path.extname(filePath).toLowerCase(); + const { data, loading, error, run } = useRequest( + async () => FileManager.downloadToURL(filePath), + { + refreshDeps: [filePath], + manual: true, + refreshDepsAction: () => { + const ext = path.extname(filePath).toLowerCase(); + if (!filePath || !supportedPreviewExts.includes(ext)) { + return; + } + run(); + }, + } + ); + + useEffect(() => { + if (filePath) { + run(); + } + }, [filePath]); + + let contentElement = null; + if (!supportedPreviewExts.includes(ext)) { + contentElement =
暂不支持预览此文件类型
; + } else if (error) { + contentElement =
读取文件失败
; + } else if (loading || !data) { + contentElement = ( +
+ +
+ ); + } else if (videoExts.includes(ext)) { + contentElement =
+ ); +} + +export default function MoveModal ({ + isOpen, + moveTargetPath, + selectionInfo, + onClose, + onMove, + onSelect, +}: MoveModalProps) { + return ( + + + 选择目标目录 + +
+ +
+

+ 当前选择:{moveTargetPath || '未选择'} +

+

移动项:{selectionInfo}

+
+ + + + +
+
+ ); +} diff --git a/packages/snowcat-webui-frontend/src/components/file_manage/rename_modal.tsx b/packages/snowcat-webui-frontend/src/components/file_manage/rename_modal.tsx new file mode 100644 index 0000000..adbabed --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/file_manage/rename_modal.tsx @@ -0,0 +1,44 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@heroui/modal'; + +interface RenameModalProps { + isOpen: boolean; + newFileName: string; + onNameChange: (e: React.ChangeEvent) => void; + onClose: () => void; + onRename: () => void; +} + +export default function RenameModal ({ + isOpen, + newFileName, + onNameChange, + onClose, + onRename, +}: RenameModalProps) { + return ( + + + 重命名 + + + + + + + + + + ); +} diff --git a/packages/snowcat-webui-frontend/src/components/github_info/icon_wrapper.tsx b/packages/snowcat-webui-frontend/src/components/github_info/icon_wrapper.tsx new file mode 100644 index 0000000..4ed35df --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/github_info/icon_wrapper.tsx @@ -0,0 +1,19 @@ +import clsx from 'clsx'; + +export interface IconWrapperProps { + children?: React.ReactNode + className?: string +} + +const IconWrapper = ({ children, className }: IconWrapperProps) => ( +
+ {children} +
+); + +export default IconWrapper; diff --git a/packages/snowcat-webui-frontend/src/components/github_info/item_counter.tsx b/packages/snowcat-webui-frontend/src/components/github_info/item_counter.tsx new file mode 100644 index 0000000..33b9676 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/github_info/item_counter.tsx @@ -0,0 +1,10 @@ +import { ChevronRightIcon } from '../icons'; + +const ItemCounter = ({ number }: { number: number }) => ( +
+ {number} + +
+); + +export default ItemCounter; diff --git a/packages/snowcat-webui-frontend/src/components/github_info/release.tsx b/packages/snowcat-webui-frontend/src/components/github_info/release.tsx new file mode 100644 index 0000000..4c63a25 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/github_info/release.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +import { getReleaseTime } from '@/utils/time'; + +import type { GithubRelease as GithubReleaseType } from '@/types/github'; + +export interface GithubReleaseProps { + releaseData: GithubReleaseType +} +const GithubRelease: React.FC = (props) => { + const { releaseData } = props; + const [releaseTime, setReleaseTime] = useState(null); + + useEffect(() => { + if (releaseData) { + const timer = setInterval(() => { + const time = getReleaseTime(releaseData.published_at); + + setReleaseTime(time); + }, 1000); + + return () => clearInterval(timer); + } + }, [releaseData]); + + return ( +
+ Releases +
+ {releaseData.name} +
+ {releaseTime} + Latest +
+
+
+ ); +}; + +export default GithubRelease; diff --git a/packages/snowcat-webui-frontend/src/components/guid_manager.tsx b/packages/snowcat-webui-frontend/src/components/guid_manager.tsx new file mode 100644 index 0000000..4525500 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/guid_manager.tsx @@ -0,0 +1,720 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Divider } from '@heroui/divider'; +import { Chip } from '@heroui/chip'; +import { Listbox, ListboxItem } from '@heroui/listbox'; +import { Spinner } from '@heroui/spinner'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import toast from 'react-hot-toast'; +import { MdContentCopy, MdDelete, MdRefresh, MdSave, MdRestorePage, MdBackup } from 'react-icons/md'; +import MD5 from 'crypto-js/md5'; + +import QQManager from '@/controllers/qq_manager'; +import useDialog from '@/hooks/use-dialog'; + +interface GUIDManagerProps { + /** 是否显示重启按钮 */ + showRestart?: boolean; + /** 紧凑模式(用于弹窗场景) */ + compact?: boolean; +} + +const GUIDManager: React.FC = ({ showRestart = true, compact = false }) => { + const dialog = useDialog(); + + // 平台检测 + const [platform, setPlatform] = useState(''); + const isWindows = platform === 'win32'; + const isMac = platform === 'darwin'; + const isLinux = platform !== '' && platform !== 'win32' && platform !== 'darwin'; + const platformDetected = platform !== ''; + + // Windows 状态 + const [currentGUID, setCurrentGUID] = useState(''); + const [inputGUID, setInputGUID] = useState(''); + const [backups, setBackups] = useState([]); + + // Linux 状态 + const [currentMAC, setCurrentMAC] = useState(''); + const [inputMAC, setInputMAC] = useState(''); + const [machineId, setMachineId] = useState(''); + const [linuxBackups, setLinuxBackups] = useState([]); + + // 通用状态 + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [restarting, setRestarting] = useState(false); + + const isValidGUID = (guid: string) => /^[0-9a-fA-F]{32}$/.test(guid); + const isValidMAC = (mac: string) => /^[0-9a-fA-F]{2}(-[0-9a-fA-F]{2}){5}$/.test(mac.trim().toLowerCase()); + + // 前端实时计算 Linux GUID = MD5(machine-id + MAC) + const computedLinuxGUID = useMemo(() => { + if (!isLinux) return ''; + const mac = inputMAC.trim().toLowerCase(); + if (!machineId && !mac) return ''; + return MD5(machineId + mac).toString(); + }, [isLinux, machineId, inputMAC]); + + // 当前生效的 GUID (基于已保存的 MAC) + const currentLinuxGUID = useMemo(() => { + if (!isLinux || !currentMAC) return ''; + return MD5(machineId + currentMAC).toString(); + }, [isLinux, machineId, currentMAC]); + + // 检测平台 + const fetchPlatform = useCallback(async () => { + try { + const data = await QQManager.getPlatformInfo(); + setPlatform(data.platform); + } catch { + // 如果获取失败,默认 win32 向后兼容 + setPlatform('win32'); + } + }, []); + + // Windows: 获取 GUID + const fetchGUID = useCallback(async () => { + setLoading(true); + try { + const data = await QQManager.getDeviceGUID(); + setCurrentGUID(data.guid); + setInputGUID(data.guid); + } catch (error) { + const msg = (error as Error).message; + setCurrentGUID(''); + setInputGUID(''); + if (!msg.includes('not found')) { + toast.error(`获取 GUID 失败: ${msg}`); + } + } finally { + setLoading(false); + } + }, []); + + // Windows: 获取备份 + const fetchBackups = useCallback(async () => { + try { + const data = await QQManager.getGUIDBackups(); + setBackups(data); + } catch { + // ignore + } + }, []); + + // Linux: 获取 MAC + machine-id + const fetchLinuxInfo = useCallback(async () => { + setLoading(true); + try { + const [macData, midData] = await Promise.all([ + QQManager.getLinuxMAC().catch(() => ({ mac: '' })), + QQManager.getLinuxMachineId().catch(() => ({ machineId: '' })), + ]); + setCurrentMAC(macData.mac); + setInputMAC(macData.mac); + setMachineId(midData.machineId); + } catch (error) { + const msg = (error as Error).message; + toast.error(`获取设备信息失败: ${msg}`); + } finally { + setLoading(false); + } + }, []); + + // Linux: 获取备份 + const fetchLinuxBackups = useCallback(async () => { + try { + const data = await QQManager.getLinuxMachineInfoBackups(); + setLinuxBackups(data); + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchPlatform(); + }, [fetchPlatform]); + + useEffect(() => { + if (!platformDetected) return; + if (isWindows) { + fetchGUID(); + fetchBackups(); + } else { + fetchLinuxInfo(); + fetchLinuxBackups(); + } + }, [platformDetected, isWindows, fetchGUID, fetchBackups, fetchLinuxInfo, fetchLinuxBackups]); + + // ========== Windows 操作 ========== + + const handleCopy = () => { + const guid = isLinux ? currentLinuxGUID : currentGUID; + if (guid) { + navigator.clipboard.writeText(guid); + toast.success('已复制到剪贴板'); + } + }; + + const handleSave = async () => { + if (!isValidGUID(inputGUID)) { + toast.error('GUID 格式无效,需要 32 位十六进制字符'); + return; + } + setSaving(true); + try { + await QQManager.setDeviceGUID(inputGUID); + setCurrentGUID(inputGUID); + toast.success('GUID 已设置,重启后生效'); + await fetchBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`设置 GUID 失败: ${msg}`); + } finally { + setSaving(false); + } + }; + + const handleDelete = () => { + dialog.confirm({ + title: '确认删除', + content: '删除 Registry20 后,QQ 将在下次启动时生成新的设备标识。确定要删除吗?', + confirmText: '删除', + cancelText: '取消', + onConfirm: async () => { + try { + await QQManager.resetDeviceID(); + setCurrentGUID(''); + setInputGUID(''); + toast.success('已删除,重启后生效'); + await fetchBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`删除失败: ${msg}`); + } + }, + }); + }; + + const handleBackup = async () => { + try { + await QQManager.createGUIDBackup(); + toast.success('备份已创建'); + await fetchBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`备份失败: ${msg}`); + } + }; + + const handleRestore = (backupName: string) => { + dialog.confirm({ + title: '确认恢复', + content: `确定要从备份 "${backupName}" 恢复吗?当前的 Registry20 将被覆盖。`, + confirmText: '恢复', + cancelText: '取消', + onConfirm: async () => { + try { + await QQManager.restoreGUIDBackup(backupName); + toast.success('已恢复,重启后生效'); + await fetchGUID(); + await fetchBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`恢复失败: ${msg}`); + } + }, + }); + }; + + // ========== Linux 操作 ========== + + const handleLinuxSaveMAC = async () => { + const mac = inputMAC.trim().toLowerCase(); + if (!isValidMAC(mac)) { + toast.error('MAC 格式无效,需要 xx-xx-xx-xx-xx-xx 格式'); + return; + } + setSaving(true); + try { + await QQManager.setLinuxMAC(mac); + setCurrentMAC(mac); + toast.success('MAC 已设置,重启后生效'); + await fetchLinuxBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`设置 MAC 失败: ${msg}`); + } finally { + setSaving(false); + } + }; + + const handleLinuxCopyMAC = () => { + if (currentMAC) { + navigator.clipboard.writeText(currentMAC); + toast.success('MAC 已复制到剪贴板'); + } + }; + + const handleLinuxDelete = () => { + dialog.confirm({ + title: '确认删除', + content: '删除 machine-info 后,QQ 将在下次启动时生成新的设备标识。确定要删除吗?', + confirmText: '删除', + cancelText: '取消', + onConfirm: async () => { + try { + await QQManager.resetLinuxDeviceID(); + setCurrentMAC(''); + setInputMAC(''); + toast.success('已删除,重启后生效'); + await fetchLinuxBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`删除失败: ${msg}`); + } + }, + }); + }; + + const handleLinuxBackup = async () => { + try { + await QQManager.createLinuxMachineInfoBackup(); + toast.success('备份已创建'); + await fetchLinuxBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`备份失败: ${msg}`); + } + }; + + const handleLinuxRestore = (backupName: string) => { + dialog.confirm({ + title: '确认恢复', + content: `确定要从备份 "${backupName}" 恢复吗?当前的 machine-info 将被覆盖。`, + confirmText: '恢复', + cancelText: '取消', + onConfirm: async () => { + try { + await QQManager.restoreLinuxMachineInfoBackup(backupName); + toast.success('已恢复,重启后生效'); + await fetchLinuxInfo(); + await fetchLinuxBackups(); + } catch (error) { + const msg = (error as Error).message; + toast.error(`恢复失败: ${msg}`); + } + }, + }); + }; + + // ========== 重启 ========== + + const handleRestart = () => { + dialog.confirm({ + title: '确认重启', + content: '确定要重启 SnowCat 吗?这将导致当前连接断开。', + confirmText: '重启', + cancelText: '取消', + onConfirm: async () => { + setRestarting(true); + try { + await QQManager.restartSnowCat(); + toast.success('重启指令已发送'); + } catch (error) { + const msg = (error as Error).message; + toast.error(`重启失败: ${msg}`); + } finally { + setRestarting(false); + } + }, + }); + }; + + if (loading || !platformDetected) { + return ( +
+ +
+ ); + } + + // ========== macOS 不支持 ========== + if (isMac) { + return ( +
+
+ + macOS 平台暂不支持 GUID 管理 + +
+ 该功能仅适用于 Windows 和 Linux 平台 +
+
+
+ ); + } + + // ========== Linux 渲染 ========== + if (isLinux) { + return ( +
+ {/* 当前设备信息 */} +
+
当前设备 GUID
+
+ {currentLinuxGUID + ? ( + + {currentLinuxGUID} + + ) + : ( + + 未设置 / 不存在 + + )} + {currentLinuxGUID && ( + + )} + +
+
+ GUID = MD5(machine-id + MAC),修改 MAC 即可改变 GUID +
+
+ + + + {/* machine-id 显示 */} +
+
Machine ID
+ + {machineId || '未知'} + +
+ 来自 /etc/machine-id,不可修改 +
+
+ + + + {/* 当前 MAC 显示 */} +
+
当前 MAC 地址
+
+ {currentMAC + ? ( + + {currentMAC} + + ) + : ( + + 未设置 / 不存在 + + )} + {currentMAC && ( + + )} +
+
+ + + + {/* 编辑 MAC 地址 */} +
+
设置 MAC 地址
+
+ 0 && !isValidMAC(inputMAC)} + errorMessage={inputMAC.length > 0 && !isValidMAC(inputMAC) ? '格式: xx-xx-xx-xx-xx-xx' : undefined} + classNames={{ + input: 'font-mono text-sm', + }} + maxLength={17} + /> +
+ + {/* 实时 GUID 预览 */} + {inputMAC && isValidMAC(inputMAC) && ( +
+
预览 GUID
+
+ {computedLinuxGUID} +
+ {computedLinuxGUID !== currentLinuxGUID && ( +
+ 与当前 GUID 不同,保存后重启生效 +
+ )} +
+ )} + +
+ + + +
+
+ 修改 MAC 后 GUID 将变化,需重启 SnowCat 才能生效,操作前会自动备份 +
+
+ + {/* 备份恢复 */} + {linuxBackups.length > 0 && ( + <> + +
+
+ 备份列表 + (点击恢复) +
+
+ handleLinuxRestore(key as string)} + > + {linuxBackups.map((name) => ( + } + className='font-mono text-xs' + > + {name} + + ))} + +
+
+ + )} + + {/* 重启 */} + {showRestart && ( + <> + + + + )} +
+ ); + } + + // ========== Windows 渲染 ========== + return ( +
+ {/* 当前 GUID 显示 */} +
+
当前设备 GUID
+
+ {currentGUID + ? ( + + {currentGUID} + + ) + : ( + + 未设置 / 不存在 + + )} + {currentGUID && ( + + )} + +
+
+ + + + {/* 设置 GUID */} +
+
设置 GUID
+
+ 0 && !isValidGUID(inputGUID)} + errorMessage={inputGUID.length > 0 && !isValidGUID(inputGUID) ? '需要32位十六进制字符' : undefined} + classNames={{ + input: 'font-mono text-sm', + }} + maxLength={32} + /> +
+
+ + + +
+
+ 修改或删除 GUID 后需重启 SnowCat 才能生效,操作前会自动备份 +
+
+ + {/* 备份恢复 */} + {backups.length > 0 && ( + <> + +
+
+ 备份列表 + (点击恢复) +
+
+ handleRestore(key as string)} + > + {backups.map((name) => ( + } + className='font-mono text-xs' + > + {name} + + ))} + +
+
+ + )} + + {/* 重启 */} + {showRestart && ( + <> + + + + )} +
+ ); +}; + +export default GUIDManager; diff --git a/packages/snowcat-webui-frontend/src/components/hitokoto.tsx b/packages/snowcat-webui-frontend/src/components/hitokoto.tsx new file mode 100644 index 0000000..c98e55d --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/hitokoto.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @stylistic/jsx-closing-bracket-location */ +/* eslint-disable @stylistic/jsx-closing-tag-location */ +import { Button } from '@heroui/button'; +import { Tooltip } from '@heroui/tooltip'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import { useRequest } from 'ahooks'; +import clsx from 'clsx'; +import toast from 'react-hot-toast'; +import { IoMdQuote } from 'react-icons/io'; +import { IoCopy, IoRefresh } from 'react-icons/io5'; + +import key from '@/const/key'; +import { request } from '@/utils/request'; + +import PageLoading from './page_loading'; + +export default function Hitokoto () { + const { + data: dataOri, + error, + loading, + run, + } = useRequest(() => request.get('https://hi.logacg.com/'), { + throttleWait: 1000, + }); + const backupData = { + hitokoto: '凡是过往,皆为序章。', + from: '暴风雨', + from_who: '莎士比亚', + }; + const data = dataOri?.data || (error ? backupData : undefined); + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + const onCopy = () => { + try { + const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`; + navigator.clipboard.writeText(text); + toast.success('复制成功'); + } catch (_error) { + toast.error('复制失败, 请手动复制'); + } + }; + return ( +
+
+ {loading && !data && } + {data && ( + <> + +
+ “{data?.hitokoto}” +
+
+ —— {data?.from} + + {data?.from_who && ( + <> + + + {data?.from_who} + + + )} +
+ + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/packages/snowcat-webui-frontend/src/components/hover_titled_card.tsx b/packages/snowcat-webui-frontend/src/components/hover_titled_card.tsx new file mode 100644 index 0000000..7872952 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/hover_titled_card.tsx @@ -0,0 +1,146 @@ +import { motion, useMotionValue, useSpring } from 'motion/react'; +import { useRef, useState } from 'react'; + +const springValues = { + damping: 30, + stiffness: 100, + mass: 2, +}; + +export interface HoverTiltedCardProps { + imageSrc: string + altText?: string + captionText?: string + containerHeight?: string + containerWidth?: string + imageHeight?: string + imageWidth?: string + scaleOnHover?: number + rotateAmplitude?: number + showTooltip?: boolean + overlayContent?: React.ReactNode + displayOverlayContent?: boolean +} + +export default function HoverTiltedCard ({ + imageSrc, + altText = 'SnowCat', + captionText = 'SnowCat', + containerHeight = '200px', + containerWidth = '100%', + imageHeight = '200px', + imageWidth = '200px', + scaleOnHover = 1.1, + rotateAmplitude = 14, + showTooltip = false, + overlayContent = ( +
+ SnowCat +
+ ), + displayOverlayContent = true, +}: HoverTiltedCardProps) { + const ref = useRef(null); + const x = useMotionValue(0); + const y = useMotionValue(0); + const rotateX = useSpring(useMotionValue(0), springValues); + const rotateY = useSpring(useMotionValue(0), springValues); + const scale = useSpring(1, springValues); + const opacity = useSpring(0); + const rotateFigcaption = useSpring(0, { + stiffness: 350, + damping: 30, + mass: 1, + }); + + const [lastY, setLastY] = useState(0); + + function handleMouse (e: React.MouseEvent) { + if (!ref.current) return; + + const rect = ref.current.getBoundingClientRect(); + const offsetX = e.clientX - rect.left - rect.width / 2; + const offsetY = e.clientY - rect.top - rect.height / 2; + + const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude; + const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude; + + rotateX.set(rotationX); + rotateY.set(rotationY); + + x.set(e.clientX - rect.left); + y.set(e.clientY - rect.top); + + const velocityY = offsetY - lastY; + rotateFigcaption.set(-velocityY * 0.6); + setLastY(offsetY); + } + + function handleMouseEnter () { + scale.set(scaleOnHover); + opacity.set(1); + } + + function handleMouseLeave () { + opacity.set(0); + scale.set(1); + rotateX.set(0); + rotateY.set(0); + rotateFigcaption.set(0); + } + + return ( +
+ + + + {displayOverlayContent && overlayContent && ( + + {overlayContent} + + )} + + + {showTooltip && ( + + {captionText} + + )} +
+ ); +} diff --git a/packages/snowcat-webui-frontend/src/components/icons.tsx b/packages/snowcat-webui-frontend/src/components/icons.tsx new file mode 100644 index 0000000..6fc82da --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/icons.tsx @@ -0,0 +1,1967 @@ +import * as React from 'react'; + +import logo from '@/assets/images/logo.png'; +import { IconImageProps, IconSvgProps } from '@/types'; + +export const Logo: React.FC = ({ + size = 36, + height, + ...props +}) => ( + // + // + // + Napcat +); + +export const DiscordIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const TwitterIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const GithubIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const MoonFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const SunFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const HeartFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const SearchIcon = (props: IconSvgProps) => ( + +); + +export const NextUILogo: React.FC = (props) => { + const { width, height = 40 } = props; + + return ( + + + + + + ); +}; + +export const RouteIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + +); + +export const InfoIcon = (props: IconSvgProps) => ( + + + + + + + + + +); + +export const SignalTowerIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + +); + +export const SettingsIcon = (props: IconSvgProps) => ( + + + + + + + + + +); + +export const TerminalIcon = (props: IconSvgProps) => ( + + + + + + + + + + +); + +export const PlusIcon = (props: IconSvgProps) => ( + + + + + + + + + +); + +export const HTTPClientIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const HTTPServerIcon = (props: IconSvgProps) => ( + + + + + + + + + +); + +export const WebsocketIcon = (props: IconSvgProps) => ( + + + + + + + + + +); + +export const PCIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const BugIcon = (props: IconSvgProps) => ( + + + +); + +export const PullRequestIcon = (props: IconSvgProps) => ( + + + +); + +export const ChatIcon = (props: IconSvgProps) => ( + + + + +); + +export const PlayCircleIcon = (props: IconSvgProps) => ( + + + + +); + +export const VolumeHighIcon = (props: IconSvgProps) => { + return ( + + ); +}; + +export const VolumeLowIcon = (props: IconSvgProps) => { + return ( + + ); +}; + +export const LayoutIcon = (props: IconSvgProps) => ( + + + +); + +export const TagIcon = (props: IconSvgProps) => ( + + + + +); + +export const UsersIcon = (props: IconSvgProps) => ( + + + + +); + +export const WatchersIcon = (props: IconSvgProps) => ( + + + +); + +export const BookIcon = (props: IconSvgProps) => ( + + + + +); + +export const ChevronRightIcon = (props: IconSvgProps) => ( + +); + +export const StarIcon = (props: IconSvgProps) => ( + + + +); + +export const BugIcon2 = (props: IconSvgProps) => ( + +); + +export const WebUIIcon = (props: IconSvgProps) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const BietiaopIcon = (props: IconSvgProps) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const FileIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const LogIcon = (props: IconSvgProps) => ( + + + + + + + + + + + +); diff --git a/packages/snowcat-webui-frontend/src/components/input/file_input.tsx b/packages/snowcat-webui-frontend/src/components/input/file_input.tsx new file mode 100644 index 0000000..dc0107f --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/input/file_input.tsx @@ -0,0 +1,76 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { useRef, useState } from 'react'; + +export interface FileInputProps { + onChange: (file: File) => Promise | void; + onDelete?: () => Promise | void; + label?: string; + accept?: string; + placeholder?: string; +} + +const FileInput: React.FC = ({ + onChange, + onDelete, + label, + accept, + placeholder, +}) => { + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + return ( +
+
+ { + try { + setIsLoading(true); + const file = e.target.files?.[0]; + if (file) { + await onChange(file); + } + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + if (inputRef.current) inputRef.current.value = ''; + } + }} + /> +
+ +
+ ); +}; + +export default FileInput; diff --git a/packages/snowcat-webui-frontend/src/components/input/image_input.tsx b/packages/snowcat-webui-frontend/src/components/input/image_input.tsx new file mode 100644 index 0000000..c31bba8 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/input/image_input.tsx @@ -0,0 +1,63 @@ +import { Button } from '@heroui/button'; +import { Image } from '@heroui/image'; +import { Input } from '@heroui/input'; +import { useRef } from 'react'; + +export interface ImageInputProps { + onChange: (base64: string) => void; + value: string; + label?: string; +} + +const ImageInput: React.FC = ({ onChange, value, label }) => { + const inputRef = useRef(null); + return ( +
+
+ {value + ? {label} + : 无图} +
+ { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = async () => { + const base64 = reader.result as string; + onChange(base64); + }; + reader.readAsDataURL(file); + } + }} + /> + +
+ ); +}; + +export default ImageInput; diff --git a/packages/snowcat-webui-frontend/src/components/log_com/history.tsx b/packages/snowcat-webui-frontend/src/components/log_com/history.tsx new file mode 100644 index 0000000..809eb2e --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/log_com/history.tsx @@ -0,0 +1,147 @@ +import { Button } from '@heroui/button'; +import { Card, CardBody, CardHeader } from '@heroui/card'; +import { Select, SelectItem } from '@heroui/select'; +import type { Selection } from '@react-types/shared'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; + +import key from '@/const/key'; +import { colorizeLogLevel } from '@/utils/terminal'; + +import PageLoading from '../page_loading'; +import XTerm from '../xterm'; +import type { XTermRef } from '../xterm'; +import LogLevelSelect from './log_level_select'; + +export interface HistoryLogsProps { + list: string[]; + onSelect: (name: string) => void; + selectedLog?: string; + refreshList: () => void; + refreshLog: () => void; + listLoading?: boolean; + logLoading?: boolean; + listError?: Error; + logContent?: string; +} +const HistoryLogs: React.FC = (props) => { + const { + list, + onSelect, + selectedLog, + refreshList, + refreshLog, + listLoading, + logContent, + listError, + logLoading, + } = props; + const Xterm = useRef(null); + + const [logLevel, setLogLevel] = useState( + new Set(['info', 'warn', 'error']) + ); + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + const logToColored = (log: string) => { + const logs = log + .split('\n') + .map((line) => { + const colored = colorizeLogLevel(line); + return colored; + }) + .filter((log) => { + if (logLevel === 'all') { + return true; + } + return logLevel.has(log.level); + }) + .map((log) => log.content) + .join('\r\n'); + return logs; + }; + + const onDownloadLog = () => { + if (!logContent) { + return; + } + const blob = new Blob([logContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedLog}.log`; + a.click(); + URL.revokeObjectURL(url); + }; + + useEffect(() => { + if (!Xterm.current || !logContent) { + return; + } + Xterm.current.clear(); + const _logContent = logToColored(logContent); + Xterm.current.write(_logContent + '\r\nsnowcat@webui:~$ '); + }, [logContent, logLevel]); + + return ( + <> + 历史日志 - SnowCat WebUI + + + + +
+ + + +
+
+ + + + +
+ + ); +}; + +export default HistoryLogs; diff --git a/packages/snowcat-webui-frontend/src/components/log_com/log_level_select.tsx b/packages/snowcat-webui-frontend/src/components/log_com/log_level_select.tsx new file mode 100644 index 0000000..04555d8 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/log_com/log_level_select.tsx @@ -0,0 +1,87 @@ +import { Chip } from '@heroui/chip'; +import { Select, SelectItem } from '@heroui/select'; +import { SharedSelection } from '@heroui/system'; +import type { Selection } from '@react-types/shared'; + +import { LogLevel } from '@/const/enum'; + +export interface LogLevelSelectProps { + selectedKeys: Selection; + onSelectionChange: (keys: SharedSelection) => void; +} +const logLevelColor: { + [key in LogLevel]: + | 'default' + | 'primary' + | 'secondary' + | 'success' + | 'warning' + | 'primary' +} = { + [LogLevel.DEBUG]: 'default', + [LogLevel.INFO]: 'primary', + [LogLevel.WARN]: 'warning', + [LogLevel.ERROR]: 'primary', + [LogLevel.FATAL]: 'primary', +}; +const LogLevelSelect = (props: LogLevelSelectProps) => { + const { selectedKeys, onSelectionChange } = props; + return ( + + ); +}; + +export default LogLevelSelect; diff --git a/packages/snowcat-webui-frontend/src/components/log_com/realtime.tsx b/packages/snowcat-webui-frontend/src/components/log_com/realtime.tsx new file mode 100644 index 0000000..7659969 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/log_com/realtime.tsx @@ -0,0 +1,125 @@ +import { Button } from '@heroui/button'; +import type { Selection } from '@react-types/shared'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { IoDownloadOutline } from 'react-icons/io5'; + +import key from '@/const/key'; +import { colorizeLogLevelWithTag } from '@/utils/terminal'; + +import WebUIManager, { Log } from '@/controllers/webui_manager'; + +import type { XTermRef } from '../xterm'; +import XTerm from '../xterm'; +import LogLevelSelect from './log_level_select'; + +const RealTimeLogs = () => { + const Xterm = useRef(null); + const [logLevel, setLogLevel] = useState( + new Set(['info', 'warn', 'error']) + ); + const [dataArr, setDataArr] = useState([]); + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + const onDownloadLog = () => { + const logContent = dataArr + .filter((log) => { + if (logLevel === 'all') { + return true; + } + return logLevel.has(log.level); + }) + .map((log) => colorizeLogLevelWithTag(log.message, log.level)) + .join('\r\n'); + const blob = new Blob([logContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'snowcat.log'; + a.click(); + URL.revokeObjectURL(url); + }; + + const writeStream = () => { + try { + const _data = dataArr + .filter((log) => { + if (logLevel === 'all') { + return true; + } + return logLevel.has(log.level); + }) + .map((log) => colorizeLogLevelWithTag(log.message, log.level)) + .join('\r\n'); + Xterm.current?.clear(); + Xterm.current?.write(_data); + } catch (error) { + console.error(error); + toast.error('获取实时日志失败'); + } + }; + + useEffect(() => { + writeStream(); + }, [logLevel, dataArr]); + + useEffect(() => { + const subscribeLogs = () => { + try { + const source = WebUIManager.getRealTimeLogs((data) => { + setDataArr((prev) => { + const newData = [...prev, ...data]; + if (newData.length > 1000) { + newData.splice(0, newData.length - 1000); + } + return newData; + }); + }); + return () => { + source.close(); + }; + } catch (_error) { + toast.error('获取实时日志失败'); + } + }; + + const close = subscribeLogs(); + return () => { + console.log('close'); + close?.(); + }; + }, []); + + return ( + <> + 实时日志 - SnowCat WebUI +
+ + +
+
+ +
+ + ); +}; + +export default RealTimeLogs; diff --git a/packages/snowcat-webui-frontend/src/components/mirror_selector_modal.tsx b/packages/snowcat-webui-frontend/src/components/mirror_selector_modal.tsx new file mode 100644 index 0000000..9425bf9 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/mirror_selector_modal.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from 'react'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal'; +import { Button } from '@heroui/button'; +import { Chip } from '@heroui/chip'; +import { Tooltip } from '@heroui/tooltip'; +import { IoMdFlash, IoMdCheckmark, IoMdClose } from 'react-icons/io'; +import clsx from 'clsx'; + +import MirrorManager, { MirrorTestResult } from '@/controllers/mirror_manager'; + +interface MirrorSelectorModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (mirror: string | undefined) => void; + currentMirror?: string; + type?: 'file' | 'raw'; +} + +export default function MirrorSelectorModal ({ + isOpen, + onClose, + onSelect, + currentMirror, + type = 'file', +}: MirrorSelectorModalProps) { + const [mirrors, setMirrors] = useState([]); + const [selectedMirror, setSelectedMirror] = useState(currentMirror || 'auto'); + const [testResults, setTestResults] = useState>(new Map()); + const [isTesting, setIsTesting] = useState(false); + const [testProgress, setTestProgress] = useState(0); + const [testMessage, setTestMessage] = useState(''); + const [fastestMirror, setFastestMirror] = useState(null); + + // 加载镜像列表 + useEffect(() => { + if (isOpen) { + loadMirrors(); + } + }, [isOpen]); + + const loadMirrors = async () => { + try { + const data = await MirrorManager.getMirrorList(); + const mirrorList = type === 'raw' ? data.rawMirrors : data.fileMirrors; + setMirrors(mirrorList); + if (data.customMirror) { + setSelectedMirror(data.customMirror); + } + } catch (e) { + console.error('Failed to load mirrors:', e); + } + }; + + const startSpeedTest = () => { + setIsTesting(true); + setTestProgress(0); + setTestResults(new Map()); + setFastestMirror(null); + setTestMessage('准备测速...'); + + MirrorManager.testMirrorsSSE(type, { + onStart: (data) => { + setTestMessage(data.message); + }, + onTesting: (data) => { + setTestProgress((data.index / data.total) * 100); + setTestMessage(data.message); + }, + onResult: (data) => { + setTestResults((prev) => { + const newMap = new Map(prev); + newMap.set(data.result.mirror, data.result); + return newMap; + }); + setTestProgress(((data.index + 1) / data.total) * 100); + }, + onComplete: (data) => { + setIsTesting(false); + setTestProgress(100); + setTestMessage(data.message); + if (data.fastest) { + setFastestMirror(data.fastest.mirror); + } + }, + onError: (error) => { + setIsTesting(false); + setTestMessage(`测速失败: ${error}`); + }, + }); + }; + + const handleConfirm = () => { + const mirror = selectedMirror === 'auto' ? undefined : selectedMirror; + onSelect(mirror); + onClose(); + }; + + return ( + + + +
+ 选择镜像源 + +
+ {isTesting && ( +
+
+
+
+

{testMessage}

+
+ )} + + +
+ {/* 自动选择选项 */} + setSelectedMirror('auto')} + badge={推荐} + /> + + {/* 原始 GitHub */} + setSelectedMirror('https://github.com')} + testResult={testResults.get('https://github.com (原始)')} + isFastest={fastestMirror === 'https://github.com (原始)'} + /> + + {/* 镜像列表 */} + {mirrors.map((mirror) => { + if (!mirror) return null; + const result = testResults.get(mirror); + const isFastest = fastestMirror === mirror; + + let hostname = mirror; + try { + hostname = new URL(mirror).hostname; + } catch { } + + return ( + setSelectedMirror(mirror)} + testResult={result} + isFastest={isFastest} + /> + ); + })} +
+
+ + + + + + + ); +} + +// 镜像选项组件 +interface MirrorOptionProps { + value: string; + label: string; + description: string; + isSelected: boolean; + onSelect: () => void; + testResult?: MirrorTestResult; + isFastest?: boolean; + badge?: React.ReactNode; +} + +function MirrorOption ({ + label, + description, + isSelected, + onSelect, + testResult, + isFastest, + badge, +}: MirrorOptionProps) { + return ( +
+
+

{label}

+

{description}

+
+
+ {badge} + {isFastest && !badge && ( + 最快 + )} + {testResult && } +
+
+ ); +} + +// 镜像状态显示组件 +function MirrorStatus ({ result }: { result: MirrorTestResult; }) { + const formatLatency = (latency: number) => { + if (latency >= 5000) return '>5s'; + if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`; + return `${latency}ms`; + }; + + if (!result.success) { + return ( + + } + > + 失败 + + + ); + } + + const getColor = (): 'success' | 'warning' | 'danger' => { + if (result.latency < 300) return 'success'; + if (result.latency < 1000) return 'warning'; + return 'danger'; + }; + + return ( + } + > + {formatLatency(result.latency)} + + ); +} diff --git a/packages/snowcat-webui-frontend/src/components/modal.tsx b/packages/snowcat-webui-frontend/src/components/modal.tsx new file mode 100644 index 0000000..b825a86 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/modal.tsx @@ -0,0 +1,101 @@ +import { Button } from '@heroui/button'; +import { + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Modal as NextUIModal, + useDisclosure, +} from '@heroui/modal'; +import React from 'react'; + +export interface ModalProps { + content: React.ReactNode; + title?: React.ReactNode; + size?: React.ComponentProps['size']; + scrollBehavior?: React.ComponentProps['scrollBehavior']; + onClose?: () => void; + onConfirm?: () => void; + onCancel?: () => void; + backdrop?: 'opaque' | 'blur' | 'transparent'; + showCancel?: boolean; + dismissible?: boolean; + confirmText?: string; + cancelText?: string; + hideFooter?: boolean; +} + +const Modal: React.FC = React.memo((props) => { + const { + backdrop = 'blur', + title, + content, + showCancel = true, + dismissible, + confirmText = '确定', + cancelText = '取消', + hideFooter = false, + onClose, + onConfirm, + onCancel, + ...rest + } = props; + const { onClose: onNativeClose } = useDisclosure(); + + return ( + { + onClose?.(); + onNativeClose(); + }} + classNames={{ + backdrop: 'z-[99] backdrop-blur-sm', + wrapper: 'z-[99]', + }} + {...rest} + > + + {(nativeClose) => ( + <> + {title && ( + {title} + )} + {content} + {!hideFooter && ( + + {showCancel && ( + + )} + + + )} + + )} + + + ); +}); + +Modal.displayName = 'Modal'; + +export default Modal; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/generic_form.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/generic_form.tsx new file mode 100644 index 0000000..17ad71f --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/generic_form.tsx @@ -0,0 +1,193 @@ +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { ModalBody, ModalFooter } from '@heroui/modal'; +import { Select, SelectItem } from '@heroui/select'; +import { ReactElement, useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import type { + DefaultValues, + Path, + PathValue, + SubmitHandler, +} from 'react-hook-form'; +import toast from 'react-hot-toast'; + +import SwitchCard from '../switch_card'; + +export type FieldTypes = 'input' | 'select' | 'switch'; + +type NetworkConfigType = OneBotConfig['network']; + +export interface Field { + name: keyof NetworkConfigType[T][0]; + label: string; + type: FieldTypes; + options?: Array<{ key: string; value: string; }>; + placeholder?: string; + isRequired?: boolean; + isDisabled?: boolean; + description?: string; + colSpan?: 1 | 2; +} + +export interface GenericFormProps { + data?: NetworkConfigType[T][0]; + defaultValues: DefaultValues; + onClose: () => void; + onSubmit: (data: NetworkConfigType[T][0]) => Promise; + fields: Array>; +} + +const GenericForm = ({ + data, + defaultValues, + onClose, + onSubmit, + fields, +}: GenericFormProps): ReactElement => { + const { control, handleSubmit, formState, setValue, reset } = useForm< + NetworkConfigType[T][0] + >({ + defaultValues, + }); + + const submitAction: SubmitHandler = async (data) => { + await onSubmit(data); + onClose(); + }; + + const _onSubmit = handleSubmit(submitAction, (e) => { + const errors = Object.values(e); + if (errors.length > 0) { + toast.error(errors[0]?.message as string); + } + }); + + useEffect(() => { + if (data) { + const keys = Object.keys(data) as Path[]; + for (const key of keys) { + const value = data[key] as PathValue< + NetworkConfig[T][0], + Path + >; + setValue(key, value); + } + } else { + reset(); + } + }, [data, reset, setValue]); + + return ( + <> + +
+ {fields.map((field) => ( +
+ } + rules={ + field.isRequired + ? { + required: `请填写${field.label}`, + } + : undefined + } + render={({ field: controllerField }) => { + switch (field.type) { + case 'input': + return ( + controllerField.onChange(value)} + ref={controllerField.ref} + isRequired={field.isRequired} + isDisabled={field.isDisabled} + label={field.label} + placeholder={field.placeholder} + classNames={{ + inputWrapper: + 'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35 group-data-[focus=true]:border-primary/55', + input: 'bg-transparent text-default-700 dark:text-default-100 placeholder:text-default-400', + }} + /> + ); + case 'select': + return ( + + ); + case 'switch': + return ( + + ); + default: + return <>; + } + }} + /> +
+ ))} +
+
+ + + + + + ); +}; + +export default GenericForm; +export function random_token (length: number) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/http_client.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/http_client.tsx new file mode 100644 index 0000000..732184f --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/http_client.tsx @@ -0,0 +1,95 @@ +import GenericForm, { random_token } from './generic_form'; +import type { Field } from './generic_form'; + +export interface HTTPClientFormProps { + data?: OneBotConfig['network']['httpClients'][0] + onClose: () => void + onSubmit: (data: OneBotConfig['network']['httpClients'][0]) => Promise +} + +type HTTPClientFormType = OneBotConfig['network']['httpClients']; + +const HTTPClientForm: React.FC = ({ + data, + onClose, + onSubmit, +}) => { + const defaultValues: HTTPClientFormType[0] = { + enable: false, + name: '', + url: 'http://localhost:8080', + reportSelfMessage: false, + messagePostFormat: 'array', + token: random_token(16), + debug: false, + }; + + const fields: Field<'httpClients'>[] = [ + { + name: 'enable', + label: '启用', + type: 'switch', + description: '保存后启用此配置', + colSpan: 1, + }, + { + name: 'debug', + label: '开启Debug', + type: 'switch', + description: '是否开启调试模式', + colSpan: 1, + }, + { + name: 'name', + label: '名称', + type: 'input', + placeholder: '请输入名称', + isRequired: true, + isDisabled: !!data, + }, + { + name: 'url', + label: 'URL', + type: 'input', + placeholder: '请输入URL', + isRequired: true, + }, + { + name: 'reportSelfMessage', + label: '上报自身消息', + type: 'switch', + description: '是否上报自身消息', + colSpan: 1, + }, + { + name: 'messagePostFormat', + label: '消息格式', + type: 'select', + placeholder: '请选择消息格式', + isRequired: true, + options: [ + { key: 'array', value: 'Array' }, + { key: 'string', value: 'String' }, + ], + colSpan: 1, + }, + { + name: 'token', + label: 'Token', + type: 'input', + placeholder: '请输入Token', + }, + ]; + + return ( + + ); +}; + +export default HTTPClientForm; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/http_server.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/http_server.tsx new file mode 100644 index 0000000..3df9384 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/http_server.tsx @@ -0,0 +1,110 @@ +import GenericForm, { random_token } from './generic_form'; +import type { Field } from './generic_form'; + +export interface HTTPServerFormProps { + data?: OneBotConfig['network']['httpServers'][0]; + onClose: () => void; + onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise; +} + +type HTTPServerFormType = OneBotConfig['network']['httpServers']; + +const HTTPServerForm: React.FC = ({ + data, + onClose, + onSubmit, +}) => { + const defaultValues: HTTPServerFormType[0] = { + enable: false, + name: '', + host: '127.0.0.1', + port: 3000, + enableCors: true, + enableWebsocket: false, + messagePostFormat: 'array', + token: random_token(16), + debug: false, + }; + + const fields: Field<'httpServers'>[] = [ + { + name: 'enable', + label: '启用', + type: 'switch', + description: '保存后启用此配置', + colSpan: 1, + }, + { + name: 'debug', + label: '开启Debug', + type: 'switch', + description: '是否开启调试模式', + colSpan: 1, + }, + { + name: 'name', + label: '名称', + type: 'input', + placeholder: '请输入名称', + isRequired: true, + isDisabled: !!data, + }, + { + name: 'host', + label: 'Host', + type: 'input', + placeholder: '请输入主机地址', + isRequired: true, + }, + { + name: 'port', + label: 'Port', + type: 'input', + placeholder: '请输入端口', + isRequired: true, + }, + { + name: 'enableCors', + label: '启用CORS', + type: 'switch', + description: '是否启用CORS跨域', + colSpan: 1, + }, + { + name: 'enableWebsocket', + label: '启用Websocket', + type: 'switch', + description: '是否启用Websocket', + colSpan: 1, + }, + { + name: 'messagePostFormat', + label: '消息格式', + type: 'select', + placeholder: '请选择消息格式', + isRequired: true, + options: [ + { key: 'array', value: 'Array' }, + { key: 'string', value: 'String' }, + ], + }, + { + name: 'token', + label: 'Token', + type: 'input', + placeholder: '请输入Token', + }, + ]; + + return ( + + ); +}; + +export default HTTPServerForm; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/http_sse.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/http_sse.tsx new file mode 100644 index 0000000..18f23cc --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/http_sse.tsx @@ -0,0 +1,120 @@ +import GenericForm, { random_token } from './generic_form'; +import type { Field } from './generic_form'; + +export interface HTTPServerSSEFormProps { + data?: OneBotConfig['network']['httpSseServers'][0]; + onClose: () => void; + onSubmit: ( + data: OneBotConfig['network']['httpSseServers'][0] + ) => Promise; +} + +type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers']; + +const HTTPServerSSEForm: React.FC = ({ + data, + onClose, + onSubmit, +}) => { + const defaultValues: HTTPServerSSEFormType[0] = { + enable: false, + name: '', + host: '127.0.0.1', + port: 3000, + enableCors: true, + enableWebsocket: false, + messagePostFormat: 'array', + token: random_token(16), + debug: false, + reportSelfMessage: false, + }; + + const fields: Field<'httpSseServers'>[] = [ + { + name: 'enable', + label: '启用', + type: 'switch', + description: '保存后启用此配置', + colSpan: 1, + }, + { + name: 'debug', + label: '开启Debug', + type: 'switch', + description: '是否开启调试模式', + colSpan: 1, + }, + { + name: 'name', + label: '名称', + type: 'input', + placeholder: '请输入名称', + isRequired: true, + isDisabled: !!data, + }, + { + name: 'host', + label: 'Host', + type: 'input', + placeholder: '请输入主机地址', + isRequired: true, + }, + { + name: 'port', + label: 'Port', + type: 'input', + placeholder: '请输入端口', + isRequired: true, + }, + { + name: 'enableCors', + label: '启用CORS', + type: 'switch', + description: '是否启用CORS跨域', + colSpan: 1, + }, + { + name: 'enableWebsocket', + label: '启用Websocket', + type: 'switch', + description: '是否启用Websocket', + colSpan: 1, + }, + { + name: 'messagePostFormat', + label: '消息格式', + type: 'select', + placeholder: '请选择消息格式', + isRequired: true, + options: [ + { key: 'array', value: 'Array' }, + { key: 'string', value: 'String' }, + ], + }, + { + name: 'token', + label: 'Token', + type: 'input', + placeholder: '请输入Token', + }, + { + name: 'reportSelfMessage', + label: '上报自身消息', + type: 'switch', + description: '是否上报自身消息', + colSpan: 1, + }, + ]; + + return ( + + ); +}; + +export default HTTPServerSSEForm; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/mcp_server.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/mcp_server.tsx new file mode 100644 index 0000000..6f3a390 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/mcp_server.tsx @@ -0,0 +1,85 @@ +import GenericForm, { random_token } from './generic_form'; +import type { Field } from './generic_form'; + +export interface McpServerFormProps { + data?: OneBotConfig['network']['mcpServers'][0]; + onClose: () => void; + onSubmit: ( + data: OneBotConfig['network']['mcpServers'][0] + ) => Promise; +} + +type McpServerFormType = OneBotConfig['network']['mcpServers']; + +const McpServerForm: React.FC = ({ + data, + onClose, + onSubmit, +}) => { + const defaultValues: McpServerFormType[0] = { + enable: false, + name: '', + host: '127.0.0.1', + port: 3002, + token: random_token(16), + debug: false, + }; + + const fields: Field<'mcpServers'>[] = [ + { + name: 'enable', + label: '启用', + type: 'switch', + description: '保存后启用此配置', + colSpan: 1, + }, + { + name: 'debug', + label: '开启Debug', + type: 'switch', + description: '是否开启调试模式', + colSpan: 1, + }, + { + name: 'name', + label: '名称', + type: 'input', + placeholder: '请输入名称', + isRequired: true, + isDisabled: !!data, + }, + { + name: 'host', + label: 'Host', + type: 'input', + placeholder: '请输入主机地址', + isRequired: true, + }, + { + name: 'port', + label: 'Port', + type: 'input', + placeholder: '请输入端口', + isRequired: true, + }, + { + name: 'token', + label: 'Token', + type: 'input', + placeholder: '留空则不鉴权(仅限本机使用)', + description: 'AI 宿主以 Authorization: Bearer 连接', + }, + ]; + + return ( + + ); +}; + +export default McpServerForm; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/modal.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/modal.tsx new file mode 100644 index 0000000..78602e2 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/modal.tsx @@ -0,0 +1,171 @@ +import { Modal, ModalContent, ModalHeader } from '@heroui/modal'; +import toast from 'react-hot-toast'; + +import useConfig from '@/hooks/use-config'; +import useDialog from '@/hooks/use-dialog'; + +import HTTPClientForm from './http_client'; +import HTTPServerForm from './http_server'; +import HTTPServerSSEForm from './http_sse'; +import McpServerForm from './mcp_server'; +import WebsocketClientForm from './ws_client'; +import WebsocketServerForm from './ws_server'; + +const modalTitle = { + httpServers: 'HTTP Server', + httpClients: 'HTTP Client', + httpSseServers: 'HTTP SSE Server', + websocketServers: 'Websocket Server', + websocketClients: 'Websocket Client', + mcpServers: 'MCP Server', +}; + +export interface NetworkFormModalProps< + T extends keyof OneBotConfig['network'] +> { + isOpen: boolean; + field: T; + data?: OneBotConfig['network'][T][0]; + onOpenChange: (isOpen: boolean) => void; +} + +const NetworkFormModal = ( + props: NetworkFormModalProps +) => { + const { isOpen, onOpenChange, field, data } = props; + const { createNetworkConfig, updateNetworkConfig } = useConfig(); + const dialog = useDialog(); + const isCreate = !data; + + const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => { + const saveData = async (dataToSave: OneBotConfig['network'][typeof field][0]) => { + try { + if (isCreate) { + await createNetworkConfig(field, dataToSave); + } else { + await updateNetworkConfig(field, dataToSave); + } + toast.success('保存配置成功'); + } catch (error) { + const msg = (error as Error).message; + + toast.error(`保存配置失败: ${msg}`); + + throw error; + } + }; + + if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) { + const serverData = data as any; + if (!serverData.token && serverData.host !== '127.0.0.1') { + await new Promise((resolve, reject) => { + dialog.confirm({ + title: '安全警告', + content: ( +
+

检测到未配置Token,这可能导致安全风险。确认要继续吗?

+

(未配置Token时,Host将被强制限制为 127.0.0.1)

+
+ ), + onConfirm: async () => { + serverData.host = '127.0.0.1'; + try { + await saveData(serverData); + resolve(); + } catch (e) { + reject(e); + } + }, + onCancel: () => { + reject(new Error('Cancelled')); + }, + }); + }); + return; + } + } + await saveData(data); + }; + + const renderFormComponent = (onClose: () => void) => { + switch (field) { + case 'httpServers': + return ( + + ); + case 'httpClients': + return ( + + ); + case 'websocketServers': + return ( + + ); + case 'websocketClients': + return ( + + ); + case 'httpSseServers': + return ( + + ); + case 'mcpServers': + return ( + + ); + default: + return null; + } + }; + + return ( + + + {(onClose) => ( + <> + + {modalTitle[field]} + + {isCreate ? '新建一个接入点' : `编辑「${data?.name ?? ''}」`} + + + {renderFormComponent(onClose)} + + )} + + + ); +}; + +export default NetworkFormModal; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/ws_client.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/ws_client.tsx new file mode 100644 index 0000000..4cbece6 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/ws_client.tsx @@ -0,0 +1,123 @@ +import GenericForm, { random_token } from './generic_form'; +import type { Field } from './generic_form'; + +export interface WebsocketClientFormProps { + data?: OneBotConfig['network']['websocketClients'][0]; + onClose: () => void; + onSubmit: ( + data: OneBotConfig['network']['websocketClients'][0] + ) => Promise; +} + +type WebsocketClientFormType = OneBotConfig['network']['websocketClients']; + +const WebsocketClientForm: React.FC = ({ + data, + onClose, + onSubmit, +}) => { + const defaultValues: WebsocketClientFormType[0] = { + enable: false, + name: '', + url: 'ws://localhost:8082', + reportSelfMessage: false, + messagePostFormat: 'array', + token: random_token(16), + debug: false, + heartInterval: 30000, + reconnectInterval: 30000, + verifyCertificate: true, + }; + + const fields: Field<'websocketClients'>[] = [ + { + name: 'enable', + label: '启用', + type: 'switch', + description: '保存后启用此配置', + colSpan: 1, + }, + { + name: 'debug', + label: '开启Debug', + type: 'switch', + description: '是否开启调试模式', + colSpan: 1, + }, + { + name: 'name', + label: '名称', + type: 'input', + placeholder: '请输入名称', + isRequired: true, + isDisabled: !!data, + }, + { + name: 'url', + label: 'URL', + type: 'input', + placeholder: '请输入URL', + isRequired: true, + }, + { + name: 'reportSelfMessage', + label: '上报自身消息', + type: 'switch', + description: '是否上报自身消息', + colSpan: 1, + }, + { + name: 'messagePostFormat', + label: '消息格式', + type: 'select', + placeholder: '请选择消息格式', + isRequired: true, + options: [ + { key: 'array', value: 'Array' }, + { key: 'string', value: 'String' }, + ], + colSpan: 1, + }, + { + name: 'token', + label: 'Token', + type: 'input', + placeholder: '请输入Token', + }, + { + name: 'heartInterval', + label: '心跳间隔', + type: 'input', + placeholder: '请输入心跳间隔', + isRequired: true, + colSpan: 1, + }, + { + name: 'reconnectInterval', + label: '重连间隔', + type: 'input', + placeholder: '请输入重连间隔', + isRequired: true, + colSpan: 1, + }, + { + name: 'verifyCertificate', + label: 'SSL证书验证', + type: 'switch', + description: '是否验证SSL证书(wss连接)。关闭后可连接自签名证书的服务器,但会降低安全性', + colSpan: 2, + }, + ]; + + return ( + + ); +}; + +export default WebsocketClientForm; diff --git a/packages/snowcat-webui-frontend/src/components/network_edit/ws_server.tsx b/packages/snowcat-webui-frontend/src/components/network_edit/ws_server.tsx new file mode 100644 index 0000000..e3b2adc --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/network_edit/ws_server.tsx @@ -0,0 +1,122 @@ +import GenericForm, { random_token } from './generic_form'; +import type { Field } from './generic_form'; + +export interface WebsocketServerFormProps { + data?: OneBotConfig['network']['websocketServers'][0] + onClose: () => void + onSubmit: ( + data: OneBotConfig['network']['websocketServers'][0] + ) => Promise +} + +type WebsocketServerFormType = OneBotConfig['network']['websocketServers']; + +const WebsocketServerForm: React.FC = ({ + data, + onClose, + onSubmit, +}) => { + const defaultValues: WebsocketServerFormType[0] = { + enable: false, + name: '', + host: '127.0.0.1', + port: 3001, + reportSelfMessage: false, + enableForcePushEvent: true, + messagePostFormat: 'array', + token: random_token(16), + debug: false, + heartInterval: 30000, + }; + + const fields: Field<'websocketServers'>[] = [ + { + name: 'enable', + label: '启用', + type: 'switch', + description: '保存后启用此配置', + colSpan: 1, + }, + { + name: 'debug', + label: '开启Debug', + type: 'switch', + description: '是否开启调试模式', + colSpan: 1, + }, + { + name: 'name', + label: '名称', + type: 'input', + placeholder: '请输入名称', + isRequired: true, + isDisabled: !!data, + }, + { + name: 'host', + label: 'Host', + type: 'input', + placeholder: '请输入主机地址', + isRequired: true, + }, + { + name: 'port', + label: 'Port', + type: 'input', + placeholder: '请输入端口', + isRequired: true, + colSpan: 1, + }, + { + name: 'messagePostFormat', + label: '消息格式', + type: 'select', + placeholder: '请选择消息格式', + isRequired: true, + options: [ + { key: 'array', value: 'Array' }, + { key: 'string', value: 'String' }, + ], + colSpan: 1, + }, + { + name: 'reportSelfMessage', + label: '上报自身消息', + type: 'switch', + description: '是否上报自身消息', + colSpan: 1, + }, + { + name: 'enableForcePushEvent', + label: '强制推送事件', + type: 'switch', + description: '是否强制推送事件', + colSpan: 1, + }, + { + name: 'token', + label: 'Token', + type: 'input', + placeholder: '请输入Token', + }, + { + name: 'heartInterval', + label: '心跳间隔', + type: 'input', + placeholder: '请输入心跳间隔', + isRequired: true, + }, + ]; + + return ( + + ); +}; + +export default WebsocketServerForm; diff --git a/packages/snowcat-webui-frontend/src/components/new_device_verify.tsx b/packages/snowcat-webui-frontend/src/components/new_device_verify.tsx new file mode 100644 index 0000000..7cc4e00 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/new_device_verify.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { Button } from '@heroui/button'; +import { Spinner } from '@heroui/spinner'; +import { QRCodeSVG } from 'qrcode.react'; + +import QQManager from '@/controllers/qq_manager'; + +interface NewDeviceVerifyProps { + /** jumpUrl from loginErrorInfo */ + jumpUrl: string; + /** QQ uin for OIDB requests */ + uin: string; + /** Called when QR verification is confirmed, passes str_nt_succ_token */ + onVerified: (token: string) => void; + /** Called when user cancels */ + onCancel?: () => void; +} + +type QRStatus = 'loading' | 'waiting' | 'scanned' | 'confirmed' | 'error'; + +const NewDeviceVerify: React.FC = ({ + jumpUrl, + uin, + onVerified, + onCancel, +}) => { + const [qrUrl, setQrUrl] = useState(''); + const [status, setStatus] = useState('loading'); + const [errorMsg, setErrorMsg] = useState(''); + const pollTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const startPolling = useCallback((token: string) => { + stopPolling(); + pollTimerRef.current = setInterval(async () => { + if (!mountedRef.current) return; + try { + const result = await QQManager.pollNewDeviceQR(uin, token); + if (!mountedRef.current) return; + const s = result?.uint32_guarantee_status; + if (s === 3) { + setStatus('scanned'); + } else if (s === 1) { + stopPolling(); + setStatus('confirmed'); + const ntToken = result?.str_nt_succ_token || ''; + onVerified(ntToken); + } + // s === 0 means still waiting, do nothing + } catch { + // Ignore poll errors, keep polling + } + }, 2500); + }, [uin, onVerified, stopPolling]); + + const fetchQRCode = useCallback(async () => { + setStatus('loading'); + setErrorMsg(''); + try { + const result = await QQManager.getNewDeviceQRCode(uin, jumpUrl); + if (!mountedRef.current) return; + if (result?.str_url) { + setQrUrl(result.str_url); + setStatus('waiting'); + // bytes_token 用于轮询,如果 OIDB 未返回则用空字符串 + startPolling(result.bytes_token || ''); + } else { + setStatus('error'); + setErrorMsg('获取二维码失败,请重试'); + } + } catch (e) { + if (!mountedRef.current) return; + setStatus('error'); + setErrorMsg((e as Error).message || '获取二维码失败'); + } + }, [uin, jumpUrl, startPolling]); + + useEffect(() => { + mountedRef.current = true; + fetchQRCode(); + return () => { + mountedRef.current = false; + stopPolling(); + }; + }, [fetchQRCode, stopPolling]); + + const statusText: Record = { + loading: '正在获取二维码...', + waiting: '请使用手机QQ扫描二维码完成验证', + scanned: '已扫描,请在手机上确认', + confirmed: '验证成功,正在登录...', + error: errorMsg || '获取二维码失败', + }; + + const statusColor: Record = { + loading: 'text-default-500', + waiting: 'text-warning', + scanned: 'text-primary', + confirmed: 'text-success', + error: 'text-danger', + }; + + return ( +
+

+ 检测到新设备登录,请使用手机QQ扫描下方二维码完成验证 +

+ +
+ {status === 'loading' + ? ( +
+ +
+ ) + : status === 'error' + ? ( +
+

{errorMsg}

+ +
+ ) + : ( +
+ +
+ )} + +

+ {statusText[status]} +

+
+ +
+ {status === 'waiting' && ( + + )} + +
+
+ ); +}; + +export default NewDeviceVerify; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/api/debug.tsx b/packages/snowcat-webui-frontend/src/components/onebot/api/debug.tsx new file mode 100644 index 0000000..d0f34c1 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/api/debug.tsx @@ -0,0 +1,477 @@ +import { Button } from '@heroui/button'; + +import { Input } from '@heroui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Tooltip } from '@heroui/tooltip'; +import { Tab, Tabs } from '@heroui/tabs'; +import { Chip } from '@heroui/chip'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import clsx from 'clsx'; +import { forwardRef, useEffect, useImperativeHandle, useState, useCallback, useMemo } from 'react'; +import toast from 'react-hot-toast'; +import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5'; +import { TbCode, TbMessageCode } from 'react-icons/tb'; + +import key from '@/const/key'; +import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'; + +import ChatInputModal from '@/components/chat_input/modal'; +import CodeEditor from '@/components/code_editor'; +import PageLoading from '@/components/page_loading'; + +import { request } from '@/utils/request'; + +import { BaseResponseSchema, parseTypeBox, generateDefaultFromTypeBox } from '@/utils/typebox'; +import { Type } from '@sinclair/typebox'; + +import DisplayStruct from './display_struct'; + +export interface OneBotApiDebugProps { + path: OneBotHttpApiPath; + data: OneBotHttpApiContent; + adapterName?: string; +} + +export interface OneBotApiDebugRef { + setRequestBody: (value: string) => void; + sendWithBody: (value: string) => void; + focusRequestEditor: () => void; +} + +const OneBotApiDebug = forwardRef((props, ref) => { + const { path, data, adapterName } = props; + const currentURL = new URL(window.location.origin); + currentURL.port = '3000'; + const defaultHttpUrl = currentURL.href; + const defaultToken = localStorage.getItem('token') || ''; + const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, { + url: defaultHttpUrl, + token: defaultToken, + }); + + const [requestBody, setRequestBody] = useState('{}'); + const [responseContent, setResponseContent] = useState(''); + const [isFetching, setIsFetching] = useState(false); + const [activeTab, setActiveTab] = useState('request'); + const [responseExpanded, setResponseExpanded] = useState(true); + const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null); + // Height Resizing Logic + const [responseHeight, setResponseHeight] = useState(240); + const [storedHeight, setStoredHeight] = useLocalStorage('snowcat_debug_response_height', 240); + + // 使用 useMemo 缓存解析结果,避免每次渲染都重新解析 + const parsedRequest = useMemo(() => { + try { + return parseTypeBox(data?.payload); + } catch (e) { + console.error('Error parsing request schema:', e); + return []; + } + }, [data?.payload]); + + // 将返回值的 data 结构包装进 BaseResponseSchema 进行展示 + // 使用解构属性的方式重新构建对象,确保 parseTypeBox 能够识别为 object 类型 + const parsedResponse = useMemo(() => { + try { + const wrappedResponseSchema = Type.Object({ + ...BaseResponseSchema.properties, + data: data?.response || Type.Any({ description: '数据' }), + }); + return parseTypeBox(wrappedResponseSchema); + } catch (e) { + console.error('Error parsing response schema:', e); + return []; + } + }, [data?.response]); + const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); + const hasBackground = !!backgroundImage; + + const sendRequest = async (bodyOverride?: string) => { + if (isFetching) return; + setIsFetching(true); + setResponseStatus(null); + const r = toast.loading('正在发送请求...'); + + try { + const parsedRequestBody = JSON.parse(bodyOverride ?? requestBody); + + // 如果有 adapterName,走后端转发 + if (adapterName) { + request.post(`/api/Debug/call/${adapterName}`, { + action: path, + params: parsedRequestBody, + }, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }).then((res) => { + if (res.data.code === 0) { + setResponseContent(JSON.stringify(res.data.data, null, 2)); + setResponseStatus({ code: 200, text: 'OK' }); + } else { + setResponseContent(JSON.stringify(res.data, null, 2)); + setResponseStatus({ code: 500, text: res.data.message }); + } + setResponseExpanded(true); + toast.success('请求成功'); + }).catch((err) => { + toast.error('请求失败:' + err.message); + setResponseContent(JSON.stringify({ error: err.message }, null, 2)); + setResponseStatus({ code: 500, text: 'Error' }); + setResponseExpanded(true); + }).finally(() => { + setIsFetching(false); + toast.dismiss(r); + }); + return; + } + + // 回退到旧逻辑 (直接请求) + const requestURL = new URL(httpConfig.url); + requestURL.pathname = path; + request + .post(requestURL.href, parsedRequestBody, { + headers: { + Authorization: `Bearer ${httpConfig.token}`, + }, + }) // 移除 responseType: 'text',以便 axios 自动解析 JSON + .then((res) => { + setResponseContent(JSON.stringify(res.data, null, 2)); + setResponseStatus({ code: res.status, text: res.statusText }); + setResponseExpanded(true); + toast.success('请求成功'); + }) + .catch((err) => { + toast.error('请求失败:' + err.message); + setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2)); + if (err.response) { + setResponseStatus({ code: err.response.status, text: err.response.statusText }); + } + setResponseExpanded(true); + }) + .finally(() => { + setIsFetching(false); + toast.dismiss(r); + }); + } catch (_error) { + toast.error('请求体 JSON 格式错误'); + setIsFetching(false); + toast.dismiss(r); + } + }; + + useImperativeHandle(ref, () => ({ + setRequestBody: (value: string) => { + setActiveTab('request'); + setRequestBody(value); + }, + sendWithBody: (value: string) => { + setActiveTab('request'); + setRequestBody(value); + // 直接用 override 发送,避免 setState 异步导致拿到旧值 + sendRequest(value); + }, + focusRequestEditor: () => { + setActiveTab('request'); + }, + })); + + useEffect(() => { + if (data?.payloadExample) { + setRequestBody(JSON.stringify(data.payloadExample, null, 2)); + } else { + try { + setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2)); + } catch (e) { + console.error('Error generating default:', e); + setRequestBody('{}'); + } + } + setResponseContent(''); + setResponseStatus(null); + }, [path]); + + // Sync from storage on mount + useEffect(() => { + setResponseHeight(storedHeight); + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const startY = e.clientY; + const startHeight = responseHeight; + let currentH = startHeight; + let frameId: number; + + const handleMouseMove = (mv: MouseEvent) => { + if (frameId) cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(() => { + const delta = startY - mv.clientY; + currentH = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)); + setResponseHeight(currentH); + }); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + if (frameId) cancelAnimationFrame(frameId); + setStoredHeight(currentH); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [responseHeight, setStoredHeight]); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + const startY = touch.clientY; + const startHeight = responseHeight; + let currentH = startHeight; + let frameId: number; + + const handleTouchMove = (mv: TouchEvent) => { + if (frameId) cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(() => { + const mvTouch = mv.touches[0]; + const delta = startY - mvTouch.clientY; + currentH = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)); + setResponseHeight(currentH); + }); + }; + + const handleTouchEnd = () => { + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + if (frameId) cancelAnimationFrame(frameId); + setStoredHeight(currentH); + }; + + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleTouchEnd); + }, [responseHeight, setStoredHeight]); + + return ( + +
+ {/* 1. Top Toolbar: URL & Actions */} +
+ {/* Method & Path */} + {/* Method & Path */} + {/* Method & Path */} +
+
+ {path} +
+
+ + {/* Actions */} +
+ + + + + +
+

Debug Setup

+ setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='bordered' /> + setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='bordered' /> +
+
+
+ + +
+
+ + {/* 2. Main Workspace (Request) - Flexible Height */} +
+
+ {/* Request Toolbar */} +
+ + + + + +
+ + {(onOpen) => ( + + + + )} + + + + +
+
+ + {/* Content Area */} +
+ {activeTab === 'request' + ? ( +
+ setRequestBody(value ?? '')} + language='json' + options={{ + minimap: { enabled: false }, + fontSize: 13, + fontFamily: 'JetBrains Mono, monospace', + scrollBeyondLastLine: false, + wordWrap: 'on', + padding: { top: 16, bottom: 16 }, + lineNumbersMinChars: 3, + chromeless: true, + backgroundColor: 'transparent', + }} + /> +
+ ) + : ( +
+
+

Request Params

+ +
+
+
+

Response Data

+ +
+
+ )} +
+
+
+ + {/* 3. Response Panel (Bottom) */} +
+ {/* Resize Handle / Header */} +
setResponseExpanded(!responseExpanded)} + > + {/* Invisible Draggable Area */} + {responseExpanded && ( +
{ e.stopPropagation(); handleMouseDown(e); }} + onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }} + onClick={(e) => e.stopPropagation()} + /> + )} + +
+
+ +
+ Response + + {responseStatus && ( + = 200 && responseStatus.code < 300 ? 'success' : 'danger'} className='h-5 text-[10px] font-mono border-none bg-transparent pl-0'> + {responseStatus.code} {responseStatus.text} + + )} +
+ + +
+ + {/* Response Editor */} + {responseExpanded && ( +
+ +
+ +
+
+ )} +
+ +
+ ); +}); + +export default OneBotApiDebug; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/api/display_struct.tsx b/packages/snowcat-webui-frontend/src/components/onebot/api/display_struct.tsx new file mode 100644 index 0000000..96bb10b --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/api/display_struct.tsx @@ -0,0 +1,222 @@ +import { Chip } from '@heroui/chip'; +import { Tooltip } from '@heroui/tooltip'; +import { motion } from 'motion/react'; +import React, { useState } from 'react'; +import toast from 'react-hot-toast'; +import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb'; + +import type { LiteralValue, ParsedSchema } from '@/utils/zod'; + +interface DisplayStructProps { + schema: ParsedSchema | ParsedSchema[]; +} + +const SchemaType = ({ + type, + value, +}: { + type: string; + value?: LiteralValue; +}) => { + let name = type; + switch (type) { + case 'union': + name = '联合类型'; + break; + case 'value': + name = '固定值'; + break; + } + let chipColor: 'primary' | 'success' | 'primary' | 'warning' | 'secondary' = + 'primary'; + switch (type) { + case 'enum': + chipColor = 'warning'; + break; + case 'union': + chipColor = 'secondary'; + break; + case 'array': + chipColor = 'primary'; + break; + case 'object': + chipColor = 'success'; + break; + } + + return ( + + {name} + {type === 'value' && ( + + {value} + + )} + + ); +}; + +const SchemaLabel: React.FC<{ + schema: ParsedSchema; +}> = ({ schema }) => ( + <> + {Array.isArray(schema.type) + ? ( + schema.type.map((type) => ( + + )) + ) + : ( + + )} + {schema.optional && ( + + 可选 + + )} + {schema.description && ( + {schema.description} + )} + +); + +const SchemaContainer: React.FC<{ + schema: ParsedSchema; + children: React.ReactNode; +}> = ({ schema, children }) => { + const [expanded, setExpanded] = useState(false); + + const toggleExpand = () => setExpanded(!expanded); + + return ( +
+
+ + + + + { + e.stopPropagation(); + navigator.clipboard.writeText(schema.name || ''); + toast.success('已复制'); + }} + > + {schema.name} + + + +
+ +
+ {children} + +
+ ); +}; + +const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => { + // 处理循环引用和截断的情况,直接显示提示而不继续递归 + if (schema.isCircularRef || schema.isTruncated) { + return ( +
+ {schema.name && ( + {schema.name} + )} + + {schema.description || '...'} + +
+ ); + } + + if (schema.type === 'object') { + return ( + + {schema.children && schema.children.length > 0 + ? ( + schema.children.map((child, i) => ( + + )) + ) + : ( +
{'{}'}
+ )} +
+ ); + } + + if (schema.type === 'array' || schema.type === 'union') { + return ( + + {schema.children?.map((child, i) => ( + + ))} + + ); + } + + if (schema.type === 'enum' && Array.isArray(schema.enum)) { + return ( + +
+ {schema.enum?.map((value, i) => ( + + {value?.toString()} + + ))} +
+
+ ); + } + + return ( +
+ + { + e.stopPropagation(); + navigator.clipboard.writeText(schema.name || ''); + toast.success('已复制'); + }} + > + {schema.name} + + + +
+ ); +}; + +const DisplayStruct: React.FC = ({ schema }) => { + return ( +
+ {Array.isArray(schema) + ? ( + schema.map((s, i) => ) + ) + : ( + + )} +
+ ); +}; + +export default DisplayStruct; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/display_card/message.tsx b/packages/snowcat-webui-frontend/src/components/onebot/display_card/message.tsx new file mode 100644 index 0000000..1dd3ef0 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/display_card/message.tsx @@ -0,0 +1,122 @@ +import { Avatar } from '@heroui/avatar'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import clsx from 'clsx'; + +import { isOB11GroupMessage } from '@/utils/onebot'; + +import type { + OB11GroupMessage, + OB11Message, + OB11PrivateMessage, +} from '@/types/onebot'; + +import { renderMessageContent } from '../render_message'; + +export interface OneBotMessageProps { + data: OB11Message; +} + +export interface OneBotMessageGroupProps { + data: OB11GroupMessage; +} + +export interface OneBotMessagePrivateProps { + data: OB11PrivateMessage; +} + +const MessageContent: React.FC<{ data: OB11Message; }> = ({ data }) => { + return ( +
+
+
+ {isOB11GroupMessage(data) && data.sender.card && ( + {data.sender.card} + )} + + {data.sender.nickname} + +
+
({data.sender.user_id})
+
消息ID: {data.message_id}
+
+ + +
+
+ 点击查看完整消息 +
+ {Array.isArray(data.message) + ? renderMessageContent(data.message, true) + : data.raw_message} +
+
+ +
+ {Array.isArray(data.message) + ? renderMessageContent(data.message) + : data.raw_message} +
+
+
+
+ ); +}; + +const OneBotMessageGroup: React.FC = ({ data }) => { + return ( +
+
+ +
群 {data.group_id}
+
+
+ + +
+
+ ); +}; + +const OneBotMessagePrivate: React.FC = ({ + data, +}) => { + return ( +
+ + +
+ ); +}; + +const OneBotMessage: React.FC = ({ data }) => { + if (data.message_type === 'group') { + return ; + } else if (data.message_type === 'private') { + return ; + } else { + return
未知消息类型
; + } +}; + +export default OneBotMessage; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/display_card/meta.tsx b/packages/snowcat-webui-frontend/src/components/onebot/display_card/meta.tsx new file mode 100644 index 0000000..e5c7faa --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/display_card/meta.tsx @@ -0,0 +1,60 @@ +import { Chip } from '@heroui/chip'; + +import { getLifecycleColor, getLifecycleName } from '@/utils/onebot'; + +import type { + OB11Meta, + OneBot11Heartbeat, + OneBot11Lifecycle, +} from '@/types/onebot'; + +export interface OneBotDisplayMetaProps { + data: OB11Meta +} + +export interface OneBotDisplayMetaHeartbeatProps { + data: OneBot11Heartbeat +} + +export interface OneBotDisplayMetaLifecycleProps { + data: OneBot11Lifecycle +} + +const OneBotDisplayMetaHeartbeat: React.FC = ({ + data, +}) => { + return ( +
+ 心跳 + 间隔 {data.status.interval}ms +
+ ); +}; + +const OneBotDisplayMetaLifecycle: React.FC = ({ + data, +}) => { + return ( +
+ 生命周期 + + {getLifecycleName(data.sub_type)} + +
+ ); +}; + +const OneBotDisplayMeta: React.FC = ({ data }) => { + return ( +
+ {data.meta_event_type === 'lifecycle' && ( + + )} + {data.meta_event_type === 'heartbeat' && ( + + )} +
+ ); +}; + +export default OneBotDisplayMeta; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/display_card/notice.tsx b/packages/snowcat-webui-frontend/src/components/onebot/display_card/notice.tsx new file mode 100644 index 0000000..0e8e8a5 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/display_card/notice.tsx @@ -0,0 +1,292 @@ +import { Chip } from '@heroui/chip'; + +import { getNoticeTypeName } from '@/utils/onebot'; + +import { + OB11Notice, + OB11NoticeType, + OneBot11FriendAdd, + OneBot11FriendRecall, + OneBot11GroupAdmin, + OneBot11GroupBan, + OneBot11GroupCard, + OneBot11GroupDecrease, + OneBot11GroupEssence, + OneBot11GroupIncrease, + OneBot11GroupMessageReaction, + OneBot11GroupRecall, + OneBot11GroupUpload, + OneBot11Honor, + OneBot11LuckyKing, + OneBot11Poke, +} from '@/types/onebot'; + +export interface OneBotNoticeProps { + data: OB11Notice +} + +export interface NoticeProps { + data: T +} + +const GroupUploadNotice: React.FC> = ({ + data, +}) => { + const { group_id, user_id, file } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
文件名: {file.name}
+
文件大小: {file.size} 字节
+ + ); +}; + +const GroupAdminNotice: React.FC> = ({ + data, +}) => { + const { group_id, user_id, sub_type } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
变动类型: {sub_type === 'set' ? '设置管理员' : '取消管理员'}
+ + ); +}; + +const GroupDecreaseNotice: React.FC> = ({ + data, +}) => { + const { group_id, operator_id, user_id, sub_type } = data; + return ( + <> +
群号: {group_id}
+
操作者ID: {operator_id}
+
用户ID: {user_id}
+
原因: {sub_type}
+ + ); +}; + +const GroupIncreaseNotice: React.FC> = ({ + data, +}) => { + const { group_id, operator_id, user_id, sub_type } = data; + return ( + <> +
群号: {group_id}
+
操作者ID: {operator_id}
+
用户ID: {user_id}
+
增加类型: {sub_type}
+ + ); +}; + +const GroupBanNotice: React.FC> = ({ data }) => { + const { group_id, operator_id, user_id, sub_type, duration } = data; + return ( + <> +
群号: {group_id}
+
操作者ID: {operator_id}
+
用户ID: {user_id}
+
禁言类型: {sub_type}
+
禁言时长: {duration} 秒
+ + ); +}; + +const FriendAddNotice: React.FC> = ({ + data, +}) => { + const { user_id } = data; + return ( + <> +
用户ID: {user_id}
+ + ); +}; + +const GroupRecallNotice: React.FC> = ({ + data, +}) => { + const { group_id, user_id, operator_id, message_id } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
操作者ID: {operator_id}
+
消息ID: {message_id}
+ + ); +}; + +const FriendRecallNotice: React.FC> = ({ + data, +}) => { + const { user_id, message_id } = data; + return ( + <> +
用户ID: {user_id}
+
消息ID: {message_id}
+ + ); +}; + +const PokeNotice: React.FC> = ({ data }) => { + const { group_id, user_id, target_id } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
目标ID: {target_id}
+ + ); +}; + +const LuckyKingNotice: React.FC> = ({ + data, +}) => { + const { group_id, user_id, target_id } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
目标ID: {target_id}
+ + ); +}; + +const HonorNotice: React.FC> = ({ data }) => { + const { group_id, user_id, honor_type } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
荣誉类型: {honor_type}
+ + ); +}; + +const GroupMessageReactionNotice: React.FC< + NoticeProps +> = ({ data }) => { + const { group_id, user_id, message_id, likes } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
消息ID: {message_id}
+
+ 表情回应: + {likes + .map((like) => `表情ID: ${like.emoji_id}, 数量: ${like.count}`) + .join(', ')} +
+ + ); +}; + +const GroupEssenceNotice: React.FC> = ({ + data, +}) => { + const { group_id, message_id, sender_id, operator_id, sub_type } = data; + return ( + <> +
群号: {group_id}
+
消息ID: {message_id}
+
发送者ID: {sender_id}
+
操作者ID: {operator_id}
+
操作类型: {sub_type}
+ + ); +}; + +const GroupCardNotice: React.FC> = ({ + data, +}) => { + const { group_id, user_id, card_new, card_old } = data; + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
新名片: {card_new}
+
旧名片: {card_old}
+ + ); +}; + +const OneBotNotice: React.FC = ({ data }) => { + let NoticeComponent: React.ReactNode; + switch (data.notice_type) { + case OB11NoticeType.GroupUpload: + NoticeComponent = ; + break; + case OB11NoticeType.GroupAdmin: + NoticeComponent = ; + break; + case OB11NoticeType.GroupDecrease: + NoticeComponent = ; + break; + case OB11NoticeType.GroupIncrease: + NoticeComponent = ( + + ); + break; + case OB11NoticeType.GroupBan: + NoticeComponent = ; + break; + case OB11NoticeType.FriendAdd: + NoticeComponent = ; + break; + case OB11NoticeType.GroupRecall: + NoticeComponent = ; + break; + case OB11NoticeType.FriendRecall: + NoticeComponent = ( + + ); + break; + case OB11NoticeType.Notify: + switch (data.sub_type) { + case 'poke': + NoticeComponent = ; + break; + case 'lucky_king': + NoticeComponent = ; + break; + case 'honor': + NoticeComponent = ; + break; + } + break; + case OB11NoticeType.GroupMsgEmojiLike: + NoticeComponent = ( + + ); + break; + case OB11NoticeType.GroupEssence: + NoticeComponent = ( + + ); + break; + case OB11NoticeType.GroupCard: + NoticeComponent = ; + break; + } + + return ( +
+ + 通知 + + {getNoticeTypeName(data.notice_type)} + {NoticeComponent} +
+ ); +}; + +export default OneBotNotice; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/display_card/render.tsx b/packages/snowcat-webui-frontend/src/components/onebot/display_card/render.tsx new file mode 100644 index 0000000..1d25d70 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/display_card/render.tsx @@ -0,0 +1,152 @@ +import { Button } from '@heroui/button'; +import { Card, CardBody, CardHeader } from '@heroui/card'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Snippet } from '@heroui/snippet'; +import { motion } from 'motion/react'; +import { IoCode } from 'react-icons/io5'; + +import OneBotDisplayMeta from '@/components/onebot/display_card/meta'; + +import { getEventName, isOB11Event } from '@/utils/onebot'; +import { timestampToDateString } from '@/utils/time'; + +import type { + AllOB11WsResponse, + OB11AllEvent, + OB11Request, +} from '@/types/onebot'; + +import OneBotMessage from './message'; +import OneBotNotice from './notice'; +import OneBotDisplayResponse from './response'; + +const itemVariants = { + hidden: { opacity: 0, scale: 0.8, y: 50 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { type: 'spring' as const, stiffness: 300, damping: 20 }, + }, +}; + +function RequestComponent ({ data: _ }: { data: OB11Request; }) { + return
Request消息,暂未适配
; +} + +export interface OneBotItemRenderProps { + data: AllOB11WsResponse[]; + index: number; + style: React.CSSProperties; +} + +export const getItemSize = (event: OB11AllEvent['post_type']) => { + if (event === 'meta_event') { + return 100; + } + if (event === 'message') { + return 180; + } + if (event === 'request') { + return 100; + } + if (event === 'notice') { + return 100; + } + if (event === 'message_sent') { + return 250; + } + return 100; +}; + +const renderDetail = (data: AllOB11WsResponse) => { + if (isOB11Event(data)) { + switch (data.post_type) { + case 'meta_event': + return ; + case 'message': + return ; + case 'request': + return ; + case 'notice': + return ; + case 'message_sent': + return ; + default: + return
未知类型的消息
; + } + } + return ; +}; + +const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => { + const msg = data[index]; + const isEvent = isOB11Event(msg); + return ( +
+ + + +
+ {isEvent ? getEventName(msg.post_type) : '请求响应'} +
+
+ {isEvent && timestampToDateString(msg.time)} +
+
+ + + + + + + {JSON.stringify(msg, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + + +
+
+ {renderDetail(msg)} +
+
+
+ ); +}; + +export default OneBotItemRender; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/display_card/response.tsx b/packages/snowcat-webui-frontend/src/components/onebot/display_card/response.tsx new file mode 100644 index 0000000..fed8286 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/display_card/response.tsx @@ -0,0 +1,76 @@ +import { Button } from '@heroui/button'; +import { Chip } from '@heroui/chip'; +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; +import { Snippet } from '@heroui/snippet'; + +import { getResponseStatusColor, getResponseStatusText } from '@/utils/onebot'; + +import { RequestResponse } from '@/types/onebot'; + +export interface OneBotDisplayResponseProps { + data: RequestResponse +} + +const OneBotDisplayResponse: React.FC = ({ + data, +}) => { + return ( +
+ + {getResponseStatusText(data.status)} + + {data.data && ( + + + + + + + {JSON.stringify(data.data, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + + + )} + {data.message && ( + + + 返回消息 + + {data.message} + + )} +
+ ); +}; + +export default OneBotDisplayResponse; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/filter_message_type.tsx b/packages/snowcat-webui-frontend/src/components/onebot/filter_message_type.tsx new file mode 100644 index 0000000..cebe66a --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/filter_message_type.tsx @@ -0,0 +1,59 @@ +import { Select, SelectItem } from '@heroui/select'; +import { SharedSelection } from '@heroui/system'; +import type { Selection } from '@react-types/shared'; + +export interface FilterMessageTypeProps { + filterTypes: Selection; + onSelectionChange: (keys: SharedSelection) => void; +} +const items = [ + { label: '元事件', value: 'meta_event' }, + { label: '消息', value: 'message' }, + { label: '请求', value: 'request' }, + { label: '通知', value: 'notice' }, + { label: '消息发送', value: 'message_sent' }, +]; +const FilterMessageType: React.FC = (props) => { + const { filterTypes, onSelectionChange } = props; + return ( + + ); +}; + +export const renderFilterMessageType = ( + filterTypes: Selection, + onSelectionChange: (keys: SharedSelection) => void +) => { + return ( + + ); +}; + +export default FilterMessageType; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/message_list.tsx b/packages/snowcat-webui-frontend/src/components/onebot/message_list.tsx new file mode 100644 index 0000000..9af7428 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/message_list.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react'; +import { VariableSizeList } from 'react-window'; + +import OneBotItemRender, { + getItemSize, +} from '@/components/onebot/display_card/render'; + +import { isOB11Event } from '@/utils/onebot'; + +import type { AllOB11WsResponse } from '@/types/onebot'; + +export interface OneBotMessageListProps { + messages: AllOB11WsResponse[] +} + +const OneBotMessageList: React.FC = (props) => { + const { messages } = props; + const containerRef = useRef(null); + const listRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(400); + + useEffect(() => { + const resizeObserver = new ResizeObserver(() => { + if (containerRef.current) { + setContainerHeight(containerRef.current.offsetHeight); + } + }); + + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + if (listRef.current) { + listRef.current.resetAfterIndex(0, true); + } + }, [messages]); + + return ( +
+ { + const msg = messages[idx]; + if (isOB11Event(msg)) { + const size = getItemSize(msg.post_type); + return size; + } else { + return 100; + } + }} + height={containerHeight} + itemData={messages} + itemKey={(index) => messages.length - index - 1} + > + {OneBotItemRender} + +
+ ); +}; + +export default OneBotMessageList; diff --git a/packages/snowcat-webui-frontend/src/components/onebot/render_message.tsx b/packages/snowcat-webui-frontend/src/components/onebot/render_message.tsx new file mode 100644 index 0000000..99c5993 --- /dev/null +++ b/packages/snowcat-webui-frontend/src/components/onebot/render_message.tsx @@ -0,0 +1,166 @@ +import { Image } from '@heroui/image'; +import qface from 'qface'; +import { FaReply } from 'react-icons/fa6'; + +import { OB11Segment } from '@/types/onebot'; + +export const renderMessageContent = ( + segments: OB11Segment[], + small = false +): React.ReactElement[] => { + return segments.map((segment, index) => { + switch (segment.type) { + case 'text': + return {segment.data.text}; + case 'face': + return ( + {`face-${segment.data.id}`} + ); + case 'image': + return ( + image + ); + case 'record': + return ( +