@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
package/src/api.ts CHANGED
@@ -9,12 +9,16 @@ import { sanitizeFileName } from "./utils/platform.js";
9
9
 
10
10
  // ============ 自定义错误 ============
11
11
 
12
- /** API 请求错误,携带 HTTP status code */
12
+ /** API 请求错误,携带 HTTP status code 和业务错误码 */
13
13
  export class ApiError extends Error {
14
14
  constructor(
15
15
  message: string,
16
16
  public readonly status: number,
17
17
  public readonly path: string,
18
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
19
+ public readonly bizCode?: number,
20
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
21
+ public readonly bizMessage?: string,
18
22
  ) {
19
23
  super(message);
20
24
  this.name = "ApiError";
@@ -320,8 +324,9 @@ export async function apiRequest<T = unknown>(
320
324
  }
321
325
  // JSON 错误响应
322
326
  try {
323
- const error = JSON.parse(rawBody) as { message?: string; code?: number };
324
- throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
327
+ const error = JSON.parse(rawBody) as { message?: string; code?: number; err_code?: number };
328
+ const bizCode = error.code ?? error.err_code;
329
+ throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path, bizCode, error.message);
325
330
  } catch (parseErr) {
326
331
  if (parseErr instanceof ApiError) throw parseErr;
327
332
  throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
@@ -413,16 +418,62 @@ async function completeUploadWithRetry(
413
418
  throw lastError!;
414
419
  }
415
420
 
416
- // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
421
+ // ============ 分片完成重试 ============
417
422
 
423
+ /** 普通错误最大重试次数 */
418
424
  const PART_FINISH_MAX_RETRIES = 2;
419
425
  const PART_FINISH_BASE_DELAY_MS = 1000;
420
426
 
427
+ /**
428
+ * 需要持续重试的业务错误码集合
429
+ * 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
430
+ */
431
+ export const PART_FINISH_RETRYABLE_CODES: Set<number> = new Set([
432
+ 40093001,
433
+ ]);
434
+
435
+ /**
436
+ * upload_prepare 接口命中此错误码时,携带文件信息抛出 UploadDailyLimitExceededError,
437
+ * 由上层(outbound.ts)构造包含文件路径和大小的兜底文案发送给用户,
438
+ * 而非走通用的"文件发送失败,请稍后重试"
439
+ */
440
+ export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
441
+
442
+ /** 特定错误码持续重试的默认超时(服务端未返回 retry_timeout 时的兜底) */
443
+ const PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
444
+
445
+ /** 特定错误码重试的固定间隔(1 秒) */
446
+ const PART_FINISH_RETRYABLE_INTERVAL_MS = 1000;
447
+
448
+ /**
449
+ * 判断错误是否命中"需要持续重试"的业务错误码
450
+ */
451
+ function isRetryableBizCode(err: unknown): boolean {
452
+ if (PART_FINISH_RETRYABLE_CODES.size === 0) return false;
453
+ if (err instanceof ApiError && err.bizCode !== undefined) {
454
+ return PART_FINISH_RETRYABLE_CODES.has(err.bizCode);
455
+ }
456
+ return false;
457
+ }
458
+
459
+ /**
460
+ * 分片完成接口重试策略:
461
+ *
462
+ * 1. 命中 PART_FINISH_RETRYABLE_CODES 的错误码 → 每 1s 重试一次,直到成功或超时
463
+ * 超时时间 = min(API 返回的 retry_timeout, 10 分钟)
464
+ * 2. 其他错误 → 最多重试 PART_FINISH_MAX_RETRIES 次(与之前逻辑一致)
465
+ *
466
+ * 若持续重试超时或普通重试耗尽,抛出错误,调用方(chunkedUpload)
467
+ * 可据此中止后续分片上传。
468
+ *
469
+ * @param retryTimeoutMs - 持续重试的超时时间(毫秒),由 upload_prepare 返回的 retry_timeout 计算得出
470
+ */
421
471
  async function partFinishWithRetry(
422
472
  accessToken: string,
423
473
  method: string,
424
474
  path: string,
425
475
  body?: unknown,
476
+ retryTimeoutMs?: number,
426
477
  ): Promise<void> {
427
478
  let lastError: Error | null = null;
428
479
 
@@ -433,6 +484,14 @@ async function partFinishWithRetry(
433
484
  } catch (err) {
434
485
  lastError = err instanceof Error ? err : new Error(String(err));
435
486
 
487
+ // 命中特定错误码 → 进入持续重试模式
488
+ if (isRetryableBizCode(err)) {
489
+ const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
490
+ console.warn(`[qqbot-api] PartFinish hit retryable bizCode=${(err as ApiError).bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
491
+ await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
492
+ return;
493
+ }
494
+
436
495
  if (attempt < PART_FINISH_MAX_RETRIES) {
437
496
  const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
438
497
  console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
@@ -444,11 +503,71 @@ async function partFinishWithRetry(
444
503
  throw lastError!;
445
504
  }
446
505
 
506
+ /**
507
+ * 特定错误码的持续重试模式
508
+ * 不限次数,仅受总超时时间约束,固定每 1 秒重试一次
509
+ */
510
+ async function partFinishPersistentRetry(
511
+ accessToken: string,
512
+ method: string,
513
+ path: string,
514
+ body: unknown,
515
+ timeoutMs: number,
516
+ ): Promise<void> {
517
+ const deadline = Date.now() + timeoutMs;
518
+ let attempt = 0;
519
+ let lastError: Error | null = null;
520
+
521
+ while (Date.now() < deadline) {
522
+ try {
523
+ await apiRequest<Record<string, unknown>>(accessToken, method, path, body);
524
+ console.log(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
525
+ return;
526
+ } catch (err) {
527
+ lastError = err instanceof Error ? err : new Error(String(err));
528
+
529
+ // 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
530
+ if (!isRetryableBizCode(err)) {
531
+ console.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${(err as ApiError).bizCode ?? "N/A"}), aborting`);
532
+ throw lastError;
533
+ }
534
+
535
+ attempt++;
536
+ const remaining = deadline - Date.now();
537
+
538
+ if (remaining <= 0) break;
539
+
540
+ const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
541
+ console.warn(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${(err as ApiError).bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
542
+ await new Promise(resolve => setTimeout(resolve, actualDelay));
543
+ }
544
+ }
545
+
546
+ // 超时
547
+ console.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
548
+ throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
549
+ }
550
+
447
551
  export async function getGatewayUrl(accessToken: string): Promise<string> {
448
552
  const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
449
553
  return data.url;
450
554
  }
451
555
 
556
+ /** 回应按钮交互(INTERACTION_CREATE),避免客户端按钮持续 loading */
557
+ export async function acknowledgeInteraction(
558
+ accessToken: string,
559
+ interactionId: string,
560
+ code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
561
+ data?: Record<string, unknown>
562
+ ): Promise<void> {
563
+ await apiRequest(accessToken, "PUT", `/interactions/${interactionId}`, { code, ...(data ? { data } : {}) });
564
+ }
565
+
566
+ /** 获取插件版本号(从 package.json 读取,和 PLUGIN_USER_AGENT 同源) */
567
+ export function getApiPluginVersion(): string {
568
+ return _pluginVersion;
569
+ }
570
+
452
571
  // ============ 消息发送接口 ============
453
572
 
454
573
  export interface MessageResponse {
@@ -574,11 +693,12 @@ export async function sendGroupMessage(
574
693
  accessToken: string,
575
694
  groupOpenid: string,
576
695
  content: string,
577
- msgId?: string
696
+ msgId?: string,
697
+ messageReference?: string
578
698
  ): Promise<MessageResponse> {
579
699
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
580
- const body = buildMessageBody(content, msgId, msgSeq);
581
- return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
700
+ const body = buildMessageBody(content, msgId, msgSeq, messageReference);
701
+ return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
582
702
  }
583
703
 
584
704
  function buildProactiveMessageBody(content: string): Record<string, unknown> {
@@ -644,6 +764,10 @@ export interface UploadPrepareResponse {
644
764
  block_size: number;
645
765
  /** 分片列表(含预签名链接) */
646
766
  parts: UploadPart[];
767
+ /** 上传并发数(由服务端控制,可选,不返回时使用客户端默认值) */
768
+ concurrency?: number;
769
+ /** upload_part_finish 特定错误码的重试超时时间(秒),由服务端控制,客户端上限 10 分钟 */
770
+ retry_timeout?: number;
647
771
  }
648
772
 
649
773
  /** 完成文件上传响应(与 UploadMediaResponse 一致) */
@@ -710,10 +834,12 @@ export async function c2cUploadPartFinish(
710
834
  partIndex: number,
711
835
  blockSize: number,
712
836
  md5: string,
837
+ retryTimeoutMs?: number,
713
838
  ): Promise<void> {
714
839
  await partFinishWithRetry(
715
840
  accessToken, "POST", `/v2/users/${userId}/upload_part_finish`,
716
841
  { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
842
+ retryTimeoutMs,
717
843
  );
718
844
  }
719
845
 
@@ -766,10 +892,12 @@ export async function groupUploadPartFinish(
766
892
  partIndex: number,
767
893
  blockSize: number,
768
894
  md5: string,
895
+ retryTimeoutMs?: number,
769
896
  ): Promise<void> {
770
897
  await partFinishWithRetry(
771
898
  accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`,
772
899
  { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
900
+ retryTimeoutMs,
773
901
  );
774
902
  }
775
903
 
package/src/channel.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from "openclaw/plugin-sdk/core";
8
8
 
9
9
  import type { ResolvedQQBotAccount } from "./types.js";
10
- import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
10
+ import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
11
11
  import { sendText, sendMedia } from "./outbound.js";
12
12
  import { startGateway } from "./gateway.js";
13
13
  import { qqbotOnboardingAdapter } from "./onboarding.js";
@@ -50,8 +50,49 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
50
50
  blockStreaming: true,
51
51
  },
52
52
  reload: { configPrefixes: ["channels.qqbot"] },
53
+
54
+ // ============ 群消息策略适配器 ============
55
+ groups: {
56
+ /** 是否需要 @机器人才响应 */
57
+ resolveRequireMention: ({ cfg, accountId, groupId }) => {
58
+ if (!groupId) return undefined;
59
+ return resolveRequireMention(cfg, groupId, accountId ?? undefined);
60
+ },
61
+
62
+ /** 群聊工具范围 */
63
+ resolveToolPolicy: ({ cfg, accountId, groupId }) => {
64
+ if (!groupId) return undefined;
65
+ const policy = resolveToolPolicy(cfg, groupId, accountId ?? undefined);
66
+ // 将简单字符串策略映射为 GroupToolPolicyConfig 对象
67
+ if (policy === "full") return undefined; // full = 默认不限制
68
+ if (policy === "none") return { allow: [], deny: ["*"] };
69
+ // restricted: 默认空 allow(框架会使用内置 restricted 列表)
70
+ return { allow: [] };
71
+ },
72
+
73
+ /** QQ Bot 平台特有的群聊行为提示 */
74
+ resolveGroupIntroHint: ({ cfg, accountId, groupId }) => {
75
+ if (!groupId) return undefined;
76
+ const groupCfg = resolveGroupConfig(cfg, groupId, accountId ?? undefined);
77
+ const hints: string[] = [];
78
+ if (groupCfg.name) {
79
+ hints.push(`当前群: ${groupCfg.name}`);
80
+ }
81
+ // bot 互聊防护、@状态行为指引在 gateway.ts 动态注入
82
+ return hints.join(" ") || undefined;
83
+ },
84
+ },
85
+
86
+ // ============ @mention 检测与清理 ============
87
+ mentions: {
88
+ /** 清理 @mention 文本(SDK ChannelMentionAdapter 接口) */
89
+ stripMentions: ({ text, ctx }) => {
90
+ const mentions = (ctx as any)?.mentions as Array<{ member_openid?: string; id?: string; user_openid?: string; is_you?: boolean; nickname?: string; username?: string }> | undefined;
91
+ return stripMentionText(text, mentions);
92
+ },
93
+ },
53
94
  // CLI onboarding wizard
54
- // @ts-expect-error onboarding removed from ChannelPlugin type in 2026.3.23 but still supported at runtime
95
+ // @ts-ignore onboarding removed from ChannelPlugin type in 2026.3.23 but still supported at runtime
55
96
  onboarding: qqbotOnboardingAdapter,
56
97
 
57
98
  config: {
@@ -392,3 +433,45 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
392
433
  }),
393
434
  },
394
435
  };
436
+
437
+ // ============ 独立的 mention 工具函数(供 gateway.ts 等直接调用) ============
438
+
439
+ /** 清理 @mention:替换 <@openid> 为 @用户名,去除 @机器人自身 */
440
+ export function stripMentionText(text: string, mentions?: Array<{ member_openid?: string; id?: string; user_openid?: string; is_you?: boolean; nickname?: string; username?: string }>): string {
441
+ if (!text || !mentions?.length) return text;
442
+ let cleaned = text;
443
+ for (const m of mentions) {
444
+ const openid = m.member_openid ?? m.id ?? m.user_openid;
445
+ if (!openid) continue;
446
+ if (m.is_you) {
447
+ cleaned = cleaned.replace(new RegExp(`<@!?${openid}>`, "g"), "").trim();
448
+ } else {
449
+ const displayName = m.nickname ?? m.username;
450
+ if (displayName) {
451
+ cleaned = cleaned.replace(new RegExp(`<@!?${openid}>`, "g"), `@${displayName}`);
452
+ }
453
+ }
454
+ }
455
+ return cleaned;
456
+ }
457
+
458
+ /** 检测消息是否 @了机器人(mentions > eventType > mentionPatterns) */
459
+ export function detectWasMentioned({ eventType, mentions, content, mentionPatterns }: {
460
+ eventType?: string;
461
+ mentions?: Array<{ is_you?: boolean }>;
462
+ content?: string;
463
+ mentionPatterns?: string[];
464
+ }): boolean {
465
+ if (mentions?.some((m) => m.is_you)) return true;
466
+ if (eventType === "GROUP_AT_MESSAGE_CREATE") return true;
467
+ if (mentionPatterns?.length && content) {
468
+ for (const pattern of mentionPatterns) {
469
+ try {
470
+ if (new RegExp(pattern, "i").test(content)) return true;
471
+ } catch {
472
+ // 无效正则,跳过
473
+ }
474
+ }
475
+ }
476
+ return false;
477
+ }
package/src/config.ts CHANGED
@@ -1,12 +1,179 @@
1
- import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
2
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { ResolvedQQBotAccount, QQBotAccountConfig, ToolPolicy, GroupConfig } from "./types.js";
2
+ import type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk";
3
+
4
+ // ============ Agent-aware mentionPatterns 解析 ============
5
+
6
+ type AgentEntry = { id?: string; groupChat?: { mentionPatterns?: string[]; historyLimit?: number } };
7
+
8
+ /**
9
+ * 解析 mentionPatterns(agent → global → 空数组)
10
+ *
11
+ * 优先级:
12
+ * 1. agents.list[agentId].groupChat.mentionPatterns
13
+ * 2. messages.groupChat.mentionPatterns
14
+ * 3. []
15
+ */
16
+ export function resolveMentionPatterns(cfg: OpenClawConfig, agentId?: string): string[] {
17
+ // 1. agent 级别
18
+ if (agentId) {
19
+ const agents = (cfg as Record<string, unknown>).agents as { list?: AgentEntry[] } | undefined;
20
+ const entry = agents?.list?.find((a) => a.id?.trim().toLowerCase() === agentId.trim().toLowerCase());
21
+ const agentGroupChat = entry?.groupChat;
22
+ if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
23
+ return agentGroupChat.mentionPatterns ?? [];
24
+ }
25
+ }
26
+ // 2. 全局级别
27
+ const globalGroupChat = (cfg as any)?.messages?.groupChat;
28
+ if (globalGroupChat && typeof globalGroupChat === "object" && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
29
+ return (globalGroupChat as { mentionPatterns?: string[] }).mentionPatterns ?? [];
30
+ }
31
+ // 3. 空数组
32
+ return [];
33
+ }
3
34
 
4
35
  export const DEFAULT_ACCOUNT_ID = "default";
5
36
 
37
+ // 内联 evaluateMatchedGroupAccessForPolicy(openclaw dist 尚未导出,本地实现)
38
+
39
+ type MatchedGroupAccessReason = "allowed" | "disabled" | "missing_match_input" | "empty_allowlist" | "not_allowlisted";
40
+
41
+ interface MatchedGroupAccessDecision {
42
+ allowed: boolean;
43
+ groupPolicy: GroupPolicy;
44
+ reason: MatchedGroupAccessReason;
45
+ }
46
+
47
+ function evaluateMatchedGroupAccessForPolicy(params: {
48
+ groupPolicy: GroupPolicy;
49
+ allowlistConfigured: boolean;
50
+ allowlistMatched: boolean;
51
+ requireMatchInput?: boolean;
52
+ hasMatchInput?: boolean;
53
+ }): MatchedGroupAccessDecision {
54
+ if (params.groupPolicy === "disabled") {
55
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "disabled" };
56
+ }
57
+ if (params.groupPolicy === "allowlist") {
58
+ if (params.requireMatchInput && !params.hasMatchInput) {
59
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "missing_match_input" };
60
+ }
61
+ if (!params.allowlistConfigured) {
62
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "empty_allowlist" };
63
+ }
64
+ if (!params.allowlistMatched) {
65
+ return { allowed: false, groupPolicy: params.groupPolicy, reason: "not_allowlisted" };
66
+ }
67
+ }
68
+ return { allowed: true, groupPolicy: params.groupPolicy, reason: "allowed" };
69
+ }
70
+
6
71
  interface QQBotChannelConfig extends QQBotAccountConfig {
7
72
  accounts?: Record<string, QQBotAccountConfig>;
8
73
  }
9
74
 
75
+ // ============ 群消息策略 ============
76
+
77
+ const DEFAULT_GROUP_POLICY: GroupPolicy = "open";
78
+
79
+ /** 群历史缓存条数默认值 */
80
+ const DEFAULT_GROUP_HISTORY_LIMIT = 50;
81
+
82
+ const DEFAULT_GROUP_CONFIG: Omit<Required<GroupConfig>, "prompt"> = {
83
+ requireMention: true,
84
+ ignoreOtherMentions: false,
85
+ toolPolicy: "restricted",
86
+ name: "",
87
+ historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
88
+ };
89
+
90
+ /** 默认群消息行为 PE(可通过配置覆盖) */
91
+ const DEFAULT_GROUP_PROMPT = [
92
+ "若发送者为机器人,仅在对方明确@你提问或请求协助具体任务时,以简洁明了的内容回复,",
93
+ "避免与其他机器人产生抢答或多轮无意义对话。",
94
+ "在群聊中优先让人类用户的消息得到响应,机器人之间保持协作而非竞争,确保对话有序不刷屏。",
95
+ ].join("");
96
+
97
+ /** 解析群消息策略 */
98
+ export function resolveGroupPolicy(cfg: OpenClawConfig, accountId?: string): GroupPolicy {
99
+ const account = resolveQQBotAccount(cfg, accountId);
100
+ return account.config?.groupPolicy ?? DEFAULT_GROUP_POLICY;
101
+ }
102
+
103
+ /** 解析群白名单(统一转大写) */
104
+ export function resolveGroupAllowFrom(cfg: OpenClawConfig, accountId?: string): string[] {
105
+ const account = resolveQQBotAccount(cfg, accountId);
106
+ return (account.config?.groupAllowFrom ?? []).map((id) => String(id).trim().toUpperCase());
107
+ }
108
+
109
+ /** 检查指定群是否被允许(使用标准策略引擎) */
110
+ export function isGroupAllowed(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean {
111
+ const policy = resolveGroupPolicy(cfg, accountId);
112
+ const allowList = resolveGroupAllowFrom(cfg, accountId);
113
+ const allowlistConfigured = allowList.length > 0;
114
+ const allowlistMatched = allowList.some((id) => id === "*" || id === groupOpenid.toUpperCase());
115
+
116
+ return evaluateMatchedGroupAccessForPolicy({
117
+ groupPolicy: policy,
118
+ allowlistConfigured,
119
+ allowlistMatched,
120
+ }).allowed;
121
+ }
122
+
123
+ type ResolvedGroupConfig = Omit<Required<GroupConfig>, "prompt"> & Pick<GroupConfig, "prompt">;
124
+
125
+ /** 解析指定群配置(具体 groupOpenid > 通配符 "*" > 默认值) */
126
+ export function resolveGroupConfig(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): ResolvedGroupConfig {
127
+ const account = resolveQQBotAccount(cfg, accountId);
128
+ const groups = account.config?.groups ?? {};
129
+
130
+ const wildcardCfg = groups["*"] ?? {};
131
+ const specificCfg = groups[groupOpenid] ?? {};
132
+
133
+ return {
134
+ requireMention: specificCfg.requireMention ?? wildcardCfg.requireMention ?? DEFAULT_GROUP_CONFIG.requireMention,
135
+ ignoreOtherMentions: specificCfg.ignoreOtherMentions ?? wildcardCfg.ignoreOtherMentions ?? DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
136
+ toolPolicy: specificCfg.toolPolicy ?? wildcardCfg.toolPolicy ?? DEFAULT_GROUP_CONFIG.toolPolicy,
137
+ name: specificCfg.name ?? wildcardCfg.name ?? DEFAULT_GROUP_CONFIG.name,
138
+ prompt: specificCfg.prompt ?? wildcardCfg.prompt,
139
+ historyLimit: specificCfg.historyLimit ?? wildcardCfg.historyLimit ?? DEFAULT_GROUP_CONFIG.historyLimit,
140
+ };
141
+ }
142
+
143
+ /** 解析群历史消息缓存条数 */
144
+ export function resolveHistoryLimit(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): number {
145
+ return Math.max(0, resolveGroupConfig(cfg, groupOpenid, accountId).historyLimit);
146
+ }
147
+
148
+ /** 解析群行为 PE(具体群 > "*" > 默认值) */
149
+ export function resolveGroupPrompt(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): string {
150
+ const account = resolveQQBotAccount(cfg, accountId);
151
+ const groups = account.config?.groups ?? {};
152
+
153
+ return groups[groupOpenid]?.prompt ?? groups["*"]?.prompt ?? DEFAULT_GROUP_PROMPT;
154
+ }
155
+
156
+ /** 解析群是否需要 @机器人才响应 */
157
+ export function resolveRequireMention(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean {
158
+ return resolveGroupConfig(cfg, groupOpenid, accountId).requireMention;
159
+ }
160
+
161
+ /** 解析群是否忽略 @了其他人(非 bot)的消息 */
162
+ export function resolveIgnoreOtherMentions(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean {
163
+ return resolveGroupConfig(cfg, groupOpenid, accountId).ignoreOtherMentions;
164
+ }
165
+
166
+ /** 解析群工具策略 */
167
+ export function resolveToolPolicy(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): ToolPolicy {
168
+ return resolveGroupConfig(cfg, groupOpenid, accountId).toolPolicy;
169
+ }
170
+
171
+ /** 解析群名称(优先配置,fallback 为 openid 前 8 位) */
172
+ export function resolveGroupName(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): string {
173
+ const name = resolveGroupConfig(cfg, groupOpenid, accountId).name;
174
+ return name || groupOpenid.slice(0, 8);
175
+ }
176
+
10
177
  function normalizeAppId(raw: unknown): string {
11
178
  if (raw === null || raw === undefined) return "";
12
179
  return String(raw).trim();
@@ -60,7 +227,7 @@ export function resolveQQBotAccount(
60
227
  cfg: OpenClawConfig,
61
228
  accountId?: string | null
62
229
  ): ResolvedQQBotAccount {
63
- const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
230
+ const resolvedAccountId = accountId ?? resolveDefaultQQBotAccountId(cfg);
64
231
  const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
65
232
 
66
233
  // 基础配置