@huo15/dingtalk-connector-pro 1.0.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/CHANGELOG.md +485 -0
- package/LICENSE +21 -0
- package/README.en.md +479 -0
- package/README.md +209 -0
- package/docs/AGENT_ROUTING.md +335 -0
- package/docs/DEAP_AGENT_GUIDE.en.md +115 -0
- package/docs/DEAP_AGENT_GUIDE.md +115 -0
- package/docs/images/dingtalk.svg +1 -0
- package/docs/images/image-1.png +0 -0
- package/docs/images/image-2.png +0 -0
- package/docs/images/image-3.png +0 -0
- package/docs/images/image-4.png +0 -0
- package/docs/images/image-5.png +0 -0
- package/docs/images/image-6.png +0 -0
- package/docs/images/image-7.png +0 -0
- package/index.ts +28 -0
- package/install-beta.sh +438 -0
- package/install-npm.sh +167 -0
- package/openclaw.plugin.json +498 -0
- package/package.json +80 -0
- package/src/channel.ts +463 -0
- package/src/config/accounts.ts +242 -0
- package/src/config/schema.ts +148 -0
- package/src/core/connection.ts +722 -0
- package/src/core/message-handler.ts +1700 -0
- package/src/core/provider.ts +111 -0
- package/src/core/state.ts +54 -0
- package/src/directory.ts +95 -0
- package/src/docs.ts +293 -0
- package/src/gateway-methods.ts +404 -0
- package/src/onboarding.ts +413 -0
- package/src/policy.ts +32 -0
- package/src/probe.ts +212 -0
- package/src/reply-dispatcher.ts +630 -0
- package/src/runtime.ts +32 -0
- package/src/sdk/helpers.ts +322 -0
- package/src/sdk/types.ts +513 -0
- package/src/secret-input.ts +19 -0
- package/src/services/media/audio.ts +54 -0
- package/src/services/media/chunk-upload.ts +296 -0
- package/src/services/media/common.ts +155 -0
- package/src/services/media/file.ts +70 -0
- package/src/services/media/image.ts +81 -0
- package/src/services/media/index.ts +10 -0
- package/src/services/media/video.ts +162 -0
- package/src/services/media.ts +1136 -0
- package/src/services/messaging/card.ts +342 -0
- package/src/services/messaging/index.ts +17 -0
- package/src/services/messaging/send.ts +141 -0
- package/src/services/messaging.ts +1013 -0
- package/src/targets.ts +45 -0
- package/src/types/index.ts +59 -0
- package/src/utils/agent.ts +63 -0
- package/src/utils/async.ts +51 -0
- package/src/utils/constants.ts +27 -0
- package/src/utils/http-client.ts +37 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +78 -0
- package/src/utils/session.ts +147 -0
- package/src/utils/token.ts +93 -0
- package/src/utils/utils-legacy.ts +454 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉消息业务处理器
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 处理钉钉消息的业务逻辑
|
|
6
|
+
* - 支持多种消息类型:text、richText、picture、audio、video、file
|
|
7
|
+
* - 媒体文件下载和上传(图片、语音、视频、文件)
|
|
8
|
+
* - 会话上下文构建和管理
|
|
9
|
+
* - 消息分发(AI Card、命令处理、主动消息)
|
|
10
|
+
* - Policy 检查(DM 白名单、群聊策略)
|
|
11
|
+
*
|
|
12
|
+
* 核心功能:
|
|
13
|
+
* - 消息内容提取和归一化
|
|
14
|
+
* - 媒体文件本地缓存管理
|
|
15
|
+
* - 钉钉 API 调用(accessToken、文件下载)
|
|
16
|
+
* - 与 OpenClaw 框架集成(bindings、runtime)
|
|
17
|
+
*/
|
|
18
|
+
// 类型定义
|
|
19
|
+
interface ClawdbotConfig {
|
|
20
|
+
[key: string]: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RuntimeEnv {
|
|
24
|
+
log?: (...args: any[]) => void;
|
|
25
|
+
error?: (...args: any[]) => void;
|
|
26
|
+
warn?: (...args: any[]) => void;
|
|
27
|
+
debug?: (...args: any[]) => void;
|
|
28
|
+
info?: (...args: any[]) => void;
|
|
29
|
+
[key: string]: any;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface HistoryEntry {
|
|
33
|
+
role: string;
|
|
34
|
+
content: string;
|
|
35
|
+
[key: string]: any;
|
|
36
|
+
}
|
|
37
|
+
import type { ResolvedDingtalkAccount, DingtalkConfig } from "../types/index.ts";
|
|
38
|
+
import {
|
|
39
|
+
buildSessionContext,
|
|
40
|
+
getAccessToken,
|
|
41
|
+
getOapiAccessToken,
|
|
42
|
+
DINGTALK_API,
|
|
43
|
+
DINGTALK_OAPI,
|
|
44
|
+
addEmotionReply,
|
|
45
|
+
recallEmotionReply,
|
|
46
|
+
} from "../utils/utils-legacy.ts";
|
|
47
|
+
import { resolveAgentWorkspaceDir } from "../utils/agent.ts";
|
|
48
|
+
import {
|
|
49
|
+
processLocalImages,
|
|
50
|
+
processVideoMarkers,
|
|
51
|
+
processAudioMarkers,
|
|
52
|
+
processFileMarkers
|
|
53
|
+
} from "../services/media/index.ts";
|
|
54
|
+
import { sendProactive, type AICardTarget } from "../services/messaging/index.ts";
|
|
55
|
+
import { createAICardForTarget, streamAICard, type AICardInstance } from "../services/messaging/card.ts";
|
|
56
|
+
import { QUEUE_BUSY_ACK_PHRASES } from "../utils/constants.ts";
|
|
57
|
+
import { createDingtalkReplyDispatcher } from "../reply-dispatcher.ts";
|
|
58
|
+
import { normalizeSlashCommand } from "../utils/session.ts";
|
|
59
|
+
import { getDingtalkRuntime } from "../runtime.ts";
|
|
60
|
+
import { dingtalkHttp } from '../utils/http-client.ts';
|
|
61
|
+
import { createLoggerFromConfig } from '../utils/index.ts';
|
|
62
|
+
import * as fs from 'fs';
|
|
63
|
+
import * as path from 'path';
|
|
64
|
+
import * as os from 'os';
|
|
65
|
+
|
|
66
|
+
// ============ 常量 ============
|
|
67
|
+
|
|
68
|
+
const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
|
|
69
|
+
|
|
70
|
+
const AICardStatus = {
|
|
71
|
+
PROCESSING: '1',
|
|
72
|
+
INPUTING: '2',
|
|
73
|
+
FINISHED: '3',
|
|
74
|
+
EXECUTING: '4',
|
|
75
|
+
FAILED: '5',
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
// ============ 会话级别消息队列 ============
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 会话消息队列管理
|
|
82
|
+
* 用于确保同一会话+agent的消息按顺序处理,避免并发冲突导致AI返回空响应
|
|
83
|
+
* 队列键格式:{sessionId}:{agentId}
|
|
84
|
+
* 这样不同 agent 可以并发处理,同一 agent 的同一会话串行处理
|
|
85
|
+
*/
|
|
86
|
+
const sessionQueues = new Map<string, Promise<void>>();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 清理过期的会话队列(超过5分钟没有新消息的会话+agent)
|
|
90
|
+
*/
|
|
91
|
+
const sessionLastActivity = new Map<string, number>();
|
|
92
|
+
const SESSION_QUEUE_TTL = 5 * 60 * 1000; // 5分钟
|
|
93
|
+
|
|
94
|
+
function cleanupExpiredSessionQueues(): void {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
for (const [queueKey, lastActivity] of sessionLastActivity.entries()) {
|
|
97
|
+
if (now - lastActivity > SESSION_QUEUE_TTL) {
|
|
98
|
+
sessionQueues.delete(queueKey);
|
|
99
|
+
sessionLastActivity.delete(queueKey);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 每分钟清理一次过期队列
|
|
105
|
+
setInterval(cleanupExpiredSessionQueues, 60_000);
|
|
106
|
+
|
|
107
|
+
// ============ 类型定义 ============
|
|
108
|
+
|
|
109
|
+
export type DingtalkReactionCreatedEvent = {
|
|
110
|
+
type: "reaction_created";
|
|
111
|
+
channelId: string;
|
|
112
|
+
messageId: string;
|
|
113
|
+
userId: string;
|
|
114
|
+
emoji: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type MonitorDingtalkAccountOpts = {
|
|
118
|
+
cfg: ClawdbotConfig;
|
|
119
|
+
account: ResolvedDingtalkAccount;
|
|
120
|
+
runtime?: RuntimeEnv;
|
|
121
|
+
abortSignal?: AbortSignal;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ============ Agent 路由解析 ============
|
|
125
|
+
// SDK 会自动处理 bindings 解析,无需手动实现
|
|
126
|
+
|
|
127
|
+
// ============ 消息内容提取 ============
|
|
128
|
+
|
|
129
|
+
interface ExtractedMessage {
|
|
130
|
+
text: string;
|
|
131
|
+
messageType: string;
|
|
132
|
+
imageUrls: string[];
|
|
133
|
+
downloadCodes: string[];
|
|
134
|
+
fileNames: string[];
|
|
135
|
+
atDingtalkIds: string[];
|
|
136
|
+
atMobiles: string[];
|
|
137
|
+
/** interactiveCard 消息中提取的文档链接(biz_custom_action_url),用于下游 URL 路由 */
|
|
138
|
+
interactiveCardUrl?: string;
|
|
139
|
+
/** actionCard 消息中提取的操作链接(单个时直接存储),用于下游 URL 路由 */
|
|
140
|
+
actionCardUrl?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 解析 data.content 字段:可能是对象,也可能是 JSON 字符串(钉钉部分 API 版本会将 content 序列化为字符串)。
|
|
145
|
+
* 返回解析后的对象,或 null(字段不存在 / 无法解析)。
|
|
146
|
+
*/
|
|
147
|
+
function resolveContent(data: any): any | null {
|
|
148
|
+
const raw = data?.content;
|
|
149
|
+
if (raw == null) return null;
|
|
150
|
+
if (typeof raw === 'object') return raw;
|
|
151
|
+
if (typeof raw === 'string') {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(raw);
|
|
154
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
155
|
+
} catch {
|
|
156
|
+
// 非 JSON 字符串,忽略
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 从消息的内容容器(data.text 或 data.content)中提取引用消息文本,最多递归 maxDepth 层。
|
|
164
|
+
* 对齐 Rust chatbot.rs 的 extract_quoted_msg_text 逻辑。
|
|
165
|
+
*
|
|
166
|
+
* 钉钉引用消息结构:
|
|
167
|
+
* { isReplyMsg: true, repliedMsg: { msgType, content, msgId, senderId } }
|
|
168
|
+
*/
|
|
169
|
+
function extractQuotedMsgText(container: any, maxDepth: number): string | null {
|
|
170
|
+
if (maxDepth <= 0 || !container) return null;
|
|
171
|
+
if (!container.isReplyMsg) return null;
|
|
172
|
+
|
|
173
|
+
const repliedMsg = container.repliedMsg;
|
|
174
|
+
if (!repliedMsg) return null;
|
|
175
|
+
|
|
176
|
+
const msgType = repliedMsg.msgType || 'text';
|
|
177
|
+
|
|
178
|
+
// 解析 repliedMsg.content(可能是对象或 JSON 字符串)
|
|
179
|
+
let contentObj: any = null;
|
|
180
|
+
const rawContent = repliedMsg.content;
|
|
181
|
+
if (rawContent && typeof rawContent === 'object') {
|
|
182
|
+
contentObj = rawContent;
|
|
183
|
+
} else if (typeof rawContent === 'string') {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(rawContent);
|
|
186
|
+
if (parsed && typeof parsed === 'object') contentObj = parsed;
|
|
187
|
+
} catch {
|
|
188
|
+
// 忽略
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let bodyText = '';
|
|
193
|
+
switch (msgType) {
|
|
194
|
+
case 'text': {
|
|
195
|
+
// repliedMsg.content.text 存放正文
|
|
196
|
+
bodyText = contentObj?.text?.trim() || repliedMsg.text?.trim() || '';
|
|
197
|
+
// 嵌套引用:content 中可能还有 isReplyMsg/repliedMsg
|
|
198
|
+
if (contentObj?.isReplyMsg) {
|
|
199
|
+
const nested = extractQuotedMsgText(contentObj, maxDepth - 1);
|
|
200
|
+
if (nested) bodyText = bodyText ? `${bodyText}\n${nested}` : nested;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case 'richText': {
|
|
205
|
+
const richList: any[] = contentObj?.richText || [];
|
|
206
|
+
const textParts = richList
|
|
207
|
+
.filter((item: any) => item.text && item.msgType !== 'skill' && !item.skillData)
|
|
208
|
+
.map((item: any) => item.text as string);
|
|
209
|
+
bodyText = textParts.join('');
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'picture':
|
|
213
|
+
bodyText = '[图片]';
|
|
214
|
+
break;
|
|
215
|
+
case 'video':
|
|
216
|
+
bodyText = '[视频]';
|
|
217
|
+
break;
|
|
218
|
+
case 'audio':
|
|
219
|
+
bodyText = contentObj?.recognition || '[语音消息]';
|
|
220
|
+
break;
|
|
221
|
+
case 'file': {
|
|
222
|
+
const fileName = contentObj?.fileName || 'unknown';
|
|
223
|
+
bodyText = `[文件: ${fileName}]`;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'markdown':
|
|
227
|
+
bodyText = contentObj?.text?.trim() || '[markdown消息]';
|
|
228
|
+
break;
|
|
229
|
+
case 'interactiveCard': {
|
|
230
|
+
const cardUrl = contentObj?.biz_custom_action_url || repliedMsg.biz_custom_action_url || '';
|
|
231
|
+
bodyText = cardUrl ? `收到交互式卡片链接:${cardUrl}` : '[interactiveCard消息]';
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
bodyText = `[${msgType}消息]`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!bodyText) return null;
|
|
239
|
+
return `[引用] ${bodyText}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 从 richText 列表中提取媒体附件(图片 downloadCode)。
|
|
244
|
+
* 兼容新结构(content.richText)和旧结构(richText.richTextList)。
|
|
245
|
+
*/
|
|
246
|
+
function extractRichTextMediaAttachments(
|
|
247
|
+
data: any,
|
|
248
|
+
content: any,
|
|
249
|
+
): { imageUrls: string[]; downloadCodes: string[]; fileNames: string[] } {
|
|
250
|
+
const imageUrls: string[] = [];
|
|
251
|
+
const downloadCodes: string[] = [];
|
|
252
|
+
const fileNames: string[] = [];
|
|
253
|
+
|
|
254
|
+
// 兼容新结构 content.richText 和旧结构 richText.richTextList
|
|
255
|
+
const richList: any[] =
|
|
256
|
+
content?.richText ||
|
|
257
|
+
data?.richText?.richTextList ||
|
|
258
|
+
[];
|
|
259
|
+
|
|
260
|
+
for (const item of richList) {
|
|
261
|
+
if (item.pictureUrl) {
|
|
262
|
+
imageUrls.push(item.pictureUrl);
|
|
263
|
+
}
|
|
264
|
+
if (item.downloadCode) {
|
|
265
|
+
const itemType: string = item.type || '';
|
|
266
|
+
if (itemType === 'picture' || !itemType) {
|
|
267
|
+
imageUrls.push(`downloadCode:${item.downloadCode}`);
|
|
268
|
+
} else if (itemType === 'video') {
|
|
269
|
+
downloadCodes.push(item.downloadCode);
|
|
270
|
+
fileNames.push(item.fileName || 'video.mp4');
|
|
271
|
+
} else if (itemType === 'audio') {
|
|
272
|
+
downloadCodes.push(item.downloadCode);
|
|
273
|
+
fileNames.push(item.fileName || 'audio.amr');
|
|
274
|
+
} else if (itemType === 'file') {
|
|
275
|
+
downloadCodes.push(item.downloadCode);
|
|
276
|
+
fileNames.push(item.fileName || '文件');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { imageUrls, downloadCodes, fileNames };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 从 repliedMsg 中提取媒体附件(用于 reply 类型消息)。
|
|
286
|
+
*/
|
|
287
|
+
function extractRepliedMsgMediaAttachments(
|
|
288
|
+
repliedMsg: any,
|
|
289
|
+
): { imageUrls: string[]; downloadCodes: string[]; fileNames: string[] } {
|
|
290
|
+
const imageUrls: string[] = [];
|
|
291
|
+
const downloadCodes: string[] = [];
|
|
292
|
+
const fileNames: string[] = [];
|
|
293
|
+
|
|
294
|
+
if (!repliedMsg) return { imageUrls, downloadCodes, fileNames };
|
|
295
|
+
|
|
296
|
+
const msgType = repliedMsg.msgType || 'text';
|
|
297
|
+
|
|
298
|
+
let contentObj: any = null;
|
|
299
|
+
const rawContent = repliedMsg.content;
|
|
300
|
+
if (rawContent && typeof rawContent === 'object') {
|
|
301
|
+
contentObj = rawContent;
|
|
302
|
+
} else if (typeof rawContent === 'string') {
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(rawContent);
|
|
305
|
+
if (parsed && typeof parsed === 'object') contentObj = parsed;
|
|
306
|
+
} catch {
|
|
307
|
+
// 忽略
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
switch (msgType) {
|
|
312
|
+
case 'picture':
|
|
313
|
+
case 'video':
|
|
314
|
+
case 'audio': {
|
|
315
|
+
const code = contentObj?.downloadCode;
|
|
316
|
+
if (code) {
|
|
317
|
+
if (msgType === 'picture') {
|
|
318
|
+
imageUrls.push(`downloadCode:${code}`);
|
|
319
|
+
} else {
|
|
320
|
+
downloadCodes.push(code);
|
|
321
|
+
fileNames.push(contentObj?.fileName || (msgType === 'video' ? 'video.mp4' : 'audio.amr'));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
case 'file': {
|
|
327
|
+
const code = contentObj?.downloadCode;
|
|
328
|
+
if (code) {
|
|
329
|
+
downloadCodes.push(code);
|
|
330
|
+
fileNames.push(contentObj?.fileName || '文件');
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case 'richText': {
|
|
335
|
+
const richList: any[] = contentObj?.richText || [];
|
|
336
|
+
for (const item of richList) {
|
|
337
|
+
if (item.downloadCode) {
|
|
338
|
+
imageUrls.push(`downloadCode:${item.downloadCode}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
default:
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { imageUrls, downloadCodes, fileNames };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function extractMessageContent(data: any): ExtractedMessage {
|
|
351
|
+
const msgtype = data.msgtype || 'text';
|
|
352
|
+
switch (msgtype) {
|
|
353
|
+
case 'text': {
|
|
354
|
+
const atDingtalkIds = data.text?.at?.atDingtalkIds || [];
|
|
355
|
+
const atMobiles = data.text?.at?.atMobiles || [];
|
|
356
|
+
|
|
357
|
+
const bodyText = data.text?.content?.trim() || '';
|
|
358
|
+
|
|
359
|
+
// 检测引用消息(isReplyMsg + repliedMsg 在 data.text 对象内)
|
|
360
|
+
const hasReply = !!data.text?.isReplyMsg;
|
|
361
|
+
const quotedText = extractQuotedMsgText(data.text, 3);
|
|
362
|
+
const text = quotedText ? `${bodyText}\n${quotedText}` : bodyText;
|
|
363
|
+
|
|
364
|
+
// 提取引用消息中的媒体附件(图片/视频/音频/文件)
|
|
365
|
+
const repliedMsgInText = data.text?.repliedMsg;
|
|
366
|
+
const { imageUrls, downloadCodes, fileNames } = extractRepliedMsgMediaAttachments(repliedMsgInText);
|
|
367
|
+
|
|
368
|
+
// 从引用消息的文本内容中提取 URL,触发链接路由(如 alidocs 文档链接)
|
|
369
|
+
// 场景:用户引用了一条含链接的文本消息,并追问"这个文档内容是什么"
|
|
370
|
+
let interactiveCardUrl: string | undefined;
|
|
371
|
+
if (hasReply && repliedMsgInText) {
|
|
372
|
+
const repliedContentObj = typeof repliedMsgInText.content === 'object'
|
|
373
|
+
? repliedMsgInText.content
|
|
374
|
+
: (() => { try { return JSON.parse(repliedMsgInText.content); } catch { return null; } })();
|
|
375
|
+
const repliedText = repliedContentObj?.text || repliedMsgInText.text || '';
|
|
376
|
+
const extractedUrl = extractFirstUrlFromText(repliedText);
|
|
377
|
+
if (extractedUrl) {
|
|
378
|
+
interactiveCardUrl = extractedUrl;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
text,
|
|
384
|
+
messageType: hasReply ? 'reply' : 'text',
|
|
385
|
+
imageUrls,
|
|
386
|
+
downloadCodes,
|
|
387
|
+
fileNames,
|
|
388
|
+
atDingtalkIds,
|
|
389
|
+
atMobiles,
|
|
390
|
+
interactiveCardUrl,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
case 'richText': {
|
|
394
|
+
// 兼容 content 为 JSON 字符串的情况
|
|
395
|
+
const content = resolveContent(data);
|
|
396
|
+
const textParts: string[] = [];
|
|
397
|
+
|
|
398
|
+
// 兼容新结构 content.richText 和旧结构 richText.richTextList
|
|
399
|
+
const richList: any[] =
|
|
400
|
+
content?.richText ||
|
|
401
|
+
data?.richText?.richTextList ||
|
|
402
|
+
[];
|
|
403
|
+
|
|
404
|
+
for (const item of richList) {
|
|
405
|
+
const isSkillItem = item.type === 'skill' || !!item.skillData;
|
|
406
|
+
|
|
407
|
+
if (item.text && !isSkillItem) {
|
|
408
|
+
textParts.push(item.text);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (isSkillItem && item.skillData) {
|
|
412
|
+
// 将 skillData 转换为 <skill> 标签,供下游识别斜杠命令
|
|
413
|
+
const skillId = item.skillData.skillId || '';
|
|
414
|
+
const displayName = item.skillData.displayName || '';
|
|
415
|
+
const iconUrl = item.skillData.iconUrl || '';
|
|
416
|
+
const skillTag = iconUrl
|
|
417
|
+
? `<skill data-id="${skillId}" data-name="${displayName}" icon="${iconUrl}">`
|
|
418
|
+
: `<skill data-id="${skillId}" data-name="${displayName}">`;
|
|
419
|
+
textParts.push(skillTag);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (item.pictureUrl) {
|
|
423
|
+
// pictureUrl 在 imageUrls 里处理,文本部分不需要占位
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 检测引用消息(isReplyMsg + repliedMsg 在 content 对象内)
|
|
428
|
+
const hasReply = !!content?.isReplyMsg;
|
|
429
|
+
const quotedText = extractQuotedMsgText(content, 3);
|
|
430
|
+
if (quotedText) textParts.push(quotedText);
|
|
431
|
+
|
|
432
|
+
const richTextMedia = extractRichTextMediaAttachments(data, content);
|
|
433
|
+
|
|
434
|
+
// 同时提取引用消息中的媒体附件(richText 引用图片场景)
|
|
435
|
+
const repliedMsgInRichText = content?.repliedMsg;
|
|
436
|
+
const repliedMedia = extractRepliedMsgMediaAttachments(repliedMsgInRichText);
|
|
437
|
+
|
|
438
|
+
const imageUrls = [...richTextMedia.imageUrls, ...repliedMedia.imageUrls];
|
|
439
|
+
const downloadCodes = [...richTextMedia.downloadCodes, ...repliedMedia.downloadCodes];
|
|
440
|
+
const fileNames = [...richTextMedia.fileNames, ...repliedMedia.fileNames];
|
|
441
|
+
|
|
442
|
+
const text =
|
|
443
|
+
textParts.join('') ||
|
|
444
|
+
(imageUrls.length > 0 ? '[图片]' : downloadCodes.length > 0 ? '[媒体文件]' : '[富文本消息]');
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
text,
|
|
448
|
+
messageType: hasReply ? 'reply' : 'richText',
|
|
449
|
+
imageUrls,
|
|
450
|
+
downloadCodes,
|
|
451
|
+
fileNames,
|
|
452
|
+
atDingtalkIds: [],
|
|
453
|
+
atMobiles: [],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
case 'picture': {
|
|
457
|
+
const content = resolveContent(data);
|
|
458
|
+
const downloadCode = content?.downloadCode || '';
|
|
459
|
+
const pictureUrl = content?.pictureUrl || '';
|
|
460
|
+
const imageUrls: string[] = [];
|
|
461
|
+
const downloadCodes: string[] = [];
|
|
462
|
+
|
|
463
|
+
if (pictureUrl) {
|
|
464
|
+
imageUrls.push(pictureUrl);
|
|
465
|
+
}
|
|
466
|
+
if (downloadCode) {
|
|
467
|
+
downloadCodes.push(downloadCode);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { text: '[图片]', messageType: 'picture', imageUrls, downloadCodes, fileNames: [], atDingtalkIds: [], atMobiles: [] };
|
|
471
|
+
}
|
|
472
|
+
case 'audio': {
|
|
473
|
+
const content = resolveContent(data);
|
|
474
|
+
// 兼容旧结构 /audio/recognition
|
|
475
|
+
const recognition =
|
|
476
|
+
content?.recognition ||
|
|
477
|
+
data?.audio?.recognition ||
|
|
478
|
+
'[语音消息]';
|
|
479
|
+
const audioDownloadCode = content?.downloadCode || '';
|
|
480
|
+
const audioFileName = content?.fileName || 'audio.amr';
|
|
481
|
+
const downloadCodes: string[] = [];
|
|
482
|
+
const fileNames: string[] = [];
|
|
483
|
+
if (audioDownloadCode) {
|
|
484
|
+
downloadCodes.push(audioDownloadCode);
|
|
485
|
+
fileNames.push(audioFileName);
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
text: recognition,
|
|
489
|
+
messageType: 'audio',
|
|
490
|
+
imageUrls: [],
|
|
491
|
+
downloadCodes,
|
|
492
|
+
fileNames,
|
|
493
|
+
atDingtalkIds: [],
|
|
494
|
+
atMobiles: [],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
case 'video': {
|
|
498
|
+
const content = resolveContent(data);
|
|
499
|
+
const videoDownloadCode = content?.downloadCode || '';
|
|
500
|
+
const videoFileName = content?.fileName || 'video.mp4';
|
|
501
|
+
const downloadCodes: string[] = [];
|
|
502
|
+
const fileNames: string[] = [];
|
|
503
|
+
if (videoDownloadCode) {
|
|
504
|
+
downloadCodes.push(videoDownloadCode);
|
|
505
|
+
fileNames.push(videoFileName);
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
text: '[视频]',
|
|
509
|
+
messageType: 'video',
|
|
510
|
+
imageUrls: [],
|
|
511
|
+
downloadCodes,
|
|
512
|
+
fileNames,
|
|
513
|
+
atDingtalkIds: [],
|
|
514
|
+
atMobiles: [],
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
case 'file': {
|
|
518
|
+
const content = resolveContent(data);
|
|
519
|
+
// 兼容旧结构 /file/fileName
|
|
520
|
+
const fileName = content?.fileName || data?.file?.fileName || '文件';
|
|
521
|
+
const downloadCode = content?.downloadCode || '';
|
|
522
|
+
const downloadCodes: string[] = [];
|
|
523
|
+
const fileNames: string[] = [];
|
|
524
|
+
if (downloadCode) {
|
|
525
|
+
downloadCodes.push(downloadCode);
|
|
526
|
+
fileNames.push(fileName);
|
|
527
|
+
}
|
|
528
|
+
return { text: `[文件: ${fileName}]`, messageType: 'file', imageUrls: [], downloadCodes, fileNames, atDingtalkIds: [], atMobiles: [] };
|
|
529
|
+
}
|
|
530
|
+
case 'markdown': {
|
|
531
|
+
// 钉钉 markdown 消息内容在 data.text.content(与 text 类型一致),不在 data.content
|
|
532
|
+
const text = data.text?.content?.trim() || resolveContent(data)?.text?.trim() || '[markdown消息]';
|
|
533
|
+
return { text, messageType: 'markdown', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] };
|
|
534
|
+
}
|
|
535
|
+
case 'actionCard': {
|
|
536
|
+
const content = resolveContent(data);
|
|
537
|
+
const title = content?.title?.trim() || '';
|
|
538
|
+
const body = content?.text?.trim() || '';
|
|
539
|
+
// 提取操作链接列表
|
|
540
|
+
const actionUrlItems: any[] = content?.actionUrlItemList || [];
|
|
541
|
+
const actionUrls = actionUrlItems
|
|
542
|
+
.map((item: any) => item.actionUrl?.trim())
|
|
543
|
+
.filter((url: string) => !!url);
|
|
544
|
+
|
|
545
|
+
const sections: string[] = [];
|
|
546
|
+
if (title) sections.push(title);
|
|
547
|
+
if (body) sections.push(body);
|
|
548
|
+
if (actionUrls.length > 0) {
|
|
549
|
+
const linkSection =
|
|
550
|
+
actionUrls.length === 1
|
|
551
|
+
? `操作链接:${actionUrls[0]}`
|
|
552
|
+
: `操作链接:\n- ${actionUrls.join('\n- ')}`;
|
|
553
|
+
sections.push(linkSection);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const text = sections.length > 0 ? sections.join('\n\n') : '[actionCard消息]';
|
|
557
|
+
// 单个操作链接时作为 actionCardUrl 传给下游做 URL 路由
|
|
558
|
+
const actionCardUrl = actionUrls.length === 1 ? actionUrls[0] : undefined;
|
|
559
|
+
return { text, messageType: 'actionCard', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [], actionCardUrl };
|
|
560
|
+
}
|
|
561
|
+
case 'interactiveCard': {
|
|
562
|
+
// 交互式卡片消息(通常是文档分享)
|
|
563
|
+
const content = resolveContent(data);
|
|
564
|
+
// 兼容 content 字段不存在时直接从顶层取(repliedMsg 透传场景)
|
|
565
|
+
const interactiveCardUrl =
|
|
566
|
+
(content?.biz_custom_action_url || data?.biz_custom_action_url || '').trim() || undefined;
|
|
567
|
+
if (interactiveCardUrl) {
|
|
568
|
+
const text = `收到交互式卡片链接:${interactiveCardUrl}`;
|
|
569
|
+
return { text, messageType: 'interactiveCard', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [], interactiveCardUrl };
|
|
570
|
+
}
|
|
571
|
+
return { text: '[interactiveCard消息]', messageType: 'interactiveCard', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] };
|
|
572
|
+
}
|
|
573
|
+
case 'reply': {
|
|
574
|
+
// 显式 reply 类型(部分钉钉版本直接发 msgtype=reply)
|
|
575
|
+
// 优先从 data.text 取引用容器,取不到再从 content 取
|
|
576
|
+
const replyContainer = data.text || resolveContent(data);
|
|
577
|
+
const bodyText = data.text?.content?.trim() || '';
|
|
578
|
+
const quotedText = extractQuotedMsgText(replyContainer, 3);
|
|
579
|
+
const text = quotedText ? `${bodyText}\n${quotedText}` : bodyText || '[引用消息]';
|
|
580
|
+
|
|
581
|
+
// 提取引用中的媒体附件
|
|
582
|
+
const repliedMsg = data.text?.repliedMsg || resolveContent(data)?.repliedMsg;
|
|
583
|
+
const { imageUrls, downloadCodes, fileNames } = extractRepliedMsgMediaAttachments(repliedMsg);
|
|
584
|
+
|
|
585
|
+
return { text, messageType: 'reply', imageUrls, downloadCodes, fileNames, atDingtalkIds: [], atMobiles: [] };
|
|
586
|
+
}
|
|
587
|
+
default:
|
|
588
|
+
return {
|
|
589
|
+
text: data.text?.content?.trim() || `[${msgtype}消息]`,
|
|
590
|
+
messageType: msgtype,
|
|
591
|
+
imageUrls: [],
|
|
592
|
+
downloadCodes: [],
|
|
593
|
+
fileNames: [],
|
|
594
|
+
atDingtalkIds: [],
|
|
595
|
+
atMobiles: [],
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ============ 卡片链接路由 system prompt ============
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 从文本内容中提取第一个 HTTP/HTTPS URL。
|
|
604
|
+
* 用于处理引用消息文本里直接粘贴链接的场景(如引用一条含 alidocs 链接的文本消息)。
|
|
605
|
+
*/
|
|
606
|
+
function extractFirstUrlFromText(text: string): string | null {
|
|
607
|
+
const urlMatch = text.match(/https?:\/\/[^\s\u3000\u3001\uff0c\u3002\uff01\uff1f"'<>]+/);
|
|
608
|
+
return urlMatch ? urlMatch[0].trim() : null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 根据消息中的 interactiveCardUrl / actionCardUrl 构建链接路由 system prompt。
|
|
613
|
+
* 对齐 Rust agent_support.rs 的 build_link_routing_prompt 逻辑:
|
|
614
|
+
* - alidocs.dingtalk.com → 使用 dws skill 的 doc 能力读取
|
|
615
|
+
* - 其他 URL → 使用 read_url 读取
|
|
616
|
+
* 返回 null 表示无需注入额外 prompt。
|
|
617
|
+
*/
|
|
618
|
+
function buildLinkRoutingPrompt(content: ExtractedMessage): string | null {
|
|
619
|
+
const interactiveCardUrl = content.interactiveCardUrl?.trim();
|
|
620
|
+
const actionCardUrl = content.actionCardUrl?.trim();
|
|
621
|
+
|
|
622
|
+
const linkUrl = interactiveCardUrl || actionCardUrl;
|
|
623
|
+
if (!linkUrl) return null;
|
|
624
|
+
|
|
625
|
+
const cardKind = interactiveCardUrl ? 'interactive card' : 'action card';
|
|
626
|
+
|
|
627
|
+
let host: string | null = null;
|
|
628
|
+
try {
|
|
629
|
+
host = new URL(linkUrl).hostname;
|
|
630
|
+
} catch {
|
|
631
|
+
// URL 解析失败,当作普通链接处理
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (host === 'alidocs.dingtalk.com') {
|
|
635
|
+
return [
|
|
636
|
+
`The inbound DingTalk message is an ${cardKind} with a document link.`,
|
|
637
|
+
`Linked URL: ${linkUrl}`,
|
|
638
|
+
`This URL is hosted on \`alidocs.dingtalk.com\`.`,
|
|
639
|
+
`You MUST inspect and summarize it via the \`dws\` skill using its \`doc\` product capability.`,
|
|
640
|
+
`If \`dws\` is not already visible in the skill snapshot, call \`search_skills\` to locate it, then call \`use_skill\` with the exact id.`,
|
|
641
|
+
`Never switch to browser-based reading for this link. Browser incompatibility or markdown export limitations are not final answers.`,
|
|
642
|
+
`Do not use \`read_url\` for this link.`,
|
|
643
|
+
`Reply to the DingTalk user with a concise summary of the linked document content.`,
|
|
644
|
+
].join('\n');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return [
|
|
648
|
+
`The inbound DingTalk message is an ${cardKind} with a link.`,
|
|
649
|
+
`Linked URL: ${linkUrl}`,
|
|
650
|
+
`For this URL, you MUST use \`read_url\` to inspect the linked content before answering.`,
|
|
651
|
+
`Do not use the \`dws\` skill for this link.`,
|
|
652
|
+
`Reply to the DingTalk user with a concise summary of the linked content.`,
|
|
653
|
+
].join('\n');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ============ 图片下载 ============
|
|
657
|
+
|
|
658
|
+
export async function downloadImageToFile(
|
|
659
|
+
downloadUrl: string,
|
|
660
|
+
agentWorkspaceDir: string,
|
|
661
|
+
log?: any,
|
|
662
|
+
): Promise<string | null> {
|
|
663
|
+
try {
|
|
664
|
+
log?.info?.(`开始下载图片: ${downloadUrl.slice(0, 100)}...`);
|
|
665
|
+
const resp = await dingtalkHttp.get(downloadUrl, {
|
|
666
|
+
proxy: false, // 禁用代理,避免 PAC 文件影响
|
|
667
|
+
|
|
668
|
+
headers: {
|
|
669
|
+
'Content-Type': undefined, // 删除默认的 Content-Type 请求头,让 OSS 签名验证通过
|
|
670
|
+
},
|
|
671
|
+
responseType: 'arraybuffer',
|
|
672
|
+
timeout: 30_000,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const buffer = Buffer.from(resp.data);
|
|
676
|
+
const contentType = resp.headers['content-type'] || 'image/jpeg';
|
|
677
|
+
const ext = contentType.includes('png') ? '.png' : contentType.includes('gif') ? '.gif' : contentType.includes('webp') ? '.webp' : '.jpg';
|
|
678
|
+
// 使用 Agent 工作空间路径
|
|
679
|
+
const mediaDir = path.join(agentWorkspaceDir, 'media', 'inbound');
|
|
680
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
681
|
+
const tmpFile = path.join(mediaDir, `openclaw-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
682
|
+
fs.writeFileSync(tmpFile, buffer);
|
|
683
|
+
|
|
684
|
+
log?.info?.(`图片下载成功: size=${buffer.length} bytes, type=${contentType}, path=${tmpFile}`);
|
|
685
|
+
return tmpFile;
|
|
686
|
+
} catch (err: any) {
|
|
687
|
+
log?.error?.(`图片下载失败: ${err.message}`);
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export async function downloadMediaByCode(
|
|
693
|
+
downloadCode: string,
|
|
694
|
+
config: DingtalkConfig,
|
|
695
|
+
agentWorkspaceDir: string,
|
|
696
|
+
log?: any,
|
|
697
|
+
): Promise<string | null> {
|
|
698
|
+
try {
|
|
699
|
+
const token = await getAccessToken(config);
|
|
700
|
+
log?.info?.(`通过 downloadCode 下载媒体: ${downloadCode.slice(0, 30)}...`);
|
|
701
|
+
|
|
702
|
+
const resp = await dingtalkHttp.post(
|
|
703
|
+
`${DINGTALK_API}/v1.0/robot/messageFiles/download`,
|
|
704
|
+
{ downloadCode, robotCode: config.clientId },
|
|
705
|
+
{
|
|
706
|
+
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
707
|
+
timeout: 30_000,
|
|
708
|
+
},
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
const downloadUrl = resp.data?.downloadUrl;
|
|
712
|
+
if (!downloadUrl) {
|
|
713
|
+
log?.warn?.(`downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`);
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return downloadImageToFile(downloadUrl, agentWorkspaceDir, log);
|
|
718
|
+
} catch (err: any) {
|
|
719
|
+
log?.error?.(`downloadCode 下载失败: ${err.message}`);
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export async function getFileDownloadUrl(
|
|
725
|
+
downloadCode: string,
|
|
726
|
+
fileName: string,
|
|
727
|
+
config: DingtalkConfig,
|
|
728
|
+
log?: any,
|
|
729
|
+
): Promise<string | null> {
|
|
730
|
+
try {
|
|
731
|
+
const token = await getAccessToken(config);
|
|
732
|
+
log?.info?.(`获取文件下载链接: ${fileName}`);
|
|
733
|
+
|
|
734
|
+
const resp = await dingtalkHttp.post(
|
|
735
|
+
`${DINGTALK_API}/v1.0/robot/messageFiles/download`,
|
|
736
|
+
{ downloadCode, robotCode: config.clientId },
|
|
737
|
+
{
|
|
738
|
+
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
739
|
+
timeout: 30_000,
|
|
740
|
+
},
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
const downloadUrl = resp.data?.downloadUrl;
|
|
744
|
+
if (!downloadUrl) {
|
|
745
|
+
log?.warn?.(`downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`);
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
log?.info?.(`获取下载链接成功: ${fileName}`);
|
|
750
|
+
return downloadUrl;
|
|
751
|
+
} catch (err: any) {
|
|
752
|
+
log?.error?.(`获取下载链接失败: ${err.message}`);
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ============ 文件下载和解析 ============
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 下载文件到本地
|
|
761
|
+
*/
|
|
762
|
+
export async function downloadFileToLocal(
|
|
763
|
+
downloadUrl: string,
|
|
764
|
+
fileName: string,
|
|
765
|
+
agentWorkspaceDir: string,
|
|
766
|
+
log?: any,
|
|
767
|
+
): Promise<string | null> {
|
|
768
|
+
try {
|
|
769
|
+
log?.info?.(`开始下载文件: ${fileName}`);
|
|
770
|
+
const resp = await dingtalkHttp.get(downloadUrl, {
|
|
771
|
+
proxy: false, // 禁用代理,避免 PAC 文件影响
|
|
772
|
+
headers: {
|
|
773
|
+
'Content-Type': undefined, // 删除默认的 Content-Type 请求头,让 OSS 签名验证通过
|
|
774
|
+
},
|
|
775
|
+
responseType: 'arraybuffer',
|
|
776
|
+
timeout: 60_000, // 文件可能较大,增加超时时间
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const buffer = Buffer.from(resp.data);
|
|
780
|
+
const mediaDir = path.join(agentWorkspaceDir, 'media', 'inbound');
|
|
781
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
782
|
+
|
|
783
|
+
// 安全过滤文件名
|
|
784
|
+
const sanitizeFileName = (name: string): string => {
|
|
785
|
+
// 移除路径分隔符,防止目录遍历攻击
|
|
786
|
+
let safe = name.replace(/[/\\]/g, '_');
|
|
787
|
+
// 移除或替换危险字符
|
|
788
|
+
safe = safe.replace(/[<>:"|?*\x00-\x1f]/g, '_');
|
|
789
|
+
// 移除开头的点,防止隐藏文件
|
|
790
|
+
safe = safe.replace(/^\.+/, '');
|
|
791
|
+
// 限制长度
|
|
792
|
+
if (safe.length > 200) {
|
|
793
|
+
const ext = path.extname(safe);
|
|
794
|
+
const base = path.basename(safe, ext);
|
|
795
|
+
safe = base.substring(0, 200 - ext.length) + ext;
|
|
796
|
+
}
|
|
797
|
+
// 如果处理后为空,使用默认名称
|
|
798
|
+
if (!safe) {
|
|
799
|
+
safe = 'unnamed_file';
|
|
800
|
+
}
|
|
801
|
+
return safe;
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// 保留原始文件名,但添加时间戳避免冲突
|
|
805
|
+
const ext = path.extname(fileName);
|
|
806
|
+
const baseName = path.basename(fileName, ext);
|
|
807
|
+
const timestamp = Date.now();
|
|
808
|
+
const safeBaseName = sanitizeFileName(baseName);
|
|
809
|
+
const safeFileName = `${safeBaseName}-${timestamp}${ext}`;
|
|
810
|
+
const localPath = path.join(mediaDir, safeFileName);
|
|
811
|
+
|
|
812
|
+
fs.writeFileSync(localPath, buffer);
|
|
813
|
+
log?.info?.(`文件下载成功: ${fileName}, size=${buffer.length} bytes, path=${localPath}`);
|
|
814
|
+
return localPath;
|
|
815
|
+
} catch (err: any) {
|
|
816
|
+
log?.error?.(`downloadFileToLocal 异常: ${err.message}\n${err.stack}`);
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* 解析 Word 文档 (.docx)
|
|
823
|
+
*/
|
|
824
|
+
async function parseDocxFile(filePath: string, log?: any): Promise<string | null> {
|
|
825
|
+
try {
|
|
826
|
+
log?.info?.(`开始解析 Word 文档: ${filePath}`);
|
|
827
|
+
|
|
828
|
+
let mammoth: any;
|
|
829
|
+
try {
|
|
830
|
+
mammoth = (await import('mammoth')).default;
|
|
831
|
+
} catch {
|
|
832
|
+
log?.warn?.('mammoth 库未安装,无法解析 .docx 文件。请运行: npm install mammoth');
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const buffer = fs.readFileSync(filePath);
|
|
837
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
838
|
+
const text = result.value.trim();
|
|
839
|
+
|
|
840
|
+
if (text) {
|
|
841
|
+
log?.info?.(`Word 文档解析成功: ${filePath}, 文本长度=${text.length}`);
|
|
842
|
+
return text;
|
|
843
|
+
} else {
|
|
844
|
+
log?.warn?.(`Word 文档解析结果为空: ${filePath}`);
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
} catch (err: any) {
|
|
848
|
+
log?.error?.(`Word 文档解析失败: ${filePath}, error=${err.message}`);
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* 解析 PDF 文档
|
|
855
|
+
*/
|
|
856
|
+
async function parsePdfFile(filePath: string, log?: any): Promise<string | null> {
|
|
857
|
+
try {
|
|
858
|
+
log?.info?.(`开始解析 PDF 文档: ${filePath}`);
|
|
859
|
+
|
|
860
|
+
let pdfParse: any;
|
|
861
|
+
try {
|
|
862
|
+
pdfParse = (await import('pdf-parse')).default;
|
|
863
|
+
} catch {
|
|
864
|
+
log?.warn?.('pdf-parse 库未安装,无法解析 .pdf 文件。请运行: npm install pdf-parse');
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const buffer = fs.readFileSync(filePath);
|
|
869
|
+
const data = await pdfParse(buffer);
|
|
870
|
+
const text = data.text.trim();
|
|
871
|
+
|
|
872
|
+
if (text) {
|
|
873
|
+
log?.info?.(`PDF 文档解析成功: ${filePath}, 文本长度=${text.length}, 页数=${data.numpages}`);
|
|
874
|
+
return text;
|
|
875
|
+
} else {
|
|
876
|
+
log?.warn?.(`PDF 文档解析结果为空: ${filePath}`);
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
} catch (err: any) {
|
|
880
|
+
log?.error?.(`PDF 文档解析失败: ${filePath}, error=${err.message}`);
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* 读取纯文本文件
|
|
887
|
+
*/
|
|
888
|
+
async function readTextFile(filePath: string, log?: any): Promise<string | null> {
|
|
889
|
+
try {
|
|
890
|
+
log?.info?.(`开始读取文本文件: ${filePath}`);
|
|
891
|
+
const text = fs.readFileSync(filePath, 'utf-8').trim();
|
|
892
|
+
|
|
893
|
+
if (text) {
|
|
894
|
+
log?.info?.(`文本文件读取成功: ${filePath}, 文本长度=${text.length}`);
|
|
895
|
+
return text;
|
|
896
|
+
} else {
|
|
897
|
+
log?.warn?.(`文本文件内容为空: ${filePath}`);
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
} catch (err: any) {
|
|
901
|
+
log?.error?.(`文本文件读取失败: ${filePath}, error=${err.message}`);
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* 根据文件类型解析文件内容
|
|
908
|
+
*/
|
|
909
|
+
async function parseFileContent(
|
|
910
|
+
filePath: string,
|
|
911
|
+
fileName: string,
|
|
912
|
+
log?: any,
|
|
913
|
+
): Promise<{ content: string | null; type: 'text' | 'binary' }> {
|
|
914
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
915
|
+
|
|
916
|
+
// Word 文档
|
|
917
|
+
if (['.docx', '.doc'].includes(ext)) {
|
|
918
|
+
const content = await parseDocxFile(filePath, log);
|
|
919
|
+
return { content, type: 'text' };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// PDF 文档
|
|
923
|
+
if (ext === '.pdf') {
|
|
924
|
+
const content = await parsePdfFile(filePath, log);
|
|
925
|
+
return { content, type: 'text' };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 纯文本文件
|
|
929
|
+
if (['.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.csv', '.log', '.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.sh', '.bat'].includes(ext)) {
|
|
930
|
+
const content = await readTextFile(filePath, log);
|
|
931
|
+
return { content, type: 'text' };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// 二进制文件(不解析)
|
|
935
|
+
return { content: null, type: 'binary' };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ============ 消息处理 ============
|
|
939
|
+
|
|
940
|
+
interface HandleMessageParams {
|
|
941
|
+
accountId: string;
|
|
942
|
+
config: DingtalkConfig;
|
|
943
|
+
data: any;
|
|
944
|
+
sessionWebhook: string;
|
|
945
|
+
runtime?: RuntimeEnv;
|
|
946
|
+
log?: any;
|
|
947
|
+
cfg: ClawdbotConfig;
|
|
948
|
+
/** 队列繁忙时预先创建的 AI Card,处理时直接复用而非新建,实现"占位→更新"效果 */
|
|
949
|
+
preCreatedCard?: AICardInstance;
|
|
950
|
+
/** 队列繁忙时已在入队阶段提前贴上了思考中表情,内部处理时跳过重复贴表情 */
|
|
951
|
+
emotionAlreadyAdded?: boolean;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* 内部消息处理函数(实际执行消息处理逻辑)
|
|
956
|
+
*/
|
|
957
|
+
export async function handleDingTalkMessageInternal(params: HandleMessageParams): Promise<void> {
|
|
958
|
+
const { accountId, config, data, sessionWebhook, runtime, cfg } = params;
|
|
959
|
+
|
|
960
|
+
const log = createLoggerFromConfig(config, `DingTalk:${accountId}`);
|
|
961
|
+
|
|
962
|
+
const content = extractMessageContent(data);
|
|
963
|
+
if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return;
|
|
964
|
+
|
|
965
|
+
const isDirect = data.conversationType === '1';
|
|
966
|
+
const senderId = data.senderStaffId || data.senderId;
|
|
967
|
+
const senderName = data.senderNick || 'Unknown';
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
// ===== DM Policy 检查 =====
|
|
974
|
+
if (isDirect) {
|
|
975
|
+
const dmPolicy = config.dmPolicy || 'open';
|
|
976
|
+
const allowFrom: (string | number)[] = config.allowFrom || [];
|
|
977
|
+
|
|
978
|
+
// 处理 pairing 策略(暂不支持,当作 open 处理并记录警告)
|
|
979
|
+
if (dmPolicy === 'pairing') {
|
|
980
|
+
log?.warn?.(`dmPolicy="pairing" 暂不支持,将按 "open" 策略处理`);
|
|
981
|
+
// 继续执行,不拦截
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 处理 allowlist 策略
|
|
985
|
+
if (dmPolicy === 'allowlist') {
|
|
986
|
+
if (!senderId) {
|
|
987
|
+
log?.warn?.(`DM 被拦截: senderId 为空`);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// 规范化 senderId 和 allowFrom 进行比较(支持 string 和 number 类型)
|
|
992
|
+
const normalizedSenderId = String(senderId);
|
|
993
|
+
const normalizedAllowFrom = allowFrom.map(id => String(id));
|
|
994
|
+
|
|
995
|
+
// 白名单为空时拦截所有(虽然 Schema 验证会阻止这种情况,但代码层面也要防御)
|
|
996
|
+
if (normalizedAllowFrom.length === 0) {
|
|
997
|
+
log?.warn?.(`[DingTalk] DM 被拦截: allowFrom 白名单为空,拒绝所有请求`);
|
|
998
|
+
|
|
999
|
+
try {
|
|
1000
|
+
await sendProactive(config, { userId: senderId }, '抱歉,此机器人的访问白名单配置有误。请联系管理员检查配置。', {
|
|
1001
|
+
msgType: 'text',
|
|
1002
|
+
useAICard: false,
|
|
1003
|
+
fallbackToNormal: true,
|
|
1004
|
+
log,
|
|
1005
|
+
});
|
|
1006
|
+
} catch (err: any) {
|
|
1007
|
+
log?.error?.(`[DingTalk] 发送 DM 配置错误提示失败: ${err.message}`);
|
|
1008
|
+
}
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// 检查是否在白名单中
|
|
1013
|
+
if (!normalizedAllowFrom.includes(normalizedSenderId)) {
|
|
1014
|
+
log?.warn?.(`DM 被拦截: senderId=${senderId} (${senderName}) 不在白名单中`);
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
await sendProactive(config, { userId: senderId }, '抱歉,您暂无权限使用此机器人。如需开通权限,请联系管理员。', {
|
|
1018
|
+
msgType: 'text',
|
|
1019
|
+
useAICard: false,
|
|
1020
|
+
fallbackToNormal: true,
|
|
1021
|
+
log,
|
|
1022
|
+
});
|
|
1023
|
+
} catch (err: any) {
|
|
1024
|
+
log?.error?.(`发送 DM 拦截提示失败: ${err.message}`);
|
|
1025
|
+
}
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ===== 群聊 Policy 检查 =====
|
|
1032
|
+
if (!isDirect) {
|
|
1033
|
+
const groupPolicy = config.groupPolicy || 'open';
|
|
1034
|
+
const conversationId = data.conversationId;
|
|
1035
|
+
const groupAllowFrom: (string | number)[] = config.groupAllowFrom || [];
|
|
1036
|
+
|
|
1037
|
+
// 处理 disabled 策略
|
|
1038
|
+
if (groupPolicy === 'disabled') {
|
|
1039
|
+
log?.warn?.(`群聊被拦截: groupPolicy=disabled`);
|
|
1040
|
+
|
|
1041
|
+
try {
|
|
1042
|
+
await sendProactive(config, { openConversationId: conversationId }, '抱歉,此机器人暂不支持群聊功能。', {
|
|
1043
|
+
msgType: 'text',
|
|
1044
|
+
useAICard: false,
|
|
1045
|
+
fallbackToNormal: true,
|
|
1046
|
+
log,
|
|
1047
|
+
});
|
|
1048
|
+
} catch (err: any) {
|
|
1049
|
+
log?.error?.(`发送群聊 disabled 提示失败: ${err.message}`);
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// 处理 allowlist 策略
|
|
1055
|
+
if (groupPolicy === 'allowlist') {
|
|
1056
|
+
if (!conversationId) {
|
|
1057
|
+
log?.warn?.(`群聊被拦截: conversationId 为空`);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// 规范化 conversationId 和 groupAllowFrom 进行比较(支持 string 和 number 类型)
|
|
1062
|
+
const normalizedConversationId = String(conversationId);
|
|
1063
|
+
const normalizedGroupAllowFrom = groupAllowFrom.map(id => String(id));
|
|
1064
|
+
|
|
1065
|
+
// 白名单为空时拦截所有(虽然 Schema 验证会阻止这种情况,但代码层面也要防御)
|
|
1066
|
+
if (normalizedGroupAllowFrom.length === 0) {
|
|
1067
|
+
log?.warn?.(`群聊被拦截: groupAllowFrom 白名单为空,拒绝所有请求`);
|
|
1068
|
+
|
|
1069
|
+
try {
|
|
1070
|
+
await sendProactive(config, { openConversationId: conversationId }, '抱歉,此机器人的群组访问白名单配置有误。请联系管理员检查配置。', {
|
|
1071
|
+
msgType: 'text',
|
|
1072
|
+
useAICard: false,
|
|
1073
|
+
fallbackToNormal: true,
|
|
1074
|
+
log,
|
|
1075
|
+
});
|
|
1076
|
+
} catch (err: any) {
|
|
1077
|
+
log?.error?.(`发送群聊配置错误提示失败: ${err.message}`);
|
|
1078
|
+
}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// 检查是否在白名单中
|
|
1083
|
+
if (!normalizedGroupAllowFrom.includes(normalizedConversationId)) {
|
|
1084
|
+
log?.warn?.(`群聊被拦截: conversationId=${conversationId} 不在 groupAllowFrom 白名单中`);
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
await sendProactive(config, { openConversationId: conversationId }, '抱歉,此群组暂无权限使用此机器人。如需开通权限,请联系管理员。', {
|
|
1088
|
+
msgType: 'text',
|
|
1089
|
+
useAICard: false,
|
|
1090
|
+
fallbackToNormal: true,
|
|
1091
|
+
log,
|
|
1092
|
+
});
|
|
1093
|
+
} catch (err: any) {
|
|
1094
|
+
log?.error?.(`发送群聊 allowlist 提示失败: ${err.message}`);
|
|
1095
|
+
}
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// 构建会话上下文
|
|
1102
|
+
const sessionContext = buildSessionContext({
|
|
1103
|
+
accountId,
|
|
1104
|
+
senderId,
|
|
1105
|
+
senderName,
|
|
1106
|
+
conversationType: data.conversationType,
|
|
1107
|
+
conversationId: data.conversationId,
|
|
1108
|
+
groupSubject: data.conversationTitle,
|
|
1109
|
+
separateSessionByConversation: config.separateSessionByConversation,
|
|
1110
|
+
groupSessionScope: config.groupSessionScope,
|
|
1111
|
+
sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
// ===== 解析 agentId 和工作空间路径(在 sessionContext 之后,确保 chatType 与会话隔离策略一致)=====
|
|
1115
|
+
// 使用 sessionContext.peerId 进行匹配(真实的 conversationId/senderId,与 match.peer.id 语义一致)。
|
|
1116
|
+
// 注意:不能使用 sessionContext.sessionPeerId,它受 sharedMemoryAcrossConversations 等配置影响,
|
|
1117
|
+
// 可能被设为 accountId,导致不同群/用户的消息匹配到同一个 binding,路由错误。
|
|
1118
|
+
let matchedAgentId: string | null = null;
|
|
1119
|
+
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
1120
|
+
for (const binding of cfg.bindings) {
|
|
1121
|
+
const match = binding.match;
|
|
1122
|
+
if (match.channel && match.channel !== "dingtalk-connector") continue;
|
|
1123
|
+
if (match.accountId && match.accountId !== accountId) continue;
|
|
1124
|
+
if (match.peer) {
|
|
1125
|
+
if (match.peer.kind && match.peer.kind !== sessionContext.chatType) continue;
|
|
1126
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== sessionContext.peerId) continue;
|
|
1127
|
+
}
|
|
1128
|
+
matchedAgentId = binding.agentId;
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (!matchedAgentId) {
|
|
1133
|
+
matchedAgentId = cfg.defaultAgent || 'main';
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 获取 Agent 工作空间路径
|
|
1137
|
+
const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, matchedAgentId);
|
|
1138
|
+
log?.info?.(`Agent 工作空间路径: ${agentWorkspaceDir}`);
|
|
1139
|
+
|
|
1140
|
+
// 构建消息内容
|
|
1141
|
+
// ✅ 使用 normalizeSlashCommand 归一化新会话命令
|
|
1142
|
+
const rawText = content.text || '';
|
|
1143
|
+
|
|
1144
|
+
// 归一化命令(将 /reset、/clear、新会话 等别名统一为 /new)
|
|
1145
|
+
const normalizedText = normalizeSlashCommand(rawText);
|
|
1146
|
+
let userContent = normalizedText || (content.imageUrls.length > 0 ? '请描述这张图片' : '');
|
|
1147
|
+
|
|
1148
|
+
// ===== 图片下载到本地文件 =====
|
|
1149
|
+
const imageLocalPaths: string[] = [];
|
|
1150
|
+
|
|
1151
|
+
log?.info?.(`处理消息: accountId=${accountId}, data= ${JSON.stringify(data, null, 2)}, sender=${senderName}, text=${content.text.slice(0, 50)}...`);
|
|
1152
|
+
|
|
1153
|
+
// 处理 imageUrls(来自富文本消息)
|
|
1154
|
+
for (let i = 0; i < content.imageUrls.length; i++) {
|
|
1155
|
+
const url = content.imageUrls[i];
|
|
1156
|
+
try {
|
|
1157
|
+
log?.info?.(`处理图片 ${i + 1}/${content.imageUrls.length}: ${url.slice(0, 50)}...`);
|
|
1158
|
+
|
|
1159
|
+
if (url.startsWith('downloadCode:')) {
|
|
1160
|
+
const code = url.slice('downloadCode:'.length);
|
|
1161
|
+
const localPath = await downloadMediaByCode(code, config, agentWorkspaceDir, log);
|
|
1162
|
+
if (localPath) {
|
|
1163
|
+
imageLocalPaths.push(localPath);
|
|
1164
|
+
log?.info?.(`图片下载成功 ${i + 1}/${content.imageUrls.length}`);
|
|
1165
|
+
} else {
|
|
1166
|
+
log?.warn?.(`图片下载失败 ${i + 1}/${content.imageUrls.length}`);
|
|
1167
|
+
}
|
|
1168
|
+
} else {
|
|
1169
|
+
const localPath = await downloadImageToFile(url, agentWorkspaceDir, log);
|
|
1170
|
+
if (localPath) {
|
|
1171
|
+
imageLocalPaths.push(localPath);
|
|
1172
|
+
log?.info?.(`图片下载成功 ${i + 1}/${content.imageUrls.length}`);
|
|
1173
|
+
} else {
|
|
1174
|
+
log?.warn?.(`图片下载失败 ${i + 1}/${content.imageUrls.length}`);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
} catch (err: any) {
|
|
1178
|
+
log?.error?.(`图片下载异常 ${i + 1}/${content.imageUrls.length}: ${err.message}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// 处理 downloadCodes(来自 picture 消息,fileNames 为空的是图片)
|
|
1183
|
+
for (let i = 0; i < content.downloadCodes.length; i++) {
|
|
1184
|
+
const code = content.downloadCodes[i];
|
|
1185
|
+
const fileName = content.fileNames[i];
|
|
1186
|
+
if (!fileName) {
|
|
1187
|
+
try {
|
|
1188
|
+
log?.info?.(`处理 downloadCode 图片 ${i + 1}/${content.downloadCodes.length}`);
|
|
1189
|
+
const localPath = await downloadMediaByCode(code, config, agentWorkspaceDir, log);
|
|
1190
|
+
if (localPath) {
|
|
1191
|
+
imageLocalPaths.push(localPath);
|
|
1192
|
+
log?.info?.(`downloadCode 图片下载成功 ${i + 1}/${content.downloadCodes.length}`);
|
|
1193
|
+
} else {
|
|
1194
|
+
log?.warn?.(`downloadCode 图片下载失败 ${i + 1}/${content.downloadCodes.length}`);
|
|
1195
|
+
}
|
|
1196
|
+
} catch (err: any) {
|
|
1197
|
+
log?.error?.(`downloadCode 图片下载异常 ${i + 1}/${content.downloadCodes.length}: ${err.message}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
log?.info?.(`图片下载完成: 成功=${imageLocalPaths.length}, 总数=${content.imageUrls.length + content.downloadCodes.filter((_, i) => !content.fileNames[i]).length}`);
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
// ===== 文件附件处理:自动下载并解析内容 =====
|
|
1207
|
+
const fileContentParts: string[] = [];
|
|
1208
|
+
for (let i = 0; i < content.downloadCodes.length; i++) {
|
|
1209
|
+
const code = content.downloadCodes[i];
|
|
1210
|
+
const fileName = content.fileNames[i];
|
|
1211
|
+
if (!fileName) continue;
|
|
1212
|
+
|
|
1213
|
+
try {
|
|
1214
|
+
log?.info?.(`处理文件附件 ${i + 1}/${content.downloadCodes.length}: ${fileName}`);
|
|
1215
|
+
|
|
1216
|
+
// 获取下载链接
|
|
1217
|
+
const downloadUrl = await getFileDownloadUrl(code, fileName, config, log);
|
|
1218
|
+
if (!downloadUrl) {
|
|
1219
|
+
fileContentParts.push(`⚠️ 文件获取失败: ${fileName}`);
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// 下载文件到本地
|
|
1224
|
+
const localPath = await downloadFileToLocal(downloadUrl, fileName, agentWorkspaceDir, log);
|
|
1225
|
+
if (!localPath) {
|
|
1226
|
+
fileContentParts.push(`⚠️ 文件下载失败: ${fileName}\n🔗 [点击下载](${downloadUrl})`);
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// 识别文件类型
|
|
1231
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
1232
|
+
let fileType = '文件';
|
|
1233
|
+
|
|
1234
|
+
if (['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'].includes(ext)) {
|
|
1235
|
+
fileType = '视频';
|
|
1236
|
+
} else if (['.mp3', '.wav', '.aac', '.ogg', '.m4a', '.flac', '.wma'].includes(ext)) {
|
|
1237
|
+
fileType = '音频';
|
|
1238
|
+
} else if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
|
1239
|
+
fileType = '图片';
|
|
1240
|
+
} else if (['.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.csv', '.log', '.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.sh', '.bat'].includes(ext)) {
|
|
1241
|
+
fileType = '文本文件';
|
|
1242
|
+
} else if (['.docx', '.doc'].includes(ext)) {
|
|
1243
|
+
fileType = 'Word 文档';
|
|
1244
|
+
} else if (ext === '.pdf') {
|
|
1245
|
+
fileType = 'PDF 文档';
|
|
1246
|
+
} else if (['.xlsx', '.xls'].includes(ext)) {
|
|
1247
|
+
fileType = 'Excel 表格';
|
|
1248
|
+
} else if (['.pptx', '.ppt'].includes(ext)) {
|
|
1249
|
+
fileType = 'PPT 演示文稿';
|
|
1250
|
+
} else if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
|
1251
|
+
fileType = '压缩包';
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// 解析文件内容
|
|
1255
|
+
const parseResult = await parseFileContent(localPath, fileName, log);
|
|
1256
|
+
|
|
1257
|
+
if (parseResult.type === 'text' && parseResult.content) {
|
|
1258
|
+
// 文本类文件:将内容注入到上下文(即使解析成功也给出文件路径)
|
|
1259
|
+
const contentPreview = parseResult.content.length > 200
|
|
1260
|
+
? parseResult.content.slice(0, 200) + '...'
|
|
1261
|
+
: parseResult.content;
|
|
1262
|
+
|
|
1263
|
+
fileContentParts.push(
|
|
1264
|
+
`📄 **${fileType}**: ${fileName}\n` +
|
|
1265
|
+
`✅ 已解析文件内容(${parseResult.content.length} 字符)\n` +
|
|
1266
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
1267
|
+
`📝 内容预览:\n\`\`\`\n${contentPreview}\n\`\`\`\n\n` +
|
|
1268
|
+
`📋 完整内容:\n${parseResult.content}`
|
|
1269
|
+
);
|
|
1270
|
+
log?.info?.(`文件解析成功: ${fileName}, 内容长度=${parseResult.content.length}`);
|
|
1271
|
+
} else if (parseResult.type === 'text' && !parseResult.content) {
|
|
1272
|
+
// 文本类文件但解析失败
|
|
1273
|
+
fileContentParts.push(
|
|
1274
|
+
`📄 **${fileType}**: ${fileName}\n` +
|
|
1275
|
+
`⚠️ 文件解析失败,已保存到本地\n` +
|
|
1276
|
+
`💾 本地路径: ${localPath}\n` +
|
|
1277
|
+
`🔗 [点击下载](${downloadUrl})`
|
|
1278
|
+
);
|
|
1279
|
+
log?.warn?.(`文件解析失败: ${fileName}`);
|
|
1280
|
+
} else {
|
|
1281
|
+
// 二进制文件:只保存到磁盘
|
|
1282
|
+
// 特殊处理音频文件的语音识别文本
|
|
1283
|
+
if (fileType === '音频' && content.text && content.text !== '[语音消息]') {
|
|
1284
|
+
fileContentParts.push(
|
|
1285
|
+
`🎤 **${fileType}**: ${fileName}\n` +
|
|
1286
|
+
`📝 语音识别: ${content.text}\n` +
|
|
1287
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
1288
|
+
`🔗 [点击下载](${downloadUrl})`
|
|
1289
|
+
);
|
|
1290
|
+
} else {
|
|
1291
|
+
fileContentParts.push(
|
|
1292
|
+
`📎 **${fileType}**: ${fileName}\n` +
|
|
1293
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
1294
|
+
`🔗 [点击下载](${downloadUrl})`
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
log?.info?.(`二进制文件已保存: ${fileName}, path=${localPath}`);
|
|
1298
|
+
}
|
|
1299
|
+
} catch (err: any) {
|
|
1300
|
+
log?.error?.(`文件处理异常: ${fileName}, error=${err.message}`);
|
|
1301
|
+
fileContentParts.push(`⚠️ 文件处理失败: ${fileName}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (fileContentParts.length > 0) {
|
|
1306
|
+
const fileText = fileContentParts.join('\n\n');
|
|
1307
|
+
userContent = userContent ? `${userContent}\n\n${fileText}` : fileText;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (!userContent && imageLocalPaths.length === 0) return;
|
|
1311
|
+
|
|
1312
|
+
// ===== 贴处理中表情 =====
|
|
1313
|
+
// 若队列繁忙时已在入队阶段提前贴过表情,此处跳过,避免重复贴
|
|
1314
|
+
if (!params.emotionAlreadyAdded) {
|
|
1315
|
+
addEmotionReply(config, data, log).catch(err => {
|
|
1316
|
+
log?.warn?.(`贴表情失败: ${err.message}`);
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 =====
|
|
1321
|
+
const asyncMode = config.asyncMode === true;
|
|
1322
|
+
log?.info?.(`asyncMode 检测: config.asyncMode=${config.asyncMode}, asyncMode=${asyncMode}`);
|
|
1323
|
+
|
|
1324
|
+
const proactiveTarget = isDirect
|
|
1325
|
+
? { userId: senderId }
|
|
1326
|
+
: { openConversationId: data.conversationId };
|
|
1327
|
+
|
|
1328
|
+
if (asyncMode) {
|
|
1329
|
+
log?.info?.(`进入异步模式分支`);
|
|
1330
|
+
const ackText = config.ackText || '🫡 任务已接收,处理中...';
|
|
1331
|
+
try {
|
|
1332
|
+
await sendProactive(config, proactiveTarget, ackText, {
|
|
1333
|
+
msgType: 'text',
|
|
1334
|
+
useAICard: false,
|
|
1335
|
+
fallbackToNormal: true,
|
|
1336
|
+
log,
|
|
1337
|
+
});
|
|
1338
|
+
} catch (ackErr: any) {
|
|
1339
|
+
log?.warn?.(`Failed to send acknowledgment: ${ackErr?.message || ackErr}`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// ===== 使用 SDK 的 dispatchReplyFromConfig =====
|
|
1344
|
+
try {
|
|
1345
|
+
const core = getDingtalkRuntime();
|
|
1346
|
+
|
|
1347
|
+
// 构建消息体(添加图片)
|
|
1348
|
+
let finalContent = userContent;
|
|
1349
|
+
if (imageLocalPaths.length > 0) {
|
|
1350
|
+
const imageMarkdown = imageLocalPaths.map(p => ``).join('\n');
|
|
1351
|
+
finalContent = finalContent ? `${finalContent}\n\n${imageMarkdown}` : imageMarkdown;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// 构建 envelope 格式的消息
|
|
1355
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1356
|
+
const envelopeFrom = isDirect ? senderId : `${data.conversationId}:${senderId}`;
|
|
1357
|
+
|
|
1358
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
1359
|
+
channel: "DingTalk",
|
|
1360
|
+
from: envelopeFrom,
|
|
1361
|
+
timestamp: new Date(),
|
|
1362
|
+
envelope: envelopeOptions,
|
|
1363
|
+
body: finalContent,
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// matchedAgentId 已在 sessionContext 构建之后通过 bindings 匹配确定,此处直接使用
|
|
1367
|
+
const matchedBy = matchedAgentId !== (cfg.defaultAgent || 'main') ? 'binding' : 'default';
|
|
1368
|
+
|
|
1369
|
+
// ✅ 使用 SDK 标准方法构建 sessionKey,符合 OpenClaw 规范
|
|
1370
|
+
// 格式:agent:{agentId}:{channel}:{peerKind}:{sessionPeerId}
|
|
1371
|
+
// ✅ 使用 sessionContext.sessionPeerId 构建 sessionKey,确保会话隔离配置生效
|
|
1372
|
+
// ✅ 关键修复:传递 dmScope 参数,让 SDK 使用配置文件中的 session.dmScope 设置
|
|
1373
|
+
const dmScope = cfg.session?.dmScope || 'per-channel-peer';
|
|
1374
|
+
log?.info?.(`🔍 构建 sessionKey 前的参数: agentId=${matchedAgentId}, channel=dingtalk-connector, accountId=${accountId}, chatType=${sessionContext.chatType}, sessionPeerId=${sessionContext.sessionPeerId}, dmScope=${dmScope}`);
|
|
1375
|
+
const sessionKey = core.channel.routing.buildAgentSessionKey({
|
|
1376
|
+
agentId: matchedAgentId,
|
|
1377
|
+
channel: 'dingtalk-connector', // ✅ 使用 'dingtalk-connector' 而不是 'dingtalk'
|
|
1378
|
+
accountId: accountId,
|
|
1379
|
+
peer: {
|
|
1380
|
+
kind: sessionContext.chatType, // ✅ 使用 sessionContext.chatType
|
|
1381
|
+
id: sessionContext.sessionPeerId, // ✅ 使用 sessionContext.sessionPeerId(包含会话隔离逻辑)
|
|
1382
|
+
},
|
|
1383
|
+
dmScope: dmScope, // ✅ 传递 dmScope 参数,确保生成完整格式的 sessionKey
|
|
1384
|
+
});
|
|
1385
|
+
log?.info?.(`路由解析完成: agentId=${matchedAgentId}, sessionKey=${sessionKey}, matchedBy=${matchedBy}`);
|
|
1386
|
+
|
|
1387
|
+
// 构建 inbound context,使用解析后的 sessionKey
|
|
1388
|
+
log?.info?.(`开始构建 inbound context...`);
|
|
1389
|
+
|
|
1390
|
+
// ✅ 计算正确的 To 字段
|
|
1391
|
+
const toField = isDirect ? senderId : data.conversationId;
|
|
1392
|
+
log?.info?.(`构建 inbound context: isDirect=${isDirect}, senderId=${senderId}, conversationId=${data.conversationId}, To=${toField}`);
|
|
1393
|
+
|
|
1394
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
1395
|
+
Body: body,
|
|
1396
|
+
BodyForAgent: finalContent,
|
|
1397
|
+
RawBody: userContent,
|
|
1398
|
+
CommandBody: userContent,
|
|
1399
|
+
From: senderId,
|
|
1400
|
+
To: toField, // ✅ 修复:单聊用 senderId,群聊用 conversationId
|
|
1401
|
+
SessionKey: sessionKey, // ✅ 使用手动匹配的 sessionKey
|
|
1402
|
+
AccountId: accountId,
|
|
1403
|
+
ChatType: sessionContext.chatType,
|
|
1404
|
+
GroupSubject: isDirect ? undefined : data.conversationTitle,
|
|
1405
|
+
SenderName: senderName,
|
|
1406
|
+
SenderId: senderId,
|
|
1407
|
+
Provider: "dingtalk-connector" as const,
|
|
1408
|
+
Surface: "dingtalk-connector" as const,
|
|
1409
|
+
MessageSid: data.msgId,
|
|
1410
|
+
Timestamp: Date.now(),
|
|
1411
|
+
CommandAuthorized: true,
|
|
1412
|
+
OriginatingChannel: "dingtalk-connector" as const,
|
|
1413
|
+
OriginatingTo: toField, // ✅ 修复:应该使用 toField,而不是 accountId
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
// 创建 reply dispatcher,使用解析后的 agentId
|
|
1417
|
+
const { dispatcher, replyOptions, markDispatchIdle, getAsyncModeResponse } = createDingtalkReplyDispatcher({
|
|
1418
|
+
cfg,
|
|
1419
|
+
agentId: matchedAgentId, // ✅ 使用手动匹配的 agentId
|
|
1420
|
+
runtime: runtime as RuntimeEnv,
|
|
1421
|
+
conversationId: data.conversationId,
|
|
1422
|
+
senderId,
|
|
1423
|
+
isDirect,
|
|
1424
|
+
accountId,
|
|
1425
|
+
messageCreateTimeMs: Date.now(),
|
|
1426
|
+
sessionWebhook: data.sessionWebhook,
|
|
1427
|
+
asyncMode,
|
|
1428
|
+
preCreatedCard: params.preCreatedCard,
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
// ===== 构建卡片链接路由指令(对齐 Rust agent_support.rs build_link_routing_prompt)=====
|
|
1432
|
+
// 识别 interactiveCard / actionCard 消息中的 URL,根据 host 注入不同的 AI 指令:
|
|
1433
|
+
// - alidocs.dingtalk.com → 使用 dingtalk-workspace skill 读取(同时尝试 doc 和 AI table workflow)
|
|
1434
|
+
// - 其他 URL → 使用 read_url 读取
|
|
1435
|
+
// 注:SDK 的 replyOptions 不支持 extraSystemPrompt,改为追加到消息内容中传递给 AI
|
|
1436
|
+
const linkRoutingPrompt = buildLinkRoutingPrompt(content);
|
|
1437
|
+
if (linkRoutingPrompt) {
|
|
1438
|
+
finalContent = finalContent
|
|
1439
|
+
? `${finalContent}\n\n${linkRoutingPrompt}`
|
|
1440
|
+
: linkRoutingPrompt;
|
|
1441
|
+
log?.info?.(`注入卡片链接路由指令: ${linkRoutingPrompt.slice(0, 100)}...`);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// 使用 SDK 的 dispatchReplyFromConfig
|
|
1445
|
+
const dispatchResult = await core.channel.reply.withReplyDispatcher({
|
|
1446
|
+
dispatcher,
|
|
1447
|
+
onSettled: () => {
|
|
1448
|
+
markDispatchIdle();
|
|
1449
|
+
},
|
|
1450
|
+
run: async () => {
|
|
1451
|
+
const result = await core.channel.reply.dispatchReplyFromConfig({
|
|
1452
|
+
ctx: ctxPayload,
|
|
1453
|
+
cfg,
|
|
1454
|
+
dispatcher,
|
|
1455
|
+
replyOptions,
|
|
1456
|
+
});
|
|
1457
|
+
return result;
|
|
1458
|
+
},
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
const { queuedFinal, counts } = dispatchResult;
|
|
1462
|
+
|
|
1463
|
+
// ===== 异步模式:主动推送最终结果 =====
|
|
1464
|
+
if (asyncMode) {
|
|
1465
|
+
try {
|
|
1466
|
+
const fullResponse = getAsyncModeResponse();
|
|
1467
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
1468
|
+
let finalText = fullResponse;
|
|
1469
|
+
|
|
1470
|
+
if (oapiToken) {
|
|
1471
|
+
finalText = await processLocalImages(finalText, oapiToken, log);
|
|
1472
|
+
|
|
1473
|
+
const mediaTarget: AICardTarget = isDirect
|
|
1474
|
+
? { type: 'user', userId: senderId }
|
|
1475
|
+
: { type: 'group', openConversationId: data.conversationId };
|
|
1476
|
+
|
|
1477
|
+
// ✅ 处理 Markdown 标记格式的媒体文件
|
|
1478
|
+
finalText = await processVideoMarkers(
|
|
1479
|
+
finalText,
|
|
1480
|
+
'',
|
|
1481
|
+
config,
|
|
1482
|
+
oapiToken,
|
|
1483
|
+
log,
|
|
1484
|
+
true, // ✅ 使用主动 API 模式
|
|
1485
|
+
mediaTarget
|
|
1486
|
+
);
|
|
1487
|
+
finalText = await processAudioMarkers(
|
|
1488
|
+
finalText,
|
|
1489
|
+
'',
|
|
1490
|
+
config,
|
|
1491
|
+
oapiToken,
|
|
1492
|
+
log,
|
|
1493
|
+
true, // ✅ 使用主动 API 模式
|
|
1494
|
+
mediaTarget
|
|
1495
|
+
);
|
|
1496
|
+
finalText = await processFileMarkers(
|
|
1497
|
+
finalText,
|
|
1498
|
+
'',
|
|
1499
|
+
config,
|
|
1500
|
+
oapiToken,
|
|
1501
|
+
log,
|
|
1502
|
+
true, // ✅ 使用主动 API 模式
|
|
1503
|
+
mediaTarget
|
|
1504
|
+
);
|
|
1505
|
+
|
|
1506
|
+
// ✅ 处理裸露的本地文件路径(绕过 OpenClaw SDK 的 bug)
|
|
1507
|
+
const { processRawMediaPaths } = await import('../services/media');
|
|
1508
|
+
finalText = await processRawMediaPaths(
|
|
1509
|
+
finalText,
|
|
1510
|
+
config,
|
|
1511
|
+
oapiToken,
|
|
1512
|
+
log,
|
|
1513
|
+
mediaTarget
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const textToSend = finalText.trim() || '✅ 任务执行完成(无文本输出)';
|
|
1518
|
+
const title =
|
|
1519
|
+
textToSend.split('\n')[0]?.replace(/^[#*\s\->]+/, '').trim() || '消息';
|
|
1520
|
+
await sendProactive(config, proactiveTarget, textToSend, {
|
|
1521
|
+
msgType: 'markdown',
|
|
1522
|
+
title,
|
|
1523
|
+
useAICard: false,
|
|
1524
|
+
fallbackToNormal: true,
|
|
1525
|
+
log,
|
|
1526
|
+
});
|
|
1527
|
+
} catch (asyncErr: any) {
|
|
1528
|
+
const errMsg = `⚠️ 任务执行失败: ${asyncErr?.message || asyncErr}`;
|
|
1529
|
+
try {
|
|
1530
|
+
await sendProactive(config, proactiveTarget, errMsg, {
|
|
1531
|
+
msgType: 'text',
|
|
1532
|
+
useAICard: false,
|
|
1533
|
+
fallbackToNormal: true,
|
|
1534
|
+
log,
|
|
1535
|
+
});
|
|
1536
|
+
} catch (sendErr: any) {
|
|
1537
|
+
log?.error?.(`错误通知发送失败: ${sendErr?.message || sendErr}`);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
} catch (err: any) {
|
|
1543
|
+
log?.error?.(`SDK dispatch 失败: ${err.message}`);
|
|
1544
|
+
|
|
1545
|
+
// 降级:发送错误消息
|
|
1546
|
+
try {
|
|
1547
|
+
const token = await getAccessToken(config);
|
|
1548
|
+
const body: any = {
|
|
1549
|
+
msgtype: 'text',
|
|
1550
|
+
text: { content: `抱歉,处理请求时出错: ${err.message}` }
|
|
1551
|
+
};
|
|
1552
|
+
if (!isDirect) body.at = { atUserIds: [senderId], isAtAll: false };
|
|
1553
|
+
|
|
1554
|
+
await dingtalkHttp.post(sessionWebhook, body, {
|
|
1555
|
+
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
1556
|
+
});
|
|
1557
|
+
} catch (fallbackErr: any) {
|
|
1558
|
+
log?.error?.(`错误消息发送也失败: ${fallbackErr.message}`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// ===== 撤回处理中表情 =====
|
|
1563
|
+
// 使用 await 确保表情撤销完成后再结束函数
|
|
1564
|
+
try {
|
|
1565
|
+
await recallEmotionReply(config, data, log);
|
|
1566
|
+
} catch (err: any) {
|
|
1567
|
+
log?.warn?.(`撤回表情异常: ${err.message}`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* 消息处理入口函数(带队列管理)
|
|
1573
|
+
* 确保同一会话+agent的消息按顺序处理,避免并发冲突
|
|
1574
|
+
*/
|
|
1575
|
+
export async function handleDingTalkMessage(params: HandleMessageParams): Promise<void> {
|
|
1576
|
+
const { accountId, config, data, log, cfg } = params;
|
|
1577
|
+
|
|
1578
|
+
// 使用 buildSessionContext 构建会话标识,与 handleDingTalkMessageInternal 保持一致
|
|
1579
|
+
// 确保 queueKey 的隔离策略(groupSessionScope、sharedMemoryAcrossConversations)与 sessionKey 一致
|
|
1580
|
+
const isDirect = data.conversationType === '1';
|
|
1581
|
+
const senderId = data.senderStaffId || data.senderId;
|
|
1582
|
+
const conversationId = data.conversationId;
|
|
1583
|
+
|
|
1584
|
+
const queueSessionContext = buildSessionContext({
|
|
1585
|
+
accountId,
|
|
1586
|
+
senderId,
|
|
1587
|
+
conversationType: data.conversationType,
|
|
1588
|
+
conversationId,
|
|
1589
|
+
separateSessionByConversation: config.separateSessionByConversation,
|
|
1590
|
+
groupSessionScope: config.groupSessionScope,
|
|
1591
|
+
sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
const baseSessionId = queueSessionContext.sessionPeerId;
|
|
1595
|
+
|
|
1596
|
+
if (!baseSessionId) {
|
|
1597
|
+
log?.warn?.('无法构建会话标识,跳过队列管理');
|
|
1598
|
+
return handleDingTalkMessageInternal(params);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// 解析 agentId:使用 queueSessionContext.peerId(真实 peer 标识)进行匹配
|
|
1602
|
+
// 与 handleDingTalkMessageInternal 中的匹配逻辑保持一致。
|
|
1603
|
+
// 必须使用 peerId 而非 sessionPeerId,原因:sharedMemoryAcrossConversations=true 时
|
|
1604
|
+
// sessionPeerId 被设为 accountId,导致不同群的消息匹配到同一个 binding。
|
|
1605
|
+
let matchedAgentId: string | null = null;
|
|
1606
|
+
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
1607
|
+
for (const binding of cfg.bindings) {
|
|
1608
|
+
const match = binding.match;
|
|
1609
|
+
if (match.channel && match.channel !== "dingtalk-connector") continue;
|
|
1610
|
+
if (match.accountId && match.accountId !== accountId) continue;
|
|
1611
|
+
if (match.peer) {
|
|
1612
|
+
if (match.peer.kind && match.peer.kind !== queueSessionContext.chatType) continue;
|
|
1613
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== queueSessionContext.peerId) continue;
|
|
1614
|
+
}
|
|
1615
|
+
matchedAgentId = binding.agentId;
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (!matchedAgentId) {
|
|
1620
|
+
matchedAgentId = cfg.defaultAgent || 'main';
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// 构建队列标识:会话 peerId + agentId
|
|
1624
|
+
// queueKey 与 sessionKey 使用相同的 peerId,确保隔离策略一致:
|
|
1625
|
+
// - groupSessionScope: 'group_sender' 时,同群不同用户的消息可并行处理
|
|
1626
|
+
// - sharedMemoryAcrossConversations: true 时,所有消息共享同一队列
|
|
1627
|
+
const queueKey = `${baseSessionId}:${matchedAgentId}`;
|
|
1628
|
+
|
|
1629
|
+
try {
|
|
1630
|
+
|
|
1631
|
+
// 更新会话活跃时间
|
|
1632
|
+
sessionLastActivity.set(queueKey, Date.now());
|
|
1633
|
+
|
|
1634
|
+
// 检测队列是否繁忙(入队前检查,此时 previousTask 尚未被当前消息覆盖)
|
|
1635
|
+
const isQueueBusy = sessionQueues.has(queueKey);
|
|
1636
|
+
|
|
1637
|
+
// 获取该会话+agent的上一个处理任务
|
|
1638
|
+
const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
|
|
1639
|
+
|
|
1640
|
+
// 队列繁忙时:立即创建一个 AI Card 显示排队 ACK 文案,并将 Card 实例传入处理任务
|
|
1641
|
+
// 处理完成后 reply-dispatcher 会复用此 Card 更新为最终结果,用户看到的是同一条消息的内容变化
|
|
1642
|
+
let preCreatedCard: AICardInstance | undefined;
|
|
1643
|
+
if (isQueueBusy) {
|
|
1644
|
+
const ackPhrases = QUEUE_BUSY_ACK_PHRASES;
|
|
1645
|
+
const ackText = ackPhrases[Math.floor(Math.random() * ackPhrases.length)];
|
|
1646
|
+
const cardTarget: AICardTarget = isDirect
|
|
1647
|
+
? { type: 'user', userId: senderId }
|
|
1648
|
+
: { type: 'group', openConversationId: data.conversationId };
|
|
1649
|
+
|
|
1650
|
+
try {
|
|
1651
|
+
const card = await createAICardForTarget(config, cardTarget, log);
|
|
1652
|
+
if (card) {
|
|
1653
|
+
// 用 streamAICard 把 ACK 文案写入 Card(INPUTING 状态,表示正在处理中)
|
|
1654
|
+
await streamAICard(card, ackText, false, config, log);
|
|
1655
|
+
preCreatedCard = card;
|
|
1656
|
+
log?.info?.(`[队列] 队列繁忙,已创建排队 ACK Card,cardInstanceId=${card.cardInstanceId}`);
|
|
1657
|
+
} else {
|
|
1658
|
+
log?.warn?.(`[队列] 创建排队 ACK Card 失败(返回 null),跳过 ACK`);
|
|
1659
|
+
}
|
|
1660
|
+
// 在发送 ACK 的同时立即贴上思考中表情,让用户知道消息已被接收
|
|
1661
|
+
addEmotionReply(config, data, log).catch(err => {
|
|
1662
|
+
log?.warn?.(`[队列] 贴排队表情失败: ${err.message}`);
|
|
1663
|
+
});
|
|
1664
|
+
} catch (ackErr: any) {
|
|
1665
|
+
log?.warn?.(`[队列] 创建排队 ACK Card 异常: ${ackErr?.message || ackErr}`);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// 创建当前消息的处理任务
|
|
1670
|
+
const currentTask = previousTask
|
|
1671
|
+
.then(async () => {
|
|
1672
|
+
log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
|
|
1673
|
+
await handleDingTalkMessageInternal({ ...params, preCreatedCard, emotionAlreadyAdded: isQueueBusy });
|
|
1674
|
+
log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
|
|
1675
|
+
})
|
|
1676
|
+
.catch((err: any) => {
|
|
1677
|
+
log?.error?.(`[队列] 消息处理异常,queueKey=${queueKey}, error=${err.message}`);
|
|
1678
|
+
// 不抛出错误,避免阻塞后续消息
|
|
1679
|
+
})
|
|
1680
|
+
.finally(() => {
|
|
1681
|
+
// 如果当前任务是队列中的最后一个任务,清理队列
|
|
1682
|
+
if (sessionQueues.get(queueKey) === currentTask) {
|
|
1683
|
+
sessionQueues.delete(queueKey);
|
|
1684
|
+
log?.info?.(`[队列] 队列已清空,queueKey=${queueKey}`);
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// 更新队列
|
|
1689
|
+
sessionQueues.set(queueKey, currentTask);
|
|
1690
|
+
|
|
1691
|
+
// 不等待任务完成,立即返回,不阻塞 WebSocket 消息接收
|
|
1692
|
+
// 消息处理在后台异步执行,队列保证同一会话+agent的消息串行处理
|
|
1693
|
+
} catch (err: any) {
|
|
1694
|
+
log?.error?.(`[队列] 队列管理异常,直接处理: ${err.message}`);
|
|
1695
|
+
// 如果队列管理失败,直接调用内部处理函数(不阻塞)
|
|
1696
|
+
void handleDingTalkMessageInternal(params);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// handleDingTalkMessage 已在函数定义处直接导出
|