@tencent-connect/openclaw-qqbot 1.5.6 → 1.5.7

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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * QQ Bot 斜杠指令处理模块
3
+ *
4
+ * 支持的指令:
5
+ * - /echo <message> 直接回复消息(不经过 AI)
6
+ * - /debug 切换 debug 模式(开启后附带链路耗时统计)
7
+ * - /upgrade 自动执行插件更新
8
+ */
9
+ import { exec } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import { getAccessToken, sendC2CMessage, sendGroupMessage, sendChannelMessage, clearTokenCache, } from "./api.js";
12
+ const execAsync = promisify(exec);
13
+ // ============ Debug 模式管理 ============
14
+ /** 每个会话(peerId)独立的 debug 开关 */
15
+ const debugSessions = new Map();
16
+ export function isDebugEnabled(peerId) {
17
+ return debugSessions.get(peerId) === true;
18
+ }
19
+ export function setDebugEnabled(peerId, enabled) {
20
+ if (enabled) {
21
+ debugSessions.set(peerId, true);
22
+ }
23
+ else {
24
+ debugSessions.delete(peerId);
25
+ }
26
+ }
27
+ /** 格式化耗时统计为可读文本 */
28
+ export function formatTimingTrace(trace) {
29
+ const lines = ["📊 链路耗时统计"];
30
+ const t0 = trace.messageReceivedAt;
31
+ const eventTs = trace.eventTimestamp ? new Date(trace.eventTimestamp).getTime() : 0;
32
+ const platformDelay = eventTs > 0 ? `${t0 - eventTs}ms` : "N/A";
33
+ lines.push(`├ 平台→QQBot插件: ${platformDelay}`);
34
+ if (trace.dispatchToOpenClawAt) {
35
+ lines.push(`├ QQBot插件耗时: ${trace.dispatchToOpenClawAt - t0}ms`);
36
+ }
37
+ if (trace.sendCompleteAt && trace.dispatchToOpenClawAt) {
38
+ lines.push(`└ OpenClaw耗时: ${trace.sendCompleteAt - trace.dispatchToOpenClawAt}ms`);
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+ /** 发送带 token 重试的消息 */
43
+ async function sendReply(ctx, text) {
44
+ const { type, senderId, messageId, channelId, groupOpenid, account } = ctx;
45
+ const send = async (token) => {
46
+ if (type === "c2c") {
47
+ await sendC2CMessage(token, senderId, text, messageId);
48
+ }
49
+ else if (type === "group" && groupOpenid) {
50
+ await sendGroupMessage(token, groupOpenid, text, messageId);
51
+ }
52
+ else if (channelId) {
53
+ await sendChannelMessage(token, channelId, text, messageId);
54
+ }
55
+ };
56
+ try {
57
+ const token = await getAccessToken(account.appId, account.clientSecret);
58
+ await send(token);
59
+ }
60
+ catch (err) {
61
+ const errMsg = String(err);
62
+ if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
63
+ clearTokenCache(account.appId);
64
+ const newToken = await getAccessToken(account.appId, account.clientSecret);
65
+ await send(newToken);
66
+ }
67
+ else {
68
+ throw err;
69
+ }
70
+ }
71
+ }
72
+ // ============ 指令处理 ============
73
+ /** 处理 /echo 指令 */
74
+ async function handleEcho(ctx, args, receivedAt, eventTimestamp) {
75
+ const message = args.trim();
76
+ if (message) {
77
+ await sendReply(ctx, message);
78
+ }
79
+ // 计算事件时间戳 → 插件收到消息的延迟(QQ 平台 → WebSocket 传输耗时)
80
+ const eventTs = eventTimestamp ? new Date(eventTimestamp).getTime() : 0;
81
+ const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
82
+ const timing = [
83
+ "⏱ 通道耗时",
84
+ `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
85
+ `├ 平台→插件: ${platformDelay}`,
86
+ `└ 插件处理: ${Date.now() - receivedAt}ms`,
87
+ ].join("\n");
88
+ await sendReply(ctx, timing);
89
+ }
90
+ /** 处理 /debug 指令 */
91
+ async function handleDebug(ctx) {
92
+ const current = isDebugEnabled(ctx.peerId);
93
+ const next = !current;
94
+ setDebugEnabled(ctx.peerId, next);
95
+ const status = next
96
+ ? "🔍 Debug 模式已开启\n后续消息回复将附带链路耗时统计"
97
+ : "🔕 Debug 模式已关闭";
98
+ await sendReply(ctx, status);
99
+ }
100
+ /** 处理 /upgrade 指令 */
101
+ async function handleUpgrade(ctx) {
102
+ await sendReply(ctx, "⏳ 正在执行插件更新,请稍候...");
103
+ // 检测 CLI 名称
104
+ let cmdName = "";
105
+ for (const name of ["openclaw", "clawdbot", "moltbot"]) {
106
+ try {
107
+ await execAsync(`command -v ${name}`);
108
+ cmdName = name;
109
+ break;
110
+ }
111
+ catch {
112
+ // not found, try next
113
+ }
114
+ }
115
+ if (!cmdName) {
116
+ await sendReply(ctx, "❌ 更新失败: 未找到 openclaw / clawdbot / moltbot CLI");
117
+ return;
118
+ }
119
+ const PKG_NAME = "@tencent-connect/openclaw-qqbot";
120
+ const steps = [];
121
+ let hasError = false;
122
+ try {
123
+ // [1/2] 直接安装最新版本(覆盖安装,不先 uninstall 避免触发框架自动重启)
124
+ steps.push("[1/2] 安装最新版本...");
125
+ try {
126
+ const { stdout, stderr } = await execAsync(`${cmdName} plugins install "${PKG_NAME}@latest"`, { timeout: 120000 });
127
+ const output = (stdout + "\n" + stderr).trim();
128
+ if (output) {
129
+ const lines = output.split("\n").filter(l => l.trim());
130
+ steps.push(...lines.slice(0, 10).map(l => ` ${l}`));
131
+ if (lines.length > 10) {
132
+ steps.push(` ... (${lines.length - 10} more lines)`);
133
+ }
134
+ }
135
+ steps.push(" ✅ 安装成功");
136
+ }
137
+ catch (installErr) {
138
+ const errOutput = installErr instanceof Error ? installErr.stderr || installErr.message : String(installErr);
139
+ steps.push(` ❌ 安装失败: ${String(errOutput).slice(0, 200)}`);
140
+ hasError = true;
141
+ }
142
+ }
143
+ catch (err) {
144
+ steps.push(`❌ 更新过程出错: ${String(err).slice(0, 200)}`);
145
+ hasError = true;
146
+ }
147
+ // 先发送结果汇总(必须在重启之前发送,否则进程被杀后无法发送)
148
+ if (!hasError) {
149
+ steps.push("[2/2] 即将重启网关...");
150
+ }
151
+ const header = hasError ? "❌ 插件更新完成(有错误)" : "✅ 插件更新完成";
152
+ const result = `${header}\n${"=".repeat(30)}\n${steps.join("\n")}`;
153
+ try {
154
+ await sendReply(ctx, result);
155
+ }
156
+ catch (sendErr) {
157
+ ctx.log?.error(`[qqbot] Failed to send upgrade result: ${sendErr}`);
158
+ }
159
+ // 最后再重启网关(重启会杀掉当前进程,之后的代码不会执行)
160
+ if (!hasError) {
161
+ try {
162
+ await execAsync(`${cmdName} gateway restart`, { timeout: 30000 });
163
+ }
164
+ catch (restartErr) {
165
+ const errOutput = restartErr instanceof Error ? restartErr.stderr || restartErr.message : String(restartErr);
166
+ ctx.log?.error(`[qqbot] Gateway restart failed: ${errOutput}`);
167
+ }
168
+ }
169
+ }
170
+ // ============ 指令分发 ============
171
+ /**
172
+ * 尝试处理斜杠指令
173
+ *
174
+ * @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道
175
+ */
176
+ export async function handleSlashCommand(content, ctx, receivedAt, eventTimestamp) {
177
+ const trimmed = content.trim();
178
+ if (!trimmed.startsWith("/")) {
179
+ return { handled: false };
180
+ }
181
+ // 解析指令名和参数
182
+ const spaceIdx = trimmed.indexOf(" ");
183
+ const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
184
+ const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
185
+ ctx.log?.info(`[qqbot:${ctx.account.accountId}] Slash command: ${command}, args: ${args.slice(0, 50)}`);
186
+ try {
187
+ switch (command) {
188
+ case "/echo":
189
+ await handleEcho(ctx, args, receivedAt ?? Date.now(), eventTimestamp);
190
+ return { handled: true };
191
+ case "/debug":
192
+ await handleDebug(ctx);
193
+ return { handled: true };
194
+ case "/upgrade":
195
+ await handleUpgrade(ctx);
196
+ return { handled: true };
197
+ default:
198
+ // 不是已知指令,交给 AI 处理
199
+ return { handled: false };
200
+ }
201
+ }
202
+ catch (err) {
203
+ ctx.log?.error(`[qqbot:${ctx.account.accountId}] Slash command error: ${err}`);
204
+ try {
205
+ await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
206
+ }
207
+ catch {
208
+ // 发送错误消息也失败了,只能记日志
209
+ }
210
+ return { handled: true };
211
+ }
212
+ }
@@ -22,8 +22,6 @@ export declare function isVoiceAttachment(att: {
22
22
  export declare function formatDuration(durationMs: number): string;
23
23
  export declare function isAudioFile(filePath: string): boolean;
24
24
  export interface TTSConfig {
25
- /** TTS 引擎类型:openai 兼容 API 或 Edge TTS */
26
- provider: "openai" | "edge";
27
25
  baseUrl: string;
28
26
  apiKey: string;
29
27
  model: string;
@@ -34,10 +32,6 @@ export interface TTSConfig {
34
32
  queryParams?: Record<string, string>;
35
33
  /** 自定义速度(默认不传) */
36
34
  speed?: number;
37
- /** Edge TTS 专用:语速调整,如 "+10%"、"-20%" */
38
- rate?: string;
39
- /** Edge TTS 专用:音调调整,如 "+10%"、"-10%" */
40
- pitch?: string;
41
35
  }
42
36
  export declare function resolveTTSConfig(cfg: Record<string, unknown>): TTSConfig | null;
43
37
  export declare function textToSpeechPCM(text: string, ttsCfg: TTSConfig): Promise<{
@@ -132,7 +132,6 @@ function resolveTTSFromBlock(block, providerCfg) {
132
132
  const queryParams = { ...(providerCfg?.queryParams ?? {}), ...(block?.queryParams ?? {}) };
133
133
  const speed = block?.speed;
134
134
  return {
135
- provider: "openai",
136
135
  baseUrl: baseUrl.replace(/\/+$/, ""),
137
136
  apiKey,
138
137
  model,
@@ -142,27 +141,12 @@ function resolveTTSFromBlock(block, providerCfg) {
142
141
  ...(speed !== undefined ? { speed } : {}),
143
142
  };
144
143
  }
145
- function resolveEdgeTTSFromBlock(block) {
146
- const voice = block?.voice || "zh-CN-XiaoxiaoNeural";
147
- return {
148
- provider: "edge",
149
- baseUrl: "",
150
- apiKey: "",
151
- model: "edge-tts",
152
- voice,
153
- ...(block?.rate ? { rate: block.rate } : {}),
154
- ...(block?.pitch ? { pitch: block.pitch } : {}),
155
- };
156
- }
157
144
  export function resolveTTSConfig(cfg) {
158
145
  const c = cfg;
159
146
  // 优先使用 channels.qqbot.tts(插件专属配置)
160
147
  const channelTts = c?.channels?.qqbot?.tts;
161
148
  if (channelTts && channelTts.enabled !== false) {
162
149
  const providerId = channelTts?.provider || "openai";
163
- if (providerId === "edge") {
164
- return resolveEdgeTTSFromBlock(channelTts);
165
- }
166
150
  const providerCfg = c?.models?.providers?.[providerId];
167
151
  const result = resolveTTSFromBlock(channelTts, providerCfg);
168
152
  if (result)
@@ -172,9 +156,6 @@ export function resolveTTSConfig(cfg) {
172
156
  const msgTts = c?.messages?.tts;
173
157
  if (msgTts && msgTts.auto !== "disabled") {
174
158
  const providerId = msgTts?.provider || "openai";
175
- if (providerId === "edge") {
176
- return resolveEdgeTTSFromBlock(msgTts);
177
- }
178
159
  const providerBlock = msgTts?.[providerId];
179
160
  const providerCfg = c?.models?.providers?.[providerId];
180
161
  const result = resolveTTSFromBlock(providerBlock ?? {}, providerCfg);
@@ -205,76 +186,6 @@ function buildTTSRequest(ttsCfg) {
205
186
  return { url, headers };
206
187
  }
207
188
  export async function textToSpeechPCM(text, ttsCfg) {
208
- if (ttsCfg.provider === "edge") {
209
- return edgeTTSToPCM(text, ttsCfg);
210
- }
211
- return openaiTTSToPCM(text, ttsCfg);
212
- }
213
- async function edgeTTSToPCM(text, ttsCfg) {
214
- const sampleRate = 24000;
215
- const startTime = Date.now();
216
- console.log(`[tts:edge] Request: voice=${ttsCfg.voice}, rate=${ttsCfg.rate ?? "default"}, pitch=${ttsCfg.pitch ?? "default"}`);
217
- console.log(`[tts:edge] Input text (${text.length} chars): "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);
218
- const { EdgeTTS } = await import("node-edge-tts");
219
- const tts = new EdgeTTS({
220
- voice: ttsCfg.voice,
221
- outputFormat: `raw-${sampleRate}hz-16bit-mono-pcm`,
222
- ...(ttsCfg.rate ? { rate: ttsCfg.rate } : {}),
223
- ...(ttsCfg.pitch ? { pitch: ttsCfg.pitch } : {}),
224
- timeout: 60000,
225
- });
226
- const tmpDir = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "edge-tts-"));
227
- const tmpFile = path.join(tmpDir, "tts.pcm");
228
- try {
229
- await tts.ttsPromise(text, tmpFile);
230
- const pcmBuffer = fs.readFileSync(tmpFile);
231
- console.log(`[tts:edge] Done: ${pcmBuffer.length} bytes, total=${Date.now() - startTime}ms`);
232
- return { pcmBuffer, sampleRate };
233
- }
234
- catch (err) {
235
- // raw PCM 格式可能不支持,回退到 mp3
236
- console.log(`[tts:edge] PCM format failed, trying mp3 fallback: ${err instanceof Error ? err.message : String(err)}`);
237
- const tmpMp3 = path.join(tmpDir, "tts.mp3");
238
- try {
239
- const ttsMp3 = new EdgeTTS({
240
- voice: ttsCfg.voice,
241
- outputFormat: "audio-24khz-96kbitrate-mono-mp3",
242
- ...(ttsCfg.rate ? { rate: ttsCfg.rate } : {}),
243
- ...(ttsCfg.pitch ? { pitch: ttsCfg.pitch } : {}),
244
- timeout: 60000,
245
- });
246
- await ttsMp3.ttsPromise(text, tmpMp3);
247
- const mp3Buffer = fs.readFileSync(tmpMp3);
248
- console.log(`[tts:edge] mp3 generated: ${mp3Buffer.length} bytes`);
249
- const ffmpegCmd = await checkFfmpeg();
250
- if (ffmpegCmd) {
251
- const pcmBuf = await ffmpegToPCM(ffmpegCmd, tmpMp3, sampleRate);
252
- console.log(`[tts:edge] Done: mp3→PCM (ffmpeg), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
253
- return { pcmBuffer: pcmBuf, sampleRate };
254
- }
255
- const pcmBuf = await wasmDecodeMp3ToPCM(mp3Buffer, sampleRate);
256
- if (pcmBuf) {
257
- console.log(`[tts:edge] Done: mp3→PCM (wasm), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
258
- return { pcmBuffer: pcmBuf, sampleRate };
259
- }
260
- throw new Error("Edge TTS: no decoder available for mp3");
261
- }
262
- finally {
263
- try {
264
- fs.unlinkSync(tmpMp3);
265
- }
266
- catch { }
267
- }
268
- }
269
- finally {
270
- try {
271
- fs.unlinkSync(tmpFile);
272
- fs.rmdirSync(tmpDir);
273
- }
274
- catch { }
275
- }
276
- }
277
- async function openaiTTSToPCM(text, ttsCfg) {
278
189
  const sampleRate = 24000;
279
190
  const { url, headers } = buildTTSRequest(ttsCfg);
280
191
  console.log(`[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.5.6",
3
+ "version": "1.5.7",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,11 +1,11 @@
1
1
  #!/bin/bash
2
- # QQBot 插件升级脚本
2
+ # qqbot 插件升级脚本
3
3
  # 用于清理旧版本插件并重新安装
4
4
  # 兼容 clawdbot 和 openclaw 两种安装
5
5
 
6
6
  set -e
7
7
 
8
- echo "=== QQBot 插件升级脚本 ==="
8
+ echo "=== qqbot 插件升级脚本 ==="
9
9
 
10
10
  # 检测使用的是 clawdbot 还是 openclaw
11
11
  detect_installation() {
@@ -123,5 +123,5 @@ echo ""
123
123
  echo "接下来请执行以下命令重新安装插件:"
124
124
  echo " cd /path/to/openclaw-qqbot"
125
125
  echo " $CMD plugins install ."
126
- echo " $CMD channels add --channel qqbot --token \"AppID:AppSecret\""
126
+ echo " $CMD channels add --channel qqbot --token \"appid:appsecret\""
127
127
  echo " $CMD gateway restart"
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
 
3
- # QQBot Markdown 配置脚本
4
- # 用于单独设置是否启用 Markdown 消息格式
3
+ # qqbot markdown 配置脚本
4
+ # 用于单独设置是否启用 markdown 消息格式
5
5
  # 直接编辑 JSON 配置文件,避免框架验证拒绝未注册的 channel
6
6
 
7
7
  set -e
@@ -29,18 +29,18 @@ show_help() {
29
29
  echo "用法: $0 [选项]"
30
30
  echo ""
31
31
  echo "选项:"
32
- echo " enable, on, yes 启用 Markdown 消息格式"
33
- echo " disable, off, no 禁用 Markdown 消息格式(使用纯文本)"
34
- echo " status 显示当前 Markdown 配置状态"
32
+ echo " enable, on, yes 启用 markdown 消息格式"
33
+ echo " disable, off, no 禁用 markdown 消息格式(使用纯文本)"
34
+ echo " status 显示当前 markdown 配置状态"
35
35
  echo " -h, --help 显示帮助信息"
36
36
  echo ""
37
37
  echo "示例:"
38
- echo " $0 enable 启用 Markdown"
39
- echo " $0 disable 禁用 Markdown"
38
+ echo " $0 enable 启用 markdown"
39
+ echo " $0 disable 禁用 markdown"
40
40
  echo " $0 status 查看当前状态"
41
41
  echo " $0 交互式选择"
42
42
  echo ""
43
- echo "⚠️ 注意: 启用 Markdown 需要在 QQ 开放平台申请 Markdown 消息权限"
43
+ echo "⚠️ 注意: 启用 markdown 需要在 QQ 开放平台申请 markdown 消息权限"
44
44
  echo " 如果没有权限,消息将无法正常发送!"
45
45
  }
46
46
 
@@ -57,22 +57,22 @@ set_markdown_value() {
57
57
  }
58
58
 
59
59
  enable_markdown() {
60
- echo "✅ 启用 Markdown 消息格式..."
60
+ echo "✅ 启用 markdown 消息格式..."
61
61
  set_markdown_value true
62
62
  echo ""
63
- echo "Markdown 已启用。"
64
- echo "⚠️ 请确保您已在 QQ 开放平台申请了 Markdown 消息权限。"
63
+ echo "markdown 已启用。"
64
+ echo "⚠️ 请确保您已在 QQ 开放平台申请了 markdown 消息权限。"
65
65
  }
66
66
 
67
67
  disable_markdown() {
68
- echo "❌ 禁用 Markdown 消息格式(使用纯文本)..."
68
+ echo "❌ 禁用 markdown 消息格式(使用纯文本)..."
69
69
  set_markdown_value false
70
70
  echo ""
71
- echo "Markdown 已禁用,将使用纯文本格式发送消息。"
71
+ echo "markdown 已禁用,将使用纯文本格式发送消息。"
72
72
  }
73
73
 
74
74
  show_status() {
75
- echo "当前 Markdown 配置状态:"
75
+ echo "当前 markdown 配置状态:"
76
76
  echo " 配置文件: $OPENCLAW_CONFIG"
77
77
  echo ""
78
78
  current=$(node -e "
@@ -82,7 +82,7 @@ show_status() {
82
82
  if [ "$current" = "true" ]; then
83
83
  echo " 状态: ✅ 已启用"
84
84
  echo ""
85
- echo " ⚠️ 请确保您已在 QQ 开放平台申请了 Markdown 消息权限。"
85
+ echo " ⚠️ 请确保您已在 QQ 开放平台申请了 markdown 消息权限。"
86
86
  elif [ "$current" = "false" ]; then
87
87
  echo " 状态: ❌ 已禁用(纯文本模式)"
88
88
  else
@@ -92,20 +92,20 @@ show_status() {
92
92
 
93
93
  interactive_select() {
94
94
  echo "========================================="
95
- echo " QQBot Markdown 配置"
95
+ echo " qqbot markdown 配置"
96
96
  echo "========================================="
97
97
  echo ""
98
98
  show_status
99
99
  echo ""
100
100
  echo "-----------------------------------------"
101
101
  echo ""
102
- echo "是否启用 Markdown 消息格式?"
102
+ echo "是否启用 markdown 消息格式?"
103
103
  echo ""
104
- echo "⚠️ 注意: 启用 Markdown 需要在 QQ 开放平台申请 Markdown 消息权限"
104
+ echo "⚠️ 注意: 启用 markdown 需要在 QQ 开放平台申请 markdown 消息权限"
105
105
  echo " 如果没有权限,消息将无法正常发送!"
106
106
  echo ""
107
- echo " 1) 启用 Markdown"
108
- echo " 2) 禁用 Markdown(纯文本)"
107
+ echo " 1) 启用 markdown"
108
+ echo " 2) 禁用 markdown(纯文本)"
109
109
  echo " 3) 取消"
110
110
  echo ""
111
111
  read -t 10 -p "请选择 [1-3] (默认: 2): " choice || choice="2"