@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
|
@@ -1,15 +1,97 @@
|
|
|
1
|
-
//
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const
|
|
1
|
+
// ── 消息队列默认配置 ──
|
|
2
|
+
const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
|
|
3
|
+
const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
|
|
4
|
+
const DEFAULT_GROUP_QUEUE_SIZE = 50;
|
|
5
|
+
const DEFAULT_MAX_CONCURRENT_USERS = 10;
|
|
6
|
+
// ── 群消息合并工具函数 ──
|
|
7
|
+
/** 判断 peerId 是否属于群聊 */
|
|
8
|
+
const isGroupPeer = (peerId) => peerId.startsWith("group:") || peerId.startsWith("guild:");
|
|
9
|
+
/**
|
|
10
|
+
* 将多条群消息合并为一条,用于群聊场景下排队消息的批量处理。
|
|
11
|
+
* - content 拼接为多行,每行带发送者前缀
|
|
12
|
+
* - 附件合并
|
|
13
|
+
* - messageId / msgIdx / timestamp 取最后一条(用于回复引用)
|
|
14
|
+
* - mentions 合并去重
|
|
15
|
+
* - 如果有任意一条 @了你(is_you),合并结果也标记 @你
|
|
16
|
+
* - senderIsBot 只要有一条不是 bot 就算非 bot
|
|
17
|
+
*/
|
|
18
|
+
function mergeGroupMessages(batch) {
|
|
19
|
+
if (batch.length === 1)
|
|
20
|
+
return batch[0];
|
|
21
|
+
const last = batch[batch.length - 1];
|
|
22
|
+
const first = batch[0];
|
|
23
|
+
// 拼接内容:每条消息带发送者前缀
|
|
24
|
+
const mergedContent = batch
|
|
25
|
+
.map((m) => {
|
|
26
|
+
const name = m.senderName ?? m.senderId;
|
|
27
|
+
return `[${name}]: ${m.content}`;
|
|
28
|
+
})
|
|
29
|
+
.join("\n");
|
|
30
|
+
// 合并附件
|
|
31
|
+
const mergedAttachments = [];
|
|
32
|
+
for (const m of batch) {
|
|
33
|
+
if (m.attachments?.length) {
|
|
34
|
+
mergedAttachments.push(...m.attachments);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 合并 mentions(去重 by member_openid/id)
|
|
38
|
+
const seenMentionIds = new Set();
|
|
39
|
+
const mergedMentions = [];
|
|
40
|
+
let hasAtYouEvent = false;
|
|
41
|
+
for (const m of batch) {
|
|
42
|
+
if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
|
|
43
|
+
hasAtYouEvent = true;
|
|
44
|
+
}
|
|
45
|
+
if (m.mentions) {
|
|
46
|
+
for (const mt of m.mentions) {
|
|
47
|
+
const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
|
|
48
|
+
if (key && seenMentionIds.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
if (key)
|
|
51
|
+
seenMentionIds.add(key);
|
|
52
|
+
mergedMentions.push(mt);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// senderIsBot: 只要有一条来自非 bot 用户,就算非 bot
|
|
57
|
+
const allFromBot = batch.every((m) => m.senderIsBot);
|
|
58
|
+
return {
|
|
59
|
+
type: last.type,
|
|
60
|
+
senderId: last.senderId,
|
|
61
|
+
senderName: last.senderName,
|
|
62
|
+
senderIsBot: allFromBot,
|
|
63
|
+
content: mergedContent,
|
|
64
|
+
messageId: last.messageId,
|
|
65
|
+
timestamp: last.timestamp,
|
|
66
|
+
channelId: last.channelId,
|
|
67
|
+
guildId: last.guildId,
|
|
68
|
+
groupOpenid: last.groupOpenid,
|
|
69
|
+
attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
|
|
70
|
+
refMsgIdx: first.refMsgIdx,
|
|
71
|
+
msgIdx: last.msgIdx,
|
|
72
|
+
eventType: hasAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
|
|
73
|
+
mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
|
|
74
|
+
messageScene: last.messageScene,
|
|
75
|
+
_mergedCount: batch.length,
|
|
76
|
+
_mergedMessages: batch.length > 1 ? batch : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
5
79
|
/**
|
|
6
80
|
* 创建按用户并发的消息队列(同用户串行,跨用户并行)
|
|
81
|
+
*
|
|
82
|
+
* 内置群消息增强:
|
|
83
|
+
* - 群聊 / 私聊使用不同队列上限
|
|
84
|
+
* - 群聊溢出时优先丢弃 bot 消息
|
|
85
|
+
* - drain 时自动合并群聊排队消息(斜杠命令单独处理)
|
|
7
86
|
*/
|
|
8
87
|
export function createMessageQueue(ctx) {
|
|
9
88
|
const { accountId, log } = ctx;
|
|
89
|
+
const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
|
|
90
|
+
const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
|
|
91
|
+
const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
|
|
92
|
+
const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
|
|
10
93
|
const userQueues = new Map();
|
|
11
94
|
const activeUsers = new Set();
|
|
12
|
-
let messagesProcessed = 0;
|
|
13
95
|
let handleMessageFnRef = null;
|
|
14
96
|
let totalEnqueued = 0;
|
|
15
97
|
const getMessagePeerId = (msg) => {
|
|
@@ -19,11 +101,55 @@ export function createMessageQueue(ctx) {
|
|
|
19
101
|
return `group:${msg.groupOpenid ?? "unknown"}`;
|
|
20
102
|
return `dm:${msg.senderId}`;
|
|
21
103
|
};
|
|
104
|
+
/** 从满队列中淘汰一条消息(群聊优先丢弃 bot 消息,否则丢弃最旧) */
|
|
105
|
+
const evictOne = (queue, isGroup) => {
|
|
106
|
+
if (isGroup) {
|
|
107
|
+
const botIdx = queue.findIndex(m => m.senderIsBot);
|
|
108
|
+
if (botIdx >= 0)
|
|
109
|
+
return queue.splice(botIdx, 1)[0];
|
|
110
|
+
}
|
|
111
|
+
return queue.shift();
|
|
112
|
+
};
|
|
113
|
+
/** 判断消息是否为斜杠指令 */
|
|
114
|
+
const isSlashCommand = (msg) => (msg.content ?? "").trim().startsWith("/");
|
|
115
|
+
/** 处理单条消息,捕获异常并记录日志 */
|
|
116
|
+
const processOne = async (msg, peerId, label) => {
|
|
117
|
+
try {
|
|
118
|
+
await handleMessageFnRef(msg);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
log?.error(`[qqbot:${accountId}] ${label} error for ${peerId}: ${err}`);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
/** 批量处理群聊排队消息:斜杠指令逐条处理,普通消息合并后处理 */
|
|
127
|
+
const drainGroupBatch = async (all, peerId) => {
|
|
128
|
+
const commands = [];
|
|
129
|
+
const normal = [];
|
|
130
|
+
for (const m of all) {
|
|
131
|
+
(isSlashCommand(m) ? commands : normal).push(m);
|
|
132
|
+
}
|
|
133
|
+
// 指令消息逐条处理
|
|
134
|
+
for (const cmd of commands) {
|
|
135
|
+
log?.info(`[qqbot:${accountId}] Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
|
|
136
|
+
await processOne(cmd, peerId, "Command processor");
|
|
137
|
+
}
|
|
138
|
+
// 普通消息合并后处理
|
|
139
|
+
if (normal.length > 0) {
|
|
140
|
+
const merged = mergeGroupMessages(normal);
|
|
141
|
+
if (normal.length > 1) {
|
|
142
|
+
log?.info(`[qqbot:${accountId}] Merged ${normal.length} queued group messages for ${peerId} into one`);
|
|
143
|
+
}
|
|
144
|
+
await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/** 处理指定 peer 队列中的消息(串行) */
|
|
22
148
|
const drainUserQueue = async (peerId) => {
|
|
23
149
|
if (activeUsers.has(peerId))
|
|
24
150
|
return;
|
|
25
|
-
if (activeUsers.size >=
|
|
26
|
-
log?.info(`[qqbot:${accountId}] Max concurrent users (${
|
|
151
|
+
if (activeUsers.size >= maxConcurrentUsers) {
|
|
152
|
+
log?.info(`[qqbot:${accountId}] Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
|
|
27
153
|
return;
|
|
28
154
|
}
|
|
29
155
|
const queue = userQueues.get(peerId);
|
|
@@ -32,26 +158,30 @@ export function createMessageQueue(ctx) {
|
|
|
32
158
|
return;
|
|
33
159
|
}
|
|
34
160
|
activeUsers.add(peerId);
|
|
161
|
+
const isGroup = isGroupPeer(peerId);
|
|
35
162
|
try {
|
|
36
163
|
while (queue.length > 0 && !ctx.isAborted()) {
|
|
164
|
+
// 群聊排队 > 1 条:批量处理
|
|
165
|
+
if (isGroup && queue.length > 1 && handleMessageFnRef) {
|
|
166
|
+
const all = queue.splice(0, queue.length);
|
|
167
|
+
totalEnqueued = Math.max(0, totalEnqueued - all.length);
|
|
168
|
+
await drainGroupBatch(all, peerId);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// 非群聊 或 队列只剩 1 条:逐条处理
|
|
37
172
|
const msg = queue.shift();
|
|
38
173
|
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
await handleMessageFnRef(msg);
|
|
42
|
-
messagesProcessed++;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
catch (err) {
|
|
46
|
-
log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${err}`);
|
|
174
|
+
if (handleMessageFnRef) {
|
|
175
|
+
await processOne(msg, peerId, "Message processor");
|
|
47
176
|
}
|
|
48
177
|
}
|
|
49
178
|
}
|
|
50
179
|
finally {
|
|
51
180
|
activeUsers.delete(peerId);
|
|
52
181
|
userQueues.delete(peerId);
|
|
182
|
+
// 尽量填满并发槽位
|
|
53
183
|
for (const [waitingPeerId, waitingQueue] of userQueues) {
|
|
54
|
-
if (activeUsers.size >=
|
|
184
|
+
if (activeUsers.size >= maxConcurrentUsers)
|
|
55
185
|
break;
|
|
56
186
|
if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
|
|
57
187
|
drainUserQueue(waitingPeerId);
|
|
@@ -61,26 +191,38 @@ export function createMessageQueue(ctx) {
|
|
|
61
191
|
};
|
|
62
192
|
const enqueue = (msg) => {
|
|
63
193
|
const peerId = getMessagePeerId(msg);
|
|
194
|
+
const isGroup = isGroupPeer(peerId);
|
|
64
195
|
let queue = userQueues.get(peerId);
|
|
65
196
|
if (!queue) {
|
|
66
197
|
queue = [];
|
|
67
198
|
userQueues.set(peerId, queue);
|
|
68
199
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
200
|
+
// 群聊和非群聊使用不同的队列上限
|
|
201
|
+
const maxSize = isGroup ? groupQueueSize : peerQueueSize;
|
|
202
|
+
// 队列溢出:淘汰一条旧消息
|
|
203
|
+
if (queue.length >= maxSize) {
|
|
204
|
+
const dropped = evictOne(queue, isGroup);
|
|
205
|
+
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
206
|
+
if (isGroup && dropped?.senderIsBot) {
|
|
207
|
+
log?.info(`[qqbot:${accountId}] Queue full for ${peerId}, dropping bot message ${dropped.messageId}`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
log?.error(`[qqbot:${accountId}] Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
|
|
211
|
+
}
|
|
72
212
|
}
|
|
213
|
+
// 全局总量保护
|
|
73
214
|
totalEnqueued++;
|
|
74
|
-
if (totalEnqueued >
|
|
215
|
+
if (totalEnqueued > globalQueueSize) {
|
|
75
216
|
log?.error(`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
|
|
76
217
|
}
|
|
77
218
|
queue.push(msg);
|
|
78
219
|
log?.debug?.(`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
|
|
220
|
+
// 如果该用户没有正在处理的消息,立即启动处理
|
|
79
221
|
drainUserQueue(peerId);
|
|
80
222
|
};
|
|
81
223
|
const startProcessor = (handleMessageFn) => {
|
|
82
224
|
handleMessageFnRef = handleMessageFn;
|
|
83
|
-
log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${
|
|
225
|
+
log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`);
|
|
84
226
|
};
|
|
85
227
|
const getSnapshot = (senderPeerId) => {
|
|
86
228
|
let totalPending = 0;
|
|
@@ -91,7 +233,7 @@ export function createMessageQueue(ctx) {
|
|
|
91
233
|
return {
|
|
92
234
|
totalPending,
|
|
93
235
|
activeUsers: activeUsers.size,
|
|
94
|
-
maxConcurrentUsers
|
|
236
|
+
maxConcurrentUsers,
|
|
95
237
|
senderPending: senderQueue ? senderQueue.length : 0,
|
|
96
238
|
};
|
|
97
239
|
};
|
package/dist/src/outbound.d.ts
CHANGED
|
@@ -75,7 +75,7 @@ export interface MediaTargetContext {
|
|
|
75
75
|
logPrefix?: string;
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* sendPhoto —
|
|
78
|
+
* sendPhoto — 发送图片消息
|
|
79
79
|
*
|
|
80
80
|
* 支持三种来源:
|
|
81
81
|
* - 本地文件路径 → 分片上传
|
|
@@ -86,7 +86,7 @@ export declare function sendPhoto(ctx: MediaTargetContext, imagePath: string,
|
|
|
86
86
|
/** 原始来源 URL(仅 fallback 路径使用,记录到引用索引) */
|
|
87
87
|
sourceUrl?: string): Promise<OutboundResult>;
|
|
88
88
|
/**
|
|
89
|
-
* sendVoice —
|
|
89
|
+
* sendVoice — 发送语音消息
|
|
90
90
|
*
|
|
91
91
|
* 支持本地音频文件和公网 URL:
|
|
92
92
|
* - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
|
|
@@ -101,13 +101,13 @@ directUploadFormats?: string[],
|
|
|
101
101
|
/** 是否启用转码(默认 true),false 时非原生格式直接返回错误 */
|
|
102
102
|
transcodeEnabled?: boolean): Promise<OutboundResult>;
|
|
103
103
|
/**
|
|
104
|
-
* sendVideoMsg —
|
|
104
|
+
* sendVideoMsg — 发送视频消息
|
|
105
105
|
*
|
|
106
106
|
* 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
|
|
107
107
|
*/
|
|
108
108
|
export declare function sendVideoMsg(ctx: MediaTargetContext, videoPath: string): Promise<OutboundResult>;
|
|
109
109
|
/**
|
|
110
|
-
* sendDocument —
|
|
110
|
+
* sendDocument — 发送文件消息
|
|
111
111
|
*
|
|
112
112
|
* 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
|
|
113
113
|
*/
|
package/dist/src/outbound.js
CHANGED
|
@@ -8,7 +8,7 @@ import { decodeCronPayload } from "./utils/payload.js";
|
|
|
8
8
|
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, } from "./api.js";
|
|
9
9
|
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
10
10
|
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
11
|
-
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
|
11
|
+
import { chunkedUploadC2C, chunkedUploadGroup, UploadDailyLimitExceededError } from "./utils/chunked-upload.js";
|
|
12
12
|
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
|
13
13
|
import { downloadFile } from "./image-server.js";
|
|
14
14
|
import { parseMediaTagsToSendQueue, executeSendQueue } from "./utils/media-send.js";
|
|
@@ -181,7 +181,7 @@ async function getToken(account) {
|
|
|
181
181
|
return getAccessToken(account.appId, account.clientSecret);
|
|
182
182
|
}
|
|
183
183
|
/**
|
|
184
|
-
* sendPhoto —
|
|
184
|
+
* sendPhoto — 发送图片消息
|
|
185
185
|
*
|
|
186
186
|
* 支持三种来源:
|
|
187
187
|
* - 本地文件路径 → 分片上传
|
|
@@ -262,7 +262,7 @@ sourceUrl) {
|
|
|
262
262
|
return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
|
|
263
263
|
}
|
|
264
264
|
/**
|
|
265
|
-
* sendVoice —
|
|
265
|
+
* sendVoice — 发送语音消息
|
|
266
266
|
*
|
|
267
267
|
* 支持本地音频文件和公网 URL:
|
|
268
268
|
* - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
|
|
@@ -328,7 +328,7 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
|
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
/**
|
|
331
|
-
* sendVideoMsg —
|
|
331
|
+
* sendVideoMsg — 发送视频消息
|
|
332
332
|
*
|
|
333
333
|
* 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
|
|
334
334
|
*/
|
|
@@ -393,6 +393,12 @@ sendMeta) {
|
|
|
393
393
|
catch (err) {
|
|
394
394
|
const msg = err instanceof Error ? err.message : String(err);
|
|
395
395
|
console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
|
|
396
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
397
|
+
const dir = path.dirname(err.filePath);
|
|
398
|
+
const name = path.basename(err.filePath);
|
|
399
|
+
const size = formatFileSize(err.fileSize);
|
|
400
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
401
|
+
}
|
|
396
402
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
397
403
|
}
|
|
398
404
|
}
|
|
@@ -412,6 +418,12 @@ sendMeta) {
|
|
|
412
418
|
catch (err) {
|
|
413
419
|
const msg = err instanceof Error ? err.message : String(err);
|
|
414
420
|
console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
|
|
421
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
422
|
+
const dir = path.dirname(err.filePath);
|
|
423
|
+
const name = path.basename(err.filePath);
|
|
424
|
+
const size = formatFileSize(err.fileSize);
|
|
425
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
426
|
+
}
|
|
415
427
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
416
428
|
}
|
|
417
429
|
}
|
|
@@ -425,7 +437,7 @@ async function sendVideoFromLocal(ctx, mediaPath, prefix, sourceUrl) {
|
|
|
425
437
|
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.VIDEO, prefix, "sendVideoMsg", { mediaType: "video", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
426
438
|
}
|
|
427
439
|
/**
|
|
428
|
-
* sendDocument —
|
|
440
|
+
* sendDocument — 发送文件消息
|
|
429
441
|
*
|
|
430
442
|
* 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
|
|
431
443
|
*/
|
|
@@ -612,7 +624,7 @@ export async function sendText(ctx) {
|
|
|
612
624
|
});
|
|
613
625
|
return lastResult;
|
|
614
626
|
}
|
|
615
|
-
// ============
|
|
627
|
+
// ============ 主动消息校验 ============
|
|
616
628
|
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
|
617
629
|
if (!replyToId) {
|
|
618
630
|
if (!text || text.trim().length === 0) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { getQQBotDataDir } from "./utils/platform.js";
|
|
18
|
+
import { formatAttachmentTags } from "./group-history.js";
|
|
18
19
|
// ============ 配置 ============
|
|
19
20
|
const STORAGE_DIR = getQQBotDataDir("data");
|
|
20
21
|
const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
|
|
@@ -220,34 +221,10 @@ export function formatRefEntryForAgent(entry) {
|
|
|
220
221
|
if (entry.content.trim()) {
|
|
221
222
|
parts.push(entry.content);
|
|
222
223
|
}
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
switch (att.type) {
|
|
228
|
-
case "image":
|
|
229
|
-
parts.push(`[图片${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
230
|
-
break;
|
|
231
|
-
case "voice":
|
|
232
|
-
if (att.transcript) {
|
|
233
|
-
const sourceMap = { stt: "本地识别", asr: "官方识别", tts: "TTS原文", fallback: "兜底文案" };
|
|
234
|
-
const sourceTag = att.transcriptSource ? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}` : "";
|
|
235
|
-
parts.push(`[语音消息(内容: "${att.transcript}"${sourceTag})${sourceHint}]`);
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
parts.push(`[语音消息${sourceHint}]`);
|
|
239
|
-
}
|
|
240
|
-
break;
|
|
241
|
-
case "video":
|
|
242
|
-
parts.push(`[视频${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
243
|
-
break;
|
|
244
|
-
case "file":
|
|
245
|
-
parts.push(`[文件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
246
|
-
break;
|
|
247
|
-
default:
|
|
248
|
-
parts.push(`[附件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
224
|
+
// 附件描述(委托 formatAttachmentTags 统一格式化)
|
|
225
|
+
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
|
226
|
+
if (attachmentDesc) {
|
|
227
|
+
parts.push(attachmentDesc);
|
|
251
228
|
}
|
|
252
229
|
return parts.join(" ") || "[空消息]";
|
|
253
230
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export interface RequestContext {
|
|
2
2
|
/** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
|
|
3
3
|
target: string;
|
|
4
|
+
/** 当前请求的 QQBot 账户 ID(多账户场景) */
|
|
5
|
+
accountId?: string;
|
|
4
6
|
}
|
|
5
7
|
/**
|
|
6
8
|
* 在请求级作用域中执行回调。
|
|
@@ -16,3 +18,8 @@ export declare function getRequestContext(): RequestContext | undefined;
|
|
|
16
18
|
* 便捷方法,等价于 getRequestContext()?.target。
|
|
17
19
|
*/
|
|
18
20
|
export declare function getRequestTarget(): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* 获取当前请求的账户 ID。
|
|
23
|
+
* 便捷方法,等价于 getRequestContext()?.accountId。
|
|
24
|
+
*/
|
|
25
|
+
export declare function getRequestAccountId(): string | undefined;
|
|
@@ -28,3 +28,10 @@ export function getRequestContext() {
|
|
|
28
28
|
export function getRequestTarget() {
|
|
29
29
|
return asyncLocalStorage.getStore()?.target;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* 获取当前请求的账户 ID。
|
|
33
|
+
* 便捷方法,等价于 getRequestContext()?.accountId。
|
|
34
|
+
*/
|
|
35
|
+
export function getRequestAccountId() {
|
|
36
|
+
return asyncLocalStorage.getStore()?.accountId;
|
|
37
|
+
}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* 从而计算「开平→插件」和「插件处理」两段耗时
|
|
12
12
|
*/
|
|
13
13
|
import type { QQBotAccountConfig } from "./types.js";
|
|
14
|
+
export declare function getFrameworkVersion(): string;
|
|
15
|
+
/**
|
|
16
|
+
* 解析框架版本字符串中的日期版本号
|
|
17
|
+
* 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseFrameworkDateVersion(versionStr: string): string | null;
|
|
14
20
|
/** 斜杠指令上下文(消息元数据 + 运行时状态) */
|
|
15
21
|
export interface SlashCommandContext {
|
|
16
22
|
/** 消息类型 */
|
|
@@ -23,7 +23,7 @@ const require = createRequire(import.meta.url);
|
|
|
23
23
|
let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
24
24
|
// 获取 openclaw 框架版本(缓存结果,只执行一次)
|
|
25
25
|
let _frameworkVersion = null;
|
|
26
|
-
function getFrameworkVersion() {
|
|
26
|
+
export function getFrameworkVersion() {
|
|
27
27
|
if (_frameworkVersion !== null)
|
|
28
28
|
return _frameworkVersion;
|
|
29
29
|
try {
|
|
@@ -80,7 +80,7 @@ const UPGRADE_REQUIREMENTS = {
|
|
|
80
80
|
* 解析框架版本字符串中的日期版本号
|
|
81
81
|
* 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
|
|
82
82
|
*/
|
|
83
|
-
function parseFrameworkDateVersion(versionStr) {
|
|
83
|
+
export function parseFrameworkDateVersion(versionStr) {
|
|
84
84
|
const m = versionStr.match(/(\d{4}\.\d{1,2}\.\d{1,2})/);
|
|
85
85
|
return m ? m[1] : null;
|
|
86
86
|
}
|
|
@@ -790,7 +790,7 @@ registerCommand({
|
|
|
790
790
|
].join("\n"),
|
|
791
791
|
handler: async (ctx) => {
|
|
792
792
|
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
793
|
-
const upgradeMode = ctx.accountConfig?.upgradeMode || "
|
|
793
|
+
const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
|
|
794
794
|
const args = ctx.args.trim();
|
|
795
795
|
const info = await getUpdateInfo();
|
|
796
796
|
const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
|
package/dist/src/tools/remind.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getRequestTarget } from "../request-context.js";
|
|
1
|
+
import { getRequestTarget, getRequestAccountId } from "../request-context.js";
|
|
2
2
|
// ========== JSON Schema ==========
|
|
3
3
|
const RemindSchema = {
|
|
4
4
|
type: "object",
|
|
@@ -100,7 +100,7 @@ function generateJobName(content) {
|
|
|
100
100
|
/**
|
|
101
101
|
* 构建一次性提醒的 cron 工具参数
|
|
102
102
|
*/
|
|
103
|
-
function buildOnceJob(params, delayMs, to) {
|
|
103
|
+
function buildOnceJob(params, delayMs, to, accountId) {
|
|
104
104
|
const atMs = Date.now() + delayMs;
|
|
105
105
|
const content = params.content;
|
|
106
106
|
const name = params.name || generateJobName(content);
|
|
@@ -115,9 +115,12 @@ function buildOnceJob(params, delayMs, to) {
|
|
|
115
115
|
payload: {
|
|
116
116
|
kind: "agentTurn",
|
|
117
117
|
message: buildReminderPrompt(content),
|
|
118
|
-
|
|
118
|
+
},
|
|
119
|
+
delivery: {
|
|
120
|
+
mode: "announce",
|
|
119
121
|
channel: "qqbot",
|
|
120
122
|
to,
|
|
123
|
+
accountId,
|
|
121
124
|
},
|
|
122
125
|
},
|
|
123
126
|
};
|
|
@@ -125,7 +128,7 @@ function buildOnceJob(params, delayMs, to) {
|
|
|
125
128
|
/**
|
|
126
129
|
* 构建周期提醒的 cron 工具参数
|
|
127
130
|
*/
|
|
128
|
-
function buildCronJob(params, to) {
|
|
131
|
+
function buildCronJob(params, to, accountId) {
|
|
129
132
|
const content = params.content;
|
|
130
133
|
const name = params.name || generateJobName(content);
|
|
131
134
|
const tz = params.timezone || "Asia/Shanghai";
|
|
@@ -139,9 +142,12 @@ function buildCronJob(params, to) {
|
|
|
139
142
|
payload: {
|
|
140
143
|
kind: "agentTurn",
|
|
141
144
|
message: buildReminderPrompt(content),
|
|
142
|
-
|
|
145
|
+
},
|
|
146
|
+
delivery: {
|
|
147
|
+
mode: "announce",
|
|
143
148
|
channel: "qqbot",
|
|
144
149
|
to,
|
|
150
|
+
accountId,
|
|
145
151
|
},
|
|
146
152
|
},
|
|
147
153
|
};
|
|
@@ -211,15 +217,17 @@ export function registerRemindTool(api) {
|
|
|
211
217
|
if (!resolvedTo) {
|
|
212
218
|
return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
|
|
213
219
|
}
|
|
220
|
+
// 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
|
|
221
|
+
const resolvedAccountId = getRequestAccountId() || "default";
|
|
214
222
|
if (!p.time) {
|
|
215
223
|
return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
|
|
216
224
|
}
|
|
217
225
|
// 判断是 cron 表达式还是相对时间
|
|
218
226
|
if (isCronExpression(p.time)) {
|
|
219
227
|
// 周期提醒
|
|
220
|
-
const cronJob = buildCronJob(p, resolvedTo);
|
|
228
|
+
const cronJob = buildCronJob(p, resolvedTo, resolvedAccountId);
|
|
221
229
|
return json({
|
|
222
|
-
_instruction: "请立即使用 cron
|
|
230
|
+
_instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
|
|
223
231
|
cronParams: cronJob,
|
|
224
232
|
summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
|
|
225
233
|
});
|
|
@@ -236,9 +244,9 @@ export function registerRemindTool(api) {
|
|
|
236
244
|
if (delayMs < 30_000) {
|
|
237
245
|
return json({ error: "提醒时间不能少于 30 秒" });
|
|
238
246
|
}
|
|
239
|
-
const onceJob = buildOnceJob(p, delayMs, resolvedTo);
|
|
247
|
+
const onceJob = buildOnceJob(p, delayMs, resolvedTo, resolvedAccountId);
|
|
240
248
|
return json({
|
|
241
|
-
_instruction: "请立即使用 cron
|
|
249
|
+
_instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
|
|
242
250
|
cronParams: onceJob,
|
|
243
251
|
summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
|
|
244
252
|
});
|