@insta-dev01/intclaw 1.0.11 → 1.1.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/LICENSE +1 -1
- package/README.en.md +424 -0
- package/README.md +365 -164
- package/index.ts +28 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +69 -40
- package/src/channel.ts +557 -0
- package/src/config/accounts.ts +230 -0
- package/src/config/schema.ts +144 -0
- package/src/core/connection.ts +733 -0
- package/src/core/message-handler.ts +1268 -0
- package/src/core/provider.ts +106 -0
- package/src/core/state.ts +54 -0
- package/src/directory.ts +95 -0
- package/src/gateway-methods.ts +237 -0
- package/src/onboarding.ts +387 -0
- package/src/policy.ts +19 -0
- package/src/probe.ts +213 -0
- package/src/reply-dispatcher.ts +674 -0
- package/src/runtime.ts +7 -0
- package/src/sdk/helpers.ts +317 -0
- package/src/sdk/types.ts +515 -0
- package/src/secret-input.ts +19 -0
- package/src/services/media/audio.ts +54 -0
- package/src/services/media/chunk-upload.ts +293 -0
- package/src/services/media/common.ts +154 -0
- package/src/services/media/file.ts +70 -0
- package/src/services/media/image.ts +67 -0
- package/src/services/media/index.ts +10 -0
- package/src/services/media/video.ts +162 -0
- package/src/services/media.ts +1134 -0
- package/src/services/messaging/index.ts +16 -0
- package/src/services/messaging/send.ts +137 -0
- package/src/services/messaging.ts +800 -0
- package/src/targets.ts +45 -0
- package/src/types/index.ts +52 -0
- package/src/utils/agent.ts +63 -0
- package/src/utils/async.ts +51 -0
- package/src/utils/constants.ts +9 -0
- package/src/utils/http-client.ts +84 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +78 -0
- package/src/utils/session.ts +118 -0
- package/src/utils/token.ts +94 -0
- package/src/utils/utils-legacy.ts +506 -0
- package/.env.example +0 -11
- package/skills/intclaw_matrix/SKILL.md +0 -20
- package/src/channel/intclaw_channel.js +0 -155
- package/src/index.js +0 -23
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntClaw消息业务处理器
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 处理IntClaw消息的业务逻辑
|
|
6
|
+
* - 支持多种消息类型:text、richText、picture、audio、video、file
|
|
7
|
+
* - 媒体文件下载和上传(图片、语音、视频、文件)
|
|
8
|
+
* - 会话上下文构建和管理
|
|
9
|
+
* - 消息分发(AI Card、命令处理、主动消息)
|
|
10
|
+
* - Policy 检查(DM 白名单、群聊策略)
|
|
11
|
+
*
|
|
12
|
+
* 核心功能:
|
|
13
|
+
* - 消息内容提取和归一化
|
|
14
|
+
* - 媒体文件本地缓存管理
|
|
15
|
+
* - IntClaw API 调用(accessToken、文件下载)
|
|
16
|
+
* - 与 OpenClaw 框架集成(bindings、runtime)
|
|
17
|
+
*/
|
|
18
|
+
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
|
19
|
+
import type { ResolvedIntclawAccount, IntclawConfig } from "../types/index.ts";
|
|
20
|
+
import {
|
|
21
|
+
isMessageProcessed,
|
|
22
|
+
markMessageProcessed,
|
|
23
|
+
buildSessionContext,
|
|
24
|
+
getAccessToken,
|
|
25
|
+
getOapiAccessToken,
|
|
26
|
+
INTCLAW_API,
|
|
27
|
+
INTCLAW_OAPI,
|
|
28
|
+
addEmotionReply,
|
|
29
|
+
recallEmotionReply,
|
|
30
|
+
} from "../utils/utils-legacy.ts";
|
|
31
|
+
import { resolveAgentWorkspaceDir } from "../utils/agent.ts";
|
|
32
|
+
import {
|
|
33
|
+
processLocalImages,
|
|
34
|
+
processVideoMarkers,
|
|
35
|
+
processAudioMarkers,
|
|
36
|
+
processFileMarkers,
|
|
37
|
+
uploadMediaToIntClaw,
|
|
38
|
+
toLocalPath,
|
|
39
|
+
FILE_MARKER_PATTERN,
|
|
40
|
+
VIDEO_MARKER_PATTERN,
|
|
41
|
+
AUDIO_MARKER_PATTERN
|
|
42
|
+
} from "../services/media/index.ts";
|
|
43
|
+
import { sendProactive } from "../services/messaging/index.ts";
|
|
44
|
+
import { createIntclawReplyDispatcher, normalizeSlashCommand } from "../reply-dispatcher.ts";
|
|
45
|
+
import { getIntclawRuntime } from "../runtime.ts";
|
|
46
|
+
import { intclawHttp } from '../utils/http-client.ts';
|
|
47
|
+
import * as fs from 'fs';
|
|
48
|
+
import * as path from 'path';
|
|
49
|
+
import * as os from 'os';
|
|
50
|
+
import mammoth from 'mammoth';
|
|
51
|
+
import pdfParse from 'pdf-parse';
|
|
52
|
+
|
|
53
|
+
// ============ 常量 ============
|
|
54
|
+
|
|
55
|
+
const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
|
|
56
|
+
|
|
57
|
+
const AICardStatus = {
|
|
58
|
+
PROCESSING: '1',
|
|
59
|
+
INPUTING: '2',
|
|
60
|
+
FINISHED: '3',
|
|
61
|
+
EXECUTING: '4',
|
|
62
|
+
FAILED: '5',
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
// ============ 会话级别消息队列 ============
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 会话消息队列管理
|
|
69
|
+
* 用于确保同一会话+agent的消息按顺序处理,避免并发冲突导致AI返回空响应
|
|
70
|
+
* 队列键格式:{sessionId}:{agentId}
|
|
71
|
+
* 这样不同 agent 可以并发处理,同一 agent 的同一会话串行处理
|
|
72
|
+
*/
|
|
73
|
+
const sessionQueues = new Map<string, Promise<void>>();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 清理过期的会话队列(超过5分钟没有新消息的会话+agent)
|
|
77
|
+
*/
|
|
78
|
+
const sessionLastActivity = new Map<string, number>();
|
|
79
|
+
const SESSION_QUEUE_TTL = 5 * 60 * 1000; // 5分钟
|
|
80
|
+
|
|
81
|
+
function cleanupExpiredSessionQueues(): void {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
for (const [queueKey, lastActivity] of sessionLastActivity.entries()) {
|
|
84
|
+
if (now - lastActivity > SESSION_QUEUE_TTL) {
|
|
85
|
+
sessionQueues.delete(queueKey);
|
|
86
|
+
sessionLastActivity.delete(queueKey);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 每分钟清理一次过期队列
|
|
92
|
+
setInterval(cleanupExpiredSessionQueues, 60_000);
|
|
93
|
+
|
|
94
|
+
// ============ 类型定义 ============
|
|
95
|
+
|
|
96
|
+
export type IntclawReactionCreatedEvent = {
|
|
97
|
+
type: "reaction_created";
|
|
98
|
+
channelId: string;
|
|
99
|
+
messageId: string;
|
|
100
|
+
userId: string;
|
|
101
|
+
emoji: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type MonitorIntclawAccountOpts = {
|
|
105
|
+
cfg: ClawdbotConfig;
|
|
106
|
+
account: ResolvedIntclawAccount;
|
|
107
|
+
runtime?: RuntimeEnv;
|
|
108
|
+
abortSignal?: AbortSignal;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ============ Agent 路由解析 ============
|
|
112
|
+
// SDK 会自动处理 bindings 解析,无需手动实现
|
|
113
|
+
|
|
114
|
+
// ============ 消息内容提取 ============
|
|
115
|
+
|
|
116
|
+
interface ExtractedMessage {
|
|
117
|
+
text: string;
|
|
118
|
+
messageType: string;
|
|
119
|
+
imageUrls: string[];
|
|
120
|
+
downloadCodes: string[];
|
|
121
|
+
fileNames: string[];
|
|
122
|
+
atIntclawIds: string[];
|
|
123
|
+
atMobiles: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function extractMessageContent(data: any): ExtractedMessage {
|
|
127
|
+
const msgtype = data.msgtype || 'text';
|
|
128
|
+
switch (msgtype) {
|
|
129
|
+
case 'text': {
|
|
130
|
+
const atIntclawIds = data.text?.at?.atIntclawIds || [];
|
|
131
|
+
const atMobiles = data.text?.at?.atMobiles || [];
|
|
132
|
+
return {
|
|
133
|
+
text: data.text?.content?.trim() || '',
|
|
134
|
+
messageType: 'text',
|
|
135
|
+
imageUrls: [],
|
|
136
|
+
downloadCodes: [],
|
|
137
|
+
fileNames: [],
|
|
138
|
+
atIntclawIds,
|
|
139
|
+
atMobiles
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
case 'richText': {
|
|
143
|
+
const parts = data.content?.richText || [];
|
|
144
|
+
const textParts: string[] = [];
|
|
145
|
+
const imageUrls: string[] = [];
|
|
146
|
+
const downloadCodes: string[] = [];
|
|
147
|
+
const fileNames: string[] = [];
|
|
148
|
+
|
|
149
|
+
for (const part of parts) {
|
|
150
|
+
if (part.text) {
|
|
151
|
+
textParts.push(part.text);
|
|
152
|
+
}
|
|
153
|
+
// 处理图片
|
|
154
|
+
if (part.pictureUrl) {
|
|
155
|
+
imageUrls.push(part.pictureUrl);
|
|
156
|
+
}
|
|
157
|
+
if (part.type === 'picture' && part.downloadCode) {
|
|
158
|
+
imageUrls.push(`downloadCode:${part.downloadCode}`);
|
|
159
|
+
}
|
|
160
|
+
// 处理视频
|
|
161
|
+
if (part.type === 'video' && part.downloadCode) {
|
|
162
|
+
downloadCodes.push(part.downloadCode);
|
|
163
|
+
fileNames.push(part.fileName || 'video.mp4');
|
|
164
|
+
}
|
|
165
|
+
// 处理音频
|
|
166
|
+
if (part.type === 'audio' && part.downloadCode) {
|
|
167
|
+
downloadCodes.push(part.downloadCode);
|
|
168
|
+
fileNames.push(part.fileName || 'audio');
|
|
169
|
+
}
|
|
170
|
+
// 处理文件
|
|
171
|
+
if (part.type === 'file' && part.downloadCode) {
|
|
172
|
+
downloadCodes.push(part.downloadCode);
|
|
173
|
+
fileNames.push(part.fileName || '文件');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const text = textParts.join('') || (imageUrls.length > 0 ? '[图片]' : (downloadCodes.length > 0 ? '[媒体文件]' : '[富文本消息]'));
|
|
178
|
+
return { text, messageType: 'richText', imageUrls, downloadCodes, fileNames, atIntclawIds: [], atMobiles: [] };
|
|
179
|
+
}
|
|
180
|
+
case 'picture': {
|
|
181
|
+
const downloadCode = data.content?.downloadCode || '';
|
|
182
|
+
const pictureUrl = data.content?.pictureUrl || '';
|
|
183
|
+
const imageUrls: string[] = [];
|
|
184
|
+
const downloadCodes: string[] = [];
|
|
185
|
+
|
|
186
|
+
if (pictureUrl) {
|
|
187
|
+
imageUrls.push(pictureUrl);
|
|
188
|
+
}
|
|
189
|
+
if (downloadCode) {
|
|
190
|
+
downloadCodes.push(downloadCode);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { text: '[图片]', messageType: 'picture', imageUrls, downloadCodes, fileNames: [], atIntclawIds: [], atMobiles: [] };
|
|
194
|
+
}
|
|
195
|
+
case 'audio': {
|
|
196
|
+
const audioDownloadCode = data.content?.downloadCode || '';
|
|
197
|
+
const audioFileName = data.content?.fileName || 'audio.amr';
|
|
198
|
+
const downloadCodes: string[] = [];
|
|
199
|
+
const fileNames: string[] = [];
|
|
200
|
+
if (audioDownloadCode) {
|
|
201
|
+
downloadCodes.push(audioDownloadCode);
|
|
202
|
+
fileNames.push(audioFileName);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
text: data.content?.recognition || '[语音消息]',
|
|
206
|
+
messageType: 'audio',
|
|
207
|
+
imageUrls: [],
|
|
208
|
+
downloadCodes,
|
|
209
|
+
fileNames,
|
|
210
|
+
atIntclawIds: [],
|
|
211
|
+
atMobiles: []
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
case 'video': {
|
|
215
|
+
const videoDownloadCode = data.content?.downloadCode || '';
|
|
216
|
+
const videoFileName = data.content?.fileName || 'video.mp4';
|
|
217
|
+
const downloadCodes: string[] = [];
|
|
218
|
+
const fileNames: string[] = [];
|
|
219
|
+
if (videoDownloadCode) {
|
|
220
|
+
downloadCodes.push(videoDownloadCode);
|
|
221
|
+
fileNames.push(videoFileName);
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
text: '[视频]',
|
|
225
|
+
messageType: 'video',
|
|
226
|
+
imageUrls: [],
|
|
227
|
+
downloadCodes,
|
|
228
|
+
fileNames,
|
|
229
|
+
atIntclawIds: [],
|
|
230
|
+
atMobiles: []
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
case 'file': {
|
|
234
|
+
const fileName = data.content?.fileName || '文件';
|
|
235
|
+
const downloadCode = data.content?.downloadCode || '';
|
|
236
|
+
const downloadCodes: string[] = [];
|
|
237
|
+
const fileNames: string[] = [];
|
|
238
|
+
if (downloadCode) {
|
|
239
|
+
downloadCodes.push(downloadCode);
|
|
240
|
+
fileNames.push(fileName);
|
|
241
|
+
}
|
|
242
|
+
return { text: `[文件: ${fileName}]`, messageType: 'file', imageUrls: [], downloadCodes, fileNames, atIntclawIds: [], atMobiles: [] };
|
|
243
|
+
}
|
|
244
|
+
case 'interactiveCard': {
|
|
245
|
+
// 交互式卡片消息(通常是文档分享)
|
|
246
|
+
const actionUrl = data.content?.biz_custom_action_url || '';
|
|
247
|
+
if (actionUrl) {
|
|
248
|
+
// 提取文档链接并格式化
|
|
249
|
+
const text = `[IntClaw文档]\n🔗 ${actionUrl}`;
|
|
250
|
+
return { text, messageType: 'interactiveCard', imageUrls: [], downloadCodes: [], fileNames: [], atIntclawIds: [], atMobiles: [] };
|
|
251
|
+
}
|
|
252
|
+
return { text: '[交互式卡片]', messageType: 'interactiveCard', imageUrls: [], downloadCodes: [], fileNames: [], atIntclawIds: [], atMobiles: [] };
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype, imageUrls: [], downloadCodes: [], fileNames: [], atIntclawIds: [], atMobiles: [] };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============ 图片下载 ============
|
|
260
|
+
|
|
261
|
+
export async function downloadImageToFile(
|
|
262
|
+
downloadUrl: string,
|
|
263
|
+
agentWorkspaceDir: string,
|
|
264
|
+
log?: any,
|
|
265
|
+
): Promise<string | null> {
|
|
266
|
+
try {
|
|
267
|
+
log?.info?.(`开始下载图片: ${downloadUrl.slice(0, 100)}...`);
|
|
268
|
+
const resp = await intclawHttp.get(downloadUrl, {
|
|
269
|
+
proxy: false, // 禁用代理,避免 PAC 文件影响
|
|
270
|
+
|
|
271
|
+
headers: {
|
|
272
|
+
'Content-Type': undefined, // 删除默认的 Content-Type 请求头,让 OSS 签名验证通过
|
|
273
|
+
},
|
|
274
|
+
responseType: 'arraybuffer',
|
|
275
|
+
timeout: 30_000,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const buffer = Buffer.from(resp.data);
|
|
279
|
+
const contentType = resp.headers['content-type'] || 'image/jpeg';
|
|
280
|
+
const ext = contentType.includes('png') ? '.png' : contentType.includes('gif') ? '.gif' : contentType.includes('webp') ? '.webp' : '.jpg';
|
|
281
|
+
// 使用 Agent 工作空间路径
|
|
282
|
+
const mediaDir = path.join(agentWorkspaceDir, 'media', 'inbound');
|
|
283
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
284
|
+
const tmpFile = path.join(mediaDir, `openclaw-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
285
|
+
fs.writeFileSync(tmpFile, buffer);
|
|
286
|
+
|
|
287
|
+
log?.info?.(`图片下载成功: size=${buffer.length} bytes, type=${contentType}, path=${tmpFile}`);
|
|
288
|
+
return tmpFile;
|
|
289
|
+
} catch (err: any) {
|
|
290
|
+
log?.error?.(`图片下载失败: ${err.message}`);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function downloadMediaByCode(
|
|
296
|
+
downloadCode: string,
|
|
297
|
+
config: IntclawConfig,
|
|
298
|
+
agentWorkspaceDir: string,
|
|
299
|
+
log?: any,
|
|
300
|
+
): Promise<string | null> {
|
|
301
|
+
try {
|
|
302
|
+
const token = await getAccessToken(config);
|
|
303
|
+
log?.info?.(`通过 downloadCode 下载媒体: ${downloadCode.slice(0, 30)}...`);
|
|
304
|
+
|
|
305
|
+
const resp = await intclawHttp.post(
|
|
306
|
+
`${INTCLAW_API}/v1.0/robot/messageFiles/download`,
|
|
307
|
+
{ downloadCode, robotCode: config.clientId },
|
|
308
|
+
{
|
|
309
|
+
headers: { 'x-acs-intclaw-access-token': token, 'Content-Type': 'application/json' },
|
|
310
|
+
timeout: 30_000,
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const downloadUrl = resp.data?.downloadUrl;
|
|
315
|
+
if (!downloadUrl) {
|
|
316
|
+
log?.warn?.(`downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return downloadImageToFile(downloadUrl, agentWorkspaceDir, log);
|
|
321
|
+
} catch (err: any) {
|
|
322
|
+
log?.error?.(`downloadCode 下载失败: ${err.message}`);
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function getFileDownloadUrl(
|
|
328
|
+
downloadCode: string,
|
|
329
|
+
fileName: string,
|
|
330
|
+
config: IntclawConfig,
|
|
331
|
+
log?: any,
|
|
332
|
+
): Promise<string | null> {
|
|
333
|
+
try {
|
|
334
|
+
const token = await getAccessToken(config);
|
|
335
|
+
log?.info?.(`获取文件下载链接: ${fileName}`);
|
|
336
|
+
|
|
337
|
+
const resp = await intclawHttp.post(
|
|
338
|
+
`${INTCLAW_API}/v1.0/robot/messageFiles/download`,
|
|
339
|
+
{ downloadCode, robotCode: config.clientId },
|
|
340
|
+
{
|
|
341
|
+
headers: { 'x-acs-intclaw-access-token': token, 'Content-Type': 'application/json' },
|
|
342
|
+
timeout: 30_000,
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const downloadUrl = resp.data?.downloadUrl;
|
|
347
|
+
if (!downloadUrl) {
|
|
348
|
+
log?.warn?.(`downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
log?.info?.(`获取下载链接成功: ${fileName}`);
|
|
353
|
+
return downloadUrl;
|
|
354
|
+
} catch (err: any) {
|
|
355
|
+
log?.error?.(`获取下载链接失败: ${err.message}`);
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ============ 文件下载和解析 ============
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 下载文件到本地
|
|
364
|
+
*/
|
|
365
|
+
export async function downloadFileToLocal(
|
|
366
|
+
downloadUrl: string,
|
|
367
|
+
fileName: string,
|
|
368
|
+
agentWorkspaceDir: string,
|
|
369
|
+
log?: any,
|
|
370
|
+
): Promise<string | null> {
|
|
371
|
+
try {
|
|
372
|
+
log?.info?.(`开始下载文件: ${fileName}`);
|
|
373
|
+
const resp = await intclawHttp.get(downloadUrl, {
|
|
374
|
+
proxy: false, // 禁用代理,避免 PAC 文件影响
|
|
375
|
+
headers: {
|
|
376
|
+
'Content-Type': undefined, // 删除默认的 Content-Type 请求头,让 OSS 签名验证通过
|
|
377
|
+
},
|
|
378
|
+
responseType: 'arraybuffer',
|
|
379
|
+
timeout: 60_000, // 文件可能较大,增加超时时间
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const buffer = Buffer.from(resp.data);
|
|
383
|
+
const mediaDir = path.join(agentWorkspaceDir, 'media', 'inbound');
|
|
384
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
385
|
+
|
|
386
|
+
// 安全过滤文件名
|
|
387
|
+
const sanitizeFileName = (name: string): string => {
|
|
388
|
+
// 移除路径分隔符,防止目录遍历攻击
|
|
389
|
+
let safe = name.replace(/[/\\]/g, '_');
|
|
390
|
+
// 移除或替换危险字符
|
|
391
|
+
safe = safe.replace(/[<>:"|?*\x00-\x1f]/g, '_');
|
|
392
|
+
// 移除开头的点,防止隐藏文件
|
|
393
|
+
safe = safe.replace(/^\.+/, '');
|
|
394
|
+
// 限制长度
|
|
395
|
+
if (safe.length > 200) {
|
|
396
|
+
const ext = path.extname(safe);
|
|
397
|
+
const base = path.basename(safe, ext);
|
|
398
|
+
safe = base.substring(0, 200 - ext.length) + ext;
|
|
399
|
+
}
|
|
400
|
+
// 如果处理后为空,使用默认名称
|
|
401
|
+
if (!safe) {
|
|
402
|
+
safe = 'unnamed_file';
|
|
403
|
+
}
|
|
404
|
+
return safe;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// 保留原始文件名,但添加时间戳避免冲突
|
|
408
|
+
const ext = path.extname(fileName);
|
|
409
|
+
const baseName = path.basename(fileName, ext);
|
|
410
|
+
const timestamp = Date.now();
|
|
411
|
+
const safeBaseName = sanitizeFileName(baseName);
|
|
412
|
+
const safeFileName = `${safeBaseName}-${timestamp}${ext}`;
|
|
413
|
+
const localPath = path.join(mediaDir, safeFileName);
|
|
414
|
+
|
|
415
|
+
fs.writeFileSync(localPath, buffer);
|
|
416
|
+
log?.info?.(`文件下载成功: ${fileName}, size=${buffer.length} bytes, path=${localPath}`);
|
|
417
|
+
return localPath;
|
|
418
|
+
} catch (err: any) {
|
|
419
|
+
console.error(`[ERROR] downloadFileToLocal 异常: ${err.message}`);
|
|
420
|
+
console.error(`[ERROR] 异常堆栈:\n${err.stack}`);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 解析 Word 文档 (.docx)
|
|
427
|
+
*/
|
|
428
|
+
async function parseDocxFile(filePath: string, log?: any): Promise<string | null> {
|
|
429
|
+
try {
|
|
430
|
+
log?.info?.(`开始解析 Word 文档: ${filePath}`);
|
|
431
|
+
const buffer = fs.readFileSync(filePath);
|
|
432
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
433
|
+
const text = result.value.trim();
|
|
434
|
+
|
|
435
|
+
if (text) {
|
|
436
|
+
log?.info?.(`Word 文档解析成功: ${filePath}, 文本长度=${text.length}`);
|
|
437
|
+
return text;
|
|
438
|
+
} else {
|
|
439
|
+
log?.warn?.(`Word 文档解析结果为空: ${filePath}`);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
} catch (err: any) {
|
|
443
|
+
log?.error?.(`Word 文档解析失败: ${filePath}, error=${err.message}`);
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 解析 PDF 文档
|
|
450
|
+
*/
|
|
451
|
+
async function parsePdfFile(filePath: string, log?: any): Promise<string | null> {
|
|
452
|
+
try {
|
|
453
|
+
log?.info?.(`开始解析 PDF 文档: ${filePath}`);
|
|
454
|
+
const buffer = fs.readFileSync(filePath);
|
|
455
|
+
const data = await pdfParse(buffer);
|
|
456
|
+
const text = data.text.trim();
|
|
457
|
+
|
|
458
|
+
if (text) {
|
|
459
|
+
log?.info?.(`PDF 文档解析成功: ${filePath}, 文本长度=${text.length}, 页数=${data.numpages}`);
|
|
460
|
+
return text;
|
|
461
|
+
} else {
|
|
462
|
+
log?.warn?.(`PDF 文档解析结果为空: ${filePath}`);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
} catch (err: any) {
|
|
466
|
+
log?.error?.(`PDF 文档解析失败: ${filePath}, error=${err.message}`);
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 读取纯文本文件
|
|
473
|
+
*/
|
|
474
|
+
async function readTextFile(filePath: string, log?: any): Promise<string | null> {
|
|
475
|
+
try {
|
|
476
|
+
log?.info?.(`开始读取文本文件: ${filePath}`);
|
|
477
|
+
const text = fs.readFileSync(filePath, 'utf-8').trim();
|
|
478
|
+
|
|
479
|
+
if (text) {
|
|
480
|
+
log?.info?.(`文本文件读取成功: ${filePath}, 文本长度=${text.length}`);
|
|
481
|
+
return text;
|
|
482
|
+
} else {
|
|
483
|
+
log?.warn?.(`文本文件内容为空: ${filePath}`);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
} catch (err: any) {
|
|
487
|
+
log?.error?.(`文本文件读取失败: ${filePath}, error=${err.message}`);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 根据文件类型解析文件内容
|
|
494
|
+
*/
|
|
495
|
+
async function parseFileContent(
|
|
496
|
+
filePath: string,
|
|
497
|
+
fileName: string,
|
|
498
|
+
log?: any,
|
|
499
|
+
): Promise<{ content: string | null; type: 'text' | 'binary' }> {
|
|
500
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
501
|
+
|
|
502
|
+
// Word 文档
|
|
503
|
+
if (['.docx', '.doc'].includes(ext)) {
|
|
504
|
+
const content = await parseDocxFile(filePath, log);
|
|
505
|
+
return { content, type: 'text' };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// PDF 文档
|
|
509
|
+
if (ext === '.pdf') {
|
|
510
|
+
const content = await parsePdfFile(filePath, log);
|
|
511
|
+
return { content, type: 'text' };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 纯文本文件
|
|
515
|
+
if (['.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.csv', '.log', '.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.sh', '.bat'].includes(ext)) {
|
|
516
|
+
const content = await readTextFile(filePath, log);
|
|
517
|
+
return { content, type: 'text' };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 二进制文件(不解析)
|
|
521
|
+
return { content: null, type: 'binary' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ============ 消息处理 ============
|
|
525
|
+
|
|
526
|
+
interface HandleMessageParams {
|
|
527
|
+
accountId: string;
|
|
528
|
+
config: IntclawConfig;
|
|
529
|
+
data: any;
|
|
530
|
+
sessionWebhook: string;
|
|
531
|
+
runtime?: RuntimeEnv;
|
|
532
|
+
log?: any;
|
|
533
|
+
cfg: ClawdbotConfig;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 内部消息处理函数(实际执行消息处理逻辑)
|
|
538
|
+
*/
|
|
539
|
+
export async function handleIntClawMessageInternal(params: HandleMessageParams): Promise<void> {
|
|
540
|
+
const { accountId, config, data, sessionWebhook, runtime, log, cfg } = params;
|
|
541
|
+
|
|
542
|
+
const content = extractMessageContent(data);
|
|
543
|
+
if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return;
|
|
544
|
+
|
|
545
|
+
const isDirect = data.conversationType === '1';
|
|
546
|
+
const senderId = data.senderStaffId || data.senderId;
|
|
547
|
+
const senderName = data.senderNick || 'Unknown';
|
|
548
|
+
|
|
549
|
+
// ===== 提前解析 agentId 和工作空间路径 =====
|
|
550
|
+
const chatType = isDirect ? "direct" : "group";
|
|
551
|
+
const peerId = isDirect ? senderId : data.conversationId;
|
|
552
|
+
|
|
553
|
+
// 手动匹配 bindings 获取 agentId
|
|
554
|
+
let matchedAgentId: string | null = null;
|
|
555
|
+
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
556
|
+
for (const binding of cfg.bindings) {
|
|
557
|
+
const match = binding.match;
|
|
558
|
+
if (match.channel && match.channel !== "intclaw-connector") continue;
|
|
559
|
+
if (match.accountId && match.accountId !== accountId) continue;
|
|
560
|
+
if (match.peer) {
|
|
561
|
+
if (match.peer.kind && match.peer.kind !== chatType) continue;
|
|
562
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
|
|
563
|
+
}
|
|
564
|
+
matchedAgentId = binding.agentId;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!matchedAgentId) {
|
|
569
|
+
matchedAgentId = cfg.defaultAgent || 'main';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 获取 Agent 工作空间路径
|
|
573
|
+
const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, matchedAgentId);
|
|
574
|
+
log?.info?.(`Agent 工作空间路径: ${agentWorkspaceDir}`);
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
// ===== DM Policy 检查 =====
|
|
579
|
+
if (isDirect) {
|
|
580
|
+
const dmPolicy = config.dmPolicy || 'open';
|
|
581
|
+
const allowFrom: (string | number)[] = config.allowFrom || [];
|
|
582
|
+
|
|
583
|
+
// 处理 pairing 策略(暂不支持,当作 open 处理并记录警告)
|
|
584
|
+
if (dmPolicy === 'pairing') {
|
|
585
|
+
log?.warn?.(`dmPolicy="pairing" 暂不支持,将按 "open" 策略处理`);
|
|
586
|
+
// 继续执行,不拦截
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 处理 allowlist 策略
|
|
590
|
+
if (dmPolicy === 'allowlist') {
|
|
591
|
+
if (!senderId) {
|
|
592
|
+
log?.warn?.(`DM 被拦截: senderId 为空`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 规范化 senderId 和 allowFrom 进行比较(支持 string 和 number 类型)
|
|
597
|
+
const normalizedSenderId = String(senderId);
|
|
598
|
+
const normalizedAllowFrom = allowFrom.map(id => String(id));
|
|
599
|
+
|
|
600
|
+
// 白名单为空时拦截所有(虽然 Schema 验证会阻止这种情况,但代码层面也要防御)
|
|
601
|
+
if (normalizedAllowFrom.length === 0) {
|
|
602
|
+
log?.warn?.(`[IntClaw] DM 被拦截: allowFrom 白名单为空,拒绝所有请求`);
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
await sendProactive(config, { userId: senderId }, '抱歉,此机器人的访问白名单配置有误。请联系管理员检查配置。', {
|
|
606
|
+
msgType: 'text',
|
|
607
|
+
log,
|
|
608
|
+
});
|
|
609
|
+
} catch (err: any) {
|
|
610
|
+
log?.error?.(`[IntClaw] 发送 DM 配置错误提示失败: ${err.message}`);
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 检查是否在白名单中
|
|
616
|
+
if (!normalizedAllowFrom.includes(normalizedSenderId)) {
|
|
617
|
+
log?.warn?.(`DM 被拦截: senderId=${senderId} (${senderName}) 不在白名单中`);
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
await sendProactive(config, { userId: senderId }, '抱歉,您暂无权限使用此机器人。如需开通权限,请联系管理员。', {
|
|
621
|
+
msgType: 'text',
|
|
622
|
+
log,
|
|
623
|
+
});
|
|
624
|
+
} catch (err: any) {
|
|
625
|
+
log?.error?.(`发送 DM 拦截提示失败: ${err.message}`);
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ===== 群聊 Policy 检查 =====
|
|
633
|
+
if (!isDirect) {
|
|
634
|
+
const groupPolicy = config.groupPolicy || 'open';
|
|
635
|
+
const conversationId = data.conversationId;
|
|
636
|
+
const groupAllowFrom: (string | number)[] = config.groupAllowFrom || [];
|
|
637
|
+
|
|
638
|
+
// 处理 disabled 策略
|
|
639
|
+
if (groupPolicy === 'disabled') {
|
|
640
|
+
log?.warn?.(`群聊被拦截: groupPolicy=disabled`);
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
await sendProactive(config, { openConversationId: conversationId }, '抱歉,此机器人暂不支持群聊功能。', {
|
|
644
|
+
msgType: 'text',
|
|
645
|
+
log,
|
|
646
|
+
});
|
|
647
|
+
} catch (err: any) {
|
|
648
|
+
log?.error?.(`发送群聊 disabled 提示失败: ${err.message}`);
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 处理 allowlist 策略
|
|
654
|
+
if (groupPolicy === 'allowlist') {
|
|
655
|
+
if (!conversationId) {
|
|
656
|
+
log?.warn?.(`群聊被拦截: conversationId 为空`);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 规范化 conversationId 和 groupAllowFrom 进行比较(支持 string 和 number 类型)
|
|
661
|
+
const normalizedConversationId = String(conversationId);
|
|
662
|
+
const normalizedGroupAllowFrom = groupAllowFrom.map(id => String(id));
|
|
663
|
+
|
|
664
|
+
// 白名单为空时拦截所有(虽然 Schema 验证会阻止这种情况,但代码层面也要防御)
|
|
665
|
+
if (normalizedGroupAllowFrom.length === 0) {
|
|
666
|
+
log?.warn?.(`群聊被拦截: groupAllowFrom 白名单为空,拒绝所有请求`);
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
await sendProactive(config, { openConversationId: conversationId }, '抱歉,此机器人的群组访问白名单配置有误。请联系管理员检查配置。', {
|
|
670
|
+
msgType: 'text',
|
|
671
|
+
log,
|
|
672
|
+
});
|
|
673
|
+
} catch (err: any) {
|
|
674
|
+
log?.error?.(`发送群聊配置错误提示失败: ${err.message}`);
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 检查是否在白名单中
|
|
680
|
+
if (!normalizedGroupAllowFrom.includes(normalizedConversationId)) {
|
|
681
|
+
log?.warn?.(`群聊被拦截: conversationId=${conversationId} 不在 groupAllowFrom 白名单中`);
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
await sendProactive(config, { openConversationId: conversationId }, '抱歉,此群组暂无权限使用此机器人。如需开通权限,请联系管理员。', {
|
|
685
|
+
msgType: 'text',
|
|
686
|
+
log,
|
|
687
|
+
});
|
|
688
|
+
} catch (err: any) {
|
|
689
|
+
log?.error?.(`发送群聊 allowlist 提示失败: ${err.message}`);
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 构建会话上下文
|
|
697
|
+
const sessionContext = buildSessionContext({
|
|
698
|
+
accountId,
|
|
699
|
+
senderId,
|
|
700
|
+
senderName,
|
|
701
|
+
conversationType: data.conversationType,
|
|
702
|
+
conversationId: data.conversationId,
|
|
703
|
+
groupSubject: data.conversationTitle,
|
|
704
|
+
separateSessionByConversation: config.separateSessionByConversation,
|
|
705
|
+
groupSessionScope: config.groupSessionScope,
|
|
706
|
+
sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
// 构建消息内容
|
|
711
|
+
// ✅ 使用 normalizeSlashCommand 归一化新会话命令
|
|
712
|
+
const rawText = content.text || '';
|
|
713
|
+
|
|
714
|
+
// 归一化命令(将 /reset、/clear、新会话 等别名统一为 /new)
|
|
715
|
+
const normalizedText = normalizeSlashCommand(rawText);
|
|
716
|
+
let userContent = normalizedText || (content.imageUrls.length > 0 ? '请描述这张图片' : '');
|
|
717
|
+
|
|
718
|
+
// ===== 图片下载到本地文件 =====
|
|
719
|
+
const imageLocalPaths: string[] = [];
|
|
720
|
+
|
|
721
|
+
log?.info?.(`处理消息: accountId=${accountId}, sender=${senderName}, text=${content.text.slice(0, 50)}...`);
|
|
722
|
+
|
|
723
|
+
// 处理 imageUrls(来自富文本消息)
|
|
724
|
+
for (let i = 0; i < content.imageUrls.length; i++) {
|
|
725
|
+
const url = content.imageUrls[i];
|
|
726
|
+
try {
|
|
727
|
+
log?.info?.(`处理图片 ${i + 1}/${content.imageUrls.length}: ${url.slice(0, 50)}...`);
|
|
728
|
+
|
|
729
|
+
if (url.startsWith('downloadCode:')) {
|
|
730
|
+
const code = url.slice('downloadCode:'.length);
|
|
731
|
+
const localPath = await downloadMediaByCode(code, config, agentWorkspaceDir, log);
|
|
732
|
+
if (localPath) {
|
|
733
|
+
imageLocalPaths.push(localPath);
|
|
734
|
+
log?.info?.(`图片下载成功 ${i + 1}/${content.imageUrls.length}`);
|
|
735
|
+
} else {
|
|
736
|
+
log?.warn?.(`图片下载失败 ${i + 1}/${content.imageUrls.length}`);
|
|
737
|
+
}
|
|
738
|
+
} else {
|
|
739
|
+
const localPath = await downloadImageToFile(url, agentWorkspaceDir, log);
|
|
740
|
+
if (localPath) {
|
|
741
|
+
imageLocalPaths.push(localPath);
|
|
742
|
+
log?.info?.(`图片下载成功 ${i + 1}/${content.imageUrls.length}`);
|
|
743
|
+
} else {
|
|
744
|
+
log?.warn?.(`图片下载失败 ${i + 1}/${content.imageUrls.length}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} catch (err: any) {
|
|
748
|
+
log?.error?.(`图片下载异常 ${i + 1}/${content.imageUrls.length}: ${err.message}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 处理 downloadCodes(来自 picture 消息,fileNames 为空的是图片)
|
|
753
|
+
for (let i = 0; i < content.downloadCodes.length; i++) {
|
|
754
|
+
const code = content.downloadCodes[i];
|
|
755
|
+
const fileName = content.fileNames[i];
|
|
756
|
+
if (!fileName) {
|
|
757
|
+
try {
|
|
758
|
+
log?.info?.(`处理 downloadCode 图片 ${i + 1}/${content.downloadCodes.length}`);
|
|
759
|
+
const localPath = await downloadMediaByCode(code, config, agentWorkspaceDir, log);
|
|
760
|
+
if (localPath) {
|
|
761
|
+
imageLocalPaths.push(localPath);
|
|
762
|
+
log?.info?.(`downloadCode 图片下载成功 ${i + 1}/${content.downloadCodes.length}`);
|
|
763
|
+
} else {
|
|
764
|
+
log?.warn?.(`downloadCode 图片下载失败 ${i + 1}/${content.downloadCodes.length}`);
|
|
765
|
+
}
|
|
766
|
+
} catch (err: any) {
|
|
767
|
+
log?.error?.(`downloadCode 图片下载异常 ${i + 1}/${content.downloadCodes.length}: ${err.message}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
log?.info?.(`图片下载完成: 成功=${imageLocalPaths.length}, 总数=${content.imageUrls.length + content.downloadCodes.filter((_, i) => !content.fileNames[i]).length}`);
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
// ===== 文件附件处理:自动下载并解析内容 =====
|
|
777
|
+
const fileContentParts: string[] = [];
|
|
778
|
+
for (let i = 0; i < content.downloadCodes.length; i++) {
|
|
779
|
+
const code = content.downloadCodes[i];
|
|
780
|
+
const fileName = content.fileNames[i];
|
|
781
|
+
if (!fileName) continue;
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
log?.info?.(`处理文件附件 ${i + 1}/${content.downloadCodes.length}: ${fileName}`);
|
|
785
|
+
|
|
786
|
+
// 获取下载链接
|
|
787
|
+
const downloadUrl = await getFileDownloadUrl(code, fileName, config, log);
|
|
788
|
+
if (!downloadUrl) {
|
|
789
|
+
fileContentParts.push(`⚠️ 文件获取失败: ${fileName}`);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// 下载文件到本地
|
|
794
|
+
const localPath = await downloadFileToLocal(downloadUrl, fileName, agentWorkspaceDir, log);
|
|
795
|
+
if (!localPath) {
|
|
796
|
+
fileContentParts.push(`⚠️ 文件下载失败: ${fileName}\n🔗 [点击下载](${downloadUrl})`);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 识别文件类型
|
|
801
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
802
|
+
let fileType = '文件';
|
|
803
|
+
|
|
804
|
+
if (['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'].includes(ext)) {
|
|
805
|
+
fileType = '视频';
|
|
806
|
+
} else if (['.mp3', '.wav', '.aac', '.ogg', '.m4a', '.flac', '.wma'].includes(ext)) {
|
|
807
|
+
fileType = '音频';
|
|
808
|
+
} else if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
|
809
|
+
fileType = '图片';
|
|
810
|
+
} else if (['.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.csv', '.log', '.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.sh', '.bat'].includes(ext)) {
|
|
811
|
+
fileType = '文本文件';
|
|
812
|
+
} else if (['.docx', '.doc'].includes(ext)) {
|
|
813
|
+
fileType = 'Word 文档';
|
|
814
|
+
} else if (ext === '.pdf') {
|
|
815
|
+
fileType = 'PDF 文档';
|
|
816
|
+
} else if (['.xlsx', '.xls'].includes(ext)) {
|
|
817
|
+
fileType = 'Excel 表格';
|
|
818
|
+
} else if (['.pptx', '.ppt'].includes(ext)) {
|
|
819
|
+
fileType = 'PPT 演示文稿';
|
|
820
|
+
} else if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
|
821
|
+
fileType = '压缩包';
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 解析文件内容
|
|
825
|
+
const parseResult = await parseFileContent(localPath, fileName, log);
|
|
826
|
+
|
|
827
|
+
if (parseResult.type === 'text' && parseResult.content) {
|
|
828
|
+
// 文本类文件:将内容注入到上下文(即使解析成功也给出文件路径)
|
|
829
|
+
const contentPreview = parseResult.content.length > 200
|
|
830
|
+
? parseResult.content.slice(0, 200) + '...'
|
|
831
|
+
: parseResult.content;
|
|
832
|
+
|
|
833
|
+
fileContentParts.push(
|
|
834
|
+
`📄 **${fileType}**: ${fileName}\n` +
|
|
835
|
+
`✅ 已解析文件内容(${parseResult.content.length} 字符)\n` +
|
|
836
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
837
|
+
`📝 内容预览:\n\`\`\`\n${contentPreview}\n\`\`\`\n\n` +
|
|
838
|
+
`📋 完整内容:\n${parseResult.content}`
|
|
839
|
+
);
|
|
840
|
+
log?.info?.(`文件解析成功: ${fileName}, 内容长度=${parseResult.content.length}`);
|
|
841
|
+
} else if (parseResult.type === 'text' && !parseResult.content) {
|
|
842
|
+
// 文本类文件但解析失败
|
|
843
|
+
fileContentParts.push(
|
|
844
|
+
`📄 **${fileType}**: ${fileName}\n` +
|
|
845
|
+
`⚠️ 文件解析失败,已保存到本地\n` +
|
|
846
|
+
`💾 本地路径: ${localPath}\n` +
|
|
847
|
+
`🔗 [点击下载](${downloadUrl})`
|
|
848
|
+
);
|
|
849
|
+
log?.warn?.(`文件解析失败: ${fileName}`);
|
|
850
|
+
} else {
|
|
851
|
+
// 二进制文件:只保存到磁盘
|
|
852
|
+
// 特殊处理音频文件的语音识别文本
|
|
853
|
+
if (fileType === '音频' && content.text && content.text !== '[语音消息]') {
|
|
854
|
+
fileContentParts.push(
|
|
855
|
+
`🎤 **${fileType}**: ${fileName}\n` +
|
|
856
|
+
`📝 语音识别: ${content.text}\n` +
|
|
857
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
858
|
+
`🔗 [点击下载](${downloadUrl})`
|
|
859
|
+
);
|
|
860
|
+
} else {
|
|
861
|
+
fileContentParts.push(
|
|
862
|
+
`📎 **${fileType}**: ${fileName}\n` +
|
|
863
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
864
|
+
`🔗 [点击下载](${downloadUrl})`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
log?.info?.(`二进制文件已保存: ${fileName}, path=${localPath}`);
|
|
868
|
+
}
|
|
869
|
+
} catch (err: any) {
|
|
870
|
+
log?.error?.(`文件处理异常: ${fileName}, error=${err.message}`);
|
|
871
|
+
fileContentParts.push(`⚠️ 文件处理失败: ${fileName}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (fileContentParts.length > 0) {
|
|
876
|
+
const fileText = fileContentParts.join('\n\n');
|
|
877
|
+
userContent = userContent ? `${userContent}\n\n${fileText}` : fileText;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (!userContent && imageLocalPaths.length === 0) return;
|
|
881
|
+
|
|
882
|
+
// ===== 贴处理中表情 =====
|
|
883
|
+
addEmotionReply(config, data, log).catch(err => {
|
|
884
|
+
log?.warn?.(`贴表情失败: ${err.message}`);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 =====
|
|
888
|
+
const asyncMode = config.asyncMode === true;
|
|
889
|
+
log?.info?.(`asyncMode 检测: config.asyncMode=${config.asyncMode}, asyncMode=${asyncMode}`);
|
|
890
|
+
|
|
891
|
+
const proactiveTarget = isDirect
|
|
892
|
+
? { userId: senderId }
|
|
893
|
+
: { openConversationId: data.conversationId };
|
|
894
|
+
|
|
895
|
+
if (asyncMode) {
|
|
896
|
+
log?.info?.(`进入异步模式分支`);
|
|
897
|
+
const ackText = config.ackText || '🫡 任务已接收,处理中...';
|
|
898
|
+
try {
|
|
899
|
+
await sendProactive(config, proactiveTarget, ackText, {
|
|
900
|
+
msgType: 'text',
|
|
901
|
+
log,
|
|
902
|
+
});
|
|
903
|
+
} catch (ackErr: any) {
|
|
904
|
+
log?.warn?.(`Failed to send acknowledgment: ${ackErr?.message || ackErr}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ===== 使用 SDK 的 dispatchReplyFromConfig =====
|
|
909
|
+
try {
|
|
910
|
+
const core = getIntclawRuntime();
|
|
911
|
+
|
|
912
|
+
// 构建消息体(添加图片)
|
|
913
|
+
let finalContent = userContent;
|
|
914
|
+
if (imageLocalPaths.length > 0) {
|
|
915
|
+
const imageMarkdown = imageLocalPaths.map(p => ``).join('\n');
|
|
916
|
+
finalContent = finalContent ? `${finalContent}\n\n${imageMarkdown}` : imageMarkdown;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 构建 envelope 格式的消息
|
|
920
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
921
|
+
const envelopeFrom = isDirect ? senderId : `${data.conversationId}:${senderId}`;
|
|
922
|
+
|
|
923
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
924
|
+
channel: "IntClaw",
|
|
925
|
+
from: envelopeFrom,
|
|
926
|
+
timestamp: new Date(),
|
|
927
|
+
envelope: envelopeOptions,
|
|
928
|
+
body: finalContent,
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// 手动实现路由匹配(支持通配符 *)
|
|
932
|
+
const chatType = isDirect ? "direct" : "group";
|
|
933
|
+
const peerId = isDirect ? senderId : data.conversationId;
|
|
934
|
+
|
|
935
|
+
// 手动匹配 bindings(支持通配符 *)
|
|
936
|
+
let matchedAgentId: string | null = null;
|
|
937
|
+
let matchedBy = 'default';
|
|
938
|
+
|
|
939
|
+
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
940
|
+
for (const binding of cfg.bindings) {
|
|
941
|
+
const match = binding.match;
|
|
942
|
+
|
|
943
|
+
// 检查 channel
|
|
944
|
+
if (match.channel && match.channel !== "intclaw-connector") {
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// 检查 accountId
|
|
949
|
+
if (match.accountId && match.accountId !== accountId) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// 检查 peer
|
|
954
|
+
if (match.peer) {
|
|
955
|
+
// 检查 peer.kind
|
|
956
|
+
if (match.peer.kind && match.peer.kind !== sessionContext.chatType) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ✅ 使用 sessionContext.peerId 进行匹配(支持通配符 *)
|
|
961
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== sessionContext.peerId) {
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// 匹配成功
|
|
967
|
+
matchedAgentId = binding.agentId;
|
|
968
|
+
matchedBy = 'binding';
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// 如果没有匹配到,使用默认 agent
|
|
974
|
+
if (!matchedAgentId) {
|
|
975
|
+
matchedAgentId = cfg.defaultAgent || 'main';
|
|
976
|
+
log?.info?.(`未匹配到 binding,使用默认 agent: ${matchedAgentId}`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ✅ 使用 SDK 标准方法构建 sessionKey,符合 OpenClaw 规范
|
|
980
|
+
// 格式:agent:{agentId}:{channel}:{peerKind}:{peerId}
|
|
981
|
+
// ✅ 修复:使用 sessionContext.peerId,确保会话隔离配置生效
|
|
982
|
+
const sessionKey = core.channel.routing.buildAgentSessionKey({
|
|
983
|
+
agentId: matchedAgentId,
|
|
984
|
+
channel: 'intclaw', // ✅ 使用 'intclaw' 而不是 'intclaw-connector'
|
|
985
|
+
accountId: accountId,
|
|
986
|
+
peer: {
|
|
987
|
+
kind: sessionContext.chatType, // ✅ 使用 sessionContext.chatType
|
|
988
|
+
id: sessionContext.peerId, // ✅ 使用 sessionContext.peerId(包含会话隔离逻辑)
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
log?.info?.(`路由解析完成: agentId=${matchedAgentId}, sessionKey=${sessionKey}, matchedBy=${matchedBy}`);
|
|
992
|
+
|
|
993
|
+
// 构建 inbound context,使用解析后的 sessionKey
|
|
994
|
+
log?.info?.(`开始构建 inbound context...`);
|
|
995
|
+
|
|
996
|
+
// ✅ 计算正确的 To 字段
|
|
997
|
+
const toField = isDirect ? senderId : data.conversationId;
|
|
998
|
+
log?.info?.(`构建 inbound context: isDirect=${isDirect}, senderId=${senderId}, conversationId=${data.conversationId}, To=${toField}`);
|
|
999
|
+
|
|
1000
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
1001
|
+
Body: body,
|
|
1002
|
+
BodyForAgent: finalContent,
|
|
1003
|
+
RawBody: userContent,
|
|
1004
|
+
CommandBody: userContent,
|
|
1005
|
+
From: senderId,
|
|
1006
|
+
To: toField, // ✅ 修复:单聊用 senderId,群聊用 conversationId
|
|
1007
|
+
SessionKey: sessionKey, // ✅ 使用手动匹配的 sessionKey
|
|
1008
|
+
AccountId: accountId,
|
|
1009
|
+
ChatType: chatType,
|
|
1010
|
+
GroupSubject: isDirect ? undefined : data.conversationId,
|
|
1011
|
+
SenderName: senderId,
|
|
1012
|
+
SenderId: senderId,
|
|
1013
|
+
Provider: "intclaw" as const,
|
|
1014
|
+
Surface: "intclaw" as const,
|
|
1015
|
+
MessageSid: data.msgId,
|
|
1016
|
+
Timestamp: Date.now(),
|
|
1017
|
+
CommandAuthorized: true,
|
|
1018
|
+
OriginatingChannel: "intclaw" as const,
|
|
1019
|
+
OriginatingTo: toField, // ✅ 修复:应该使用 toField,而不是 accountId
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// 创建 reply dispatcher,使用解析后的 agentId
|
|
1023
|
+
const { dispatcher, replyOptions, markDispatchIdle, getAsyncModeResponse } = createIntclawReplyDispatcher({
|
|
1024
|
+
cfg,
|
|
1025
|
+
agentId: matchedAgentId, // ✅ 使用手动匹配的 agentId
|
|
1026
|
+
runtime: runtime as RuntimeEnv,
|
|
1027
|
+
conversationId: data.conversationId,
|
|
1028
|
+
senderId,
|
|
1029
|
+
isDirect,
|
|
1030
|
+
accountId,
|
|
1031
|
+
messageCreateTimeMs: Date.now(),
|
|
1032
|
+
sessionWebhook: data.sessionWebhook,
|
|
1033
|
+
asyncMode,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// 使用 SDK 的 dispatchReplyFromConfig
|
|
1037
|
+
log?.info?.(`调用 withReplyDispatcher,asyncMode=${asyncMode}`);
|
|
1038
|
+
log?.info?.(`准备调用 withReplyDispatcher...`);
|
|
1039
|
+
|
|
1040
|
+
let dispatchResult;
|
|
1041
|
+
try {
|
|
1042
|
+
dispatchResult = await core.channel.reply.withReplyDispatcher({
|
|
1043
|
+
dispatcher,
|
|
1044
|
+
onSettled: () => {
|
|
1045
|
+
log?.info?.(`onSettled 被调用`);
|
|
1046
|
+
markDispatchIdle();
|
|
1047
|
+
},
|
|
1048
|
+
run: async () => {
|
|
1049
|
+
log?.info?.(`run 被调用,开始 dispatchReplyFromConfig`);
|
|
1050
|
+
log?.info?.(`ctxPayload.SessionKey=${ctxPayload.SessionKey}`);
|
|
1051
|
+
log?.info?.(`ctxPayload.Body 长度=${ctxPayload.Body?.length || 0}`);
|
|
1052
|
+
log?.info?.(`replyOptions keys=${Object.keys(replyOptions).join(',')}`);
|
|
1053
|
+
|
|
1054
|
+
const result = await core.channel.reply.dispatchReplyFromConfig({
|
|
1055
|
+
ctx: ctxPayload,
|
|
1056
|
+
cfg,
|
|
1057
|
+
dispatcher,
|
|
1058
|
+
replyOptions,
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
log?.info?.(`dispatchReplyFromConfig 返回: queuedFinal=${result.queuedFinal}, counts=${JSON.stringify(result.counts)}`);
|
|
1062
|
+
return result;
|
|
1063
|
+
},
|
|
1064
|
+
});
|
|
1065
|
+
log?.info?.(`withReplyDispatcher 返回成功`);
|
|
1066
|
+
} catch (dispatchErr: any) {
|
|
1067
|
+
log?.error?.(`withReplyDispatcher 抛出异常: ${dispatchErr?.message || dispatchErr}`);
|
|
1068
|
+
log?.error?.(`异常堆栈: ${dispatchErr?.stack || 'no stack'}`);
|
|
1069
|
+
log?.error?.(`消息处理异常,但不阻塞后续消息: ${dispatchErr?.message || dispatchErr}`);
|
|
1070
|
+
|
|
1071
|
+
// ⚠️ 不要直接 throw,避免阻塞后续消息处理
|
|
1072
|
+
// 记录错误后继续执行,确保后续消息能正常处理
|
|
1073
|
+
dispatchResult = { queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const { queuedFinal, counts } = dispatchResult;
|
|
1077
|
+
log?.info?.(`SDK dispatch 完成: queuedFinal=${queuedFinal}, replies=${counts.final}, asyncMode=${asyncMode}`);
|
|
1078
|
+
|
|
1079
|
+
// ===== 异步模式:主动推送最终结果 =====
|
|
1080
|
+
if (asyncMode) {
|
|
1081
|
+
try {
|
|
1082
|
+
const fullResponse = getAsyncModeResponse();
|
|
1083
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
1084
|
+
let finalText = fullResponse;
|
|
1085
|
+
|
|
1086
|
+
if (oapiToken) {
|
|
1087
|
+
finalText = await processLocalImages(finalText, oapiToken, log);
|
|
1088
|
+
|
|
1089
|
+
const mediaTarget: AICardTarget = isDirect
|
|
1090
|
+
? { type: 'user', userId: senderId }
|
|
1091
|
+
: { type: 'group', openConversationId: data.conversationId };
|
|
1092
|
+
|
|
1093
|
+
// ✅ 处理 Markdown 标记格式的媒体文件
|
|
1094
|
+
finalText = await processVideoMarkers(
|
|
1095
|
+
finalText,
|
|
1096
|
+
'',
|
|
1097
|
+
config,
|
|
1098
|
+
oapiToken,
|
|
1099
|
+
log,
|
|
1100
|
+
true, // ✅ 使用主动 API 模式
|
|
1101
|
+
mediaTarget
|
|
1102
|
+
);
|
|
1103
|
+
finalText = await processAudioMarkers(
|
|
1104
|
+
finalText,
|
|
1105
|
+
'',
|
|
1106
|
+
config,
|
|
1107
|
+
oapiToken,
|
|
1108
|
+
log,
|
|
1109
|
+
true, // ✅ 使用主动 API 模式
|
|
1110
|
+
mediaTarget
|
|
1111
|
+
);
|
|
1112
|
+
finalText = await processFileMarkers(
|
|
1113
|
+
finalText,
|
|
1114
|
+
'',
|
|
1115
|
+
config,
|
|
1116
|
+
oapiToken,
|
|
1117
|
+
log,
|
|
1118
|
+
true, // ✅ 使用主动 API 模式
|
|
1119
|
+
mediaTarget
|
|
1120
|
+
);
|
|
1121
|
+
|
|
1122
|
+
// ✅ 处理裸露的本地文件路径(绕过 OpenClaw SDK 的 bug)
|
|
1123
|
+
const { processRawMediaPaths } = await import('../services/media.js');
|
|
1124
|
+
finalText = await processRawMediaPaths(
|
|
1125
|
+
finalText,
|
|
1126
|
+
config,
|
|
1127
|
+
oapiToken,
|
|
1128
|
+
log,
|
|
1129
|
+
mediaTarget
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const textToSend = finalText.trim() || '✅ 任务执行完成(无文本输出)';
|
|
1134
|
+
const title =
|
|
1135
|
+
textToSend.split('\n')[0]?.replace(/^[#*\s\->]+/, '').trim() || '消息';
|
|
1136
|
+
await sendProactive(config, proactiveTarget, textToSend, {
|
|
1137
|
+
msgType: 'markdown',
|
|
1138
|
+
title,
|
|
1139
|
+
log,
|
|
1140
|
+
});
|
|
1141
|
+
} catch (asyncErr: any) {
|
|
1142
|
+
const errMsg = `⚠️ 任务执行失败: ${asyncErr?.message || asyncErr}`;
|
|
1143
|
+
try {
|
|
1144
|
+
await sendProactive(config, proactiveTarget, errMsg, {
|
|
1145
|
+
msgType: 'text',
|
|
1146
|
+
log,
|
|
1147
|
+
});
|
|
1148
|
+
} catch (sendErr: any) {
|
|
1149
|
+
log?.error?.(`错误通知发送失败: ${sendErr?.message || sendErr}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
} catch (err: any) {
|
|
1155
|
+
log?.error?.(`SDK dispatch 失败: ${err.message}`);
|
|
1156
|
+
|
|
1157
|
+
// 降级:发送错误消息
|
|
1158
|
+
try {
|
|
1159
|
+
const token = await getAccessToken(config);
|
|
1160
|
+
const body: any = {
|
|
1161
|
+
msgtype: 'text',
|
|
1162
|
+
text: { content: `抱歉,处理请求时出错: ${err.message}` }
|
|
1163
|
+
};
|
|
1164
|
+
if (!isDirect) body.at = { atUserIds: [senderId], isAtAll: false };
|
|
1165
|
+
|
|
1166
|
+
await intclawHttp.post(sessionWebhook, body, {
|
|
1167
|
+
headers: { 'x-acs-intclaw-access-token': token, 'Content-Type': 'application/json' },
|
|
1168
|
+
});
|
|
1169
|
+
} catch (fallbackErr: any) {
|
|
1170
|
+
log?.error?.(`错误消息发送也失败: ${fallbackErr.message}`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// ===== 撤回处理中表情 =====
|
|
1175
|
+
// 使用 await 确保表情撤销完成后再结束函数
|
|
1176
|
+
try {
|
|
1177
|
+
await recallEmotionReply(config, data, log);
|
|
1178
|
+
} catch (err: any) {
|
|
1179
|
+
log?.warn?.(`撤回表情异常: ${err.message}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* 消息处理入口函数(带队列管理)
|
|
1185
|
+
* 确保同一会话+agent的消息按顺序处理,避免并发冲突
|
|
1186
|
+
*/
|
|
1187
|
+
export async function handleIntClawMessage(params: HandleMessageParams): Promise<void> {
|
|
1188
|
+
const { accountId, data, log, cfg } = params;
|
|
1189
|
+
|
|
1190
|
+
// 构建会话标识(与会话上下文保持一致)
|
|
1191
|
+
const isDirect = data.conversationType === '1';
|
|
1192
|
+
const senderId = data.senderStaffId || data.senderId;
|
|
1193
|
+
const conversationId = data.conversationId;
|
|
1194
|
+
const baseSessionId = isDirect ? senderId : conversationId;
|
|
1195
|
+
|
|
1196
|
+
if (!baseSessionId) {
|
|
1197
|
+
log?.warn?.('无法构建会话标识,跳过队列管理');
|
|
1198
|
+
return handleIntClawMessageInternal(params);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// 解析 agentId(与消息处理逻辑保持一致)
|
|
1202
|
+
const chatType = isDirect ? "direct" : "group";
|
|
1203
|
+
const peerId = isDirect ? senderId : conversationId;
|
|
1204
|
+
|
|
1205
|
+
let matchedAgentId: string | null = null;
|
|
1206
|
+
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
1207
|
+
for (const binding of cfg.bindings) {
|
|
1208
|
+
const match = binding.match;
|
|
1209
|
+
if (match.channel && match.channel !== "intclaw-connector") continue;
|
|
1210
|
+
if (match.accountId && match.accountId !== accountId) continue;
|
|
1211
|
+
if (match.peer) {
|
|
1212
|
+
if (match.peer.kind && match.peer.kind !== chatType) continue;
|
|
1213
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
|
|
1214
|
+
}
|
|
1215
|
+
matchedAgentId = binding.agentId;
|
|
1216
|
+
break;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (!matchedAgentId) {
|
|
1220
|
+
matchedAgentId = cfg.defaultAgent || 'main';
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// 构建队列标识:会话 + agentId
|
|
1224
|
+
// 这样不同 agent 可以并发处理,同一 agent 的同一会话串行处理
|
|
1225
|
+
const queueKey = `${baseSessionId}:${matchedAgentId}`;
|
|
1226
|
+
|
|
1227
|
+
try {
|
|
1228
|
+
|
|
1229
|
+
// 更新会话活跃时间
|
|
1230
|
+
sessionLastActivity.set(queueKey, Date.now());
|
|
1231
|
+
|
|
1232
|
+
// 获取该会话+agent的上一个处理任务
|
|
1233
|
+
const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
|
|
1234
|
+
|
|
1235
|
+
// 创建当前消息的处理任务
|
|
1236
|
+
const currentTask = previousTask
|
|
1237
|
+
.then(async () => {
|
|
1238
|
+
log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
|
|
1239
|
+
await handleIntClawMessageInternal(params);
|
|
1240
|
+
log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
|
|
1241
|
+
})
|
|
1242
|
+
.catch((err: any) => {
|
|
1243
|
+
log?.error?.(`[队列] 消息处理异常,queueKey=${queueKey}, error=${err.message}`);
|
|
1244
|
+
// 不抛出错误,避免阻塞后续消息
|
|
1245
|
+
})
|
|
1246
|
+
.finally(() => {
|
|
1247
|
+
// 如果当前任务是队列中的最后一个任务,清理队列
|
|
1248
|
+
if (sessionQueues.get(queueKey) === currentTask) {
|
|
1249
|
+
sessionQueues.delete(queueKey);
|
|
1250
|
+
log?.info?.(`[队列] 队列已清空,queueKey=${queueKey}`);
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// 更新队列
|
|
1255
|
+
sessionQueues.set(queueKey, currentTask);
|
|
1256
|
+
|
|
1257
|
+
// 等待当前任务完成
|
|
1258
|
+
await currentTask;
|
|
1259
|
+
console.log(`[DEBUG] 任务执行完成`);
|
|
1260
|
+
} catch (err: any) {
|
|
1261
|
+
console.error(`[DEBUG] 队列管理异常: ${err.message}`);
|
|
1262
|
+
console.error(`[DEBUG] 队列管理异常堆栈: ${err.stack}`);
|
|
1263
|
+
// 如果队列管理失败,直接调用内部处理函数
|
|
1264
|
+
return handleIntClawMessageInternal(params);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// handleIntClawMessage 已在函数定义处直接导出
|