@raolin2025/claude-code-node 1.0.1 → 1.2.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 +112 -0
- package/package.json +13 -4
- package/src/channel/index.js +361 -0
- package/src/channel/notify-daemon.js +491 -0
- package/src/core/cli.js +105 -65
- package/src/core/config.js +2 -0
package/README.md
CHANGED
|
@@ -317,3 +317,115 @@ claude-code-node/ 33 文件 · 4307 行
|
|
|
317
317
|
|
|
318
318
|
*基于 Claude Code 架构的 OpenAI 兼容重构*
|
|
319
319
|
*零外部依赖 · DeepSeek 默认 · 安全加固 · MIT License*
|
|
320
|
+
|
|
321
|
+
## 📡 通讯通道 (Notification Channels)
|
|
322
|
+
|
|
323
|
+
cc-node 支持多平台消息推送,任务完成或出错时自动通知到手机。
|
|
324
|
+
|
|
325
|
+
### 支持的通道
|
|
326
|
+
|
|
327
|
+
| 通道 | 配置方式 | 说明 |
|
|
328
|
+
|------|----------|------|
|
|
329
|
+
| **Telegram** | Bot Token + Chat ID | 最推荐,支持 Markdown |
|
|
330
|
+
| **企业微信** | Webhook URL | 群机器人 |
|
|
331
|
+
| **飞书** | Webhook URL | 群机器人 |
|
|
332
|
+
| **Discord** | Webhook URL | 服务器频道 |
|
|
333
|
+
| **Slack** | Webhook URL | Incoming Webhook |
|
|
334
|
+
| **自定义** | HTTP URL + Method | 任意 Webhook |
|
|
335
|
+
|
|
336
|
+
### 快速配置 (Telegram 为例)
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
# 1. 在 Telegram 找 @BotFather 创建 Bot,拿到 Token
|
|
340
|
+
# 2. 获取你的 Chat ID(给 Bot 发消息后访问 https://api.telegram.org/bot<TOKEN>/getUpdates)
|
|
341
|
+
# 3. 设置环境变量
|
|
342
|
+
export CC_NODE_CHANNEL_TELEGRAM_TOKEN=123456:ABC-DEF
|
|
343
|
+
export CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=78901234
|
|
344
|
+
export CC_NODE_CHANNEL_DEFAULT=telegram
|
|
345
|
+
|
|
346
|
+
# 4. 启动 cc-node,会自动加载
|
|
347
|
+
cc-node
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### 配置文件方式
|
|
351
|
+
|
|
352
|
+
在 `.claude-code/config.json` 中:
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
{
|
|
356
|
+
"channels": {
|
|
357
|
+
"telegram": {
|
|
358
|
+
"type": "telegram",
|
|
359
|
+
"token": "123456:ABC-DEF",
|
|
360
|
+
"chatId": "78901234"
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
"defaultChannel": "telegram"
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### REPL 命令
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
/channel list — 列出已配置通道
|
|
371
|
+
/channel test — 测试通道连通性
|
|
372
|
+
/channel send hello — 手动发送消息
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### 通知时机
|
|
376
|
+
|
|
377
|
+
- ✅ **任务完成**(one-shot 模式自动通知)
|
|
378
|
+
- ❌ **执行出错**(自动通知错误内容)
|
|
379
|
+
- 🔄 **手动发送**(`/channel send` 命令)
|
|
380
|
+
|
|
381
|
+
## 🔔 后台运行 (cc-notify)
|
|
382
|
+
|
|
383
|
+
cc-notify 是独立的通知守护进程,**不需要 cc-node 在前台运行**,开机自启后随时可用。
|
|
384
|
+
|
|
385
|
+
### 三种运行方式
|
|
386
|
+
|
|
387
|
+
| 方式 | 命令 | 说明 |
|
|
388
|
+
|------|------|------|
|
|
389
|
+
| **前台** | `cc-notify` | 调试用,Ctrl+C 退出 |
|
|
390
|
+
| **后台守护** | `cc-notify --daemon` | 脱离终端后台运行 |
|
|
391
|
+
| **系统服务** | `systemctl start cc-notify` | 开机自启,最推荐 |
|
|
392
|
+
|
|
393
|
+
### 手机交互
|
|
394
|
+
|
|
395
|
+
在 Telegram 上给 Bot 发消息:
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
你好 → 当作一次性任务发给 cc-node 执行
|
|
399
|
+
/ping → 检查服务是否在线
|
|
400
|
+
/run ls -la → 执行 shell 命令
|
|
401
|
+
/notify 任务完成! → 向所有通道广播通知
|
|
402
|
+
/status → 查看服务状态
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### HTTP API(守护模式可用)
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
# 发送通知
|
|
409
|
+
curl -X POST http://localhost:3456/send \
|
|
410
|
+
-H 'Content-Type: application/json' \
|
|
411
|
+
-d '{"text":"构建完成 ✅"}'
|
|
412
|
+
|
|
413
|
+
# 查看状态
|
|
414
|
+
curl http://localhost:3456/status
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### systemd 开机自启
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
# 安装服务
|
|
421
|
+
sudo cp cc-notify.service /etc/systemd/system/
|
|
422
|
+
# 编辑 Token
|
|
423
|
+
sudo vim /etc/systemd/system/cc-notify.service
|
|
424
|
+
# 启用
|
|
425
|
+
sudo systemctl daemon-reload
|
|
426
|
+
sudo systemctl enable cc-notify
|
|
427
|
+
sudo systemctl start cc-notify
|
|
428
|
+
|
|
429
|
+
# 查看日志
|
|
430
|
+
journalctl -u cc-notify -f
|
|
431
|
+
```
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"cc-node": "src/index.js"
|
|
8
|
+
"cc-node": "src/index.js",
|
|
9
|
+
"cc-notify": "src/channel/notify-daemon.js"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
12
|
"start": "node src/index.js",
|
|
@@ -13,7 +14,15 @@
|
|
|
13
14
|
"test": "node --test src/__tests__/*.js",
|
|
14
15
|
"repl": "node src/index.js"
|
|
15
16
|
},
|
|
16
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"code",
|
|
20
|
+
"agent",
|
|
21
|
+
"cli",
|
|
22
|
+
"llm",
|
|
23
|
+
"ai",
|
|
24
|
+
"security"
|
|
25
|
+
],
|
|
17
26
|
"license": "MIT",
|
|
18
27
|
"author": "bg1avd",
|
|
19
28
|
"repository": {
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通讯通道模块 — 支持多平台消息推送
|
|
3
|
+
*
|
|
4
|
+
* 支持的通道:
|
|
5
|
+
* - Telegram Bot API
|
|
6
|
+
* - 企业微信 (WeCom) Webhook
|
|
7
|
+
* - 飞书 (Feishu) Webhook
|
|
8
|
+
* - Discord Webhook
|
|
9
|
+
* - Slack Webhook
|
|
10
|
+
* - 自定义 HTTP Webhook
|
|
11
|
+
*
|
|
12
|
+
* 配置方式:
|
|
13
|
+
* 环境变量或 .claude-code/config.json 中的 channels 字段
|
|
14
|
+
*
|
|
15
|
+
* 用法:
|
|
16
|
+
* import { ChannelManager } from './channel.js'
|
|
17
|
+
* const cm = new ChannelManager()
|
|
18
|
+
* await cm.send('任务完成!结果:xxx')
|
|
19
|
+
* await cm.send('⚠️ 警告', { channel: 'telegram' })
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync } from 'fs'
|
|
23
|
+
import { resolve } from 'path'
|
|
24
|
+
import { homedir } from 'os'
|
|
25
|
+
import { execSync } from 'child_process'
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// 通道适配器
|
|
29
|
+
// ============================================================
|
|
30
|
+
|
|
31
|
+
/** Telegram Bot API 适配器 */
|
|
32
|
+
class TelegramChannel {
|
|
33
|
+
constructor({ token, chatId }) {
|
|
34
|
+
this.token = token
|
|
35
|
+
this.chatId = chatId
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get name() { return 'telegram' }
|
|
39
|
+
|
|
40
|
+
async send(text, options = {}) {
|
|
41
|
+
const url = `https://api.telegram.org/bot${this.token}/sendMessage`
|
|
42
|
+
const body = {
|
|
43
|
+
chat_id: this.chatId,
|
|
44
|
+
text,
|
|
45
|
+
parse_mode: options.parseMode || 'Markdown',
|
|
46
|
+
disable_notification: options.silent || false,
|
|
47
|
+
}
|
|
48
|
+
return fetch(url, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
}).then(r => r.json())
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 企业微信 Webhook 适配器 */
|
|
57
|
+
class WeComChannel {
|
|
58
|
+
constructor({ webhookUrl }) {
|
|
59
|
+
this.webhookUrl = webhookUrl
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get name() { return 'wecom' }
|
|
63
|
+
|
|
64
|
+
async send(text, options = {}) {
|
|
65
|
+
const body = {
|
|
66
|
+
msgtype: options.msgType || 'text',
|
|
67
|
+
text: { content: text },
|
|
68
|
+
markdown: options.parseMode === 'markdown' ? { content: text } : undefined,
|
|
69
|
+
}
|
|
70
|
+
// 清理 undefined 字段
|
|
71
|
+
if (body.markdown === undefined) delete body.markdown
|
|
72
|
+
if (body.msgtype === 'markdown') delete body.text
|
|
73
|
+
|
|
74
|
+
return fetch(this.webhookUrl, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
}).then(r => r.json())
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 飞书 Webhook 适配器 */
|
|
83
|
+
class FeishuChannel {
|
|
84
|
+
constructor({ webhookUrl }) {
|
|
85
|
+
this.webhookUrl = webhookUrl
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get name() { return 'feishu' }
|
|
89
|
+
|
|
90
|
+
async send(text, options = {}) {
|
|
91
|
+
const body = {
|
|
92
|
+
msg_type: options.msgType || 'text',
|
|
93
|
+
content: options.parseMode === 'markdown'
|
|
94
|
+
? JSON.stringify({ text: text })
|
|
95
|
+
: JSON.stringify({ text: text }),
|
|
96
|
+
}
|
|
97
|
+
return fetch(this.webhookUrl, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
}).then(r => r.json())
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Discord Webhook 适配器 */
|
|
106
|
+
class DiscordChannel {
|
|
107
|
+
constructor({ webhookUrl }) {
|
|
108
|
+
this.webhookUrl = webhookUrl
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get name() { return 'discord' }
|
|
112
|
+
|
|
113
|
+
async send(text, options = {}) {
|
|
114
|
+
const body = {
|
|
115
|
+
content: text,
|
|
116
|
+
username: options.username || 'cc-node',
|
|
117
|
+
}
|
|
118
|
+
return fetch(this.webhookUrl, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify(body),
|
|
122
|
+
}).then(r => r.json())
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Slack Webhook 适配器 */
|
|
127
|
+
class SlackChannel {
|
|
128
|
+
constructor({ webhookUrl }) {
|
|
129
|
+
this.webhookUrl = webhookUrl
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get name() { return 'slack' }
|
|
133
|
+
|
|
134
|
+
async send(text, options = {}) {
|
|
135
|
+
const body = {
|
|
136
|
+
text,
|
|
137
|
+
username: options.username || 'cc-node',
|
|
138
|
+
mrkdwn: options.parseMode !== 'plain',
|
|
139
|
+
}
|
|
140
|
+
return fetch(this.webhookUrl, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
}).then(r => r.text())
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 通用 HTTP Webhook 适配器 */
|
|
149
|
+
class WebhookChannel {
|
|
150
|
+
constructor({ url, method = 'POST', headers = {}, bodyTemplate }) {
|
|
151
|
+
this.url = url
|
|
152
|
+
this.method = method
|
|
153
|
+
this.headers = headers
|
|
154
|
+
this.bodyTemplate = bodyTemplate
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get name() { return 'webhook' }
|
|
158
|
+
|
|
159
|
+
async send(text, options = {}) {
|
|
160
|
+
const body = this.bodyTemplate
|
|
161
|
+
? this.bodyTemplate.replace('{text}', text)
|
|
162
|
+
: JSON.stringify({ text, ...options })
|
|
163
|
+
|
|
164
|
+
return fetch(this.url, {
|
|
165
|
+
method: this.method,
|
|
166
|
+
headers: { 'Content-Type': 'application/json', ...this.headers },
|
|
167
|
+
body,
|
|
168
|
+
}).then(r => r.text())
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================
|
|
173
|
+
// 通道管理器
|
|
174
|
+
// ============================================================
|
|
175
|
+
|
|
176
|
+
const CHANNEL_ADAPTERS = {
|
|
177
|
+
telegram: TelegramChannel,
|
|
178
|
+
wecom: WeComChannel,
|
|
179
|
+
feishu: FeishuChannel,
|
|
180
|
+
discord: DiscordChannel,
|
|
181
|
+
slack: SlackChannel,
|
|
182
|
+
webhook: WebhookChannel,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ENV_PREFIX = 'CC_NODE_CHANNEL_'
|
|
186
|
+
|
|
187
|
+
export class ChannelManager {
|
|
188
|
+
constructor(config = {}) {
|
|
189
|
+
this.channels = new Map()
|
|
190
|
+
this.defaultChannel = config.defaultChannel || null
|
|
191
|
+
this._loadFromConfig(config)
|
|
192
|
+
this._loadFromEnv()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 从配置对象加载通道 */
|
|
196
|
+
_loadFromConfig(config) {
|
|
197
|
+
if (!config.channels) return
|
|
198
|
+
for (const [name, chConfig] of Object.entries(config.channels)) {
|
|
199
|
+
if (chConfig.enabled === false) continue
|
|
200
|
+
this._registerChannel(name, chConfig)
|
|
201
|
+
}
|
|
202
|
+
if (config.defaultChannel) {
|
|
203
|
+
this.defaultChannel = config.defaultChannel
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 从环境变量加载通道
|
|
208
|
+
*
|
|
209
|
+
* 环境变量格式:
|
|
210
|
+
* CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx
|
|
211
|
+
* CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=xxx
|
|
212
|
+
* CC_NODE_CHANNEL_WECOM_WEBHOOK_URL=xxx
|
|
213
|
+
* CC_NODE_CHANNEL_DEFAULT=telegram
|
|
214
|
+
*/
|
|
215
|
+
_loadFromEnv() {
|
|
216
|
+
const envChannels = {}
|
|
217
|
+
|
|
218
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
219
|
+
if (!key.startsWith(ENV_PREFIX)) continue
|
|
220
|
+
const rest = key.slice(ENV_PREFIX.length)
|
|
221
|
+
|
|
222
|
+
if (rest === 'DEFAULT') {
|
|
223
|
+
this.defaultChannel = value.toLowerCase()
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 解析 CC_NODE_CHANNEL_<TYPE>_<PARAM>
|
|
228
|
+
const parts = rest.split('_')
|
|
229
|
+
const channelType = parts[0].toLowerCase()
|
|
230
|
+
const param = parts.slice(1).join('_').toLowerCase()
|
|
231
|
+
|
|
232
|
+
if (!envChannels[channelType]) envChannels[channelType] = { type: channelType }
|
|
233
|
+
// 将 SNAKE_CASE 转为 camelCase
|
|
234
|
+
const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
235
|
+
envChannels[channelType][camelKey] = value
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const [name, chConfig] of Object.entries(envChannels)) {
|
|
239
|
+
if (!this.channels.has(name)) {
|
|
240
|
+
this._registerChannel(name, chConfig)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 注册一个通道 */
|
|
246
|
+
_registerChannel(name, config) {
|
|
247
|
+
const type = config.type || name
|
|
248
|
+
const Adapter = CHANNEL_ADAPTERS[type]
|
|
249
|
+
if (!Adapter) {
|
|
250
|
+
console.warn(`[channel] Unknown channel type: ${type}`)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const instance = new Adapter(config)
|
|
255
|
+
this.channels.set(name, instance)
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.warn(`[channel] Failed to register ${name}: ${e.message}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 获取已注册通道列表 */
|
|
262
|
+
list() {
|
|
263
|
+
return Array.from(this.channels.keys())
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 发送消息
|
|
267
|
+
* @param {string} text — 消息内容
|
|
268
|
+
* @param {object} options —
|
|
269
|
+
* channel: 通道名(默认用 defaultChannel 或全部通道)
|
|
270
|
+
* parseMode: 'Markdown' | 'plain'
|
|
271
|
+
* silent: 静默通知
|
|
272
|
+
*/
|
|
273
|
+
async send(text, options = {}) {
|
|
274
|
+
const targetChannels = options.channel
|
|
275
|
+
? [options.channel]
|
|
276
|
+
: this.defaultChannel
|
|
277
|
+
? [this.defaultChannel]
|
|
278
|
+
: this.list()
|
|
279
|
+
|
|
280
|
+
const results = []
|
|
281
|
+
for (const name of targetChannels) {
|
|
282
|
+
const ch = this.channels.get(name)
|
|
283
|
+
if (!ch) {
|
|
284
|
+
results.push({ channel: name, ok: false, error: 'not registered' })
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const result = await ch.send(text, options)
|
|
289
|
+
results.push({ channel: name, ok: true, result })
|
|
290
|
+
} catch (e) {
|
|
291
|
+
results.push({ channel: name, ok: false, error: e.message })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return results
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 发送模板消息
|
|
298
|
+
* @param {string} template — 模板名:'task-done', 'error', 'question'
|
|
299
|
+
* @param {object} data — 模板变量
|
|
300
|
+
*/
|
|
301
|
+
async sendTemplate(template, data = {}, options = {}) {
|
|
302
|
+
const templates = {
|
|
303
|
+
'task-done': `✅ 任务完成\n${data.task || ''}\n${data.result ? '结果:' + data.result : ''}`,
|
|
304
|
+
'error': `❌ 错误\n${data.task || ''}\n${data.error || ''}`,
|
|
305
|
+
'question': `❓ 需要确认\n${data.question || ''}\n${data.options ? '选项:' + data.options.join(' / ') : ''}`,
|
|
306
|
+
'progress': `🔄 进度更新\n${data.task || ''}\n${data.progress || ''}${data.percent ? ' (' + data.percent + '%)' : ''}`,
|
|
307
|
+
'warning': `⚠️ 警告\n${data.message || ''}`,
|
|
308
|
+
}
|
|
309
|
+
const text = templates[template] || `📢 ${data.message || text}`
|
|
310
|
+
return this.send(text, options)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================================
|
|
315
|
+
// REPL 命令集成
|
|
316
|
+
// ============================================================
|
|
317
|
+
|
|
318
|
+
/** 注册 /channel 命令到 REPL */
|
|
319
|
+
export function registerChannelCommands(repl, channelManager) {
|
|
320
|
+
if (!repl || !channelManager) return
|
|
321
|
+
|
|
322
|
+
repl.addCommand('/channel', {
|
|
323
|
+
description: '管理通讯通道',
|
|
324
|
+
handler: async (args) => {
|
|
325
|
+
const sub = args.trim()
|
|
326
|
+
if (sub === 'list' || sub === '') {
|
|
327
|
+
const channels = channelManager.list()
|
|
328
|
+
if (channels.length === 0) {
|
|
329
|
+
console.log('📭 没有配置通讯通道')
|
|
330
|
+
console.log(' 设置方法:')
|
|
331
|
+
console.log(' 1. 环境变量: CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx')
|
|
332
|
+
console.log(' 2. 配置文件: .claude-code/config.json -> channels')
|
|
333
|
+
} else {
|
|
334
|
+
console.log('📬 已配置通道:')
|
|
335
|
+
channels.forEach(ch => {
|
|
336
|
+
const isDefault = channelManager.defaultChannel === ch ? ' (默认)' : ''
|
|
337
|
+
console.log(` - ${ch}${isDefault}`)
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
} else if (sub.startsWith('send ')) {
|
|
341
|
+
const text = sub.slice(5)
|
|
342
|
+
const results = await channelManager.send(text)
|
|
343
|
+
results.forEach(r => {
|
|
344
|
+
console.log(r.ok ? `✅ ${r.channel}: 发送成功` : `❌ ${r.channel}: ${r.error}`)
|
|
345
|
+
})
|
|
346
|
+
} else if (sub.startsWith('test')) {
|
|
347
|
+
const results = await channelManager.send('📡 cc-node 通道测试消息')
|
|
348
|
+
results.forEach(r => {
|
|
349
|
+
console.log(r.ok ? `✅ ${r.channel}: 测试成功` : `❌ ${r.channel}: ${r.error}`)
|
|
350
|
+
})
|
|
351
|
+
} else {
|
|
352
|
+
console.log('用法:')
|
|
353
|
+
console.log(' /channel list — 列出通道')
|
|
354
|
+
console.log(' /channel send <msg> — 发送消息')
|
|
355
|
+
console.log(' /channel test — 测试通道')
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export default ChannelManager
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cc-notify — 轻量通知守护进程
|
|
4
|
+
*
|
|
5
|
+
* 独立于 cc-node 运行,常驻后台,提供:
|
|
6
|
+
* 1. Telegram Bot 长轮询监听(手机发消息 → 处理 → 回复)
|
|
7
|
+
* 2. HTTP API 接口(其他程序调用发通知)
|
|
8
|
+
* 3. Webhook 接收器(接收外部事件触发通知)
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* cc-notify # 前台运行
|
|
12
|
+
* cc-notify --daemon # 后台守护进程
|
|
13
|
+
* cc-notify --daemon --pidfile /tmp/cc.pid # 指定 PID 文件
|
|
14
|
+
* cc-notify --stop # 停止守护进程
|
|
15
|
+
* cc-notify --status # 查看状态
|
|
16
|
+
*
|
|
17
|
+
* # HTTP API(守护模式可用)
|
|
18
|
+
* curl -X POST http://localhost:3456/send -d '{"text":"hello"}'
|
|
19
|
+
* curl http://localhost:3456/status
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createServer } from 'http'
|
|
23
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from 'fs'
|
|
24
|
+
import { resolve, join } from 'path'
|
|
25
|
+
import { homedir } from 'os'
|
|
26
|
+
import { spawn, execSync } from 'child_process'
|
|
27
|
+
|
|
28
|
+
// ============================================================
|
|
29
|
+
// 配置
|
|
30
|
+
// ============================================================
|
|
31
|
+
|
|
32
|
+
const DEFAULT_PORT = 3456
|
|
33
|
+
const DEFAULT_PID_FILE = join(homedir(), '.cc-notify.pid')
|
|
34
|
+
const DEFAULT_LOG_FILE = join(homedir(), '.cc-notify.log')
|
|
35
|
+
const POLL_INTERVAL_MS = 3000 // Telegram 长轮询间隔
|
|
36
|
+
|
|
37
|
+
function loadConfig() {
|
|
38
|
+
// 1. 环境变量
|
|
39
|
+
const config = {
|
|
40
|
+
channels: {},
|
|
41
|
+
defaultChannel: process.env.CC_NODE_CHANNEL_DEFAULT || null,
|
|
42
|
+
port: parseInt(process.env.CC_NOTIFY_PORT || '3456', 10),
|
|
43
|
+
pidFile: process.env.CC_NOTIFY_PID_FILE || DEFAULT_PID_FILE,
|
|
44
|
+
logFile: process.env.CC_NOTIFY_LOG_FILE || DEFAULT_LOG_FILE,
|
|
45
|
+
ccNodePath: process.env.CC_NODE_PATH || 'cc-node',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. 从 .claude-code/config.json 加载
|
|
49
|
+
for (const dir of [process.cwd(), homedir()]) {
|
|
50
|
+
const cfgPath = join(dir, '.claude-code', 'config.json')
|
|
51
|
+
if (existsSync(cfgPath)) {
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(readFileSync(cfgPath, 'utf8'))
|
|
54
|
+
if (data.channels) {
|
|
55
|
+
Object.assign(config.channels, data.channels)
|
|
56
|
+
}
|
|
57
|
+
if (data.defaultChannel && !config.defaultChannel) {
|
|
58
|
+
config.defaultChannel = data.defaultChannel
|
|
59
|
+
}
|
|
60
|
+
if (data.notify?.port) config.port = data.notify.port
|
|
61
|
+
if (data.notify?.ccNodePath) config.ccNodePath = data.notify.ccNodePath
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. 环境变量覆盖(CC_NODE_CHANNEL_*)
|
|
67
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
68
|
+
if (!key.startsWith('CC_NODE_CHANNEL_')) continue
|
|
69
|
+
const rest = key.slice('CC_NODE_CHANNEL_'.length)
|
|
70
|
+
if (rest === 'DEFAULT') continue
|
|
71
|
+
const parts = rest.split('_')
|
|
72
|
+
const type = parts[0].toLowerCase()
|
|
73
|
+
const param = parts.slice(1).join('_').toLowerCase()
|
|
74
|
+
if (!config.channels[type]) config.channels[type] = { type }
|
|
75
|
+
const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
76
|
+
config.channels[type][camelKey] = value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return config
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// 通道适配器(复用 cc-node 的逻辑,独立实现避免依赖)
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
async function sendTelegram(config, text) {
|
|
87
|
+
const url = `https://api.telegram.org/bot${config.token}/sendMessage`
|
|
88
|
+
const res = await fetch(url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ chat_id: config.chatId, text, parse_mode: 'Markdown' }),
|
|
92
|
+
})
|
|
93
|
+
return res.json()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function sendWebhook(url, text, parseMode) {
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ text, msgtype: parseMode === 'markdown' ? 'markdown' : 'text' }),
|
|
101
|
+
})
|
|
102
|
+
return res.text()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function sendToChannel(channels, defaultChannel, text) {
|
|
106
|
+
const targets = defaultChannel ? [defaultChannel] : Object.keys(channels)
|
|
107
|
+
const results = []
|
|
108
|
+
for (const name of targets) {
|
|
109
|
+
const ch = channels[name]
|
|
110
|
+
if (!ch) { results.push({ channel: name, ok: false, error: 'not configured' }); continue }
|
|
111
|
+
try {
|
|
112
|
+
if (ch.type === 'telegram') {
|
|
113
|
+
const r = await sendTelegram(ch, text)
|
|
114
|
+
results.push({ channel: name, ok: r.ok || false, result: r })
|
|
115
|
+
} else if (ch.webhookUrl) {
|
|
116
|
+
const r = await sendWebhook(ch.webhookUrl, text)
|
|
117
|
+
results.push({ channel: name, ok: true, result: r })
|
|
118
|
+
} else {
|
|
119
|
+
results.push({ channel: name, ok: false, error: 'unknown type' })
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
results.push({ channel: name, ok: false, error: e.message })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return results
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================
|
|
129
|
+
// Telegram Bot 长轮询(接收手机消息)
|
|
130
|
+
// ============================================================
|
|
131
|
+
|
|
132
|
+
class TelegramListener {
|
|
133
|
+
constructor(config) {
|
|
134
|
+
this.config = config
|
|
135
|
+
this.lastUpdateId = 0
|
|
136
|
+
this.running = false
|
|
137
|
+
this.handlers = [] // 消息处理器
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMessage(handler) {
|
|
141
|
+
this.handlers.push(handler)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async start() {
|
|
145
|
+
const ch = this.config.channels.telegram
|
|
146
|
+
if (!ch?.token) {
|
|
147
|
+
log('Telegram listener: no token, skipping')
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
this.running = true
|
|
151
|
+
log('Telegram listener: started (long polling)')
|
|
152
|
+
this._poll()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
stop() {
|
|
156
|
+
this.running = false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async _poll() {
|
|
160
|
+
while (this.running) {
|
|
161
|
+
try {
|
|
162
|
+
const url = `https://api.telegram.org/bot${this.config.channels.telegram.token}/getUpdates`
|
|
163
|
+
const res = await fetch(url, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
offset: this.lastUpdateId + 1,
|
|
168
|
+
timeout: 30, // 长轮询超时
|
|
169
|
+
allowed_updates: ['message'],
|
|
170
|
+
}),
|
|
171
|
+
})
|
|
172
|
+
const data = await res.json()
|
|
173
|
+
|
|
174
|
+
if (data.ok && data.result?.length) {
|
|
175
|
+
for (const update of data.result) {
|
|
176
|
+
this.lastUpdateId = update.update_id
|
|
177
|
+
if (update.message?.text) {
|
|
178
|
+
const msg = {
|
|
179
|
+
text: update.message.text,
|
|
180
|
+
chatId: update.message.chat.id,
|
|
181
|
+
from: update.message.from?.username || update.message.from?.first_name || 'unknown',
|
|
182
|
+
date: new Date(update.message.date * 1000),
|
|
183
|
+
}
|
|
184
|
+
log(`Telegram msg from ${msg.from}: ${msg.text.slice(0, 80)}`)
|
|
185
|
+
for (const handler of this.handlers) {
|
|
186
|
+
try { await handler(msg) } catch (e) { log(`Handler error: ${e.message}`) }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
log(`Telegram poll error: ${e.message}`)
|
|
193
|
+
await sleep(5000) // 出错后等 5 秒重试
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================
|
|
200
|
+
// HTTP API 服务
|
|
201
|
+
// ============================================================
|
|
202
|
+
|
|
203
|
+
class HttpServer {
|
|
204
|
+
constructor(config, channels) {
|
|
205
|
+
this.config = config
|
|
206
|
+
this.channels = channels
|
|
207
|
+
this.server = null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
start() {
|
|
211
|
+
this.server = createServer(async (req, res) => {
|
|
212
|
+
const url = new URL(req.url, `http://localhost:${this.config.port}`)
|
|
213
|
+
|
|
214
|
+
// CORS
|
|
215
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
216
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
217
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
218
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
222
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
223
|
+
res.end(JSON.stringify({
|
|
224
|
+
status: 'running',
|
|
225
|
+
channels: Object.keys(this.channels),
|
|
226
|
+
defaultChannel: this.config.defaultChannel,
|
|
227
|
+
uptime: process.uptime(),
|
|
228
|
+
}))
|
|
229
|
+
} else if (req.method === 'POST' && url.pathname === '/send') {
|
|
230
|
+
const body = await readBody(req)
|
|
231
|
+
const { text, channel, parseMode } = JSON.parse(body)
|
|
232
|
+
if (!text) {
|
|
233
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
234
|
+
res.end(JSON.stringify({ error: 'text is required' }))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const results = await sendToChannel(
|
|
238
|
+
this.channels,
|
|
239
|
+
channel || this.config.defaultChannel,
|
|
240
|
+
text
|
|
241
|
+
)
|
|
242
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
243
|
+
res.end(JSON.stringify({ results }))
|
|
244
|
+
} else {
|
|
245
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
246
|
+
res.end(JSON.stringify({ error: 'not found' }))
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
250
|
+
res.end(JSON.stringify({ error: e.message }))
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
this.server.listen(this.config.port, () => {
|
|
255
|
+
log(`HTTP API listening on http://localhost:${this.config.port}`)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
stop() {
|
|
260
|
+
this.server?.close()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ============================================================
|
|
265
|
+
// 消息处理(收到的消息如何处理)
|
|
266
|
+
// ============================================================
|
|
267
|
+
|
|
268
|
+
async function handleIncomingMessage(msg, config, channels) {
|
|
269
|
+
const text = msg.text
|
|
270
|
+
|
|
271
|
+
// 命令处理
|
|
272
|
+
if (text.startsWith('/')) {
|
|
273
|
+
const [cmd, ...args] = text.split(' ')
|
|
274
|
+
switch (cmd) {
|
|
275
|
+
case '/start':
|
|
276
|
+
case '/help':
|
|
277
|
+
return `🤖 *cc-notify* — AI Code Agent 通知服务\n\nCommands:\n/ping — 检查服务状态\n/run <cmd> — 执行一次性命令\n/notify <text> — 发送通知到所有通道\n/status — 查看状态`
|
|
278
|
+
case '/ping':
|
|
279
|
+
return '🏓 pong!'
|
|
280
|
+
case '/status':
|
|
281
|
+
return `📊 cc-notify status\nChannels: ${Object.keys(channels).join(', ') || 'none'}\nUptime: ${Math.floor(process.uptime())}s`
|
|
282
|
+
case '/notify':
|
|
283
|
+
const notifyText = args.join(' ')
|
|
284
|
+
if (!notifyText) return 'Usage: /notify <text>'
|
|
285
|
+
const results = await sendToChannel(channels, config.defaultChannel, notifyText)
|
|
286
|
+
return results.map(r => r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`).join('\n')
|
|
287
|
+
case '/run':
|
|
288
|
+
const cmd = args.join(' ')
|
|
289
|
+
if (!cmd) return 'Usage: /run <command>'
|
|
290
|
+
return await runOneShot(config.ccNodePath, cmd)
|
|
291
|
+
default:
|
|
292
|
+
return `Unknown command: ${cmd}\nType /help for available commands`
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 普通消息 → 当作一次性任务执行
|
|
297
|
+
return await runOneShot(config.ccNodePath, text)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 调用 cc-node 执行一次性命令 */
|
|
301
|
+
function runOneShot(ccNodePath, input) {
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
const timeout = 60000 // 60 秒超时
|
|
304
|
+
const timer = setTimeout(() => {
|
|
305
|
+
child.kill()
|
|
306
|
+
resolve('⏰ 执行超时(60秒)')
|
|
307
|
+
}, timeout)
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const child = spawn(ccNodePath, [], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
311
|
+
let stdout = ''
|
|
312
|
+
let stderr = ''
|
|
313
|
+
child.stdout.on('data', (d) => { stdout += d.toString() })
|
|
314
|
+
child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
315
|
+
child.stdin.write(input + '\n')
|
|
316
|
+
child.stdin.end()
|
|
317
|
+
child.on('close', (code) => {
|
|
318
|
+
clearTimeout(timer)
|
|
319
|
+
if (code === 0 && stdout.trim()) {
|
|
320
|
+
resolve(stdout.trim().slice(0, 4000)) // Telegram 消息长度限制
|
|
321
|
+
} else if (stderr.trim()) {
|
|
322
|
+
resolve(`❌ Error: ${stderr.trim().slice(0, 1000)}`)
|
|
323
|
+
} else {
|
|
324
|
+
resolve(stdout.trim().slice(0, 4000) || '(no output)')
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
} catch (e) {
|
|
328
|
+
clearTimeout(timer)
|
|
329
|
+
resolve(`❌ Failed to run cc-node: ${e.message}`)
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ============================================================
|
|
335
|
+
// 守护进程管理
|
|
336
|
+
// ============================================================
|
|
337
|
+
|
|
338
|
+
function startDaemon(config) {
|
|
339
|
+
log('Starting cc-notify daemon...')
|
|
340
|
+
|
|
341
|
+
// 检查是否已在运行
|
|
342
|
+
if (existsSync(config.pidFile)) {
|
|
343
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
344
|
+
try {
|
|
345
|
+
process.kill(pid, 0) // 检查进程是否存活
|
|
346
|
+
console.error(`cc-notify already running (PID ${pid})`)
|
|
347
|
+
console.log('Use --stop to stop it first')
|
|
348
|
+
process.exit(1)
|
|
349
|
+
} catch {
|
|
350
|
+
// 进程已死,清理旧 PID 文件
|
|
351
|
+
unlinkSync(config.pidFile)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 用子进程启动自己
|
|
356
|
+
const child = spawn(process.execPath, [import.meta.url], {
|
|
357
|
+
detached: true,
|
|
358
|
+
stdio: 'ignore',
|
|
359
|
+
env: { ...process.env, CC_NOTIFY_DAEMON: '1' },
|
|
360
|
+
})
|
|
361
|
+
child.unref()
|
|
362
|
+
console.log(`cc-notify daemon started (PID ${child.pid})`)
|
|
363
|
+
console.log(`PID file: ${config.pidFile}`)
|
|
364
|
+
console.log(`Log file: ${config.logFile}`)
|
|
365
|
+
console.log(`HTTP API: http://localhost:${config.port}`)
|
|
366
|
+
process.exit(0)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function stopDaemon(config) {
|
|
370
|
+
if (!existsSync(config.pidFile)) {
|
|
371
|
+
console.log('cc-notify is not running')
|
|
372
|
+
process.exit(0)
|
|
373
|
+
}
|
|
374
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
375
|
+
try {
|
|
376
|
+
process.kill(pid, 'SIGTERM')
|
|
377
|
+
console.log(`cc-notify stopped (PID ${pid})`)
|
|
378
|
+
} catch {
|
|
379
|
+
console.log(`Process ${pid} not found, cleaning PID file`)
|
|
380
|
+
}
|
|
381
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
382
|
+
process.exit(0)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function showStatus(config) {
|
|
386
|
+
if (!existsSync(config.pidFile)) {
|
|
387
|
+
console.log('cc-notify is not running')
|
|
388
|
+
process.exit(0)
|
|
389
|
+
}
|
|
390
|
+
const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
|
|
391
|
+
try {
|
|
392
|
+
process.kill(pid, 0)
|
|
393
|
+
console.log(`cc-notify running (PID ${pid})`)
|
|
394
|
+
// 尝试从 HTTP API 获取详细状态
|
|
395
|
+
fetch(`http://localhost:${config.port}/status`)
|
|
396
|
+
.then(r => r.json())
|
|
397
|
+
.then(data => console.log('Status:', JSON.stringify(data, null, 2)))
|
|
398
|
+
.catch(() => console.log('(HTTP API not responding)'))
|
|
399
|
+
} catch {
|
|
400
|
+
console.log(`PID ${pid} is dead, cleaning up`)
|
|
401
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ============================================================
|
|
406
|
+
// 工具函数
|
|
407
|
+
// ============================================================
|
|
408
|
+
|
|
409
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
410
|
+
|
|
411
|
+
function readBody(req) {
|
|
412
|
+
return new Promise((resolve) => {
|
|
413
|
+
let body = ''
|
|
414
|
+
req.on('data', (d) => { body += d })
|
|
415
|
+
req.on('end', () => resolve(body))
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function log(msg) {
|
|
420
|
+
const ts = new Date().toISOString().slice(11, 19)
|
|
421
|
+
const line = `[${ts}] ${msg}\n`
|
|
422
|
+
process.stdout.write(line)
|
|
423
|
+
try {
|
|
424
|
+
const config = loadConfig()
|
|
425
|
+
if (config.logFile) {
|
|
426
|
+
appendFileSync(config.logFile, line)
|
|
427
|
+
}
|
|
428
|
+
} catch {}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ============================================================
|
|
432
|
+
// 主入口
|
|
433
|
+
// ============================================================
|
|
434
|
+
|
|
435
|
+
async function main() {
|
|
436
|
+
const config = loadConfig()
|
|
437
|
+
|
|
438
|
+
// 解析命令行
|
|
439
|
+
const args = process.argv.slice(2)
|
|
440
|
+
const isDaemon = args.includes('--daemon')
|
|
441
|
+
const isStop = args.includes('--stop')
|
|
442
|
+
const isStatus = args.includes('--status')
|
|
443
|
+
|
|
444
|
+
if (isStop) return stopDaemon(config)
|
|
445
|
+
if (isStatus) return showStatus(config)
|
|
446
|
+
if (isDaemon) return startDaemon(config)
|
|
447
|
+
|
|
448
|
+
// 写 PID 文件
|
|
449
|
+
writeFileSync(config.pidFile, String(process.pid))
|
|
450
|
+
|
|
451
|
+
// 优雅退出
|
|
452
|
+
const cleanup = () => {
|
|
453
|
+
log('Shutting down...')
|
|
454
|
+
try { unlinkSync(config.pidFile) } catch {}
|
|
455
|
+
process.exit(0)
|
|
456
|
+
}
|
|
457
|
+
process.on('SIGTERM', cleanup)
|
|
458
|
+
process.on('SIGINT', cleanup)
|
|
459
|
+
|
|
460
|
+
log('cc-notify starting...')
|
|
461
|
+
log(`Channels: ${Object.keys(config.channels).join(', ') || 'none'}`)
|
|
462
|
+
log(`Default: ${config.defaultChannel || 'none'}`)
|
|
463
|
+
|
|
464
|
+
// 启动 Telegram 监听
|
|
465
|
+
const tgListener = new TelegramListener(config)
|
|
466
|
+
tgListener.onMessage(async (msg) => {
|
|
467
|
+
const reply = await handleIncomingMessage(msg, config, config.channels)
|
|
468
|
+
// 回复到 Telegram
|
|
469
|
+
if (config.channels.telegram?.token && msg.chatId) {
|
|
470
|
+
await sendTelegram(
|
|
471
|
+
{ token: config.channels.telegram.token, chatId: msg.chatId },
|
|
472
|
+
reply
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
await tgListener.start()
|
|
477
|
+
|
|
478
|
+
// 启动 HTTP API
|
|
479
|
+
const httpServer = new HttpServer(config, config.channels)
|
|
480
|
+
httpServer.start()
|
|
481
|
+
|
|
482
|
+
log('cc-notify ready ✅')
|
|
483
|
+
|
|
484
|
+
// 保持进程存活
|
|
485
|
+
setInterval(() => {}, 60000)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
main().catch((err) => {
|
|
489
|
+
console.error('Fatal:', err)
|
|
490
|
+
process.exit(1)
|
|
491
|
+
})
|
package/src/core/cli.js
CHANGED
|
@@ -9,28 +9,30 @@ import { SessionManager } from './session.js'
|
|
|
9
9
|
import { Config } from './config.js'
|
|
10
10
|
import { TokenBudget } from './token-budget.js'
|
|
11
11
|
import { PermissionChecker } from '../permission/permission.js'
|
|
12
|
+
import { ChannelManager } from '../channel/index.js'
|
|
12
13
|
|
|
13
14
|
const BANNER = `
|
|
14
15
|
╔═══════════════════════════════════════════════╗
|
|
15
|
-
║
|
|
16
|
-
║
|
|
17
|
-
║
|
|
18
|
-
║
|
|
16
|
+
║ AI Code Agent — Node.js Edition ║
|
|
17
|
+
║ OpenAI-Compatible · DeepSeek Default ║
|
|
18
|
+
║ Type '/help' for commands ║
|
|
19
|
+
║ Type '/exit' or Ctrl+C to quit ║
|
|
19
20
|
╚═══════════════════════════════════════════════╝
|
|
20
21
|
`.trim()
|
|
21
22
|
|
|
22
23
|
const HELP_TEXT = `
|
|
23
24
|
Commands:
|
|
24
|
-
/help
|
|
25
|
-
/model NAME
|
|
26
|
-
/tools
|
|
27
|
-
/session
|
|
28
|
-
/sessions
|
|
29
|
-
/clear
|
|
30
|
-
/config KEY
|
|
31
|
-
/budget
|
|
32
|
-
/
|
|
33
|
-
/
|
|
25
|
+
/help — Show this help
|
|
26
|
+
/model NAME — Switch model
|
|
27
|
+
/tools — List available tools
|
|
28
|
+
/session — Show session info
|
|
29
|
+
/sessions — List all sessions
|
|
30
|
+
/clear — Clear conversation
|
|
31
|
+
/config KEY — Show config value
|
|
32
|
+
/budget — Show token budget
|
|
33
|
+
/channel CMD — Manage notification channels (list|send|test)
|
|
34
|
+
/exit — Exit (also Ctrl+C)
|
|
35
|
+
/quit — Same as /exit
|
|
34
36
|
`
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -52,41 +54,16 @@ function parseArgs(argv) {
|
|
|
52
54
|
while (i < argv.length) {
|
|
53
55
|
const arg = argv[i]
|
|
54
56
|
switch (arg) {
|
|
55
|
-
case '--model':
|
|
56
|
-
case '-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
case '--
|
|
60
|
-
case '-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
case '--
|
|
64
|
-
case '-
|
|
65
|
-
args.permissionMode = argv[++i]
|
|
66
|
-
break
|
|
67
|
-
case '--max-turns':
|
|
68
|
-
case '-t':
|
|
69
|
-
args.maxTurns = parseInt(argv[++i], 10)
|
|
70
|
-
break
|
|
71
|
-
case '--api-key':
|
|
72
|
-
args.apiKey = argv[++i]
|
|
73
|
-
break
|
|
74
|
-
case '--api-base':
|
|
75
|
-
args.apiBase = argv[++i]
|
|
76
|
-
break
|
|
77
|
-
case '--resume':
|
|
78
|
-
case '-r':
|
|
79
|
-
args.resume = argv[++i]
|
|
80
|
-
break
|
|
81
|
-
case '--verbose':
|
|
82
|
-
case '-v':
|
|
83
|
-
args.verbose = true
|
|
84
|
-
break
|
|
85
|
-
case '--no-stream':
|
|
86
|
-
args.noStream = true
|
|
87
|
-
break
|
|
88
|
-
case '--help':
|
|
89
|
-
case '-h':
|
|
57
|
+
case '--model': case '-m': args.model = argv[++i]; break
|
|
58
|
+
case '--system-prompt': case '-s': args.systemPrompt = argv[++i]; break
|
|
59
|
+
case '--permission-mode': case '-p': args.permissionMode = argv[++i]; break
|
|
60
|
+
case '--max-turns': case '-t': args.maxTurns = parseInt(argv[++i], 10); break
|
|
61
|
+
case '--api-key': args.apiKey = argv[++i]; break
|
|
62
|
+
case '--api-base': args.apiBase = argv[++i]; break
|
|
63
|
+
case '--resume': case '-r': args.resume = argv[++i]; break
|
|
64
|
+
case '--verbose': case '-v': args.verbose = true; break
|
|
65
|
+
case '--no-stream': args.noStream = true; break
|
|
66
|
+
case '--help': case '-h':
|
|
90
67
|
console.log(`Usage: cc-node [options]
|
|
91
68
|
|
|
92
69
|
Options:
|
|
@@ -95,24 +72,33 @@ Options:
|
|
|
95
72
|
-p, --permission-mode Permission mode: ask|always-allow|deny (default: ask)
|
|
96
73
|
-t, --max-turns N Max tool loop turns (default: 100)
|
|
97
74
|
--api-base URL API base URL (default: https://api.deepseek.com/v1)
|
|
98
|
-
--api-key
|
|
75
|
+
--api-key *** API key (or set LLM_API_KEY env)
|
|
99
76
|
-r, --resume ID Resume a session
|
|
100
77
|
-v, --verbose Verbose mode
|
|
101
78
|
--no-stream Disable streaming
|
|
102
79
|
-h, --help Show this help
|
|
103
80
|
|
|
104
81
|
Environment variables:
|
|
105
|
-
LLM_API_KEY
|
|
106
|
-
DEEPSEEK_API_KEY
|
|
107
|
-
OPENAI_API_KEY
|
|
108
|
-
QWEN_API_KEY
|
|
109
|
-
GLM_API_KEY
|
|
110
|
-
KIMI_API_KEY
|
|
111
|
-
LLM_API_BASE
|
|
82
|
+
LLM_API_KEY Universal API key (recommended)
|
|
83
|
+
DEEPSEEK_API_KEY DeepSeek API key (default)
|
|
84
|
+
OPENAI_API_KEY OpenAI API key
|
|
85
|
+
QWEN_API_KEY Qwen (DashScope) API key
|
|
86
|
+
GLM_API_KEY Zhipu GLM API key
|
|
87
|
+
KIMI_API_KEY Moonshot Kimi API key
|
|
88
|
+
LLM_API_BASE API base URL (default: https://api.deepseek.com/v1)
|
|
89
|
+
|
|
90
|
+
Channel environment variables:
|
|
91
|
+
CC_NODE_CHANNEL_DEFAULT Default channel name
|
|
92
|
+
CC_NODE_CHANNEL_TELEGRAM_TOKEN Telegram bot token
|
|
93
|
+
CC_NODE_CHANNEL_TELEGRAM_CHAT_ID Telegram chat ID
|
|
94
|
+
CC_NODE_CHANNEL_WECOM_WEBHOOK_URL WeCom webhook URL
|
|
95
|
+
CC_NODE_CHANNEL_FEISHU_WEBHOOK_URL Feishu webhook URL
|
|
96
|
+
CC_NODE_CHANNEL_DISCORD_WEBHOOK_URL Discord webhook URL
|
|
97
|
+
CC_NODE_CHANNEL_SLACK_WEBHOOK_URL Slack webhook URL
|
|
98
|
+
`)
|
|
112
99
|
process.exit(0)
|
|
113
100
|
default:
|
|
114
101
|
if (!arg.startsWith('-')) {
|
|
115
|
-
// 非选项参数视为一次性输入
|
|
116
102
|
args.oneShot = argv.slice(i).join(' ')
|
|
117
103
|
i = argv.length
|
|
118
104
|
}
|
|
@@ -120,7 +106,6 @@ Environment variables:
|
|
|
120
106
|
}
|
|
121
107
|
i++
|
|
122
108
|
}
|
|
123
|
-
|
|
124
109
|
return args
|
|
125
110
|
}
|
|
126
111
|
|
|
@@ -191,10 +176,23 @@ export async function main() {
|
|
|
191
176
|
maxTokens: config.get('maxBudgetTokens') || 200_000,
|
|
192
177
|
})
|
|
193
178
|
|
|
179
|
+
// 初始化通讯通道
|
|
180
|
+
const channelManager = new ChannelManager({
|
|
181
|
+
channels: config.get('channels') || {},
|
|
182
|
+
defaultChannel: config.get('defaultChannel') || null,
|
|
183
|
+
})
|
|
184
|
+
|
|
194
185
|
// 一次性输入模式
|
|
195
186
|
if (cliArgs.oneShot) {
|
|
196
187
|
const result = await engine.processMessage(cliArgs.oneShot)
|
|
197
188
|
console.log(result.response)
|
|
189
|
+
// 一次性模式结束后发通知
|
|
190
|
+
if (channelManager.list().length > 0) {
|
|
191
|
+
await channelManager.sendTemplate('task-done', {
|
|
192
|
+
task: cliArgs.oneShot.slice(0, 80),
|
|
193
|
+
result: result.response.slice(0, 200),
|
|
194
|
+
})
|
|
195
|
+
}
|
|
198
196
|
process.exit(0)
|
|
199
197
|
}
|
|
200
198
|
|
|
@@ -207,16 +205,17 @@ export async function main() {
|
|
|
207
205
|
|
|
208
206
|
console.log(BANNER)
|
|
209
207
|
console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
|
|
208
|
+
if (channelManager.list().length > 0) {
|
|
209
|
+
const chList = channelManager.list().join(', ')
|
|
210
|
+
const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
|
|
211
|
+
console.log(`Channels: ${chList}${def}`)
|
|
212
|
+
}
|
|
210
213
|
console.log()
|
|
211
|
-
|
|
212
214
|
rl.prompt()
|
|
213
215
|
|
|
214
216
|
rl.on('line', async (line) => {
|
|
215
217
|
const input = line.trim()
|
|
216
|
-
if (!input) {
|
|
217
|
-
rl.prompt()
|
|
218
|
-
return
|
|
219
|
-
}
|
|
218
|
+
if (!input) { rl.prompt(); return }
|
|
220
219
|
|
|
221
220
|
// 命令处理
|
|
222
221
|
if (input.startsWith('/')) {
|
|
@@ -273,6 +272,41 @@ export async function main() {
|
|
|
273
272
|
case 'budget':
|
|
274
273
|
console.log(tokenBudget.format())
|
|
275
274
|
break
|
|
275
|
+
case 'channel': {
|
|
276
|
+
const subCmd = rest.join(' ')
|
|
277
|
+
if (subCmd === 'list' || subCmd === '') {
|
|
278
|
+
const channels = channelManager.list()
|
|
279
|
+
if (channels.length === 0) {
|
|
280
|
+
console.log('No channels configured')
|
|
281
|
+
console.log('Setup options:')
|
|
282
|
+
console.log(' 1. Environment: CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=xxx')
|
|
283
|
+
console.log(' 2. Config: .claude-code/config.json -> { "channels": { "telegram": { ... } } }')
|
|
284
|
+
} else {
|
|
285
|
+
console.log('Channels:')
|
|
286
|
+
for (const ch of channels) {
|
|
287
|
+
const isDefault = channelManager.defaultChannel === ch ? ' (default)' : ''
|
|
288
|
+
console.log(` - ${ch}${isDefault}`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} else if (subCmd.startsWith('send ')) {
|
|
292
|
+
const text = subCmd.slice(5)
|
|
293
|
+
const results = await channelManager.send(text)
|
|
294
|
+
for (const r of results) {
|
|
295
|
+
console.log(r.ok ? `✅ ${r.channel}: sent` : `❌ ${r.channel}: ${r.error}`)
|
|
296
|
+
}
|
|
297
|
+
} else if (subCmd.startsWith('test')) {
|
|
298
|
+
const results = await channelManager.send('📡 cc-node channel test')
|
|
299
|
+
for (const r of results) {
|
|
300
|
+
console.log(r.ok ? `✅ ${r.channel}: test OK` : `❌ ${r.channel}: ${r.error}`)
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
console.log('Usage:')
|
|
304
|
+
console.log(' /channel list — List configured channels')
|
|
305
|
+
console.log(' /channel send <msg> — Send message to channels')
|
|
306
|
+
console.log(' /channel test — Test channel connectivity')
|
|
307
|
+
}
|
|
308
|
+
break
|
|
309
|
+
}
|
|
276
310
|
case 'exit':
|
|
277
311
|
case 'quit':
|
|
278
312
|
console.log('Goodbye!')
|
|
@@ -302,8 +336,14 @@ export async function main() {
|
|
|
302
336
|
}
|
|
303
337
|
} catch (err) {
|
|
304
338
|
console.error(`\nError: ${err.message}\n`)
|
|
339
|
+
// 错误也通知
|
|
340
|
+
if (channelManager.list().length > 0) {
|
|
341
|
+
await channelManager.sendTemplate('error', {
|
|
342
|
+
task: input.slice(0, 80),
|
|
343
|
+
error: err.message.slice(0, 200),
|
|
344
|
+
}).catch(() => {}) // 通知失败不影响主流程
|
|
345
|
+
}
|
|
305
346
|
}
|
|
306
|
-
|
|
307
347
|
rl.prompt()
|
|
308
348
|
})
|
|
309
349
|
|