@tencent-connect/openclaw-qqbot 1.6.3 → 1.6.4-alpha.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/README.md CHANGED
@@ -163,7 +163,7 @@ Measures end-to-end latency from QQ server push to plugin response, broken down
163
163
 
164
164
  > **You**: `/bot-version`
165
165
  >
166
- > **QQBot**: 🦞 Framework: OpenClaw 2026.3.13 (61d171a) / 🤖 Plugin: v1.6.2 / 🌟 GitHub repo
166
+ > **QQBot**: 🦞 Framework: OpenClaw 2026.3.13 (61d171a) / 🤖 Plugin: v1.6.3 / 🌟 GitHub repo
167
167
 
168
168
  Shows framework version, plugin version, and a direct link to the official repository.
169
169
 
package/README.zh.md CHANGED
@@ -158,7 +158,7 @@ AI 可直接发送视频,支持本地文件和公网 URL。
158
158
 
159
159
  > **你**:`/bot-version`
160
160
  >
161
- > **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.2 / 🌟官方 GitHub 仓库
161
+ > **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.3 / 🌟官方 GitHub 仓库
162
162
 
163
163
  一目了然查看框架版本、插件版本,并可直接跳转官方仓库。
164
164
 
@@ -3,7 +3,7 @@
3
3
  "name": "OpenClaw QQ Bot",
4
4
  "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "channels": ["qqbot"],
6
- "skills": ["skills/qqbot-cron", "skills/qqbot-media"],
6
+ "skills": ["skills/qqbot-channel", "skills/qqbot-cron", "skills/qqbot-media"],
7
7
  "capabilities": {
8
8
  "proactiveMessaging": true,
9
9
  "cronJobs": true
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
2
  import { qqbotPlugin } from "./src/channel.js";
3
3
  import { setQQBotRuntime } from "./src/runtime.js";
4
+ import { registerChannelTool } from "./src/tools/channel.js";
4
5
  const plugin = {
5
6
  id: "openclaw-qqbot",
6
7
  name: "QQ Bot",
@@ -9,6 +10,7 @@ const plugin = {
9
10
  register(api) {
10
11
  setQQBotRuntime(api.runtime);
11
12
  api.registerChannel({ plugin: qqbotPlugin });
13
+ registerChannelTool(api);
12
14
  },
13
15
  };
14
16
  export default plugin;
package/dist/src/api.d.ts CHANGED
@@ -81,6 +81,15 @@ export declare function sendChannelMessage(accessToken: string, channelId: strin
81
81
  id: string;
82
82
  timestamp: string;
83
83
  }>;
84
+ /**
85
+ * 发送频道私信消息
86
+ * @param guildId - 私信会话的 guild_id(由 DIRECT_MESSAGE_CREATE 事件提供)
87
+ * @param msgId - 被动回复时必填
88
+ */
89
+ export declare function sendDmMessage(accessToken: string, guildId: string, content: string, msgId?: string): Promise<{
90
+ id: string;
91
+ timestamp: string;
92
+ }>;
84
93
  export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string): Promise<MessageResponse>;
85
94
  export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<MessageResponse>;
86
95
  export declare function sendProactiveGroupMessage(accessToken: string, groupOpenid: string, content: string): Promise<{
package/dist/src/api.js CHANGED
@@ -340,6 +340,17 @@ export async function sendChannelMessage(accessToken, channelId, content, msgId)
340
340
  ...(msgId ? { msg_id: msgId } : {}),
341
341
  });
342
342
  }
