@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.
@@ -1,491 +1,592 @@
1
- #!/usr/bin/env node
1
+ import { ChannelManager } from "./index.js";
2
2
  /**
3
- * cc-notify — 轻量通知守护进程
4
- *
5
- * 独立于 cc-node 运行,常驻后台,提供:
6
- * 1. Telegram Bot 长轮询监听(手机发消息 处理 → 回复)
7
- * 2. HTTP API 接口(其他程序调用发通知)
8
- * 3. Webhook 接收器(接收外部事件触发通知)
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
- * cc-notify # 前台运行
12
- * cc-notify --daemon # 后台守护进程
13
- * cc-notify --daemon --pidfile /tmp/cc.pid # 指定 PID 文件
14
- * cc-notify --stop # 停止守护进程
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 { createServer } from 'http'
23
- import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from 'fs'
24
- import { resolve, join } from 'path'
25
- import { homedir } from 'os'
26
- import { spawn, execSync } from 'child_process'
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
- // 1. 环境变量
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 || '3456', 10),
43
- pidFile: process.env.CC_NOTIFY_PID_FILE || DEFAULT_PID_FILE,
44
- logFile: process.env.CC_NOTIFY_LOG_FILE || DEFAULT_LOG_FILE,
45
- ccNodePath: process.env.CC_NODE_PATH || 'cc-node',
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, '.claude-code', 'config.json')
73
+ const cfgPath = join(dir, ".claude-code", "config.json");
51
74
  if (existsSync(cfgPath)) {
52
75
  try {
53
- const data = JSON.parse(readFileSync(cfgPath, 'utf8'))
54
- if (data.channels) {
55
- Object.assign(config.channels, data.channels)
56
- }
57
- if (data.defaultChannel && !config.defaultChannel) {
58
- config.defaultChannel = data.defaultChannel
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('CC_NODE_CHANNEL_')) continue
69
- const rest = key.slice('CC_NODE_CHANNEL_'.length)
70
- if (rest === 'DEFAULT') continue
71
- const parts = rest.split('_')
72
- const type = parts[0].toLowerCase()
73
- const param = parts.slice(1).join('_').toLowerCase()
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
- // 通道适配器(复用 cc-node 的逻辑,独立实现避免依赖)
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
- async function sendTelegram(config, text) {
87
- const url = `https://api.telegram.org/bot${config.token}/sendMessage`
88
- const res = await fetch(url, {
89
- method: 'POST',
90
- headers: { 'Content-Type': 'application/json' },
91
- body: JSON.stringify({ chat_id: config.chatId, text, parse_mode: 'Markdown' }),
92
- })
93
- return res.json()
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
- async function sendWebhook(url, text, parseMode) {
97
- const res = await fetch(url, {
98
- method: 'POST',
99
- headers: { 'Content-Type': 'application/json' },
100
- body: JSON.stringify({ text, msgtype: parseMode === 'markdown' ? 'markdown' : 'text' }),
101
- })
102
- return res.text()
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
- async function sendToChannel(channels, defaultChannel, text) {
106
- const targets = defaultChannel ? [defaultChannel] : Object.keys(channels)
107
- const results = []
108
- for (const name of targets) {
109
- const ch = channels[name]
110
- if (!ch) { results.push({ channel: name, ok: false, error: 'not configured' }); continue }
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
- if (ch.type === 'telegram') {
113
- const r = await sendTelegram(ch, text)
114
- results.push({ channel: name, ok: r.ok || false, result: r })
115
- } else if (ch.webhookUrl) {
116
- const r = await sendWebhook(ch.webhookUrl, text)
117
- results.push({ channel: name, ok: true, result: r })
118
- } else {
119
- results.push({ channel: name, ok: false, error: 'unknown type' })
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
- results.push({ channel: name, ok: false, error: e.message })
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
- onMessage(handler) {
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('Telegram listener: no token, skipping')
148
- return
241
+ log("Telegram: no token, skipping");
242
+ return;
149
243
  }
150
- this.running = true
151
- log('Telegram listener: started (long polling)')
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${this.config.channels.telegram.token}/getUpdates`
248
+ const url = `https://api.telegram.org/bot${ch.token}/getUpdates`;
163
249
  const res = await fetch(url, {
164
- method: 'POST',
165
- headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify({
167
- offset: this.lastUpdateId + 1,
168
- timeout: 30, // 长轮询超时
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 || 'unknown',
182
- date: new Date(update.message.date * 1000),
183
- }
184
- log(`Telegram msg from ${msg.from}: ${msg.text.slice(0, 80)}`)
185
- for (const handler of this.handlers) {
186
- try { await handler(msg) } catch (e) { log(`Handler error: ${e.message}`) }
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(`Telegram poll error: ${e.message}`)
193
- await sleep(5000) // 出错后等 5 秒重试
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
- start() {
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
- // CORS
215
- res.setHeader('Access-Control-Allow-Origin', '*')
216
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
217
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
218
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
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 === 'GET' && url.pathname === '/status') {
222
- res.writeHead(200, { 'Content-Type': 'application/json' })
223
- res.end(JSON.stringify({
224
- status: 'running',
225
- channels: Object.keys(this.channels),
226
- defaultChannel: this.config.defaultChannel,
227
- uptime: process.uptime(),
228
- }))
229
- } else if (req.method === 'POST' && url.pathname === '/send') {
230
- const body = await readBody(req)
231
- const { text, channel, parseMode } = JSON.parse(body)
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, { 'Content-Type': 'application/json' })
234
- res.end(JSON.stringify({ error: 'text is required' }))
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 results = await sendToChannel(
238
- this.channels,
239
- channel || this.config.defaultChannel,
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, { 'Content-Type': 'application/json' })
246
- res.end(JSON.stringify({ error: 'not found' }))
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, { 'Content-Type': 'application/json' })
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 listening on http://localhost:${this.config.port}`)
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, 'utf8').trim(), 10)
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
- console.log('Use --stop to stop it first')
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
- // 进程已死,清理旧 PID 文件
351
- unlinkSync(config.pidFile)
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: 'ignore',
359
- env: { ...process.env, CC_NOTIFY_DAEMON: '1' },
360
- })
361
- child.unref()
362
- console.log(`cc-notify daemon started (PID ${child.pid})`)
363
- console.log(`PID file: ${config.pidFile}`)
364
- console.log(`Log file: ${config.logFile}`)
365
- console.log(`HTTP API: http://localhost:${config.port}`)
366
- process.exit(0)
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('cc-notify is not running')
372
- process.exit(0)
419
+ console.log("cc-notify not running");
420
+ process.exit(0);
373
421
  }
374
- const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
422
+ const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
375
423
  try {
376
- process.kill(pid, 'SIGTERM')
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(`Process ${pid} not found, cleaning PID file`)
427
+ console.log(`PID ${pid} not found`);
380
428
  }
381
- try { unlinkSync(config.pidFile) } catch {}
382
- process.exit(0)
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('cc-notify is not running')
388
- process.exit(0)
437
+ console.log("cc-notify not running");
438
+ process.exit(0);
389
439
  }
390
- const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
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(data => console.log('Status:', JSON.stringify(data, null, 2)))
398
- .catch(() => console.log('(HTTP API not responding)'))
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, cleaning up`)
401
- try { unlinkSync(config.pidFile) } catch {}
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
- function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
459
+ function sleep(ms) {
460
+ return new Promise((r) => setTimeout(r, ms));
461
+ }
410
462
 
411
463
  function readBody(req) {
412
- return new Promise((resolve) => {
413
- let body = ''
414
- req.on('data', (d) => { body += d })
415
- req.on('end', () => resolve(body))
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
- const config = loadConfig()
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
- const args = process.argv.slice(2)
440
- const isDaemon = args.includes('--daemon')
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
- if (isStop) return stopDaemon(config)
445
- if (isStatus) return showStatus(config)
446
- if (isDaemon) return startDaemon(config)
491
+ // 确保 socket 目录存在
492
+ mkdirSync(SOCK_DIR, { recursive: true });
447
493
 
448
- // PID 文件
449
- writeFileSync(config.pidFile, String(process.pid))
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('Shutting down...')
454
- try { unlinkSync(config.pidFile) } catch {}
455
- process.exit(0)
456
- }
457
- process.on('SIGTERM', cleanup)
458
- process.on('SIGINT', cleanup)
459
-
460
- log('cc-notify starting...')
461
- log(`Channels: ${Object.keys(config.channels).join(', ') || 'none'}`)
462
- log(`Default: ${config.defaultChannel || 'none'}`)
463
-
464
- // 启动 Telegram 监听
465
- const tgListener = new TelegramListener(config)
466
- tgListener.onMessage(async (msg) => {
467
- const reply = await handleIncomingMessage(msg, config, config.channels)
468
- // 回复到 Telegram
469
- if (config.channels.telegram?.token && msg.chatId) {
470
- await sendTelegram(
471
- { token: config.channels.telegram.token, chatId: msg.chatId },
472
- reply
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
- await tgListener.start()
477
-
478
- // 启动 HTTP API
479
- const httpServer = new HttpServer(config, config.channels)
480
- httpServer.start()
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
- log('cc-notify ready ✅')
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('Fatal:', err)
490
- process.exit(1)
491
- })
590
+ console.error("Fatal:", err);
591
+ process.exit(1);
592
+ });