@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 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
- ## 🔔 后台运行 (cc-notify)
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.3.6",
4
- "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications",
3
+ "version": "2.4.1",
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": {
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * 通讯通道模块 — 支持多平台消息推送
3
3
  *
4
- * v1.1 修复:
5
- * - TelegramChannel: 添加缺失的 url 属性
6
- * - 新增 WeComChannel, FeishuChannel, DiscordChannel, SlackChannel 适配器实现
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,265 @@
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
- // v1.1 修复: 添加缺失的 url 属性
28
- this.url = `https://api.telegram.org/bot${token}/sendMessage`
29
- this.method = 'POST'
30
- this.headers = {}
31
- this.bodyTemplate = null
38
+ this.proxyAddr = process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || ''
39
+ const customBase = process.env.CC_NODE_CHANNEL_TELEGRAM_API_BASE || ''
40
+ this.apiBase = customBase || `https://api.telegram.org/bot${token}`
41
+ this.lastCall = 0
42
+ this.callInterval = 50 // 20 calls/sec max
43
+ }
44
+
45
+ /** 带代理支持的 fetch */
46
+ async _fetch(url, options = {}) {
47
+ if (!this.proxyAddr) return fetch(url, options)
48
+ const { fetchViaSocks5 } = await import('./tg-proxy.js')
49
+ return fetchViaSocks5(url, options, this.proxyAddr)
32
50
  }
33
51
 
34
52
  get name() { return 'telegram' }
35
53
 
