@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,9 +1,10 @@
1
1
  import type { QueueSnapshot } from "./slash-commands.js";
2
2
 
3
- // 消息队列配置
4
- const MESSAGE_QUEUE_SIZE = 1000;
5
- const PER_USER_QUEUE_SIZE = 20;
6
- const MAX_CONCURRENT_USERS = 10;
3
+ // ── 消息队列默认配置 ──
4
+ const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
5
+ const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
6
+ const DEFAULT_GROUP_QUEUE_SIZE = 50;
7
+ const DEFAULT_MAX_CONCURRENT_USERS = 10;
7
8
 
8
9
  /**
9
10
  * 消息队列项类型(用于异步处理消息,防止阻塞心跳)
@@ -23,6 +24,18 @@ export interface QueuedMessage {
23
24
  refMsgIdx?: string;
24
25
  /** 当前消息自身的 refIdx(供将来被引用) */
25
26
  msgIdx?: string;
27
+ /** 事件类型(如 GROUP_AT_MESSAGE_CREATE),用于群消息合并时判断是否有 @ */
28
+ eventType?: string;
29
+ /** 发送者是否为机器人 */
30
+ senderIsBot?: boolean;
31
+ /** @ 提及列表(群消息合并时需要去重合并) */
32
+ mentions?: Array<{ scope?: "all" | "single"; id?: string; user_openid?: string; member_openid?: string; username?: string; bot?: boolean; is_you?: boolean }>;
33
+ /** 消息场景(来源、扩展字段) */
34
+ messageScene?: { source?: string; ext?: string[] };
35
+ /** 群消息合并标记:记录合并了多少条原始消息 */
36
+ _mergedCount?: number;
37
+ /** 合并前的原始消息列表(用于 gateway 侧逐条格式化信封) */
38
+ _mergedMessages?: QueuedMessage[];
26
39
  }
27
40
 
28
41
  export interface MessageQueueContext {
@@ -34,6 +47,14 @@ export interface MessageQueueContext {
34
47
  };
35
48
  /** 外部提供的 abort 状态检查 */
36
49
  isAborted: () => boolean;
50
+ /** 群聊队列上限(默认 50) */
51
+ groupQueueSize?: number;
52
+ /** 私聊/DM 队列上限(默认 20) */
53
+ peerQueueSize?: number;
54
+ /** 全局队列总量上限(默认 1000) */
55
+ globalQueueSize?: number;
56
+ /** 最大并发处理用户数(默认 10) */
57
+ maxConcurrentUsers?: number;
37
58
  }
38
59
 
