@yaoyuanchao/dingtalk 1.4.19 → 1.5.0

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,27 @@ 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.0] - 2026-01-31
9
+
10
+ ### 🎉 New Feature: Typing Indicator with Auto-Recall
11
+
12
+ - **Typing Indicator** — When processing a message, automatically sends "⏳ 思考中..." which is silently recalled when the reply is ready. Much better UX than the old `showThinking` option.
13
+ - **Message Recall APIs** — New functions in `api.ts`:
14
+ - `sendDMMessageWithKey()` — Send DM and return processQueryKey for recall
15
+ - `sendGroupMessageWithKey()` — Send group message and return processQueryKey
16
+ - `recallDMMessages()` — Batch recall DM messages
17
+ - `recallGroupMessages()` — Batch recall group messages
18
+ - `sendTypingIndicator()` — One-stop helper that returns a cleanup function
19
+
20
+ ### Configuration
21
+
22
+ - `typingIndicator: false` — Disable typing indicator (default: enabled)
23
+ - `typingIndicatorMessage: "xxx"` — Customize the thinking message
24
+
25
+ ### Changed
26
+
27
+ - Deprecated `showThinking` option (still works as fallback if `typingIndicator` is explicitly disabled)
28
+
8
29
  ## [1.4.10] - 2026-01-30
9
30
 
10
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.4.19",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for Clawdbot with Stream Mode support",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -689,3 +689,267 @@ export function textToMarkdownFile(text: string, title?: string): { buffer: Buff
689
689
 
690
690
  return { buffer, fileName };
691
691
  }
