@raolin2025/claude-code-node 2.4.3 → 2.5.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/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-tools-wrapper.js +185 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming",
|
|
3
|
+
"version": "2.5.0",
|
|
4
|
+
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
package/src/channel/index.js
CHANGED
|
@@ -195,82 +195,140 @@ class TelegramChannel {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
// ============================================================
|
|
198
|
-
// QQ Bot 通道适配器
|
|
198
|
+
// QQ Bot 通道适配器 v3.0 — 多账户、权限、富媒体
|
|
199
199
|
// ============================================================
|
|
200
200
|
|
|
201
|
+
import { QQBotEnhanced } from './qqbot-enhanced.js'
|
|
202
|
+
import { QQBotAccountManager } from './qqbot-account-manager.js'
|
|
203
|
+
|
|
201
204
|
/**
|
|
202
|
-
* QQ Bot
|
|
205
|
+
* QQ Bot 增强版通道适配器
|
|
203
206
|
*
|
|
204
|
-
*
|
|
207
|
+
* 功能:
|
|
208
|
+
* - 多账户管理(从配置文件加载)
|
|
209
|
+
* - 权限控制(dmPolicy/groupPolicy + allowFrom 白名单)
|
|
210
|
+
* - 自动解析 <qqmedia> 标签并上传富媒体
|
|
211
|
+
* - 支持图片、文件、语音上传(限于 ~/.openclaw/media/qqbot)
|
|
212
|
+
* - WebSocket 监听(可选)
|
|
205
213
|
*/
|
|
206
214
|
class QQBotChannel {
|
|
207
|
-
constructor(config = {}) {
|
|
208
|
-
this.
|
|
209
|
-
this.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
constructor(config = {}, globalConfig = {}) {
|
|
216
|
+
this.name = 'qqbot'
|
|
217
|
+
this.enabled = config.enabled !== false
|
|
218
|
+
|
|
219
|
+
// 合并全局配置
|
|
220
|
+
const qqbotConfig = {
|
|
221
|
+
...globalConfig.qqbot,
|
|
222
|
+
...config
|
|
223
|
+
}
|
|
215
224
|
|
|
216
|
-
|
|
225
|
+
// 创建增强版 QQBot 实例
|
|
226
|
+
this.bot = new QQBotEnhanced(qqbotConfig)
|
|
217
227
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
body: JSON.stringify({ appId: this.appId, clientSecret: this.secret }),
|
|
226
|
-
})
|
|
227
|
-
if (!res.ok) throw new Error('QQ Token API ' + res.status)
|
|
228
|
-
const data = JSON.parse(await res.text())
|
|
229
|
-
if (!data.access_token) throw new Error('Token API no access_token')
|
|
230
|
-
this._tokenCache.token = data.access_token
|
|
231
|
-
this._tokenCache.expireAt = Date.now() + (data.expires_in || 7200) * 1000
|
|
232
|
-
return data.access_token
|
|
228
|
+
// 通道特定配置
|
|
229
|
+
this.scope = config.scope || 'group' // 默认发送到群
|
|
230
|
+
this.defaultTargetId = config.targetId || config.groupOpenId || ''
|
|
231
|
+
|
|
232
|
+
// WebSocket 监听状态
|
|
233
|
+
this._listener = null
|
|
234
|
+
this._onMessageCallback = null
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
/** 启动消息监听 */
|
|
238
|
+
async listen(onMessage) {
|
|
239
|
+
if (!this.enabled) {
|
|
240
|
+
console.warn('[QQBotChannel] 通道未启用')
|
|
241
|
+
return
|
|
238
242
|
}
|
|
239
243
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
244
|
+
this._onMessageCallback = onMessage
|
|
245
|
+
await this.bot.listen(this._handleIncomingMessage.bind(this))
|
|
246
|
+
console.log('[QQBotChannel] 监听已启动')
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error('[QQBotChannel] 监听启动失败:', e.message)
|
|
249
|
+
throw e
|
|
250
|
+
}
|
|
251
|
+
}
|
|
245
252
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
_handleIncomingMessage(msg) {
|
|
254
|
+
// 包装为统一消息格式,转发给回调
|
|
255
|
+
if (this._onMessageCallback) {
|
|
256
|
+
this._onMessageCallback({
|
|
257
|
+
channel: 'qqbot',
|
|
258
|
+
text: msg.text,
|
|
259
|
+
scope: msg.scope,
|
|
260
|
+
chatId: msg.chatId,
|
|
261
|
+
from: msg.from,
|
|
262
|
+
messageId: msg.messageId,
|
|
263
|
+
raw: msg.raw,
|
|
264
|
+
accountId: msg.accountId
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
}
|
|
249
268
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
269
|
+
/** 停止监听 */
|
|
270
|
+
stop() {
|
|
271
|
+
if (this.bot) {
|
|
272
|
+
this.bot.stop()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
253
275
|
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
/** 发送消息(支持富媒体标签) */
|
|
277
|
+
async send(text, options = {}) {
|
|
278
|
+
if (!this.enabled) {
|
|
279
|
+
return [{ channel: 'qqbot', ok: false, error: '通道未启用' }]
|
|
280
|
+
}
|
|
256
281
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
282
|
+
try {
|
|
283
|
+
const scope = options.scope || this.scope
|
|
284
|
+
const targetId = options.targetId || this.defaultTargetId
|
|
285
|
+
const accountId = options.accountId || null
|
|
286
|
+
|
|
287
|
+
const result = await this.bot.send({
|
|
288
|
+
text,
|
|
289
|
+
scope,
|
|
290
|
+
targetId,
|
|
291
|
+
accountId,
|
|
292
|
+
opts: { replyMsgId: options.replyMsgId }
|
|
261
293
|
})
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
results.push({ channel: 'qqbot', ok: true })
|
|
267
|
-
return results
|
|
294
|
+
|
|
295
|
+
return result.ok
|
|
296
|
+
? [{ channel: 'qqbot', ok: true }]
|
|
297
|
+
: [{ channel: 'qqbot', ok: false, error: result.error }]
|
|
268
298
|
} catch (e) {
|
|
269
|
-
|
|
299
|
+
console.error('[QQBotChannel] 发送失败:', e)
|
|
300
|
+
return [{ channel: 'qqbot', ok: false, error: e.message }]
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 支持的工具调用(提供给 Agent) */
|
|
305
|
+
get tools() {
|
|
306
|
+
return {
|
|
307
|
+
/** 发送 QQ 消息 */
|
|
308
|
+
qqbot_send: async (args) => {
|
|
309
|
+
const { text, scope = this.scope, targetId = this.defaultTargetId } = args
|
|
310
|
+
const result = await this.send(text, { scope, targetId })
|
|
311
|
+
return result[0]
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
/** 获取账户列表 */
|
|
315
|
+
qqbot_list_accounts: async () => {
|
|
316
|
+
const accounts = this.bot.accountManager.getAllAccounts()
|
|
317
|
+
return accounts.map(a => ({ id: a.id, name: a.name, enabled: a.enabled }))
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/** 发送图片(直接文件路径) */
|
|
321
|
+
qqbot_send_image: async (args) => {
|
|
322
|
+
const { path, scope = this.scope, targetId = this.defaultTargetId } = args
|
|
323
|
+
// 这里需要支持直接的图片发送,不通过文本标签
|
|
324
|
+
// 临时方案:调用 bot 的底层方法
|
|
325
|
+
return { ok: false, error: '暂未实现' }
|
|
326
|
+
}
|
|
270
327
|
}
|
|
271
328
|
}
|
|
272
329
|
}
|
|
273
330
|
|
|
331
|
+
|
|
274
332
|
// ============================================================
|
|
275
333
|
// 已有适配器(保持兼容)
|
|
276
334
|
// ============================================================
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot 多账户管理器
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 管理多个 QQ 机器人账户
|
|
6
|
+
* - 自动选择账户发送消息
|
|
7
|
+
* - 支持账户级别的权限控制
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class QQBotAccount {
|
|
11
|
+
constructor(config, globalConfig = {}) {
|
|
12
|
+
this.id = config.id || 'default'
|
|
13
|
+
this.name = config.name || this.id
|
|
14
|
+
this.enabled = config.enabled !== false
|
|
15
|
+
|
|
16
|
+
// 认证信息(支持环境变量或直接配置)
|
|
17
|
+
this.appId = config.appId || process.env[`CC_NODE_CHANNEL_QQBOT_${this.id.toUpperCase()}_APPID`] || ''
|
|
18
|
+
this.clientSecret = config.clientSecret || process.env[`CC_NODE_CHANNEL_QQBOT_${this.id.toUpperCase()}_SECRET`] || ''
|
|
19
|
+
|
|
20
|
+
// 权限策略
|
|
21
|
+
this.dmPolicy = config.dmPolicy || globalConfig.dmPolicy || 'open' // 'open'|'allowlist'|'disabled'
|
|
22
|
+
this.groupPolicy = config.groupPolicy || globalConfig.groupPolicy || 'open'
|
|
23
|
+
|
|
24
|
+
// 白名单(格式: "qqbot:openid" 或 "qqbot:group:group_openid")
|
|
25
|
+
this.allowFrom = config.allowFrom || globalConfig.allowFrom || ['*']
|
|
26
|
+
|
|
27
|
+
// 目标群/用户(可选默认值)
|
|
28
|
+
this.defaultTargets = config.defaultTargets || {}
|
|
29
|
+
|
|
30
|
+
// 高级配置
|
|
31
|
+
this.markdownSupport = config.markdownSupport !== false
|
|
32
|
+
this.streaming = config.streaming || { mode: 'partial' }
|
|
33
|
+
|
|
34
|
+
// 验证
|
|
35
|
+
if (!this.appId || !this.clientSecret) {
|
|
36
|
+
console.warn(`[QQBot] 账户 ${this.id} 未配置 appId/clientSecret`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 检查是否允许来自指定源的消息 */
|
|
41
|
+
isAllowed(scope, openid, groupOpenid = null) {
|
|
42
|
+
// 如果允许所有人
|
|
43
|
+
if (this.allowFrom.includes('*')) return true
|
|
44
|
+
|
|
45
|
+
// 检查具体白名单
|
|
46
|
+
const checks = []
|
|
47
|
+
if (scope === 'c2c') {
|
|
48
|
+
checks.push(`qqbot:${openid}`)
|
|
49
|
+
} else if (scope === 'group' && groupOpenid) {
|
|
50
|
+
checks.push(`qqbot:${groupOpenid}`) // 群级别
|
|
51
|
+
checks.push(`qqbot:group:${groupOpenid}:${openid}`) // 用户级别
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return checks.some(c => this.allowFrom.includes(c))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 获取目标 ID(优先级:传入 > 默认配置) */
|
|
58
|
+
getTargetId(scope, overrideTargetId = null) {
|
|
59
|
+
if (overrideTargetId) return overrideTargetId
|
|
60
|
+
return this.defaultTargets[scope] || null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class QQBotAccountManager {
|
|
65
|
+
constructor(config = {}) {
|
|
66
|
+
this.accounts = new Map()
|
|
67
|
+
this.defaultAccountId = config.defaultAccount || 'default'
|
|
68
|
+
this.globalConfig = {
|
|
69
|
+
dmPolicy: config.dmPolicy || 'open',
|
|
70
|
+
groupPolicy: config.groupPolicy || 'open',
|
|
71
|
+
allowFrom: config.allowFrom || ['*']
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 初始化所有账户
|
|
75
|
+
if (config.accounts) {
|
|
76
|
+
for (const [id, accountConfig] of Object.entries(config.accounts)) {
|
|
77
|
+
this.addAccount(new QQBotAccount(accountConfig, this.globalConfig))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 确保有默认账户
|
|
82
|
+
if (!this.accounts.has(this.defaultAccountId)) {
|
|
83
|
+
this.addAccount(new QQBotAccount({ id: this.defaultAccountId }, this.globalConfig))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
addAccount(account) {
|
|
88
|
+
this.accounts.set(account.id, account)
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getAccount(id = null) {
|
|
93
|
+
const targetId = id || this.defaultAccountId
|
|
94
|
+
return this.accounts.get(targetId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getAllAccounts() {
|
|
98
|
+
return Array.from(this.accounts.values()).filter(a => a.enabled)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 根据消息上下文选择账户(基于 routing 规则) */
|
|
102
|
+
selectAccount(scope, openid, groupOpenid = null) {
|
|
103
|
+
// 遍历所有账户,找到第一个匹配权限的
|
|
104
|
+
for (const account of this.getAllAccounts()) {
|
|
105
|
+
const policy = scope === 'c2c' ? account.dmPolicy : account.groupPolicy
|
|
106
|
+
if (policy === 'disabled') continue
|
|
107
|
+
|
|
108
|
+
if (account.isAllowed(scope, openid, groupOpenid)) {
|
|
109
|
+
return account
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 没匹配到,返回默认账户(如果默认账户开启了的话)
|
|
114
|
+
const defaultAcc = this.getAccount()
|
|
115
|
+
return defaultAcc && defaultAcc.enabled ? defaultAcc : null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 工具包装器 — 将新工具适配到 claude-code-node 工具系统
|
|
3
|
+
*
|
|
4
|
+
* 将以下工具包装为标准 ToolDef 格式:
|
|
5
|
+
* - qqbot_channel_api
|
|
6
|
+
* - qqbot_remind
|
|
7
|
+
* - qqbot_media
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ToolDef } from '../types/index.js'
|
|
11
|
+
import { tools as qqbotChannelApiTools } from '../../tools/qqbot-channel-api.js'
|
|
12
|
+
import { tools as qqbotRemindTools } from '../../tools/qqbot-remind.js'
|
|
13
|
+
import { tools as qqbotMediaTools } from '../../tools/qqbot-media.js'
|
|
14
|
+
|
|
15
|
+
// 通用执行器:将原始函数包装为 ToolDef 的执行格式
|
|
16
|
+
function createExecutor(originalFunc) {
|
|
17
|
+
return async (input, ctx) => {
|
|
18
|
+
try {
|
|
19
|
+
// 合并上下文配置(如 targetId, scope, accountId)
|
|
20
|
+
const args = { ...input, ...ctx }
|
|
21
|
+
const result = await originalFunc(args)
|
|
22
|
+
// 保持与现有工具一致的返回格式
|
|
23
|
+
if (result.ok) {
|
|
24
|
+
return typeof result.result !== 'undefined' ? result.result : result
|
|
25
|
+
}
|
|
26
|
+
return `[ERROR] ${result.error || 'Unknown error'}`
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return `[ERROR] ${e.message}`
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 导出所有工具(使用 ToolDef 格式)
|
|
34
|
+
export const qqbotTools = [
|
|
35
|
+
// qqbot_channel_api — 通用 API 调用
|
|
36
|
+
new ToolDef(
|
|
37
|
+
'qqbot_channel_api',
|
|
38
|
+
`调用 QQ Bot API v2。自动携带鉴权 Token,无需手动处理。
|
|
39
|
+
使用方法:
|
|
40
|
+
method: HTTP 方法 (GET/POST/PUT/PATCH/DELETE)
|
|
41
|
+
path: API 路径(不含域名),如 /guilds/{guild_id}/channels
|
|
42
|
+
body: 请求体 JSON(POST/PUT/PATCH 使用)
|
|
43
|
+
query: URL 查询参数对象(值必须是字符串)
|
|
44
|
+
|
|
45
|
+
示例:
|
|
46
|
+
- 获取频道列表: { "method": "GET", "path": "/users/@me/guilds", "query": { "limit": "100" } }
|
|
47
|
+
- 获取子频道: { "method": "GET", "path": "/guilds/123/channels" }`,
|
|
48
|
+
{
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], description: 'HTTP 方法' },
|
|
52
|
+
path: { type: 'string', description: 'API 路径(不含域名),如 /guilds/{guild_id}/channels' },
|
|
53
|
+
body: { type: 'object', description: '请求体 JSON(POST/PUT/PATCH 使用)' },
|
|
54
|
+
query: { type: 'object', additionalProperties: { type: 'string' }, description: 'URL 查询参数(值必须是字符串)' }
|
|
55
|
+
},
|
|
56
|
+
required: ['method', 'path']
|
|
57
|
+
},
|
|
58
|
+
createExecutor(qqbotChannelApiTools.qqbot_channel_api)
|
|
59
|
+
),
|
|
60
|
+
|
|
61
|
+
// qqbot_list_guilds — 获取频道列表
|
|
62
|
+
new ToolDef(
|
|
63
|
+
'qqbot_list_guilds',
|
|
64
|
+
'获取机器人所在的频道列表(GUILD 列表)',
|
|
65
|
+
{
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
limit: { type: 'number', description: '返回数量,最大100' },
|
|
69
|
+
before: { type: 'string', description: '分页游标(上一页最后一条的 id)' },
|
|
70
|
+
after: { type: 'string', description: '分页游标(上一页第一条的 id)' }
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
createExecutor(qqbotChannelApiTools.qqbot_list_guilds)
|
|
74
|
+
),
|
|
75
|
+
|
|
76
|
+
// qqbot_list_channels — 获取子频道列表
|
|
77
|
+
new ToolDef(
|
|
78
|
+
'qqbot_list_channels',
|
|
79
|
+
'获取指定频道的子频道列表',
|
|
80
|
+
{
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
guildId: { type: 'string', description: '频道 ID' }
|
|
84
|
+
},
|
|
85
|
+
required: ['guildId']
|
|
86
|
+
},
|
|
87
|
+
createExecutor(qqbotChannelApiTools.qqbot_list_channels)
|
|
88
|
+
),
|
|
89
|
+
|
|
90
|
+
// qqbot_get_member — 获取成员详情
|
|
91
|
+
new ToolDef(
|
|
92
|
+
'qqbot_get_member',
|
|
93
|
+
'获取指定成员详情',
|
|
94
|
+
{
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
guildId: { type: 'string', description: '频道 ID' },
|
|
98
|
+
userId: { type: 'string', description: '用户 ID' }
|
|
99
|
+
},
|
|
100
|
+
required: ['guildId', 'userId']
|
|
101
|
+
},
|
|
102
|
+
createExecutor(qqbotChannelApiTools.qqbot_get_member)
|
|
103
|
+
),
|
|
104
|
+
|
|
105
|
+
// qqbot_list_members — 获取成员列表(分页)
|
|
106
|
+
new ToolDef(
|
|
107
|
+
'qqbot_list_members',
|
|
108
|
+
'获取频道成员列表(分页),首次调用 after=0',
|
|
109
|
+
{
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
guildId: { type: 'string', description: '频道 ID' },
|
|
113
|
+
limit: { type: 'number', description: '每页数量(1-400)' },
|
|
114
|
+
after: { type: 'string', description: '上一页最后一条的 user.id,首次填 0' }
|
|
115
|
+
},
|
|
116
|
+
required: ['guildId']
|
|
117
|
+
},
|
|
118
|
+
createExecutor(qqbotChannelApiTools.qqbot_list_members)
|
|
119
|
+
),
|
|
120
|
+
|
|
121
|
+
// qqbot_get_channel_online — 获取在线人数
|
|
122
|
+
new ToolDef(
|
|
123
|
+
'qqbot_get_channel_online',
|
|
124
|
+
'获取子频道在线人数',
|
|
125
|
+
{
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
channelId: { type: 'string', description: '子频道 ID' }
|
|
129
|
+
},
|
|
130
|
+
required: ['channelId']
|
|
131
|
+
},
|
|
132
|
+
createExecutor(qqbotChannelApiTools.qqbot_get_channel_online)
|
|
133
|
+
),
|
|
134
|
+
|
|
135
|
+
// qqbot_remind — 定时提醒
|
|
136
|
+
new ToolDef(
|
|
137
|
+
'qqbot_remind',
|
|
138
|
+
`QQ Bot 定时提醒。支持:
|
|
139
|
+
- 一次性:time = "5m"(5分钟)、"1h30m"(1.5小时)
|
|
140
|
+
- 周期性:time = "0 8 * * *"(每天8点),需设置 tz = "Asia/Shanghai"
|
|
141
|
+
|
|
142
|
+
注意:必须提供 targetId(openid 或 group_openid)和 content。
|
|
143
|
+
|
|
144
|
+
示例:
|
|
145
|
+
{ "action": "add", "content": "喝水", "time": "5m", "targetId": "群OPENID" }`,
|
|
146
|
+
{
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
action: { type: 'string', enum: ['add', 'list', 'remove'], description: '操作类型' },
|
|
150
|
+
content: { type: 'string', description: '提醒内容' },
|
|
151
|
+
time: { type: 'string', description: '相对时间 (5m, 1h30m) 或 cron 表达式 ("0 8 * * *")' },
|
|
152
|
+
targetId: { type: 'string', description: '目标 openid 或 group_openid' },
|
|
153
|
+
accountId: { type: 'string', description: '使用的 QQ 账户 ID(可选)' },
|
|
154
|
+
jobId: { type: 'string', description: '任务 ID(仅 remove 使用)' }
|
|
155
|
+
},
|
|
156
|
+
required: ['action']
|
|
157
|
+
},
|
|
158
|
+
createExecutor(qqbotRemindTools.qqbotRemind)
|
|
159
|
+
),
|
|
160
|
+
|
|
161
|
+
// qqbot_media_upload — 富媒体上传
|
|
162
|
+
new ToolDef(
|
|
163
|
+
'qqbot_media_upload',
|
|
164
|
+
`上传并发送图片/文件/语音。
|
|
165
|
+
重要:文件必须位于 ~/.openclaw/media/qqbot/ 或 ~/.openclaw/media/ 目录下(安全限制)。
|
|
166
|
+
自动检测文件类型:图片(jpg/png/gif)、视频(mp4/mkv)、语音(mp3/silk)、文件(其他)。
|
|
167
|
+
|
|
168
|
+
示例:
|
|
169
|
+
{ "path": "/home/user/.openclaw/media/qqbot/result.png", "scope": "group", "targetId": "群OPENID" }`,
|
|
170
|
+
{
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
path: { type: 'string', description: '本地文件绝对路径' },
|
|
174
|
+
scope: { type: 'string', enum: ['c2c', 'group'], description: '发送范围' },
|
|
175
|
+
targetId: { type: 'string', description: '目标 ID' },
|
|
176
|
+
accountId: { type: 'string', description: '使用的 QQ 账户 ID(可选)' },
|
|
177
|
+
appId: { type: 'string', description: 'QQ Bot AppID(可选,默认使用环境变量)' },
|
|
178
|
+
clientSecret: { type: 'string', description: 'QQ Bot ClientSecret(可选)' }
|
|
179
|
+
},
|
|
180
|
+
required: ['path', 'scope', 'targetId']
|
|
181
|
+
},
|
|
182
|
+
createExecutor(qqbotMediaTools.qqbot_media_upload)
|
|
183
|
+
)
|
|
184
|
+
]
|
|
185
|
+
|