@dingtalk-real-ai/dingtalk-connector 0.8.6 → 0.8.7

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,21 @@ 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.7] - 2026-03-26
9
+
10
+ ### 修复 / Fixes
11
+ - 🐛 **账号 ID 大小写敏感修复** - 修复 `normalizeAccountId` 函数强制将账号 ID 转为小写(`.toLowerCase()`)导致驼峰命名账号(如 `zhizaoDashuIP`)无法匹配配置的问题。现在账号 ID 仅做 `trim()` 处理,保留原始大小写,与配置文件中的 key 严格匹配
12
+ **Account ID case-sensitivity fix** - Fixed `normalizeAccountId` forcibly lowercasing account IDs, which caused camelCase account IDs (e.g., `zhizaoDashuIP`) to fail configuration lookup. Account IDs are now only trimmed, preserving original casing for strict matching against config keys
13
+
14
+ - 🐛 **WebSocket 连接代理控制统一** - 修复 `src/core/connection.ts` 中 WebSocket 连接未遵循 `DINGTALK_FORCE_PROXY` 环境变量的问题,现在与 HTTP 请求保持一致的代理控制逻辑
15
+ **Unified proxy control for WebSocket connections** - Fixed WebSocket connections not respecting the `DINGTALK_FORCE_PROXY` environment variable; proxy control is now consistent with HTTP requests
16
+
17
+ - 🐛 **媒体下载代理控制统一** - 修复 `src/core/message-handler.ts` 中图片/文件下载时代理配置与 HTTP 客户端不一致的问题,确保所有媒体下载请求统一遵循代理控制策略
18
+ **Unified proxy control for media downloads** - Fixed inconsistent proxy configuration for image/file downloads; all media download requests now follow the unified proxy control policy
19
+
20
+ - 🐛 **多账号消息去重误判** - 修复多账号(多机器人)场景下,同一条群消息 @多个机器人时,第二个机器人因去重缓存未按账号隔离,误将消息判定为重复而跳过处理的问题。`checkAndMarkDingtalkMessage` 的去重 key 现在带有 `accountId` 前缀,不同机器人账号的去重缓存完全隔离
21
+ **Multi-account message deduplication false positive** - Fixed an issue where a group message mentioning multiple bots caused the second bot to skip processing due to a shared deduplication cache. The deduplication key now includes an `accountId` prefix, fully isolating each bot's cache
22
+
8
23
  ## [0.8.6] - 2026-03-24
9
24
 
10
25
  ### 改进 / Improvements
