乐于分享
好东西不私藏

钉钉openclaw插件调研及本地案例学习系列-消息表情反馈功能

钉钉openclaw插件调研及本地案例学习系列-消息表情反馈功能

点击上方蓝字关注我们

一、背景与概述

1.1 调研背景

调研目的

在研发内部 AI Chat 项目时,需要实现一个用户体验优化功能:当用户发送消息后,系统能够立即在消息上贴上回复表情,向用户反馈消息已被接收并正在处理。这个功能可以提升用户体验,让用户无需等待就能知道消息的状态,避免重复发送或不确定消息是否已被接收的情况。

场景需求

  • • 用户发送消息后,立即在消息上贴上「处理中」或「思考中」表情
  • • 消息处理完成后,自动撤回表情或更新为「已完成」状态
  • • 支持队列场景:当系统繁忙时,消息进入队列时也应贴上表情反馈
  • • 表情操作失败不应影响主消息处理流程

开源项目来源

本次调研基于钉钉官方开源项目:

  • • 官方仓库: https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector
  • • 项目描述: DingTalk Channel for OpenClaw AI Agent Platform
  • • 项目类型: 钉钉机器人集成插件,支持 Stream 模式消息接收

该项目已经实现了完整的表情反馈功能,包括贴表情、撤回表情、队列优化等,是我们学习和参考的优秀案例。

1.2 功能概述

钉钉openclaw-connector实现了一个用户体验优化功能:当用户发送消息后,机器人会立即在消息上贴上「🤔思考中」表情,用于表示机器人正在处理该消息。处理完成后,机器人会自动撤回这个表情。这个功能让用户能够直观地知道消息是否被机器人接收和处理,提升了交互体验。

1.3 功能特性

  • • 即时反馈: 消息发送后立即贴表情,用户无需等待
  • • 自动撤回: 消息处理完成后自动移除表情
  • • 队列优化: 队列繁忙时提前贴表情,配合排队ACK Card提供更好的用户体验
  • • 容错机制: 表情操作失败不影响主消息处理流程
  • • 完整日志: 所有操作都有详细的日志记录,便于问题排查

1.4 技术栈

  • • 编程语言: TypeScript / Java
  • • HTTP 客户端: 自定义 dingtalkHttp (基于 axios) / 钉钉官方 Java SDK
  • • API 钉钉开放平台: 钉钉机器人 API
  • • 日志: 自定义 logger / Slf4j

1.5 相关文档

  • • RELEASE_NOTES_V0.7.7.md – v0.7.7 版本表情反馈功能的详细说明
  • • RELEASE_NOTES_V0.8.3.md – v0.8.3 版本排队场景下的表情优化

二、核心实现

2.1 实现架构

核心文件

文件路径
说明
src/utils/utils-legacy.ts
表情功能的核心实现(贴表情、撤回表情)
src/core/message-handler.ts
消息处理流程中的表情逻辑调用
src/core/connection.ts
表情事件的类型定义
tests/utils-legacy/utils-legacy.test.ts
表情功能的单元测试

工作流程

用户发送消息    ↓WebSocket 连接层接收消息    ↓消息处理入口 (message-handler.ts)    ↓会话队列管理    ├─ 队列空闲 → 直接进入处理流程    └─ 队列繁忙 → 创建排队ACK Card + 立即贴表情        ↓    消息处理内部        ↓    贴表情检查 (emotionAlreadyAdded参数)    ├─ 未贴表情 → 调用 addEmotionReply()    └─ 已贴表情 → 跳过(队列繁忙场景)        ↓    处理消息内容(AI响应)        ↓    finally 块中调用 recallEmotionReply()        ↓    撤回表情

2.2 核心代码实现

贴表情函数

文件src/utils/utils-legacy.ts:397-425

/** * 在用户消息上贴 🤔思考中 表情,表示正在处理 */export async function addEmotionReply(config: DingtalkConfig, data: any, log?: any): Promise<void> {  if (!data.msgId || !data.conversationId) return;  try {    const token = await getAccessToken(config);    const { dingtalkHttp } = await import('./http-client.ts');    await dingtalkHttp.post(`${DINGTALK_API}/v1.0/robot/emotion/reply`, {      robotCode: data.robotCode ?? config.clientId,      openMsgId: data.msgId,      openConversationId: data.conversationId,      emotionType: 2,      emotionName: '🤔思考中',      textEmotion: {        emotionId: '2659900',        emotionName: '🤔思考中',        text: '🤔思考中',        backgroundId: 'im_bg_1',      },    }, {      headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },      timeout: 5_000,    });    log?.info?.(`[DingTalk][Emotion] 贴表情成功: msgId=${data.msgId}`);  } catch (err: any) {    log?.warn?.(`[DingTalk][Emotion] 贴表情失败(不影响主流程): ${err.message}`);  }}

