@tencent-connect/openclaw-qqbot 1.6.4 → 1.6.5-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -183,18 +183,11 @@ Shows framework version, plugin version, and a direct link to the official repos
183
183
  >
184
184
  > **QQBot**: 📌 Current: v1.6.3 / ✅ New version v1.6.4 available / Click button below to confirm
185
185
 
186
- Send in private chat to upgrade the plugin without server login. Supported usage:
187
-
188
- | Command | Description |
189
- |---------|-------------|
190
- | `/bot-upgrade` | Check for updates, show confirmation button |
191
- | `/bot-upgrade --latest` | Confirm upgrade to the latest version |
192
- | `/bot-upgrade --version 1.6.4` | Upgrade to a specific version |
193
- | `/bot-upgrade --force` | Force reinstall current version |
194
-
195
186
  Credentials are automatically backed up before upgrade. Version existence is verified against npm before proceeding. Auto-recovery on failure.
196
187
 
197
- <!-- TODO: add /bot-upgrade screenshot -->
188
+ > ⚠️ Hot upgrade is currently not supported on Windows. Sending `/bot-upgrade` on Windows will return a manual upgrade guide instead.
189
+
190
+ <img width="360" src="docs/images/hot-update.jpg" alt="Hot Upgrade Demo" />
198
191
 
199
192
  #### `/bot-logs` — Log Export
200
193
 
package/README.zh.md CHANGED
@@ -178,18 +178,11 @@ AI 可直接发送视频,支持本地文件和公网 URL。
178
178
  >
179
179
  > **QQBot**:📌当前版本 v1.6.3 / ✅发现新版本 v1.6.4 / 点击下方按钮确认升级
180
180
 
181
- 在私聊中发送即可完成版本升级,全程无需登录服务器。支持的用法:
182
-
183
- | 命令 | 说明 |
184
- |------|------|
185
- | `/bot-upgrade` | 检查是否有新版本,展示确认按钮 |
186
- | `/bot-upgrade --latest` | 确认升级到最新版本 |
187
- | `/bot-upgrade --version 1.6.4` | 升级到指定版本 |
188
- | `/bot-upgrade --force` | 强制重新安装当前版本 |
189
-
190
181
  升级流程自动备份凭证,升级前校验版本是否存在于 npm,升级失败自动恢复。
191
182
 
192
- <!-- TODO: 补充 /bot-upgrade 截图 -->
183
+ > ⚠️ 热更新指令暂不支持 Windows 系统,在 Windows 上发送 `/bot-upgrade` 会返回手动升级指引。
184
+
185
+ <img width="360" src="docs/images/hot-update.jpg" alt="一键热更新演示" />
193
186
 
194
187
  #### `/bot-logs` — 日志导出
195
188
 
@@ -13,15 +13,21 @@ export interface AdminResolverContext {
13
13
  error: (msg: string) => void;
14
14
  };
15
15
  }
16
- export declare function loadAdminOpenId(accountId: string): string | undefined;
17
- export declare function saveAdminOpenId(accountId: string, openid: string): void;
18
- export declare function loadUpgradeGreetingTargetOpenId(accountId: string, appId: string): string | undefined;
16
+ /**
17
+ * 读取 admin openid(按 accountId + appId 区分)
18
+ * 兼容策略:新路径优先 fallback 旧路径 自动迁移
19
+ */
20
+ export declare function loadAdminOpenId(accountId: string, appId: string): string | undefined;
21
+ export declare function saveAdminOpenId(accountId: string, appId: string, openid: string): void;
22
+ export declare function loadUpgradeGreetingTargetOpenId(accountId: string, appId: string, log?: {
23
+ info: (msg: string) => void;
24
+ }): string | undefined;
19
25
  export declare function clearUpgradeGreetingTargetOpenId(accountId: string, appId: string): void;
20
26
  /**
21
27
  * 解析管理员 openid:
22
- * 1. 优先读持久化文件(稳定)
28
+ * 1. 优先读持久化文件(按 accountId + appId 区分)
23
29
  * 2. fallback 取第一个私聊用户,并写入文件锁定
24
30
  */
