github soimy/openclaw-channel-dingtalk v3.8.1

2 hours ago

v3.8.1 发布说明

本次 patch 版本包含两项修复:

  1. AI Card 长回复渲染为空白卡片Issue #615):卡片模式下最终回复超过约 3000 个中文字符时,钉钉接口返回成功但客户端渲染为空白卡片。本版本按 Unicode 码点引入两级分片保护(单卡多 block + 多卡兜底),并把分片策略统一到 src/shared/message-chunker.ts,同时补齐此前没有长度保护的主动消息链路。
  2. markdown 模式长回复的多条消息显示顺序错乱Issue #626,修复见 PR #627):钉钉不能编辑已发消息,流式回复只能按增量逐条追加,同一秒内到达的多条消息在客户端可能被显示成乱序(实测 6 条出现 1,2,3,5,4,6)。本版本新增 outboundSendIntervalMs 会话级发送间隔,默认 1000,把连续发送摊到不同秒。

同时修正 ClawHub 发布 workflow 的"假绿"问题:v3.8.0 发布时该 job 在版本实际可解析前约 230 秒就已报成功。

Important

本版本新增一个配置项 channels.dingtalk.outboundSendIntervalMs 并默认启用 1000(毫秒)
这是行为变更:同一会话内长度不足以装进一条消息的回复,现在会按约 1 秒间隔分多条发出(6 条回复约慢 5 秒)。
这一代价换来的是显示顺序正确;若你更在意延迟、可以接受偶发乱序,把它设为 0 即完全恢复 v3.8.0 的发送行为。
最低宿主版本保持 OpenClaw 2026.8.1 不变,除该键外无其它配置迁移。

最新版本入口latest.md

⚙️ 配置项变更

配置项 v3.8.0 v3.8.1 说明
outboundSendIntervalMs 不存在 1000 同一会话内两次出站消息之间的最小间隔(毫秒),范围 0100000 表示不节流
  • 作用范围限于同一会话:不同会话/不同账号互不影响;每个 HTTP 消息消耗一个时间槽,因此"多次调用"(流式增量尾巴)与"一次调用内多分片"(超过 3800 码点的拆分)都会被覆盖,且不会叠加成双倍间隔
  • 首次发送立即发出,不等待;后续消息只补齐"距上一槽的剩余时间",不会硬等一整格(实测等待 814944 ms,差额即上一次 API 调用耗时)。
  • 不影响卡片流式更新:AI Card 的 PUT /v1.0/card/streaming 不经过节流路径,卡片刷新节奏仍由 cardStreamInterval 控制。
  • 多账号下按账号 + 会话隔离,可用账号级配置覆盖渠道级默认值。
  • 配置示例:
{
  "channels": {
    "dingtalk": {
      "messageType": "markdown",
      // 默认 1000;设为 0 恢复 v3.8.0 的连续发送行为
      "outboundSendIntervalMs": 1000
    }
  }
}

🛠 修复

