@raolin2025/claude-code-node 2.3.6 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -4
- package/package.json +2 -2
- package/src/channel/index.js +278 -33
- package/src/channel/notify-daemon.js +604 -384
- package/src/channel/qqbot-listener.js +403 -0
- package/src/channel/tg-listener.js +595 -0
package/README.md
CHANGED
|
@@ -359,6 +359,13 @@ cc-node
|
|
|
359
359
|
"type": "telegram",
|
|
360
360
|
"token": "123456:ABC-DEF",
|
|
361
361
|
"chatId": "78901234"
|
|
362
|
+
},
|
|
363
|
+
"qqbot": {
|
|
364
|
+
"type": "qqbot",
|
|
365
|
+
"token": "你的BotToken",
|
|
366
|
+
"appId": "你的AppID",
|
|
367
|
+
"channelId": "频道ID",
|
|
368
|
+
"groupOpenId": "群OpenID"
|
|
362
369
|
}
|
|
363
370
|
},
|
|
364
371
|
"defaultChannel": "telegram"
|
|
@@ -379,9 +386,61 @@ cc-node
|
|
|
379
386
|
- ❌ **执行出错**(自动通知错误内容)
|
|
380
387
|
- 🔄 **手动发送**(`/channel send` 命令)
|
|
381
388
|
|
|
382
|
-
##
|
|
389
|
+
## 📱 QQ Bot 远端编程(v2.0 新增)
|
|
390
|
+
|
|
391
|
+
通过 QQ Bot API v2 实现远程编程控制。独立、零依赖、无需 OpenClaw。
|
|
392
|
+
|
|
393
|
+
### 前置配置
|
|
394
|
+
|
|
395
|
+
1. 前往 [QQ 开放平台](https://q.qq.com/) 注册机器人,获取 **AppID** 和 **AppSecret**
|
|
396
|
+
2. 在 QQ 中与机器人对话或拉入群,使用 `/bot-me` 获取你的 **OpenID** 或 **群 OpenID**
|
|
397
|
+
3. 配置环境变量:
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
export CC_NODE_CHANNEL_QQBOT_APPID=你的AppID
|
|
401
|
+
export CC_NODE_CHANNEL_QQBOT_SECRET=你的AppSecret
|
|
402
|
+
export CC_NODE_CHANNEL_QQBOT_GROUPID=你的群OpenID
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### 使用方式
|
|
406
|
+
|
|
407
|
+
启动 cc-notify 后,直接给机器人发消息或在群中 @机器人:
|
|
408
|
+
|
|
409
|
+
```
|
|
410
|
+
帮我写一个快速排序
|
|
411
|
+
/ping
|
|
412
|
+
/status
|
|
413
|
+
/run ls -la
|
|
414
|
+
/notify 任务完成!
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### 技术架构
|
|
418
|
+
|
|
419
|
+
```
|
|
420
|
+
QQ消息 → WebSocket (wss://api.sgroup.qq.com) → cc-notify → cc-node
|
|
421
|
+
cc-node → cc-notify → QQ API v2 (api.sgroup.qq.com/v2) → QQ消息
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
- **认证**: `appId + clientSecret` → `access_token`(自动续期,token 有效期 2 小时)
|
|
425
|
+
- **发送**: POST `/v2/groups/{group_openid}/messages` (群) 或 `/v2/users/{openid}/messages` (C2C)
|
|
426
|
+
- **接收**: WebSocket 长连接 + 心跳保活 + 自动重连
|
|
427
|
+
- **代码**: 基于 [qqbot-standalone](https://github.com/bg1avd/qqbot-standalone) 的架构
|
|
428
|
+
- **参考**: `src/channel/qqbot-listener.js`
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 🔔 后台运行 (cc-notify v2.0)
|
|
383
433
|
|
|
384
434
|
cc-notify 是独立的通知守护进程,**不需要 cc-node 在前台运行**,开机自启后随时可用。
|
|
435
|
+
支持 **Telegram** 和 **QQ Bot** 双通道远程编程。
|
|
436
|
+
|
|
437
|
+
### v2.0 新特性
|
|
438
|
+
|
|
439
|
+
- ✅ **QQ Bot 支持** — 通过 QQ 群/频道远程操控
|
|
440
|
+
- ✅ **增强版 Telegram 监听** — 速率限制、Markdown 安全编码、多轮对话
|
|
441
|
+
- ✅ **统一消息处理器** — 所有通道共享同一路由逻辑
|
|
442
|
+
- ✅ **长消息分段** — 自动切分超过 4000 字符的回复
|
|
443
|
+
- ✅ **API Key 持久化** — 自动生成并保存,重启不丢失
|
|
385
444
|
|
|
386
445
|
### 三种运行方式
|
|
387
446
|
|
|
@@ -393,14 +452,15 @@ cc-notify 是独立的通知守护进程,**不需要 cc-node 在前台运行**
|
|
|
393
452
|
|
|
394
453
|
### 手机交互
|
|
395
454
|
|
|
396
|
-
在 Telegram 上给 Bot 发消息:
|
|
455
|
+
在 Telegram/QQ 上给 Bot 发消息:
|
|
397
456
|
|
|
398
457
|
```
|
|
399
458
|
你好 → 当作一次性任务发给 cc-node 执行
|
|
400
459
|
/ping → 检查服务是否在线
|
|
401
460
|
/run ls -la → 执行 shell 命令
|
|
402
|
-
/notify 任务完成!
|
|
461
|
+
/notify 任务完成! → 向所有通道广播通知
|
|
403
462
|
/status → 查看服务状态
|
|
463
|
+
/cancel → 取消当前操作
|
|
404
464
|
```
|
|
405
465
|
|
|
406
466
|
### HTTP API(守护模式可用)
|
|
@@ -409,9 +469,16 @@ cc-notify 是独立的通知守护进程,**不需要 cc-node 在前台运行**
|
|
|
409
469
|
# 发送通知
|
|
410
470
|
curl -X POST http://localhost:3456/send \
|
|
411
471
|
-H 'Content-Type: application/json' \
|
|
472
|
+
-H 'X-API-Key: <你的API_Key>' \
|
|
412
473
|
-d '{"text":"构建完成 ✅"}'
|
|
413
474
|
|
|
414
|
-
#
|
|
475
|
+
# 远程编程
|
|
476
|
+
curl -X POST http://localhost:3456/chat \
|
|
477
|
+
-H 'Content-Type: application/json' \
|
|
478
|
+
-H 'X-API-Key: <你的API_Key>' \
|
|
479
|
+
-d '{"text":"帮我查下当前目录"}'
|
|
480
|
+
|
|
481
|
+
# 查看状态(无需 API Key)
|
|
415
482
|
curl http://localhost:3456/status
|
|
416
483
|
```
|
|
417
484
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications",
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
package/src/channel/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 通讯通道模块 — 支持多平台消息推送
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
4
|
+
* v2.0 增强:
|
|
5
|
+
* - 新增 QQBotChannel 适配器(QQ Bot API v2)
|
|
6
|
+
* - 增强 TelegramChannel:Markdown 安全编码、分片发送、编辑/删除
|
|
7
|
+
* - 增强速率限制和错误处理
|
|
8
8
|
*
|
|
9
9
|
* 支持的通道:
|
|
10
10
|
* - Telegram Bot API
|
|
11
|
+
* - QQ Bot (群/频道)
|
|
11
12
|
* - 企业微信 (WeCom) Webhook
|
|
12
13
|
* - 飞书 (Feishu) Webhook
|
|
13
14
|
* - Discord Webhook
|
|
@@ -15,44 +16,256 @@
|
|
|
15
16
|
* - 自定义 HTTP Webhook
|
|
16
17
|
*/
|
|
17
18
|
|
|
19
|
+
// ============================================================
|
|
20
|
+
// MarkdownV2 安全编码
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
const TG_MD_ESCAPE_CHARS = /[_*[\]()~`>#+\-=|{}.!]/g
|
|
24
|
+
|
|
25
|
+
function escapeMarkdownV2(text) {
|
|
26
|
+
return text.replace(TG_MD_ESCAPE_CHARS, '\\$&')
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
// ============================================================
|
|
19
30
|
// 通道适配器
|
|
20
31
|
// ============================================================
|
|
21
32
|
|
|
22
|
-
/** Telegram Bot API 适配器 */
|
|
33
|
+
/** Telegram Bot API 适配器 v2.0 */
|
|
23
34
|
class TelegramChannel {
|
|
24
35
|
constructor({ token, chatId }) {
|
|
25
36
|
this.token = token
|
|
26
37
|
this.chatId = chatId
|
|
27
|
-
|
|
28
|
-
this.
|
|
29
|
-
this.
|
|
30
|
-
this.headers = {}
|
|
31
|
-
this.bodyTemplate = null
|
|
38
|
+
this.apiBase = `https://api.telegram.org/bot${token}`
|
|
39
|
+
this.lastCall = 0
|
|
40
|
+
this.callInterval = 50 // 20 calls/sec max
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
get name() { return 'telegram' }
|
|
35
44
|
|
|
45
|
+
/** 速率限制等待 */
|
|
46
|
+
async _rateLimit() {
|
|
47
|
+
const now = Date.now()
|
|
48
|
+
const wait = this.callInterval - (now - this.lastCall)
|
|
49
|
+
if (wait > 0) await new Promise(r => setTimeout(r, wait))
|
|
50
|
+
this.lastCall = Date.now()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 发送消息(自动分段) */
|
|
36
54
|
async send(text, options = {}) {
|
|
37
|
-
const
|
|
55
|
+
const MAX_LEN = 4000
|
|
56
|
+
const parts = this._splitMessage(text, MAX_LEN)
|
|
57
|
+
const results = []
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < parts.length; i++) {
|
|
60
|
+
const part = parts[i]
|
|
61
|
+
const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
|
|
62
|
+
const body = header + part
|
|
63
|
+
|
|
64
|
+
await this._rateLimit()
|
|
65
|
+
const result = await this._sendSingle(body, options)
|
|
66
|
+
results.push(result)
|
|
67
|
+
|
|
68
|
+
// 分段之间稍作延迟
|
|
69
|
+
if (i < parts.length - 1) {
|
|
70
|
+
await new Promise(r => setTimeout(r, 200))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return results[0] || {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 单条发送 */
|
|
77
|
+
async _sendSingle(text, options = {}) {
|
|
78
|
+
const { parseMode, silent, replyTo, disableWebPreview, keyboard } = options
|
|
79
|
+
const useMarkdown = parseMode === 'Markdown' || parseMode === 'MarkdownV2' || !parseMode
|
|
80
|
+
|
|
81
|
+
const body = {
|
|
38
82
|
chat_id: this.chatId,
|
|
39
|
-
text,
|
|
40
|
-
parse_mode:
|
|
41
|
-
disable_notification:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
83
|
+
text: text.slice(0, 4096),
|
|
84
|
+
parse_mode: 'HTML', // HTML 比 Markdown 更稳定
|
|
85
|
+
disable_notification: silent || false,
|
|
86
|
+
disable_web_page_preview: disableWebPreview ?? true,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (replyTo) body.reply_parameters = { message_id: replyTo }
|
|
90
|
+
if (keyboard) body.reply_markup = JSON.stringify(keyboard)
|
|
91
|
+
|
|
92
|
+
// Telegram HTML 安全编码(只保留基本标签)
|
|
93
|
+
body.text = this._safeHTML(body.text)
|
|
94
|
+
|
|
95
|
+
const r = await fetch(`${this.apiBase}/sendMessage`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(body),
|
|
47
99
|
})
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
100
|
+
|
|
101
|
+
const data = await r.json()
|
|
102
|
+
if (!data.ok) {
|
|
103
|
+
// 429 — 速率限制,自动等待
|
|
104
|
+
if (data.error_code === 429) {
|
|
105
|
+
const retryAfter = data.parameters?.retry_after || 3
|
|
106
|
+
await new Promise(r => setTimeout(r, retryAfter * 1000))
|
|
107
|
+
return this._sendSingle(text, { ...options, parseMode: undefined })
|
|
108
|
+
}
|
|
109
|
+
// 400 格式错 — 降级纯文本
|
|
110
|
+
if (data.error_code === 400) {
|
|
111
|
+
return this._sendSingle(text, { ...options, parseMode: 'text' })
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`Telegram error ${data.error_code}: ${(data.description || '').slice(0, 200)}`)
|
|
51
114
|
}
|
|
115
|
+
return data.result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 安全 HTML(只允许 Telegram 支持的基本标签) */
|
|
119
|
+
_safeHTML(text) {
|
|
120
|
+
// Telegram HTML 只支持: <b>, <i>, <u>, <s>, <code>, <pre>, <a href="">
|
|
121
|
+
// 替换不支持的标签
|
|
122
|
+
return text
|
|
123
|
+
.replace(/<h[1-6][^>]*>/gi, '<b>')
|
|
124
|
+
.replace(/<\/h[1-6]>/gi, '</b>')
|
|
125
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
126
|
+
.replace(/<p[^>]*>/gi, '')
|
|
127
|
+
.replace(/<\/p>/gi, '\n\n')
|
|
128
|
+
.replace(/<strong>/gi, '<b>')
|
|
129
|
+
.replace(/<\/strong>/gi, '</b>')
|
|
130
|
+
.replace(/<em>/gi, '<i>')
|
|
131
|
+
.replace(/<\/em>/gi, '</i>')
|
|
132
|
+
.replace(/<hr[^>]*>/gi, '\n────────────\n')
|
|
133
|
+
.replace(/<img[^>]*>/gi, '[图片]')
|
|
134
|
+
.replace(/<[^>]+>/g, (tag) => {
|
|
135
|
+
const allowed = ['<b>', '</b>', '<i>', '</i>', '<u>', '</u>', '<s>', '</s>', '<code>', '</code>', '<pre>', '</pre>']
|
|
136
|
+
if (allowed.includes(tag.toLowerCase())) return tag
|
|
137
|
+
if (tag.toLowerCase().startsWith('<a ')) return tag
|
|
138
|
+
if (tag === '</a>') return tag
|
|
139
|
+
return ''
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 分片消息 */
|
|
144
|
+
_splitMessage(text, maxLen) {
|
|
145
|
+
if (!text || text.length <= maxLen) return [text || '']
|
|
146
|
+
const parts = []
|
|
147
|
+
let current = ''
|
|
148
|
+
for (const line of text.split('\n')) {
|
|
149
|
+
if (current.length + line.length + 1 > maxLen) {
|
|
150
|
+
parts.push(current)
|
|
151
|
+
current = line
|
|
152
|
+
} else {
|
|
153
|
+
current += (current ? '\n' : '') + line
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (current) parts.push(current)
|
|
157
|
+
return parts
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 编辑消息 */
|
|
161
|
+
async edit(messageId, text, options = {}) {
|
|
162
|
+
await this._rateLimit()
|
|
163
|
+
const r = await fetch(`${this.apiBase}/editMessageText`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
chat_id: this.chatId,
|
|
168
|
+
message_id: messageId,
|
|
169
|
+
text: this._safeHTML(text).slice(0, 4096),
|
|
170
|
+
parse_mode: 'HTML',
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
52
173
|
return r.json()
|
|
53
174
|
}
|
|
175
|
+
|
|
176
|
+
/** 删除消息 */
|
|
177
|
+
async delete(messageId) {
|
|
178
|
+
await this._rateLimit()
|
|
179
|
+
const r = await fetch(`${this.apiBase}/deleteMessage`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({ chat_id: this.chatId, message_id: messageId }),
|
|
183
|
+
})
|
|
184
|
+
return r.ok
|
|
185
|
+
}
|
|
54
186
|
}
|
|
55
187
|
|
|
188
|
+
// ============================================================
|
|
189
|
+
// QQ Bot 通道适配器 v2.0
|
|
190
|
+
// ============================================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* QQ Bot 通道适配器 — 独立、零依赖
|
|
194
|
+
*
|
|
195
|
+
* 使用 QQ Bot API v2,仅需 appId + clientSecret
|
|
196
|
+
*/
|
|
197
|
+
class QQBotChannel {
|
|
198
|
+
constructor(config = {}) {
|
|
199
|
+
this.appId = config.appId || process.env.CC_NODE_CHANNEL_QQBOT_APPID || ''
|
|
200
|
+
this.secret = config.secret || config.clientSecret || process.env.CC_NODE_CHANNEL_QQBOT_SECRET || ''
|
|
201
|
+
this._token = null
|
|
202
|
+
this._tokenCache = { token: null, expireAt: 0 }
|
|
203
|
+
this.channelId = config.channelId || ''
|
|
204
|
+
this.groupOpenId = config.groupOpenId || ''
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get name() { return 'qqbot' }
|
|
208
|
+
|
|
209
|
+
async _getToken() {
|
|
210
|
+
if (this._tokenCache.token && Date.now() < this._tokenCache.expireAt - 300000) {
|
|
211
|
+
return this._tokenCache.token
|
|
212
|
+
}
|
|
213
|
+
const res = await fetch('https://bots.qq.com/app/getAppAccessToken', {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/json' },
|
|
216
|
+
body: JSON.stringify({ appId: this.appId, clientSecret: this.secret }),
|
|
217
|
+
})
|
|
218
|
+
if (!res.ok) throw new Error('QQ Token API ' + res.status)
|
|
219
|
+
const data = JSON.parse(await res.text())
|
|
220
|
+
if (!data.access_token) throw new Error('Token API no access_token')
|
|
221
|
+
this._tokenCache.token = data.access_token
|
|
222
|
+
this._tokenCache.expireAt = Date.now() + (data.expires_in || 7200) * 1000
|
|
223
|
+
return data.access_token
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async send(text, options = {}) {
|
|
227
|
+
if (!this.appId || !this.secret) {
|
|
228
|
+
return [{ channel: 'qqbot', ok: false, error: '需要 appId 和 clientSecret' }]
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const token = await this._getToken()
|
|
232
|
+
const results = []
|
|
233
|
+
|
|
234
|
+
const scope = options.scope || (this.groupOpenId ? 'group' : '')
|
|
235
|
+
const targetId = options.targetId || this.groupOpenId || this.channelId
|
|
236
|
+
|
|
237
|
+
if (!scope || !targetId) {
|
|
238
|
+
return [{ channel: 'qqbot', ok: false, error: '未配置目标 (groupOpenId)' }]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const path = scope === 'group'
|
|
242
|
+
? '/v2/groups/' + targetId + '/messages'
|
|
243
|
+
: '/v2/users/' + targetId + '/messages'
|
|
244
|
+
|
|
245
|
+
const body = { content: text.slice(0, 2000), msg_type: 0 }
|
|
246
|
+
if (options.replyMsgId) body.msg_id = options.replyMsgId
|
|
247
|
+
|
|
248
|
+
const r = await fetch('https://api.sgroup.qq.com' + path, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'QQBot ' + token },
|
|
251
|
+
body: JSON.stringify(body),
|
|
252
|
+
})
|
|
253
|
+
if (!r.ok) {
|
|
254
|
+
const errText = await r.text().catch(() => '')
|
|
255
|
+
throw new Error('HTTP ' + r.status + ': ' + errText.slice(0, 100))
|
|
256
|
+
}
|
|
257
|
+
results.push({ channel: 'qqbot', ok: true })
|
|
258
|
+
return results
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return [{ channel: 'qqbot', ok: false, error: e.message.slice(0, 200) }]
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ============================================================
|
|
266
|
+
// 已有适配器(保持兼容)
|
|
267
|
+
// ============================================================
|
|
268
|
+
|
|
56
269
|
/** 企业微信 Webhook 适配器 */
|
|
57
270
|
class WeComChannel {
|
|
58
271
|
constructor({ webhookUrl }) {
|
|
@@ -64,7 +277,7 @@ class WeComChannel {
|
|
|
64
277
|
async send(text, options = {}) {
|
|
65
278
|
const body = JSON.stringify({
|
|
66
279
|
msgtype: 'text',
|
|
67
|
-
text: { content: text },
|
|
280
|
+
text: { content: text.slice(0, 2048) },
|
|
68
281
|
})
|
|
69
282
|
const r = await fetch(this.webhookUrl, {
|
|
70
283
|
method: 'POST',
|
|
@@ -90,7 +303,7 @@ class FeishuChannel {
|
|
|
90
303
|
async send(text, options = {}) {
|
|
91
304
|
const body = JSON.stringify({
|
|
92
305
|
msg_type: 'text',
|
|
93
|
-
content: { text },
|
|
306
|
+
content: { text: text.slice(0, 4096) },
|
|
94
307
|
})
|
|
95
308
|
const r = await fetch(this.webhookUrl, {
|
|
96
309
|
method: 'POST',
|
|
@@ -114,7 +327,7 @@ class DiscordChannel {
|
|
|
114
327
|
get name() { return 'discord' }
|
|
115
328
|
|
|
116
329
|
async send(text, options = {}) {
|
|
117
|
-
const body = JSON.stringify({ content: text })
|
|
330
|
+
const body = JSON.stringify({ content: text.slice(0, 2000) })
|
|
118
331
|
const r = await fetch(this.webhookUrl, {
|
|
119
332
|
method: 'POST',
|
|
120
333
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -137,7 +350,7 @@ class SlackChannel {
|
|
|
137
350
|
get name() { return 'slack' }
|
|
138
351
|
|
|
139
352
|
async send(text, options = {}) {
|
|
140
|
-
const body = JSON.stringify({ text })
|
|
353
|
+
const body = JSON.stringify({ text: text.slice(0, 3000) })
|
|
141
354
|
const r = await fetch(this.webhookUrl, {
|
|
142
355
|
method: 'POST',
|
|
143
356
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -185,6 +398,7 @@ class WebhookChannel {
|
|
|
185
398
|
|
|
186
399
|
const CHANNEL_ADAPTERS = {
|
|
187
400
|
telegram: TelegramChannel,
|
|
401
|
+
qqbot: QQBotChannel,
|
|
188
402
|
wecom: WeComChannel,
|
|
189
403
|
feishu: FeishuChannel,
|
|
190
404
|
discord: DiscordChannel,
|
|
@@ -195,10 +409,16 @@ const CHANNEL_ADAPTERS = {
|
|
|
195
409
|
const ENV_PREFIX = 'CC_NODE_CHANNEL_'
|
|
196
410
|
|
|
197
411
|
export class ChannelManager {
|
|
198
|
-
constructor(config = {}) {
|
|
412
|
+
constructor(config = {}, defaultChannel) {
|
|
199
413
|
this.channels = new Map()
|
|
200
|
-
this.defaultChannel = config.defaultChannel || null
|
|
201
|
-
|
|
414
|
+
this.defaultChannel = defaultChannel || config.defaultChannel || null
|
|
415
|
+
if (config.channels) {
|
|
416
|
+
this._loadFromConfig(config)
|
|
417
|
+
}
|
|
418
|
+
// If config is array-like or flat, handle old format
|
|
419
|
+
if (typeof config === 'object' && !config.channels && Object.keys(config).length > 0) {
|
|
420
|
+
this._loadFromConfig({ channels: config })
|
|
421
|
+
}
|
|
202
422
|
this._loadFromEnv()
|
|
203
423
|
}
|
|
204
424
|
|
|
@@ -209,9 +429,6 @@ export class ChannelManager {
|
|
|
209
429
|
if (chConfig.enabled === false) continue
|
|
210
430
|
this._registerChannel(name, chConfig)
|
|
211
431
|
}
|
|
212
|
-
if (config.defaultChannel) {
|
|
213
|
-
this.defaultChannel = config.defaultChannel
|
|
214
|
-
}
|
|
215
432
|
}
|
|
216
433
|
|
|
217
434
|
/** 从环境变量加载通道 */
|
|
@@ -221,12 +438,13 @@ export class ChannelManager {
|
|
|
221
438
|
if (!key.startsWith(ENV_PREFIX)) continue
|
|
222
439
|
const rest = key.slice(ENV_PREFIX.length)
|
|
223
440
|
if (rest === 'DEFAULT') {
|
|
224
|
-
this.defaultChannel = value.toLowerCase()
|
|
441
|
+
if (!this.defaultChannel) this.defaultChannel = value.toLowerCase()
|
|
225
442
|
continue
|
|
226
443
|
}
|
|
227
444
|
const parts = rest.split('_')
|
|
228
445
|
const channelType = parts[0].toLowerCase()
|
|
229
446
|
const param = parts.slice(1).join('_').toLowerCase()
|
|
447
|
+
|
|
230
448
|
if (!envChannels[channelType]) envChannels[channelType] = { type: channelType }
|
|
231
449
|
const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
232
450
|
envChannels[channelType][camelKey] = value
|
|
@@ -268,6 +486,21 @@ export class ChannelManager {
|
|
|
268
486
|
for (const name of targetChannels) {
|
|
269
487
|
const ch = this.channels.get(name)
|
|
270
488
|
if (!ch) {
|
|
489
|
+
// 不存在的通道尝试按 type 注册
|
|
490
|
+
const Adapter = CHANNEL_ADAPTERS[name]
|
|
491
|
+
if (Adapter) {
|
|
492
|
+
this._registerChannel(name, { type: name })
|
|
493
|
+
const ch2 = this.channels.get(name)
|
|
494
|
+
if (ch2) {
|
|
495
|
+
try {
|
|
496
|
+
const result = await ch2.send(text, options)
|
|
497
|
+
results.push({ channel: name, ok: true, result })
|
|
498
|
+
} catch (e) {
|
|
499
|
+
results.push({ channel: name, ok: false, error: e.message })
|
|
500
|
+
}
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
}
|
|
271
504
|
results.push({ channel: name, ok: false, error: 'not registered' })
|
|
272
505
|
continue
|
|
273
506
|
}
|
|
@@ -289,8 +522,20 @@ export class ChannelManager {
|
|
|
289
522
|
'question': `❓ 需要确认\n${data.question || ''}\n${data.options ? '选项:' + data.options.join(' / ') : ''}`,
|
|
290
523
|
'progress': `🔄 进度更新\n${data.task || ''}\n${data.progress || ''}${data.percent ? ' (' + data.percent + '%)' : ''}`,
|
|
291
524
|
'warning': `⚠️ 警告\n${data.message || ''}`,
|
|
525
|
+
'debug': `🔍 调试信息\n${data.info || ''}`,
|
|
292
526
|
}
|
|
293
527
|
const text = templates[template] || `📢 ${data.message || ''}`
|
|
294
528
|
return this.send(text, options)
|
|
295
529
|
}
|
|
296
530
|
}
|
|
531
|
+
|
|
532
|
+
// 导出适配器类(供外部使用)
|
|
533
|
+
export {
|
|
534
|
+
TelegramChannel,
|
|
535
|
+
QQBotChannel,
|
|
536
|
+
WeComChannel,
|
|
537
|
+
FeishuChannel,
|
|
538
|
+
DiscordChannel,
|
|
539
|
+
SlackChannel,
|
|
540
|
+
WebhookChannel,
|
|
541
|
+
}
|