文章目录
- 前言
- 一、背景与概述
- 二、核心实现
-
- [2.1 实现架构](#2.1 实现架构)
- [2.2 核心代码实现](#2.2 核心代码实现)
- [2.3 API 接口详情](#2.3 API 接口详情)
-
- [贴表情 API](#贴表情 API)
- [撤回表情 API](#撤回表情 API)
- [2.4 官方文档情况](#2.4 官方文档情况)
- [2.5 测试用例](#2.5 测试用例)
- [2.6 设计亮点](#2.6 设计亮点)
- [2.7 版本历史](#2.7 版本历史)
- 三、实践应用
- 总结
- 资料获取

前言
博主介绍:✌目前全网粉丝4W+,csdn博客专家、Java领域优质创作者,博客之星、阿里云平台优质作者、专注于Java后端技术领域。
涵盖技术内容:Java后端、大数据、算法、分布式微服务、中间件、前端、运维等。
博主所有博客文件目录索引:博客目录索引(持续更新)
CSDN搜索:长路
视频平台:b站-Coder长路
一、背景与概述
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
typescript
/**
* 在用户消息上贴 🤔思考中 表情,表示正在处理
*/
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
typescript
/**
* 撤回用户消息上的 🤔思考中 表情
*/
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
typescript
// ===== 贴处理中表情 =====
// 若队列繁忙时已在入队阶段提前贴过表情,此处跳过,避免重复贴
if (!params.emotionAlreadyAdded) {
addEmotionReply(config, data, log).catch(err => {
log?.warn?.(`贴表情失败: ${err.message}`);
});
}
文件 : src/core/message-handler.ts:1546-1551
typescript
// ===== 撤回处理中表情 =====
// 使用 await 确保表情撤销完成后再结束函数
try {
await recallEmotionReply(config, data, log);
} catch (err: any) {
log?.warn?.(`撤回表情异常: ${err.message}`);
}
文件 : src/core/message-handler.ts:1644-1646
typescript
// 在发送 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
请求体:
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 还提到了相关链接:
文档获取建议
由于钉钉开放平台的文档网站可能存在访问限制,以下方式可以帮助获取官方文档:
- 钉钉开发者后台 :登录钉钉开发者后台,在 API Explorer 中搜索
emotion相关 API - 技术支持工单:向钉钉提交技术支持工单,咨询 emotion API 的详细文档
- 钉钉开放社区:在钉钉开放社区或钉钉开发者论坛提问
- 钉钉官方 SDK:查看钉钉官方 GitHub 仓库,可能包含 API 使用示例
2.5 测试用例
文件 : tests/utils-legacy/utils-legacy.test.ts:101-120
typescript
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
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
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 项目中,对于接入钉钉后可为用户提供了更好的交互体验。
资料获取
大家点赞、收藏、关注、评论啦~
精彩专栏推荐订阅:在下方专栏👇🏻
- 长路-文章目录汇总(算法、后端Java、前端、运维技术导航):博主所有博客导航索引汇总
- 开源项目Studio-Vue---校园工作室管理系统(含前后台,SpringBoot+Vue):博主个人独立项目,包含详细部署上线视频,已开源
- 学习与生活-专栏:可以了解博主的学习历程
- 算法专栏:算法收录
更多博客与资料可查看👇🏻获取联系方式👇🏻,🍅文末获取开发资源及更多资源博客获取🍅