@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
package/src/admin-resolver.ts
CHANGED
|
@@ -26,48 +26,90 @@ export interface AdminResolverContext {
|
|
|
26
26
|
|
|
27
27
|
// ---- 文件路径 ----
|
|
28
28
|
|
|
29
|
-
function
|
|
29
|
+
function safeName(id: string): string {
|
|
30
|
+
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 新版 admin 文件路径(按 accountId + appId 区分) */
|
|
34
|
+
function getAdminMarkerFile(accountId: string, appId: string): string {
|
|
35
|
+
return path.join(getQQBotDataDir("data"), `admin-${safeName(accountId)}-${safeName(appId)}.json`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 旧版 admin 文件路径(仅按 accountId 区分,用于迁移兼容) */
|
|
39
|
+
function getLegacyAdminMarkerFile(accountId: string): string {
|
|
30
40
|
return path.join(getQQBotDataDir("data"), `admin-${accountId}.json`);
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
function getUpgradeGreetingTargetFile(accountId: string, appId: string): string {
|
|
34
|
-
|
|
35
|
-
const safeAppId = appId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
36
|
-
return path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeAccountId}-${safeAppId}.json`);
|
|
44
|
+
return path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeName(accountId)}-${safeName(appId)}.json`);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
// ---- 管理员 openid 持久化 ----
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
/**
|
|
50
|
+
* 读取 admin openid(按 accountId + appId 区分)
|
|
51
|
+
* 兼容策略:新路径优先 → fallback 旧路径 → 自动迁移
|
|
52
|
+
*/
|
|
53
|
+
export function loadAdminOpenId(accountId: string, appId: string): string | undefined {
|
|
42
54
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
// 1. 先尝试新版路径
|
|
56
|
+
const newFile = getAdminMarkerFile(accountId, appId);
|
|
57
|
+
if (fs.existsSync(newFile)) {
|
|
58
|
+
const data = JSON.parse(fs.readFileSync(newFile, "utf8"));
|
|
46
59
|
if (data.openid) return data.openid;
|
|
47
60
|
}
|
|
61
|
+
|
|
62
|
+
// 2. fallback 旧版路径(仅按 accountId)
|
|
63
|
+
const legacyFile = getLegacyAdminMarkerFile(accountId);
|
|
64
|
+
if (fs.existsSync(legacyFile)) {
|
|
65
|
+
const data = JSON.parse(fs.readFileSync(legacyFile, "utf8"));
|
|
66
|
+
if (data.openid) {
|
|
67
|
+
// 自动迁移:写到新路径,删除旧文件
|
|
68
|
+
saveAdminOpenId(accountId, appId, data.openid);
|
|
69
|
+
try { fs.unlinkSync(legacyFile); } catch { /* ignore */ }
|
|
70
|
+
return data.openid;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
48
73
|
} catch { /* 文件损坏视为无 */ }
|
|
49
74
|
return undefined;
|
|
50
75
|
}
|
|
51
76
|
|
|
52
|
-
export function saveAdminOpenId(accountId: string, openid: string): void {
|
|
77
|
+
export function saveAdminOpenId(accountId: string, appId: string, openid: string): void {
|
|
53
78
|
try {
|
|
54
|
-
fs.writeFileSync(
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
getAdminMarkerFile(accountId, appId),
|
|
81
|
+
JSON.stringify({ accountId, appId, openid, savedAt: new Date().toISOString() }),
|
|
82
|
+
);
|
|
55
83
|
} catch { /* ignore */ }
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
// ---- 升级问候目标 ----
|
|
59
87
|
|
|
60
|
-
export function loadUpgradeGreetingTargetOpenId(accountId: string, appId: string): string | undefined {
|
|
88
|
+
export function loadUpgradeGreetingTargetOpenId(accountId: string, appId: string, log?: { info: (msg: string) => void }): string | undefined {
|
|
61
89
|
try {
|
|
62
90
|
const file = getUpgradeGreetingTargetFile(accountId, appId);
|
|
63
91
|
if (fs.existsSync(file)) {
|
|
64
92
|
const data = JSON.parse(fs.readFileSync(file, "utf8")) as { accountId?: string; appId?: string; openid?: string };
|
|
65
|
-
if (!data.openid)
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
if (!data.openid) {
|
|
94
|
+
log?.info(`[qqbot:${accountId}] upgrade-greeting-target file found but openid is empty`);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (data.appId && data.appId !== appId) {
|
|
98
|
+
log?.info(`[qqbot:${accountId}] upgrade-greeting-target appId mismatch: file=${data.appId}, current=${appId}`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (data.accountId && data.accountId !== accountId) {
|
|
102
|
+
log?.info(`[qqbot:${accountId}] upgrade-greeting-target accountId mismatch: file=${data.accountId}, current=${accountId}`);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
log?.info(`[qqbot:${accountId}] upgrade-greeting-target loaded: openid=${data.openid}`);
|
|
68
106
|
return data.openid;
|
|
107
|
+
} else {
|
|
108
|
+
log?.info(`[qqbot:${accountId}] upgrade-greeting-target file not found: ${file}`);
|
|
69
109
|
}
|
|
70
|
-
} catch {
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log?.info(`[qqbot:${accountId}] upgrade-greeting-target file read error: ${err}`);
|
|
112
|
+
}
|
|
71
113
|
return undefined;
|
|
72
114
|
}
|
|
73
115
|
|
|
@@ -84,15 +126,15 @@ export function clearUpgradeGreetingTargetOpenId(accountId: string, appId: strin
|
|
|
84
126
|
|
|
85
127
|
/**
|
|
86
128
|
* 解析管理员 openid:
|
|
87
|
-
* 1.
|
|
129
|
+
* 1. 优先读持久化文件(按 accountId + appId 区分)
|
|
88
130
|
* 2. fallback 取第一个私聊用户,并写入文件锁定
|
|
89
131
|
*/
|
|
90
|
-
export function resolveAdminOpenId(ctx: Pick<AdminResolverContext, "accountId" | "log">): string | undefined {
|
|
91
|
-
const saved = loadAdminOpenId(ctx.accountId);
|
|
132
|
+
export function resolveAdminOpenId(ctx: Pick<AdminResolverContext, "accountId" | "appId" | "log">): string | undefined {
|
|
133
|
+
const saved = loadAdminOpenId(ctx.accountId, ctx.appId);
|
|
92
134
|
if (saved) return saved;
|
|
93
135
|
const first = listKnownUsers({ accountId: ctx.accountId, type: "c2c", sortBy: "firstSeenAt", sortOrder: "asc", limit: 1 })[0]?.openid;
|
|
94
136
|
if (first) {
|
|
95
|
-
saveAdminOpenId(ctx.accountId, first);
|
|
137
|
+
saveAdminOpenId(ctx.accountId, ctx.appId, first);
|
|
96
138
|
ctx.log?.info(`[qqbot:${ctx.accountId}] Auto-detected admin openid: ${first} (persisted)`);
|
|
97
139
|
}
|
|
98
140
|
return first;
|
|
@@ -100,19 +142,19 @@ export function resolveAdminOpenId(ctx: Pick<AdminResolverContext, "accountId" |
|
|
|
100
142
|
|
|
101
143
|
// ---- 启动问候语 ----
|
|
102
144
|
|
|
103
|
-
/**
|
|
145
|
+
/** 异步发送启动问候语(优先发给升级触发者,fallback 发给管理员) */
|
|
104
146
|
export function sendStartupGreetings(ctx: AdminResolverContext, trigger: "READY" | "RESUMED"): void {
|
|
105
147
|
(async () => {
|
|
106
|
-
const plan = getStartupGreetingPlan();
|
|
148
|
+
const plan = getStartupGreetingPlan(ctx.accountId, ctx.appId);
|
|
107
149
|
if (!plan.shouldSend || !plan.greeting) {
|
|
108
150
|
ctx.log?.info(`[qqbot:${ctx.accountId}] Skipping startup greeting (${plan.reason ?? "debounced"}, trigger=${trigger})`);
|
|
109
151
|
return;
|
|
110
152
|
}
|
|
111
153
|
|
|
112
|
-
const upgradeTargetOpenId = loadUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId);
|
|
154
|
+
const upgradeTargetOpenId = loadUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId, ctx.log);
|
|
113
155
|
const targetOpenId = upgradeTargetOpenId || resolveAdminOpenId(ctx);
|
|
114
156
|
if (!targetOpenId) {
|
|
115
|
-
markStartupGreetingFailed(plan.version, "no-admin");
|
|
157
|
+
markStartupGreetingFailed(ctx.accountId, ctx.appId, plan.version, "no-admin");
|
|
116
158
|
ctx.log?.info(`[qqbot:${ctx.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
117
159
|
return;
|
|
118
160
|
}
|
|
@@ -126,14 +168,14 @@ export function sendStartupGreetings(ctx: AdminResolverContext, trigger: "READY"
|
|
|
126
168
|
sendProactiveC2CMessage(token, targetOpenId, plan.greeting),
|
|
127
169
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
128
170
|
]);
|
|
129
|
-
markStartupGreetingSent(plan.version);
|
|
171
|
+
markStartupGreetingSent(ctx.accountId, ctx.appId, plan.version);
|
|
130
172
|
if (upgradeTargetOpenId) {
|
|
131
173
|
clearUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId);
|
|
132
174
|
}
|
|
133
175
|
ctx.log?.info(`[qqbot:${ctx.accountId}] Sent startup greeting to ${receiverType}: ${targetOpenId}`);
|
|
134
176
|
} catch (err) {
|
|
135
177
|
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
-
markStartupGreetingFailed(plan.version, message);
|
|
178
|
+
markStartupGreetingFailed(ctx.accountId, ctx.appId, plan.version, message);
|
|
137
179
|
ctx.log?.error(`[qqbot:${ctx.accountId}] Failed to send startup greeting: ${message}`);
|
|
138
180
|
}
|
|
139
181
|
})();
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
import type { DeliverDebounceConfig } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// ============ 默认值 ============
|
|
17
|
+
|
|
18
|
+
const DEFAULT_WINDOW_MS = 1500;
|
|
19
|
+
const DEFAULT_MAX_WAIT_MS = 8000;
|
|
20
|
+
const DEFAULT_SEPARATOR = "\n\n---\n\n";
|
|
21
|
+
|
|
22
|
+
// ============ 类型定义 ============
|
|
23
|
+
|
|
24
|
+
export interface DeliverPayload {
|
|
25
|
+
text?: string;
|
|
26
|
+
mediaUrls?: string[];
|
|
27
|
+
mediaUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeliverInfo {
|
|
31
|
+
kind: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 实际执行发送的回调 */
|
|
35
|
+
export type DeliverExecutor = (payload: DeliverPayload, info: DeliverInfo) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
// ============ DeliverDebouncer 类 ============
|
|
38
|
+
|
|
39
|
+
export class DeliverDebouncer {
|
|
40
|
+
private readonly windowMs: number;
|
|
41
|
+
private readonly maxWaitMs: number;
|
|
42
|
+
private readonly separator: string;
|
|
43
|
+
private readonly executor: DeliverExecutor;
|
|
44
|
+
private readonly log?: {
|
|
45
|
+
info: (msg: string) => void;
|
|
46
|
+
error: (msg: string) => void;
|
|
47
|
+
};
|
|
48
|
+
private readonly prefix: string;
|
|
49
|
+
|
|
50
|
+
/** 缓冲中的文本片段 */
|
|
51
|
+
private bufferedTexts: string[] = [];
|
|
52
|
+
/** 缓冲中最后一次 deliver 的 info(用于 flush 时传递 kind) */
|
|
53
|
+
private lastInfo: DeliverInfo | null = null;
|
|
54
|
+
/** 缓冲中最后一次 deliver 的 payload(非文本字段,如 mediaUrls) */
|
|
55
|
+
private lastPayload: DeliverPayload | null = null;
|
|
56
|
+
/** debounce 定时器 */
|
|
57
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
58
|
+
/** 最大等待定时器(从第一条 deliver 开始计算) */
|
|
59
|
+
private maxWaitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
/** 是否正在 flush */
|
|
61
|
+
private flushing = false;
|
|
62
|
+
/** 已销毁标记 */
|
|
63
|
+
private disposed = false;
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
config: DeliverDebounceConfig | undefined,
|
|
67
|
+
executor: DeliverExecutor,
|
|
68
|
+
log?: { info: (msg: string) => void; error: (msg: string) => void },
|
|
69
|
+
prefix = "[debounce]",
|
|
70
|
+
) {
|
|
71
|
+
this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
72
|
+
this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
73
|
+
this.separator = config?.separator ?? DEFAULT_SEPARATOR;
|
|
74
|
+
this.executor = executor;
|
|
75
|
+
this.log = log;
|
|
76
|
+
this.prefix = prefix;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 接收一次 deliver 调用。
|
|
81
|
+
* - 纯文本 deliver → 缓冲并设置 debounce 定时器
|
|
82
|
+
* - 含媒体 deliver → 先 flush 已缓冲文本,再直接执行当前 deliver
|
|
83
|
+
*/
|
|
84
|
+
async deliver(payload: DeliverPayload, info: DeliverInfo): Promise<void> {
|
|
85
|
+
if (this.disposed) return;
|
|
86
|
+
|
|
87
|
+
const hasMedia = Boolean(
|
|
88
|
+
(payload.mediaUrls && payload.mediaUrls.length > 0) || payload.mediaUrl,
|
|
89
|
+
);
|
|
90
|
+
const text = (payload.text ?? "").trim();
|
|
91
|
+
|
|
92
|
+
// 含媒体的 deliver:立即 flush 缓冲 + 直接执行
|
|
93
|
+
if (hasMedia) {
|
|
94
|
+
this.log?.info(`${this.prefix} Media deliver detected, flushing ${this.bufferedTexts.length} buffered text(s) first`);
|
|
95
|
+
await this.flush();
|
|
96
|
+
await this.executor(payload, info);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 空文本 deliver:直接透传(不缓冲)
|
|
101
|
+
if (!text) {
|
|
102
|
+
await this.executor(payload, info);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 纯文本 deliver:缓冲
|
|
107
|
+
this.bufferedTexts.push(text);
|
|
108
|
+
this.lastInfo = info;
|
|
109
|
+
this.lastPayload = payload;
|
|
110
|
+
|
|
111
|
+
this.log?.info(
|
|
112
|
+
`${this.prefix} Buffered text #${this.bufferedTexts.length} (${text.length} chars), window=${this.windowMs}ms`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// 重置 debounce 定时器
|
|
116
|
+
if (this.debounceTimer) {
|
|
117
|
+
clearTimeout(this.debounceTimer);
|
|
118
|
+
}
|
|
119
|
+
this.debounceTimer = setTimeout(() => {
|
|
120
|
+
this.flush().catch((err) => {
|
|
121
|
+
this.log?.error(`${this.prefix} Flush error (debounce timer): ${err}`);
|
|
122
|
+
});
|
|
123
|
+
}, this.windowMs);
|
|
124
|
+
|
|
125
|
+
// 首次缓冲时启动最大等待定时器
|
|
126
|
+
if (this.bufferedTexts.length === 1) {
|
|
127
|
+
if (this.maxWaitTimer) {
|
|
128
|
+
clearTimeout(this.maxWaitTimer);
|
|
129
|
+
}
|
|
130
|
+
this.maxWaitTimer = setTimeout(() => {
|
|
131
|
+
this.log?.info(`${this.prefix} Max wait (${this.maxWaitMs}ms) reached, force flushing`);
|
|
132
|
+
this.flush().catch((err) => {
|
|
133
|
+
this.log?.error(`${this.prefix} Flush error (max wait timer): ${err}`);
|
|
134
|
+
});
|
|
135
|
+
}, this.maxWaitMs);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 将缓冲中的文本合并为一条消息发送
|
|
141
|
+
*/
|
|
142
|
+
async flush(): Promise<void> {
|
|
143
|
+
if (this.flushing || this.bufferedTexts.length === 0) return;
|
|
144
|
+
this.flushing = true;
|
|
145
|
+
|
|
146
|
+
// 清除定时器
|
|
147
|
+
if (this.debounceTimer) {
|
|
148
|
+
clearTimeout(this.debounceTimer);
|
|
149
|
+
this.debounceTimer = null;
|
|
150
|
+
}
|
|
151
|
+
if (this.maxWaitTimer) {
|
|
152
|
+
clearTimeout(this.maxWaitTimer);
|
|
153
|
+
this.maxWaitTimer = null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 取出缓冲
|
|
157
|
+
const texts = this.bufferedTexts;
|
|
158
|
+
const info = this.lastInfo!;
|
|
159
|
+
const lastPayload = this.lastPayload!;
|
|
160
|
+
this.bufferedTexts = [];
|
|
161
|
+
this.lastInfo = null;
|
|
162
|
+
this.lastPayload = null;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
if (texts.length === 1) {
|
|
166
|
+
// 只有一条,直接透传原始 payload
|
|
167
|
+
this.log?.info(`${this.prefix} Flushing single buffered text (${texts[0].length} chars)`);
|
|
168
|
+
await this.executor({ ...lastPayload, text: texts[0] }, info);
|
|
169
|
+
} else {
|
|
170
|
+
// 多条合并
|
|
171
|
+
const merged = texts.join(this.separator);
|
|
172
|
+
this.log?.info(
|
|
173
|
+
`${this.prefix} Merged ${texts.length} buffered texts into one (${merged.length} chars)`,
|
|
174
|
+
);
|
|
175
|
+
await this.executor({ ...lastPayload, text: merged }, info);
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
this.flushing = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 销毁:flush 剩余缓冲并清除定时器
|
|
184
|
+
*/
|
|
185
|
+
async dispose(): Promise<void> {
|
|
186
|
+
this.disposed = true;
|
|
187
|
+
if (this.debounceTimer) {
|
|
188
|
+
clearTimeout(this.debounceTimer);
|
|
189
|
+
this.debounceTimer = null;
|
|
190
|
+
}
|
|
191
|
+
if (this.maxWaitTimer) {
|
|
192
|
+
clearTimeout(this.maxWaitTimer);
|
|
193
|
+
this.maxWaitTimer = null;
|
|
194
|
+
}
|
|
195
|
+
// flush 剩余
|
|
196
|
+
if (this.bufferedTexts.length > 0) {
|
|
197
|
+
this.flushing = false; // 确保 flush 能执行
|
|
198
|
+
await this.flush();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 当前是否有缓冲中的文本 */
|
|
203
|
+
get hasPending(): boolean {
|
|
204
|
+
return this.bufferedTexts.length > 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 缓冲中的文本数量 */
|
|
208
|
+
get pendingCount(): number {
|
|
209
|
+
return this.bufferedTexts.length;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============ 工厂函数 ============
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 根据配置创建 debouncer 或返回 null(禁用时)
|
|
217
|
+
*/
|
|
218
|
+
export function createDeliverDebouncer(
|
|
219
|
+
config: DeliverDebounceConfig | undefined,
|
|
220
|
+
executor: DeliverExecutor,
|
|
221
|
+
log?: { info: (msg: string) => void; error: (msg: string) => void },
|
|
222
|
+
prefix?: string,
|
|
223
|
+
): DeliverDebouncer | null {
|
|
224
|
+
// 未配置时默认启用
|
|
225
|
+
if (config?.enabled === false) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return new DeliverDebouncer(config, executor, log, prefix);
|
|
229
|
+
}
|
package/src/gateway.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { sendStartupGreetings, type AdminResolverContext } from "./admin-resolve
|
|
|
20
20
|
import { sendWithTokenRetry, sendErrorToTarget, handleStructuredPayload, type ReplyContext, type MessageTarget } from "./reply-dispatcher.js";
|
|
21
21
|
import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
|
|
22
22
|
import { parseAndSendMediaTags, sendPlainReply, type DeliverEventContext, type DeliverAccountContext } from "./outbound-deliver.js";
|
|
23
|
+
import { createDeliverDebouncer, type DeliverDebouncer } from "./deliver-debounce.js";
|
|
24
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
23
25
|
|
|
24
26
|
// QQ Bot intents - 按权限级别分组
|
|
25
27
|
const INTENTS = {
|
|
@@ -86,9 +88,10 @@ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: st
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
//
|
|
91
|
+
// 模块级变量:per-account 首次 READY 跟踪
|
|
90
92
|
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
91
|
-
|
|
93
|
+
// 每个 account 首次 READY/RESUMED 时从 Set 中移除,之后不再发送问候语
|
|
94
|
+
const _pendingFirstReady = new Set<string>();
|
|
92
95
|
|
|
93
96
|
/**
|
|
94
97
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
|
@@ -186,8 +189,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
186
189
|
let isConnecting = false; // 防止并发连接
|
|
187
190
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
|
|
188
191
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
|
189
|
-
//
|
|
190
|
-
|
|
192
|
+
// 标记此 account 为待发问候(进程重启时 Set 里已有,断线重连不会重新加入)
|
|
193
|
+
_pendingFirstReady.add(account.accountId);
|
|
191
194
|
|
|
192
195
|
const adminCtx: AdminResolverContext = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
|
|
193
196
|
|
|
@@ -621,7 +624,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
621
624
|
|
|
622
625
|
// ============ 构建 contextInfo(静态/动态分离) ============
|
|
623
626
|
// 设计原则(参考 Telegram/Discord 做法):
|
|
624
|
-
// -
|
|
627
|
+
// - 静态指引:每条消息不变的能力声明,
|
|
625
628
|
// 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
|
|
626
629
|
// 且保证长 session 窗口截断后仍可见。
|
|
627
630
|
// - 动态标签:每条消息变化的数据(时间、附件、ASR),
|
|
@@ -629,17 +632,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
629
632
|
|
|
630
633
|
// --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
|
|
631
634
|
// 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
|
|
632
|
-
//
|
|
633
|
-
const staticParts: string[] = [
|
|
634
|
-
`[QQBot] to=${qualifiedTarget}`,
|
|
635
|
-
];
|
|
635
|
+
// 投递地址通过 AsyncLocalStorage 请求上下文传递给 remind 工具,无需在 agentBody 中暴露
|
|
636
|
+
const staticParts: string[] = [];
|
|
636
637
|
// TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
|
|
637
638
|
// STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
|
|
638
639
|
if (hasTTS) staticParts.push("语音合成已启用");
|
|
639
|
-
const staticInstruction = staticParts.join(" | ");
|
|
640
640
|
|
|
641
|
-
//
|
|
642
|
-
|
|
641
|
+
// 仅在有静态指引时注入 systemPrompts
|
|
642
|
+
if (staticParts.length > 0) {
|
|
643
|
+
const staticInstruction = staticParts.join(" | ");
|
|
644
|
+
systemPrompts.unshift(staticInstruction);
|
|
645
|
+
}
|
|
643
646
|
|
|
644
647
|
// --- 动态上下文(仅框架信封未覆盖的附件信息) ---
|
|
645
648
|
const dynLines: string[] = [];
|
|
@@ -757,6 +760,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
757
760
|
// 发送错误提示的辅助函数
|
|
758
761
|
const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText);
|
|
759
762
|
|
|
763
|
+
// 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
|
|
764
|
+
// (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
|
|
765
|
+
await runWithRequestContext({ target: qualifiedTarget }, async () => {
|
|
760
766
|
try {
|
|
761
767
|
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
|
762
768
|
|
|
@@ -774,6 +780,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
774
780
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
775
781
|
let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
776
782
|
|
|
783
|
+
// ============ Deliver Debouncer:合并短时间内连续到达的 block deliver ============
|
|
784
|
+
const debounceConfig = account.config?.deliverDebounce;
|
|
785
|
+
let debouncer: DeliverDebouncer | null = null as DeliverDebouncer | null;
|
|
786
|
+
|
|
777
787
|
// tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
|
|
778
788
|
const sendToolFallback = async (): Promise<void> => {
|
|
779
789
|
// 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
|
|
@@ -924,63 +934,82 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
924
934
|
log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
|
|
925
935
|
}
|
|
926
936
|
|
|
927
|
-
// ============
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
937
|
+
// ============ 实际发送逻辑(可被 debouncer 包裹) ============
|
|
938
|
+
const executeDeliver = async (deliverPayload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, _deliverInfo: { kind: string }) => {
|
|
939
|
+
// ============ 引用回复 ============
|
|
940
|
+
const quoteRef = event.msgIdx;
|
|
941
|
+
let quoteRefUsed = false;
|
|
942
|
+
const consumeQuoteRef = (): string | undefined => {
|
|
943
|
+
if (quoteRef && !quoteRefUsed) {
|
|
944
|
+
quoteRefUsed = true;
|
|
945
|
+
return quoteRef;
|
|
946
|
+
}
|
|
947
|
+
return undefined;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
let replyText = deliverPayload.text ?? "";
|
|
951
|
+
|
|
952
|
+
// ============ 媒体标签解析 + 发送 ============
|
|
953
|
+
const deliverEvent: DeliverEventContext = {
|
|
954
|
+
type: event.type,
|
|
955
|
+
senderId: event.senderId,
|
|
956
|
+
messageId: event.messageId,
|
|
957
|
+
channelId: event.channelId,
|
|
958
|
+
groupOpenid: event.groupOpenid,
|
|
959
|
+
msgIdx: event.msgIdx,
|
|
960
|
+
};
|
|
961
|
+
const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log };
|
|
962
|
+
|
|
963
|
+
const mediaResult = await parseAndSendMediaTags(
|
|
964
|
+
replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef,
|
|
965
|
+
);
|
|
966
|
+
if (mediaResult.handled) {
|
|
967
|
+
pluginRuntime.channel.activity.record({
|
|
968
|
+
channel: "qqbot",
|
|
969
|
+
accountId: account.accountId,
|
|
970
|
+
direction: "outbound",
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
934
973
|
}
|
|
935
|
-
|
|
936
|
-
};
|
|
974
|
+
replyText = mediaResult.normalizedText;
|
|
937
975
|
|
|
938
|
-
|
|
976
|
+
// ============ 结构化载荷检测与分发 ============
|
|
977
|
+
const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
|
|
978
|
+
channel: "qqbot",
|
|
979
|
+
accountId: account.accountId,
|
|
980
|
+
direction: "outbound",
|
|
981
|
+
});
|
|
982
|
+
const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
|
|
983
|
+
if (handled) return;
|
|
939
984
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
channelId: event.channelId,
|
|
946
|
-
groupOpenid: event.groupOpenid,
|
|
947
|
-
msgIdx: event.msgIdx,
|
|
948
|
-
};
|
|
949
|
-
const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log };
|
|
985
|
+
// ============ 非结构化消息发送 ============
|
|
986
|
+
await sendPlainReply(
|
|
987
|
+
deliverPayload, replyText, deliverEvent, deliverActx,
|
|
988
|
+
sendWithRetry, consumeQuoteRef, toolMediaUrls,
|
|
989
|
+
);
|
|
950
990
|
|
|
951
|
-
const mediaResult = await parseAndSendMediaTags(
|
|
952
|
-
replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef,
|
|
953
|
-
);
|
|
954
|
-
if (mediaResult.handled) {
|
|
955
991
|
pluginRuntime.channel.activity.record({
|
|
956
992
|
channel: "qqbot",
|
|
957
993
|
accountId: account.accountId,
|
|
958
994
|
direction: "outbound",
|
|
959
995
|
});
|
|
960
|
-
|
|
961
|
-
}
|
|
962
|
-
replyText = mediaResult.normalizedText;
|
|
963
|
-
|
|
964
|
-
// ============ 结构化载荷检测与分发 ============
|
|
965
|
-
const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
|
|
966
|
-
channel: "qqbot",
|
|
967
|
-
accountId: account.accountId,
|
|
968
|
-
direction: "outbound",
|
|
969
|
-
});
|
|
970
|
-
const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
|
|
971
|
-
if (handled) return;
|
|
996
|
+
};
|
|
972
997
|
|
|
973
|
-
// ============
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
998
|
+
// ============ Debounce 合并回复 ============
|
|
999
|
+
if (!debouncer) {
|
|
1000
|
+
debouncer = createDeliverDebouncer(
|
|
1001
|
+
debounceConfig,
|
|
1002
|
+
executeDeliver,
|
|
1003
|
+
log,
|
|
1004
|
+
`[qqbot:${account.accountId}:debounce]`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
978
1007
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
1008
|
+
if (debouncer) {
|
|
1009
|
+
await debouncer.deliver(payload, info);
|
|
1010
|
+
} else {
|
|
1011
|
+
await executeDeliver(payload, info);
|
|
1012
|
+
}
|
|
984
1013
|
},
|
|
985
1014
|
onError: async (err: unknown) => {
|
|
986
1015
|
log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
|
|
@@ -1026,6 +1055,11 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1026
1055
|
log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
|
|
1027
1056
|
await sendToolFallback();
|
|
1028
1057
|
}
|
|
1058
|
+
// 销毁 debouncer,flush 剩余缓冲的文本
|
|
1059
|
+
if (debouncer) {
|
|
1060
|
+
await debouncer.dispose();
|
|
1061
|
+
debouncer = null;
|
|
1062
|
+
}
|
|
1029
1063
|
}
|
|
1030
1064
|
} catch (err) {
|
|
1031
1065
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
@@ -1033,6 +1067,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1033
1067
|
// 无论成功/失败/超时,都停止输入状态续期
|
|
1034
1068
|
typing.keepAlive?.stop();
|
|
1035
1069
|
}
|
|
1070
|
+
}); // end runWithRequestContext
|
|
1036
1071
|
};
|
|
1037
1072
|
|
|
1038
1073
|
ws.on("open", () => {
|
|
@@ -1131,18 +1166,18 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1131
1166
|
|
|
1132
1167
|
// 仅 startGateway 后的首次 READY 才发送上线通知
|
|
1133
1168
|
// ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
|
|
1134
|
-
if (!
|
|
1169
|
+
if (!_pendingFirstReady.has(account.accountId)) {
|
|
1135
1170
|
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
|
|
1136
1171
|
} else {
|
|
1137
|
-
|
|
1172
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1138
1173
|
sendStartupGreetings(adminCtx, "READY");
|
|
1139
1174
|
} // end isFirstReady
|
|
1140
1175
|
} else if (t === "RESUMED") {
|
|
1141
1176
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
1142
1177
|
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
1143
1178
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1179
|
+
if (_pendingFirstReady.has(account.accountId)) {
|
|
1180
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1146
1181
|
sendStartupGreetings(adminCtx, "RESUMED");
|
|
1147
1182
|
}
|
|
1148
1183
|
// P1-2: 更新 Session 连接时间
|