@tencent-connect/openclaw-qqbot 1.6.3-alpha.channel → 1.6.3
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 +1 -1
- package/README.zh.md +1 -1
- package/dist/src/gateway.js +13 -15
- package/dist/src/slash-commands.js +10 -240
- package/dist/src/types.d.ts +0 -6
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +6 -17
- package/src/gateway.ts +14 -14
- package/src/slash-commands.ts +10 -262
- package/src/types.ts +0 -6
- package/dist/src/tools/channel.d.ts +0 -16
- package/dist/src/tools/channel.js +0 -234
- package/scripts/test-sendmedia.ts +0 -116
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.
|
|
166
|
+
> **QQBot**: 🦞 Framework: OpenClaw 2026.3.13 (61d171a) / 🤖 Plugin: v1.6.2 / 🌟 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.
|
|
161
|
+
> **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.2 / 🌟官方 GitHub 仓库
|
|
162
162
|
|
|
163
163
|
一目了然查看框架版本、插件版本,并可直接跳转官方仓库。
|
|
164
164
|
|
package/dist/src/gateway.js
CHANGED
|
@@ -438,23 +438,21 @@ 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;
|
|
441
442
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
}
|
|
443
|
+
const adminId = resolveAdminOpenId();
|
|
444
|
+
if (!adminId) {
|
|
445
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
446
|
+
return;
|
|
457
447
|
}
|
|
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}`);
|
|
458
456
|
}
|
|
459
457
|
catch (err) {
|
|
460
458
|
log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${err}`);
|
|
@@ -11,12 +11,10 @@
|
|
|
11
11
|
* 从而计算「开平→插件」和「插件处理」两段耗时
|
|
12
12
|
*/
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
|
-
import { execFileSync
|
|
14
|
+
import { execFileSync } 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";
|
|
20
18
|
const require = createRequire(import.meta.url);
|
|
21
19
|
// 读取 package.json 中的版本号
|
|
22
20
|
let PLUGIN_VERSION = "unknown";
|
|
@@ -126,282 +124,54 @@ registerCommand({
|
|
|
126
124
|
},
|
|
127
125
|
});
|
|
128
126
|
const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
|
|
129
|
-
// ============ 热更新 ============
|
|
130
127
|
/**
|
|
131
|
-
*
|
|
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
|
-
* 找到升级脚本路径
|
|
148
|
-
*/
|
|
149
|
-
function getUpgradeScriptPath() {
|
|
150
|
-
const currentFile = fileURLToPath(import.meta.url);
|
|
151
|
-
const scriptDir = path.resolve(path.dirname(currentFile), "..", "..", "scripts");
|
|
152
|
-
const scriptPath = path.join(scriptDir, "upgrade-via-npm.sh");
|
|
153
|
-
return fs.existsSync(scriptPath) ? scriptPath : null;
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* 在 Windows 上查找可用的 bash(Git Bash / WSL 等)
|
|
157
|
-
*/
|
|
158
|
-
function findBash() {
|
|
159
|
-
if (!isWindows())
|
|
160
|
-
return "bash";
|
|
161
|
-
// Git Bash 常见路径
|
|
162
|
-
const candidates = [
|
|
163
|
-
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
164
|
-
path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
|
|
165
|
-
path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"),
|
|
166
|
-
];
|
|
167
|
-
for (const p of candidates) {
|
|
168
|
-
if (p && fs.existsSync(p))
|
|
169
|
-
return p;
|
|
170
|
-
}
|
|
171
|
-
// 尝试 PATH 中的 bash
|
|
172
|
-
try {
|
|
173
|
-
execFileSync("where", ["bash"], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
|
|
174
|
-
return "bash";
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* 执行热更新:执行脚本(--no-restart) → 触发 gateway restart
|
|
182
|
-
*
|
|
183
|
-
* fire-and-forget 操作:
|
|
184
|
-
* - 异步执行升级脚本(--no-restart,只做文件替换)
|
|
185
|
-
* - 脚本完成后触发 gateway restart(当前进程会被杀掉)
|
|
186
|
-
* - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
|
|
187
|
-
*
|
|
188
|
-
* @returns true 表示已启动升级流程,false 表示无法执行(如 Windows 无 bash)
|
|
189
|
-
*/
|
|
190
|
-
function fireHotUpgrade(targetVersion) {
|
|
191
|
-
const scriptPath = getUpgradeScriptPath();
|
|
192
|
-
if (!scriptPath)
|
|
193
|
-
return false;
|
|
194
|
-
const cli = findCli();
|
|
195
|
-
if (!cli)
|
|
196
|
-
return false;
|
|
197
|
-
const bash = findBash();
|
|
198
|
-
if (!bash)
|
|
199
|
-
return false;
|
|
200
|
-
const args = ["--no-restart"];
|
|
201
|
-
if (targetVersion) {
|
|
202
|
-
args.push("--version", targetVersion);
|
|
203
|
-
}
|
|
204
|
-
// 异步执行升级脚本
|
|
205
|
-
execFile(bash, [scriptPath, ...args], {
|
|
206
|
-
timeout: 120_000,
|
|
207
|
-
env: { ...process.env },
|
|
208
|
-
...(isWindows() ? { windowsHide: true } : {}),
|
|
209
|
-
}, (error, _stdout, _stderr) => {
|
|
210
|
-
if (error) {
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
// 文件替换成功,触发 gateway restart
|
|
214
|
-
execFile(cli, ["gateway", "restart"], { timeout: 30_000 }, () => { });
|
|
215
|
-
});
|
|
216
|
-
return true;
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* /bot-upgrade — 查看版本更新状态 + 升级指引(根据 upgradeMode 决定行为)
|
|
128
|
+
* /bot-upgrade — 查看版本更新状态 + 升级指引
|
|
220
129
|
*/
|
|
221
130
|
registerCommand({
|
|
222
131
|
name: "bot-upgrade",
|
|
223
132
|
description: "查看版本更新与升级指引",
|
|
224
133
|
handler: (ctx) => {
|
|
225
|
-
// 升级相关指令仅在私聊中可用
|
|
226
|
-
if (ctx.type !== "c2c") {
|
|
227
|
-
return `💡 请在私聊中使用此指令`;
|
|
228
|
-
}
|
|
229
|
-
const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
|
|
230
134
|
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
231
135
|
const info = getUpdateInfo();
|
|
232
136
|
const lines = [];
|
|
233
137
|
lines.push(`📌当前版本:v${PLUGIN_VERSION}`);
|
|
234
138
|
if (info.checkedAt === 0) {
|
|
235
139
|
lines.push(`⏳ 版本检查中,请稍后再试`);
|
|
236
|
-
return lines.join("\n");
|
|
237
140
|
}
|
|
238
141
|
else if (info.error) {
|
|
239
142
|
lines.push(`⚠️ 版本检查失败`);
|
|
240
|
-
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
241
|
-
return lines.join("\n");
|
|
242
143
|
}
|
|
243
144
|
else if (info.hasUpdate && info.latest) {
|
|
244
145
|
lines.push(`🆕最新可用版本:v${info.latest}`);
|
|
245
146
|
}
|
|
246
147
|
else {
|
|
247
148
|
lines.push(`✅ 当前已是最新版本`);
|
|
248
|
-
return lines.join("\n");
|
|
249
|
-
}
|
|
250
|
-
// 有新版本:根据 upgradeMode 决定行为
|
|
251
|
-
if (upgradeMode === "hot-reload") {
|
|
252
|
-
const started = fireHotUpgrade(info.latest);
|
|
253
|
-
if (!started) {
|
|
254
|
-
lines.push(`⚠️ 当前环境不支持热更新(需要 bash 环境)`);
|
|
255
|
-
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
256
|
-
return lines.join("\n");
|
|
257
|
-
}
|
|
258
|
-
lines.push(``);
|
|
259
|
-
lines.push(`🔄 正在执行热更新到 v${info.latest}...`);
|
|
260
|
-
lines.push(`⏳ 升级过程约需 30~60 秒,完成后会自动通知您`);
|
|
261
|
-
return lines.join("\n");
|
|
262
149
|
}
|
|
263
|
-
// doc 模式:展示升级文档
|
|
264
150
|
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
265
151
|
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
266
|
-
lines.push(``, `> 💡 提示:管理员可通过 <qqbot-cmd-input text="/bot-hot-upgrade" show="/bot-hot-upgrade"/> 直接执行热更新`);
|
|
267
|
-
return lines.join("\n");
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
/**
|
|
271
|
-
* /bot-hot-upgrade — 直接执行热更新(无论 upgradeMode 配置如何)
|
|
272
|
-
*
|
|
273
|
-
* 支持参数:
|
|
274
|
-
* /bot-hot-upgrade — 升级到 latest
|
|
275
|
-
* /bot-hot-upgrade 1.6.4 — 升级到指定版本
|
|
276
|
-
* /bot-hot-upgrade --force — 强制升级(即使当前已是最新版)
|
|
277
|
-
*/
|
|
278
|
-
registerCommand({
|
|
279
|
-
name: "bot-hot-upgrade",
|
|
280
|
-
description: "直接执行热更新升级",
|
|
281
|
-
handler: (ctx) => {
|
|
282
|
-
// 升级相关指令仅在私聊中可用
|
|
283
|
-
if (ctx.type !== "c2c") {
|
|
284
|
-
return `💡 请在私聊中使用此指令`;
|
|
285
|
-
}
|
|
286
|
-
// 前置检查
|
|
287
|
-
const scriptPath = getUpgradeScriptPath();
|
|
288
|
-
if (!scriptPath) {
|
|
289
|
-
return "❌ 升级脚本不存在,请检查安装是否完整";
|
|
290
|
-
}
|
|
291
|
-
const cli = findCli();
|
|
292
|
-
if (!cli) {
|
|
293
|
-
return "❌ 未找到 openclaw / clawdbot / moltbot CLI";
|
|
294
|
-
}
|
|
295
|
-
const args = ctx.args.trim();
|
|
296
|
-
const info = getUpdateInfo();
|
|
297
|
-
// 解析参数
|
|
298
|
-
const isForce = args.includes("--force");
|
|
299
|
-
const versionArg = args.replace("--force", "").trim() || undefined;
|
|
300
|
-
// 如果没有指定版本,先检查是否有更新
|
|
301
|
-
if (!versionArg && !isForce) {
|
|
302
|
-
if (info.checkedAt === 0) {
|
|
303
|
-
return `⏳ 版本检查中,请稍后再试`;
|
|
304
|
-
}
|
|
305
|
-
if (!info.hasUpdate && !info.error) {
|
|
306
|
-
return `✅ 当前版本 v${PLUGIN_VERSION} 已是最新,无需升级\n\n> 💡 使用 /bot-hot-upgrade --force 可强制重新安装`;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
const targetVersion = versionArg || info.latest || undefined;
|
|
310
|
-
// 异步执行升级
|
|
311
|
-
const started = fireHotUpgrade(targetVersion);
|
|
312
|
-
if (!started) {
|
|
313
|
-
return `❌ 当前环境不支持热更新(需要 bash 环境)\n\n> Windows 用户请安装 Git for Windows 后重试,或手动执行升级脚本`;
|
|
314
|
-
}
|
|
315
|
-
const lines = [
|
|
316
|
-
`🔄 开始热更新...`,
|
|
317
|
-
`📌 当前版本:v${PLUGIN_VERSION}`,
|
|
318
|
-
];
|
|
319
|
-
if (targetVersion) {
|
|
320
|
-
lines.push(`🎯 目标版本:v${targetVersion}`);
|
|
321
|
-
}
|
|
322
|
-
lines.push(``);
|
|
323
|
-
lines.push(`⏳ 升级过程约需 30~60 秒,完成后会自动通知您`);
|
|
324
152
|
return lines.join("\n");
|
|
325
153
|
},
|
|
326
154
|
});
|
|
327
155
|
/**
|
|
328
156
|
* /bot-logs — 导出本地日志文件
|
|
329
|
-
*
|
|
330
|
-
* 日志路径检测策略(兼容特殊安装路径和 --profile/--dev 模式):
|
|
331
|
-
* 1. OPENCLAW_STATE_DIR 环境变量指定的目录
|
|
332
|
-
* 2. 扫描 home 目录下所有 .openclaw-xxx/logs/ 目录,取最近修改的 gateway.log
|
|
333
157
|
*/
|
|
334
158
|
registerCommand({
|
|
335
159
|
name: "bot-logs",
|
|
336
160
|
description: "导出本地日志文件",
|
|
337
161
|
handler: () => {
|
|
338
|
-
const homeDir =
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
|
343
|
-
if (stateDir) {
|
|
344
|
-
logDirs.push(path.join(stateDir, "logs"));
|
|
345
|
-
}
|
|
346
|
-
// 扫描搜索根目录列表(兼容 Windows APPDATA 路径)
|
|
347
|
-
const searchRoots = new Set([homeDir]);
|
|
348
|
-
const appData = process.env.APPDATA; // Windows: C:\Users\xxx\AppData\Roaming
|
|
349
|
-
if (appData)
|
|
350
|
-
searchRoots.add(appData);
|
|
351
|
-
const localAppData = process.env.LOCALAPPDATA; // Windows: C:\Users\xxx\AppData\Local
|
|
352
|
-
if (localAppData)
|
|
353
|
-
searchRoots.add(localAppData);
|
|
354
|
-
for (const root of searchRoots) {
|
|
355
|
-
try {
|
|
356
|
-
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
357
|
-
for (const entry of entries) {
|
|
358
|
-
if (entry.isDirectory() && (entry.name.startsWith(".openclaw") || entry.name.startsWith("openclaw"))) {
|
|
359
|
-
const candidate = path.join(root, entry.name, "logs");
|
|
360
|
-
if (!logDirs.includes(candidate)) {
|
|
361
|
-
logDirs.push(candidate);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
catch {
|
|
367
|
-
// 无权限或不存在,跳过
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// 兜底:默认路径
|
|
371
|
-
const defaultLogDir = path.join(homeDir, ".openclaw", "logs");
|
|
372
|
-
if (!logDirs.includes(defaultLogDir)) {
|
|
373
|
-
logDirs.push(defaultLogDir);
|
|
374
|
-
}
|
|
375
|
-
// 从所有候选目录中找到存在且最近修改的 gateway.log
|
|
376
|
-
let bestLogDir = null;
|
|
377
|
-
let bestMtime = 0;
|
|
378
|
-
for (const logDir of logDirs) {
|
|
379
|
-
const gatewayLog = path.join(logDir, "gateway.log");
|
|
380
|
-
try {
|
|
381
|
-
const stat = fs.statSync(gatewayLog);
|
|
382
|
-
if (stat.mtimeMs > bestMtime) {
|
|
383
|
-
bestMtime = stat.mtimeMs;
|
|
384
|
-
bestLogDir = logDir;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
// 不存在或无权限,跳过
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (!bestLogDir) {
|
|
392
|
-
const searched = logDirs.map(d => ` - ${d}`).join("\n");
|
|
393
|
-
return `⚠️ 未找到日志文件\n\n已搜索以下路径:\n${searched}`;
|
|
394
|
-
}
|
|
395
|
-
const gatewayLog = path.join(bestLogDir, "gateway.log");
|
|
396
|
-
const errLog = path.join(bestLogDir, "gateway.err.log");
|
|
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");
|
|
397
166
|
const lines = [];
|
|
167
|
+
// 读取 gateway.log 最后 2000 行
|
|
398
168
|
for (const logFile of [gatewayLog, errLog]) {
|
|
399
169
|
if (!fs.existsSync(logFile))
|
|
400
170
|
continue;
|
|
401
171
|
try {
|
|
402
172
|
const content = fs.readFileSync(logFile, "utf8");
|
|
403
173
|
const allLines = content.split("\n");
|
|
404
|
-
const tail = allLines.slice(-1000);
|
|
174
|
+
const tail = allLines.slice(-1000); // 每个文件取最后 1000 行
|
|
405
175
|
if (tail.length > 0) {
|
|
406
176
|
lines.push(`\n========== ${path.basename(logFile)} (last ${tail.length} lines) ==========\n`);
|
|
407
177
|
lines.push(...tail);
|
|
@@ -412,7 +182,7 @@ registerCommand({
|
|
|
412
182
|
}
|
|
413
183
|
}
|
|
414
184
|
if (lines.length === 0) {
|
|
415
|
-
return
|
|
185
|
+
return "⚠️ 未找到日志文件";
|
|
416
186
|
}
|
|
417
187
|
// 写入临时文件
|
|
418
188
|
const tmpDir = path.join(homeDir, ".openclaw", "qqbot", "downloads");
|
|
@@ -424,7 +194,7 @@ registerCommand({
|
|
|
424
194
|
fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
|
|
425
195
|
const totalLines = lines.filter(l => !l.startsWith("=")).length;
|
|
426
196
|
return {
|
|
427
|
-
text: `📋 日志已打包(约 ${totalLines}
|
|
197
|
+
text: `📋 日志已打包(约 ${totalLines} 行),正在发送文件...`,
|
|
428
198
|
filePath: tmpFile,
|
|
429
199
|
};
|
|
430
200
|
},
|
package/dist/src/types.d.ts
CHANGED
|
@@ -62,12 +62,6 @@ 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";
|
|
71
65
|
}
|
|
72
66
|
/**
|
|
73
67
|
* 音频格式策略:控制哪些格式可跳过转换
|
package/package.json
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
# upgrade-via-npm.sh --version <version> # 升级到指定版本
|
|
12
12
|
# upgrade-via-npm.sh --self-version # 升级到当前仓库 package.json 版本
|
|
13
13
|
# upgrade-via-npm.sh --appid <appid> --secret <secret> # 首次安装时配置 appid/secret
|
|
14
|
-
# upgrade-via-npm.sh --no-restart # 只做文件替换,不重启 gateway(供热更指令使用)
|
|
15
14
|
|
|
16
15
|
set -eo pipefail
|
|
17
16
|
|
|
@@ -19,7 +18,6 @@ PKG_NAME="@tencent-connect/openclaw-qqbot"
|
|
|
19
18
|
INSTALL_SRC=""
|
|
20
19
|
APPID=""
|
|
21
20
|
SECRET=""
|
|
22
|
-
NO_RESTART=false
|
|
23
21
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
24
22
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
25
23
|
|
|
@@ -79,10 +77,6 @@ while [[ $# -gt 0 ]]; do
|
|
|
79
77
|
SECRET="$2"
|
|
80
78
|
shift 2
|
|
81
79
|
;;
|
|
82
|
-
--no-restart)
|
|
83
|
-
NO_RESTART=true
|
|
84
|
-
shift 1
|
|
85
|
-
;;
|
|
86
80
|
-h|--help)
|
|
87
81
|
print_usage
|
|
88
82
|
exit 0
|
|
@@ -271,16 +265,11 @@ elif [ -n "$APPID" ] || [ -n "$SECRET" ]; then
|
|
|
271
265
|
echo "⚠️ --appid 和 --secret 必须同时提供"
|
|
272
266
|
fi
|
|
273
267
|
|
|
274
|
-
# [5/5] 重启 gateway
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
268
|
+
# [5/5] 重启 gateway 使新版本生效
|
|
269
|
+
echo ""
|
|
270
|
+
echo "[重启] 重启 gateway 使新版本生效..."
|
|
271
|
+
if $CMD gateway restart 2>&1; then
|
|
272
|
+
echo " ✅ gateway 已重启"
|
|
278
273
|
else
|
|
279
|
-
echo ""
|
|
280
|
-
echo "[重启] 重启 gateway 使新版本生效..."
|
|
281
|
-
if $CMD gateway restart 2>&1; then
|
|
282
|
-
echo " ✅ gateway 已重启"
|
|
283
|
-
else
|
|
284
|
-
echo " ⚠️ gateway 重启失败,请手动执行: $CMD gateway restart"
|
|
285
|
-
fi
|
|
274
|
+
echo " ⚠️ gateway 重启失败,请手动执行: $CMD gateway restart"
|
|
286
275
|
fi
|
package/src/gateway.ts
CHANGED
|
@@ -538,21 +538,21 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
538
538
|
const greeting = getStartupGreeting();
|
|
539
539
|
if (!greeting) {
|
|
540
540
|
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (debounced, trigger=${trigger})`);
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
548
|
-
const GREETING_TIMEOUT_MS = 10_000;
|
|
549
|
-
await Promise.race([
|
|
550
|
-
sendProactiveC2CMessage(token, adminId, greeting),
|
|
551
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
552
|
-
]);
|
|
553
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to admin: ${adminId}`);
|
|
554
|
-
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const adminId = resolveAdminOpenId();
|
|
544
|
+
if (!adminId) {
|
|
545
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
546
|
+
return;
|
|
555
547
|
}
|
|
548
|
+
log?.info(`[qqbot:${account.accountId}] Sending startup greeting to admin (trigger=${trigger}): "${greeting}"`);
|
|
549
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
550
|
+
const GREETING_TIMEOUT_MS = 10_000;
|
|
551
|
+
await Promise.race([
|
|
552
|
+
sendProactiveC2CMessage(token, adminId, greeting),
|
|
553
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
554
|
+
]);
|
|
555
|
+
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to admin: ${adminId}`);
|
|
556
556
|
} catch (err) {
|
|
557
557
|
log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${err}`);
|
|
558
558
|
}
|
package/src/slash-commands.ts
CHANGED
|
@@ -13,12 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import type { QQBotAccountConfig } from "./types.js";
|
|
15
15
|
import { createRequire } from "node:module";
|
|
16
|
-
import { execFileSync
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import { getUpdateInfo } from "./update-checker.js";
|
|
20
|
-
import { getHomeDir, isWindows } from "./utils/platform.js";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
20
|
const require = createRequire(import.meta.url);
|
|
23
21
|
|
|
24
22
|
// 读取 package.json 中的版本号
|
|
@@ -197,115 +195,13 @@ registerCommand({
|
|
|
197
195
|
|
|
198
196
|
const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
|
|
199
197
|
|
|
200
|
-
// ============ 热更新 ============
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* 找到 CLI 命令名(openclaw / clawdbot / moltbot)
|
|
204
|
-
*/
|
|
205
|
-
function findCli(): string | null {
|
|
206
|
-
const whichCmd = isWindows() ? "where" : "which";
|
|
207
|
-
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
208
|
-
try {
|
|
209
|
-
execFileSync(whichCmd, [cli], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
|
|
210
|
-
return cli;
|
|
211
|
-
} catch {
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* 找到升级脚本路径
|
|
220
|
-
*/
|
|
221
|
-
function getUpgradeScriptPath(): string | null {
|
|
222
|
-
const currentFile = fileURLToPath(import.meta.url);
|
|
223
|
-
const scriptDir = path.resolve(path.dirname(currentFile), "..", "..", "scripts");
|
|
224
|
-
const scriptPath = path.join(scriptDir, "upgrade-via-npm.sh");
|
|
225
|
-
return fs.existsSync(scriptPath) ? scriptPath : null;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
198
|
/**
|
|
229
|
-
*
|
|
230
|
-
*/
|
|
231
|
-
function findBash(): string | null {
|
|
232
|
-
if (!isWindows()) return "bash";
|
|
233
|
-
|
|
234
|
-
// Git Bash 常见路径
|
|
235
|
-
const candidates = [
|
|
236
|
-
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
237
|
-
path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
|
|
238
|
-
path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"),
|
|
239
|
-
];
|
|
240
|
-
|
|
241
|
-
for (const p of candidates) {
|
|
242
|
-
if (p && fs.existsSync(p)) return p;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// 尝试 PATH 中的 bash
|
|
246
|
-
try {
|
|
247
|
-
execFileSync("where", ["bash"], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
|
|
248
|
-
return "bash";
|
|
249
|
-
} catch {
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* 执行热更新:执行脚本(--no-restart) → 触发 gateway restart
|
|
256
|
-
*
|
|
257
|
-
* fire-and-forget 操作:
|
|
258
|
-
* - 异步执行升级脚本(--no-restart,只做文件替换)
|
|
259
|
-
* - 脚本完成后触发 gateway restart(当前进程会被杀掉)
|
|
260
|
-
* - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
|
|
261
|
-
*
|
|
262
|
-
* @returns true 表示已启动升级流程,false 表示无法执行(如 Windows 无 bash)
|
|
263
|
-
*/
|
|
264
|
-
function fireHotUpgrade(targetVersion?: string): boolean {
|
|
265
|
-
const scriptPath = getUpgradeScriptPath();
|
|
266
|
-
if (!scriptPath) return false;
|
|
267
|
-
|
|
268
|
-
const cli = findCli();
|
|
269
|
-
if (!cli) return false;
|
|
270
|
-
|
|
271
|
-
const bash = findBash();
|
|
272
|
-
if (!bash) return false;
|
|
273
|
-
|
|
274
|
-
const args: string[] = ["--no-restart"];
|
|
275
|
-
if (targetVersion) {
|
|
276
|
-
args.push("--version", targetVersion);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 异步执行升级脚本
|
|
280
|
-
execFile(bash, [scriptPath, ...args], {
|
|
281
|
-
timeout: 120_000,
|
|
282
|
-
env: { ...process.env },
|
|
283
|
-
...(isWindows() ? { windowsHide: true } : {}),
|
|
284
|
-
}, (error, _stdout, _stderr) => {
|
|
285
|
-
if (error) {
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// 文件替换成功,触发 gateway restart
|
|
290
|
-
execFile(cli, ["gateway", "restart"], { timeout: 30_000 }, () => {});
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
return true;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* /bot-upgrade — 查看版本更新状态 + 升级指引(根据 upgradeMode 决定行为)
|
|
199
|
+
* /bot-upgrade — 查看版本更新状态 + 升级指引
|
|
298
200
|
*/
|
|
299
201
|
registerCommand({
|
|
300
202
|
name: "bot-upgrade",
|
|
301
203
|
description: "查看版本更新与升级指引",
|
|
302
204
|
handler: (ctx) => {
|
|
303
|
-
// 升级相关指令仅在私聊中可用
|
|
304
|
-
if (ctx.type !== "c2c") {
|
|
305
|
-
return `💡 请在私聊中使用此指令`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
|
|
309
205
|
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
310
206
|
const info = getUpdateInfo();
|
|
311
207
|
const lines: string[] = [];
|
|
@@ -314,189 +210,41 @@ registerCommand({
|
|
|
314
210
|
|
|
315
211
|
if (info.checkedAt === 0) {
|
|
316
212
|
lines.push(`⏳ 版本检查中,请稍后再试`);
|
|
317
|
-
return lines.join("\n");
|
|
318
213
|
} else if (info.error) {
|
|
319
214
|
lines.push(`⚠️ 版本检查失败`);
|
|
320
|
-
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
321
|
-
return lines.join("\n");
|
|
322
215
|
} else if (info.hasUpdate && info.latest) {
|
|
323
216
|
lines.push(`🆕最新可用版本:v${info.latest}`);
|
|
324
217
|
} else {
|
|
325
218
|
lines.push(`✅ 当前已是最新版本`);
|
|
326
|
-
return lines.join("\n");
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// 有新版本:根据 upgradeMode 决定行为
|
|
330
|
-
if (upgradeMode === "hot-reload") {
|
|
331
|
-
const started = fireHotUpgrade(info.latest!);
|
|
332
|
-
if (!started) {
|
|
333
|
-
lines.push(`⚠️ 当前环境不支持热更新(需要 bash 环境)`);
|
|
334
|
-
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
335
|
-
return lines.join("\n");
|
|
336
|
-
}
|
|
337
|
-
lines.push(``);
|
|
338
|
-
lines.push(`🔄 正在执行热更新到 v${info.latest}...`);
|
|
339
|
-
lines.push(`⏳ 升级过程约需 30~60 秒,完成后会自动通知您`);
|
|
340
|
-
return lines.join("\n");
|
|
341
219
|
}
|
|
342
220
|
|
|
343
|
-
// doc 模式:展示升级文档
|
|
344
221
|
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
345
222
|
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
346
|
-
lines.push(``, `> 💡 提示:管理员可通过 <qqbot-cmd-input text="/bot-hot-upgrade" show="/bot-hot-upgrade"/> 直接执行热更新`);
|
|
347
|
-
return lines.join("\n");
|
|
348
|
-
},
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* /bot-hot-upgrade — 直接执行热更新(无论 upgradeMode 配置如何)
|
|
353
|
-
*
|
|
354
|
-
* 支持参数:
|
|
355
|
-
* /bot-hot-upgrade — 升级到 latest
|
|
356
|
-
* /bot-hot-upgrade 1.6.4 — 升级到指定版本
|
|
357
|
-
* /bot-hot-upgrade --force — 强制升级(即使当前已是最新版)
|
|
358
|
-
*/
|
|
359
|
-
registerCommand({
|
|
360
|
-
name: "bot-hot-upgrade",
|
|
361
|
-
description: "直接执行热更新升级",
|
|
362
|
-
handler: (ctx) => {
|
|
363
|
-
// 升级相关指令仅在私聊中可用
|
|
364
|
-
if (ctx.type !== "c2c") {
|
|
365
|
-
return `💡 请在私聊中使用此指令`;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// 前置检查
|
|
369
|
-
const scriptPath = getUpgradeScriptPath();
|
|
370
|
-
if (!scriptPath) {
|
|
371
|
-
return "❌ 升级脚本不存在,请检查安装是否完整";
|
|
372
|
-
}
|
|
373
|
-
const cli = findCli();
|
|
374
|
-
if (!cli) {
|
|
375
|
-
return "❌ 未找到 openclaw / clawdbot / moltbot CLI";
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const args = ctx.args.trim();
|
|
379
|
-
const info = getUpdateInfo();
|
|
380
|
-
|
|
381
|
-
// 解析参数
|
|
382
|
-
const isForce = args.includes("--force");
|
|
383
|
-
const versionArg = args.replace("--force", "").trim() || undefined;
|
|
384
|
-
|
|
385
|
-
// 如果没有指定版本,先检查是否有更新
|
|
386
|
-
if (!versionArg && !isForce) {
|
|
387
|
-
if (info.checkedAt === 0) {
|
|
388
|
-
return `⏳ 版本检查中,请稍后再试`;
|
|
389
|
-
}
|
|
390
|
-
if (!info.hasUpdate && !info.error) {
|
|
391
|
-
return `✅ 当前版本 v${PLUGIN_VERSION} 已是最新,无需升级\n\n> 💡 使用 /bot-hot-upgrade --force 可强制重新安装`;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const targetVersion = versionArg || info.latest || undefined;
|
|
396
|
-
|
|
397
|
-
// 异步执行升级
|
|
398
|
-
const started = fireHotUpgrade(targetVersion);
|
|
399
|
-
if (!started) {
|
|
400
|
-
return `❌ 当前环境不支持热更新(需要 bash 环境)\n\n> Windows 用户请安装 Git for Windows 后重试,或手动执行升级脚本`;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const lines = [
|
|
404
|
-
`🔄 开始热更新...`,
|
|
405
|
-
`📌 当前版本:v${PLUGIN_VERSION}`,
|
|
406
|
-
];
|
|
407
|
-
if (targetVersion) {
|
|
408
|
-
lines.push(`🎯 目标版本:v${targetVersion}`);
|
|
409
|
-
}
|
|
410
|
-
lines.push(``);
|
|
411
|
-
lines.push(`⏳ 升级过程约需 30~60 秒,完成后会自动通知您`);
|
|
412
223
|
return lines.join("\n");
|
|
413
224
|
},
|
|
414
225
|
});
|
|
415
226
|
|
|
416
227
|
/**
|
|
417
228
|
* /bot-logs — 导出本地日志文件
|
|
418
|
-
*
|
|
419
|
-
* 日志路径检测策略(兼容特殊安装路径和 --profile/--dev 模式):
|
|
420
|
-
* 1. OPENCLAW_STATE_DIR 环境变量指定的目录
|
|
421
|
-
* 2. 扫描 home 目录下所有 .openclaw-xxx/logs/ 目录,取最近修改的 gateway.log
|
|
422
229
|
*/
|
|
423
230
|
registerCommand({
|
|
424
231
|
name: "bot-logs",
|
|
425
232
|
description: "导出本地日志文件",
|
|
426
233
|
handler: () => {
|
|
427
|
-
const homeDir =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
// 优先:环境变量指定的状态目录
|
|
433
|
-
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
|
434
|
-
if (stateDir) {
|
|
435
|
-
logDirs.push(path.join(stateDir, "logs"));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// 扫描搜索根目录列表(兼容 Windows APPDATA 路径)
|
|
439
|
-
const searchRoots = new Set<string>([homeDir]);
|
|
440
|
-
const appData = process.env.APPDATA; // Windows: C:\Users\xxx\AppData\Roaming
|
|
441
|
-
if (appData) searchRoots.add(appData);
|
|
442
|
-
const localAppData = process.env.LOCALAPPDATA; // Windows: C:\Users\xxx\AppData\Local
|
|
443
|
-
if (localAppData) searchRoots.add(localAppData);
|
|
444
|
-
|
|
445
|
-
for (const root of searchRoots) {
|
|
446
|
-
try {
|
|
447
|
-
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
448
|
-
for (const entry of entries) {
|
|
449
|
-
if (entry.isDirectory() && (entry.name.startsWith(".openclaw") || entry.name.startsWith("openclaw"))) {
|
|
450
|
-
const candidate = path.join(root, entry.name, "logs");
|
|
451
|
-
if (!logDirs.includes(candidate)) {
|
|
452
|
-
logDirs.push(candidate);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
} catch {
|
|
457
|
-
// 无权限或不存在,跳过
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// 兜底:默认路径
|
|
462
|
-
const defaultLogDir = path.join(homeDir, ".openclaw", "logs");
|
|
463
|
-
if (!logDirs.includes(defaultLogDir)) {
|
|
464
|
-
logDirs.push(defaultLogDir);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// 从所有候选目录中找到存在且最近修改的 gateway.log
|
|
468
|
-
let bestLogDir: string | null = null;
|
|
469
|
-
let bestMtime = 0;
|
|
470
|
-
|
|
471
|
-
for (const logDir of logDirs) {
|
|
472
|
-
const gatewayLog = path.join(logDir, "gateway.log");
|
|
473
|
-
try {
|
|
474
|
-
const stat = fs.statSync(gatewayLog);
|
|
475
|
-
if (stat.mtimeMs > bestMtime) {
|
|
476
|
-
bestMtime = stat.mtimeMs;
|
|
477
|
-
bestLogDir = logDir;
|
|
478
|
-
}
|
|
479
|
-
} catch {
|
|
480
|
-
// 不存在或无权限,跳过
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (!bestLogDir) {
|
|
485
|
-
const searched = logDirs.map(d => ` - ${d}`).join("\n");
|
|
486
|
-
return `⚠️ 未找到日志文件\n\n已搜索以下路径:\n${searched}`;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const gatewayLog = path.join(bestLogDir, "gateway.log");
|
|
490
|
-
const errLog = path.join(bestLogDir, "gateway.err.log");
|
|
234
|
+
const homeDir = process.env.HOME || "~";
|
|
235
|
+
const logDir = path.join(homeDir, ".openclaw", "logs");
|
|
236
|
+
const gatewayLog = path.join(logDir, "gateway.log");
|
|
237
|
+
const errLog = path.join(logDir, "gateway.err.log");
|
|
491
238
|
|
|
492
239
|
const lines: string[] = [];
|
|
493
240
|
|
|
241
|
+
// 读取 gateway.log 最后 2000 行
|
|
494
242
|
for (const logFile of [gatewayLog, errLog]) {
|
|
495
243
|
if (!fs.existsSync(logFile)) continue;
|
|
496
244
|
try {
|
|
497
245
|
const content = fs.readFileSync(logFile, "utf8");
|
|
498
246
|
const allLines = content.split("\n");
|
|
499
|
-
const tail = allLines.slice(-1000);
|
|
247
|
+
const tail = allLines.slice(-1000); // 每个文件取最后 1000 行
|
|
500
248
|
if (tail.length > 0) {
|
|
501
249
|
lines.push(`\n========== ${path.basename(logFile)} (last ${tail.length} lines) ==========\n`);
|
|
502
250
|
lines.push(...tail);
|
|
@@ -507,7 +255,7 @@ registerCommand({
|
|
|
507
255
|
}
|
|
508
256
|
|
|
509
257
|
if (lines.length === 0) {
|
|
510
|
-
return
|
|
258
|
+
return "⚠️ 未找到日志文件";
|
|
511
259
|
}
|
|
512
260
|
|
|
513
261
|
// 写入临时文件
|
|
@@ -521,7 +269,7 @@ registerCommand({
|
|
|
521
269
|
|
|
522
270
|
const totalLines = lines.filter(l => !l.startsWith("=")).length;
|
|
523
271
|
return {
|
|
524
|
-
text: `📋 日志已打包(约 ${totalLines}
|
|
272
|
+
text: `📋 日志已打包(约 ${totalLines} 行),正在发送文件...`,
|
|
525
273
|
filePath: tmpFile,
|
|
526
274
|
};
|
|
527
275
|
},
|
package/src/types.ts
CHANGED
|
@@ -64,12 +64,6 @@ export interface QQBotAccountConfig {
|
|
|
64
64
|
* 默认: https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg
|
|
65
65
|
*/
|
|
66
66
|
upgradeUrl?: string;
|
|
67
|
-
/**
|
|
68
|
-
* /bot-upgrade 指令的行为模式
|
|
69
|
-
* - "doc":展示升级文档链接(默认,安全模式)
|
|
70
|
-
* - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
|
|
71
|
-
*/
|
|
72
|
-
upgradeMode?: "doc" | "hot-reload";
|
|
73
67
|
}
|
|
74
68
|
|
|
75
69
|
/**
|
|
@@ -1,16 +0,0 @@
|
|
|
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;
|
|
@@ -1,234 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 测试 sendMedia 路径:语音、视频、文件
|
|
3
|
-
* 用法:npx tsx scripts/test-sendmedia.ts <openid>
|
|
4
|
-
*/
|
|
5
|
-
import { sendMedia } from "../src/outbound.js";
|
|
6
|
-
import type { ResolvedQQBotAccount } from "../src/types.js";
|
|
7
|
-
import * as fs from "node:fs";
|
|
8
|
-
import * as path from "node:path";
|
|
9
|
-
|
|
10
|
-
const LOG_FILE = "/tmp/test-sendmedia-output.log";
|
|
11
|
-
|
|
12
|
-
function log(msg: string) {
|
|
13
|
-
const line = msg + "\n";
|
|
14
|
-
process.stdout.write(line);
|
|
15
|
-
fs.appendFileSync(LOG_FILE, line);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function normalizeAppId(raw: unknown): string {
|
|
19
|
-
if (raw === null || raw === undefined) return "";
|
|
20
|
-
return String(raw).trim();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function detectConfigPath(): string | null {
|
|
24
|
-
const home = process.env.HOME || "/home/ubuntu";
|
|
25
|
-
for (const app of ["openclaw", "clawdbot", "moltbot"]) {
|
|
26
|
-
const p = path.join(home, `.${app}`, `${app}.json`);
|
|
27
|
-
if (fs.existsSync(p)) return p;
|
|
28
|
-
}
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function loadAccount(): ResolvedQQBotAccount | null {
|
|
33
|
-
const configPath = detectConfigPath();
|
|
34
|
-
try {
|
|
35
|
-
if (!configPath || !fs.existsSync(configPath)) {
|
|
36
|
-
const appId = process.env.QQBOT_APP_ID;
|
|
37
|
-
const clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
|
38
|
-
if (appId && clientSecret) {
|
|
39
|
-
return { accountId: "default", appId: normalizeAppId(appId), clientSecret, enabled: true, secretSource: "env", markdownSupport: true, config: {} };
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
44
|
-
const qqbot = config.channels?.qqbot;
|
|
45
|
-
if (!qqbot) return null;
|
|
46
|
-
return {
|
|
47
|
-
accountId: "default",
|
|
48
|
-
appId: normalizeAppId(qqbot.appId ?? process.env.QQBOT_APP_ID),
|
|
49
|
-
clientSecret: qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
|
|
50
|
-
enabled: qqbot.enabled ?? true,
|
|
51
|
-
secretSource: qqbot.clientSecret ? "config" as const : "env" as const,
|
|
52
|
-
markdownSupport: qqbot.markdownSupport ?? true,
|
|
53
|
-
config: qqbot,
|
|
54
|
-
};
|
|
55
|
-
} catch (err) {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
|
61
|
-
|
|
62
|
-
async function main() {
|
|
63
|
-
// 清空日志
|
|
64
|
-
fs.writeFileSync(LOG_FILE, "");
|
|
65
|
-
|
|
66
|
-
const openid = process.argv[2];
|
|
67
|
-
if (!openid) { log("用法: npx tsx scripts/test-sendmedia.ts <openid>"); process.exit(1); }
|
|
68
|
-
|
|
69
|
-
const account = loadAccount();
|
|
70
|
-
if (!account) { log("无法加载账户配置"); process.exit(1); }
|
|
71
|
-
|
|
72
|
-
const to = `c2c:${openid}`;
|
|
73
|
-
log(`目标: ${to}\n`);
|
|
74
|
-
|
|
75
|
-
// ===== 1. 语音 =====
|
|
76
|
-
log("==================================================");
|
|
77
|
-
log("TEST 1: 语音 (本地 WAV 文件)");
|
|
78
|
-
log("==================================================");
|
|
79
|
-
const wavPath = "/tmp/test-voice.wav";
|
|
80
|
-
if (fs.existsSync(wavPath)) {
|
|
81
|
-
const r1 = await sendMedia({ to, text: "测试语音 sendMedia", mediaUrl: wavPath, account });
|
|
82
|
-
log("结果: " + JSON.stringify(r1, null, 2));
|
|
83
|
-
} else {
|
|
84
|
-
log("跳过: /tmp/test-voice.wav 不存在");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
await sleep(2000);
|
|
88
|
-
|
|
89
|
-
// ===== 2. 视频 =====
|
|
90
|
-
log("\n==================================================");
|
|
91
|
-
log("TEST 2: 视频 (公网 MP4 URL)");
|
|
92
|
-
log("==================================================");
|
|
93
|
-
const videoUrl = "https://www.w3schools.com/html/mov_bbb.mp4";
|
|
94
|
-
const r2 = await sendMedia({ to, text: "测试视频 sendMedia", mediaUrl: videoUrl, account });
|
|
95
|
-
log("结果: " + JSON.stringify(r2, null, 2));
|
|
96
|
-
|
|
97
|
-
await sleep(2000);
|
|
98
|
-
|
|
99
|
-
// ===== 3. 文件 =====
|
|
100
|
-
log("\n==================================================");
|
|
101
|
-
log("TEST 3: 文件 (本地 TXT 文件)");
|
|
102
|
-
log("==================================================");
|
|
103
|
-
const txtPath = "/tmp/test-doc.txt";
|
|
104
|
-
if (fs.existsSync(txtPath)) {
|
|
105
|
-
const r3 = await sendMedia({ to, text: "测试文件 sendMedia", mediaUrl: txtPath, account });
|
|
106
|
-
log("结果: " + JSON.stringify(r3, null, 2));
|
|
107
|
-
} else {
|
|
108
|
-
log("跳过: /tmp/test-doc.txt 不存在");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
log("\n==================================================");
|
|
112
|
-
log("全部测试完成");
|
|
113
|
-
log("==================================================");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
main().catch(err => { log("脚本异常: " + err); process.exit(1); });
|