@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.
@@ -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
- log?.error?.(`文件下载失败: ${fileName}, error=${err.message}`);
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
- // ===== 提前解析 agentId 和工作空间路径 =====
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 chatType = isDirect ? "direct" : "group";
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-connector'
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
- const baseSessionId = isDirect ? senderId : conversationId;
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
- const chatType = isDirect ? "direct" : "group";
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
- // 这样不同 agent 可以并发处理,同一 agent 的同一会话串行处理
1209
+
1210
+ // 构建队列标识:会话 peerId + agentId
1211
+ // queueKey sessionKey 使用相同的 peerId,确保隔离策略一致:
1212
+ // - groupSessionScope: 'group_sender' 时,同群不同用户的消息可并行处理
1213
+ // - sharedMemoryAcrossConversations: true 时,所有消息共享同一队列
1230
1214
  const queueKey = `${baseSessionId}:${matchedAgentId}`;
1231
-
1232
- // 更新会话活跃时间
1233
- sessionLastActivity.set(queueKey, Date.now());
1234
-
1235
- // 获取该会话+agent的上一个处理任务
1236
- const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
1237
-
1238
- // 创建当前消息的处理任务
1239
- const currentTask = previousTask
1240
- .then(async () => {
1241
- log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
1242
- await handleDingTalkMessageInternal(params);
1243
- log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
1244
- })
1245
- .catch((err: any) => {
1246
- log?.error?.(`[队列] 消息处理异常,queueKey=${queueKey}, error=${err.message}`);
1247
- // 不抛出错误,避免阻塞后续消息
1248
- })
1249
- .finally(() => {
1250
- // 如果当前任务是队列中的最后一个任务,清理队列
1251
- if (sessionQueues.get(queueKey) === currentTask) {
1252
- sessionQueues.delete(queueKey);
1253
- log?.info?.(`[队列] 队列已清空,queueKey=${queueKey}`);
1254
- }
1255
- });
1256
-
1257
- // 更新队列
1258
- sessionQueues.set(queueKey, currentTask);
1259
- log?.info?.(`[队列] 消息已加入队列,queueKey=${queueKey}, 队列大小=${sessionQueues.size}`);
1260
-
1261
- // 不等待任务完成,让消息异步处理
1262
- // 这样可以立即返回,不阻塞 WebSocket 消息接收
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 已在函数定义处直接导出
@@ -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 = opts.runtime?.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 App Key (Client ID) and App Secret (Client Secret) from Credentials page",
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 App Key (Client ID)",
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
- const clientSecretResult = await promptSingleChannelSecretInput({
278
- cfg: next,
279
- prompter,
280
- providerHint: "dingtalk",
281
- credentialLabel: "App Secret (Client Secret)",
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) {
@@ -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
- const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
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}`);
@@ -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
- if (text?.trim()) {
596
- log.info("先发送文本消息");
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
- const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
733
-
734
- // 使用上传结果中的下载链接
735
- const downloadUrl = uploadResult.downloadUrl;
736
-
737
- // 根据媒体类型选择图标和描述
738
- let icon = "📄";
739
- let typeLabel = "文件";
740
- if (mediaType === "voice") {
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
- const result = await sendProactive(config, targetParam, message, {
748
- msgType: "text",
749
- replyToId,
750
- });
743
+ // 使用 sendFileProactive 发送文件消息
744
+ const { sendFileProactive } = await import("./media.ts");
745
+ await sendFileProactive(config, targetParam, fileInfo, uploadResult.mediaId, log);
751
746
 
752
- // 确保返回值中有 processQueryKey,告诉 SDK 消息已发送成功
747
+ // 返回成功结果
753
748
  return {
754
- ...result,
755
- processQueryKey: result.processQueryKey || "media-message-sent",
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 = false,
866
- fallbackToNormal = false,
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: msgType === "markdown" ? "sampleMarkdown" : "sampleText",
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];