@raolin2025/claude-code-node 1.2.0 → 2.0.0
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/src/channel/index.js +81 -146
- package/src/channel/notify-daemon.js +451 -350
- package/src/core/cli.js +195 -114
- package/src/core/compact.js +171 -0
- package/src/core/config.js +1 -2
- package/src/core/cost-tracker.js +171 -0
- package/src/core/paths.js +12 -0
- package/src/core/query-engine.js +192 -89
- package/src/core/session.js +24 -9
- package/src/mcp/client.js +99 -1
- package/src/mcp/registry.js +1 -2
- package/src/security/bash-guard.js +174 -141
- package/src/security/enhanced-permission.js +72 -34
- package/src/security/path-guard.js +32 -29
- package/src/security/ssrf-guard.js +153 -50
- package/src/tools/glob.js +1 -1
- package/src/types/index.js +2 -1
- package/src/utils/file-ops.js +2 -3
|
@@ -1,491 +1,592 @@
|
|
|
1
|
-
|
|
1
|
+
import { ChannelManager } from "./index.js";
|
|
2
2
|
/**
|
|
3
|
-
* cc-notify —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
3
|
+
* cc-notify — 通知守护进程(C 方案:智能路由)
|
|
4
|
+
*
|
|
5
|
+
* 核心逻辑:
|
|
6
|
+
* 手机发消息 → cc-notify 收到
|
|
7
|
+
* → 检查 cc-node 是否在运行
|
|
8
|
+
* → 在运行:转发消息给已运行的 cc-node(通过 Unix socket)
|
|
9
|
+
* → 没在运行:spawn 一个新的 cc-node 执行,完成后退出
|
|
10
|
+
*
|
|
10
11
|
* 用法:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* cc-notify --status # 查看状态
|
|
16
|
-
*
|
|
17
|
-
* # HTTP API(守护模式可用)
|
|
18
|
-
* curl -X POST http://localhost:3456/send -d '{"text":"hello"}'
|
|
19
|
-
* curl http://localhost:3456/status
|
|
12
|
+
* cc-notify # 前台运行
|
|
13
|
+
* cc-notify --daemon # 后台守护进程
|
|
14
|
+
* cc-notify --stop # 停止守护进程
|
|
15
|
+
* cc-notify --status # 查看状态
|
|
20
16
|
*/
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
import { createServer } from "http";
|
|
18
|
+
import {
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
appendFileSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
openSync,
|
|
26
|
+
closeSync,
|
|
27
|
+
} from "fs";
|
|
28
|
+
import { resolve, join } from "path";
|
|
29
|
+
import { homedir } from "os";
|
|
30
|
+
import { spawn } from "child_process";
|
|
31
|
+
import { createConnection } from "net";
|
|
32
|
+
import crypto from "crypto";
|
|
33
|
+
import {
|
|
34
|
+
SOCK_DIR,
|
|
35
|
+
SOCK_PATH,
|
|
36
|
+
CC_NODE_PID,
|
|
37
|
+
CC_NOTIFY_PID,
|
|
38
|
+
CC_NOTIFY_LOG,
|
|
39
|
+
DEFAULT_HTTP_PORT,
|
|
40
|
+
} from "../core/paths.js";
|
|
27
41
|
|
|
28
42
|
// ============================================================
|
|
29
|
-
//
|
|
43
|
+
// 配置加载
|
|
30
44
|
// ============================================================
|
|
31
|
-
|
|
32
|
-
const DEFAULT_PORT = 3456
|
|
33
|
-
const DEFAULT_PID_FILE = join(homedir(), '.cc-notify.pid')
|
|
34
|
-
const DEFAULT_LOG_FILE = join(homedir(), '.cc-notify.log')
|
|
35
|
-
const POLL_INTERVAL_MS = 3000 // Telegram 长轮询间隔
|
|
36
|
-
|
|
37
45
|
function loadConfig() {
|
|
38
|
-
//
|
|
46
|
+
// 生成或加载 API Key
|
|
47
|
+
let apiKey = process.env.CC_NOTIFY_API_KEY || "";
|
|
48
|
+
if (!apiKey) {
|
|
49
|
+
// 自动生成并保存 API Key
|
|
50
|
+
apiKey = crypto.randomBytes(32).toString("hex");
|
|
51
|
+
const configDir = join(process.cwd(), ".claude-code");
|
|
52
|
+
const configPath = join(configDir, "notify-api-key.txt");
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(configDir, { recursive: true });
|
|
55
|
+
writeFileSync(configPath, apiKey, "utf8");
|
|
56
|
+
console.log(`[notify] Generated API Key: ${apiKey} (saved to ${configPath})`);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn("[notify] Failed to save API Key:", e.message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
const config = {
|
|
40
63
|
channels: {},
|
|
41
64
|
defaultChannel: process.env.CC_NODE_CHANNEL_DEFAULT || null,
|
|
42
|
-
port: parseInt(process.env.CC_NOTIFY_PORT ||
|
|
43
|
-
pidFile: process.env.
|
|
44
|
-
logFile: process.env.CC_NOTIFY_LOG_FILE ||
|
|
45
|
-
ccNodePath: process.env.CC_NODE_PATH ||
|
|
46
|
-
|
|
65
|
+
port: parseInt(process.env.CC_NOTIFY_PORT || String(DEFAULT_HTTP_PORT), 10),
|
|
66
|
+
pidFile: process.env.CC_NOTIFY_CC_NODE_PID || CC_NOTIFY_PID,
|
|
67
|
+
logFile: process.env.CC_NOTIFY_LOG_FILE || CC_NOTIFY_LOG,
|
|
68
|
+
ccNodePath: process.env.CC_NODE_PATH || "cc-node",
|
|
69
|
+
apiKey,
|
|
70
|
+
};
|
|
47
71
|
|
|
48
|
-
// 2. 从 .claude-code/config.json 加载
|
|
49
72
|
for (const dir of [process.cwd(), homedir()]) {
|
|
50
|
-
const cfgPath = join(dir,
|
|
73
|
+
const cfgPath = join(dir, ".claude-code", "config.json");
|
|
51
74
|
if (existsSync(cfgPath)) {
|
|
52
75
|
try {
|
|
53
|
-
const data = JSON.parse(readFileSync(cfgPath,
|
|
54
|
-
if (data.channels)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (data.
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
if (data.notify?.port) config.port = data.notify.port
|
|
61
|
-
if (data.notify?.ccNodePath) config.ccNodePath = data.notify.ccNodePath
|
|
76
|
+
const data = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
77
|
+
if (data.channels) Object.assign(config.channels, data.channels);
|
|
78
|
+
if (data.defaultChannel && !config.defaultChannel)
|
|
79
|
+
config.defaultChannel = data.defaultChannel;
|
|
80
|
+
if (data.notify?.port) config.port = data.notify.port;
|
|
81
|
+
if (data.notify?.ccNodePath) config.ccNodePath = data.notify.ccNodePath;
|
|
62
82
|
} catch {}
|
|
63
83
|
}
|
|
64
84
|
}
|
|
65
85
|
|
|
66
|
-
// 3. 环境变量覆盖(CC_NODE_CHANNEL_*)
|
|
67
86
|
for (const [key, value] of Object.entries(process.env)) {
|
|
68
|
-
if (!key.startsWith(
|
|
69
|
-
const rest = key.slice(
|
|
70
|
-
if (rest ===
|
|
71
|
-
const parts = rest.split(
|
|
72
|
-
const type = parts[0].toLowerCase()
|
|
73
|
-
const param = parts.slice(1).join(
|
|
74
|
-
if (!config.channels[type]) config.channels[type] = { type }
|
|
75
|
-
const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
76
|
-
config.channels[type][camelKey] = value
|
|
87
|
+
if (!key.startsWith("CC_NODE_CHANNEL_")) continue;
|
|
88
|
+
const rest = key.slice("CC_NODE_CHANNEL_".length);
|
|
89
|
+
if (rest === "DEFAULT") continue;
|
|
90
|
+
const parts = rest.split("_");
|
|
91
|
+
const type = parts[0].toLowerCase();
|
|
92
|
+
const param = parts.slice(1).join("_").toLowerCase();
|
|
93
|
+
if (!config.channels[type]) config.channels[type] = { type };
|
|
94
|
+
const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
95
|
+
config.channels[type][camelKey] = value;
|
|
77
96
|
}
|
|
78
97
|
|
|
79
|
-
return config
|
|
98
|
+
return config;
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
// ============================================================
|
|
83
|
-
//
|
|
102
|
+
// 通道发送
|
|
84
103
|
// ============================================================
|
|
104
|
+
async function sendToChannel(channels, defaultChannel, text) {
|
|
105
|
+
const cm = new ChannelManager({ channels, defaultChannel });
|
|
106
|
+
return await cm.send(text);
|
|
107
|
+
}
|
|
85
108
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
// ============================================================
|
|
110
|
+
// 进程发现 — cc-node 是否在跑?
|
|
111
|
+
// ============================================================
|
|
112
|
+
function findCcNode() {
|
|
113
|
+
if (existsSync(SOCK_PATH)) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const client = createConnection(SOCK_PATH, () => {
|
|
116
|
+
client.end();
|
|
117
|
+
resolve({ running: true, socketPath: SOCK_PATH });
|
|
118
|
+
});
|
|
119
|
+
client.on("error", () => {
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(SOCK_PATH);
|
|
122
|
+
} catch {}
|
|
123
|
+
resolve({ running: false });
|
|
124
|
+
});
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
client.destroy();
|
|
127
|
+
resolve({ running: false });
|
|
128
|
+
}, 2000);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (existsSync(CC_NODE_PID)) {
|
|
132
|
+
const pid = parseInt(readFileSync(CC_NODE_PID, "utf8").trim(), 10);
|
|
133
|
+
try {
|
|
134
|
+
process.kill(pid, 0);
|
|
135
|
+
return { running: true, pid };
|
|
136
|
+
} catch {
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(CC_NODE_PID);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { running: false };
|
|
94
143
|
}
|
|
95
144
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
145
|
+
// ============================================================
|
|
146
|
+
// 消息路由 — C 方案核心
|
|
147
|
+
// ============================================================
|
|
148
|
+
function sendToExistingNode(socketPath, text) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const client = createConnection(socketPath, () => {
|
|
151
|
+
const msg = JSON.stringify({ type: "user_input", text });
|
|
152
|
+
client.write(msg + "\n");
|
|
153
|
+
});
|
|
154
|
+
let buffer = "";
|
|
155
|
+
client.on("data", (data) => {
|
|
156
|
+
buffer += data.toString();
|
|
157
|
+
const lines = buffer.split("\n");
|
|
158
|
+
if (lines.length > 1) {
|
|
159
|
+
try {
|
|
160
|
+
const response = JSON.parse(lines[0]);
|
|
161
|
+
client.end();
|
|
162
|
+
resolve(response);
|
|
163
|
+
} catch {
|
|
164
|
+
client.end();
|
|
165
|
+
resolve({ type: "reply", text: buffer.trim() });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
client.on("error", (err) => reject(err));
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
client.destroy();
|
|
172
|
+
reject(new Error("timeout waiting for cc-node reply"));
|
|
173
|
+
}, 60000);
|
|
174
|
+
});
|
|
103
175
|
}
|
|
104
176
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
177
|
+
function spawnNewNode(ccNodePath, text) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const timeout = 120000;
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
child.kill();
|
|
182
|
+
resolve({ type: "reply", text: "⏰ 执行超时(2 分钟)" });
|
|
183
|
+
}, timeout);
|
|
111
184
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
185
|
+
const child = spawn(ccNodePath, [text], {
|
|
186
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
187
|
+
});
|
|
188
|
+
let stdout = "";
|
|
189
|
+
let stderr = "";
|
|
190
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
191
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
192
|
+
child.on("close", (code) => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
if (stdout.trim()) {
|
|
195
|
+
resolve({ type: "reply", text: stdout.trim().slice(0, 4000) });
|
|
196
|
+
} else if (stderr.trim()) {
|
|
197
|
+
resolve({ type: "reply", text: `❌ Error: ${stderr.trim().slice(0, 1000)}` });
|
|
198
|
+
} else {
|
|
199
|
+
resolve({ type: "reply", text: "(no output)" });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
} catch (e) {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
resolve({ type: "reply", text: `❌ Failed: ${e.message}` });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function routeMessage(text, config) {
|
|
210
|
+
const nodeInfo = await findCcNode();
|
|
211
|
+
if (nodeInfo.running && nodeInfo.socketPath) {
|
|
212
|
+
log(`[route] cc-node running → forwarding via socket`);
|
|
213
|
+
try {
|
|
214
|
+
const reply = await sendToExistingNode(nodeInfo.socketPath, text);
|
|
215
|
+
return reply.text || JSON.stringify(reply);
|
|
121
216
|
} catch (e) {
|
|
122
|
-
|
|
217
|
+
log(`[route] socket forward failed: ${e.message} → spawning new`);
|
|
218
|
+
return (await spawnNewNode(config.ccNodePath, text)).text;
|
|
123
219
|
}
|
|
220
|
+
} else if (nodeInfo.running && nodeInfo.pid) {
|
|
221
|
+
log(`[route] cc-node running (PID ${nodeInfo.pid}) but no socket → spawning new (one-shot mode)`);
|
|
222
|
+
return (await spawnNewNode(config.ccNodePath, text)).text;
|
|
223
|
+
} else {
|
|
224
|
+
log(`[route] cc-node not running → spawning new`);
|
|
225
|
+
return (await spawnNewNode(config.ccNodePath, text)).text;
|
|
124
226
|
}
|
|
125
|
-
return results
|
|
126
227
|
}
|
|
127
228
|
|
|
128
229
|
// ============================================================
|
|
129
|
-
// Telegram Bot
|
|
230
|
+
// Telegram Bot 长轮询
|
|
130
231
|
// ============================================================
|
|
131
|
-
|
|
132
232
|
class TelegramListener {
|
|
133
233
|
constructor(config) {
|
|
134
|
-
this.config = config
|
|
135
|
-
this.lastUpdateId = 0
|
|
136
|
-
this.running = false
|
|
137
|
-
this.handlers = [] // 消息处理器
|
|
234
|
+
this.config = config;
|
|
235
|
+
this.lastUpdateId = 0;
|
|
236
|
+
this.running = false;
|
|
138
237
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
this.handlers.push(handler)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async start() {
|
|
145
|
-
const ch = this.config.channels.telegram
|
|
238
|
+
async start(onMessage) {
|
|
239
|
+
const ch = this.config.channels.telegram;
|
|
146
240
|
if (!ch?.token) {
|
|
147
|
-
log(
|
|
148
|
-
return
|
|
241
|
+
log("Telegram: no token, skipping");
|
|
242
|
+
return;
|
|
149
243
|
}
|
|
150
|
-
this.running = true
|
|
151
|
-
log(
|
|
152
|
-
this._poll()
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
stop() {
|
|
156
|
-
this.running = false
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async _poll() {
|
|
244
|
+
this.running = true;
|
|
245
|
+
log("Telegram: started (long polling)");
|
|
160
246
|
while (this.running) {
|
|
161
247
|
try {
|
|
162
|
-
const url = `https://api.telegram.org/bot${
|
|
248
|
+
const url = `https://api.telegram.org/bot${ch.token}/getUpdates`;
|
|
163
249
|
const res = await fetch(url, {
|
|
164
|
-
method:
|
|
165
|
-
headers: {
|
|
166
|
-
body: JSON.stringify({
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
allowed_updates: ['message'],
|
|
170
|
-
}),
|
|
171
|
-
})
|
|
172
|
-
const data = await res.json()
|
|
173
|
-
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({ offset: this.lastUpdateId + 1, timeout: 30, allowed_updates: ["message"] }),
|
|
253
|
+
});
|
|
254
|
+
const data = await res.json();
|
|
174
255
|
if (data.ok && data.result?.length) {
|
|
175
256
|
for (const update of data.result) {
|
|
176
|
-
this.lastUpdateId = update.update_id
|
|
257
|
+
this.lastUpdateId = update.update_id;
|
|
177
258
|
if (update.message?.text) {
|
|
178
259
|
const msg = {
|
|
179
260
|
text: update.message.text,
|
|
180
261
|
chatId: update.message.chat.id,
|
|
181
|
-
from: update.message.from?.username || update.message.from?.first_name ||
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
262
|
+
from: update.message.from?.username || update.message.from?.first_name || "?",
|
|
263
|
+
};
|
|
264
|
+
log(`TG ← ${msg.from}: ${msg.text.slice(0, 60)}`);
|
|
265
|
+
try {
|
|
266
|
+
await onMessage(msg);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
log(`handler error: ${e.message}`);
|
|
187
269
|
}
|
|
188
270
|
}
|
|
189
271
|
}
|
|
190
272
|
}
|
|
191
273
|
} catch (e) {
|
|
192
|
-
log(`
|
|
193
|
-
await sleep(5000)
|
|
274
|
+
log(`TG poll error: ${e.message}`);
|
|
275
|
+
await sleep(5000);
|
|
194
276
|
}
|
|
195
277
|
}
|
|
196
278
|
}
|
|
279
|
+
stop() {
|
|
280
|
+
this.running = false;
|
|
281
|
+
}
|
|
197
282
|
}
|
|
198
283
|
|
|
199
284
|
// ============================================================
|
|
200
|
-
// HTTP API
|
|
285
|
+
// HTTP API — 带 API Key 认证
|
|
201
286
|
// ============================================================
|
|
202
|
-
|
|
203
287
|
class HttpServer {
|
|
204
288
|
constructor(config, channels) {
|
|
205
|
-
this.config = config
|
|
206
|
-
this.channels = channels
|
|
207
|
-
this.server = null
|
|
289
|
+
this.config = config;
|
|
290
|
+
this.channels = channels;
|
|
291
|
+
this.server = null;
|
|
208
292
|
}
|
|
209
293
|
|
|
210
|
-
|
|
294
|
+
/** 验证 API Key */
|
|
295
|
+
_validateApiKey(req) {
|
|
296
|
+
const authHeader = req.headers["x-api-key"] || "";
|
|
297
|
+
const url = new URL(req.url, `http://localhost`);
|
|
298
|
+
const queryKey = url.searchParams.get("api_key") || "";
|
|
299
|
+
const providedKey = authHeader || queryKey;
|
|
300
|
+
|
|
301
|
+
if (!providedKey) {
|
|
302
|
+
return { valid: false, error: "API Key required. Use X-API-Key header or ?api_key=xxx" };
|
|
303
|
+
}
|
|
304
|
+
if (providedKey !== this.config.apiKey) {
|
|
305
|
+
return { valid: false, error: "Invalid API Key" };
|
|
306
|
+
}
|
|
307
|
+
return { valid: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
start(onMessage) {
|
|
211
311
|
this.server = createServer(async (req, res) => {
|
|
212
|
-
const url = new URL(req.url, `http://localhost:${this.config.port}`)
|
|
312
|
+
const url = new URL(req.url, `http://localhost:${this.config.port}`);
|
|
313
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
314
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
315
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-API-Key");
|
|
316
|
+
|
|
317
|
+
if (req.method === "OPTIONS") {
|
|
318
|
+
res.writeHead(204);
|
|
319
|
+
res.end();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
213
322
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
323
|
+
// API Key 认证(/status 端点不需要认证)
|
|
324
|
+
if (req.method !== "GET" || url.pathname !== "/status") {
|
|
325
|
+
const authResult = this._validateApiKey(req);
|
|
326
|
+
if (!authResult.valid) {
|
|
327
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
328
|
+
res.end(JSON.stringify({ error: authResult.error }));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
219
332
|
|
|
220
333
|
try {
|
|
221
|
-
if (req.method ===
|
|
222
|
-
|
|
223
|
-
res.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
334
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
335
|
+
const nodeInfo = await findCcNode();
|
|
336
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
337
|
+
res.end(
|
|
338
|
+
JSON.stringify({
|
|
339
|
+
status: "running",
|
|
340
|
+
channels: Object.keys(this.channels),
|
|
341
|
+
defaultChannel: this.config.defaultChannel,
|
|
342
|
+
uptime: Math.floor(process.uptime()),
|
|
343
|
+
ccNodeRunning: nodeInfo.running,
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
} else if (req.method === "POST" && url.pathname === "/send") {
|
|
347
|
+
const body = JSON.parse(await readBody(req));
|
|
348
|
+
const { text, channel } = body;
|
|
349
|
+
if (!text) {
|
|
350
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
351
|
+
res.end(JSON.stringify({ error: "text is required" }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const results = await sendToChannel(this.channels, channel || this.config.defaultChannel, text);
|
|
355
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
356
|
+
res.end(JSON.stringify({ results }));
|
|
357
|
+
} else if (req.method === "POST" && url.pathname === "/chat") {
|
|
358
|
+
const body = JSON.parse(await readBody(req));
|
|
359
|
+
const { text } = body;
|
|
232
360
|
if (!text) {
|
|
233
|
-
res.writeHead(400, {
|
|
234
|
-
res.end(JSON.stringify({ error:
|
|
235
|
-
return
|
|
361
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
362
|
+
res.end(JSON.stringify({ error: "text is required" }));
|
|
363
|
+
return;
|
|
236
364
|
}
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
text
|
|
241
|
-
)
|
|
242
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
243
|
-
res.end(JSON.stringify({ results }))
|
|
365
|
+
const reply = await routeMessage(text, this.config);
|
|
366
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
367
|
+
res.end(JSON.stringify({ reply }));
|
|
244
368
|
} else {
|
|
245
|
-
res.writeHead(404, {
|
|
246
|
-
res.end(JSON.stringify({ error:
|
|
369
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
370
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
247
371
|
}
|
|
248
372
|
} catch (e) {
|
|
249
|
-
res.writeHead(500, {
|
|
250
|
-
res.end(JSON.stringify({ error: e.message }))
|
|
373
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
374
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
251
375
|
}
|
|
252
|
-
})
|
|
253
|
-
|
|
376
|
+
});
|
|
254
377
|
this.server.listen(this.config.port, () => {
|
|
255
|
-
log(`HTTP API
|
|
256
|
-
})
|
|
378
|
+
log(`HTTP API: http://localhost:${this.config.port} (API Key protected)`);
|
|
379
|
+
});
|
|
257
380
|
}
|
|
258
381
|
|
|
259
382
|
stop() {
|
|
260
|
-
this.server?.close()
|
|
383
|
+
this.server?.close();
|
|
261
384
|
}
|
|
262
385
|
}
|
|
263
386
|
|
|
264
|
-
// ============================================================
|
|
265
|
-
// 消息处理(收到的消息如何处理)
|
|
266
|
-
// ============================================================
|
|
267
|
-
|
|
268
|
-
async function handleIncomingMessage(msg, config, channels) {
|
|
269
|
-
const text = msg.text
|
|
270
|
-
|
|
271
|
-
// 命令处理
|
|
272
|
-
if (text.startsWith('/')) {
|
|
273
|
-
const [cmd, ...args] = text.split(' ')
|
|
274
|
-
switch (cmd) {
|
|
275
|
-
case '/start':
|
|
276
|
-
case '/help':
|
|
277
|
-
return `🤖 *cc-notify* — AI Code Agent 通知服务\n\nCommands:\n/ping — 检查服务状态\n/run <cmd> — 执行一次性命令\n/notify <text> — 发送通知到所有通道\n/status — 查看状态`
|
|
278
|
-
case '/ping':
|
|
279
|
-
return '🏓 pong!'
|
|
280
|
-
case '/status':
|
|
281
|
-
return `📊 cc-notify status\nChannels: ${Object.keys(channels).join(', ') || 'none'}\nUptime: ${Math.floor(process.uptime())}s`
|
|
282
|
-
case '/notify':
|
|
283
|
-
const notifyText = args.join(' ')
|
|
284
|
-
if (!notifyText) return 'Usage: /notify <text>'
|
|
285
|
-
const results = await sendToChannel(channels, config.defaultChannel, notifyText)
|
|
286
|
-
return results.map(r => r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`).join('\n')
|
|
287
|
-
case '/run':
|
|
288
|
-
const cmd = args.join(' ')
|
|
289
|
-
if (!cmd) return 'Usage: /run <command>'
|
|
290
|
-
return await runOneShot(config.ccNodePath, cmd)
|
|
291
|
-
default:
|
|
292
|
-
return `Unknown command: ${cmd}\nType /help for available commands`
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// 普通消息 → 当作一次性任务执行
|
|
297
|
-
return await runOneShot(config.ccNodePath, text)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** 调用 cc-node 执行一次性命令 */
|
|
301
|
-
function runOneShot(ccNodePath, input) {
|
|
302
|
-
return new Promise((resolve) => {
|
|
303
|
-
const timeout = 60000 // 60 秒超时
|
|
304
|
-
const timer = setTimeout(() => {
|
|
305
|
-
child.kill()
|
|
306
|
-
resolve('⏰ 执行超时(60秒)')
|
|
307
|
-
}, timeout)
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
const child = spawn(ccNodePath, [], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
311
|
-
let stdout = ''
|
|
312
|
-
let stderr = ''
|
|
313
|
-
child.stdout.on('data', (d) => { stdout += d.toString() })
|
|
314
|
-
child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
315
|
-
child.stdin.write(input + '\n')
|
|
316
|
-
child.stdin.end()
|
|
317
|
-
child.on('close', (code) => {
|
|
318
|
-
clearTimeout(timer)
|
|
319
|
-
if (code === 0 && stdout.trim()) {
|
|
320
|
-
resolve(stdout.trim().slice(0, 4000)) // Telegram 消息长度限制
|
|
321
|
-
} else if (stderr.trim()) {
|
|
322
|
-
resolve(`❌ Error: ${stderr.trim().slice(0, 1000)}`)
|
|
323
|
-
} else {
|
|
324
|
-
resolve(stdout.trim().slice(0, 4000) || '(no output)')
|
|
325
|
-
}
|
|
326
|
-
})
|
|
327
|
-
} catch (e) {
|
|
328
|
-
clearTimeout(timer)
|
|
329
|
-
resolve(`❌ Failed to run cc-node: ${e.message}`)
|
|
330
|
-
}
|
|
331
|
-
})
|
|
332
|
-
}
|
|
333
|
-
|
|
334
387
|
// ============================================================
|
|
335
388
|
// 守护进程管理
|
|
336
389
|
// ============================================================
|
|
337
|
-
|
|
338
390
|
function startDaemon(config) {
|
|
339
|
-
log('Starting cc-notify daemon...')
|
|
340
|
-
|
|
341
|
-
// 检查是否已在运行
|
|
342
391
|
if (existsSync(config.pidFile)) {
|
|
343
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
392
|
+
const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
|
|
344
393
|
try {
|
|
345
|
-
process.kill(pid, 0)
|
|
346
|
-
console.error(`cc-notify already running (PID ${pid})`)
|
|
347
|
-
|
|
348
|
-
process.exit(1)
|
|
394
|
+
process.kill(pid, 0);
|
|
395
|
+
console.error(`cc-notify already running (PID ${pid})`);
|
|
396
|
+
process.exit(1);
|
|
349
397
|
} catch {
|
|
350
|
-
|
|
351
|
-
|
|
398
|
+
try {
|
|
399
|
+
unlinkSync(config.pidFile);
|
|
400
|
+
} catch {}
|
|
352
401
|
}
|
|
353
402
|
}
|
|
354
|
-
|
|
355
|
-
// 用子进程启动自己
|
|
356
403
|
const child = spawn(process.execPath, [import.meta.url], {
|
|
357
404
|
detached: true,
|
|
358
|
-
stdio:
|
|
359
|
-
env: { ...process.env, CC_NOTIFY_DAEMON:
|
|
360
|
-
})
|
|
361
|
-
child.unref()
|
|
362
|
-
console.log(`cc-notify daemon started (PID ${child.pid})`)
|
|
363
|
-
console.log(`PID
|
|
364
|
-
console.log(`Log
|
|
365
|
-
console.log(`HTTP
|
|
366
|
-
|
|
405
|
+
stdio: "ignore",
|
|
406
|
+
env: { ...process.env, CC_NOTIFY_DAEMON: "1" },
|
|
407
|
+
});
|
|
408
|
+
child.unref();
|
|
409
|
+
console.log(`cc-notify daemon started (PID ${child.pid})`);
|
|
410
|
+
console.log(`PID: ${config.pidFile}`);
|
|
411
|
+
console.log(`Log: ${config.logFile}`);
|
|
412
|
+
console.log(`HTTP: http://localhost:${config.port}`);
|
|
413
|
+
console.log(`API Key: ${config.apiKey}`);
|
|
414
|
+
process.exit(0);
|
|
367
415
|
}
|
|
368
416
|
|
|
369
417
|
function stopDaemon(config) {
|
|
370
418
|
if (!existsSync(config.pidFile)) {
|
|
371
|
-
console.log(
|
|
372
|
-
process.exit(0)
|
|
419
|
+
console.log("cc-notify not running");
|
|
420
|
+
process.exit(0);
|
|
373
421
|
}
|
|
374
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
422
|
+
const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
|
|
375
423
|
try {
|
|
376
|
-
process.kill(pid,
|
|
377
|
-
console.log(`cc-notify stopped (PID ${pid})`)
|
|
424
|
+
process.kill(pid, "SIGTERM");
|
|
425
|
+
console.log(`cc-notify stopped (PID ${pid})`);
|
|
378
426
|
} catch {
|
|
379
|
-
console.log(`
|
|
427
|
+
console.log(`PID ${pid} not found`);
|
|
380
428
|
}
|
|
381
|
-
try {
|
|
382
|
-
|
|
429
|
+
try {
|
|
430
|
+
unlinkSync(config.pidFile);
|
|
431
|
+
} catch {}
|
|
432
|
+
process.exit(0);
|
|
383
433
|
}
|
|
384
434
|
|
|
385
435
|
function showStatus(config) {
|
|
386
436
|
if (!existsSync(config.pidFile)) {
|
|
387
|
-
console.log(
|
|
388
|
-
process.exit(0)
|
|
437
|
+
console.log("cc-notify not running");
|
|
438
|
+
process.exit(0);
|
|
389
439
|
}
|
|
390
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
440
|
+
const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
|
|
391
441
|
try {
|
|
392
|
-
process.kill(pid, 0)
|
|
393
|
-
console.log(`cc-notify running (PID ${pid})`)
|
|
394
|
-
// 尝试从 HTTP API 获取详细状态
|
|
442
|
+
process.kill(pid, 0);
|
|
443
|
+
console.log(`cc-notify running (PID ${pid})`);
|
|
395
444
|
fetch(`http://localhost:${config.port}/status`)
|
|
396
|
-
.then(r => r.json())
|
|
397
|
-
.then(
|
|
398
|
-
.catch(() => console.log(
|
|
445
|
+
.then((r) => r.json())
|
|
446
|
+
.then((d) => console.log(JSON.stringify(d, null, 2)))
|
|
447
|
+
.catch(() => console.log("(HTTP API not responding)"));
|
|
399
448
|
} catch {
|
|
400
|
-
console.log(`PID ${pid} is dead
|
|
401
|
-
try {
|
|
449
|
+
console.log(`PID ${pid} is dead`);
|
|
450
|
+
try {
|
|
451
|
+
unlinkSync(config.pidFile);
|
|
452
|
+
} catch {}
|
|
402
453
|
}
|
|
403
454
|
}
|
|
404
455
|
|
|
405
456
|
// ============================================================
|
|
406
|
-
//
|
|
457
|
+
// 工具
|
|
407
458
|
// ============================================================
|
|
408
|
-
|
|
409
|
-
|
|
459
|
+
function sleep(ms) {
|
|
460
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
461
|
+
}
|
|
410
462
|
|
|
411
463
|
function readBody(req) {
|
|
412
|
-
return new Promise((
|
|
413
|
-
let
|
|
414
|
-
req.on(
|
|
415
|
-
req.on(
|
|
416
|
-
})
|
|
464
|
+
return new Promise((r) => {
|
|
465
|
+
let b = "";
|
|
466
|
+
req.on("data", (d) => (b += d));
|
|
467
|
+
req.on("end", () => r(b));
|
|
468
|
+
});
|
|
417
469
|
}
|
|
418
470
|
|
|
419
471
|
function log(msg) {
|
|
420
|
-
const ts = new Date().toISOString().slice(11, 19)
|
|
421
|
-
const line = `[${ts}] ${msg}\n
|
|
422
|
-
process.stdout.write(line)
|
|
472
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
473
|
+
const line = `[${ts}] ${msg}\n`;
|
|
474
|
+
process.stdout.write(line);
|
|
423
475
|
try {
|
|
424
|
-
|
|
425
|
-
if (config.logFile) {
|
|
426
|
-
appendFileSync(config.logFile, line)
|
|
427
|
-
}
|
|
476
|
+
appendFileSync(CC_NOTIFY_LOG, line);
|
|
428
477
|
} catch {}
|
|
429
478
|
}
|
|
430
479
|
|
|
431
480
|
// ============================================================
|
|
432
481
|
// 主入口
|
|
433
482
|
// ============================================================
|
|
434
|
-
|
|
435
483
|
async function main() {
|
|
436
|
-
const config = loadConfig()
|
|
484
|
+
const config = loadConfig();
|
|
485
|
+
const args = process.argv.slice(2);
|
|
437
486
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const isStop = args.includes('--stop')
|
|
442
|
-
const isStatus = args.includes('--status')
|
|
487
|
+
if (args.includes("--stop")) return stopDaemon(config);
|
|
488
|
+
if (args.includes("--status")) return showStatus(config);
|
|
489
|
+
if (args.includes("--daemon")) return startDaemon(config);
|
|
443
490
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (isDaemon) return startDaemon(config)
|
|
491
|
+
// 确保 socket 目录存在
|
|
492
|
+
mkdirSync(SOCK_DIR, { recursive: true });
|
|
447
493
|
|
|
448
|
-
//
|
|
449
|
-
|
|
494
|
+
// v1.1: PID file lock — atomic create, prevent multiple instances
|
|
495
|
+
try {
|
|
496
|
+
const fd = openSync(config.pidFile, "wx");
|
|
497
|
+
writeFileSync(fd, String(process.pid));
|
|
498
|
+
closeSync(fd);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
if (err.code === "EEXIST") {
|
|
501
|
+
const oldPid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
|
|
502
|
+
try {
|
|
503
|
+
process.kill(oldPid, 0);
|
|
504
|
+
console.error("cc-notify already running (PID " + oldPid + "). Use --stop first.");
|
|
505
|
+
process.exit(1);
|
|
506
|
+
} catch {
|
|
507
|
+
try {
|
|
508
|
+
unlinkSync(config.pidFile);
|
|
509
|
+
} catch {}
|
|
510
|
+
writeFileSync(config.pidFile, String(process.pid));
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
450
516
|
|
|
451
|
-
// 优雅退出
|
|
452
517
|
const cleanup = () => {
|
|
453
|
-
log(
|
|
454
|
-
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
log(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
518
|
+
log("Shutting down...");
|
|
519
|
+
try {
|
|
520
|
+
unlinkSync(config.pidFile);
|
|
521
|
+
} catch {}
|
|
522
|
+
process.exit(0);
|
|
523
|
+
};
|
|
524
|
+
process.on("SIGTERM", cleanup);
|
|
525
|
+
process.on("SIGINT", cleanup);
|
|
526
|
+
|
|
527
|
+
log("cc-notify starting...");
|
|
528
|
+
log(`Channels: ${Object.keys(config.channels).join(", ") || "none"}`);
|
|
529
|
+
log(`API Key: ${config.apiKey}`);
|
|
530
|
+
|
|
531
|
+
// Telegram 监听
|
|
532
|
+
const tg = new TelegramListener(config);
|
|
533
|
+
tg.start(async (msg) => {
|
|
534
|
+
const text = msg.text;
|
|
535
|
+
// 内部命令
|
|
536
|
+
if (text.startsWith("/")) {
|
|
537
|
+
const [cmd, ...rest] = text.split(" ");
|
|
538
|
+
let reply;
|
|
539
|
+
switch (cmd) {
|
|
540
|
+
case "/start":
|
|
541
|
+
case "/help":
|
|
542
|
+
reply = "🤖 *cc-notify* — AI Code Agent 通知服务\n\nCommands:\n/ping — 检查服务\n/status — 状态\n/notify <text> — 广播通知\n其他消息 → 自动发给 cc-node 处理";
|
|
543
|
+
break;
|
|
544
|
+
case "/ping":
|
|
545
|
+
reply = "🏓 pong!";
|
|
546
|
+
break;
|
|
547
|
+
case "/status": {
|
|
548
|
+
const nodeInfo = await findCcNode();
|
|
549
|
+
reply = `📊 cc-notify\nChannels: ${Object.keys(config.channels).join(", ")}\ncc-node: ${nodeInfo.running ? "✅ running" : "❌ not running"}\nUptime: ${Math.floor(process.uptime())}s`;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
case "/notify": {
|
|
553
|
+
const notifyText = rest.join(" ");
|
|
554
|
+
if (!notifyText) {
|
|
555
|
+
reply = "Usage: /notify <text>";
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
const results = await sendToChannel(config.channels, config.defaultChannel, notifyText);
|
|
559
|
+
reply = results
|
|
560
|
+
.map((r) => (r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`))
|
|
561
|
+
.join("\n");
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
default:
|
|
565
|
+
reply = await routeMessage(text, config);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
if (config.channels.telegram?.token) {
|
|
569
|
+
await sendToChannel(config.channels, "telegram", reply);
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
474
572
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
573
|
+
// 普通消息 → C 方案路由
|
|
574
|
+
log(`[route] processing: "${text.slice(0, 50)}"`);
|
|
575
|
+
const reply = await routeMessage(text, config);
|
|
576
|
+
if (config.channels.telegram?.token) {
|
|
577
|
+
await sendToChannel(config.channels, "telegram", reply);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
481
580
|
|
|
482
|
-
|
|
581
|
+
// HTTP API
|
|
582
|
+
const http = new HttpServer(config, config.channels);
|
|
583
|
+
http.start();
|
|
483
584
|
|
|
484
|
-
|
|
485
|
-
setInterval(() => {}, 60000)
|
|
585
|
+
log("cc-notify ready ✅");
|
|
586
|
+
setInterval(() => {}, 60000); // keep alive
|
|
486
587
|
}
|
|
487
588
|
|
|
488
589
|
main().catch((err) => {
|
|
489
|
-
console.error(
|
|
490
|
-
process.exit(1)
|
|
491
|
-
})
|
|
590
|
+
console.error("Fatal:", err);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
});
|