39
60
  export interface MessageQueue {
@@ -47,15 +68,103 @@ export interface MessageQueue {
47
68
  executeImmediate: (msg: QueuedMessage) => void;
48
69
  }
49
70
 
71
+ // ── 群消息合并工具函数 ──
72
+
73
+ /** 判断 peerId 是否属于群聊 */
74
+ const isGroupPeer = (peerId: string): boolean =>
75
+ peerId.startsWith("group:") || peerId.startsWith("guild:");
76
+
77
+ /**
78
+ * 将多条群消息合并为一条,用于群聊场景下排队消息的批量处理。
79
+ * - content 拼接为多行,每行带发送者前缀
80
+ * - 附件合并
81
+ * - messageId / msgIdx / timestamp 取最后一条(用于回复引用)
82
+ * - mentions 合并去重
83
+ * - 如果有任意一条 @了你(is_you),合并结果也标记 @你
84
+ * - senderIsBot 只要有一条不是 bot 就算非 bot
85
+ */
86
+ function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage {
87
+ if (batch.length === 1) return batch[0];
88
+
89
+ const last = batch[batch.length - 1];
90
+ const first = batch[0];
91
+
92
+ // 拼接内容:每条消息带发送者前缀
93
+ const mergedContent = batch
94
+ .map((m) => {
95
+ const name = m.senderName ?? m.senderId;
96
+ return `[${name}]: ${m.content}`;
97
+ })
98
+ .join("\n");
99
+
100
+ // 合并附件
101
+ const mergedAttachments: QueuedMessage["attachments"] = [];
102
+ for (const m of batch) {
103
+ if (m.attachments?.length) {
104
+ mergedAttachments.push(...m.attachments);
105
+ }
106
+ }
107
+
108
+ // 合并 mentions(去重 by member_openid/id)
109
+ const seenMentionIds = new Set<string>();
110
+ const mergedMentions: NonNullable<QueuedMessage["mentions"]> = [];
111
+ let hasAtYouEvent = false;
112
+ for (const m of batch) {
113
+ if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
114
+ hasAtYouEvent = true;
115
+ }
116
+ if (m.mentions) {
117
+ for (const mt of m.mentions) {
118
+ const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
119
+ if (key && seenMentionIds.has(key)) continue;
120
+ if (key) seenMentionIds.add(key);
121
+ mergedMentions.push(mt);
122
+ }
123
+ }
124
+ }
125
+
126
+ // senderIsBot: 只要有一条来自非 bot 用户,就算非 bot
127
+ const allFromBot = batch.every((m) => m.senderIsBot);
128
+
129
+ return {
130
+ type: last.type,
131
+ senderId: last.senderId,
132
+ senderName: last.senderName,
133
+ senderIsBot: allFromBot,
134
+ content: mergedContent,
135
+ messageId: last.messageId,
136
+ timestamp: last.timestamp,
137
+ channelId: last.channelId,
138
+ guildId: last.guildId,
139
+ groupOpenid: last.groupOpenid,
140
+ attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
141
+ refMsgIdx: first.refMsgIdx,
142
+ msgIdx: last.msgIdx,
143
+ eventType: hasAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
144
+ mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
145
+ messageScene: last.messageScene,
146
+ _mergedCount: batch.length,
147
+ _mergedMessages: batch.length > 1 ? batch : undefined,
148
+ };
149
+ }
150
+
50
151
  /**
51
152
  * 创建按用户并发的消息队列(同用户串行,跨用户并行)
153
+ *
154
+ * 内置群消息增强:
155
+ * - 群聊 / 私聊使用不同队列上限
156
+ * - 群聊溢出时优先丢弃 bot 消息
157
+ * - drain 时自动合并群聊排队消息(斜杠命令单独处理)
52
158
  */
53
159
  export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
54
160
  const { accountId, log } = ctx;
161
+ const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
162
+ const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
163
+ const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
164
+ const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
55
165
 
56
166
  const userQueues = new Map<string, QueuedMessage[]>();
57
167
  const activeUsers = new Set<string>();
58
- let messagesProcessed = 0;
59
168
  let handleMessageFnRef: ((msg: QueuedMessage) => Promise<void>) | null = null;
60
169
  let totalEnqueued = 0;
61
170
 
@@ -65,10 +174,63 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
65
174
  return `dm:${msg.senderId}`;
66
175
  };
67
176
 
