@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
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session 持久化存储
|
|
3
|
-
* 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件
|
|
4
|
-
* 支持进程重启后通过 Resume 机制快速恢复连接
|
|
5
|
-
*/
|
|
6
|
-
import fs from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import { getQQBotDataDir } from "./utils/platform.js";
|
|
9
|
-
// Session 文件目录
|
|
10
|
-
const SESSION_DIR = getQQBotDataDir("sessions");
|
|
11
|
-
// Session 过期时间(5分钟)- Resume 要求在断开后一定时间内恢复
|
|
12
|
-
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
|
|
13
|
-
// 写入节流时间(避免频繁写入)
|
|
14
|
-
const SAVE_THROTTLE_MS = 1000;
|
|
15
|
-
// 每个账户的节流状态
|
|
16
|
-
const throttleState = new Map();
|
|
17
|
-
/**
|
|
18
|
-
* 确保目录存在
|
|
19
|
-
*/
|
|
20
|
-
function ensureDir() {
|
|
21
|
-
if (!fs.existsSync(SESSION_DIR)) {
|
|
22
|
-
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* 获取 Session 文件路径
|
|
27
|
-
*/
|
|
28
|
-
function getSessionPath(accountId) {
|
|
29
|
-
// 清理 accountId 中的特殊字符
|
|
30
|
-
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
31
|
-
return path.join(SESSION_DIR, `session-${safeId}.json`);
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* 加载 Session 状态
|
|
35
|
-
* @param accountId 账户 ID
|
|
36
|
-
* @param expectedAppId 当前使用的 appId,如果与保存时的 appId 不匹配则视为失效
|
|
37
|
-
* @returns Session 状态,如果不存在、已过期或 appId 不匹配返回 null
|
|
38
|
-
*/
|
|
39
|
-
export function loadSession(accountId, expectedAppId) {
|
|
40
|
-
const filePath = getSessionPath(accountId);
|
|
41
|
-
try {
|
|
42
|
-
if (!fs.existsSync(filePath)) {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
const data = fs.readFileSync(filePath, "utf-8");
|
|
46
|
-
const state = JSON.parse(data);
|
|
47
|
-
// 检查是否过期
|
|
48
|
-
const now = Date.now();
|
|
49
|
-
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
|
50
|
-
console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`);
|
|
51
|
-
try {
|
|
52
|
-
fs.unlinkSync(filePath);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// 忽略删除错误
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
// 检查 appId 是否匹配(凭据变更检测)
|
|
60
|
-
if (expectedAppId && state.appId && state.appId !== expectedAppId) {
|
|
61
|
-
console.log(`[session-store] appId mismatch for ${accountId}: saved=${state.appId}, current=${expectedAppId}. Discarding stale session.`);
|
|
62
|
-
try {
|
|
63
|
-
fs.unlinkSync(filePath);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// 忽略删除错误
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
// 验证必要字段
|
|
71
|
-
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
|
|
72
|
-
console.log(`[session-store] Invalid session data for ${accountId}`);
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, appId=${state.appId ?? "unknown"}, age=${Math.round((now - state.savedAt) / 1000)}s`);
|
|
76
|
-
return state;
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
console.error(`[session-store] Failed to load session for ${accountId}: ${err}`);
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* 保存 Session 状态(带节流,避免频繁写入)
|
|
85
|
-
* @param state Session 状态
|
|
86
|
-
*/
|
|
87
|
-
export function saveSession(state) {
|
|
88
|
-
const { accountId } = state;
|
|
89
|
-
// 获取或初始化节流状态
|
|
90
|
-
let throttle = throttleState.get(accountId);
|
|
91
|
-
if (!throttle) {
|
|
92
|
-
throttle = {
|
|
93
|
-
pendingState: null,
|
|
94
|
-
lastSaveTime: 0,
|
|
95
|
-
throttleTimer: null,
|
|
96
|
-
};
|
|
97
|
-
throttleState.set(accountId, throttle);
|
|
98
|
-
}
|
|
99
|
-
const now = Date.now();
|
|
100
|
-
const timeSinceLastSave = now - throttle.lastSaveTime;
|
|
101
|
-
// 如果距离上次保存时间足够长,立即保存
|
|
102
|
-
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
|
|
103
|
-
doSaveSession(state);
|
|
104
|
-
throttle.lastSaveTime = now;
|
|
105
|
-
throttle.pendingState = null;
|
|
106
|
-
// 清除待定的节流定时器
|
|
107
|
-
if (throttle.throttleTimer) {
|
|
108
|
-
clearTimeout(throttle.throttleTimer);
|
|
109
|
-
throttle.throttleTimer = null;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
// 记录待保存的状态
|
|
114
|
-
throttle.pendingState = state;
|
|
115
|
-
// 如果没有设置定时器,设置一个
|
|
116
|
-
if (!throttle.throttleTimer) {
|
|
117
|
-
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
|
|
118
|
-
throttle.throttleTimer = setTimeout(() => {
|
|
119
|
-
const t = throttleState.get(accountId);
|
|
120
|
-
if (t && t.pendingState) {
|
|
121
|
-
doSaveSession(t.pendingState);
|
|
122
|
-
t.lastSaveTime = Date.now();
|
|
123
|
-
t.pendingState = null;
|
|
124
|
-
}
|
|
125
|
-
if (t) {
|
|
126
|
-
t.throttleTimer = null;
|
|
127
|
-
}
|
|
128
|
-
}, delay);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* 实际执行保存操作
|
|
134
|
-
*/
|
|
135
|
-
function doSaveSession(state) {
|
|
136
|
-
const filePath = getSessionPath(state.accountId);
|
|
137
|
-
try {
|
|
138
|
-
ensureDir();
|
|
139
|
-
// 更新保存时间
|
|
140
|
-
const stateToSave = {
|
|
141
|
-
...state,
|
|
142
|
-
savedAt: Date.now(),
|
|
143
|
-
};
|
|
144
|
-
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
|
|
145
|
-
console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* 清除 Session 状态
|
|
153
|
-
* @param accountId 账户 ID
|
|
154
|
-
*/
|
|
155
|
-
export function clearSession(accountId) {
|
|
156
|
-
const filePath = getSessionPath(accountId);
|
|
157
|
-
// 清除节流状态
|
|
158
|
-
const throttle = throttleState.get(accountId);
|
|
159
|
-
if (throttle) {
|
|
160
|
-
if (throttle.throttleTimer) {
|
|
161
|
-
clearTimeout(throttle.throttleTimer);
|
|
162
|
-
}
|
|
163
|
-
throttleState.delete(accountId);
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
if (fs.existsSync(filePath)) {
|
|
167
|
-
fs.unlinkSync(filePath);
|
|
168
|
-
console.log(`[session-store] Cleared session for ${accountId}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* 更新 lastSeq(轻量级更新)
|
|
177
|
-
* @param accountId 账户 ID
|
|
178
|
-
* @param lastSeq 最新的消息序号
|
|
179
|
-
*/
|
|
180
|
-
export function updateLastSeq(accountId, lastSeq) {
|
|
181
|
-
const existing = loadSession(accountId);
|
|
182
|
-
if (existing && existing.sessionId) {
|
|
183
|
-
saveSession({
|
|
184
|
-
...existing,
|
|
185
|
-
lastSeq,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* 获取所有保存的 Session 状态
|
|
191
|
-
*/
|
|
192
|
-
export function getAllSessions() {
|
|
193
|
-
const sessions = [];
|
|
194
|
-
try {
|
|
195
|
-
ensureDir();
|
|
196
|
-
const files = fs.readdirSync(SESSION_DIR);
|
|
197
|
-
for (const file of files) {
|
|
198
|
-
if (file.startsWith("session-") && file.endsWith(".json")) {
|
|
199
|
-
const filePath = path.join(SESSION_DIR, file);
|
|
200
|
-
try {
|
|
201
|
-
const data = fs.readFileSync(filePath, "utf-8");
|
|
202
|
-
const state = JSON.parse(data);
|
|
203
|
-
sessions.push(state);
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// 忽略解析错误
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
// 目录不存在等错误
|
|
213
|
-
}
|
|
214
|
-
return sessions;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* 清理过期的 Session 文件
|
|
218
|
-
*/
|
|
219
|
-
export function cleanupExpiredSessions() {
|
|
220
|
-
let cleaned = 0;
|
|
221
|
-
try {
|
|
222
|
-
ensureDir();
|
|
223
|
-
const files = fs.readdirSync(SESSION_DIR);
|
|
224
|
-
const now = Date.now();
|
|
225
|
-
for (const file of files) {
|
|
226
|
-
if (file.startsWith("session-") && file.endsWith(".json")) {
|
|
227
|
-
const filePath = path.join(SESSION_DIR, file);
|
|
228
|
-
try {
|
|
229
|
-
const data = fs.readFileSync(filePath, "utf-8");
|
|
230
|
-
const state = JSON.parse(data);
|
|
231
|
-
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
|
232
|
-
fs.unlinkSync(filePath);
|
|
233
|
-
cleaned++;
|
|
234
|
-
console.log(`[session-store] Cleaned expired session: ${file}`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
// 忽略解析错误,但也删除损坏的文件
|
|
239
|
-
try {
|
|
240
|
-
fs.unlinkSync(filePath);
|
|
241
|
-
cleaned++;
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
// 忽略
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
catch {
|
|
251
|
-
// 目录不存在等错误
|
|
252
|
-
}
|
|
253
|
-
return cleaned;
|
|
254
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ Bot 斜杠指令处理模块
|
|
3
|
-
*
|
|
4
|
-
* 支持的指令:
|
|
5
|
-
* - /echo <message> 直接回复消息(不经过 AI)
|
|
6
|
-
* - /debug 切换 debug 模式(开启后附带链路耗时统计)
|
|
7
|
-
* - /upgrade 自动执行插件更新
|
|
8
|
-
*/
|
|
9
|
-
import type { ResolvedQQBotAccount } from "./types.js";
|
|
10
|
-
export declare function isDebugEnabled(peerId: string): boolean;
|
|
11
|
-
export declare function setDebugEnabled(peerId: string, enabled: boolean): void;
|
|
12
|
-
export interface TimingTrace {
|
|
13
|
-
/** QQ 平台事件时间戳 */
|
|
14
|
-
eventTimestamp?: string;
|
|
15
|
-
/** 收到 QQ WebSocket 消息的时间 */
|
|
16
|
-
messageReceivedAt: number;
|
|
17
|
-
/** 发送给 OpenClaw 的时间 */
|
|
18
|
-
dispatchToOpenClawAt?: number;
|
|
19
|
-
/** 消息发送完成的时间 */
|
|
20
|
-
sendCompleteAt?: number;
|
|
21
|
-
}
|
|
22
|
-
/** 格式化耗时统计为可读文本 */
|
|
23
|
-
export declare function formatTimingTrace(trace: TimingTrace): string;
|
|
24
|
-
export interface SlashCommandResult {
|
|
25
|
-
/** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */
|
|
26
|
-
handled: boolean;
|
|
27
|
-
}
|
|
28
|
-
interface SendContext {
|
|
29
|
-
type: "c2c" | "guild" | "dm" | "group";
|
|
30
|
-
senderId: string;
|
|
31
|
-
messageId: string;
|
|
32
|
-
channelId?: string;
|
|
33
|
-
groupOpenid?: string;
|
|
34
|
-
account: ResolvedQQBotAccount;
|
|
35
|
-
peerId: string;
|
|
36
|
-
log?: {
|
|
37
|
-
info: (msg: string) => void;
|
|
38
|
-
error: (msg: string) => void;
|
|
39
|
-
debug?: (msg: string) => void;
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* 尝试处理斜杠指令
|
|
44
|
-
*
|
|
45
|
-
* @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道
|
|
46
|
-
*/
|
|
47
|
-
export declare function handleSlashCommand(content: string, ctx: SendContext, receivedAt?: number, eventTimestamp?: string): Promise<SlashCommandResult>;
|
|
48
|
-
export {};
|
|
@@ -1,212 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/src/types.d.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ Bot 配置类型
|
|
3
|
-
*/
|
|
4
|
-
export interface QQBotConfig {
|
|
5
|
-
appId: string;
|
|
6
|
-
clientSecret?: string;
|
|
7
|
-
clientSecretFile?: string;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* 解析后的 QQ Bot 账户
|
|
11
|
-
*/
|
|
12
|
-
export interface ResolvedQQBotAccount {
|
|
13
|
-
accountId: string;
|
|
14
|
-
name?: string;
|
|
15
|
-
enabled: boolean;
|
|
16
|
-
appId: string;
|
|
17
|
-
clientSecret: string;
|
|
18
|
-
secretSource: "config" | "file" | "env" | "none";
|
|
19
|
-
/** 系统提示词 */
|
|
20
|
-
systemPrompt?: string;
|
|
21
|
-
/** 图床服务器公网地址 */
|
|
22
|
-
imageServerBaseUrl?: string;
|
|
23
|
-
/** 是否支持 markdown 消息(默认 true) */
|
|
24
|
-
markdownSupport: boolean;
|
|
25
|
-
config: QQBotAccountConfig;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* QQ Bot 账户配置
|
|
29
|
-
*/
|
|
30
|
-
export interface QQBotAccountConfig {
|
|
31
|
-
enabled?: boolean;
|
|
32
|
-
name?: string;
|
|
33
|
-
appId?: string;
|
|
34
|
-
clientSecret?: string;
|
|
35
|
-
clientSecretFile?: string;
|
|
36
|
-
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
37
|
-
allowFrom?: string[];
|
|
38
|
-
/** 系统提示词,会添加在用户消息前面 */
|
|
39
|
-
systemPrompt?: string;
|
|
40
|
-
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
|
41
|
-
imageServerBaseUrl?: string;
|
|
42
|
-
/** 是否支持 markdown 消息(默认 true,设为 false 可禁用) */
|
|
43
|
-
markdownSupport?: boolean;
|
|
44
|
-
/**
|
|
45
|
-
* @deprecated 请使用 audioFormatPolicy.uploadDirectFormats
|
|
46
|
-
* 可直接上传的音频格式(不转换为 SILK),向后兼容
|
|
47
|
-
*/
|
|
48
|
-
voiceDirectUploadFormats?: string[];
|
|
49
|
-
/**
|
|
50
|
-
* 音频格式策略配置
|
|
51
|
-
* 统一管理入站(STT)和出站(上传)的音频格式转换行为
|
|
52
|
-
*/
|
|
53
|
-
audioFormatPolicy?: AudioFormatPolicy;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* 音频格式策略:控制哪些格式可跳过转换
|
|
57
|
-
*/
|
|
58
|
-
export interface AudioFormatPolicy {
|
|
59
|
-
/**
|
|
60
|
-
* STT 模型直接支持的音频格式(入站:跳过 SILK→WAV 转换)
|
|
61
|
-
* 如果 STT 服务支持直接处理某些格式(如 silk/amr),可将其加入此列表
|
|
62
|
-
* 例如: [".silk", ".amr", ".wav", ".mp3", ".ogg"]
|
|
63
|
-
* 默认为空(所有语音都先转换为 WAV 再送 STT)
|
|
64
|
-
*/
|
|
65
|
-
sttDirectFormats?: string[];
|
|
66
|
-
/**
|
|
67
|
-
* QQ 平台支持直传的音频格式(出站:跳过→SILK 转换)
|
|
68
|
-
* 默认为 [".wav", ".mp3", ".silk"](QQ Bot API 原生支持的三种格式)
|
|
69
|
-
* 仅当需要覆盖默认值时才配置此项
|
|
70
|
-
*/
|
|
71
|
-
uploadDirectFormats?: string[];
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* 富媒体附件
|
|
75
|
-
*/
|
|
76
|
-
export interface MessageAttachment {
|
|
77
|
-
content_type: string;
|
|
78
|
-
filename?: string;
|
|
79
|
-
height?: number;
|
|
80
|
-
width?: number;
|
|
81
|
-
size?: number;
|
|
82
|
-
url: string;
|
|
83
|
-
voice_wav_url?: string;
|
|
84
|
-
asr_refer_text?: string;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* C2C 消息事件
|
|
88
|
-
*/
|
|
89
|
-
export interface C2CMessageEvent {
|
|
90
|
-
author: {
|
|
91
|
-
id: string;
|
|
92
|
-
union_openid: string;
|
|
93
|
-
user_openid: string;
|
|
94
|
-
};
|
|
95
|
-
content: string;
|
|
96
|
-
id: string;
|
|
97
|
-
timestamp: string;
|
|
98
|
-
message_scene?: {
|
|
99
|
-
source: string;
|
|
100
|
-
};
|
|
101
|
-
attachments?: MessageAttachment[];
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* 频道 AT 消息事件
|
|
105
|
-
*/
|
|
106
|
-
export interface GuildMessageEvent {
|
|
107
|
-
id: string;
|
|
108
|
-
channel_id: string;
|
|
109
|
-
guild_id: string;
|
|
110
|
-
content: string;
|
|
111
|
-
timestamp: string;
|
|
112
|
-
author: {
|
|
113
|
-
id: string;
|
|
114
|
-
username?: string;
|
|
115
|
-
bot?: boolean;
|
|
116
|
-
};
|
|
117
|
-
member?: {
|
|
118
|
-
nick?: string;
|
|
119
|
-
joined_at?: string;
|
|
120
|
-
};
|
|
121
|
-
attachments?: MessageAttachment[];
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* 群聊 AT 消息事件
|
|
125
|
-
*/
|
|
126
|
-
export interface GroupMessageEvent {
|
|
127
|
-
author: {
|
|
128
|
-
id: string;
|
|
129
|
-
member_openid: string;
|
|
130
|
-
};
|
|
131
|
-
content: string;
|
|
132
|
-
id: string;
|
|
133
|
-
timestamp: string;
|
|
134
|
-
group_id: string;
|
|
135
|
-
group_openid: string;
|
|
136
|
-
attachments?: MessageAttachment[];
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* WebSocket 事件负载
|
|
140
|
-
*/
|
|
141
|
-
export interface WSPayload {
|
|
142
|
-
op: number;
|
|
143
|
-
d?: unknown;
|
|
144
|
-
s?: number;
|
|
145
|
-
t?: string;
|
|
146
|
-
}
|
package/dist/src/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|