@yaoyuanchao/dingtalk 1.7.0 → 1.7.2

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,15 @@ 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
+ ## [1.5.11] - 2026-03-30
9
+
10
+ ### Added
11
+
12
+ - **Per-group sender allowlist** — New `groups.<conversationId>.allowFrom` config field.
13
+ When set, the bot silently ignores @ messages from anyone not in the list.
14
+ Supports `"*"` wildcard to allow all senders (same as omitting the field).
15
+ Useful for restricting a shared group bot to specific authorized users.
16
+
8
17
  ## [1.5.3] - 2026-02-03
9
18
 
10
19
  ### Improved
package/README.md CHANGED
@@ -61,6 +61,9 @@ clawdbot gateway
61
61
  | `messageFormat` | 消息格式: `text`/`markdown`/`auto` | `auto` |
62
62
  | `typingIndicator` | 显示"思考中"提示 | `true` |
63
63
  | `longTextMode` | 长文本处理: `chunk`/`file` | `chunk` |
64
+ | `groups.<id>.systemPrompt` | 指定群的额外 system prompt | - |
65
+ | `groups.<id>.enabled` | 是否启用该群(false = 完全忽略) | `true` |
66
+ | `groups.<id>.allowFrom` | 该群只响应这些 staffId 的消息(支持 `"*"` 通配符) | `[]`(不限制) |
64
67
 
65
68
  </details>
66
69
 