25
- export declare function resolveAdminOpenId(ctx: Pick<AdminResolverContext, "accountId" | "log">): string | undefined;
26
- /** 异步发送启动问候语(仅发给管理员) */
31
+ export declare function resolveAdminOpenId(ctx: Pick<AdminResolverContext, "accountId" | "appId" | "log">): string | undefined;
32
+ /** 异步发送启动问候语(优先发给升级触发者,fallback 发给管理员) */
27
33
  export declare function sendStartupGreetings(ctx: AdminResolverContext, trigger: "READY" | "RESUMED"): void;
@@ -11,49 +11,86 @@ import { listKnownUsers } from "./known-users.js";
11
11
  import { getAccessToken, sendProactiveC2CMessage } from "./api.js";
12
12
  import { getStartupGreetingPlan, markStartupGreetingSent, markStartupGreetingFailed } from "./startup-greeting.js";
13
13
  // ---- 文件路径 ----
14
- function getAdminMarkerFile(accountId) {
14
+ function safeName(id) {
15
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_");
16
+ }
17
+ /** 新版 admin 文件路径(按 accountId + appId 区分) */
18
+ function getAdminMarkerFile(accountId, appId) {
19
+ return path.join(getQQBotDataDir("data"), `admin-${safeName(accountId)}-${safeName(appId)}.json`);
20
+ }
21
+ /** 旧版 admin 文件路径(仅按 accountId 区分,用于迁移兼容) */
22
+ function getLegacyAdminMarkerFile(accountId) {
15
23
  return path.join(getQQBotDataDir("data"), `admin-${accountId}.json`);
16
24
  }
17
25
  function getUpgradeGreetingTargetFile(accountId, appId) {
18
- const safeAccountId = accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
19
- const safeAppId = appId.replace(/[^a-zA-Z0-9._-]/g, "_");
20
- return path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeAccountId}-${safeAppId}.json`);
26
+ return path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeName(accountId)}-${safeName(appId)}.json`);
21
27
  }
22
28
  // ---- 管理员 openid 持久化 ----