markdown 模式多条消息显示顺序错乱

  • 问题现象Issue #626,由本轮 v3.8.1 真机验证发现并归档)

    • messageType: "markdown" 下,钉钉无法编辑已发消息,插件按 reply-strategy-markdown.tscomputeIncrementalSuffix() 把流式回复切成增量尾巴逐条追加。
    • 增量较多时多条消息在同一秒内连续发出,钉钉客户端对它们的排序不稳定:实测 6 条消息显示为 1,2,3,5,4,6(第 4/5 条互换)。内容不丢失、不重复,但阅读顺序被打断。
    • 该缺陷先于本版本存在,不是 Issue #615 修复引入的;本版本一并修好。
  • 根因定位(真机对照实验,两次回复正文完全一致:6392 字符、单行、1..1500

    • 发送侧证据:日志中 6 轮 Session webhook response → QuotedRef][Persist] 严格交替,每次 await 发完才记下一个引用,answer prefix drift 告警为 0发送有序、内容无损,且 computeIncrementalSuffix() 只在 current.startsWith(prev) 时输出差值,构造上必然有序。
    • 6 条不是分片器产生的:分片器(上限 3800)对该 6392 字符单行正文只产出 2 条;6 条的条数由宿主吐出的 block 数决定。
    • 对照结果:无节流时 6 条全部落在同一秒 → 显示乱序;启用 1000ms 后摊到 6 个不同秒 → 显示顺序恢复正常。
    • 结论:根因是同一秒内多条消息的时间戳打平
  • 修复方式PR #627,by @soimy

    • 新增 src/shared/outbound-throttle.ts:会话级出站闸门 runThrottledOutboundSend()。同一会话的连续发送按 outboundSendIntervalMs 间隔,并串行通过该会话的发送链——下一条只在上一条 settle 之后才被准入。
    • 为什么不只做"间隔":客户端看到的是完成顺序,不是准入顺序。只错开开始时间的话,慢的发送仍会与下一条重叠并可能后完成,等于没修。因此闸门接管的是发送本身,间隔同时从"上一条开始"起算:快速发送保持 ~1s 节奏,慢速发送顺延而非重叠。
    • 单条发送失败只影响它自己的调用方,链不会中断——一次失败不会卡死该会话后续所有发送。
    • 作用域按账号 + 会话隔离:不同账号即使复用同一 conversationId 也不会互相等待;拿不到会话标识时直接跳过节流,避免把不相关的会话串到同一个闸门上。
    • 接入出站边界而非各调用方:sendBySession() 分片循环、sendProactiveTextOrMarkdown() 分片循环、sendProactiveCardText()(因此同时覆盖 sendSplitProactiveCards() 的多卡兜底)。每个 HTTP 消息消耗一次准入,所以"多次调用"(流式增量尾巴)与"一次调用内多分片"(>3800 码点)都被覆盖,且不会叠加成双倍间隔。
    • 每次真正等待都会输出可核对日志:[DingTalk] Outbound send throttled scope=… waitMs=… intervalMs=…
  • 已知边界

    • 1000ms保守实测值,不是最小有效间隔:只验证了 1000ms 有效,没有做下界二分。"必须 ≥1000ms"是与秒级时间戳粒度一致的推断。
    • 该缺陷本身是概率性的(首次也只错了一对相邻消息),真机验证为单次通过。
    • 多卡兜底与主动消息分片路径的节流已接入并有单元测试覆盖,但未做真机故障注入验证
    • 真机验证覆盖的是顺序发送路径:真实回复链路按顺序 await 每次发送,因此真机不会触发并发场景。并发下的顺序与间隔由单元测试覆盖(含"慢发送不可被后发超越""同一 tick 三条排队仍按间隔"),未做真机并发验证

AI Card 长回复不再渲染为空白卡片

  • 问题现象Issue #615,by @jznrhnn

    • messageType: "card" 模式下,最终回复超过约 3000 个中文字符时,钉钉客户端显示空白消息卡片;较短回复正常。
    • 最终提交链路把完整回答放进单个 { type: 0, markdown: content } block 并通过 blockList 一次性提交,代码中没有单 block 长度校验、分块或超限降级。
    • 普通 Markdown 消息虽有约 3800 字符的分片逻辑,但该逻辑没有应用到 AI Card block
    • 属于"接口成功、客户端不渲染"的静默失败:插件侧无法从 HTTP status 判断内容是否真的渲染成功。
  • 单卡多 block 分片PR #624,by @soimy

    • 新增 splitCardBlocks()blockList 中任何超过 CARD_BLOCK_CHUNK_LIMIT2500 码点,取值保守地低于实测约 3000 中文字符的空白渲染阈值)的 markdown block 会被拆成多个 block 一次提交,非 markdown 字段按片保留。
    • 在两个 blockList 产出点统一应用:renderTimelineAsBlocks()(经由 queueRender(),覆盖 finalize、/stop 与流式 blockList 更新三条路径)与 sendProactiveCardText()
    • 超长回归用例确认:超限回答渲染为多个有界 block,内容按序完整保留。
  • 多卡兜底:卡片失败时优先补发新卡,再降级 Markdown

    • 新增 sendSplitProactiveCards():用同一套共享分片器按 CARD_BLOCK_CHUNK_LIMIT(2500 码点)切分长文本,每片一张新 AI Card 顺序投递,保持阅读顺序。页码加在 statusLine 头部,不占用正文长度预算。
    • 页码 page(n/m) 追加到原 statusLine 头部而不是替换它,所以模型名、effort 等任务元数据仍然可见。
    • reply-strategy-card 的 finalize 分支:卡片为 FAILED 时优先走拆多卡,只有在拿不到 conversationId 或新卡也无法创建时才降级为 Markdown;commitAICardBlocks() 抛错的 finalize catch 分支同样先用多卡救援(并防止对已 FINISHED 的卡片重复救援)。
  • 不丢内容、不重复投递

    • sendSplitProactiveCards() 返回 unsentChunks,调用方只补发确实没有投递出去的部分(零片成功时补发全文),避免"接口半成功"导致答案被静默截断。
    • 未投递分片逐片单独重发,不做拼接:用任何分隔符重新拼接都会引入原文中不存在的字符(例如无换行的超长 URL、压缩过的单行文本)。
    • 已部分投递(sent > 0)时不再整段重发 Markdown,避免用户收到重复内容。
  • 内容提取保持无损

    • getRenderedContent() 改为读取未分片的原始 timeline;展示层分片(splitCardBlocks)只作用于 blockList 产出点(getRenderedBlocks() / queueRender())。因此 contentcopy_content 重建出来的回答不会出现伪造的 \n\n 分隔符或被重开的代码围栏。
  • 分片器保真规则src/shared/message-chunker.ts

    • 按 Unicode 码点计量,不会切断 emoji / 代理对。
    • 代码围栏感知:跨片时自动闭合并在下一片重开围栏;重开的围栏后必定补换行,内容不会落到围栏 info-string 行上而停止渲染。
    • 无换行超长单行(超长 URL、压缩单行)先按码点硬切,硬切产生的续片重新接回时不插入换行,不伪造原文没有的断行。
    • 会话 webhook 与主动消息走同一套策略,MESSAGE_CHUNK_LIMIT 维持 3800 码点不变。
  • 主动消息链路补上长度保护

    • 主动发送(oToMessages / groupMessages)此前把无界内容塞进单个 sampleMarkdown / sampleText 请求,现在同样经由共享分片器切分。
  • 文档更新:docs/user/features/ai-card.md 新增「长回复分片」章节,说明两级分片、页码展示与降级顺序。

