@dingtalk-real-ai/dingtalk-connector 0.8.20 → 0.8.21

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,51 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [0.8.21] - 2026-05-19
11
+
12
+ 晋升自 `0.8.21-beta.0` 的 GA 版本,与 beta.0 内容完全一致,经过社区验证后正式发布。
13
+ GA promotion of `0.8.21-beta.0` after community validation; functionally identical to the beta.
14
+
15
+ ### 升级 / Upgrade
16
+
17
+ \`\`\`bash
18
+ npx openclaw@latest add @dingtalk-real-ai/dingtalk-connector@0.8.21
19
+ \`\`\`
20
+
21
+ 以下内容沿用自 `0.8.21-beta.0` 的修复与改进 / Same fixes and improvements as `0.8.21-beta.0`:
22
+
23
+ ## [0.8.21-beta.0] - 2026-05-17
24
+
25
+ ### 修复 / Fixes
26
+ - 🐛 **WebSocket phantom reconnect 根因修复 (#566)** — `setupPongListener` / `setupMessageListener` / `setupCloseListener` 原本在 `client.connect()` 之前调用,此时 `client.socket === undefined`,可选链让三个 listener 静默 no-op,从未真正挂上。pong 没人接 → `lastSocketAvailableTime` 不更新 → 命中 `TIMEOUT_THRESHOLD = 20s` → 约 30 秒一次的幽灵重连。本次把 setup 移到 `client.connect()` 之后(初次连接 + `doReconnect` 两条路径都覆盖),并放在 `await for OPEN` 之前,消除 race window。感谢 @Majorshi 贡献。
27
+ **Root-cause fix for WebSocket phantom reconnect (#566)** — `setupPongListener` / `setupMessageListener` / `setupCloseListener` were called before `client.connect()` when `client.socket === undefined`, so optional-chaining silently no-op'd and the three listeners were never attached. With no pong handler, `lastSocketAvailableTime` never refreshed, hit `TIMEOUT_THRESHOLD = 20s`, and triggered a phantom reconnect every ~30s. This release moves the setup calls to right after `client.connect()` (covering both initial connect and `doReconnect`), before the OPEN await, eliminating the race window. Credit: @Majorshi.
28
+
29
+ - 🐛 **消息处理保活 interval 兜底 bug (#594)** — `markMessageProcessingStart` 启动的兜底定时器原本 30s 间隔,但 `TIMEOUT_THRESHOLD` 早已从 90s 降到 20s,30s 间隔无法在 AI 长任务期间防住超时(约 21s 就可能触发幽灵重连,下次刷新还要等 9 秒)。本次改为 15s 间隔(< TIMEOUT_THRESHOLD),让保活真正生效。
30
+ **Fix message-processing keepalive interval (#594)** — The fallback `setInterval` in `markMessageProcessingStart` was 30s, but `TIMEOUT_THRESHOLD` had since dropped from 90s to 20s — the 30s interval couldn't prevent the timeout during long AI tasks (could trigger phantom reconnect around the 21s mark, with the next refresh 9s away). This release changes the interval to 15s (< TIMEOUT_THRESHOLD) so the safety net actually works.
31
+
32
+ - 🐛 **过滤上游 SDK `console.info` 噪音 (#571 / #536 / #573)** — 上游 `dingtalk-stream@2.1.4` SDK 在 `client.cjs:138 / :185` 每次 `disconnect()` / `connect()` 时直接 `console.info("Disconnecting.")` / `connect success`,绕过 logger,在频繁重连场景下会刷屏。本次在 `src/core/connection.ts` 加 `silenceDingtalkStreamConsoleNoise()`:模块级一次性 patch `console.info`,**只过滤这两条精确字符串**,其他 `console.info` 不受影响。同时在首个账号连上时通过 `printConnectionNoticeOnce()` 打印一次连接生命周期说明,解释过滤动机以及"高频重连不正常"的预期。
33
+ **Filter upstream SDK `console.info` noise (#571 / #536 / #573)** — Upstream `dingtalk-stream@2.1.4` SDK calls `console.info("Disconnecting.")` / `connect success` directly (`client.cjs:138 / :185`) on every `disconnect()` / `connect()`, bypassing the logger and spamming logs in frequent-reconnect scenarios. This release adds `silenceDingtalkStreamConsoleNoise()` in `src/core/connection.ts`: a module-level one-time `console.info` patch that filters **only these two exact strings**, leaving other `console.info` untouched. It also prints a one-time connection-lifecycle notice via `printConnectionNoticeOnce()` on first connect explaining the filter and setting the expectation that high-frequency reconnects are not normal.
34
+
35
+ ### 改进 / Improvements
36
+ - 🩹 **群聊空回复兜底文案带修复指引 (#589)** — 当 OpenClaw `messages.groupChat.visibleReplies` 未设为 `"automatic"` 时,群聊 @ 机器人会因上游 `source-reply-delivery-mode.ts` 走 `message_tool_only` 而拿不到流式 token,最终落到 connector 空回复兜底;以前固定文案「任务执行完成(无文本输出)」无信息量,现群聊场景改为带 `openclaw.json` 配置修复指引的提示,并在 warn 日志中打印完整片段;同时在 `onIdle` / `onError` 时若本轮无任何用户可见输出,主动 nudge 一条配置指引,覆盖上游根本不调 `deliver()` 的盲区。单聊文案保持不变。
37
+ **Group-chat empty-reply fallback now ships actionable hint (#589)** — When OpenClaw’s `messages.groupChat.visibleReplies` is not `"automatic"`, group `@` mentions hit `message_tool_only` upstream and the connector’s accumulated text stays empty, falling through to a cold fallback. The group-chat fallback message now embeds the exact `openclaw.json` fix snippet, and the warn-level log prints the full hint for operators. A new idle nudge in `onIdle` / `onError` proactively sends the same hint when no user-visible output happens this turn — covering the case where upstream never calls `deliver()` at all. DM fallback text unchanged.
38
+
39
+ ### 文档 / Docs
40
+ - 📚 **TROUBLESHOOTING (#589)** — 新增「群聊 @ 机器人只返回『任务执行完成(无文本输出)』」条目,给出 `messages.groupChat.visibleReplies = "automatic"` 修复步骤。
41
+ **TROUBLESHOOTING (#589)** — Added "Group `@` only returns 'Task done (no text output)'" entry with the `messages.groupChat.visibleReplies = "automatic"` fix.
42
+
43
+ - 📚 **`silenceDingtalkStreamConsoleNoise` 文案与函数命名收紧 (#592)** — `printLoadBalanceNoticeOnce` → `printConnectionNoticeOnce`,banner 文案改为「SDK noise 已过滤 + 真实重连 connector 自动处理 + 高频重连不正常」的预期;相关注释同步收紧,去掉对重连频率的强归因。
44
+ **Tighten copy & rename for `silenceDingtalkStreamConsoleNoise` (#592)** — Renamed `printLoadBalanceNoticeOnce` to `printConnectionNoticeOnce`; banner copy now reads "SDK noise filtered + real reconnects handled by connector + high-frequency reconnects are not expected"; related comments tightened.
45
+
46
+ - 📚 **清理 stale `90 秒` 文档残留 (#594)** — 文件头 docstring 与消息处理保活注释里的 `90 秒超时` → `20 秒超时`,与实际 `TIMEOUT_THRESHOLD` 保持一致。
47
+ **Clean up stale `90s` doc references (#594)** — File-header docstring and message-processing keepalive comments updated from `90s timeout` to `20s timeout`, matching the actual `TIMEOUT_THRESHOLD`.
48
+
49
+ ### 说明 / Notes
50
+ - 不改动 `startKeepAlive` / `setupPongListener` / `lastSocketAvailableTime` 写入时机的语义,不影响 #437 的心跳超时检测修复。
51
+ No changes to `startKeepAlive` / `setupPongListener` / `lastSocketAvailableTime` write semantics — does not affect the #437 heartbeat-timeout fix.
52
+
8
53
  ## [0.8.20] - 2026-04-28
9
54
 
10
55
  ### 修复 / Fixes
package/README.en.md CHANGED
@@ -104,11 +104,27 @@ openclaw gateway restart
104
104
 
105
105
  ---
106
106
 
107
+ ## Feedback & Community
108
+
109
+ Before filing an Issue / PR, please skim the pinned notice — clear context helps us debug faster:
110
+
111
+ - English: [[READ FIRST] #585](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/585)
112
+ - 中文:[【提 Issue 前必看】#584](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/584)
113
+
114
+ Quick routing:
115
+
116
+ - **Other DingTalk product / open-platform capabilities** (outside this connector) → [DingTalk developer ticket center](https://applink.dingtalk.com/client/aiAgent?assistantId=381bb5860c264d33bc184c51db776fa7&from=development)
117
+ - **OpenClaw upstream** (gateway, models, channels, etc.) → [openclaw/openclaw Issues](https://github.com/openclaw/openclaw/issues)
118
+ - **`dingtalk-workspace-cli` (dws)** (DingTalk workspace CLI behavior) → [dingtalk-workspace-cli Issues](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues)
119
+ - **This connector** → [GitHub Issues](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues)
120
+
121
+ ---
122
+
107
123
  ## Contributing
108
124
 
109
- Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues) or Pull Request.
125
+ PRs are welcome small, well-described changes with clear verification land fastest; for larger changes, please open an Issue first. Doc-style reference: [#514](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/pull/514/changes) · code-style reference: [#581](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/pull/581).
110
126
 
111
- For major changes, we recommend discussing with us first via an Issue.
127
+ > **Quarterly thanks**: each quarter, the top 3 PR contributors get a small gift or an invite to a DingTalk on-site visit (details per quarterly announcement). Thanks for co-building the community.
112
128
 
113
129
  ---
114
130
 
package/README.md CHANGED
@@ -104,11 +104,27 @@ openclaw gateway restart
104
104
 
105
105
  ---
106
106
 
107
+ ## 反馈与社区
108
+
109
+ 提 Issue / PR 前,建议先看一眼置顶说明(信息完整能帮我们更快定位问题):
110
+
111
+ - 中文:[【提 Issue 前必看】#584](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/584)
112
+ - English: [[READ FIRST] #585](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/585)
113
+
114
+ 简要分流:
115
+
116
+ - **钉钉其他业务能力**(非本 connector 范畴)→ [钉钉开发者侧工单入口](https://applink.dingtalk.com/client/aiAgent?assistantId=381bb5860c264d33bc184c51db776fa7&from=development)
117
+ - **OpenClaw 本体**(网关、模型、多通道等)→ [openclaw/openclaw Issues](https://github.com/openclaw/openclaw/issues)
118
+ - **dws(钉钉工作空间 CLI)** → [dingtalk-workspace-cli Issues](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues)
119
+ - **本仓库 connector** → [GitHub Issues](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues)
120
+
121
+ ---
122
+
107
123
  ## 贡献
108
124
 
109
- 欢迎社区贡献!如果你发现 Bug 或有功能建议,请提交 [Issue](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues) 或 Pull Request
125
+ 欢迎提交 Pull Request:改动越小、描述与验证步骤越清晰,越容易合入;较大改动建议先开 Issue 同步。文档类参考 [#514](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/pull/514/changes),代码类参考 [#581](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/pull/581)
110
126
 
111
- 对于较大的改动,建议先通过 Issue 与我们讨论。
127
+ > **季度致谢**:每个季度对 PR 贡献量居前 3 位的伙伴提供小礼品或钉钉参观交流机会(细则以当季公告为准),感谢大家对社区共建的支持。
112
128
 
113
129
  ---
114
130
 
@@ -6,7 +6,7 @@ import * as fs from "fs";
6
6
  *
7
7
  * 职责:
8
8
  * - 管理单个钉钉账号的 WebSocket 连接
9
- * - 实现应用层心跳检测(10 秒间隔,90 秒超时)
9
+ * - 实现应用层心跳检测(10 秒间隔,20 秒超时)
10
10
  * - 处理连接重连逻辑,带指数退避
11
11
  * - 消息去重(内置 Map,5 分钟 TTL)
12
12
  *
@@ -23,9 +23,30 @@ const TIMEOUT_THRESHOLD = 20 * 1e3;
23
23
  const BASE_BACKOFF_DELAY = 1e3;
24
24
  /** 最大退避时间(毫秒) */
25
25
  const MAX_BACKOFF_DELAY = 30 * 1e3;
26
+ let _streamNoiseSilenced = false;
27
+ function silenceDingtalkStreamConsoleNoise() {
28
+ if (_streamNoiseSilenced) return;
29
+ _streamNoiseSilenced = true;
30
+ const origConsoleInfo = console.info.bind(console);
31
+ console.info = (...args) => {
32
+ const first = args[0];
33
+ if (typeof first === "string") {
34
+ if (first === "Disconnecting.") return;
35
+ if (/^\[[^\]]+\] connect success$/.test(first)) return;
36
+ }
37
+ return origConsoleInfo(...args);
38
+ };
39
+ }
40
+ let _connectionNoticePrinted = false;
41
+ function printConnectionNoticeOnce() {
42
+ if (_connectionNoticePrinted) return;
43
+ _connectionNoticePrinted = true;
44
+ console.log("[dingtalk-connector] ℹ️ 上游 dingtalk-stream SDK 的 `Disconnecting.` / `connect success` 日志已由本插件过滤;真实重连(网络抖动、服务端推 disconnect 等)由 connector 自动处理。正常运行下不应看到高频(≤30s)周期性重连,如有请提 issue。");
45
+ }
26
46
  async function monitorSingleAccount(opts) {
27
47
  const { cfg, account, runtime, abortSignal, messageHandler, onStatusChange } = opts;
28
48
  const { accountId } = account;
49
+ silenceDingtalkStreamConsoleNoise();
29
50
  const clawdbotConfig = cfg;
30
51
  const log = runtime?.log;
31
52
  const { createLoggerFromConfig } = await import("./logger-BeHWErmX.mjs");
@@ -75,7 +96,7 @@ async function monitorSingleAccount(opts) {
75
96
  let messageProcessingKeepAliveTimer = null;
76
97
  /**
77
98
  * 标记消息处理开始,启动定期更新机制
78
- * 在消息处理期间,每 30 秒更新一次 lastSocketAvailableTime
99
+ * 在消息处理期间,定时刷新 lastSocketAvailableTime
79
100
  * 防止长时间处理(如复杂的 AI 任务)触发心跳超时
80
101
  */
81
102
  function markMessageProcessingStart() {
@@ -87,7 +108,7 @@ async function monitorSingleAccount(opts) {
87
108
  lastSocketAvailableTime = Date.now();
88
109
  logger.debug(`📝 消息处理中,更新 socket 可用时间`);
89
110
  }
90
- }, 30 * 1e3);
111
+ }, 15 * 1e3);
91
112
  logger.debug(`📝 消息处理开始,启动活跃标记定时器`);
92
113
  }
93
114
  /**
@@ -126,6 +147,9 @@ async function monitorSingleAccount(opts) {
126
147
  logger.info(`已断开旧连接`);
127
148
  }
128
149
  await client.connect();
150
+ setupPongListener();
151
+ setupMessageListener();
152
+ setupCloseListener();
129
153
  if (!await new Promise((resolve) => {
130
154
  const timeout = setTimeout(() => {
131
155
  resolve(false);
@@ -180,6 +204,7 @@ async function monitorSingleAccount(opts) {
180
204
  try {
181
205
  const msg = JSON.parse(data);
182
206
  if (msg.type === "SYSTEM" && msg.headers?.topic === "disconnect") {
207
+ logger.debug(`收到服务端 disconnect topic,即将重连`);
183
208
  if (!isStopped && !isReconnecting) doReconnect(true).catch((err) => {
184
209
  logger.error(`[${accountId}] 重连失败:${err.message}`);
185
210
  });
@@ -263,9 +288,6 @@ async function monitorSingleAccount(opts) {
263
288
  if (client.socket) client.socket.removeAllListeners();
264
289
  logger.debug(`Connection 已停止`);
265
290
  }
266
- setupPongListener();
267
- setupMessageListener();
268
- setupCloseListener();
269
291
  return new Promise(async (resolve, reject) => {
270
292
  if (abortSignal) {
271
293
  const onAbort = async () => {
@@ -367,9 +389,13 @@ async function monitorSingleAccount(opts) {
367
389
  };
368
390
  try {
369
391
  await client.connect();
392
+ setupPongListener();
393
+ setupMessageListener();
394
+ setupCloseListener();
370
395
  logger.info(`Connected to DingTalk Stream successfully`);
371
396
  logger.info(`PID: ${process.pid}`);
372
- logger.info(`✅ 自定义 keepAlive: true (10 秒心跳,90 秒超时), 指数退避重连`);
397
+ logger.info(`✅ 自定义 keepAlive: true (10 秒心跳,20 秒超时), 指数退避重连`);
398
+ printConnectionNoticeOnce();
373
399
  onStatusChange?.({
374
400
  connected: true,
375
401
  lastConnectedAt: Date.now()
@@ -23,7 +23,7 @@ var entry_bundled_default = defineBundledChannelEntry({
23
23
  exportName: "setDingtalkRuntime"
24
24
  },
25
25
  async registerFull(api) {
26
- const { registerGatewayMethods } = await import("./gateway-methods-DtdiDpYK.mjs");
26
+ const { registerGatewayMethods } = await import("./gateway-methods-B0_tBGPn.mjs");
27
27
  registerGatewayMethods(api);
28
28
  }
29
29
  });
@@ -0,0 +1,2 @@
1
+ import { t as registerGatewayMethods } from "./gateway-methods-BNuB2wXl.mjs";
2
+ export { registerGatewayMethods };
@@ -1,7 +1,7 @@
1
1
  import { a as resolveDingtalkAccount, t as listDingtalkAccountIds } from "./accounts-CF4oK_HZ.mjs";
2
2
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
3
- import { r as getAccessToken, t as DINGTALK_API } from "./utils-CIfI_3Jh.mjs";
4
- import { c as prepareMultiBotMentions, i as sendProactive, s as buildBotMentionTable, u as finishAICard } from "./messaging-CyIJY4h2.mjs";
3
+ import { i as DINGTALK_API, o as getAccessToken } from "./utils-QEvgZ2uM.mjs";
4
+ import { c as prepareMultiBotMentions, i as sendProactive, s as buildBotMentionTable, u as finishAICard } from "./messaging-DQwrrd68.mjs";
5
5
  import { c as getUnionId, d as recallEmotionReply } from "./utils-legacy-CALCPP1t.mjs";
6
6
  //#region src/docs.ts
7
7
  var DingtalkDocsClient = class {
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-b4xvqwW6.mjs";
2
- import { t as registerGatewayMethods } from "./gateway-methods-DI8lkjSd.mjs";
1
+ import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-BphH7_vR.mjs";
2
+ import { t as registerGatewayMethods } from "./gateway-methods-BNuB2wXl.mjs";
3
3
  //#region index.ts
4
4
  /**
5
5
  * 检测同一 plugin id 在多个路径被加载的情况。
@@ -1,5 +1,5 @@
1
1
  import { n as dingtalkOapiHttp, t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
2
- import { n as DINGTALK_OAPI } from "./utils-CIfI_3Jh.mjs";
2
+ import { a as DINGTALK_OAPI } from "./utils-QEvgZ2uM.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import * as fs from "fs";
5
5
  import * as path from "path";
@@ -287,7 +287,7 @@ async function extractAudioDuration(filePath, log) {
287
287
  */
288
288
  async function sendVideoMessage(config, sessionWebhook, fileName, mediaId, log, metadata) {
289
289
  try {
290
- const token = await (await import("./utils-DY1gFCdU.mjs")).getAccessToken(config);
290
+ const token = await (await import("./utils-BqUoUOwd.mjs")).getAccessToken(config);
291
291
  const videoMessage = {
292
292
  msgtype: "video",
293
293
  video: {
@@ -315,8 +315,8 @@ async function sendVideoMessage(config, sessionWebhook, fileName, mediaId, log,
315
315
  */
316
316
  async function sendVideoProactive(config, target, videoMediaId, picMediaId, metadata, log) {
317
317
  try {
318
- const token = await (await import("./utils-DY1gFCdU.mjs")).getAccessToken(config);
319
- const { DINGTALK_API } = await import("./utils-DY1gFCdU.mjs");
318
+ const token = await (await import("./utils-BqUoUOwd.mjs")).getAccessToken(config);
319
+ const { DINGTALK_API } = await import("./utils-BqUoUOwd.mjs");
320
320
  const msgParam = {
321
321
  duration: metadata?.duration.toString() || "60000",
322
322
  videoMediaId,
@@ -361,8 +361,8 @@ async function sendVideoProactive(config, target, videoMediaId, picMediaId, meta
361
361
  */
362
362
  async function sendAudioProactive(config, target, fileName, mediaId, log, durationMs) {
363
363
  try {
364
- const token = await (await import("./utils-DY1gFCdU.mjs")).getAccessToken(config);
365
- const { DINGTALK_API } = await import("./utils-DY1gFCdU.mjs");
364
+ const token = await (await import("./utils-BqUoUOwd.mjs")).getAccessToken(config);
365
+ const { DINGTALK_API } = await import("./utils-BqUoUOwd.mjs");
366
366
  const msgParam = {
367
367
  mediaId,
368
368
  duration: durationMs && durationMs > 0 ? durationMs.toString() : "60000"
@@ -399,8 +399,8 @@ async function sendAudioProactive(config, target, fileName, mediaId, log, durati
399
399
  */
400
400
  async function sendFileProactive(config, target, fileInfo, mediaId, log) {
401
401
  try {
402
- const token = await (await import("./utils-DY1gFCdU.mjs")).getAccessToken(config);
403
- const { DINGTALK_API } = await import("./utils-DY1gFCdU.mjs");
402
+ const token = await (await import("./utils-BqUoUOwd.mjs")).getAccessToken(config);
403
+ const { DINGTALK_API } = await import("./utils-BqUoUOwd.mjs");
404
404
  const resolvedFileName = fileInfo.fileName || path.basename(fileInfo.path);
405
405
  const msgParam = {
406
406
  mediaId,
@@ -1,2 +1,2 @@
1
- import { a as processVideoMarkers, i as processRawMediaPaths, l as toLocalPath, s as sendFileProactive } from "./media-DUMfXnwJ.mjs";
1
+ import { a as processVideoMarkers, i as processRawMediaPaths, l as toLocalPath, s as sendFileProactive } from "./media-BRqGsKUB.mjs";
2
2
  export { processRawMediaPaths, processVideoMarkers, sendFileProactive, toLocalPath };
@@ -1,10 +1,10 @@
1
- import { u as uploadMediaToDingTalk } from "./media-DUMfXnwJ.mjs";
1
+ import { u as uploadMediaToDingTalk } from "./media-BRqGsKUB.mjs";
2
2
  import { a as resolveDingtalkAccount } from "./accounts-CF4oK_HZ.mjs";
3
- import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-b4xvqwW6.mjs";
3
+ import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-BphH7_vR.mjs";
4
4
  import { n as createLoggerFromConfig } from "./logger-BDWwViGT.mjs";
5
5
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
6
- import { i as getOapiAccessToken } from "./utils-CIfI_3Jh.mjs";
7
- import { a as sendTextMessage, d as isQpsLimitError, f as streamAICard, i as sendProactive, l as createAICardForTarget, r as sendMessage, t as sendMarkdownMessage, u as finishAICard } from "./messaging-CyIJY4h2.mjs";
6
+ import { n as groupChatLacksVisibleRepliesAutomatic, r as pickEmptyReplyFallbackText, s as getOapiAccessToken, t as emptyGroupReplyLogHint } from "./utils-QEvgZ2uM.mjs";
7
+ import { a as sendTextMessage, d as isQpsLimitError, f as streamAICard, i as sendProactive, l as createAICardForTarget, r as sendMessage, t as sendMarkdownMessage, u as finishAICard } from "./messaging-DQwrrd68.mjs";
8
8
  import { a as QUEUE_BUSY_ACK_PHRASES, n as normalizeSlashCommand, t as buildSessionContext } from "./session-DJ4jYqPv.mjs";
9
9
  import { d as recallEmotionReply, o as getAccessToken, r as addEmotionReply, s as getOapiAccessToken$1, t as DINGTALK_API } from "./utils-legacy-CALCPP1t.mjs";
10
10
  import "./chunk-upload-6p9cf3UB.mjs";
@@ -222,6 +222,10 @@ function createDingtalkReplyDispatcher(params) {
222
222
  let currentCardTarget = null;
223
223
  let accumulatedText = "";
224
224
  const deliveredFinalTexts = /* @__PURE__ */ new Set();
225
+ /** 本轮是否已向用户发出过可见回复(final / 流式更新 / 错误兜底等) */
226
+ let outboundUserVisibleThisTurn = false;
227
+ /** 防止 onIdle / onError 重复发送 visibleReplies 配置指引 */
228
+ let idleConfigNudgeSent = false;
225
229
  let asyncModeFullResponse = "";
226
230
  const detectedDwsProducts = /* @__PURE__ */ new Set();
227
231
  const DWS_PRODUCT_PATTERN = /\bdws\s+(aitable|calendar|chat|contact|todo|approval|attendance|report|ding|workbench|devdoc)\b/;
@@ -257,6 +261,7 @@ function createDingtalkReplyDispatcher(params) {
257
261
  });
258
262
  deliveredErrorTypes.add(errorKey);
259
263
  lastErrorTime = now;
264
+ outboundUserVisibleThisTurn = true;
260
265
  log.info(`[DingTalk][Fallback] ✅ 错误消息发送成功`);
261
266
  } catch (fallbackErr) {
262
267
  log.error(`[DingTalk][Fallback] ❌ 错误消息发送失败:${fallbackErr.message}`);
@@ -301,6 +306,7 @@ function createDingtalkReplyDispatcher(params) {
301
306
  log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
302
307
  currentCardTarget = preCreatedCard;
303
308
  accumulatedText = "";
309
+ outboundUserVisibleThisTurn = true;
304
310
  return;
305
311
  }
306
312
  log.info(`[DingTalk][startStreaming] 开始创建 AI Card...`);
@@ -338,8 +344,10 @@ function createDingtalkReplyDispatcher(params) {
338
344
  try {
339
345
  let finalText = accumulatedText;
340
346
  if (!finalText.trim()) {
341
- finalText = "✅ 任务执行完成(无文本输出)";
342
- log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案`);
347
+ const isGroup = !isDirect;
348
+ finalText = pickEmptyReplyFallbackText(isGroup);
349
+ log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案 (isGroup=${isGroup})`);
350
+ if (isGroup) log.warn?.(`[DingTalk][closeStreaming] ${emptyGroupReplyLogHint()}`);
343
351
  }
344
352
  const oapiToken = await getOapiAccessToken(account.config);
345
353
  const target = isDirect ? {
@@ -356,7 +364,7 @@ function createDingtalkReplyDispatcher(params) {
356
364
  finalText = await processAudioMarkers(finalText, "", account.config, oapiToken, log, true, target);
357
365
  finalText = await uploadAndReplaceFileMarkers(finalText, "", account.config, oapiToken, log, true, target);
358
366
  log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
359
- const { processRawMediaPaths } = await import("./media-DEuF7r3G.mjs");
367
+ const { processRawMediaPaths } = await import("./media-DD7Rlljd.mjs");
360
368
  finalText = await processRawMediaPaths(finalText, account.config, oapiToken, log, target);
361
369
  log.info(`[DingTalk][closeStreaming] processRawMediaPaths 处理完成`);
362
370
  } else log.warn(`[DingTalk][closeStreaming] oapiToken 为空,跳过媒体处理`);
@@ -389,6 +397,7 @@ function createDingtalkReplyDispatcher(params) {
389
397
  log.info(`[DingTalk][closeStreaming] 准备调用 finishAICard,文本长度=${finalText.length}`);
390
398
  log.debug(`[DingTalk][closeStreaming] 最终发送内容长度=${finalText.length}`);
391
399
  await finishAICard(cardSnapshot, finalText, account.config, log);
400
+ outboundUserVisibleThisTurn = true;
392
401
  log.info(`[DingTalk][closeStreaming] ✅ AI Card 关闭成功`);
393
402
  } catch (error) {
394
403
  log.error(`[DingTalk][closeStreaming] ❌ AI Card 关闭失败:${error?.message || String(error)}`);
@@ -399,6 +408,7 @@ function createDingtalkReplyDispatcher(params) {
399
408
  useMarkdown: true,
400
409
  log: params.runtime.log
401
410
  });
411
+ outboundUserVisibleThisTurn = true;
402
412
  log.info(`[DingTalk][closeStreaming] ✅ 降级发送成功`);
403
413
  } catch (sendErr) {
404
414
  log.error(`[DingTalk][closeStreaming] ❌ 降级发送失败:${sendErr.message}`);
@@ -407,12 +417,50 @@ function createDingtalkReplyDispatcher(params) {
407
417
  accumulatedText = "";
408
418
  }
409
419
  };
420
+ /**
421
+ * 群聊且 OpenClaw 未配置 `messages.groupChat.visibleReplies=automatic` 时,
422
+ * 若本轮结束时仍没有任何用户可见输出(上游可能未调用空 final 的 deliver),
423
+ * 补发与空 final 一致的配置指引,避免只有「思考中」却无声。
424
+ */
425
+ const maybeSendGroupVisibleRepliesIdleNudge = async () => {
426
+ if (isDirect) return;
427
+ if (!groupChatLacksVisibleRepliesAutomatic(cfg)) return;
428
+ if (asyncMode) return;
429
+ if (outboundUserVisibleThisTurn) return;
430
+ if (idleConfigNudgeSent) return;
431
+ idleConfigNudgeSent = true;
432
+ log.info(`[DingTalk][idleNudge] 本轮无用户可见回复且群聊未启用 visibleReplies=automatic,发送配置指引`);
433
+ try {
434
+ const text = pickEmptyReplyFallbackText(true);
435
+ log.warn(`[DingTalk][idleNudge] ${emptyGroupReplyLogHint()}`);
436
+ for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) if (isTextMode) if (groupReplyMode === "markdown") await sendMarkdownMessage(account.config, sessionWebhook, chunk.split("\n")[0]?.replace(/^[#*\s\->]+/, "").slice(0, 20) || "Message", chunk, {
437
+ cfg,
438
+ detectBareAliases: true
439
+ });
440
+ else await sendTextMessage(account.config, sessionWebhook, chunk, {
441
+ cfg,
442
+ detectBareAliases: true
443
+ });
444
+ else await sendMessage(account.config, sessionWebhook, chunk, {
445
+ useMarkdown: true,
446
+ log: params.runtime.log,
447
+ cfg,
448
+ detectBareAliases: true
449
+ });
450
+ outboundUserVisibleThisTurn = true;
451
+ log.info(`[DingTalk][idleNudge] ✅ 配置指引已发送`);
452
+ } catch (e) {
453
+ log.error(`[DingTalk][idleNudge] 发送失败: ${e?.message || e}`);
454
+ }
455
+ };
410
456
  const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
411
457
  ...prefixOptions,
412
458
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
413
459
  onReplyStart: () => {
414
460
  log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
415
461
  deliveredFinalTexts.clear();
462
+ outboundUserVisibleThisTurn = false;
463
+ idleConfigNudgeSent = false;
416
464
  if (streamingEnabled) startStreaming();
417
465
  typingCallbacks.onActive?.();
418
466
  },
@@ -432,7 +480,7 @@ function createDingtalkReplyDispatcher(params) {
432
480
  const oapiToken = await getOapiAccessToken(account.config);
433
481
  if (oapiToken) {
434
482
  log.info(`[DingTalk][deliver] 检测到 final 响应,准备处理裸露文件路径`);
435
- const { processRawMediaPaths } = await import("./media-DEuF7r3G.mjs");
483
+ const { processRawMediaPaths } = await import("./media-DD7Rlljd.mjs");
436
484
  text = await processRawMediaPaths(text, account.config, oapiToken, log, target);
437
485
  log.info(`[DingTalk][deliver] 裸露文件路径处理完成`);
438
486
  }
@@ -443,8 +491,10 @@ function createDingtalkReplyDispatcher(params) {
443
491
  const hasText = Boolean(text.trim());
444
492
  const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
445
493
  if (info?.kind === "final" && !hasText) {
446
- text = "✅ 任务执行完成(无文本输出)";
447
- log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案`);
494
+ const isGroup = !isDirect;
495
+ text = pickEmptyReplyFallbackText(isGroup);
496
+ log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案 (isGroup=${isGroup})`);
497
+ if (isGroup) log.warn?.(`[DingTalk][deliver] ${emptyGroupReplyLogHint()}`);
448
498
  }
449
499
  if (!(Boolean(text.trim()) && !skipTextForDuplicateFinal)) {
450
500
  log.info(`[DingTalk][deliver] 跳过发送:hasText=${hasText}, skipTextForDuplicateFinal=${skipTextForDuplicateFinal}`);
@@ -468,6 +518,7 @@ function createDingtalkReplyDispatcher(params) {
468
518
  lastUpdateTime = now;
469
519
  try {
470
520
  await streamAICard(currentCardTarget, text, false, account.config, log);
521
+ outboundUserVisibleThisTurn = true;
471
522
  log.info(`[DingTalk][deliver] ✅ block 更新到 AI Card 成功`);
472
523
  } catch (streamErr) {
473
524
  log.error(`[DingTalk][deliver] ❌ block 更新 AI Card 失败:${streamErr.message}`);
@@ -504,6 +555,7 @@ function createDingtalkReplyDispatcher(params) {
504
555
  cfg,
505
556
  detectBareAliases: true
506
557
  });
558
+ outboundUserVisibleThisTurn = true;
507
559
  log.info(`[DingTalk][deliver] ✅ 非流式发送成功`);
508
560
  deliveredFinalTexts.add(text);
509
561
  } catch (error) {
@@ -519,11 +571,13 @@ function createDingtalkReplyDispatcher(params) {
519
571
  params.runtime.error?.(`dingtalk[${account.accountId}] ${info.kind} reply failed: ${String(error)}`);
520
572
  await closeStreaming();
521
573
  typingCallbacks.onIdle?.();
574
+ await maybeSendGroupVisibleRepliesIdleNudge();
522
575
  },
523
576
  onIdle: async () => {
524
577
  log.info(`[DingTalk][onIdle] 回复空闲,关闭 AI Card`);
525
578
  typingCallbacks.onIdle?.();
526
579
  await closeStreaming();
580
+ await maybeSendGroupVisibleRepliesIdleNudge();
527
581
  },
528
582
  onCleanup: () => {
529
583
  log.info(`[DingTalk][onCleanup] 清理回调`);
@@ -559,6 +613,7 @@ function createDingtalkReplyDispatcher(params) {
559
613
  lastUpdateTime = now;
560
614
  try {
561
615
  await streamAICard(currentCardTarget, displayContent, false, account.config, log);
616
+ outboundUserVisibleThisTurn = true;
562
617
  log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
563
618
  } catch (err) {
564
619
  if (isQpsLimitError(err)) log.warn(`[DingTalk][onPartialReply] AI Card 流式更新遇到 QPS 限流,已在内部退避重试;本次跳过,等待下一次 partial 更新补齐内容`);
@@ -1696,13 +1751,19 @@ async function handleDingTalkMessageInternal(params) {
1696
1751
  finalText = await processVideoMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
1697
1752
  finalText = await processAudioMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
1698
1753
  finalText = await uploadAndReplaceFileMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
1699
- const { processRawMediaPaths } = await import("./media-DEuF7r3G.mjs");
1754
+ const { processRawMediaPaths } = await import("./media-DD7Rlljd.mjs");
1700
1755
  finalText = await processRawMediaPaths(finalText, config, oapiToken, log, mediaTarget);
1701
1756
  }
1702
- const textToSend = finalText.trim() || "✅ 任务执行完成(无文本输出)";
1757
+ let textToSend = finalText.trim();
1758
+ if (!textToSend) {
1759
+ const isGroup = !isDirect;
1760
+ textToSend = pickEmptyReplyFallbackText(isGroup);
1761
+ if (isGroup) log?.warn?.(`[DingTalk][asyncMode] ${emptyGroupReplyLogHint()}`);
1762
+ }
1763
+ const title = textToSend.split("\n")[0]?.replace(/^[#*\s\->]+/, "").trim() || "消息";
1703
1764
  await sendProactive(config, proactiveTarget, textToSend, {
1704
1765
  msgType: "markdown",
1705
- title: textToSend.split("\n")[0]?.replace(/^[#*\s\->]+/, "").trim() || "消息",
1766
+ title,
1706
1767
  useAICard: false,
1707
1768
  fallbackToNormal: true,
1708
1769
  log