@ryantest/openclaw-qqbot 1.6.6-alpha.4 → 1.6.7-beta.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.
Files changed (53) hide show
  1. package/README.md +24 -15
  2. package/README.zh.md +24 -15
  3. package/dist/src/api.d.ts +32 -5
  4. package/dist/src/api.js +111 -12
  5. package/dist/src/channel.d.ts +18 -0
  6. package/dist/src/channel.js +85 -2
  7. package/dist/src/config.d.ts +33 -2
  8. package/dist/src/config.js +125 -1
  9. package/dist/src/gateway.js +566 -24
  10. package/dist/src/group-history.d.ts +136 -0
  11. package/dist/src/group-history.js +226 -0
  12. package/dist/src/message-gating.d.ts +53 -0
  13. package/dist/src/message-gating.js +107 -0
  14. package/dist/src/message-queue.d.ts +36 -0
  15. package/dist/src/message-queue.js +164 -22
  16. package/dist/src/outbound.d.ts +4 -4
  17. package/dist/src/outbound.js +18 -6
  18. package/dist/src/ref-index-store.js +5 -28
  19. package/dist/src/request-context.d.ts +7 -0
  20. package/dist/src/request-context.js +7 -0
  21. package/dist/src/slash-commands.d.ts +6 -0
  22. package/dist/src/slash-commands.js +3 -3
  23. package/dist/src/tools/remind.js +17 -9
  24. package/dist/src/types.d.ts +90 -2
  25. package/dist/src/utils/audio-convert.d.ts +1 -1
  26. package/dist/src/utils/audio-convert.js +1 -1
  27. package/dist/src/utils/chunked-upload.d.ts +11 -2
  28. package/dist/src/utils/chunked-upload.js +63 -11
  29. package/dist/src/utils/media-send.js +1 -1
  30. package/dist/src/utils/text-parsing.js +7 -18
  31. package/package.json +1 -1
  32. package/scripts/postinstall-link-sdk.js +22 -9
  33. package/scripts/upgrade-via-npm.sh +11 -3
  34. package/scripts/upgrade-via-source.sh +63 -15
  35. package/skills/qqbot-remind/SKILL.md +21 -11
  36. package/src/api.ts +135 -7
  37. package/src/channel.ts +85 -2
  38. package/src/config.ts +170 -3
  39. package/src/gateway.ts +662 -29
  40. package/src/group-history.ts +328 -0
  41. package/src/message-gating.ts +190 -0
  42. package/src/message-queue.ts +201 -21
  43. package/src/openclaw-plugin-sdk.d.ts +65 -0
  44. package/src/outbound.ts +18 -6
  45. package/src/ref-index-store.ts +5 -27
  46. package/src/request-context.ts +10 -0
  47. package/src/slash-commands.ts +3 -3
  48. package/src/tools/remind.ts +17 -9
  49. package/src/types.ts +94 -2
  50. package/src/utils/audio-convert.ts +1 -1
  51. package/src/utils/chunked-upload.ts +76 -12
  52. package/src/utils/media-send.ts +1 -2
  53. package/src/utils/text-parsing.ts +7 -14
