@raolin2025/claude-code-node 2.3.6 → 2.4.1

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,61 +1,59 @@
1
- import { ChannelManager } from "./index.js";
2
1
  /**
3
- * cc-notify — 通知守护进程(C 方案:智能路由)
2
+ * cc-notify — 通知守护进程(v2.0 增强版)
4
3
  *
5
- * 核心逻辑:
6
- * 手机发消息 → cc-notify 收到
7
- * → 检查 cc-node 是否在运行
8
- * → 在运行:转发消息给已运行的 cc-node(通过 Unix socket)
9
- * → 没在运行:spawn 一个新的 cc-node 执行,完成后退出
4
+ * 架构:
5
+ * Telegram Bot API → cc-notify (长轮询) → cc-node
6
+ * QQ ← OpenClaw QQ Bot cc-notify (HTTP API) → cc-node
7
+ * HTTP API cc-notify cc-node
8
+ *
9
+ * 支持:
10
+ * - Telegram Bot 长轮询监听(增强版)
11
+ * - QQ Bot 消息(通过 OpenClaw 转发到 HTTP API /chat 端点)
12
+ * - HTTP API(带 API Key 认证)
13
+ * - C 方案智能路由(运行中 → socket 转发,未运行 → 启动新进程)
14
+ * - 多通道消息推送
15
+ * - API Key 自动生成 + 持久化
10
16
  *
11
17
  * 用法:
12
- * cc-notify # 前台运行
13
- * cc-notify --daemon # 后台守护进程
14
- * cc-notify --stop # 停止守护进程
15
- * cc-notify --status # 查看状态
18
+ * cc-notify # 前台运行
19
+ * cc-notify --daemon # 后台守护进程
20
+ * cc-notify --stop # 停止守护进程
21
+ * cc-notify --status # 查看状态
16
22
  */
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";
23
+ import { createServer } from 'node:http'
33
24
  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";
25
+ readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync,
26
+ mkdirSync, openSync, closeSync,
27
+ } from 'node:fs'
28
+ import { join } from 'node:path'
29
+ import { homedir } from 'node:os'
30
+ import { spawn, execSync } from 'node:child_process'
31
+ import { createConnection } from 'node:net'
32
+ import crypto from 'node:crypto'
33
+ import { SOCK_DIR, SOCK_PATH, CC_NODE_PID, CC_NOTIFY_PID, CC_NOTIFY_LOG, DEFAULT_HTTP_PORT } from '../core/paths.js'
34
+ import { ChannelManager } from './index.js'
41
35
 
42
36
  // ============================================================
43
37
  // 配置加载
44
38
  // ============================================================
39
+
45
40
  function loadConfig() {
46
41
  // 生成或加载 API Key
47
- let apiKey = process.env.CC_NOTIFY_API_KEY || "";
42
+ let apiKey = process.env.CC_NOTIFY_API_KEY || ''
48
43
  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");
44
+ const configDir = join(process.cwd(), '.claude-code')
45
+ const configPath = join(configDir, 'notify-api-key.txt')
53
46
  try {
54
- mkdirSync(configDir, { recursive: true });
55
- writeFileSync(configPath, apiKey, "utf8");
56
- console.log(`[notify] Generated API Key: ${apiKey} (saved to ${configPath})`);
47
+ if (existsSync(configPath)) {
48
+ apiKey = readFileSync(configPath, 'utf8').trim()
49
+ } else {
50
+ apiKey = crypto.randomBytes(32).toString('hex')
51
+ mkdirSync(configDir, { recursive: true })
52
+ writeFileSync(configPath, apiKey, 'utf8')
53
+ log(`[config] Generated API Key: ${apiKey.slice(0, 8)}... (saved to ${configPath})`)
54
+ }
57
55
  } catch (e) {
58
- console.warn("[notify] Failed to save API Key:", e.message);
56
+ log(`[config] API Key file error: ${e.message}`)
59
57
  }
60
58
  }
61
59
 
@@ -65,531 +63,765 @@ function loadConfig() {
65
63
  port: parseInt(process.env.CC_NOTIFY_PORT || String(DEFAULT_HTTP_PORT), 10),
66
64
  pidFile: process.env.CC_NOTIFY_CC_NODE_PID || CC_NOTIFY_PID,
67
65
  logFile: process.env.CC_NOTIFY_LOG_FILE || CC_NOTIFY_LOG,
68
- ccNodePath: process.env.CC_NODE_PATH || "cc-node",
66
+ ccNodePath: process.env.CC_NODE_PATH || 'cc-node',
69
67
  apiKey,
70
- };
71
68
 
69
+ }
70
+
71
+ // 从 .claude-code/config.json 加载
72
72
  for (const dir of [process.cwd(), homedir()]) {
73
- const cfgPath = join(dir, ".claude-code", "config.json");
73
+ const cfgPath = join(dir, '.claude-code', 'config.json')
74
74
  if (existsSync(cfgPath)) {
75
75
  try {
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;
76
+ const data = JSON.parse(readFileSync(cfgPath, 'utf8'))
77
+ if (data.channels) Object.assign(config.channels, data.channels)
78
+ if (data.defaultChannel && !config.defaultChannel) config.defaultChannel = data.defaultChannel
79
+ if (data.notify?.port) config.port = data.notify.port
80
+ if (data.notify?.ccNodePath) config.ccNodePath = data.notify.ccNodePath
81
+ if (data.notify?.apiKey) config.apiKey = data.notify.apiKey
82
+ // (QQ Bot config handled via channels.qqbot)
82
83
  } catch {}
83
84
  }
84
85
  }
85
86
 
