@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,1013 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉消息发送模块
|
|
3
|
+
* 支持 AI Card 流式响应、普通消息、主动消息
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DingtalkConfig } from "../types/index.ts";
|
|
7
|
+
import { DINGTALK_API, getAccessToken, getOapiAccessToken } from "../utils/index.ts";
|
|
8
|
+
import { dingtalkHttp, dingtalkOapiHttp } from "../utils/http-client.ts";
|
|
9
|
+
import { MEDIA_MSG_TYPES } from "../utils/constants.ts";
|
|
10
|
+
import { createLoggerFromConfig } from "../utils/logger.ts";
|
|
11
|
+
import {
|
|
12
|
+
processLocalImages,
|
|
13
|
+
processVideoMarkers,
|
|
14
|
+
processAudioMarkers,
|
|
15
|
+
processFileMarkers,
|
|
16
|
+
uploadMediaToDingTalk,
|
|
17
|
+
} from "./media.ts";
|
|
18
|
+
// ✅ 导入 AI Card 相关函数,避免重复实现
|
|
19
|
+
import {
|
|
20
|
+
createAICardForTarget,
|
|
21
|
+
streamAICard,
|
|
22
|
+
finishAICard,
|
|
23
|
+
type AICardInstance,
|
|
24
|
+
type AICardTarget,
|
|
25
|
+
} from "./messaging/card.ts";
|
|
26
|
+
|
|
27
|
+
// ============ 常量 ============
|
|
28
|
+
// 注意:AI Card 相关的类型和函数已移至 ./messaging/card.ts,通过上方 import 引入
|
|
29
|
+
|
|
30
|
+
/** 消息类型枚举 */
|
|
31
|
+
export type DingTalkMsgType =
|
|
32
|
+
| "text"
|
|
33
|
+
| "markdown"
|
|
34
|
+
| "link"
|
|
35
|
+
| "actionCard"
|
|
36
|
+
| "image";
|
|
37
|
+
|
|
38
|
+
/** 主动发送消息的结果 */
|
|
39
|
+
export interface SendResult {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
processQueryKey?: string;
|
|
42
|
+
cardInstanceId?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
usedAICard?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 主动发送选项 */
|
|
48
|
+
export interface ProactiveSendOptions {
|
|
49
|
+
msgType?: DingTalkMsgType;
|
|
50
|
+
replyToId?: string;
|
|
51
|
+
title?: string;
|
|
52
|
+
log?: any;
|
|
53
|
+
useAICard?: boolean;
|
|
54
|
+
fallbackToNormal?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============ AI Card 相关函数已移至 ./messaging/card.ts ============
|
|
58
|
+
// createAICardForTarget, streamAICard, finishAICard 现在从 card.ts 导入使用
|
|
59
|
+
|
|
60
|
+
// ============ 普通消息发送 ============
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 发送 Markdown 消息
|
|
64
|
+
*/
|
|
65
|
+
export async function sendMarkdownMessage(
|
|
66
|
+
config: DingtalkConfig,
|
|
67
|
+
sessionWebhook: string,
|
|
68
|
+
title: string,
|
|
69
|
+
markdown: string,
|
|
70
|
+
options: any = {},
|
|
71
|
+
): Promise<any> {
|
|
72
|
+
const token = await getAccessToken(config);
|
|
73
|
+
let text = markdown;
|
|
74
|
+
if (options.atUserId) text = `${text} @${options.atUserId}`;
|
|
75
|
+
|
|
76
|
+
const body: any = {
|
|
77
|
+
msgtype: "markdown",
|
|
78
|
+
markdown: { title: title || "Message", text },
|
|
79
|
+
};
|
|
80
|
+
if (options.atUserId)
|
|
81
|
+
body.at = { atUserIds: [options.atUserId], isAtAll: false };
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
await dingtalkHttp.post(sessionWebhook, body, {
|
|
85
|
+
headers: {
|
|
86
|
+
"x-acs-dingtalk-access-token": token,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
).data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 发送文本消息
|
|
95
|
+
*/
|
|
96
|
+
export async function sendTextMessage(
|
|
97
|
+
config: DingtalkConfig,
|
|
98
|
+
sessionWebhook: string,
|
|
99
|
+
text: string,
|
|
100
|
+
options: any = {},
|
|
101
|
+
): Promise<any> {
|
|
102
|
+
const token = await getAccessToken(config);
|
|
103
|
+
const body: any = { msgtype: "text", text: { content: text } };
|
|
104
|
+
if (options.atUserId)
|
|
105
|
+
body.at = { atUserIds: [options.atUserId], isAtAll: false };
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
await dingtalkHttp.post(sessionWebhook, body, {
|
|
109
|
+
headers: {
|
|
110
|
+
"x-acs-dingtalk-access-token": token,
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
).data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 智能选择 text / markdown
|
|
119
|
+
*/
|
|
120
|
+
export async function sendMessage(
|
|
121
|
+
config: DingtalkConfig,
|
|
122
|
+
sessionWebhook: string,
|
|
123
|
+
text: string,
|
|
124
|
+
options: any = {},
|
|
125
|
+
): Promise<any> {
|
|
126
|
+
const hasMarkdown =
|
|
127
|
+
/^[#*>-]|[*_`#\[\]]/.test(text) ||
|
|
128
|
+
(text && typeof text === "string" && text.includes("\n"));
|
|
129
|
+
const useMarkdown =
|
|
130
|
+
options.useMarkdown !== false && (options.useMarkdown || hasMarkdown);
|
|
131
|
+
|
|
132
|
+
if (useMarkdown) {
|
|
133
|
+
const title =
|
|
134
|
+
options.title ||
|
|
135
|
+
text
|
|
136
|
+
.split("\n")[0]
|
|
137
|
+
.replace(/^[#*\s\->]+/, "")
|
|
138
|
+
.slice(0, 20) ||
|
|
139
|
+
"Message";
|
|
140
|
+
return sendMarkdownMessage(config, sessionWebhook, title, text, options);
|
|
141
|
+
}
|
|
142
|
+
return sendTextMessage(config, sessionWebhook, text, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============ 主动发送消息 ============
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 构建普通消息的 msgKey 和 msgParam
|
|
149
|
+
*/
|
|
150
|
+
export function buildMsgPayload(
|
|
151
|
+
msgType: DingTalkMsgType,
|
|
152
|
+
content: string,
|
|
153
|
+
title?: string,
|
|
154
|
+
): { msgKey: string; msgParam: Record<string, any> } | { error: string } {
|
|
155
|
+
switch (msgType) {
|
|
156
|
+
case "markdown":
|
|
157
|
+
return {
|
|
158
|
+
msgKey: "sampleMarkdown",
|
|
159
|
+
msgParam: {
|
|
160
|
+
title:
|
|
161
|
+
title ||
|
|
162
|
+
content
|
|
163
|
+
.split("\n")[0]
|
|
164
|
+
.replace(/^[#*\s\->]+/, "")
|
|
165
|
+
.slice(0, 20) ||
|
|
166
|
+
"Message",
|
|
167
|
+
text: content,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
case "link":
|
|
171
|
+
try {
|
|
172
|
+
return {
|
|
173
|
+
msgKey: "sampleLink",
|
|
174
|
+
msgParam: typeof content === "string" ? JSON.parse(content) : content,
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
return { error: "Invalid link message format, expected JSON" };
|
|
178
|
+
}
|
|
179
|
+
case "actionCard":
|
|
180
|
+
try {
|
|
181
|
+
return {
|
|
182
|
+
msgKey: "sampleActionCard",
|
|
183
|
+
msgParam: typeof content === "string" ? JSON.parse(content) : content,
|
|
184
|
+
};
|
|
185
|
+
} catch {
|
|
186
|
+
return { error: "Invalid actionCard message format, expected JSON" };
|
|
187
|
+
}
|
|
188
|
+
case "image":
|
|
189
|
+
return {
|
|
190
|
+
msgKey: "sampleImageMsg",
|
|
191
|
+
msgParam: { photoURL: content },
|
|
192
|
+
};
|
|
193
|
+
case "text":
|
|
194
|
+
default:
|
|
195
|
+
return {
|
|
196
|
+
msgKey: "sampleText",
|
|
197
|
+
msgParam: { content },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 使用普通消息 API 发送单聊消息(降级方案)
|
|
204
|
+
*/
|
|
205
|
+
export async function sendNormalToUser(
|
|
206
|
+
config: DingtalkConfig,
|
|
207
|
+
userIds: string | string[],
|
|
208
|
+
content: string,
|
|
209
|
+
options: ProactiveSendOptions = {},
|
|
210
|
+
): Promise<SendResult> {
|
|
211
|
+
const { msgType = "text", title, log } = options;
|
|
212
|
+
const userIdArray = Array.isArray(userIds) ? userIds : [userIds];
|
|
213
|
+
|
|
214
|
+
// ✅ 后处理:上传本地图片到钉钉,替换 markdown 图片语法中的本地路径为 media_id
|
|
215
|
+
let processedContent = content;
|
|
216
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
217
|
+
if (oapiToken) {
|
|
218
|
+
log?.info?.(`[sendNormalToUser] 开始图片后处理`);
|
|
219
|
+
processedContent = await processLocalImages(content, oapiToken, log);
|
|
220
|
+
} else {
|
|
221
|
+
log?.warn?.(`[sendNormalToUser] 无法获取 oapiToken,跳过媒体后处理`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const payload = buildMsgPayload(msgType, processedContent, title);
|
|
225
|
+
if ("error" in payload) {
|
|
226
|
+
return { ok: false, error: payload.error, usedAICard: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const token = await getAccessToken(config);
|
|
231
|
+
const body = {
|
|
232
|
+
robotCode: config.clientId,
|
|
233
|
+
userIds: userIdArray,
|
|
234
|
+
msgKey: payload.msgKey,
|
|
235
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
log?.info?.(
|
|
239
|
+
`发送单聊消息: userIds=${userIdArray.join(",")}, msgType=${msgType}`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const resp = await dingtalkHttp.post(
|
|
243
|
+
`${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`,
|
|
244
|
+
body,
|
|
245
|
+
{
|
|
246
|
+
headers: {
|
|
247
|
+
"x-acs-dingtalk-access-token": token,
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
},
|
|
250
|
+
timeout: 10_000,
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (resp.data?.processQueryKey) {
|
|
255
|
+
log?.info?.(
|
|
256
|
+
`发送成功: processQueryKey=${resp.data.processQueryKey}`,
|
|
257
|
+
);
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
processQueryKey: resp.data.processQueryKey,
|
|
261
|
+
usedAICard: false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
log?.warn?.(
|
|
266
|
+
`发送响应异常: ${JSON.stringify(resp.data)}`,
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: resp.data?.message || "Unknown error",
|
|
271
|
+
usedAICard: false,
|
|
272
|
+
};
|
|
273
|
+
} catch (err: any) {
|
|
274
|
+
const errMsg = err.response?.data?.message || err.message;
|
|
275
|
+
log?.error?.(`发送失败: ${errMsg}`);
|
|
276
|
+
return { ok: false, error: errMsg, usedAICard: false };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 使用普通消息 API 发送群聊消息(降级方案)
|
|
282
|
+
*/
|
|
283
|
+
export async function sendNormalToGroup(
|
|
284
|
+
config: DingtalkConfig,
|
|
285
|
+
openConversationId: string,
|
|
286
|
+
content: string,
|
|
287
|
+
options: ProactiveSendOptions = {},
|
|
288
|
+
): Promise<SendResult> {
|
|
289
|
+
const { msgType = "text", title, log } = options;
|
|
290
|
+
|
|
291
|
+
// ✅ 后处理:上传本地图片到钉钉,替换 markdown 图片语法中的本地路径为 media_id
|
|
292
|
+
let processedContent = content;
|
|
293
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
294
|
+
if (oapiToken) {
|
|
295
|
+
log?.info?.(`[sendNormalToGroup] 开始图片后处理`);
|
|
296
|
+
processedContent = await processLocalImages(content, oapiToken, log);
|
|
297
|
+
} else {
|
|
298
|
+
log?.warn?.(`[sendNormalToGroup] 无法获取 oapiToken,跳过媒体后处理`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const payload = buildMsgPayload(msgType, processedContent, title);
|
|
302
|
+
if ("error" in payload) {
|
|
303
|
+
return { ok: false, error: payload.error, usedAICard: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const token = await getAccessToken(config);
|
|
308
|
+
const body = {
|
|
309
|
+
robotCode: config.clientId,
|
|
310
|
+
openConversationId,
|
|
311
|
+
msgKey: payload.msgKey,
|
|
312
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
log?.info?.(
|
|
316
|
+
`发送群聊消息: openConversationId=${openConversationId}, msgType=${msgType}`,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const resp = await dingtalkHttp.post(
|
|
320
|
+
`${DINGTALK_API}/v1.0/robot/groupMessages/send`,
|
|
321
|
+
body,
|
|
322
|
+
{
|
|
323
|
+
headers: {
|
|
324
|
+
"x-acs-dingtalk-access-token": token,
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
},
|
|
327
|
+
timeout: 10_000,
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (resp.data?.processQueryKey) {
|
|
332
|
+
log?.info?.(
|
|
333
|
+
`发送成功: processQueryKey=${resp.data.processQueryKey}`,
|
|
334
|
+
);
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
processQueryKey: resp.data.processQueryKey,
|
|
338
|
+
usedAICard: false,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
log?.warn?.(
|
|
343
|
+
`发送响应异常: ${JSON.stringify(resp.data)}`,
|
|
344
|
+
);
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error: resp.data?.message || "Unknown error",
|
|
348
|
+
usedAICard: false,
|
|
349
|
+
};
|
|
350
|
+
} catch (err: any) {
|
|
351
|
+
const errMsg = err.response?.data?.message || err.message;
|
|
352
|
+
log?.error?.(`发送失败: ${errMsg}`);
|
|
353
|
+
return { ok: false, error: errMsg, usedAICard: false };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 主动创建并发送 AI Card(通用内部实现)
|
|
359
|
+
*/
|
|
360
|
+
export async function sendAICardInternal(
|
|
361
|
+
config: DingtalkConfig,
|
|
362
|
+
target: AICardTarget,
|
|
363
|
+
content: string,
|
|
364
|
+
log?: any,
|
|
365
|
+
): Promise<SendResult> {
|
|
366
|
+
const targetDesc =
|
|
367
|
+
target.type === "group"
|
|
368
|
+
? `群聊 ${target.openConversationId}`
|
|
369
|
+
: `用户 ${target.userId}`;
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
// 0. 获取 oapiToken 用于后处理
|
|
373
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
374
|
+
|
|
375
|
+
// 1. 后处理01:上传本地图片到钉钉,替换路径为 media_id
|
|
376
|
+
let processedContent = content;
|
|
377
|
+
if (oapiToken) {
|
|
378
|
+
log?.info?.(`开始图片后处理`);
|
|
379
|
+
processedContent = await processLocalImages(content, oapiToken, log);
|
|
380
|
+
} else {
|
|
381
|
+
log?.warn?.(
|
|
382
|
+
`无法获取 oapiToken,跳过媒体后处理`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 2. 后处理02:提取视频标记并发送视频消息
|
|
387
|
+
log?.info?.(`开始视频后处理`);
|
|
388
|
+
processedContent = await processVideoMarkers(
|
|
389
|
+
processedContent,
|
|
390
|
+
"",
|
|
391
|
+
config,
|
|
392
|
+
oapiToken,
|
|
393
|
+
log,
|
|
394
|
+
true,
|
|
395
|
+
target,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// 3. 后处理03:提取音频标记并发送音频消息
|
|
399
|
+
log?.info?.(`开始音频后处理`);
|
|
400
|
+
processedContent = await processAudioMarkers(
|
|
401
|
+
processedContent,
|
|
402
|
+
"",
|
|
403
|
+
config,
|
|
404
|
+
oapiToken,
|
|
405
|
+
log,
|
|
406
|
+
true,
|
|
407
|
+
target,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// 4. 后处理04:提取文件标记并发送独立文件消息
|
|
411
|
+
log?.info?.(`开始文件后处理`);
|
|
412
|
+
processedContent = await processFileMarkers(
|
|
413
|
+
processedContent,
|
|
414
|
+
"",
|
|
415
|
+
config,
|
|
416
|
+
oapiToken,
|
|
417
|
+
log,
|
|
418
|
+
true,
|
|
419
|
+
target,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// 5. 检查处理后的内容是否为空
|
|
423
|
+
const trimmedContent = processedContent.trim();
|
|
424
|
+
if (!trimmedContent) {
|
|
425
|
+
log?.info?.(
|
|
426
|
+
`处理后内容为空(纯文件/视频消息),跳过创建 AI Card`,
|
|
427
|
+
);
|
|
428
|
+
return { ok: true, usedAICard: false };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 6. 创建卡片
|
|
432
|
+
const card = await createAICardForTarget(config, target, log);
|
|
433
|
+
if (!card) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
error: "Failed to create AI Card",
|
|
437
|
+
usedAICard: false,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 7. 使用 finishAICard 设置内容
|
|
442
|
+
await finishAICard(card, processedContent, log);
|
|
443
|
+
|
|
444
|
+
log?.info?.(
|
|
445
|
+
`AI Card 发送成功: ${targetDesc}, cardInstanceId=${card.cardInstanceId}`,
|
|
446
|
+
);
|
|
447
|
+
return { ok: true, cardInstanceId: card.cardInstanceId, usedAICard: true };
|
|
448
|
+
} catch (err: any) {
|
|
449
|
+
log?.error?.(
|
|
450
|
+
`AI Card 发送失败 (${targetDesc}): ${err.message}`,
|
|
451
|
+
);
|
|
452
|
+
if (err.response) {
|
|
453
|
+
log?.error?.(
|
|
454
|
+
`错误响应: status=${err.response.status} data=${JSON.stringify(err.response.data)}`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
error: err.response?.data?.message || err.message,
|
|
460
|
+
usedAICard: false,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 主动发送 AI Card 到单聊用户
|
|
467
|
+
*/
|
|
468
|
+
export async function sendAICardToUser(
|
|
469
|
+
config: DingtalkConfig,
|
|
470
|
+
userId: string,
|
|
471
|
+
content: string,
|
|
472
|
+
log?: any,
|
|
473
|
+
): Promise<SendResult> {
|
|
474
|
+
return sendAICardInternal(config, { type: "user", userId }, content, log);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 主动发送 AI Card 到群聊
|
|
479
|
+
*/
|
|
480
|
+
export async function sendAICardToGroup(
|
|
481
|
+
config: DingtalkConfig,
|
|
482
|
+
openConversationId: string,
|
|
483
|
+
content: string,
|
|
484
|
+
log?: any,
|
|
485
|
+
): Promise<SendResult> {
|
|
486
|
+
return sendAICardInternal(
|
|
487
|
+
config,
|
|
488
|
+
{ type: "group", openConversationId },
|
|
489
|
+
content,
|
|
490
|
+
log,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 主动发送文本消息到钉钉
|
|
496
|
+
*/
|
|
497
|
+
export async function sendToUser(
|
|
498
|
+
config: DingtalkConfig,
|
|
499
|
+
userId: string | string[],
|
|
500
|
+
text: string,
|
|
501
|
+
options?: ProactiveSendOptions,
|
|
502
|
+
): Promise<SendResult> {
|
|
503
|
+
if (!config?.clientId || !config?.clientSecret) {
|
|
504
|
+
return { ok: false, error: "Missing clientId or clientSecret", usedAICard: false };
|
|
505
|
+
}
|
|
506
|
+
if (!userId || (Array.isArray(userId) && userId.length === 0)) {
|
|
507
|
+
return { ok: false, error: "userId is empty", usedAICard: false };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 多用户:使用普通消息 API(不走 AI Card)
|
|
511
|
+
if (Array.isArray(userId)) {
|
|
512
|
+
return sendNormalToUser(config, userId, text, options || {});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return sendProactive(config, { userId }, text, options || {});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 主动发送文本消息到钉钉群
|
|
520
|
+
*/
|
|
521
|
+
export async function sendToGroup(
|
|
522
|
+
config: DingtalkConfig,
|
|
523
|
+
openConversationId: string,
|
|
524
|
+
text: string,
|
|
525
|
+
options?: ProactiveSendOptions,
|
|
526
|
+
): Promise<SendResult> {
|
|
527
|
+
if (!config?.clientId || !config?.clientSecret) {
|
|
528
|
+
return { ok: false, error: "Missing clientId or clientSecret", usedAICard: false };
|
|
529
|
+
}
|
|
530
|
+
if (!openConversationId || typeof openConversationId !== "string") {
|
|
531
|
+
return { ok: false, error: "openConversationId is empty", usedAICard: false };
|
|
532
|
+
}
|
|
533
|
+
return sendProactive(config, { openConversationId }, text, options || {});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 发送文本消息(用于 outbound 接口)
|
|
538
|
+
*/
|
|
539
|
+
export async function sendTextToDingTalk(params: {
|
|
540
|
+
config: DingtalkConfig;
|
|
541
|
+
target: string;
|
|
542
|
+
text: string;
|
|
543
|
+
replyToId?: string;
|
|
544
|
+
}): Promise<SendResult> {
|
|
545
|
+
const { config, target, text, replyToId } = params;
|
|
546
|
+
|
|
547
|
+
const log = createLoggerFromConfig(config, 'sendTextToDingTalk');
|
|
548
|
+
|
|
549
|
+
// 参数校验
|
|
550
|
+
if (!target || typeof target !== "string") {
|
|
551
|
+
log.error("target 参数无效:", target);
|
|
552
|
+
return { ok: false, error: "Invalid target parameter", usedAICard: false };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 判断目标是用户还是群
|
|
556
|
+
const isUser = !target.startsWith("cid");
|
|
557
|
+
const targetParam = isUser
|
|
558
|
+
? { type: "user" as const, userId: target }
|
|
559
|
+
: { type: "group" as const, openConversationId: target };
|
|
560
|
+
|
|
561
|
+
return sendProactive(config, targetParam, text, {
|
|
562
|
+
msgType: "text",
|
|
563
|
+
replyToId,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 发送媒体消息(用于 outbound 接口)
|
|
569
|
+
*/
|
|
570
|
+
export async function sendMediaToDingTalk(params: {
|
|
571
|
+
config: DingtalkConfig;
|
|
572
|
+
target: string;
|
|
573
|
+
text?: string;
|
|
574
|
+
mediaUrl: string;
|
|
575
|
+
replyToId?: string;
|
|
576
|
+
}): Promise<SendResult> {
|
|
577
|
+
const log = createLoggerFromConfig(params.config, 'sendMediaToDingTalk');
|
|
578
|
+
|
|
579
|
+
log.info(
|
|
580
|
+
"开始处理,params:",
|
|
581
|
+
JSON.stringify({
|
|
582
|
+
target: params.target,
|
|
583
|
+
text: params.text,
|
|
584
|
+
mediaUrl: params.mediaUrl,
|
|
585
|
+
replyToId: params.replyToId,
|
|
586
|
+
hasConfig: !!params.config,
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const { config, target, text, mediaUrl, replyToId } = params;
|
|
591
|
+
|
|
592
|
+
// 参数校验
|
|
593
|
+
if (!target || typeof target !== "string") {
|
|
594
|
+
log.error("target 参数无效:", target);
|
|
595
|
+
return { ok: false, error: "Invalid target parameter", usedAICard: false };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 判断目标是用户还是群
|
|
599
|
+
const isUser = !target.startsWith("cid");
|
|
600
|
+
const targetParam = isUser
|
|
601
|
+
? { type: "user" as const, userId: target }
|
|
602
|
+
: { type: "group" as const, openConversationId: target };
|
|
603
|
+
|
|
604
|
+
log.info("参数解析完成,mediaUrl:", mediaUrl, "type:", typeof mediaUrl);
|
|
605
|
+
|
|
606
|
+
// 参数校验
|
|
607
|
+
if (!mediaUrl) {
|
|
608
|
+
log.info("mediaUrl 为空,返回错误提示");
|
|
609
|
+
return sendProactive(config, targetParam, text ?? "⚠️ 缺少媒体文件 URL", {
|
|
610
|
+
msgType: "text",
|
|
611
|
+
replyToId,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 1. 先发送文本消息(如果有且不为空)
|
|
616
|
+
// 注意:只有在 text 有实际内容时才发送,避免发送空消息
|
|
617
|
+
if (text && text.trim().length > 0) {
|
|
618
|
+
log.info("先发送文本消息:", text);
|
|
619
|
+
await sendProactive(config, targetParam, text, {
|
|
620
|
+
msgType: "text",
|
|
621
|
+
replyToId,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 2. 上传媒体文件并发送媒体消息
|
|
626
|
+
try {
|
|
627
|
+
log.info("开始获取 oapiToken");
|
|
628
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
629
|
+
log.info("oapiToken 获取成功");
|
|
630
|
+
|
|
631
|
+
// 根据文件扩展名判断媒体类型
|
|
632
|
+
log.info("开始解析文件扩展名,mediaUrl:", mediaUrl);
|
|
633
|
+
const ext = mediaUrl.toLowerCase().split(".").pop() || "";
|
|
634
|
+
log.info("文件扩展名:", ext);
|
|
635
|
+
let mediaType: "image" | "file" | "video" | "voice" = "file";
|
|
636
|
+
|
|
637
|
+
if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext)) {
|
|
638
|
+
mediaType = "image";
|
|
639
|
+
} else if (
|
|
640
|
+
["mp4", "avi", "mov", "mkv", "flv", "wmv", "webm"].includes(ext)
|
|
641
|
+
) {
|
|
642
|
+
mediaType = "video";
|
|
643
|
+
} else if (
|
|
644
|
+
["mp3", "wav", "aac", "ogg", "m4a", "flac", "wma", "amr"].includes(ext)
|
|
645
|
+
) {
|
|
646
|
+
mediaType = "voice";
|
|
647
|
+
}
|
|
648
|
+
log.info("媒体类型判断完成:", mediaType);
|
|
649
|
+
|
|
650
|
+
// 上传文件到钉钉
|
|
651
|
+
// 根据媒体类型设置不同的大小限制(钉钉 OAPI 官方限制)
|
|
652
|
+
let maxSize: number;
|
|
653
|
+
switch (mediaType) {
|
|
654
|
+
case "image":
|
|
655
|
+
maxSize = 10 * 1024 * 1024; // 图片最大 10MB
|
|
656
|
+
break;
|
|
657
|
+
case "voice":
|
|
658
|
+
maxSize = 2 * 1024 * 1024; // 语音最大 2MB
|
|
659
|
+
break;
|
|
660
|
+
case "video":
|
|
661
|
+
case "file":
|
|
662
|
+
maxSize = 20 * 1024 * 1024; // 视频和文件最大 20MB
|
|
663
|
+
break;
|
|
664
|
+
default:
|
|
665
|
+
maxSize = 20 * 1024 * 1024; // 默认 20MB
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
log.info("准备调用 uploadMediaToDingTalk,参数:", { mediaUrl, mediaType, maxSizeMB: (maxSize / (1024 * 1024)).toFixed(0) });
|
|
669
|
+
if (!oapiToken) {
|
|
670
|
+
log.error("oapiToken 为空,无法上传媒体文件");
|
|
671
|
+
return sendProactive(
|
|
672
|
+
config,
|
|
673
|
+
targetParam,
|
|
674
|
+
"⚠️ 媒体文件处理失败:缺少 oapiToken",
|
|
675
|
+
{ msgType: "text", replyToId },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
const uploadResult = await uploadMediaToDingTalk(
|
|
679
|
+
mediaUrl,
|
|
680
|
+
mediaType,
|
|
681
|
+
oapiToken,
|
|
682
|
+
maxSize,
|
|
683
|
+
log,
|
|
684
|
+
);
|
|
685
|
+
log.info("uploadMediaToDingTalk 返回结果:", uploadResult);
|
|
686
|
+
|
|
687
|
+
if (!uploadResult) {
|
|
688
|
+
// 上传失败,发送文本消息提示
|
|
689
|
+
log.error("上传失败,返回错误提示");
|
|
690
|
+
return sendProactive(config, targetParam, "⚠️ 媒体文件上传失败", {
|
|
691
|
+
msgType: "text",
|
|
692
|
+
replyToId,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// uploadResult 现在是对象,包含 mediaId、cleanMediaId、downloadUrl
|
|
697
|
+
log.info("提取 media_id:", uploadResult.mediaId);
|
|
698
|
+
|
|
699
|
+
// 3. 根据媒体类型发送对应的消息
|
|
700
|
+
const fileName = mediaUrl.split("/").pop() || "file";
|
|
701
|
+
|
|
702
|
+
if (mediaType === "image") {
|
|
703
|
+
// 图片消息 - 发送真正的图片消息,使用原始 mediaId(带 @)
|
|
704
|
+
const result = await sendProactive(config, targetParam, uploadResult.mediaId, {
|
|
705
|
+
msgType: "image",
|
|
706
|
+
replyToId,
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
...result,
|
|
710
|
+
processQueryKey: result.processQueryKey || "image-message-sent",
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// 对于视频,使用视频标记机制
|
|
715
|
+
if (mediaType === "video") {
|
|
716
|
+
// 构建视频标记
|
|
717
|
+
const videoMarker = `[DINGTALK_VIDEO]{"path":"${mediaUrl}"}[/DINGTALK_VIDEO]`;
|
|
718
|
+
|
|
719
|
+
// 直接处理视频标记(上传并发送视频消息)
|
|
720
|
+
const { processVideoMarkers } = await import("./media");
|
|
721
|
+
await processVideoMarkers(
|
|
722
|
+
videoMarker, // 只传入标记,不包含原始文本
|
|
723
|
+
"",
|
|
724
|
+
config,
|
|
725
|
+
oapiToken,
|
|
726
|
+
console,
|
|
727
|
+
true, // useProactiveApi
|
|
728
|
+
targetParam,
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
// 如果有原始文本,单独发送
|
|
732
|
+
if (text?.trim()) {
|
|
733
|
+
const result = await sendProactive(config, targetParam, text, {
|
|
734
|
+
msgType: "text",
|
|
735
|
+
replyToId,
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
...result,
|
|
739
|
+
processQueryKey: result.processQueryKey || "video-text-sent",
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 视频已发送,返回成功
|
|
744
|
+
return {
|
|
745
|
+
ok: true,
|
|
746
|
+
usedAICard: false,
|
|
747
|
+
processQueryKey: "video-message-sent",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 对于音频、文件,发送真正的文件消息
|
|
752
|
+
const fs = await import("fs");
|
|
753
|
+
const stats = fs.statSync(mediaUrl);
|
|
754
|
+
|
|
755
|
+
// 获取文件扩展名作为 fileType
|
|
756
|
+
const fileType = ext || "file";
|
|
757
|
+
|
|
758
|
+
// 构建文件信息
|
|
759
|
+
const fileInfo = {
|
|
760
|
+
fileName: fileName,
|
|
761
|
+
fileType: fileType,
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// 使用 sendFileProactive 发送文件消息
|
|
765
|
+
const { sendFileProactive } = await import("./media.ts");
|
|
766
|
+
await sendFileProactive(config, targetParam, fileInfo, uploadResult.mediaId, log);
|
|
767
|
+
|
|
768
|
+
// 返回成功结果
|
|
769
|
+
return {
|
|
770
|
+
ok: true,
|
|
771
|
+
usedAICard: false,
|
|
772
|
+
processQueryKey: "file-message-sent",
|
|
773
|
+
};
|
|
774
|
+
} catch (err: any) {
|
|
775
|
+
log.error("发送媒体消息失败:", err.message);
|
|
776
|
+
// 发生错误,发送文本消息提示
|
|
777
|
+
return sendProactive(
|
|
778
|
+
config,
|
|
779
|
+
targetParam,
|
|
780
|
+
`⚠️ 媒体文件处理失败: ${err.message}`,
|
|
781
|
+
{ msgType: "text", replyToId },
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* 智能发送消息
|
|
788
|
+
*/
|
|
789
|
+
export async function sendProactive(
|
|
790
|
+
config: DingtalkConfig,
|
|
791
|
+
target: { userId?: string; userIds?: string[]; openConversationId?: string },
|
|
792
|
+
content: string,
|
|
793
|
+
options: ProactiveSendOptions = {},
|
|
794
|
+
): Promise<SendResult> {
|
|
795
|
+
const log = createLoggerFromConfig(config, 'sendProactive');
|
|
796
|
+
|
|
797
|
+
log.info(
|
|
798
|
+
"开始处理,参数:",
|
|
799
|
+
JSON.stringify({
|
|
800
|
+
target,
|
|
801
|
+
contentLength: content?.length,
|
|
802
|
+
hasOptions: !!options,
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
if (!options.msgType) {
|
|
807
|
+
const hasMarkdown =
|
|
808
|
+
/^[#*>-]|[*_`#\[\]]/.test(content) ||
|
|
809
|
+
(content && typeof content === "string" && content.includes("\n"));
|
|
810
|
+
if (hasMarkdown) {
|
|
811
|
+
options.msgType = "markdown";
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 直接实现发送逻辑,不要递归调用 sendToUser/sendToGroup
|
|
816
|
+
if (target.userId || target.userIds) {
|
|
817
|
+
const userIds = target.userIds || [target.userId!];
|
|
818
|
+
const userId = userIds[0];
|
|
819
|
+
log.info("发送给用户,userId:", userId);
|
|
820
|
+
|
|
821
|
+
// 构建发送参数
|
|
822
|
+
return sendProactiveInternal(
|
|
823
|
+
config,
|
|
824
|
+
{ type: "user", userId },
|
|
825
|
+
content,
|
|
826
|
+
options,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (target.openConversationId) {
|
|
831
|
+
log.info(
|
|
832
|
+
"发送给群聊,openConversationId:",
|
|
833
|
+
target.openConversationId,
|
|
834
|
+
);
|
|
835
|
+
return sendProactiveInternal(
|
|
836
|
+
config,
|
|
837
|
+
{ type: "group", openConversationId: target.openConversationId },
|
|
838
|
+
content,
|
|
839
|
+
options,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
log.error("target 参数缺少必要字段:", target);
|
|
844
|
+
return {
|
|
845
|
+
ok: false,
|
|
846
|
+
error: "Must specify userId, userIds, or openConversationId",
|
|
847
|
+
usedAICard: false,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 内部发送实现
|
|
853
|
+
*/
|
|
854
|
+
async function sendProactiveInternal(
|
|
855
|
+
config: DingtalkConfig,
|
|
856
|
+
target: AICardTarget,
|
|
857
|
+
content: string,
|
|
858
|
+
options: ProactiveSendOptions,
|
|
859
|
+
): Promise<SendResult> {
|
|
860
|
+
const log = createLoggerFromConfig(config, 'sendProactiveInternal');
|
|
861
|
+
|
|
862
|
+
log.info(
|
|
863
|
+
"开始处理,参数:",
|
|
864
|
+
JSON.stringify({
|
|
865
|
+
target,
|
|
866
|
+
contentLength: content?.length,
|
|
867
|
+
msgType: options.msgType,
|
|
868
|
+
useAICard: options.useAICard,
|
|
869
|
+
targetType: target?.type,
|
|
870
|
+
hasTarget: !!target,
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
// 参数校验
|
|
875
|
+
if (!target || typeof target !== "object") {
|
|
876
|
+
log.error("target 参数无效:", target);
|
|
877
|
+
return { ok: false, error: "Invalid target parameter", usedAICard: false };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const {
|
|
881
|
+
msgType = "text",
|
|
882
|
+
useAICard = true, // 默认启用 AI Card,让主动发送消息优先使用卡片形式
|
|
883
|
+
fallbackToNormal = true, // 默认降级,AI Card 失败时自动回退到普通消息
|
|
884
|
+
log: externalLog,
|
|
885
|
+
} = options;
|
|
886
|
+
|
|
887
|
+
// 图片、音频、视频、文件等媒体类型消息不支持 AI Card,必须走普通消息 API
|
|
888
|
+
const isMediaMessage = MEDIA_MSG_TYPES.has(msgType as any);
|
|
889
|
+
|
|
890
|
+
// 如果启用 AI Card(媒体消息强制跳过)
|
|
891
|
+
if (useAICard && !isMediaMessage) {
|
|
892
|
+
try {
|
|
893
|
+
const card = await createAICardForTarget(config, target, externalLog);
|
|
894
|
+
if (card) {
|
|
895
|
+
await finishAICard(card, content, externalLog);
|
|
896
|
+
return {
|
|
897
|
+
ok: true,
|
|
898
|
+
cardInstanceId: card.cardInstanceId,
|
|
899
|
+
usedAICard: true,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
if (!fallbackToNormal) {
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
error: "Failed to create AI Card",
|
|
906
|
+
usedAICard: false,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
} catch (err: any) {
|
|
910
|
+
externalLog?.error?.(`AI Card 发送失败: ${err.message}`);
|
|
911
|
+
if (!fallbackToNormal) {
|
|
912
|
+
return { ok: false, error: err.message, usedAICard: false };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// 发送普通消息
|
|
918
|
+
try {
|
|
919
|
+
log.info(
|
|
920
|
+
"准备发送普通消息,target.type:",
|
|
921
|
+
target.type,
|
|
922
|
+
);
|
|
923
|
+
const token = await getAccessToken(config);
|
|
924
|
+
const isUser = target.type === "user";
|
|
925
|
+
log.info(
|
|
926
|
+
"isUser:",
|
|
927
|
+
isUser,
|
|
928
|
+
"target:",
|
|
929
|
+
JSON.stringify(target),
|
|
930
|
+
);
|
|
931
|
+
const targetId = isUser ? target.userId : target.openConversationId;
|
|
932
|
+
log.info("targetId:", targetId);
|
|
933
|
+
|
|
934
|
+
// ✅ 根据目标类型选择不同的 API
|
|
935
|
+
const webhookUrl = isUser
|
|
936
|
+
? `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`
|
|
937
|
+
: `${DINGTALK_API}/v1.0/robot/groupMessages/send`;
|
|
938
|
+
|
|
939
|
+
// 使用 buildMsgPayload 构建消息体(支持所有消息类型)
|
|
940
|
+
const payload = buildMsgPayload(msgType, content, options.title);
|
|
941
|
+
if ("error" in payload) {
|
|
942
|
+
log.error("构建消息失败:", payload.error);
|
|
943
|
+
return { ok: false, error: payload.error, usedAICard: false };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const body: any = {
|
|
947
|
+
robotCode: config.clientId,
|
|
948
|
+
msgKey: payload.msgKey,
|
|
949
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
// ✅ 根据目标类型设置不同的参数
|
|
953
|
+
if (isUser) {
|
|
954
|
+
body.userIds = [targetId];
|
|
955
|
+
} else {
|
|
956
|
+
body.openConversationId = targetId;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
externalLog?.info?.(
|
|
960
|
+
`发送${isUser ? '单聊' : '群聊'}消息:${isUser ? 'userIds=' : 'openConversationId='}${targetId}`,
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
const resp = await dingtalkHttp.post(webhookUrl, body, {
|
|
964
|
+
headers: {
|
|
965
|
+
"x-acs-dingtalk-access-token": token,
|
|
966
|
+
"Content-Type": "application/json",
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
// 重要:钉钉接口有时会出现 HTTP 200 但业务失败的情况,需要打印返回体辅助排查
|
|
971
|
+
try {
|
|
972
|
+
const dataPreview = JSON.stringify(resp.data ?? {});
|
|
973
|
+
const truncated =
|
|
974
|
+
dataPreview.length > 2000 ? `${dataPreview.slice(0, 2000)}...(truncated)` : dataPreview;
|
|
975
|
+
const msg = `发送${isUser ? "单聊" : "群聊"}消息响应:status=${resp.status}, processQueryKey=${resp.data?.processQueryKey ?? ""}, data=${truncated}`;
|
|
976
|
+
log.info(msg);
|
|
977
|
+
externalLog?.info?.(msg);
|
|
978
|
+
} catch {
|
|
979
|
+
const msg = `发送${isUser ? "单聊" : "群聊"}消息响应:status=${resp.status}, processQueryKey=${resp.data?.processQueryKey ?? ""}`;
|
|
980
|
+
log.info(msg);
|
|
981
|
+
externalLog?.info?.(msg);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return {
|
|
985
|
+
ok: true,
|
|
986
|
+
processQueryKey: resp.data?.processQueryKey,
|
|
987
|
+
usedAICard: false,
|
|
988
|
+
};
|
|
989
|
+
} catch (err: any) {
|
|
990
|
+
const status = err?.response?.status;
|
|
991
|
+
const respData = err?.response?.data;
|
|
992
|
+
let respPreview = "";
|
|
993
|
+
try {
|
|
994
|
+
const raw = JSON.stringify(respData ?? {});
|
|
995
|
+
respPreview = raw.length > 2000 ? `${raw.slice(0, 2000)}...(truncated)` : raw;
|
|
996
|
+
} catch {
|
|
997
|
+
respPreview = String(respData ?? "");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const baseMsg = err?.message ? String(err.message) : String(err);
|
|
1001
|
+
const extra =
|
|
1002
|
+
typeof status === "number"
|
|
1003
|
+
? ` status=${status}${respPreview ? `, data=${respPreview}` : ""}`
|
|
1004
|
+
: respPreview
|
|
1005
|
+
? ` data=${respPreview}`
|
|
1006
|
+
: "";
|
|
1007
|
+
|
|
1008
|
+
const msg = `发送${target.type === "user" ? "单聊" : "群聊"}消息失败:${baseMsg}${extra}`;
|
|
1009
|
+
log.error(msg);
|
|
1010
|
+
externalLog?.error?.(msg);
|
|
1011
|
+
return { ok: false, error: baseMsg, usedAICard: false };
|
|
1012
|
+
}
|
|
1013
|
+
}
|