23
- export function loadAdminOpenId(accountId) {
29
+ /**
30
+ * 读取 admin openid(按 accountId + appId 区分)
31
+ * 兼容策略:新路径优先 → fallback 旧路径 → 自动迁移
32
+ */
33
+ export function loadAdminOpenId(accountId, appId) {
24
34
  try {
25
- const file = getAdminMarkerFile(accountId);
26
- if (fs.existsSync(file)) {
27
- const data = JSON.parse(fs.readFileSync(file, "utf8"));
35
+ // 1. 先尝试新版路径
36
+ const newFile = getAdminMarkerFile(accountId, appId);
37
+ if (fs.existsSync(newFile)) {
38
+ const data = JSON.parse(fs.readFileSync(newFile, "utf8"));
28
39
  if (data.openid)
29
40
  return data.openid;
30
41
  }
42
+ // 2. fallback 旧版路径(仅按 accountId)
43
+ const legacyFile = getLegacyAdminMarkerFile(accountId);
44
+ if (fs.existsSync(legacyFile)) {
45
+ const data = JSON.parse(fs.readFileSync(legacyFile, "utf8"));
46
+ if (data.openid) {
47
+ // 自动迁移:写到新路径,删除旧文件
48
+ saveAdminOpenId(accountId, appId, data.openid);
49
+ try {
50
+ fs.unlinkSync(legacyFile);
51
+ }
52
+ catch { /* ignore */ }
53
+ return data.openid;
54
+ }
55
+ }
31
56
  }
32
57
  catch { /* 文件损坏视为无 */ }
33
58
  return undefined;
34
59
  }
35
- export function saveAdminOpenId(accountId, openid) {
60
+ export function saveAdminOpenId(accountId, appId, openid) {
36
61
  try {
37
- fs.writeFileSync(getAdminMarkerFile(accountId), JSON.stringify({ openid, savedAt: new Date().toISOString() }));
62
+ fs.writeFileSync(getAdminMarkerFile(accountId, appId), JSON.stringify({ accountId, appId, openid, savedAt: new Date().toISOString() }));
38
63
  }
39
64
  catch { /* ignore */ }
40
65
  }
41
66
  // ---- 升级问候目标 ----
42
- export function loadUpgradeGreetingTargetOpenId(accountId, appId) {
67
+ export function loadUpgradeGreetingTargetOpenId(accountId, appId, log) {
43
68
  try {
44
69
  const file = getUpgradeGreetingTargetFile(accountId, appId);
45
70
  if (fs.existsSync(file)) {
46
71
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
47
- if (!data.openid)
72
+ if (!data.openid) {
73
+ log?.info(`[qqbot:${accountId}] upgrade-greeting-target file found but openid is empty`);
48
74
  return undefined;
49
- if (data.appId && data.appId !== appId)
75
+ }
76
+ if (data.appId && data.appId !== appId) {
77
+ log?.info(`[qqbot:${accountId}] upgrade-greeting-target appId mismatch: file=${data.appId}, current=${appId}`);
50
78
  return undefined;
51
- if (data.accountId && data.accountId !== accountId)
79
+ }
80
+ if (data.accountId && data.accountId !== accountId) {
81
+ log?.info(`[qqbot:${accountId}] upgrade-greeting-target accountId mismatch: file=${data.accountId}, current=${accountId}`);
52
82
  return undefined;
83
+ }
84
+ log?.info(`[qqbot:${accountId}] upgrade-greeting-target loaded: openid=${data.openid}`);
53
85
  return data.openid;
54
86
  }
87
+ else {
88
+ log?.info(`[qqbot:${accountId}] upgrade-greeting-target file not found: ${file}`);
89
+ }
90
+ }
91
+ catch (err) {
92
+ log?.info(`[qqbot:${accountId}] upgrade-greeting-target file read error: ${err}`);
55
93
  }
56
- catch { /* 文件损坏视为无 */ }
57
94
  return undefined;
58
95
  }
59
96
  export function clearUpgradeGreetingTargetOpenId(accountId, appId) {
@@ -68,33 +105,33 @@ export function clearUpgradeGreetingTargetOpenId(accountId, appId) {
68
105
  // ---- 解析管理员 ----
69
106
  /**
70
107
  * 解析管理员 openid:
71
- * 1. 优先读持久化文件(稳定)
108
+ * 1. 优先读持久化文件(按 accountId + appId 区分)
72
109
  * 2. fallback 取第一个私聊用户,并写入文件锁定
73
110
  */
74
111
  export function resolveAdminOpenId(ctx) {
75
- const saved = loadAdminOpenId(ctx.accountId);
112
+ const saved = loadAdminOpenId(ctx.accountId, ctx.appId);
76
113
  if (saved)
77
114
  return saved;
78
115
  const first = listKnownUsers({ accountId: ctx.accountId, type: "c2c", sortBy: "firstSeenAt", sortOrder: "asc", limit: 1 })[0]?.openid;
79
116
  if (first) {
80
- saveAdminOpenId(ctx.accountId, first);
117
+ saveAdminOpenId(ctx.accountId, ctx.appId, first);
81
118
  ctx.log?.info(`[qqbot:${ctx.accountId}] Auto-detected admin openid: ${first} (persisted)`);
82
119
  }
83
120
  return first;
84
121
  }
85
122
  // ---- 启动问候语 ----
86
- /** 异步发送启动问候语(仅发给管理员) */
123
+ /** 异步发送启动问候语(优先发给升级触发者,fallback 发给管理员) */
87
124
  export function sendStartupGreetings(ctx, trigger) {
88
125
  (async () => {
89
- const plan = getStartupGreetingPlan();
126
+ const plan = getStartupGreetingPlan(ctx.accountId, ctx.appId);
90
127
  if (!plan.shouldSend || !plan.greeting) {
91
128
  ctx.log?.info(`[qqbot:${ctx.accountId}] Skipping startup greeting (${plan.reason ?? "debounced"}, trigger=${trigger})`);
92
129
  return;
93
130
  }
94
- const upgradeTargetOpenId = loadUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId);
131
+ const upgradeTargetOpenId = loadUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId, ctx.log);
95
132
  const targetOpenId = upgradeTargetOpenId || resolveAdminOpenId(ctx);
96
133
  if (!targetOpenId) {
97
- markStartupGreetingFailed(plan.version, "no-admin");
134
+ markStartupGreetingFailed(ctx.accountId, ctx.appId, plan.version, "no-admin");
98
135
  ctx.log?.info(`[qqbot:${ctx.accountId}] Skipping startup greeting (no admin or known user)`);
99
136
  return;
100
137
  }
@@ -107,7 +144,7 @@ export function sendStartupGreetings(ctx, trigger) {
107
144
  sendProactiveC2CMessage(token, targetOpenId, plan.greeting),
108
145
  new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
109
146
  ]);
110
- markStartupGreetingSent(plan.version);
147
+ markStartupGreetingSent(ctx.accountId, ctx.appId, plan.version);
111
148
  if (upgradeTargetOpenId) {
112
149
  clearUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId);
113
150
  }
@@ -115,7 +152,7 @@ export function sendStartupGreetings(ctx, trigger) {
115
152
  }
116
153
  catch (err) {
117
154
  const message = err instanceof Error ? err.message : String(err);
118
- markStartupGreetingFailed(plan.version, message);
155
+ markStartupGreetingFailed(ctx.accountId, ctx.appId, plan.version, message);
119
156
  ctx.log?.error(`[qqbot:${ctx.accountId}] Failed to send startup greeting: ${message}`);
120
157
  }
121
158
  })();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 出站消息合并回复(Deliver Debounce)模块
3
+ *
4
+ * 解决的问题:
5
+ * 当 openclaw 框架层的 embedded agent 超时或快速连续产生多次 deliver 时,
6
+ * 用户会在短时间内收到大量碎片消息(消息轰炸)。
7
+ *
8
+ * 解决方案:
9
+ * 在 deliver 回调和实际发送之间加入 debounce 层。
10
+ * 短时间内(windowMs)连续到达的多条纯文本 deliver 会被合并为一条消息发送。
11
+ * 含媒体的 deliver 会立即 flush 已缓冲的文本并正常处理媒体。
12
+ */
13
+ import type { DeliverDebounceConfig } from "./types.js";
14
+ export interface DeliverPayload {
15
+ text?: string;
16
+ mediaUrls?: string[];
17
+ mediaUrl?: string;
18
+ }
19
+ export interface DeliverInfo {
20
+ kind: string;
21
+ }
22
+ /** 实际执行发送的回调 */
23
+ export type DeliverExecutor = (payload: DeliverPayload, info: DeliverInfo) => Promise<void>;
24
+ export declare class DeliverDebouncer {
25
+ private readonly windowMs;
26
+ private readonly maxWaitMs;
27
+ private readonly separator;
28
+ private readonly executor;
29
+ private readonly log?;
30
+ private readonly prefix;
31
+ /** 缓冲中的文本片段 */
32
+ private bufferedTexts;
33
+ /** 缓冲中最后一次 deliver 的 info(用于 flush 时传递 kind) */
34
+ private lastInfo;
35
+ /** 缓冲中最后一次 deliver 的 payload(非文本字段,如 mediaUrls) */
36
+ private lastPayload;
37
+ /** debounce 定时器 */
38
+ private debounceTimer;
39
+ /** 最大等待定时器(从第一条 deliver 开始计算) */
40
+ private maxWaitTimer;
41
+ /** 是否正在 flush */
42
+ private flushing;
43
+ /** 已销毁标记 */
44
+ private disposed;
45
+ constructor(config: DeliverDebounceConfig | undefined, executor: DeliverExecutor, log?: {
46
+ info: (msg: string) => void;
47
+ error: (msg: string) => void;
48
+ }, prefix?: string);
49
+ /**
50
+ * 接收一次 deliver 调用。
51
+ * - 纯文本 deliver → 缓冲并设置 debounce 定时器
52
+ * - 含媒体 deliver → 先 flush 已缓冲文本,再直接执行当前 deliver
53
+ */
54
+ deliver(payload: DeliverPayload, info: DeliverInfo): Promise<void>;
55
+ /**
56
+ * 将缓冲中的文本合并为一条消息发送
57
+ */
58
+ flush(): Promise<void>;
59
+ /**
60
+ * 销毁:flush 剩余缓冲并清除定时器
61
+ */
62
+ dispose(): Promise<void>;
63
+ /** 当前是否有缓冲中的文本 */
64
+ get hasPending(): boolean;
65
+ /** 缓冲中的文本数量 */
66
+ get pendingCount(): number;
67
+ }
68
+ /**
69
+ * 根据配置创建 debouncer 或返回 null(禁用时)
70
+ */
71
+ export declare function createDeliverDebouncer(config: DeliverDebounceConfig | undefined, executor: DeliverExecutor, log?: {
72
+ info: (msg: string) => void;
73
+ error: (msg: string) => void;
74
+ }, prefix?: string): DeliverDebouncer | null;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 出站消息合并回复(Deliver Debounce)模块
3
+ *
4
+ * 解决的问题:
5
+ * 当 openclaw 框架层的 embedded agent 超时或快速连续产生多次 deliver 时,
6
+ * 用户会在短时间内收到大量碎片消息(消息轰炸)。
7
+ *
8
+ * 解决方案:
9
+ * 在 deliver 回调和实际发送之间加入 debounce 层。
10
+ * 短时间内(windowMs)连续到达的多条纯文本 deliver 会被合并为一条消息发送。
11
+ * 含媒体的 deliver 会立即 flush 已缓冲的文本并正常处理媒体。
12
+ */
13
+ // ============ 默认值 ============
14
+ const DEFAULT_WINDOW_MS = 1500;
15
+ const DEFAULT_MAX_WAIT_MS = 8000;
16
+ const DEFAULT_SEPARATOR = "\n\n---\n\n";
17
+ // ============ DeliverDebouncer 类 ============
18
+ export class DeliverDebouncer {
19
+ windowMs;
20
+ maxWaitMs;
21
+ separator;
22
+ executor;
23
+ log;
24
+ prefix;
25
+ /** 缓冲中的文本片段 */
26
+ bufferedTexts = [];
27
+ /** 缓冲中最后一次 deliver 的 info(用于 flush 时传递 kind) */
28
+ lastInfo = null;
29
+ /** 缓冲中最后一次 deliver 的 payload(非文本字段,如 mediaUrls) */
30
+ lastPayload = null;
31
+ /** debounce 定时器 */
32
+ debounceTimer = null;
33
+ /** 最大等待定时器(从第一条 deliver 开始计算) */
34
+ maxWaitTimer = null;
35
+ /** 是否正在 flush */
36
+ flushing = false;
37
+ /** 已销毁标记 */
38
+ disposed = false;
39
+ constructor(config, executor, log, prefix = "[debounce]") {
40
+ this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
41
+ this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
42
+ this.separator = config?.separator ?? DEFAULT_SEPARATOR;
43
+ this.executor = executor;
44
+ this.log = log;
45
+ this.prefix = prefix;
46
+ }
47
+ /**
48
+ * 接收一次 deliver 调用。
49
+ * - 纯文本 deliver → 缓冲并设置 debounce 定时器
50
+ * - 含媒体 deliver → 先 flush 已缓冲文本,再直接执行当前 deliver
51
+ */
52
+ async deliver(payload, info) {
53
+ if (this.disposed)
54
+ return;
55
+ const hasMedia = Boolean((payload.mediaUrls && payload.mediaUrls.length > 0) || payload.mediaUrl);
56
+ const text = (payload.text ?? "").trim();
57
+ // 含媒体的 deliver:立即 flush 缓冲 + 直接执行
58
+ if (hasMedia) {
59
+ this.log?.info(`${this.prefix} Media deliver detected, flushing ${this.bufferedTexts.length} buffered text(s) first`);
60
+ await this.flush();
61
+ await this.executor(payload, info);
62
+ return;
63
+ }
64
+ // 空文本 deliver:直接透传(不缓冲)
65
+ if (!text) {
66
+ await this.executor(payload, info);
67
+ return;
68
+ }
69
+ // 纯文本 deliver:缓冲
70
+ this.bufferedTexts.push(text);
71
+ this.lastInfo = info;
72
+ this.lastPayload = payload;
73
+ this.log?.info(`${this.prefix} Buffered text #${this.bufferedTexts.length} (${text.length} chars), window=${this.windowMs}ms`);
74
+ // 重置 debounce 定时器
75
+ if (this.debounceTimer) {
76
+ clearTimeout(this.debounceTimer);
77
+ }
78
+ this.debounceTimer = setTimeout(() => {
79
+ this.flush().catch((err) => {
80
+ this.log?.error(`${this.prefix} Flush error (debounce timer): ${err}`);
81
+ });
82
+ }, this.windowMs);
83
+ // 首次缓冲时启动最大等待定时器
84
+ if (this.bufferedTexts.length === 1) {
85
+ if (this.maxWaitTimer) {
86
+ clearTimeout(this.maxWaitTimer);
87
+ }
88
+ this.maxWaitTimer = setTimeout(() => {
89
+ this.log?.info(`${this.prefix} Max wait (${this.maxWaitMs}ms) reached, force flushing`);
90
+ this.flush().catch((err) => {
91
+ this.log?.error(`${this.prefix} Flush error (max wait timer): ${err}`);
92
+ });
93
+ }, this.maxWaitMs);
94
+ }
95
+ }
96
+ /**
97
+ * 将缓冲中的文本合并为一条消息发送
98
+ */
99
+ async flush() {
100
+ if (this.flushing || this.bufferedTexts.length === 0)
101
+ return;
102
+ this.flushing = true;
103
+ // 清除定时器
104
+ if (this.debounceTimer) {
105
+ clearTimeout(this.debounceTimer);
106
+ this.debounceTimer = null;
107
+ }
108
+ if (this.maxWaitTimer) {
109
+ clearTimeout(this.maxWaitTimer);
110
+ this.maxWaitTimer = null;
111
+ }
112
+ // 取出缓冲
113
+ const texts = this.bufferedTexts;
114
+ const info = this.lastInfo;
115
+ const lastPayload = this.lastPayload;
116
+ this.bufferedTexts = [];
117
+ this.lastInfo = null;
118
+ this.lastPayload = null;
119
+ try {
120
+ if (texts.length === 1) {
121
+ // 只有一条,直接透传原始 payload
122
+ this.log?.info(`${this.prefix} Flushing single buffered text (${texts[0].length} chars)`);
123
+ await this.executor({ ...lastPayload, text: texts[0] }, info);
124
+ }
125
+ else {
126
+ // 多条合并
127
+ const merged = texts.join(this.separator);
128
+ this.log?.info(`${this.prefix} Merged ${texts.length} buffered texts into one (${merged.length} chars)`);
129
+ await this.executor({ ...lastPayload, text: merged }, info);
130
+ }
131
+ }
132
+ finally {
133
+ this.flushing = false;
134
+ }
135
+ }
136
+ /**
137
+ * 销毁:flush 剩余缓冲并清除定时器
138
+ */
139
+ async dispose() {
140
+ this.disposed = true;
141
+ if (this.debounceTimer) {
142
+ clearTimeout(this.debounceTimer);
143
+ this.debounceTimer = null;
144
+ }
145
+ if (this.maxWaitTimer) {
146
+ clearTimeout(this.maxWaitTimer);
147
+ this.maxWaitTimer = null;
148
+ }
149
+ // flush 剩余
150
+ if (this.bufferedTexts.length > 0) {
151
+ this.flushing = false; // 确保 flush 能执行
152
+ await this.flush();
153
+ }
154
+ }
155
+ /** 当前是否有缓冲中的文本 */
156
+ get hasPending() {
157
+ return this.bufferedTexts.length > 0;
158
+ }
159
+ /** 缓冲中的文本数量 */
160
+ get pendingCount() {
161
+ return this.bufferedTexts.length;
162
+ }
163
+ }
164
+ // ============ 工厂函数 ============
165
+ /**
166
+ * 根据配置创建 debouncer 或返回 null(禁用时)
167
+ */
168
+ export function createDeliverDebouncer(config, executor, log, prefix) {
169
+ // 未配置时默认启用
170
+ if (config?.enabled === false) {
171
+ return null;
172
+ }
173
+ return new DeliverDebouncer(config, executor, log, prefix);
174
+ }