692
+
693
+ // ============================================================================
694
+ // Message Recall (撤回) APIs
695
+ // ============================================================================
696
+
697
+ /**
698
+ * Send a DM message and return the processQueryKey for later recall
699
+ * This is an enhanced version that returns the message ID
700
+ */
701
+ export async function sendDMMessageWithKey(params: {
702
+ clientId: string;
703
+ clientSecret: string;
704
+ robotCode: string;
705
+ userId: string;
706
+ text: string;
707
+ format?: 'text' | 'markdown';
708
+ }): Promise<{ ok: boolean; processQueryKey?: string; error?: string }> {
709
+ try {
710
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
711
+ const headers = { "x-acs-dingtalk-access-token": token };
712
+
713
+ const useMarkdown = params.format !== 'text';
714
+ const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
715
+ const msgParam = useMarkdown
716
+ ? JSON.stringify({ title: 'AI', text: params.text })
717
+ : JSON.stringify({ content: params.text });
718
+
719
+ const res = await jsonPost(
720
+ `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
721
+ {
722
+ robotCode: params.robotCode,
723
+ userIds: [params.userId],
724
+ msgKey,
725
+ msgParam,
726
+ },
727
+ headers,
728
+ );
729
+
730
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
731
+ console.warn(`[dingtalk] DM send error:`, res);
732
+ return { ok: false, error: res.message || res.errmsg };
733
+ }
734
+
735
+ return {
736
+ ok: true,
737
+ processQueryKey: res.processQueryKey
738
+ };
739
+ } catch (err) {
740
+ console.warn(`[dingtalk] Error sending DM:`, err);
741
+ return { ok: false, error: String(err) };
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Send a group message and return the processQueryKey for later recall
747
+ */
748
+ export async function sendGroupMessageWithKey(params: {
749
+ clientId: string;
750
+ clientSecret: string;
751
+ robotCode: string;
752
+ conversationId: string;
753
+ text: string;
754
+ format?: 'text' | 'markdown';
755
+ }): Promise<{ ok: boolean; processQueryKey?: string; error?: string }> {
756
+ try {
757
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
758
+ const headers = { "x-acs-dingtalk-access-token": token };
759
+
760
+ const useMarkdown = params.format !== 'text';
761
+ const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
762
+ const msgParam = useMarkdown
763
+ ? JSON.stringify({ title: 'AI', text: params.text })
764
+ : JSON.stringify({ content: params.text });
765
+
766
+ const res = await jsonPost(
767
+ `${DINGTALK_API_BASE}/robot/groupMessages/send`,
768
+ {
769
+ robotCode: params.robotCode,
770
+ openConversationId: params.conversationId,
771
+ msgKey,
772
+ msgParam,
773
+ },
774
+ headers,
775
+ );
776
+
777
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
778
+ console.warn(`[dingtalk] Group send error:`, res);
779
+ return { ok: false, error: res.message || res.errmsg };
780
+ }
781
+
782
+ return {
783
+ ok: true,
784
+ processQueryKey: res.processQueryKey
785
+ };
786
+ } catch (err) {
787
+ console.warn(`[dingtalk] Error sending group message:`, err);
788
+ return { ok: false, error: String(err) };
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Recall (撤回) DM messages
794
+ * Note: This is a "silent recall" - the message just disappears without notification
795
+ */
796
+ export async function recallDMMessages(params: {
797
+ clientId: string;
798
+ clientSecret: string;
799
+ robotCode: string;
800
+ userId: string;
801
+ processQueryKeys: string[];
802
+ }): Promise<{ ok: boolean; successKeys?: string[]; failedKeys?: Record<string, string>; error?: string }> {
803
+ try {
804
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
805
+ const headers = { "x-acs-dingtalk-access-token": token };
806
+
807
+ const res = await jsonPost(
808
+ `${DINGTALK_API_BASE}/robot/otoMessages/batchRecall`,
809
+ {
810
+ robotCode: params.robotCode,
811
+ chatBotUserId: params.userId,
812
+ processQueryKeys: params.processQueryKeys,
813
+ },
814
+ headers,
815
+ );
816
+
817
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
818
+ console.warn(`[dingtalk] DM recall error:`, res);
819
+ return { ok: false, error: res.message || res.errmsg };
820
+ }
821
+
822
+ return {
823
+ ok: true,
824
+ successKeys: res.successResult,
825
+ failedKeys: res.failedResult,
826
+ };
827
+ } catch (err) {
828
+ console.warn(`[dingtalk] Error recalling DM:`, err);
829
+ return { ok: false, error: String(err) };
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Recall (撤回) group messages
835
+ */
836
+ export async function recallGroupMessages(params: {
837
+ clientId: string;
838
+ clientSecret: string;
839
+ robotCode: string;
840
+ conversationId: string;
841
+ processQueryKeys: string[];
842
+ }): Promise<{ ok: boolean; successKeys?: string[]; failedKeys?: Record<string, string>; error?: string }> {
843
+ try {
844
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
845
+ const headers = { "x-acs-dingtalk-access-token": token };
846
+
847
+ const res = await jsonPost(
848
+ `${DINGTALK_API_BASE}/robot/groupMessages/recall`,
849
+ {
850
+ robotCode: params.robotCode,
851
+ openConversationId: params.conversationId,
852
+ processQueryKeys: params.processQueryKeys,
853
+ },
854
+ headers,
855
+ );
856
+
857
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
858
+ console.warn(`[dingtalk] Group recall error:`, res);
859
+ return { ok: false, error: res.message || res.errmsg };
860
+ }
861
+
862
+ return {
863
+ ok: true,
864
+ successKeys: res.successResult,
865
+ failedKeys: res.failedResult,
866
+ };
867
+ } catch (err) {
868
+ console.warn(`[dingtalk] Error recalling group message:`, err);
869
+ return { ok: false, error: String(err) };
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Typing indicator helper - sends a "thinking" message that will be recalled
875
+ * Returns a cleanup function to recall the message
876
+ */
877
+ export async function sendTypingIndicator(params: {
878
+ clientId: string;
879
+ clientSecret: string;
880
+ robotCode: string;
881
+ userId?: string;
882
+ conversationId?: string;
883
+ message?: string;
884
+ }): Promise<{ cleanup: () => Promise<void>; error?: string }> {
885
+ const typingMessage = params.message || "⏳ 思考中...";
886
+
887
+ try {
888
+ if (params.userId) {
889
+ const result = await sendDMMessageWithKey({
890
+ clientId: params.clientId,
891
+ clientSecret: params.clientSecret,
892
+ robotCode: params.robotCode,
893
+ userId: params.userId,
894
+ text: typingMessage,
895
+ format: 'text',
896
+ });
897
+
898
+ if (!result.ok || !result.processQueryKey) {
899
+ return {
900
+ cleanup: async () => {},
901
+ error: result.error || "Failed to send typing indicator"
902
+ };
903
+ }
904
+
905
+ const processQueryKey = result.processQueryKey;
906
+ return {
907
+ cleanup: async () => {
908
+ await recallDMMessages({
909
+ clientId: params.clientId,
910
+ clientSecret: params.clientSecret,
911
+ robotCode: params.robotCode,
912
+ userId: params.userId!,
913
+ processQueryKeys: [processQueryKey],
914
+ });
915
+ }
916
+ };
917
+ }
918
+
919
+ if (params.conversationId) {
920
+ const result = await sendGroupMessageWithKey({
921
+ clientId: params.clientId,
922
+ clientSecret: params.clientSecret,
923
+ robotCode: params.robotCode,
924
+ conversationId: params.conversationId,
925
+ text: typingMessage,
926
+ format: 'text',
927
+ });
928
+
929
+ if (!result.ok || !result.processQueryKey) {
930
+ return {
931
+ cleanup: async () => {},
932
+ error: result.error || "Failed to send typing indicator"
933
+ };
934
+ }
935
+
936
+ const processQueryKey = result.processQueryKey;
937
+ return {
938
+ cleanup: async () => {
939
+ await recallGroupMessages({
940
+ clientId: params.clientId,
941
+ clientSecret: params.clientSecret,
942
+ robotCode: params.robotCode,
943
+ conversationId: params.conversationId!,
944
+ processQueryKeys: [processQueryKey],
945
+ });
946
+ }
947
+ };
948
+ }
949
+
950
+ return { cleanup: async () => {}, error: "Either userId or conversationId required" };
951
+ } catch (err) {
952
+ console.warn(`[dingtalk] Error sending typing indicator:`, err);
953
+ return { cleanup: async () => {}, error: String(err) };
954
+ }
955
+ }
@@ -83,6 +83,15 @@ export const dingTalkConfigSchema = z.object({
83
83
  ),
84
84
  longTextThreshold: z.number().int().positive().default(8000).optional()
85
85
  .describe('Character threshold for longTextMode=file (default 8000)'),
86
+
87
+ // 消息聚合
88
+ messageAggregation: z.boolean().default(true)
89
+ .describe(
90
+ 'Aggregate messages from the same sender within a short time window.\n' +
91
+ 'Useful when DingTalk splits link cards into multiple messages.'
92
+ ),
93
+ messageAggregationDelayMs: z.number().int().positive().default(2000).optional()
94
+ .describe('Time window in milliseconds to wait for additional messages (default 2000)'),
86
95
  }).strict();
87
96
 
88
97
  // 导出配置类型
package/src/monitor.ts CHANGED
@@ -1,7 +1,36 @@
1
1
  import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
2
- import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile } from "./api.js";
2
+ import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator } from "./api.js";
3
3
  import { getDingTalkRuntime } from "./runtime.js";
4
4
 
5
+ // ============================================================================
6
+ // Message Aggregation Buffer
7
+ // ============================================================================
8
+ // When users share links via DingTalk's "share link" feature, the message may
9
+ // arrive as multiple separate messages (text + URL). This buffer aggregates
10
+ // messages from the same sender within a short time window.
11
+
12
+ interface BufferedMessage {
13
+ messages: Array<{ text: string; timestamp: number; mediaPath?: string; mediaType?: string }>;
14
+ timer: ReturnType<typeof setTimeout>;
15
+ ctx: DingTalkMonitorContext;
16
+ msg: DingTalkRobotMessage; // Keep latest msg for reply target
17
+ replyTarget: any;
18
+ sessionKey: string;
19
+ isDm: boolean;
20
+ senderId: string;
21
+ senderName: string;
22
+ conversationId: string;
23
+ }
24
+
25
+ const messageBuffer = new Map<string, BufferedMessage>();
26
+ const AGGREGATION_DELAY_MS = 2000; // 2 seconds - balance between UX and catching split messages
27
+
28
+ function getBufferKey(msg: DingTalkRobotMessage, accountId: string): string {
29
+ return `${accountId}:${msg.conversationId}:${msg.senderId || msg.senderStaffId}`;
30
+ }
31
+
32
+ // ============================================================================
33
+
5
34
  export interface DingTalkMonitorContext {
6
35
  account: ResolvedDingTalkAccount;
7
36
  cfg: any;
@@ -696,11 +725,168 @@ async function processInboundMessage(
696
725
  account,
697
726
  };
698
727
 
699
- // Send thinking feedback (opt-in)
700
- if (account.config.showThinking && msg.sessionWebhook) {
728
+ // Check if message aggregation is enabled
729
+ const aggregationEnabled = account.config.messageAggregation !== false;
730
+ const aggregationDelayMs = account.config.messageAggregationDelayMs ?? AGGREGATION_DELAY_MS;
731
+
732
+ if (aggregationEnabled) {
733
+ // Buffer this message for aggregation
734
+ await bufferMessageForAggregation({
735
+ msg, ctx, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId,
736
+ mediaPath, mediaType,
737
+ });
738
+ return; // Actual dispatch happens when timer fires
739
+ }
740
+
741
+ // No aggregation - dispatch immediately
742
+ await dispatchMessage({
743
+ ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId,
744
+ mediaPath, mediaType,
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Buffer a message for aggregation with other messages from the same sender.
750
+ */
751
+ async function bufferMessageForAggregation(params: {
752
+ msg: DingTalkRobotMessage;
753
+ ctx: DingTalkMonitorContext;
754
+ rawBody: string;
755
+ replyTarget: any;
756
+ sessionKey: string;
757
+ isDm: boolean;
758
+ senderId: string;
759
+ senderName: string;
760
+ conversationId: string;
761
+ mediaPath?: string;
762
+ mediaType?: string;
763
+ }): Promise<void> {
764
+ const { msg, ctx, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
765
+ const { account, log } = ctx;
766
+ const bufferKey = getBufferKey(msg, account.accountId);
767
+ const aggregationDelayMs = account.config.messageAggregationDelayMs ?? AGGREGATION_DELAY_MS;
768
+
769
+ const existing = messageBuffer.get(bufferKey);
770
+
771
+ if (existing) {
772
+ // Add to existing buffer
773
+ existing.messages.push({ text: rawBody, timestamp: Date.now(), mediaPath, mediaType });
774
+ // Update to latest msg for reply target (use latest sessionWebhook)
775
+ existing.msg = msg;
776
+ existing.replyTarget = replyTarget;
777
+
778
+ // Reset timer
779
+ clearTimeout(existing.timer);
780
+ existing.timer = setTimeout(() => {
781
+ flushMessageBuffer(bufferKey);
782
+ }, aggregationDelayMs);
783
+
784
+ log?.info?.(`[dingtalk] Message buffered, total: ${existing.messages.length} messages`);
785
+ } else {
786
+ // Create new buffer entry
787
+ const newEntry: BufferedMessage = {
788
+ messages: [{ text: rawBody, timestamp: Date.now(), mediaPath, mediaType }],
789
+ timer: setTimeout(() => {
790
+ flushMessageBuffer(bufferKey);
791
+ }, aggregationDelayMs),
792
+ ctx,
793
+ msg,
794
+ replyTarget,
795
+ sessionKey,
796
+ isDm,
797
+ senderId,
798
+ senderName,
799
+ conversationId,
800
+ };
801
+ messageBuffer.set(bufferKey, newEntry);
802
+
803
+ log?.info?.(`[dingtalk] Message buffered (new), waiting ${aggregationDelayMs}ms for more...`);
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Flush the message buffer and dispatch the combined message.
809
+ */
810
+ async function flushMessageBuffer(bufferKey: string): Promise<void> {
811
+ const entry = messageBuffer.get(bufferKey);
812
+ if (!entry) return;
813
+
814
+ messageBuffer.delete(bufferKey);
815
+
816
+ const { messages, ctx, msg, replyTarget, sessionKey, isDm, senderId, senderName, conversationId } = entry;
817
+ const { log } = ctx;
818
+
819
+ // Combine all messages
820
+ const combinedText = messages.map(m => m.text).join('\n');
821
+ // Use the last media if any
822
+ const lastWithMedia = [...messages].reverse().find(m => m.mediaPath);
823
+ const mediaPath = lastWithMedia?.mediaPath;
824
+ const mediaType = lastWithMedia?.mediaType;
825
+
826
+ log?.info?.(`[dingtalk] Flushing buffer: ${messages.length} message(s) combined into ${combinedText.length} chars`);
827
+
828
+ // Dispatch the combined message
829
+ await dispatchMessage({
830
+ ctx, msg, rawBody: combinedText, replyTarget, sessionKey, isDm, senderId, senderName, conversationId,
831
+ mediaPath, mediaType,
832
+ });
833
+ }
834
+
835
+ /**
836
+ * Dispatch a message to the agent (after aggregation or immediately).
837
+ */
838
+ async function dispatchMessage(params: {
839
+ ctx: DingTalkMonitorContext;
840
+ msg: DingTalkRobotMessage;
841
+ rawBody: string;
842
+ replyTarget: any;
843
+ sessionKey: string;
844
+ isDm: boolean;
845
+ senderId: string;
846
+ senderName: string;
847
+ conversationId: string;
848
+ mediaPath?: string;
849
+ mediaType?: string;
850
+ }): Promise<void> {
851
+ const { ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
852
+ const { account, cfg, log, setStatus } = ctx;
853
+ const runtime = getDingTalkRuntime();
854
+ const isGroup = !isDm;
855
+
856
+ // Typing indicator cleanup function (will be called after dispatch completes)
857
+ let typingCleanup: (() => Promise<void>) | null = null;
858
+
859
+ // Send typing indicator (recallable) if enabled
860
+ // This replaces the old showThinking feature with a better UX - the indicator disappears when reply arrives
861
+ if (account.config.typingIndicator !== false && account.clientId && account.clientSecret) {
862
+ try {
863
+ const typingMessage = account.config.typingIndicatorMessage || '⏳ 思考中...';
864
+ const robotCode = account.robotCode || account.clientId;
865
+
866
+ const result = await sendTypingIndicator({
867
+ clientId: account.clientId,
868
+ clientSecret: account.clientSecret,
869
+ robotCode,
870
+ userId: isDm ? senderId : undefined,
871
+ conversationId: !isDm ? conversationId : undefined,
872
+ message: typingMessage,
873
+ });
874
+
875
+ if (result.error) {
876
+ log?.info?.('[dingtalk] Typing indicator failed: ' + result.error);
877
+ } else {
878
+ typingCleanup = result.cleanup;
879
+ log?.info?.('[dingtalk] Typing indicator sent (will be recalled on reply)');
880
+ }
881
+ } catch (err) {
882
+ log?.info?.('[dingtalk] Typing indicator error: ' + err);
883
+ }
884
+ }
885
+ // Legacy: Send thinking feedback (opt-in, non-recallable) - only if typingIndicator is explicitly disabled
886
+ else if (account.config.showThinking && replyTarget.sessionWebhook) {
701
887
  try {
702
- await sendViaSessionWebhook(msg.sessionWebhook, '正在思考...');
703
- log?.info?.('[dingtalk] Sent thinking indicator');
888
+ await sendViaSessionWebhook(replyTarget.sessionWebhook, '正在思考...');
889
+ log?.info?.('[dingtalk] Sent thinking indicator (legacy, non-recallable)');
704
890
  } catch (_) {
705
891
  // fire-and-forget, don't block processing
706
892
  }
@@ -724,6 +910,20 @@ async function processInboundMessage(
724
910
  runtime?.channel?.reply?.dispatchReplyFromConfig
725
911
  );
726
912
 
913
+ // Track if we've already cleaned up the typing indicator
914
+ let typingCleaned = false;
915
+ const cleanupTyping = async () => {
916
+ if (typingCleanup && !typingCleaned) {
917
+ typingCleaned = true;
918
+ try {
919
+ await typingCleanup();
920
+ log?.info?.('[dingtalk] Typing indicator recalled');
921
+ } catch (err) {
922
+ log?.info?.('[dingtalk] Failed to recall typing indicator: ' + err);
923
+ }
924
+ }
925
+ };
926
+
727
927
  try {
728
928
  if (hasFullPipeline) {
729
929
  // Full SDK pipeline: route → session → envelope → dispatch
@@ -731,6 +931,7 @@ async function processInboundMessage(
731
931
  runtime, msg, rawBody, account, cfg: actualCfg, sessionKey, isDm,
732
932
  senderId, senderName, conversationId, replyTarget,
733
933
  mediaPath, mediaType, log, setStatus,
934
+ onFirstReply: cleanupTyping,
734
935
  });
735
936
  } else if (runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
736
937
  // Fallback: existing buffered block dispatcher
@@ -763,6 +964,9 @@ async function processInboundMessage(
763
964
  cfg: actualCfg,
764
965
  dispatcherOptions: {
765
966
  deliver: async (payload: any) => {
967
+ // Recall typing indicator on first delivery
968
+ await cleanupTyping();
969
+
766
970
  log?.info?.("[dingtalk] Deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
767
971
  const textToSend = resolveDeliverText(payload, log);
768
972
  if (textToSend) {
@@ -773,10 +977,13 @@ async function processInboundMessage(
773
977
  }
774
978
  },
775
979
  onError: (err: any) => {
980
+ // Also cleanup on error
981
+ cleanupTyping().catch(() => {});
776
982
  log?.info?.("[dingtalk] Reply error: " + err);
777
983
  },
778
984
  },
779
985
  }).catch((err) => {
986
+ cleanupTyping().catch(() => {});
780
987
  log?.info?.("[dingtalk] Dispatch failed: " + err);
781
988
  });
782
989
 
@@ -784,8 +991,10 @@ async function processInboundMessage(
784
991
  runtime.channel?.activity?.record?.('dingtalk', account.accountId, 'message');
785
992
  } else {
786
993
  log?.info?.("[dingtalk] Runtime dispatch not available");
994
+ await cleanupTyping();
787
995
  }
788
996
  } catch (err) {
997
+ await cleanupTyping();
789
998
  log?.info?.("[dingtalk] Dispatch error: " + err);
790
999
  }
791
1000
  }
@@ -810,10 +1019,13 @@ async function dispatchWithFullPipeline(params: {
810
1019
  mediaType?: string;
811
1020
  log?: any;
812
1021
  setStatus?: (update: Record<string, unknown>) => void;
1022
+ onFirstReply?: () => Promise<void>;
813
1023
  }): Promise<void> {
814
1024
  const { runtime: rt, msg, rawBody, account, cfg, isDm,
815
1025
  senderId, senderName, conversationId, replyTarget,
816
- log, setStatus } = params;
1026
+ log, setStatus, onFirstReply } = params;
1027
+
1028
+ let firstReplyFired = false;
817
1029
 
818
1030
  // 1. Resolve agent route
819
1031
  const route = rt.channel.routing.resolveAgentRoute({
@@ -868,6 +1080,14 @@ async function dispatchWithFullPipeline(params: {
868
1080
  const { dispatcher, replyOptions, markDispatchIdle } = rt.channel.reply.createReplyDispatcherWithTyping({
869
1081
  responsePrefix: '',
870
1082
  deliver: async (payload: any) => {
1083
+ // Recall typing indicator on first delivery
1084
+ if (!firstReplyFired && onFirstReply) {
1085
+ firstReplyFired = true;
1086
+ await onFirstReply().catch((err) => {
1087
+ log?.info?.("[dingtalk] onFirstReply error: " + err);
1088
+ });
1089
+ }
1090
+
871
1091
  try {
872
1092
  log?.info?.("[dingtalk] Pipeline deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
873
1093
  const textToSend = resolveDeliverText(payload, log);
@@ -890,6 +1110,10 @@ async function dispatchWithFullPipeline(params: {
890
1110
  await rt.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
891
1111
  } finally {
892
1112
  markDispatchIdle();
1113
+ // Ensure typing indicator is cleaned up even if no reply was sent
1114
+ if (!firstReplyFired && onFirstReply) {
1115
+ await onFirstReply().catch(() => {});
1116
+ }
893
1117
  }
894
1118
 
895
1119
  // 10. Record activity