@raolin2025/claude-code-node 2.6.1 → 2.6.2

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,319 @@
1
+ /**
2
+ * Telegram 工具 — 发送消息/媒体、通用 Bot API 调用、定时提醒
3
+ *
4
+ * 替代原 qqbot_* 工具(QQ 通道已放弃,统一改用 Telegram)。
5
+ *
6
+ * 配置:
7
+ * CC_NODE_CHANNEL_TELEGRAM_TOKEN — Bot Token(必填)
8
+ * CC_NODE_CHANNEL_TELEGRAM_CHAT_ID — 默认聊天 ID(可选)
9
+ * CC_NODE_CHANNEL_TELEGRAM_PROXY — SOCKS5 代理(可选)
10
+ * CC_NODE_CHANNEL_TELEGRAM_API_BASE— 自定义 API Base(可选)
11
+ *
12
+ * 依赖复用 src/channel/tg-listener.js 中的 TelegramBotClient,
13
+ * 保证与 cc-node 双向通道使用同一套客户端逻辑(速率限制、代理、重试)。
14
+ */
15
+
16
+ import { ToolDef } from '../types/index.js'
17
+ import { TelegramBotClient } from '../channel/tg-listener.js'
18
+
19
+ const API_BASE = (token) => `https://api.telegram.org/bot${token}`
20
+
21
+ /** 读取配置 */
22
+ function getToken() {
23
+ return process.env.CC_NODE_CHANNEL_TELEGRAM_TOKEN || ''
24
+ }
25
+
26
+ function getDefaultChatId() {
27
+ return process.env.CC_NODE_CHANNEL_TELEGRAM_CHAT_ID || ''
28
+ }
29
+
30
+ /** 创建 TelegramBotClient 实例 */
31
+ function getClient() {
32
+ const token = getToken()
33
+ if (!token) {
34
+ throw new Error('未配置 Telegram Token(请设置 CC_NODE_CHANNEL_TELEGRAM_TOKEN)')
35
+ }
36
+ const proxy = process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || ''
37
+ const apiBase = process.env.CC_NODE_CHANNEL_TELEGRAM_API_BASE || API_BASE(token)
38
+ return new TelegramBotClient(token, { proxy, apiBase })
39
+ }
40
+
41
+ /** 通用执行器:包装为 ToolDef 的执行格式 */
42
+ function createExecutor(originalFunc) {
43
+ return async (input) => {
44
+ try {
45
+ const result = await originalFunc(input)
46
+ if (result && result.ok) {
47
+ return typeof result.result !== 'undefined' ? result.result : result
48
+ }
49
+ return result
50
+ } catch (e) {
51
+ return `[ERROR] ${e.message}`
52
+ }
53
+ }
54
+ }
55
+
56
+ /** 校验聊天 ID(允许用默认值) */
57
+ function resolveChatId(chatId) {
58
+ return chatId || getDefaultChatId()
59
+ }
60
+
61
+ // ============================================================
62
+ // 具体工具函数
63
+ // ============================================================
64
+
65
+ /** 发送文本消息 */
66
+ async function sendMessage(args) {
67
+ const { chatId, text, parseMode, silent, disableWebPreview } = args
68
+ const resolvedChatId = resolveChatId(chatId)
69
+ if (!resolvedChatId) throw new Error('chatId 必填(或设置 CC_NODE_CHANNEL_TELEGRAM_CHAT_ID 作为默认)')
70
+ if (!text) throw new Error('text 必填')
71
+
72
+ const client = getClient()
73
+ const result = await client.sendMessage(resolvedChatId, text, {
74
+ parseMode: parseMode || 'HTML',
75
+ silent: silent || false,
76
+ disableWebPreview: disableWebPreview ?? true,
77
+ })
78
+ return { ok: true, messageId: result.message_id, chat: result.chat?.id }
79
+ }
80
+
81
+ /** 发送媒体文件(图片/文档/音频/视频) */
82
+ async function sendMedia(args) {
83
+ const { chatId, path, caption, mediaType } = args
84
+ const resolvedChatId = resolveChatId(chatId)
85
+ if (!resolvedChatId) throw new Error('chatId 必填(或设置 CC_NODE_CHANNEL_TELEGRAM_CHAT_ID 作为默认)')
86
+ if (!path) throw new Error('path 必填')
87
+
88
+ const { readFileSync, existsSync } = await import('fs')
89
+ const { resolve, basename } = await import('path')
90
+
91
+ const absPath = resolve(path)
92
+ if (!existsSync(absPath)) throw new Error(`文件不存在: ${path}`)
93
+
94
+ const client = getClient()
95
+ const token = getToken()
96
+ const apiBase = client.apiBase || API_BASE(token)
97
+
98
+ // 检测文件类型
99
+ const ext = absPath.split('.').pop().toLowerCase()
100
+ const type = mediaType || (
101
+ ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext) ? 'photo'
102
+ : ['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(ext) ? 'video'
103
+ : ['mp3', 'ogg', 'm4a', 'wav', 'flac', 'aac'].includes(ext) ? 'audio'
104
+ : 'document'
105
+ )
106
+
107
+ const form = new FormData()
108
+ form.append('chat_id', String(resolvedChatId))
109
+ if (caption) form.append('caption', String(caption).slice(0, 1024))
110
+
111
+ // 上传文件
112
+ const buf = readFileSync(absPath)
113
+ const blob = new Blob([buf])
114
+ // 不同文件类型的 multipart 字段名
115
+ const fieldName = {
116
+ photo: 'photo',
117
+ video: 'video',
118
+ audio: 'audio',
119
+ document: 'document',
120
+ sticker: 'sticker',
121
+ }[type] || 'document'
122
+
123
+ // 带文件名(用 File 形式,node 18+ 支持)
124
+ const FileCtor = (typeof File !== 'undefined') ? File : null
125
+ let filePart
126
+ if (FileCtor) {
127
+ filePart = new File([buf], basename(absPath))
128
+ } else {
129
+ // 兼容:手动组装 multipart
130
+ form.append(fieldName, blob, basename(absPath))
131
+ filePart = null
132
+ }
133
+ if (filePart) form.append(fieldName, filePart)
134
+
135
+ const method = type === 'photo' ? 'sendPhoto'
136
+ : type === 'video' ? 'sendVideo'
137
+ : type === 'audio' ? 'sendAudio'
138
+ : type === 'sticker' ? 'sendSticker'
139
+ : 'sendDocument'
140
+
141
+ const res = await client._fetch(`${apiBase}/${method}`, {
142
+ method: 'POST',
143
+ body: form,
144
+ })
145
+ const data = await res.json()
146
+ if (!data.ok) {
147
+ throw new Error(`Telegram ${method} ${data.error_code}: ${data.description?.slice(0, 200) || 'unknown'}`)
148
+ }
149
+ return { ok: true, type, messageId: data.result?.message_id, chat: data.result?.chat?.id }
150
+ }
151
+
152
+ /** 通用 Telegram Bot API 调用 */
153
+ async function channelApi(args) {
154
+ const { method = 'POST', path, body = {}, query = {} } = args
155
+ if (!path) throw new Error('path 必填(如 /sendMessage、/getMe、/getUpdates)')
156
+
157
+ const client = getClient()
158
+ const token = getToken()
159
+ const apiBase = client.apiBase || API_BASE(token)
160
+
161
+ const url = new URL(apiBase + (path.startsWith('/') ? path : '/' + path))
162
+ for (const [k, v] of Object.entries(query)) url.searchParams.append(k, String(v))
163
+
164
+ const res = await client._fetch(url.toString(), {
165
+ method,
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: ['POST', 'PUT', 'PATCH'].includes(method) ? JSON.stringify(body) : undefined,
168
+ })
169
+ const raw = await res.text()
170
+ if (!res.ok) {
171
+ throw new Error(`Telegram API ${method} ${path} → ${res.status}: ${raw.slice(0, 200)}`)
172
+ }
173
+ const data = raw.trim() ? JSON.parse(raw) : null
174
+ if (data && !data.ok) {
175
+ throw new Error(`Telegram API ${data.error_code}: ${data.description?.slice(0, 200) || 'unknown'}`)
176
+ }
177
+ return data?.result ?? data
178
+ }
179
+
180
+ /** 获取机器人信息 */
181
+ async function getMe() {
182
+ const result = await channelApi({ method: 'POST', path: '/getMe' })
183
+ return { ok: true, username: result?.username, id: result?.id, firstName: result?.first_name }
184
+ }
185
+
186
+ /** 定时提醒(复用 qqbot_remind 的调度占位,目标改为 Telegram chatId) */
187
+ async function remind(args) {
188
+ const { action, content, time, chatId, jobId } = args
189
+ if (!action || !['add', 'list', 'remove'].includes(action)) {
190
+ throw new Error('action 必须为 add/list/remove')
191
+ }
192
+ const resolvedChatId = resolveChatId(chatId)
193
+ if (!resolvedChatId) throw new Error('chatId 必填(或设置 CC_NODE_CHANNEL_TELEGRAM_CHAT_ID 作为默认)')
194
+
195
+ // TODO: 与 cc-node 调度系统集成后实现真正的定时发送
196
+ // 当前占位:返回需集成的提示
197
+ return {
198
+ ok: false,
199
+ error: `telegram_remind 尚未完整集成定时任务系统。当前收到: action=${action}, content=${content}, time=${time}, chatId=${resolvedChatId}`
200
+ }
201
+ }
202
+
203
+ // ============================================================
204
+ // 导出工具定义
205
+ // ============================================================
206
+
207
+ export const telegramTools = [
208
+ new ToolDef(
209
+ 'telegram_send_message',
210
+ `发送文本消息到 Telegram 聊天。
211
+ 使用方法:
212
+ chatId: 目标聊天 ID(数字或 @username;留空用 CC_NODE_CHANNEL_TELEGRAM_CHAT_ID 默认值)
213
+ text: 消息内容(Telegram 单条上限 4096 字符,超长自动截断)
214
+ parseMode: 解析模式(HTML 或 Markdown,默认 HTML)
215
+ silent: 是否静默发送(可选)
216
+ disableWebPreview: 是否禁用网页预览(可选)
217
+
218
+ 示例:
219
+ - 发送文本: { "chatId": "123456789", "text": "任务完成 ✅" }`,
220
+ {
221
+ type: 'object',
222
+ properties: {
223
+ chatId: { type: 'string', description: '目标聊天 ID(数字或 @username,可省略用默认)' },
224
+ text: { type: 'string', description: '消息内容' },
225
+ parseMode: { type: 'string', enum: ['HTML', 'Markdown'], description: '解析模式' },
226
+ silent: { type: 'boolean', description: '静默发送' },
227
+ disableWebPreview: { type: 'boolean', description: '禁用网页预览' }
228
+ },
229
+ required: ['text']
230
+ },
231
+ createExecutor(sendMessage)
232
+ ),
233
+
234
+ new ToolDef(
235
+ 'telegram_send_media',
236
+ `发送图片/文件/音频/视频到 Telegram 聊天。
237
+ 使用方法:
238
+ chatId: 目标聊天 ID(可省略用默认)
239
+ path: 本地文件绝对路径
240
+ caption: 附加说明文字(可选)
241
+ mediaType: 文件类型 photo|video|audio|document|sticker(可选,自动检测)
242
+
243
+ 示例:
244
+ { "chatId": "123456789", "path": "/tmp/result.png", "caption": "结果截图" }`,
245
+ {
246
+ type: 'object',
247
+ properties: {
248
+ chatId: { type: 'string', description: '目标聊天 ID(可省略用默认)' },
249
+ path: { type: 'string', description: '本地文件绝对路径' },
250
+ caption: { type: 'string', description: '附加说明文字' },
251
+ mediaType: { type: 'string', enum: ['photo', 'video', 'audio', 'document', 'sticker'], description: '文件类型' }
252
+ },
253
+ required: ['path']
254
+ },
255
+ createExecutor(sendMedia)
256
+ ),
257
+
258
+ new ToolDef(
259
+ 'telegram_channel_api',
260
+ `调用 Telegram Bot API(通用)。
261
+ 使用方法:
262
+ method: HTTP 方法 (GET/POST)
263
+ path: API 路径,如 /getMe、/getUpdates、/sendMessage(自动加 /bot<token> 前缀)
264
+ body: 请求体 JSON(POST 使用)
265
+ query: URL 查询参数对象
266
+
267
+ 示例:
268
+ - 获取机器人信息: { "method": "POST", "path": "/getMe" }
269
+ - 获取更新: { "method": "POST", "path": "/getUpdates", "body": { "limit": 10 } }`,
270
+ {
271
+ type: 'object',
272
+ properties: {
273
+ method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP 方法' },
274
+ path: { type: 'string', description: 'API 路径,如 /getMe、/sendMessage' },
275
+ body: { type: 'object', description: '请求体 JSON(POST 使用)' },
276
+ query: { type: 'object', additionalProperties: { type: 'string' }, description: 'URL 查询参数' }
277
+ },
278
+ required: ['method', 'path']
279
+ },
280
+ createExecutor(channelApi)
281
+ ),
282
+
283
+ new ToolDef(
284
+ 'telegram_get_me',
285
+ '获取 Telegram 机器人自身信息(用户名、ID)。无需参数。',
286
+ { type: 'object', properties: {} },
287
+ createExecutor(getMe)
288
+ ),
289
+
290
+ new ToolDef(
291
+ 'telegram_remind',
292
+ `Telegram 定时提醒(计划集成调度系统)。
293
+ 使用方法:
294
+ action: add|list|remove
295
+ content: 提醒内容
296
+ time: 相对时间 (5m, 1h30m) 或 cron 表达式
297
+ chatId: 目标聊天 ID(可省略用默认)
298
+
299
+ 注意:当前为占位实现,完整调度待集成。`,
300
+ {
301
+ type: 'object',
302
+ properties: {
303
+ action: { type: 'string', enum: ['add', 'list', 'remove'], description: '操作类型' },
304
+ content: { type: 'string', description: '提醒内容' },
305
+ time: { type: 'string', description: '相对时间 (5m, 1h30m) 或 cron 表达式 ("0 8 * * *")' },
306
+ chatId: { type: 'string', description: '目标聊天 ID(可省略用默认)' },
307
+ jobId: { type: 'string', description: '任务 ID(仅 remove 使用)' }
308
+ },
309
+ required: ['action']
310
+ },
311
+ createExecutor(remind)
312
+ ),
313
+ ]
314
+
315
+ export const metadata = {
316
+ name: 'telegram-tools',
317
+ description: 'Telegram 工具:发送消息/媒体、通用 Bot API 调用、定时提醒',
318
+ tools: telegramTools.map(t => t.name)
319
+ }
@@ -24,8 +24,13 @@ export class Message {
24
24
  }