已知边界

  • CARD_BLOCK_CHUNK_LIMIT(2500)与 MESSAGE_CHUNK_LIMIT(3800)是经验阈值:空白渲染边界为实测约 3000 中文字符,钉钉侧未公开该限制,因此取值偏保守而非精确。
  • 单行内部包含 ``` 的超长行仍可能在硬切时切断围栏标记,导致围栏状态漂移;当前判定为可接受的已知折衷,在确认真实影响前不做处理。
  • 分片只保证内容完整投递,不改变卡片整体形态:一次超长回复可能呈现为多张卡片。

🧩 工程与 CI

  • ClawHub 发布不再"假绿"(commit 48224c7,by @soimy

    • 背景:v3.8.0 发布暴露了该问题——clawhub package publish --json 在 ClawHub 刚把发布排进队列时即返回 status=pending-publication / publicationStatus=pending,步骤随即打印"ClawHub accepted the release"并报绿,而 GET /versions/3.8.0 约 230 秒后才停止返回 404。在审计不再阻断发布之后,"run 绿了"就更不能作为"已经发出去"的证据。
    • clawhub-publish.yml 的发布调用加上 --wait --wait-timeout 2400,让退出码承载发布判定blocked / failed / expired / 超时均为非 0)。
    • job timeout-minutes 由 30 提到 50:否则等待会先被 job 超时掐死,等于让该修复变成空操作。
    • 额外断言捕获到的 JSON 中终态 publicationStatus === 'published',防止将来 CLI 改动丢掉 --wait 语义后静默退回"发了就不管"。
    • 测试把该接线与超时关系固定住:job 超时小于等于 --wait-timeout 会直接让测试失败。
  • 测试:本区间新增分片器、卡片分块、多卡兜底(含零片救援、部分分片补发、无换行硬切保真、围栏重开换行)与无损内容提取的回归用例;src/shared/message-chunker.ts 语句覆盖率 95.6%。已知待办 Issue #622(拆分超过 800 行的测试文件)仍为 open,不在本版本处理。

📋 升级检查清单

  • 宿主版本 ≥ OpenClaw 2026.8.1(未变化)
  • 确认可接受 outboundSendIntervalMs 默认 1000 带来的延迟:长度需分多条发出的回复现在按约 1 秒间隔发送(6 条约慢 5 秒),换来显示顺序正确
  • 若长回复延迟不可接受、可接受偶发乱序:设 outboundSendIntervalMs: 0 恢复 v3.8.0 的连续发送行为
  • 若使用 messageType: "markdown":升级后用一条超过 3800 码点的回复确认多条消息按顺序显示
  • 如在卡片模式下依赖"一次回复一张卡片"的形态,请留意超长回复现在可能拆为多张卡片(内容不丢失;卡片带 page(n/m) 页码,即使显示颠倒也能看出正确页序)
  • 升级后建议用一条超过 3000 字的回复确认卡片正常渲染

🤝 鸣谢

Issue #626(markdown 模式多条消息显示顺序)不是外部报告:它由本轮 v3.8.1 真机验证在 markdown 模式下发现——1..1500 的探针回复被拆成 6 条,客户端显示为 1,2,3,5,4,6。随后通过"发送侧日志严格交替 + 正文无损 + 客户端显示顺序逐条回报"确认发送有序、显示错序,再用 1000ms 节流对照实验坐实根因为同一秒内时间戳打平。相关内容一并记为已知边界:该缺陷具有概率性,验证为单次通过,且未测定最小有效间隔。


发布页面https://github.com/soimy/openclaw-channel-dingtalk/releases/tag/v3.8.1

Full Changelog: v3.8.0...v3.8.1

Don't miss a new openclaw-channel-dingtalk release

NewReleases is sending notifications on new releases.