@tencent-connect/openclaw-qqbot 1.6.6-alpha.1 → 1.6.6-alpha.4

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 (48) hide show
  1. package/README.md +2 -3
  2. package/README.zh.md +2 -3
  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 +124 -0
  9. package/dist/src/gateway.js +565 -23
  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-deliver.js +4 -3
  17. package/dist/src/outbound.d.ts +4 -4
  18. package/dist/src/outbound.js +18 -6
  19. package/dist/src/ref-index-store.js +5 -28
  20. package/dist/src/slash-commands.d.ts +6 -0
  21. package/dist/src/slash-commands.js +2 -2
  22. package/dist/src/types.d.ts +88 -0
  23. package/dist/src/utils/audio-convert.d.ts +1 -1
  24. package/dist/src/utils/audio-convert.js +1 -1
  25. package/dist/src/utils/chunked-upload.d.ts +11 -2
  26. package/dist/src/utils/chunked-upload.js +63 -11
  27. package/dist/src/utils/media-send.d.ts +1 -0
  28. package/dist/src/utils/media-send.js +22 -0
  29. package/dist/src/utils/text-parsing.js +7 -18
  30. package/package.json +1 -1
  31. package/scripts/upgrade-via-source.sh +62 -15
  32. package/src/api.ts +135 -7
  33. package/src/channel.ts +85 -2
  34. package/src/config.ts +169 -2
  35. package/src/gateway.ts +661 -28
  36. package/src/group-history.ts +328 -0
  37. package/src/message-gating.ts +190 -0
  38. package/src/message-queue.ts +201 -21
  39. package/src/openclaw-plugin-sdk.d.ts +65 -0
  40. package/src/outbound-deliver.ts +4 -3
  41. package/src/outbound.ts +18 -6
  42. package/src/ref-index-store.ts +5 -27
  43. package/src/slash-commands.ts +2 -2
  44. package/src/types.ts +92 -0
  45. package/src/utils/audio-convert.ts +1 -1
  46. package/src/utils/chunked-upload.ts +76 -12
  47. package/src/utils/media-send.ts +22 -0
  48. package/src/utils/text-parsing.ts +7 -14
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
12
12
 
13
- ### 🚀 Current Version: `v1.6.6`
13
+ ### 🚀 Current Version: `v1.6.5`
14
14
 
15
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
16
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -46,7 +46,6 @@ Scan to join the QQ group chat
46
46
  | 📝 **Markdown** | Full Markdown formatting support |
47
47
  | 🛠️ **Commands** | Native OpenClaw command integration |
48
48
  | 💬 **Quoted Context** | Resolve QQ `REFIDX_*` quoted messages and inject quote body into AI context |
49
- | 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
50
49
 
51
50
  ---
52
51
 
@@ -130,7 +129,7 @@ This capability depends on OpenClaw cron scheduling and proactive messaging. If
130
129
  >
131
130
  > **QQBot**: *(sends a .txt file)*
132
131
 
133
- AI can send files directly. Any format, up to 100MB. Large files are automatically chunked and uploaded in parallel.
132
+ AI can send files directly. Any format, up to 20MB.
134
133
 
135
134
  <img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="File Sending Demo" />
136
135
 
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
11
11
 
12
- ### 🚀 当前版本: `v1.6.6`
12
+ ### 🚀 当前版本: `v1.6.5`
13
13
 
14
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
15
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -41,7 +41,6 @@
41
41
  | 📝 **Markdown** | 完整支持 Markdown 格式消息 |
42
42
  | 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
43
43
  | 💬 **引用上下文** | 解析 QQ `REFIDX_*` 引用消息,并将引用内容注入 AI 上下文 |
44
- | 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
45
44
 
46
45
  ---
47
46
 
@@ -125,7 +124,7 @@ AI 可直接发送语音消息。格式:mp3/wav/silk/ogg,无需安装 ffmpeg
125
124
  >
126
125
  > **QQBot**:*(发送 .txt 文件)*
127
126
 
128
- AI 可直接发送文件。任意格式,最大 100MB。大文件自动分片并行上传。
127
+ AI 可直接发送文件。任意格式,最大 20MB。
129
128
 
130
129
  <img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="发文件演示" />
131
130
 