177
+ /** 从满队列中淘汰一条消息(群聊优先丢弃 bot 消息,否则丢弃最旧) */
178
+ const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => {
179
+ if (isGroup) {
180
+ const botIdx = queue.findIndex(m => m.senderIsBot);
181
+ if (botIdx >= 0) return queue.splice(botIdx, 1)[0];
182
+ }
183
+ return queue.shift();
184
+ };
185
+
186
+ /** 判断消息是否为斜杠指令 */
187
+ const isSlashCommand = (msg: QueuedMessage): boolean =>
188
+ (msg.content ?? "").trim().startsWith("/");
189
+
190
+ /** 处理单条消息,捕获异常并记录日志 */
191
+ const processOne = async (
192
+ msg: QueuedMessage,
193
+ peerId: string,
194
+ label: string,
195
+ ): Promise<boolean> => {
196
+ try {
197
+ await handleMessageFnRef!(msg);
198
+ return true;
199
+ } catch (err) {
200
+ log?.error(`[qqbot:${accountId}] ${label} error for ${peerId}: ${err}`);
201
+ return false;
202
+ }
203
+ };
204
+
205
+ /** 批量处理群聊排队消息:斜杠指令逐条处理,普通消息合并后处理 */
206
+ const drainGroupBatch = async (all: QueuedMessage[], peerId: string): Promise<void> => {
207
+ const commands: QueuedMessage[] = [];
208
+ const normal: QueuedMessage[] = [];
209
+ for (const m of all) {
210
+ (isSlashCommand(m) ? commands : normal).push(m);
211
+ }
212
+
213
+ // 指令消息逐条处理
214
+ for (const cmd of commands) {
215
+ log?.info(`[qqbot:${accountId}] Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
216
+ await processOne(cmd, peerId, "Command processor");
217
+ }
218
+
219
+ // 普通消息合并后处理
220
+ if (normal.length > 0) {
221
+ const merged = mergeGroupMessages(normal);
222
+ if (normal.length > 1) {
223
+ log?.info(`[qqbot:${accountId}] Merged ${normal.length} queued group messages for ${peerId} into one`);
224
+ }
225
+ await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
226
+ }
227
+ };
228
+
229
+ /** 处理指定 peer 队列中的消息(串行) */
68
230
  const drainUserQueue = async (peerId: string): Promise<void> => {
69
231
  if (activeUsers.has(peerId)) return;
70
- if (activeUsers.size >= MAX_CONCURRENT_USERS) {
71
- log?.info(`[qqbot:${accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`);
232
+ if (activeUsers.size >= maxConcurrentUsers) {
233
+ log?.info(`[qqbot:${accountId}] Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
72
234
  return;
73
235
  }
74
236
 
@@ -79,25 +241,31 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
79
241
  }
80
242
 
81
243
  activeUsers.add(peerId);
244
+ const isGroup = isGroupPeer(peerId);
82
245
 
83
246
  try {
84
247
  while (queue.length > 0 && !ctx.isAborted()) {
248
+ // 群聊排队 > 1 条:批量处理
249
+ if (isGroup && queue.length > 1 && handleMessageFnRef) {
250
+ const all = queue.splice(0, queue.length);
251
+ totalEnqueued = Math.max(0, totalEnqueued - all.length);
252
+ await drainGroupBatch(all, peerId);
253
+ continue;
254
+ }
255
+
256
+ // 非群聊 或 队列只剩 1 条:逐条处理
85
257
  const msg = queue.shift()!;
86
258
  totalEnqueued = Math.max(0, totalEnqueued - 1);
87
- try {
88
- if (handleMessageFnRef) {
89
- await handleMessageFnRef(msg);
90
- messagesProcessed++;
91
- }
92
- } catch (err) {
93
- log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${err}`);
259
+ if (handleMessageFnRef) {
260
+ await processOne(msg, peerId, "Message processor");
94
261
  }
95
262
  }
96
263
  } finally {
97
264
  activeUsers.delete(peerId);
98
265
  userQueues.delete(peerId);
266
+ // 尽量填满并发槽位
99
267
  for (const [waitingPeerId, waitingQueue] of userQueues) {
100
- if (activeUsers.size >= MAX_CONCURRENT_USERS) break;
268
+ if (activeUsers.size >= maxConcurrentUsers) break;
101
269
  if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
102
270
  drainUserQueue(waitingPeerId);
103
271
  }
@@ -107,31 +275,43 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
107
275
 
108
276
  const enqueue = (msg: QueuedMessage): void => {
109
277
  const peerId = getMessagePeerId(msg);
278
+ const isGroup = isGroupPeer(peerId);
110
279
  let queue = userQueues.get(peerId);
111
280
  if (!queue) {
112
281
  queue = [];
113
282
  userQueues.set(peerId, queue);
114
283
  }
115
284
 
116
- if (queue.length >= PER_USER_QUEUE_SIZE) {
117
- const dropped = queue.shift();
118
- log?.error(`[qqbot:${accountId}] Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
285
+ // 群聊和非群聊使用不同的队列上限
286
+ const maxSize = isGroup ? groupQueueSize : peerQueueSize;
287
+
288
+ // 队列溢出:淘汰一条旧消息
289
+ if (queue.length >= maxSize) {
290
+ const dropped = evictOne(queue, isGroup);
291
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
292
+ if (isGroup && dropped?.senderIsBot) {
293
+ log?.info(`[qqbot:${accountId}] Queue full for ${peerId}, dropping bot message ${dropped.messageId}`);
294
+ } else {
295
+ log?.error(`[qqbot:${accountId}] Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
296
+ }
119
297
  }
120
298
 
299
+ // 全局总量保护
121
300
  totalEnqueued++;
122
- if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
301
+ if (totalEnqueued > globalQueueSize) {
123
302
  log?.error(`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
124
303
  }
125
304
 
126
305
  queue.push(msg);
127
306
  log?.debug?.(`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
128
307
 
308
+ // 如果该用户没有正在处理的消息,立即启动处理
129
309
  drainUserQueue(peerId);
130
310
  };
131
311
 
132
312
  const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
133
313
  handleMessageFnRef = handleMessageFn;
134
- log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`);
314
+ log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`);
135
315
  };
136
316
 
137
317
  const getSnapshot = (senderPeerId: string): QueueSnapshot => {
@@ -143,7 +323,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
143
323
  return {
144
324
  totalPending,
145
325
  activeUsers: activeUsers.size,
146
- maxConcurrentUsers: MAX_CONCURRENT_USERS,
326
+ maxConcurrentUsers,
147
327
  senderPending: senderQueue ? senderQueue.length : 0,
148
328
  };
149
329
  };
@@ -362,6 +362,35 @@ declare module "openclaw/plugin-sdk" {
362
362
  [key: string]: unknown;
363
363
  }
364
364
 
365
+ // ============ 群消息适配器 ============
366
+
367
+ /** 群消息策略适配器(resolveRequireMention / resolveToolPolicy / resolveGroupIntroHint) */
368
+ export interface ChannelGroupAdapter {
369
+ /** 是否需要 @机器人才响应 */
370
+ resolveRequireMention?: (ctx: { cfg: OpenClawConfig; accountId?: string; groupId: string }) => boolean;
371
+ /** 群聊 AI 工具使用范围 */
372
+ resolveToolPolicy?: (ctx: { cfg: OpenClawConfig; accountId?: string; groupId: string; senderId?: string }) => "full" | "restricted" | "none";
373
+ /** 平台特有的群聊行为提示 */
374
+ resolveGroupIntroHint?: (ctx: { cfg: OpenClawConfig; accountId?: string; groupId: string }) => string | undefined;
375
+ /** 其他适配器方法 */
376
+ [key: string]: unknown;
377
+ }
378
+
379
+ /** @mention 检测与清理适配器(stripMentionText / detectWasMentioned) */
380
+ export interface ChannelMentionAdapter {
381
+ /** 清理 @mention 文本:平台格式→可读格式,去除 @机器人自身 */
382
+ stripMentionText?: (text: string, mentions?: Array<{ member_openid?: string; nickname?: string; is_you?: boolean }>) => string;
383
+ /** 检测当前消息是否 @了机器人 */
384
+ detectWasMentioned?: (ctx: {
385
+ eventType?: string;
386
+ mentions?: Array<{ is_you?: boolean; bot?: boolean }>;
387
+ content?: string;
388
+ mentionPatterns?: string[];
389
+ }) => boolean;
390
+ /** 其他适配器方法 */
391
+ [key: string]: unknown;
392
+ }
393
+
365
394
  /**
366
395
  * 频道插件接口(泛型)
367
396
  */
@@ -388,6 +417,10 @@ declare module "openclaw/plugin-sdk" {
388
417
  outbound?: ChannelPluginOutbound;
389
418
  /** Gateway 配置 */
390
419
  gateway?: ChannelPluginGateway<TAccount>;
420
+ /** 群消息策略适配器 */
421
+ groups?: ChannelGroupAdapter;
422
+ /** @mention 检测与清理适配器 */
423
+ mentions?: ChannelMentionAdapter;
391
424
  /** 启动函数 */
392
425
  start?: (runtime: PluginRuntime) => void | Promise<void>;
393
426
  /** 停止函数 */
@@ -512,6 +545,38 @@ declare module "openclaw/plugin-sdk" {
512
545
  allowTopLevel?: boolean;
513
546
  }): OpenClawConfig;
514
547
 
548
+ // ============ 群访问策略引擎(核心框架标准) ============
549
+
550
+ /** 群组访问策略类型:"open" | "disabled" | "allowlist" */
551
+ export type GroupPolicy = "open" | "disabled" | "allowlist";
552
+
553
+ /** 基于白名单匹配的群访问决策原因 */
554
+ export type MatchedGroupAccessReason =
555
+ | "allowed"
556
+ | "disabled"
557
+ | "missing_match_input"
558
+ | "empty_allowlist"
559
+ | "not_allowlisted";
560
+
561
+ /** 基于白名单匹配的群访问决策结果 */
562
+ export type MatchedGroupAccessDecision = {
563
+ allowed: boolean;
564
+ groupPolicy: GroupPolicy;
565
+ reason: MatchedGroupAccessReason;
566
+ };
567
+
568
+ /**
569
+ * 核心框架标准群访问策略评估引擎(基于 policy + allowlist 匹配)
570
+ * @see openclaw/src/plugin-sdk/group-access.ts
571
+ */
572
+ export function evaluateMatchedGroupAccessForPolicy(params: {
573
+ groupPolicy: GroupPolicy;
574
+ allowlistConfigured: boolean;
575
+ allowlistMatched: boolean;
576
+ requireMatchInput?: boolean;
577
+ hasMatchInput?: boolean;
578
+ }): MatchedGroupAccessDecision;
579
+
515
580
  // ============ 其他导出 ============
516
581
 
517
582
  /** 默认账户 ID 常量 */
package/src/outbound.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from "./api.js";
21
21
  import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
22
22
  import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
23
- import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
23
+ import { chunkedUploadC2C, chunkedUploadGroup, UploadDailyLimitExceededError } from "./utils/chunked-upload.js";
24
24
  import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
25
25
  import { downloadFile } from "./image-server.js";
26
26
  import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
@@ -274,7 +274,7 @@ async function getToken(account: ResolvedQQBotAccount): Promise<string> {
274
274
  }
275
275
 
276
276
  /**
277
- * sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
277
+ * sendPhoto — 发送图片消息
278
278
  *
279
279
  * 支持三种来源:
280
280
  * - 本地文件路径 → 分片上传
@@ -365,7 +365,7 @@ export async function sendPhoto(
365
365
  }
366
366
 
367
367
  /**
368
- * sendVoice — 发送语音消息(对齐 Telegram sendVoice)
368
+ * sendVoice — 发送语音消息
369
369
  *
370
370
  * 支持本地音频文件和公网 URL:
371
371
  * - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
@@ -452,7 +452,7 @@ async function sendVoiceFromLocal(
452
452
  }
453
453
 
454
454
  /**
455
- * sendVideoMsg — 发送视频消息(对齐 Telegram sendVideo)
455
+ * sendVideoMsg — 发送视频消息
456
456
  *
457
457
  * 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
458
458
  */
@@ -533,6 +533,12 @@ async function chunkedUploadAndSend(
533
533
  } catch (err) {
534
534
  const msg = err instanceof Error ? err.message : String(err);
535
535
  console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
536
+ if (err instanceof UploadDailyLimitExceededError) {
537
+ const dir = path.dirname(err.filePath);
538
+ const name = path.basename(err.filePath);
539
+ const size = formatFileSize(err.fileSize);
540
+ return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
541
+ }
536
542
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
537
543
  }
538
544
  }
@@ -556,6 +562,12 @@ async function chunkedUploadAndSend(
556
562
  } catch (err) {
557
563
  const msg = err instanceof Error ? err.message : String(err);
558
564
  console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
565
+ if (err instanceof UploadDailyLimitExceededError) {
566
+ const dir = path.dirname(err.filePath);
567
+ const name = path.basename(err.filePath);
568
+ const size = formatFileSize(err.fileSize);
569
+ return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
570
+ }
559
571
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
560
572
  }
561
573
  }
@@ -573,7 +585,7 @@ async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, pr
573
585
  }
574
586
 
575
587
  /**
576
- * sendDocument — 发送文件消息(对齐 Telegram sendDocument)
588
+ * sendDocument — 发送文件消息
577
589
  *
578
590
  * 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
579
591
  */
@@ -785,7 +797,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
785
797
  return lastResult;
786
798
  }
787
799
 
788
- // ============ 主动消息校验(参考 Telegram 机制) ============
800
+ // ============ 主动消息校验 ============
789
801
  // 如果是主动消息(无 replyToId 或降级后),必须有消息内容
790
802
  if (!replyToId) {
791
803
  if (!text || text.trim().length === 0) {
@@ -16,6 +16,7 @@
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
18
  import { getQQBotDataDir } from "./utils/platform.js";
19
+ import { formatAttachmentTags } from "./group-history.js";
19
20
 
20
21
  // ============ 存储的消息摘要 ============
21
22
 
@@ -297,33 +298,10 @@ export function formatRefEntryForAgent(entry: RefIndexEntry): string {
297
298
  parts.push(entry.content);
298
299
  }
299
300
 
300
- // 附件描述
301
- if (entry.attachments?.length) {
302
- for (const att of entry.attachments) {
303
- const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
304
- switch (att.type) {
305
- case "image":
306
- parts.push(`[图片${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
307
- break;
308
- case "voice":
309
- if (att.transcript) {
310
- const sourceMap = { stt: "本地识别", asr: "官方识别", tts: "TTS原文", fallback: "兜底文案" };
311
- const sourceTag = att.transcriptSource ? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}` : "";
312
- parts.push(`[语音消息(内容: "${att.transcript}"${sourceTag})${sourceHint}]`);
313
- } else {
314
- parts.push(`[语音消息${sourceHint}]`);
315
- }
316
- break;
317
- case "video":
318
- parts.push(`[视频${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
319
- break;
320
- case "file":
321
- parts.push(`[文件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
322
- break;
323
- default:
324
- parts.push(`[附件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
325
- }
326
- }
301
+ // 附件描述(委托 formatAttachmentTags 统一格式化)
302
+ const attachmentDesc = formatAttachmentTags(entry.attachments);
303
+ if (attachmentDesc) {
304
+ parts.push(attachmentDesc);
327
305
  }
328
306
 
329
307
  return parts.join(" ") || "[空消息]";
@@ -11,6 +11,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
11
11
  export interface RequestContext {
12
12
  /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
13
13
  target: string;
14
+ /** 当前请求的 QQBot 账户 ID(多账户场景) */
15
+ accountId?: string;
14
16
  }
15
17
 
16
18
  const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
@@ -37,3 +39,11 @@ export function getRequestContext(): RequestContext | undefined {
37
39
  export function getRequestTarget(): string | undefined {
38
40
  return asyncLocalStorage.getStore()?.target;
39
41
  }
42
+
43
+ /**
44
+ * 获取当前请求的账户 ID。
45
+ * 便捷方法,等价于 getRequestContext()?.accountId。
46
+ */
47
+ export function getRequestAccountId(): string | undefined {
48
+ return asyncLocalStorage.getStore()?.accountId;
49
+ }
@@ -27,7 +27,7 @@ let PLUGIN_VERSION = getPackageVersion(import.meta.url);
27
27
 
28
28
  // 获取 openclaw 框架版本(缓存结果,只执行一次)
29
29
  let _frameworkVersion: string | null = null;
30
- function getFrameworkVersion(): string {
30
+ export function getFrameworkVersion(): string {
31
31
  if (_frameworkVersion !== null) return _frameworkVersion;
32
32
  try {
33
33
  // 先尝试 PATH 中的 CLI
@@ -90,7 +90,7 @@ interface UpgradeCompatResult {
90
90
  * 解析框架版本字符串中的日期版本号
91
91
  * 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
92
92
  */
93
- function parseFrameworkDateVersion(versionStr: string): string | null {
93
+ export function parseFrameworkDateVersion(versionStr: string): string | null {
94
94
  const m = versionStr.match(/(\d{4}\.\d{1,2}\.\d{1,2})/);
95
95
  return m ? m[1] : null;
96
96
  }
@@ -898,7 +898,7 @@ registerCommand({
898
898
  ].join("\n"),
899
899
  handler: async (ctx) => {
900
900
  const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
901
- const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
901
+ const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
902
902
  const args = ctx.args.trim();
903
903
  const info = await getUpdateInfo();
904
904
 
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { getRequestTarget } from "../request-context.js";
2
+ import { getRequestTarget, getRequestAccountId } from "../request-context.js";
3
3
 
4
4
  // ========== 类型定义 ==========
5
5
 
@@ -134,7 +134,7 @@ function generateJobName(content: string): string {
134
134
  /**
135
135
  * 构建一次性提醒的 cron 工具参数
136
136
  */
137
- function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
137
+ function buildOnceJob(params: RemindParams, delayMs: number, to: string, accountId: string) {
138
138
  const atMs = Date.now() + delayMs;
139
139
  const content = params.content!;
140
140
  const name = params.name || generateJobName(content);
@@ -150,9 +150,12 @@ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
150
150
  payload: {
151
151
  kind: "agentTurn",
152
152
  message: buildReminderPrompt(content),
153
- deliver: true,
153
+ },
154
+ delivery: {
155
+ mode: "announce",
154
156
  channel: "qqbot",
155
157
  to,
158
+ accountId,
156
159
  },
157
160
  },
158
161
  };
@@ -161,7 +164,7 @@ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
161
164
  /**
162
165
  * 构建周期提醒的 cron 工具参数
163
166
  */
164
- function buildCronJob(params: RemindParams, to: string) {
167
+ function buildCronJob(params: RemindParams, to: string, accountId: string) {
165
168
  const content = params.content!;
166
169
  const name = params.name || generateJobName(content);
167
170
  const tz = params.timezone || "Asia/Shanghai";
@@ -176,9 +179,12 @@ function buildCronJob(params: RemindParams, to: string) {
176
179
  payload: {
177
180
  kind: "agentTurn",
178
181
  message: buildReminderPrompt(content),
179
- deliver: true,
182
+ },
183
+ delivery: {
184
+ mode: "announce",
180
185
  channel: "qqbot",
181
186
  to,
187
+ accountId,
182
188
  },
183
189
  },
184
190
  };
@@ -256,6 +262,8 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
256
262
  if (!resolvedTo) {
257
263
  return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
258
264
  }
265
+ // 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
266
+ const resolvedAccountId = getRequestAccountId() || "default";
259
267
  if (!p.time) {
260
268
  return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
261
269
  }
@@ -263,9 +271,9 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
263
271
  // 判断是 cron 表达式还是相对时间
264
272
  if (isCronExpression(p.time)) {
265
273
  // 周期提醒
266
- const cronJob = buildCronJob(p, resolvedTo);
274
+ const cronJob = buildCronJob(p, resolvedTo, resolvedAccountId);
267
275
  return json({
268
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
276
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
269
277
  cronParams: cronJob,
270
278
  summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
271
279
  });
@@ -285,9 +293,9 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
285
293
  return json({ error: "提醒时间不能少于 30 秒" });
286
294
  }
287
295
 
288
- const onceJob = buildOnceJob(p, delayMs, resolvedTo);
296
+ const onceJob = buildOnceJob(p, delayMs, resolvedTo, resolvedAccountId);
289
297
  return json({
290
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
298
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
291
299
  cronParams: onceJob,
292
300
  summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
293
301
  });