@sliverp/qqbot 1.3.0

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.
Files changed (78) hide show
  1. package/README.md +231 -0
  2. package/clawdbot.plugin.json +16 -0
  3. package/dist/index.d.ts +17 -0
  4. package/dist/index.js +22 -0
  5. package/dist/src/api.d.ts +194 -0
  6. package/dist/src/api.js +555 -0
  7. package/dist/src/channel.d.ts +3 -0
  8. package/dist/src/channel.js +146 -0
  9. package/dist/src/config.d.ts +25 -0
  10. package/dist/src/config.js +148 -0
  11. package/dist/src/gateway.d.ts +17 -0
  12. package/dist/src/gateway.js +722 -0
  13. package/dist/src/image-server.d.ts +62 -0
  14. package/dist/src/image-server.js +401 -0
  15. package/dist/src/known-users.d.ts +100 -0
  16. package/dist/src/known-users.js +264 -0
  17. package/dist/src/onboarding.d.ts +10 -0
  18. package/dist/src/onboarding.js +190 -0
  19. package/dist/src/outbound.d.ts +149 -0
  20. package/dist/src/outbound.js +476 -0
  21. package/dist/src/proactive.d.ts +170 -0
  22. package/dist/src/proactive.js +398 -0
  23. package/dist/src/runtime.d.ts +3 -0
  24. package/dist/src/runtime.js +10 -0
  25. package/dist/src/session-store.d.ts +49 -0
  26. package/dist/src/session-store.js +242 -0
  27. package/dist/src/types.d.ts +116 -0
  28. package/dist/src/types.js +1 -0
  29. package/dist/src/utils/image-size.d.ts +51 -0
  30. package/dist/src/utils/image-size.js +234 -0
  31. package/dist/src/utils/payload.d.ts +112 -0
  32. package/dist/src/utils/payload.js +186 -0
  33. package/index.ts +27 -0
  34. package/moltbot.plugin.json +16 -0
  35. package/node_modules/ws/LICENSE +20 -0
  36. package/node_modules/ws/README.md +548 -0
  37. package/node_modules/ws/browser.js +8 -0
  38. package/node_modules/ws/index.js +13 -0
  39. package/node_modules/ws/lib/buffer-util.js +131 -0
  40. package/node_modules/ws/lib/constants.js +19 -0
  41. package/node_modules/ws/lib/event-target.js +292 -0
  42. package/node_modules/ws/lib/extension.js +203 -0
  43. package/node_modules/ws/lib/limiter.js +55 -0
  44. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  45. package/node_modules/ws/lib/receiver.js +706 -0
  46. package/node_modules/ws/lib/sender.js +602 -0
  47. package/node_modules/ws/lib/stream.js +161 -0
  48. package/node_modules/ws/lib/subprotocol.js +62 -0
  49. package/node_modules/ws/lib/validation.js +152 -0
  50. package/node_modules/ws/lib/websocket-server.js +554 -0
  51. package/node_modules/ws/lib/websocket.js +1393 -0
  52. package/node_modules/ws/package.json +69 -0
  53. package/node_modules/ws/wrapper.mjs +8 -0
  54. package/openclaw.plugin.json +16 -0
  55. package/package.json +38 -0
  56. package/qqbot-1.3.0.tgz +0 -0
  57. package/scripts/proactive-api-server.ts +346 -0
  58. package/scripts/send-proactive.ts +273 -0
  59. package/scripts/upgrade.sh +106 -0
  60. package/skills/qqbot-cron/SKILL.md +490 -0
  61. package/skills/qqbot-media/SKILL.md +138 -0
  62. package/src/api.ts +752 -0
  63. package/src/channel.ts +303 -0
  64. package/src/config.ts +172 -0
  65. package/src/gateway.ts +1588 -0
  66. package/src/image-server.ts +474 -0
  67. package/src/known-users.ts +358 -0
  68. package/src/onboarding.ts +254 -0
  69. package/src/openclaw-plugin-sdk.d.ts +483 -0
  70. package/src/outbound.ts +571 -0
  71. package/src/proactive.ts +528 -0
  72. package/src/runtime.ts +14 -0
  73. package/src/session-store.ts +292 -0
  74. package/src/types.ts +123 -0
  75. package/src/utils/image-size.ts +266 -0
  76. package/src/utils/payload.ts +265 -0
  77. package/tsconfig.json +16 -0
  78. package/upgrade-and-run.sh +89 -0