@@ -1,15 +1,97 @@
1
- // 消息队列配置
2
- const MESSAGE_QUEUE_SIZE = 1000;
3
- const PER_USER_QUEUE_SIZE = 20;
4
- const MAX_CONCURRENT_USERS = 10;
1
+ // ── 消息队列默认配置 ──
2
+ const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
3
+ const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
4
+ const DEFAULT_GROUP_QUEUE_SIZE = 50;
5
+ const DEFAULT_MAX_CONCURRENT_USERS = 10;
6
+ // ── 群消息合并工具函数 ──
7
+ /** 判断 peerId 是否属于群聊 */
8
+ const isGroupPeer = (peerId) => peerId.startsWith("group:") || peerId.startsWith("guild:");
9
+ /**
10
+ * 将多条群消息合并为一条,用于群聊场景下排队消息的批量处理。
11
+ * - content 拼接为多行,每行带发送者前缀
12
+ * - 附件合并
13
+ * - messageId / msgIdx / timestamp 取最后一条(用于回复引用)
14
+ * - mentions 合并去重
15
+ * - 如果有任意一条 @了你(is_you),合并结果也标记 @你
16
+ * - senderIsBot 只要有一条不是 bot 就算非 bot
17
+ */
18
+ function mergeGroupMessages(batch) {
19
+ if (batch.length === 1)
20
+ return batch[0];
21
+ const last = batch[batch.length - 1];
22
+ const first = batch[0];
23
+ // 拼接内容:每条消息带发送者前缀
24
+ const mergedContent = batch
25
+ .map((m) => {
26
+ const name = m.senderName ?? m.senderId;
27
+ return `[${name}]: ${m.content}`;
28
+ })
29
+ .join("\n");
30
+ // 合并附件
31
+ const mergedAttachments = [];
32
+ for (const m of batch) {
33
+ if (m.attachments?.length) {
34
+ mergedAttachments.push(...m.attachments);
35
+ }
36
+ }
37
+ // 合并 mentions(去重 by member_openid/id)
38
+ const seenMentionIds = new Set();
39
+ const mergedMentions = [];
40
+ let hasAtYouEvent = false;
41
+ for (const m of batch) {
42
+ if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
43
+ hasAtYouEvent = true;
44
+ }
45
+ if (m.mentions) {
46
+ for (const mt of m.mentions) {
47
+ const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
48
+ if (key && seenMentionIds.has(key))
49
+ continue;
50
+ if (key)
51
+ seenMentionIds.add(key);
52
+ mergedMentions.push(mt);
53
+ }
54
+ }
55
+ }
56
+ // senderIsBot: 只要有一条来自非 bot 用户,就算非 bot
57
+ const allFromBot = batch.every((m) => m.senderIsBot);
58
+ return {
59
+ type: last.type,
60
+ senderId: last.senderId,
61
+ senderName: last.senderName,
62
+ senderIsBot: allFromBot,
63
+ content: mergedContent,
64
+ messageId: last.messageId,
65
+ timestamp: last.timestamp,
66
+ channelId: last.channelId,
67
+ guildId: last.guildId,
68
+ groupOpenid: last.groupOpenid,
69
+ attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
70
+ refMsgIdx: first.refMsgIdx,
71
+ msgIdx: last.msgIdx,
72
+ eventType: hasAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
73
+ mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
74
+ messageScene: last.messageScene,
75
+ _mergedCount: batch.length,
76
+ _mergedMessages: batch.length > 1 ? batch : undefined,
77
+ };
78
+ }
5
79
  /**
6
80
  * 创建按用户并发的消息队列(同用户串行,跨用户并行)
81
+ *
82
+ * 内置群消息增强:
83
+ * - 群聊 / 私聊使用不同队列上限
84
+ * - 群聊溢出时优先丢弃 bot 消息
85
+ * - drain 时自动合并群聊排队消息(斜杠命令单独处理)
7
86
  */
