@tencent-connect/openclaw-qqbot 1.5.7 → 1.6.0-alpha.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 +9 -2
- package/README.zh.md +7 -2
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +85 -115
- package/scripts/upgrade-via-source.sh +203 -35
- package/skills/qqbot-cron/SKILL.md +46 -423
- package/skills/qqbot-media/SKILL.md +29 -182
- package/src/api.ts +16 -5
- package/src/channel.ts +6 -7
- package/src/gateway.ts +510 -525
- package/src/image-server.ts +72 -10
- package/src/openclaw-plugin-sdk.d.ts +1 -1
- package/src/outbound.ts +571 -611
- package/src/ref-index-store.ts +1 -1
- package/src/slash-commands.ts +425 -0
- package/src/types.ts +18 -1
- package/src/update-checker.ts +102 -0
- package/src/user-messages.ts +73 -0
- package/src/utils/audio-convert.ts +69 -4
- package/src/utils/media-tags.ts +46 -4
- package/dist/AI/345/210/233/346/226/260/345/272/224/347/224/250/345/245/226_/347/224/263/346/212/245/344/271/246.md +0 -211
- package/dist/index.d.ts +0 -17
- package/dist/index.js +0 -22
- package/dist/src/api.d.ts +0 -138
- package/dist/src/api.js +0 -525
- package/dist/src/channel.d.ts +0 -3
- package/dist/src/channel.js +0 -337
- package/dist/src/config.d.ts +0 -25
- package/dist/src/config.js +0 -161
- package/dist/src/gateway.d.ts +0 -18
- package/dist/src/gateway.js +0 -2468
- package/dist/src/image-server.d.ts +0 -62
- package/dist/src/image-server.js +0 -401
- package/dist/src/known-users.d.ts +0 -100
- package/dist/src/known-users.js +0 -263
- package/dist/src/onboarding.d.ts +0 -10
- package/dist/src/onboarding.js +0 -203
- package/dist/src/outbound.d.ts +0 -150
- package/dist/src/outbound.js +0 -1175
- package/dist/src/proactive.d.ts +0 -170
- package/dist/src/proactive.js +0 -399
- package/dist/src/runtime.d.ts +0 -3
- package/dist/src/runtime.js +0 -10
- package/dist/src/session-store.d.ts +0 -52
- package/dist/src/session-store.js +0 -254
- package/dist/src/slash-commands.d.ts +0 -48
- package/dist/src/slash-commands.js +0 -212
- package/dist/src/types.d.ts +0 -146
- package/dist/src/types.js +0 -1
- package/dist/src/utils/audio-convert.d.ts +0 -73
- package/dist/src/utils/audio-convert.js +0 -645
- package/dist/src/utils/file-utils.d.ts +0 -46
- package/dist/src/utils/file-utils.js +0 -107
- package/dist/src/utils/image-size.d.ts +0 -51
- package/dist/src/utils/image-size.js +0 -234
- package/dist/src/utils/media-tags.d.ts +0 -14
- package/dist/src/utils/media-tags.js +0 -120
- package/dist/src/utils/payload.d.ts +0 -112
- package/dist/src/utils/payload.js +0 -186
- package/dist/src/utils/platform.d.ts +0 -126
- package/dist/src/utils/platform.js +0 -358
- package/dist/src/utils/upload-cache.d.ts +0 -34
- package/dist/src/utils/upload-cache.js +0 -93
package/src/ref-index-store.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface RefAttachmentSummary {
|
|
|
44
44
|
contentType?: string;
|
|
45
45
|
/** 语音转录文本(入站:STT/ASR识别结果;出站:TTS原文本) */
|
|
46
46
|
transcript?: string;
|
|
47
|
-
/** 语音转录来源:stt=本地STT、asr
|
|
47
|
+
/** 语音转录来源:stt=本地STT、asr=平台ASR、tts=TTS原文本、fallback=兜底文案 */
|
|
48
48
|
transcriptSource?: "stt" | "asr" | "tts" | "fallback";
|
|
49
49
|
/** 已下载到本地的文件路径(持久化后可供引用时访问) */
|
|
50
50
|
localPath?: string;
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot 插件级斜杠指令处理器
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* 1. 在消息入队前拦截,匹配到插件级指令后直接回复,不进入 AI 处理队列
|
|
6
|
+
* 2. 不匹配的 "/" 消息照常入队,交给 OpenClaw 框架处理
|
|
7
|
+
* 3. 每个指令通过 SlashCommand 接口注册,易于扩展
|
|
8
|
+
*
|
|
9
|
+
* 时间线追踪:
|
|
10
|
+
* 开平推送时间戳 → 插件收到(Date.now()) → 指令处理完成(Date.now())
|
|
11
|
+
* 从而计算「开平→插件」和「插件处理」两段耗时
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { QQBotAccountConfig } from "./types.js";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { getUpdateInfo, formatUpdateNotice } from "./update-checker.js";
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
// 读取 package.json 中的版本号
|
|
26
|
+
let PLUGIN_VERSION = "unknown";
|
|
27
|
+
try {
|
|
28
|
+
const pkg = require("../package.json");
|
|
29
|
+
PLUGIN_VERSION = pkg.version ?? "unknown";
|
|
30
|
+
} catch {
|
|
31
|
+
// fallback
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 获取 openclaw 框架版本(缓存结果,只执行一次)
|
|
35
|
+
let _frameworkVersion: string | null = null;
|
|
36
|
+
function getFrameworkVersion(): string {
|
|
37
|
+
if (_frameworkVersion !== null) return _frameworkVersion;
|
|
38
|
+
try {
|
|
39
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
40
|
+
try {
|
|
41
|
+
const out = execFileSync(cli, ["--version"], { timeout: 3000, encoding: "utf8" }).trim();
|
|
42
|
+
// 输出格式: "OpenClaw 2026.3.13 (61d171a)"
|
|
43
|
+
if (out) {
|
|
44
|
+
_frameworkVersion = out;
|
|
45
|
+
return _frameworkVersion;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// fallback
|
|
53
|
+
}
|
|
54
|
+
_frameworkVersion = "unknown";
|
|
55
|
+
return _frameworkVersion;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============ 类型定义 ============
|
|
59
|
+
|
|
60
|
+
/** 斜杠指令上下文(消息元数据 + 运行时状态) */
|
|
61
|
+
export interface SlashCommandContext {
|
|
62
|
+
/** 消息类型 */
|
|
63
|
+
type: "c2c" | "guild" | "dm" | "group";
|
|
64
|
+
/** 发送者 ID */
|
|
65
|
+
senderId: string;
|
|
66
|
+
/** 发送者昵称 */
|
|
67
|
+
senderName?: string;
|
|
68
|
+
/** 消息 ID(用于被动回复) */
|
|
69
|
+
messageId: string;
|
|
70
|
+
/** 开平推送的事件时间戳(ISO 字符串) */
|
|
71
|
+
eventTimestamp: string;
|
|
72
|
+
/** 插件收到消息的本地时间(ms) */
|
|
73
|
+
receivedAt: number;
|
|
74
|
+
/** 原始消息内容 */
|
|
75
|
+
rawContent: string;
|
|
76
|
+
/** 指令参数(去掉指令名后的部分) */
|
|
77
|
+
args: string;
|
|
78
|
+
/** 频道 ID(guild 类型) */
|
|
79
|
+
channelId?: string;
|
|
80
|
+
/** 群 openid(group 类型) */
|
|
81
|
+
groupOpenid?: string;
|
|
82
|
+
/** 账号 ID */
|
|
83
|
+
accountId: string;
|
|
84
|
+
/** 账号配置(供指令读取可配置项) */
|
|
85
|
+
accountConfig?: QQBotAccountConfig;
|
|
86
|
+
/** 当前用户队列状态快照 */
|
|
87
|
+
queueSnapshot: QueueSnapshot;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 队列状态快照 */
|
|
91
|
+
export interface QueueSnapshot {
|
|
92
|
+
/** 各用户队列中的消息总数 */
|
|
93
|
+
totalPending: number;
|
|
94
|
+
/** 正在并行处理的用户数 */
|
|
95
|
+
activeUsers: number;
|
|
96
|
+
/** 最大并发用户数 */
|
|
97
|
+
maxConcurrentUsers: number;
|
|
98
|
+
/** 当前发送者在队列中的待处理消息数 */
|
|
99
|
+
senderPending: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 斜杠指令返回值:文本、带文件的结果、或 null(不处理) */
|
|
103
|
+
export type SlashCommandResult = string | SlashCommandFileResult | null;
|
|
104
|
+
|
|
105
|
+
/** 带文件的指令结果(先回复文本,再发送文件) */
|
|
106
|
+
export interface SlashCommandFileResult {
|
|
107
|
+
text: string;
|
|
108
|
+
/** 要发送的本地文件路径 */
|
|
109
|
+
filePath: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 斜杠指令定义 */
|
|
113
|
+
interface SlashCommand {
|
|
114
|
+
/** 指令名(不含 /) */
|
|
115
|
+
name: string;
|
|
116
|
+
/** 简要描述 */
|
|
117
|
+
description: string;
|
|
118
|
+
/** 处理函数 */
|
|
119
|
+
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============ 指令注册表 ============
|
|
123
|
+
|
|
124
|
+
const commands: Map<string, SlashCommand> = new Map();
|
|
125
|
+
|
|
126
|
+
function registerCommand(cmd: SlashCommand): void {
|
|
127
|
+
commands.set(cmd.name.toLowerCase(), cmd);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============ 内置指令 ============
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* /qqbot-ping — 连通性检查(含分段耗时)
|
|
134
|
+
*
|
|
135
|
+
* 测试当前 openclaw 与 QQ 连接的网络延迟情况
|
|
136
|
+
* 耗时拆解:
|
|
137
|
+
* 总延迟 = QQ开平→插件收到 + 插件处理
|
|
138
|
+
* 其中 QQ开平→插件 ≈ eventTimestamp → receivedAt
|
|
139
|
+
* 插件处理 ≈ receivedAt → now
|
|
140
|
+
*/
|
|
141
|
+
registerCommand({
|
|
142
|
+
name: "qqbot-ping",
|
|
143
|
+
description: "测试当前 openclaw 与 QQ 连接的网络延迟",
|
|
144
|
+
handler: (ctx) => {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const eventTime = new Date(ctx.eventTimestamp).getTime();
|
|
147
|
+
if (isNaN(eventTime)) {
|
|
148
|
+
return `🏓 pong!`;
|
|
149
|
+
}
|
|
150
|
+
const totalMs = now - eventTime;
|
|
151
|
+
const qqToPlugin = ctx.receivedAt - eventTime;
|
|
152
|
+
const pluginProcess = now - ctx.receivedAt;
|
|
153
|
+
const lines = [
|
|
154
|
+
`🏓 pong!`,
|
|
155
|
+
`⏱ 总延迟: ${totalMs}ms`,
|
|
156
|
+
` ├ QQ → 插件: ${qqToPlugin}ms`,
|
|
157
|
+
` └ 插件处理: ${pluginProcess}ms`,
|
|
158
|
+
];
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* /qqbot-version — 版本信息(框架 + 插件 + 更新状态)
|
|
165
|
+
*
|
|
166
|
+
* 示例输出:
|
|
167
|
+
* 🦞 框架: 2026.3.13 (61d171a)
|
|
168
|
+
* 🤖 插件: v1.6.0
|
|
169
|
+
* ✅ 当前已是最新版本
|
|
170
|
+
*/
|
|
171
|
+
registerCommand({
|
|
172
|
+
name: "qqbot-version",
|
|
173
|
+
description: "版本信息",
|
|
174
|
+
handler: () => {
|
|
175
|
+
const frameworkVersion = getFrameworkVersion();
|
|
176
|
+
const lines = [
|
|
177
|
+
`🦞 框架: ${frameworkVersion}`,
|
|
178
|
+
`🤖 qqbot插件: v${PLUGIN_VERSION}`,
|
|
179
|
+
];
|
|
180
|
+
const info = getUpdateInfo();
|
|
181
|
+
if (info.checkedAt === 0) {
|
|
182
|
+
// 尚未检查过
|
|
183
|
+
lines.push(`⏳ 版本检查中...`);
|
|
184
|
+
} else if (info.error) {
|
|
185
|
+
lines.push(`⚠️ 版本检查失败`);
|
|
186
|
+
} else if (info.hasUpdate && info.latest) {
|
|
187
|
+
lines.push(`🆕 有新版本: v${info.latest},使用 /qqbot-upgrade 升级`);
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(`✅ 当前已是最新版本`);
|
|
190
|
+
}
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* /qqbot-help — 列出所有插件级斜杠指令
|
|
197
|
+
*/
|
|
198
|
+
registerCommand({
|
|
199
|
+
name: "qqbot-help",
|
|
200
|
+
description: "显示帮助信息",
|
|
201
|
+
handler: () => {
|
|
202
|
+
const lines = [`**qqbot 插件 v${PLUGIN_VERSION}**`, ``];
|
|
203
|
+
for (const [name, cmd] of commands) {
|
|
204
|
+
lines.push(`- \`/${name}\` — ${cmd.description}`);
|
|
205
|
+
}
|
|
206
|
+
// 如有更新可用,追加提示
|
|
207
|
+
const info = getUpdateInfo();
|
|
208
|
+
const notice = formatUpdateNotice(info);
|
|
209
|
+
if (notice) {
|
|
210
|
+
lines.push("", notice);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const DEFAULT_UPGRADE_URL = "https://github.com/tencent-connect/openclaw-qqbot";
|
|
217
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
218
|
+
const UPGRADE_SCRIPT = path.resolve(__dirname, "../scripts/upgrade-via-npm.sh");
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* /qqbot-upgrade [version] — 触发插件版本升级
|
|
222
|
+
* 无参数: 升级到 latest
|
|
223
|
+
* 带参数: 升级到指定版本,如 /qqbot-upgrade 1.6.1
|
|
224
|
+
*
|
|
225
|
+
* 智能判断:
|
|
226
|
+
* - 当前版本 >= latest → 提示已是最新
|
|
227
|
+
* - 当前版本 < latest → 展示升级指引 + 执行升级
|
|
228
|
+
*/
|
|
229
|
+
registerCommand({
|
|
230
|
+
name: "qqbot-upgrade",
|
|
231
|
+
description: "升级插件(/qqbot-upgrade [版本号])",
|
|
232
|
+
handler: async (ctx) => {
|
|
233
|
+
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
234
|
+
const targetVersion = ctx.args.trim();
|
|
235
|
+
|
|
236
|
+
// 无指定版本时,先检查是否需要升级
|
|
237
|
+
if (!targetVersion) {
|
|
238
|
+
const info = getUpdateInfo();
|
|
239
|
+
if (info.checkedAt > 0 && !info.error && !info.hasUpdate) {
|
|
240
|
+
return `✅ 当前已是最新版本 v${PLUGIN_VERSION}\n\n升级指引: ${url}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const scriptArgs = targetVersion ? ["--version", targetVersion] : [];
|
|
244
|
+
|
|
245
|
+
let upgradeOk = false;
|
|
246
|
+
let report = "";
|
|
247
|
+
try {
|
|
248
|
+
const { stdout, stderr } = await execFileAsync("bash", [UPGRADE_SCRIPT, ...scriptArgs], {
|
|
249
|
+
timeout: 120_000,
|
|
250
|
+
env: { ...process.env, PATH: process.env.PATH },
|
|
251
|
+
});
|
|
252
|
+
const output = (stdout + stderr).trim();
|
|
253
|
+
|
|
254
|
+
// 从脚本输出解析报告文本(QQBOT_REPORT=...)和版本号
|
|
255
|
+
const reportMatch = output.match(/QQBOT_REPORT=(.+)/);
|
|
256
|
+
const versionMatch = output.match(/QQBOT_NEW_VERSION=(\S+)/);
|
|
257
|
+
const newVersion = versionMatch?.[1] || "unknown";
|
|
258
|
+
report = reportMatch?.[1] || `✅ QQBot 升级完成: v${newVersion}`;
|
|
259
|
+
|
|
260
|
+
upgradeOk = newVersion !== "unknown" && newVersion !== PLUGIN_VERSION;
|
|
261
|
+
if (!upgradeOk && newVersion === PLUGIN_VERSION) {
|
|
262
|
+
report = `ℹ️ 已是最新版本 v${PLUGIN_VERSION}`;
|
|
263
|
+
}
|
|
264
|
+
} catch (err: unknown) {
|
|
265
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
266
|
+
report = `❌ 升级失败: ${msg}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (upgradeOk) {
|
|
270
|
+
setTimeout(() => {
|
|
271
|
+
try { updatePluginsInstalls(); } catch {}
|
|
272
|
+
const cliNames = ["openclaw", "clawdbot", "moltbot"];
|
|
273
|
+
const tryRestart = (idx: number) => {
|
|
274
|
+
if (idx >= cliNames.length) { process.exit(0); return; }
|
|
275
|
+
const cli = cliNames[idx];
|
|
276
|
+
const child = spawn(cli, ["gateway", "restart"], {
|
|
277
|
+
detached: true, stdio: "ignore", env: process.env,
|
|
278
|
+
});
|
|
279
|
+
child.on("error", () => tryRestart(idx + 1));
|
|
280
|
+
child.unref();
|
|
281
|
+
};
|
|
282
|
+
tryRestart(0);
|
|
283
|
+
}, 2000);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return report + `\n\n升级指引: ${url}`;
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* /qqbot-logs — 打包最近 2000 行日志发送给用户
|
|
292
|
+
*/
|
|
293
|
+
registerCommand({
|
|
294
|
+
name: "qqbot-logs",
|
|
295
|
+
description: "获取最近运行日志",
|
|
296
|
+
handler: () => {
|
|
297
|
+
const homeDir = process.env.HOME || "~";
|
|
298
|
+
const logDir = path.join(homeDir, ".openclaw", "logs");
|
|
299
|
+
const gatewayLog = path.join(logDir, "gateway.log");
|
|
300
|
+
const errLog = path.join(logDir, "gateway.err.log");
|
|
301
|
+
|
|
302
|
+
const lines: string[] = [];
|
|
303
|
+
|
|
304
|
+
// 读取 gateway.log 最后 2000 行
|
|
305
|
+
for (const logFile of [gatewayLog, errLog]) {
|
|
306
|
+
if (!fs.existsSync(logFile)) continue;
|
|
307
|
+
try {
|
|
308
|
+
const content = fs.readFileSync(logFile, "utf8");
|
|
309
|
+
const allLines = content.split("\n");
|
|
310
|
+
const tail = allLines.slice(-1000); // 每个文件取最后 1000 行
|
|
311
|
+
if (tail.length > 0) {
|
|
312
|
+
lines.push(`\n========== ${path.basename(logFile)} (last ${tail.length} lines) ==========\n`);
|
|
313
|
+
lines.push(...tail);
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
lines.push(`[读取 ${path.basename(logFile)} 失败]`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (lines.length === 0) {
|
|
321
|
+
return "⚠️ 未找到日志文件";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 写入临时文件
|
|
325
|
+
const tmpDir = path.join(homeDir, ".openclaw", "qqbot", "downloads");
|
|
326
|
+
if (!fs.existsSync(tmpDir)) {
|
|
327
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
328
|
+
}
|
|
329
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
330
|
+
const tmpFile = path.join(tmpDir, `qqbot-logs-${timestamp}.txt`);
|
|
331
|
+
fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
|
|
332
|
+
|
|
333
|
+
const totalLines = lines.filter(l => !l.startsWith("=")).length;
|
|
334
|
+
return {
|
|
335
|
+
text: `📋 日志已打包(约 ${totalLines} 行),正在发送文件...`,
|
|
336
|
+
filePath: tmpFile,
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ============ 匹配入口 ============
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 尝试匹配并执行插件级斜杠指令
|
|
345
|
+
*
|
|
346
|
+
* @returns 回复文本(匹配成功),null(不匹配,应入队正常处理)
|
|
347
|
+
*/
|
|
348
|
+
export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
|
|
349
|
+
const content = ctx.rawContent.trim();
|
|
350
|
+
if (!content.startsWith("/")) return null;
|
|
351
|
+
|
|
352
|
+
// 解析指令名和参数
|
|
353
|
+
const spaceIdx = content.indexOf(" ");
|
|
354
|
+
const cmdName = (spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)).toLowerCase();
|
|
355
|
+
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
|
|
356
|
+
|
|
357
|
+
const cmd = commands.get(cmdName);
|
|
358
|
+
if (!cmd) return null; // 不是插件级指令,交给框架
|
|
359
|
+
|
|
360
|
+
ctx.args = args;
|
|
361
|
+
const result = await cmd.handler(ctx);
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 获取插件版本号(供外部使用) */
|
|
366
|
+
export function getPluginVersion(): string {
|
|
367
|
+
return PLUGIN_VERSION;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 更新 openclaw.json 中的 plugins.installs 记录。
|
|
372
|
+
* - 如果 source 是 "path"(开发目录),改为 "npm" 并删除 sourcePath
|
|
373
|
+
* - 更新 installPath、version、installedAt
|
|
374
|
+
*/
|
|
375
|
+
function updatePluginsInstalls(): void {
|
|
376
|
+
const cliNames = ["openclaw", "clawdbot", "moltbot"];
|
|
377
|
+
let configPath = "";
|
|
378
|
+
let cliName = "";
|
|
379
|
+
for (const name of cliNames) {
|
|
380
|
+
const candidate = path.join(process.env.HOME || "~", `.${name}`, `${name}.json`);
|
|
381
|
+
if (fs.existsSync(candidate)) {
|
|
382
|
+
configPath = candidate;
|
|
383
|
+
cliName = name;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!configPath) return;
|
|
388
|
+
|
|
389
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
390
|
+
const extensionsDir = path.join(process.env.HOME || "~", `.${cliName}`, "extensions");
|
|
391
|
+
const installPath = path.join(extensionsDir, "openclaw-qqbot");
|
|
392
|
+
|
|
393
|
+
// 读取新安装的版本号
|
|
394
|
+
let newVersion = "unknown";
|
|
395
|
+
try {
|
|
396
|
+
const pkgPath = path.join(installPath, "package.json");
|
|
397
|
+
if (fs.existsSync(pkgPath)) {
|
|
398
|
+
newVersion = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "unknown";
|
|
399
|
+
}
|
|
400
|
+
} catch {}
|
|
401
|
+
|
|
402
|
+
cfg.plugins = cfg.plugins || {};
|
|
403
|
+
cfg.plugins.installs = cfg.plugins.installs || {};
|
|
404
|
+
|
|
405
|
+
const existing = cfg.plugins.installs["openclaw-qqbot"] || {};
|
|
406
|
+
|
|
407
|
+
// 保留已有记录,只更新关键字段
|
|
408
|
+
cfg.plugins.installs["openclaw-qqbot"] = {
|
|
409
|
+
...existing,
|
|
410
|
+
source: "npm",
|
|
411
|
+
installPath,
|
|
412
|
+
version: newVersion,
|
|
413
|
+
installedAt: new Date().toISOString(),
|
|
414
|
+
};
|
|
415
|
+
// 如果之前是 source:"path",清除 sourcePath(指向开发目录)
|
|
416
|
+
delete cfg.plugins.installs["openclaw-qqbot"].sourcePath;
|
|
417
|
+
|
|
418
|
+
// 确保 plugins.entries 存在
|
|
419
|
+
cfg.plugins.entries = cfg.plugins.entries || {};
|
|
420
|
+
if (!cfg.plugins.entries["openclaw-qqbot"]) {
|
|
421
|
+
cfg.plugins.entries["openclaw-qqbot"] = { enabled: true };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 4) + "\n");
|
|
425
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -53,6 +53,17 @@ export interface QQBotAccountConfig {
|
|
|
53
53
|
* 统一管理入站(STT)和出站(上传)的音频格式转换行为
|
|
54
54
|
*/
|
|
55
55
|
audioFormatPolicy?: AudioFormatPolicy;
|
|
56
|
+
/**
|
|
57
|
+
* 是否启用公网 URL 直传 QQ 平台(默认 true)
|
|
58
|
+
* 启用时:公网 URL 先直传给 QQ 开放平台的富媒体 API,平台自行拉取;失败后自动 fallback 到插件下载再 Base64 上传
|
|
59
|
+
* 禁用时:公网 URL 始终由插件先下载到本地,再以 Base64 上传(适用于 QQ 平台无法访问目标 URL 的场景)
|
|
60
|
+
*/
|
|
61
|
+
urlDirectUpload?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* /qqbot-upgrade 指令返回的升级指引网址
|
|
64
|
+
* 默认: https://github.com/tencent-connect/openclaw-qqbot
|
|
65
|
+
*/
|
|
66
|
+
upgradeUrl?: string;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/**
|
|
@@ -72,6 +83,12 @@ export interface AudioFormatPolicy {
|
|
|
72
83
|
* 仅当需要覆盖默认值时才配置此项
|
|
73
84
|
*/
|
|
74
85
|
uploadDirectFormats?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* 是否启用语音转码(默认 true)
|
|
88
|
+
* 设为 false 可在环境无 ffmpeg 时跳过转码,直接以文件形式发送
|
|
89
|
+
* 当禁用时,非原生格式的音频会 fallback 到 sendDocument(文件发送)
|
|
90
|
+
*/
|
|
91
|
+
transcodeEnabled?: boolean;
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
/**
|
|
@@ -85,7 +102,7 @@ export interface MessageAttachment {
|
|
|
85
102
|
size?: number;
|
|
86
103
|
url: string;
|
|
87
104
|
voice_wav_url?: string; // QQ 提供的 WAV 格式语音直链,有值时优先使用以避免 SILK→WAV 转换
|
|
88
|
-
asr_refer_text?: string; // QQ 事件内置 ASR
|
|
105
|
+
asr_refer_text?: string; // QQ 事件内置 ASR 语音识别文本
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
/**
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台版本检查器
|
|
3
|
+
*
|
|
4
|
+
* - triggerUpdateCheck(): gateway 启动时调用,后台检查 npm registry 是否有新版本
|
|
5
|
+
* - getUpdateInfo(): 返回上次检查结果(供 /version、/help 指令使用)
|
|
6
|
+
* - formatUpdateNotice(): 格式化更新提示文本
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
const PKG_NAME = "@tencent-connect/openclaw-qqbot";
|
|
15
|
+
|
|
16
|
+
let CURRENT_VERSION = "unknown";
|
|
17
|
+
try {
|
|
18
|
+
const pkg = require("../package.json");
|
|
19
|
+
CURRENT_VERSION = pkg.version ?? "unknown";
|
|
20
|
+
} catch {
|
|
21
|
+
// fallback
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UpdateInfo {
|
|
25
|
+
current: string;
|
|
26
|
+
latest: string | null;
|
|
27
|
+
hasUpdate: boolean;
|
|
28
|
+
checkedAt: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let _lastInfo: UpdateInfo = {
|
|
33
|
+
current: CURRENT_VERSION,
|
|
34
|
+
latest: null,
|
|
35
|
+
hasUpdate: false,
|
|
36
|
+
checkedAt: 0,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let _checking = false;
|
|
40
|
+
|
|
41
|
+
export function triggerUpdateCheck(log?: {
|
|
42
|
+
info: (msg: string) => void;
|
|
43
|
+
error: (msg: string) => void;
|
|
44
|
+
debug?: (msg: string) => void;
|
|
45
|
+
}): void {
|
|
46
|
+
if (_checking) return;
|
|
47
|
+
const INTERVAL_MS = 30 * 60 * 1000;
|
|
48
|
+
if (_lastInfo.checkedAt > 0 && Date.now() - _lastInfo.checkedAt < INTERVAL_MS) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
_checking = true;
|
|
52
|
+
log?.debug?.(`[qqbot:update-checker] checking (current: ${CURRENT_VERSION})...`);
|
|
53
|
+
|
|
54
|
+
execFile(
|
|
55
|
+
"npm",
|
|
56
|
+
["view", PKG_NAME, "version", "--json"],
|
|
57
|
+
{ timeout: 15_000, env: { ...process.env, PATH: process.env.PATH } },
|
|
58
|
+
(err, stdout, _stderr) => {
|
|
59
|
+
_checking = false;
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
if (err) {
|
|
62
|
+
log?.debug?.(`[qqbot:update-checker] check failed: ${err.message}`);
|
|
63
|
+
_lastInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: now, error: err.message };
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const latest = JSON.parse(stdout.trim());
|
|
68
|
+
const hasUpdate = typeof latest === "string" && latest !== CURRENT_VERSION && compareVersions(latest, CURRENT_VERSION) > 0;
|
|
69
|
+
_lastInfo = { current: CURRENT_VERSION, latest, hasUpdate, checkedAt: now };
|
|
70
|
+
if (hasUpdate) {
|
|
71
|
+
log?.info?.(`[qqbot:update-checker] new version available: ${latest} (current: ${CURRENT_VERSION})`);
|
|
72
|
+
}
|
|
73
|
+
} catch (parseErr) {
|
|
74
|
+
_lastInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: now, error: String(parseErr) };
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getUpdateInfo(): UpdateInfo {
|
|
81
|
+
return { ..._lastInfo };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatUpdateNotice(info: UpdateInfo): string {
|
|
85
|
+
if (!info.hasUpdate || !info.latest) return "";
|
|
86
|
+
return `\u{1f195} 有新版本可用: v${info.latest}(当前 v${info.current})\n使用 /upgrade 升级`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compareVersions(a: string, b: string): number {
|
|
90
|
+
const normalize = (v: string) => v.replace(/^v/, "").split("-")[0].split(".").map(Number);
|
|
91
|
+
const pa = normalize(a);
|
|
92
|
+
const pb = normalize(b);
|
|
93
|
+
for (let i = 0; i < 3; i++) {
|
|
94
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
95
|
+
if (diff !== 0) return diff;
|
|
96
|
+
}
|
|
97
|
+
const aIsPrerelease = a.includes("-");
|
|
98
|
+
const bIsPrerelease = b.includes("-");
|
|
99
|
+
if (aIsPrerelease && !bIsPrerelease) return -1;
|
|
100
|
+
if (!aIsPrerelease && bIsPrerelease) return 1;
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用户面向的提示文案集中管理
|
|
3
|
+
*
|
|
4
|
+
* 设计原则(参考 Telegram / Discord / Slack / 飞书):
|
|
5
|
+
* 1. 禁止暴露服务器路径、原始 Error、配置文件结构
|
|
6
|
+
* 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
|
|
7
|
+
* 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
|
|
8
|
+
* 4. 所有面向用户的文案集中在此文件,便于维护和国际化
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ============ 媒体发送错误 ============
|
|
12
|
+
|
|
13
|
+
export const MSG = {
|
|
14
|
+
// 通用错误
|
|
15
|
+
GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~",
|
|
16
|
+
AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~",
|
|
17
|
+
AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~",
|
|
18
|
+
TIMEOUT_HINT: "已收到消息,正在处理中…",
|
|
19
|
+
|
|
20
|
+
// 图片
|
|
21
|
+
IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~",
|
|
22
|
+
IMAGE_FORMAT_UNSUPPORTED: (ext: string) => `抱歉,暂不支持 ${ext} 格式的图片~`,
|
|
23
|
+
IMAGE_SEND_FAILED: "抱歉,图片发送失败了,请稍后再试~",
|
|
24
|
+
IMAGE_UPLOADING: (size: string) => `正在上传图片 (${size})...`,
|
|
25
|
+
|
|
26
|
+
// 语音
|
|
27
|
+
VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~",
|
|
28
|
+
VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~",
|
|
29
|
+
VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~",
|
|
30
|
+
VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~",
|
|
31
|
+
VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
|
|
32
|
+
VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~",
|
|
33
|
+
|
|
34
|
+
// 视频
|
|
35
|
+
VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~",
|
|
36
|
+
VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~",
|
|
37
|
+
VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
|
|
38
|
+
VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~",
|
|
39
|
+
VIDEO_UPLOADING: (size: string) => `正在上传视频 (${size})...`,
|
|
40
|
+
|
|
41
|
+
// 文件
|
|
42
|
+
FILE_NOT_FOUND: "抱歉,文件不存在或已失效,无法发送~",
|
|
43
|
+
FILE_SEND_FAILED: "抱歉,文件发送失败了,请稍后再试~",
|
|
44
|
+
FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
|
|
45
|
+
FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
|
|
46
|
+
FILE_UPLOADING: (name: string, size: string) => `正在上传文件 ${name} (${size})...`,
|
|
47
|
+
|
|
48
|
+
// 载荷解析
|
|
49
|
+
PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~",
|
|
50
|
+
UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~",
|
|
51
|
+
UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 将媒体上传/发送错误转为对用户友好的提示文案
|
|
56
|
+
* 技术细节不暴露给用户,仅记录到日志
|
|
57
|
+
*/
|
|
58
|
+
export function formatMediaErrorMessage(mediaType: string, err: unknown): string {
|
|
59
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
60
|
+
if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
|
|
61
|
+
return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
|
|
62
|
+
}
|
|
63
|
+
if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
|
|
64
|
+
return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
|
|
65
|
+
}
|
|
66
|
+
if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
|
|
67
|
+
return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
|
|
68
|
+
}
|
|
69
|
+
if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
|
|
70
|
+
return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
|
|
71
|
+
}
|
|
72
|
+
return `抱歉,${mediaType}发送失败了,请稍后再试~`;
|
|
73
|
+
}
|