@raolin2025/claude-code-node 2.5.2 → 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.
- package/README.md +33 -2
- package/package.json +2 -2
- package/src/__tests__/llm-server.test.js +62 -0
- package/src/channel/notify-daemon.js +105 -18
- package/src/channel/qqbot-listener.js +3 -7
- package/src/channel/tg-listener.js +1 -1
- package/src/channel/tg-proxy.js +105 -32
- package/src/core/cli.js +220 -58
- package/src/core/index.js +2 -0
- package/src/core/query-engine.js +40 -9
- package/src/stdio/server.js +426 -0
- package/src/tools/index.js +3 -3
- package/src/tools/telegram-tools.js +319 -0
- package/src/types/index.js +6 -1
- package/src/utils/index.js +1 -0
- package/src/utils/llm-server.js +106 -0
- package/src/tools/qqbot-channel-api.js +0 -147
- package/src/tools/qqbot-media.js +0 -177
- package/src/tools/qqbot-remind.js +0 -42
- package/src/tools/qqbot-tools-wrapper.js +0 -185
|
@@ -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
|
+
}
|
package/src/types/index.js
CHANGED
|
@@ -24,8 +24,13 @@ export class Message {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export class UserMessage extends Message {
|
|
27
|
-
|
|
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
|
|
package/src/utils/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { unifiedDiff, inlineDiff } from './diff.js'
|
|
|
5
5
|
export { safeReadFile, safeWriteFile, editFile, pathExists, removeRecursive, getMtime, resolvePath } from './file-ops.js'
|
|
6
6
|
export { execCommand, spawnProcess, commandExists, sendInput } from './process.js'
|
|
7
7
|
export { format, codeBlock, formatPath, formatToolCall, formatToolResult, formatTokenUsage, formatDuration, formatBytes, formatTable, progressBar } from './format.js'
|
|
8
|
+
export { isLocalLlmServer, isLocalHostname, buildAuthHeaders } from './llm-server.js'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM 服务器识别工具
|
|
3
|
+
*
|
|
4
|
+
* 用于判断一个 `--api-base` 是否是「自建本地 LLM 服务」。
|
|
5
|
+
* 自建服务(Ollama / llama.cpp / vLLM 等)通常运行在 localhost 或内网,
|
|
6
|
+
* 且默认【不需要 apiKey】(认证是可选配置)。
|
|
7
|
+
*
|
|
8
|
+
* 用途:当 apiBase 指向自建服务时,允许缺省 apiKey 运行,
|
|
9
|
+
* 并将旧的 `Authorization: Bearer undefined` 替换为不附带该头。
|
|
10
|
+
* 这解决了公共项目接入 Ollama / llama.cpp / vLLM 时被强制要求
|
|
11
|
+
* 假 apiKey 的问题。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// RFC1918 私有段 + 回环
|
|
15
|
+
const PRIVATE_PATTERNS = [
|
|
16
|
+
/^127\./, // 127.0.0.0/8 回环
|
|
17
|
+
/^10\./, // 10.0.0.0/8
|
|
18
|
+
/^192\.168\./, // 192.168.0.0/16
|
|
19
|
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
|
|
20
|
+
/^169\.254\./, // 链路本地
|
|
21
|
+
/^::1$/, // IPv6 回环
|
|
22
|
+
/^fc[0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
|
|
23
|
+
/^fe[89ab][0-9a-f]:/i, // IPv6 link-local fe80::/10
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// 明确视为「本地自建」的主机名关键词
|
|
27
|
+
const LOCAL_HOSTNAME_KEYWORDS = [
|
|
28
|
+
'localhost',
|
|
29
|
+
'127.0.0.1',
|
|
30
|
+
'::1',
|
|
31
|
+
'.local',
|
|
32
|
+
'.lan',
|
|
33
|
+
'.localdomain',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
let cachedHost = null
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 从 URL 中提取 host(含端口),便于可读性判断
|
|
40
|
+
*/
|
|
41
|
+
function parseUrl(apiBase) {
|
|
42
|
+
try {
|
|
43
|
+
return new URL(apiBase)
|
|
44
|
+
} catch {
|
|
45
|
+
// 非标准 URL(可能缺 scheme),尝试补 http:// 再解析
|
|
46
|
+
try {
|
|
47
|
+
return new URL(apiBase.startsWith('http') ? apiBase : `http://${apiBase}`)
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 判断主机是否为「本地/内网/回环」地址
|
|
56
|
+
* @param {string} hostname - 无端口的主机名或 IP
|
|
57
|
+
*/
|
|
58
|
+
export function isLocalHostname(hostname) {
|
|
59
|
+
if (!hostname) return false
|
|
60
|
+
const h = hostname.toLowerCase()
|
|
61
|
+
|
|
62
|
+
// 主机名关键词
|
|
63
|
+
for (const kw of LOCAL_HOSTNAME_KEYWORDS) {
|
|
64
|
+
if (kw.startsWith('.') ? h.endsWith(kw) : (h === kw || h.startsWith(kw + '.'))) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// IPv4 / IPv6 私有段
|
|
70
|
+
for (const re of PRIVATE_PATTERNS) {
|
|
71
|
+
if (re.test(h)) return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 判断一个 apiBase 是否指向「自建本地 LLM 服务」(无需强制 apiKey)
|
|
79
|
+
* @param {string} apiBase - 如 http://127.0.0.1:11434/v1
|
|
80
|
+
*/
|
|
81
|
+
export function isLocalLlmServer(apiBase) {
|
|
82
|
+
if (!apiBase) return false
|
|
83
|
+
const url = parseUrl(apiBase)
|
|
84
|
+
if (!url || !url.hostname) return false
|
|
85
|
+
return isLocalHostname(url.hostname)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 计算请求应携带的 Authorization 头。
|
|
90
|
+
* - 若提供了 apiKey → 正常 Bearer
|
|
91
|
+
* - 若未提供 apiKey 且目标是【自建本地服务】 → 不附带该头(返回 null/undefined)
|
|
92
|
+
* - 若未提供 apiKey 且目标是【云端服务】 → 返回 null(由上层决定是否报错)
|
|
93
|
+
* @param {string} apiBase
|
|
94
|
+
* @param {string} apiKey
|
|
95
|
+
* @returns {{ Authorization: string } | undefined} 返回 undefined 表示不附带 Authorization 头
|
|
96
|
+
*/
|
|
97
|
+
export function buildAuthHeaders(apiBase, apiKey) {
|
|
98
|
+
if (apiKey) {
|
|
99
|
+
return { 'Authorization': `Bearer ${apiKey}` }
|
|
100
|
+
}
|
|
101
|
+
// 无 key:本地自建服务返回空对象(不带 Authorization),云端返回 undefined(保持不变,交由上层判断)
|
|
102
|
+
if (isLocalLlmServer(apiBase)) {
|
|
103
|
+
return {}
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
@@ -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
|
-
}
|