@raolin2025/claude-code-node 2.3.5 → 2.4.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/README.md +71 -4
- package/package.json +2 -2
- package/src/channel/index.js +278 -33
- package/src/channel/notify-daemon.js +604 -384
- package/src/channel/qqbot-listener.js +403 -0
- package/src/channel/tg-listener.js +595 -0
- package/src/core/query-engine.js +2 -16
|
@@ -1,61 +1,59 @@
|
|
|
1
|
-
import { ChannelManager } from "./index.js";
|
|
2
1
|
/**
|
|
3
|
-
* cc-notify — 通知守护进程(
|
|
2
|
+
* cc-notify — 通知守护进程(v2.0 增强版)
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* →
|
|
8
|
-
* →
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* cc-notify # 前台运行
|
|
19
|
+
* cc-notify --daemon # 后台守护进程
|
|
20
|
+
* cc-notify --stop # 停止守护进程
|
|
21
|
+
* cc-notify --status # 查看状态
|
|
16
22
|
*/
|
|
17
|
-
import { createServer } from
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
56
|
+
log(`[config] API Key file error: ${e.message}`)
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
@@ -65,531 +63,753 @@ 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 ||
|
|
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,
|
|
73
|
+
const cfgPath = join(dir, '.claude-code', 'config.json')
|
|
74
74
|
if (existsSync(cfgPath)) {
|
|
75
75
|
try {
|
|
76
|
-
const data = JSON.parse(readFileSync(cfgPath,
|
|
77
|
-
if (data.channels) Object.assign(config.channels, data.channels)
|
|
78
|
-
if (data.defaultChannel && !config.defaultChannel)
|
|
79
|
-
|
|
80
|
-
if (data.notify?.
|
|
81
|
-
if (data.notify?.
|
|
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(
|
|
88
|
-
const rest = key.slice(
|
|
89
|
-
if (rest ===
|
|
90
|
-
const parts = rest.split(
|
|
91
|
-
const type = parts[0].toLowerCase()
|
|
92
|
-
const param = parts.slice(1).join(
|
|
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
|
-
|
|
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(
|
|
120
|
-
try {
|
|
121
|
-
|
|
122
|
-
|
|
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,
|
|
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:
|
|
152
|
-
client.write(msg +
|
|
153
|
-
})
|
|
154
|
-
let buffer =
|
|
155
|
-
client.on(
|
|
156
|
-
buffer += data.toString()
|
|
157
|
-
const lines = buffer.split(
|
|
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:
|
|
168
|
+
client.end()
|
|
169
|
+
resolve({ type: 'reply', text: buffer.trim() })
|
|
166
170
|
}
|
|
167
171
|
}
|
|
168
|
-
})
|
|
169
|
-
client.on(
|
|
172
|
+
})
|
|
173
|
+
client.on('error', (err) => reject(err))
|
|
170
174
|
setTimeout(() => {
|
|
171
|
-
client.destroy()
|
|
172
|
-
reject(new Error(
|
|
173
|
-
},
|
|
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 =
|
|
180
|
-
|
|
181
|
-
child.kill()
|
|
182
|
-
resolve({ type:
|
|
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: [
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
child.on(
|
|
193
|
-
|
|
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:
|
|
202
|
+
resolve({ type: 'reply', text: stdout.trim().slice(0, 4000) })
|
|
196
203
|
} else if (stderr.trim()) {
|
|
197
|
-
resolve({ type:
|
|
204
|
+
resolve({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
|
|
198
205
|
} else {
|
|
199
|
-
resolve({ type:
|
|
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
|
-
|
|
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
|
|
243
|
+
// Telegram 监听器(动态加载)
|
|
231
244
|
// ============================================================
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
+
await fetch(`https://api.telegram.org/bot${config.channels.telegram.token}/sendChatAction`, {
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: { 'Content-Type': 'application/json' },
|
|
419
|
+
body: JSON.stringify({ chat_id: chatId, action: 'typing' }),
|
|
420
|
+
})
|
|
421
|
+
} catch {}
|
|
422
|
+
}
|
|
423
|
+
if (isQQBot) {
|
|
424
|
+
await sendToChannel(config.channels, 'qqbot', '🤖 收到,正在处理...')
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const result = await routeMessage(text, config)
|
|
429
|
+
const reply = result || '(no response)'
|
|
430
|
+
|
|
431
|
+
if (isTelegram && config.channels.telegram?.token) {
|
|
432
|
+
const { TelegramListener } = await import('./tg-listener.js')
|
|
433
|
+
const tl = new TelegramListener(config)
|
|
434
|
+
if (tl.bot) {
|
|
435
|
+
if (reply.length > 4000) {
|
|
436
|
+
const parts = []
|
|
437
|
+
let cur = ''
|
|
438
|
+
for (const line of reply.split('\n')) {
|
|
439
|
+
if (cur.length + line.length > 3800) { parts.push(cur); cur = line }
|
|
440
|
+
else { cur += (cur ? '\n' : '') + line }
|
|
441
|
+
}
|
|
442
|
+
if (cur) parts.push(cur)
|
|
443
|
+
for (let i = 0; i < parts.length; i++) {
|
|
444
|
+
const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
|
|
445
|
+
await tl.bot.sendMessage(chatId, header + parts[i], { replyTo: i === 0 ? replyTo : undefined })
|
|
446
|
+
await new Promise(r => setTimeout(r, 300))
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
await tl.bot.sendMessage(chatId, reply, { replyTo })
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (isQQBot) {
|
|
455
|
+
await sendToChannel(config.channels, 'qqbot', reply.slice(0, 2000))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
log(`[route] done (${reply.length} chars)`)
|
|
459
|
+
|
|
460
|
+
} catch (e) {
|
|
461
|
+
log(`[route] error: ${e.message}`)
|
|
462
|
+
const errMsg = `❌ Error processing: ${e.message}`
|
|
463
|
+
if (isTelegram && config.channels.telegram?.token) {
|
|
464
|
+
try {
|
|
465
|
+
const { TelegramListener } = await import('./tg-listener.js')
|
|
466
|
+
const tl = new TelegramListener(config)
|
|
467
|
+
if (tl.bot) await tl.bot.sendMessage(chatId, errMsg, { replyTo })
|
|
468
|
+
} catch {}
|
|
469
|
+
}
|
|
470
|
+
if (isQQBot) {
|
|
471
|
+
try { await sendToChannel(config.channels, 'qqbot', errMsg) } catch {}
|
|
276
472
|
}
|
|
277
473
|
}
|
|
278
|
-
}
|
|
279
|
-
stop() {
|
|
280
|
-
this.running = false;
|
|
281
474
|
}
|
|
282
475
|
}
|
|
283
476
|
|
|
284
477
|
// ============================================================
|
|
285
478
|
// HTTP API — 带 API Key 认证
|
|
286
479
|
// ============================================================
|
|
480
|
+
|
|
287
481
|
class HttpServer {
|
|
288
|
-
constructor(config
|
|
289
|
-
this.config = config
|
|
290
|
-
this.
|
|
291
|
-
this.server = null;
|
|
482
|
+
constructor(config) {
|
|
483
|
+
this.config = config
|
|
484
|
+
this.server = null
|
|
292
485
|
}
|
|
293
486
|
|
|
294
|
-
/** 验证 API Key */
|
|
295
487
|
_validateApiKey(req) {
|
|
296
|
-
const authHeader = req.headers[
|
|
297
|
-
const url = new URL(req.url, `http://localhost`)
|
|
298
|
-
const queryKey = url.searchParams.get(
|
|
299
|
-
const providedKey = authHeader || queryKey
|
|
488
|
+
const authHeader = req.headers['x-api-key'] || ''
|
|
489
|
+
const url = new URL(req.url, `http://localhost`)
|
|
490
|
+
const queryKey = url.searchParams.get('api_key') || ''
|
|
491
|
+
const providedKey = authHeader || queryKey
|
|
300
492
|
|
|
301
493
|
if (!providedKey) {
|
|
302
|
-
return { valid: false, error:
|
|
494
|
+
return { valid: false, error: 'API Key required. Use X-API-Key header or ?api_key=xxx' }
|
|
303
495
|
}
|
|
304
496
|
if (providedKey !== this.config.apiKey) {
|
|
305
|
-
return { valid: false, error:
|
|
497
|
+
return { valid: false, error: 'Invalid API Key' }
|
|
306
498
|
}
|
|
307
|
-
return { valid: true }
|
|
499
|
+
return { valid: true }
|
|
308
500
|
}
|
|
309
501
|
|
|
310
502
|
start(onMessage) {
|
|
311
503
|
this.server = createServer(async (req, res) => {
|
|
312
|
-
const url = new URL(req.url, `http://localhost:${this.config.port}`)
|
|
313
|
-
res.setHeader(
|
|
314
|
-
res.setHeader(
|
|
315
|
-
res.setHeader(
|
|
316
|
-
|
|
317
|
-
if (req.method ===
|
|
318
|
-
res.writeHead(204)
|
|
319
|
-
res.end()
|
|
320
|
-
return
|
|
504
|
+
const url = new URL(req.url, `http://localhost:${this.config.port}`)
|
|
505
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
506
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
507
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key')
|
|
508
|
+
|
|
509
|
+
if (req.method === 'OPTIONS') {
|
|
510
|
+
res.writeHead(204)
|
|
511
|
+
res.end()
|
|
512
|
+
return
|
|
321
513
|
}
|
|
322
514
|
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
515
|
+
// /status 端点无需认证
|
|
516
|
+
const needsAuth = !(req.method === 'GET' && url.pathname === '/status')
|
|
517
|
+
if (needsAuth) {
|
|
518
|
+
const authResult = this._validateApiKey(req)
|
|
326
519
|
if (!authResult.valid) {
|
|
327
|
-
res.writeHead(401, {
|
|
328
|
-
res.end(JSON.stringify({ error: authResult.error }))
|
|
329
|
-
return
|
|
520
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
521
|
+
res.end(JSON.stringify({ error: authResult.error }))
|
|
522
|
+
return
|
|
330
523
|
}
|
|
331
524
|
}
|
|
332
525
|
|
|
333
526
|
try {
|
|
334
|
-
if (req.method ===
|
|
335
|
-
const nodeInfo = await findCcNode()
|
|
336
|
-
res.writeHead(200, {
|
|
337
|
-
res.end(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
527
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
528
|
+
const nodeInfo = await findCcNode()
|
|
529
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
530
|
+
res.end(JSON.stringify({
|
|
531
|
+
status: 'running',
|
|
532
|
+
version: '2.0',
|
|
533
|
+
channels: Object.keys(this.config.channels),
|
|
534
|
+
defaultChannel: this.config.defaultChannel,
|
|
535
|
+
uptime: Math.floor(process.uptime()),
|
|
536
|
+
ccNodeRunning: nodeInfo.running,
|
|
537
|
+
pid: process.pid,
|
|
538
|
+
apiKeyPrefix: this.config.apiKey.slice(0, 8) + '...',
|
|
539
|
+
qqbot: this.config.channels?.qqbot?.appId ? 'configured' : 'not set',
|
|
540
|
+
}))
|
|
541
|
+
|
|
542
|
+
} else if (req.method === 'POST' && url.pathname === '/send') {
|
|
543
|
+
const body = JSON.parse(await readBody(req))
|
|
544
|
+
const { text, channel } = body
|
|
349
545
|
if (!text) {
|
|
350
|
-
res.writeHead(400, {
|
|
351
|
-
res.end(JSON.stringify({ error:
|
|
352
|
-
return
|
|
546
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
547
|
+
res.end(JSON.stringify({ error: 'text is required' }))
|
|
548
|
+
return
|
|
353
549
|
}
|
|
354
|
-
const results = await sendToChannel(this.channels, channel || this.config.defaultChannel, text)
|
|
355
|
-
res.writeHead(200, {
|
|
356
|
-
res.end(JSON.stringify({ results }))
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
550
|
+
const results = await sendToChannel(this.config.channels, channel || this.config.defaultChannel, text)
|
|
551
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
552
|
+
res.end(JSON.stringify({ results }))
|
|
553
|
+
|
|
554
|
+
} else if (req.method === 'POST' && url.pathname === '/chat') {
|
|
555
|
+
const body = JSON.parse(await readBody(req))
|
|
556
|
+
const { text, channel, from, replyTo, messageId, target } = body
|
|
360
557
|
if (!text) {
|
|
361
|
-
res.writeHead(400, {
|
|
362
|
-
res.end(JSON.stringify({ error:
|
|
363
|
-
return
|
|
558
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
559
|
+
res.end(JSON.stringify({ error: 'text is required' }))
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 构建消息对象
|
|
564
|
+
const msg = {
|
|
565
|
+
text,
|
|
566
|
+
channel: channel || 'http',
|
|
567
|
+
chatId: target || '',
|
|
568
|
+
from: from || 'API',
|
|
569
|
+
replyTo: replyTo || undefined,
|
|
570
|
+
messageId: messageId || undefined,
|
|
364
571
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
572
|
+
|
|
573
|
+
await onMessage(msg)
|
|
574
|
+
// onMessage 自己发送回复,这里只返回 ack
|
|
575
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
576
|
+
res.end(JSON.stringify({ status: 'processing' }))
|
|
577
|
+
|
|
368
578
|
} else {
|
|
369
|
-
res.writeHead(404, {
|
|
370
|
-
res.end(JSON.stringify({ error:
|
|
579
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
580
|
+
res.end(JSON.stringify({ error: 'not found' }))
|
|
371
581
|
}
|
|
372
582
|
} catch (e) {
|
|
373
|
-
res.writeHead(500, {
|
|
374
|
-
res.end(JSON.stringify({ error: e.message }))
|
|
583
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
584
|
+
res.end(JSON.stringify({ error: e.message }))
|
|
375
585
|
}
|
|
376
|
-
})
|
|
586
|
+
})
|
|
587
|
+
|
|
377
588
|
this.server.listen(this.config.port, () => {
|
|
378
|
-
log(`
|
|
379
|
-
})
|
|
589
|
+
log(`[http] API: http://localhost:${this.config.port} (API Key protected)`)
|
|
590
|
+
})
|
|
380
591
|
}
|
|
381
592
|
|
|
382
593
|
stop() {
|
|
383
|
-
this.server?.close()
|
|
594
|
+
this.server?.close()
|
|
384
595
|
}
|
|
385
596
|
}
|
|
386
597
|
|
|
387
598
|
// ============================================================
|
|
388
599
|
// 守护进程管理
|
|
389
600
|
// ============================================================
|
|
601
|
+
|
|
390
602
|
function startDaemon(config) {
|
|
391
603
|
if (existsSync(config.pidFile)) {
|
|
392
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
604
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
393
605
|
try {
|
|
394
|
-
process.kill(pid, 0)
|
|
395
|
-
console.error(`cc-notify already running (PID ${pid})`)
|
|
396
|
-
process.exit(1)
|
|
606
|
+
process.kill(pid, 0)
|
|
607
|
+
console.error(`cc-notify already running (PID ${pid})`)
|
|
608
|
+
process.exit(1)
|
|
397
609
|
} catch {
|
|
398
|
-
try {
|
|
399
|
-
unlinkSync(config.pidFile);
|
|
400
|
-
} catch {}
|
|
610
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
401
611
|
}
|
|
402
612
|
}
|
|
403
613
|
const child = spawn(process.execPath, [import.meta.url], {
|
|
404
614
|
detached: true,
|
|
405
|
-
stdio:
|
|
406
|
-
env: { ...process.env, CC_NOTIFY_DAEMON:
|
|
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)
|
|
615
|
+
stdio: 'ignore',
|
|
616
|
+
env: { ...process.env, CC_NOTIFY_DAEMON: '1' },
|
|
617
|
+
})
|
|
618
|
+
child.unref()
|
|
619
|
+
console.log(`cc-notify daemon started (PID ${child.pid})`)
|
|
620
|
+
console.log(`PID: ${config.pidFile}`)
|
|
621
|
+
console.log(`Log: ${config.logFile}`)
|
|
622
|
+
console.log(`HTTP: http://localhost:${config.port}`)
|
|
623
|
+
console.log(`API Key: ${config.apiKey.slice(0, 8)}...`)
|
|
624
|
+
process.exit(0)
|
|
415
625
|
}
|
|
416
626
|
|
|
417
627
|
function stopDaemon(config) {
|
|
418
628
|
if (!existsSync(config.pidFile)) {
|
|
419
|
-
console.log(
|
|
420
|
-
process.exit(0)
|
|
629
|
+
console.log('cc-notify not running')
|
|
630
|
+
process.exit(0)
|
|
421
631
|
}
|
|
422
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
632
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
423
633
|
try {
|
|
424
|
-
process.kill(pid,
|
|
425
|
-
console.log(`cc-notify stopped (PID ${pid})`)
|
|
634
|
+
process.kill(pid, 'SIGTERM')
|
|
635
|
+
console.log(`cc-notify stopped (PID ${pid})`)
|
|
426
636
|
} catch {
|
|
427
|
-
console.log(`PID ${pid} not found`)
|
|
637
|
+
console.log(`PID ${pid} not found`)
|
|
428
638
|
}
|
|
429
|
-
try {
|
|
430
|
-
|
|
431
|
-
} catch {}
|
|
432
|
-
process.exit(0);
|
|
639
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
640
|
+
process.exit(0)
|
|
433
641
|
}
|
|
434
642
|
|
|
435
643
|
function showStatus(config) {
|
|
436
644
|
if (!existsSync(config.pidFile)) {
|
|
437
|
-
console.log(
|
|
438
|
-
process.exit(0)
|
|
645
|
+
console.log('cc-notify not running')
|
|
646
|
+
process.exit(0)
|
|
439
647
|
}
|
|
440
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
648
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
441
649
|
try {
|
|
442
|
-
process.kill(pid, 0)
|
|
443
|
-
console.log(`cc-notify running (PID ${pid})`)
|
|
650
|
+
process.kill(pid, 0)
|
|
651
|
+
console.log(`cc-notify running (PID ${pid})`)
|
|
444
652
|
fetch(`http://localhost:${config.port}/status`)
|
|
445
|
-
.then(
|
|
446
|
-
.then(
|
|
447
|
-
.catch(() => console.log(
|
|
653
|
+
.then(r => r.json())
|
|
654
|
+
.then(d => console.log(JSON.stringify(d, null, 2)))
|
|
655
|
+
.catch(() => console.log('(HTTP API not responding)'))
|
|
448
656
|
} catch {
|
|
449
|
-
console.log(`PID ${pid} is dead`)
|
|
450
|
-
try {
|
|
451
|
-
unlinkSync(config.pidFile);
|
|
452
|
-
} catch {}
|
|
657
|
+
console.log(`PID ${pid} is dead`)
|
|
658
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
453
659
|
}
|
|
454
660
|
}
|
|
455
661
|
|
|
456
662
|
// ============================================================
|
|
457
|
-
//
|
|
663
|
+
// 工具函数
|
|
458
664
|
// ============================================================
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
665
|
+
|
|
666
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
462
667
|
|
|
463
668
|
function readBody(req) {
|
|
464
669
|
return new Promise((r) => {
|
|
465
|
-
let b =
|
|
466
|
-
req.on(
|
|
467
|
-
req.on(
|
|
468
|
-
})
|
|
670
|
+
let b = ''
|
|
671
|
+
req.on('data', (d) => (b += d))
|
|
672
|
+
req.on('end', () => r(b))
|
|
673
|
+
})
|
|
469
674
|
}
|
|
470
675
|
|
|
471
676
|
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 {}
|
|
677
|
+
const ts = new Date().toISOString().slice(11, 19)
|
|
678
|
+
const line = `[${ts}] ${msg}\n`
|
|
679
|
+
process.stdout.write(line)
|
|
680
|
+
try { appendFileSync(CC_NOTIFY_LOG, line) } catch {}
|
|
478
681
|
}
|
|
479
682
|
|
|
480
683
|
// ============================================================
|
|
481
684
|
// 主入口
|
|
482
685
|
// ============================================================
|
|
686
|
+
|
|
483
687
|
async function main() {
|
|
484
|
-
const config = loadConfig()
|
|
485
|
-
const args = process.argv.slice(2)
|
|
688
|
+
const config = loadConfig()
|
|
689
|
+
const args = process.argv.slice(2)
|
|
486
690
|
|
|
487
|
-
if (args.includes(
|
|
488
|
-
if (args.includes(
|
|
489
|
-
if (args.includes(
|
|
691
|
+
if (args.includes('--stop')) return stopDaemon(config)
|
|
692
|
+
if (args.includes('--status')) return showStatus(config)
|
|
693
|
+
if (args.includes('--daemon')) return startDaemon(config)
|
|
490
694
|
|
|
491
695
|
// 确保 socket 目录存在
|
|
492
|
-
mkdirSync(SOCK_DIR, { recursive: true })
|
|
696
|
+
mkdirSync(SOCK_DIR, { recursive: true })
|
|
493
697
|
|
|
494
|
-
//
|
|
495
|
-
let pidAcquired = false
|
|
698
|
+
// PID 文件 — 原子锁
|
|
699
|
+
let pidAcquired = false
|
|
496
700
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
497
701
|
try {
|
|
498
|
-
const fd = openSync(config.pidFile,
|
|
499
|
-
writeFileSync(fd, String(process.pid))
|
|
500
|
-
closeSync(fd)
|
|
501
|
-
pidAcquired = true
|
|
502
|
-
break
|
|
702
|
+
const fd = openSync(config.pidFile, 'wx')
|
|
703
|
+
writeFileSync(fd, String(process.pid))
|
|
704
|
+
closeSync(fd)
|
|
705
|
+
pidAcquired = true
|
|
706
|
+
break
|
|
503
707
|
} catch (err) {
|
|
504
|
-
if (err.code !==
|
|
505
|
-
const oldPid = parseInt(readFileSync(config.pidFile,
|
|
708
|
+
if (err.code !== 'EEXIST') throw err
|
|
709
|
+
const oldPid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
506
710
|
try {
|
|
507
|
-
process.kill(oldPid, 0)
|
|
508
|
-
console.error(
|
|
509
|
-
process.exit(1)
|
|
711
|
+
process.kill(oldPid, 0)
|
|
712
|
+
console.error(`cc-notify already running (PID ${oldPid}). Use --stop first.`)
|
|
713
|
+
process.exit(1)
|
|
510
714
|
} catch {
|
|
511
|
-
try { unlinkSync(config.pidFile)
|
|
512
|
-
if (attempt < 2) await sleep(100)
|
|
715
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
716
|
+
if (attempt < 2) await sleep(100)
|
|
513
717
|
}
|
|
514
718
|
}
|
|
515
719
|
}
|
|
516
|
-
if (!pidAcquired)
|
|
517
|
-
|
|
518
|
-
|
|
720
|
+
if (!pidAcquired) writeFileSync(config.pidFile, String(process.pid))
|
|
721
|
+
|
|
722
|
+
// 创建统一消息处理器
|
|
723
|
+
const onMessage = createMessageHandler(config)
|
|
519
724
|
|
|
725
|
+
// 清理函数
|
|
520
726
|
const cleanup = () => {
|
|
521
|
-
log(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
} catch {}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
process.on(
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
log(
|
|
532
|
-
log(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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);
|
|
727
|
+
log('[main] Shutting down...')
|
|
728
|
+
tgListener?.stop()
|
|
729
|
+
httpServer?.stop()
|
|
730
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
731
|
+
log('[main] Goodbye!')
|
|
732
|
+
process.exit(0)
|
|
733
|
+
}
|
|
734
|
+
process.on('SIGTERM', cleanup)
|
|
735
|
+
process.on('SIGINT', cleanup)
|
|
736
|
+
|
|
737
|
+
log('╔══════════════════════════════════════╗')
|
|
738
|
+
log('║ cc-notify v2.0 — 启动中... ║')
|
|
739
|
+
log('╚══════════════════════════════════════╝')
|
|
740
|
+
|
|
741
|
+
// ============================================================
|
|
742
|
+
// 启动 Telegram 监听器
|
|
743
|
+
// ============================================================
|
|
744
|
+
let tgListener = null
|
|
745
|
+
if (config.channels.telegram?.token) {
|
|
746
|
+
tgListener = await createTelegramListener(config)
|
|
747
|
+
if (tgListener) {
|
|
748
|
+
tgListener.start(async (msg) => {
|
|
749
|
+
msg.channel = 'telegram'
|
|
750
|
+
await onMessage(msg)
|
|
751
|
+
}).catch(e => log(`[main] TG listener error: ${e.message}`))
|
|
752
|
+
log('[main] ✅ Telegram listener started')
|
|
581
753
|
}
|
|
582
|
-
}
|
|
754
|
+
}
|
|
583
755
|
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
756
|
+
// ============================================================
|
|
757
|
+
// 启动 QQ Bot 监听器
|
|
758
|
+
// ============================================================
|
|
759
|
+
let qqBot = null
|
|
760
|
+
const qqAppId = (
|
|
761
|
+
config.channels?.qqbot?.appId
|
|
762
|
+
|| process.env.CC_NODE_CHANNEL_QQBOT_APPID
|
|
763
|
+
|| ''
|
|
764
|
+
)
|
|
765
|
+
const qqSecret = (
|
|
766
|
+
config.channels?.qqbot?.secret
|
|
767
|
+
|| config.channels?.qqbot?.clientSecret
|
|
768
|
+
|| process.env.CC_NODE_CHANNEL_QQBOT_SECRET
|
|
769
|
+
|| ''
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
if (qqAppId && qqSecret) {
|
|
773
|
+
try {
|
|
774
|
+
const { QQBot } = await import('./qqbot-listener.js')
|
|
775
|
+
qqBot = new QQBot({ appId: qqAppId, clientSecret: qqSecret })
|
|
776
|
+
qqBot.listen(async (msg) => {
|
|
777
|
+
await onMessage(msg)
|
|
778
|
+
}).catch(e => log('[main] QQ listener error: ' + e.message))
|
|
779
|
+
log('[main] ✅ QQ Bot WebSocket listener started')
|
|
780
|
+
} catch (e) {
|
|
781
|
+
log('[main] ⚠️ QQ Bot load failed: ' + e.message)
|
|
782
|
+
}
|
|
783
|
+
} else {
|
|
784
|
+
log('[main] ℹ️ QQ Bot not configured (set CC_NODE_CHANNEL_QQBOT_APPID + CC_NODE_CHANNEL_QQBOT_SECRET)')
|
|
785
|
+
}
|
|
587
786
|
|
|
588
|
-
|
|
589
|
-
|
|
787
|
+
// 启动 HTTP API
|
|
788
|
+
// ============================================================
|
|
789
|
+
const httpServer = new HttpServer(config)
|
|
790
|
+
httpServer.start(onMessage)
|
|
791
|
+
|
|
792
|
+
// ============================================================
|
|
793
|
+
// 状态报告
|
|
794
|
+
// ============================================================
|
|
795
|
+
const activeListeners = []
|
|
796
|
+
if (tgListener) activeListeners.push('Telegram')
|
|
797
|
+
if (qqBot) activeListeners.push('QQBot')
|
|
798
|
+
|
|
799
|
+
log('╔══════════════════════════════════════╗')
|
|
800
|
+
log('║ cc-notify v2.0 READY ✅ ║')
|
|
801
|
+
log('╠══════════════════════════════════════╣')
|
|
802
|
+
log(`║ Listeners: ${activeListeners.join(', ') || 'HTTP only'}`)
|
|
803
|
+
log(`║ HTTP API: http://localhost:${config.port}`)
|
|
804
|
+
log(`║ API Key: ${config.apiKey.slice(0, 8)}...`)
|
|
805
|
+
log(`║ PID: ${process.pid}`)
|
|
806
|
+
log('╚══════════════════════════════════════╝')
|
|
807
|
+
|
|
808
|
+
// Keep alive
|
|
809
|
+
setInterval(() => {}, 60000)
|
|
590
810
|
}
|
|
591
811
|
|
|
592
812
|
main().catch((err) => {
|
|
593
|
-
console.error(
|
|
594
|
-
process.exit(1)
|
|
595
|
-
})
|
|
813
|
+
console.error('Fatal error:', err.message)
|
|
814
|
+
process.exit(1)
|
|
815
|
+
})
|