hong-review-cli 1.0.15 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,74 +1,93 @@
1
1
  # hong-review-cli
2
2
 
3
- `hong-review-cli` 是一个基于命令行界面的智能代码审查工具。它通过结合大语言模型的能力与 GitLab 代码库的 MR(Merge Request)审查场景集成,旨在帮助开发者快速在终端实现交互式的高质量代码审核审查和自动化 MR 合并。
3
+ **`hong-review-cli` 是一款为现代研发团队打造的、基于命令行与大模型驱动的极速代码审查与自动合并工具。**
4
+ > *让 Code Review 回归代码层!无需离开终端,一键呼叫 AI 进行深度逻辑审查、找Bug、发评论并完美推送审查报告到你的手机!*
4
5
 
5
- ## 🌟 核心特性
6
+ ## 🌟 核心理念与特性
6
7
 
7
- - **终端直接审查**:无需离开编写代码的终端环境。
8
- - **智能分析**:使用类似 ChatGPT 这样的强 AI 模型分析所有的文件增删改查。
9
- - **全键盘操作**:直观的下钻式命令选项与带颜色的排版,不再枯燥。
10
- - **集成 Hooks 挂钩**:与 CI/CD 轻易相连,审查前后触发定制脚本流程。
8
+ - **🚀 零上下文切换**:直接在写代码的终端一键跑查,告别繁琐的浏览器操作界面。
9
+ - **🧠 AI 深度分析**:对接 OpenAI、GLM、DeepSeek 等顶级模型,自动理解所有文件的增删改查。
10
+ - **🤖 自动化挂机防守**:支持静默化批处理模式,可被 CI/CD 或本地 Cron 调起,自动回复并合并安全代码。
11
+ - **⚡️ Telegram 极速推送通道**:原生内置毫秒级直推 Telegram 能力,长篇大论的缺陷审查报告无需等待,极速触达个人或群组!
11
12
 
12
- ## 🚀 安装
13
+ ---
13
14
 
14
- 要全局安装此命令行工具,您需要环境中已安装 Node.js > 14+。
15
+ ## 🚀 保姆级入门使用
15
16
 
16
- 在终端中执行以下命令进行全局安装:
17
+ **环境要求**:Node.js >= 14+
17
18
 
19
+ ### 第一步:全局安装
18
20
  ```bash
19
21
  npm install -g hong-review-cli
20
22
  ```
21
23
 
22
- ## 🛠️ 开始使用
23
-
24
- ### 第一步:初始化登录配置
25
- 安装成功后,请在任何终端目录输入该命令绑定您的资源:
26
-
24
+ ### 第二步:初始化资源绑定
25
+ 安装成功后,在终端执行以下命令进行本地账号绑定:
27
26
  ```bash
28
27
  hong-review login
29
28
  ```
30
- *根据向导提示填写您的 GitLab 服务器域名,GitLab 令牌(Token)与模型(API Key)。配置会保存在您的本地安全目录中。*
31
-
32
- ### 第二步:一键智能审查
33
- 这是这个程序最强大的功能部分,只需要在任何地方运行:
29
+ *根据向导提示填写您的 GitLab 服务器域名、GitLab 私人令牌(Token)与模型(API Key)。配置会安全地保存在本地 `~/.hong-review-config.json` 中。*
34
30
 
31
+ ### 第三步:日常使用
32
+ 当你不知道有什么审查任务时:
35
33
  ```bash
36
34
  hong-review ls
37
35
  ```
38
- 系统将自动为您拉取目前分配到你身上的待处理 MR。通过方向键选择之后,工具将在后台呼唤 AI 进行高强度的深度审查与建议反馈。
36
+ 系统将自动为您拉取目前分配给你的待处理 MR 列表,全键盘方向键直观选择进行审查。
37
+
38
+ 当你知道确切的 MR 编号时:
39
+ ```bash
40
+ hong-review mr <mr_id> -p <项目ID>
41
+ ```
42
+ *注:加上 `-y` 或 `--yes` 参数可以开启静默免打扰模式,跳过一切二次询问。*
39
43
 
