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