@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.
- package/README.md +71 -4
- package/package.json +2 -2
- package/src/channel/index.js +287 -33
- package/src/channel/notify-daemon.js +616 -384
- package/src/channel/qqbot-listener.js +403 -0
- package/src/channel/tg-listener.js +614 -0
- package/src/channel/tg-proxy.js +236 -0
|
@@ -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,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 ||
|
|
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
|
+
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
|
|
289
|
-
this.config = config
|
|
290
|
-
this.
|
|
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[
|
|
297
|
-
const url = new URL(req.url, `http://localhost`)
|
|
298
|
-
const queryKey = url.searchParams.get(
|
|
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:
|
|
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:
|
|
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(
|
|
314
|
-
res.setHeader(
|
|
315
|
-
res.setHeader(
|
|
316
|
-
|
|
317
|
-
if (req.method ===
|
|
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
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
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, {
|
|
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 ===
|
|
335
|
-
const nodeInfo = await findCcNode()
|
|
336
|
-
res.writeHead(200, {
|
|
337
|
-
res.end(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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, {
|
|
351
|
-
res.end(JSON.stringify({ error:
|
|
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, {
|
|
356
|
-
res.end(JSON.stringify({ results }))
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
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, {
|
|
362
|
-
res.end(JSON.stringify({ error:
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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, {
|
|
370
|
-
res.end(JSON.stringify({ error:
|
|
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, {
|
|
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(`
|
|
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,
|
|
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:
|
|
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)
|
|
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(
|
|
420
|
-
process.exit(0)
|
|
641
|
+
console.log('cc-notify not running')
|
|
642
|
+
process.exit(0)
|
|
421
643
|
}
|
|
422
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
644
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
423
645
|
try {
|
|
424
|
-
process.kill(pid,
|
|
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
|
-
|
|
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(
|
|
438
|
-
process.exit(0)
|
|
657
|
+
console.log('cc-notify not running')
|
|
658
|
+
process.exit(0)
|
|
439
659
|
}
|
|
440
|
-
const pid = parseInt(readFileSync(config.pidFile,
|
|
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(
|
|
446
|
-
.then(
|
|
447
|
-
.catch(() => console.log(
|
|
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
|
-
|
|
460
|
-
|
|
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(
|
|
467
|
-
req.on(
|
|
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(
|
|
488
|
-
if (args.includes(
|
|
489
|
-
if (args.includes(
|
|
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
|
-
//
|
|
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,
|
|
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 !==
|
|
505
|
-
const oldPid = parseInt(readFileSync(config.pidFile,
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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);
|
|
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
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
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(
|
|
594
|
-
process.exit(1)
|
|
595
|
-
})
|
|
825
|
+
console.error('Fatal error:', err.message)
|
|
826
|
+
process.exit(1)
|
|
827
|
+
})
|