claw-subagent-service 0.0.28 → 0.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/package.json +3 -2
- package/scripts/sync-local.js +269 -0
- package/service/daemon.js +29 -8
- package/service/logger.js +6 -2
- package/service/modules/normal-message-handler.js +9 -27
- package/service/rongcloud/message-handler.js +20 -5
- package/service/rongcloud/openclaw-client.js +12 -46
- package/service/rongcloud/rongcloud-client.js +23 -0
- package/service/worker.js +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claw-subagent-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"description": "虾说静态服务",
|
|
5
5
|
"main": "cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"dev": "nodemon cli.js --run",
|
|
15
15
|
"publish:patch": "npm version patch && npm publish",
|
|
16
16
|
"publish:minor": "npm version minor && npm publish",
|
|
17
|
-
"publish:major": "npm version major && npm publish"
|
|
17
|
+
"publish:major": "npm version major && npm publish",
|
|
18
|
+
"sync": "node scripts/sync-local.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"cli.js",
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sync-local.js - 本地开发快速同步脚本
|
|
5
|
+
*
|
|
6
|
+
* 运行后将当前代码直接替换到全局安装的 claw-subagent-service 目录,
|
|
7
|
+
* 无需发布到 npm 后再重新下载安装。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* npm run sync (推荐)
|
|
11
|
+
* node scripts/sync-local.js
|
|
12
|
+
*
|
|
13
|
+
* 注意: Windows 下若服务正在运行,会自动卸载服务、同步后再重新安装。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
const isWindows = process.platform === 'win32';
|
|
21
|
+
const SERVICE_NAME = 'claw-subagent-service';
|
|
22
|
+
const PACKAGE_NAME = 'claw-subagent-service';
|
|
23
|
+
const ROOT = path.join(__dirname, '..');
|
|
24
|
+
|
|
25
|
+
// 需要同步的文件/目录(与 package.json files 保持一致)
|
|
26
|
+
const FILES_TO_SYNC = [
|
|
27
|
+
'cli.js',
|
|
28
|
+
'service',
|
|
29
|
+
'scripts',
|
|
30
|
+
'command',
|
|
31
|
+
'version.json',
|
|
32
|
+
'README.md',
|
|
33
|
+
'package.json',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function getGlobalPackageDir() {
|
|
37
|
+
try {
|
|
38
|
+
const globalRoot = execSync('npm root -g', {
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
}).trim();
|
|
42
|
+
return path.join(globalRoot, PACKAGE_NAME);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error('❌ 无法获取全局 npm 路径:', e.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stopAndUninstallService() {
|
|
50
|
+
if (!isWindows) {
|
|
51
|
+
try {
|
|
52
|
+
execSync('systemctl stop claw-subagent-service 2>/dev/null', {
|
|
53
|
+
stdio: 'ignore',
|
|
54
|
+
timeout: 15000,
|
|
55
|
+
});
|
|
56
|
+
console.log('✅ 服务已停止');
|
|
57
|
+
} catch {}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('🛑 正在停止并卸载 Windows 服务(释放文件锁)...');
|
|
62
|
+
|
|
63
|
+
// 1. 停止服务
|
|
64
|
+
try {
|
|
65
|
+
execSync('net stop "claw-subagent-service" 2>nul', {
|
|
66
|
+
stdio: 'ignore',
|
|
67
|
+
timeout: 35000,
|
|
68
|
+
});
|
|
69
|
+
console.log(' ✅ 服务已停止');
|
|
70
|
+
} catch {}
|
|
71
|
+
|
|
72
|
+
// 2. 杀掉 wrapper 进程(node-windows 生成的 .exe)
|
|
73
|
+
try {
|
|
74
|
+
execSync('taskkill /f /im "claw-subagent-service.exe" 2>nul', {
|
|
75
|
+
stdio: 'ignore',
|
|
76
|
+
timeout: 10000,
|
|
77
|
+
});
|
|
78
|
+
console.log(' ✅ wrapper 进程已终止');
|
|
79
|
+
} catch {}
|
|
80
|
+
|
|
81
|
+
// 3. 杀掉相关 node 进程
|
|
82
|
+
try {
|
|
83
|
+
execSync(
|
|
84
|
+
'powershell -NoProfile -Command "Get-Process node -ErrorAction SilentlyContinue | Where-Object {$_.Path -like \'*claw-subagent*\'} | Stop-Process -Force -ErrorAction SilentlyContinue" 2>nul',
|
|
85
|
+
{ stdio: 'ignore', timeout: 15000 }
|
|
86
|
+
);
|
|
87
|
+
} catch {}
|
|
88
|
+
|
|
89
|
+
// 4. 通过 wmic 按命令行匹配杀掉相关 node 进程
|
|
90
|
+
try {
|
|
91
|
+
execSync('wmic process where "name=\'node.exe\' and commandline like \'%claw-subagent%\'" delete 2>nul', {
|
|
92
|
+
stdio: 'ignore',
|
|
93
|
+
timeout: 10000,
|
|
94
|
+
});
|
|
95
|
+
console.log(' ✅ 相关 node 进程已清理');
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
// 5. 从注册表删除服务(彻底释放 node-windows wrapper 的文件锁)
|
|
99
|
+
try {
|
|
100
|
+
execSync('sc.exe delete "claw-subagent-service" 2>nul', {
|
|
101
|
+
stdio: 'ignore',
|
|
102
|
+
timeout: 10000,
|
|
103
|
+
});
|
|
104
|
+
console.log(' ✅ 服务已从注册表删除');
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
// 6. 等待 Windows 回收文件句柄(关键!node-windows wrapper 需要足够时间释放)
|
|
108
|
+
console.log(' ⏳ 等待系统释放文件锁(10秒)...');
|
|
109
|
+
execSync('ping -n 11 127.0.0.1 >nul', { stdio: 'ignore', windowsHide: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function installAndStartService() {
|
|
113
|
+
console.log('🚀 正在安装并启动服务...');
|
|
114
|
+
try {
|
|
115
|
+
if (isWindows) {
|
|
116
|
+
execSync('claw-subagent-service --install', {
|
|
117
|
+
stdio: 'inherit',
|
|
118
|
+
timeout: 60000,
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
execSync('systemctl start claw-subagent-service', {
|
|
123
|
+
stdio: 'inherit',
|
|
124
|
+
timeout: 15000,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
console.log('✅ 服务已安装/启动');
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error('⚠️ 服务安装/启动失败:', e.message);
|
|
130
|
+
console.log(' 请手动运行: claw-subagent-service --install');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isServiceInstalled() {
|
|
135
|
+
try {
|
|
136
|
+
if (isWindows) {
|
|
137
|
+
execSync('sc query "claw-subagent-service" | findstr SERVICE_NAME >nul', {
|
|
138
|
+
stdio: 'ignore',
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
});
|
|
141
|
+
return true;
|
|
142
|
+
} else {
|
|
143
|
+
execSync('systemctl is-active --quiet claw-subagent-service || systemctl is-enabled --quiet claw-subagent-service', {
|
|
144
|
+
stdio: 'ignore',
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function copyDirSync(src, dest) {
|
|
154
|
+
if (isWindows) {
|
|
155
|
+
// 确保目标目录存在
|
|
156
|
+
if (!fs.existsSync(dest)) {
|
|
157
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 使用 robocopy,遇到锁定的文件时跳过(/R:0 /W:0 表示不重试)
|
|
161
|
+
const cmd = `robocopy "${src}" "${dest}" /E /R:0 /W:0 /NJH /NJS /NDL /NC /NS /NFL`;
|
|
162
|
+
try {
|
|
163
|
+
execSync(cmd, { encoding: 'utf-8', windowsHide: true });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// robocopy 返回码含义:
|
|
166
|
+
// 0 = 成功,1 = 有文件被跳过,2 = 有额外文件,4 = 有不匹配,8 = 有错误,16 = 严重错误
|
|
167
|
+
const status = e.status || 0;
|
|
168
|
+
if (status === 1) {
|
|
169
|
+
console.log(' ℹ️ 部分文件被跳过(可能正在使用)');
|
|
170
|
+
} else if (status >= 8) {
|
|
171
|
+
throw new Error(`robocopy 失败 (code ${status})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
if (!fs.existsSync(dest)) {
|
|
176
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
177
|
+
}
|
|
178
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
179
|
+
const s = path.join(src, entry.name);
|
|
180
|
+
const d = path.join(dest, entry.name);
|
|
181
|
+
if (entry.isDirectory()) {
|
|
182
|
+
copyDirSync(s, d);
|
|
183
|
+
} else {
|
|
184
|
+
fs.copyFileSync(s, d);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function syncFiles(targetDir) {
|
|
191
|
+
console.log(`📦 同步到: ${targetDir}\n`);
|
|
192
|
+
|
|
193
|
+
for (const file of FILES_TO_SYNC) {
|
|
194
|
+
const src = path.join(ROOT, file);
|
|
195
|
+
const dest = path.join(targetDir, file);
|
|
196
|
+
|
|
197
|
+
if (!fs.existsSync(src)) {
|
|
198
|
+
console.log(` ⚠️ 跳过: ${file} (不存在)`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const stat = fs.statSync(src);
|
|
204
|
+
if (stat.isDirectory()) {
|
|
205
|
+
copyDirSync(src, dest);
|
|
206
|
+
} else {
|
|
207
|
+
fs.copyFileSync(src, dest);
|
|
208
|
+
}
|
|
209
|
+
console.log(` ✅ ${file}`);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
console.error(` ❌ ${file}: ${e.message}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function installDepsIfNeeded(targetDir) {
|
|
218
|
+
const nodeModulesPath = path.join(targetDir, 'node_modules');
|
|
219
|
+
const hasNodeModules = fs.existsSync(nodeModulesPath) && fs.readdirSync(nodeModulesPath).length > 0;
|
|
220
|
+
|
|
221
|
+
if (hasNodeModules) {
|
|
222
|
+
console.log('\n📦 依赖已存在,跳过安装');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log('\n📦 安装依赖...');
|
|
227
|
+
try {
|
|
228
|
+
execSync('npm install --ignore-scripts --prefer-offline --no-audit --no-fund --progress=false', {
|
|
229
|
+
cwd: targetDir,
|
|
230
|
+
stdio: 'inherit',
|
|
231
|
+
encoding: 'utf-8',
|
|
232
|
+
windowsHide: true,
|
|
233
|
+
timeout: 300000,
|
|
234
|
+
});
|
|
235
|
+
console.log('✅ 依赖安装完成');
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.error('⚠️ 依赖安装失败,请手动运行:');
|
|
238
|
+
console.error(` cd "${targetDir}" && npm install`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function main() {
|
|
243
|
+
console.log('🦞 claw-subagent-service - 本地同步\n');
|
|
244
|
+
|
|
245
|
+
const targetDir = getGlobalPackageDir();
|
|
246
|
+
const wasInstalled = isServiceInstalled();
|
|
247
|
+
|
|
248
|
+
if (wasInstalled) {
|
|
249
|
+
console.log('ℹ️ 检测到服务已安装,先卸载以释放文件锁\n');
|
|
250
|
+
stopAndUninstallService();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
syncFiles(targetDir);
|
|
254
|
+
installDepsIfNeeded(targetDir);
|
|
255
|
+
|
|
256
|
+
console.log('\n✅ 同步完成!');
|
|
257
|
+
console.log(` 目标目录: ${targetDir}`);
|
|
258
|
+
|
|
259
|
+
if (wasInstalled) {
|
|
260
|
+
console.log('\n🔄 正在重新安装并启动服务...');
|
|
261
|
+
installAndStartService();
|
|
262
|
+
} else {
|
|
263
|
+
console.log('\n💡 提示:');
|
|
264
|
+
console.log(' 服务未安装,如需安装请运行:');
|
|
265
|
+
console.log(' claw-subagent-service --install');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
main();
|
package/service/daemon.js
CHANGED
|
@@ -10,9 +10,16 @@ const log = createLogger('daemon');
|
|
|
10
10
|
const WORKER_PATH = path.join(__dirname, 'worker.js');
|
|
11
11
|
const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 28765;
|
|
12
12
|
// 使用全局 PID 文件,防止不同安装路径的多个实例同时运行
|
|
13
|
-
const PID_FILE =
|
|
13
|
+
const PID_FILE = (() => {
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
const programData = process.env.PROGRAMDATA || process.env.ALLUSERSPROFILE || 'C:\\ProgramData';
|
|
16
|
+
return path.join(programData, 'claw-subagent-service', 'daemon.pid');
|
|
17
|
+
}
|
|
18
|
+
return path.join(os.tmpdir(), '.claw-subagent-service.pid');
|
|
19
|
+
})();
|
|
14
20
|
|
|
15
21
|
let worker = null;
|
|
22
|
+
let currentWorkerPid = null;
|
|
16
23
|
let stopping = false;
|
|
17
24
|
let isRollingBack = false;
|
|
18
25
|
let healthTimer = null;
|
|
@@ -36,7 +43,12 @@ function checkSingleton() {
|
|
|
36
43
|
process.kill(pid, 0);
|
|
37
44
|
log.error(`[DAEMON] 另一个 Daemon 实例已在运行 (PID: ${pid}),当前实例退出`);
|
|
38
45
|
return false;
|
|
39
|
-
} catch {
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === 'EPERM') {
|
|
48
|
+
// 进程存在但无权限发送信号,视为仍在运行
|
|
49
|
+
log.error(`[DAEMON] 另一个 Daemon 实例已在运行 (PID: ${pid}, 权限不足),当前实例退出`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
40
52
|
// 进程不存在,继续启动
|
|
41
53
|
}
|
|
42
54
|
}
|
|
@@ -46,6 +58,10 @@ function checkSingleton() {
|
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
try {
|
|
61
|
+
const pidDir = path.dirname(PID_FILE);
|
|
62
|
+
if (!fs.existsSync(pidDir)) {
|
|
63
|
+
fs.mkdirSync(pidDir, { recursive: true });
|
|
64
|
+
}
|
|
49
65
|
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
50
66
|
} catch (err) {
|
|
51
67
|
log.warn(`[DAEMON] 写入 PID 文件失败: ${err.message}`);
|
|
@@ -92,7 +108,7 @@ function freePortIfNeeded(port) {
|
|
|
92
108
|
const localAddr = parts[1] || '';
|
|
93
109
|
if (localAddr.endsWith(`:${port}`) || localAddr.endsWith(`]:${port}`)) {
|
|
94
110
|
const pid = parseInt(parts[parts.length - 1], 10);
|
|
95
|
-
if (pid && pid > 0) {
|
|
111
|
+
if (pid && pid > 0 && pid !== currentWorkerPid && pid !== process.pid) {
|
|
96
112
|
log.warn(`[DAEMON] 端口 ${port} 被进程 ${pid} 占用,正在释放...`);
|
|
97
113
|
try {
|
|
98
114
|
execSync(`taskkill /F /PID ${pid}`, { timeout: 5000, windowsHide: true });
|
|
@@ -126,7 +142,7 @@ function getRestartDelay() {
|
|
|
126
142
|
crashCount++;
|
|
127
143
|
// 指数退避: 1s, 2s, 4s, 8s, 16s, 32s, 60s(封顶)
|
|
128
144
|
const delay = Math.min(1000 * Math.pow(2, crashCount - 1), 60000);
|
|
129
|
-
log.info(`[DAEMON] Worker 连续崩溃 ${crashCount} 次,等待 ${delay/1000}s 后重启`);
|
|
145
|
+
log.info(`[DAEMON] Worker 连续崩溃 ${crashCount} 次,等待 ${delay / 1000}s 后重启`);
|
|
130
146
|
return delay;
|
|
131
147
|
}
|
|
132
148
|
|
|
@@ -146,9 +162,13 @@ function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
|
|
|
146
162
|
};
|
|
147
163
|
|
|
148
164
|
worker = fork(WORKER_PATH, [], forkOptions);
|
|
165
|
+
currentWorkerPid = worker.pid || null;
|
|
149
166
|
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
// 只有 pipe 模式才需要手动转发到 logger
|
|
168
|
+
if (forkOptions.stdio !== 'inherit') {
|
|
169
|
+
worker.stdout?.on('data', d => log.info(`[WORKER] ${d.toString().trim()}`));
|
|
170
|
+
worker.stderr?.on('data', d => log.error(`[WORKER] ${d.toString().trim()}`));
|
|
171
|
+
}
|
|
152
172
|
|
|
153
173
|
if (isAfterUpdate && backupDirForRollback) {
|
|
154
174
|
currentBackupDir = backupDirForRollback;
|
|
@@ -166,6 +186,7 @@ function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
|
|
|
166
186
|
|
|
167
187
|
log.error(`[DAEMON] Worker 退出,code=${code}, signal=${signal}`);
|
|
168
188
|
worker = null;
|
|
189
|
+
currentWorkerPid = null;
|
|
169
190
|
|
|
170
191
|
if (isAfterUpdate && code !== 0 && currentBackupDir && !isRollingBack) {
|
|
171
192
|
isRollingBack = true;
|
|
@@ -186,8 +207,8 @@ function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
|
|
|
186
207
|
}
|
|
187
208
|
|
|
188
209
|
if (!stopping && !isRollingBack) {
|
|
189
|
-
//
|
|
190
|
-
|
|
210
|
+
// 使用指数退避延迟后重启;端口释放交由 startWorker 中的 freePortIfNeeded 处理,
|
|
211
|
+
// 避免此处误杀刚刚由其他实例启动的 worker
|
|
191
212
|
const delay = getRestartDelay();
|
|
192
213
|
setTimeout(() => startWorker(false, null), delay);
|
|
193
214
|
}
|
package/service/logger.js
CHANGED
|
@@ -13,12 +13,16 @@ function createLogger(name) {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const write = (level, msg) => {
|
|
16
|
-
const line = `[${new Date().toISOString()}] [${level}] ${msg}
|
|
16
|
+
const line = `[${new Date().toISOString()}] [${level}] ${msg}`;
|
|
17
17
|
try {
|
|
18
|
-
fs.appendFileSync(getFile(), line);
|
|
18
|
+
fs.appendFileSync(getFile(), line + '\n');
|
|
19
19
|
} catch (e) {
|
|
20
20
|
// ignore write errors
|
|
21
21
|
}
|
|
22
|
+
if (process.stdout && process.stdout.isTTY) {
|
|
23
|
+
const fn = level === 'ERROR' ? 'error' : level === 'WARN' ? 'warn' : 'log';
|
|
24
|
+
console[fn](line);
|
|
25
|
+
}
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
return {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 普通消息处理器 - 接收 rongcloud 转发的普通消息并调用 AI 服务
|
|
3
|
-
*
|
|
4
|
-
* 被调用位置:rongcloud/message-handler.js
|
|
3
|
+
*
|
|
4
|
+
* 被调用位置:rongcloud/message-handler.js
|
|
5
5
|
* 调用方式:await this.handleNormalMessage(msg)
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* @param {Object} msg - 消息对象
|
|
8
8
|
* @param {string} msg.content - 消息内容
|
|
9
9
|
* @param {string} msg.senderUserId - 发送者ID
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* @param {number} msg.conversationType - 会话类型 (1=私聊, 3=群聊)
|
|
12
12
|
* @returns {string} 回复内容
|
|
13
13
|
*/
|
|
14
|
-
const {
|
|
14
|
+
const { OpenClawClient } = require('../rongcloud/openclaw-client');
|
|
15
|
+
|
|
16
|
+
const openclawClient = new OpenClawClient(console);
|
|
15
17
|
|
|
16
18
|
async function handleNormalMessage(msg) {
|
|
17
19
|
console.log(`[NormalMessageHandler] 收到普通消息:`, {
|
|
@@ -21,34 +23,14 @@ async function handleNormalMessage(msg) {
|
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
try {
|
|
24
|
-
// 使用 senderUserId 作为 session ID,确保每个用户有独立的会话
|
|
25
|
-
const sessionId = msg.senderUserId || 'default';
|
|
26
26
|
const content = msg.content;
|
|
27
|
-
|
|
28
27
|
if (!content || !content.trim()) {
|
|
29
28
|
return '消息内容为空';
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
await forwardChatMessage(
|
|
36
|
-
sessionId,
|
|
37
|
-
content,
|
|
38
|
-
async (delta) => {
|
|
39
|
-
// 收集响应片段
|
|
40
|
-
fullResponse += delta;
|
|
41
|
-
},
|
|
42
|
-
(level, message) => {
|
|
43
|
-
// 日志回调
|
|
44
|
-
console.log(`[CHAT-${level}] ${message}`);
|
|
45
|
-
},
|
|
46
|
-
600000 // 10分钟超时
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
console.log(`[NormalMessageHandler] AI 回复: ${fullResponse.substring(0, 50)}...`);
|
|
50
|
-
return fullResponse;
|
|
51
|
-
|
|
31
|
+
const reply = await openclawClient.chat(content, msg.senderUserId);
|
|
32
|
+
console.log(`[NormalMessageHandler] AI 回复: ${reply.substring(0, 50)}...`);
|
|
33
|
+
return reply;
|
|
52
34
|
} catch (err) {
|
|
53
35
|
console.error(`[NormalMessageHandler] 处理异常:`, err.message);
|
|
54
36
|
return `抱歉,处理消息时出错: ${err.message}`;
|
|
@@ -41,7 +41,13 @@ class MessageHandler {
|
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// claw 控制消息不需要 @mention 过滤
|
|
45
|
+
if (msg.messageType === 'claw') {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const textContent = typeof msg.content === 'string' ? msg.content : (msg.content?.content || '');
|
|
50
|
+
const mentions = this.extractMentions(textContent);
|
|
45
51
|
|
|
46
52
|
if (mentions.length > 0) {
|
|
47
53
|
if (!mentions.includes(this.nodeId)) {
|
|
@@ -49,8 +55,11 @@ class MessageHandler {
|
|
|
49
55
|
return false;
|
|
50
56
|
}
|
|
51
57
|
this.log?.info(`[MessageHandler] 消息提及本节点(${this.nodeId}),处理`);
|
|
58
|
+
} else if (msg.conversationType === 3) {
|
|
59
|
+
this.log?.info(`[MessageHandler] 群聊消息未 @ 本节点(${this.nodeId}),忽略`);
|
|
60
|
+
return false;
|
|
52
61
|
} else {
|
|
53
|
-
this.log?.info(`[MessageHandler]
|
|
62
|
+
this.log?.info(`[MessageHandler] 单聊消息未指定节点,本节点(${this.nodeId})处理`);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
return true;
|
|
@@ -63,12 +72,17 @@ class MessageHandler {
|
|
|
63
72
|
|
|
64
73
|
try {
|
|
65
74
|
const type = this.getMessageType(msg);
|
|
66
|
-
|
|
75
|
+
const logContent = typeof msg.content === 'string' ? msg.content : (msg.content?.content || '');
|
|
76
|
+
this.log?.info(`[MessageHandler] 收到消息 from=${msg.senderUserId}, type=${type}, content=${logContent.substring(0, 50)}`);
|
|
67
77
|
if (msg.messageType === 'claw') {
|
|
68
78
|
this.log?.info(`收到龙虾消息,交由 OpenClawClient 处理`);
|
|
69
79
|
await this.handleClaw(msg);
|
|
70
80
|
} else {
|
|
71
|
-
await this.handleNormalMessage(msg);
|
|
81
|
+
const reply = await this.handleNormalMessage(msg);
|
|
82
|
+
if (reply) {
|
|
83
|
+
const targetId = this.getReplyTarget(msg);
|
|
84
|
+
await this.sendFn(targetId, reply, msg.conversationType);
|
|
85
|
+
}
|
|
72
86
|
}
|
|
73
87
|
} catch (err) {
|
|
74
88
|
this.log?.error(`[MessageHandler] 处理消息异常: ${err.message}`);
|
|
@@ -81,7 +95,8 @@ class MessageHandler {
|
|
|
81
95
|
if (msg.messageType === 'claw') {
|
|
82
96
|
return MessageType.CLAW;
|
|
83
97
|
}
|
|
84
|
-
|
|
98
|
+
const text = typeof msg.content === 'string' ? msg.content : (msg.content?.content || '');
|
|
99
|
+
if (text.startsWith('/')) {
|
|
85
100
|
return MessageType.COMMAND;
|
|
86
101
|
}
|
|
87
102
|
return MessageType.NORMAL;
|
|
@@ -249,44 +249,8 @@ class OpenClawClient {
|
|
|
249
249
|
|
|
250
250
|
this.log?.info(`[OpenClawClient] 准备发送消息到 OpenClaw,from=${fromUser}, message=${message.substring(0, 50)}`);
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
try {
|
|
255
|
-
const response = await axios.post(
|
|
256
|
-
`${gatewayUrl}/v1/chat/completions`,
|
|
257
|
-
{
|
|
258
|
-
model: 'claw-local',
|
|
259
|
-
messages: [
|
|
260
|
-
{ role: 'user', content: message }
|
|
261
|
-
],
|
|
262
|
-
stream: false
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
headers: { 'Content-Type': 'application/json' },
|
|
266
|
-
timeout: 120000
|
|
267
|
-
}
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
if (response.status === 200 && response.data) {
|
|
271
|
-
const result = response.data;
|
|
272
|
-
const aiMessage = result.choices?.[0]?.message;
|
|
273
|
-
const content = aiMessage?.content;
|
|
274
|
-
if (content) {
|
|
275
|
-
this.log?.info(`[OpenClawClient] AI 回复: ${content.substring(0, 50)}...`);
|
|
276
|
-
return content;
|
|
277
|
-
}
|
|
278
|
-
this.log?.warn('[OpenClawClient] OpenClaw 返回了空响应');
|
|
279
|
-
return 'OpenClaw 未返回有效响应';
|
|
280
|
-
} else {
|
|
281
|
-
this.log?.error(`[OpenClawClient] OpenClaw 返回错误: ${response.status}`);
|
|
282
|
-
return `OpenClaw 返回错误: ${response.status}`;
|
|
283
|
-
}
|
|
284
|
-
} catch (err) {
|
|
285
|
-
this.log?.error(`[OpenClawClient] HTTP 调用失败: ${err.message}`);
|
|
286
|
-
// fallback 到 CLI 调用
|
|
287
|
-
this.log?.info('[OpenClawClient] HTTP 失败,尝试 CLI fallback');
|
|
288
|
-
return this.chatViaCLI(message, fromUser);
|
|
289
|
-
}
|
|
252
|
+
// 直接走 CLI 调用(OpenClaw Gateway 未暴露兼容的 HTTP REST API)
|
|
253
|
+
return this.chatViaCLI(message, fromUser);
|
|
290
254
|
}
|
|
291
255
|
|
|
292
256
|
async chatViaCLI(message, fromUser) {
|
|
@@ -307,12 +271,14 @@ class OpenClawClient {
|
|
|
307
271
|
this.log?.info('[OpenClawClient] 已读取到 gateway token');
|
|
308
272
|
}
|
|
309
273
|
|
|
310
|
-
const
|
|
274
|
+
const quoteArg = (s) => `"${s}"`;
|
|
275
|
+
const cmdParts = ['openclaw', 'agent', '-m', quoteArg(escapedMessage), '--session-id', quoteArg(sessionId)];
|
|
311
276
|
if (gatewayToken) {
|
|
312
|
-
|
|
277
|
+
cmdParts.push('--token', quoteArg(gatewayToken));
|
|
313
278
|
}
|
|
279
|
+
const command = cmdParts.join(' ');
|
|
314
280
|
|
|
315
|
-
this.log?.info(`[OpenClawClient] 执行:
|
|
281
|
+
this.log?.info(`[OpenClawClient] 执行: ${command}`);
|
|
316
282
|
|
|
317
283
|
return new Promise((resolve) => {
|
|
318
284
|
let stdout = '';
|
|
@@ -321,7 +287,7 @@ class OpenClawClient {
|
|
|
321
287
|
|
|
322
288
|
// 关键:不设置 OPENCLAW_GATEWAY_URL,避免触发 "gateway url override requires explicit credentials"
|
|
323
289
|
// 让 openclaw agent 通过默认方式自动发现本地 gateway
|
|
324
|
-
const child = spawn(
|
|
290
|
+
const child = spawn(command, {
|
|
325
291
|
shell: true,
|
|
326
292
|
windowsHide: true,
|
|
327
293
|
env: getOpenClawEnv(),
|
|
@@ -339,12 +305,12 @@ class OpenClawClient {
|
|
|
339
305
|
this.log?.info(`[OpenClawClient] CLI stderr: ${text.trim().substring(0, 300)}`);
|
|
340
306
|
});
|
|
341
307
|
|
|
342
|
-
// 超时兜底(30
|
|
308
|
+
// 超时兜底(30 分钟)
|
|
343
309
|
const timeout = setTimeout(() => {
|
|
344
310
|
killed = true;
|
|
345
311
|
child.kill('SIGTERM');
|
|
346
|
-
this.log?.error('[OpenClawClient] CLI 执行超时(30
|
|
347
|
-
},
|
|
312
|
+
this.log?.error('[OpenClawClient] CLI 执行超时(30分钟),强制终止');
|
|
313
|
+
}, 1800000);
|
|
348
314
|
|
|
349
315
|
child.on('error', (err) => {
|
|
350
316
|
clearTimeout(timeout);
|
|
@@ -360,7 +326,7 @@ class OpenClawClient {
|
|
|
360
326
|
clearTimeout(timeout);
|
|
361
327
|
|
|
362
328
|
if (killed) {
|
|
363
|
-
resolve('OpenClaw 响应超时(30
|
|
329
|
+
resolve('OpenClaw 响应超时(30分钟),请检查 openclaw 服务状态');
|
|
364
330
|
return;
|
|
365
331
|
}
|
|
366
332
|
|
|
@@ -20,6 +20,8 @@ class RongCloudClient {
|
|
|
20
20
|
this.isConnected = false;
|
|
21
21
|
this.handler = null;
|
|
22
22
|
this.processingQueue = Promise.resolve();
|
|
23
|
+
this.processedMessageUIds = new Set();
|
|
24
|
+
this.messageDedupMaxSize = 1000;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
async connect(handler) {
|
|
@@ -98,10 +100,31 @@ class RongCloudClient {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
handleReceivedMessage(message) {
|
|
103
|
+
// 1. 过滤离线消息
|
|
101
104
|
if (message.isOffLineMessage) {
|
|
102
105
|
return;
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
// 2. 过滤自己发送的消息(融云 SDK 可能将发送消息回传)
|
|
109
|
+
// messageDirection: 1=发送, 2=接收
|
|
110
|
+
if (message.messageDirection === 1) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (message.senderUserId === this.config.accountId) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. 消息去重:防止同一条消息被多次触发(融云重推或多端同步)
|
|
118
|
+
const dedupKey = message.messageUId || `${message.senderUserId}-${message.sentTime}-${message.messageType}`;
|
|
119
|
+
if (this.processedMessageUIds.has(dedupKey)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.processedMessageUIds.add(dedupKey);
|
|
123
|
+
if (this.processedMessageUIds.size > this.messageDedupMaxSize) {
|
|
124
|
+
const first = this.processedMessageUIds.values().next().value;
|
|
125
|
+
this.processedMessageUIds.delete(first);
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
try {
|
|
106
129
|
const msgType = message.messageType;
|
|
107
130
|
let rawContent = message.content;
|
package/service/worker.js
CHANGED
|
@@ -15,6 +15,23 @@ const { startOpencodeService, stopOpencodeService } = require('./modules/opencod
|
|
|
15
15
|
const log = createLogger('worker');
|
|
16
16
|
const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 28765;
|
|
17
17
|
|
|
18
|
+
|
|
19
|
+
// 捕获所有异常,强制打印到控制台(绕过 logger)
|
|
20
|
+
process.on('uncaughtException', (err) => {
|
|
21
|
+
console.error('[WORKER_FATAL] 未捕获异常:', err);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
26
|
+
console.error('[WORKER_FATAL] 未捕获Promise:', reason);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 如果 logger 初始化后,也同步写到 logger
|
|
30
|
+
const originalStderr = process.stderr.write.bind(process.stderr);
|
|
31
|
+
process.stderr.write = (chunk, encoding, callback) => {
|
|
32
|
+
originalStderr(chunk, encoding, callback);
|
|
33
|
+
};
|
|
34
|
+
|
|
18
35
|
/**
|
|
19
36
|
* 查找占用指定端口的进程 PID
|
|
20
37
|
* Windows 下检查本地地址(第二列)是否匹配目标端口,不限于 LISTENING 状态
|