@dingtalk-real-ai/dingtalk-connector 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.en.md +14 -8
- package/README.md +14 -8
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.ts +51 -51
- package/src/config/accounts.ts +13 -1
- package/src/core/connection.ts +71 -35
- package/src/core/message-handler.ts +118 -126
- package/src/core/provider.ts +2 -1
- package/src/onboarding.ts +91 -31
- package/src/reply-dispatcher.ts +14 -1
- package/src/services/media.ts +1 -1
- package/src/services/messaging.ts +32 -38
- package/src/utils/logger.ts +3 -3
- package/src/utils/session.ts +7 -2
- package/src/utils/utils-legacy.ts +4 -101
- package/test-dingtalk-connection.mjs +0 -105
|
@@ -44,6 +44,7 @@ import { sendProactive, type AICardTarget } from "../services/messaging/index.ts
|
|
|
44
44
|
import { createDingtalkReplyDispatcher, normalizeSlashCommand } from "../reply-dispatcher.ts";
|
|
45
45
|
import { getDingtalkRuntime } from "../runtime.ts";
|
|
46
46
|
import { dingtalkHttp } from '../utils/http-client.ts';
|
|
47
|
+
import { createLoggerFromConfig } from '../utils/logger.ts';
|
|
47
48
|
import * as fs from 'fs';
|
|
48
49
|
import * as path from 'path';
|
|
49
50
|
import * as os from 'os';
|
|
@@ -266,6 +267,11 @@ export async function downloadImageToFile(
|
|
|
266
267
|
try {
|
|
267
268
|
log?.info?.(`开始下载图片: ${downloadUrl.slice(0, 100)}...`);
|
|
268
269
|
const resp = await dingtalkHttp.get(downloadUrl, {
|
|
270
|
+
proxy: false, // 禁用代理,避免 PAC 文件影响
|
|
271
|
+
|
|
272
|
+
headers: {
|
|
273
|
+
'Content-Type': undefined, // 删除默认的 Content-Type 请求头,让 OSS 签名验证通过
|
|
274
|
+
},
|
|
269
275
|
responseType: 'arraybuffer',
|
|
270
276
|
timeout: 30_000,
|
|
271
277
|
});
|
|
@@ -366,6 +372,10 @@ export async function downloadFileToLocal(
|
|
|
366
372
|
try {
|
|
367
373
|
log?.info?.(`开始下载文件: ${fileName}`);
|
|
368
374
|
const resp = await dingtalkHttp.get(downloadUrl, {
|
|
375
|
+
proxy: false, // 禁用代理,避免 PAC 文件影响
|
|
376
|
+
headers: {
|
|
377
|
+
'Content-Type': undefined, // 删除默认的 Content-Type 请求头,让 OSS 签名验证通过
|
|
378
|
+
},
|
|
369
379
|
responseType: 'arraybuffer',
|
|
370
380
|
timeout: 60_000, // 文件可能较大,增加超时时间
|
|
371
381
|
});
|
|
@@ -407,7 +417,8 @@ export async function downloadFileToLocal(
|
|
|
407
417
|
log?.info?.(`文件下载成功: ${fileName}, size=${buffer.length} bytes, path=${localPath}`);
|
|
408
418
|
return localPath;
|
|
409
419
|
} catch (err: any) {
|
|
410
|
-
|
|
420
|
+
console.error(`[ERROR] downloadFileToLocal 异常: ${err.message}`);
|
|
421
|
+
console.error(`[ERROR] 异常堆栈:\n${err.stack}`);
|
|
411
422
|
return null;
|
|
412
423
|
}
|
|
413
424
|
}
|
|
@@ -526,8 +537,11 @@ interface HandleMessageParams {
|
|
|
526
537
|
/**
|
|
527
538
|
* 内部消息处理函数(实际执行消息处理逻辑)
|
|
528
539
|
*/
|
|
529
|
-
async function handleDingTalkMessageInternal(params: HandleMessageParams): Promise<void> {
|
|
530
|
-
const { accountId, config, data, sessionWebhook, runtime, log, cfg } = params;
|
|
540
|
+
export async function handleDingTalkMessageInternal(params: HandleMessageParams): Promise<void> {
|
|
541
|
+
const { accountId, config, data, sessionWebhook, runtime, log: inputLog, cfg } = params;
|
|
542
|
+
|
|
543
|
+
// 如果传入的 log 为空,则使用基于 config 的 logger
|
|
544
|
+
const log = createLoggerFromConfig(config, `DingTalk:${accountId}`);
|
|
531
545
|
|
|
532
546
|
const content = extractMessageContent(data);
|
|
533
547
|
if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return;
|
|
@@ -536,32 +550,7 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
536
550
|
const senderId = data.senderStaffId || data.senderId;
|
|
537
551
|
const senderName = data.senderNick || 'Unknown';
|
|
538
552
|
|
|
539
|
-
|
|
540
|
-
const chatType = isDirect ? "direct" : "group";
|
|
541
|
-
const peerId = isDirect ? senderId : data.conversationId;
|
|
542
|
-
|
|
543
|
-
// 手动匹配 bindings 获取 agentId
|
|
544
|
-
let matchedAgentId: string | null = null;
|
|
545
|
-
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
546
|
-
for (const binding of cfg.bindings) {
|
|
547
|
-
const match = binding.match;
|
|
548
|
-
if (match.channel && match.channel !== "dingtalk-connector") continue;
|
|
549
|
-
if (match.accountId && match.accountId !== accountId) continue;
|
|
550
|
-
if (match.peer) {
|
|
551
|
-
if (match.peer.kind && match.peer.kind !== chatType) continue;
|
|
552
|
-
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
|
|
553
|
-
}
|
|
554
|
-
matchedAgentId = binding.agentId;
|
|
555
|
-
break;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
if (!matchedAgentId) {
|
|
559
|
-
matchedAgentId = cfg.defaultAgent || 'main';
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// 获取 Agent 工作空间路径
|
|
563
|
-
const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, matchedAgentId);
|
|
564
|
-
log?.info?.(`Agent 工作空间路径: ${agentWorkspaceDir}`);
|
|
553
|
+
|
|
565
554
|
|
|
566
555
|
|
|
567
556
|
|
|
@@ -706,6 +695,29 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
706
695
|
sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
|
|
707
696
|
});
|
|
708
697
|
|
|
698
|
+
// ===== 解析 agentId 和工作空间路径(在 sessionContext 之后,确保 peerId 与会话隔离策略一致)=====
|
|
699
|
+
// 使用 sessionContext.peerId 进行匹配,与后续 sessionKey 构建保持一致,避免两次匹配结果不一致
|
|
700
|
+
let matchedAgentId: string | null = null;
|
|
701
|
+
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
702
|
+
for (const binding of cfg.bindings) {
|
|
703
|
+
const match = binding.match;
|
|
704
|
+
if (match.channel && match.channel !== "dingtalk-connector") continue;
|
|
705
|
+
if (match.accountId && match.accountId !== accountId) continue;
|
|
706
|
+
if (match.peer) {
|
|
707
|
+
if (match.peer.kind && match.peer.kind !== sessionContext.chatType) continue;
|
|
708
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== sessionContext.peerId) continue;
|
|
709
|
+
}
|
|
710
|
+
matchedAgentId = binding.agentId;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (!matchedAgentId) {
|
|
715
|
+
matchedAgentId = cfg.defaultAgent || 'main';
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 获取 Agent 工作空间路径
|
|
719
|
+
const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, matchedAgentId);
|
|
720
|
+
log?.info?.(`Agent 工作空间路径: ${agentWorkspaceDir}`);
|
|
709
721
|
|
|
710
722
|
// 构建消息内容
|
|
711
723
|
// ✅ 使用 normalizeSlashCommand 归一化新会话命令
|
|
@@ -825,7 +837,7 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
825
837
|
const parseResult = await parseFileContent(localPath, fileName, log);
|
|
826
838
|
|
|
827
839
|
if (parseResult.type === 'text' && parseResult.content) {
|
|
828
|
-
//
|
|
840
|
+
// 文本类文件:将内容注入到上下文(即使解析成功也给出文件路径)
|
|
829
841
|
const contentPreview = parseResult.content.length > 200
|
|
830
842
|
? parseResult.content.slice(0, 200) + '...'
|
|
831
843
|
: parseResult.content;
|
|
@@ -833,6 +845,7 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
833
845
|
fileContentParts.push(
|
|
834
846
|
`📄 **${fileType}**: ${fileName}\n` +
|
|
835
847
|
`✅ 已解析文件内容(${parseResult.content.length} 字符)\n` +
|
|
848
|
+
`💾 已保存到本地: ${localPath}\n` +
|
|
836
849
|
`📝 内容预览:\n\`\`\`\n${contentPreview}\n\`\`\`\n\n` +
|
|
837
850
|
`📋 完整内容:\n${parseResult.content}`
|
|
838
851
|
);
|
|
@@ -929,65 +942,24 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
929
942
|
body: finalContent,
|
|
930
943
|
});
|
|
931
944
|
|
|
932
|
-
//
|
|
933
|
-
const
|
|
934
|
-
const peerId = isDirect ? senderId : data.conversationId;
|
|
935
|
-
|
|
936
|
-
// 手动匹配 bindings(支持通配符 *)
|
|
937
|
-
let matchedAgentId: string | null = null;
|
|
938
|
-
let matchedBy = 'default';
|
|
939
|
-
|
|
940
|
-
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
941
|
-
for (const binding of cfg.bindings) {
|
|
942
|
-
const match = binding.match;
|
|
943
|
-
|
|
944
|
-
// 检查 channel
|
|
945
|
-
if (match.channel && match.channel !== "dingtalk-connector") {
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// 检查 accountId
|
|
950
|
-
if (match.accountId && match.accountId !== accountId) {
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// 检查 peer
|
|
955
|
-
if (match.peer) {
|
|
956
|
-
// 检查 peer.kind
|
|
957
|
-
if (match.peer.kind && match.peer.kind !== sessionContext.chatType) {
|
|
958
|
-
continue;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// ✅ 使用 sessionContext.peerId 进行匹配(支持通配符 *)
|
|
962
|
-
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== sessionContext.peerId) {
|
|
963
|
-
continue;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// 匹配成功
|
|
968
|
-
matchedAgentId = binding.agentId;
|
|
969
|
-
matchedBy = 'binding';
|
|
970
|
-
break;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// 如果没有匹配到,使用默认 agent
|
|
975
|
-
if (!matchedAgentId) {
|
|
976
|
-
matchedAgentId = cfg.defaultAgent || 'main';
|
|
977
|
-
log?.info?.(`未匹配到 binding,使用默认 agent: ${matchedAgentId}`);
|
|
978
|
-
}
|
|
945
|
+
// matchedAgentId 已在 sessionContext 构建之后通过 bindings 匹配确定,此处直接使用
|
|
946
|
+
const matchedBy = matchedAgentId !== (cfg.defaultAgent || 'main') ? 'binding' : 'default';
|
|
979
947
|
|
|
980
948
|
// ✅ 使用 SDK 标准方法构建 sessionKey,符合 OpenClaw 规范
|
|
981
949
|
// 格式:agent:{agentId}:{channel}:{peerKind}:{peerId}
|
|
982
950
|
// ✅ 修复:使用 sessionContext.peerId,确保会话隔离配置生效
|
|
951
|
+
// ✅ 关键修复:传递 dmScope 参数,让 SDK 使用配置文件中的 session.dmScope 设置
|
|
952
|
+
const dmScope = cfg.session?.dmScope || 'per-channel-peer';
|
|
953
|
+
log?.info?.(`🔍 构建 sessionKey 前的参数: agentId=${matchedAgentId}, channel=dingtalk-connector, accountId=${accountId}, chatType=${sessionContext.chatType}, peerId=${sessionContext.peerId}, dmScope=${dmScope}`);
|
|
983
954
|
const sessionKey = core.channel.routing.buildAgentSessionKey({
|
|
984
955
|
agentId: matchedAgentId,
|
|
985
|
-
channel: 'dingtalk', // ✅ 使用 'dingtalk' 而不是 'dingtalk
|
|
956
|
+
channel: 'dingtalk-connector', // ✅ 使用 'dingtalk-connector' 而不是 'dingtalk'
|
|
986
957
|
accountId: accountId,
|
|
987
958
|
peer: {
|
|
988
959
|
kind: sessionContext.chatType, // ✅ 使用 sessionContext.chatType
|
|
989
960
|
id: sessionContext.peerId, // ✅ 使用 sessionContext.peerId(包含会话隔离逻辑)
|
|
990
961
|
},
|
|
962
|
+
dmScope: dmScope, // ✅ 传递 dmScope 参数,确保生成完整格式的 sessionKey
|
|
991
963
|
});
|
|
992
964
|
log?.info?.(`路由解析完成: agentId=${matchedAgentId}, sessionKey=${sessionKey}, matchedBy=${matchedBy}`);
|
|
993
965
|
|
|
@@ -1007,7 +979,7 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
1007
979
|
To: toField, // ✅ 修复:单聊用 senderId,群聊用 conversationId
|
|
1008
980
|
SessionKey: sessionKey, // ✅ 使用手动匹配的 sessionKey
|
|
1009
981
|
AccountId: accountId,
|
|
1010
|
-
ChatType: chatType,
|
|
982
|
+
ChatType: sessionContext.chatType,
|
|
1011
983
|
GroupSubject: isDirect ? undefined : data.conversationId,
|
|
1012
984
|
SenderName: senderId,
|
|
1013
985
|
SenderId: senderId,
|
|
@@ -1190,23 +1162,33 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
|
|
|
1190
1162
|
* 确保同一会话+agent的消息按顺序处理,避免并发冲突
|
|
1191
1163
|
*/
|
|
1192
1164
|
export async function handleDingTalkMessage(params: HandleMessageParams): Promise<void> {
|
|
1193
|
-
const { accountId, data, log, cfg } = params;
|
|
1194
|
-
|
|
1195
|
-
//
|
|
1165
|
+
const { accountId, config, data, log, cfg } = params;
|
|
1166
|
+
|
|
1167
|
+
// 使用 buildSessionContext 构建会话标识,与 handleDingTalkMessageInternal 保持一致
|
|
1168
|
+
// 确保 queueKey 的隔离策略(groupSessionScope、sharedMemoryAcrossConversations)与 sessionKey 一致
|
|
1196
1169
|
const isDirect = data.conversationType === '1';
|
|
1197
1170
|
const senderId = data.senderStaffId || data.senderId;
|
|
1198
1171
|
const conversationId = data.conversationId;
|
|
1199
|
-
|
|
1200
|
-
|
|
1172
|
+
|
|
1173
|
+
const queueSessionContext = buildSessionContext({
|
|
1174
|
+
accountId,
|
|
1175
|
+
senderId,
|
|
1176
|
+
conversationType: data.conversationType,
|
|
1177
|
+
conversationId,
|
|
1178
|
+
separateSessionByConversation: config.separateSessionByConversation,
|
|
1179
|
+
groupSessionScope: config.groupSessionScope,
|
|
1180
|
+
sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
const baseSessionId = queueSessionContext.peerId;
|
|
1184
|
+
|
|
1201
1185
|
if (!baseSessionId) {
|
|
1202
1186
|
log?.warn?.('无法构建会话标识,跳过队列管理');
|
|
1203
1187
|
return handleDingTalkMessageInternal(params);
|
|
1204
1188
|
}
|
|
1205
|
-
|
|
1206
|
-
// 解析 agentId
|
|
1207
|
-
|
|
1208
|
-
const peerId = isDirect ? senderId : conversationId;
|
|
1209
|
-
|
|
1189
|
+
|
|
1190
|
+
// 解析 agentId:使用 sessionContext.peerId 和 sessionContext.chatType 进行匹配
|
|
1191
|
+
// 与 handleDingTalkMessageInternal 中的匹配逻辑保持一致
|
|
1210
1192
|
let matchedAgentId: string | null = null;
|
|
1211
1193
|
if (cfg.bindings && cfg.bindings.length > 0) {
|
|
1212
1194
|
for (const binding of cfg.bindings) {
|
|
@@ -1214,8 +1196,8 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
|
|
|
1214
1196
|
if (match.channel && match.channel !== "dingtalk-connector") continue;
|
|
1215
1197
|
if (match.accountId && match.accountId !== accountId) continue;
|
|
1216
1198
|
if (match.peer) {
|
|
1217
|
-
if (match.peer.kind && match.peer.kind !== chatType) continue;
|
|
1218
|
-
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
|
|
1199
|
+
if (match.peer.kind && match.peer.kind !== queueSessionContext.chatType) continue;
|
|
1200
|
+
if (match.peer.id && match.peer.id !== '*' && match.peer.id !== queueSessionContext.peerId) continue;
|
|
1219
1201
|
}
|
|
1220
1202
|
matchedAgentId = binding.agentId;
|
|
1221
1203
|
break;
|
|
@@ -1224,42 +1206,52 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
|
|
|
1224
1206
|
if (!matchedAgentId) {
|
|
1225
1207
|
matchedAgentId = cfg.defaultAgent || 'main';
|
|
1226
1208
|
}
|
|
1227
|
-
|
|
1228
|
-
// 构建队列标识:会话 + agentId
|
|
1229
|
-
//
|
|
1209
|
+
|
|
1210
|
+
// 构建队列标识:会话 peerId + agentId
|
|
1211
|
+
// queueKey 与 sessionKey 使用相同的 peerId,确保隔离策略一致:
|
|
1212
|
+
// - groupSessionScope: 'group_sender' 时,同群不同用户的消息可并行处理
|
|
1213
|
+
// - sharedMemoryAcrossConversations: true 时,所有消息共享同一队列
|
|
1230
1214
|
const queueKey = `${baseSessionId}:${matchedAgentId}`;
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1215
|
+
|
|
1216
|
+
try {
|
|
1217
|
+
|
|
1218
|
+
// 更新会话活跃时间
|
|
1219
|
+
sessionLastActivity.set(queueKey, Date.now());
|
|
1220
|
+
|
|
1221
|
+
// 获取该会话+agent的上一个处理任务
|
|
1222
|
+
const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
|
|
1223
|
+
|
|
1224
|
+
// 创建当前消息的处理任务
|
|
1225
|
+
const currentTask = previousTask
|
|
1226
|
+
.then(async () => {
|
|
1227
|
+
log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
|
|
1228
|
+
await handleDingTalkMessageInternal(params);
|
|
1229
|
+
log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
|
|
1230
|
+
})
|
|
1231
|
+
.catch((err: any) => {
|
|
1232
|
+
log?.error?.(`[队列] 消息处理异常,queueKey=${queueKey}, error=${err.message}`);
|
|
1233
|
+
// 不抛出错误,避免阻塞后续消息
|
|
1234
|
+
})
|
|
1235
|
+
.finally(() => {
|
|
1236
|
+
// 如果当前任务是队列中的最后一个任务,清理队列
|
|
1237
|
+
if (sessionQueues.get(queueKey) === currentTask) {
|
|
1238
|
+
sessionQueues.delete(queueKey);
|
|
1239
|
+
log?.info?.(`[队列] 队列已清空,queueKey=${queueKey}`);
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
// 更新队列
|
|
1244
|
+
sessionQueues.set(queueKey, currentTask);
|
|
1245
|
+
|
|
1246
|
+
// 等待当前任务完成
|
|
1247
|
+
await currentTask;
|
|
1248
|
+
console.log(`[DEBUG] 任务执行完成`);
|
|
1249
|
+
} catch (err: any) {
|
|
1250
|
+
console.error(`[DEBUG] 队列管理异常: ${err.message}`);
|
|
1251
|
+
console.error(`[DEBUG] 队列管理异常堆栈: ${err.stack}`);
|
|
1252
|
+
// 如果队列管理失败,直接调用内部处理函数
|
|
1253
|
+
return handleDingTalkMessageInternal(params);
|
|
1254
|
+
}
|
|
1263
1255
|
}
|
|
1264
1256
|
|
|
1265
1257
|
// handleDingTalkMessage 已在函数定义处直接导出
|
package/src/core/provider.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
15
15
|
import * as monitorState from "./state";
|
|
16
|
+
import { createLogger } from "../utils/logger";
|
|
16
17
|
|
|
17
18
|
// 只解构 monitorState 的导出
|
|
18
19
|
const {
|
|
@@ -44,7 +45,7 @@ export async function monitorDingtalkProvider(opts: MonitorDingtalkOpts = {}): P
|
|
|
44
45
|
throw new Error("Config is required for DingTalk monitor");
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const log =
|
|
48
|
+
const log = createLogger(cfg.channels?.["dingtalk-connector"]?.debug ?? false);
|
|
48
49
|
|
|
49
50
|
// 并行导入所有模块(无循环依赖,可以并行)
|
|
50
51
|
const [accountsModule, monitorAccountModule, monitorSingleModule] = await Promise.all([
|
package/src/onboarding.ts
CHANGED
|
@@ -112,7 +112,7 @@ async function noteDingtalkCredentialHelp(prompter: WizardPrompter): Promise<voi
|
|
|
112
112
|
[
|
|
113
113
|
"1) Go to DingTalk Open Platform (open-dev.dingtalk.com)",
|
|
114
114
|
"2) Create an enterprise internal app",
|
|
115
|
-
"3) Get
|
|
115
|
+
"3) Get Client ID and Client Secret from Credentials page",
|
|
116
116
|
"4) Enable required permissions: im:message, im:chat",
|
|
117
117
|
"5) Publish the app or add it to a test group",
|
|
118
118
|
"Tip: you can also set DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET env vars.",
|
|
@@ -128,7 +128,7 @@ async function promptDingtalkClientId(params: {
|
|
|
128
128
|
}): Promise<string> {
|
|
129
129
|
const clientId = String(
|
|
130
130
|
await params.prompter.text({
|
|
131
|
-
message: "Enter DingTalk
|
|
131
|
+
message: "Enter DingTalk Client ID",
|
|
132
132
|
initialValue: params.initialValue,
|
|
133
133
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
134
134
|
}),
|
|
@@ -274,36 +274,96 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
274
274
|
await noteDingtalkCredentialHelp(prompter);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
prompter
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
accountConfigured: Boolean(resolved),
|
|
283
|
-
canUseEnv,
|
|
284
|
-
hasConfigToken: hasConfigSecret,
|
|
285
|
-
envPrompt: "DINGTALK_CLIENT_ID + DINGTALK_CLIENT_SECRET detected. Use env vars?",
|
|
286
|
-
keepPrompt: "DingTalk App Secret already configured. Keep it?",
|
|
287
|
-
inputPrompt: "Enter DingTalk App Secret (Client Secret)",
|
|
288
|
-
preferredEnvVar: "DINGTALK_CLIENT_SECRET",
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
if (clientSecretResult.action === "use-env") {
|
|
292
|
-
next = {
|
|
293
|
-
...next,
|
|
294
|
-
channels: {
|
|
295
|
-
...next.channels,
|
|
296
|
-
"dingtalk-connector": { ...next.channels?.["dingtalk-connector"], enabled: true },
|
|
297
|
-
},
|
|
298
|
-
};
|
|
299
|
-
} else if (clientSecretResult.action === "set") {
|
|
300
|
-
clientSecret = clientSecretResult.value;
|
|
301
|
-
clientSecretProbeValue = clientSecretResult.resolvedValue;
|
|
302
|
-
clientId = await promptDingtalkClientId({
|
|
303
|
-
prompter,
|
|
304
|
-
initialValue:
|
|
305
|
-
normalizeString(dingtalkCfg?.clientId) ?? normalizeString(process.env.DINGTALK_CLIENT_ID),
|
|
277
|
+
// Check if we can use environment variables
|
|
278
|
+
if (canUseEnv) {
|
|
279
|
+
const useEnv = await prompter.confirm({
|
|
280
|
+
message: "DINGTALK_CLIENT_ID + DINGTALK_CLIENT_SECRET detected. Use env vars?",
|
|
281
|
+
initialValue: true,
|
|
306
282
|
});
|
|
283
|
+
|
|
284
|
+
if (useEnv) {
|
|
285
|
+
next = {
|
|
286
|
+
...next,
|
|
287
|
+
channels: {
|
|
288
|
+
...next.channels,
|
|
289
|
+
"dingtalk-connector": { ...next.channels?.["dingtalk-connector"], enabled: true },
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
// Environment variables will be used, skip manual input
|
|
293
|
+
} else {
|
|
294
|
+
// User chose not to use env vars, proceed to manual input
|
|
295
|
+
canUseEnv = false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// If not using env vars, prompt for credentials
|
|
300
|
+
if (!canUseEnv) {
|
|
301
|
+
// Check if we should keep existing configuration
|
|
302
|
+
if (resolved && hasConfigSecret) {
|
|
303
|
+
const keepExisting = await prompter.confirm({
|
|
304
|
+
message: "DingTalk credentials already configured. Keep them?",
|
|
305
|
+
initialValue: true,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (!keepExisting) {
|
|
309
|
+
// User wants to reconfigure, proceed to input
|
|
310
|
+
// Step 1: Prompt for Client ID first
|
|
311
|
+
clientId = await promptDingtalkClientId({
|
|
312
|
+
prompter,
|
|
313
|
+
initialValue:
|
|
314
|
+
normalizeString(dingtalkCfg?.clientId) ?? normalizeString(process.env.DINGTALK_CLIENT_ID),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Step 2: Then prompt for Client Secret
|
|
318
|
+
const clientSecretResult = await promptSingleChannelSecretInput({
|
|
319
|
+
cfg: next,
|
|
320
|
+
prompter,
|
|
321
|
+
providerHint: "dingtalk",
|
|
322
|
+
credentialLabel: "Client Secret",
|
|
323
|
+
accountConfigured: false, // Force new input
|
|
324
|
+
canUseEnv: false, // Already handled above
|
|
325
|
+
hasConfigToken: false, // Force new input
|
|
326
|
+
envPrompt: "", // Not used
|
|
327
|
+
keepPrompt: "", // Not used
|
|
328
|
+
inputPrompt: "Enter DingTalk Client Secret",
|
|
329
|
+
preferredEnvVar: "DINGTALK_CLIENT_SECRET",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (clientSecretResult.action === "set") {
|
|
333
|
+
clientSecret = clientSecretResult.value;
|
|
334
|
+
clientSecretProbeValue = clientSecretResult.resolvedValue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// If keepExisting is true, we don't modify anything
|
|
338
|
+
} else {
|
|
339
|
+
// No existing config, prompt for new credentials
|
|
340
|
+
// Step 1: Prompt for Client ID first
|
|
341
|
+
clientId = await promptDingtalkClientId({
|
|
342
|
+
prompter,
|
|
343
|
+
initialValue:
|
|
344
|
+
normalizeString(dingtalkCfg?.clientId) ?? normalizeString(process.env.DINGTALK_CLIENT_ID),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Step 2: Then prompt for Client Secret
|
|
348
|
+
const clientSecretResult = await promptSingleChannelSecretInput({
|
|
349
|
+
cfg: next,
|
|
350
|
+
prompter,
|
|
351
|
+
providerHint: "dingtalk",
|
|
352
|
+
credentialLabel: "Client Secret",
|
|
353
|
+
accountConfigured: false,
|
|
354
|
+
canUseEnv: false,
|
|
355
|
+
hasConfigToken: false,
|
|
356
|
+
envPrompt: "",
|
|
357
|
+
keepPrompt: "",
|
|
358
|
+
inputPrompt: "Enter DingTalk Client Secret",
|
|
359
|
+
preferredEnvVar: "DINGTALK_CLIENT_SECRET",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (clientSecretResult.action === "set") {
|
|
363
|
+
clientSecret = clientSecretResult.value;
|
|
364
|
+
clientSecretProbeValue = clientSecretResult.resolvedValue;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
307
367
|
}
|
|
308
368
|
|
|
309
369
|
if (clientId && clientSecret) {
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -276,6 +276,12 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
276
276
|
// 处理媒体标记
|
|
277
277
|
let finalText = accumulatedText;
|
|
278
278
|
|
|
279
|
+
// ✅ 如果累积的文本为空,使用默认提示文案
|
|
280
|
+
if (!finalText.trim()) {
|
|
281
|
+
finalText = '✅ 任务执行完成(无文本输出)';
|
|
282
|
+
log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案`);
|
|
283
|
+
}
|
|
284
|
+
|
|
279
285
|
// 获取 oapiToken 用于媒体处理
|
|
280
286
|
const oapiToken = await getOapiAccessToken(account.config as DingtalkConfig);
|
|
281
287
|
|
|
@@ -420,7 +426,14 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
420
426
|
const hasText = Boolean(text.trim());
|
|
421
427
|
const skipTextForDuplicateFinal =
|
|
422
428
|
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
423
|
-
|
|
429
|
+
|
|
430
|
+
// ✅ 如果是 final 响应且没有文本,使用默认提示文案
|
|
431
|
+
if (info?.kind === "final" && !hasText) {
|
|
432
|
+
text = '✅ 任务执行完成(无文本输出)';
|
|
433
|
+
log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const shouldDeliverText = Boolean(text.trim()) && !skipTextForDuplicateFinal;
|
|
424
437
|
|
|
425
438
|
if (!shouldDeliverText) {
|
|
426
439
|
log.info(`[DingTalk][deliver] 跳过发送:hasText=${hasText}, skipTextForDuplicateFinal=${skipTextForDuplicateFinal}`);
|
package/src/services/media.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as fs from 'fs';
|
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import type { DingtalkConfig } from '../types/index.ts';
|
|
9
9
|
import { DINGTALK_OAPI, getOapiAccessToken } from '../utils/index.ts';
|
|
10
|
-
import { dingtalkOapiHttp } from '../utils/http-client.ts';
|
|
10
|
+
import { dingtalkHttp, dingtalkOapiHttp } from '../utils/http-client.ts';
|
|
11
11
|
|
|
12
12
|
// ============ 常量 ============
|
|
13
13
|
|
|
@@ -591,9 +591,10 @@ export async function sendMediaToDingTalk(params: {
|
|
|
591
591
|
});
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
// 1.
|
|
595
|
-
|
|
596
|
-
|
|
594
|
+
// 1. 先发送文本消息(如果有且不为空)
|
|
595
|
+
// 注意:只有在 text 有实际内容时才发送,避免发送空消息
|
|
596
|
+
if (text && text.trim().length > 0) {
|
|
597
|
+
log.info("先发送文本消息:", text);
|
|
597
598
|
await sendProactive(config, targetParam, text, {
|
|
598
599
|
msgType: "text",
|
|
599
600
|
replyToId,
|
|
@@ -726,33 +727,28 @@ export async function sendMediaToDingTalk(params: {
|
|
|
726
727
|
};
|
|
727
728
|
}
|
|
728
729
|
|
|
729
|
-
//
|
|
730
|
+
// 对于音频、文件,发送真正的文件消息
|
|
730
731
|
const fs = await import("fs");
|
|
731
732
|
const stats = fs.statSync(mediaUrl);
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
icon = "🎵";
|
|
742
|
-
typeLabel = "音频";
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
const message = `${icon} ${typeLabel}文件已上传\n\n文件: ${fileName}\n大小: ${fileSizeMB} MB\n\n下载链接: ${downloadUrl}`;
|
|
733
|
+
|
|
734
|
+
// 获取文件扩展名作为 fileType
|
|
735
|
+
const fileType = ext || "file";
|
|
736
|
+
|
|
737
|
+
// 构建文件信息
|
|
738
|
+
const fileInfo = {
|
|
739
|
+
fileName: fileName,
|
|
740
|
+
fileType: fileType,
|
|
741
|
+
};
|
|
746
742
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
});
|
|
743
|
+
// 使用 sendFileProactive 发送文件消息
|
|
744
|
+
const { sendFileProactive } = await import("./media.ts");
|
|
745
|
+
await sendFileProactive(config, targetParam, fileInfo, uploadResult.mediaId, log);
|
|
751
746
|
|
|
752
|
-
//
|
|
747
|
+
// 返回成功结果
|
|
753
748
|
return {
|
|
754
|
-
|
|
755
|
-
|
|
749
|
+
ok: true,
|
|
750
|
+
usedAICard: false,
|
|
751
|
+
processQueryKey: "file-message-sent",
|
|
756
752
|
};
|
|
757
753
|
} catch (err: any) {
|
|
758
754
|
log.error("发送媒体消息失败:", err.message);
|
|
@@ -862,8 +858,8 @@ async function sendProactiveInternal(
|
|
|
862
858
|
|
|
863
859
|
const {
|
|
864
860
|
msgType = "text",
|
|
865
|
-
useAICard =
|
|
866
|
-
fallbackToNormal =
|
|
861
|
+
useAICard = true, // 默认启用 AI Card,让主动发送消息优先使用卡片形式
|
|
862
|
+
fallbackToNormal = true, // 默认降级,AI Card 失败时自动回退到普通消息
|
|
867
863
|
log: externalLog,
|
|
868
864
|
} = options;
|
|
869
865
|
|
|
@@ -916,21 +912,19 @@ async function sendProactiveInternal(
|
|
|
916
912
|
? `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`
|
|
917
913
|
: `${DINGTALK_API}/v1.0/robot/groupMessages/send`;
|
|
918
914
|
|
|
919
|
-
//
|
|
915
|
+
// 使用 buildMsgPayload 构建消息体(支持所有消息类型)
|
|
916
|
+
const payload = buildMsgPayload(msgType, content, options.title);
|
|
917
|
+
if ("error" in payload) {
|
|
918
|
+
log.error("构建消息失败:", payload.error);
|
|
919
|
+
return { ok: false, error: payload.error, usedAICard: false };
|
|
920
|
+
}
|
|
921
|
+
|
|
920
922
|
const body: any = {
|
|
921
923
|
robotCode: config.clientId,
|
|
922
|
-
msgKey:
|
|
924
|
+
msgKey: payload.msgKey,
|
|
925
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
923
926
|
};
|
|
924
927
|
|
|
925
|
-
if (msgType === "markdown") {
|
|
926
|
-
body.msgParam = JSON.stringify({
|
|
927
|
-
title: options.title || "Message",
|
|
928
|
-
text: content,
|
|
929
|
-
});
|
|
930
|
-
} else {
|
|
931
|
-
body.msgParam = JSON.stringify({ content });
|
|
932
|
-
}
|
|
933
|
-
|
|
934
928
|
// ✅ 根据目标类型设置不同的参数
|
|
935
929
|
if (isUser) {
|
|
936
930
|
body.userIds = [targetId];
|