@@ -0,0 +1,722 @@
1
+ import WebSocket from "ws";
2
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
3
+ import { getQQBotRuntime } from "./runtime.js";
4
+ import { downloadFile } from "./image-server.js";
5
+ // QQ Bot intents - 按权限级别分组
6
+ const INTENTS = {
7
+ // 基础权限(默认有)
8
+ GUILDS: 1 << 0, // 频道相关
9
+ GUILD_MEMBERS: 1 << 1, // 频道成员
10
+ PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域)
11
+ // 需要申请的权限
12
+ DIRECT_MESSAGE: 1 << 12, // 频道私信
13
+ GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
14
+ };
15
+ // 权限级别:从高到低依次尝试
16
+ const INTENT_LEVELS = [
17
+ // Level 0: 完整权限(群聊 + 私信 + 频道)
18
+ {
19
+ name: "full",
20
+ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
21
+ description: "群聊+私信+频道",
22
+ },
23
+ // Level 1: 群聊 + 频道(无私信)
24
+ {
25
+ name: "group+channel",
26
+ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GROUP_AND_C2C,
27
+ description: "群聊+频道",
28
+ },
29
+ // Level 2: 仅频道(基础权限)
30
+ {
31
+ name: "channel-only",
32
+ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS,
33
+ description: "仅频道消息",
34
+ },
35
+ ];
36
+ // 重连配置
37
+ const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
38
+ const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
39
+ const MAX_RECONNECT_ATTEMPTS = 100;
40
+ const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
41
+ const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
42
+ /**
43
+ * 启动 Gateway WebSocket 连接(带自动重连)
44
+ */
45
+ export async function startGateway(ctx) {
46
+ const { account, abortSignal, cfg, onReady, onError, log } = ctx;
47
+ if (!account.appId || !account.clientSecret) {
48
+ throw new Error("QQBot not configured (missing appId or clientSecret)");
49
+ }
50
+ // 不再需要图床服务器,使用 file_data 直接上传二进制
51
+ let reconnectAttempts = 0;
52
+ let isAborted = false;
53
+ let currentWs = null;
54
+ let heartbeatInterval = null;
55
+ let sessionId = null;
56
+ let lastSeq = null;
57
+ let lastConnectTime = 0; // 上次连接成功的时间
58
+ let quickDisconnectCount = 0; // 连续快速断开次数
59
+ let isConnecting = false; // 防止并发连接
60
+ let reconnectTimer = null; // 重连定时器
61
+ let shouldRefreshToken = false; // 下次连接是否需要刷新 token
62
+ let intentLevelIndex = 0; // 当前尝试的权限级别索引
63
+ let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
64
+ abortSignal.addEventListener("abort", () => {
65
+ isAborted = true;
66
+ if (reconnectTimer) {
67
+ clearTimeout(reconnectTimer);
68
+ reconnectTimer = null;
69
+ }
70
+ cleanup();
71
+ });
72
+ const cleanup = () => {
73
+ if (heartbeatInterval) {
74
+ clearInterval(heartbeatInterval);
75
+ heartbeatInterval = null;
76
+ }
77
+ if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) {
78
+ currentWs.close();
79
+ }
80
+ currentWs = null;
81
+ };
82
+ const getReconnectDelay = () => {
83
+ const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1);
84
+ return RECONNECT_DELAYS[idx];
85
+ };
86
+ const scheduleReconnect = (customDelay) => {
87
+ if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
88
+ log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`);
89
+ return;
90
+ }
91
+ // 取消已有的重连定时器
92
+ if (reconnectTimer) {
93
+ clearTimeout(reconnectTimer);
94
+ reconnectTimer = null;
95
+ }
96
+ const delay = customDelay ?? getReconnectDelay();
97
+ reconnectAttempts++;
98
+ log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
99
+ reconnectTimer = setTimeout(() => {
100
+ reconnectTimer = null;
101
+ if (!isAborted) {
102
+ connect();
103
+ }
104
+ }, delay);
105
+ };
106
+ const connect = async () => {
107
+ // 防止并发连接
108
+ if (isConnecting) {
109
+ log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`);
110
+ return;
111
+ }
112
+ isConnecting = true;
113
+ try {
114
+ cleanup();
115
+ // 如果标记了需要刷新 token,则清除缓存
116
+ if (shouldRefreshToken) {
117
+ log?.info(`[qqbot:${account.accountId}] Refreshing token...`);
118
+ clearTokenCache();
119
+ shouldRefreshToken = false;
120
+ }
121
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
122
+ const gatewayUrl = await getGatewayUrl(accessToken);
123
+ log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
124
+ const ws = new WebSocket(gatewayUrl);
125
+ currentWs = ws;
126
+ const pluginRuntime = getQQBotRuntime();
127
+ // 处理收到的消息
128
+ const handleMessage = async (event) => {
129
+ log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
130
+ if (event.attachments?.length) {
131
+ log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
132
+ }
133
+ // 对于 C2C 消息,可以发送输入状态提示(可选功能,暂时跳过)
134
+ pluginRuntime.channel.activity.record({
135
+ channel: "qqbot",
136
+ accountId: account.accountId,
137
+ direction: "inbound",
138
+ });
139
+ const isGroup = event.type === "guild" || event.type === "group";
140
+ const peerId = event.type === "guild" ? `channel:${event.channelId}`
141
+ : event.type === "group" ? `group:${event.groupOpenid}`
142
+ : event.senderId;
143
+ const route = pluginRuntime.channel.routing.resolveAgentRoute({
144
+ cfg,
145
+ channel: "qqbot",
146
+ accountId: account.accountId,
147
+ peer: {
148
+ kind: isGroup ? "group" : "dm",
149
+ id: peerId,
150
+ },
151
+ });
152
+ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
153
+ // 组装消息体,添加系统提示词
154
+ let builtinPrompt = "";
155
+ // 图片发送功能已改为使用 file_data 直接上传,不需要图床
156
+ builtinPrompt += `
157
+
158
+ 【发送图片】
159
+ 你可以发送本地图片文件给用户。只需在回复中直接引用图片的绝对路径即可,系统会自动处理。
160
+ 支持 png、jpg、gif、webp 格式。`;
161
+ const systemPrompts = [builtinPrompt];
162
+ if (account.systemPrompt) {
163
+ systemPrompts.push(account.systemPrompt);
164
+ }
165
+ // 处理附件(图片等)- 下载到本地供 clawdbot 访问
166
+ let attachmentInfo = "";
167
+ const imageUrls = [];
168
+ // 存到 clawdbot 工作目录下的 downloads 文件夹
169
+ const downloadDir = `${process.env.HOME || "/home/ubuntu"}/clawd/downloads`;
170
+ if (event.attachments?.length) {
171
+ for (const att of event.attachments) {
172
+ // 下载附件到本地,使用原始文件名
173
+ const localPath = await downloadFile(att.url, downloadDir, att.filename);
174
+ if (localPath) {
175
+ if (att.content_type?.startsWith("image/")) {
176
+ imageUrls.push(localPath);
177
+ attachmentInfo += `\n[图片: ${localPath}]`;
178
+ }
179
+ else {
180
+ attachmentInfo += `\n[附件: ${localPath}]`;
181
+ }
182
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
183
+ }
184
+ else {
185
+ // 下载失败,提供原始 URL 作为后备
186
+ log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`);
187
+ if (att.content_type?.startsWith("image/")) {
188
+ imageUrls.push(att.url);
189
+ attachmentInfo += `\n[图片: ${att.url}] (下载失败,可能无法访问)`;
190
+ }
191
+ else {
192
+ attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}] (下载失败)`;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ const userContent = event.content + attachmentInfo;
198
+ const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
199
+ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
200
+ channel: "QQBot",
201
+ from: event.senderName ?? event.senderId,
202
+ timestamp: new Date(event.timestamp).getTime(),
203
+ body: messageBody,
204
+ chatType: isGroup ? "group" : "direct",
205
+ sender: {
206
+ id: event.senderId,
207
+ name: event.senderName,
208
+ },
209
+ envelope: envelopeOptions,
210
+ // 传递图片 URL 列表
211
+ ...(imageUrls.length > 0 ? { imageUrls } : {}),
212
+ });
213
+ const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
214
+ : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
215
+ : `qqbot:c2c:${event.senderId}`;
216
+ const toAddress = fromAddress;
217
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
218
+ Body: body,
219
+ RawBody: event.content,
220
+ CommandBody: event.content,
221
+ From: fromAddress,
222
+ To: toAddress,
223
+ SessionKey: route.sessionKey,
224
+ AccountId: route.accountId,
225
+ ChatType: isGroup ? "group" : "direct",
226
+ SenderId: event.senderId,
227
+ SenderName: event.senderName,
228
+ Provider: "qqbot",
229
+ Surface: "qqbot",
230
+ MessageSid: event.messageId,
231
+ Timestamp: new Date(event.timestamp).getTime(),
232
+ OriginatingChannel: "qqbot",
233
+ OriginatingTo: toAddress,
234
+ QQChannelId: event.channelId,
235
+ QQGuildId: event.guildId,
236
+ QQGroupOpenid: event.groupOpenid,
237
+ });
238
+ // 打印 ctxPayload 详细信息(便于调试)
239
+ log?.info(`[qqbot:${account.accountId}] ctxPayload: From=${fromAddress}, To=${toAddress}, SessionKey=${route.sessionKey}, AccountId=${route.accountId}, ChatType=${isGroup ? "group" : "direct"}, SenderId=${event.senderId}, MessageSid=${event.messageId}, BodyLen=${body?.length ?? 0}`);
240
+ // 发送消息的辅助函数,带 token 过期重试
241
+ const sendWithTokenRetry = async (sendFn) => {
242
+ try {
243
+ const token = await getAccessToken(account.appId, account.clientSecret);
244
+ await sendFn(token);
245
+ }
246
+ catch (err) {
247
+ const errMsg = String(err);
248
+ // 如果是 token 相关错误,清除缓存重试一次
249
+ if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
250
+ log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`);
251
+ clearTokenCache();
252
+ const newToken = await getAccessToken(account.appId, account.clientSecret);
253
+ await sendFn(newToken);
254
+ }
255
+ else {
256
+ throw err;
257
+ }
258
+ }
259
+ };
260
+ // 发送错误提示的辅助函数
261
+ const sendErrorMessage = async (errorText) => {
262
+ try {
263
+ await sendWithTokenRetry(async (token) => {
264
+ if (event.type === "c2c") {
265
+ await sendC2CMessage(token, event.senderId, errorText, event.messageId);
266
+ }
267
+ else if (event.type === "group" && event.groupOpenid) {
268
+ await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId);
269
+ }
270
+ else if (event.channelId) {
271
+ await sendChannelMessage(token, event.channelId, errorText, event.messageId);
272
+ }
273
+ });
274
+ }
275
+ catch (sendErr) {
276
+ log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`);
277
+ }
278
+ };
279
+ try {
280
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
281
+ // 追踪是否有响应
282
+ let hasResponse = false;
283
+ const responseTimeout = 90000; // 90s 超时
284
+ let timeoutId = null;
285
+ const timeoutPromise = new Promise((_, reject) => {
286
+ timeoutId = setTimeout(() => {
287
+ if (!hasResponse) {
288
+ reject(new Error("Response timeout"));
289
+ }
290
+ }, responseTimeout);
291
+ });
292
+ // 调用 dispatchReply
293
+ log?.info(`[qqbot:${account.accountId}] dispatchReply: agentId=${route.agentId}, prefix=${messagesConfig.responsePrefix ?? "(none)"}`);
294
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
295
+ ctx: ctxPayload,
296
+ cfg,
297
+ dispatcherOptions: {
298
+ responsePrefix: messagesConfig.responsePrefix,
299
+ deliver: async (payload, info) => {
300
+ hasResponse = true;
301
+ if (timeoutId) {
302
+ clearTimeout(timeoutId);
303
+ timeoutId = null;
304
+ }
305
+ log?.info(`[qqbot:${account.accountId}] deliver(${info.kind}): textLen=${payload.text?.length ?? 0}, mediaUrls=${payload.mediaUrls?.length ?? 0}, mediaUrl=${payload.mediaUrl ? "yes" : "no"}`);
306
+ if (payload.text) {
307
+ log?.info(`[qqbot:${account.accountId}] text preview: ${payload.text.slice(0, 150).replace(/\n/g, "\\n")}...`);
308
+ }
309
+ let replyText = payload.text ?? "";
310
+ // 收集所有图片来源(本地路径或 base64 数据)
311
+ const imageSources = [];
312
+ // 处理 mediaUrls 和 mediaUrl 字段(本地文件路径)
313
+ const mediaPaths = [];
314
+ if (payload.mediaUrls?.length) {
315
+ mediaPaths.push(...payload.mediaUrls);
316
+ }
317
+ if (payload.mediaUrl && !mediaPaths.includes(payload.mediaUrl)) {
318
+ mediaPaths.push(payload.mediaUrl);
319
+ }
320
+ for (const localPath of mediaPaths) {
321
+ if (localPath) {
322
+ // 直接添加本地路径,API 会读取文件并用 file_data 上传
323
+ imageSources.push(localPath);
324
+ log?.info(`[qqbot:${account.accountId}] Added media path: ${localPath}`);
325
+ }
326
+ }
327
+ // 如果没有文本也没有图片,跳过
328
+ if (!replyText.trim() && imageSources.length === 0) {
329
+ log?.info(`[qqbot:${account.accountId}] Empty reply, skipping`);
330
+ return;
331
+ }
332
+ // 0. 提取 <qqimg>路径</qqimg> 格式的图片(支持 </qqimg> 和 </img> 两种闭合方式)
333
+ const qqimgRegex = /<qqimg>([^<>]+)<\/(?:qqimg|img)>/gi;
334
+ const qqimgMatches = [...replyText.matchAll(qqimgRegex)];
335
+ for (const match of qqimgMatches) {
336
+ const imagePath = match[1]?.trim();
337
+ if (imagePath) {
338
+ imageSources.push(imagePath);
339
+ log?.info(`[qqbot:${account.accountId}] Added <qqimg> path: ${imagePath}`);
340
+ }
341
+ // 从文本中移除 <qqimg> 标签
342
+ replyText = replyText.replace(match[0], "").trim();
343
+ }
344
+ // 0.5. 提取 MEDIA: 前缀的本地文件路径(从文本中)
345
+ const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
346
+ const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
347
+ for (const match of mediaMatches) {
348
+ const localPath = match[1];
349
+ if (localPath) {
350
+ imageSources.push(localPath);
351
+ log?.info(`[qqbot:${account.accountId}] Added MEDIA path: ${localPath}`);
352
+ }
353
+ // 从文本中移除 MEDIA: 行
354
+ replyText = replyText.replace(match[0], "").trim();
355
+ }
356
+ // 1. 提取本地绝对文件路径(/path/to/image.png 或 /path/to/image_123_png 格式)
357
+ // 支持标准扩展名和下划线替换后的扩展名
358
+ const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi;
359
+ const localPathMatches = [...replyText.matchAll(localPathRegex)];
360
+ for (const match of localPathMatches) {
361
+ let localPath = match[1].trim();
362
+ if (localPath) {
363
+ // 如果是下划线格式的扩展名,转换回点格式
364
+ localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, ".");
365
+ imageSources.push(localPath);
366
+ log?.info(`[qqbot:${account.accountId}] Added local path: ${localPath}`);
367
+ }
368
+ // 从文本中移除本地路径
369
+ replyText = replyText.replace(match[0], "").trim();
370
+ }
371
+ // 1. 提取 base64 图片(data:image/xxx;base64,...)
372
+ const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
373
+ const base64Matches = [...replyText.matchAll(base64ImageRegex)];
374
+ for (const match of base64Matches) {
375
+ const dataUrl = match[2] || match[3];
376
+ if (dataUrl) {
377
+ // 直接添加 data URL,API 会提取 base64 并用 file_data 上传
378
+ imageSources.push(dataUrl);
379
+ log?.info(`[qqbot:${account.accountId}] Added base64 image`);
380
+ }
381
+ // 从文本中移除 base64
382
+ replyText = replyText.replace(match[0], "").trim();
383
+ }
384
+ // 2. 提取 URL 图片(Markdown 格式或纯 URL)- 这种情况仍用 url 方式上传
385
+ // 注意:网络 URL 需要 QQ 服务器能访问,可能会失败
386
+ const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
387
+ const urlMatches = [...replyText.matchAll(imageUrlRegex)];
388
+ for (const match of urlMatches) {
389
+ // match[2] 是 Markdown 格式的 URL,match[3] 是纯 URL
390
+ const url = match[2] || match[3];
391
+ if (url) {
392
+ // 网络 URL 暂时跳过,因为 QQ 服务器可能无法访问
393
+ // TODO: 可以下载到本地再用 file_data 上传
394
+ log?.info(`[qqbot:${account.accountId}] Skipping network URL (not supported yet): ${url}`);
395
+ }
396
+ }
397
+ try {
398
+ // 先发送图片(如果有)
399
+ log?.info(`[qqbot:${account.accountId}] imageSources to send: ${imageSources.length} items`);
400
+ for (const imageSource of imageSources) {
401
+ try {
402
+ await sendWithTokenRetry(async (token) => {
403
+ if (event.type === "c2c") {
404
+ log?.info(`[qqbot:${account.accountId}] sendC2CImage -> ${event.senderId}, source: ${imageSource.slice(0, 50)}...`);
405
+ await sendC2CImageMessage(token, event.senderId, imageSource, event.messageId);
406
+ }
407
+ else if (event.type === "group" && event.groupOpenid) {
408
+ log?.info(`[qqbot:${account.accountId}] sendGroupImage -> ${event.groupOpenid}, source: ${imageSource.slice(0, 50)}...`);
409
+ await sendGroupImageMessage(token, event.groupOpenid, imageSource, event.messageId);
410
+ }
411
+ // 频道消息暂不支持富媒体,跳过图片
412
+ });
413
+ }
414
+ catch (imgErr) {
415
+ log?.error(`[qqbot:${account.accountId}] Image send failed: ${imgErr}`);
416
+ // 图片发送失败时,显示错误信息而不是 URL
417
+ const errMsg = String(imgErr).slice(0, 200);
418
+ replyText = `[图片发送失败: ${errMsg}]\n${replyText}`;
419
+ }
420
+ }
421
+ // 再发送文本(如果有)
422
+ if (replyText.trim()) {
423
+ await sendWithTokenRetry(async (token) => {
424
+ if (event.type === "c2c") {
425
+ log?.info(`[qqbot:${account.accountId}] sendC2CText -> ${event.senderId}, len=${replyText.length}`);
426
+ await sendC2CMessage(token, event.senderId, replyText, event.messageId);
427
+ }
428
+ else if (event.type === "group" && event.groupOpenid) {
429
+ log?.info(`[qqbot:${account.accountId}] sendGroupText -> ${event.groupOpenid}, len=${replyText.length}`);
430
+ await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId);
431
+ }
432
+ else if (event.channelId) {
433
+ log?.info(`[qqbot:${account.accountId}] sendChannelText -> ${event.channelId}, len=${replyText.length}`);
434
+ await sendChannelMessage(token, event.channelId, replyText, event.messageId);
435
+ }
436
+ });
437
+ }
438
+ pluginRuntime.channel.activity.record({
439
+ channel: "qqbot",
440
+ accountId: account.accountId,
441
+ direction: "outbound",
442
+ });
443
+ }
444
+ catch (err) {
445
+ log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
446
+ }
447
+ },
448
+ onError: async (err) => {
449
+ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
450
+ hasResponse = true;
451
+ if (timeoutId) {
452
+ clearTimeout(timeoutId);
453
+ timeoutId = null;
454
+ }
455
+ // 发送错误提示给用户,显示完整错误信息
456
+ const errMsg = String(err);
457
+ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
458
+ await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置");
459
+ }
460
+ else {
461
+ // 显示完整错误信息,截取前 500 字符
462
+ await sendErrorMessage(`[ClawdBot] 出错: ${errMsg.slice(0, 500)}`);
463
+ }
464
+ },
465
+ },
466
+ replyOptions: {},
467
+ });
468
+ // 等待分发完成或超时
469
+ try {
470
+ await Promise.race([dispatchPromise, timeoutPromise]);
471
+ }
472
+ catch (err) {
473
+ if (timeoutId) {
474
+ clearTimeout(timeoutId);
475
+ }
476
+ if (!hasResponse) {
477
+ log?.error(`[qqbot:${account.accountId}] No response within timeout`);
478
+ await sendErrorMessage("QQ已经收到了你的请求并转交给了Openclaw,任务可能比较复杂,正在处理中...");
479
+ }
480
+ }
481
+ }
482
+ catch (err) {
483
+ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
484
+ await sendErrorMessage(`[ClawdBot] 处理失败: ${String(err).slice(0, 500)}`);
485
+ }
486
+ };
487
+ ws.on("open", () => {
488
+ log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
489
+ isConnecting = false; // 连接完成,释放锁
490
+ reconnectAttempts = 0; // 连接成功,重置重试计数
491
+ lastConnectTime = Date.now(); // 记录连接时间
492
+ });
493
+ ws.on("message", async (data) => {
494
+ try {
495
+ const rawData = data.toString();
496
+ const payload = JSON.parse(rawData);
497
+ const { op, d, s, t } = payload;
498
+ if (s)
499
+ lastSeq = s;
500
+ log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
501
+ switch (op) {
502
+ case 10: // Hello
503
+ log?.info(`[qqbot:${account.accountId}] Hello received`);
504
+ // 如果有 session_id,尝试 Resume
505
+ if (sessionId && lastSeq !== null) {
506
+ log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`);
507
+ ws.send(JSON.stringify({
508
+ op: 6, // Resume
509
+ d: {
510
+ token: `QQBot ${accessToken}`,
511
+ session_id: sessionId,
512
+ seq: lastSeq,
513
+ },
514
+ }));
515
+ }
516
+ else {
517
+ // 新连接,发送 Identify
518
+ // 如果有上次成功的级别,直接使用;否则从当前级别开始尝试
519
+ const levelToUse = lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex;
520
+ const intentLevel = INTENT_LEVELS[Math.min(levelToUse, INTENT_LEVELS.length - 1)];
521
+ log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intentLevel.intents} (${intentLevel.description})`);
522
+ ws.send(JSON.stringify({
523
+ op: 2,
524
+ d: {
525
+ token: `QQBot ${accessToken}`,
526
+ intents: intentLevel.intents,
527
+ shard: [0, 1],
528
+ },
529
+ }));
530
+ }
531
+ // 启动心跳
532
+ const interval = d.heartbeat_interval;
533
+ if (heartbeatInterval)
534
+ clearInterval(heartbeatInterval);
535
+ heartbeatInterval = setInterval(() => {
536
+ if (ws.readyState === WebSocket.OPEN) {
537
+ ws.send(JSON.stringify({ op: 1, d: lastSeq }));
538
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`);
539
+ }
540
+ }, interval);
541
+ break;
542
+ case 0: // Dispatch
543
+ if (t === "READY") {
544
+ const readyData = d;
545
+ sessionId = readyData.session_id;
546
+ // 记录成功的权限级别
547
+ lastSuccessfulIntentLevel = intentLevelIndex;
548
+ const successLevel = INTENT_LEVELS[intentLevelIndex];
549
+ log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
550
+ onReady?.(d);
551
+ }
552
+ else if (t === "RESUMED") {
553
+ log?.info(`[qqbot:${account.accountId}] Session resumed`);
554
+ }
555
+ else if (t === "C2C_MESSAGE_CREATE") {
556
+ const event = d;
557
+ await handleMessage({
558
+ type: "c2c",
559
+ senderId: event.author.user_openid,
560
+ content: event.content,
561
+ messageId: event.id,
562
+ timestamp: event.timestamp,
563
+ attachments: event.attachments,
564
+ });
565
+ }
566
+ else if (t === "AT_MESSAGE_CREATE") {
567
+ const event = d;
568
+ await handleMessage({
569
+ type: "guild",
570
+ senderId: event.author.id,
571
+ senderName: event.author.username,
572
+ content: event.content,
573
+ messageId: event.id,
574
+ timestamp: event.timestamp,
575
+ channelId: event.channel_id,
576
+ guildId: event.guild_id,
577
+ attachments: event.attachments,
578
+ });
579
+ }
580
+ else if (t === "DIRECT_MESSAGE_CREATE") {
581
+ const event = d;
582
+ await handleMessage({
583
+ type: "dm",
584
+ senderId: event.author.id,
585
+ senderName: event.author.username,
586
+ content: event.content,
587
+ messageId: event.id,
588
+ timestamp: event.timestamp,
589
+ guildId: event.guild_id,
590
+ attachments: event.attachments,
591
+ });
592
+ }
593
+ else if (t === "GROUP_AT_MESSAGE_CREATE") {
594
+ const event = d;
595
+ await handleMessage({
596
+ type: "group",
597
+ senderId: event.author.member_openid,
598
+ content: event.content,
599
+ messageId: event.id,
600
+ timestamp: event.timestamp,
601
+ groupOpenid: event.group_openid,
602
+ attachments: event.attachments,
603
+ });
604
+ }
605
+ break;
606
+ case 11: // Heartbeat ACK
607
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
608
+ break;
609
+ case 7: // Reconnect
610
+ log?.info(`[qqbot:${account.accountId}] Server requested reconnect`);
611
+ cleanup();
612
+ scheduleReconnect();
613
+ break;
614
+ case 9: // Invalid Session
615
+ const canResume = d;
616
+ const currentLevel = INTENT_LEVELS[intentLevelIndex];
617
+ log?.error(`[qqbot:${account.accountId}] Invalid session (${currentLevel.description}), can resume: ${canResume}, raw: ${rawData}`);
618
+ if (!canResume) {
619
+ sessionId = null;
620
+ lastSeq = null;
621
+ // 尝试降级到下一个权限级别
622
+ if (intentLevelIndex < INTENT_LEVELS.length - 1) {
623
+ intentLevelIndex++;
624
+ const nextLevel = INTENT_LEVELS[intentLevelIndex];
625
+ log?.info(`[qqbot:${account.accountId}] Downgrading intents to: ${nextLevel.description}`);
626
+ }
627
+ else {
628
+ // 已经是最低权限级别了
629
+ log?.error(`[qqbot:${account.accountId}] All intent levels failed. Please check AppID/Secret.`);
630
+ shouldRefreshToken = true;
631
+ }
632
+ }
633
+ cleanup();
634
+ // Invalid Session 后等待一段时间再重连
635
+ scheduleReconnect(3000);
636
+ break;
637
+ }
638
+ }
639
+ catch (err) {
640
+ log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
641
+ }
642
+ });
643
+ ws.on("close", (code, reason) => {
644
+ log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
645
+ isConnecting = false; // 释放锁
646
+ // 根据错误码处理
647
+ // 4009: 可以重新发起 resume
648
+ // 4900-4913: 内部错误,需要重新 identify
649
+ // 4914: 机器人已下架
650
+ // 4915: 机器人已封禁
651
+ if (code === 4914 || code === 4915) {
652
+ log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`);
653
+ cleanup();
654
+ // 不重连,直接退出
655
+ return;
656
+ }
657
+ if (code === 4009) {
658
+ // 4009 可以尝试 resume,保留 session
659
+ log?.info(`[qqbot:${account.accountId}] Error 4009, will try resume`);
660
+ shouldRefreshToken = true;
661
+ }
662
+ else if (code >= 4900 && code <= 4913) {
663
+ // 4900-4913 内部错误,清除 session 重新 identify
664
+ log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`);
665
+ sessionId = null;
666
+ lastSeq = null;
667
+ shouldRefreshToken = true;
668
+ }
669
+ // 检测是否是快速断开(连接后很快就断了)
670
+ const connectionDuration = Date.now() - lastConnectTime;
671
+ if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) {
672
+ quickDisconnectCount++;
673
+ log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
674
+ // 如果连续快速断开超过阈值,等待更长时间
675
+ if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
676
+ log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`);
677
+ log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`);
678
+ quickDisconnectCount = 0;
679
+ cleanup();
680
+ // 快速断开太多次,等待更长时间再重连
681
+ if (!isAborted && code !== 1000) {
682
+ scheduleReconnect(RATE_LIMIT_DELAY);
683
+ }
684
+ return;
685
+ }
686
+ }
687
+ else {
688
+ // 连接持续时间够长,重置计数
689
+ quickDisconnectCount = 0;
690
+ }
691
+ cleanup();
692
+ // 非正常关闭则重连
693
+ if (!isAborted && code !== 1000) {
694
+ scheduleReconnect();
695
+ }
696
+ });
697
+ ws.on("error", (err) => {
698
+ log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
699
+ onError?.(err);
700
+ });
701
+ }
702
+ catch (err) {
703
+ isConnecting = false; // 释放锁
704
+ const errMsg = String(err);
705
+ log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`);
706
+ // 如果是频率限制错误,等待更长时间
707
+ if (errMsg.includes("Too many requests") || errMsg.includes("100001")) {
708
+ log?.info(`[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`);
709
+ scheduleReconnect(RATE_LIMIT_DELAY);
710
+ }
711
+ else {
712
+ scheduleReconnect();
713
+ }
714
+ }
715
+ };
716
+ // 开始连接
717
+ await connect();
718
+ // 等待 abort 信号
719
+ return new Promise((resolve) => {
720
+ abortSignal.addEventListener("abort", () => resolve());
721
+ });
722
+ }