54
+ /** 速率限制等待 */
55
+ async _rateLimit() {
56
+ const now = Date.now()
57
+ const wait = this.callInterval - (now - this.lastCall)
58
+ if (wait > 0) await new Promise(r => setTimeout(r, wait))
59
+ this.lastCall = Date.now()
60
+ }
61
+
62
+ /** 发送消息(自动分段) */
36
63
  async send(text, options = {}) {
37
- const body = JSON.stringify({
64
+ const MAX_LEN = 4000
65
+ const parts = this._splitMessage(text, MAX_LEN)
66
+ const results = []
67
+
68
+ for (let i = 0; i < parts.length; i++) {
69
+ const part = parts[i]
70
+ const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
71
+ const body = header + part
72
+
73
+ await this._rateLimit()
74
+ const result = await this._sendSingle(body, options)
75
+ results.push(result)
76
+
77
+ // 分段之间稍作延迟
78
+ if (i < parts.length - 1) {
79
+ await new Promise(r => setTimeout(r, 200))
80
+ }
81
+ }
82
+ return results[0] || {}
83
+ }
84
+
85
+ /** 单条发送 */
86
+ async _sendSingle(text, options = {}) {
87
+ const { parseMode, silent, replyTo, disableWebPreview, keyboard } = options
88
+ const useMarkdown = parseMode === 'Markdown' || parseMode === 'MarkdownV2' || !parseMode
89
+
90
+ const body = {
38
91
  chat_id: this.chatId,
39
- text,
40
- parse_mode: options.parseMode || 'Markdown',
41
- disable_notification: options.silent || false,
42
- })
43
- const r = await fetch(this.url, {
44
- method: this.method,
45
- headers: { 'Content-Type': 'application/json', ...this.headers },
46
- body,
92
+ text: text.slice(0, 4096),
93
+ parse_mode: 'HTML', // HTML Markdown 更稳定
94
+ disable_notification: silent || false,
95
+ disable_web_page_preview: disableWebPreview ?? true,
96
+ }
97
+
98
+ if (replyTo) body.reply_parameters = { message_id: replyTo }
99
+ if (keyboard) body.reply_markup = JSON.stringify(keyboard)
100
+
101
+ // Telegram HTML 安全编码(只保留基本标签)
102
+ body.text = this._safeHTML(body.text)
103
+
104
+ const r = await this._fetch(`${this.apiBase}/sendMessage`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify(body),
47
108
  })
48
- if (!r.ok) {
49
- const errText = await r.text()
50
- throw new Error(`Telegram API error ${r.status}: ${errText.slice(0, 200)}`)
109
+
110
+ const data = await r.json()
111
+ if (!data.ok) {
112
+ // 429 — 速率限制,自动等待
113
+ if (data.error_code === 429) {
114
+ const retryAfter = data.parameters?.retry_after || 3
115
+ await new Promise(r => setTimeout(r, retryAfter * 1000))
116
+ return this._sendSingle(text, { ...options, parseMode: undefined })
117
+ }
118
+ // 400 格式错 — 降级纯文本
119
+ if (data.error_code === 400) {
120
+ return this._sendSingle(text, { ...options, parseMode: 'text' })
121
+ }
122
+ throw new Error(`Telegram error ${data.error_code}: ${(data.description || '').slice(0, 200)}`)
51
123
  }
124
+ return data.result
125
+ }
126
+
127
+ /** 安全 HTML(只允许 Telegram 支持的基本标签) */
128
+ _safeHTML(text) {
129
+ // Telegram HTML 只支持: <b>, <i>, <u>, <s>, <code>, <pre>, <a href="">
130
+ // 替换不支持的标签
131
+ return text
132
+ .replace(/<h[1-6][^>]*>/gi, '<b>')
133
+ .replace(/<\/h[1-6]>/gi, '</b>')
134
+ .replace(/<br\s*\/?>/gi, '\n')
135
+ .replace(/<p[^>]*>/gi, '')
136
+ .replace(/<\/p>/gi, '\n\n')
137
+ .replace(/<strong>/gi, '<b>')
138
+ .replace(/<\/strong>/gi, '</b>')
139
+ .replace(/<em>/gi, '<i>')
140
+ .replace(/<\/em>/gi, '</i>')
141
+ .replace(/<hr[^>]*>/gi, '\n────────────\n')
142
+ .replace(/<img[^>]*>/gi, '[图片]')
143
+ .replace(/<[^>]+>/g, (tag) => {
144
+ const allowed = ['<b>', '</b>', '<i>', '</i>', '<u>', '</u>', '<s>', '</s>', '<code>', '</code>', '<pre>', '</pre>']
145
+ if (allowed.includes(tag.toLowerCase())) return tag
146
+ if (tag.toLowerCase().startsWith('<a ')) return tag
147
+ if (tag === '</a>') return tag
148
+ return ''
149
+ })
150
+ }
151
+
152
+ /** 分片消息 */
153
+ _splitMessage(text, maxLen) {
154
+ if (!text || text.length <= maxLen) return [text || '']
155
+ const parts = []
156
+ let current = ''
157
+ for (const line of text.split('\n')) {
158
+ if (current.length + line.length + 1 > maxLen) {
159
+ parts.push(current)
160
+ current = line
161
+ } else {
162
+ current += (current ? '\n' : '') + line
163
+ }
164
+ }
165
+ if (current) parts.push(current)
166
+ return parts
167
+ }
168
+
169
+ /** 编辑消息 */
170
+ async edit(messageId, text, options = {}) {
171
+ await this._rateLimit()
172
+ const r = await this._fetch(`${this.apiBase}/editMessageText`, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({
176
+ chat_id: this.chatId,
177
+ message_id: messageId,
178
+ text: this._safeHTML(text).slice(0, 4096),
179
+ parse_mode: 'HTML',
180
+ }),
181
+ })
52
182
  return r.json()
53
183
  }
184
+
185
+ /** 删除消息 */
186
+ async delete(messageId) {
187
+ await this._rateLimit()
188
+ const r = await this._fetch(`${this.apiBase}/deleteMessage`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify({ chat_id: this.chatId, message_id: messageId }),
192
+ })
193
+ return r.ok
194
+ }
195
+ }
196
+
197
+ // ============================================================
198
+ // QQ Bot 通道适配器 v2.0
199
+ // ============================================================
200
+
201
+ /**
202
+ * QQ Bot 通道适配器 — 独立、零依赖
203
+ *
204
+ * 使用 QQ Bot API v2,仅需 appId + clientSecret
205
+ */
206
+ class QQBotChannel {
207
+ constructor(config = {}) {
208
+ this.appId = config.appId || process.env.CC_NODE_CHANNEL_QQBOT_APPID || ''
209
+ this.secret = config.secret || config.clientSecret || process.env.CC_NODE_CHANNEL_QQBOT_SECRET || ''
210
+ this._token = null
211
+ this._tokenCache = { token: null, expireAt: 0 }
212
+ this.channelId = config.channelId || ''
213
+ this.groupOpenId = config.groupOpenId || ''
214
+ }
215
+
216
+ get name() { return 'qqbot' }
217
+
218
+ async _getToken() {
219
+ if (this._tokenCache.token && Date.now() < this._tokenCache.expireAt - 300000) {
220
+ return this._tokenCache.token
221
+ }
222
+ const res = await fetch('https://bots.qq.com/app/getAppAccessToken', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ appId: this.appId, clientSecret: this.secret }),
226
+ })
227
+ if (!res.ok) throw new Error('QQ Token API ' + res.status)
228
+ const data = JSON.parse(await res.text())
229
+ if (!data.access_token) throw new Error('Token API no access_token')
230
+ this._tokenCache.token = data.access_token
231
+ this._tokenCache.expireAt = Date.now() + (data.expires_in || 7200) * 1000
232
+ return data.access_token
233
+ }
234
+
235
+ async send(text, options = {}) {
236
+ if (!this.appId || !this.secret) {
237
+ return [{ channel: 'qqbot', ok: false, error: '需要 appId 和 clientSecret' }]
238
+ }
239
+ try {
240
+ const token = await this._getToken()
241
+ const results = []
242
+
243
+ const scope = options.scope || (this.groupOpenId ? 'group' : '')
244
+ const targetId = options.targetId || this.groupOpenId || this.channelId
245
+
246
+ if (!scope || !targetId) {
247
+ return [{ channel: 'qqbot', ok: false, error: '未配置目标 (groupOpenId)' }]
248
+ }
249
+
250
+ const path = scope === 'group'
251
+ ? '/v2/groups/' + targetId + '/messages'
252
+ : '/v2/users/' + targetId + '/messages'
253
+
254
+ const body = { content: text.slice(0, 2000), msg_type: 0 }
255
+ if (options.replyMsgId) body.msg_id = options.replyMsgId
256
+
257
+ const r = await fetch('https://api.sgroup.qq.com' + path, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json', Authorization: 'QQBot ' + token },
260
+ body: JSON.stringify(body),
261
+ })
262
+ if (!r.ok) {
263
+ const errText = await r.text().catch(() => '')
264
+ throw new Error('HTTP ' + r.status + ': ' + errText.slice(0, 100))
265
+ }
266
+ results.push({ channel: 'qqbot', ok: true })
267
+ return results
268
+ } catch (e) {
269
+ return [{ channel: 'qqbot', ok: false, error: e.message.slice(0, 200) }]
270
+ }
271
+ }
54
272
  }