关键点:

  • • 检查 msgId 和 conversationId 是否存在,不存在则直接返回
  • • 获取 Access Token 用于 API 调用
  • • 调用钉钉机器人表情回复 API
  • • 设置 5 秒超时,防止长时间阻塞
  • • 失败只记录警告日志,不影响主流程

撤回表情函数

文件src/utils/utils-legacy.ts:427-455

/** * 撤回用户消息上的 🤔思考中 表情 */export async function recallEmotionReply(config: DingtalkConfig, data: any, log?: any): Promise<void> {  if (!data.msgId || !data.conversationId) return;  try {    const token = await getAccessToken(config);    const { dingtalkHttp } = await import('./http-client.ts');    await dingtalkHttp.post(`${DINGTALK_API}/v1.0/robot/emotion/recall`, {      robotCode: data.robotCode ?? config.clientId,      openMsgId: data.msgId,      openConversationId: data.conversationId,      emotionType: 2,      emotionName: '🤔思考中',      textEmotion: {        emotionId: '2659900',        emotionName: '🤔思考中',        text: '🤔思考中',        backgroundId: 'im_bg_1',      },    }, {      headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },      timeout: 5_000,    });    log?.info?.(`[DingTalk][Emotion] 撤回表情成功: msgId=${data.msgId}`);  } catch (err: any) {    log?.warn?.(`[DingTalk][Emotion] 撤回表情失败(不影响主流程): ${err.message}`);  }}

关键点:

  • • 与贴表情函数结构相同
  • • 调用钉钉机器人表情撤回 API
  • • 同样使用 5 秒超时
  • • 失败只记录警告日志

消息处理中的表情逻辑

文件src/core/message-handler.ts:1296-1300

// ===== 贴处理中表情 =====// 若队列繁忙时已在入队阶段提前贴过表情,此处跳过,避免重复贴if (!params.emotionAlreadyAdded) {  addEmotionReply(config, data, log).catch(err => {    log?.warn?.(`贴表情失败: ${err.message}`);  });}

文件src/core/message-handler.ts:1546-1551

// ===== 撤回处理中表情 =====// 使用 await 确保表情撤销完成后再结束函数try {  await recallEmotionReply(config, data, log);} catch (err: any) {  log?.warn?.(`撤回表情异常: ${err.message}`);}

文件src/core/message-handler.ts:1644-1646

// 在发送 ACK 的同时立即贴上思考中表情,让用户知道消息已被接收addEmotionReply(config, data, log).catch(err => {  log?.warn?.(`[队列] 贴排队表情失败: ${err.message}`);});

2.3 API 接口详情

贴表情 API

端点POST https://api.dingtalk.com/v1.0/robot/emotion/reply

请求头:

x-acs-dingtalk-access-token: {access_token}Content-Type: application/json

请求体:

{  "robotCode": "机器人编码",  "openMsgId": "消息ID",  "openConversationId": "会话ID",  "emotionType": 2,  "emotionName": "🤔思考中",  "textEmotion": {    "emotionId": "2659900",    "emotionName": "🤔思考中",    "text": "🤔思考中",    "backgroundId": "im_bg_1"  }}

参数说明:

参数
类型
必填
说明
robotCode
string
机器人编码,即 clientId
openMsgId
string
消息的唯一标识
openConversationId
string
会话的唯一标识
emotionType
number
表情类型,2 表示文本表情
emotionName
string
表情名称
textEmotion
object
文本表情详细信息

撤回表情 API

端点POST https://api.dingtalk.com/v1.0/robot/emotion/recall

请求头: 与贴表情 API 相同

请求体: 与贴表情 API 相同

2.4 官方文档情况

API 确认

从钉钉相关项目和插件中确认以下 API 确实存在:

  • • POST https://api.dingtalk.com/v1.0/robot/emotion/reply – 贴表情
  • • POST https://api.dingtalk.com/v1.0/robot/emotion/recall – 撤回表情

这些 API 在多个钉钉相关的开源项目中被使用,证明了其真实性和可用性。

相关功能提及

在 openclaw issue #22519 中提到:

“这个功能属于钉钉服务端 API 的概念,用于控制机器人消息回复时允许使用的 emoji 表情范围”

该 issue 还提到了相关链接:

  • • https://open.dingtalk.com/document/orgapp/emoji-reaction-scopes

