@raolin2025/claude-code-node 2.4.3 → 2.5.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/package.json +2 -2
- package/src/channel/index.js +112 -54
- package/src/channel/qqbot-account-manager.js +117 -0
- package/src/channel/qqbot-enhanced.js +470 -0
- package/src/core/config.js +20 -2
- package/src/tools/index.js +3 -0
- package/src/tools/qqbot-channel-api.js +147 -0
- package/src/tools/qqbot-media.js +177 -0
- package/src/tools/qqbot-remind.js +42 -0
- package/src/tools/qqbot-tools-wrapper.js +185 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 增强版 — 多账户、权限控制、富媒体、工具集成
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 多账户管理(基于配置路由)
|
|
6
|
+
* - 权限控制(dmPolicy/groupPolicy + allowFrom 白名单)
|
|
7
|
+
* - 富媒体上传(图片/文件/语音)
|
|
8
|
+
* - 支持 <qqmedia> 标签自动替换
|
|
9
|
+
* - Markdown 安全编码
|
|
10
|
+
*
|
|
11
|
+
* 与 OpenClaw 能力对齐的独立实现
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { QQBotAccountManager } from './qqbot-account-manager.js'
|
|
15
|
+
|
|
16
|
+
const API_BASE = 'https://api.sgroup.qq.com'
|
|
17
|
+
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
|
|
18
|
+
|
|
19
|
+
// ── Token 管理 ─────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
class TokenCache {
|
|
22
|
+
constructor() {
|
|
23
|
+
this._tokens = new Map() // appId → { token, expireAt }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async get(appId, clientSecret) {
|
|
27
|
+
const cache = this._tokens.get(appId)
|
|
28
|
+
if (cache && Date.now() < cache.expireAt - 300_000) {
|
|
29
|
+
return cache.token
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const res = await fetch(TOKEN_URL, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ appId, clientSecret }),
|
|
36
|
+
})
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const t = await res.text().catch(() => '')
|
|
39
|
+
throw new Error(`Token API ${res.status}: ${t.slice(0, 200)}`)
|
|
40
|
+
}
|
|
41
|
+
const data = JSON.parse(await res.text())
|
|
42
|
+
if (!data.access_token) throw new Error('Token API 未返回 access_token')
|
|
43
|
+
|
|
44
|
+
const token = data.access_token
|
|
45
|
+
const expireAt = Date.now() + (data.expires_in || 7200) * 1000
|
|
46
|
+
this._tokens.set(appId, { token, expireAt })
|
|
47
|
+
return token
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear(appId) {
|
|
51
|
+
if (appId) {
|
|
52
|
+
this._tokens.delete(appId)
|
|
53
|
+
} else {
|
|
54
|
+
this._tokens.clear()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── API 调用 ────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async function apiCall(token, method, path, body, timeoutMs = 30_000) {
|
|
62
|
+
const ac = new AbortController()
|
|
63
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `QQBot ${token}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: body && ['POST', 'PUT', 'PATCH'].includes(method) ? JSON.stringify(body) : undefined,
|
|
72
|
+
signal: ac.signal,
|
|
73
|
+
})
|
|
74
|
+
const raw = await res.text()
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
let detail = raw.slice(0, 200)
|
|
77
|
+
try { detail = JSON.parse(raw).message || detail } catch {}
|
|
78
|
+
throw new Error(`API ${method} ${path} → ${res.status}: ${detail}`)
|
|
79
|
+
}
|
|
80
|
+
return raw.trim() ? JSON.parse(raw) : null
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── 富媒体处理 ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 解析文本中的 <qqmedia> 标签,提取文件路径
|
|
90
|
+
* @param {string} text
|
|
91
|
+
* @returns {{ text: string, mediaFiles: Array<{path: string, type: string}> }}
|
|
92
|
+
*/
|
|
93
|
+
export function parseQQMediaTags(text) {
|
|
94
|
+
const mediaRegex = /<qqmedia>(.*?)<\/qqmedia>/g
|
|
95
|
+
const mediaFiles = []
|
|
96
|
+
let match
|
|
97
|
+
let parsedText = text
|
|
98
|
+
|
|
99
|
+
while ((match = mediaRegex.exec(text)) !== null) {
|
|
100
|
+
const path = match[1].trim()
|
|
101
|
+
const ext = path.split('.').pop().toLowerCase()
|
|
102
|
+
let type = 'file'
|
|
103
|
+
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) type = 'image'
|
|
104
|
+
else if (['silk', 'wav', 'mp3', 'ogg', 'aac', 'flac', 'm4a'].includes(ext)) type = 'audio'
|
|
105
|
+
else if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(ext)) type = 'video'
|
|
106
|
+
|
|
107
|
+
mediaFiles.push({ path, type })
|
|
108
|
+
// 替换标签为占位符
|
|
109
|
+
parsedText = parsedText.replace(match[0], `[上传${type}: ${path.split('/').pop()}]`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { text: parsedText, mediaFiles }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 增强 QQBot 类 ───────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export class QQBotEnhanced {
|
|
118
|
+
constructor(config = {}) {
|
|
119
|
+
// 账户管理器
|
|
120
|
+
this.accountManager = new QQBotAccountManager(config)
|
|
121
|
+
|
|
122
|
+
// Token 缓存(多账户)
|
|
123
|
+
this._tokenCache = new TokenCache()
|
|
124
|
+
|
|
125
|
+
// 监听状态
|
|
126
|
+
this.onMessage = null
|
|
127
|
+
this._listening = false
|
|
128
|
+
this._ws = null
|
|
129
|
+
this._hbTimer = null
|
|
130
|
+
this._seq = 0
|
|
131
|
+
this._sessionId = null
|
|
132
|
+
|
|
133
|
+
// WebSocket 连接参数(所有账户共享一个 WS,通过 intents 接收所有消息)
|
|
134
|
+
this._allAccounts = [] // 将用于存储所有账户的 appId 用于 WS identify
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 获取指定账户的 token */
|
|
138
|
+
async _getToken(account) {
|
|
139
|
+
return await this._tokenCache.get(account.appId, account.clientSecret)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 发送消息(主入口) ───────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 发送消息(自动处理富媒体、权限检查、账户路由)
|
|
146
|
+
*
|
|
147
|
+
* @param {object} params
|
|
148
|
+
* @param {string} params.text - 消息文本(支持 <qqmedia> 标签)
|
|
149
|
+
* @param {'c2c'|'group'} [params.scope] - 发送范围
|
|
150
|
+
* @param {string} [params.targetId] - 目标 openid / group_openid
|
|
151
|
+
* @param {string} [params.accountId] - 指定账户(不指定则自动路由)
|
|
152
|
+
* @param {object} [params.opts] - 原始发送选项
|
|
153
|
+
* @param {string} [params.opts.replyMsgId] - 回复消息 ID
|
|
154
|
+
* @returns {Promise<{ok: boolean, error?: string, accountId?: string}>}
|
|
155
|
+
*/
|
|
156
|
+
async send({ text, scope, targetId, accountId, opts = {} }) {
|
|
157
|
+
try {
|
|
158
|
+
// 1. 选择账户
|
|
159
|
+
const account = accountId
|
|
160
|
+
? this.accountManager.getAccount(accountId)
|
|
161
|
+
: this.accountManager.selectAccount(scope, null, targetId) // 注意:权限检查需要 openid,这里简化
|
|
162
|
+
|
|
163
|
+
if (!account) {
|
|
164
|
+
return { ok: false, error: '无可用账户或权限不足' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. 解析 <qqmedia> 标签
|
|
168
|
+
const { text: cleanText, mediaFiles } = parseQQMediaTags(text)
|
|
169
|
+
|
|
170
|
+
// 3. 发送文本
|
|
171
|
+
const resolvedTargetId = targetId || account.getTargetId(scope)
|
|
172
|
+
if (!resolvedTargetId) {
|
|
173
|
+
return { ok: false, error: '未配置目标 ID (targetId)' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 发送主文本
|
|
177
|
+
await this._sendText(account, scope, resolvedTargetId, cleanText, opts)
|
|
178
|
+
|
|
179
|
+
// 4. 发送富媒体(如果有)
|
|
180
|
+
for (const media of mediaFiles) {
|
|
181
|
+
await this._sendMedia(account, scope, resolvedTargetId, media)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { ok: true, accountId: account.id }
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.error('[QQBotEnhanced] 发送失败:', e)
|
|
187
|
+
return { ok: false, error: e.message }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async _sendText(account, scope, targetId, content, opts) {
|
|
192
|
+
const token = await this._getToken(account)
|
|
193
|
+
const path = scope === 'group'
|
|
194
|
+
? `/v2/groups/${targetId}/messages`
|
|
195
|
+
: `/v2/users/${targetId}/messages`
|
|
196
|
+
const body = { content: content.slice(0, 2000), msg_type: 0 }
|
|
197
|
+
if (opts.replyMsgId) body.msg_id = opts.replyMsgId
|
|
198
|
+
|
|
199
|
+
return apiCall(token, 'POST', path, body)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _sendMedia(account, scope, targetId, { path: filePath, type }) {
|
|
203
|
+
const token = await this._getToken(account)
|
|
204
|
+
|
|
205
|
+
// 文件类型映射
|
|
206
|
+
const fileTypeMap = {
|
|
207
|
+
image: 1,
|
|
208
|
+
video: 2,
|
|
209
|
+
audio: 3,
|
|
210
|
+
file: 4
|
|
211
|
+
}
|
|
212
|
+
const fileType = fileTypeMap[type] || 4
|
|
213
|
+
|
|
214
|
+
// 检查文件是否存在
|
|
215
|
+
const { existsSync } = await import('node:fs')
|
|
216
|
+
if (!existsSync(filePath)) {
|
|
217
|
+
throw new Error(`文件不存在: ${filePath}`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 获取绝对路径(如果是 media/qqbot 下的)
|
|
221
|
+
const { resolve } = await import('node:path')
|
|
222
|
+
const absPath = resolve(filePath)
|
|
223
|
+
|
|
224
|
+
// 检查媒体目录限制
|
|
225
|
+
const allowedDirs = [
|
|
226
|
+
process.env.HOME + '/.openclaw/media/qqbot',
|
|
227
|
+
process.env.HOME + '/.openclaw/media'
|
|
228
|
+
]
|
|
229
|
+
const isAllowed = allowedDirs.some(dir => absPath.startsWith(dir))
|
|
230
|
+
if (!isAllowed) {
|
|
231
|
+
throw new Error(`安全限制: 文件必须在 ~/.openclaw/media/qqbot 或 ~/.openclaw/media 目录下`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 上传
|
|
235
|
+
const { readFileSync } = await import('node:fs')
|
|
236
|
+
const buf = readFileSync(absPath)
|
|
237
|
+
const uploadResult = await uploadMedia(token, scope, targetId, {
|
|
238
|
+
fileType,
|
|
239
|
+
base64: buf.toString('base64')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
if (!uploadResult?.file_info) {
|
|
243
|
+
throw new Error(`媒体上传失败: ${JSON.stringify(uploadResult)}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 发送媒体消息
|
|
247
|
+
const targetPath = scope === 'group'
|
|
248
|
+
? `/v2/groups/${targetId}/messages`
|
|
249
|
+
: `/v2/users/${targetId}/messages`
|
|
250
|
+
const body = { msg_type: 7, media: { file_info: uploadResult.file_info } }
|
|
251
|
+
|
|
252
|
+
return apiCall(token, 'POST', targetPath, body)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── WebSocket 监听 ──────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 启动监听(接收 QQ 消息)
|
|
259
|
+
* 注意:WebSocket 只能连接一个账户,所以这里选择第一个启用的账户
|
|
260
|
+
*/
|
|
261
|
+
async listen(onMessage) {
|
|
262
|
+
this.onMessage = onMessage
|
|
263
|
+
this._listening = true
|
|
264
|
+
|
|
265
|
+
// 选择一个用于 WS 的账户(第一个启用的)
|
|
266
|
+
const listenAccount = this.accountManager.getAllAccounts()[0]
|
|
267
|
+
if (!listenAccount) {
|
|
268
|
+
throw new Error('无可用账户用于监听')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await this._connect(listenAccount)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async _connect(account) {
|
|
275
|
+
let delay = 1000
|
|
276
|
+
while (this._listening) {
|
|
277
|
+
try {
|
|
278
|
+
const token = await this._getToken(account)
|
|
279
|
+
const url = await this._getWSURL(token)
|
|
280
|
+
log('[QQ] Connecting to WebSocket...')
|
|
281
|
+
const ws = await this._createWS(url)
|
|
282
|
+
this._ws = ws
|
|
283
|
+
delay = 1000
|
|
284
|
+
|
|
285
|
+
ws.onopen = () => log('[QQ] WebSocket connected')
|
|
286
|
+
ws.onmessage = (event) => {
|
|
287
|
+
try {
|
|
288
|
+
const msg = JSON.parse(event.data)
|
|
289
|
+
this._handleWS(msg, account)
|
|
290
|
+
} catch (e) {
|
|
291
|
+
log(`[QQ] WS parse error: ${e.message}`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
ws.onclose = (ev) => {
|
|
295
|
+
log(`[QQ] WS closed: ${ev.code}`)
|
|
296
|
+
this._hbTimer = null
|
|
297
|
+
if (this._listening) setTimeout(() => this._connect(account), delay)
|
|
298
|
+
}
|
|
299
|
+
ws.onerror = () => log('[QQ] WS error, reconnecting...')
|
|
300
|
+
|
|
301
|
+
// 等待 Identify 完成
|
|
302
|
+
await new Promise((resolve, reject) => {
|
|
303
|
+
const timeout = setTimeout(() => reject(new Error('Identify timeout')), 15000)
|
|
304
|
+
const handler = (event) => {
|
|
305
|
+
try {
|
|
306
|
+
const parsed = JSON.parse(event.data)
|
|
307
|
+
if (parsed.op === 0 && parsed.t === 'READY') {
|
|
308
|
+
clearTimeout(timeout)
|
|
309
|
+
ws.removeEventListener('message', handler)
|
|
310
|
+
resolve()
|
|
311
|
+
}
|
|
312
|
+
} catch {}
|
|
313
|
+
}
|
|
314
|
+
ws.addEventListener('message', handler)
|
|
315
|
+
})
|
|
316
|
+
log('[QQ] Ready!')
|
|
317
|
+
return
|
|
318
|
+
} catch (e) {
|
|
319
|
+
log(`[QQ] Connection error: ${e.message}, retry in ${delay}ms`)
|
|
320
|
+
this._cleanupWS()
|
|
321
|
+
await this._sleep(delay)
|
|
322
|
+
delay = Math.min(delay * 2, 30000)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async _getWSURL(token) {
|
|
328
|
+
const res = await fetch(`${API_BASE}/websocket`, {
|
|
329
|
+
headers: { Authorization: `QQBot ${token}` },
|
|
330
|
+
})
|
|
331
|
+
if (!res.ok) throw new Error(`WS URL ${res.status}`)
|
|
332
|
+
const data = await res.json()
|
|
333
|
+
return data.url
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_handleWS(msg, account) {
|
|
337
|
+
const { op, d, s, t } = msg
|
|
338
|
+
if (s) this._seq = s
|
|
339
|
+
|
|
340
|
+
switch (op) {
|
|
341
|
+
case 0: this._dispatch(t, d, account); break
|
|
342
|
+
case 7: log('[QQ] Reconnect requested'); this._reconnect(); break
|
|
343
|
+
case 9: log('[QQ] Invalid session'); this._sessionId = null; this._reconnect(); break
|
|
344
|
+
case 10:
|
|
345
|
+
this._startHeartbeat(d?.heartbeat_interval || 30000)
|
|
346
|
+
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' } } })
|
|
347
|
+
break
|
|
348
|
+
case 11: break
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_dispatch(eventType, data, account) {
|
|
353
|
+
if (!data) return
|
|
354
|
+
|
|
355
|
+
let scope = 'c2c'
|
|
356
|
+
let chatId = ''
|
|
357
|
+
let from = ''
|
|
358
|
+
let content = ''
|
|
359
|
+
|
|
360
|
+
switch (eventType) {
|
|
361
|
+
case 'READY':
|
|
362
|
+
this._sessionId = data.session_id
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
case 'AT_MESSAGE_CREATE':
|
|
366
|
+
case 'MESSAGE_CREATE': {
|
|
367
|
+
scope = 'c2c'
|
|
368
|
+
chatId = data.author?.id || data.channel_id
|
|
369
|
+
from = data.author?.username || data.member?.nick || '?'
|
|
370
|
+
content = (data.content || '').replace(/<@!\d+>/g, '').trim()
|
|
371
|
+
break
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case 'GROUP_AT_MESSAGE_CREATE': {
|
|
375
|
+
scope = 'group'
|
|
376
|
+
chatId = data.group_openid
|
|
377
|
+
from = data.author?.member_name || data.member?.nick || '?'
|
|
378
|
+
content = (data.content || '').replace(/<@bot\w*>/g, '').trim()
|
|
379
|
+
break
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
case 'DIRECT_MESSAGE_CREATE': {
|
|
383
|
+
scope = 'c2c'
|
|
384
|
+
chatId = data.author?.id || data.guild_id
|
|
385
|
+
from = data.author?.username || '?'
|
|
386
|
+
content = data.content || ''
|
|
387
|
+
break
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!content) return
|
|
392
|
+
|
|
393
|
+
// 权限检查
|
|
394
|
+
if (scope === 'c2c') {
|
|
395
|
+
if (!account.isAllowed(scope, chatId)) {
|
|
396
|
+
log(`[QQ] 消息来自未授权用户 ${from} (${chatId}),已忽略`)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
} else if (scope === 'group') {
|
|
400
|
+
const memberOpenId = data.author?.id
|
|
401
|
+
if (!account.isAllowed(scope, memberOpenId, chatId)) {
|
|
402
|
+
log(`[QQ] 群消息来自未授权用户 ${from} (${memberOpenId}),已忽略`)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
log(`[QQ] ← ${from} (${scope}): ${content.slice(0, 60)}`)
|
|
408
|
+
|
|
409
|
+
this.onMessage?.({
|
|
410
|
+
text: content,
|
|
411
|
+
scope,
|
|
412
|
+
chatId,
|
|
413
|
+
from,
|
|
414
|
+
messageId: data.id,
|
|
415
|
+
raw: data,
|
|
416
|
+
accountId: account.id
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
_send(payload) {
|
|
421
|
+
if (this._ws?.readyState === 1) {
|
|
422
|
+
this._ws.send(JSON.stringify(payload))
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_startHeartbeat(intervalMs) {
|
|
427
|
+
if (this._hbTimer) clearInterval(this._hbTimer)
|
|
428
|
+
this._hbTimer = setInterval(() => {
|
|
429
|
+
this._send({ op: 1, d: this._seq || null })
|
|
430
|
+
}, intervalMs)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
_reconnect() {
|
|
434
|
+
this._cleanupWS()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
_cleanupWS() {
|
|
438
|
+
if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null }
|
|
439
|
+
if (this._ws) { try { this._ws.close() } catch {}; this._ws = null }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
_createWS(url) {
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
if (typeof WebSocket === 'undefined') {
|
|
445
|
+
reject(new Error('WebSocket 不可用。请使用 Node.js >= 21'))
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
const ws = new WebSocket(url)
|
|
449
|
+
const t = setTimeout(() => { ws.close(); reject(new Error('WS timeout')) }, 10000)
|
|
450
|
+
ws.onopen = () => { clearTimeout(t); resolve(ws) }
|
|
451
|
+
ws.onerror = () => { clearTimeout(t); reject(new Error('WS failed')) }
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 停止监听 */
|
|
456
|
+
stop() {
|
|
457
|
+
this._listening = false
|
|
458
|
+
this._cleanupWS()
|
|
459
|
+
log('[QQ] Listener stopped')
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
_sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function log(msg) {
|
|
466
|
+
const ts = new Date().toISOString().slice(11, 19)
|
|
467
|
+
process.stdout.write(`[${ts}] ${msg}\n`)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export default QQBotEnhanced
|
package/src/core/config.js
CHANGED
|
@@ -26,8 +26,26 @@ const DEFAULTS = {
|
|
|
26
26
|
fileRead: { maxLines: 2000, maxSizeKB: 256 },
|
|
27
27
|
webFetch: { timeout: 30, maxChars: 100000 },
|
|
28
28
|
},
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
channels: {},
|
|
30
|
+
defaultChannel: null,
|
|
31
|
+
qqbot: {
|
|
32
|
+
enabled: false,
|
|
33
|
+
accounts: {
|
|
34
|
+
default: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
appId: '',
|
|
37
|
+
clientSecret: '',
|
|
38
|
+
dmPolicy: 'open',
|
|
39
|
+
groupPolicy: 'open',
|
|
40
|
+
allowFrom: ['*'],
|
|
41
|
+
defaultTargets: {}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
defaultAccount: 'default',
|
|
45
|
+
globalDmPolicy: 'open',
|
|
46
|
+
globalGroupPolicy: 'open',
|
|
47
|
+
globalAllowFrom: ['*']
|
|
48
|
+
},
|
|
31
49
|
mcp: {
|
|
32
50
|
servers: {},
|
|
33
51
|
},
|
package/src/tools/index.js
CHANGED
|
@@ -11,6 +11,8 @@ import { webFetchTool } from './web-fetch.js'
|
|
|
11
11
|
import { webSearchTool } from './web-search.js'
|
|
12
12
|
import { askUserTool } from './ask-user.js'
|
|
13
13
|
import { gitTool } from './git-tool.js'
|
|
14
|
+
// QQ Bot 工具(外部包装)
|
|
15
|
+
import { qqbotTools } from '../tools/qqbot-tools-wrapper.js'
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* 所有内置工具列表
|
|
@@ -26,6 +28,7 @@ export const builtinTools = [
|
|
|
26
28
|
webSearchTool,
|
|
27
29
|
askUserTool,
|
|
28
30
|
gitTool,
|
|
31
|
+
...qqbotTools
|
|
29
32
|
]
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 频道管理工具 — 调用 QQ Bot API v2
|
|
3
|
+
*
|
|
4
|
+
* 使用方式:在工具调用中指定 method、path、body、query
|
|
5
|
+
*
|
|
6
|
+
* 示例:
|
|
7
|
+
* qqbot_channel_api: {\n method: 'GET',\n path: '/users/@me/guilds',\n query: { limit: '100' }\n }
|
|
8
|
+
*
|
|
9
|
+
* 所有请求自动携带 Authorization 头,无需手动处理 Token
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const API_BASE = 'https://api.sgroup.qq.com'
|
|
13
|
+
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
|
|
14
|
+
|
|
15
|
+
/** 获取指定账户的 Token(通过环境变量或全局配置) */
|
|
16
|
+
async function getToken(appId, clientSecret) {
|
|
17
|
+
if (!appId || !clientSecret) {
|
|
18
|
+
throw new Error('QQBot 需要 appId 和 clientSecret(请配置 CC_NODE_CHANNEL_QQBOT_APPID / CC_NODE_CHANNEL_QQBOT_SECRET)')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const res = await fetch(TOKEN_URL, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ appId, clientSecret }),
|
|
25
|
+
})
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const t = await res.text().catch(() => '')
|
|
28
|
+
throw new Error(`Token API ${res.status}: ${t.slice(0, 200)}`)
|
|
29
|
+
}
|
|
30
|
+
const data = await res.json()
|
|
31
|
+
if (!data.access_token) throw new Error('Token API no access_token')
|
|
32
|
+
return data.access_token
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 核心 API 调用 */
|
|
36
|
+
async function qqbotChannelApiCall(params) {
|
|
37
|
+
const { method = 'GET', path, body, query = {} } = params
|
|
38
|
+
|
|
39
|
+
if (!path) throw new Error('path 是必填参数')
|
|
40
|
+
|
|
41
|
+
// 从环境变量获取凭证(简化:使用全局默认账户)
|
|
42
|
+
const appId = process.env.CC_NODE_CHANNEL_QQBOT_APPID || ''
|
|
43
|
+
const clientSecret = process.env.CC_NODE_CHANNEL_QQBOT_SECRET || ''
|
|
44
|
+
const token = await getToken(appId, clientSecret)
|
|
45
|
+
|
|
46
|
+
// 构建 URL + 查询参数
|
|
47
|
+
const url = new URL(API_BASE + path)
|
|
48
|
+
for (const [k, v] of Object.entries(query)) {
|
|
49
|
+
url.searchParams.append(k, String(v))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 执行请求
|
|
53
|
+
const res = await fetch(url.toString(), {
|
|
54
|
+
method,
|
|
55
|
+
headers: {
|
|
56
|
+
'Authorization': `QQBot ${token}`,
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
},
|
|
59
|
+
body: (body && ['POST', 'PUT', 'PATCH'].includes(method)) ? JSON.stringify(body) : undefined,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const raw = await res.text()
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
let detail = raw.slice(0, 200)
|
|
65
|
+
try { detail = JSON.parse(raw).message || detail } catch {}
|
|
66
|
+
throw new Error(`QQ API ${method} ${path} → ${res.status}: ${detail}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return raw.trim() ? JSON.parse(raw) : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 工具函数(具体操作封装) ───────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** 获取机器人所在的频道列表 */
|
|
75
|
+
async function listGuilds(limit = 100, before, after) {
|
|
76
|
+
const query = { limit: String(limit) }
|
|
77
|
+
if (before) query.before = String(before)
|
|
78
|
+
if (after) query.after = String(after)
|
|
79
|
+
return await qqbotChannelApiCall({ method: 'GET', path: '/users/@me/guilds', query })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 获取频道的子频道列表 */
|
|
83
|
+
async function listChannels(guildId) {
|
|
84
|
+
return await qqbotChannelApiCall({ method: 'GET', path: `/guilds/${guildId}/channels` })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 创建子频道 */
|
|
88
|
+
async function createChannel(guildId, { name, type = 0, position = 1, sub_type = 0, parent_id, private_type, private_user_ids, speak_permission, application_id }) {
|
|
89
|
+
const body = { name, type: Number(type), position: Number(position), sub_type: Number(sub_type) }
|
|
90
|
+
if (parent_id) body.parent_id = parent_id
|
|
91
|
+
if (private_type !== undefined) body.private_type = private_type
|
|
92
|
+
if (private_user_ids) body.private_user_ids = private_user_ids
|
|
93
|
+
if (speak_permission !== undefined) body.speak_permission = speak_permission
|
|
94
|
+
if (application_id) body.application_id = application_id
|
|
95
|
+
return await qqbotChannelApiCall({ method: 'POST', path: `/guilds/${guildId}/channels`, body })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 获取频道成员列表(分页) */
|
|
99
|
+
async function listMembers(guildId, limit = 100, after = 0) {
|
|
100
|
+
return await qqbotChannelApiCall({ method: 'GET', path: `/guilds/${guildId}/members`, query: { limit: String(limit), after: String(after) } })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 获取指定成员详情 */
|
|
104
|
+
async function getMember(guildId, userId) {
|
|
105
|
+
return await qqbotChannelApiCall({ method: 'GET', path: `/guilds/${guildId}/members/${userId}` })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 发布公告 */
|
|
109
|
+
async function createAnnounce(guildId, { message_id, channel_id, announces_type = 0, recommend_channels = [] }) {
|
|
110
|
+
const body = { announces_type, recommend_channels }
|
|
111
|
+
if (message_id) body.message_id = message_id
|
|
112
|
+
if (channel_id) body.channel_id = channel_id
|
|
113
|
+
return await qqbotChannelApiCall({ method: 'POST', path: `/guilds/${guild_id}/announces`, body })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 删除公告 */
|
|
117
|
+
async function deleteAnnounce(guildId, message_id) {
|
|
118
|
+
const messageId = message_id === 'all' ? 'all' : encodeURIComponent(message_id)
|
|
119
|
+
return await qqbotChannelApiCall({ method: 'DELETE', path: `/guilds/${guildId}/announces/${messageId}` })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 获取子频道在线人数 */
|
|
123
|
+
async function getChannelOnlineCount(channelId) {
|
|
124
|
+
return await qqbotChannelApiCall({ method: 'GET', path: `/channels/${channelId}/online_nums` })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 导出工具函数作为工具接口
|
|
128
|
+
export const tools = {
|
|
129
|
+
qqbot_channel_api: async (args) => {
|
|
130
|
+
// 通用 API 调用
|
|
131
|
+
return await qqbotChannelApiCall(args)
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// 便捷函数
|
|
135
|
+
qqbot_list_guilds: async (args = {}) => await listGuilds(args.limit, args.before, args.after),
|
|
136
|
+
qqbot_list_channels: async (args) => await listChannels(args.guildId),
|
|
137
|
+
qqbot_get_member: async (args) => await getMember(args.guildId, args.userId),
|
|
138
|
+
qqbot_list_members: async (args) => await listMembers(args.guildId, args.limit, args.after),
|
|
139
|
+
qqbot_get_channel_online: async (args) => await getChannelOnlineCount(args.channelId),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 工具元数据
|
|
143
|
+
export const metadata = {
|
|
144
|
+
name: 'qqbot-channel-api',
|
|
145
|
+
description: 'QQ Bot 频道管理工具,调用 QQ Bot API v2,支持频道、成员、公告等操作',
|
|
146
|
+
tools: Object.keys(tools)
|
|
147
|
+
}
|