@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
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 监听器 + 发送器 — 独立、零依赖
|
|
3
|
+
*
|
|
4
|
+
* 基于 QQ Bot API v2
|
|
5
|
+
* 认证: appId + clientSecret → access_token (自动续期)
|
|
6
|
+
* 发送: /v2/users/{openid}/messages (C2C) | /v2/groups/{group_openid}/messages (群)
|
|
7
|
+
* 接收: WebSocket (wss://api.sgroup.qq.com/websocket/)
|
|
8
|
+
*
|
|
9
|
+
* 参考: qqbot-standalone (https://github.com/bg1avd/qqbot-standalone)
|
|
10
|
+
*
|
|
11
|
+
* 使用:
|
|
12
|
+
* const bot = new QQBot({ appId, clientSecret })
|
|
13
|
+
* await bot.sendText('group', 'GROUP_OPENID', '你好')
|
|
14
|
+
*
|
|
15
|
+
* bot.onMessage = (msg) => { ... }
|
|
16
|
+
* await bot.listen()
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const API_BASE = 'https://api.sgroup.qq.com'
|
|
20
|
+
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
|
|
21
|
+
|
|
22
|
+
// ── Token 管理 ─────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
class TokenCache {
|
|
25
|
+
constructor() {
|
|
26
|
+
this._token = null
|
|
27
|
+
this._expireAt = 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(appId, clientSecret) {
|
|
31
|
+
if (this._token && Date.now() < this._expireAt - 300_000) return this._token
|
|
32
|
+
|
|
33
|
+
const res = await fetch(TOKEN_URL, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ appId, clientSecret }),
|
|
37
|
+
})
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const t = await res.text().catch(() => '')
|
|
40
|
+
throw new Error(`Token API ${res.status}: ${t.slice(0, 200)}`)
|
|
41
|
+
}
|
|
42
|
+
const data = JSON.parse(await res.text())
|
|
43
|
+
if (!data.access_token) throw new Error('Token API 未返回 access_token')
|
|
44
|
+
|
|
45
|
+
this._token = data.access_token
|
|
46
|
+
this._expireAt = Date.now() + (data.expires_in || 7200) * 1000
|
|
47
|
+
return this._token
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear() {
|
|
51
|
+
this._token = null
|
|
52
|
+
this._expireAt = 0
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── API 调用 ────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async function apiCall(token, method, path, body, timeoutMs = 30_000) {
|
|
59
|
+
const ac = new AbortController()
|
|
60
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `QQBot ${token}`,
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body: body && ['POST', 'PUT', 'PATCH'].includes(method) ? JSON.stringify(body) : undefined,
|
|
69
|
+
signal: ac.signal,
|
|
70
|
+
})
|
|
71
|
+
const raw = await res.text()
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
let detail = raw.slice(0, 200)
|
|
74
|
+
try { detail = JSON.parse(raw).message || detail } catch {}
|
|
75
|
+
throw new Error(`API ${method} ${path} → ${res.status}: ${detail}`)
|
|
76
|
+
}
|
|
77
|
+
return raw.trim() ? JSON.parse(raw) : null
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 富媒体上传 ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async function uploadMedia(token, scope, targetId, { fileType, url, base64 }, timeoutMs = 120_000) {
|
|
86
|
+
const path = scope === 'group'
|
|
87
|
+
? `/v2/groups/${targetId}/files`
|
|
88
|
+
: `/v2/users/${targetId}/files`
|
|
89
|
+
const body = { file_type: fileType, srv_send_msg: false }
|
|
90
|
+
if (url) body.url = url
|
|
91
|
+
if (base64) body.file_data = base64
|
|
92
|
+
return apiCall(token, 'POST', path, body, timeoutMs)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── 主类 ────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export class QQBot {
|
|
98
|
+
/**
|
|
99
|
+
* @param {object} opts
|
|
100
|
+
* @param {string} opts.appId — QQ 机器人的 AppID
|
|
101
|
+
* @param {string} opts.clientSecret — QQ 机器人的 AppSecret
|
|
102
|
+
*/
|
|
103
|
+
constructor(opts = {}) {
|
|
104
|
+
this.appId = opts.appId || process.env.CC_NODE_CHANNEL_QQBOT_APPID || ''
|
|
105
|
+
this.clientSecret = opts.clientSecret || process.env.CC_NODE_CHANNEL_QQBOT_SECRET || ''
|
|
106
|
+
if (!this.appId || !this.clientSecret) {
|
|
107
|
+
throw new Error('QQBot: 需要 appId 和 clientSecret (或设置 CC_NODE_CHANNEL_QQBOT_APPID / CC_NODE_CHANNEL_QQBOT_SECRET)')
|
|
108
|
+
}
|
|
109
|
+
this._tokens = new TokenCache()
|
|
110
|
+
this._token = null
|
|
111
|
+
this.onMessage = null // (msg) => void
|
|
112
|
+
this._listening = false
|
|
113
|
+
this._sessionId = null
|
|
114
|
+
this._ws = null
|
|
115
|
+
this._hbTimer = null
|
|
116
|
+
this._seq = 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async _t() {
|
|
120
|
+
if (!this._token) this._token = await this._tokens.get(this.appId, this.clientSecret)
|
|
121
|
+
return this._token
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 刷新 token */
|
|
125
|
+
async refreshToken() {
|
|
126
|
+
this._tokens.clear()
|
|
127
|
+
this._token = await this._tokens.get(this.appId, this.clientSecret)
|
|
128
|
+
return this._token
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 发送消息 ────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 发送文本消息 (API v2)
|
|
135
|
+
*
|
|
136
|
+
* @param {'c2c'|'group'} scope — 'c2c' 单聊, 'group' 群聊
|
|
137
|
+
* @param {string} targetId — openid (c2c) 或 group_openid (group)
|
|
138
|
+
* @param {string} content — 消息文本
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} [opts.msgId] — 被动回复: 原消息 ID
|
|
141
|
+
* @param {string} [opts.eventId] — 被动回复: 事件 ID
|
|
142
|
+
* @param {number} [opts.msgSeq] — 回复序号
|
|
143
|
+
* @returns {{ id: string, timestamp: number }}
|
|
144
|
+
*/
|
|
145
|
+
async sendText(scope, targetId, content, opts = {}) {
|
|
146
|
+
const token = await this._t()
|
|
147
|
+
const path = scope === 'group'
|
|
148
|
+
? `/v2/groups/${targetId}/messages`
|
|
149
|
+
: `/v2/users/${targetId}/messages`
|
|
150
|
+
const body = { content: content.slice(0, 2000), msg_type: 0 }
|
|
151
|
+
if (opts.msgId) body.msg_id = opts.msgId
|
|
152
|
+
if (opts.eventId) body.event_id = opts.eventId
|
|
153
|
+
if (opts.msgSeq) body.msg_seq = opts.msgSeq
|
|
154
|
+
return apiCall(token, 'POST', path, body)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 发送图片
|
|
159
|
+
*
|
|
160
|
+
* @param {'c2c'|'group'} scope
|
|
161
|
+
* @param {string} targetId
|
|
162
|
+
* @param {string} source — URL 或本地路径
|
|
163
|
+
* @param {object} [opts]
|
|
164
|
+
*/
|
|
165
|
+
async sendImage(scope, targetId, source, opts = {}) {
|
|
166
|
+
return this._sendMedia(scope, targetId, 1, source, opts)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 发送文件 (仅 C2C)
|
|
171
|
+
*
|
|
172
|
+
* @param {'c2c'} scope
|
|
173
|
+
* @param {string} targetId
|
|
174
|
+
* @param {string} source — URL 或本地路径
|
|
175
|
+
* @param {object} [opts]
|
|
176
|
+
*/
|
|
177
|
+
async sendFile(scope, targetId, source, opts = {}) {
|
|
178
|
+
return this._sendMedia(scope, targetId, 4, source, opts)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async _sendMedia(scope, targetId, fileType, source, opts = {}) {
|
|
182
|
+
const token = await this._t()
|
|
183
|
+
|
|
184
|
+
let mediaResult
|
|
185
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
186
|
+
mediaResult = await uploadMedia(token, scope, targetId, { fileType, url: source })
|
|
187
|
+
} else {
|
|
188
|
+
const { readFileSync } = await import('node:fs')
|
|
189
|
+
const buf = readFileSync(source)
|
|
190
|
+
mediaResult = await uploadMedia(token, scope, targetId, { fileType, base64: buf.toString('base64') })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!mediaResult?.file_info) {
|
|
194
|
+
throw new Error(`上传失败: ${JSON.stringify(mediaResult)}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const path = scope === 'group'
|
|
198
|
+
? `/v2/groups/${targetId}/messages`
|
|
199
|
+
: `/v2/users/${targetId}/messages`
|
|
200
|
+
const body = { msg_type: 7, media: { file_info: mediaResult.file_info } }
|
|
201
|
+
if (opts.msgId) body.msg_id = opts.msgId
|
|
202
|
+
|
|
203
|
+
return apiCall(token, 'POST', path, body)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── WebSocket 监听 ──────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 启动 WebSocket 消息监听
|
|
210
|
+
*
|
|
211
|
+
* @param {function} [onMessage] — (msg) => void
|
|
212
|
+
* msg = { text, scope: 'c2c'|'group', chatId, from, messageId, raw }
|
|
213
|
+
*/
|
|
214
|
+
async listen(onMessage) {
|
|
215
|
+
if (onMessage) this.onMessage = onMessage
|
|
216
|
+
this._listening = true
|
|
217
|
+
log('[QQ] Starting WebSocket listener...')
|
|
218
|
+
await this._connect()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async _connect() {
|
|
222
|
+
let delay = 1000
|
|
223
|
+
while (this._listening) {
|
|
224
|
+
try {
|
|
225
|
+
const token = await this._t()
|
|
226
|
+
const url = await this._getWSURL()
|
|
227
|
+
log(`[QQ] Connecting to WebSocket...`)
|
|
228
|
+
const ws = await this._createWS(url)
|
|
229
|
+
this._ws = ws
|
|
230
|
+
delay = 1000
|
|
231
|
+
|
|
232
|
+
ws.onopen = () => log('[QQ] WebSocket connected')
|
|
233
|
+
ws.onmessage = (event) => {
|
|
234
|
+
try { this._handleWS(JSON.parse(event.data)) } catch (e) {
|
|
235
|
+
log(`[QQ] WS parse error: ${e.message}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
ws.onclose = (ev) => {
|
|
239
|
+
log(`[QQ] WS closed: ${ev.code}`)
|
|
240
|
+
this._hbTimer = null
|
|
241
|
+
if (this._listening) setTimeout(() => this._connect(), delay)
|
|
242
|
+
}
|
|
243
|
+
ws.onerror = () => log('[QQ] WS error, reconnecting...')
|
|
244
|
+
|
|
245
|
+
// 等待 Identify 完成
|
|
246
|
+
await new Promise((resolve, reject) => {
|
|
247
|
+
const timeout = setTimeout(() => reject(new Error('Identify timeout')), 15000)
|
|
248
|
+
const handler = (event) => {
|
|
249
|
+
try {
|
|
250
|
+
const msg = JSON.parse(event.data)
|
|
251
|
+
if (msg.op === 0 && msg.t === 'READY') {
|
|
252
|
+
clearTimeout(timeout)
|
|
253
|
+
ws.removeEventListener('message', handler)
|
|
254
|
+
resolve()
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
ws.addEventListener('message', handler)
|
|
259
|
+
})
|
|
260
|
+
log('[QQ] Ready!')
|
|
261
|
+
return // 连接成功
|
|
262
|
+
} catch (e) {
|
|
263
|
+
log(`[QQ] Connection error: ${e.message}, retry in ${delay}ms`)
|
|
264
|
+
this._cleanupWS()
|
|
265
|
+
await this._sleep(delay)
|
|
266
|
+
delay = Math.min(delay * 2, 30000)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async _getWSURL() {
|
|
272
|
+
const token = await this._t()
|
|
273
|
+
const res = await fetch(`${API_BASE}/websocket`, {
|
|
274
|
+
headers: { Authorization: `QQBot ${token}` },
|
|
275
|
+
})
|
|
276
|
+
if (!res.ok) throw new Error(`WS URL ${res.status}`)
|
|
277
|
+
const data = await res.json()
|
|
278
|
+
return data.url
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_handleWS(msg) {
|
|
282
|
+
const { op, d, s, t } = msg
|
|
283
|
+
if (s) this._seq = s
|
|
284
|
+
|
|
285
|
+
switch (op) {
|
|
286
|
+
case 0: this._dispatch(t, d); break
|
|
287
|
+
case 7: log('[QQ] Reconnect requested'); this._reconnect(); break
|
|
288
|
+
case 9: log('[QQ] Invalid session'); this._sessionId = null; this._reconnect(); break
|
|
289
|
+
case 10:
|
|
290
|
+
this._startHeartbeat(d?.heartbeat_interval || 30000)
|
|
291
|
+
this._send({ op: 2, d: { token: `QQBot ${this._token}`, intents: 1 << 30 | 1 << 25 | 1 << 12, shard: [0, 1], properties: { $os: 'linux', $browser: 'cc-notify', $device: 'cc-notify' } } })
|
|
292
|
+
break
|
|
293
|
+
case 11: break
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_dispatch(eventType, data) {
|
|
298
|
+
if (!data) return
|
|
299
|
+
switch (eventType) {
|
|
300
|
+
case 'READY':
|
|
301
|
+
this._sessionId = data.session_id
|
|
302
|
+
break
|
|
303
|
+
case 'AT_MESSAGE_CREATE':
|
|
304
|
+
case 'MESSAGE_CREATE': {
|
|
305
|
+
const content = (data.content || '').replace(/<@!\d+>/g, '').trim()
|
|
306
|
+
if (!content) return
|
|
307
|
+
this._emitMsg({
|
|
308
|
+
text: content,
|
|
309
|
+
scope: 'c2c',
|
|
310
|
+
chatId: data.author?.id || data.channel_id,
|
|
311
|
+
from: data.author?.username || data.member?.nick || '?',
|
|
312
|
+
messageId: data.id,
|
|
313
|
+
raw: data,
|
|
314
|
+
})
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
case 'GROUP_AT_MESSAGE_CREATE': {
|
|
318
|
+
const content = (data.content || '').replace(/<@bot\w*>/g, '').trim()
|
|
319
|
+
if (!content) return
|
|
320
|
+
this._emitMsg({
|
|
321
|
+
text: content,
|
|
322
|
+
scope: 'group',
|
|
323
|
+
chatId: data.group_openid,
|
|
324
|
+
from: data.author?.member_name || '?',
|
|
325
|
+
messageId: data.id,
|
|
326
|
+
raw: data,
|
|
327
|
+
})
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
case 'DIRECT_MESSAGE_CREATE': {
|
|
331
|
+
const content = data.content || ''
|
|
332
|
+
if (!content) return
|
|
333
|
+
this._emitMsg({
|
|
334
|
+
text: content,
|
|
335
|
+
scope: 'c2c',
|
|
336
|
+
chatId: data.author?.id || data.guild_id,
|
|
337
|
+
from: data.author?.username || '?',
|
|
338
|
+
messageId: data.id,
|
|
339
|
+
raw: data,
|
|
340
|
+
})
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_emitMsg(msg) {
|
|
347
|
+
log(`[QQ] ← ${msg.from} (${msg.scope}): ${msg.text.slice(0, 60)}`)
|
|
348
|
+
this.onMessage?.(msg)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_send(payload) {
|
|
352
|
+
if (this._ws?.readyState === 1) {
|
|
353
|
+
this._ws.send(JSON.stringify(payload))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_startHeartbeat(intervalMs) {
|
|
358
|
+
if (this._hbTimer) clearInterval(this._hbTimer)
|
|
359
|
+
this._hbTimer = setInterval(() => {
|
|
360
|
+
this._send({ op: 1, d: this._seq || null })
|
|
361
|
+
}, intervalMs)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_reconnect() {
|
|
365
|
+
this._cleanupWS()
|
|
366
|
+
// _cleanupWS() 会触发 onclose → onclose 里已有重连逻辑
|
|
367
|
+
// 这里不再重复调用 _connect(),避免双重连接循环
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_cleanupWS() {
|
|
371
|
+
if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null }
|
|
372
|
+
if (this._ws) { try { this._ws.close() } catch {}; this._ws = null }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_createWS(url) {
|
|
376
|
+
return new Promise((resolve, reject) => {
|
|
377
|
+
if (typeof WebSocket === 'undefined') {
|
|
378
|
+
reject(new Error('WebSocket 不可用。请使用 Node.js >= 21'))
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
const ws = new WebSocket(url)
|
|
382
|
+
const t = setTimeout(() => { ws.close(); reject(new Error('WS timeout')) }, 10000)
|
|
383
|
+
ws.onopen = () => { clearTimeout(t); resolve(ws) }
|
|
384
|
+
ws.onerror = () => { clearTimeout(t); reject(new Error('WS failed')) }
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 停止监听 */
|
|
389
|
+
stop() {
|
|
390
|
+
this._listening = false
|
|
391
|
+
this._cleanupWS()
|
|
392
|
+
log('[QQ] Listener stopped')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
_sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function log(msg) {
|
|
399
|
+
const ts = new Date().toISOString().slice(11, 19)
|
|
400
|
+
process.stdout.write(`[${ts}] ${msg}\n`)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export default QQBot
|