@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.
@@ -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
@@ -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,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
+ }