package/dist/src/api.d.ts CHANGED
@@ -2,11 +2,19 @@
2
2
  * QQ Bot API 鉴权和请求封装
3
3
  * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
4
  */
5
- /** API 请求错误,携带 HTTP status code */
5
+ /** API 请求错误,携带 HTTP status code 和业务错误码 */
6
6
  export declare class ApiError extends Error {
7
7
  readonly status: number;
8
8
  readonly path: string;
9
- constructor(message: string, status: number, path: string);
9
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
10
+ readonly bizCode?: number | undefined;
11
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
12
+ readonly bizMessage?: string | undefined;
13
+ constructor(message: string, status: number, path: string,
14
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
15
+ bizCode?: number | undefined,
16
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
17
+ bizMessage?: string | undefined);
10
18
  }
11
19
  export declare const PLUGIN_USER_AGENT: string;
12
20
  /** 出站消息元信息(结构化存储,不做预格式化) */
@@ -69,7 +77,22 @@ export declare function getNextMsgSeq(_msgId: string): number;
69
77
  * API 请求封装
70
78
  */
71
79
  export declare function apiRequest<T = unknown>(accessToken: string, method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
80
+ /**
81
+ * 需要持续重试的业务错误码集合
82
+ * 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
83
+ */
84
+ export declare const PART_FINISH_RETRYABLE_CODES: Set<number>;
85
+ /**
86
+ * upload_prepare 接口命中此错误码时,携带文件信息抛出 UploadDailyLimitExceededError,
87
+ * 由上层(outbound.ts)构造包含文件路径和大小的兜底文案发送给用户,
88
+ * 而非走通用的"文件发送失败,请稍后重试"
89
+ */
90
+ export declare const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
72
91
  export declare function getGatewayUrl(accessToken: string): Promise<string>;
92
+ /** 回应按钮交互(INTERACTION_CREATE),避免客户端按钮持续 loading */
93
+ export declare function acknowledgeInteraction(accessToken: string, interactionId: string, code?: 0 | 1 | 2 | 3 | 4 | 5, data?: Record<string, unknown>): Promise<void>;
94
+ /** 获取插件版本号(从 package.json 读取,和 PLUGIN_USER_AGENT 同源) */
95
+ export declare function getApiPluginVersion(): string;
73
96
  export interface MessageResponse {
74
97
  id: string;
75
98
  timestamp: number | string;
@@ -95,7 +118,7 @@ export declare function sendDmMessage(accessToken: string, guildId: string, cont
95
118
  id: string;
96
119
  timestamp: string;
97
120
  }>;
98
- export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string): Promise<MessageResponse>;
121
+ export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string, messageReference?: string): Promise<MessageResponse>;
99
122
  export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<MessageResponse>;