40
- 或者,若您确切知道 GitLab 项目下的 MR 数字 ID,您也可以直接运行:
44
+ ---
45
+
46
+ ## 📱 进阶体验:极速接入 Telegram 直连通知与 OpenClaw 指令网关
47
+
48
+ 想要在喝咖啡的时候,手机自动收到每一份新鲜出炉的代码审计报告?使用 `setup` 一键集成!
49
+
50
+ ### 1. 准备工作
51
+ - 请确保本地已安装运行 [OpenClaw](https://github.com/xxxx/openclaw) 并在其配置中集成了 Telegram 机器人。
52
+
53
+ ### 2. 运行一键嗅探配置
54
+ 由于配置项繁多,为了达到“零门槛”的用户体验,CLI 提供了强大的配置自动提取功能。
55
+ 在终端执行:
41
56
  ```bash
42
- hong-review mr <mr_id>
57
+ hong-review setup-openclaw
43
58
  ```
59
+ 程序会自动寻找到你本机的 `.openclaw/openclaw.json`,聪明的它会自动阅读文件,并毫秒级提取出所有所需的 `Webhook Token`、`Telegram Bot Token` 以及 `Chat ID`,直接注入回 CLI,一步配平!
60
+
61
+ > 💡 **架构亮点:**
62
+ > 配置完成后,CLI 将启用**极速直推架构**。所有包含大段代码漏洞和建议的长报告文件,将完全绕过大模型的二次复述(打字机延迟),直接通过原生 Telegram 接口**毫秒级**发送到你的手机上。同时发送极其简短的存根日志留存到内部后台以供审计分析。
44
63
 
45
- > **自动化特性支持**
46
- > - 增加参数 `-R`(--report)可额外生成本地 Markdown 文件报告。
47
- > - 增加参数 `-c`(--comment)可自动在 GitLab Merge Request 发送一条包含排版的 Markdown 代码评论。
48
- > - 增加参数 `-m`(--merge)可以在 AI 判断通过/极低风险时执行静默合并代码!
64
+ ---
49
65
 
50
- ### 其他指令
66
+ ## 🛠 其他实用指令速查
51
67
 
52
- 你可以随时执行 `hong-review` 或带上 `--help` 查看说明书:
53
- - `hong-review comment <mr_id> "<评论文本>"` 快捷留言
54
- - `hong-review merge <mr_id>` 快捷请求远端合并
55
- - `hong-review view <mr_id>` 终端内分页展示代码 Diff 变动详情
56
- - `hong-review config list` 列出所有系统配置的状态
68
+ 你可以随时执行 `hong-review --help` 查看完整说明书:
69
+ - `hong-review comment <mr_id> "<评论文本>" -p <pid>` 快速向 GitLab 提交一条评审意见。
70
+ - `hong-review merge <mr_id> -p <pid>` 一键请求远端合并(支持检测是否发生冲突)。
71
+ - `hong-review view <mr_id> -p <pid>` 在纯命令行界面中分页彩印展示代码 Diff 详情。
72
+ - `hong-review config list` 列出系统当前的配置状态。
57
73
 
58
74
  ---
59
75
 
60
- ## 🪝 流水线生命周期 (Hooks)
76
+ ## 🪝 为极客准备的自定义 Hook 流水线
61
77
 
62
- 作为现代化的工具,它可以绑定到第三方系统的事件提醒中。通过命令写入系统全局 Hooks 来进行拦截事件:
78
+ 除了自带的 Telegram/OpenClaw 集成,你可以利用配置绑定任何第三方系统的本地可执行脚本去拦截生命周期。
79
+ 修改本地的配置文件(通常在 `~/.hong-review-config.json`):
63
80
  ```json
81
+ {
64
82
  "hooks": {
65
- "onReviewStart": "curl -X POST http://xxx",
66
- "onReviewSuccess": "bash ./scripts/notify-slack.sh",
67
- "onReviewFailed": "node ./scripts/error.js",
83
+ "onReviewStart": "curl -X POST http://我的内部统计系统",
84
+ "onReviewSuccess": "bash ./scripts/notify-wechat.sh",
85
+ "onReviewFailed": "node ./scripts/dingtalk_error.js",
68
86
  "onMerged": "..."
69
87
  }
88
+ }
70
89
  ```
71
90
 
72
91
  ## 📄 协议
73
92
 
74
- 本项目基于 MIT 协议开发开源。
93
+ 本项目基于 MIT 协议开发与开源。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hong-review-cli",
3
- "version": "1.0.15",
3
+ "version": "1.0.30",
4
4
  "main": "index.js",
5
5
  "bin": {
6
6
  "hong-review": "bin/hong-review.js"
@@ -0,0 +1,11 @@
1
+ const fs = require('fs');
2
+ const configPath = '/Users/hong/.openclaw/openclaw.json';
3
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
4
+
5
+ const mapping = config.hooks.mappings.find(m => m.match && m.match.path === 'code-review');
6
+ if (mapping) {
7
+ mapping.messageTemplate = "{{{payload.formattedText}}}";
8
+ mapping.deliver = true; // DIRECTLY SEND TO TELEGRAM!
9
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
10
+ console.log("Updated!");
11
+ }
@@ -2,6 +2,7 @@ const inquirer = require('inquirer');
2
2
  const storage = require('../utils/storage');
3
3
  const logger = require('../utils/logger');
4
4
  const GitLabClient = require('../core/gitlab');
5
+ const hooks = require('../utils/hooks');
5
6
 
6
7
  function getClient() {
7
8
  const config = storage.getAll();
@@ -74,6 +75,7 @@ module.exports = {
74
75
  await gitlab.merge(projectId, mrId);
75
76
  if (!options.yes) logger.stopSpinner('merge', true, '合并成功!');
76
77
  else logger.logRaw(`✅ MR !${mrId} 合并成功!`);
78
+ await hooks.emit('onMerged', { projectId, mrId });
77
79
  } else {
78
80
  logger.info('已取消合并。');
79
81
  }
@@ -1,19 +1,24 @@
1
1
  const storage = require('../utils/storage');
2
2
  const logger = require('../utils/logger');
3
3
  const chalk = require('chalk');
4
+ const notifier = require('../utils/notifier');
4
5
 
5
6
  module.exports = {
6
- list() {
7
+ async list() {
7
8
  const config = storage.getAll();
8
9
  logger.divider();
9
10
  logger.info('当前全局配置 (存储在 ~/.hong-review-config.json)');
10
11
  logger.divider();
11
12
 
13
+ let reportText = `📋 **当前代码审查全局配置状态**\n\n`;
14
+
12
15
  for (const [key, value] of Object.entries(config)) {
13
16
  if (typeof value === 'object') {
14
17
  logger.logRaw(`${chalk.bold(key)}:`);
18
+ reportText += `🔹 **${key}**:\n`;
15
19
  for (const [subKey, subValue] of Object.entries(value)) {
16
20
  logger.logRaw(` ${chalk.gray('-')} ${subKey}: ${subValue ? chalk.green('已配置') : chalk.gray('未配置')}`);
21
+ reportText += ` - \`${subKey}\`: ${subValue ? '✅ 已配置' : '❌ 未配置'}\n`;
17
22
  }
18
23
  } else {
19
24
  let displayValue = value;
@@ -23,17 +28,25 @@ module.exports = {
23
28
  displayValue = chalk.gray('(未提供)');
24
29
  }
25
30
  logger.logRaw(`${chalk.bold(key)}: ${displayValue}`);
31
+ reportText += `🔹 **${key}**:\n - \`${value ? displayValue : '(未配置)'}\`\n`;
26
32
  }
27
33
  }
28
34
  logger.divider();
35
+
36
+ // 当查询完成,同时抄送一份漂亮的 Markdown 报告到外部网关
37
+ await notifier.broadcast('onConfigList', reportText);
29
38
  },
30
39
 
31
- set(key, value) {
40
+ async set(key, value) {
32
41
  if (!key || value === undefined) {
33
42
  logger.error('参数错误。用法: hong-review config set <key> <value>');
34
43
  return;
35
44
  }
36
45
 
46
+ // 统一布尔值转换
47
+ if (value === 'true') value = true;
48
+ if (value === 'false') value = false;
49
+
37
50
  // 处理嵌套对象赋值,如 hooks.onReviewStart
38
51
  if (key.includes('.')) {
39
52
  const [parentKey, subKey] = key.split('.');
@@ -44,6 +57,13 @@ module.exports = {
44
57
  storage.set(key, value);
45
58
  }
46
59
 
47
- logger.success(`配置项 ${chalk.bold(key)} 已更新。`);
60
+ logger.success(`配置项 ${chalk.bold(key)} 已更新为 ${value}。`);
61
+
62
+ let safeValue = value;
63
+ if ((key.toLowerCase().includes('token') || key.toLowerCase().includes('key')) && typeof value === 'string' && value.length > 8) {
64
+ safeValue = value.substring(0, 4) + '*'.repeat(value.length - 8) + value.substring(value.length - 4);
65
+ }
66
+
67
+ await notifier.broadcast('onConfigSet', `⚙️ **系统配置热更新完毕**\n\n- 字段: \`${key}\`\n- 新值: \`${safeValue}\``);
48
68
  }
49
69
  };
@@ -2,6 +2,7 @@ const storage = require('../utils/storage');
2
2
  const logger = require('../utils/logger');
3
3
  const GitLabClient = require('../core/gitlab');
4
4
  const chalk = require('chalk');
5
+ const notifier = require('../utils/notifier');
5
6
 
6
7
  module.exports = async function (options = {}) {
7
8
  const config = storage.getAll();
@@ -23,6 +24,11 @@ module.exports = async function (options = {}) {
23
24
  mrs.forEach(mr => {
24
25
  logger.logRaw(`- ProjectID: ${mr.project_id} | MR !${mr.iid} | Title: ${mr.title} | Branch: ${mr.source_branch} -> ${mr.target_branch}`);
25
26
  });
27
+
28
+ // 触发原有 webhook 流程,底层的 hooks.js 会自动排版并直发 Telegram
29
+ const hooks = require('../utils/hooks');
30
+ await hooks.emit('onReviewList', { count: mrs.length, mrs: mrs });
31
+
26
32
  return;
27
33
  }
28
34
 
@@ -94,6 +94,7 @@ module.exports = async function (mrId, options) {
94
94
  const action = parseAgentResponse(aiResponse);
95
95
 
96
96
  if (!action) {
97
+ logger.error(`[AI 返回的原始非 JSON 数据]:\n${aiResponse}`);
97
98
  throw new Error('AI 返回的数据无法解析为期望的 JSON 格式。');
98
99
  }
99
100
 
@@ -1,32 +1,134 @@
1
1
  const inquirer = require('inquirer');
2
2
  const chalk = require('chalk');
3
3
  const ora = require('ora');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
4
7
  const configCmd = require('./config');
5
8
  const logger = require('../utils/logger');
6
9
 
10
+ const { execSync } = require('child_process');
11
+
12
+ function extractOpenClawConfig(filePath) {
13
+ if (!fs.existsSync(filePath)) {
14
+ throw new Error(`找不到文件: ${filePath}`);
15
+ }
16
+
17
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
18
+ const config = {
19
+ webhookUrl: '',
20
+ token: '',
21
+ tgToken: '',
22
+ tgChatId: ''
23
+ };
24
+
25
+ // 提取 gateway 端口
26
+ const port = (data.gateway && data.gateway.port) ? data.gateway.port : 18790;
27
+ config.webhookUrl = `http://127.0.0.1:${port}/hooks/code-review`;
28
+
29
+ // 提取 hooks token
30
+ if (data.hooks && data.hooks.token) {
31
+ config.token = data.hooks.token;
32
+ }
33
+
34
+ // 提取 Telegram Bot Token
35
+ if (data.channels && data.channels.telegram && data.channels.telegram.botToken) {
36
+ config.tgToken = data.channels.telegram.botToken;
37
+ }
38
+
39
+ // 提取 Telegram Chat ID
40
+ if (data.hooks && data.hooks.mappings && data.hooks.mappings.length > 0) {
41
+ const tgMapping = data.hooks.mappings.find(m => m.channel === 'telegram');
42
+ if (tgMapping && tgMapping.to) {
43
+ config.tgChatId = tgMapping.to;
44
+ }
45
+ }
46
+
47
+ return config;
48
+ }
49
+
50
+ // 尝试调用最新版 OpenClaw CLI 指令获取精确的配置文件路径
51
+ function getExactOpenClawConfigPath() {
52
+ try {
53
+ const stdout = execSync('openclaw config file', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
54
+ const exactPath = stdout.trim();
55
+ if (fs.existsSync(exactPath)) {
56
+ return exactPath;
57
+ }
58
+ } catch (err) {
59
+ // 静默处理,老版本或未安装 CLI 时退化到经验路径
60
+ }
61
+ return path.join(os.homedir(), '.openclaw', 'openclaw.json');
62
+ }
63
+
7
64
  module.exports = async function setupOpenClaw() {
8
- logger.logRaw(chalk.bold.blue('\n🚀 开始一键配置 OpenClaw 集成\n'));
65
+ logger.logRaw(chalk.bold.blue('\n🚀 开始一键智能配置 OpenClaw 与极速推送\n'));
66
+
67
+ // 利用 OpenClaw v2026.3.1 提供的新命令,100% 精确嗅探配置文件位置
68
+ const defaultPath = getExactOpenClawConfigPath();
9
69
 
10
70
  const answers = await inquirer.prompt([
11
71
  {
12
72
  type: 'input',
13
- name: 'webhookUrl',
14
- message: '请输入 OpenClaw Webhook 地址:',
15
- default: 'http://127.0.0.1:18790/hooks/code-review',
16
- validate: input => input ? true : '请输入有效的 Webhook 地址'
17
- },
18
- {
19
- type: 'input',
20
- name: 'token',
21
- message: '请输入 OpenClaw 授权 Token (Authorization Bearer):',
22
- validate: input => input ? true : '请输入有效的授权 Token'
73
+ name: 'configPath',
74
+ message: '请输入您的 OpenClaw 配置文件目录或路径:',
75
+ default: defaultPath,
76
+ validate: input => fs.existsSync(input) ? true : '输入的文件路径不存在,请重新输入'
23
77
  }
24
78
  ]);
25
79
 
26
- const spinner = ora('正在写入 hooks 配置...').start();
80
+ const spinner = ora('正在解析配置文件并提取设置...').start();
27
81
 
28
- configCmd.set('hook', answers.webhookUrl);
29
- configCmd.set('hookToken', answers.token);
82
+ try {
83
+ let targetPath = answers.configPath;
84
+ if (fs.statSync(targetPath).isDirectory()) {
85
+ targetPath = path.join(targetPath, 'openclaw.json');
86
+ }
87
+
88
+ const extractedConfig = extractOpenClawConfig(targetPath);
89
+
90
+ let hasWebhook = true;
91
+ if (!extractedConfig.token) {
92
+ spinner.warn('在配置文件中未找到 OpenClaw Webhook 授权 Token。因为在您的 OpenClaw.json 中 hooks 可能尚未开启。');
93
+ hasWebhook = false;
94
+ }
95
+
96
+ if (hasWebhook) {
97
+ configCmd.set('hook', extractedConfig.webhookUrl);
98
+ configCmd.set('hookToken', extractedConfig.token);
99
+ } else {
100
+ // 如果连 hookToken 都没有,我们可以选择清除当前的 webhook 配置
101
+ configCmd.set('hook', '');
102
+ configCmd.set('hookToken', '');
103
+ }
104
+
105
+ if (extractedConfig.tgToken && extractedConfig.tgChatId) {
106
+ configCmd.set('telegramToken', extractedConfig.tgToken);
107
+ configCmd.set('telegramChatId', extractedConfig.tgChatId);
108
+ } else {
109
+ configCmd.set('telegramToken', '');
110
+ configCmd.set('telegramChatId', '');
111
+ }
112
+
113
+ spinner.succeed('配置解析与注入完成!');
114
+
115
+ logger.info(`\n✅ ${chalk.green('成功提取到的配置:')}`);
116
+ if (hasWebhook) {
117
+ logger.info(` - Webhook 地址: ${extractedConfig.webhookUrl}`);
118
+ logger.info(` - Webhook 令牌: ***${extractedConfig.token.substring(extractedConfig.token.length - 4)}`);
119
+ } else {
120
+ logger.warn(` - Webhook 设置: ❌ OpenClaw 尚未启用 hooks,CLI 跳过了此项配置。`);
121
+ }
122
+
123
+ if (extractedConfig.tgToken && extractedConfig.tgChatId) {
124
+ logger.info(` - Telegram Token: ***${extractedConfig.tgToken.substring(extractedConfig.tgToken.length - 4)}`);
125
+ logger.info(` - Telegram Chat ID: ${extractedConfig.tgChatId}`);
126
+ logger.info('\n🚀 核心:已为您自动开启 Telegram 极速直连通道!');
127
+ } else {
128
+ logger.warn('\n⚠️ 未在您的 OpenClaw 配置中检测到全套 Telegram 频道的完整绑定配置。');
129
+ }
30
130
 
31
- spinner.succeed('配置完成!您已成功接入 OpenClaw 路由,审查过程的所有动态都将自动进行广播推送。');
131
+ } catch (error) {
132
+ spinner.fail(`解析配置文件失败: ${error.message}`);
133
+ }
32
134
  };
package/src/core/agent.js CHANGED
@@ -88,8 +88,18 @@ function formatFileContents(contents) {
88
88
 
89
89
  function parseAgentResponse(content) {
90
90
  try {
91
- const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
92
- const jsonStr = jsonMatch ? jsonMatch[1] : content;
91
+ let jsonStr = content;
92
+ const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
93
+ if (jsonMatch) {
94
+ jsonStr = jsonMatch[1];
95
+ } else {
96
+ const start = content.indexOf('{');
97
+ const end = content.lastIndexOf('}');
98
+ if (start !== -1 && end !== -1 && end > start) {
99
+ jsonStr = content.substring(start, end + 1);
100
+ }
101
+ }
102
+
93
103
  const cleanedJson = jsonStr.trim();
94
104
  const parsed = JSON.parse(cleanedJson);
95
105
 
@@ -98,6 +108,7 @@ function parseAgentResponse(content) {
98
108
  }
99
109
  return null;
100
110
  } catch (error) {
111
+ // console.error("解析失败:", error.message, "\n片段:", content);
101
112
  return null;
102
113
  }
103
114
  }
package/src/core/ai.js CHANGED
@@ -31,14 +31,64 @@ class AIClient {
31
31
  requestBody.response_format = { type: 'json_object' };
32
32
  }
33
33
 
34
+ // 始终开启流式输出以绕过 Cloudflare 100秒即断的网关 524 限制(无论是纯文本还是JSON模式)
35
+ requestBody.stream = true;
36
+
34
37
  try {
35
- const response = await this.client.post('/chat/completions', requestBody);
36
- return response.data.choices[0]?.message?.content || '';
38
+ const response = await this.client.post('/chat/completions', requestBody, {
39
+ responseType: 'stream'
40
+ });
41
+
42
+ return new Promise((resolve, reject) => {
43
+ let fullContent = '';
44
+ let buffer = '';
45
+ let chunkCount = 0;
46
+
47
+ response.data.on('data', chunk => {
48
+ buffer += chunk.toString();
49
+ let parts = buffer.split('\n');
50
+ buffer = parts.pop();
51
+
52
+ for (let line of parts) {
53
+ line = line.trim();
54
+ if (!line) continue;
55
+ if (line === 'data: [DONE]') {
56
+ return resolve(fullContent);
57
+ }
58
+ if (line.startsWith('data: ')) {
59
+ try {
60
+ const parsed = JSON.parse(line.slice(6));
61
+ if (parsed.choices && parsed.choices[0] && parsed.choices[0].delta && parsed.choices[0].delta.content) {
62
+ fullContent += parsed.choices[0].delta.content;
63
+ chunkCount++;
64
+ // 偶尔打印一点动静到控制台(避免完全死寂),同时不影响日志文件太乱
65
+ if (chunkCount % 20 === 0) {
66
+ process.stdout.write('.');
67
+ }
68
+ }
69
+ } catch (e) {
70
+ // 忽略截断导致的解析错误
71
+ }
72
+ }
73
+ }
74
+ });
75
+
76
+ response.data.on('end', () => resolve(fullContent));
77
+ response.data.on('error', err => reject(new Error(`AI API stream error: ${err.message}`)));
78
+ });
37
79
  } catch (error) {
80
+ let errorMsg = error.message;
38
81
  if (error.response) {
39
- throw new Error(`AI API error: ${error.response.status} - ${JSON.stringify(error.response.data)}`);
82
+ // stream error doesn't always have error.response.data as string
83
+ let responseData = error.response.data;
84
+ if (responseData && typeof responseData === 'object' && responseData.on) {
85
+ responseData = 'Stream Error';
86
+ } else if (typeof responseData !== 'string') {
87
+ responseData = JSON.stringify(responseData);
88
+ }
89
+ throw new Error(`AI API error: ${error.response.status} - ${responseData}`);
40
90
  }
41
- throw new Error(`AI API error: ${error.message}`);
91
+ throw new Error(`AI API error: ${errorMsg}`);
42
92
  }
43
93
  }
44
94
 
@@ -7,6 +7,8 @@ class Hooks {
7
7
  constructor() {
8
8
  this.hookConfig = storage.get('hook') || '';
9
9
  this.hookToken = storage.get('hookToken') || '';
10
+ this.tgToken = storage.get('telegramToken') || '';
11
+ this.tgChatId = storage.get('telegramChatId') || '';
10
12
  }
11
13
 
12
14
  /**
@@ -18,37 +20,77 @@ class Hooks {
18
20
  // 每次执行时都实时拉取 config
19
21
  this.hookConfig = storage.get('hook') || '';
20
22
  this.hookToken = storage.get('hookToken') || '';
23
+ this.tgToken = storage.get('telegramToken') || '';
24
+ this.tgChatId = storage.get('telegramChatId') || '';
21
25
 
22
26
  // 如果配置了具体的 HTTP Webhook (比如由 setup-openclaw 设置)
23
27
  if (this.hookConfig && this.hookConfig.startsWith('http')) {
24
- // 这里通过刚刚独立出来的模块,判断是否是我们允许且想通知的类型
25
- if (!ALLOWED_HOOKS.includes(eventName)) {
26
- logger.info(`Webhook 被挂起 [${eventName}] (该事件不在 telegramFormatter 的 ALLOWED_HOOKS 白名单中,已静默处理)`);
27
- return;
28
- }
28
+ const isNotify = ALLOWED_HOOKS.includes(eventName);
29
29
 
30
30
  try {
31
31
  // 将状态和特定字段从 payload 中拉平
32
32
  const statusStr = payload.status || (eventName === 'onReviewStart' ? 'Started' : (eventName === 'onReviewSuccess' ? 'Success' : (eventName === 'onReviewFailed' ? 'Failed' : eventName)));
33
33
 
34
- // 核心:使用我们在独立模块中写死的优美模版,它将把带有问题列表的整句话都打印好!
34
+ // 核心:使用我们在独立模块中写死的优美模版
35
35
  const preFormattedMessage = formatTelegramMessage(eventName, { ...payload, status: statusStr });
36
36
 
37
+ let webhookFormattedText = preFormattedMessage;
38
+
37
39
  const hookPayload = {
38
40
  event: eventName,
39
41
  mrId: payload.mrId || '',
40
42
  status: statusStr,
41
- // ✅ 强行喂给下游 OpenClaw 直接渲染的最佳原生文本!
42
43
  formattedText: preFormattedMessage,
43
44
  ...payload
44
45
  };
45
46
 
46
- logger.info(`开始推送 Webhook [${eventName}] 到 ${this.hookConfig} 参数: ${JSON.stringify(hookPayload)}`);
47
+ // 不管通不通知,都在本地记录该环节的具体内容(供排错与回顾)
48
+ logger.info(`[内部事件追踪: ${eventName}] 状态: ${statusStr} | 通知OpenClaw: ${isNotify ? "是" : "否 (被ALLOWED_HOOKS拦截)"} | 载荷: ${JSON.stringify(hookPayload)}`);
49
+
50
+ // ✅ 如果不在允许的白名单内,本地记完日志即视为处理完毕
51
+ if (!isNotify) {
52
+ return;
53
+ }
54
+
55
+ // Telegram 直连极速推送逻辑:绕过 OpenClaw AI 大脑产生的延迟
56
+ if (this.tgToken && this.tgChatId) {
57
+ try {
58
+ try {
59
+ await axios.post(`https://api.telegram.org/bot${this.tgToken}/sendMessage`, {
60
+ chat_id: this.tgChatId,
61
+ text: preFormattedMessage,
62
+ parse_mode: 'Markdown'
63
+ }, { timeout: 10000 });
64
+ } catch (tgErr) {
65
+ if (tgErr.response && tgErr.response.status === 400) {
66
+ logger.warn(`⚡️ Telegram Markdown解析受阻(长文本或符号逃逸),执行纯文本降级推送兜底...`);
67
+ await axios.post(`https://api.telegram.org/bot${this.tgToken}/sendMessage`, {
68
+ chat_id: this.tgChatId,
69
+ text: preFormattedMessage
70
+ }, { timeout: 10000 });
71
+ } else {
72
+ throw tgErr;
73
+ }
74
+ }
75
+ logger.success(`⚡️ 直连 Telegram 推送成功 [${eventName}]`);
76
+
77
+ // 直发成功后,大幅裁剪向 OpenClaw 发送的载荷,给 AI “减负”,实现秒出
78
+ if (eventName === 'onReviewSuccess' || eventName === 'onReviewFailed' || eventName === 'onReviewList' || eventName === 'onMerged' || eventName === 'onReviewStart') {
79
+ webhookFormattedText = `⚡️ [直连加速] (MR !${payload.mrId || 'List'}) 状态更改变为 ${statusStr}。本次审查的详细排版长报告已跳过 AI 思考,由 CLI 原生秒投递至 Telegram。`;
80
+ }
81
+ } catch (err) {
82
+ logger.warn(`直推 Telegram 失败,回退到标准 Webhook 流程 [${eventName}]: ${err.message}`);
83
+ }
84
+ }
85
+
86
+ // 将准备发给 OpenClaw 的 payload 更新(可能是减负后的,也可能是原版的)
87
+ hookPayload.formattedText = webhookFormattedText;
88
+
47
89
  const response = await this.executeWebhook(this.hookConfig, hookPayload, this.hookToken);
48
90
  logger.success(`Webhook 推送完成 [${eventName}] HTTP状态: ${response.status}`);
49
91
  } catch (err) {
50
92
  // 如果是用户配置的静态 URL,失败不中断主流程,仅打个 Warn
51
- logger.warn(`Webhook 推送失败 [${eventName}]: ${err.message}`);
93
+ logger.warn(`Webhook 处理异常或推送失败 [${eventName}]: ${err.message}`);
52
94
  }
53
95
  return;
54
96
  }
@@ -48,7 +48,7 @@ class Logger {
48
48
  dirname: defaultLogDir,
49
49
  filename: 'hong-review-%DATE%.log',
50
50
  datePattern: 'YYYY-MM-DD',
51
- maxFiles: '14d'
51
+ maxFiles: '2d'
52
52
  })
53
53
  ]
54
54
  });
@@ -56,22 +56,22 @@ class Logger {
56
56
 
57
57
  info(msg) {
58
58
  console.log(chalk.blue('ℹ ' + msg));
59
- this.fileLogger.info(msg);
59
+ if (this.fileLogger) this.fileLogger.info(msg);
60
60
  }
61
61
 
62
62
  success(msg) {
63
63
  console.log(chalk.green('✔ ' + msg));
64
- this.fileLogger.info(msg);
64
+ if (this.fileLogger) this.fileLogger.info(msg);
65
65
  }
66
66
 
67
67
  warn(msg) {
68
68
  console.log(chalk.yellow('⚠ ' + msg));
69
- this.fileLogger.warn(msg);
69
+ if (this.fileLogger) this.fileLogger.warn(msg);
70
70
  }
71
71
 
72
72
  error(msg) {
73
73
  console.log(chalk.red('✖ ' + msg));
74
- this.fileLogger.error(msg);
74
+ if (this.fileLogger) this.fileLogger.error(msg);
75
75
  }
76
76
 
77
77
  startSpinner(id, text) {
@@ -80,7 +80,7 @@ class Logger {
80
80
  }
81
81
  const spinner = ora(text).start();
82
82
  spinners.set(id, spinner);
83
- this.fileLogger.info(`(Started) ${text}`);
83
+ if (this.fileLogger) this.fileLogger.info(`(Started) ${text}`);
84
84
  }
85
85
 
86
86
  stopSpinner(id, success = true, text = '') {
@@ -90,22 +90,22 @@ class Logger {
90
90
  const finalMsg = text || spinner.text;
91
91
  if (success) {
92
92
  spinner.succeed(finalMsg);
93
- this.fileLogger.info(`(Completed) ${finalMsg}`);
93
+ if (this.fileLogger) this.fileLogger.info(`(Completed) ${finalMsg}`);
94
94
  } else {
95
95
  spinner.fail(finalMsg);
96
- this.fileLogger.error(`(Failed) ${finalMsg}`);
96
+ if (this.fileLogger) this.fileLogger.error(`(Failed) ${finalMsg}`);
97
97
  }
98
98
  spinners.delete(id);
99
99
  }
100
100
 
101
101
  logRaw(msg) {
102
102
  console.log(msg);
103
- this.fileLogger.info(msg);
103
+ if (this.fileLogger) this.fileLogger.info(msg);
104
104
  }
105
105
 
106
106
  divider() {
107
107
  console.log(chalk.gray('-'.repeat(50)));
108
- this.fileLogger.info('-'.repeat(50));
108
+ if (this.fileLogger) this.fileLogger.info('-'.repeat(50));
109
109
  }
110
110
  }
111
111
 
@@ -0,0 +1,102 @@
1
+ const axios = require('axios');
2
+ const storage = require('./storage');
3
+ const logger = require('./logger');
4
+
5
+ class Notifier {
6
+ constructor() {
7
+ // 配置是在运行时才被初始化的,所以每次发送前应该通过 storage 动态获取
8
+ }
9
+
10
+ /**
11
+ * 向 Telegram 发送直接推送
12
+ * @param {string} text - 要发送的排版好的 Markdown 文本
13
+ * @returns {Promise<boolean>} 是否成功送达
14
+ */
15
+ async sendToTelegram(text) {
16
+ const tgToken = storage.get('telegramToken') || '';
17
+ const tgChatId = storage.get('telegramChatId') || '';
18
+
19
+ if (!tgToken || !tgChatId) {
20
+ return false;
21
+ }
22
+
23
+ try {
24
+ try {
25
+ await axios.post(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
26
+ chat_id: tgChatId,
27
+ text: text,
28
+ parse_mode: 'Markdown'
29
+ }, { timeout: 10000 });
30
+ } catch (tgErr) {
31
+ if (tgErr.response && tgErr.response.status === 400) {
32
+ logger.warn(`⚡️ Telegram Markdown解析受阻,执行纯文本降级推送兜底...`);
33
+ await axios.post(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
34
+ chat_id: tgChatId,
35
+ text: text
36
+ }, { timeout: 10000 });
37
+ } else {
38
+ throw tgErr;
39
+ }
40
+ }
41
+ return true;
42
+ } catch (err) {
43
+ logger.warn(`直推 Telegram 失败: ${err.message}`);
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * 日志记录静默打点到 OpenClaw (绕过 AI 回复,仅供本地审计)
50
+ * 这里我们复用标准的 Webhook 通道,利用特殊的事件名或在原有的 hook.js 基础上发送轻量 Payload
51
+ * @param {string} eventName - 事件名,如 'onConfigChange', 'onListQuery'
52
+ * @param {object} payload - 附加负载
53
+ */
54
+ async sendToOpenClaw(eventName, text, payload = {}) {
55
+ const hookConfig = storage.get('hook') || '';
56
+ const hookToken = storage.get('hookToken') || '';
57
+
58
+ if (!hookConfig || !hookConfig.startsWith('http')) {
59
+ return false;
60
+ }
61
+
62
+ try {
63
+ const hookPayload = {
64
+ event: eventName,
65
+ // 这里加个特制状态,暗示给 OpenClaw 的日志拦截器:这是一条无需回复的审计记录
66
+ status: 'SilentAudit',
67
+ formattedText: `[审计流水] ${text}`,
68
+ ...payload
69
+ };
70
+
71
+ const headers = { 'Content-Type': 'application/json' };
72
+ if (hookToken) {
73
+ headers['Authorization'] = `Bearer ${hookToken}`;
74
+ }
75
+
76
+ // 发送给 OpenClaw,仅控台打印,不求群组转发
77
+ await axios.post(hookConfig, hookPayload, { headers, timeout: 5000 });
78
+ return true;
79
+ } catch (err) {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 发送全局广播:同时推给 Telegram (给用户看) 和 OpenClaw (系统归档)
86
+ * @param {string} eventName - 当前发起的动作名称
87
+ * @param {string} text - 精美排版的报告主内容
88
+ */
89
+ async broadcast(eventName, text, payload = {}) {
90
+ // 1. 直推给用户(最重要,消灭延迟的心智保证)
91
+ const tgSuccess = await this.sendToTelegram(text);
92
+ if (tgSuccess) {
93
+ logger.success(`⚡️ 已通过极速通道推送至 Telegram [${eventName}]`);
94
+ }
95
+
96
+ // 2. 存档到 OpenClaw
97
+ // 注意我们发送的也是同一个 text,但是在 OpenClaw 那边被包裹成 [审计流水] 标记
98
+ await this.sendToOpenClaw(eventName, text, payload);
99
+ }
100
+ }
101
+
102
+ module.exports = new Notifier();
@@ -7,13 +7,15 @@
7
7
  // 如果后续你觉得哪个节点太吵了不需要通知,直接在这里注释掉它即可!
8
8
  const ALLOWED_HOOKS = [
9
9
  'onReviewStart', // 评审任务开始启动
10
- 'onReviewProgress', // 评审进度实时更新
11
- 'onReviewCLIContext', // 提取并发送 CLI 上下文信息
12
- 'onReviewAgentAction', // Agent 正在执行具体操作步骤
13
- 'onReviewCLIResponse', // 接收到 CLI 命令的执行结果
10
+ // 'onReviewProgress', // 评审进度实时更新
11
+ // 'onReviewCLIContext', // 提取并发送 CLI 上下文信息
12
+ // 'onReviewAgentAction', // Agent 正在执行具体操作步骤
13
+ // 'onReviewCLIResponse', // 接收到 CLI 命令的执行结果
14
14
  'onReviewSuccess', // 评审流程圆满完成
15
15
  'onReviewFailed', // 评审流程判定为不通过
16
- 'onReviewError' // 评审过程中触发了系统错误
16
+ 'onReviewError', // 评审过程中触发了系统错误
17
+ 'onReviewList', // 查询待评审列表
18
+ 'onMerged' // 合并成功
17
19
  ];
18
20
 
19
21
  /**
@@ -168,6 +170,51 @@ function formatTelegramMessage(eventName, payload) {
168
170
  return `� **[系统异常]** ➯ 处理代码审查时发生错误 (MR !${mrId})\n\n❌ **异常信息:**\n${err}`;
169
171
  }
170
172
 
173
+ // -------------------------------------------------------------
174
+ // [9] 用户发起批量查询待办列表的返回信息
175
+ // -------------------------------------------------------------
176
+ case 'onReviewList': {
177
+ const count = payload.count || 0;
178
+ if (count === 0) {
179
+ return `🤖 **[系统查询]** ➯ ☕️ 恭喜!目前暂时没有分配给你或需要你审核的合并请求。`;
180
+ }
181
+
182
+ const listStr = payload.mrs.map(mr => {
183
+ const safeUrl = mr.web_url || '';
184
+ // 剔除可能破坏 Telegram Markdown V1 的特殊字符
185
+ const safeTitle = mr.title ? mr.title.replace(/[\*\_\`\[\]]/g, '') : '无标题';
186
+
187
+ // 格式化时间
188
+ let dateStr = mr.created_at;
189
+ if (dateStr) {
190
+ const d = new Date(dateStr);
191
+ dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
192
+ }
193
+
194
+ let desc = mr.description ? mr.description.replace(/[\*\_\`\[\]]/g, '') : '';
195
+ if (desc && desc.length > 50) desc = desc.substring(0, 50) + '...';
196
+
197
+ let item = `🔹 **[MR !${mr.iid}]** [${safeTitle}](${safeUrl})\n`;
198
+ if (desc) item += ` 📝 描述: ${desc}\n`;
199
+ item += ` 👤 提交者: ${mr.author && mr.author.name ? mr.author.name : '未知'}\n`;
200
+ item += ` 🏢 项目: ID ${mr.project_id}\n`;
201
+ item += ` 🌿 分支: \`${mr.source_branch}\` ➔ \`${mr.target_branch}\`\n`;
202
+ item += ` 🕒 时间: ${dateStr || '未知'}`;
203
+
204
+ return item;
205
+ }).join('\n\n');
206
+
207
+ return `💬 **[系统查询]** ➯ **目前有 ${count} 个待审核的合并请求:**\n\n${listStr}`;
208
+ }
209
+
210
+ // -------------------------------------------------------------
211
+ // [10] MR 合并成功
212
+ // -------------------------------------------------------------
213
+ case 'onMerged': {
214
+ const mrId = payload.mrId || '未知';
215
+ return `✅ **[系统执行]** ➯ 成功合并代码 (MR !${mrId}) 🎉`;
216
+ }
217
+
171
218
  // -------------------------------------------------------------
172
219
  // 未覆盖的兜底预案
173
220
  // -------------------------------------------------------------
package/test_hook.js ADDED
@@ -0,0 +1,11 @@
1
+ const axios = require('axios');
2
+ const token = 'your_hook_token_here';
3
+
4
+ axios.post('http://127.0.0.1:18790/hooks/code-review', {
5
+ event: 'onReviewStart',
6
+ mrId: 841,
7
+ status: 'Started',
8
+ formattedText: 'hello world test formattedText webhook'
9
+ }, {
10
+ headers: { 'Authorization': 'Bearer ' + token }
11
+ }).then(res => console.log('OK', res.data)).catch(err => console.error(err.message));