@@ -80,6 +83,32 @@ Access denied. Your staffId: 050914XXXXXXXXX
80
83
  ```
81
84
  把这个 ID 加到 `dm.allowFrom` 里,重启 gateway 即可。
82
85
 
86
+ ## 🔒 群聊发言人限制(Per-group allowFrom)
87
+
88
+ 可以让机器人在某个群里**只响应指定用户**的 @ 消息,其他人 @ 会被静默忽略。
89
+
90
+ ```json
91
+ {
92
+ "channels": {
93
+ "dingtalk": {
94
+ "groups": {
95
+ "<conversationId>": {
96
+ "allowFrom": ["050914185922786044"]
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ **获取 conversationId:** 机器人收到群消息时日志里会打印,或通过钉钉开放平台 API 查询。
105
+
106
+ **支持通配符:** `"allowFrom": ["*"]` 表示允许所有人(等同于不设置)。
107
+
108
+ **与 `dm.allowFrom` 的区别:**
109
+ - `dm.allowFrom` — 私聊白名单(全局)
110
+ - `groups.<id>.allowFrom` — 指定群的发言人白名单(per-group)
111
+
83
112
  ## 📝 更新日志
84
113
 
85
114
  **v1.5.0** — 新增 Typing Indicator(思考中提示,自动撤回)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for ClawdBot/OpenClaw with Stream Mode support",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -882,6 +882,73 @@ export async function recallGroupMessages(params: {
882
882
  * Typing indicator helper - sends a "thinking" message that will be recalled
883
883
  * Returns a cleanup function to recall the message
884
884
  */
885
+ // ============================================================================
886
+ // Emotion Reaction API - add/recall emoji reactions on user messages
887
+ // Cleaner UX than typing indicators: reaction floats on the message itself
888
+ // ============================================================================
889
+
890
+ export async function addEmotionReply(params: {
891
+ clientId: string;
892
+ clientSecret: string;
893
+ robotCode: string;
894
+ msgId: string;
895
+ conversationId: string;
896
+ emotionName?: string;
897
+ }): Promise<{ cleanup: () => Promise<void>; error?: string }> {
898
+ const emotionName = params.emotionName || '🤔思考中';
899
+ try {
900
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
901
+ const res = await jsonPost(
902
+ `${DINGTALK_API_BASE}/robot/emotion/reply`,
903
+ {
904
+ robotCode: params.robotCode,
905
+ openMsgId: params.msgId,
906
+ openConversationId: params.conversationId,
907
+ emotionType: 2,
908
+ emotionName,
909
+ textEmotion: {
910
+ emotionId: "2659900",
911
+ emotionName,
912
+ text: emotionName,
913
+ backgroundId: "im_bg_1",
914
+ },
915
+ },
916
+ { "x-acs-dingtalk-access-token": token },
917
+ );
918
+
919
+ if (res?.errcode && res.errcode !== 0) {
920
+ return { cleanup: async () => {}, error: `emotion reply failed: ${res.errmsg || res.errcode}` };
921
+ }
922
+
923
+ return {
924
+ cleanup: async () => {
925
+ try {
926
+ const t = await getDingTalkAccessToken(params.clientId, params.clientSecret);
927
+ await jsonPost(
928
+ `${DINGTALK_API_BASE}/robot/emotion/recall`,
929
+ {
930
+ robotCode: params.robotCode,
931
+ openMsgId: params.msgId,
932
+ openConversationId: params.conversationId,
933
+ emotionType: 2,
934
+ emotionName,
935
+ textEmotion: {
936
+ emotionId: "2659900",
937
+ emotionName,
938
+ text: emotionName,
939
+ backgroundId: "im_bg_1",
940
+ },
941
+ },
942
+ { "x-acs-dingtalk-access-token": t },
943
+ );
944
+ } catch (_) { /* best-effort recall */ }
945
+ },
946
+ };
947
+ } catch (err) {
948
+ return { cleanup: async () => {}, error: String(err) };
949
+ }
950
+ }
951
+
885
952
  export async function sendTypingIndicator(params: {
886
953
  clientId: string;
887
954
  clientSecret: string;
@@ -1,167 +1,169 @@
1
- import { z } from 'zod';
2
-
3
- // 定义枚举类型
4
- export const dmPolicySchema = z.enum(['disabled', 'pairing', 'allowlist', 'open'], {
5
- description: 'DM access control policy',
6
- });
7
-
8
- export const groupPolicySchema = z.enum(['disabled', 'allowlist', 'open'], {
9
- description: 'Group chat access control policy',
10
- });
11
-
12
- export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto'], {
13
- description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features)',
14
- });
15
-
16
- export const longTextModeSchema = z.enum(['chunk', 'file']);
17
-
18
- // Per-account config schema (everything that can be overridden per account)
19
- export const dingTalkAccountConfigSchema = z.object({
20
- enabled: z.boolean().default(true).describe('Enable this account'),
21
- name: z.string().optional().describe('Display name for this account'),
22
-
23
- // Credentials
24
- clientId: z.string().optional()
25
- .describe('DingTalk application AppKey'),
26
- clientSecret: z.string().optional()
27
- .describe('DingTalk application AppSecret'),
28
- robotCode: z.string().optional()
29
- .describe('Robot code (optional, defaults to clientId if not provided)'),
30
-
31
- // DM config
32
- dm: z.object({
33
- enabled: z.boolean().default(true)
34
- .describe('Enable direct messages'),
35
- policy: dmPolicySchema.default('pairing')
36
- .describe(
37
- 'Access control policy:\n' +
38
- ' - disabled: No DM allowed\n' +
39
- ' - pairing: Show staffId on first contact, admin adds to allowlist\n' +
40
- ' - allowlist: Only specified users can DM\n' +
41
- ' - open: Anyone can DM (not recommended)'
42
- ),
43
- allowFrom: z.array(z.string()).default([])
44
- .describe('Allowed staff IDs (for pairing/allowlist policy)'),
45
- }).default({}),
46
-
47
- // Group config
48
- groupPolicy: groupPolicySchema.default('allowlist')
49
- .describe(
50
- 'Group chat policy:\n' +
51
- ' - disabled: No groups\n' +
52
- ' - allowlist: Only specified groups\n' +
53
- ' - open: All groups'
54
- ),
55
- groupAllowlist: z.array(z.string()).default([])
56
- .describe('Allowed group conversation IDs (only used when groupPolicy is "allowlist")'),
57
- requireMention: z.boolean().default(true)
58
- .describe('Require @ mention in group chats'),
59
-
60
- // Message format
61
- messageFormat: messageFormatSchema.default('text')
62
- .describe(
63
- 'Message format:\n' +
64
- ' - text: Plain text (recommended, supports tables)\n' +
65
- ' - markdown: DingTalk markdown (limited support, no tables)\n' +
66
- ' - richtext: Alias for markdown (deprecated, use markdown instead)\n' +
67
- ' - auto: Auto-detect markdown features in response'
68
- ),
69
-
70
- // Thinking feedback
71
- showThinking: z.boolean().default(false)
72
- .describe('Send "正在思考..." feedback before AI responds'),
73
-
74
- // Advanced
75
- textChunkLimit: z.number().int().positive().default(2000).optional()
76
- .describe('Text chunk size limit for long messages'),
77
-
78
- longTextMode: longTextModeSchema.default('chunk')
79
- .describe(
80
- 'How to handle long text messages:\n' +
81
- ' - chunk: Split into multiple messages (default, same as official channels)\n' +
82
- ' - file: Convert to .md file and send as attachment'
83
- ),
84
- longTextThreshold: z.number().int().positive().default(8000).optional()
85
- .describe('Character threshold for longTextMode=file (default 8000)'),
86
-
87
- // Per-group overrides
88
- groups: z.record(z.string(), z.object({
89
- systemPrompt: z.string().optional()
90
- .describe('Per-group extra system prompt injected as GroupSystemPrompt'),
91
- enabled: z.boolean().optional()
92
- .describe('Disable this group (false = ignore all messages)'),
93
- })).optional().default({})
94
- .describe('Per-group overrides keyed by conversationId'),
95
-
96
- // Message aggregation
97
- messageAggregation: z.boolean().default(true)
98
- .describe(
99
- 'Aggregate messages from the same sender within a short time window.\n' +
100
- 'Useful when DingTalk splits link cards into multiple messages.'
101
- ),
102
- messageAggregationDelayMs: z.number().int().positive().default(2000).optional()
103
- .describe('Time window in milliseconds to wait for additional messages (default 2000)'),
104
- }).passthrough();
105
-
106
- // Top-level DingTalk config: account config + multi-account support
107
- export const dingTalkConfigSchema = dingTalkAccountConfigSchema.extend({
108
- accounts: z.record(z.string(), dingTalkAccountConfigSchema.partial().optional()).optional()
109
- .describe('Named accounts that override top-level settings'),
110
- defaultAccount: z.string().optional()
111
- .describe('Default account ID (if accounts are defined)'),
112
- });
113
-
114
- // 导出配置类型
115
- export type DingTalkAccountConfig = z.infer<typeof dingTalkAccountConfigSchema>;
116
- export type DingTalkConfig = z.infer<typeof dingTalkConfigSchema>;
117
-
118
- /**
119
- * 验证 DingTalk 配置
120
- * @param config - 原始配置对象
121
- * @returns 验证后的配置
122
- * @throws ZodError 如果配置无效
123
- */
124
- export function validateDingTalkConfig(config: unknown): DingTalkConfig {
125
- try {
126
- return dingTalkConfigSchema.parse(config);
127
- } catch (error) {
128
- if (error instanceof z.ZodError) {
129
- const formatted = error.issues.map(e => {
130
- const path = e.path.join('.');
131
- return ` - ${path || 'root'}: ${e.message}`;
132
- }).join('\n');
133
- throw new Error(`DingTalk config validation failed:\n${formatted}`);
134
- }
135
- throw error;
136
- }
137
- }
138
-
139
- /**
140
- * Validate per-account config (partial, used for account overrides)
141
- */
142
- export function validateDingTalkAccountConfig(config: unknown): DingTalkAccountConfig {
143
- return dingTalkAccountConfigSchema.parse(config);
144
- }
145
-
146
- /**
147
- * 安全地验证配置,返回错误而不抛出异常
148
- * @param config - 原始配置对象
149
- * @returns { success: true, data } 或 { success: false, error }
150
- */
151
- export function safeValidateDingTalkConfig(config: unknown):
152
- | { success: true; data: DingTalkConfig }
153
- | { success: false; error: string } {
154
- try {
155
- const data = dingTalkConfigSchema.parse(config);
156
- return { success: true, data };
157
- } catch (error) {
158
- if (error instanceof z.ZodError) {
159
- const formatted = error.issues.map(e => {
160
- const path = e.path.join('.');
161
- return `${path || 'root'}: ${e.message}`;
162
- }).join('; ');
163
- return { success: false, error: formatted };
164
- }
165
- return { success: false, error: String(error) };
166
- }
167
- }
1
+ import { z } from 'zod';
2
+
3
+ // 定义枚举类型
4
+ export const dmPolicySchema = z.enum(['disabled', 'pairing', 'allowlist', 'open'], {
5
+ description: 'DM access control policy',
6
+ });
7
+
8
+ export const groupPolicySchema = z.enum(['disabled', 'allowlist', 'open'], {
9
+ description: 'Group chat access control policy',
10
+ });
11
+
12
+ export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto'], {
13
+ description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features)',
14
+ });
15
+
16
+ export const longTextModeSchema = z.enum(['chunk', 'file']);
17
+
18
+ // Per-account config schema (everything that can be overridden per account)
19
+ export const dingTalkAccountConfigSchema = z.object({
20
+ enabled: z.boolean().default(true).describe('Enable this account'),
21
+ name: z.string().optional().describe('Display name for this account'),
22
+
23
+ // Credentials
24
+ clientId: z.string().optional()
25
+ .describe('DingTalk application AppKey'),
26
+ clientSecret: z.string().optional()
27
+ .describe('DingTalk application AppSecret'),
28
+ robotCode: z.string().optional()
29
+ .describe('Robot code (optional, defaults to clientId if not provided)'),
30
+
31
+ // DM config
32
+ dm: z.object({
33
+ enabled: z.boolean().default(true)
34
+ .describe('Enable direct messages'),
35
+ policy: dmPolicySchema.default('pairing')
36
+ .describe(
37
+ 'Access control policy:\n' +
38
+ ' - disabled: No DM allowed\n' +
39
+ ' - pairing: Show staffId on first contact, admin adds to allowlist\n' +
40
+ ' - allowlist: Only specified users can DM\n' +
41
+ ' - open: Anyone can DM (not recommended)'
42
+ ),
43
+ allowFrom: z.array(z.string()).default([])
44
+ .describe('Allowed staff IDs (for pairing/allowlist policy)'),
45
+ }).default({}),
46
+
47
+ // Group config
48
+ groupPolicy: groupPolicySchema.default('allowlist')
49
+ .describe(
50
+ 'Group chat policy:\n' +
51
+ ' - disabled: No groups\n' +
52
+ ' - allowlist: Only specified groups\n' +
53
+ ' - open: All groups'
54
+ ),
55
+ groupAllowlist: z.array(z.string()).default([])
56
+ .describe('Allowed group conversation IDs (only used when groupPolicy is "allowlist")'),
57
+ requireMention: z.boolean().default(true)
58
+ .describe('Require @ mention in group chats'),
59
+
60
+ // Message format
61
+ messageFormat: messageFormatSchema.default('text')
62
+ .describe(
63
+ 'Message format:\n' +
64
+ ' - text: Plain text (recommended, supports tables)\n' +
65
+ ' - markdown: DingTalk markdown (limited support, no tables)\n' +
66
+ ' - richtext: Alias for markdown (deprecated, use markdown instead)\n' +
67
+ ' - auto: Auto-detect markdown features in response'
68
+ ),
69
+
70
+ // Thinking feedback
71
+ showThinking: z.boolean().default(false)
72
+ .describe('Send "正在思考..." feedback before AI responds'),
73
+
74
+ // Advanced
75
+ textChunkLimit: z.number().int().positive().default(2000).optional()
76
+ .describe('Text chunk size limit for long messages'),
77
+
78
+ longTextMode: longTextModeSchema.default('chunk')
79
+ .describe(
80
+ 'How to handle long text messages:\n' +
81
+ ' - chunk: Split into multiple messages (default, same as official channels)\n' +
82
+ ' - file: Convert to .md file and send as attachment'
83
+ ),
84
+ longTextThreshold: z.number().int().positive().default(8000).optional()
85
+ .describe('Character threshold for longTextMode=file (default 8000)'),
86
+
87
+ // Per-group overrides
88
+ groups: z.record(z.string(), z.object({
89
+ systemPrompt: z.string().optional()
90
+ .describe('Per-group extra system prompt injected as GroupSystemPrompt'),
91
+ enabled: z.boolean().optional()
92
+ .describe('Disable this group (false = ignore all messages)'),
93
+ allowFrom: z.array(z.string()).optional()
94
+ .describe('Only respond to messages from these senderStaffIds in this group (supports "*" wildcard). If omitted, all senders are allowed.'),
95
+ })).optional().default({})
96
+ .describe('Per-group overrides keyed by conversationId'),
97
+
98
+ // Message aggregation
99
+ messageAggregation: z.boolean().default(true)
100
+ .describe(
101
+ 'Aggregate messages from the same sender within a short time window.\n' +
102
+ 'Useful when DingTalk splits link cards into multiple messages.'
103
+ ),
104
+ messageAggregationDelayMs: z.number().int().positive().default(2000).optional()
105
+ .describe('Time window in milliseconds to wait for additional messages (default 2000)'),
106
+ }).passthrough();
107
+
108
+ // Top-level DingTalk config: account config + multi-account support
109
+ export const dingTalkConfigSchema = dingTalkAccountConfigSchema.extend({
110
+ accounts: z.record(z.string(), dingTalkAccountConfigSchema.partial().optional()).optional()
111
+ .describe('Named accounts that override top-level settings'),
112
+ defaultAccount: z.string().optional()
113
+ .describe('Default account ID (if accounts are defined)'),
114
+ });
115
+
116
+ // 导出配置类型
117
+ export type DingTalkAccountConfig = z.infer<typeof dingTalkAccountConfigSchema>;
118
+ export type DingTalkConfig = z.infer<typeof dingTalkConfigSchema>;
119
+
120
+ /**
121
+ * 验证 DingTalk 配置
122
+ * @param config - 原始配置对象
123
+ * @returns 验证后的配置
124
+ * @throws ZodError 如果配置无效
125
+ */
126
+ export function validateDingTalkConfig(config: unknown): DingTalkConfig {
127
+ try {
128
+ return dingTalkConfigSchema.parse(config);
129
+ } catch (error) {
130
+ if (error instanceof z.ZodError) {
131
+ const formatted = error.issues.map(e => {
132
+ const path = e.path.join('.');
133
+ return ` - ${path || 'root'}: ${e.message}`;
134
+ }).join('\n');
135
+ throw new Error(`DingTalk config validation failed:\n${formatted}`);
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Validate per-account config (partial, used for account overrides)
143
+ */
144
+ export function validateDingTalkAccountConfig(config: unknown): DingTalkAccountConfig {
145
+ return dingTalkAccountConfigSchema.parse(config);
146
+ }
147
+
148
+ /**
149
+ * 安全地验证配置,返回错误而不抛出异常
150
+ * @param config - 原始配置对象
151
+ * @returns { success: true, data } 或 { success: false, error }
152
+ */
153
+ export function safeValidateDingTalkConfig(config: unknown):
154
+ | { success: true; data: DingTalkConfig }
155
+ | { success: false; error: string } {
156
+ try {
157
+ const data = dingTalkConfigSchema.parse(config);
158
+ return { success: true, data };
159
+ } catch (error) {
160
+ if (error instanceof z.ZodError) {
161
+ const formatted = error.issues.map(e => {
162
+ const path = e.path.join('.');
163
+ return `${path || 'root'}: ${e.message}`;
164
+ }).join('; ');
165
+ return { success: false, error: formatted };
166
+ }
167
+ return { success: false, error: String(error) };
168
+ }
169
+ }
package/src/monitor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
2
- import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator } from "./api.js";
2
+ import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator, addEmotionReply } from "./api.js";
3
3
  import { getDingTalkRuntime } from "./runtime.js";
4
4
  import { cacheInboundDownloadCode, getCachedDownloadCode } from "./quoted-msg-cache.js";
5
5
  import { resolveQuotedFile } from "./quoted-file-service.js";
@@ -1231,6 +1231,17 @@ async function processInboundMessage(
1231
1231
  // Check @mention requirement
1232
1232
  const requireMention = account.config.requireMention !== false;
1233
1233
  if (requireMention && !msg.isInAtList) return;
1234
+
1235
+ // Check per-group sender allowlist
1236
+ const groupConfig = (account.config.groups ?? {})[conversationId];
1237
+ const groupAllowFrom = groupConfig?.allowFrom;
1238
+ if (groupAllowFrom && groupAllowFrom.length > 0) {
1239
+ log?.info?.("[dingtalk] Group allowFrom check: senderId=" + senderId + " allowFrom=" + JSON.stringify(groupAllowFrom));
1240
+ if (!isSenderAllowed(senderId, groupAllowFrom)) {
1241
+ log?.info?.("[dingtalk] Group sender not in allowFrom: " + senderId + " for group " + conversationId);
1242
+ return;
1243
+ }
1244
+ }
1234
1245
  }
1235
1246
 
1236
1247
  const sessionKey = "dingtalk:" + account.accountId + ":" + (isDm ? "dm" : "group") + ":" + conversationId;
@@ -1374,21 +1385,20 @@ async function dispatchMessage(params: {
1374
1385
  const queueKey = `${account.accountId}:${conversationId}`;
1375
1386
  const isQueueBusy = sessionQueues.has(queueKey);
1376
1387
 
1377
- // If queue is busy, send a recallable notification so it disappears when processing starts
1388
+ // If queue is busy, add emotion reaction on user's message to indicate queued
1378
1389
  let queueAckCleanup: (() => Promise<void>) | null = null;
1379
1390
  if (isQueueBusy) {
1380
- const phrase = QUEUE_BUSY_PHRASES[Math.floor(Math.random() * QUEUE_BUSY_PHRASES.length)];
1381
- log?.info?.("[dingtalk] Queue busy for " + queueKey + ", notifying user");
1391
+ log?.info?.("[dingtalk] Queue busy for " + queueKey + ", adding queue reaction");
1382
1392
  try {
1383
- if (account.clientId && account.clientSecret) {
1393
+ if (account.clientId && account.clientSecret && params.msg.msgId && conversationId) {
1384
1394
  const robotCode = account.robotCode || account.clientId;
1385
- const result = await sendTypingIndicator({
1395
+ const result = await addEmotionReply({
1386
1396
  clientId: account.clientId,
1387
1397
  clientSecret: account.clientSecret,
1388
1398
  robotCode,
1389
- userId: params.isDm ? params.senderId : undefined,
1390
- conversationId: !params.isDm ? conversationId : undefined,
1391
- message: '' + phrase,
1399
+ msgId: params.msg.msgId,
1400
+ conversationId,
1401
+ emotionName: '⏳排队中',
1392
1402
  });
1393
1403
  if (!result.error) {
1394
1404
  queueAckCleanup = result.cleanup;
@@ -1448,30 +1458,55 @@ async function dispatchMessageInternal(params: {
1448
1458
  // Typing indicator cleanup function (will be called after dispatch completes)
1449
1459
  let typingCleanup: (() => Promise<void>) | null = null;
1450
1460
 
1451
- // Send typing indicator (recallable) if enabled
1452
- // This replaces the old showThinking feature with a better UX - the indicator disappears when reply arrives
1461
+ // Send thinking feedback: prefer emotion reaction (clean UX, floats on message),
1462
+ // fall back to typing indicator (separate message) if emotion API fails or msgId unavailable.
1453
1463
  if (account.config.typingIndicator !== false && account.clientId && account.clientSecret) {
1454
- try {
1455
- const typingMessage = account.config.typingIndicatorMessage || '⏳ 思考中...';
1456
- const robotCode = account.robotCode || account.clientId;
1457
-
1458
- const result = await sendTypingIndicator({
1459
- clientId: account.clientId,
1460
- clientSecret: account.clientSecret,
1461
- robotCode,
1462
- userId: isDm ? senderId : undefined,
1463
- conversationId: !isDm ? conversationId : undefined,
1464
- message: typingMessage,
1465
- });
1466
-
1467
- if (result.error) {
1468
- log?.info?.('[dingtalk] Typing indicator failed: ' + result.error);
1469
- } else {
1470
- typingCleanup = result.cleanup;
1471
- log?.info?.('[dingtalk] Typing indicator sent (will be recalled on reply)');
1464
+ const robotCode = account.robotCode || account.clientId;
1465
+ let emotionOk = false;
1466
+
1467
+ // Try emotion reaction first (requires msgId + conversationId)
1468
+ if (msg.msgId && conversationId) {
1469
+ try {
1470
+ const result = await addEmotionReply({
1471
+ clientId: account.clientId,
1472
+ clientSecret: account.clientSecret,
1473
+ robotCode,
1474
+ msgId: msg.msgId,
1475
+ conversationId,
1476
+ });
1477
+ if (!result.error) {
1478
+ typingCleanup = result.cleanup;
1479
+ emotionOk = true;
1480
+ log?.info?.('[dingtalk] Emotion reaction added (will be recalled on reply)');
1481
+ } else {
1482
+ log?.info?.('[dingtalk] Emotion reaction failed: ' + result.error + ', falling back to typing indicator');
1483
+ }
1484
+ } catch (err) {
1485
+ log?.info?.('[dingtalk] Emotion reaction error: ' + err + ', falling back to typing indicator');
1486
+ }
1487
+ }
1488
+
1489
+ // Fallback to typing indicator (separate message)
1490
+ if (!emotionOk) {
1491
+ try {
1492
+ const typingMessage = account.config.typingIndicatorMessage || '⏳ 思考中...';
1493
+ const result = await sendTypingIndicator({
1494
+ clientId: account.clientId,
1495
+ clientSecret: account.clientSecret,
1496
+ robotCode,
1497
+ userId: isDm ? senderId : undefined,
1498
+ conversationId: !isDm ? conversationId : undefined,
1499
+ message: typingMessage,
1500
+ });
1501
+ if (!result.error) {
1502
+ typingCleanup = result.cleanup;
1503
+ log?.info?.('[dingtalk] Typing indicator sent (will be recalled on reply)');
1504
+ } else {
1505
+ log?.info?.('[dingtalk] Typing indicator failed: ' + result.error);
1506
+ }
1507
+ } catch (err) {
1508
+ log?.info?.('[dingtalk] Typing indicator error: ' + err);
1472
1509
  }
1473
- } catch (err) {
1474
- log?.info?.('[dingtalk] Typing indicator error: ' + err);
1475
1510
  }
1476
1511
  }
1477
1512
  // Legacy: Send thinking feedback (opt-in, non-recallable) - only if typingIndicator is explicitly disabled
@@ -1502,16 +1537,16 @@ async function dispatchMessageInternal(params: {
1502
1537
  runtime?.channel?.reply?.dispatchReplyFromConfig
1503
1538
  );
1504
1539
 
1505
- // Track if we've already cleaned up the typing indicator
1540
+ // Track if we've already cleaned up the thinking feedback
1506
1541
  let typingCleaned = false;
1507
1542
  const cleanupTyping = async () => {
1508
1543
  if (typingCleanup && !typingCleaned) {
1509
1544
  typingCleaned = true;
1510
1545
  try {
1511
1546
  await typingCleanup();
1512
- log?.info?.('[dingtalk] Typing indicator recalled');
1547
+ log?.info?.('[dingtalk] Thinking feedback recalled');
1513
1548
  } catch (err) {
1514
- log?.info?.('[dingtalk] Failed to recall typing indicator: ' + err);
1549
+ log?.info?.('[dingtalk] Failed to recall thinking feedback: ' + err);
1515
1550
  }
1516
1551
  }
1517
1552
  };