100
123
  export declare function sendProactiveGroupMessage(accessToken: string, groupOpenid: string, content: string): Promise<{
101
124
  id: string;
@@ -128,6 +151,10 @@ export interface UploadPrepareResponse {
128
151
  block_size: number;
129
152
  /** 分片列表(含预签名链接) */
130
153
  parts: UploadPart[];
154
+ /** 上传并发数(由服务端控制,可选,不返回时使用客户端默认值) */
155
+ concurrency?: number;
156
+ /** upload_part_finish 特定错误码的重试超时时间(秒),由服务端控制,客户端上限 10 分钟 */
157
+ retry_timeout?: number;
131
158
  }
132
159
  /** 完成文件上传响应(与 UploadMediaResponse 一致) */
133
160
  export interface MediaUploadResponse {
@@ -171,7 +198,7 @@ export declare function c2cUploadPrepare(accessToken: string, userId: string, fi
171
198
  * @param blockSize - 分块大小(字节)
172
199
  * @param md5 - 分片数据的 MD5(十六进制)
173
200
  */
174
- export declare function c2cUploadPartFinish(accessToken: string, userId: string, uploadId: string, partIndex: number, blockSize: number, md5: string): Promise<void>;
201
+ export declare function c2cUploadPartFinish(accessToken: string, userId: string, uploadId: string, partIndex: number, blockSize: number, md5: string, retryTimeoutMs?: number): Promise<void>;
175
202
  /**
176
203
  * 完成文件上传(C2C)
177
204
  * POST /v2/users/{user_id}/files
@@ -191,7 +218,7 @@ export declare function groupUploadPrepare(accessToken: string, groupId: string,
191
218
  * 完成分片上传(Group)
192
219
  * POST /v2/groups/{group_id}/upload_part_finish
193
220
  */
194
- export declare function groupUploadPartFinish(accessToken: string, groupId: string, uploadId: string, partIndex: number, blockSize: number, md5: string): Promise<void>;
221
+ export declare function groupUploadPartFinish(accessToken: string, groupId: string, uploadId: string, partIndex: number, blockSize: number, md5: string, retryTimeoutMs?: number): Promise<void>;
195
222
  /**
196
223
  * 完成文件上传(Group)
197
224
  * POST /v2/groups/{group_id}/files
package/dist/src/api.js CHANGED
@@ -6,14 +6,22 @@ import os from "node:os";
6
6
  import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
7
7
  import { sanitizeFileName } from "./utils/platform.js";
8
8
  // ============ 自定义错误 ============
9
- /** API 请求错误,携带 HTTP status code */
9
+ /** API 请求错误,携带 HTTP status code 和业务错误码 */
10
10
  export class ApiError extends Error {
11
11
  status;
12
12
  path;
13
- constructor(message, status, path) {
13
+ bizCode;
14
+ bizMessage;
15
+ constructor(message, status, path,
16
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
17
+ bizCode,
18
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
19
+ bizMessage) {
14
20
  super(message);
15
21
  this.status = status;
16
22
  this.path = path;
23
+ this.bizCode = bizCode;
24
+ this.bizMessage = bizMessage;
17
25
  this.name = "ApiError";
18
26
  }
19
27
  }
@@ -264,7 +272,8 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
264
272
  // JSON 错误响应
265
273
  try {
266
274
  const error = JSON.parse(rawBody);
267
- throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
275
+ const bizCode = error.code ?? error.err_code;
276
+ throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path, bizCode, error.message);
268
277
  }
269
278
  catch (parseErr) {
270
279
  if (parseErr instanceof ApiError)
@@ -332,10 +341,51 @@ async function completeUploadWithRetry(accessToken, method, path, body) {
332
341
  }
333
342
  throw lastError;
334
343
  }
335
- // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
344
+ // ============ 分片完成重试 ============
345
+ /** 普通错误最大重试次数 */
336
346
  const PART_FINISH_MAX_RETRIES = 2;
337
347
  const PART_FINISH_BASE_DELAY_MS = 1000;
338
- async function partFinishWithRetry(accessToken, method, path, body) {
348
+ /**
349
+ * 需要持续重试的业务错误码集合
350
+ * 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
351
+ */
352
+ export const PART_FINISH_RETRYABLE_CODES = new Set([
353
+ 40093001,
354
+ ]);
355
+ /**
356
+ * upload_prepare 接口命中此错误码时,携带文件信息抛出 UploadDailyLimitExceededError,
357
+ * 由上层(outbound.ts)构造包含文件路径和大小的兜底文案发送给用户,
358
+ * 而非走通用的"文件发送失败,请稍后重试"
359
+ */
360
+ export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
361
+ /** 特定错误码持续重试的默认超时(服务端未返回 retry_timeout 时的兜底) */
362
+ const PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
363
+ /** 特定错误码重试的固定间隔(1 秒) */
364
+ const PART_FINISH_RETRYABLE_INTERVAL_MS = 1000;
365
+ /**
366
+ * 判断错误是否命中"需要持续重试"的业务错误码
367
+ */
368
+ function isRetryableBizCode(err) {
369
+ if (PART_FINISH_RETRYABLE_CODES.size === 0)
370
+ return false;
371
+ if (err instanceof ApiError && err.bizCode !== undefined) {
372
+ return PART_FINISH_RETRYABLE_CODES.has(err.bizCode);
373
+ }
374
+ return false;
375
+ }
376
+ /**
377
+ * 分片完成接口重试策略:
378
+ *
379
+ * 1. 命中 PART_FINISH_RETRYABLE_CODES 的错误码 → 每 1s 重试一次,直到成功或超时
380
+ * 超时时间 = min(API 返回的 retry_timeout, 10 分钟)
381
+ * 2. 其他错误 → 最多重试 PART_FINISH_MAX_RETRIES 次(与之前逻辑一致)
382
+ *
383
+ * 若持续重试超时或普通重试耗尽,抛出错误,调用方(chunkedUpload)
384
+ * 可据此中止后续分片上传。
385
+ *
386
+ * @param retryTimeoutMs - 持续重试的超时时间(毫秒),由 upload_prepare 返回的 retry_timeout 计算得出
387
+ */
388
+ async function partFinishWithRetry(accessToken, method, path, body, retryTimeoutMs) {
339
389
  let lastError = null;
340
390
  for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
341
391
  try {
@@ -344,6 +394,13 @@ async function partFinishWithRetry(accessToken, method, path, body) {
344
394
  }
345
395
  catch (err) {
346
396
  lastError = err instanceof Error ? err : new Error(String(err));
397
+ // 命中特定错误码 → 进入持续重试模式
398
+ if (isRetryableBizCode(err)) {
399
+ const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
400
+ console.warn(`[qqbot-api] PartFinish hit retryable bizCode=${err.bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
401
+ await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
402
+ return;
403
+ }
347
404
  if (attempt < PART_FINISH_MAX_RETRIES) {
348
405
  const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
349
406
  console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
@@ -353,10 +410,52 @@ async function partFinishWithRetry(accessToken, method, path, body) {
353
410
  }
354
411
  throw lastError;
355
412
  }
413
+ /**
414
+ * 特定错误码的持续重试模式
415
+ * 不限次数,仅受总超时时间约束,固定每 1 秒重试一次
416
+ */
417
+ async function partFinishPersistentRetry(accessToken, method, path, body, timeoutMs) {
418
+ const deadline = Date.now() + timeoutMs;
419
+ let attempt = 0;
420
+ let lastError = null;
421
+ while (Date.now() < deadline) {
422
+ try {
423
+ await apiRequest(accessToken, method, path, body);
424
+ console.log(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
425
+ return;
426
+ }
427
+ catch (err) {
428
+ lastError = err instanceof Error ? err : new Error(String(err));
429
+ // 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
430
+ if (!isRetryableBizCode(err)) {
431
+ console.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${err.bizCode ?? "N/A"}), aborting`);
432
+ throw lastError;
433
+ }
434
+ attempt++;
435
+ const remaining = deadline - Date.now();
436
+ if (remaining <= 0)
437
+ break;
438
+ const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
439
+ console.warn(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${err.bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
440
+ await new Promise(resolve => setTimeout(resolve, actualDelay));
441
+ }
442
+ }
443
+ // 超时
444
+ console.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
445
+ throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
446
+ }
356
447
  export async function getGatewayUrl(accessToken) {
357
448
  const data = await apiRequest(accessToken, "GET", "/gateway");
358
449
  return data.url;
359
450
  }
451
+ /** 回应按钮交互(INTERACTION_CREATE),避免客户端按钮持续 loading */
452
+ export async function acknowledgeInteraction(accessToken, interactionId, code = 0, data) {
453
+ await apiRequest(accessToken, "PUT", `/interactions/${interactionId}`, { code, ...(data ? { data } : {}) });
454
+ }
455
+ /** 获取插件版本号(从 package.json 读取,和 PLUGIN_USER_AGENT 同源) */
456
+ export function getApiPluginVersion() {
457
+ return _pluginVersion;
458
+ }
360
459
  /**
361
460
  * 发送消息并自动触发 refIdx 回调
362
461
  * 所有消息发送函数统一经过此处,确保每条出站消息的 refIdx 都被捕获
@@ -429,10 +528,10 @@ export async function sendDmMessage(accessToken, guildId, content, msgId) {
429
528
  ...(msgId ? { msg_id: msgId } : {}),
430
529
  });
431
530
  }
432
- export async function sendGroupMessage(accessToken, groupOpenid, content, msgId) {
531
+ export async function sendGroupMessage(accessToken, groupOpenid, content, msgId, messageReference) {
433
532
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
434
- const body = buildMessageBody(content, msgId, msgSeq);
435
- return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
533
+ const body = buildMessageBody(content, msgId, msgSeq, messageReference);
534
+ return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
436
535
  }
437
536
  function buildProactiveMessageBody(content) {
438
537
  if (!content || content.trim().length === 0) {
@@ -487,8 +586,8 @@ export async function c2cUploadPrepare(accessToken, userId, fileType, fileName,
487
586
  * @param blockSize - 分块大小(字节)
488
587
  * @param md5 - 分片数据的 MD5(十六进制)
489
588
  */
490
- export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5) {
491
- await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
589
+ export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
590
+ await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 }, retryTimeoutMs);
492
591
  }
493
592
  /**
494
593
  * 完成文件上传(C2C)
@@ -513,8 +612,8 @@ export async function groupUploadPrepare(accessToken, groupId, fileType, fileNam
513
612
  * 完成分片上传(Group)
514
613
  * POST /v2/groups/{group_id}/upload_part_finish
515
614
  */
516
- export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5) {
517
- await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
615
+ export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
616
+ await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 }, retryTimeoutMs);
518
617
  }