package/install-npm.sh ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # install-npm.sh
4
+ # 将 dingtalk-connector 插件从本地 git 路径安装切换到 npm 包安装
5
+ #
6
+ # 流程:
7
+ # 1. 备份当前 openclaw.json 配置文件
8
+ # 2. 写入最小化干净配置(让 openclaw plugins 命令能正常运行)
9
+ # 3. 卸载旧插件(清除本地路径安装记录)
10
+ # 4. 从 npm 安装最新版插件
11
+ # 5. 恢复备份的配置文件(保留用户的所有业务配置)
12
+ # =============================================================================
13
+
14
+ set -euo pipefail
15
+
16
+ # ============ 常量 ============
17
+ OPENCLAW_CONFIG="$HOME/.openclaw/openclaw.json"
18
+ PLUGIN_NAME="dingtalk-connector"
19
+ NPM_PACKAGE="@dingtalk-real-ai/dingtalk-connector"
20
+ TIMESTAMP=$(date +%Y%m%d_%H%M%S)
21
+ BACKUP_FILE="${OPENCLAW_CONFIG}.migrate_backup.${TIMESTAMP}"
22
+
23
+ # ============ 颜色输出 ============
24
+ RED='\033[0;31m'
25
+ GREEN='\033[0;32m'
26
+ YELLOW='\033[1;33m'
27
+ BLUE='\033[0;34m'
28
+ NC='\033[0m' # No Color
29
+
30
+ log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
31
+ log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
32
+ log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
33
+ log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
34
+
35
+ # ============ 错误恢复 ============
36
+ # 如果脚本中途失败,自动恢复备份
37
+ restore_on_error() {
38
+ if [[ -f "$BACKUP_FILE" ]]; then
39
+ log_warn "脚本异常退出,正在恢复备份配置..."
40
+ cp "$BACKUP_FILE" "$OPENCLAW_CONFIG"
41
+ log_warn "已恢复备份: $BACKUP_FILE"
42
+ fi
43
+ }
44
+ trap restore_on_error ERR
45
+
46
+ # ============ 前置检查 ============
47
+ echo ""
48
+ echo "============================================================"
49
+ echo " DingTalk Connector — 切换到 npm 安装"
50
+ echo "============================================================"
51
+ echo ""
52
+
53
+ if [[ ! -f "$OPENCLAW_CONFIG" ]]; then
54
+ log_error "找不到 OpenClaw 配置文件: $OPENCLAW_CONFIG"
55
+ exit 1
56
+ fi
57
+
58
+ if ! command -v openclaw &>/dev/null; then
59
+ log_error "找不到 openclaw 命令,请确认已安装 OpenClaw CLI"
60
+ exit 1
61
+ fi
62
+
63
+ # ============ 步骤 1:备份配置文件 ============
64
+ log_info "步骤 1/5:备份当前配置文件..."
65
+ cp "$OPENCLAW_CONFIG" "$BACKUP_FILE"
66
+ log_success "备份已保存至: $BACKUP_FILE"
67
+
68
+ # ============ 步骤 2:写入最小化干净配置 ============
69
+ log_info "步骤 2/5:写入临时干净配置(用于执行插件命令)..."
70
+
71
+ # 提取当前配置中 plugins 以外的所有字段,保留业务配置结构
72
+ # 同时写入一个空的 plugins 配置,让 openclaw 能正常初始化
73
+ python3 - <<'PYEOF'
74
+ import json, sys, os
75
+
76
+ config_path = os.path.expanduser("~/.openclaw/openclaw.json")
77
+
78
+ with open(config_path, "r") as f:
79
+ original = json.load(f)
80
+
81
+ # 保留所有非 plugins 字段,plugins 置为空(让 openclaw 重新初始化)
82
+ clean_config = {k: v for k, v in original.items() if k != "plugins"}
83
+ clean_config["plugins"] = {
84
+ "load": {},
85
+ "entries": {},
86
+ "allow": [],
87
+ "installs": {}
88
+ }
89
+
90
+ with open(config_path, "w") as f:
91
+ json.dump(clean_config, f, indent=2)
92
+
93
+ print(" 干净配置写入完成")
94
+ PYEOF
95
+
96
+ log_success "临时干净配置写入完成"
97
+
98
+ # ============ 步骤 3:卸载旧插件 ============
99
+ log_info "步骤 3/5:卸载旧版插件 (${PLUGIN_NAME})..."
100
+ if openclaw plugins uninstall "$PLUGIN_NAME" --yes 2>&1 | grep -v "^$"; then
101
+ log_success "旧插件卸载完成"
102
+ else
103
+ log_warn "卸载命令返回非零,可能插件本来就未安装,继续执行..."
104
+ fi
105
+
106
+ # ============ 步骤 4:从 npm 安装最新版插件 ============
107
+ log_info "步骤 4/5:从 npm 安装最新版插件 (${NPM_PACKAGE})..."
108
+ echo ""
109
+ openclaw plugins install "$NPM_PACKAGE"
110
+ echo ""
111
+ log_success "npm 插件安装完成"
112
+
113
+ # ============ 步骤 5:恢复备份配置(保留业务配置) ============
114
+ log_info "步骤 5/5:将新安装的插件信息合并回原始业务配置..."
115
+
116
+ python3 - <<'PYEOF'
117
+ import json, os
118
+
119
+ config_path = os.path.expanduser("~/.openclaw/openclaw.json")
120
+ import glob
121
+ backup_files = sorted(glob.glob(config_path + ".migrate_backup.*"))
122
+ backup_path = backup_files[-1] # 取最新的备份
123
+
124
+ with open(config_path, "r") as f:
125
+ new_config = json.load(f)
126
+
127
+ with open(backup_path, "r") as f:
128
+ original_config = json.load(f)
129
+
130
+ # 策略:以原始业务配置为基础,只替换 plugins.installs 为新安装的结果
131
+ # 这样保留了用户所有的 channels、agents、bindings 等业务配置
132
+ merged = dict(original_config)
133
+ merged["plugins"] = dict(original_config.get("plugins", {}))
134
+
135
+ # 用新安装的 installs 记录覆盖旧的(包含新的 npm source 信息)
136
+ new_installs = new_config.get("plugins", {}).get("installs", {})
137
+ if new_installs:
138
+ merged["plugins"]["installs"] = new_installs
139
+ # 同步更新 entries 和 allow(保留原有的 enabled 状态)
140
+ for plugin_id in new_installs:
141
+ if plugin_id not in merged["plugins"].get("entries", {}):
142
+ merged["plugins"].setdefault("entries", {})[plugin_id] = {"enabled": True}
143
+ if plugin_id not in merged["plugins"].get("allow", []):
144
+ merged["plugins"].setdefault("allow", []).append(plugin_id)
145
+
146
+ with open(config_path, "w") as f:
147
+ json.dump(merged, f, indent=2)
148
+
149
+ print(f" 已合并配置,新插件安装信息:")
150
+ for plugin_id, install_info in new_installs.items():
151
+ print(f" - {plugin_id}: {install_info.get('spec', 'unknown')} (source: {install_info.get('source', 'unknown')})")
152
+ PYEOF
153
+
154
+ log_success "配置合并完成"
155
+
156
+ # ============ 完成 ============
157
+ echo ""
158
+ echo "============================================================"
159
+ log_success "迁移完成!"
160
+ echo ""
161
+ echo " 备份文件: $BACKUP_FILE"
162
+ echo " 如需回滚: cp \"$BACKUP_FILE\" \"$OPENCLAW_CONFIG\""
163
+ echo ""
164
+ echo " 下一步:重启 OpenClaw Gateway 使新插件生效"
165
+ echo " openclaw gateway --force"
166
+ echo "============================================================"
167
+ echo ""
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dingtalk-connector",
3
3
  "name": "DingTalk Channel",