87
+ // 从环境变量加载通道配置
86
88
  for (const [key, value] of Object.entries(process.env)) {
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;
89
+ if (!key.startsWith('CC_NODE_CHANNEL_')) continue
90
+ const rest = key.slice('CC_NODE_CHANNEL_'.length)
91
+ if (rest === 'DEFAULT') continue
92
+ const parts = rest.split('_')
93
+ const type = parts[0].toLowerCase()
94
+ const param = parts.slice(1).join('_').toLowerCase()
95
+ if (!config.channels[type]) config.channels[type] = { type }
96
+ const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
97
+ config.channels[type][camelKey] = value
96
98
  }
97
99
 
98
- return config;
100
+ log(`[config] Channels: ${Object.keys(config.channels).join(', ') || 'none'}`)
101
+ log(`[config] Default channel: ${config.defaultChannel || 'none'}`)
102
+ log(`[config] QQ Bot appId: ${config.channels?.qqbot?.appId || process.env.CC_NODE_CHANNEL_QQBOT_APPID ? 'configured' : 'not set'}`)
103
+ return config
99
104
  }
100
105
 
101
106
  // ============================================================
102
107
  // 通道发送
103
108
  // ============================================================
109
+
104
110
  async function sendToChannel(channels, defaultChannel, text) {
105
- const cm = new ChannelManager({ channels, defaultChannel });
106
- return await cm.send(text);
111
+ const cm = new ChannelManager({ channels }, defaultChannel)
112
+ return await cm.send(text)
107
113
  }
108
114
 
109
115
  // ============================================================
110
116
  // 进程发现 — cc-node 是否在跑?
111
117
  // ============================================================
118
+
112
119
  function findCcNode() {
113
120
  if (existsSync(SOCK_PATH)) {
114
121
  return new Promise((resolve) => {
115
122
  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
- });
123
+ client.end()
124
+ resolve({ running: true, socketPath: SOCK_PATH })
125
+ })
126
+ client.on('error', () => {
127
+ try { unlinkSync(SOCK_PATH) } catch {}
128
+ resolve({ running: false })
129
+ })
125
130
  setTimeout(() => {
126
- client.destroy();
127
- resolve({ running: false });
128
- }, 2000);
129
- });
131
+ client.destroy()
132
+ resolve({ running: false })
133
+ }, 2000)
134
+ })
130
135
  }
131
136
  if (existsSync(CC_NODE_PID)) {
132
- const pid = parseInt(readFileSync(CC_NODE_PID, "utf8").trim(), 10);
137
+ const pid = parseInt(readFileSync(CC_NODE_PID, 'utf8').trim(), 10)
133
138
  try {
134
- process.kill(pid, 0);
135
- return { running: true, pid };
139
+ process.kill(pid, 0)
140
+ return { running: true, pid }
136
141
  } catch {
137
- try {
138
- unlinkSync(CC_NODE_PID);
139
- } catch {}
142
+ try { unlinkSync(CC_NODE_PID) } catch {}
140
143
  }
141
144
  }
142
- return { running: false };
145
+ return { running: false }
143
146
  }
144
147
 
145
148
  // ============================================================
146
149
  // 消息路由 — C 方案核心
147
150
  // ============================================================
151
+
148
152
  function sendToExistingNode(socketPath, text) {
149
153
  return new Promise((resolve, reject) => {
150
154
  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");
155
+ const msg = JSON.stringify({ type: 'user_input', text })
156
+ client.write(msg + '\n')
157
+ })
158
+ let buffer = ''
159
+ client.on('data', (data) => {
160
+ buffer += data.toString()
161
+ const lines = buffer.split('\n')
158
162
  if (lines.length > 1) {
159
163
  try {
160
- const response = JSON.parse(lines[0]);
161
- client.end();
162
- resolve(response);
164
+ const response = JSON.parse(lines[0])
165
+ client.end()
166
+ resolve(response)
163
167
  } catch {
164
- client.end();
165
- resolve({ type: "reply", text: buffer.trim() });
168
+ client.end()
169
+ resolve({ type: 'reply', text: buffer.trim() })
166
170
  }
167
171
  }
168
- });
169
- client.on("error", (err) => reject(err));
172
+ })
173
+ client.on('error', (err) => reject(err))
170
174
  setTimeout(() => {
171
- client.destroy();
172
- reject(new Error("timeout waiting for cc-node reply"));
173
- }, 60000);
174
- });
175
+ client.destroy()
176
+ reject(new Error('timeout waiting for cc-node reply'))
177
+ }, 120000)
178
+ })
175
179
  }
176
180
 
177
181
  function spawnNewNode(ccNodePath, text) {
178
182
  return new Promise((resolve) => {
179
- const timeout = 120000;
180
- const timer = setTimeout(() => {
181
- child.kill();
182
- resolve({ type: "reply", text: "⏰ 执行超时(2 分钟)" });
183
- }, timeout);
183
+ const timeout = 180000 // 3分钟超时
184
+ let timer = setTimeout(() => {
185
+ child.kill()
186
+ resolve({ type: 'reply', text: '⏰ 执行超时(3 分钟)' })
187
+ }, timeout)
184
188
  try {
185
189
  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);
190
+ stdio: ['pipe', 'pipe', 'pipe'],
191
+ env: { ...process.env, CC_NODE_ONESHOT: '1' },
192
+ timeout,
193
+ })
194
+ let stdout = ''
195
+ let stderr = ''
196
+ child.stdout.on('data', (d) => (stdout += d.toString()))
197
+ child.stderr.on('data', (d) => (stderr += d.toString()))
198
+ child.on('close', (code) => {
199
+ clearTimeout(timer)
200
+ timer = null
194
201
  if (stdout.trim()) {
195
- resolve({ type: "reply", text: stdout.trim().slice(0, 4000) });
202
+ resolve({ type: 'reply', text: stdout.trim().slice(0, 4000) })
196
203
  } else if (stderr.trim()) {
197
- resolve({ type: "reply", text: `❌ Error: ${stderr.trim().slice(0, 1000)}` });
204
+ resolve({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
198
205
  } else {
199
- resolve({ type: "reply", text: "(no output)" });
206
+ resolve({ type: 'reply', text: `(completed with code ${code})` })
200
207
  }
201
- });
208
+ })
209
+ child.on('error', (e) => {
210
+ clearTimeout(timer)
211
+ timer = null
212
+ resolve({ type: 'reply', text: `❌ Failed: ${e.message}` })
213
+ })
202
214
  } catch (e) {
203
- clearTimeout(timer);
204
- resolve({ type: "reply", text: `❌ Failed: ${e.message}` });
215
+ clearTimeout(timer)
216
+ timer = null
217
+ resolve({ type: 'reply', text: `❌ Failed: ${e.message}` })
205
218
  }
