@dingtalk-real-ai/dingtalk-connector 0.7.6 → 0.7.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.
Files changed (48) hide show
  1. package/.github/workflows/issue-to-AI-table.yml +52 -0
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +23 -21
  4. package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
  5. package/openclaw.plugin.json +1 -1
  6. package/package.json +13 -5
  7. package/plugin.ts +164 -16
  8. package/tests/README.md +54 -0
  9. package/tests/ai-card/PLAN.md +54 -0
  10. package/tests/ai-card/ai-card.test.ts +372 -0
  11. package/tests/audio/PLAN.md +64 -0
  12. package/tests/audio/audio.test.ts +283 -0
  13. package/tests/bindings/PLAN.md +99 -0
  14. package/tests/bindings/bindings.test.ts +191 -0
  15. package/tests/card-update/PLAN.md +29 -0
  16. package/tests/card-update/card-update.test.ts +127 -0
  17. package/tests/config-token/PLAN.md +94 -0
  18. package/tests/config-token/config-token.test.ts +153 -0
  19. package/tests/core/PLAN.md +65 -0
  20. package/tests/core/core.test.ts +286 -0
  21. package/tests/deliver-payload/PLAN.md +59 -0
  22. package/tests/deliver-payload/deliver-payload.test.ts +91 -0
  23. package/tests/download/PLAN.md +47 -0
  24. package/tests/download/download.test.ts +261 -0
  25. package/tests/file-markers/PLAN.md +74 -0
  26. package/tests/file-markers/file-markers.test.ts +105 -0
  27. package/tests/index.ts +129 -0
  28. package/tests/integration/PLAN.md +65 -0
  29. package/tests/integration/integration.test.ts +232 -0
  30. package/tests/mcp-tools/PLAN.md +67 -0
  31. package/tests/mcp-tools/mcp-tools.test.ts +327 -0
  32. package/tests/media/PLAN.md +37 -0
  33. package/tests/media/media.test.ts +50 -0
  34. package/tests/message-extract/PLAN.md +83 -0
  35. package/tests/message-extract/message-extract.test.ts +205 -0
  36. package/tests/proactive/PLAN.md +88 -0
  37. package/tests/proactive/proactive.test.ts +502 -0
  38. package/tests/prompts/PLAN.md +71 -0
  39. package/tests/prompts/prompts.test.ts +64 -0
  40. package/tests/send-message/PLAN.md +44 -0
  41. package/tests/send-message/send-message.test.ts +228 -0
  42. package/tests/session/PLAN.md +90 -0
  43. package/tests/session/session.test.ts +166 -0
  44. package/tests/upload/PLAN.md +72 -0
  45. package/tests/upload/upload.test.ts +390 -0
  46. package/tests/video/PLAN.md +118 -0
  47. package/tests/video/video.test.ts +40 -0
  48. package/vitest.config.ts +13 -0
@@ -0,0 +1,52 @@
1
+ # Issue 变更推送到 Webhook
2
+ # 当有 Issue 变更时,发送指定格式的数据到 webhook
3
+ name: 📤 Issue Webhook Notification
4
+
5
+ on:
6
+ issues:
7
+ types: [opened, reopened, closed, edited, labeled, unlabeled]
8
+
9
+ jobs:
10
+ notify:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: 📬 Send Issue to Webhook
14
+ uses: actions/github-script@v7
15
+ with:
16
+ script: |
17
+ const webhook = process.env.ISSUE_WEBHOOK_URL;
18
+ if (!webhook) {
19
+ console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification');
20
+ return;
21
+ }
22
+
23
+ const payload = context.payload;
24
+ const issue = payload.issue;
25
+ const action = payload.action;
26
+
27
+ // 构建指定格式的数据
28
+ const webhookPayload = {
29
+ action: action,
30
+ issue: {
31
+ id: issue.id,
32
+ number: issue.number,
33
+ title: issue.title,
34
+ body: issue.body,
35
+ state: issue.state,
36
+ html_url: issue.html_url
37
+ }
38
+ };
39
+
40
+ const response = await fetch(webhook, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(webhookPayload)
44
+ });
45
+
46
+ if (response.ok) {
47
+ console.log('✅ Webhook notification sent successfully');
48
+ } else {
49
+ console.log('❌ Failed to send webhook notification:', response.status, response.statusText);
50
+ }
51
+ env:
52
+ ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }}
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@
6
6
  This document records all significant changes. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7