8
87
  export function createMessageQueue(ctx) {
9
88
  const { accountId, log } = ctx;
89
+ const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
90
+ const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
91
+ const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
92
+ const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
10
93
  const userQueues = new Map();
11
94
  const activeUsers = new Set();
12
- let messagesProcessed = 0;
13
95
  let handleMessageFnRef = null;
14
96
  let totalEnqueued = 0;
15
97
  const getMessagePeerId = (msg) => {
@@ -19,11 +101,55 @@ export function createMessageQueue(ctx) {
19
101
  return `group:${msg.groupOpenid ?? "unknown"}`;
20
102
  return `dm:${msg.senderId}`;
21
103
  };
104
+ /** 从满队列中淘汰一条消息(群聊优先丢弃 bot 消息,否则丢弃最旧) */
105
+ const evictOne = (queue, isGroup) => {
106
+ if (isGroup) {
107
+ const botIdx = queue.findIndex(m => m.senderIsBot);
108
+ if (botIdx >= 0)
109
+ return queue.splice(botIdx, 1)[0];
110
+ }
111
+ return queue.shift();
112
+ };
113
+ /** 判断消息是否为斜杠指令 */
114
+ const isSlashCommand = (msg) => (msg.content ?? "").trim().startsWith("/");
115
+ /** 处理单条消息,捕获异常并记录日志 */
116
+ const processOne = async (msg, peerId, label) => {
117
+ try {
118
+ await handleMessageFnRef(msg);
119
+ return true;
120
+ }
121
+ catch (err) {
122
+ log?.error(`[qqbot:${accountId}] ${label} error for ${peerId}: ${err}`);
123
+ return false;
124
+ }
125
+ };
126
+ /** 批量处理群聊排队消息:斜杠指令逐条处理,普通消息合并后处理 */
127
+ const drainGroupBatch = async (all, peerId) => {
128
+ const commands = [];
129
+ const normal = [];
130
+ for (const m of all) {
131
+ (isSlashCommand(m) ? commands : normal).push(m);
132
+ }
133
+ // 指令消息逐条处理
134
+ for (const cmd of commands) {
135
+ log?.info(`[qqbot:${accountId}] Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
136
+ await processOne(cmd, peerId, "Command processor");
137
+ }
138
+ // 普通消息合并后处理
139
+ if (normal.length > 0) {
140
+ const merged = mergeGroupMessages(normal);
141
+ if (normal.length > 1) {
142
+ log?.info(`[qqbot:${accountId}] Merged ${normal.length} queued group messages for ${peerId} into one`);
143
+ }
144
+ await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
145
+ }
146
+ };
147
+ /** 处理指定 peer 队列中的消息(串行) */
22
148
  const drainUserQueue = async (peerId) => {
23
149
  if (activeUsers.has(peerId))
24
150
  return;
25
- if (activeUsers.size >= MAX_CONCURRENT_USERS) {
26
- log?.info(`[qqbot:${accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`);
151
+ if (activeUsers.size >= maxConcurrentUsers) {
152
+ log?.info(`[qqbot:${accountId}] Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
27
153
  return;
28
154
  }
29
155
  const queue = userQueues.get(peerId);
@@ -32,26 +158,30 @@ export function createMessageQueue(ctx) {
32
158
  return;
33
159
  }
34
160
  activeUsers.add(peerId);
161
+ const isGroup = isGroupPeer(peerId);
35
162
  try {
36
163
  while (queue.length > 0 && !ctx.isAborted()) {
164
+ // 群聊排队 > 1 条:批量处理
165
+ if (isGroup && queue.length > 1 && handleMessageFnRef) {
166
+ const all = queue.splice(0, queue.length);
167
+ totalEnqueued = Math.max(0, totalEnqueued - all.length);
168
+ await drainGroupBatch(all, peerId);
169
+ continue;
170
+ }
171
+ // 非群聊 或 队列只剩 1 条:逐条处理
37
172
  const msg = queue.shift();
38
173
  totalEnqueued = Math.max(0, totalEnqueued - 1);
39
- try {
40
- if (handleMessageFnRef) {
41
- await handleMessageFnRef(msg);
42
- messagesProcessed++;
43
- }
44
- }
45
- catch (err) {
46
- log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${err}`);
174
+ if (handleMessageFnRef) {
175
+ await processOne(msg, peerId, "Message processor");
47
176
  }
48
177
  }
49
178
  }
50
179
  finally {
51
180
  activeUsers.delete(peerId);
52
181
  userQueues.delete(peerId);
182
+ // 尽量填满并发槽位
53
183
  for (const [waitingPeerId, waitingQueue] of userQueues) {
54
- if (activeUsers.size >= MAX_CONCURRENT_USERS)
184
+ if (activeUsers.size >= maxConcurrentUsers)
55
185
  break;
56
186
  if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
57
187
  drainUserQueue(waitingPeerId);
@@ -61,26 +191,38 @@ export function createMessageQueue(ctx) {
61
191
  };
62
192
  const enqueue = (msg) => {
63
193
  const peerId = getMessagePeerId(msg);
194
+ const isGroup = isGroupPeer(peerId);
64
195
  let queue = userQueues.get(peerId);
65
196
  if (!queue) {
66
197
  queue = [];
67
198
  userQueues.set(peerId, queue);
68
199
  }
69
- if (queue.length >= PER_USER_QUEUE_SIZE) {
70
- const dropped = queue.shift();
71
- log?.error(`[qqbot:${accountId}] Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
200
+ // 群聊和非群聊使用不同的队列上限
201
+ const maxSize = isGroup ? groupQueueSize : peerQueueSize;
202
+ // 队列溢出:淘汰一条旧消息
203
+ if (queue.length >= maxSize) {
204
+ const dropped = evictOne(queue, isGroup);
205
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
206
+ if (isGroup && dropped?.senderIsBot) {
207
+ log?.info(`[qqbot:${accountId}] Queue full for ${peerId}, dropping bot message ${dropped.messageId}`);
208
+ }
209
+ else {
210
+ log?.error(`[qqbot:${accountId}] Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
211
+ }
72
212
  }
213
+ // 全局总量保护
73
214
  totalEnqueued++;
74
- if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
215
+ if (totalEnqueued > globalQueueSize) {
75
216
  log?.error(`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
76
217
  }
77
218
  queue.push(msg);
78
219
  log?.debug?.(`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
220
+ // 如果该用户没有正在处理的消息,立即启动处理
79
221
  drainUserQueue(peerId);
80
222
  };
81
223
  const startProcessor = (handleMessageFn) => {
82
224
  handleMessageFnRef = handleMessageFn;
83
- log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`);
225
+ log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`);
84
226
  };
85
227
  const getSnapshot = (senderPeerId) => {
86
228
  let totalPending = 0;
@@ -91,7 +233,7 @@ export function createMessageQueue(ctx) {
91
233
  return {
92
234
  totalPending,
93
235
  activeUsers: activeUsers.size,
94
- maxConcurrentUsers: MAX_CONCURRENT_USERS,
236
+ maxConcurrentUsers,
95
237
  senderPending: senderQueue ? senderQueue.length : 0,
96
238
  };
97
239
  };
@@ -75,7 +75,7 @@ export interface MediaTargetContext {
75
75
  logPrefix?: string;
76
76
  }
77
77
  /**
78
- * sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
78
+ * sendPhoto — 发送图片消息
79
79
  *
80
80
  * 支持三种来源:
81
81
  * - 本地文件路径 → 分片上传
@@ -86,7 +86,7 @@ export declare function sendPhoto(ctx: MediaTargetContext, imagePath: string,
86
86
  /** 原始来源 URL(仅 fallback 路径使用,记录到引用索引) */
87
87
  sourceUrl?: string): Promise<OutboundResult>;
88
88
  /**
89
- * sendVoice — 发送语音消息(对齐 Telegram sendVoice)
89
+ * sendVoice — 发送语音消息
90
90
  *
91
91
  * 支持本地音频文件和公网 URL:
92
92
  * - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
@@ -101,13 +101,13 @@ directUploadFormats?: string[],
101
101
  /** 是否启用转码(默认 true),false 时非原生格式直接返回错误 */
102
102
  transcodeEnabled?: boolean): Promise<OutboundResult>;
103
103
  /**
104
- * sendVideoMsg — 发送视频消息(对齐 Telegram sendVideo)
104
+ * sendVideoMsg — 发送视频消息
105
105
  *
106
106
  * 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
107
107
  */
108
108
  export declare function sendVideoMsg(ctx: MediaTargetContext, videoPath: string): Promise<OutboundResult>;
109
109
  /**
110
- * sendDocument — 发送文件消息(对齐 Telegram sendDocument)
110
+ * sendDocument — 发送文件消息
111
111
  *
112
112
  * 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
113
113
  */
@@ -8,7 +8,7 @@ import { decodeCronPayload } from "./utils/payload.js";
8
8
  import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, } from "./api.js";
9
9
  import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
10
10
  import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
11
- import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
11
+ import { chunkedUploadC2C, chunkedUploadGroup, UploadDailyLimitExceededError } from "./utils/chunked-upload.js";
12
12
  import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
13
13
  import { downloadFile } from "./image-server.js";
14
14
  import { parseMediaTagsToSendQueue, executeSendQueue } from "./utils/media-send.js";
@@ -181,7 +181,7 @@ async function getToken(account) {
181
181
  return getAccessToken(account.appId, account.clientSecret);
182
182
  }
183
183
  /**
184
- * sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
184
+ * sendPhoto — 发送图片消息
185
185
  *
186
186
  * 支持三种来源:
187
187
  * - 本地文件路径 → 分片上传
@@ -262,7 +262,7 @@ sourceUrl) {
262
262
  return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
263
263
  }
264
264
  /**
265
- * sendVoice — 发送语音消息(对齐 Telegram sendVoice)
265
+ * sendVoice — 发送语音消息
266
266
  *
267
267
  * 支持本地音频文件和公网 URL:
268
268
  * - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
@@ -328,7 +328,7 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
328
328
  }
329
329
  }
330
330
  /**
331
- * sendVideoMsg — 发送视频消息(对齐 Telegram sendVideo)
331
+ * sendVideoMsg — 发送视频消息
332
332
  *
333
333
  * 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
334
334
  */
@@ -393,6 +393,12 @@ sendMeta) {
393
393
  catch (err) {
394
394
  const msg = err instanceof Error ? err.message : String(err);
395
395
  console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
396
+ if (err instanceof UploadDailyLimitExceededError) {
397
+ const dir = path.dirname(err.filePath);
398
+ const name = path.basename(err.filePath);
399
+ const size = formatFileSize(err.fileSize);
400
+ return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
401
+ }
396
402
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
397
403
  }
398
404
  }
@@ -412,6 +418,12 @@ sendMeta) {
412
418
  catch (err) {
413
419
  const msg = err instanceof Error ? err.message : String(err);
414
420
  console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
421
+ if (err instanceof UploadDailyLimitExceededError) {
422
+ const dir = path.dirname(err.filePath);
423
+ const name = path.basename(err.filePath);
424
+ const size = formatFileSize(err.fileSize);
425
+ return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
426
+ }
415
427
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
416
428
  }
417
429
  }
@@ -425,7 +437,7 @@ async function sendVideoFromLocal(ctx, mediaPath, prefix, sourceUrl) {
425
437
  return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.VIDEO, prefix, "sendVideoMsg", { mediaType: "video", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
426
438
  }
427
439
  /**
428
- * sendDocument — 发送文件消息(对齐 Telegram sendDocument)
440
+ * sendDocument — 发送文件消息
429
441
  *
430
442
  * 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
431
443
  */
@@ -612,7 +624,7 @@ export async function sendText(ctx) {
612
624
  });
613
625
  return lastResult;
614
626
  }
615
- // ============ 主动消息校验(参考 Telegram 机制) ============
627
+ // ============ 主动消息校验 ============
616
628
  // 如果是主动消息(无 replyToId 或降级后),必须有消息内容
617
629
  if (!replyToId) {
618
630
  if (!text || text.trim().length === 0) {
@@ -15,6 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { getQQBotDataDir } from "./utils/platform.js";
18
+ import { formatAttachmentTags } from "./group-history.js";
18
19
  // ============ 配置 ============
19
20
  const STORAGE_DIR = getQQBotDataDir("data");
20
21
  const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
@@ -220,34 +221,10 @@ export function formatRefEntryForAgent(entry) {
220
221
  if (entry.content.trim()) {
221
222
  parts.push(entry.content);
222
223
  }
223
- // 附件描述
224
- if (entry.attachments?.length) {
225
- for (const att of entry.attachments) {
226
- const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
227
- switch (att.type) {
228
- case "image":
229
- parts.push(`[图片${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
230
- break;
231
- case "voice":
232
- if (att.transcript) {
233
- const sourceMap = { stt: "本地识别", asr: "官方识别", tts: "TTS原文", fallback: "兜底文案" };
234
- const sourceTag = att.transcriptSource ? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}` : "";
235
- parts.push(`[语音消息(内容: "${att.transcript}"${sourceTag})${sourceHint}]`);
236
- }
237
- else {
238
- parts.push(`[语音消息${sourceHint}]`);
239
- }
240
- break;
241
- case "video":
242
- parts.push(`[视频${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
243
- break;
244
- case "file":
245
- parts.push(`[文件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
246
- break;
247
- default:
248
- parts.push(`[附件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
249
- }
250
- }
224
+ // 附件描述(委托 formatAttachmentTags 统一格式化)
225
+ const attachmentDesc = formatAttachmentTags(entry.attachments);
226
+ if (attachmentDesc) {
227
+ parts.push(attachmentDesc);
251
228
  }
252
229
  return parts.join(" ") || "[空消息]";
253
230
  }
@@ -1,6 +1,8 @@
1
1
  export interface RequestContext {
2
2
  /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
3
3
  target: string;
4
+ /** 当前请求的 QQBot 账户 ID(多账户场景) */
5
+ accountId?: string;
4
6
  }
5
7
  /**
6
8
  * 在请求级作用域中执行回调。
@@ -16,3 +18,8 @@ export declare function getRequestContext(): RequestContext | undefined;
16
18
  * 便捷方法,等价于 getRequestContext()?.target。
17
19
  */
18
20
  export declare function getRequestTarget(): string | undefined;
21
+ /**
22
+ * 获取当前请求的账户 ID。
23
+ * 便捷方法,等价于 getRequestContext()?.accountId。
24
+ */
25
+ export declare function getRequestAccountId(): string | undefined;
@@ -28,3 +28,10 @@ export function getRequestContext() {
28
28
  export function getRequestTarget() {
29
29
  return asyncLocalStorage.getStore()?.target;
30
30
  }
31
+ /**
32
+ * 获取当前请求的账户 ID。
33
+ * 便捷方法,等价于 getRequestContext()?.accountId。
34
+ */
35
+ export function getRequestAccountId() {
36
+ return asyncLocalStorage.getStore()?.accountId;
37
+ }
@@ -11,6 +11,12 @@
11
11
  * 从而计算「开平→插件」和「插件处理」两段耗时
12
12
  */
13
13
  import type { QQBotAccountConfig } from "./types.js";
14
+ export declare function getFrameworkVersion(): string;
15
+ /**
16
+ * 解析框架版本字符串中的日期版本号
17
+ * 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
18
+ */
19
+ export declare function parseFrameworkDateVersion(versionStr: string): string | null;
14
20
  /** 斜杠指令上下文(消息元数据 + 运行时状态) */
15
21
  export interface SlashCommandContext {
16
22
  /** 消息类型 */
@@ -23,7 +23,7 @@ const require = createRequire(import.meta.url);
23
23
  let PLUGIN_VERSION = getPackageVersion(import.meta.url);
24
24
  // 获取 openclaw 框架版本(缓存结果,只执行一次)
25
25
  let _frameworkVersion = null;
26
- function getFrameworkVersion() {
26
+ export function getFrameworkVersion() {
27
27
  if (_frameworkVersion !== null)
28
28
  return _frameworkVersion;
29
29
  try {
@@ -80,7 +80,7 @@ const UPGRADE_REQUIREMENTS = {
80
80
  * 解析框架版本字符串中的日期版本号
81
81
  * 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
82
82
  */
83
- function parseFrameworkDateVersion(versionStr) {
83
+ export function parseFrameworkDateVersion(versionStr) {
84
84
  const m = versionStr.match(/(\d{4}\.\d{1,2}\.\d{1,2})/);
85
85
  return m ? m[1] : null;
86
86
  }
@@ -790,7 +790,7 @@ registerCommand({
790
790
  ].join("\n"),
791
791
  handler: async (ctx) => {
792
792
  const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
793
- const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
793
+ const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
794
794
  const args = ctx.args.trim();
795
795
  const info = await getUpdateInfo();
796
796
  const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
@@ -1,4 +1,4 @@
1
- import { getRequestTarget } from "../request-context.js";
1
+ import { getRequestTarget, getRequestAccountId } from "../request-context.js";
2
2
  // ========== JSON Schema ==========
3
3
  const RemindSchema = {
4
4
  type: "object",
@@ -100,7 +100,7 @@ function generateJobName(content) {
100
100
  /**
101
101
  * 构建一次性提醒的 cron 工具参数
102
102
  */
103
- function buildOnceJob(params, delayMs, to) {
103
+ function buildOnceJob(params, delayMs, to, accountId) {
104
104
  const atMs = Date.now() + delayMs;
105
105
  const content = params.content;
106
106
  const name = params.name || generateJobName(content);
@@ -115,9 +115,12 @@ function buildOnceJob(params, delayMs, to) {
115
115
  payload: {
116
116
  kind: "agentTurn",
117
117
  message: buildReminderPrompt(content),
118
- deliver: true,
118
+ },
119
+ delivery: {
120
+ mode: "announce",
119
121
  channel: "qqbot",
120
122
  to,
123
+ accountId,
121
124
  },
122
125
  },
123
126
  };
@@ -125,7 +128,7 @@ function buildOnceJob(params, delayMs, to) {
125
128
  /**
126
129
  * 构建周期提醒的 cron 工具参数
127
130
  */
128
- function buildCronJob(params, to) {
131
+ function buildCronJob(params, to, accountId) {
129
132
  const content = params.content;
130
133
  const name = params.name || generateJobName(content);
131
134
  const tz = params.timezone || "Asia/Shanghai";
@@ -139,9 +142,12 @@ function buildCronJob(params, to) {
139
142
  payload: {
140
143
  kind: "agentTurn",
141
144
  message: buildReminderPrompt(content),
142
- deliver: true,
145
+ },
146
+ delivery: {
147
+ mode: "announce",
143
148
  channel: "qqbot",
144
149
  to,
150
+ accountId,
145
151
  },
146
152
  },
147
153
  };
@@ -211,15 +217,17 @@ export function registerRemindTool(api) {
211
217
  if (!resolvedTo) {
212
218
  return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
213
219
  }
220
+ // 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
221
+ const resolvedAccountId = getRequestAccountId() || "default";
214
222
  if (!p.time) {
215
223
  return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
216
224
  }
217
225
  // 判断是 cron 表达式还是相对时间
218
226
  if (isCronExpression(p.time)) {
219
227
  // 周期提醒
220
- const cronJob = buildCronJob(p, resolvedTo);
228
+ const cronJob = buildCronJob(p, resolvedTo, resolvedAccountId);
221
229
  return json({
222
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
230
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
223
231
  cronParams: cronJob,
224
232
  summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
225
233
  });
@@ -236,9 +244,9 @@ export function registerRemindTool(api) {
236
244
  if (delayMs < 30_000) {
237
245
  return json({ error: "提醒时间不能少于 30 秒" });
238
246
  }
239
- const onceJob = buildOnceJob(p, delayMs, resolvedTo);
247
+ const onceJob = buildOnceJob(p, delayMs, resolvedTo, resolvedAccountId);
240
248
  return json({
241
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
249
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
242
250
  cronParams: onceJob,
243
251
  summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
244
252
  });