25
25
 
26
26
  export class UserMessage extends Message {
27
- constructor(content) {
27
+ /**
28
+ * @param {string} content 文本内容
29
+ * @param {string[]} [images] 图片 URL 列表(data URL 或 http URL,视觉模型用)
30
+ */
31
+ constructor(content, images = []) {
28
32
  super(Role.USER, content)
33
+ this.images = images
29
34
  }
30
35
  }
31
36
 
@@ -76,7 +76,7 @@ export function isLocalHostname(hostname) {
76
76
 
77
77
  /**
78
78
  * 判断一个 apiBase 是否指向「自建本地 LLM 服务」(无需强制 apiKey)
79
- * @param {string} apiBase - 如 http://192.168.1.50:11434/v1
79
+ * @param {string} apiBase - 如 http://127.0.0.1:11434/v1
80
80
  */
81
81
  export function isLocalLlmServer(apiBase) {
82
82
  if (!apiBase) return false
@@ -1,147 +0,0 @@
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
- }
@@ -1,177 +0,0 @@
1
- /**
2
- * QQ Bot 富媒体工具 — 图片/语音/文件上传与发送
3
- *
4
- * 功能:
5
- * - 验证文件路径(必须在 ~/.openclaw/media/qqbot 或 ~/.openclaw/media)
6
- * - 自动检测文件类型
7
- * - 上传并通过 QQ Bot 发送
8
- *
9
- * 使用方式:
10
- * qqbot_media: { action: 'upload', path: '/home/.../image.png', scope: 'group', targetId: '群OPENID' }
11
- */
12
-
13
- import { QQBotEnhanced } from '../channel/qqbot-enhanced.js'
14
- import { parseQQMediaTags } from '../channel/qqbot-enhanced.js'
15
-
16
- const API_BASE = 'https://api.sgroup.qq.com'
17
-
18
- async function getToken(appId, clientSecret) {
19
- const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
20
- const res = await fetch(TOKEN_URL, {
21
- method: 'POST',
22
- headers: { 'Content-Type': 'application/json' },
23
- body: JSON.stringify({ appId, clientSecret }),
24
- })
25
- if (!res.ok) throw new Error(`Token API ${res.status}`)
26
- const data = await res.json()
27
- if (!data.access_token) throw new Error('No access_token')
28
- return data.access_token
29
- }
30
-
31
- async function uploadFileToQQ(token, scope, targetId, filePath, fileType) {
32
- const { readFileSync, existsSync } = await import('fs')
33
- const { resolve } = await import('path')
34
-
35
- const absPath = resolve(filePath)
36
- if (!existsSync(absPath)) {
37
- throw new Error(`文件不存在: ${filePath}`)
38
- }
39
-
40
- // 安全检查:必须在 media/qqbot 或 media 目录下
41
- const allowedDirs = [
42
- process.env.HOME + '/.openclaw/media/qqbot',
43
- process.env.HOME + '/.openclaw/media'
44
- ]
45
- const isAllowed = allowedDirs.some(dir => absPath.startsWith(dir))
46
- if (!isAllowed) {
47
- throw new Error(`安全限制: 文件必须在 ~/.openclaw/media/qqbot 或 ~/.openclaw/media 目录下`)
48
- }
49
-
50
- const path = scope === 'group'
51
- ? `/v2/groups/${targetId}/files`
52
- : `/v2/users/${targetId}/files`
53
-
54
- const body = {
55
- file_type: fileType,
56
- srv_send_msg: false
57
- }
58
-
59
- // 读取文件并转为 base64
60
- const buf = readFileSync(absPath)
61
- body.file_data = buf.toString('base64')
62
-
63
- const res = await fetch(API_BASE + path, {
64
- method: 'POST',
65
- headers: {
66
- 'Authorization': `QQBot ${token}`,
67
- 'Content-Type': 'application/json',
68
- },
69
- body: JSON.stringify(body),
70
- })
71
-
72
- if (!res.ok) {
73
- const err = await res.text().catch(() => '')
74
- throw new Error(`上传失败 ${res.status}: ${err.slice(0, 100)}`)
75
- }
76
-
77
- const result = await res.json()
78
- if (!result.file_info) {
79
- throw new Error(`上传响应异常: ${JSON.stringify(result)}`)
80
- }
81
-
82
- return result.file_info
83
- }
84
-
85
- async function sendMediaMessage(token, scope, targetId, fileInfo) {
86
- const path = scope === 'group'
87
- ? `/v2/groups/${targetId}/messages`
88
- : `/v2/users/${targetId}/messages`
89
-
90
- const body = {
91
- msg_type: 7, // 媒体消息
92
- media: { file_info: fileInfo }
93
- }
94
-
95
- const res = await fetch(API_BASE + path, {
96
- method: 'POST',
97
- headers: {
98
- 'Authorization': `QQBot ${token}`,
99
- 'Content-Type': 'application/json',
100
- },
101
- body: JSON.stringify(body),
102
- })
103
-
104
- if (!res.ok) {
105
- const err = await res.text().catch(() => '')
106
- throw new Error(`发送失败 ${res.status}: ${err.slice(0, 100)}`)
107
- }
108
-
109
- return res.json()
110
- }
111
-
112
- /** 工具函数 */
113
-
114
- async function uploadAndSendMedia(params) {
115
- const { path: filePath, scope, targetId, appId, clientSecret } = params
116
-
117
- if (!filePath) throw new Error('path 必填')
118
- if (!scope || !targetId) throw new Error('scope 和 targetId 必填')
119
-
120
- // 获取凭证
121
- const resolvedAppId = appId || process.env.CC_NODE_CHANNEL_QQBOT_APPID || ''
122
- const resolvedSecret = clientSecret || process.env.CC_NODE_CHANNEL_QQBOT_SECRET || ''
123
- const token = await getToken(resolvedAppId, resolvedSecret)
124
-
125
- // 检测文件类型
126
- const ext = filePath.split('.').pop().toLowerCase()
127
- let fileType
128
- if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) fileType = 1
129
- else if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(ext)) fileType = 2
130
- else if (['silk', 'wav', 'mp3', 'ogg', 'aac', 'flac', 'm4a'].includes(ext)) fileType = 3
131
- else fileType = 4
132
-
133
- // 上传
134
- const fileInfo = await uploadFileToQQ(token, scope, targetId, filePath, fileType)
135
-
136
- // 发送
137
- const result = await sendMediaMessage(token, scope, targetId, fileInfo)
138
-
139
- return { ok: true, fileInfo, result }
140
- }
141
-
142
- /** 解析文本中的 <qqmedia> 标签并批量处理 */
143
- async function processQQMediaText(text, context) {
144
- const { mediaFiles } = parseQQMediaTags(text)
145
- const scope = context.scope || 'group'
146
- const targetId = context.targetId
147
-
148
- const results = []
149
- for (const media of mediaFiles) {
150
- try {
151
- const result = await uploadAndSendMedia({
152
- path: media.path,
153
- scope,
154
- targetId,
155
- appId: context.appId,
156
- clientSecret: context.clientSecret
157
- })
158
- results.push({ ok: true, path: media.path, result })
159
- } catch (e) {
160
- results.push({ ok: false, path: media.path, error: e.message })
161
- }
162
- }
163
-
164
- return results
165
- }
166
-
167
- // 导出
168
- export const tools = {
169
- qqbot_media_upload: async (args) => await uploadAndSendMedia(args),
170
- qqbot_media_process_text: async (args) => await processQQMediaText(args.text, args.context)
171
- }
172
-
173
- export const metadata = {
174
- name: 'qqbot-media',
175
- description: 'QQ Bot 富媒体上传工具,支持图片、文件、语音',
176
- tools: ['qqbot_media_upload', 'qqbot_media_process_text']
177
- }