7
  and version numbers follow [Semantic Versioning](https://semver.org/).
8
8
 
9
+ ## [0.7.7] - 2026-03-13
10
+
11
+ ### 新增 / Added
12
+ - ✨ **自定义 Gateway URL 支持** - 新增 `gatewayBaseUrl` 配置项,支持通过自定义 URL(如 Nginx 反向代理到 TLS/HTTPS Gateway)访问 Gateway
13
+ **Custom Gateway URL support** - Added `gatewayBaseUrl` option to allow using a custom URL (e.g., Nginx reverse proxy to a TLS/HTTPS Gateway)
14
+ - ✨ **钉钉「思考中」表情反馈** - 在处理用户消息期间为原消息贴上「🤔思考中」表情,处理结束后自动撤回,清晰展示处理进度
15
+ **DingTalk “thinking” emotion feedback** - Attaches a “🤔 Thinking” emotion to the original user message while processing and automatically recalls it after completion to clearly indicate progress
16
+ - ✨ **测试基础设施完善** - 引入 Vitest 及多种测试脚本(run/watch/coverage/ui/integration),为后续自动化测试和回归验证提供基础
17
+ **Improved testing infrastructure** - Introduced Vitest and multiple test scripts (run/watch/coverage/ui/integration) to enable better automated and regression testing
18
+ - ✨ **Issue Webhook 工作流** - 新增 GitHub Actions 工作流,将 Issue 变更以统一 JSON 格式推送到配置的 Webhook
19
+ **Issue webhook workflow** - Added a GitHub Actions workflow to push Issue changes as unified JSON payloads to a configured webhook
20
+
21
+ ### 修复 / Fixes
22
+ - 🐛 **媒体元数据与缩略图提取更健壮** - ffprobe 或缩略图生成失败时不再中断主流程,而是返回默认元数据或空缩略图
23
+ **More robust media metadata & thumbnail extraction** - ffprobe or thumbnail generation failures no longer abort the main flow but return default metadata or a null thumbnail instead
24
+ - 🐛 **音频时长提取兼容性改进** - 使用动态 `import('child_process')` 替代 `require('child_process')`,提升在不同运行环境下的兼容性
25
+ **Audio duration extraction compatibility** - Replaced `require('child_process')` with dynamic `import('child_process')` to improve compatibility across environments
26
+ - 🐛 **主动消息用户列表校验** - 为主动消息的 `userIds` 列表增加空值过滤,避免因无效用户 ID 导致请求失败
27
+ **Proactive message user list validation** - Added empty value filtering for the `userIds` list in proactive messages to prevent request failures caused by invalid IDs
28
+
9
29
  ## [0.7.6] - 2026-03-12
10
30
 
11
31
  ### 修复 / Fixes
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
- # DingTalk OpenClaw Connector
1
+ # Offical DingTalk OpenClaw Connector
2
+ ## 钉钉官方OpenClaw连接器
2
3
 
3
4
  以下提供两种方案连接到 [OpenClaw](https://openclaw.ai) Gateway,分别是钉钉机器人和钉钉 DEAP Agent。
4
5
 
5
- > 📝 **版本信息**:当前版本 v0.7.6 | [查看变更日志](CHANGELOG.md) | [发布说明](docs/RELEASE_NOTES_V0.7.6.md) | [发布指南](RELEASE.md)
6
+ > 📝 **版本信息**:当前版本 v0.7.7 | [查看变更日志](CHANGELOG.md) | [发布说明](docs/RELEASE_NOTES_V0.7.7.md) | [发布指南](RELEASE.md)
6
7
 
7
8
  ## 快速导航
8
9
 
@@ -86,6 +87,7 @@ openclaw plugins install -l .
86
87
  "channels": {
87
88
  "dingtalk-connector": {
88
89
  "enabled": true,
90
+ "gatewayBaseUrl": "http://localhost:18789", // 可选:如果Gateway地址是TLS/HTTPS,see PR #117
89
91
  "clientId": "dingxxxxxxxxx", // 钉钉 AppKey
90
92
  "clientSecret": "your_secret_here", // 钉钉 AppSecret
91
93
  "gatewayToken": "", // 可选:Gateway 认证 token, openclaw.json配置中 gateway.auth.token 的值
@@ -97,19 +99,17 @@ openclaw plugins install -l .
97
99
  "asyncMode": false, // 可选:异步模式,立即回执用户消息,后台处理并推送结果(默认:false)
98
100
  "ackText": "🫡 任务已接收" // 可选:异步模式下的回执消息文本(默认:'🫡 任务已接收,处理中...')
99
101
  }
100
- },
101
- "gateway": { // gateway通常是已有的节点,配置时注意把http部分追加到已有节点下
102
- "http": {
103
- "endpoints": {
104
- "chatCompletions": {
105
- "enabled": true
106
- }
107
- }
108
- }
109
102
  }
110
103
  }
111
104
  ```
112
105
 
106
+ 执行配置命令
107
+
108
+ ```bash
109
+ openclaw config set gateway.http.endpoints.chatCompletions.enabled true
110
+ ```
111
+
112
+
113
113
  或者在 OpenClaw Dashboard 页面配置:
114
114
 
115
115
  <img width="1916" height="1996" alt="image" src="https://github.com/user-attachments/assets/00b585ca-c1df-456c-9c65-7345a718b94b" />
@@ -129,21 +129,20 @@ openclaw plugins list # 确认 dingtalk-connector 已加载
129
129
  ## 创建钉钉机器人
130
130
 
131
131
  1. 打开 [钉钉开放平台](https://open.dingtalk.com/)
132
- 2. 进入 **应用开发** → **企业内部开发** → 创建应用
133
- 3. 添加 **机器人** 能力,消息接收模式选择 **Stream 模式**
134
- 4. 开通权限:
135
- - `Card.Streaming.Write` - AI Card 流式响应
136
- - `Card.Instance.Write` - AI Card 实例写入
137
- - `qyapi_robot_sendmsg` - 主动发送消息
138
- - 如需使用文档 API 功能,还需开通文档相关权限
139
- 5. **发布应用**,记录 **AppKey** 和 **AppSecret**
132
+ 2. 进入 **应用开发** → **企业内部开发** → 创建openclaw机器人
133
+ <img width="2529" height="961" alt="image" src="https://github.com/user-attachments/assets/4163dc50-808c-43c2-84bd-a1e8dde42e05" />
134
+ 3. 创建好机器人后,保存一下生成的clientId和clientSecret, 后续配置参数需要
135
+ <img width="687" height="540" alt="image" src="https://github.com/user-attachments/assets/c036ea46-9750-4814-8c24-3e4b54bd2788" />
136
+
137
+ 或者在机器人详情中 也可以拿到
138
+ <img width="1527" height="377" alt="image" src="https://github.com/user-attachments/assets/3cf2a661-ed81-441d-9cda-024a6a5377cc" />
140
139
 
141
140
  ## 配置参考
142
141
 
143
142
  | 配置项 | 环境变量 | 说明 |
144
143
  |--------|----------|------|
145
- | `clientId` | `DINGTALK_CLIENT_ID` | 钉钉 AppKey |
146
- | `clientSecret` | `DINGTALK_CLIENT_SECRET` | 钉钉 AppSecret |
144
+ | `clientId` | `DINGTALK_CLIENT_ID` | 上一步创建openclaw机器人给到的 clinetId |
145
+ | `clientSecret` | `DINGTALK_CLIENT_SECRET` | 上一步创建openclaw机器人给到的 clientSecret |
147
146
  | `gatewayToken` | `OPENCLAW_GATEWAY_TOKEN` | Gateway 认证 token(可选) |
148
147
  | `gatewayPassword` | — | Gateway 认证 password(可选,与 token 二选一) |
149
148
  | `sessionTimeout` | — | ⚠️ 已废弃,请使用 Gateway 的 [`session.reset.idleMinutes`](https://docs.openclaw.ai/gateway/configuration) 配置 |
@@ -679,6 +678,9 @@ openclaw gateway start
679
678
 
680
679
  <img width="3426" height="1752" alt="配置 OpenClaw 技能参数" src="https://github.com/user-attachments/assets/bc725789-382f-41b5-bbdb-ba8f29923d5c" />
681
680
 
681
+ 注意 OpenClaw 属于一个MCP,还需要配置他的触发规则,满足规则的情况下才会使用这个MCP:
682
+ <img width="1088" height="526" alt="image" src="https://github.com/user-attachments/assets/8b0b6f6d-70ff-4edc-b674-7a24126aadfa" />
683
+
682
684
  4. 发布 Agent:
683
685
 
684
686
  <img width="3416" height="1762" alt="发布 Agent" src="https://github.com/user-attachments/assets/3f8c3fdb-5f2b-4a4b-8896-35202e713bf3" />
@@ -0,0 +1,122 @@
1
+ # Release Notes - v0.7.7
2
+
3
+ ## ✨ 功能与体验改进 / Features & Improvements
4
+
5
+ - **自定义 Gateway URL 支持 / Custom Gateway URL Support**
6
+ 通过新增 `gatewayBaseUrl` 配置项,支持将请求发送到自定义的 Gateway 地址,例如通过 Nginx 反向代理到启用 TLS/HTTPS 的 Gateway。
7
+ With the new `gatewayBaseUrl` option, requests can be sent to a custom Gateway URL, such as an Nginx reverse proxy in front of a TLS/HTTPS-enabled Gateway.
8
+
9
+ - **钉钉「思考中」表情反馈 / DingTalk “Thinking” Emotion Feedback**
10
+ 在处理用户消息期间,Connector 会在原消息上贴上「🤔思考中」表情,处理结束后自动撤回,以更直观地展示处理进度。
11
+ While processing a user message, the connector attaches a “🤔 Thinking” emotion to the original message and automatically recalls it after completion to clearly indicate processing progress.
12
+
13
+ - **测试基础设施完善 / Testing Infrastructure Enhancement**
14
+ 引入 Vitest 测试框架与多种测试脚本(单次运行、watch、覆盖率、UI、集成测试),为后续质量保障和回归测试打下基础。
15
+ Introduced the Vitest testing framework and multiple test scripts (run, watch, coverage, UI, integration) to improve quality assurance and regression testing.
16
+
17
+ - **文档与示例优化 / Documentation & Examples Improvements**
18
+ 更新 README,补充了 OpenClaw 官方连接器定位、Gateway TLS/HTTPS 配置示例、钉钉机器人创建引导图、以及在 OpenClaw 中配置 MCP 触发规则的截图说明。
19
+ Updated README with official connector positioning, Gateway TLS/HTTPS configuration example, DingTalk bot creation walkthrough images, and screenshots for configuring MCP trigger rules in OpenClaw.
20
+
21
+ ## 🐛 修复 / Fixes
22
+
23
+ - **媒体元数据与缩略图提取健壮性提升 / More Robust Media Metadata & Thumbnail Extraction**
24
+ 当 ffprobe 或缩略图生成失败(例如环境缺少依赖、视频流缺失)时,不再抛出异常中断主流程,而是安全返回默认元数据或空缩略图,确保消息处理不中断。
25
+ When ffprobe or thumbnail generation fails (e.g., missing dependencies or video stream), the connector no longer throws and aborts the main flow, but safely returns default metadata or a null thumbnail so message handling continues.
26
+
27
+ - **音频时长提取兼容性改进 / Audio Duration Extraction Compatibility**
28
+ 使用动态 `import('child_process')` 替代 `require('child_process')`,提升在不同打包/运行环境下的兼容性。
29
+ Switched from `require('child_process')` to dynamic `import('child_process')` to improve compatibility across different bundling and runtime environments.
30
+
31
+ - **主动消息用户列表校验 / Proactive Message User List Validation**
32
+ 在向多个用户发送主动消息时,对 `userIds` 列表进行空值过滤,避免因无效用户 ID 导致的请求失败。
33
+ Filters out empty values from the `userIds` list when sending proactive messages to multiple users, preventing request failures caused by invalid user IDs.
34
+
35
+ ## 📋 技术细节 / Technical Details
36
+
37
+ ### Gateway URL 配置 / Gateway URL Configuration
38
+
39
+ - `GatewayOptions` 接口新增 `gatewayBaseUrl?: string` 字段,用于指定自定义 Gateway URL(例如 `http://127.0.0.1:18788`)。
40
+ - `streamFromGateway` 中优先使用 `gatewayBaseUrl` 构造请求地址,否则回退到本地端口逻辑:
41
+ `gatewayBaseUrl ? \`\${gatewayBaseUrl}/v1/chat/completions\` : \`http://127.0.0.1:\${port}/v1/chat/completions\``。
42
+ - 插件配置新增 `gatewayBaseUrl` 字段说明,帮助用户在 TLS/HTTPS 或 Nginx 代理场景下正确配置。
43
+
44
+ ### 钉钉表情反馈逻辑 / DingTalk Emotion Feedback Logic
45
+
46
+ - 新增 `addEmotionReply`:在处理用户消息前,为该消息贴上「🤔思考中」表情,使用机器人凭证调用 `robot/emotion/reply` 接口。
47
+ - 新增 `recallEmotionReply`:在消息处理完成后的 `finally` 块内调用,撤回之前贴上的表情,通过 `robot/emotion/recall` 接口实现。
48
+ - 以上调用均带有完善的错误日志,失败不会中断主消息处理流程,仅记录警告日志。
49
+
50
+ ### 媒体处理健壮性 / Media Handling Robustness
51
+
52
+ - `extractVideoMetadata`:
53
+ - 将 Promise 回调中的错误处理改为返回 `{ duration: 0, width: 0, height: 0 }`,而非直接 reject。
54
+ - 当未找到视频流时,同样返回默认元数据结构。
55
+ - 外层 `catch` 中也返回默认元数据,确保调用方不需要对 `null` 做额外分支判断。
56
+ - `extractVideoThumbnail`:
57
+ - 截图失败时不再 reject,而是 resolve `null`,由上层逻辑决定是否展示缩略图。
58
+ - `extractAudioDuration`:
59
+ - 使用 `await import('child_process')` 获取 `execFile`,提高 ESM/打包场景下的兼容性。
60
+
61
+ ### 测试与依赖 / Testing & Dependencies
62
+
63
+ - 在 `package.json` 中:
64
+ - 将 `test` 脚本更新为 `vitest run`,并新增 `test:watch`、`test:coverage`、`test:ui`、`test:integration` 等脚本。
65
+ - 新增开发依赖:`@types/node`、`typescript`、`vitest`。
66
+ - 这些变更为后续补充单元测试、集成测试以及 CI 集成提供基础设施支持。
67
+
68
+ ### CI 工作流 / CI Workflow
69
+
70
+ - 新增 `.github/workflows/issue-to-AI-table.yml`:
71
+ - 监听 Issue 的创建、重开、关闭、编辑、打标签/去标签等事件。
72
+ - 将 Issue 的关键信息(编号、标题、内容、状态、链接等)以统一格式推送到配置的 Webhook(`ISSUE_WEBHOOK_URL`)。
73
+ - 可用于接入内部 AI 分析、需求盘点或看板同步等自动化流程。
74
+
75
+ ## 📥 安装升级 / Installation & Upgrade
76
+
77
+ ```bash
78
+ # 通过 npm 安装最新版本 / Install latest version via npm
79
+ openclaw plugins install @dingtalk-real-ai/dingtalk-connector
80
+
81
+ # 或升级现有版本 / Or upgrade existing version
82
+ openclaw plugins update dingtalk-connector
83
+
84
+ # 通过 Git 安装 / Install via Git
85
+ openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git
86
+ ```
87
+
88
+ ## ⚠️ 升级注意事项 / Upgrade Notes
89
+
90
+ ### 兼容性说明 / Compatibility Notes
91
+
92
+ - **向下兼容 / Backward Compatible**:本次更新为小版本改进,保留了 v0.7.x 既有行为,对现有配置完全兼容。
93
+ - **推荐使用 `gatewayBaseUrl` 配置 TLS 场景 / Recommended for TLS via `gatewayBaseUrl`**:
94
+ 在通过 Nginx 或其他代理为 Gateway 启用 TLS/HTTPS 的场景下,建议配置 `gatewayBaseUrl`,以确保 Connector 能够直接访问代理层地址。
95
+ - **媒体处理更安全 / Safer Media Handling**:即便视频/音频元数据提取失败,也不会影响消息主流程,仅在日志中记录错误。
96
+
97
+ ### 验证步骤 / Verification Steps
98
+
99
+ 升级到此版本后,建议进行以下验证:
100
+
101
+ 1. **Gateway URL 验证 / Gateway URL Verification**
102
+ - 配置 `gatewayBaseUrl` 指向你的 Nginx/Gateway 代理地址。
103
+ - 发送一条消息,确认能够正常与 Gateway 通信。
104
+ 2. **钉钉表情反馈验证 / DingTalk Emotion Feedback Verification**
105
+ - 在钉钉中向机器人发送一条消息。
106
+ - 确认消息上出现「🤔思考中」表情。
107
+ - 等待 AI 回复结束后,确认该表情被自动撤回。
108
+ 3. **媒体消息兼容性验证 / Media Message Compatibility Verification**
109
+ - 发送包含视频或音频的消息,在缺少部分 ffmpeg 依赖的环境下确认不会导致整个会话失败,仅记录错误日志。
110
+
111
+ ## 🔗 相关链接 / Related Links
112
+
113
+ - [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md)
114
+ - [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md)
115
+ - [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues)
116
+
117
+ ---
118
+
119
+ **发布日期 / Release Date**:2026-03-13
120
+ **版本号 / Version**:v0.7.7
121
+ **兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+
122
+
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dingtalk-connector",
3
3
  "name": "DingTalk Channel",
4
- "version": "0.7.6",
4
+ "version": "0.7.7",
5
5
  "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming",
6
6
  "author": "DingTalk Real Team",
7
7
  "channels": ["dingtalk-connector"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingtalk-real-ai/dingtalk-connector",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "plugin.ts",
6
6
  "type": "module",
@@ -8,8 +8,11 @@
8
8
  "build": "echo 'No build needed - jiti loads TS at runtime'",
9
9
  "lint": "echo 'Lint check skipped'",
10
10
  "lint:fix": "echo 'Lint fix skipped'",
11
- "test": "echo 'Tests skipped'",
12
- "test:watch": "echo 'Tests skipped'",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest --watch",
13
+ "test:coverage": "vitest run --coverage",
14
+ "test:ui": "vitest --ui",
15
+ "test:integration": "vitest run tests/integration",
13
16
  "type-check": "npx tsc --noEmit",
14
17
  "version:check": "echo 'Version check skipped'",
15
18
  "release:prepare": "echo 'Release prepare skipped'",
@@ -39,13 +42,18 @@
39
42
  "access": "public"
40
43
  },
41
44
  "dependencies": {
42
- "dingtalk-stream": "^2.1.4",
45
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
43
46
  "axios": "^1.6.0",
47
+ "dingtalk-stream": "^2.1.4",
44
48
  "fluent-ffmpeg": "^2.1.3",
45
- "@ffmpeg-installer/ffmpeg": "^1.1.0",
46
49
  "mammoth": "^1.8.0",
47
50
  "pdf-parse": "^1.1.1"
48
51
  },
52
+ "devDependencies": {
53
+ "@types/node": "^20.19.37",
54
+ "typescript": "^5.6.0",
55
+ "vitest": "^1.6.0"
56
+ },
49
57
  "openclaw": {
50
58
  "extensions": [
51
59
  "./plugin.ts"
package/plugin.ts CHANGED
@@ -553,17 +553,17 @@ async function extractVideoMetadata(
553
553
  const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
554
554
  ffmpeg.setFfmpegPath(ffmpegPath);
555
555
 
556
- return new Promise((resolve, reject) => {
556
+ return new Promise((resolve) => {
557
557
  ffmpeg.ffprobe(filePath, (err: any, metadata: any) => {
558
558
  if (err) {
559
559
  log?.error?.(`[DingTalk][Video] 提取元数据失败: ${err.message}`);
560
- return reject(err);
560
+ return resolve({ duration: 0, width: 0, height: 0 });
561
561
  }
562
562
 
563
563
  const videoStream = metadata.streams.find((s: any) => s.codec_type === 'video');
564
564
  if (!videoStream) {
565
565
  log?.warn?.(`[DingTalk][Video] 未找到视频流`);
566
- return resolve(null);
566
+ return resolve({ duration: 0, width: 0, height: 0 });
567
567
  }
568
568
 
569
569
  const result = {
@@ -578,7 +578,7 @@ async function extractVideoMetadata(
578
578
  });
579
579
  } catch (err: any) {
580
580
  log?.error?.(`[DingTalk][Video] ffprobe 失败: ${err.message}`);
581
- return null;
581
+ return { duration: 0, width: 0, height: 0 };
582
582
  }
583
583
  }
584
584
 
@@ -596,7 +596,7 @@ async function extractVideoThumbnail(
596
596
  const path = await import('path');
597
597
  ffmpeg.setFfmpegPath(ffmpegPath);
598
598
 
599
- return new Promise((resolve, reject) => {
599
+ return new Promise((resolve) => {
600
600
  ffmpeg(videoPath)
601
601
  .screenshots({
602
602
  count: 1,
@@ -611,7 +611,7 @@ async function extractVideoThumbnail(
611
611
  })
612
612
  .on('error', (err: any) => {
613
613
  log?.error?.(`[DingTalk][Video] 封面生成失败: ${err.message}`);
614
- reject(err);
614
+ resolve(null);
615
615
  });
616
616
  });
617
617
  } catch (err: any) {
@@ -920,7 +920,7 @@ async function extractAudioDuration(
920
920
  log?: any,
921
921
  ): Promise<number | null> {
922
922
  try {
923
- const { execFile } = require('child_process');
923
+ const { execFile } = await import('child_process');
924
924
  const ffprobeBin = getFfprobePath();
925
925
 
926
926
  return new Promise((resolve) => {
@@ -1377,6 +1377,8 @@ interface GatewayOptions {
1377
1377
  memoryUser?: string;
1378
1378
  /** 本地图片文件路径列表,用于 OpenClaw AgentMediaPayload */
1379
1379
  imageLocalPaths?: string[];
1380
+ /** 自定义 Gateway URL(如通过 Nginx 代理),用于 TLS 等场景 */
1381
+ gatewayBaseUrl?: string;
1380
1382
  /** 会话类型:'direct'(单聊)或 'group'(群聊),用于 bindings 匹配 */
1381
1383
  peerKind?: 'direct' | 'group';
1382
1384
  /** 发送者 ID,用于 bindings 匹配 */
@@ -1386,10 +1388,13 @@ interface GatewayOptions {
1386
1388
  }
1387
1389
 
1388
1390
  async function* streamFromGateway(options: GatewayOptions, accountId: string): AsyncGenerator<string, void, unknown> {
1389
- const { userContent, systemPrompts, sessionKey, gatewayAuth, memoryUser, imageLocalPaths, peerKind, peerId, gatewayPort, log } = options;
1391
+ // 支持自定义 Gateway URL(如通过 Nginx 代理),用于 TLS 等场景
1392
+ const { userContent, systemPrompts, sessionKey, gatewayAuth, gatewayBaseUrl, memoryUser, imageLocalPaths, peerKind, peerId, gatewayPort, log } = options;
1390
1393
  const rt = getRuntime();
1391
1394
  const port = gatewayPort || rt.gateway?.port || 18789;
1392
- const gatewayUrl = `http://127.0.0.1:${port}/v1/chat/completions`;
1395
+ const gatewayUrl = gatewayBaseUrl
1396
+ ? `${gatewayBaseUrl}/v1/chat/completions`
1397
+ : `http://127.0.0.1:${port}/v1/chat/completions`;
1393
1398
 
1394
1399
  const messages: any[] = [];
1395
1400
  for (const prompt of systemPrompts) {
@@ -2380,7 +2385,7 @@ async function sendToUser(
2380
2385
  return { ok: false, error: 'Missing clientId or clientSecret', usedAICard: false };
2381
2386
  }
2382
2387
 
2383
- const userIdArray = Array.isArray(userIds) ? userIds : [userIds];
2388
+ const userIdArray = (Array.isArray(userIds) ? userIds : [userIds]).filter((id) => Boolean(id));
2384
2389
  if (userIdArray.length === 0) {
2385
2390
  return { ok: false, error: 'userIds cannot be empty', usedAICard: false };
2386
2391
  }
@@ -2495,6 +2500,62 @@ async function sendProactive(
2495
2500
  return { ok: false, error: 'Must specify userId, userIds, or openConversationId', usedAICard: false };
2496
2501
  }
2497
2502
 
2503
+ // ============ 消息处理中表情 ============
2504
+
2505
+ /** 在用户消息上贴 🤔思考中 表情,表示正在处理 */
2506
+ async function addEmotionReply(config: any, data: any, log?: any): Promise<void> {
2507
+ if (!data.msgId || !data.conversationId) return;
2508
+ try {
2509
+ const token = await getAccessToken(config);
2510
+ await axios.post(`${DINGTALK_API}/v1.0/robot/emotion/reply`, {
2511
+ robotCode: data.robotCode ?? config.clientId,
2512
+ openMsgId: data.msgId,
2513
+ openConversationId: data.conversationId,
2514
+ emotionType: 2,
2515
+ emotionName: '🤔思考中',
2516
+ textEmotion: {
2517
+ emotionId: '2659900',
2518
+ emotionName: '🤔思考中',
2519
+ text: '🤔思考中',
2520
+ backgroundId: 'im_bg_1',
2521
+ },
2522
+ }, {
2523
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
2524
+ timeout: 5_000,
2525
+ });
2526
+ log?.info?.(`[DingTalk][Emotion] 贴表情成功: msgId=${data.msgId}`);
2527
+ } catch (err: any) {
2528
+ log?.warn?.(`[DingTalk][Emotion] 贴表情失败(不影响主流程): ${err.message}`);
2529
+ }
2530
+ }
2531
+
2532
+ /** 撤回用户消息上的 🤔思考中 表情 */
2533
+ async function recallEmotionReply(config: any, data: any, log?: any): Promise<void> {
2534
+ if (!data.msgId || !data.conversationId) return;
2535
+ try {
2536
+ const token = await getAccessToken(config);
2537
+ await axios.post(`${DINGTALK_API}/v1.0/robot/emotion/recall`, {
2538
+ robotCode: data.robotCode ?? config.clientId,
2539
+ openMsgId: data.msgId,
2540
+ openConversationId: data.conversationId,
2541
+ emotionType: 2,
2542
+ emotionName: '🤔思考中',
2543
+ textEmotion: {
2544
+ emotionId: '2659900',
2545
+ emotionName: '🤔思考中',
2546
+ text: '🤔思考中',
2547
+ backgroundId: 'im_bg_1',
2548
+ },
2549
+ }, {
2550
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
2551
+ timeout: 5_000,
2552
+ });
2553
+ log?.info?.(`[DingTalk][Emotion] 撤回表情成功: msgId=${data.msgId}`);
2554
+ } catch (err: any) {
2555
+ log?.warn?.(`[DingTalk][Emotion] 撤回表情失败(不影响主流程): ${err.message}`);
2556
+ }
2557
+ }
2558
+
2498
2559
  // ============ 核心消息处理 (AI Card Streaming) ============
2499
2560
 
2500
2561
  async function handleDingTalkMessage(params: {
@@ -2679,6 +2740,10 @@ async function handleDingTalkMessage(params: {
2679
2740
  }
2680
2741
  if (!userContent && imageLocalPaths.length === 0) return;
2681
2742
 
2743
+ // ===== 贴处理中表情 =====
2744
+ await addEmotionReply(dingtalkConfig, data, log);
2745
+
2746
+ try {
2682
2747
  // ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 =====
2683
2748
  const asyncMode = dingtalkConfig.asyncMode === true;
2684
2749
  const proactiveTarget = isDirect
@@ -2709,6 +2774,7 @@ async function handleDingTalkMessage(params: {
2709
2774
  systemPrompts,
2710
2775
  sessionKey: sessionContextJson,
2711
2776
  gatewayAuth,
2777
+ gatewayBaseUrl: dingtalkConfig.gatewayBaseUrl,
2712
2778
  memoryUser,
2713
2779
  imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined,
2714
2780
  peerKind,
@@ -2786,6 +2852,7 @@ async function handleDingTalkMessage(params: {
2786
2852
  systemPrompts,
2787
2853
  sessionKey: sessionContextJson,
2788
2854
  gatewayAuth,
2855
+ gatewayBaseUrl: dingtalkConfig.gatewayBaseUrl,
2789
2856
  memoryUser,
2790
2857
  imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined,
2791
2858
  peerKind,
@@ -2870,6 +2937,7 @@ async function handleDingTalkMessage(params: {
2870
2937
  systemPrompts,
2871
2938
  sessionKey: sessionContextJson,
2872
2939
  gatewayAuth,
2940
+ gatewayBaseUrl: dingtalkConfig.gatewayBaseUrl,
2873
2941
  memoryUser,
2874
2942
  imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined,
2875
2943
  peerKind,
@@ -2909,6 +2977,10 @@ async function handleDingTalkMessage(params: {
2909
2977
  });
2910
2978
  }
2911
2979
  }
2980
+ } finally {
2981
+ // ===== 撤回处理中表情 =====
2982
+ await recallEmotionReply(dingtalkConfig, data, log);
2983
+ }
2912
2984
  }
2913
2985
 
2914
2986
  // ============ 钉钉文档 API ============
@@ -3253,6 +3325,7 @@ const dingtalkPlugin = {
3253
3325
  groupPolicy: { type: 'string', enum: ['open', 'allowlist'], default: 'open' },
3254
3326
  gatewayToken: { type: 'string', default: '', description: 'Gateway auth token (Bearer)' },
3255
3327
  gatewayPassword: { type: 'string', default: '', description: 'Gateway auth password (alternative to token)' },
3328
+ gatewayBaseUrl: { type: 'string', default: '', description: 'Custom Gateway URL (e.g., http://127.0.0.1:18788 for Nginx proxy to TLS Gateway)' },
3256
3329
  sessionTimeout: { type: 'number', default: 1800000, description: 'Session timeout in ms (default 30min)' },
3257
3330
  separateSessionByConversation: { type: 'boolean', default: true, description: '是否按单聊/群聊/群区分 session' },
3258
3331
  sharedMemoryAcrossConversations: { type: 'boolean', default: false, description: '单 agent 场景下是否共享记忆;false 时不同群聊、群聊与私聊记忆隔离' },
@@ -3282,7 +3355,10 @@ const dingtalkPlugin = {
3282
3355
  const config = getConfig(cfg);
3283
3356
  const id = accountId || DEFAULT_ACCOUNT_ID;
3284
3357
  if (config.accounts?.[id]) {
3285
- return { accountId: id, config: config.accounts[id], enabled: config.accounts[id].enabled !== false };
3358
+ // 合并 channel 级别配置(如 gatewayBaseUrl)到 account 配置
3359
+ const { accounts, ...channelConfig } = config;
3360
+ const mergedConfig = { ...channelConfig, ...config.accounts[id] };
3361
+ return { accountId: id, config: mergedConfig, enabled: config.accounts[id].enabled !== false };
3286
3362
  }
3287
3363
  // 没有 accounts 配置或找不到指定账号时,使用顶层配置
3288
3364
  return { accountId: DEFAULT_ACCOUNT_ID, config, enabled: config.enabled !== false };
@@ -3456,15 +3532,15 @@ const dingtalkPlugin = {
3456
3532
  ctx.log?.info?.(`[DingTalk] 已立即确认回调: messageId=${messageId}`);
3457
3533
  }
3458
3534
 
3459
- // 【消息去重】检查是否已处理过该消息
3460
- if (messageId && isMessageProcessed(messageId)) {
3461
- ctx.log?.warn?.(`[DingTalk] 检测到重复消息,跳过处理: messageId=${messageId}`);
3535
+ // 【消息去重】检查是否已处理过该消息(按账号维度隔离)
3536
+ if (messageId && isMessageProcessed(account.accountId, messageId)) {
3537
+ ctx.log?.warn?.(`[DingTalk][${account.accountId}] 检测到重复消息,跳过处理: messageId=${messageId}`);
3462
3538
  return;
3463
3539
  }
3464
3540
 
3465
- // 标记消息为已处理
3541
+ // 标记消息为已处理(按账号维度隔离)
3466
3542
  if (messageId) {
3467
- markMessageProcessed(messageId);
3543
+ markMessageProcessed(account.accountId, messageId);
3468
3544
  }
3469
3545
 
3470
3546
  // 异步处理消息(不阻塞回调确认)
@@ -3859,3 +3935,75 @@ export {
3859
3935
  // 钉钉文档客户端
3860
3936
  DingtalkDocsClient,
3861
3937
  };
3938
+
3939
+ // ============ 测试辅助导出 ============
3940
+ // 仅用于单元测试,避免在业务代码中直接依赖内部实现细节
3941
+ export const __testables = {
3942
+ // 会话 & 去重
3943
+ normalizeSlashCommand,
3944
+ buildSessionContext,
3945
+ isMessageProcessed,
3946
+ markMessageProcessed,
3947
+ cleanupProcessedMessages,
3948
+ // 配置 & Token
3949
+ getConfig,
3950
+ isConfigured,
3951
+ getAccessToken,
3952
+ getOapiAccessToken,
3953
+ getUnionId,
3954
+ // 媒体处理
3955
+ toLocalPath,
3956
+ processLocalImages,
3957
+ uploadMediaToDingTalk,
3958
+ downloadImageToFile,
3959
+ downloadMediaByCode,
3960
+ downloadFileByCode,
3961
+ // 视频处理
3962
+ extractVideoMetadata,
3963
+ extractVideoThumbnail,
3964
+ processVideoMarkers,
3965
+ sendVideoMessage,
3966
+ // 音频处理
3967
+ getFfprobePath,
3968
+ extractAudioDuration,
3969
+ sendAudioMessage,
3970
+ processAudioMarkers,
3971
+ isAudioFile,
3972
+ // 文件处理
3973
+ extractFileMarkers,
3974
+ sendFileMessage,
3975
+ processFileMarkers,
3976
+ // 消息内容提取
3977
+ extractMessageContent,
3978
+ // 消息发送
3979
+ sendMarkdownMessage,
3980
+ sendTextMessage,
3981
+ sendMessage,
3982
+ // 提示词与消息体
3983
+ buildMediaSystemPrompt,
3984
+ buildDeliverBody,
3985
+ buildMsgPayload,
3986
+ // AI Card
3987
+ createAICard,
3988
+ streamAICard,
3989
+ finishAICard,
3990
+ createAICardForTarget,
3991
+ sendFileProactive,
3992
+ sendAudioProactive,
3993
+ sendVideoProactive,
3994
+ sendAICardInternal,
3995
+ sendAICardToUser,
3996
+ sendAICardToGroup,
3997
+ // 主动消息
3998
+ sendNormalToUser,
3999
+ sendNormalToGroup,
4000
+ sendToUser,
4001
+ sendToGroup,
4002
+ sendProactive,
4003
+ // Bindings 解析(测试时需 mock getRuntime/fs/path/os)
4004
+ resolveAgentIdByBindings,
4005
+ /** 仅测试用:注入 runtime 使 resolveAgentIdByBindings 不抛错 */
4006
+ setRuntimeForTest(r: PluginRuntime | null) {
4007
+ runtime = r;
4008
+ },
4009
+ };