343
+ /**
344
+ * 发送频道私信消息
345
+ * @param guildId - 私信会话的 guild_id(由 DIRECT_MESSAGE_CREATE 事件提供)
346
+ * @param msgId - 被动回复时必填
347
+ */
348
+ export async function sendDmMessage(accessToken, guildId, content, msgId) {
349
+ return apiRequest(accessToken, "POST", `/dms/${guildId}/messages`, {
350
+ content,
351
+ ...(msgId ? { msg_id: msgId } : {}),
352
+ });
353
+ }
343
354
  export async function sendGroupMessage(accessToken, groupOpenid, content, msgId) {
344
355
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
345
356
  const body = buildMessageBody(content, msgId, msgSeq);
@@ -438,21 +438,23 @@ export async function startGateway(ctx) {
438
438
  const greeting = getStartupGreeting();
439
439
  if (!greeting) {
440
440
  log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (debounced, trigger=${trigger})`);
441
- return;
442
441
  }
443
- const adminId = resolveAdminOpenId();
444
- if (!adminId) {
445
- log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
446
- return;
442
+ else {
443
+ const adminId = resolveAdminOpenId();
444
+ if (!adminId) {
445
+ log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
446
+ }
447
+ else {
448
+ log?.info(`[qqbot:${account.accountId}] Sending startup greeting to admin (trigger=${trigger}): "${greeting}"`);
449
+ const token = await getAccessToken(account.appId, account.clientSecret);
450
+ const GREETING_TIMEOUT_MS = 10_000;
451
+ await Promise.race([
452
+ sendProactiveC2CMessage(token, adminId, greeting),
453
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
454
+ ]);
455
+ log?.info(`[qqbot:${account.accountId}] Sent startup greeting to admin: ${adminId}`);
456
+ }
447
457
  }
448
- log?.info(`[qqbot:${account.accountId}] Sending startup greeting to admin (trigger=${trigger}): "${greeting}"`);
449
- const token = await getAccessToken(account.appId, account.clientSecret);
450
- const GREETING_TIMEOUT_MS = 10_000;
451
- await Promise.race([
452
- sendProactiveC2CMessage(token, adminId, greeting),
453
- new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
454
- ]);
455
- log?.info(`[qqbot:${account.accountId}] Sent startup greeting to admin: ${adminId}`);
456
458
  }
457
459
  catch (err) {
458
460
  log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${err}`);
@@ -11,10 +11,12 @@
11
11
  * 从而计算「开平→插件」和「插件处理」两段耗时
12
12
  */
13
13
  import { createRequire } from "node:module";
14
- import { execFileSync } from "node:child_process";
14
+ import { execFileSync, execFile } from "node:child_process";
15
15
  import path from "node:path";
16
16
  import fs from "node:fs";
17
17
  import { getUpdateInfo } from "./update-checker.js";
18
+ import { getHomeDir, isWindows } from "./utils/platform.js";
19
+ import { fileURLToPath } from "node:url";
18
20
  const require = createRequire(import.meta.url);
19
21
  // 读取 package.json 中的版本号
20
22
  let PLUGIN_VERSION = "unknown";
@@ -124,54 +126,235 @@ registerCommand({
124
126
  },
125
127
  });
126
128
  const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
129
+ // ============ 热更新 ============
127
130
  /**
128
- * /bot-upgrade 查看版本更新状态 + 升级指引
131
+ * 找到 CLI 命令名(openclaw / clawdbot / moltbot)
132
+ */
133
+ function findCli() {
134
+ const whichCmd = isWindows() ? "where" : "which";
135
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
136
+ try {
137
+ execFileSync(whichCmd, [cli], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
138
+ return cli;
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ /**
147
+ * 找到升级脚本路径(兼容源码运行、dist 运行、已安装扩展目录)
148
+ */
149
+ function getUpgradeScriptPath() {
150
+ const currentFile = fileURLToPath(import.meta.url);
151
+ const currentDir = path.dirname(currentFile);
152
+ const candidates = [
153
+ path.resolve(currentDir, "..", "..", "scripts", "upgrade-via-npm.sh"),
154
+ path.resolve(currentDir, "..", "scripts", "upgrade-via-npm.sh"),
155
+ path.resolve(process.cwd(), "scripts", "upgrade-via-npm.sh"),
156
+ ];
157
+ const homeDir = getHomeDir();
158
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
159
+ candidates.push(path.join(homeDir, `.${cli}`, "extensions", "openclaw-qqbot", "scripts", "upgrade-via-npm.sh"));
160
+ }
161
+ for (const p of candidates) {
162
+ if (fs.existsSync(p))
163
+ return p;
164
+ }
165
+ return null;
166
+ }
167
+ /**
168
+ * 在 Windows 上查找可用的 bash(Git Bash / WSL 等)
169
+ */
170
+ function findBash() {
171
+ if (!isWindows())
172
+ return "bash";
173
+ // Git Bash 常见路径
174
+ const candidates = [
175
+ path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
176
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
177
+ path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"),
178
+ ];
179
+ for (const p of candidates) {
180
+ if (p && fs.existsSync(p))
181
+ return p;
182
+ }
183
+ // 尝试 PATH 中的 bash
184
+ try {
185
+ execFileSync("where", ["bash"], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
186
+ return "bash";
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ /**
193
+ * 执行热更新:执行脚本(--no-restart) → 触发 gateway restart
194
+ *
195
+ * fire-and-forget 操作:
196
+ * - 异步执行升级脚本(--no-restart,只做文件替换)
197
+ * - 脚本完成后触发 gateway restart(当前进程会被杀掉)
198
+ * - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
199
+ */
200
+ function fireHotUpgrade(targetVersion) {
201
+ const scriptPath = getUpgradeScriptPath();
202
+ if (!scriptPath)
203
+ return { ok: false, reason: "no-script" };
204
+ const cli = findCli();
205
+ if (!cli)
206
+ return { ok: false, reason: "no-cli" };
207
+ const bash = findBash();
208
+ if (!bash)
209
+ return { ok: false, reason: "no-bash" };
210
+ // 异步执行升级脚本
211
+ execFile(bash, [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])], {
212
+ timeout: 120_000,
213
+ env: { ...process.env },
214
+ ...(isWindows() ? { windowsHide: true } : {}),
215
+ }, (error, _stdout, _stderr) => {
216
+ if (error) {
217
+ return;
218
+ }
219
+ // 文件替换成功,触发 gateway restart
220
+ execFile(cli, ["gateway", "restart"], { timeout: 30_000 }, () => { });
221
+ });
222
+ return { ok: true };
223
+ }
224
+ /**
225
+ * /bot-upgrade — 统一升级入口:能热更就热更,失败则返回升级指引
226
+ *
227
+ * 支持参数:
228
+ * /bot-upgrade — 检查更新后自动热更
229
+ * /bot-upgrade 1.6.4 — 升级到指定版本
230
+ * /bot-upgrade --force — 强制升级(即使当前已是最新版)
129
231
  */
130
232
  registerCommand({
131
233
  name: "bot-upgrade",
132
- description: "查看版本更新与升级指引",
234
+ description: "检查更新并自动热更(失败则返回升级指引)",
133
235
  handler: (ctx) => {
236
+ // 升级相关指令仅在私聊中可用
237
+ if (ctx.type !== "c2c") {
238
+ return `💡 请在私聊中使用此指令`;
239
+ }
134
240
  const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
241
+ const args = ctx.args.trim();
135
242
  const info = getUpdateInfo();
136
- const lines = [];
137
- lines.push(`📌当前版本:v${PLUGIN_VERSION}`);
138
- if (info.checkedAt === 0) {
139
- lines.push(`⏳ 版本检查中,请稍后再试`);
140
- }
141
- else if (info.error) {
142
- lines.push(`⚠️ 版本检查失败`);
243
+ const isForce = args.includes("--force");
244
+ const versionArg = args.replace("--force", "").trim() || undefined;
245
+ if (!versionArg && !isForce) {
246
+ if (info.checkedAt === 0) {
247
+ return `⏳ 版本检查中,请稍后再试`;
248
+ }
249
+ if (info.error) {
250
+ return `⚠️ 版本检查失败\n⬆️升级指引:[点击查看](${url})`;
251
+ }
252
+ if (!info.hasUpdate) {
253
+ return `✅ 当前版本 v${PLUGIN_VERSION} 已是最新,无需升级\n\n> 💡 使用 /bot-upgrade --force 可强制重新安装`;
254
+ }
143
255
  }
144
- else if (info.hasUpdate && info.latest) {
145
- lines.push(`🆕最新可用版本:v${info.latest}`);
256
+ const targetVersion = versionArg || info.latest || undefined;
257
+ // 异步执行升级
258
+ const startResult = fireHotUpgrade(targetVersion);
259
+ if (!startResult.ok) {
260
+ if (startResult.reason === "no-script") {
261
+ return `❌ 未找到本地升级脚本\n⬆️升级指引:[点击查看](${url})`;
262
+ }
263
+ if (startResult.reason === "no-cli") {
264
+ return `❌ 未找到 openclaw / clawdbot / moltbot CLI\n⬆️升级指引:[点击查看](${url})`;
265
+ }
266
+ return `❌ 当前环境不支持热更新(需要 bash 环境)\n⬆️升级指引:[点击查看](${url})\n\n> Windows 用户请安装 Git for Windows 后重试,或手动执行升级脚本`;
146
267
  }
147
- else {
148
- lines.push(`✅ 当前已是最新版本`);
268
+ const lines = [
269
+ `🔄 开始热更新...`,
270
+ `📌 当前版本:v${PLUGIN_VERSION}`,
271
+ ];
272
+ if (targetVersion) {
273
+ lines.push(`🎯 目标版本:v${targetVersion}`);
149
274
  }
150
- lines.push(`⬆️升级指引:[点击查看](${url})`);
151
- lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
275
+ lines.push(``);
276
+ lines.push(`⏳ 升级过程约需 30~60 秒,完成后会自动通知您`);
152
277
  return lines.join("\n");
153
278
  },
154
279
  });
155
280
  /**
156
281
  * /bot-logs — 导出本地日志文件
282
+ *
283
+ * 日志路径检测策略(兼容特殊安装路径和 --profile/--dev 模式):
284
+ * 1. OPENCLAW_STATE_DIR 环境变量指定的目录
285
+ * 2. 扫描 home 目录下所有 .openclaw-xxx/logs/ 目录,取最近修改的 gateway.log
157
286
  */
158
287
  registerCommand({
159
288
  name: "bot-logs",
160
289
  description: "导出本地日志文件",
161
290
  handler: () => {
162
- const homeDir = process.env.HOME || "~";
163
- const logDir = path.join(homeDir, ".openclaw", "logs");
164
- const gatewayLog = path.join(logDir, "gateway.log");
165
- const errLog = path.join(logDir, "gateway.err.log");
291
+ const homeDir = getHomeDir();
292
+ // 收集所有可能的日志目录
293
+ const logDirs = [];
294
+ // 优先:环境变量指定的状态目录
295
+ const stateDir = process.env.OPENCLAW_STATE_DIR;
296
+ if (stateDir) {
297
+ logDirs.push(path.join(stateDir, "logs"));
298
+ }
299
+ // 扫描搜索根目录列表(兼容 Windows APPDATA 路径)
300
+ const searchRoots = new Set([homeDir]);
301
+ const appData = process.env.APPDATA; // Windows: C:\Users\xxx\AppData\Roaming
302
+ if (appData)
303
+ searchRoots.add(appData);
304
+ const localAppData = process.env.LOCALAPPDATA; // Windows: C:\Users\xxx\AppData\Local
305
+ if (localAppData)
306
+ searchRoots.add(localAppData);
307
+ for (const root of searchRoots) {
308
+ try {
309
+ const entries = fs.readdirSync(root, { withFileTypes: true });
310
+ for (const entry of entries) {
311
+ if (entry.isDirectory() && (entry.name.startsWith(".openclaw") || entry.name.startsWith("openclaw"))) {
312
+ const candidate = path.join(root, entry.name, "logs");
313
+ if (!logDirs.includes(candidate)) {
314
+ logDirs.push(candidate);
315
+ }
316
+ }
317
+ }
318
+ }
319
+ catch {
320
+ // 无权限或不存在,跳过
321
+ }
322
+ }
323
+ // 兜底:默认路径
324
+ const defaultLogDir = path.join(homeDir, ".openclaw", "logs");
325
+ if (!logDirs.includes(defaultLogDir)) {
326
+ logDirs.push(defaultLogDir);
327
+ }
328
+ // 从所有候选目录中找到存在且最近修改的 gateway.log
329
+ let bestLogDir = null;
330
+ let bestMtime = 0;
331
+ for (const logDir of logDirs) {
332
+ const gatewayLog = path.join(logDir, "gateway.log");
333
+ try {
334
+ const stat = fs.statSync(gatewayLog);
335
+ if (stat.mtimeMs > bestMtime) {
336
+ bestMtime = stat.mtimeMs;
337
+ bestLogDir = logDir;
338
+ }
339
+ }
340
+ catch {
341
+ // 不存在或无权限,跳过
342
+ }
343
+ }
344
+ if (!bestLogDir) {
345
+ const searched = logDirs.map(d => ` - ${d}`).join("\n");
346
+ return `⚠️ 未找到日志文件\n\n已搜索以下路径:\n${searched}`;
347
+ }
348
+ const gatewayLog = path.join(bestLogDir, "gateway.log");
349
+ const errLog = path.join(bestLogDir, "gateway.err.log");
166
350
  const lines = [];
167
- // 读取 gateway.log 最后 2000 行
168
351
  for (const logFile of [gatewayLog, errLog]) {
169
352
  if (!fs.existsSync(logFile))
170
353
  continue;
171
354
  try {
172
355
  const content = fs.readFileSync(logFile, "utf8");
173
356
  const allLines = content.split("\n");
174
- const tail = allLines.slice(-1000); // 每个文件取最后 1000 行
357
+ const tail = allLines.slice(-1000);
175
358
  if (tail.length > 0) {
176
359
  lines.push(`\n========== ${path.basename(logFile)} (last ${tail.length} lines) ==========\n`);
177
360
  lines.push(...tail);
@@ -182,7 +365,7 @@ registerCommand({
182
365
  }
183
366
  }
184
367
  if (lines.length === 0) {
185
- return "⚠️ 未找到日志文件";
368
+ return `⚠️ 日志文件为空(路径:${bestLogDir})`;
186
369
  }
187
370
  // 写入临时文件
188
371
  const tmpDir = path.join(homeDir, ".openclaw", "qqbot", "downloads");
@@ -194,7 +377,7 @@ registerCommand({
194
377
  fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
195
378
  const totalLines = lines.filter(l => !l.startsWith("=")).length;
196
379
  return {
197
- text: `📋 日志已打包(约 ${totalLines} 行),正在发送文件...`,
380
+ text: `📋 日志已打包(约 ${totalLines} 行),正在发送文件...\n📂 来源:${bestLogDir}`,
198
381
  filePath: tmpFile,
199
382
  };
200
383
  },
@@ -0,0 +1,16 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ /**
3
+ * 注册 QQ 频道 API 代理工具。
4
+ *
5
+ * 该工具作为 QQ 开放平台频道 API 的 HTTP 代理,自动处理 Token 鉴权。
6
+ * AI 通过 skill 文档了解各接口的路径、方法和参数,构造请求后由此工具代理发送。
7
+ *
8
+ * 支持的能力:
9
+ * - 频道管理(Guild)
10
+ * - 子频道管理(Channel)
11
+ * - 成员管理(Member)
12
+ * - 公告管理(Announces)
13
+ * - 论坛管理(Forum Thread)
14
+ * - 日程管理(Schedule)
15
+ */
16
+ export declare function registerChannelTool(api: OpenClawPluginApi): void;
@@ -0,0 +1,234 @@
1
+ import { resolveQQBotAccount } from "../config.js";
2
+ import { listQQBotAccountIds } from "../config.js";
3
+ import { getAccessToken } from "../api.js";
4
+ // ========== 常量 ==========
5
+ const API_BASE = "https://api.sgroup.qq.com";
6
+ const DEFAULT_TIMEOUT_MS = 30000;
7
+ // ========== JSON Schema ==========
8
+ const ChannelApiSchema = {
9
+ type: "object",
10
+ properties: {
11
+ method: {
12
+ type: "string",
13
+ description: "HTTP 请求方法。可选值:GET, POST, PUT, PATCH, DELETE",
14
+ enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
15
+ },
16
+ path: {
17
+ type: "string",
18
+ description: "API 路径(不含域名),占位符需替换为实际值。" +
19
+ "示例:/users/@me/guilds, /guilds/{guild_id}/channels, /channels/{channel_id}",
20
+ },
21
+ body: {
22
+ type: "object",
23
+ description: "请求体(JSON),用于 POST/PUT/PATCH 请求。" +
24
+ "GET/DELETE 请求不需要此参数。",
25
+ },
26
+ query: {
27
+ type: "object",
28
+ description: "URL 查询参数(键值对),会拼接到路径后面。" +
29
+ "如 { \"limit\": \"100\", \"after\": \"0\" } 会拼接为 ?limit=100&after=0",
30
+ additionalProperties: { type: "string" },
31
+ },
32
+ },
33
+ required: ["method", "path"],
34
+ };
35
+ // ========== 工具函数 ==========
36
+ function json(data) {
37
+ return {
38
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
39
+ details: data,
40
+ };
41
+ }
42
+ /**
43
+ * 构建完整的请求 URL
44
+ */
45
+ function buildUrl(path, query) {
46
+ let url = `${API_BASE}${path}`;
47
+ if (query && Object.keys(query).length > 0) {
48
+ const params = new URLSearchParams();
49
+ for (const [key, value] of Object.entries(query)) {
50
+ if (value !== undefined && value !== null && value !== "") {
51
+ params.set(key, value);
52
+ }
53
+ }
54
+ const qs = params.toString();
55
+ if (qs) {
56
+ url += `?${qs}`;
57
+ }
58
+ }
59
+ return url;
60
+ }
61
+ /**
62
+ * 校验 path 防止 SSRF
63
+ */
64
+ function validatePath(path) {
65
+ if (!path.startsWith("/")) {
66
+ return "path 必须以 / 开头";
67
+ }
68
+ if (path.includes("..") || path.includes("//")) {
69
+ return "path 不允许包含 .. 或 //";
70
+ }
71
+ // 只允许合法的 URL path 字符
72
+ if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") {
73
+ return "path 包含非法字符";
74
+ }
75
+ return null;
76
+ }
77
+ // ========== 注册入口 ==========
78
+ /**
79
+ * 注册 QQ 频道 API 代理工具。
80
+ *
81
+ * 该工具作为 QQ 开放平台频道 API 的 HTTP 代理,自动处理 Token 鉴权。
82
+ * AI 通过 skill 文档了解各接口的路径、方法和参数,构造请求后由此工具代理发送。
83
+ *
84
+ * 支持的能力:
85
+ * - 频道管理(Guild)
86
+ * - 子频道管理(Channel)
87
+ * - 成员管理(Member)
88
+ * - 公告管理(Announces)
89
+ * - 论坛管理(Forum Thread)
90
+ * - 日程管理(Schedule)
91
+ */
92
+ export function registerChannelTool(api) {
93
+ const cfg = api.config;
94
+ if (!cfg) {
95
+ console.log("[qqbot-channel-api] No config available, skipping");
96
+ return;
97
+ }
98
+ const accountIds = listQQBotAccountIds(cfg);
99
+ if (accountIds.length === 0) {
100
+ console.log("[qqbot-channel-api] No QQBot accounts configured, skipping");
101
+ return;
102
+ }
103
+ const firstAccountId = accountIds[0];
104
+ const account = resolveQQBotAccount(cfg, firstAccountId);
105
+ if (!account.appId || !account.clientSecret) {
106
+ console.log("[qqbot-channel-api] Account not fully configured, skipping");
107
+ return;
108
+ }
109
+ api.registerTool({
110
+ name: "qqbot_channel_api",
111
+ label: "QQBot Channel API",
112
+ description: "QQ 开放平台频道 API HTTP 代理,自动填充鉴权 Token。" +
113
+ "常用接口速查:" +
114
+ "频道列表 GET /users/@me/guilds | " +
115
+ "子频道列表 GET /guilds/{guild_id}/channels | " +
116
+ "子频道详情 GET /channels/{channel_id} | " +
117
+ "创建子频道 POST /guilds/{guild_id}/channels | " +
118
+ "成员列表 GET /guilds/{guild_id}/members?after=0&limit=100 | " +
119
+ "成员详情 GET /guilds/{guild_id}/members/{user_id} | " +
120
+ "帖子列表 GET /channels/{channel_id}/threads | " +
121
+ "发帖 PUT /channels/{channel_id}/threads | " +
122
+ "创建公告 POST /guilds/{guild_id}/announces | " +
123
+ "创建日程 POST /channels/{channel_id}/schedules。" +
124
+ "更多接口和参数详情请阅读 qqbot-channel skill。",
125
+ parameters: ChannelApiSchema,
126
+ async execute(_toolCallId, params) {
127
+ const p = params;
128
+ // 参数校验
129
+ if (!p.method) {
130
+ return json({ error: "method 为必填参数" });
131
+ }
132
+ if (!p.path) {
133
+ return json({ error: "path 为必填参数" });
134
+ }
135
+ const method = p.method.toUpperCase();
136
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
137
+ return json({ error: `不支持的 HTTP 方法: ${method},可选值:GET, POST, PUT, PATCH, DELETE` });
138
+ }
139
+ // 路径安全校验
140
+ const pathError = validatePath(p.path);
141
+ if (pathError) {
142
+ return json({ error: pathError });
143
+ }
144
+ // GET/DELETE 不应有 body
145
+ if ((method === "GET" || method === "DELETE") && p.body && Object.keys(p.body).length > 0) {
146
+ console.log(`[qqbot-channel-api] ${method} request with body, body will be ignored`);
147
+ }
148
+ try {
149
+ // 获取鉴权 Token
150
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
151
+ // 构建请求
152
+ const url = buildUrl(p.path, p.query);
153
+ const headers = {
154
+ Authorization: `QQBot ${accessToken}`,
155
+ "Content-Type": "application/json",
156
+ };
157
+ const controller = new AbortController();
158
+ const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
159
+ const fetchOptions = {
160
+ method,
161
+ headers,
162
+ signal: controller.signal,
163
+ };
164
+ // POST/PUT/PATCH 才发送 body
165
+ if (p.body && ["POST", "PUT", "PATCH"].includes(method)) {
166
+ fetchOptions.body = JSON.stringify(p.body);
167
+ }
168
+ console.log(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`);
169
+ let res;
170
+ try {
171
+ res = await fetch(url, fetchOptions);
172
+ }
173
+ catch (err) {
174
+ clearTimeout(timeoutId);
175
+ if (err instanceof Error && err.name === "AbortError") {
176
+ console.error(`[qqbot-channel-api] <<< Request timeout after ${DEFAULT_TIMEOUT_MS}ms`);
177
+ return json({ error: `请求超时(${DEFAULT_TIMEOUT_MS}ms)`, path: p.path });
178
+ }
179
+ console.error(`[qqbot-channel-api] <<< Network error:`, err);
180
+ return json({
181
+ error: `网络错误: ${err instanceof Error ? err.message : String(err)}`,
182
+ path: p.path,
183
+ });
184
+ }
185
+ finally {
186
+ clearTimeout(timeoutId);
187
+ }
188
+ console.log(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
189
+ // 解析响应
190
+ const rawBody = await res.text();
191
+ // 空响应(如 DELETE 204)
192
+ if (!rawBody || rawBody.trim() === "") {
193
+ if (res.ok) {
194
+ return json({ success: true, status: res.status, path: p.path });
195
+ }
196
+ return json({
197
+ error: `API 返回 ${res.status} ${res.statusText}`,
198
+ status: res.status,
199
+ path: p.path,
200
+ });
201
+ }
202
+ let data;
203
+ try {
204
+ data = JSON.parse(rawBody);
205
+ }
206
+ catch {
207
+ return json({
208
+ error: "响应解析失败",
209
+ status: res.status,
210
+ raw: rawBody.slice(0, 500),
211
+ path: p.path,
212
+ });
213
+ }
214
+ if (!res.ok) {
215
+ const errData = data;
216
+ return json({
217
+ error: errData.message ?? `API 错误 (HTTP ${res.status})`,
218
+ code: errData.code,
219
+ status: res.status,
220
+ path: p.path,
221
+ detail: data,
222
+ });
223
+ }
224
+ return json(data);
225
+ }
226
+ catch (err) {
227
+ const errMsg = err instanceof Error ? err.message : String(err);
228
+ console.error(`[qqbot-channel-api] Error [${method} ${p.path}]: ${errMsg}`);
229
+ return json({ error: errMsg, path: p.path });
230
+ }
231
+ },
232
+ }, { name: "qqbot_channel_api" });
233
+ console.log("[qqbot-channel-api] Registered QQ channel API proxy tool");
234
+ }
@@ -62,6 +62,12 @@ export interface QQBotAccountConfig {
62
62
  * 默认: https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg
63
63
  */
64
64
  upgradeUrl?: string;
65
+ /**
66
+ * /bot-upgrade 指令的行为模式
67
+ * - "doc":展示升级文档链接(默认,安全模式)
68
+ * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
69
+ */
70
+ upgradeMode?: "doc" | "hot-reload";
65
71
  }
66
72
  /**
67
73
  * 音频格式策略:控制哪些格式可跳过转换