519
618
  /**
520
619
  * 完成文件上传(Group)
@@ -9,3 +9,21 @@ export declare const TEXT_CHUNK_LIMIT = 5000;
9
9
  */
10
10
  export declare function chunkText(text: string, limit: number): string[];
11
11
  export declare const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount>;
12
+ /** 清理 @mention:替换 <@openid> 为 @用户名,去除 @机器人自身 */
13
+ export declare function stripMentionText(text: string, mentions?: Array<{
14
+ member_openid?: string;
15
+ id?: string;
16
+ user_openid?: string;
17
+ is_you?: boolean;
18
+ nickname?: string;
19
+ username?: string;
20
+ }>): string;
21
+ /** 检测消息是否 @了机器人(mentions > eventType > mentionPatterns) */
22
+ export declare function detectWasMentioned({ eventType, mentions, content, mentionPatterns }: {
23
+ eventType?: string;
24
+ mentions?: Array<{
25
+ is_you?: boolean;
26
+ }>;
27
+ content?: string;
28
+ mentionPatterns?: string[];
29
+ }): boolean;
@@ -1,5 +1,5 @@
1
1
  import { applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
2
- import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
2
+ import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
3
3
  import { sendText, sendMedia } from "./outbound.js";
4
4
  import { startGateway } from "./gateway.js";
5
5
  import { qqbotOnboardingAdapter } from "./onboarding.js";
@@ -39,8 +39,50 @@ export const qqbotPlugin = {
39
39
  blockStreaming: true,
40
40
  },
41
41
  reload: { configPrefixes: ["channels.qqbot"] },
42
+ // ============ 群消息策略适配器 ============
43
+ groups: {
44
+ /** 是否需要 @机器人才响应 */
45
+ resolveRequireMention: ({ cfg, accountId, groupId }) => {
46
+ if (!groupId)
47
+ return undefined;
48
+ return resolveRequireMention(cfg, groupId, accountId ?? undefined);
49
+ },
50
+ /** 群聊工具范围 */
51
+ resolveToolPolicy: ({ cfg, accountId, groupId }) => {
52
+ if (!groupId)
53
+ return undefined;
54
+ const policy = resolveToolPolicy(cfg, groupId, accountId ?? undefined);
55
+ // 将简单字符串策略映射为 GroupToolPolicyConfig 对象
56
+ if (policy === "full")
57
+ return undefined; // full = 默认不限制
58
+ if (policy === "none")
59
+ return { allow: [], deny: ["*"] };
60
+ // restricted: 默认空 allow(框架会使用内置 restricted 列表)
61
+ return { allow: [] };
62
+ },
63
+ /** QQ Bot 平台特有的群聊行为提示 */
64
+ resolveGroupIntroHint: ({ cfg, accountId, groupId }) => {
65
+ if (!groupId)
66
+ return undefined;
67
+ const groupCfg = resolveGroupConfig(cfg, groupId, accountId ?? undefined);
68
+ const hints = [];
69
+ if (groupCfg.name) {
70
+ hints.push(`当前群: ${groupCfg.name}`);
71
+ }
72
+ // bot 互聊防护、@状态行为指引在 gateway.ts 动态注入
73
+ return hints.join(" ") || undefined;
74
+ },
75
+ },
76
+ // ============ @mention 检测与清理 ============
77
+ mentions: {
78
+ /** 清理 @mention 文本(SDK ChannelMentionAdapter 接口) */
79
+ stripMentions: ({ text, ctx }) => {
80
+ const mentions = ctx?.mentions;
81
+ return stripMentionText(text, mentions);
82
+ },
83
+ },
42
84
  // CLI onboarding wizard
43
- // @ts-expect-error onboarding removed from ChannelPlugin type in 2026.3.23 but still supported at runtime
85
+ // @ts-ignore onboarding removed from ChannelPlugin type in 2026.3.23 but still supported at runtime
44
86
  onboarding: qqbotOnboardingAdapter,
45
87
  config: {
46
88
  listAccountIds: (cfg) => listQQBotAccountIds(cfg),
@@ -367,3 +409,44 @@ export const qqbotPlugin = {
367
409
  }),
368
410
  },