206
- });
219
+ })
207
220
  }
208
221
 
209
222
  async function routeMessage(text, config) {
210
- const nodeInfo = await findCcNode();
223
+ const nodeInfo = await findCcNode()
211
224
  if (nodeInfo.running && nodeInfo.socketPath) {
212
- log(`[route] cc-node running → forwarding via socket`);
225
+ log(`[route] cc-node running → forwarding via socket`)
213
226
  try {
214
- const reply = await sendToExistingNode(nodeInfo.socketPath, text);
215
- return reply.text || JSON.stringify(reply);
227
+ const reply = await sendToExistingNode(nodeInfo.socketPath, text)
228
+ return reply.text || JSON.stringify(reply)
216
229
  } catch (e) {
217
- log(`[route] socket forward failed: ${e.message} → spawning new`);
218
- return (await spawnNewNode(config.ccNodePath, text)).text;
230
+ log(`[route] socket forward failed: ${e.message} → spawning new`)
231
+ return (await spawnNewNode(config.ccNodePath, text)).text
219
232
  }
220
233
  } 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;
234
+ log(`[route] cc-node running (PID ${nodeInfo.pid}) but no socket → spawning new (one-shot mode)`)
235
+ return (await spawnNewNode(config.ccNodePath, text)).text
223
236
  } else {
224
- log(`[route] cc-node not running → spawning new`);
225
- return (await spawnNewNode(config.ccNodePath, text)).text;
237
+ log(`[route] cc-node not running → spawning new`)
238
+ return (await spawnNewNode(config.ccNodePath, text)).text
226
239
  }
227
240
  }
228
241
 
229
242
  // ============================================================
230
- // Telegram Bot 长轮询
243
+ // Telegram 监听器(动态加载)
231
244
  // ============================================================
