@largezhou/ddingtalk 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/index.ts +24 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +69 -0
- package/src/accounts.ts +82 -0
- package/src/channel.ts +512 -0
- package/src/client.ts +620 -0
- package/src/constants.ts +6 -0
- package/src/logger.ts +19 -0
- package/src/monitor.ts +1092 -0
- package/src/onboarding.ts +130 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +250 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
import { DWClient, TOPIC_ROBOT, type DWClientDownStream } from "dingtalk-stream";
|
|
2
|
+
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { DingTalkMessageData, ResolvedDingTalkAccount, DingTalkGroupConfig, AudioContent, VideoContent, FileContent, PictureContent, RichTextContent, RichTextElement, RichTextPictureElement } from "./types.js";
|
|
4
|
+
import { replyViaWebhook, getFileDownloadUrl, downloadFromUrl, sendTextMessage } from "./client.js";
|
|
5
|
+
import { resolveDingTalkAccount } from "./accounts.js";
|
|
6
|
+
import { getDingTalkRuntime } from "./runtime.js";
|
|
7
|
+
import { logger } from "./logger.js";
|
|
8
|
+
import { PLUGIN_ID } from "./constants.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// 媒体信息类型定义
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/** 媒体类型枚举(与钉钉消息类型一致) */
|
|
15
|
+
export type MediaKind = "picture" | "audio" | "video" | "file";
|
|
16
|
+
|
|
17
|
+
/** 单个媒体项 */
|
|
18
|
+
export interface MediaItem {
|
|
19
|
+
/** 媒体类型 */
|
|
20
|
+
kind: MediaKind;
|
|
21
|
+
/** 本地文件路径 */
|
|
22
|
+
path: string;
|
|
23
|
+
/** MIME 类型 */
|
|
24
|
+
contentType: string;
|
|
25
|
+
/** 文件名(可选) */
|
|
26
|
+
fileName?: string;
|
|
27
|
+
/** 文件大小(字节) */
|
|
28
|
+
fileSize?: number;
|
|
29
|
+
/** 时长(秒,音视频专用) */
|
|
30
|
+
duration?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 入站消息的媒体上下文 */
|
|
34
|
+
export interface InboundMediaContext {
|
|
35
|
+
/** 媒体项列表(支持多媒体混排) */
|
|
36
|
+
items: MediaItem[];
|
|
37
|
+
/** 主媒体(第一个媒体项,兼容旧逻辑) */
|
|
38
|
+
primary?: MediaItem;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 生成媒体占位符文本 */
|
|
42
|
+
function generateMediaPlaceholder(media: InboundMediaContext): string {
|
|
43
|
+
if (media.items.length === 0) return "";
|
|
44
|
+
|
|
45
|
+
return media.items
|
|
46
|
+
.map((item) => {
|
|
47
|
+
switch (item.kind) {
|
|
48
|
+
case "picture":
|
|
49
|
+
return "<media:picture>";
|
|
50
|
+
case "audio":
|
|
51
|
+
return `<media:audio${item.duration ? ` duration=${item.duration}s` : ""}>`;
|
|
52
|
+
case "video":
|
|
53
|
+
return `<media:video${item.duration ? ` duration=${item.duration}s` : ""}>`;
|
|
54
|
+
case "file":
|
|
55
|
+
return `<media:file${item.fileName ? ` name="${item.fileName}"` : ""}>`;
|
|
56
|
+
default:
|
|
57
|
+
return `<media:${item.kind}>`;
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.join(" ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 从 InboundMediaContext 构建上下文的媒体字段 */
|
|
64
|
+
function buildMediaContextFields(media?: InboundMediaContext): Record<string, unknown> {
|
|
65
|
+
if (!media || media.items.length === 0) {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const primary = media.primary ?? media.items[0];
|
|
70
|
+
|
|
71
|
+
// 基础字段(兼容旧逻辑,使用主媒体)
|
|
72
|
+
const baseFields: Record<string, unknown> = {
|
|
73
|
+
MediaPath: primary.path,
|
|
74
|
+
MediaType: primary.contentType,
|
|
75
|
+
MediaUrl: primary.path,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 多媒体字段(与 Telegram 保持一致的命名)
|
|
79
|
+
// 即使只有一个媒体也添加这些字段,保持一致性
|
|
80
|
+
if (media.items.length > 0) {
|
|
81
|
+
baseFields.MediaPaths = media.items.map((m) => m.path);
|
|
82
|
+
baseFields.MediaUrls = media.items.map((m) => m.path);
|
|
83
|
+
baseFields.MediaTypes = media.items.map((m) => m.contentType).filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 根据主媒体类型添加特定字段
|
|
87
|
+
if (primary.kind === "audio" || primary.kind === "video") {
|
|
88
|
+
if (primary.duration !== undefined) {
|
|
89
|
+
baseFields.MediaDuration = primary.duration;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (primary.kind === "file") {
|
|
94
|
+
if (primary.fileName) {
|
|
95
|
+
baseFields.MediaFileName = primary.fileName;
|
|
96
|
+
}
|
|
97
|
+
if (primary.fileSize !== undefined) {
|
|
98
|
+
baseFields.MediaFileSize = primary.fileSize;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return baseFields;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// 消息处理器类型定义
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/** 消息处理结果 */
|
|
110
|
+
interface MessageHandleResult {
|
|
111
|
+
/** 是否成功处理 */
|
|
112
|
+
success: boolean;
|
|
113
|
+
/** 媒体上下文(支持多媒体混排) */
|
|
114
|
+
media?: InboundMediaContext;
|
|
115
|
+
/** 错误信息 */
|
|
116
|
+
errorMessage?: string;
|
|
117
|
+
/** 是否需要跳过后续处理 */
|
|
118
|
+
skipProcessing?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 消息处理器接口 */
|
|
122
|
+
interface MessageHandler {
|
|
123
|
+
/** 是否能处理该消息类型 */
|
|
124
|
+
canHandle(data: DingTalkMessageData): boolean;
|
|
125
|
+
/** 获取消息预览(用于日志) */
|
|
126
|
+
getPreview(data: DingTalkMessageData): string;
|
|
127
|
+
/** 校验消息 */
|
|
128
|
+
validate(data: DingTalkMessageData): { valid: boolean; errorMessage?: string };
|
|
129
|
+
/** 处理消息 */
|
|
130
|
+
handle(data: DingTalkMessageData, account: ResolvedDingTalkAccount): Promise<MessageHandleResult>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// 消息处理器实现
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/** 文本消息处理器 */
|
|
138
|
+
const textMessageHandler: MessageHandler = {
|
|
139
|
+
canHandle: (data) => data.msgtype === "text",
|
|
140
|
+
|
|
141
|
+
getPreview: (data) => {
|
|
142
|
+
const text = data.text?.content?.trim() ?? "";
|
|
143
|
+
return text.slice(0, 50) + (text.length > 50 ? "..." : "");
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
validate: (data) => {
|
|
147
|
+
const text = data.text?.content?.trim() ?? "";
|
|
148
|
+
if (!text) {
|
|
149
|
+
return { valid: false, errorMessage: undefined }; // 空消息静默忽略,不需要回复错误
|
|
150
|
+
}
|
|
151
|
+
return { valid: true };
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
handle: async () => {
|
|
155
|
+
// 文本消息不需要预处理,直接返回成功
|
|
156
|
+
return { success: true };
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** 图片消息处理器 */
|
|
161
|
+
const pictureMessageHandler: MessageHandler = {
|
|
162
|
+
canHandle: (data) => data.msgtype === "picture",
|
|
163
|
+
|
|
164
|
+
getPreview: () => "[图片]",
|
|
165
|
+
|
|
166
|
+
validate: (data) => {
|
|
167
|
+
const content = data.content as PictureContent | undefined;
|
|
168
|
+
const downloadCode = content?.downloadCode ?? content?.pictureDownloadCode;
|
|
169
|
+
if (!downloadCode) {
|
|
170
|
+
return { valid: false, errorMessage: "图片处理失败:缺少下载码" };
|
|
171
|
+
}
|
|
172
|
+
return { valid: true };
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
handle: async (data, account) => {
|
|
176
|
+
const content = data.content as PictureContent;
|
|
177
|
+
const downloadCode = (content?.downloadCode ?? content?.pictureDownloadCode)!;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const saved = await downloadAndSaveMedia({
|
|
181
|
+
downloadCode,
|
|
182
|
+
account,
|
|
183
|
+
mediaKind: "picture",
|
|
184
|
+
extension: content?.extension,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const mediaItem: MediaItem = {
|
|
188
|
+
kind: "picture",
|
|
189
|
+
path: saved.path,
|
|
190
|
+
contentType: saved.contentType,
|
|
191
|
+
fileSize: saved.fileSize,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
media: { items: [mediaItem], primary: mediaItem },
|
|
197
|
+
};
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error("图片处理失败:", err);
|
|
200
|
+
return { success: false, errorMessage: `图片处理失败:${getErrorMessage(err)}` };
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** 语音消息处理器 */
|
|
206
|
+
const audioMessageHandler: MessageHandler = {
|
|
207
|
+
canHandle: (data) => data.msgtype === "audio",
|
|
208
|
+
|
|
209
|
+
getPreview: (data) => {
|
|
210
|
+
const content = data.content as AudioContent | undefined;
|
|
211
|
+
const duration = content?.duration;
|
|
212
|
+
return duration ? `[语音 ${Number(duration).toFixed(1)}s]` : "[语音]";
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
validate: (data) => {
|
|
216
|
+
const content = data.content as AudioContent | undefined;
|
|
217
|
+
if (!content?.downloadCode) {
|
|
218
|
+
return { valid: false, errorMessage: "语音处理失败:缺少下载码" };
|
|
219
|
+
}
|
|
220
|
+
return { valid: true };
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
handle: async (data, account) => {
|
|
224
|
+
const content = data.content as AudioContent;
|
|
225
|
+
const downloadCode = content.downloadCode!;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const saved = await downloadAndSaveMedia({
|
|
229
|
+
downloadCode,
|
|
230
|
+
account,
|
|
231
|
+
mediaKind: "audio",
|
|
232
|
+
extension: content.extension ?? "amr",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const mediaItem: MediaItem = {
|
|
236
|
+
kind: "audio",
|
|
237
|
+
path: saved.path,
|
|
238
|
+
contentType: saved.contentType,
|
|
239
|
+
fileSize: saved.fileSize,
|
|
240
|
+
duration: content.duration != null ? Number(content.duration) : undefined,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
media: { items: [mediaItem], primary: mediaItem },
|
|
246
|
+
};
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logger.error("语音处理失败:", err);
|
|
249
|
+
return { success: false, errorMessage: `语音处理失败:${getErrorMessage(err)}` };
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** 视频消息处理器 */
|
|
255
|
+
const videoMessageHandler: MessageHandler = {
|
|
256
|
+
canHandle: (data) => data.msgtype === "video",
|
|
257
|
+
|
|
258
|
+
getPreview: (data) => {
|
|
259
|
+
const content = data.content as VideoContent | undefined;
|
|
260
|
+
const duration = content?.duration;
|
|
261
|
+
return duration ? `[视频 ${Number(duration).toFixed(1)}s]` : "[视频]";
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
validate: (data) => {
|
|
265
|
+
const content = data.content as VideoContent | undefined;
|
|
266
|
+
if (!content?.downloadCode) {
|
|
267
|
+
return { valid: false, errorMessage: "视频处理失败:缺少下载码" };
|
|
268
|
+
}
|
|
269
|
+
return { valid: true };
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
handle: async (data, account) => {
|
|
273
|
+
const content = data.content as VideoContent;
|
|
274
|
+
const downloadCode = content.downloadCode!;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const saved = await downloadAndSaveMedia({
|
|
278
|
+
downloadCode,
|
|
279
|
+
account,
|
|
280
|
+
mediaKind: "video",
|
|
281
|
+
extension: content.extension ?? "mp4",
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const mediaItem: MediaItem = {
|
|
285
|
+
kind: "video",
|
|
286
|
+
path: saved.path,
|
|
287
|
+
contentType: saved.contentType,
|
|
288
|
+
fileSize: saved.fileSize,
|
|
289
|
+
duration: content.duration != null ? Number(content.duration) : undefined,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
success: true,
|
|
294
|
+
media: { items: [mediaItem], primary: mediaItem },
|
|
295
|
+
};
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logger.error("视频处理失败:", err);
|
|
298
|
+
return { success: false, errorMessage: `视频处理失败:${getErrorMessage(err)}` };
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** 文件消息处理器 */
|
|
304
|
+
const fileMessageHandler: MessageHandler = {
|
|
305
|
+
canHandle: (data) => data.msgtype === "file",
|
|
306
|
+
|
|
307
|
+
getPreview: (data) => {
|
|
308
|
+
const content = data.content as FileContent | undefined;
|
|
309
|
+
const fileName = content?.fileName;
|
|
310
|
+
return fileName ? `[文件] ${fileName}` : "[文件]";
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
validate: (data) => {
|
|
314
|
+
const content = data.content as FileContent | undefined;
|
|
315
|
+
if (!content?.downloadCode) {
|
|
316
|
+
return { valid: false, errorMessage: "文件处理失败:缺少下载码" };
|
|
317
|
+
}
|
|
318
|
+
return { valid: true };
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
handle: async (data, account) => {
|
|
322
|
+
const content = data.content as FileContent;
|
|
323
|
+
const downloadCode = content.downloadCode!;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const saved = await downloadAndSaveMedia({
|
|
327
|
+
downloadCode,
|
|
328
|
+
account,
|
|
329
|
+
mediaKind: "file",
|
|
330
|
+
extension: content.extension,
|
|
331
|
+
fileName: content.fileName,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const mediaItem: MediaItem = {
|
|
335
|
+
kind: "file",
|
|
336
|
+
path: saved.path,
|
|
337
|
+
contentType: saved.contentType,
|
|
338
|
+
fileSize: saved.fileSize,
|
|
339
|
+
fileName: content.fileName,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
success: true,
|
|
344
|
+
media: { items: [mediaItem], primary: mediaItem },
|
|
345
|
+
};
|
|
346
|
+
} catch (err) {
|
|
347
|
+
logger.error("文件处理失败:", err);
|
|
348
|
+
return { success: false, errorMessage: `文件处理失败:${getErrorMessage(err)}` };
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// 富文本消息处理辅助函数
|
|
355
|
+
// ============================================================================
|
|
356
|
+
|
|
357
|
+
/** 判断富文本元素是否为图片 */
|
|
358
|
+
function isRichTextPicture(element: RichTextElement): element is RichTextPictureElement {
|
|
359
|
+
return element.type === "picture";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 从富文本元素中提取下载码 */
|
|
363
|
+
function getRichTextPictureDownloadCode(element: RichTextPictureElement): string | undefined {
|
|
364
|
+
return element.downloadCode ?? element.pictureDownloadCode;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 解析富文本内容,提取文本和图片信息 */
|
|
368
|
+
function parseRichTextContent(content: RichTextContent): {
|
|
369
|
+
textParts: string[];
|
|
370
|
+
imageInfos: Array<{
|
|
371
|
+
downloadCode: string;
|
|
372
|
+
width?: number;
|
|
373
|
+
height?: number;
|
|
374
|
+
extension?: string;
|
|
375
|
+
}>;
|
|
376
|
+
} {
|
|
377
|
+
const textParts: string[] = [];
|
|
378
|
+
const imageInfos: Array<{
|
|
379
|
+
downloadCode: string;
|
|
380
|
+
width?: number;
|
|
381
|
+
height?: number;
|
|
382
|
+
extension?: string;
|
|
383
|
+
}> = [];
|
|
384
|
+
|
|
385
|
+
for (const element of content.richText) {
|
|
386
|
+
if (isRichTextPicture(element)) {
|
|
387
|
+
// 图片元素
|
|
388
|
+
const downloadCode = getRichTextPictureDownloadCode(element);
|
|
389
|
+
if (downloadCode) {
|
|
390
|
+
imageInfos.push({
|
|
391
|
+
downloadCode,
|
|
392
|
+
width: element.width,
|
|
393
|
+
height: element.height,
|
|
394
|
+
extension: element.extension,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
// 文本元素(type 为 undefined 或 "text")
|
|
399
|
+
if (element.text) {
|
|
400
|
+
textParts.push(element.text);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { textParts, imageInfos };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 富文本消息处理器 */
|
|
409
|
+
const richTextMessageHandler: MessageHandler = {
|
|
410
|
+
canHandle: (data) => data.msgtype === "richText",
|
|
411
|
+
|
|
412
|
+
getPreview: (data) => {
|
|
413
|
+
const content = data.content as RichTextContent | undefined;
|
|
414
|
+
if (!content?.richText) return "[富文本]";
|
|
415
|
+
|
|
416
|
+
const { textParts, imageInfos } = parseRichTextContent(content);
|
|
417
|
+
const textPreview = textParts.join(" ").slice(0, 30);
|
|
418
|
+
const imageCount = imageInfos.length;
|
|
419
|
+
|
|
420
|
+
if (textPreview && imageCount > 0) {
|
|
421
|
+
return `[图文] ${textPreview}${textParts.join(" ").length > 30 ? "..." : ""} +${imageCount}图`;
|
|
422
|
+
} else if (textPreview) {
|
|
423
|
+
return `[富文本] ${textPreview}${textParts.join(" ").length > 30 ? "..." : ""}`;
|
|
424
|
+
} else if (imageCount > 0) {
|
|
425
|
+
return `[图文] ${imageCount}张图片`;
|
|
426
|
+
}
|
|
427
|
+
return "[富文本]";
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
validate: (data) => {
|
|
431
|
+
const content = data.content as RichTextContent | undefined;
|
|
432
|
+
if (!content?.richText || !Array.isArray(content.richText)) {
|
|
433
|
+
return { valid: false, errorMessage: "富文本消息格式错误" };
|
|
434
|
+
}
|
|
435
|
+
// 至少要有文本或图片
|
|
436
|
+
const { textParts, imageInfos } = parseRichTextContent(content);
|
|
437
|
+
if (textParts.length === 0 && imageInfos.length === 0) {
|
|
438
|
+
return { valid: false, errorMessage: undefined }; // 空消息静默忽略
|
|
439
|
+
}
|
|
440
|
+
return { valid: true };
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
handle: async (data, account) => {
|
|
444
|
+
const content = data.content as RichTextContent;
|
|
445
|
+
const { textParts, imageInfos } = parseRichTextContent(content);
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const mediaItems: MediaItem[] = [];
|
|
449
|
+
|
|
450
|
+
// 下载并保存所有图片
|
|
451
|
+
for (let i = 0; i < imageInfos.length; i++) {
|
|
452
|
+
const imgInfo = imageInfos[i];
|
|
453
|
+
logger.log(`处理富文本图片 ${i + 1}/${imageInfos.length}...`);
|
|
454
|
+
|
|
455
|
+
const saved = await downloadAndSaveMedia({
|
|
456
|
+
downloadCode: imgInfo.downloadCode,
|
|
457
|
+
account,
|
|
458
|
+
mediaKind: "picture",
|
|
459
|
+
extension: imgInfo.extension,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
mediaItems.push({
|
|
463
|
+
kind: "picture",
|
|
464
|
+
path: saved.path,
|
|
465
|
+
contentType: saved.contentType,
|
|
466
|
+
fileSize: saved.fileSize,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 构建媒体上下文
|
|
471
|
+
// 对于图文混排,将文本内容存入 data.text 以便后续处理
|
|
472
|
+
// 这里通过修改 data 对象来传递文本内容
|
|
473
|
+
const combinedText = textParts.join("\n").trim();
|
|
474
|
+
if (combinedText) {
|
|
475
|
+
// 将富文本中的文本内容写入 text 字段,以便后续流程使用
|
|
476
|
+
data.text = { content: combinedText };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const media: InboundMediaContext | undefined = mediaItems.length > 0
|
|
480
|
+
? { items: mediaItems, primary: mediaItems[0] }
|
|
481
|
+
: undefined;
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
success: true,
|
|
485
|
+
media,
|
|
486
|
+
};
|
|
487
|
+
} catch (err) {
|
|
488
|
+
logger.error("富文本消息处理失败:", err);
|
|
489
|
+
return { success: false, errorMessage: `富文本消息处理失败:${getErrorMessage(err)}` };
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/** 不支持的消息类型处理器 */
|
|
495
|
+
const unsupportedMessageHandler: MessageHandler = {
|
|
496
|
+
canHandle: () => true, // 作为兜底处理器
|
|
497
|
+
|
|
498
|
+
getPreview: (data) => `[${data.msgtype}]`,
|
|
499
|
+
|
|
500
|
+
validate: () => ({
|
|
501
|
+
valid: false,
|
|
502
|
+
errorMessage: "暂不支持该类型消息,请发送文本、图片、语音、视频、文件或图文混排消息。",
|
|
503
|
+
}),
|
|
504
|
+
|
|
505
|
+
handle: async () => {
|
|
506
|
+
return { success: false, skipProcessing: true };
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** 消息处理器注册表(按优先级排序) */
|
|
511
|
+
const messageHandlers: MessageHandler[] = [
|
|
512
|
+
textMessageHandler,
|
|
513
|
+
pictureMessageHandler,
|
|
514
|
+
audioMessageHandler,
|
|
515
|
+
videoMessageHandler,
|
|
516
|
+
fileMessageHandler,
|
|
517
|
+
richTextMessageHandler,
|
|
518
|
+
unsupportedMessageHandler, // 兜底处理器必须放在最后
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
/** 获取消息处理器 */
|
|
522
|
+
function getMessageHandler(data: DingTalkMessageData): MessageHandler {
|
|
523
|
+
return messageHandlers.find((h) => h.canHandle(data))!;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** 通过 webhook 发送错误回复(静默失败) */
|
|
527
|
+
function replyError(webhook: string | undefined, message: string | undefined): void {
|
|
528
|
+
if (!webhook || !message) return;
|
|
529
|
+
replyViaWebhook(webhook, message).catch((err) => {
|
|
530
|
+
logger.error("回复错误提示失败:", err);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export interface MonitorOptions {
|
|
535
|
+
clientId: string;
|
|
536
|
+
clientSecret: string;
|
|
537
|
+
accountId: string;
|
|
538
|
+
config: OpenClawConfig;
|
|
539
|
+
runtime?: RuntimeEnv;
|
|
540
|
+
abortSignal?: AbortSignal;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export interface MonitorResult {
|
|
544
|
+
account: ResolvedDingTalkAccount;
|
|
545
|
+
stop: () => void;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Track runtime state in memory
|
|
549
|
+
const runtimeState = new Map<
|
|
550
|
+
string,
|
|
551
|
+
{
|
|
552
|
+
running: boolean;
|
|
553
|
+
lastStartAt: number | null;
|
|
554
|
+
lastStopAt: number | null;
|
|
555
|
+
lastError: string | null;
|
|
556
|
+
lastInboundAt?: number | null;
|
|
557
|
+
lastOutboundAt?: number | null;
|
|
558
|
+
}
|
|
559
|
+
>();
|
|
560
|
+
|
|
561
|
+
function recordChannelRuntimeState(params: {
|
|
562
|
+
channel: string;
|
|
563
|
+
accountId: string;
|
|
564
|
+
state: Partial<{
|
|
565
|
+
running: boolean;
|
|
566
|
+
lastStartAt: number | null;
|
|
567
|
+
lastStopAt: number | null;
|
|
568
|
+
lastError: string | null;
|
|
569
|
+
lastInboundAt: number | null;
|
|
570
|
+
lastOutboundAt: number | null;
|
|
571
|
+
}>;
|
|
572
|
+
}): void {
|
|
573
|
+
const key = `${params.channel}:${params.accountId}`;
|
|
574
|
+
const existing = runtimeState.get(key) ?? {
|
|
575
|
+
running: false,
|
|
576
|
+
lastStartAt: null,
|
|
577
|
+
lastStopAt: null,
|
|
578
|
+
lastError: null,
|
|
579
|
+
};
|
|
580
|
+
runtimeState.set(key, { ...existing, ...params.state });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function getDingTalkRuntimeState(accountId: string) {
|
|
584
|
+
return runtimeState.get(`${PLUGIN_ID}:${accountId}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ============================================================================
|
|
588
|
+
// 媒体下载与保存
|
|
589
|
+
// ============================================================================
|
|
590
|
+
|
|
591
|
+
/** 媒体下载保存选项 */
|
|
592
|
+
interface DownloadMediaOptions {
|
|
593
|
+
/** 下载码 */
|
|
594
|
+
downloadCode: string;
|
|
595
|
+
/** 账户配置 */
|
|
596
|
+
account: ResolvedDingTalkAccount;
|
|
597
|
+
/** 媒体类型(用于日志) */
|
|
598
|
+
mediaKind: MediaKind;
|
|
599
|
+
/** 文件扩展名(可选,用于确定 MIME 和文件后缀) */
|
|
600
|
+
extension?: string;
|
|
601
|
+
/** 原始文件名(可选,用于保存时保留后缀) */
|
|
602
|
+
fileName?: string;
|
|
603
|
+
/** 强制指定的 contentType */
|
|
604
|
+
contentType?: string;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** 媒体下载保存结果 */
|
|
608
|
+
interface DownloadMediaResult {
|
|
609
|
+
path: string;
|
|
610
|
+
contentType: string;
|
|
611
|
+
fileSize: number;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 下载钉钉媒体文件并保存到本地(通用函数)
|
|
616
|
+
* 失败时直接抛出错误,错误消息可直接展示给用户
|
|
617
|
+
*/
|
|
618
|
+
async function downloadAndSaveMedia(
|
|
619
|
+
options: DownloadMediaOptions
|
|
620
|
+
): Promise<DownloadMediaResult> {
|
|
621
|
+
const { downloadCode, account, mediaKind, fileName } = options;
|
|
622
|
+
const pluginRuntime = getDingTalkRuntime();
|
|
623
|
+
|
|
624
|
+
const kindLabel = {
|
|
625
|
+
picture: "图片",
|
|
626
|
+
audio: "语音",
|
|
627
|
+
video: "视频",
|
|
628
|
+
file: "文件",
|
|
629
|
+
}[mediaKind];
|
|
630
|
+
|
|
631
|
+
// 1. 获取下载链接
|
|
632
|
+
const downloadUrl = await getFileDownloadUrl(downloadCode, account);
|
|
633
|
+
logger.log(`获取${kindLabel}下载链接成功`);
|
|
634
|
+
|
|
635
|
+
// 2. 下载文件
|
|
636
|
+
const buffer = await downloadFromUrl(downloadUrl);
|
|
637
|
+
const sizeStr = buffer.length > 1024 * 1024
|
|
638
|
+
? `${(buffer.length / 1024 / 1024).toFixed(2)} MB`
|
|
639
|
+
: `${(buffer.length / 1024).toFixed(2)} KB`;
|
|
640
|
+
logger.log(`下载${kindLabel}成功,大小: ${sizeStr}`);
|
|
641
|
+
|
|
642
|
+
// 3. 使用 OpenClaw 的 media 工具保存,让 OpenClaw 自己处理文件名和后缀
|
|
643
|
+
const saved = await pluginRuntime.channel.media.saveMediaBuffer(
|
|
644
|
+
buffer,
|
|
645
|
+
undefined, // contentType: 让 OpenClaw 自动检测
|
|
646
|
+
"inbound",
|
|
647
|
+
buffer.length, // maxBytes: 使用实际大小,避免默认 5MB 限制
|
|
648
|
+
fileName // originalFilename: 直接传原始文件名
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
logger.log(`${kindLabel}已保存到: ${saved.path}`);
|
|
652
|
+
return {
|
|
653
|
+
path: saved.path,
|
|
654
|
+
contentType: saved.contentType ?? "application/octet-stream",
|
|
655
|
+
fileSize: buffer.length,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** 提取错误消息(不含堆栈) */
|
|
660
|
+
function getErrorMessage(err: unknown): string {
|
|
661
|
+
if (err instanceof Error) {
|
|
662
|
+
return err.message;
|
|
663
|
+
}
|
|
664
|
+
return String(err);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* 启动钉钉 Stream 监听器
|
|
669
|
+
*/
|
|
670
|
+
export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult {
|
|
671
|
+
const { clientId, clientSecret, accountId, config, abortSignal } = options;
|
|
672
|
+
const pluginRuntime = getDingTalkRuntime();
|
|
673
|
+
|
|
674
|
+
const account = resolveDingTalkAccount({ cfg: config, accountId });
|
|
675
|
+
|
|
676
|
+
/** 检查发送者是否在 allowFrom 白名单中 */
|
|
677
|
+
const isSenderAllowed = (senderId: string): boolean => {
|
|
678
|
+
const allowList = account.allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
|
|
679
|
+
if (allowList.length === 0 || allowList.includes("*")) {
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
const prefixPattern = new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i");
|
|
683
|
+
return allowList
|
|
684
|
+
.map((entry) => entry.replace(prefixPattern, ""))
|
|
685
|
+
.includes(senderId);
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// Record starting state
|
|
689
|
+
recordChannelRuntimeState({
|
|
690
|
+
channel: PLUGIN_ID,
|
|
691
|
+
accountId,
|
|
692
|
+
state: {
|
|
693
|
+
running: true,
|
|
694
|
+
lastStartAt: Date.now(),
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// 创建钉钉 Stream 客户端
|
|
699
|
+
const client = new DWClient({
|
|
700
|
+
clientId,
|
|
701
|
+
clientSecret,
|
|
702
|
+
debug: false,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ============================================================================
|
|
706
|
+
// 群聊策略与 Mention 门控
|
|
707
|
+
// ============================================================================
|
|
708
|
+
|
|
709
|
+
/** 解析群组配置(按 openConversationId 查找) */
|
|
710
|
+
const resolveGroupConfig = (groupId: string): DingTalkGroupConfig | undefined => {
|
|
711
|
+
const groups = account.groups;
|
|
712
|
+
if (!groups) return undefined;
|
|
713
|
+
// 精确匹配或不区分大小写匹配
|
|
714
|
+
const key = Object.keys(groups).find(
|
|
715
|
+
(k) => k === groupId || k.toLowerCase() === groupId.toLowerCase()
|
|
716
|
+
);
|
|
717
|
+
return key ? groups[key] : undefined;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/** 检查群聊是否被允许 */
|
|
721
|
+
const isGroupAllowed = (groupId: string): boolean => {
|
|
722
|
+
const policy = account.groupPolicy;
|
|
723
|
+
if (policy === "disabled") return false;
|
|
724
|
+
if (policy === "open") return true;
|
|
725
|
+
// allowlist
|
|
726
|
+
const allowList = account.groupAllowFrom.map((e) => String(e).trim()).filter(Boolean);
|
|
727
|
+
if (allowList.length === 0 || allowList.includes("*")) return true;
|
|
728
|
+
return allowList.some((entry) => entry === groupId || entry.toLowerCase() === groupId.toLowerCase());
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
/** 检查机器人是否被 @ */
|
|
732
|
+
const checkBotMentioned = (data: DingTalkMessageData): boolean => {
|
|
733
|
+
// 钉钉 isInAtList 字段标识当前机器人是否在 @列表中
|
|
734
|
+
if (data.isInAtList) return true;
|
|
735
|
+
// 备用检查:atUsers 中是否包含 chatbotUserId
|
|
736
|
+
if (data.atUsers?.some((u) => u.dingtalkId === data.chatbotUserId)) return true;
|
|
737
|
+
return false;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// ============================================================================
|
|
741
|
+
// 消息处理核心逻辑
|
|
742
|
+
// ============================================================================
|
|
743
|
+
|
|
744
|
+
/** 构建发送者信息 */
|
|
745
|
+
const buildSenderInfo = (data: DingTalkMessageData) => {
|
|
746
|
+
const senderId = data.senderStaffId;
|
|
747
|
+
const senderName = data.senderNick;
|
|
748
|
+
const isGroup = data.conversationType === "2";
|
|
749
|
+
const groupId = data.openConversationId ?? data.conversationId;
|
|
750
|
+
|
|
751
|
+
// 参照飞书:From 始终标识发送者身份,避免不同用户被视为同一人
|
|
752
|
+
// To 区分群聊和单聊目标
|
|
753
|
+
const chatId = isGroup ? groupId : senderId;
|
|
754
|
+
const fromAddress = `${PLUGIN_ID}:${senderId}`;
|
|
755
|
+
const toAddress = isGroup ? `${PLUGIN_ID}:chat:${groupId}` : `${PLUGIN_ID}:user:${senderId}`;
|
|
756
|
+
const label = isGroup
|
|
757
|
+
? (data.conversationTitle ?? groupId)
|
|
758
|
+
: (senderName || senderId);
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
senderId,
|
|
762
|
+
senderName,
|
|
763
|
+
chatId,
|
|
764
|
+
fromAddress,
|
|
765
|
+
toAddress,
|
|
766
|
+
label,
|
|
767
|
+
isGroup,
|
|
768
|
+
groupId: isGroup ? groupId : undefined,
|
|
769
|
+
conversationTitle: data.conversationTitle,
|
|
770
|
+
};
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/** 构建消息体内容 */
|
|
774
|
+
const buildMessageBody = (data: DingTalkMessageData, media?: InboundMediaContext) => {
|
|
775
|
+
const textContent = data.text?.content?.trim() ?? "";
|
|
776
|
+
const mediaPlaceholder = media ? generateMediaPlaceholder(media) : "";
|
|
777
|
+
|
|
778
|
+
// 优先使用文本内容,如果没有则使用媒体占位符
|
|
779
|
+
const rawBody = textContent || mediaPlaceholder;
|
|
780
|
+
|
|
781
|
+
return { textContent, rawBody };
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
/** 构建入站消息上下文 */
|
|
785
|
+
const buildInboundContext = (
|
|
786
|
+
data: DingTalkMessageData,
|
|
787
|
+
sender: ReturnType<typeof buildSenderInfo>,
|
|
788
|
+
rawBody: string,
|
|
789
|
+
media?: InboundMediaContext
|
|
790
|
+
) => {
|
|
791
|
+
const isGroup = sender.isGroup;
|
|
792
|
+
const chatType = isGroup ? "group" : "direct";
|
|
793
|
+
|
|
794
|
+
// 解析路由:群聊以群 ID 为 peer,单聊以用户 ID 为 peer
|
|
795
|
+
const route = pluginRuntime.channel.routing.resolveAgentRoute({
|
|
796
|
+
cfg: config,
|
|
797
|
+
channel: PLUGIN_ID,
|
|
798
|
+
accountId,
|
|
799
|
+
peer: {
|
|
800
|
+
kind: isGroup ? "group" : "dm",
|
|
801
|
+
id: sender.chatId,
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// 格式化入站消息体
|
|
806
|
+
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
807
|
+
const body = pluginRuntime.channel.reply.formatInboundEnvelope({
|
|
808
|
+
channel: "DingTalk",
|
|
809
|
+
from: isGroup ? `${sender.senderName} in ${sender.conversationTitle ?? sender.groupId}` : sender.label,
|
|
810
|
+
timestamp: parseInt(data.createAt),
|
|
811
|
+
body: rawBody,
|
|
812
|
+
chatType,
|
|
813
|
+
sender: {
|
|
814
|
+
id: sender.senderId,
|
|
815
|
+
name: sender.senderName,
|
|
816
|
+
},
|
|
817
|
+
envelope: envelopeOptions,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// 构建基础上下文
|
|
821
|
+
const baseContext: Record<string, unknown> = {
|
|
822
|
+
Body: body,
|
|
823
|
+
RawBody: rawBody,
|
|
824
|
+
CommandBody: rawBody,
|
|
825
|
+
From: sender.fromAddress,
|
|
826
|
+
To: sender.toAddress,
|
|
827
|
+
SessionKey: route.sessionKey,
|
|
828
|
+
AccountId: accountId,
|
|
829
|
+
ChatType: chatType,
|
|
830
|
+
ConversationLabel: sender.label,
|
|
831
|
+
SenderId: sender.senderId,
|
|
832
|
+
SenderName: sender.senderName,
|
|
833
|
+
Provider: PLUGIN_ID,
|
|
834
|
+
Surface: PLUGIN_ID,
|
|
835
|
+
MessageSid: data.msgId,
|
|
836
|
+
Timestamp: parseInt(data.createAt),
|
|
837
|
+
WasMentioned: checkBotMentioned(data),
|
|
838
|
+
OriginatingChannel: PLUGIN_ID,
|
|
839
|
+
OriginatingTo: sender.toAddress,
|
|
840
|
+
CommandAuthorized: isSenderAllowed(sender.senderId),
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// 群聊特有字段
|
|
844
|
+
if (isGroup) {
|
|
845
|
+
baseContext.GroupSubject = sender.conversationTitle ?? sender.groupId;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// 合并媒体字段
|
|
849
|
+
const mediaFields = buildMediaContextFields(media);
|
|
850
|
+
|
|
851
|
+
return pluginRuntime.channel.reply.finalizeInboundContext({
|
|
852
|
+
...baseContext,
|
|
853
|
+
...mediaFields,
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
/** 创建回复分发器 */
|
|
858
|
+
const createReplyDispatcher = (data: DingTalkMessageData) => ({
|
|
859
|
+
deliver: async (payload: { text?: string }) => {
|
|
860
|
+
const replyText = payload.text ?? "";
|
|
861
|
+
if (!replyText) return;
|
|
862
|
+
|
|
863
|
+
const isGroup = data.conversationType === "2";
|
|
864
|
+
const groupId = data.openConversationId ?? data.conversationId;
|
|
865
|
+
|
|
866
|
+
// 优先使用 sessionWebhook 回复(群聊/单聊通用)
|
|
867
|
+
if (data.sessionWebhook) {
|
|
868
|
+
const result = await replyViaWebhook(data.sessionWebhook, replyText);
|
|
869
|
+
if (result.errcode === 0) {
|
|
870
|
+
recordChannelRuntimeState({
|
|
871
|
+
channel: PLUGIN_ID,
|
|
872
|
+
accountId,
|
|
873
|
+
state: { lastOutboundAt: Date.now() },
|
|
874
|
+
});
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
// webhook 失败(可能已过期),尝试主动发送 API 降级
|
|
878
|
+
logger.warn(`Webhook 回复失败 (errcode: ${result.errcode}), 尝试主动发送 API 降级`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// 降级:通过主动发送 API
|
|
882
|
+
const to = isGroup ? `chat:${groupId}` : data.senderStaffId;
|
|
883
|
+
await sendTextMessage(to, replyText, { account });
|
|
884
|
+
|
|
885
|
+
recordChannelRuntimeState({
|
|
886
|
+
channel: PLUGIN_ID,
|
|
887
|
+
accountId,
|
|
888
|
+
state: { lastOutboundAt: Date.now() },
|
|
889
|
+
});
|
|
890
|
+
},
|
|
891
|
+
onError: (err: unknown, info: { kind: string }) => {
|
|
892
|
+
logger.error(`${info.kind} reply failed:`, err);
|
|
893
|
+
},
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
/** 异步处理消息(不阻塞钉钉响应) */
|
|
897
|
+
const processMessageAsync = async (
|
|
898
|
+
data: DingTalkMessageData,
|
|
899
|
+
media?: InboundMediaContext
|
|
900
|
+
) => {
|
|
901
|
+
try {
|
|
902
|
+
// 1. 构建发送者信息
|
|
903
|
+
const sender = buildSenderInfo(data);
|
|
904
|
+
|
|
905
|
+
// 2. 构建消息体
|
|
906
|
+
const { rawBody } = buildMessageBody(data, media);
|
|
907
|
+
|
|
908
|
+
// 3. 构建入站上下文
|
|
909
|
+
const ctxPayload = buildInboundContext(data, sender, rawBody, media);
|
|
910
|
+
|
|
911
|
+
// 4. 分发消息给 OpenClaw
|
|
912
|
+
const { queuedFinal } = await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
913
|
+
ctx: ctxPayload,
|
|
914
|
+
cfg: config,
|
|
915
|
+
dispatcherOptions: createReplyDispatcher(data),
|
|
916
|
+
replyOptions: {},
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
if (!queuedFinal) {
|
|
920
|
+
logger.log(`no response generated for message from ${sender.label}`);
|
|
921
|
+
}
|
|
922
|
+
} catch (error) {
|
|
923
|
+
logger.error("处理消息出错:", error);
|
|
924
|
+
recordChannelRuntimeState({
|
|
925
|
+
channel: PLUGIN_ID,
|
|
926
|
+
accountId,
|
|
927
|
+
state: {
|
|
928
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
// 处理消息的回调函数(立即返回成功,异步处理)
|
|
935
|
+
const handleMessage = async (message: DWClientDownStream) => {
|
|
936
|
+
try {
|
|
937
|
+
const data = JSON.parse(message.data) as DingTalkMessageData;
|
|
938
|
+
const isGroup = data.conversationType === "2";
|
|
939
|
+
const groupId = data.openConversationId ?? data.conversationId;
|
|
940
|
+
|
|
941
|
+
// 群聊策略检查
|
|
942
|
+
if (isGroup) {
|
|
943
|
+
if (!isGroupAllowed(groupId)) {
|
|
944
|
+
logger.log(`群聊消息被策略拒绝 | groupPolicy: ${account.groupPolicy} | groupId: ${groupId}`);
|
|
945
|
+
client.socketCallBackResponse(message.headers.messageId, { status: "SUCCESS" });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// 群组级别 enabled 检查
|
|
950
|
+
const groupConfig = resolveGroupConfig(groupId);
|
|
951
|
+
if (groupConfig?.enabled === false) {
|
|
952
|
+
logger.log(`群聊消息被群组配置禁用 | groupId: ${groupId}`);
|
|
953
|
+
client.socketCallBackResponse(message.headers.messageId, { status: "SUCCESS" });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 获取消息处理器
|
|
959
|
+
const handler = getMessageHandler(data);
|
|
960
|
+
|
|
961
|
+
// 打印收到的消息信息
|
|
962
|
+
const preview = handler.getPreview(data);
|
|
963
|
+
const chatLabel = isGroup
|
|
964
|
+
? `群聊(${data.conversationTitle ?? groupId})`
|
|
965
|
+
: "单聊";
|
|
966
|
+
logger.log(`收到消息 | ${chatLabel} | ${data.senderNick}(${data.senderStaffId}) | ${preview}`);
|
|
967
|
+
|
|
968
|
+
// 记录入站活动
|
|
969
|
+
recordChannelRuntimeState({
|
|
970
|
+
channel: PLUGIN_ID,
|
|
971
|
+
accountId,
|
|
972
|
+
state: { lastInboundAt: Date.now() },
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// 立即返回成功响应给钉钉服务器,避免超时
|
|
976
|
+
client.socketCallBackResponse(message.headers.messageId, { status: "SUCCESS" });
|
|
977
|
+
|
|
978
|
+
// 校验消息
|
|
979
|
+
const validation = handler.validate(data);
|
|
980
|
+
if (!validation.valid) {
|
|
981
|
+
replyError(data.sessionWebhook, validation.errorMessage);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 异步处理消息
|
|
986
|
+
handler.handle(data, account)
|
|
987
|
+
.then((result) => {
|
|
988
|
+
if (!result.success) {
|
|
989
|
+
replyError(data.sessionWebhook, result.errorMessage);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (result.skipProcessing) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
// 分发消息给 OpenClaw
|
|
996
|
+
return processMessageAsync(data, result.media);
|
|
997
|
+
})
|
|
998
|
+
.catch((err) => {
|
|
999
|
+
const errMsg = getErrorMessage(err);
|
|
1000
|
+
logger.error(`处理 ${data.msgtype} 消息失败:`, err);
|
|
1001
|
+
replyError(data.sessionWebhook, `消息处理失败:${errMsg}`);
|
|
1002
|
+
});
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
const errMsg = getErrorMessage(error);
|
|
1005
|
+
logger.error("解析消息出错:", error);
|
|
1006
|
+
recordChannelRuntimeState({
|
|
1007
|
+
channel: PLUGIN_ID,
|
|
1008
|
+
accountId,
|
|
1009
|
+
state: {
|
|
1010
|
+
lastError: errMsg,
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
client.socketCallBackResponse(message.headers.messageId, { status: "FAILURE" });
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// 注册消息监听器
|
|
1018
|
+
client.registerCallbackListener(TOPIC_ROBOT, handleMessage);
|
|
1019
|
+
|
|
1020
|
+
// 注册连接事件
|
|
1021
|
+
client.on("open", () => {
|
|
1022
|
+
logger.log(`[${accountId}] Stream 连接已建立`);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
client.on("close", () => {
|
|
1026
|
+
logger.log(`[${accountId}] Stream 连接已关闭`);
|
|
1027
|
+
recordChannelRuntimeState({
|
|
1028
|
+
channel: PLUGIN_ID,
|
|
1029
|
+
accountId,
|
|
1030
|
+
state: {
|
|
1031
|
+
running: false,
|
|
1032
|
+
lastStopAt: Date.now(),
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
client.on("error", (error: Error) => {
|
|
1038
|
+
logger.error(`[${accountId}] Stream 连接错误:`, error);
|
|
1039
|
+
recordChannelRuntimeState({
|
|
1040
|
+
channel: PLUGIN_ID,
|
|
1041
|
+
accountId,
|
|
1042
|
+
state: {
|
|
1043
|
+
lastError: error.message,
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// 启动连接 — 包装 connect 方法,确保所有调用(含 DWClient 内部自动重连)都不会产生 unhandled rejection
|
|
1049
|
+
const originalConnect = client.connect.bind(client);
|
|
1050
|
+
client.connect = () =>
|
|
1051
|
+
originalConnect().catch((err: unknown) => {
|
|
1052
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1053
|
+
logger.error(`[${accountId}] DingTalk Stream 连接失败: ${errMsg}`);
|
|
1054
|
+
recordChannelRuntimeState({
|
|
1055
|
+
channel: PLUGIN_ID,
|
|
1056
|
+
accountId,
|
|
1057
|
+
state: {
|
|
1058
|
+
running: false,
|
|
1059
|
+
lastStopAt: Date.now(),
|
|
1060
|
+
lastError: errMsg,
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
client.connect();
|
|
1066
|
+
|
|
1067
|
+
// 处理中止信号
|
|
1068
|
+
const stopHandler = () => {
|
|
1069
|
+
logger.log(`[${accountId}] 停止 provider`);
|
|
1070
|
+
client.disconnect();
|
|
1071
|
+
recordChannelRuntimeState({
|
|
1072
|
+
channel: PLUGIN_ID,
|
|
1073
|
+
accountId,
|
|
1074
|
+
state: {
|
|
1075
|
+
running: false,
|
|
1076
|
+
lastStopAt: Date.now(),
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
if (abortSignal) {
|
|
1082
|
+
abortSignal.addEventListener("abort", stopHandler);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return {
|
|
1086
|
+
account,
|
|
1087
|
+
stop: () => {
|
|
1088
|
+
stopHandler();
|
|
1089
|
+
abortSignal?.removeEventListener("abort", stopHandler);
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
}
|