369
411
  };
412
+ // ============ 独立的 mention 工具函数(供 gateway.ts 等直接调用) ============
413
+ /** 清理 @mention:替换 <@openid> 为 @用户名,去除 @机器人自身 */
414
+ export function stripMentionText(text, mentions) {
415
+ if (!text || !mentions?.length)
416
+ return text;
417
+ let cleaned = text;
418
+ for (const m of mentions) {
419
+ const openid = m.member_openid ?? m.id ?? m.user_openid;
420
+ if (!openid)
421
+ continue;
422
+ if (m.is_you) {
423
+ cleaned = cleaned.replace(new RegExp(`<@!?${openid}>`, "g"), "").trim();
424
+ }
425
+ else {
426
+ const displayName = m.nickname ?? m.username;
427
+ if (displayName) {
428
+ cleaned = cleaned.replace(new RegExp(`<@!?${openid}>`, "g"), `@${displayName}`);
429
+ }
430
+ }
431
+ }
432
+ return cleaned;
433
+ }
434
+ /** 检测消息是否 @了机器人(mentions > eventType > mentionPatterns) */
435
+ export function detectWasMentioned({ eventType, mentions, content, mentionPatterns }) {
436
+ if (mentions?.some((m) => m.is_you))
437
+ return true;
438
+ if (eventType === "GROUP_AT_MESSAGE_CREATE")
439
+ return true;
440
+ if (mentionPatterns?.length && content) {
441
+ for (const pattern of mentionPatterns) {
442
+ try {
443
+ if (new RegExp(pattern, "i").test(content))
444
+ return true;
445
+ }
446
+ catch {
447
+ // 无效正则,跳过
448
+ }
449
+ }
450
+ }
451
+ return false;
452
+ }
@@ -1,6 +1,36 @@
1
- import type { ResolvedQQBotAccount } from "./types.js";
2
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { ResolvedQQBotAccount, ToolPolicy, GroupConfig } from "./types.js";
2
+ import type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk";
3
+ /**
4
+ * 解析 mentionPatterns(agent → global → 空数组)
5
+ *
6
+ * 优先级:
7
+ * 1. agents.list[agentId].groupChat.mentionPatterns
8
+ * 2. messages.groupChat.mentionPatterns
9
+ * 3. []
10
+ */
11
+ export declare function resolveMentionPatterns(cfg: OpenClawConfig, agentId?: string): string[];
3
12
  export declare const DEFAULT_ACCOUNT_ID = "default";
