@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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 请求级上下文(基于 AsyncLocalStorage)
3
+ *
4
+ * 解决并发消息下工具获取当前会话信息的竞态问题。
5
+ * gateway 在处理每条入站消息时通过 runWithRequestContext() 建立作用域,
6
+ * 作用域内的所有异步代码(包括 AI agent 调用、tool execute)
7
+ * 都能通过 getRequestContext() 安全地拿到当前请求的上下文。
8
+ */
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+
11
+ export interface RequestContext {
12
+ /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
13
+ target: string;
14
+ }
15
+
16
+ const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
17
+
18
+ /**
19
+ * 在请求级作用域中执行回调。
20
+ * 作用域内所有同步/异步代码都能通过 getRequestContext() 获取上下文。
21
+ */
22
+ export function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T {
23
+ return asyncLocalStorage.run(ctx, fn);
24
+ }
25
+
26
+ /**
27
+ * 获取当前请求的上下文,不存在时返回 undefined。
28
+ */
29
+ export function getRequestContext(): RequestContext | undefined {
30
+ return asyncLocalStorage.getStore();
31
+ }
32
+
33
+ /**
34
+ * 获取当前请求的投递目标地址。
35
+ * 便捷方法,等价于 getRequestContext()?.target。
36
+ */
37
+ export function getRequestTarget(): string | undefined {
38
+ return asyncLocalStorage.getStore()?.target;
39
+ }
@@ -7,9 +7,20 @@ import path from "node:path";
7
7
  import { getQQBotDataDir } from "./utils/platform.js";
8
8
  import { getPluginVersion } from "./slash-commands.js";
9
9
 
10
- const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
11
10
  const STARTUP_GREETING_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
12
11
 
12
+ function safeName(id: string): string {
13
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_");
14
+ }
15
+
16
+ /** 按 accountId+appId 区分的 marker 文件路径 */
17
+ function getMarkerFile(accountId: string, appId: string): string {
18
+ return path.join(getQQBotDataDir("data"), `startup-marker-${safeName(accountId)}-${safeName(appId)}.json`);
19
+ }
20
+
21
+ /** 旧版全局 marker 路径(兼容迁移) */
22
+ const LEGACY_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
23
+
13
24
  export function getFirstLaunchGreetingText(): string {
14
25
  return `Haha,我的'灵魂'已上线,随时等你吩咐。`;
15
26
  }
@@ -27,21 +38,32 @@ export type StartupMarkerData = {
27
38
  lastFailureVersion?: string;
28
39
  };
29
40
 