232
- class TelegramListener {
233
- constructor(config) {
234
- this.config = config;
235
- this.lastUpdateId = 0;
236
- this.running = false;
245
+
246
+ async function createTelegramListener(config) {
247
+ try {
248
+ const { TelegramListener } = await import('./tg-listener.js')
249
+ const listener = new TelegramListener(config)
250
+ return listener
251
+ } catch (e) {
252
+ log(`[TG] Failed to load tg-listener: ${e.message}`)
253
+ return null
237
254
  }
238
- async start(onMessage) {
239
- const ch = this.config.channels.telegram;
240
- if (!ch?.token) {
241
- log("Telegram: no token, skipping");
242
- return;
255
+ }
256
+
257
+ // ============================================================
258
+ // ============================================================
259
+
260
+
261
+
262
+ // ============================================================
263
+ // 统一消息处理器
264
+ // ============================================================
265
+
266
+ function createMessageHandler(config) {
267
+ return async (msg) => {
268
+ const { text, channel, chatId, from, replyTo, messageId } = msg
269
+
270
+ if (!text) return
271
+
272
+ log(`[msg] ${channel} ← ${from || '?'}: ${text.slice(0, 60)}`)
273
+
274
+ const isTelegram = channel === 'telegram' || channel === 'telegram_callback'
275
+ const isQQBot = channel === 'qqbot'
276
+
277
+ const lowerText = text.trim().toLowerCase()
278
+
279
+ // /ping
280
+ if (lowerText === '/ping' || lowerText === 'ping') {
281
+ const reply = '🏓 pong! cc-notify is alive.'
282
+ if (isTelegram) {
283
+ const { TelegramListener } = await import('./tg-listener.js')
284
+ const tl = new TelegramListener(config)
285
+ if (tl.bot) await tl.bot.sendMessage(chatId, reply, { replyTo })
286
+ }
287
+ if (isQQBot) {
288
+ await sendToChannel(config.channels, 'qqbot', reply)
289
+ }
290
+ return
291
+ }
292
+
293
+ // /status
294
+ if (lowerText === '/status' || lowerText === 'status') {
295
+ const nodeInfo = await findCcNode()
296
+ const chNames = Object.keys(config.channels || {})
297
+ const reply = [
298
+ '📊 cc-notify status',
299
+ `• Uptime: ${Math.floor(process.uptime())}s`,
300
+ `• Channels: ${chNames.join(', ') || 'none'}`,
301
+ `• cc-node: ${nodeInfo.running ? '✅ running' : '❌ not running'}`,
302
+ `• PID: ${process.pid}`,
303
+ ].join('\n')
304
+
305
+ if (isTelegram) {
306
+ const { TelegramListener } = await import('./tg-listener.js')
307
+ const tl = new TelegramListener(config)
308
+ if (tl.bot) await tl.bot.sendMessage(chatId, reply, { replyTo })
309
+ }
310
+ if (isQQBot) {
311
+ await sendToChannel(config.channels, 'qqbot', reply)
312
+ }
313
+ return
243
314
  }
244
- this.running = true;
245
- log("Telegram: started (long polling)");
246
- while (this.running) {
315
+
316
+ // /help
317
+ if (lowerText === '/help' || lowerText === 'help' || lowerText === '/start') {
318
+ const reply = [
319
+ '🤖 cc-notify — Remote AI Code Agent',
320
+ '',
321
+ 'Send any message → AI processes it as a programming task.',
322
+ '',
323
+ 'Commands:',
324
+ ' /ping — Check service status',
325
+ ' /status — View detailed status',
326
+ ' /run cmd — Execute shell command directly',
327
+ ' /notify — Broadcast notification to all channels',
328
+ ' /cancel — Cancel current operation',
329
+ ].join('\n')
330
+
331
+ if (isTelegram) {
332
+ const { TelegramListener } = await import('./tg-listener.js')
333
+ const tl = new TelegramListener(config)
334
+ if (tl.bot) await tl.bot.sendMessage(chatId, reply, { replyTo })
335
+ }
336
+ if (isQQBot) {
337
+ await sendToChannel(config.channels, 'qqbot', reply)
338
+ }
339
+ return
340
+ }
341
+
342
+ // /run — 直接执行命令
343
+ if (lowerText.startsWith('/run ') || lowerText.startsWith('run ')) {
344
+ const cmd = text.replace(/^\/(run|run)\s+/i, '').trim()
247
345
  try {
248
- const url = `https://api.telegram.org/bot${ch.token}/getUpdates`;
249
- const res = await fetch(url, {
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();
255
- if (data.ok && data.result?.length) {
256
- for (const update of data.result) {
257
- this.lastUpdateId = update.update_id;
258
- if (update.message?.text) {
259
- const msg = {
260
- text: update.message.text,
261
- chatId: update.message.chat.id,
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}`);
269
- }
270
- }
346
+ const output = execSync(cmd, { timeout: 30000, encoding: 'utf8', maxBuffer: 1024 * 1024 })
347
+ const reply = `💻 $ ${cmd}\n\n${output.trim().slice(0, 3500)}`
348
+
349
+ if (isTelegram) {
350
+ const { TelegramListener } = await import('./tg-listener.js')
351
+ const tl = new TelegramListener(config)
352
+ if (tl.bot) {
353
+ const escaped = '```\n' + output.trim().slice(0, 3500) + '\n```'
354
+ await tl.bot.sendMessage(chatId, `💻 $ ${cmd}\n${escaped}`, { replyTo })
271
355
  }
272
356
  }
357
+ if (isQQBot) {
358
+ await sendToChannel(config.channels, 'qqbot', reply.slice(0, 2000))
359
+ }
273
360
  } catch (e) {
274
- log(`TG poll error: ${e.message}`);
275
- await sleep(5000);
361
+ const reply = `❌ Command failed:\n${e.message}`
362
+ if (isTelegram) {
363
+ const { TelegramListener } = await import('./tg-listener.js')
364
+ const tl = new TelegramListener(config)
365
+ if (tl.bot) await tl.bot.sendMessage(chatId, reply, { replyTo })
366
+ }
367
+ if (isQQBot) {
368
+ await sendToChannel(config.channels, 'qqbot', reply)
369
+ }
370
+ }
371
+ return
372
+ }
373
+
374
+ // /notify — 广播
375
+ if (lowerText.startsWith('/notify ')) {
376
+ const notifyText = text.replace('/notify ', '')
377
+ try {
378
+ const results = await sendToChannel(config.channels, null, notifyText)
379
+ const reply = results.map(r => r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`).join('\n')
380
+ if (isTelegram) {
381
+ const { TelegramListener } = await import('./tg-listener.js')
382
+ const tl = new TelegramListener(config)
383
+ if (tl.bot) await tl.bot.sendMessage(chatId, reply, { replyTo })
384
+ }
385
+ if (isQQBot) {
386
+ await sendToChannel(config.channels, 'qqbot', reply)
387
+ }
388
+ } catch (e) {
389
+ log(`[notify] broadcast error: ${e.message}`)
390
+ }
391
+ return
392
+ }
393
+
394
+ // /cancel
395
+ if (lowerText === '/cancel') {
396
+ const reply = '🚫 Cancelled.'
397
+ if (isTelegram) {
398
+ const { TelegramListener } = await import('./tg-listener.js')
399
+ const tl = new TelegramListener(config)
400
+ if (tl.bot) await tl.bot.sendMessage(chatId, reply, { replyTo })
401
+ }
402
+ if (isQQBot) {
403
+ await sendToChannel(config.channels, 'qqbot', reply)
404
+ }
405
+ return
406
+ }
407
+
408
+ // ============================================================
409
+ // 普通消息 → C 方案路由:发给 cc-node 处理
410
+ // ============================================================
411
+ log(`[route] processing: "${text.slice(0, 50)}${text.length > 50 ? '...' : ''}"`)
412
+
413
+ // 发送"处理中"提示
414
+ if (isTelegram && config.channels.telegram?.token) {
415
+ try {
416
+ const proxyAddr = config.channels.telegram.proxy || process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || ''
417
+ const apiBase = config.channels.telegram.apiBase || `https://api.telegram.org`
418
+ const url = `${apiBase}/bot${config.channels.telegram.token}/sendChatAction`
419
+ if (proxyAddr) {
420
+ const { fetchViaSocks5 } = await import('./tg-proxy.js')
421
+ await fetchViaSocks5(url, {
422
+ method: 'POST',
423
+ headers: { 'Content-Type': 'application/json' },
424
+ body: JSON.stringify({ chat_id: chatId, action: 'typing' }),
425
+ }, proxyAddr)
426
+ } else {
427
+ await fetch(url, {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({ chat_id: chatId, action: 'typing' }),
431
+ })
432
+ }
433
+ } catch {}
434
+ }
435
+ if (isQQBot) {
436
+ await sendToChannel(config.channels, 'qqbot', '🤖 收到,正在处理...')
437
+ }
438
+
439
+ try {
440
+ const result = await routeMessage(text, config)
441
+ const reply = result || '(no response)'
442
+
443
+ if (isTelegram && config.channels.telegram?.token) {
444
+ const { TelegramListener } = await import('./tg-listener.js')
445
+ const tl = new TelegramListener(config)
446
+ if (tl.bot) {
447
+ if (reply.length > 4000) {
448
+ const parts = []
449
+ let cur = ''
450
+ for (const line of reply.split('\n')) {
451
+ if (cur.length + line.length > 3800) { parts.push(cur); cur = line }
452
+ else { cur += (cur ? '\n' : '') + line }
453
+ }
454
+ if (cur) parts.push(cur)
455
+ for (let i = 0; i < parts.length; i++) {
456
+ const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
457
+ await tl.bot.sendMessage(chatId, header + parts[i], { replyTo: i === 0 ? replyTo : undefined })
458
+ await new Promise(r => setTimeout(r, 300))
459
+ }
460
+ } else {
461
+ await tl.bot.sendMessage(chatId, reply, { replyTo })
462
+ }
463
+ }
464
+ }
465
+
466
+ if (isQQBot) {
467
+ await sendToChannel(config.channels, 'qqbot', reply.slice(0, 2000))
468
+ }
469
+
470
+ log(`[route] done (${reply.length} chars)`)
471
+
472
+ } catch (e) {
473
+ log(`[route] error: ${e.message}`)
474
+ const errMsg = `❌ Error processing: ${e.message}`
475
+ if (isTelegram && config.channels.telegram?.token) {
476
+ try {
477
+ const { TelegramListener } = await import('./tg-listener.js')
478
+ const tl = new TelegramListener(config)
479
+ if (tl.bot) await tl.bot.sendMessage(chatId, errMsg, { replyTo })
480
+ } catch {}
481
+ }
482
+ if (isQQBot) {
483
+ try { await sendToChannel(config.channels, 'qqbot', errMsg) } catch {}
276
484
  }
277
485
  }
278
- }
279
- stop() {
280
- this.running = false;
281
486
  }
282
487
  }
283
488
 
284
489
  // ============================================================
285
490
  // HTTP API — 带 API Key 认证
286
491
  // ============================================================
492
+
287
493
  class HttpServer {
288
- constructor(config, channels) {
289
- this.config = config;
290
- this.channels = channels;
291
- this.server = null;
494
+ constructor(config) {
495
+ this.config = config
496
+ this.server = null
292
497
  }
293
498
 
294
- /** 验证 API Key */
295
499
  _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;
500
+ const authHeader = req.headers['x-api-key'] || ''
501
+ const url = new URL(req.url, `http://localhost`)
502
+ const queryKey = url.searchParams.get('api_key') || ''
503
+ const providedKey = authHeader || queryKey
300
504
 
301
505
  if (!providedKey) {
302
- return { valid: false, error: "API Key required. Use X-API-Key header or ?api_key=xxx" };
506
+ return { valid: false, error: 'API Key required. Use X-API-Key header or ?api_key=xxx' }
303
507
  }
304
508
  if (providedKey !== this.config.apiKey) {
305
- return { valid: false, error: "Invalid API Key" };
509
+ return { valid: false, error: 'Invalid API Key' }
306
510
  }
307
- return { valid: true };
511
+ return { valid: true }
308
512
  }
309
513
 
310
514
  start(onMessage) {
311
515
  this.server = createServer(async (req, res) => {
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;
516
+ const url = new URL(req.url, `http://localhost:${this.config.port}`)
517
+ res.setHeader('Access-Control-Allow-Origin', '*')
518
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
519
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key')
520
+
521
+ if (req.method === 'OPTIONS') {
522
+ res.writeHead(204)
523
+ res.end()
524
+ return
321
525
  }
322
526
 
323
- // API Key 认证(/status 端点不需要认证)
324
- if (req.method !== "GET" || url.pathname !== "/status") {
325
- const authResult = this._validateApiKey(req);
527
+ // /status 端点无需认证
528
+ const needsAuth = !(req.method === 'GET' && url.pathname === '/status')
529
+ if (needsAuth) {
530
+ const authResult = this._validateApiKey(req)
326
531
  if (!authResult.valid) {
327
- res.writeHead(401, { "Content-Type": "application/json" });
328
- res.end(JSON.stringify({ error: authResult.error }));
329
- return;
532
+ res.writeHead(401, { 'Content-Type': 'application/json' })
533
+ res.end(JSON.stringify({ error: authResult.error }))
534
+ return
330
535
  }
331
536
  }
332
537
 
333
538
  try {
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;
539
+ if (req.method === 'GET' && url.pathname === '/status') {
540
+ const nodeInfo = await findCcNode()
541
+ res.writeHead(200, { 'Content-Type': 'application/json' })
542
+ res.end(JSON.stringify({
543
+ status: 'running',
544
+ version: '2.0',
545
+ channels: Object.keys(this.config.channels),
546
+ defaultChannel: this.config.defaultChannel,
547
+ uptime: Math.floor(process.uptime()),
548
+ ccNodeRunning: nodeInfo.running,
549
+ pid: process.pid,
550
+ apiKeyPrefix: this.config.apiKey.slice(0, 8) + '...',
551
+ qqbot: this.config.channels?.qqbot?.appId ? 'configured' : 'not set',
552
+ }))
553
+
554
+ } else if (req.method === 'POST' && url.pathname === '/send') {
555
+ const body = JSON.parse(await readBody(req))
556
+ const { text, channel } = body
349
557
  if (!text) {
350
- res.writeHead(400, { "Content-Type": "application/json" });
351
- res.end(JSON.stringify({ error: "text is required" }));
352
- return;
558
+ res.writeHead(400, { 'Content-Type': 'application/json' })
559
+ res.end(JSON.stringify({ error: 'text is required' }))
560
+ return
353
561
  }
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;
562
+ const results = await sendToChannel(this.config.channels, channel || this.config.defaultChannel, text)
563
+ res.writeHead(200, { 'Content-Type': 'application/json' })
564
+ res.end(JSON.stringify({ results }))
565
+
566
+ } else if (req.method === 'POST' && url.pathname === '/chat') {
567
+ const body = JSON.parse(await readBody(req))
568
+ const { text, channel, from, replyTo, messageId, target } = body
360
569
  if (!text) {
361
- res.writeHead(400, { "Content-Type": "application/json" });
362
- res.end(JSON.stringify({ error: "text is required" }));
363
- return;
570
+ res.writeHead(400, { 'Content-Type': 'application/json' })
571
+ res.end(JSON.stringify({ error: 'text is required' }))
572
+ return
573
+ }
574
+
575
+ // 构建消息对象
576
+ const msg = {
577
+ text,
578
+ channel: channel || 'http',
579
+ chatId: target || '',
580
+ from: from || 'API',
581
+ replyTo: replyTo || undefined,
582
+ messageId: messageId || undefined,
364
583
  }
365
- const reply = await routeMessage(text, this.config);
366
- res.writeHead(200, { "Content-Type": "application/json" });
367
- res.end(JSON.stringify({ reply }));
584
+
585
+ await onMessage(msg)
586
+ // onMessage 自己发送回复,这里只返回 ack
587
+ res.writeHead(200, { 'Content-Type': 'application/json' })
588
+ res.end(JSON.stringify({ status: 'processing' }))
589
+
368
590
  } else {
369
- res.writeHead(404, { "Content-Type": "application/json" });
370
- res.end(JSON.stringify({ error: "not found" }));
591
+ res.writeHead(404, { 'Content-Type': 'application/json' })
592
+ res.end(JSON.stringify({ error: 'not found' }))
371
593
  }
372
594
  } catch (e) {
373
- res.writeHead(500, { "Content-Type": "application/json" });
374
- res.end(JSON.stringify({ error: e.message }));
595
+ res.writeHead(500, { 'Content-Type': 'application/json' })
596
+ res.end(JSON.stringify({ error: e.message }))
375
597
  }
376
- });
598
+ })
599
+
377
600
  this.server.listen(this.config.port, () => {
378
- log(`HTTP API: http://localhost:${this.config.port} (API Key protected)`);
379
- });
601
+ log(`[http] API: http://localhost:${this.config.port} (API Key protected)`)
602
+ })
380
603
  }
381
604
 
382
605
  stop() {
383
- this.server?.close();
606
+ this.server?.close()
384
607
  }
385
608
  }
386
609
 
387
610
  // ============================================================
388
611
  // 守护进程管理
389
612
  // ============================================================
613
+
390
614
  function startDaemon(config) {
391
615
  if (existsSync(config.pidFile)) {
392
- const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
616
+ const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
393
617
  try {
394
- process.kill(pid, 0);
395
- console.error(`cc-notify already running (PID ${pid})`);
396
- process.exit(1);
618
+ process.kill(pid, 0)
619
+ console.error(`cc-notify already running (PID ${pid})`)
620
+ process.exit(1)
397
621
  } catch {
398
- try {
399
- unlinkSync(config.pidFile);
400
- } catch {}
622
+ try { unlinkSync(config.pidFile) } catch {}
401
623
  }
402
624
  }
403
625
  const child = spawn(process.execPath, [import.meta.url], {
404
626
  detached: true,
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);
627
+ stdio: 'ignore',
628
+ env: { ...process.env, CC_NOTIFY_DAEMON: '1' },
629
+ })
630
+ child.unref()
631
+ console.log(`cc-notify daemon started (PID ${child.pid})`)
632
+ console.log(`PID: ${config.pidFile}`)
633
+ console.log(`Log: ${config.logFile}`)
634
+ console.log(`HTTP: http://localhost:${config.port}`)
635
+ console.log(`API Key: ${config.apiKey.slice(0, 8)}...`)
636
+ process.exit(0)
415
637
  }
416
638
 
417
639
  function stopDaemon(config) {
418
640
  if (!existsSync(config.pidFile)) {
419
- console.log("cc-notify not running");
420
- process.exit(0);
641
+ console.log('cc-notify not running')
642
+ process.exit(0)
421
643
  }
422
- const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
644
+ const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
423
645
  try {
424
- process.kill(pid, "SIGTERM");
425
- console.log(`cc-notify stopped (PID ${pid})`);
646
+ process.kill(pid, 'SIGTERM')
647
+ console.log(`cc-notify stopped (PID ${pid})`)
426
648
  } catch {
427
- console.log(`PID ${pid} not found`);
649
+ console.log(`PID ${pid} not found`)
428
650
  }
429
- try {
430
- unlinkSync(config.pidFile);
431
- } catch {}
432
- process.exit(0);
651
+ try { unlinkSync(config.pidFile) } catch {}
652
+ process.exit(0)
433
653
  }
434
654
 
435
655
  function showStatus(config) {
436
656
  if (!existsSync(config.pidFile)) {
437
- console.log("cc-notify not running");
438
- process.exit(0);
657
+ console.log('cc-notify not running')
658
+ process.exit(0)
439
659
  }
440
- const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
660
+ const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
441
661
  try {
442
- process.kill(pid, 0);
443
- console.log(`cc-notify running (PID ${pid})`);
662
+ process.kill(pid, 0)
663
+ console.log(`cc-notify running (PID ${pid})`)
444
664
  fetch(`http://localhost:${config.port}/status`)
445
- .then((r) => r.json())
446
- .then((d) => console.log(JSON.stringify(d, null, 2)))
447
- .catch(() => console.log("(HTTP API not responding)"));
665
+ .then(r => r.json())
666
+ .then(d => console.log(JSON.stringify(d, null, 2)))
667
+ .catch(() => console.log('(HTTP API not responding)'))
448
668
  } catch {
449
- console.log(`PID ${pid} is dead`);
450
- try {
451
- unlinkSync(config.pidFile);
452
- } catch {}
669
+ console.log(`PID ${pid} is dead`)
670
+ try { unlinkSync(config.pidFile) } catch {}
453
671
  }
454
672
  }
455
673
 
456
674
  // ============================================================
457
- // 工具
675
+ // 工具函数
458
676
  // ============================================================
459
- function sleep(ms) {
460
- return new Promise((r) => setTimeout(r, ms));
461
- }
677
+
678
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
462
679
 
463
680
  function readBody(req) {
464
681
  return new Promise((r) => {
465
- let b = "";
466
- req.on("data", (d) => (b += d));
467
- req.on("end", () => r(b));
468
- });
682
+ let b = ''
683
+ req.on('data', (d) => (b += d))
684
+ req.on('end', () => r(b))
685
+ })
469
686
  }
470
687
 
471
688
  function log(msg) {
472
- const ts = new Date().toISOString().slice(11, 19);
473
- const line = `[${ts}] ${msg}\n`;
474
- process.stdout.write(line);
475
- try {
476
- appendFileSync(CC_NOTIFY_LOG, line);
477
- } catch {}
689
+ const ts = new Date().toISOString().slice(11, 19)
690
+ const line = `[${ts}] ${msg}\n`
691
+ process.stdout.write(line)
692
+ try { appendFileSync(CC_NOTIFY_LOG, line) } catch {}
478
693
  }
479
694
 
480
695
  // ============================================================
481
696
  // 主入口
482
697
  // ============================================================
698
+
483
699
  async function main() {
484
- const config = loadConfig();
485
- const args = process.argv.slice(2);
700
+ const config = loadConfig()
701
+ const args = process.argv.slice(2)
486
702
 
487
- if (args.includes("--stop")) return stopDaemon(config);
488
- if (args.includes("--status")) return showStatus(config);
489
- if (args.includes("--daemon")) return startDaemon(config);
703
+ if (args.includes('--stop')) return stopDaemon(config)
704
+ if (args.includes('--status')) return showStatus(config)
705
+ if (args.includes('--daemon')) return startDaemon(config)
490
706
 
491
707
  // 确保 socket 目录存在
492
- mkdirSync(SOCK_DIR, { recursive: true });
708
+ mkdirSync(SOCK_DIR, { recursive: true })
493
709
 
494
- // M9 fix: PID file lock atomic create with retry, no unlink+write race
495
- let pidAcquired = false;
710
+ // PID 文件原子锁
711
+ let pidAcquired = false
496
712
  for (let attempt = 0; attempt < 3; attempt++) {
497
713
  try {
498
- const fd = openSync(config.pidFile, "wx");
499
- writeFileSync(fd, String(process.pid));
500
- closeSync(fd);
501
- pidAcquired = true;
502
- break;
714
+ const fd = openSync(config.pidFile, 'wx')
715
+ writeFileSync(fd, String(process.pid))
716
+ closeSync(fd)
717
+ pidAcquired = true
718
+ break
503
719
  } catch (err) {
504
- if (err.code !== "EEXIST") throw err;
505
- const oldPid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
720
+ if (err.code !== 'EEXIST') throw err
721
+ const oldPid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
506
722
  try {
507
- process.kill(oldPid, 0);
508
- console.error("cc-notify already running (PID " + oldPid + "). Use --stop first.");
509
- process.exit(1);
723
+ process.kill(oldPid, 0)
724
+ console.error(`cc-notify already running (PID ${oldPid}). Use --stop first.`)
725
+ process.exit(1)
510
726
  } catch {
511
- try { unlinkSync(config.pidFile); } catch {}
512
- if (attempt < 2) await sleep(100);
727
+ try { unlinkSync(config.pidFile) } catch {}
728
+ if (attempt < 2) await sleep(100)
513
729
  }
514
730
  }
515
731
  }
516
- if (!pidAcquired) {
517
- writeFileSync(config.pidFile, String(process.pid));
518
- }
732
+ if (!pidAcquired) writeFileSync(config.pidFile, String(process.pid))
733
+
734
+ // 创建统一消息处理器
735
+ const onMessage = createMessageHandler(config)
519
736
 
737
+ // 清理函数
520
738
  const cleanup = () => {
521
- log("Shutting down...");
522
- try {
523
- unlinkSync(config.pidFile);
524
- } catch {}
525
- process.exit(0);
526
- };
527
- process.on("SIGTERM", cleanup);
528
- process.on("SIGINT", cleanup);
529
-
530
- log("cc-notify starting...");
531
- log(`Channels: ${Object.keys(config.channels).join(", ") || "none"}`);
532
- log(`API Key: ${config.apiKey}`);
533
-
534
- // Telegram 监听
535
- const tg = new TelegramListener(config);
536
- tg.start(async (msg) => {
537
- const text = msg.text;
538
- // 内部命令
539
- if (text.startsWith("/")) {
540
- const [cmd, ...rest] = text.split(" ");
541
- let reply;
542
- switch (cmd) {
543
- case "/start":
544
- case "/help":
545
- reply = "🤖 *cc-notify* AI Code Agent 通知服务\n\nCommands:\n/ping — 检查服务\n/status — 状态\n/notify <text> — 广播通知\n其他消息 → 自动发给 cc-node 处理";
546
- break;
547
- case "/ping":
548
- reply = "🏓 pong!";
549
- break;
550
- case "/status": {
551
- const nodeInfo = await findCcNode();
552
- reply = `📊 cc-notify\nChannels: ${Object.keys(config.channels).join(", ")}\ncc-node: ${nodeInfo.running ? "✅ running" : "❌ not running"}\nUptime: ${Math.floor(process.uptime())}s`;
553
- break;
554
- }
555
- case "/notify": {
556
- const notifyText = rest.join(" ");
557
- if (!notifyText) {
558
- reply = "Usage: /notify <text>";
559
- break;
560
- }
561
- const results = await sendToChannel(config.channels, config.defaultChannel, notifyText);
562
- reply = results
563
- .map((r) => (r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`))
564
- .join("\n");
565
- break;
566
- }
567
- default:
568
- reply = await routeMessage(text, config);
569
- break;
570
- }
571
- if (config.channels.telegram?.token) {
572
- await sendToChannel(config.channels, "telegram", reply);
573
- }
574
- return;
575
- }
576
- // 普通消息 → C 方案路由
577
- log(`[route] processing: "${text.slice(0, 50)}"`);
578
- const reply = await routeMessage(text, config);
579
- if (config.channels.telegram?.token) {
580
- await sendToChannel(config.channels, "telegram", reply);
739
+ log('[main] Shutting down...')
740
+ tgListener?.stop()
741
+ httpServer?.stop()
742
+ try { unlinkSync(config.pidFile) } catch {}
743
+ log('[main] Goodbye!')
744
+ process.exit(0)
745
+ }
746
+ process.on('SIGTERM', cleanup)
747
+ process.on('SIGINT', cleanup)
748
+
749
+ log('╔══════════════════════════════════════╗')
750
+ log('║ cc-notify v2.0 — 启动中... ║')
751
+ log('╚══════════════════════════════════════╝')
752
+
753
+ // ============================================================
754
+ // 启动 Telegram 监听器
755
+ // ============================================================
756
+ let tgListener = null
757
+ if (config.channels.telegram?.token) {
758
+ tgListener = await createTelegramListener(config)
759
+ if (tgListener) {
760
+ tgListener.start(async (msg) => {
761
+ msg.channel = 'telegram'
762
+ await onMessage(msg)
763
+ }).catch(e => log(`[main] TG listener error: ${e.message}`))
764
+ log('[main] ✅ Telegram listener started')
581
765
  }
582
- });
766
+ }
583
767
 
584
- // HTTP API
585
- const http = new HttpServer(config, config.channels);
586
- http.start();
768
+ // ============================================================
769
+ // 启动 QQ Bot 监听器
770
+ // ============================================================
771
+ let qqBot = null
772
+ const qqAppId = (
773
+ config.channels?.qqbot?.appId
774
+ || process.env.CC_NODE_CHANNEL_QQBOT_APPID
775
+ || ''
776
+ )
777
+ const qqSecret = (
778
+ config.channels?.qqbot?.secret
779
+ || config.channels?.qqbot?.clientSecret
780
+ || process.env.CC_NODE_CHANNEL_QQBOT_SECRET
781
+ || ''
782
+ )
783
+
784
+ if (qqAppId && qqSecret) {
785
+ try {
786
+ const { QQBot } = await import('./qqbot-listener.js')
787
+ qqBot = new QQBot({ appId: qqAppId, clientSecret: qqSecret })
788
+ qqBot.listen(async (msg) => {
789
+ await onMessage(msg)
790
+ }).catch(e => log('[main] QQ listener error: ' + e.message))
791
+ log('[main] ✅ QQ Bot WebSocket listener started')
792
+ } catch (e) {
793
+ log('[main] ⚠️ QQ Bot load failed: ' + e.message)
794
+ }
795
+ } else {
796
+ log('[main] ℹ️ QQ Bot not configured (set CC_NODE_CHANNEL_QQBOT_APPID + CC_NODE_CHANNEL_QQBOT_SECRET)')
797
+ }
587
798
 
588
- log("cc-notify ready ✅");
589
- setInterval(() => {}, 60000); // keep alive
799
+ // 启动 HTTP API
800
+ // ============================================================
801
+ const httpServer = new HttpServer(config)
802
+ httpServer.start(onMessage)
803
+
804
+ // ============================================================
805
+ // 状态报告
806
+ // ============================================================
807
+ const activeListeners = []
808
+ if (tgListener) activeListeners.push('Telegram')
809
+ if (qqBot) activeListeners.push('QQBot')
810
+
811
+ log('╔══════════════════════════════════════╗')
812
+ log('║ cc-notify v2.0 READY ✅ ║')
813
+ log('╠══════════════════════════════════════╣')
814
+ log(`║ Listeners: ${activeListeners.join(', ') || 'HTTP only'}`)
815
+ log(`║ HTTP API: http://localhost:${config.port}`)
816
+ log(`║ API Key: ${config.apiKey.slice(0, 8)}...`)
817
+ log(`║ PID: ${process.pid}`)
818
+ log('╚══════════════════════════════════════╝')
819
+
820
+ // Keep alive
821
+ setInterval(() => {}, 60000)
590
822
  }
591
823
 
592
824
  main().catch((err) => {
593
- console.error("Fatal:", err);
594
- process.exit(1);
595
- });
825
+ console.error('Fatal error:', err.message)
826
+ process.exit(1)
827
+ })