@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 +3 -10
- package/README.zh.md +3 -10
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +61 -24
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +281 -253
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +30 -0
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.ps1 +119 -6
- package/scripts/upgrade-via-npm.sh +121 -7
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +67 -25
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +97 -62
- package/src/request-context.ts +39 -0
- package/src/startup-greeting.ts +35 -13
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +31 -0
|
@@ -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
|
+
}
|
package/src/startup-greeting.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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(
|
|
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(),
|
package/src/tools/remind.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
"
|
|
44
|
-
"私聊格式:user_openid,群聊格式:group:group_openid。
|
|
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
|
-
|
|
253
|
-
|
|
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
|
/**
|