@dingtalk-real-ai/dingtalk-connector 0.8.1 → 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 CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.2] - 2026-03-22
9
+
10
+ ### 修复 / Fixes
11
+ - 🐛 **多账号重复启动问题** - 修复 `enabled: false` 的账号仍会建立 WebSocket 连接的问题,禁用账号现在正确保持 pending 状态直到 Gateway 停止
12
+ **Multi-account duplicate startup** - Fixed accounts with `enabled: false` still establishing WebSocket connections; disabled accounts now correctly remain in a pending state until the Gateway stops
13
+
14
+ - 🐛 **相同 clientId 账号去重** - 修复多个账号配置相同 `clientId` 时建立重复连接的问题,通过静态配置分析确保同一 `clientId` 只有列表中第一个启用账号建立连接
15
+ **Duplicate clientId deduplication** - Fixed duplicate connections when multiple accounts share the same `clientId`; static config analysis now ensures only the first enabled account per `clientId` establishes a connection
16
+
17
+
18
+ ### 改进 / Improvements
19
+ - ✅ **Onboarding 配置向导优化** - 改进钉钉连接器配置引导逻辑,调整凭据输入顺序(先 Client ID 后 Client Secret),优化引导文案
20
+ **Onboarding wizard improvement** - Improved DingTalk connector onboarding flow, adjusted credential input order (Client ID first, then Client Secret), and refined guidance text
21
+
22
+ - ✅ **会话 Key 遵循 OpenClaw 规范** - 会话上下文按 OpenClaw 标准规则构建,通过 `channel`、`accountId`、`chatType`、`peerId` 唯一标识会话,支持 `sharedMemoryAcrossConversations` 跨会话记忆共享
23
+ **Session key follows OpenClaw convention** - Session context now built per OpenClaw standard rules, uniquely identified via `channel`, `accountId`, `chatType`, `peerId`; supports `sharedMemoryAcrossConversations` for cross-conversation memory sharing
24
+
25
+ - ✅ **消息处理逻辑优化** - 重构消息处理流程,提升消息响应速度和处理可靠性,确保消息按序正确处理
26
+ **Message processing logic optimization** - Refactored message processing flow to improve response speed and reliability, ensuring messages are processed correctly in order
27
+
8
28
  ## [0.8.1] - 2026-03-20
9
29
 
10
30
  ### 修复 / Fixes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dingtalk-connector",
3
3
  "name": "DingTalk Channel",
4
- "version": "0.8.1",
4
+ "version": "0.8.2",
5
5
  "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming",
6
6
  "author": "DingTalk Real Team",
7
7
  "main": "index.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingtalk-real-ai/dingtalk-connector",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/src/channel.ts CHANGED
