@raolin2025/claude-code-node 2.5.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.1",
|
|
4
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",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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 '../src/channel/qqbot-enhanced.js'
|
|
14
|
+
import { parseQQMediaTags } from '../src/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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 定时提醒工具 — 基于 cron 的定时任务(简化版)
|
|
3
|
+
*
|
|
4
|
+
* 注意:此工具依赖主项目的定时任务系统。当前实现为占位符,
|
|
5
|
+
* 实际功能待后续与 cc-node 的调度系统集成后完成。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// 时间解析器
|
|
9
|
+
function parseRelativeTime(timeStr) {
|
|
10
|
+
const match = timeStr.match(/^(\d+)(h|m|s)$/)
|
|
11
|
+
if (!match) return null
|
|
12
|
+
const [, value, unit] = match
|
|
13
|
+
const num = parseInt(value, 10)
|
|
14
|
+
const multipliers = { s: 1000, m: 60000, h: 3600000 }
|
|
15
|
+
return num * multipliers[unit]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 主工具函数 */
|
|
19
|
+
async function qqbotRemind(args) {
|
|
20
|
+
const { action, content, time, targetId, accountId, jobId } = args
|
|
21
|
+
|
|
22
|
+
if (!action || !['add', 'list', 'remove'].includes(action)) {
|
|
23
|
+
return { ok: false, error: 'action 必须为 add/list/remove' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 占位:说明需要集成调度系统
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `qqbot_remind 尚未完整集成定时任务系统。当前收到: action=${action}, content=${content}, time=${time}, targetId=${targetId}`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 导出
|
|
34
|
+
export const tools = {
|
|
35
|
+
qqbotRemind
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const metadata = {
|
|
39
|
+
name: 'qqbot-remind',
|
|
40
|
+
description: 'QQ Bot 定时提醒工具 —— 支持一次性/周期性提醒(待集成调度系统)',
|
|
41
|
+
tools: ['qqbot_remind']
|
|
42
|
+
}
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ToolDef } from '../types/index.js'
|
|
11
|
-
import { tools as qqbotChannelApiTools } from '
|
|
12
|
-
import { tools as qqbotRemindTools } from '
|
|
13
|
-
import { tools as qqbotMediaTools } from '
|
|
11
|
+
import { tools as qqbotChannelApiTools } from './qqbot-channel-api.js'
|
|
12
|
+
import { tools as qqbotRemindTools } from './qqbot-remind.js'
|
|
13
|
+
import { tools as qqbotMediaTools } from './qqbot-media.js'
|
|
14
14
|
|
|
15
15
|
// 通用执行器:将原始函数包装为 ToolDef 的执行格式
|
|
16
16
|
function createExecutor(originalFunc) {
|