4
- "version": "0.8.6",
4
+ "version": "0.8.7",
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.6",
3
+ "version": "0.8.7",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -100,15 +100,26 @@ export async function monitorSingleAccount(
100
100
  logger.info(`Initializing with clientId: ${clientIdStr.substring(0, 8)}...`);
101
101
  logger.info(`WebSocket keepAlive: false (using application-layer heartbeat)`);
102
102
 
103
- // 🔧 修复代理问题:禁用 axios 的代理配置
104
- // 问题:dingtalk-stream SDK 内部使用 axios,会被系统 PAC 文件影响
105
- // 解决:在导入 dingtalk-stream 之前,先导入 axios 并禁用全局代理
106
- // 注意:这会影响 dingtalk-stream SDK 内部的 axios,与 src/utils/http-client.ts 是两个不同的配置
103
+ // 🔧 配置 dingtalk-stream SDK 的代理策略
104
+ //
105
+ // dingtalk-stream SDK 内部使用 axios 发起 HTTP 请求(获取 WebSocket endpoint)。
106
+ // 策略与 src/utils/http-client.ts 保持一致:
107
+ // - 默认禁用代理:避免阿里内网 PAC 文件将 *.dingtalk.com 路由到内网代理,
108
+ // 导致外网环境连接超时。
109
+ // - DINGTALK_FORCE_PROXY=true:保留系统代理,供需要通过代理访问外网的内网环境使用。
110
+ //
111
+ // 注意:这里修改的是 axios 全局默认值,会影响 dingtalk-stream SDK 内部的 axios 实例。
112
+ // src/utils/http-client.ts 中的专用实例已在创建时单独配置,不受此处影响。
107
113
  try {
108
114
  const axios = (await import("axios")).default;
109
115
  if (axios.defaults) {
110
- axios.defaults.proxy = false; // 禁用全局代理
111
- logger.debug(`已禁用 axios 全局代理配置(用于 dingtalk-stream SDK)`);
116
+ const shouldDisableProxy = process.env.DINGTALK_FORCE_PROXY !== 'true';
117
+ if (shouldDisableProxy) {
118
+ axios.defaults.proxy = false;
119
+ logger.debug(`已禁用 axios 全局代理(dingtalk-stream SDK),如需代理请设置 DINGTALK_FORCE_PROXY=true`);
120
+ } else {
121
+ logger.debug(`保留系统代理配置(DINGTALK_FORCE_PROXY=true),dingtalk-stream SDK 将通过代理连接`);
122
+ }
112
123
  }
113
124
  } catch (err) {
114
125
  logger.warn(`无法配置 axios 代理设置: ${err}`);
@@ -491,7 +502,7 @@ export async function monitorSingleAccount(
491
502
  // 协议层去重(headers.messageId):拦截同一次投递的重复回调
492
503
  // 注意:业务层去重(data.msgId)在 JSON 解析后执行,两层合并在 checkAndMarkDingtalkMessage 中
493
504
  // 此处仅做协议层的快速预检,避免不必要的 JSON 解析
494
- if (messageId && checkAndMarkDingtalkMessage(messageId, undefined)) {
505
+ if (messageId && checkAndMarkDingtalkMessage(accountId, messageId, undefined)) {
495
506
  processedCount++;
496
507
  logger.warn(`⚠️ 检测到重复消息(协议层),跳过处理:messageId=${messageId} (${processedCount}/${receivedCount})`);
497
508
  logger.info(`========== 消息处理结束(重复) ==========\n`);
@@ -545,12 +556,6 @@ export async function monitorSingleAccount(
545
556
  // 钉钉重发时 headers.messageId 是新值,但 data.msgId 不变,
546
557
  // checkAndMarkDingtalkMessage 会命中 data.msgId 并返回 true 拦截重发。
547
558
  const businessMsgId = data.msgId;
548
- if (checkAndMarkDingtalkMessage(undefined, businessMsgId)) {
549
- processedCount++;
550
- logger.warn(`⚠️ 检测到钉钉服务端重发消息,跳过处理:msgId=${businessMsgId} (${processedCount}/${receivedCount})`);
551
- logger.info(`========== 消息处理结束(业务层去重) ==========\n`);
552
- return;
553
- }
554
559
 
555
560
  // 记录消息内容(简化版,避免过长)
556
561
  let contentPreview = "N/A";
@@ -62,7 +62,7 @@ import { QUEUE_BUSY_ACK_PHRASES } from "../utils/constants.ts";
62
62
  import { createDingtalkReplyDispatcher, normalizeSlashCommand } from "../reply-dispatcher.ts";
63
63
  import { getDingtalkRuntime } from "../runtime.ts";
64
64
  import { dingtalkHttp } from '../utils/http-client.ts';
65
- import { createLoggerFromConfig } from '../utils/logger.ts';
65
+ import { createLoggerFromConfig } from '../utils/index.ts';
66
66
  import * as fs from 'fs';
67
67
  import * as path from 'path';
68
68
  import * as os from 'os';
@@ -1033,47 +1033,23 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1033
1033
  });
1034
1034
 
1035
1035
  // 使用 SDK 的 dispatchReplyFromConfig
1036
- log?.info?.(`调用 withReplyDispatcher,asyncMode=${asyncMode}`);
1037
- log?.info?.(`准备调用 withReplyDispatcher...`);
1038
-
1039
- let dispatchResult;
1040
- try {
1041
- dispatchResult = await core.channel.reply.withReplyDispatcher({
1042
- dispatcher,
1043
- onSettled: () => {
1044
- log?.info?.(`onSettled 被调用`);
1045
- markDispatchIdle();
1046
- },
1047
- run: async () => {
1048
- log?.info?.(`run 被调用,开始 dispatchReplyFromConfig`);
1049
- log?.info?.(`ctxPayload.SessionKey=${ctxPayload.SessionKey}`);
1050
- log?.info?.(`ctxPayload.Body 长度=${ctxPayload.Body?.length || 0}`);
1051
- log?.info?.(`replyOptions keys=${Object.keys(replyOptions).join(',')}`);
1052
-
1053
- const result = await core.channel.reply.dispatchReplyFromConfig({
1054
- ctx: ctxPayload,
1055
- cfg,
1056
- dispatcher,
1057
- replyOptions,
1058
- });
1059
-
1060
- log?.info?.(`dispatchReplyFromConfig 返回: queuedFinal=${result.queuedFinal}, counts=${JSON.stringify(result.counts)}`);
1061
- return result;
1062
- },
1063
- });
1064
- log?.info?.(`withReplyDispatcher 返回成功`);
1065
- } catch (dispatchErr: any) {
1066
- log?.error?.(`withReplyDispatcher 抛出异常: ${dispatchErr?.message || dispatchErr}`);
1067
- log?.error?.(`异常堆栈: ${dispatchErr?.stack || 'no stack'}`);
1068
- log?.error?.(`消息处理异常,但不阻塞后续消息: ${dispatchErr?.message || dispatchErr}`);
1069
-
1070
- // ⚠️ 不要直接 throw,避免阻塞后续消息处理
1071
- // 记录错误后继续执行,确保后续消息能正常处理
1072
- dispatchResult = { queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } };
1073
- }
1074
-
1036
+ const dispatchResult = await core.channel.reply.withReplyDispatcher({
1037
+ dispatcher,
1038
+ onSettled: () => {
1039
+ markDispatchIdle();
1040
+ },
1041
+ run: async () => {
1042
+ const result = await core.channel.reply.dispatchReplyFromConfig({
1043
+ ctx: ctxPayload,
1044
+ cfg,
1045
+ dispatcher,
1046
+ replyOptions,
1047
+ });
1048
+ return result;
1049
+ },
1050
+ });
1051
+
1075
1052
  const { queuedFinal, counts } = dispatchResult;
1076
- log?.info?.(`SDK dispatch 完成: queuedFinal=${queuedFinal}, replies=${counts.final}, asyncMode=${asyncMode}`);
1077
1053
 
1078
1054
  // ===== 异步模式:主动推送最终结果 =====
1079
1055
  if (asyncMode) {
@@ -17,10 +17,15 @@ export const DEFAULT_ACCOUNT_ID = "__default__" as const;
17
17
 
18
18
  /**
19
19
  * 规范化账号 ID
20
+ *
21
+ * 注意:账号 ID 保留原始大小写,仅做 trim 处理。
22
+ * 不做 toLowerCase,因为配置文件中的 accounts key 是大小写敏感的,
23
+ * 如 "zhizaoDashuIP" 与 "zhizaodashuip" 是不同的账号。
24
+ * 特殊值 "default"(不区分大小写)和空字符串映射到 DEFAULT_ACCOUNT_ID。
20
25
  */
21
26
  export function normalizeAccountId(accountId: string): string {
22
- const trimmed = accountId.trim().toLowerCase();
23
- if (trimmed === "default" || trimmed === "") {
27
+ const trimmed = accountId.trim();
28
+ if (trimmed.toLowerCase() === "default" || trimmed === "") {
24
29
  return DEFAULT_ACCOUNT_ID;
25
30
  }
26
31
  return trimmed;
@@ -4,6 +4,9 @@
4
4
 
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
+ // form-data 是 CJS 模块,静态 import 可确保 jiti/ESM 环境下 CJS 互操作行为稳定,
8
+ // 避免动态 import 时 .default 偶发为 undefined 导致 "Cannot read properties of undefined (reading 'registry')"
9
+ import FormData from 'form-data';
7
10
  import { createLogger } from '../../utils/logger.ts';
8
11
  import { CHUNK_CONFIG } from './chunk-upload.ts';
9
12
  import { dingtalkOapiHttp, dingtalkUploadHttp } from '../../utils/http-client.ts';
@@ -76,8 +79,6 @@ export async function uploadMediaToDingTalk(
76
79
  );
77
80
 
78
81
  try {
79
- const FormData = (await import('form-data')).default;
80
-
81
82
  const absPath = toLocalPath(filePath);
82
83
  log?.info?.(`检查文件是否存在:${absPath}`);
83
84
  if (!fs.existsSync(absPath)) {
@@ -256,26 +256,37 @@ export function markMessageProcessed(messageId: string): void {
256
256
  * 1. 协议层去重(headers.messageId):拦截同一次投递的重复回调
257
257
  * 2. 业务层去重(data.msgId):拦截 ~60 秒后服务端因未收到业务回复而触发的重发
258
258
  *
259
+ * 重要:key 必须带 accountId 前缀,避免多账号(多机器人)场景下,
260
+ * 同一条群消息 @多个机器人时,不同机器人收到相同 msgId 导致误判为重复消息。
261
+ *
262
+ * @param accountId - 当前账号 ID(用于命名空间隔离,防止多账号误判)
259
263
  * @param protocolMessageId - res.headers.messageId(WebSocket 协议层投递 ID)
260
264
  * @param businessMsgId - data.msgId(钉钉业务层消息 ID,来自 JSON.parse(res.data).msgId)
261
265
  * @returns true 表示消息已处理过(应跳过),false 表示首次处理(已标记为已处理)
262
266
  */
263
267
  export function checkAndMarkDingtalkMessage(
268
+ accountId: string,
264
269
  protocolMessageId: string | undefined,
265
270
  businessMsgId: string | undefined,
266
271
  ): boolean {
272
+ // 加 accountId 前缀,确保不同机器人账号的去重缓存互相隔离
273
+ // 场景:群聊 @多个机器人时,钉钉推送给每个机器人的消息 msgId 相同,
274
+ // 若不隔离,第二个机器人会被误判为重复消息而跳过处理。
275
+ const scopedProtocolId = protocolMessageId ? `${accountId}:${protocolMessageId}` : undefined;
276
+ const scopedBusinessId = businessMsgId ? `${accountId}:${businessMsgId}` : undefined;
277
+
267
278
  // 先完整检查两个 ID,再决定是否标记
268
279
  // 不能提前 return,否则命中去重的那条路径会漏掉对另一个 ID 的标记
269
- const isProtocolDuplicate = protocolMessageId ? isMessageProcessed(protocolMessageId) : false;
270
- const isBusinessDuplicate = businessMsgId ? isMessageProcessed(businessMsgId) : false;
280
+ const isProtocolDuplicate = scopedProtocolId ? isMessageProcessed(scopedProtocolId) : false;
281
+ const isBusinessDuplicate = scopedBusinessId ? isMessageProcessed(scopedBusinessId) : false;
271
282
 
272
283
  if (isProtocolDuplicate || isBusinessDuplicate) {
273
284
  return true;
274
285
  }
275
286
 
276
287
  // 首次处理:同时标记两个 ID,确保后续任意一个 ID 都能命中去重
277
- if (protocolMessageId) markMessageProcessed(protocolMessageId);
278
- if (businessMsgId) markMessageProcessed(businessMsgId);
288
+ if (scopedProtocolId) markMessageProcessed(scopedProtocolId);
289
+ if (scopedBusinessId) markMessageProcessed(scopedBusinessId);
279
290
 
280
291
  return false;
281
292
  }