@@ -495,64 +495,64 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingtalkAccount> = {
495
495
  gateway: {
496
496
  startAccount: async (ctx) => {
497
497
  const account = resolveDingtalkAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
498
- const logger = createLogger(account.config?.debug ?? false, 'DingTalk:Gateway');
498
+
499
+ // 检查账号是否启用和配置
500
+ if (!account.enabled) {
501
+ ctx.log?.info?.(`dingtalk-connector[${ctx.accountId}] is disabled, skipping startup`);
502
+ // 返回一个永不 resolve 的 Promise,保持 pending 状态直到 abort
503
+ return new Promise<void>((resolve) => {
504
+ if (ctx.abortSignal?.aborted) {
505
+ resolve();
506
+ return;
507
+ }
508
+ ctx.abortSignal?.addEventListener('abort', () => resolve(), { once: true });
509
+ });
510
+ }
499
511
 
500
- logger.info(`startAccount 被调用:accountId=${ctx.accountId}`);
501
- try {
502
- logger.info('='.repeat(60));
503
- logger.info('开始加载 provider 模块...');
504
- const monitorModule = await import("./core/provider.ts");
505
- logger.info(`monitor module 加载完成`);
506
- logger.info(`monitor module keys: ${Object.keys(monitorModule).join(', ')}`);
507
- logger.info(`monitorModule 类型: ${typeof monitorModule}`);
508
- logger.info(`monitorModule 是否为 null: ${monitorModule === null}`);
509
- logger.info(`monitorModule 是否为 undefined: ${monitorModule === undefined}`);
510
-
511
- // 使用 Object.getOwnPropertyDescriptor 检查属性
512
- const descriptor = Object.getOwnPropertyDescriptor(monitorModule, 'monitorSingleAccount');
513
- logger.info(`monitorSingleAccount descriptor: ${JSON.stringify(descriptor)}`);
514
-
515
- // 尝试安全地访问 monitorSingleAccount
516
- let monitorSingleAccountType = 'unknown';
517
- try {
518
- monitorSingleAccountType = typeof monitorModule.monitorSingleAccount;
519
- } catch (e) {
520
- monitorSingleAccountType = `error: ${e.message}`;
521
- }
522
- logger.info(`monitorModule.monitorSingleAccount: ${monitorSingleAccountType}`);
523
-
524
- logger.info(`monitorModule.monitorDingtalkProvider: ${typeof monitorModule.monitorDingtalkProvider}`);
525
-
526
- // 使用直接属性访问而不是解构
527
- const monitorDingtalkProvider = monitorModule.monitorDingtalkProvider;
528
- logger.info(`解构 monitorDingtalkProvider 完成: ${typeof monitorDingtalkProvider}`);
529
-
530
- if (!monitorDingtalkProvider) {
531
- ctx.log?.error?.(`monitorDingtalkProvider 未找到!可用导出: ${Object.keys(monitorModule).join(', ')}`);
532
- throw new Error("monitorDingtalkProvider not found in monitor module");
512
+ if (!account.configured) {
513
+ throw new Error(`DingTalk account "${ctx.accountId}" is not properly configured`);
514
+ }
515
+
516
+ // 去重检查:如果列表中排在当前账号之前的账号已使用相同 clientId,则跳过当前账号
517
+ // 使用静态配置分析(而非运行时状态),避免并发竞态条件
518
+ // 规则:同一 clientId 只有列表中第一个启用且已配置的账号才会建立连接
519
+ if (account.clientId) {
520
+ const clientId = String(account.clientId);
521
+ const allAccountIds = listDingtalkAccountIds(ctx.cfg);
522
+ const currentIndex = allAccountIds.indexOf(ctx.accountId);
523
+ const priorAccountWithSameClientId = allAccountIds.slice(0, currentIndex).find((otherId) => {
524
+ const other = resolveDingtalkAccount({ cfg: ctx.cfg, accountId: otherId });
525
+ return other.enabled && other.configured && other.clientId && String(other.clientId) === clientId;
526
+ });
527
+ if (priorAccountWithSameClientId) {
528
+ ctx.log?.info?.(
529
+ `dingtalk-connector[${ctx.accountId}] skipped: clientId "${clientId.substring(0, 8)}..." is already used by account "${priorAccountWithSameClientId}"`
530
+ );
531
+ return new Promise<void>((resolve) => {
532
+ if (ctx.abortSignal?.aborted) {
533
+ resolve();
534
+ return;
535
+ }
536
+ ctx.abortSignal?.addEventListener('abort', () => resolve(), { once: true });
537
+ });
533
538
  }
534
- logger.info(`monitorDingtalkProvider 找到`);
535
-
536
- logger.info(`account 解析完成: ${account.accountId}, enabled=${account.enabled}, configured=${account.configured}`);
537
-
538
- ctx.setStatus({ accountId: ctx.accountId, port: null });
539
- ctx.log?.info(
540
- `starting dingtalk-connector[${ctx.accountId}] (mode: stream)`,
541
- );
542
- logger.info(`准备调用 monitorDingtalkProvider`);
543
-
544
- const result = await monitorDingtalkProvider({
539
+ }
540
+
541
+ ctx.setStatus({ accountId: ctx.accountId, port: null });
542
+ ctx.log?.info(
543
+ `starting dingtalk-connector[${ctx.accountId}] (mode: stream)`,
544
+ );
545
+ try {
546
+ return await monitorDingtalkProvider({
545
547
  config: ctx.cfg,
546
548
  runtime: ctx.runtime,
547
549
  abortSignal: ctx.abortSignal,
548
550
  accountId: ctx.accountId,
549
551
  });
550
- logger.info(`monitorDingtalkProvider 调用完成`);
551
- return result;
552
- } catch (error) {
553
- ctx.log?.error?.(`startAccount 发生错误: ${error.message}`);
554
- ctx.log?.error?.(`错误堆栈: ${error.stack}`);
555
- throw error;
552
+ } catch (err: any) {
553
+ // 打印真实错误到 stderr,绕过框架 log 系统(框架的 runtime.log 可能未初始化)
554
+ ctx.log?.error(`[dingtalk-connector][${ctx.accountId}] startAccount error:`, err?.message ?? err, err?.stack);
555
+ throw err;
556
556
  }
557
557
  },
558
558
  },
@@ -222,9 +222,21 @@ export function resolveDingtalkAccount(params: {
222
222
 
223
223
  /**
224
224
  * List all enabled and configured accounts.
225
+ * Deduplicates by clientId to avoid creating multiple connections with the same credentials.
225
226
  */
226
227
  export function listEnabledDingtalkAccounts(cfg: ClawdbotConfig): ResolvedDingtalkAccount[] {
227
- return listDingtalkAccountIds(cfg)
228
+ const accounts = listDingtalkAccountIds(cfg)
228
229
  .map((accountId) => resolveDingtalkAccount({ cfg, accountId }))
229
230
  .filter((account) => account.enabled && account.configured);
231
+
232
+ // Deduplicate by clientId to avoid multiple connections with same credentials
233
+ const seen = new Set<string>();
234
+ return accounts.filter((account) => {
235
+ if (!account.clientId) return true;
236
+ if (seen.has(account.clientId)) {
237
+ return false;
238
+ }
239
+ seen.add(account.clientId);
240
+ return true;
241
+ });
230
242
  }
@@ -13,7 +13,7 @@
13
13
  * - 连接统计和监控(每分钟输出)
14
14
  */
15
15
  import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
16
- import type { MonitorDingtalkAccountOpts, ResolvedDingtalkAccount } from "../types/index.ts";
16
+ import type { ResolvedDingtalkAccount } from "../types/index.ts";
17
17
  import {
18
18
  isMessageProcessed,
19
19
  markMessageProcessed,
@@ -30,14 +30,6 @@ export type DingtalkReactionCreatedEvent = {
30
30
  emoji: string;
31
31
  };
32
32
 
33
- export type MonitorDingtalkAccountOpts = {
34
- cfg: ClawdbotConfig;
35
- account: ResolvedDingtalkAccount;
36
- runtime?: RuntimeEnv;
37
- abortSignal?: AbortSignal;
38
- messageHandler: MessageHandler; // 直接传入消息处理器
39
- };
40
-
41
33
  // 消息处理器函数类型
42
34
  export type MessageHandler = (params: {
43
35
  accountId: string;
@@ -49,6 +41,14 @@ export type MessageHandler = (params: {
49
41
  cfg: ClawdbotConfig;
50
42
  }) => Promise<void>;
51
43
 
44
+ export type MonitorDingtalkAccountOpts = {
45
+ cfg: ClawdbotConfig;
46
+ account: ResolvedDingtalkAccount;
47
+ runtime?: RuntimeEnv;
48
+ abortSignal?: AbortSignal;
49
+ messageHandler: MessageHandler;
50
+ };
51
+
52
52
  // ============ 连接配置 ============
53
53
 
54
54
  /** 心跳间隔(毫秒) */
@@ -272,7 +272,7 @@ export async function monitorSingleAccount(
272
272
  logger.info(`✅ 重连成功 (socket 状态=${client.socket?.readyState})`);
273
273
  } catch (err: any) {
274
274
  reconnectAttempts++;
275
- log?.error?.(
275
+ logger.error(
276
276
  `重连失败:${err.message} (尝试 ${reconnectAttempts})`,
277
277
  );
278
278
  throw err;
@@ -298,7 +298,7 @@ export async function monitorSingleAccount(
298
298
  if (!isStopped && !isReconnecting) {
299
299
  // 立即重连,不退避
300
300
  doReconnect(true).catch((err) => {
301
- log?.error?.(`[${accountId}] 重连失败:${err.message}`);
301
+ logger.error(`[${accountId}] 重连失败:${err.message}`);
302
302
  });
303
303
  }
304
304
  }
@@ -322,7 +322,7 @@ export async function monitorSingleAccount(
322
322
  // 立即重连,不退避
323
323
  setTimeout(() => {
324
324
  doReconnect(true).catch((err) => {
325
- log?.error?.(`重连失败:${err.message}`);
325
+ logger.error(`重连失败:${err.message}`);
326
326
  });
327
327
  }, 0);
328
328
  });
@@ -388,11 +388,11 @@ export async function monitorSingleAccount(
388
388
  lastSocketAvailableTime = Date.now();
389
389
  logger.debug(`💓 发送 PING 心跳成功`);
390
390
  } catch (err: any) {
391
- log?.warn?.(`发送 PING 失败:${err.message}`);
391
+ logger.warn(`发送 PING 失败:${err.message}`);
392
392
  // 发送失败也计入超时
393
393
  }
394
394
  } catch (err: any) {
395
- log?.error?.(`keepAlive 检测失败:${err.message}`);
395
+ logger.error(`keepAlive 检测失败:${err.message}`);
396
396
  }
397
397
  }, HEARTBEAT_INTERVAL); // 每 10 秒检测一次
398
398
 
@@ -445,7 +445,7 @@ export async function monitorSingleAccount(
445
445
  await client.disconnect();
446
446
  }
447
447
  } catch (err: any) {
448
- log?.warn?.(`断开连接时出错:${err.message}`);
448
+ logger.warn(`断开连接时出错:${err.message}`);
449
449
  }
450
450
  resolve();
451
451
  };
@@ -486,12 +486,13 @@ export async function monitorSingleAccount(
486
486
  client.socketCallBackResponse(messageId, { success: true });
487
487
  logger.info(`✅ 已立即确认回调:messageId=${messageId}`);
488
488
  } else {
489
- log?.warn?.(`⚠️ 警告:消息没有 messageId`);
489
+ logger.warn(`⚠️ 警告:消息没有 messageId`);
490
490
  }
491
491
 
492
492
  // 消息去重
493
493
  if (messageId && isMessageProcessed(messageId)) {
494
- log?.warn?.(`⚠️ 检测到重复消息,跳过处理:messageId=${messageId}`);
494
+ processedCount++; // ✅ 修复:重复消息也要计入 processedCount
495
+ logger.warn(`⚠️ 检测到重复消息,跳过处理:messageId=${messageId} (${processedCount}/${receivedCount})`);
495
496
  logger.info(`========== 消息处理结束(重复) ==========\n`);
496
497
  return;
497
498
  }
@@ -579,13 +580,9 @@ export async function monitorSingleAccount(
579
580
  const errorMsg = `❌ 处理消息异常 (${processedCount}/${receivedCount}): ${error?.message || "未知错误"}`;
580
581
  const errorStack = error?.stack || "无堆栈信息";
581
582
 
582
- // 使用 logger 确保错误信息一定会被打印
583
- logger.info(errorMsg);
584
- logger.info(`错误堆栈:\n${errorStack}`);
585
-
586
- // 同时使用 log?.error 记录(如果可用)
587
- log?.error?.(errorMsg);
588
- log?.error?.(`错误堆栈:\n${errorStack}`);
583
+ // 使用 logger 记录错误信息
584
+ logger.error(errorMsg);
585
+ logger.error(`错误堆栈:\n${errorStack}`);
589
586
 
590
587
  logger.info(`========== 消息处理结束(失败) ==========\n`);
591
588
  } finally {
@@ -672,7 +669,7 @@ export async function monitorSingleAccount(
672
669
  // client.on('close', ...) - 已移除,使用 setupCloseListener
673
670
 
674
671
  client.on("error", (err: Error) => {
675
- log?.error?.(`Connection error: ${err.message}`);
672
+ logger.error(`Connection error: ${err.message}`);
676
673
  });
677
674
 
678
675
  // 监听重连事件(仅用于日志,实际重连由自定义逻辑处理)
@@ -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';
@@ -537,7 +538,10 @@ interface HandleMessageParams {
537
538
  * 内部消息处理函数(实际执行消息处理逻辑)
538
539
  */
539
540
  export async function handleDingTalkMessageInternal(params: HandleMessageParams): Promise<void> {
540
- const { accountId, config, data, sessionWebhook, runtime, log, cfg } = params;
541
+ const { accountId, config, data, sessionWebhook, runtime, log: inputLog, cfg } = params;
542
+
543
+ // 如果传入的 log 为空,则使用基于 config 的 logger
544
+ const log = createLoggerFromConfig(config, `DingTalk:${accountId}`);
541
545
 
542
546
  const content = extractMessageContent(data);
543
547
  if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return;
@@ -546,32 +550,7 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
546
550
  const senderId = data.senderStaffId || data.senderId;
547
551
  const senderName = data.senderNick || 'Unknown';
548
552
 
549
- // ===== 提前解析 agentId 和工作空间路径 =====
550
- const chatType = isDirect ? "direct" : "group";
551
- const peerId = isDirect ? senderId : data.conversationId;
552
-
553
- // 手动匹配 bindings 获取 agentId
554
- let matchedAgentId: string | null = null;
555
- if (cfg.bindings && cfg.bindings.length > 0) {
556
- for (const binding of cfg.bindings) {
557
- const match = binding.match;
558
- if (match.channel && match.channel !== "dingtalk-connector") continue;
559
- if (match.accountId && match.accountId !== accountId) continue;
560
- if (match.peer) {
561
- if (match.peer.kind && match.peer.kind !== chatType) continue;
562
- if (match.peer.id && match.peer.id !== '*' && match.peer.id !== peerId) continue;
563
- }
564
- matchedAgentId = binding.agentId;
565
- break;
566
- }
567
- }
568
- if (!matchedAgentId) {
569
- matchedAgentId = cfg.defaultAgent || 'main';
570
- }
571
-
572
- // 获取 Agent 工作空间路径
573
- const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, matchedAgentId);
574
- log?.info?.(`Agent 工作空间路径: ${agentWorkspaceDir}`);
553
+
575
554
 
576
555
 
577
556
 
@@ -716,6 +695,29 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
716
695
  sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
717
696
  });
718
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}`);
719
721
 
720
722
  // 构建消息内容
721
723
  // ✅ 使用 normalizeSlashCommand 归一化新会话命令
@@ -940,65 +942,24 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
940
942
  body: finalContent,
941
943
  });
942
944
 
943
- // 手动实现路由匹配(支持通配符 *)
944
- const chatType = isDirect ? "direct" : "group";
945
- const peerId = isDirect ? senderId : data.conversationId;
946
-
947
- // 手动匹配 bindings(支持通配符 *)
948
- let matchedAgentId: string | null = null;
949
- let matchedBy = 'default';
950
-
951
- if (cfg.bindings && cfg.bindings.length > 0) {
952
- for (const binding of cfg.bindings) {
953
- const match = binding.match;
954
-
955
- // 检查 channel
956
- if (match.channel && match.channel !== "dingtalk-connector") {
957
- continue;
958
- }
959
-
960
- // 检查 accountId
961
- if (match.accountId && match.accountId !== accountId) {
962
- continue;
963
- }
964
-
965
- // 检查 peer
966
- if (match.peer) {
967
- // 检查 peer.kind
968
- if (match.peer.kind && match.peer.kind !== sessionContext.chatType) {
969
- continue;
970
- }
971
-
972
- // ✅ 使用 sessionContext.peerId 进行匹配(支持通配符 *)
973
- if (match.peer.id && match.peer.id !== '*' && match.peer.id !== sessionContext.peerId) {
974
- continue;
975
- }
976
- }
977
-
978
- // 匹配成功
979
- matchedAgentId = binding.agentId;
980
- matchedBy = 'binding';
981
- break;
982
- }
983
- }
984
-
985
- // 如果没有匹配到,使用默认 agent
986
- if (!matchedAgentId) {
987
- matchedAgentId = cfg.defaultAgent || 'main';
988
- log?.info?.(`未匹配到 binding,使用默认 agent: ${matchedAgentId}`);
989
- }
945
+ // matchedAgentId 已在 sessionContext 构建之后通过 bindings 匹配确定,此处直接使用
946
+ const matchedBy = matchedAgentId !== (cfg.defaultAgent || 'main') ? 'binding' : 'default';
990
947
 
991
948
  // ✅ 使用 SDK 标准方法构建 sessionKey,符合 OpenClaw 规范
992
949
  // 格式:agent:{agentId}:{channel}:{peerKind}:{peerId}
993
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}`);
994
954
  const sessionKey = core.channel.routing.buildAgentSessionKey({
995
955
  agentId: matchedAgentId,
996
- channel: 'dingtalk', // ✅ 使用 'dingtalk' 而不是 'dingtalk-connector'
956
+ channel: 'dingtalk-connector', // ✅ 使用 'dingtalk-connector' 而不是 'dingtalk'
997
957
  accountId: accountId,
998
958
  peer: {
999
959
  kind: sessionContext.chatType, // ✅ 使用 sessionContext.chatType
1000
960
  id: sessionContext.peerId, // ✅ 使用 sessionContext.peerId(包含会话隔离逻辑)
1001
961
  },
962
+ dmScope: dmScope, // ✅ 传递 dmScope 参数,确保生成完整格式的 sessionKey
1002
963
  });
1003
964
  log?.info?.(`路由解析完成: agentId=${matchedAgentId}, sessionKey=${sessionKey}, matchedBy=${matchedBy}`);
1004
965
 
@@ -1018,7 +979,7 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1018
979
  To: toField, // ✅ 修复:单聊用 senderId,群聊用 conversationId
1019
980
  SessionKey: sessionKey, // ✅ 使用手动匹配的 sessionKey
1020
981
  AccountId: accountId,
1021
- ChatType: chatType,
982
+ ChatType: sessionContext.chatType,
1022
983
  GroupSubject: isDirect ? undefined : data.conversationId,
1023
984
  SenderName: senderId,
1024
985
  SenderId: senderId,
@@ -1201,23 +1162,33 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1201
1162
  * 确保同一会话+agent的消息按顺序处理,避免并发冲突
1202
1163
  */
1203
1164
  export async function handleDingTalkMessage(params: HandleMessageParams): Promise<void> {
1204
- const { accountId, data, log, cfg } = params;
1165
+ const { accountId, config, data, log, cfg } = params;
1205
1166
 
1206
- // 构建会话标识(与会话上下文保持一致)
1167
+ // 使用 buildSessionContext 构建会话标识,与 handleDingTalkMessageInternal 保持一致
1168
+ // 确保 queueKey 的隔离策略(groupSessionScope、sharedMemoryAcrossConversations)与 sessionKey 一致
1207
1169
  const isDirect = data.conversationType === '1';
1208
1170
  const senderId = data.senderStaffId || data.senderId;
1209
1171
  const conversationId = data.conversationId;
1210
- const baseSessionId = isDirect ? senderId : conversationId;
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;
1211
1184
 
1212
1185
  if (!baseSessionId) {
1213
1186
  log?.warn?.('无法构建会话标识,跳过队列管理');
1214
1187
  return handleDingTalkMessageInternal(params);
1215
1188
  }
1216
-
1217
- // 解析 agentId(与消息处理逻辑保持一致)
1218
- const chatType = isDirect ? "direct" : "group";
1219
- const peerId = isDirect ? senderId : conversationId;
1220
1189
 
1190
+ // 解析 agentId:使用 sessionContext.peerId 和 sessionContext.chatType 进行匹配
1191
+ // 与 handleDingTalkMessageInternal 中的匹配逻辑保持一致
1221
1192
  let matchedAgentId: string | null = null;
1222
1193
  if (cfg.bindings && cfg.bindings.length > 0) {
1223
1194
  for (const binding of cfg.bindings) {
@@ -1225,8 +1196,8 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
1225
1196
  if (match.channel && match.channel !== "dingtalk-connector") continue;
1226
1197
  if (match.accountId && match.accountId !== accountId) continue;
1227
1198
  if (match.peer) {
1228
- if (match.peer.kind && match.peer.kind !== chatType) continue;
1229
- 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;
1230
1201
  }
1231
1202
  matchedAgentId = binding.agentId;
1232
1203
  break;
@@ -1235,9 +1206,11 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
1235
1206
  if (!matchedAgentId) {
1236
1207
  matchedAgentId = cfg.defaultAgent || 'main';
1237
1208
  }
1238
-
1239
- // 构建队列标识:会话 + agentId
1240
- // 这样不同 agent 可以并发处理,同一 agent 的同一会话串行处理
1209
+
1210
+ // 构建队列标识:会话 peerId + agentId
1211
+ // queueKey sessionKey 使用相同的 peerId,确保隔离策略一致:
1212
+ // - groupSessionScope: 'group_sender' 时,同群不同用户的消息可并行处理
1213
+ // - sharedMemoryAcrossConversations: true 时,所有消息共享同一队列
1241
1214
  const queueKey = `${baseSessionId}:${matchedAgentId}`;
1242
1215
 
1243
1216
  try {
@@ -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) {
@@ -858,8 +858,8 @@ async function sendProactiveInternal(
858
858
 
859
859
  const {
860
860
  msgType = "text",
861
- useAICard = false,
862
- fallbackToNormal = false,
861
+ useAICard = true, // 默认启用 AI Card,让主动发送消息优先使用卡片形式
862
+ fallbackToNormal = true, // 默认降级,AI Card 失败时自动回退到普通消息
863
863
  log: externalLog,
864
864
  } = options;
865
865
 
@@ -69,10 +69,10 @@ export function createLogger(debug: boolean = false, prefix: string = '') {
69
69
 
70
70
  /**
71
71
  * 从配置中创建日志记录器
72
- * @param config - 包含 debug 配置的对象
72
+ * @param config - 包含 debug 配置的对象(可选)
73
73
  * @param prefix - 日志前缀
74
74
  * @returns 日志记录器对象
75
75
  */
76
- export function createLoggerFromConfig(config: { debug?: boolean }, prefix: string = '') {
77
- return createLogger(!!config.debug, prefix);
76
+ export function createLoggerFromConfig(config: { debug?: boolean } | undefined | null, prefix: string = '') {
77
+ return createLogger(!!config?.debug, prefix);
78
78
  }
@@ -49,7 +49,6 @@ export function buildSessionContext(params: {
49
49
  const isDirect = conversationType === '1';
50
50
 
51
51
  // sharedMemoryAcrossConversations=true 时,所有会话共享记忆
52
- // 通过将 peerId 设置为 accountId 来实现跨会话记忆共享
53
52
  if (sharedMemoryAcrossConversations === true) {
54
53
  return {
55
54
  channel: 'dingtalk-connector',
@@ -62,17 +61,20 @@ export function buildSessionContext(params: {
62
61
  };
63
62
  }
64
63
 
64
+ // separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session
65
65
  if (separateSessionByConversation === false) {
66
66
  return {
67
67
  channel: 'dingtalk-connector',
68
68
  accountId,
69
69
  chatType: isDirect ? 'direct' : 'group',
70
- peerId: senderId,
70
+ peerId: senderId, // 只用 senderId,不区分会话
71
71
  senderName,
72
72
  };
73
73
  }
74
74
 
75
+ // 以下是 separateSessionByConversation=true(默认)的逻辑
75
76
  if (isDirect) {
77
+ // 单聊:peerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
76
78
  return {
77
79
  channel: 'dingtalk-connector',
78
80
  accountId,
@@ -82,7 +84,9 @@ export function buildSessionContext(params: {
82
84
  };
83
85
  }
84
86
 
87
+ // 群聊:根据 groupSessionScope 配置决定会话隔离策略
85
88
  if (groupSessionScope === 'group_sender') {
89
+ // 群内每个用户独立会话
86
90
  return {
87
91
  channel: 'dingtalk-connector',
88
92
  accountId,
@@ -94,6 +98,7 @@ export function buildSessionContext(params: {
94
98
  };
95
99
  }
96
100
 
101
+ // 默认:整个群共享一个会话
97
102
  return {
98
103
  channel: 'dingtalk-connector',
99
104
  accountId,
@@ -4,6 +4,10 @@
4
4
 
5
5
  import type { DingtalkConfig, ResolvedDingtalkAccount } from '../types/index.ts';
6
6
 
7
+ // SessionContext 和 buildSessionContext 统一由 session.ts 维护
8
+ export type { SessionContext } from './session.ts';
9
+ export { buildSessionContext } from './session.ts';
10
+
7
11
  // ============ 常量 ============
8
12
 
9
13
  /** 默认账号 ID,用于标记单账号模式(无 accounts 配置)时的内部标识 */
@@ -18,107 +22,6 @@ export const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
18
22
 
19
23
  // ============ 会话管理 ============
20
24
 
21
- /** OpenClaw 标准会话上下文 */
22
- export interface SessionContext {
23
- channel: 'dingtalk-connector';
24
- accountId: string;
25
- chatType: 'direct' | 'group';
26
- peerId: string;
27
- conversationId?: string;
28
- senderName?: string;
29
- groupSubject?: string;
30
- }
31
-
32
- /**
33
- * 构建 OpenClaw 标准会话上下文
34
- * 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离
35
- */
36
- export function buildSessionContext(params: {
37
- accountId: string;
38
- senderId: string;
39
- senderName?: string;
40
- conversationType: string;
41
- conversationId?: string;
42
- groupSubject?: string;
43
- separateSessionByConversation?: boolean;
44
- groupSessionScope?: 'group' | 'group_sender';
45
- sharedMemoryAcrossConversations?: boolean;
46
- }): SessionContext {
47
- const {
48
- accountId,
49
- senderId,
50
- senderName,
51
- conversationType,
52
- conversationId,
53
- groupSubject,
54
- separateSessionByConversation,
55
- groupSessionScope,
56
- sharedMemoryAcrossConversations,
57
- } = params;
58
- const isDirect = conversationType === '1';
59
-
60
- // sharedMemoryAcrossConversations=true 时,所有会话共享记忆
61
- if (sharedMemoryAcrossConversations === true) {
62
- return {
63
- channel: 'dingtalk-connector',
64
- accountId,
65
- chatType: isDirect ? 'direct' : 'group',
66
- peerId: accountId, // 使用 accountId 作为 peerId,实现跨会话记忆共享
67
- conversationId: isDirect ? undefined : conversationId,
68
- senderName,
69
- groupSubject: isDirect ? undefined : groupSubject,
70
- };
71
- }
72
-
73
- // separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session
74
- if (separateSessionByConversation === false) {
75
- return {
76
- channel: 'dingtalk-connector',
77
- accountId,
78
- chatType: isDirect ? 'direct' : 'group',
79
- peerId: senderId, // 只用 senderId,不区分会话
80
- senderName,
81
- };
82
- }
83
-
84
- // 以下是 separateSessionByConversation=true(默认)的逻辑
85
- if (isDirect) {
86
- // 单聊:peerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
87
- return {
88
- channel: 'dingtalk-connector',
89
- accountId,
90
- chatType: 'direct',
91
- peerId: senderId,
92
- senderName,
93
- };
94
- }
95
-
96
- // 群聊:根据 groupSessionScope 配置决定会话隔离策略
97
- if (groupSessionScope === 'group_sender') {
98
- // 群内每个用户独立会话
99
- return {
100
- channel: 'dingtalk-connector',
101
- accountId,
102
- chatType: 'group',
103
- peerId: `${conversationId}:${senderId}`,
104
- conversationId,
105
- senderName,
106
- groupSubject,
107
- };
108
- }
109
-
110
- // 默认:整个群共享一个会话
111
- return {
112
- channel: 'dingtalk-connector',
113
- accountId,
114
- chatType: 'group',
115
- peerId: conversationId || senderId,
116
- conversationId,
117
- senderName,
118
- groupSubject,
119
- };
120
- }
121
-
122
25
  /**
123
26
  * 检查消息是否是新会话命令
124
27
  */
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * 钉钉连接测试脚本
5
- * 用于诊断 "HTTP request sent to HTTPS port" 问题
6
- */
7
-
8
- import axios from 'axios';
9
- import https from 'https';
10
- import http from 'http';
11
-
12
- console.log('='.repeat(60));
13
- console.log('钉钉连接诊断测试');
14
- console.log('='.repeat(60));
15
- console.log('');
16
-
17
- // 1. 检查环境变量
18
- console.log('1. 环境变量检查');
19
- console.log('---');
20
- console.log(`HTTP_PROXY: ${process.env.HTTP_PROXY || '未设置'}`);
21
- console.log(`HTTPS_PROXY: ${process.env.HTTPS_PROXY || '未设置'}`);
22
- console.log(`http_proxy: ${process.env.http_proxy || '未设置'}`);
23
- console.log(`https_proxy: ${process.env.https_proxy || '未设置'}`);
24
- console.log(`NO_PROXY: ${process.env.NO_PROXY || '未设置'}`);
25
- console.log('');
26
-
27
- // 2. 检查 Node.js 版本
28
- console.log('2. Node.js 版本');
29
- console.log('---');
30
- console.log(`Node.js: ${process.version}`);
31
- console.log('');
32
-
33
- // 3. 测试 axios 默认配置
34
- console.log('3. axios 默认配置');
35
- console.log('---');
36
- console.log(`axios.defaults.proxy: ${JSON.stringify(axios.defaults.proxy || '未设置')}`);
37
- console.log(`axios.defaults.httpAgent: ${axios.defaults.httpAgent ? '已设置' : '未设置'}`);
38
- console.log(`axios.defaults.httpsAgent: ${axios.defaults.httpsAgent ? '已设置' : '未设置'}`);
39
- console.log('');
40
-
41
- // 4. 测试直接请求钉钉 API
42
- console.log('4. 测试钉钉 Gateway API');
43
- console.log('---');
44
-
45
- const GATEWAY_URL = 'https://api.dingtalk.com/v1.0/gateway/connections/open';
46
-
47
- try {
48
- console.log(`请求 URL: ${GATEWAY_URL}`);
49
- console.log('发送 POST 请求...');
50
-
51
- const response = await axios({
52
- url: GATEWAY_URL,
53
- method: 'POST',
54
- responseType: 'json',
55
- data: {
56
- clientId: 'test',
57
- clientSecret: 'test',
58
- ua: 'test',
59
- subscriptions: [{ type: 'EVENT', topic: '*' }]
60
- },
61
- headers: {
62
- 'Accept': 'application/json'
63
- },
64
- // 显式设置 HTTPS agent
65
- httpsAgent: new https.Agent({
66
- rejectUnauthorized: true
67
- }),
68
- // 确保不使用 HTTP agent
69
- httpAgent: undefined,
70
- // 禁用代理
71
- proxy: false,
72
- timeout: 10000
73
- });
74
-
75
- console.log(`✅ 请求成功!`);
76
- console.log(`状态码: ${response.status}`);
77
- console.log(`响应数据: ${JSON.stringify(response.data).substring(0, 200)}...`);
78
- } catch (error) {
79
- console.log(`❌ 请求失败!`);
80
- console.log(`错误类型: ${error.constructor.name}`);
81
- console.log(`错误消息: ${error.message}`);
82
-
83
- if (error.response) {
84
- console.log(`响应状态码: ${error.response.status}`);
85
- console.log(`响应数据: ${typeof error.response.data === 'string' ? error.response.data.substring(0, 500) : JSON.stringify(error.response.data)}`);
86
- }
87
-
88
- if (error.code) {
89
- console.log(`错误代码: ${error.code}`);
90
- }
91
-
92
- if (error.config) {
93
- console.log(`请求配置:`);
94
- console.log(` - URL: ${error.config.url}`);
95
- console.log(` - Method: ${error.config.method}`);
96
- console.log(` - Proxy: ${JSON.stringify(error.config.proxy)}`);
97
- console.log(` - httpAgent: ${error.config.httpAgent ? '已设置' : '未设置'}`);
98
- console.log(` - httpsAgent: ${error.config.httpsAgent ? '已设置' : '未设置'}`);
99
- }
100
- }
101
-
102
- console.log('');
103
- console.log('='.repeat(60));
104
- console.log('诊断完成');
105
- console.log('='.repeat(60));