13
+ /** 解析群消息策略 */
14
+ export declare function resolveGroupPolicy(cfg: OpenClawConfig, accountId?: string): GroupPolicy;
15
+ /** 解析群白名单(统一转大写) */
16
+ export declare function resolveGroupAllowFrom(cfg: OpenClawConfig, accountId?: string): string[];
17
+ /** 检查指定群是否被允许(使用标准策略引擎) */
18
+ export declare function isGroupAllowed(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean;
19
+ type ResolvedGroupConfig = Omit<Required<GroupConfig>, "prompt"> & Pick<GroupConfig, "prompt">;
20
+ /** 解析指定群配置(具体 groupOpenid > 通配符 "*" > 默认值) */
21
+ export declare function resolveGroupConfig(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): ResolvedGroupConfig;
22
+ /** 解析群历史消息缓存条数 */
23
+ export declare function resolveHistoryLimit(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): number;
24
+ /** 解析群行为 PE(具体群 > "*" > 默认值) */
25
+ export declare function resolveGroupPrompt(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): string;
26
+ /** 解析群是否需要 @机器人才响应 */
27
+ export declare function resolveRequireMention(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean;
28
+ /** 解析群是否忽略 @了其他人(非 bot)的消息 */
29
+ export declare function resolveIgnoreOtherMentions(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean;
30
+ /** 解析群工具策略 */
31
+ export declare function resolveToolPolicy(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): ToolPolicy;
32
+ /** 解析群名称(优先配置,fallback 为 openid 前 8 位) */
33
+ export declare function resolveGroupName(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): string;
4
34
  /**
5
35
  * 列出所有 QQBot 账户 ID
6
36
  */
@@ -23,3 +53,4 @@ export declare function applyQQBotAccountConfig(cfg: OpenClawConfig, accountId:
23
53
  name?: string;
24
54
  imageServerBaseUrl?: string;
25
55
  }): OpenClawConfig;
56
+ export {};
@@ -1,4 +1,128 @@
1
+ /**
2
+ * 解析 mentionPatterns(agent → global → 空数组)
3
+ *
4
+ * 优先级:
5
+ * 1. agents.list[agentId].groupChat.mentionPatterns
6
+ * 2. messages.groupChat.mentionPatterns
7
+ * 3. []
8
+ */
9
+ export function resolveMentionPatterns(cfg, agentId) {
10
+ // 1. agent 级别
11
+ if (agentId) {
12
+ const agents = cfg.agents;
13
+ const entry = agents?.list?.find((a) => a.id?.trim().toLowerCase() === agentId.trim().toLowerCase());
14
+ const agentGroupChat = entry?.groupChat;
15
+ if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
16
+ return agentGroupChat.mentionPatterns ?? [];
17
+ }
18
+ }
19
+ // 2. 全局级别
20
+ const globalGroupChat = cfg?.messages?.groupChat;
21
+ if (globalGroupChat && typeof globalGroupChat === "object" && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
22
+ return globalGroupChat.mentionPatterns ?? [];
23
+ }
24
+ // 3. 空数组
25
+ return [];
26
+ }
1
27
  export const DEFAULT_ACCOUNT_ID = "default";
28
+ function evaluateMatchedGroupAccessForPolicy(params) {
29
+ if (params.groupPolicy === "disabled") {
30
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "disabled" };
31
+ }
32
+ if (params.groupPolicy === "allowlist") {
33
+ if (params.requireMatchInput && !params.hasMatchInput) {
34
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "missing_match_input" };
35
+ }
36
+ if (!params.allowlistConfigured) {
37
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "empty_allowlist" };
38
+ }
39
+ if (!params.allowlistMatched) {
40
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "not_allowlisted" };
41
+ }
42
+ }
43
+ return { allowed: true, groupPolicy: params.groupPolicy, reason: "allowed" };
44
+ }
45
+ // ============ 群消息策略 ============
46
+ const DEFAULT_GROUP_POLICY = "open";
47
+ /** 群历史缓存条数默认值 */
48
+ const DEFAULT_GROUP_HISTORY_LIMIT = 50;
49
+ const DEFAULT_GROUP_CONFIG = {
50
+ requireMention: true,
51
+ ignoreOtherMentions: false,
52
+ toolPolicy: "restricted",
53
+ name: "",
54
+ historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
55
+ };
56
+ /** 默认群消息行为 PE(可通过配置覆盖) */
57
+ const DEFAULT_GROUP_PROMPT = [
58
+ "若发送者为机器人,仅在对方明确@你提问或请求协助具体任务时,以简洁明了的内容回复,",
59
+ "避免与其他机器人产生抢答或多轮无意义对话。",
60
+ "在群聊中优先让人类用户的消息得到响应,机器人之间保持协作而非竞争,确保对话有序不刷屏。",
61
+ ].join("");
62
+ /** 解析群消息策略 */
63
+ export function resolveGroupPolicy(cfg, accountId) {
64
+ const account = resolveQQBotAccount(cfg, accountId);
65
+ return account.config?.groupPolicy ?? DEFAULT_GROUP_POLICY;
66
+ }
67
+ /** 解析群白名单(统一转大写) */
68
+ export function resolveGroupAllowFrom(cfg, accountId) {
69
+ const account = resolveQQBotAccount(cfg, accountId);
70
+ return (account.config?.groupAllowFrom ?? []).map((id) => String(id).trim().toUpperCase());
71
+ }
72
+ /** 检查指定群是否被允许(使用标准策略引擎) */
73
+ export function isGroupAllowed(cfg, groupOpenid, accountId) {
74
+ const policy = resolveGroupPolicy(cfg, accountId);
75
+ const allowList = resolveGroupAllowFrom(cfg, accountId);
76
+ const allowlistConfigured = allowList.length > 0;
77
+ const allowlistMatched = allowList.some((id) => id === "*" || id === groupOpenid.toUpperCase());
78
+ return evaluateMatchedGroupAccessForPolicy({
79
+ groupPolicy: policy,
80
+ allowlistConfigured,
81
+ allowlistMatched,
82
+ }).allowed;
83
+ }
84
+ /** 解析指定群配置(具体 groupOpenid > 通配符 "*" > 默认值) */
85
+ export function resolveGroupConfig(cfg, groupOpenid, accountId) {
86
+ const account = resolveQQBotAccount(cfg, accountId);
87
+ const groups = account.config?.groups ?? {};
88
+ const wildcardCfg = groups["*"] ?? {};
89
+ const specificCfg = groups[groupOpenid] ?? {};
90
+ return {
91
+ requireMention: specificCfg.requireMention ?? wildcardCfg.requireMention ?? DEFAULT_GROUP_CONFIG.requireMention,
92
+ ignoreOtherMentions: specificCfg.ignoreOtherMentions ?? wildcardCfg.ignoreOtherMentions ?? DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
93
+ toolPolicy: specificCfg.toolPolicy ?? wildcardCfg.toolPolicy ?? DEFAULT_GROUP_CONFIG.toolPolicy,
94
+ name: specificCfg.name ?? wildcardCfg.name ?? DEFAULT_GROUP_CONFIG.name,
95
+ prompt: specificCfg.prompt ?? wildcardCfg.prompt,
96
+ historyLimit: specificCfg.historyLimit ?? wildcardCfg.historyLimit ?? DEFAULT_GROUP_CONFIG.historyLimit,
97
+ };
98
+ }
99
+ /** 解析群历史消息缓存条数 */
100
+ export function resolveHistoryLimit(cfg, groupOpenid, accountId) {
101
+ return Math.max(0, resolveGroupConfig(cfg, groupOpenid, accountId).historyLimit);
102
+ }
103
+ /** 解析群行为 PE(具体群 > "*" > 默认值) */
104
+ export function resolveGroupPrompt(cfg, groupOpenid, accountId) {
105
+ const account = resolveQQBotAccount(cfg, accountId);
106
+ const groups = account.config?.groups ?? {};
107
+ return groups[groupOpenid]?.prompt ?? groups["*"]?.prompt ?? DEFAULT_GROUP_PROMPT;
108
+ }
109
+ /** 解析群是否需要 @机器人才响应 */
110
+ export function resolveRequireMention(cfg, groupOpenid, accountId) {
111
+ return resolveGroupConfig(cfg, groupOpenid, accountId).requireMention;
112
+ }
113
+ /** 解析群是否忽略 @了其他人(非 bot)的消息 */
114
+ export function resolveIgnoreOtherMentions(cfg, groupOpenid, accountId) {
115
+ return resolveGroupConfig(cfg, groupOpenid, accountId).ignoreOtherMentions;
116
+ }
117
+ /** 解析群工具策略 */
118
+ export function resolveToolPolicy(cfg, groupOpenid, accountId) {
119
+ return resolveGroupConfig(cfg, groupOpenid, accountId).toolPolicy;
120
+ }
121
+ /** 解析群名称(优先配置,fallback 为 openid 前 8 位) */
122
+ export function resolveGroupName(cfg, groupOpenid, accountId) {
123
+ const name = resolveGroupConfig(cfg, groupOpenid, accountId).name;
124
+ return name || groupOpenid.slice(0, 8);
125
+ }
2
126
  function normalizeAppId(raw) {
3
127
  if (raw === null || raw === undefined)
4
128
  return "";