@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.
@@ -0,0 +1,614 @@
1
+ /**
2
+ * Telegram Bot 长轮询监听器 — 增强版 v2.0
3
+ *
4
+ * 支持:
5
+ * - 长轮询消息接收
6
+ * - MarkdownV2 安全编码
7
+ * - 速率限制 (30 msg/s 单聊, 20 msg/min 群组)
8
+ * - 指数退避重连
9
+ * - 回复/内联键盘
10
+ * - 多轮对话状态
11
+ * - 命令解析
12
+ * - 文件/图片接收
13
+ */
14
+
15
+ // ============================================================
16
+ // MarkdownV2 安全编码
17
+ // ============================================================
18
+
19
+ const TG_MD_ESCAPE_CHARS = /[_*[\]()~`>#+\-=|{}.!]/g
20
+ const TG_CODE_ESCAPE_CHARS = /[`\\]/g
21
+ const TG_LINK_ESCAPE_CHARS = /[()]/g
22
+
23
+ /**
24
+ * 对文本进行 Telegram MarkdownV2 安全转义
25
+ * Telegram 的 MarkdownV2 非常严格,特殊字符必须用 \ 转义
26
+ */
27
+ function escapeMarkdownV2(text, { code = false, link = false } = {}) {
28
+ if (code) return text.replace(TG_CODE_ESCAPE_CHARS, '\\$&')
29
+ if (link) return text.replace(TG_LINK_ESCAPE_CHARS, '\\$&')
30
+ return text.replace(TG_MD_ESCAPE_CHARS, '\\$&')
31
+ }
32
+
33
+ /**
34
+ * 安全发送 Markdown 文本(自动过滤不安全的字符)
35
+ * Telegram 某些场景下 markdown 解析失败会静默返回空
36
+ */
37
+ function safeMarkdown(text) {
38
+ // 如果包含复杂的 markdown,用 MarkdownV2 并转义文本部分
39
+ // 简单策略:用 HTML parse_mode 更安全
40
+ return text
41
+ }
42
+
43
+ const API_BASE = (token) => `https://api.telegram.org/bot${token}`
44
+
45
+ // ============================================================
46
+ // 速率限制器
47
+ // ============================================================
48
+
49
+ class RateLimiter {
50
+ constructor(maxPerSec = 30, maxPerMinPerChat = 20) {
51
+ this.maxPerSec = maxPerSec
52
+ this.maxPerMinPerChat = maxPerMinPerChat
53
+ this._calls = [] // [{ time, chatId }]
54
+ }
55
+
56
+ /** 检查是否可以发送 */
57
+ canSend(chatId) {
58
+ const now = Date.now()
59
+ // 清理过期记录
60
+ this._calls = this._calls.filter(c => now - c.time < 60000)
61
+
62
+ // 每秒限制
63
+ const lastSec = this._calls.filter(c => now - c.time < 1000)
64
+ if (lastSec.length >= this.maxPerSec) return false
65
+
66
+ // 每聊天每分钟限制
67
+ const perChat = this._calls.filter(c => c.chatId === chatId && now - c.time < 60000)
68
+ if (perChat.length >= this.maxPerMinPerChat) return false
69
+
70
+ return true
71
+ }
72
+
73
+ /** 记录一次调用 */
74
+ record(chatId) {
75
+ this._calls.push({ time: Date.now(), chatId })
76
+ }
77
+
78
+ /** 等待直到可以发送 */
79
+ async waitForSlot(chatId, timeoutMs = 30000) {
80
+ const start = Date.now()
81
+ while (!this.canSend(chatId)) {
82
+ if (Date.now() - start > timeoutMs) return false
83
+ await new Promise(r => setTimeout(r, 200))
84
+ }
85
+ this.record(chatId)
86
+ return true
87
+ }
88
+ }
89
+
90
+ // ============================================================
91
+ // Telegram Bot 客户端
92
+ // ============================================================
93
+
94
+ class TelegramBotClient {
95
+ constructor(token, opts = {}) {
96
+ this.token = token
97
+ this.apiBase = opts.apiBase || API_BASE(token)
98
+ this.proxyAddr = opts.proxy || '' // SOCKS5 代理地址, 如 "127.0.0.1:1080" 或 "socks5://user:pass@host:port"
99
+ this.rateLimiter = new RateLimiter()
100
+ }
101
+
102
+ /** 带代理支持的 fetch */
103
+ async _fetch(url, options = {}) {
104
+ if (!this.proxyAddr) {
105
+ return fetch(url, options)
106
+ }
107
+ const { fetchViaSocks5 } = await import('./tg-proxy.js')
108
+ return fetchViaSocks5(url, options, this.proxyAddr)
109
+ }
110
+
111
+ /** 发送消息(带自动重试和速率限制) */
112
+ async sendMessage(chatId, text, options = {}) {
113
+ const { parseMode, replyTo, silent, disableWebPreview, keyboard } = options
114
+
115
+ // 等待速率限制
116
+ await this.rateLimiter.waitForSlot(chatId)
117
+
118
+ const body = {
119
+ chat_id: chatId,
120
+ text: text.slice(0, 4096), // Telegram 消息最大 4096 字符
121
+ parse_mode: parseMode || 'HTML',
122
+ disable_notification: silent || false,
123
+ disable_web_page_preview: disableWebPreview ?? true,
124
+ }
125
+ if (replyTo) body.reply_parameters = { message_id: replyTo }
126
+ if (keyboard) body.reply_markup = JSON.stringify(keyboard)
127
+
128
+ const res = await this._fetch(`${this.apiBase}/sendMessage`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify(body),
132
+ })
133
+
134
+ const data = await res.json()
135
+ if (!data.ok) {
136
+ // 429 速率限制 — 自动等待后重试
137
+ if (data.error_code === 429) {
138
+ const retryAfter = data.parameters?.retry_after || 5
139
+ await new Promise(r => setTimeout(r, retryAfter * 1000))
140
+ return this.sendMessage(chatId, text, options)
141
+ }
142
+ // 400 可能是消息太长或格式问题 — 降级为纯文本
143
+ if (data.error_code === 400 && parseMode) {
144
+ return this.sendMessage(chatId, text, { ...options, parseMode: undefined })
145
+ }
146
+ throw new Error(`Telegram API ${data.error_code}: ${data.description?.slice(0, 200) || 'unknown'}`)
147
+ }
148
+ return data.result
149
+ }
150
+
151
+ /** 编辑消息 */
152
+ async editMessage(chatId, messageId, text, options = {}) {
153
+ const { parseMode } = options
154
+ const body = {
155
+ chat_id: chatId,
156
+ message_id: messageId,
157
+ text: text.slice(0, 4096),
158
+ parse_mode: parseMode || 'HTML',
159
+ }
160
+ const res = await this._fetch(`${this.apiBase}/editMessageText`, {
161
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
162
+ })
163
+ const data = await res.json()
164
+ if (!data.ok && data.error_code !== 400) throw new Error(`TG edit error: ${data.description}`)
165
+ return data.result
166
+ }
167
+
168
+ /** 删除消息 */
169
+ async deleteMessage(chatId, messageId) {
170
+ const res = await this._fetch(`${this.apiBase}/deleteMessage`, {
171
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId }),
173
+ })
174
+ return res.ok
175
+ }
176
+
177
+ /** 发送聊天动作(typing/upload_photo 等) */
178
+ async sendChatAction(chatId, action = 'typing') {
179
+ try {
180
+ await this._fetch(`${this.apiBase}/sendChatAction`, {
181
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ chat_id: chatId, action }),
183
+ })
184
+ } catch {}
185
+ }
186
+
187
+ /** 获取文件下载链接 */
188
+ async getFile(fileId) {
189
+ const res = await this._fetch(`${this.apiBase}/getFile`, {
190
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify({ file_id: fileId }),
192
+ })
193
+ const data = await res.json()
194
+ if (!data.ok) throw new Error(`TG getFile error: ${data.description}`)
195
+ return `https://api.telegram.org/file/bot${this.token}/${data.result.file_path}`
196
+ }
197
+
198
+ /** 设置机器人命令菜单 */
199
+ async setMyCommands(commands) {
200
+ await this._fetch(`${this.apiBase}/setMyCommands`, {
201
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ commands }),
203
+ })
204
+ }
205
+ }
206
+
207
+ // ============================================================
208
+ // 对话状态管理 — 支持多轮交互
209
+ // ============================================================
210
+
211
+ class ConversationState {
212
+ constructor() {
213
+ // chatId -> { state, data, context, lastActivity }
214
+ this._states = new Map()
215
+ this._timeout = 30 * 60 * 1000 // 30分钟无活动自动清理
216
+ this._cleanupTimer = setInterval(() => this._cleanup(), 5 * 60 * 1000)
217
+ }
218
+
219
+ get(chatId) {
220
+ return this._states.get(chatId)
221
+ }
222
+
223
+ set(chatId, state, data = {}) {
224
+ this._states.set(chatId, { state, data, lastActivity: Date.now() })
225
+ }
226
+
227
+ update(chatId, updates) {
228
+ const existing = this._states.get(chatId)
229
+ if (existing) {
230
+ Object.assign(existing.data, updates)
231
+ existing.lastActivity = Date.now()
232
+ }
233
+ }
234
+
235
+ delete(chatId) {
236
+ this._states.delete(chatId)
237
+ }
238
+
239
+ touch(chatId) {
240
+ const s = this._states.get(chatId)
241
+ if (s) s.lastActivity = Date.now()
242
+ }
243
+
244
+ _cleanup() {
245
+ const now = Date.now()
246
+ for (const [chatId, s] of this._states.entries()) {
247
+ if (now - s.lastActivity > this._timeout) {
248
+ this._states.delete(chatId)
249
+ }
250
+ }
251
+ }
252
+
253
+ destroy() {
254
+ clearInterval(this._cleanupTimer)
255
+ this._states.clear()
256
+ }
257
+ }
258
+
259
+ // ============================================================
260
+ // Telegram 监听器
261
+ // ============================================================
262
+
263
+ export class TelegramListener {
264
+ constructor(config) {
265
+ this.config = config
266
+ const ch = config.channels?.telegram || {}
267
+ this.token = ch.token
268
+ this.proxyAddr = ch.proxy || process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || ''
269
+ this.apiBase = ch.apiBase || ''
270
+ this.bot = this.token ? new TelegramBotClient(this.token, { proxy: this.proxyAddr, apiBase: this.apiBase }) : null
271
+ this.lastUpdateId = 0
272
+ this.running = false
273
+ this._pollTimer = null
274
+ this._retryDelay = 1000
275
+ this.maxRetryDelay = 30000
276
+ this.conversations = new ConversationState()
277
+ this._onMessage = null
278
+ this._handlers = {}
279
+ }
280
+
281
+ /** 注册消息处理器 */
282
+ on(event, handler) {
283
+ this._handlers[event] = handler
284
+ }
285
+
286
+ /** 带代理的 fetch(供类内部使用) */
287
+ async _fetch(url, options = {}) {
288
+ if (!this.proxyAddr) return fetch(url, options)
289
+ const { fetchViaSocks5 } = await import('./tg-proxy.js')
290
+ return fetchViaSocks5(url, options, this.proxyAddr)
291
+ }
292
+
293
+ /** 启动监听 */
294
+ async start(onMessage) {
295
+ if (!this.bot) {
296
+ log('[TG] No token configured, skipping')
297
+ return
298
+ }
299
+ this._onMessage = onMessage
300
+ this.running = true
301
+ log(`[TG] Starting long polling...`)
302
+
303
+ // 设置命令菜单
304
+ try {
305
+ await this.bot.setMyCommands([
306
+ { command: 'ping', description: '🏓 检查服务状态' },
307
+ { command: 'status', description: '📊 查看 cc-node 状态' },
308
+ { command: 'run', description: '💻 执行 shell 命令(如 /run ls -la)' },
309
+ { command: 'notify', description: '📢 广播通知消息' },
310
+ { command: 'help', description: '❓ 查看帮助' },
311
+ { command: 'cancel', description: '🚫 取消当前操作' },
312
+ ])
313
+ } catch {}
314
+
315
+ this._poll()
316
+ }
317
+
318
+ /** 内部轮询 */
319
+ async _poll() {
320
+ while (this.running) {
321
+ try {
322
+ const url = `${this.bot.apiBase}/getUpdates`
323
+ const res = await this._fetch(url, {
324
+ method: 'POST',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({
327
+ offset: this.lastUpdateId + 1,
328
+ timeout: 30,
329
+ allowed_updates: ['message', 'callback_query', 'edited_message'],
330
+ }),
331
+ })
332
+
333
+ if (!res.ok) {
334
+ throw new Error(`HTTP ${res.status}`)
335
+ }
336
+
337
+ const data = await res.json()
338
+ if (!data.ok) {
339
+ throw new Error(`API error: ${data.description}`)
340
+ }
341
+
342
+ if (data.result?.length) {
343
+ for (const update of data.result) {
344
+ this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id)
345
+
346
+ // 处理回调查询(内联键盘按钮)
347
+ if (update.callback_query) {
348
+ await this._handleCallbackQuery(update.callback_query)
349
+ continue
350
+ }
351
+
352
+ // 处理消息
353
+ if (update.message) {
354
+ await this._handleMessage(update.message)
355
+ }
356
+ }
357
+ }
358
+
359
+ // 成功 — 重置退避
360
+ this._retryDelay = 1000
361
+
362
+ } catch (e) {
363
+ log(`[TG] Poll error: ${e.message} (retry in ${this._retryDelay}ms)`)
364
+ await this._sleep(this._retryDelay)
365
+ this._retryDelay = Math.min(this._retryDelay * 2, this.maxRetryDelay)
366
+ }
367
+ }
368
+ }
369
+
370
+ /** 处理消息 */
371
+ async _handleMessage(msg) {
372
+ const chatId = msg.chat?.id
373
+ if (!chatId) return
374
+
375
+ const chatType = msg.chat?.type || 'private' // private, group, supergroup
376
+ const fromName = msg.from?.username || msg.from?.first_name || '?'
377
+
378
+ // 提取消息文本 / 文件 / 图片
379
+ let text = msg.text || msg.caption || ''
380
+ let files = []
381
+
382
+ // 图片
383
+ if (msg.photo?.length) {
384
+ const best = msg.photo.reduce((a, b) => (a.width > b.width ? a : b))
385
+ try {
386
+ const fileUrl = await this.bot.getFile(best.file_id)
387
+ files.push({ type: 'photo', url: fileUrl })
388
+ } catch {}
389
+ }
390
+
391
+ // 文档
392
+ if (msg.document) {
393
+ try {
394
+ const fileUrl = await this.bot.getFile(msg.document.file_id)
395
+ files.push({ type: 'document', url: fileUrl, name: msg.document.file_name })
396
+ } catch {}
397
+ }
398
+
399
+ log(`[TG] ← ${fromName} (${chatType}): ${text.slice(0, 60) || '(media)'}`)
400
+
401
+ // 处理命令
402
+ if (text.startsWith('/')) {
403
+ const reply = await this._handleCommand(chatId, text, msg)
404
+ if (reply) {
405
+ // 如果回复很长,分多条发送
406
+ await this._sendLongMessage(chatId, reply, { replyTo: msg.message_id })
407
+ }
408
+ return
409
+ }
410
+
411
+ // 处理普通消息 — 转发给 cc-node
412
+ if (this._onMessage) {
413
+ // 发送 typing 提示
414
+ this.bot.sendChatAction(chatId).catch(() => {})
415
+
416
+ try {
417
+ await this._onMessage({
418
+ text,
419
+ chatId,
420
+ from: fromName,
421
+ channel: 'telegram',
422
+ files,
423
+ replyTo: msg.message_id,
424
+ messageId: msg.message_id,
425
+ chatType,
426
+ })
427
+ } catch (e) {
428
+ log(`[TG] Message handler error: ${e.message}`)
429
+ }
430
+ }
431
+ }
432
+
433
+ /** 处理回调查询(按钮点击) */
434
+ async _handleCallbackQuery(cb) {
435
+ const chatId = cb.message?.chat?.id
436
+ const msgId = cb.message?.message_id
437
+ const data = cb.data || ''
438
+
439
+ log(`[TG] callback: ${data}`)
440
+
441
+ // 确认收到回调(去除loading状态)
442
+ try {
443
+ await this._fetch(`${this.bot.apiBase}/answerCallbackQuery`, {
444
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
445
+ body: JSON.stringify({ callback_query_id: cb.id }),
446
+ })
447
+ } catch {}
448
+
449
+ if (this._onMessage && data) {
450
+ this._onMessage({
451
+ text: data,
452
+ chatId,
453
+ from: cb.from?.username || '?',
454
+ channel: 'telegram_callback',
455
+ callbackData: data,
456
+ replyTo: msgId,
457
+ }).catch(e => log(`[TG] callback handler error: ${e.message}`))
458
+ }
459
+ }
460
+
461
+ /** 命令处理 */
462
+ async _handleCommand(chatId, text, msg) {
463
+ const parts = text.split(/\s+/)
464
+ const cmd = parts[0].toLowerCase()
465
+ const args = parts.slice(1).join(' ')
466
+
467
+ switch (cmd) {
468
+ case '/start':
469
+ case '/help':
470
+ return this._helpText()
471
+
472
+ case '/ping':
473
+ return '🏓 pong! cc-notify is alive.'
474
+
475
+ case '/status': {
476
+ const nodeInfo = await this._findCcNode()
477
+ const chNames = Object.keys(this.config.channels || {})
478
+ return [
479
+ '📊 *cc-notify 状态*',
480
+ '',
481
+ `• 运行时间: ${Math.floor(process.uptime())}s`,
482
+ `• 通道: ${chNames.join(', ') || '无'}`,
483
+ `• cc-node: ${nodeInfo.running ? '✅ 运行中' : '❌ 未运行'}`,
484
+ `• PID: ${process.pid}`,
485
+ ].join('\n')
486
+ }
487
+
488
+ case '/run': {
489
+ if (!args) return '⚠️ 用法: /run <shell命令>\n例如: /run ls -la\n或者发普通消息让 AI 处理'
490
+ // 发送 typing 提示
491
+ this.bot.sendChatAction(chatId).catch(() => {})
492
+ // 直接执行命令(不经过 AI)
493
+ try {
494
+ const result = await this._execCommand(args)
495
+ const output = result.slice(0, 3500)
496
+ return `💻 $ ${escapeMarkdownV2(args)}\n\`\`\`\n${escapeMarkdownV2(output)}\n\`\`\``
497
+ } catch (e) {
498
+ return `❌ 命令执行失败:\n${escapeMarkdownV2(e.message)}`
499
+ }
500
+ }
501
+
502
+ case '/notify': {
503
+ if (!args) return '⚠️ 用法: /notify <消息内容>'
504
+ try {
505
+ const { sendToChannel, ChannelManager } = await import('./index.js')
506
+ const cm = new ChannelManager(this.config.channels || {}, this.config.defaultChannel)
507
+ const results = await cm.send(args)
508
+ const lines = results.map(r => r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`)
509
+ return lines.join('\n')
510
+ } catch (e) {
511
+ return `❌ 通知失败: ${e.message}`
512
+ }
513
+ }
514
+
515
+ case '/cancel':
516
+ this.conversations.delete(chatId)
517
+ return '🚫 已取消当前操作'
518
+
519
+ default:
520
+ // 未知命令 — 当作编程请求发给 cc-node
521
+ return null // 由调用方处理
522
+ }
523
+ }
524
+
525
+ /** 生成帮助文本 */
526
+ _helpText() {
527
+ return [
528
+ '🤖 *cc-notify — AI Code Agent*',
529
+ '',
530
+ '通过 Telegram 远程操控 AI 编程助手。',
531
+ '',
532
+ '*命令*',
533
+ '• `/ping` — 检查服务状态',
534
+ '• `/status` — 查看详细状态',
535
+ '• `/run <cmd>` — 直接执行 shell 命令',
536
+ '• `/notify <msg>` — 广播通知到所有通道',
537
+ '• `/cancel` — 取消当前操作',
538
+ '• `/help` — 显示帮助',
539
+ '',
540
+ '*普通消息*',
541
+ '直接发送文字消息 → 自动发给 AI 处理',
542
+ '支持发送图片(AI 无法看图,但会作为附件)',
543
+ '',
544
+ ].join('\n')
545
+ }
546
+
547
+ /** 长消息分段发送 */
548
+ async _sendLongMessage(chatId, text, options = {}) {
549
+ const MAX_LEN = 4000
550
+ if (text.length <= MAX_LEN) {
551
+ return this.bot.sendMessage(chatId, text, { parseMode: 'Markdown', ...options })
552
+ }
553
+
554
+ // 分段发送
555
+ const parts = []
556
+ let current = ''
557
+ for (const line of text.split('\n')) {
558
+ if (current.length + line.length + 1 > MAX_LEN) {
559
+ parts.push(current)
560
+ current = line
561
+ } else {
562
+ current += (current ? '\n' : '') + line
563
+ }
564
+ }
565
+ if (current) parts.push(current)
566
+
567
+ for (let i = 0; i < parts.length; i++) {
568
+ const part = parts[i]
569
+ const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
570
+ await this.bot.sendMessage(chatId, header + part, { parseMode: 'Markdown' })
571
+ }
572
+ }
573
+
574
+ /** 执行 shell 命令 */
575
+ async _execCommand(cmd) {
576
+ const { execSync } = await import('child_process')
577
+ return execSync(cmd, { timeout: 30000, encoding: 'utf8', maxBuffer: 1024 * 1024 })
578
+ }
579
+
580
+ /** 查找 cc-node 进程 */
581
+ async _findCcNode() {
582
+ const { existsSync, readFileSync } = await import('fs')
583
+ const { join } = await import('path')
584
+ const { homedir } = await import('os')
585
+ const pidFile = join(homedir(), '.cc-node', 'cc-node.pid')
586
+ if (existsSync(pidFile)) {
587
+ try {
588
+ const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10)
589
+ process.kill(pid, 0)
590
+ return { running: true, pid }
591
+ } catch {}
592
+ }
593
+ return { running: false }
594
+ }
595
+
596
+ /** 停止监听 */
597
+ stop() {
598
+ this.running = false
599
+ this.conversations.destroy()
600
+ log('[TG] Listener stopped')
601
+ }
602
+
603
+ _sleep(ms) {
604
+ return new Promise(r => setTimeout(r, ms))
605
+ }
606
+ }
607
+
608
+ // ============================================================
609
+ // 日志
610
+ // ============================================================
611
+ function log(msg) {
612
+ const ts = new Date().toISOString().slice(11, 19)
613
+ process.stdout.write(`[${ts}] ${msg}\n`)
614
+ }