@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.
- package/README.md +24 -15
- package/README.zh.md +24 -15
- package/dist/src/api.d.ts +32 -5
- package/dist/src/api.js +111 -12
- package/dist/src/channel.d.ts +18 -0
- package/dist/src/channel.js +85 -2
- package/dist/src/config.d.ts +33 -2
- package/dist/src/config.js +125 -1
- package/dist/src/gateway.js +566 -24
- package/dist/src/group-history.d.ts +136 -0
- package/dist/src/group-history.js +226 -0
- package/dist/src/message-gating.d.ts +53 -0
- package/dist/src/message-gating.js +107 -0
- package/dist/src/message-queue.d.ts +36 -0
- package/dist/src/message-queue.js +164 -22
- package/dist/src/outbound.d.ts +4 -4
- package/dist/src/outbound.js +18 -6
- package/dist/src/ref-index-store.js +5 -28
- package/dist/src/request-context.d.ts +7 -0
- package/dist/src/request-context.js +7 -0
- package/dist/src/slash-commands.d.ts +6 -0
- package/dist/src/slash-commands.js +3 -3
- package/dist/src/tools/remind.js +17 -9
- package/dist/src/types.d.ts +90 -2
- package/dist/src/utils/audio-convert.d.ts +1 -1
- package/dist/src/utils/audio-convert.js +1 -1
- package/dist/src/utils/chunked-upload.d.ts +11 -2
- package/dist/src/utils/chunked-upload.js +63 -11
- package/dist/src/utils/media-send.js +1 -1
- package/dist/src/utils/text-parsing.js +7 -18
- package/package.json +1 -1
- package/scripts/postinstall-link-sdk.js +22 -9
- package/scripts/upgrade-via-npm.sh +11 -3
- package/scripts/upgrade-via-source.sh +63 -15
- package/skills/qqbot-remind/SKILL.md +21 -11
- package/src/api.ts +135 -7
- package/src/channel.ts +85 -2
- package/src/config.ts +170 -3
- package/src/gateway.ts +662 -29
- package/src/group-history.ts +328 -0
- package/src/message-gating.ts +190 -0
- package/src/message-queue.ts +201 -21
- package/src/openclaw-plugin-sdk.d.ts +65 -0
- package/src/outbound.ts +18 -6
- package/src/ref-index-store.ts +5 -27
- package/src/request-context.ts +10 -0
- package/src/slash-commands.ts +3 -3
- package/src/tools/remind.ts +17 -9
- package/src/types.ts +94 -2
- package/src/utils/audio-convert.ts +1 -1
- package/src/utils/chunked-upload.ts +76 -12
- package/src/utils/media-send.ts +1 -2
- package/src/utils/text-parsing.ts +7 -14
package/src/message-queue.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { QueueSnapshot } from "./slash-commands.js";
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
3
|
+
// ── 消息队列默认配置 ──
|
|
4
|
+
const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
|
|
5
|
+
const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
|
|
6
|
+
const DEFAULT_GROUP_QUEUE_SIZE = 50;
|
|
7
|
+
const DEFAULT_MAX_CONCURRENT_USERS = 10;
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 消息队列项类型(用于异步处理消息,防止阻塞心跳)
|
|
@@ -23,6 +24,18 @@ export interface QueuedMessage {
|
|
|
23
24
|
refMsgIdx?: string;
|
|
24
25
|
/** 当前消息自身的 refIdx(供将来被引用) */
|
|
25
26
|
msgIdx?: string;
|
|
27
|
+
/** 事件类型(如 GROUP_AT_MESSAGE_CREATE),用于群消息合并时判断是否有 @ */
|
|
28
|
+
eventType?: string;
|
|
29
|
+
/** 发送者是否为机器人 */
|
|
30
|
+
senderIsBot?: boolean;
|
|
31
|
+
/** @ 提及列表(群消息合并时需要去重合并) */
|
|
32
|
+
mentions?: Array<{ scope?: "all" | "single"; id?: string; user_openid?: string; member_openid?: string; username?: string; bot?: boolean; is_you?: boolean }>;
|
|
33
|
+
/** 消息场景(来源、扩展字段) */
|
|
34
|
+
messageScene?: { source?: string; ext?: string[] };
|
|
35
|
+
/** 群消息合并标记:记录合并了多少条原始消息 */
|
|
36
|
+
_mergedCount?: number;
|
|
37
|
+
/** 合并前的原始消息列表(用于 gateway 侧逐条格式化信封) */
|
|
38
|
+
_mergedMessages?: QueuedMessage[];
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export interface MessageQueueContext {
|
|
@@ -34,6 +47,14 @@ export interface MessageQueueContext {
|
|
|
34
47
|
};
|
|
35
48
|
/** 外部提供的 abort 状态检查 */
|
|
36
49
|
isAborted: () => boolean;
|
|
50
|
+
/** 群聊队列上限(默认 50) */
|
|
51
|
+
groupQueueSize?: number;
|
|
52
|
+
/** 私聊/DM 队列上限(默认 20) */
|
|
53
|
+
peerQueueSize?: number;
|
|
54
|
+
/** 全局队列总量上限(默认 1000) */
|
|
55
|
+
globalQueueSize?: number;
|
|
56
|
+
/** 最大并发处理用户数(默认 10) */
|
|
57
|
+
maxConcurrentUsers?: number;
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
export interface MessageQueue {
|
|
@@ -47,15 +68,103 @@ export interface MessageQueue {
|
|
|
47
68
|
executeImmediate: (msg: QueuedMessage) => void;
|
|
48
69
|
}
|
|
49
70
|
|
|
71
|
+
// ── 群消息合并工具函数 ──
|
|
72
|
+
|
|
73
|
+
/** 判断 peerId 是否属于群聊 */
|
|
74
|
+
const isGroupPeer = (peerId: string): boolean =>
|
|
75
|
+
peerId.startsWith("group:") || peerId.startsWith("guild:");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 将多条群消息合并为一条,用于群聊场景下排队消息的批量处理。
|
|
79
|
+
* - content 拼接为多行,每行带发送者前缀
|
|
80
|
+
* - 附件合并
|
|
81
|
+
* - messageId / msgIdx / timestamp 取最后一条(用于回复引用)
|
|
82
|
+
* - mentions 合并去重
|
|
83
|
+
* - 如果有任意一条 @了你(is_you),合并结果也标记 @你
|
|
84
|
+
* - senderIsBot 只要有一条不是 bot 就算非 bot
|
|
85
|
+
*/
|
|
86
|
+
function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage {
|
|
87
|
+
if (batch.length === 1) return batch[0];
|
|
88
|
+
|
|
89
|
+
const last = batch[batch.length - 1];
|
|
90
|
+
const first = batch[0];
|
|
91
|
+
|
|
92
|
+
// 拼接内容:每条消息带发送者前缀
|
|
93
|
+
const mergedContent = batch
|
|
94
|
+
.map((m) => {
|
|
95
|
+
const name = m.senderName ?? m.senderId;
|
|
96
|
+
return `[${name}]: ${m.content}`;
|
|
97
|
+
})
|
|
98
|
+
.join("\n");
|
|
99
|
+
|
|
100
|
+
// 合并附件
|
|
101
|
+
const mergedAttachments: QueuedMessage["attachments"] = [];
|
|
102
|
+
for (const m of batch) {
|
|
103
|
+
if (m.attachments?.length) {
|
|
104
|
+
mergedAttachments.push(...m.attachments);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 合并 mentions(去重 by member_openid/id)
|
|
109
|
+
const seenMentionIds = new Set<string>();
|
|
110
|
+
const mergedMentions: NonNullable<QueuedMessage["mentions"]> = [];
|
|
111
|
+
let hasAtYouEvent = false;
|
|
112
|
+
for (const m of batch) {
|
|
113
|
+
if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
|
|
114
|
+
hasAtYouEvent = true;
|
|
115
|
+
}
|
|
116
|
+
if (m.mentions) {
|
|
117
|
+
for (const mt of m.mentions) {
|
|
118
|
+
const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
|
|
119
|
+
if (key && seenMentionIds.has(key)) continue;
|
|
120
|
+
if (key) seenMentionIds.add(key);
|
|
121
|
+
mergedMentions.push(mt);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// senderIsBot: 只要有一条来自非 bot 用户,就算非 bot
|
|
127
|
+
const allFromBot = batch.every((m) => m.senderIsBot);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
type: last.type,
|
|
131
|
+
senderId: last.senderId,
|
|
132
|
+
senderName: last.senderName,
|
|
133
|
+
senderIsBot: allFromBot,
|
|
134
|
+
content: mergedContent,
|
|
135
|
+
messageId: last.messageId,
|
|
136
|
+
timestamp: last.timestamp,
|
|
137
|
+
channelId: last.channelId,
|
|
138
|
+
guildId: last.guildId,
|
|
139
|
+
groupOpenid: last.groupOpenid,
|
|
140
|
+
attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
|
|
141
|
+
refMsgIdx: first.refMsgIdx,
|
|
142
|
+
msgIdx: last.msgIdx,
|
|
143
|
+
eventType: hasAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
|
|
144
|
+
mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
|
|
145
|
+
messageScene: last.messageScene,
|
|
146
|
+
_mergedCount: batch.length,
|
|
147
|
+
_mergedMessages: batch.length > 1 ? batch : undefined,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
50
151
|
/**
|
|
51
152
|
* 创建按用户并发的消息队列(同用户串行,跨用户并行)
|
|
153
|
+
*
|
|
154
|
+
* 内置群消息增强:
|
|
155
|
+
* - 群聊 / 私聊使用不同队列上限
|
|
156
|
+
* - 群聊溢出时优先丢弃 bot 消息
|
|
157
|
+
* - drain 时自动合并群聊排队消息(斜杠命令单独处理)
|
|
52
158
|
*/
|
|
53
159
|
export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
|
54
160
|
const { accountId, log } = ctx;
|
|
161
|
+
const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
|
|
162
|
+
const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
|
|
163
|
+
const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
|
|
164
|
+
const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
|
|
55
165
|
|
|
56
166
|
const userQueues = new Map<string, QueuedMessage[]>();
|
|
57
167
|
const activeUsers = new Set<string>();
|
|
58
|
-
let messagesProcessed = 0;
|
|
59
168
|
let handleMessageFnRef: ((msg: QueuedMessage) => Promise<void>) | null = null;
|
|
60
169
|
let totalEnqueued = 0;
|
|
61
170
|
|
|
@@ -65,10 +174,63 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
|
|
65
174
|
return `dm:${msg.senderId}`;
|
|
66
175
|
};
|
|
67
176
|
|
|
177
|
+
/** 从满队列中淘汰一条消息(群聊优先丢弃 bot 消息,否则丢弃最旧) */
|
|
178
|
+
const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => {
|
|
179
|
+
if (isGroup) {
|
|
180
|
+
const botIdx = queue.findIndex(m => m.senderIsBot);
|
|
181
|
+
if (botIdx >= 0) return queue.splice(botIdx, 1)[0];
|
|
182
|
+
}
|
|
183
|
+
return queue.shift();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** 判断消息是否为斜杠指令 */
|
|
187
|
+
const isSlashCommand = (msg: QueuedMessage): boolean =>
|
|
188
|
+
(msg.content ?? "").trim().startsWith("/");
|
|
189
|
+
|
|
190
|
+
/** 处理单条消息,捕获异常并记录日志 */
|
|
191
|
+
const processOne = async (
|
|
192
|
+
msg: QueuedMessage,
|
|
193
|
+
peerId: string,
|
|
194
|
+
label: string,
|
|
195
|
+
): Promise<boolean> => {
|
|
196
|
+
try {
|
|
197
|
+
await handleMessageFnRef!(msg);
|
|
198
|
+
return true;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
log?.error(`[qqbot:${accountId}] ${label} error for ${peerId}: ${err}`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** 批量处理群聊排队消息:斜杠指令逐条处理,普通消息合并后处理 */
|
|
206
|
+
const drainGroupBatch = async (all: QueuedMessage[], peerId: string): Promise<void> => {
|
|
207
|
+
const commands: QueuedMessage[] = [];
|
|
208
|
+
const normal: QueuedMessage[] = [];
|
|
209
|
+
for (const m of all) {
|
|
210
|
+
(isSlashCommand(m) ? commands : normal).push(m);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 指令消息逐条处理
|
|
214
|
+
for (const cmd of commands) {
|
|
215
|
+
log?.info(`[qqbot:${accountId}] Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
|
|
216
|
+
await processOne(cmd, peerId, "Command processor");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 普通消息合并后处理
|
|
220
|
+
if (normal.length > 0) {
|
|
221
|
+
const merged = mergeGroupMessages(normal);
|
|
222
|
+
if (normal.length > 1) {
|
|
223
|
+
log?.info(`[qqbot:${accountId}] Merged ${normal.length} queued group messages for ${peerId} into one`);
|
|
224
|
+
}
|
|
225
|
+
await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** 处理指定 peer 队列中的消息(串行) */
|
|
68
230
|
const drainUserQueue = async (peerId: string): Promise<void> => {
|
|
69
231
|
if (activeUsers.has(peerId)) return;
|
|
70
|
-
if (activeUsers.size >=
|
|
71
|
-
log?.info(`[qqbot:${accountId}] Max concurrent users (${
|
|
232
|
+
if (activeUsers.size >= maxConcurrentUsers) {
|
|
233
|
+
log?.info(`[qqbot:${accountId}] Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
|
|
72
234
|
return;
|
|
73
235
|
}
|
|
74
236
|
|
|
@@ -79,25 +241,31 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
|
|
79
241
|
}
|
|
80
242
|
|
|
81
243
|
activeUsers.add(peerId);
|
|
244
|
+
const isGroup = isGroupPeer(peerId);
|
|
82
245
|
|
|
83
246
|
try {
|
|
84
247
|
while (queue.length > 0 && !ctx.isAborted()) {
|
|
248
|
+
// 群聊排队 > 1 条:批量处理
|
|
249
|
+
if (isGroup && queue.length > 1 && handleMessageFnRef) {
|
|
250
|
+
const all = queue.splice(0, queue.length);
|
|
251
|
+
totalEnqueued = Math.max(0, totalEnqueued - all.length);
|
|
252
|
+
await drainGroupBatch(all, peerId);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 非群聊 或 队列只剩 1 条:逐条处理
|
|
85
257
|
const msg = queue.shift()!;
|
|
86
258
|
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await handleMessageFnRef(msg);
|
|
90
|
-
messagesProcessed++;
|
|
91
|
-
}
|
|
92
|
-
} catch (err) {
|
|
93
|
-
log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${err}`);
|
|
259
|
+
if (handleMessageFnRef) {
|
|
260
|
+
await processOne(msg, peerId, "Message processor");
|
|
94
261
|
}
|
|
95
262
|
}
|
|
96
263
|
} finally {
|
|
97
264
|
activeUsers.delete(peerId);
|
|
98
265
|
userQueues.delete(peerId);
|
|
266
|
+
// 尽量填满并发槽位
|
|
99
267
|
for (const [waitingPeerId, waitingQueue] of userQueues) {
|
|
100
|
-
if (activeUsers.size >=
|
|
268
|
+
if (activeUsers.size >= maxConcurrentUsers) break;
|
|
101
269
|
if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
|
|
102
270
|
drainUserQueue(waitingPeerId);
|
|
103
271
|
}
|
|
@@ -107,31 +275,43 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
|
|
107
275
|
|
|
108
276
|
const enqueue = (msg: QueuedMessage): void => {
|
|
109
277
|
const peerId = getMessagePeerId(msg);
|
|
278
|
+
const isGroup = isGroupPeer(peerId);
|
|
110
279
|
let queue = userQueues.get(peerId);
|
|
111
280
|
if (!queue) {
|
|
112
281
|
queue = [];
|
|
113
282
|
userQueues.set(peerId, queue);
|
|
114
283
|
}
|
|
115
284
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
285
|
+
// 群聊和非群聊使用不同的队列上限
|
|
286
|
+
const maxSize = isGroup ? groupQueueSize : peerQueueSize;
|
|
287
|
+
|
|
288
|
+
// 队列溢出:淘汰一条旧消息
|
|
289
|
+
if (queue.length >= maxSize) {
|
|
290
|
+
const dropped = evictOne(queue, isGroup);
|
|
291
|
+
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
292
|
+
if (isGroup && dropped?.senderIsBot) {
|
|
293
|
+
log?.info(`[qqbot:${accountId}] Queue full for ${peerId}, dropping bot message ${dropped.messageId}`);
|
|
294
|
+
} else {
|
|
295
|
+
log?.error(`[qqbot:${accountId}] Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
|
|
296
|
+
}
|
|
119
297
|
}
|
|
120
298
|
|
|
299
|
+
// 全局总量保护
|
|
121
300
|
totalEnqueued++;
|
|
122
|
-
if (totalEnqueued >
|
|
301
|
+
if (totalEnqueued > globalQueueSize) {
|
|
123
302
|
log?.error(`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
|
|
124
303
|
}
|
|
125
304
|
|
|
126
305
|
queue.push(msg);
|
|
127
306
|
log?.debug?.(`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
|
|
128
307
|
|
|
308
|
+
// 如果该用户没有正在处理的消息,立即启动处理
|
|
129
309
|
drainUserQueue(peerId);
|
|
130
310
|
};
|
|
131
311
|
|
|
132
312
|
const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
|
|
133
313
|
handleMessageFnRef = handleMessageFn;
|
|
134
|
-
log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${
|
|
314
|
+
log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`);
|
|
135
315
|
};
|
|
136
316
|
|
|
137
317
|
const getSnapshot = (senderPeerId: string): QueueSnapshot => {
|
|
@@ -143,7 +323,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
|
|
143
323
|
return {
|
|
144
324
|
totalPending,
|
|
145
325
|
activeUsers: activeUsers.size,
|
|
146
|
-
maxConcurrentUsers
|
|
326
|
+
maxConcurrentUsers,
|
|
147
327
|
senderPending: senderQueue ? senderQueue.length : 0,
|
|
148
328
|
};
|
|
149
329
|
};
|
|
@@ -362,6 +362,35 @@ declare module "openclaw/plugin-sdk" {
|
|
|
362
362
|
[key: string]: unknown;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
// ============ 群消息适配器 ============
|
|
366
|
+
|
|
367
|
+
/** 群消息策略适配器(resolveRequireMention / resolveToolPolicy / resolveGroupIntroHint) */
|
|
368
|
+
export interface ChannelGroupAdapter {
|
|
369
|
+
/** 是否需要 @机器人才响应 */
|
|
370
|
+
resolveRequireMention?: (ctx: { cfg: OpenClawConfig; accountId?: string; groupId: string }) => boolean;
|
|
371
|
+
/** 群聊 AI 工具使用范围 */
|
|
372
|
+
resolveToolPolicy?: (ctx: { cfg: OpenClawConfig; accountId?: string; groupId: string; senderId?: string }) => "full" | "restricted" | "none";
|
|
373
|
+
/** 平台特有的群聊行为提示 */
|
|
374
|
+
resolveGroupIntroHint?: (ctx: { cfg: OpenClawConfig; accountId?: string; groupId: string }) => string | undefined;
|
|
375
|
+
/** 其他适配器方法 */
|
|
376
|
+
[key: string]: unknown;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** @mention 检测与清理适配器(stripMentionText / detectWasMentioned) */
|
|
380
|
+
export interface ChannelMentionAdapter {
|
|
381
|
+
/** 清理 @mention 文本:平台格式→可读格式,去除 @机器人自身 */
|
|
382
|
+
stripMentionText?: (text: string, mentions?: Array<{ member_openid?: string; nickname?: string; is_you?: boolean }>) => string;
|
|
383
|
+
/** 检测当前消息是否 @了机器人 */
|
|
384
|
+
detectWasMentioned?: (ctx: {
|
|
385
|
+
eventType?: string;
|
|
386
|
+
mentions?: Array<{ is_you?: boolean; bot?: boolean }>;
|
|
387
|
+
content?: string;
|
|
388
|
+
mentionPatterns?: string[];
|
|
389
|
+
}) => boolean;
|
|
390
|
+
/** 其他适配器方法 */
|
|
391
|
+
[key: string]: unknown;
|
|
392
|
+
}
|
|
393
|
+
|
|
365
394
|
/**
|
|
366
395
|
* 频道插件接口(泛型)
|
|
367
396
|
*/
|
|
@@ -388,6 +417,10 @@ declare module "openclaw/plugin-sdk" {
|
|
|
388
417
|
outbound?: ChannelPluginOutbound;
|
|
389
418
|
/** Gateway 配置 */
|
|
390
419
|
gateway?: ChannelPluginGateway<TAccount>;
|
|
420
|
+
/** 群消息策略适配器 */
|
|
421
|
+
groups?: ChannelGroupAdapter;
|
|
422
|
+
/** @mention 检测与清理适配器 */
|
|
423
|
+
mentions?: ChannelMentionAdapter;
|
|
391
424
|
/** 启动函数 */
|
|
392
425
|
start?: (runtime: PluginRuntime) => void | Promise<void>;
|
|
393
426
|
/** 停止函数 */
|
|
@@ -512,6 +545,38 @@ declare module "openclaw/plugin-sdk" {
|
|
|
512
545
|
allowTopLevel?: boolean;
|
|
513
546
|
}): OpenClawConfig;
|
|
514
547
|
|
|
548
|
+
// ============ 群访问策略引擎(核心框架标准) ============
|
|
549
|
+
|
|
550
|
+
/** 群组访问策略类型:"open" | "disabled" | "allowlist" */
|
|
551
|
+
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
|
552
|
+
|
|
553
|
+
/** 基于白名单匹配的群访问决策原因 */
|
|
554
|
+
export type MatchedGroupAccessReason =
|
|
555
|
+
| "allowed"
|
|
556
|
+
| "disabled"
|
|
557
|
+
| "missing_match_input"
|
|
558
|
+
| "empty_allowlist"
|
|
559
|
+
| "not_allowlisted";
|
|
560
|
+
|
|
561
|
+
/** 基于白名单匹配的群访问决策结果 */
|
|
562
|
+
export type MatchedGroupAccessDecision = {
|
|
563
|
+
allowed: boolean;
|
|
564
|
+
groupPolicy: GroupPolicy;
|
|
565
|
+
reason: MatchedGroupAccessReason;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 核心框架标准群访问策略评估引擎(基于 policy + allowlist 匹配)
|
|
570
|
+
* @see openclaw/src/plugin-sdk/group-access.ts
|
|
571
|
+
*/
|
|
572
|
+
export function evaluateMatchedGroupAccessForPolicy(params: {
|
|
573
|
+
groupPolicy: GroupPolicy;
|
|
574
|
+
allowlistConfigured: boolean;
|
|
575
|
+
allowlistMatched: boolean;
|
|
576
|
+
requireMatchInput?: boolean;
|
|
577
|
+
hasMatchInput?: boolean;
|
|
578
|
+
}): MatchedGroupAccessDecision;
|
|
579
|
+
|
|
515
580
|
// ============ 其他导出 ============
|
|
516
581
|
|
|
517
582
|
/** 默认账户 ID 常量 */
|
package/src/outbound.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./api.js";
|
|
21
21
|
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
22
22
|
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
23
|
-
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
|
23
|
+
import { chunkedUploadC2C, chunkedUploadGroup, UploadDailyLimitExceededError } from "./utils/chunked-upload.js";
|
|
24
24
|
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
|
25
25
|
import { downloadFile } from "./image-server.js";
|
|
26
26
|
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
|
|
@@ -274,7 +274,7 @@ async function getToken(account: ResolvedQQBotAccount): Promise<string> {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
/**
|
|
277
|
-
* sendPhoto —
|
|
277
|
+
* sendPhoto — 发送图片消息
|
|
278
278
|
*
|
|
279
279
|
* 支持三种来源:
|
|
280
280
|
* - 本地文件路径 → 分片上传
|
|
@@ -365,7 +365,7 @@ export async function sendPhoto(
|
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/**
|
|
368
|
-
* sendVoice —
|
|
368
|
+
* sendVoice — 发送语音消息
|
|
369
369
|
*
|
|
370
370
|
* 支持本地音频文件和公网 URL:
|
|
371
371
|
* - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
|
|
@@ -452,7 +452,7 @@ async function sendVoiceFromLocal(
|
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
/**
|
|
455
|
-
* sendVideoMsg —
|
|
455
|
+
* sendVideoMsg — 发送视频消息
|
|
456
456
|
*
|
|
457
457
|
* 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
|
|
458
458
|
*/
|
|
@@ -533,6 +533,12 @@ async function chunkedUploadAndSend(
|
|
|
533
533
|
} catch (err) {
|
|
534
534
|
const msg = err instanceof Error ? err.message : String(err);
|
|
535
535
|
console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
|
|
536
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
537
|
+
const dir = path.dirname(err.filePath);
|
|
538
|
+
const name = path.basename(err.filePath);
|
|
539
|
+
const size = formatFileSize(err.fileSize);
|
|
540
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
541
|
+
}
|
|
536
542
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
537
543
|
}
|
|
538
544
|
}
|
|
@@ -556,6 +562,12 @@ async function chunkedUploadAndSend(
|
|
|
556
562
|
} catch (err) {
|
|
557
563
|
const msg = err instanceof Error ? err.message : String(err);
|
|
558
564
|
console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
|
|
565
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
566
|
+
const dir = path.dirname(err.filePath);
|
|
567
|
+
const name = path.basename(err.filePath);
|
|
568
|
+
const size = formatFileSize(err.fileSize);
|
|
569
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
570
|
+
}
|
|
559
571
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
560
572
|
}
|
|
561
573
|
}
|
|
@@ -573,7 +585,7 @@ async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, pr
|
|
|
573
585
|
}
|
|
574
586
|
|
|
575
587
|
/**
|
|
576
|
-
* sendDocument —
|
|
588
|
+
* sendDocument — 发送文件消息
|
|
577
589
|
*
|
|
578
590
|
* 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
|
|
579
591
|
*/
|
|
@@ -785,7 +797,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
785
797
|
return lastResult;
|
|
786
798
|
}
|
|
787
799
|
|
|
788
|
-
// ============
|
|
800
|
+
// ============ 主动消息校验 ============
|
|
789
801
|
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
|
790
802
|
if (!replyToId) {
|
|
791
803
|
if (!text || text.trim().length === 0) {
|
package/src/ref-index-store.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { getQQBotDataDir } from "./utils/platform.js";
|
|
19
|
+
import { formatAttachmentTags } from "./group-history.js";
|
|
19
20
|
|
|
20
21
|
// ============ 存储的消息摘要 ============
|
|
21
22
|
|
|
@@ -297,33 +298,10 @@ export function formatRefEntryForAgent(entry: RefIndexEntry): string {
|
|
|
297
298
|
parts.push(entry.content);
|
|
298
299
|
}
|
|
299
300
|
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
switch (att.type) {
|
|
305
|
-
case "image":
|
|
306
|
-
parts.push(`[图片${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
307
|
-
break;
|
|
308
|
-
case "voice":
|
|
309
|
-
if (att.transcript) {
|
|
310
|
-
const sourceMap = { stt: "本地识别", asr: "官方识别", tts: "TTS原文", fallback: "兜底文案" };
|
|
311
|
-
const sourceTag = att.transcriptSource ? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}` : "";
|
|
312
|
-
parts.push(`[语音消息(内容: "${att.transcript}"${sourceTag})${sourceHint}]`);
|
|
313
|
-
} else {
|
|
314
|
-
parts.push(`[语音消息${sourceHint}]`);
|
|
315
|
-
}
|
|
316
|
-
break;
|
|
317
|
-
case "video":
|
|
318
|
-
parts.push(`[视频${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
319
|
-
break;
|
|
320
|
-
case "file":
|
|
321
|
-
parts.push(`[文件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
322
|
-
break;
|
|
323
|
-
default:
|
|
324
|
-
parts.push(`[附件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
301
|
+
// 附件描述(委托 formatAttachmentTags 统一格式化)
|
|
302
|
+
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
|
303
|
+
if (attachmentDesc) {
|
|
304
|
+
parts.push(attachmentDesc);
|
|
327
305
|
}
|
|
328
306
|
|
|
329
307
|
return parts.join(" ") || "[空消息]";
|
package/src/request-context.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
11
11
|
export interface RequestContext {
|
|
12
12
|
/** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
|
|
13
13
|
target: string;
|
|
14
|
+
/** 当前请求的 QQBot 账户 ID(多账户场景) */
|
|
15
|
+
accountId?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
|
|
@@ -37,3 +39,11 @@ export function getRequestContext(): RequestContext | undefined {
|
|
|
37
39
|
export function getRequestTarget(): string | undefined {
|
|
38
40
|
return asyncLocalStorage.getStore()?.target;
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 获取当前请求的账户 ID。
|
|
45
|
+
* 便捷方法,等价于 getRequestContext()?.accountId。
|
|
46
|
+
*/
|
|
47
|
+
export function getRequestAccountId(): string | undefined {
|
|
48
|
+
return asyncLocalStorage.getStore()?.accountId;
|
|
49
|
+
}
|
package/src/slash-commands.ts
CHANGED
|
@@ -27,7 +27,7 @@ let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
|
27
27
|
|
|
28
28
|
// 获取 openclaw 框架版本(缓存结果,只执行一次)
|
|
29
29
|
let _frameworkVersion: string | null = null;
|
|
30
|
-
function getFrameworkVersion(): string {
|
|
30
|
+
export function getFrameworkVersion(): string {
|
|
31
31
|
if (_frameworkVersion !== null) return _frameworkVersion;
|
|
32
32
|
try {
|
|
33
33
|
// 先尝试 PATH 中的 CLI
|
|
@@ -90,7 +90,7 @@ interface UpgradeCompatResult {
|
|
|
90
90
|
* 解析框架版本字符串中的日期版本号
|
|
91
91
|
* 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
|
|
92
92
|
*/
|
|
93
|
-
function parseFrameworkDateVersion(versionStr: string): string | null {
|
|
93
|
+
export function parseFrameworkDateVersion(versionStr: string): string | null {
|
|
94
94
|
const m = versionStr.match(/(\d{4}\.\d{1,2}\.\d{1,2})/);
|
|
95
95
|
return m ? m[1] : null;
|
|
96
96
|
}
|
|
@@ -898,7 +898,7 @@ registerCommand({
|
|
|
898
898
|
].join("\n"),
|
|
899
899
|
handler: async (ctx) => {
|
|
900
900
|
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
901
|
-
const upgradeMode = ctx.accountConfig?.upgradeMode || "
|
|
901
|
+
const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
|
|
902
902
|
const args = ctx.args.trim();
|
|
903
903
|
const info = await getUpdateInfo();
|
|
904
904
|
|
package/src/tools/remind.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import { getRequestTarget } from "../request-context.js";
|
|
2
|
+
import { getRequestTarget, getRequestAccountId } from "../request-context.js";
|
|
3
3
|
|
|
4
4
|
// ========== 类型定义 ==========
|
|
5
5
|
|
|
@@ -134,7 +134,7 @@ function generateJobName(content: string): string {
|
|
|
134
134
|
/**
|
|
135
135
|
* 构建一次性提醒的 cron 工具参数
|
|
136
136
|
*/
|
|
137
|
-
function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
|
|
137
|
+
function buildOnceJob(params: RemindParams, delayMs: number, to: string, accountId: string) {
|
|
138
138
|
const atMs = Date.now() + delayMs;
|
|
139
139
|
const content = params.content!;
|
|
140
140
|
const name = params.name || generateJobName(content);
|
|
@@ -150,9 +150,12 @@ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
|
|
|
150
150
|
payload: {
|
|
151
151
|
kind: "agentTurn",
|
|
152
152
|
message: buildReminderPrompt(content),
|
|
153
|
-
|
|
153
|
+
},
|
|
154
|
+
delivery: {
|
|
155
|
+
mode: "announce",
|
|
154
156
|
channel: "qqbot",
|
|
155
157
|
to,
|
|
158
|
+
accountId,
|
|
156
159
|
},
|
|
157
160
|
},
|
|
158
161
|
};
|
|
@@ -161,7 +164,7 @@ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
|
|
|
161
164
|
/**
|
|
162
165
|
* 构建周期提醒的 cron 工具参数
|
|
163
166
|
*/
|
|
164
|
-
function buildCronJob(params: RemindParams, to: string) {
|
|
167
|
+
function buildCronJob(params: RemindParams, to: string, accountId: string) {
|
|
165
168
|
const content = params.content!;
|
|
166
169
|
const name = params.name || generateJobName(content);
|
|
167
170
|
const tz = params.timezone || "Asia/Shanghai";
|
|
@@ -176,9 +179,12 @@ function buildCronJob(params: RemindParams, to: string) {
|
|
|
176
179
|
payload: {
|
|
177
180
|
kind: "agentTurn",
|
|
178
181
|
message: buildReminderPrompt(content),
|
|
179
|
-
|
|
182
|
+
},
|
|
183
|
+
delivery: {
|
|
184
|
+
mode: "announce",
|
|
180
185
|
channel: "qqbot",
|
|
181
186
|
to,
|
|
187
|
+
accountId,
|
|
182
188
|
},
|
|
183
189
|
},
|
|
184
190
|
};
|
|
@@ -256,6 +262,8 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
|
|
|
256
262
|
if (!resolvedTo) {
|
|
257
263
|
return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
|
|
258
264
|
}
|
|
265
|
+
// 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
|
|
266
|
+
const resolvedAccountId = getRequestAccountId() || "default";
|
|
259
267
|
if (!p.time) {
|
|
260
268
|
return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
|
|
261
269
|
}
|
|
@@ -263,9 +271,9 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
|
|
|
263
271
|
// 判断是 cron 表达式还是相对时间
|
|
264
272
|
if (isCronExpression(p.time)) {
|
|
265
273
|
// 周期提醒
|
|
266
|
-
const cronJob = buildCronJob(p, resolvedTo);
|
|
274
|
+
const cronJob = buildCronJob(p, resolvedTo, resolvedAccountId);
|
|
267
275
|
return json({
|
|
268
|
-
_instruction: "请立即使用 cron
|
|
276
|
+
_instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
|
|
269
277
|
cronParams: cronJob,
|
|
270
278
|
summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
|
|
271
279
|
});
|
|
@@ -285,9 +293,9 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
|
|
|
285
293
|
return json({ error: "提醒时间不能少于 30 秒" });
|
|
286
294
|
}
|
|
287
295
|
|
|
288
|
-
const onceJob = buildOnceJob(p, delayMs, resolvedTo);
|
|
296
|
+
const onceJob = buildOnceJob(p, delayMs, resolvedTo, resolvedAccountId);
|
|
289
297
|
return json({
|
|
290
|
-
_instruction: "请立即使用 cron
|
|
298
|
+
_instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
|
|
291
299
|
cronParams: onceJob,
|
|
292
300
|
summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
|
|
293
301
|
});
|