@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,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