文档获取建议

由于钉钉开放平台的文档网站可能存在访问限制,以下方式可以帮助获取官方文档:

  1. 1. 钉钉开发者后台:登录钉钉开发者后台,在 API Explorer 中搜索 emotion 相关 API
  2. 2. 技术支持工单:向钉钉提交技术支持工单,咨询 emotion API 的详细文档
  3. 3. 钉钉开放社区:在钉钉开放社区或钉钉开发者论坛提问
  4. 4. 钉钉官方 SDK:查看钉钉官方 GitHub 仓库,可能包含 API 使用示例

2.5 测试用例

文件tests/utils-legacy/utils-legacy.test.ts:101-120

it("addEmotionReply and recallEmotionReply skip without ids", async () => {  const { addEmotionReply, recallEmotionReply } = await import("../../src/utils/utils-legacy");  const cfg = { clientId: "c", clientSecret: "s" } as any;  await addEmotionReply(cfg, {});  await recallEmotionReply(cfg, {});  expect(mockHttpPost).not.toHaveBeenCalled();});it("addEmotionReply posts and handles error", async () => {  const { addEmotionReply } = await import("../../src/utils/utils-legacy");  mockHttpPost    .mockResolvedValueOnce({ data: { accessToken: "tk", expireIn: 7200 } })    .mockResolvedValueOnce({ data: {} });  const cfg = { clientId: "em-id", clientSecret: "sec" } as any;  await addEmotionReply(cfg, { msgId: "m1", conversationId: "c1" });  expect(mockHttpPost).toHaveBeenCalledTimes(2);  mockHttpPost.mockRejectedValueOnce(new Error("fail"));  const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn() };  await addEmotionReply(cfg, { msgId: "m2", conversationId: "c2" }, log);  expect(log.warn).toHaveBeenCalled();});

测试覆盖:

  • • 缺少 msgId 或 conversationId 时的跳过逻辑
  • • 正常贴表情的 API 调用
  • • 错误处理和日志记录

2.6 设计亮点

容错性设计

表情操作失败不会影响主消息处理流程,这是非常重要的设计决策:

  • • 网络问题或 API 错误时,机器人仍能正常处理和回复消息
  • • 用户体验不会因为表情功能异常而受到影响

去重机制

通过 emotionAlreadyAdded 参数实现去重:

  • • 队列繁忙时在入队阶段提前贴表情
  • • 内部处理时跳过重复贴,避免 API 调用浪费

超时控制

API 调用设置 5 秒超时:

  • • 防止网络问题导致长时间阻塞
  • • 确保消息处理流程的及时性

日志完善

所有操作都有详细的日志记录:

  • • 成功操作记录 info 日志
  • • 失败操作记录 warn 日志
  • • 便于问题排查和性能监控

用户体验优化

队列繁忙时的优化处理:

  • • 在创建排队 ACK Card 的同时贴表情
  • • 让用户第一时间知道消息已被接收并排队
  • • 配合排队卡片提供完整的排队状态反馈

2.7 版本历史

  • • v0.7.7 (2026-03-13): 首次引入「🤔思考中」表情反馈功能
  • • v0.8.3 (2026-03-20): 优化队列繁忙时的表情处理逻辑,配合排队 ACK Card 提供更好的用户体验

三、实践应用

3.1 Java 实现案例

实现说明

在内部 AI Chat 项目中,基于钉钉官方 Java SDK (dingtalkrobot_1_0) 实现了表情反馈功能。由于官方 SDK 尚未直接封装表情回复相关的 Request/Response 类,我们通过通用的 doROARequest 方式在 RobotPrivateMessageService 中手动实现这两个接口。

核心代码

文件RobotPrivateMessageService.java

