@raolin2025/claude-code-node 2.4.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.4.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": {
@@ -195,82 +195,140 @@ class TelegramChannel {
195
195
  }
196
196
 
197
197
  // ============================================================
198
- // QQ Bot 通道适配器 v2.0
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
- * 使用 QQ Bot API v2,仅需 appId + clientSecret
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.appId = config.appId || process.env.CC_NODE_CHANNEL_QQBOT_APPID || ''
209
- this.secret = config.secret || config.clientSecret || process.env.CC_NODE_CHANNEL_QQBOT_SECRET || ''
210
- this._token = null
211
- this._tokenCache = { token: null, expireAt: 0 }
212
- this.channelId = config.channelId || ''
213
- this.groupOpenId = config.groupOpenId || ''
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
- get name() { return 'qqbot' }
225
+ // 创建增强版 QQBot 实例
226
+ this.bot = new QQBotEnhanced(qqbotConfig)
217
227
 
218
- async _getToken() {
219
- if (this._tokenCache.token && Date.now() < this._tokenCache.expireAt - 300000) {
220
- return this._tokenCache.token
221
- }
222
- const res = await fetch('https://bots.qq.com/app/getAppAccessToken', {
223
- method: 'POST',
224
- headers: { 'Content-Type': 'application/json' },
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
- async send(text, options = {}) {
236
- if (!this.appId || !this.secret) {
237
- return [{ channel: 'qqbot', ok: false, error: '需要 appId 和 clientSecret' }]
237
+ /** 启动消息监听 */
238
+ async listen(onMessage) {
239
+ if (!this.enabled) {
240
+ console.warn('[QQBotChannel] 通道未启用')
241
+ return
238
242
  }
239
243
  try {
240
- const token = await this._getToken()
241
- const results = []
242
-
243
- const scope = options.scope || (this.groupOpenId ? 'group' : '')
244
- const targetId = options.targetId || this.groupOpenId || this.channelId
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
- if (!scope || !targetId) {
247
- return [{ channel: 'qqbot', ok: false, error: '未配置目标 (groupOpenId)' }]
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
- const path = scope === 'group'
251
- ? '/v2/groups/' + targetId + '/messages'
252
- : '/v2/users/' + targetId + '/messages'
269
+ /** 停止监听 */
270
+ stop() {
271
+ if (this.bot) {
272
+ this.bot.stop()
273
+ }
274
+ }
253
275
 
254
- const body = { content: text.slice(0, 2000), msg_type: 0 }
255
- if (options.replyMsgId) body.msg_id = options.replyMsgId
276
+ /** 发送消息(支持富媒体标签) */
277
+ async send(text, options = {}) {
278
+ if (!this.enabled) {
279
+ return [{ channel: 'qqbot', ok: false, error: '通道未启用' }]
280
+ }
256
281
 
257
- const r = await fetch('https://api.sgroup.qq.com' + path, {
258
- method: 'POST',
259
- headers: { 'Content-Type': 'application/json', Authorization: 'QQBot ' + token },
260
- body: JSON.stringify(body),
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
- if (!r.ok) {
263
- const errText = await r.text().catch(() => '')
264
- throw new Error('HTTP ' + r.status + ': ' + errText.slice(0, 100))
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
- return [{ channel: 'qqbot', ok: false, error: e.message.slice(0, 200) }]
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/cli.js CHANGED
@@ -408,18 +408,58 @@ export async function main() {
408
408
  await config.load(process.cwd())
409
409
 
410
410
  const apiBase = cliArgs.apiBase || config.get('apiBase') || process.env.LLM_API_BASE || ''
411
- // 智能默认模型:根据 apiBase 自动选择
412
- function getDefaultModel(base) {
413
- if (!base) return 'deepseek-chat' // 无 apiBase → DeepSeek 默认
414
- if (base.includes('11434')) return 'llama3' // Ollama 本地
415
- if (base.includes('dashscope')) return 'qwen-plus' // 通义千问
416
- if (base.includes('bigmodel.cn')) return 'glm-4-flash' // 智谱 GLM
417
- if (base.includes('moonshot')) return 'kimi-k2-0711' // Moonshot
418
- if (base.includes('openai.com')) return 'gpt-4o' // OpenAI
419
- return 'deepseek-chat' // 其他情况默认 DeepSeek
411
+ let model = cliArgs.model || config.get('model') || ''
412
+ // 未指定模型 → 自动从 API 拉取模型列表让用户选择
413
+ if (!model && apiBase) {
414
+ const apiKeyForModels = cliArgs.apiKey || config.get('apiKey') || process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || ''
415
+ if (apiKeyForModels) {
416
+ try {
417
+ const modelsUrl = apiBase.replace(/\/+$/, '') + '/models'
418
+ console.log('⚠️ 未指定模型,正在从 API 获取可用模型列表...')
419
+ const res = await fetch(modelsUrl, { headers: { 'Authorization': `Bearer ${apiKeyForModels}` } })
420
+ if (res.ok) {
421
+ const data = await res.json()
422
+ const models = data.data || []
423
+ if (models.length > 0) {
424
+ console.log(`\n可用模型 (${models.length}):`)
425
+ models.forEach((m, i) => {
426
+ const id = m.id || m
427
+ console.log(` ${(i + 1).toString().padStart(2)}. ${id}`)
428
+ })
429
+ console.log('输入编号选择,或直接输入模型名(回车跳过用 deepseek-chat):')
430
+ // 用 readline 等待输入(此时 REPL 还没启动,需要临时创建)
431
+ const tmpRl = createInterface({ input: process.stdin, output: process.stdout })
432
+ const answer = await new Promise(resolve => tmpRl.question('> ', resolve))
433
+ tmpRl.close()
434
+ const num = parseInt(answer, 10)
435
+ if (!isNaN(num) && num >= 1 && num <= models.length) {
436
+ model = models[num - 1].id || models[num - 1]
437
+ } else if (answer.trim()) {
438
+ model = answer.trim()
439
+ } else {
440
+ model = 'deepseek-chat'
441
+ }
442
+ console.log(`✅ Model → ${model}`)
443
+ } else {
444
+ model = 'deepseek-chat'
445
+ console.log('API 返回空模型列表,使用默认: deepseek-chat')
446
+ }
447
+ } else {
448
+ model = 'deepseek-chat'
449
+ console.log('无法获取模型列表,使用默认: deepseek-chat')
450
+ }
451
+ } catch (e) {
452
+ model = 'deepseek-chat'
453
+ console.log(`获取模型列表失败 (${e.message}),使用默认: deepseek-chat`)
454
+ }
455
+ } else {
456
+ model = 'deepseek-chat'
457
+ }
458
+ } else if (!model) {
459
+ model = 'deepseek-chat' // 无 apiBase 也无 model → DeepSeek 默认
420
460
  }
421
- const model = cliArgs.model || config.get('model') || getDefaultModel(apiBase)
422
- const systemPrompt = cliArgs.systemPrompt || ''
461
+ const DEFAULT_SYSTEM_PROMPT = `You are cc-node, an AI coding assistant. Configuration files: user-level ~/.claude-code/config.json, project-level .claude-code/config.json (in project root). Runtime files (pid/socket): ~/.cc-node/. Never reference settings.json or .claude.json — those paths do not exist.`
462
+ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
423
463
  const permissionMode = cliArgs.permissionMode || config.get('permissionMode')
424
464
  const maxTurns = cliArgs.maxTurns || config.get('maxTurns')
425
465
  const apiKey = cliArgs.apiKey || config.get('apiKey') || ''
@@ -26,8 +26,26 @@ const DEFAULTS = {
26
26
  fileRead: { maxLines: 2000, maxSizeKB: 256 },
27
27
  webFetch: { timeout: 30, maxChars: 100000 },
28
28
  },
29
- channels: {},
30
- defaultChannel: null,
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
  },
@@ -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
+