30
- export function readStartupMarker(): StartupMarkerData {
41
+ export function readStartupMarker(accountId: string, appId: string): StartupMarkerData {
31
42
  try {
32
- if (fs.existsSync(STARTUP_MARKER_FILE)) {
33
- const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8")) as StartupMarkerData;
43
+ // 1. 新版 per-bot 路径优先
44
+ const file = getMarkerFile(accountId, appId);
45
+ if (fs.existsSync(file)) {
46
+ const data = JSON.parse(fs.readFileSync(file, "utf8")) as StartupMarkerData;
34
47
  return data || {};
35
48
  }
49
+ // 2. fallback 旧版全局 marker(兼容迁移)
50
+ if (fs.existsSync(LEGACY_MARKER_FILE)) {
51
+ const data = JSON.parse(fs.readFileSync(LEGACY_MARKER_FILE, "utf8")) as StartupMarkerData;
52
+ if (data) {
53
+ // 自动迁移:写到新路径
54
+ writeStartupMarker(accountId, appId, data);
55
+ return data;
56
+ }
57
+ }
36
58
  } catch {
37
59
  // 文件损坏或不存在,视为无 marker
38
60
  }
39
61
  return {};
40
62
  }
41
63
 
42
- export function writeStartupMarker(data: StartupMarkerData): void {
64
+ export function writeStartupMarker(accountId: string, appId: string, data: StartupMarkerData): void {
43
65
  try {
44
- fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify(data) + "\n");
66
+ fs.writeFileSync(getMarkerFile(accountId, appId), JSON.stringify(data) + "\n");
45
67
  } catch {
46
68
  // ignore
47
69
  }
@@ -54,9 +76,9 @@ export function writeStartupMarker(data: StartupMarkerData): void {
54
76
  * - 同版本 → 不发送
55
77
  * - 同版本近期失败 → 冷却期内不重试
56
78
  */
57
- export function getStartupGreetingPlan(): { shouldSend: boolean; greeting?: string; version: string; reason?: string } {
79
+ export function getStartupGreetingPlan(accountId: string, appId: string): { shouldSend: boolean; greeting?: string; version: string; reason?: string } {
58
80
  const currentVersion = getPluginVersion();
59
- const marker = readStartupMarker();
81
+ const marker = readStartupMarker(accountId, appId);
60
82
 
61
83
  if (marker.version === currentVersion) {
62
84
  return { shouldSend: false, version: currentVersion, reason: "same-version" };
@@ -77,19 +99,19 @@ export function getStartupGreetingPlan(): { shouldSend: boolean; greeting?: stri
77
99
  return { shouldSend: true, greeting, version: currentVersion };
78
100
  }
79
101
 
80
- export function markStartupGreetingSent(version: string): void {
81
- writeStartupMarker({
102
+ export function markStartupGreetingSent(accountId: string, appId: string, version: string): void {
103
+ writeStartupMarker(accountId, appId, {
82
104
  version,
83
105
  startedAt: new Date().toISOString(),
84
106
  greetedAt: new Date().toISOString(),
85
107
  });
86
108
  }
87
109
 
88
- export function markStartupGreetingFailed(version: string, reason: string): void {
89
- const marker = readStartupMarker();
110
+ export function markStartupGreetingFailed(accountId: string, appId: string, version: string, reason: string): void {
111
+ const marker = readStartupMarker(accountId, appId);
90
112
  // 同版本已有失败记录时,不覆盖 lastFailureAt,避免冷却期被无限续期
91
113
  const shouldPreserveTimestamp = marker.lastFailureVersion === version && marker.lastFailureAt;
92
- writeStartupMarker({
114
+ writeStartupMarker(accountId, appId, {
93
115
  ...marker,
94
116
  lastFailureVersion: version,
95
117
  lastFailureAt: shouldPreserveTimestamp ? marker.lastFailureAt! : new Date().toISOString(),
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { getRequestTarget } from "../request-context.js";
2
3
 
3
4
  // ========== 类型定义 ==========
4
5
 
@@ -6,7 +7,10 @@ interface RemindParams {
6
7
  action: "add" | "list" | "remove";
7
8
  /** 提醒内容(action=add 时必填) */
8
9
  content?: string;
9
- /** 目标地址,格式为上下文中的 to= 值(action=add 时必填) */
10
+ /**
11
+ * 投递目标地址(可选,系统会自动从当前会话上下文获取)。
12
+ * 仅在需要手动指定时填写。
13
+ */
10
14
  to?: string;
11
15
  /**
12
16
  * 时间描述(action=add 时必填)
@@ -40,8 +44,8 @@ const RemindSchema = {
40
44
  to: {
41
45
  type: "string",
42
46
  description:
43
- "投递目标地址,取自上下文中 [QQBot] to= 的值。" +
44
- "私聊格式:user_openid,群聊格式:group:group_openid。action=add 时必填。",
47
+ "投递目标地址(可选)。系统会自动从当前会话获取,通常无需手动填写。" +
48
+ "私聊格式:qqbot:c2c:user_openid,群聊格式:qqbot:group:group_openid。",
45
49
  },
46
50
  time: {
47
51
  type: "string",
@@ -130,9 +134,8 @@ function generateJobName(content: string): string {
130
134
  /**
131
135
  * 构建一次性提醒的 cron 工具参数
132
136
  */
133
- function buildOnceJob(params: RemindParams, delayMs: number) {
137
+ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
134
138
  const atMs = Date.now() + delayMs;
135
- const to = params.to!;
136
139
  const content = params.content!;
137
140
  const name = params.name || generateJobName(content);
138
141
 
@@ -158,8 +161,7 @@ function buildOnceJob(params: RemindParams, delayMs: number) {
158
161
  /**
159
162
  * 构建周期提醒的 cron 工具参数
160
163
  */
161
- function buildCronJob(params: RemindParams) {
162
- const to = params.to!;
164
+ function buildCronJob(params: RemindParams, to: string) {
163
165
  const content = params.content!;
164
166
  const name = params.name || generateJobName(content);
165
167
  const tz = params.timezone || "Asia/Shanghai";
@@ -249,8 +251,10 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
249
251
  if (!p.content) {
250
252
  return json({ error: "action=add 时 content(提醒内容)为必填参数" });
251
253
  }
252
- if (!p.to) {
253
- return json({ error: "action=add to(目标地址)为必填参数,取自上下文 [QQBot] to= 的值" });
254
+ // 优先使用 AI 传入的 to,否则自动从请求级上下文获取(AsyncLocalStorage)
255
+ const resolvedTo = p.to || getRequestTarget();
256
+ if (!resolvedTo) {
257
+ return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
254
258
  }
255
259
  if (!p.time) {
256
260
  return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
@@ -259,7 +263,7 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
259
263
  // 判断是 cron 表达式还是相对时间
260
264
  if (isCronExpression(p.time)) {
261
265
  // 周期提醒
262
- const cronJob = buildCronJob(p);
266
+ const cronJob = buildCronJob(p, resolvedTo);
263
267
  return json({
264
268
  _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
265
269
  cronParams: cronJob,
@@ -281,7 +285,7 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
281
285
  return json({ error: "提醒时间不能少于 30 秒" });
282
286
  }
283
287
 
284
- const onceJob = buildOnceJob(p, delayMs);
288
+ const onceJob = buildOnceJob(p, delayMs, resolvedTo);
285
289
  return json({
286
290
  _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
287
291
  cronParams: onceJob,
package/src/types.ts CHANGED
@@ -70,6 +70,37 @@ export interface QQBotAccountConfig {
70
70
  * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
71
71
  */
72
72
  upgradeMode?: "doc" | "hot-reload";
73
+ /**
74
+ * 出站消息合并回复(debounce)配置
75
+ * 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
76
+ */
77
+ deliverDebounce?: DeliverDebounceConfig;
78
+ }
79
+
80
+ /**
81
+ * 出站消息合并回复配置
82
+ */
83
+ export interface DeliverDebounceConfig {
84
+ /**
85
+ * 是否启用合并回复(默认 true)
86
+ */
87
+ enabled?: boolean;
88
+ /**
89
+ * 合并窗口时长(毫秒),在此时间内的连续 deliver 会被合并
90
+ * 默认 1500ms
91
+ */
92
+ windowMs?: number;
93
+ /**
94
+ * 最大等待时长(毫秒),从第一条 deliver 开始计算,超过此时间强制发送
95
+ * 防止持续有新 deliver 导致一直不发送
96
+ * 默认 8000ms
97
+ */
98
+ maxWaitMs?: number;
99
+ /**
100
+ * 合并文本之间的分隔符
101
+ * 默认 "\n\n---\n\n"
102
+ */
103
+ separator?: string;
73
104
  }
74
105
 
75
106
  /**