55
273
 
274
+ // ============================================================
275
+ // 已有适配器(保持兼容)
276
+ // ============================================================
277
+
56
278
  /** 企业微信 Webhook 适配器 */
57
279
  class WeComChannel {
58
280
  constructor({ webhookUrl }) {
@@ -64,7 +286,7 @@ class WeComChannel {
64
286
  async send(text, options = {}) {
65
287
  const body = JSON.stringify({
66
288
  msgtype: 'text',
67
- text: { content: text },
289
+ text: { content: text.slice(0, 2048) },
68
290
  })
69
291
  const r = await fetch(this.webhookUrl, {
70
292
  method: 'POST',
@@ -90,7 +312,7 @@ class FeishuChannel {
90
312
  async send(text, options = {}) {
91
313
  const body = JSON.stringify({
92
314
  msg_type: 'text',
93
- content: { text },
315
+ content: { text: text.slice(0, 4096) },
94
316
  })
95
317
  const r = await fetch(this.webhookUrl, {
96
318
  method: 'POST',
@@ -114,7 +336,7 @@ class DiscordChannel {
114
336
  get name() { return 'discord' }
115
337
 
116
338
  async send(text, options = {}) {
117
- const body = JSON.stringify({ content: text })
339
+ const body = JSON.stringify({ content: text.slice(0, 2000) })
118
340
  const r = await fetch(this.webhookUrl, {
119
341
  method: 'POST',
120
342
  headers: { 'Content-Type': 'application/json' },
@@ -137,7 +359,7 @@ class SlackChannel {
137
359
  get name() { return 'slack' }
138
360
 
139
361
  async send(text, options = {}) {
140
- const body = JSON.stringify({ text })
362
+ const body = JSON.stringify({ text: text.slice(0, 3000) })
141
363
  const r = await fetch(this.webhookUrl, {
142
364
  method: 'POST',
143
365
  headers: { 'Content-Type': 'application/json' },
@@ -185,6 +407,7 @@ class WebhookChannel {
185
407
 
186
408
  const CHANNEL_ADAPTERS = {
187
409
  telegram: TelegramChannel,
410
+ qqbot: QQBotChannel,
188
411
  wecom: WeComChannel,
189
412
  feishu: FeishuChannel,
190
413
  discord: DiscordChannel,
@@ -195,10 +418,16 @@ const CHANNEL_ADAPTERS = {
195
418
  const ENV_PREFIX = 'CC_NODE_CHANNEL_'
196
419
 
197
420
  export class ChannelManager {
198
- constructor(config = {}) {
421
+ constructor(config = {}, defaultChannel) {
199
422
  this.channels = new Map()
200
- this.defaultChannel = config.defaultChannel || null
201
- this._loadFromConfig(config)
423
+ this.defaultChannel = defaultChannel || config.defaultChannel || null
424
+ if (config.channels) {
425
+ this._loadFromConfig(config)
426
+ }
427
+ // If config is array-like or flat, handle old format
428
+ if (typeof config === 'object' && !config.channels && Object.keys(config).length > 0) {
429
+ this._loadFromConfig({ channels: config })
430
+ }
202
431
  this._loadFromEnv()
203
432
  }
204
433
 
@@ -209,9 +438,6 @@ export class ChannelManager {
209
438
  if (chConfig.enabled === false) continue
210
439
  this._registerChannel(name, chConfig)
211
440
  }
212
- if (config.defaultChannel) {
213
- this.defaultChannel = config.defaultChannel
214
- }
215
441
  }
216
442
 
217
443
  /** 从环境变量加载通道 */
@@ -221,12 +447,13 @@ export class ChannelManager {
221
447
  if (!key.startsWith(ENV_PREFIX)) continue
222
448
  const rest = key.slice(ENV_PREFIX.length)
223
449
  if (rest === 'DEFAULT') {
224
- this.defaultChannel = value.toLowerCase()
450
+ if (!this.defaultChannel) this.defaultChannel = value.toLowerCase()
225
451
  continue
226
452
  }
227
453
  const parts = rest.split('_')
228
454
  const channelType = parts[0].toLowerCase()
229
455
  const param = parts.slice(1).join('_').toLowerCase()
456
+
230
457
  if (!envChannels[channelType]) envChannels[channelType] = { type: channelType }
231
458
  const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
232
459
  envChannels[channelType][camelKey] = value
@@ -268,6 +495,21 @@ export class ChannelManager {
268
495
  for (const name of targetChannels) {
269
496
  const ch = this.channels.get(name)
270
497
  if (!ch) {
498
+ // 不存在的通道尝试按 type 注册
499
+ const Adapter = CHANNEL_ADAPTERS[name]
500
+ if (Adapter) {
501
+ this._registerChannel(name, { type: name })
502
+ const ch2 = this.channels.get(name)
503
+ if (ch2) {
504
+ try {
505
+ const result = await ch2.send(text, options)
506
+ results.push({ channel: name, ok: true, result })
507
+ } catch (e) {
508
+ results.push({ channel: name, ok: false, error: e.message })
509
+ }
510
+ continue
511
+ }
512
+ }
271
513
  results.push({ channel: name, ok: false, error: 'not registered' })
272
514
  continue
273
515
  }
@@ -289,8 +531,20 @@ export class ChannelManager {
289
531
  'question': `❓ 需要确认\n${data.question || ''}\n${data.options ? '选项:' + data.options.join(' / ') : ''}`,
290
532
  'progress': `🔄 进度更新\n${data.task || ''}\n${data.progress || ''}${data.percent ? ' (' + data.percent + '%)' : ''}`,
291
533
  'warning': `⚠️ 警告\n${data.message || ''}`,
534
+ 'debug': `🔍 调试信息\n${data.info || ''}`,
292
535
  }
293
536
  const text = templates[template] || `📢 ${data.message || ''}`
294
537
  return this.send(text, options)
295
538
  }
296
539
  }
540
+
541
+ // 导出适配器类(供外部使用)
542
+ export {
543
+ TelegramChannel,
544
+ QQBotChannel,
545
+ WeComChannel,
546
+ FeishuChannel,
547
+ DiscordChannel,
548
+ SlackChannel,
549
+ WebhookChannel,
550
+ }