// 定义表情常量private static final String EMOTION_THINKING_ID = "2659900";private static final String EMOTION_THINKING_NAME = "🤔思考中";private static final String EMOTION_COMPLETED_ID = "133501"; // 示例ID,对应“已完成”类表情private static final String EMOTION_COMPLETED_NAME = "👌搞定啦";/** * 贴“🤔思考中”表情 */public void addThinkingEmotion(String openMsgId, String openConversationId) {    processEmotion(openMsgId, openConversationId, "/v1.0/robot/emotion/reply",            "AddThinking", EMOTION_THINKING_ID, EMOTION_THINKING_NAME);}/** * 撤回“🤔思考中”表情 */public void recallThinkingEmotion(String openMsgId, String openConversationId) {    processEmotion(openMsgId, openConversationId, "/v1.0/robot/emotion/recall",            "RecallThinking", EMOTION_THINKING_ID, EMOTION_THINKING_NAME);}/** * 贴“✅已完成”表情 */public void addCompletedEmotion(String openMsgId, String openConversationId) {    processEmotion(openMsgId, openConversationId, "/v1.0/robot/emotion/reply",            "AddCompleted", EMOTION_COMPLETED_ID, EMOTION_COMPLETED_NAME);}/** * 通用逻辑封装 */private void processEmotion(String openMsgId, String openConversationId, String apiPath,                            String actionName, String emotionId, String emotionName) {    if (openMsgId == null || openConversationId == null) return;    try {        Map<String, Object> body = new HashMap<>();        body.put("robotCode", robotCode);        body.put("openMsgId", openMsgId);        body.put("openConversationId", openConversationId);        body.put("emotionType", 2);        body.put("emotionName", emotionName);        Map<String, Object> textEmotion = new HashMap<>();        textEmotion.put("emotionId", emotionId);        textEmotion.put("emotionName", emotionName);        textEmotion.put("text", emotionName);        textEmotion.put("backgroundId", "im_bg_1");        body.put("textEmotion", textEmotion);        Map<String, String> headers = new HashMap<>();        headers.put("x-acs-dingtalk-access-token", getLatestAccessToken());        headers.put("Content-Type", "application/json");        com.aliyun.teaopenapi.models.OpenApiRequest req = new com.aliyun.teaopenapi.models.OpenApiRequest()                .setHeaders(headers)                .setBody(com.aliyun.openapiutil.Client.parseToMap(body));        RuntimeOptions runtime = new RuntimeOptions();        runtime.connectTimeout = 5000;        runtime.readTimeout = 5000;        robotClient.doROARequest(actionName, "robot_1.0", "HTTP", "POST", "AK", apiPath, "json", req, runtime);        log.debug("[Emotion] {} 成功: {}", actionName, openMsgId);    } catch (Exception e) {        log.warn("[Emotion] {} 失败: {}", actionName, e.getMessage());    }}

使用示例

文件ChatBotCallbackListener.java

// 开始处理前贴表情String msgId = message.getMsgId();String conversationId = message.getConversationId();// 1. 开始处理前贴表情robotPrivateMessageService.addThinkingEmotion(msgId, conversationId);try {    String answer = aiMessageProcessor.process(memoryId, userQuestion);    // 2. 发送消息(私聊或群聊)    if (!CollectionUtils.isEmpty(atUsers)) {        robotPrivateMessageService.sendGroupMessage(answer, message.getConversationId());    } else {        robotPrivateMessageService.sendPrivateMessage(userId, answer);    }}finally {    // 3. 无论成功失败,处理完成后撤回表情    // 4. 处理完成:撤回“思考中”,贴上“已完成”    robotPrivateMessageService.recallThinkingEmotion(msgId, conversationId);    robotPrivateMessageService.addCompletedEmotion(msgId, conversationId);}

封装要点说明

  • • 兼容性: 使用了 robotClient.doROARequest,这是阿里云 TEA 架构下最基础的调用方式,能够绕过官方 SDK 未定义特定 Model 的限制。
  • • 超时控制: 在 RuntimeOptions 中显式设置了 5000ms 的超时时间,与原 TS 代码逻辑一致。
  • • 异常处理: 采用 log.warn 记录异常且不向上抛出,确保贴表情失败不会导致回复流程中断。

3.2、本地测试

参考钉钉官方openclaw-dingding插件的效果实现,我们本地测试效果为:

当用户进行发送消息后,钉钉机器人会给它标注一个思考中:

当ai思考完成后,则会给出一个回答,此时就会撤回思考中表情,完成后则撤回思考中表情,并添加一个表情说明搞定啦。


总结

钉钉 openclaw-connector 的表情反馈功能是一个精心设计的用户体验优化功能。通过在用户消息上贴「🤔思考中」表情,让用户能够直观地知道消息是否被机器人接收和处理。

该功能的实现体现了以下工程实践:

  • • 容错性设计: 非核心功能失败不影响主流程
  • • 性能优化: 去重机制和超时控制
  • • 用户体验: 即时反馈和队列优化
  • • 可维护性: 完善的日志和测试覆盖

这个功能虽然是小细节,但对用户体验的提升非常明显,值得在其他聊天机器人项目中借鉴和应用。通过本次调研和实践,我们成功将这个功能集成到了内部的 AI Chat 项目中,对于接入钉钉后可为用户提供了更好的交互体验。

END

扫码二维码

获取更多精彩

感谢您的关注