codewave-openclaw-installer 2.1.12 → 2.1.13
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/package.json +1 -1
- package/skills/risk-alert/SKILL.md +110 -0
- package/skills/risk-alert/config.example.json +21 -0
- package/skills/risk-alert/scheduler.sh +74 -0
- package/skills/risk-alert/scripts/analyze-risk.js +414 -0
- package/skills/risk-alert/scripts/config-manager.js +241 -0
- package/skills/risk-alert/scripts/push-beautiful.js +225 -0
- package/skills/risk-alert/scripts/risk-alert.js +136 -0
- package/skills/risk-alert/scripts/run-full.js +60 -0
- package/skills/risk-alert/trigger-risk-alert.sh +35 -0
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Risk Alert - 智能风险预警 Skill
|
|
2
|
+
|
|
3
|
+
## 功能概述
|
|
4
|
+
|
|
5
|
+
自动监控飞书群聊消息,智能识别风险并推送预警卡片。
|
|
6
|
+
|
|
7
|
+
## 首次使用配置
|
|
8
|
+
|
|
9
|
+
### 1. 配置飞书应用凭证
|
|
10
|
+
|
|
11
|
+
需要用户提供飞书自建应用的 `App ID` 和 `App Secret`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 方式1:环境变量
|
|
15
|
+
export FEISHU_APP_ID="your_app_id"
|
|
16
|
+
export FEISHU_APP_SECRET="your_app_secret"
|
|
17
|
+
|
|
18
|
+
# 方式2:配置文件(首次运行会自动创建)
|
|
19
|
+
# 编辑 config.json 文件
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 2. 选择监控群聊
|
|
23
|
+
|
|
24
|
+
首次运行时会自动获取机器人加入的所有群聊,并展示列表让用户确认:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
📋 发现机器人已加入以下群聊:
|
|
28
|
+
1. 项目群A (oc_xxx1)
|
|
29
|
+
2. 项目群B (oc_xxx2)
|
|
30
|
+
3. 测试群 (oc_xxx3)
|
|
31
|
+
|
|
32
|
+
请选择要监控的群聊(输入序号,多个用逗号分隔,输入 all 选择全部):
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 3. 选择推送目标
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
请选择风险预警推送目标:
|
|
39
|
+
1. 推送到指定群聊
|
|
40
|
+
2. 推送到指定用户
|
|
41
|
+
3. 同时推送
|
|
42
|
+
|
|
43
|
+
请输入选择(1/2/3):
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 4. 保存配置
|
|
47
|
+
|
|
48
|
+
配置会自动保存到 `config.json`,后续运行直接使用已保存的配置。
|
|
49
|
+
|
|
50
|
+
## 使用方法
|
|
51
|
+
|
|
52
|
+
### 手动执行
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 进入 skill 目录
|
|
56
|
+
cd /Users/song/.openclaw/workspace/skills/risk-alert
|
|
57
|
+
|
|
58
|
+
# 执行完整流程
|
|
59
|
+
node scripts/risk-alert.js
|
|
60
|
+
|
|
61
|
+
# 仅抓取消息
|
|
62
|
+
node scripts/analyze-risk.js
|
|
63
|
+
|
|
64
|
+
# 仅推送卡片(AI 分析完成后)
|
|
65
|
+
node scripts/push-beautiful.js
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 通过触发器
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# 发送触发指令给 AI
|
|
72
|
+
./trigger-risk-alert.sh
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 工作流程
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
1. 抓取群聊消息 → raw-messages.json
|
|
79
|
+
2. AI 智能分析 → risk-data.json
|
|
80
|
+
3. 推送预警卡片 → 飞书
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 配置项说明
|
|
84
|
+
|
|
85
|
+
| 配置项 | 说明 | 必填 |
|
|
86
|
+
|--------|------|------|
|
|
87
|
+
| appId | 飞书应用 ID | 是 |
|
|
88
|
+
| appSecret | 飞书应用 Secret | 是 |
|
|
89
|
+
| monitoredChats | 监控的群聊列表 | 是 |
|
|
90
|
+
| pushTarget.type | 推送类型: chat/user/both | 是 |
|
|
91
|
+
| pushTarget.chatId | 推送群聊 ID | 条件必填 |
|
|
92
|
+
| pushTarget.userId | 推送用户 ID | 条件必填 |
|
|
93
|
+
| days | 首次拉取天数 | 否,默认7 |
|
|
94
|
+
|
|
95
|
+
## 文件结构
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
risk-alert/
|
|
99
|
+
├── SKILL.md # 本文件
|
|
100
|
+
├── config.json # 用户配置(首次运行生成)
|
|
101
|
+
├── trigger-risk-alert.sh # 触发脚本
|
|
102
|
+
├── scheduler.sh # 定时任务
|
|
103
|
+
└── scripts/
|
|
104
|
+
├── analyze-risk.js # 消息抓取
|
|
105
|
+
├── risk-alert.js # 流程控制
|
|
106
|
+
├── push-beautiful.js # 卡片推送
|
|
107
|
+
├── config-manager.js # 配置管理
|
|
108
|
+
└── lib/
|
|
109
|
+
└── feishu-api.js # 飞书 API 封装
|
|
110
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"feishu": {
|
|
3
|
+
"appId": "YOUR_APP_ID_HERE",
|
|
4
|
+
"appSecret": "YOUR_APP_SECRET_HERE"
|
|
5
|
+
},
|
|
6
|
+
"monitoredChats": [
|
|
7
|
+
{
|
|
8
|
+
"id": "oc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
9
|
+
"name": "群聊名称"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"pushTarget": {
|
|
13
|
+
"type": "chat",
|
|
14
|
+
"chatId": "oc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
15
|
+
"userId": ""
|
|
16
|
+
},
|
|
17
|
+
"settings": {
|
|
18
|
+
"days": 7,
|
|
19
|
+
"incrementMode": true
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Risk Alert 定时推送脚本
|
|
3
|
+
# 每20分钟执行一次
|
|
4
|
+
|
|
5
|
+
# 设置 PATH(launchd 环境变量不同)
|
|
6
|
+
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
|
7
|
+
|
|
8
|
+
# 查找 node 路径
|
|
9
|
+
NODE_PATH=$(which node)
|
|
10
|
+
if [ -z "$NODE_PATH" ]; then
|
|
11
|
+
# 尝试常见路径
|
|
12
|
+
if [ -f "/usr/local/bin/node" ]; then
|
|
13
|
+
NODE_PATH="/usr/local/bin/node"
|
|
14
|
+
elif [ -f "/opt/homebrew/bin/node" ]; then
|
|
15
|
+
NODE_PATH="/opt/homebrew/bin/node"
|
|
16
|
+
elif [ -f "$HOME/.nvm/versions/node/v22.18.0/bin/node" ]; then
|
|
17
|
+
NODE_PATH="$HOME/.nvm/versions/node/v22.18.0/bin/node"
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# 工作目录
|
|
22
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
23
|
+
cd "$SCRIPT_DIR/scripts"
|
|
24
|
+
|
|
25
|
+
# 日志文件
|
|
26
|
+
LOG_FILE="$SCRIPT_DIR/risk-alert.log"
|
|
27
|
+
|
|
28
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始执行 Risk Alert 定时任务" >> "$LOG_FILE"
|
|
29
|
+
|
|
30
|
+
# Step 1: 全量抓取消息(删除状态文件)
|
|
31
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 步骤1: 抓取消息..." >> "$LOG_FILE"
|
|
32
|
+
rm -f last-fetch-state.json
|
|
33
|
+
$NODE_PATH analyze-risk.js >> "$LOG_FILE" 2>&1
|
|
34
|
+
if [ $? -ne 0 ]; then
|
|
35
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ 消息抓取失败" >> "$LOG_FILE"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# 检查是否有新消息
|
|
40
|
+
if [ -f "raw-messages.json" ]; then
|
|
41
|
+
NEW_COUNT=$($NODE_PATH -e "console.log(JSON.parse(require('fs').readFileSync('raw-messages.json')).newMessagesCount || 0)")
|
|
42
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 新增消息: $NEW_COUNT 条" >> "$LOG_FILE"
|
|
43
|
+
|
|
44
|
+
if [ "$NEW_COUNT" -eq 0 ]; then
|
|
45
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ℹ️ 没有新消息,跳过分析" >> "$LOG_FILE"
|
|
46
|
+
# 仍然推送一次(显示无风险状态)
|
|
47
|
+
$NODE_PATH push-beautiful.js >> "$LOG_FILE" 2>&1
|
|
48
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ 已推送无风险状态卡片" >> "$LOG_FILE"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Step 2: 生成 AI 任务文件
|
|
54
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 步骤2: 生成 AI 分析任务..." >> "$LOG_FILE"
|
|
55
|
+
$NODE_PATH risk-alert.js >> "$LOG_FILE" 2>&1
|
|
56
|
+
|
|
57
|
+
# Step 3: 调用 AI 分析(通过发送消息触发)
|
|
58
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 步骤3: 等待 AI 分析..." >> "$LOG_FILE"
|
|
59
|
+
echo "请 AI 助手执行风险分析并生成 risk-data.json,然后执行: node risk-alert.js push"
|
|
60
|
+
|
|
61
|
+
# 注意:AI 分析需要手动触发或由 AI 自动处理
|
|
62
|
+
# 这里我们直接推送(假设 AI 已经完成分析或 risk-data.json 已存在)
|
|
63
|
+
|
|
64
|
+
# Step 4: 推送卡片
|
|
65
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 步骤4: 推送卡片..." >> "$LOG_FILE"
|
|
66
|
+
$NODE_PATH risk-alert.js push >> "$LOG_FILE" 2>&1
|
|
67
|
+
if [ $? -eq 0 ]; then
|
|
68
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ 推送成功" >> "$LOG_FILE"
|
|
69
|
+
else
|
|
70
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ 推送失败" >> "$LOG_FILE"
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 定时任务执行完毕" >> "$LOG_FILE"
|
|
74
|
+
echo "---" >> "$LOG_FILE"
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const { loadConfig, getCredentials, isConfigValid, runConfigWizard } = require('./config-manager');
|
|
6
|
+
|
|
7
|
+
// --- Load Config ---
|
|
8
|
+
const CONFIG = loadConfig();
|
|
9
|
+
const CREDENTIALS = getCredentials(CONFIG);
|
|
10
|
+
|
|
11
|
+
// --- State File ---
|
|
12
|
+
const STATE_FILE = path.join(__dirname, 'last-fetch-state.json');
|
|
13
|
+
const OUTPUT_MD = path.join(__dirname, 'raw-messages.md');
|
|
14
|
+
const OUTPUT_JSON = path.join(__dirname, 'raw-messages.json');
|
|
15
|
+
|
|
16
|
+
// --- Utils: Format Time ---
|
|
17
|
+
function formatTime(timestamp) {
|
|
18
|
+
const date = new Date(parseInt(timestamp));
|
|
19
|
+
return date.toLocaleString('zh-CN', {
|
|
20
|
+
year: 'numeric',
|
|
21
|
+
month: '2-digit',
|
|
22
|
+
day: '2-digit',
|
|
23
|
+
hour: '2-digit',
|
|
24
|
+
minute: '2-digit',
|
|
25
|
+
second: '2-digit'
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatTimeShort(timestamp) {
|
|
30
|
+
const date = new Date(parseInt(timestamp));
|
|
31
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Utils: Load/Save State ---
|
|
35
|
+
function loadState() {
|
|
36
|
+
try {
|
|
37
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
38
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.warn('Warning: Could not load state file, starting fresh.');
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveState(state) {
|
|
47
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Utils: Extract Message Content ---
|
|
51
|
+
function extractContent(msg) {
|
|
52
|
+
const body = msg.body || {};
|
|
53
|
+
const content = body.content ? JSON.parse(body.content) : {};
|
|
54
|
+
|
|
55
|
+
switch (msg.msg_type) {
|
|
56
|
+
case 'text':
|
|
57
|
+
return content.text || '';
|
|
58
|
+
case 'post':
|
|
59
|
+
if (content.content) {
|
|
60
|
+
return content.content.map(line =>
|
|
61
|
+
line.map(item => item.text || '').join('')
|
|
62
|
+
).join('<br>');
|
|
63
|
+
}
|
|
64
|
+
return '[富文本消息]';
|
|
65
|
+
case 'interactive':
|
|
66
|
+
return '[卡片消息]';
|
|
67
|
+
case 'system':
|
|
68
|
+
return content.template || '[系统消息]';
|
|
69
|
+
default:
|
|
70
|
+
return `[${msg.msg_type}消息]`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Utils: Fetch User Name from Feishu API ---
|
|
75
|
+
const userNameCache = {};
|
|
76
|
+
function fetchUserName(userId, token) {
|
|
77
|
+
if (userNameCache[userId]) return userNameCache[userId];
|
|
78
|
+
if (!userId || !token) return null;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const res = execSync(`curl -s -X GET 'https://open.feishu.cn/open-apis/contact/v3/users/${userId}' -H 'Authorization: Bearer ${token}'`);
|
|
82
|
+
const data = JSON.parse(res);
|
|
83
|
+
if (data.code === 0 && data.data && data.data.user) {
|
|
84
|
+
const name = data.data.user.name;
|
|
85
|
+
userNameCache[userId] = name;
|
|
86
|
+
return name;
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// API 调用失败,返回 null
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Utils: Fetch Chat Members ---
|
|
95
|
+
const chatMembersCache = {};
|
|
96
|
+
function fetchChatMembers(chatId, token) {
|
|
97
|
+
if (chatMembersCache[chatId]) return chatMembersCache[chatId];
|
|
98
|
+
if (!chatId || !token) return {};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const res = execSync(`curl -s -X GET 'https://open.feishu.cn/open-apis/im/v1/chats/${chatId}/members?page_size=100' -H 'Authorization: Bearer ${token}'`);
|
|
102
|
+
const data = JSON.parse(res);
|
|
103
|
+
if (data.code === 0 && data.data && data.data.items) {
|
|
104
|
+
const members = {};
|
|
105
|
+
data.data.items.forEach(member => {
|
|
106
|
+
if (member.member_id && member.name) {
|
|
107
|
+
members[member.member_id] = member.name;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
chatMembersCache[chatId] = members;
|
|
111
|
+
return members;
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// API 调用失败,返回空对象
|
|
115
|
+
}
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Utils: Get Sender Name ---
|
|
120
|
+
function getSenderName(msg, userMap, token) {
|
|
121
|
+
if (msg.sender.sender_type === 'app') {
|
|
122
|
+
return '机器人';
|
|
123
|
+
}
|
|
124
|
+
if (userMap && userMap[msg.sender.id]) {
|
|
125
|
+
return userMap[msg.sender.id];
|
|
126
|
+
}
|
|
127
|
+
if (msg.mentions && msg.mentions.length > 0) {
|
|
128
|
+
const mention = msg.mentions.find(m => m.id === msg.sender.id);
|
|
129
|
+
if (mention) return mention.name;
|
|
130
|
+
}
|
|
131
|
+
// Try to fetch from API
|
|
132
|
+
if (token && msg.sender.id) {
|
|
133
|
+
const apiName = fetchUserName(msg.sender.id, token);
|
|
134
|
+
if (apiName) return apiName;
|
|
135
|
+
}
|
|
136
|
+
return msg.sender.id ? `用户(${msg.sender.id.slice(-6)})` : '未知用户';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Main Data Fetching Logic ---
|
|
140
|
+
async function main() {
|
|
141
|
+
// 检查配置
|
|
142
|
+
if (!isConfigValid(CONFIG)) {
|
|
143
|
+
console.log('⚠️ 首次使用,需要进行配置\n');
|
|
144
|
+
await runConfigWizard();
|
|
145
|
+
console.log('请重新运行脚本');
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 验证凭证
|
|
150
|
+
if (!CREDENTIALS.appId || !CREDENTIALS.appSecret) {
|
|
151
|
+
console.error('❌ 未配置飞书应用凭证');
|
|
152
|
+
console.log('请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET,或运行配置向导');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Load previous state
|
|
157
|
+
const state = loadState();
|
|
158
|
+
const isFirstRun = !state || !state.lastFetchTime;
|
|
159
|
+
|
|
160
|
+
if (isFirstRun) {
|
|
161
|
+
console.log('🆕 首次运行,将拉取最近 ' + CONFIG.settings.days + ' 天的消息...');
|
|
162
|
+
} else {
|
|
163
|
+
const lastTime = formatTime(state.lastFetchTime);
|
|
164
|
+
console.log('📥 增量模式,上次拉取时间:' + lastTime);
|
|
165
|
+
console.log(' 将只拉取新消息...');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 1. Get Access Token
|
|
169
|
+
let token;
|
|
170
|
+
try {
|
|
171
|
+
const response = execSync(`curl -s -X POST 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' -H 'Content-Type: application/json' -d '{"app_id":"${CREDENTIALS.appId}","app_secret":"${CREDENTIALS.appSecret}"}'`, { encoding: 'utf8' });
|
|
172
|
+
const data = JSON.parse(response);
|
|
173
|
+
if (data.code !== 0) throw new Error(data.msg || 'Token API error');
|
|
174
|
+
token = data.tenant_access_token;
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error("Fatal: Could not obtain access token.", e.message);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
console.log('✅ Access token obtained.');
|
|
180
|
+
|
|
181
|
+
// 2. 使用配置的监控群聊列表
|
|
182
|
+
const monitoredChats = CONFIG.monitoredChats;
|
|
183
|
+
if (monitoredChats.length === 0) {
|
|
184
|
+
console.error('❌ 未配置监控群聊');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
console.log(`✅ 监控 ${monitoredChats.length} 个群聊.`);
|
|
188
|
+
|
|
189
|
+
// Calculate start time
|
|
190
|
+
let startTimeMs;
|
|
191
|
+
if (isFirstRun) {
|
|
192
|
+
startTimeMs = Date.now() - CONFIG.settings.days * 24 * 60 * 60 * 1000;
|
|
193
|
+
} else {
|
|
194
|
+
startTimeMs = state.lastFetchTime;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Build user map from state or empty
|
|
198
|
+
let userMap = state ? (state.userMap || {}) : {};
|
|
199
|
+
const existingMessages = state ? (state.messages || {}) : {};
|
|
200
|
+
const messagesByChat = {}; // New messages only
|
|
201
|
+
let latestMessageTime = startTimeMs;
|
|
202
|
+
let totalNewMessages = 0;
|
|
203
|
+
|
|
204
|
+
// Pre-fetch chat members to build user map
|
|
205
|
+
console.log('\n👥 正在获取群成员信息...');
|
|
206
|
+
for (const chat of monitoredChats) {
|
|
207
|
+
const members = fetchChatMembers(chat.id, token);
|
|
208
|
+
Object.assign(userMap, members);
|
|
209
|
+
}
|
|
210
|
+
console.log(`✅ 已缓存 ${Object.keys(userMap).length} 个用户信息`);
|
|
211
|
+
|
|
212
|
+
for (const chat of monitoredChats) {
|
|
213
|
+
messagesByChat[chat.id] = [];
|
|
214
|
+
|
|
215
|
+
let messageCount = 0;
|
|
216
|
+
let pt = '';
|
|
217
|
+
console.log(`Fetching messages from "${chat.name}"...`);
|
|
218
|
+
|
|
219
|
+
while (true) {
|
|
220
|
+
try {
|
|
221
|
+
// Build URL with from_time for incremental fetch
|
|
222
|
+
let url = `https://open.feishu.cn/open-apis/im/v1/messages?container_id=${chat.id}&container_id_type=chat&page_size=50`;
|
|
223
|
+
if (pt) {
|
|
224
|
+
url += `&page_token=${pt}`;
|
|
225
|
+
}
|
|
226
|
+
// Note: Feishu API uses 'from_time' in seconds
|
|
227
|
+
url += `&from_time=${Math.floor(startTimeMs / 1000)}`;
|
|
228
|
+
|
|
229
|
+
const res = JSON.parse(execSync(`curl -s -X GET "${url}" -H 'Authorization: Bearer ${token}'`));
|
|
230
|
+
if (res.code !== 0) {
|
|
231
|
+
console.warn(`Warning: Could not fetch messages for ${chat.name}. Code: ${res.code}, Msg: ${res.msg}`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
const items = res.data.items || [];
|
|
235
|
+
items.forEach(msg => {
|
|
236
|
+
// Skip if already exists
|
|
237
|
+
if (existingMessages[msg.message_id]) return;
|
|
238
|
+
|
|
239
|
+
const senderName = getSenderName(msg, userMap, token);
|
|
240
|
+
if (msg.sender.id && senderName !== '机器人' && !senderName.startsWith('用户(')) {
|
|
241
|
+
userMap[msg.sender.id] = senderName;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const msgTime = parseInt(msg.create_time);
|
|
245
|
+
if (msgTime > latestMessageTime) {
|
|
246
|
+
latestMessageTime = msgTime;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
messagesByChat[chat.id].push({
|
|
250
|
+
message_id: msg.message_id,
|
|
251
|
+
time: msg.create_time,
|
|
252
|
+
sender_type: msg.sender.sender_type,
|
|
253
|
+
sender_id: msg.sender.id,
|
|
254
|
+
sender_name: senderName,
|
|
255
|
+
msg_type: msg.msg_type,
|
|
256
|
+
content: extractContent(msg),
|
|
257
|
+
parent_id: msg.parent_id || null
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Mark as existing
|
|
261
|
+
existingMessages[msg.message_id] = true;
|
|
262
|
+
});
|
|
263
|
+
messageCount += items.length;
|
|
264
|
+
if (!res.data.has_more) break;
|
|
265
|
+
pt = res.data.page_token;
|
|
266
|
+
} catch(e) {
|
|
267
|
+
console.error(`Error fetching messages for ${chat.name}:`, e.message);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
console.log(` -> Fetched ${messageCount} messages (${messagesByChat[chat.id].length} new).`);
|
|
272
|
+
totalNewMessages += messagesByChat[chat.id].length;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
console.log(`\n📊 本次共获取 ${totalNewMessages} 条新消息`);
|
|
276
|
+
|
|
277
|
+
if (totalNewMessages === 0) {
|
|
278
|
+
console.log('✅ 没有新消息,无需更新。');
|
|
279
|
+
// Still update last fetch time
|
|
280
|
+
saveState({
|
|
281
|
+
lastFetchTime: latestMessageTime,
|
|
282
|
+
userMap: userMap,
|
|
283
|
+
messages: existingMessages,
|
|
284
|
+
chatList: monitoredChats
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 3. Generate/Update Markdown Report
|
|
290
|
+
let mdContent;
|
|
291
|
+
let allMessagesByChat = {};
|
|
292
|
+
|
|
293
|
+
if (isFirstRun || !fs.existsSync(OUTPUT_MD)) {
|
|
294
|
+
// First run - create new file
|
|
295
|
+
mdContent = `# 飞书群聊消息原始数据\n\n`;
|
|
296
|
+
mdContent += `**首次生成时间:** ${new Date().toLocaleString('zh-CN')}\n\n`;
|
|
297
|
+
mdContent += `**群聊数量:** ${monitoredChats.length} 个\n\n`;
|
|
298
|
+
mdContent += `**模式:** 增量拉取(首次全量)\n\n`;
|
|
299
|
+
mdContent += `---\n\n`;
|
|
300
|
+
|
|
301
|
+
// Use fetched messages as all messages
|
|
302
|
+
for (const chatId in messagesByChat) {
|
|
303
|
+
allMessagesByChat[chatId] = messagesByChat[chatId];
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
// Incremental - need to merge with existing
|
|
307
|
+
console.log('\n📝 合并新消息到现有报告...');
|
|
308
|
+
|
|
309
|
+
// Parse existing markdown to extract messages (simplified approach)
|
|
310
|
+
// For now, we'll append new sections
|
|
311
|
+
mdContent = fs.readFileSync(OUTPUT_MD, 'utf8');
|
|
312
|
+
|
|
313
|
+
// Remove trailing --- if exists
|
|
314
|
+
mdContent = mdContent.replace(/---\n\n$/, '');
|
|
315
|
+
|
|
316
|
+
// Add update timestamp
|
|
317
|
+
mdContent += `\n\n## 🔄 增量更新 ${new Date().toLocaleString('zh-CN')}\n\n`;
|
|
318
|
+
mdContent += `**本次新增消息:** ${totalNewMessages} 条\n\n`;
|
|
319
|
+
|
|
320
|
+
allMessagesByChat = messagesByChat; // Only show new messages in this update section
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Generate table for each chat
|
|
324
|
+
for (const chatId in allMessagesByChat) {
|
|
325
|
+
const chat = monitoredChats.find(c => c.id === chatId);
|
|
326
|
+
const chatName = chat?.name || '未知群聊';
|
|
327
|
+
let messages = allMessagesByChat[chatId];
|
|
328
|
+
|
|
329
|
+
if (messages.length === 0) continue;
|
|
330
|
+
|
|
331
|
+
messages.sort((a, b) => parseInt(a.time) - parseInt(b.time));
|
|
332
|
+
|
|
333
|
+
if (isFirstRun) {
|
|
334
|
+
mdContent += `## 📁 ${chatName}\n\n`;
|
|
335
|
+
mdContent += `**群聊ID:** \`${chatId}\`\n\n`;
|
|
336
|
+
} else {
|
|
337
|
+
mdContent += `### 📁 ${chatName}(新增)\n\n`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
mdContent += `| 序号 | 时间 | 发送者 | 类型 | 消息内容 |\n`;
|
|
341
|
+
mdContent += `|------|------|--------|------|----------|\n`;
|
|
342
|
+
|
|
343
|
+
// Group consecutive messages from same sender
|
|
344
|
+
const groupedMessages = [];
|
|
345
|
+
let currentGroup = null;
|
|
346
|
+
|
|
347
|
+
messages.forEach((msg, index) => {
|
|
348
|
+
const timeShort = formatTimeShort(msg.time);
|
|
349
|
+
const content = msg.content.replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
350
|
+
|
|
351
|
+
if (msg.sender_type === 'user' && msg.msg_type === 'text' &&
|
|
352
|
+
currentGroup && currentGroup.sender_id === msg.sender_id &&
|
|
353
|
+
index > 0 && messages[index - 1].sender_id === msg.sender_id) {
|
|
354
|
+
currentGroup.messages.push({ time: timeShort, content });
|
|
355
|
+
} else {
|
|
356
|
+
currentGroup = {
|
|
357
|
+
sender_id: msg.sender_id,
|
|
358
|
+
sender_name: msg.sender_name,
|
|
359
|
+
msg_type: msg.msg_type,
|
|
360
|
+
messages: [{ time: timeShort, content }]
|
|
361
|
+
};
|
|
362
|
+
groupedMessages.push(currentGroup);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
groupedMessages.forEach((group, groupIndex) => {
|
|
367
|
+
if (group.messages.length === 1) {
|
|
368
|
+
const msg = group.messages[0];
|
|
369
|
+
mdContent += `| ${groupIndex + 1} | ${msg.time} | ${group.sender_name} | ${group.msg_type} | ${msg.content} |\n`;
|
|
370
|
+
} else {
|
|
371
|
+
const times = group.messages.map(m => m.time).join(' → ');
|
|
372
|
+
const contents = group.messages.map(m => m.content).join(' → ');
|
|
373
|
+
mdContent += `| ${groupIndex + 1} | ${times} | ${group.sender_name} | ${group.msg_type} | ${contents} |\n`;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const userMessages = messages.filter(m => m.sender_type === 'user' && m.msg_type === 'text');
|
|
378
|
+
if (userMessages.length > 1) {
|
|
379
|
+
const timeLine = userMessages.map(m => formatTimeShort(m.time)).join(' → ');
|
|
380
|
+
mdContent += `| ⏱️ | **时间线** | - | - | ${timeLine} |\n`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
mdContent += `\n**消息数量:** ${messages.length} 条\n\n`;
|
|
384
|
+
mdContent += `---\n\n`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Save Markdown
|
|
388
|
+
fs.writeFileSync(OUTPUT_MD, mdContent, 'utf8');
|
|
389
|
+
console.log(`✅ Markdown report saved to ${OUTPUT_MD}`);
|
|
390
|
+
|
|
391
|
+
// Save state
|
|
392
|
+
saveState({
|
|
393
|
+
lastFetchTime: latestMessageTime,
|
|
394
|
+
userMap: userMap,
|
|
395
|
+
messages: existingMessages,
|
|
396
|
+
chatList: monitoredChats
|
|
397
|
+
});
|
|
398
|
+
console.log(`✅ State saved (last fetch: ${formatTime(latestMessageTime)})`);
|
|
399
|
+
|
|
400
|
+
// Also save JSON for backward compatibility
|
|
401
|
+
const allMessages = Object.values(messagesByChat).flat();
|
|
402
|
+
const jsonOutput = {
|
|
403
|
+
updateTime: new Date().toISOString(),
|
|
404
|
+
isIncremental: !isFirstRun,
|
|
405
|
+
newMessagesCount: totalNewMessages,
|
|
406
|
+
chats: monitoredChats,
|
|
407
|
+
messages: allMessages
|
|
408
|
+
};
|
|
409
|
+
fs.writeFileSync(OUTPUT_JSON, JSON.stringify(jsonOutput, null, 2), 'utf8');
|
|
410
|
+
|
|
411
|
+
console.log('\n✅ 增量拉取完成!');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
main();
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILE = path.join(__dirname, '..', 'config.json');
|
|
7
|
+
|
|
8
|
+
// 默认配置
|
|
9
|
+
const DEFAULT_CONFIG = {
|
|
10
|
+
feishu: {
|
|
11
|
+
appId: '',
|
|
12
|
+
appSecret: ''
|
|
13
|
+
},
|
|
14
|
+
monitoredChats: [], // { id, name }
|
|
15
|
+
pushTarget: {
|
|
16
|
+
type: 'chat', // 'chat' | 'user' | 'both'
|
|
17
|
+
chatId: '',
|
|
18
|
+
userId: ''
|
|
19
|
+
},
|
|
20
|
+
settings: {
|
|
21
|
+
days: 7,
|
|
22
|
+
incrementMode: true
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// 加载配置
|
|
27
|
+
function loadConfig() {
|
|
28
|
+
try {
|
|
29
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
30
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.warn('⚠️ 配置文件读取失败,使用默认配置');
|
|
34
|
+
}
|
|
35
|
+
return { ...DEFAULT_CONFIG };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 保存配置
|
|
39
|
+
function saveConfig(config) {
|
|
40
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
41
|
+
console.log(`✅ 配置已保存到: ${CONFIG_FILE}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 检查配置是否完整
|
|
45
|
+
function isConfigValid(config) {
|
|
46
|
+
return config.feishu.appId &&
|
|
47
|
+
config.feishu.appSecret &&
|
|
48
|
+
config.monitoredChats.length > 0 &&
|
|
49
|
+
(config.pushTarget.chatId || config.pushTarget.userId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 获取环境变量或配置的凭证
|
|
53
|
+
function getCredentials(config) {
|
|
54
|
+
return {
|
|
55
|
+
appId: process.env.FEISHU_APP_ID || config.feishu.appId,
|
|
56
|
+
appSecret: process.env.FEISHU_APP_SECRET || config.feishu.appSecret
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 交互式配置向导
|
|
61
|
+
async function runConfigWizard() {
|
|
62
|
+
const rl = readline.createInterface({
|
|
63
|
+
input: process.stdin,
|
|
64
|
+
output: process.stdout
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
|
|
68
|
+
|
|
69
|
+
console.log('\n🚀 Risk Alert 首次配置向导\n');
|
|
70
|
+
console.log('═══════════════════════════════════════\n');
|
|
71
|
+
|
|
72
|
+
const config = loadConfig();
|
|
73
|
+
const credentials = getCredentials(config);
|
|
74
|
+
|
|
75
|
+
// Step 1: 配置飞书应用凭证
|
|
76
|
+
console.log('📋 Step 1: 飞书应用凭证');
|
|
77
|
+
console.log('─────────────────────────────────────');
|
|
78
|
+
|
|
79
|
+
if (!credentials.appId) {
|
|
80
|
+
config.feishu.appId = await question('请输入飞书 App ID: ');
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`✓ App ID: ${credentials.appId}`);
|
|
83
|
+
const change = await question('是否修改? (y/N): ');
|
|
84
|
+
if (change.toLowerCase() === 'y') {
|
|
85
|
+
config.feishu.appId = await question('请输入飞书 App ID: ');
|
|
86
|
+
} else {
|
|
87
|
+
config.feishu.appId = credentials.appId;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!credentials.appSecret) {
|
|
92
|
+
config.feishu.appSecret = await question('请输入飞书 App Secret: ');
|
|
93
|
+
} else {
|
|
94
|
+
console.log(`✓ App Secret: ${'*'.repeat(credentials.appSecret.length)}`);
|
|
95
|
+
const change = await question('是否修改? (y/N): ');
|
|
96
|
+
if (change.toLowerCase() === 'y') {
|
|
97
|
+
config.feishu.appSecret = await question('请输入飞书 App Secret: ');
|
|
98
|
+
} else {
|
|
99
|
+
config.feishu.appSecret = credentials.appSecret;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 测试凭证有效性
|
|
104
|
+
console.log('\n🔄 正在验证凭证...');
|
|
105
|
+
const token = await testCredentials(config.feishu.appId, config.feishu.appSecret);
|
|
106
|
+
if (!token) {
|
|
107
|
+
console.error('❌ 凭证验证失败,请检查 App ID 和 App Secret');
|
|
108
|
+
rl.close();
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
console.log('✅ 凭证验证通过\n');
|
|
112
|
+
|
|
113
|
+
// Step 2: 选择监控群聊
|
|
114
|
+
console.log('📋 Step 2: 选择监控群聊');
|
|
115
|
+
console.log('─────────────────────────────────────');
|
|
116
|
+
|
|
117
|
+
const chats = await fetchChatList(token);
|
|
118
|
+
if (chats.length === 0) {
|
|
119
|
+
console.error('❌ 机器人未加入任何群聊');
|
|
120
|
+
rl.close();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`\n📋 发现机器人已加入 ${chats.length} 个群聊:`);
|
|
125
|
+
chats.forEach((chat, idx) => {
|
|
126
|
+
console.log(` ${idx + 1}. ${chat.name} (${chat.chat_id})`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 默认监控所有群聊,但让用户确认
|
|
130
|
+
console.log(`\n📋 默认将监控以上所有 ${chats.length} 个群聊`);
|
|
131
|
+
const selected = await question('\n请确认或修改:\n - 直接回车确认监控所有群聊\n - 输入序号选择特定群聊(多个用逗号分隔)\n - 输入 none 取消选择\n\n您的选择 [默认: all]: ');
|
|
132
|
+
|
|
133
|
+
if (selected.trim() === '' || selected.toLowerCase() === 'all') {
|
|
134
|
+
// 默认选择所有
|
|
135
|
+
config.monitoredChats = chats.map(c => ({ id: c.chat_id, name: c.name }));
|
|
136
|
+
console.log(`✅ 已选择所有 ${config.monitoredChats.length} 个群聊进行监控\n`);
|
|
137
|
+
} else if (selected.toLowerCase() === 'none') {
|
|
138
|
+
config.monitoredChats = [];
|
|
139
|
+
console.log('⚠️ 未选择任何群聊\n');
|
|
140
|
+
} else {
|
|
141
|
+
// 选择特定群聊
|
|
142
|
+
const indices = selected.split(',').map(s => parseInt(s.trim()) - 1).filter(i => i >= 0 && i < chats.length);
|
|
143
|
+
config.monitoredChats = indices.map(i => ({ id: chats[i].chat_id, name: chats[i].name }));
|
|
144
|
+
console.log(`✅ 已选择 ${config.monitoredChats.length} 个群聊进行监控\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Step 3: 选择推送目标
|
|
148
|
+
console.log('📋 Step 3: 选择推送目标');
|
|
149
|
+
console.log('─────────────────────────────────────');
|
|
150
|
+
console.log('1. 推送到指定群聊');
|
|
151
|
+
console.log('2. 推送到指定用户');
|
|
152
|
+
console.log('3. 同时推送');
|
|
153
|
+
|
|
154
|
+
const pushType = await question('\n请输入选择(1/2/3):');
|
|
155
|
+
|
|
156
|
+
switch (pushType) {
|
|
157
|
+
case '1':
|
|
158
|
+
config.pushTarget.type = 'chat';
|
|
159
|
+
const chatIdx = await question('请选择推送群聊(输入序号):');
|
|
160
|
+
const idx = parseInt(chatIdx) - 1;
|
|
161
|
+
if (idx >= 0 && idx < chats.length) {
|
|
162
|
+
config.pushTarget.chatId = chats[idx].chat_id;
|
|
163
|
+
console.log(`✅ 将推送到群聊: ${chats[idx].name}`);
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
case '2':
|
|
167
|
+
config.pushTarget.type = 'user';
|
|
168
|
+
config.pushTarget.userId = await question('请输入用户 Open ID: ');
|
|
169
|
+
console.log(`✅ 将推送到用户: ${config.pushTarget.userId}`);
|
|
170
|
+
break;
|
|
171
|
+
case '3':
|
|
172
|
+
config.pushTarget.type = 'both';
|
|
173
|
+
const chatIdx2 = await question('请选择推送群聊(输入序号):');
|
|
174
|
+
const idx2 = parseInt(chatIdx2) - 1;
|
|
175
|
+
if (idx2 >= 0 && idx2 < chats.length) {
|
|
176
|
+
config.pushTarget.chatId = chats[idx2].chat_id;
|
|
177
|
+
}
|
|
178
|
+
config.pushTarget.userId = await question('请输入用户 Open ID: ');
|
|
179
|
+
console.log(`✅ 将同时推送到群聊和用户`);
|
|
180
|
+
break;
|
|
181
|
+
default:
|
|
182
|
+
console.log('⚠️ 无效选择,默认推送到第一个监控群聊');
|
|
183
|
+
config.pushTarget.type = 'chat';
|
|
184
|
+
config.pushTarget.chatId = config.monitoredChats[0]?.id || '';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Step 4: 其他设置
|
|
188
|
+
console.log('\n📋 Step 4: 其他设置');
|
|
189
|
+
console.log('─────────────────────────────────────');
|
|
190
|
+
const days = await question(`首次拉取天数(默认 ${config.settings.days}):`);
|
|
191
|
+
if (days) config.settings.days = parseInt(days) || 7;
|
|
192
|
+
|
|
193
|
+
rl.close();
|
|
194
|
+
|
|
195
|
+
// 保存配置
|
|
196
|
+
saveConfig(config);
|
|
197
|
+
|
|
198
|
+
console.log('\n═══════════════════════════════════════');
|
|
199
|
+
console.log('✅ 配置完成!可以开始使用 Risk Alert 了。');
|
|
200
|
+
console.log('═══════════════════════════════════════\n');
|
|
201
|
+
|
|
202
|
+
return config;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 测试凭证
|
|
206
|
+
async function testCredentials(appId, appSecret) {
|
|
207
|
+
try {
|
|
208
|
+
const res = execSync(`curl -s -X POST 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' \
|
|
209
|
+
-H 'Content-Type: application/json' \
|
|
210
|
+
-d '{"app_id":"${appId}","app_secret":"${appSecret}"}'`, { encoding: 'utf8' });
|
|
211
|
+
const data = JSON.parse(res);
|
|
212
|
+
return data.code === 0 ? data.tenant_access_token : null;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 获取群聊列表
|
|
219
|
+
async function fetchChatList(token) {
|
|
220
|
+
try {
|
|
221
|
+
const res = execSync(`curl -s -X GET 'https://open.feishu.cn/open-apis/im/v1/chats?page_size=100' \
|
|
222
|
+
-H 'Authorization: Bearer ${token}'`, { encoding: 'utf8' });
|
|
223
|
+
const data = JSON.parse(res);
|
|
224
|
+
if (data.code === 0 && data.data && data.data.items) {
|
|
225
|
+
return data.data.items.filter(c => c.name);
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error('获取群聊列表失败:', e.message);
|
|
229
|
+
}
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
loadConfig,
|
|
235
|
+
saveConfig,
|
|
236
|
+
isConfigValid,
|
|
237
|
+
getCredentials,
|
|
238
|
+
runConfigWizard,
|
|
239
|
+
testCredentials,
|
|
240
|
+
fetchChatList
|
|
241
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const { loadConfig, getCredentials } = require('./config-manager');
|
|
6
|
+
|
|
7
|
+
// 加载配置
|
|
8
|
+
const CONFIG = loadConfig();
|
|
9
|
+
const CREDENTIALS = getCredentials(CONFIG);
|
|
10
|
+
|
|
11
|
+
async function getAccessToken() {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const req = https.request({
|
|
14
|
+
hostname: 'open.feishu.cn',
|
|
15
|
+
path: '/open-apis/auth/v3/tenant_access_token/internal',
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' }
|
|
18
|
+
}, res => {
|
|
19
|
+
let data = '';
|
|
20
|
+
res.on('data', chunk => data += chunk);
|
|
21
|
+
res.on('end', () => {
|
|
22
|
+
try {
|
|
23
|
+
const r = JSON.parse(data);
|
|
24
|
+
r.code === 0 ? resolve(r.tenant_access_token) : reject(new Error(r.msg));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
reject(e);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
req.on('error', reject);
|
|
31
|
+
req.write(JSON.stringify({
|
|
32
|
+
app_id: CREDENTIALS.appId,
|
|
33
|
+
app_secret: CREDENTIALS.appSecret
|
|
34
|
+
}));
|
|
35
|
+
req.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function sendCard(token, card, targetType, targetId) {
|
|
40
|
+
const receive_id_type = targetType === 'chat' ? 'chat_id' : 'open_id';
|
|
41
|
+
const receive_id = targetId;
|
|
42
|
+
const payload = JSON.stringify({
|
|
43
|
+
receive_id: receive_id,
|
|
44
|
+
msg_type: 'interactive',
|
|
45
|
+
content: JSON.stringify(card)
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const req = https.request({
|
|
50
|
+
hostname: 'open.feishu.cn',
|
|
51
|
+
path: `/open-apis/im/v1/messages?receive_id_type=${receive_id_type}`,
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${token}`,
|
|
55
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
56
|
+
}
|
|
57
|
+
}, res => {
|
|
58
|
+
let data = '';
|
|
59
|
+
res.on('data', chunk => data += chunk);
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const r = JSON.parse(data);
|
|
63
|
+
r.code === 0 ? resolve(r) : reject(new Error(r.msg));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
reject(e);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
req.on('error', reject);
|
|
70
|
+
req.write(payload);
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function generateCard(data) {
|
|
76
|
+
if (!data || !data.riskCount) {
|
|
77
|
+
return {
|
|
78
|
+
config: { wide_screen_mode: true },
|
|
79
|
+
header: {
|
|
80
|
+
template: "red",
|
|
81
|
+
title: { content: "❌ 报告生成失败", tag: "plain_text" }
|
|
82
|
+
},
|
|
83
|
+
elements: [{ tag: "div", text: { content: "未能加载风险数据。", tag: "lark_md" } }]
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const headerTemplate = data.riskCount.high > 0 ? "red" : (data.riskCount.medium > 0 ? "yellow" : "green");
|
|
88
|
+
const rc = data.riskCount;
|
|
89
|
+
const headerTitle = `风险预警报告 (${data.checkTime}) 🔴${rc.high} 🟡${rc.medium} 🟢${rc.low}`;
|
|
90
|
+
const elements = [];
|
|
91
|
+
|
|
92
|
+
// 1. 风险详情
|
|
93
|
+
elements.push({ tag: "div", text: { content: "**🚨 风险详情**", tag: "lark_md" } });
|
|
94
|
+
if (!data.hasRisk) {
|
|
95
|
+
elements.push({ tag: "div", text: { content: "未发现需关注的风险。", tag: "lark_md" } });
|
|
96
|
+
} else {
|
|
97
|
+
const allRisks = [...data.highRisks, ...data.mediumRisks, ...data.lowRisks];
|
|
98
|
+
allRisks.forEach((risk, idx) => {
|
|
99
|
+
const levelIcon = risk.level === 'high' ? '🔴' : (risk.level === 'medium' ? '🟡' : '🟢');
|
|
100
|
+
let riskTitle = `${levelIcon} **风险${idx + 1}:${risk.dangers.join('、')}**`;
|
|
101
|
+
if (risk.conclusion.includes("【升级】")) riskTitle += " 【升级】";
|
|
102
|
+
if (risk.conclusion.includes("【降级】")) riskTitle += " 【降级】";
|
|
103
|
+
if (risk.conclusion.includes("合并")) riskTitle += ` (${risk.conclusion.match(/合并\d+条/)[0]})`;
|
|
104
|
+
elements.push({ tag: "div", text: { content: riskTitle, tag: "lark_md" } });
|
|
105
|
+
let handledText = '暂无人回复,建议跟进';
|
|
106
|
+
if (risk.handledInfo.handled) handledText = `✅ 由 **${risk.handledInfo.handler}** 跟进`;
|
|
107
|
+
const finalConclusion = risk.level === 'high' ? '🔴 高风险 - 需立即处理' : (risk.level === 'medium' ? '🟡 中风险 - 建议跟进' : '🟢 低风险 - 已有响应');
|
|
108
|
+
elements.push({ tag: "div", text: { content: `**群聊:** ${risk.chat}`, tag: "lark_md" } });
|
|
109
|
+
elements.push({ tag: "div", text: { content: `<font color='red'>**原因:** ${risk.cause}</font>`, tag: "lark_md" } });
|
|
110
|
+
elements.push({ tag: "div", text: { content: `**处理状态:** ${handledText}`, tag: "lark_md" } });
|
|
111
|
+
elements.push({ tag: "div", text: { content: `**发送者:** ${risk.sender} | **时间:** ${risk.time}`, tag: "lark_md" } });
|
|
112
|
+
elements.push({ tag: "div", text: { content: `**内容:** ${risk.content.length > 200 ? risk.content.substring(0, 200) + '...' : risk.content}`, tag: "lark_md" } });
|
|
113
|
+
elements.push({ tag: "div", text: { content: `**结论:** ${finalConclusion}`, tag: "lark_md" } });
|
|
114
|
+
if (idx < allRisks.length - 1) elements.push({ tag: "hr" });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
elements.push({ tag: "hr" });
|
|
119
|
+
|
|
120
|
+
// 2. 风险概览
|
|
121
|
+
elements.push({ tag: "div", text: { content: "**📊 风险概览**", tag: "lark_md" } });
|
|
122
|
+
const chatList = (data.chatDetails || []).map((chat, i) => `${i+1}. ${chat.name} (${chat.count}条消息)`).join('\n');
|
|
123
|
+
elements.push({ tag: "div", text: { content: `**📋 检测群聊(${data.chatDetails.length}个):**\n${chatList}`, tag: "lark_md" } });
|
|
124
|
+
elements.push({ tag: "column_set", flex_mode: "none", background_style: "default", columns: [
|
|
125
|
+
{ tag: "column", width: "weighted", weight: 1, vertical_align: "top", elements: [{ tag: "div", text: { content: `🔴 ${rc.high}高风险`, tag: "lark_md" } }] },
|
|
126
|
+
{ tag: "column", width: "weighted", weight: 1, vertical_align: "top", elements: [{ tag: "div", text: { content: `🟡 ${rc.medium}中风险`, tag: "lark_md" } }] },
|
|
127
|
+
{ tag: "column", width: "weighted", weight: 1, vertical_align: "top", elements: [{ tag: "div", text: { content: `🟢 ${rc.low}低风险`, tag: "lark_md" } }] },
|
|
128
|
+
]});
|
|
129
|
+
|
|
130
|
+
// 3. 已解决风险
|
|
131
|
+
if (data.resolvedRisks && data.resolvedRisks.length > 0) {
|
|
132
|
+
elements.push({ tag: "hr" });
|
|
133
|
+
elements.push({ tag: "div", text: { content: "**✅ 已解决/已解除的风险**", tag: "lark_md" } });
|
|
134
|
+
data.resolvedRisks.forEach((resolved, idx) => {
|
|
135
|
+
elements.push({ tag: "div", text: { content: `${idx + 1}. **${resolved.chat}**`, tag: "lark_md" } });
|
|
136
|
+
elements.push({ tag: "div", text: { content: ` 🚨 风险报告:**${resolved.riskReporter || '未知'}**`, tag: "lark_md" } });
|
|
137
|
+
elements.push({ tag: "div", text: { content: ` 📋 原风险:${resolved.originalRisk}`, tag: "lark_md" } });
|
|
138
|
+
elements.push({ tag: "div", text: { content: ` ✅ 解除人:**${resolved.resolver || '未知'}**`, tag: "lark_md" } });
|
|
139
|
+
elements.push({ tag: "div", text: { content: ` 📝 解除说明:${resolved.resolution} (${resolved.resolutionTime})`, tag: "lark_md" } });
|
|
140
|
+
if (idx < data.resolvedRisks.length - 1) elements.push({ tag: "hr" });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4. 非风险记录
|
|
145
|
+
const nonRisks = [];
|
|
146
|
+
if (data.lowRisks) {
|
|
147
|
+
data.lowRisks.forEach(risk => {
|
|
148
|
+
if (risk.cause && (risk.cause.includes('测试') || risk.cause.includes('澄清') || risk.conclusion.includes('已说明'))) {
|
|
149
|
+
nonRisks.push(risk);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (nonRisks.length > 0) {
|
|
154
|
+
elements.push({ tag: "hr" });
|
|
155
|
+
elements.push({ tag: "div", text: { content: "**🚫 已排除的非风险记录**", tag: "lark_md" } });
|
|
156
|
+
nonRisks.forEach((item, idx) => {
|
|
157
|
+
elements.push({ tag: "div", text: { content: `${idx + 1}. **${item.chat}** - ${item.cause}`, tag: "lark_md" } });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
config: { wide_screen_mode: true },
|
|
163
|
+
header: {
|
|
164
|
+
template: headerTemplate,
|
|
165
|
+
title: { content: headerTitle, tag: "plain_text" }
|
|
166
|
+
},
|
|
167
|
+
elements: elements
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getCurrentTime() {
|
|
172
|
+
const now = new Date();
|
|
173
|
+
return now.toLocaleString('zh-CN', {
|
|
174
|
+
year: 'numeric',
|
|
175
|
+
month: '2-digit',
|
|
176
|
+
day: '2-digit',
|
|
177
|
+
hour: '2-digit',
|
|
178
|
+
minute: '2-digit'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function main() {
|
|
183
|
+
// 检查凭证
|
|
184
|
+
if (!CREDENTIALS.appId || !CREDENTIALS.appSecret) {
|
|
185
|
+
console.error('❌ 未配置飞书应用凭证');
|
|
186
|
+
console.log('请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET,或运行配置向导');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 检查推送目标
|
|
191
|
+
if (!CONFIG.pushTarget.chatId && !CONFIG.pushTarget.userId) {
|
|
192
|
+
console.error('❌ 未配置推送目标');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const token = await getAccessToken();
|
|
198
|
+
const data = JSON.parse(fs.readFileSync(path.join(__dirname, 'risk-data.json'), 'utf8'));
|
|
199
|
+
data.checkTime = getCurrentTime();
|
|
200
|
+
const card = generateCard(data);
|
|
201
|
+
|
|
202
|
+
// 根据配置推送
|
|
203
|
+
const { type, chatId, userId } = CONFIG.pushTarget;
|
|
204
|
+
|
|
205
|
+
if ((type === 'chat' || type === 'both') && chatId) {
|
|
206
|
+
await sendCard(token, card, 'chat', chatId);
|
|
207
|
+
console.log(`✅ 卡片已推送到群聊: ${chatId}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if ((type === 'user' || type === 'both') && userId) {
|
|
211
|
+
await sendCard(token, card, 'user', userId);
|
|
212
|
+
console.log(`✅ 卡片已推送到用户: ${userId}`);
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.error("❌ 推送失败:", e.message);
|
|
216
|
+
try {
|
|
217
|
+
const token = await getAccessToken();
|
|
218
|
+
if (token) await sendCard(token, generateCard(null), CONFIG.pushTarget.type, CONFIG.pushTarget.chatId || CONFIG.pushTarget.userId);
|
|
219
|
+
} catch (finalErr) {
|
|
220
|
+
console.error("Failed to send failure notification:", finalErr.message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
main();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Risk Alert 全自动流程
|
|
4
|
+
*
|
|
5
|
+
* 执行步骤:
|
|
6
|
+
* 1. 抓取群聊消息
|
|
7
|
+
* 2. AI 分析(通过调用 OpenClaw AI)
|
|
8
|
+
* 3. 推送预警卡片
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync, spawn } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const SCRIPTS_DIR = path.join(__dirname);
|
|
16
|
+
const RAW_MESSAGES_MD = path.join(SCRIPTS_DIR, 'raw-messages.md');
|
|
17
|
+
const RAW_MESSAGES_JSON = path.join(SCRIPTS_DIR, 'raw-messages.json');
|
|
18
|
+
const RISK_DATA_JSON = path.join(SCRIPTS_DIR, 'risk-data.json');
|
|
19
|
+
|
|
20
|
+
const command = process.argv[2];
|
|
21
|
+
|
|
22
|
+
// 辅助函数:执行命令并返回输出
|
|
23
|
+
function execCommand(cmd, args, options = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const child = spawn(cmd, args, {
|
|
26
|
+
cwd: SCRIPTS_DIR,
|
|
27
|
+
stdio: options.silent ? 'pipe' : 'inherit',
|
|
28
|
+
...options
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let output = '';
|
|
32
|
+
if (options.silent && child.stdout) {
|
|
33
|
+
child.stdout.on('data', (data) => {
|
|
34
|
+
output += data.toString();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
child.on('close', (code) => {
|
|
39
|
+
if (code === 0) {
|
|
40
|
+
resolve(output);
|
|
41
|
+
} else {
|
|
42
|
+
reject(new Error(`Command failed with code ${code}`));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 仅抓取模式
|
|
51
|
+
if (command === 'fetch') {
|
|
52
|
+
console.log('📥 抓取消息...');
|
|
53
|
+
execCommand('node', ['analyze-risk.js'])
|
|
54
|
+
.then(() => {
|
|
55
|
+
console.log('✅ 抓取完成');
|
|
56
|
+
process.exit(0);
|
|
57
|
+
})
|
|
58
|
+
.catch((e) => {
|
|
59
|
+
console.error('❌ 抓取失败:', e.message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 仅推送模式
|
|
66
|
+
if (command === 'push') {
|
|
67
|
+
console.log('📤 推送卡片...');
|
|
68
|
+
execCommand('node', ['push-beautiful.js'])
|
|
69
|
+
.then(() => {
|
|
70
|
+
console.log('✅ 推送完成');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
})
|
|
73
|
+
.catch((e) => {
|
|
74
|
+
console.error('❌ 推送失败:', e.message);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 完整自动化流程
|
|
81
|
+
async function runFullWorkflow() {
|
|
82
|
+
console.log('🚀 启动 Risk Alert 全自动流程\n');
|
|
83
|
+
|
|
84
|
+
// Step 1: 抓取消息
|
|
85
|
+
console.log('📥 Step 1: 抓取群聊消息...');
|
|
86
|
+
try {
|
|
87
|
+
await execCommand('node', ['analyze-risk.js']);
|
|
88
|
+
console.log('✅ 消息抓取完成\n');
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error('❌ 消息抓取失败:', e.message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 检查是否有新消息
|
|
95
|
+
if (!fs.existsSync(RAW_MESSAGES_JSON)) {
|
|
96
|
+
console.error('❌ 未找到 raw-messages.json');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rawData = JSON.parse(fs.readFileSync(RAW_MESSAGES_JSON, 'utf8'));
|
|
101
|
+
if (rawData.newMessagesCount === 0) {
|
|
102
|
+
console.log('ℹ️ 没有新消息');
|
|
103
|
+
|
|
104
|
+
// 推送无风险状态
|
|
105
|
+
const emptyRiskData = {
|
|
106
|
+
checkTime: new Date().toLocaleString('zh-CN'),
|
|
107
|
+
hasRisk: false,
|
|
108
|
+
riskCount: { high: 0, medium: 0, low: 0 },
|
|
109
|
+
chatDetails: (rawData.chats || []).map(c => ({ name: c.name, count: 0 })),
|
|
110
|
+
highRisks: [],
|
|
111
|
+
mediumRisks: [],
|
|
112
|
+
lowRisks: []
|
|
113
|
+
};
|
|
114
|
+
fs.writeFileSync(RISK_DATA_JSON, JSON.stringify(emptyRiskData, null, 2));
|
|
115
|
+
|
|
116
|
+
console.log('📤 推送无风险状态...');
|
|
117
|
+
await execCommand('node', ['push-beautiful.js']);
|
|
118
|
+
console.log('✅ 完成');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`📊 新增 ${rawData.newMessagesCount} 条消息\n`);
|
|
123
|
+
|
|
124
|
+
// Step 2: 输出提示,让 AI 继续执行
|
|
125
|
+
console.log('🧠 Step 2: 需要 AI 分析风险');
|
|
126
|
+
console.log('─────────────────────────────────');
|
|
127
|
+
console.log('\n⚠️ 自动分析需要 AI 介入');
|
|
128
|
+
console.log('请确保 AI 助手完成以下步骤:');
|
|
129
|
+
console.log(' 1. 读取 raw-messages.md');
|
|
130
|
+
console.log(' 2. 分析风险并生成 risk-data.json');
|
|
131
|
+
console.log(' 3. 调用: node risk-alert.js push');
|
|
132
|
+
console.log('\n或者手动执行完整流程...');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 运行
|
|
136
|
+
runFullWorkflow().catch(console.error);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const SKILL_DIR = path.join(__dirname, '..');
|
|
6
|
+
const SCRIPTS_DIR = path.join(SKILL_DIR, 'scripts');
|
|
7
|
+
|
|
8
|
+
console.log('🚀 启动 Risk Alert 完整流程...\n');
|
|
9
|
+
|
|
10
|
+
// Step 1: 抓取消息
|
|
11
|
+
console.log('📥 Step 1: 抓取群聊消息...');
|
|
12
|
+
try {
|
|
13
|
+
execSync('node analyze-risk.js', {
|
|
14
|
+
cwd: SCRIPTS_DIR,
|
|
15
|
+
stdio: 'inherit'
|
|
16
|
+
});
|
|
17
|
+
console.log('✅ 消息抓取完成\n');
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.error('❌ 消息抓取失败:', e.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Step 2: AI 分析(提示用户)
|
|
24
|
+
console.log('🧠 Step 2: AI 风险分析');
|
|
25
|
+
console.log('─────────────────────────────────');
|
|
26
|
+
console.log('请让 AI 助手执行以下操作:');
|
|
27
|
+
console.log('1. 读取 scripts/raw-messages.md');
|
|
28
|
+
console.log('2. 分析风险并更新 scripts/risk-data.json');
|
|
29
|
+
console.log('3. 完成后继续 Step 3');
|
|
30
|
+
console.log('─────────────────────────────────\n');
|
|
31
|
+
console.log('等待 AI 分析完成...(按 Ctrl+C 跳过,手动执行)\n');
|
|
32
|
+
|
|
33
|
+
// 等待用户确认
|
|
34
|
+
const readline = require('readline');
|
|
35
|
+
const rl = readline.createInterface({
|
|
36
|
+
input: process.stdin,
|
|
37
|
+
output: process.stdout
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
rl.question('AI 分析完成后,按 Enter 继续推送,或输入 "skip" 跳过: ', (answer) => {
|
|
41
|
+
rl.close();
|
|
42
|
+
|
|
43
|
+
if (answer.toLowerCase() === 'skip') {
|
|
44
|
+
console.log('⏭️ 已跳过,请手动执行: node push-beautiful.js');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Step 3: 推送卡片
|
|
49
|
+
console.log('\n📤 Step 3: 推送风险预警卡片...');
|
|
50
|
+
try {
|
|
51
|
+
execSync('node push-beautiful.js', {
|
|
52
|
+
cwd: SCRIPTS_DIR,
|
|
53
|
+
stdio: 'inherit'
|
|
54
|
+
});
|
|
55
|
+
console.log('\n✅ 完整流程执行完毕!');
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error('❌ 推送失败:', e.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 触发 Risk Alert 工作流
|
|
3
|
+
# 读取配置中的用户 ID 发送触发指令
|
|
4
|
+
|
|
5
|
+
CONFIG_FILE="$(dirname "$0")/config.json"
|
|
6
|
+
|
|
7
|
+
# 默认用户 ID(如果配置不存在)
|
|
8
|
+
DEFAULT_USER_ID=""
|
|
9
|
+
|
|
10
|
+
# 尝试从配置文件中读取用户 ID
|
|
11
|
+
if [ -f "$CONFIG_FILE" ]; then
|
|
12
|
+
# 使用 node 读取 JSON 配置
|
|
13
|
+
USER_ID=$(node -e "
|
|
14
|
+
try {
|
|
15
|
+
const config = require('$CONFIG_FILE');
|
|
16
|
+
console.log(config.pushTarget?.userId || '');
|
|
17
|
+
} catch(e) {
|
|
18
|
+
console.log('');
|
|
19
|
+
}
|
|
20
|
+
" 2>/dev/null)
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# 如果没有配置用户 ID,使用默认值
|
|
24
|
+
if [ -z "$USER_ID" ]; then
|
|
25
|
+
USER_ID="$DEFAULT_USER_ID"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [ -z "$USER_ID" ]; then
|
|
29
|
+
echo "❌ 未配置推送用户 ID"
|
|
30
|
+
echo "请先运行: node scripts/analyze-risk.js 进行配置"
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# 使用 openclaw 工具发送消息
|
|
35
|
+
/usr/local/bin/openclaw message send --channel feishu --target "user:$USER_ID" --message "[[internal_trigger:run_risk_alert_workflow]]"
|