@yaoyuanchao/dingtalk 1.4.20 β†’ 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.20",
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
+ }
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 } 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
5
  // ============================================================================
@@ -853,11 +853,40 @@ async function dispatchMessage(params: {
853
853
  const runtime = getDingTalkRuntime();
854
854
  const isGroup = !isDm;
855
855
 
856
- // Send thinking feedback (opt-in)
857
- if (account.config.showThinking && replyTarget.sessionWebhook) {
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) {
858
887
  try {
859
888
  await sendViaSessionWebhook(replyTarget.sessionWebhook, 'ζ­£εœ¨ζ€θ€ƒ...');
860
- log?.info?.('[dingtalk] Sent thinking indicator');
889
+ log?.info?.('[dingtalk] Sent thinking indicator (legacy, non-recallable)');
861
890
  } catch (_) {
862
891
  // fire-and-forget, don't block processing
863
892
  }
@@ -881,6 +910,20 @@ async function dispatchMessage(params: {
881
910
  runtime?.channel?.reply?.dispatchReplyFromConfig
882
911
  );
883
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
+
884
927
  try {
885
928
  if (hasFullPipeline) {
886
929
  // Full SDK pipeline: route β†’ session β†’ envelope β†’ dispatch
@@ -888,6 +931,7 @@ async function dispatchMessage(params: {
888
931
  runtime, msg, rawBody, account, cfg: actualCfg, sessionKey, isDm,
889
932
  senderId, senderName, conversationId, replyTarget,
890
933
  mediaPath, mediaType, log, setStatus,
934
+ onFirstReply: cleanupTyping,
891
935
  });
892
936
  } else if (runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
893
937
  // Fallback: existing buffered block dispatcher
@@ -920,6 +964,9 @@ async function dispatchMessage(params: {
920
964
  cfg: actualCfg,
921
965
  dispatcherOptions: {
922
966
  deliver: async (payload: any) => {
967
+ // Recall typing indicator on first delivery
968
+ await cleanupTyping();
969
+
923
970
  log?.info?.("[dingtalk] Deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
924
971
  const textToSend = resolveDeliverText(payload, log);
925
972
  if (textToSend) {
@@ -930,10 +977,13 @@ async function dispatchMessage(params: {
930
977
  }
931
978
  },
932
979
  onError: (err: any) => {
980
+ // Also cleanup on error
981
+ cleanupTyping().catch(() => {});
933
982
  log?.info?.("[dingtalk] Reply error: " + err);
934
983
  },
935
984
  },
936
985
  }).catch((err) => {
986
+ cleanupTyping().catch(() => {});
937
987
  log?.info?.("[dingtalk] Dispatch failed: " + err);
938
988
  });
939
989
 
@@ -941,8 +991,10 @@ async function dispatchMessage(params: {
941
991
  runtime.channel?.activity?.record?.('dingtalk', account.accountId, 'message');
942
992
  } else {
943
993
  log?.info?.("[dingtalk] Runtime dispatch not available");
994
+ await cleanupTyping();
944
995
  }
945
996
  } catch (err) {
997
+ await cleanupTyping();
946
998
  log?.info?.("[dingtalk] Dispatch error: " + err);
947
999
  }
948
1000
  }
@@ -967,10 +1019,13 @@ async function dispatchWithFullPipeline(params: {
967
1019
  mediaType?: string;
968
1020
  log?: any;
969
1021
  setStatus?: (update: Record<string, unknown>) => void;
1022
+ onFirstReply?: () => Promise<void>;
970
1023
  }): Promise<void> {
971
1024
  const { runtime: rt, msg, rawBody, account, cfg, isDm,
972
1025
  senderId, senderName, conversationId, replyTarget,
973
- log, setStatus } = params;
1026
+ log, setStatus, onFirstReply } = params;
1027
+
1028
+ let firstReplyFired = false;
974
1029
 
975
1030
  // 1. Resolve agent route
976
1031
  const route = rt.channel.routing.resolveAgentRoute({
@@ -1025,6 +1080,14 @@ async function dispatchWithFullPipeline(params: {
1025
1080
  const { dispatcher, replyOptions, markDispatchIdle } = rt.channel.reply.createReplyDispatcherWithTyping({
1026
1081
  responsePrefix: '',
1027
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
+
1028
1091
  try {
1029
1092
  log?.info?.("[dingtalk] Pipeline deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
1030
1093
  const textToSend = resolveDeliverText(payload, log);
@@ -1047,6 +1110,10 @@ async function dispatchWithFullPipeline(params: {
1047
1110
  await rt.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
1048
1111
  } finally {
1049
1112
  markDispatchIdle();
1113
+ // Ensure typing indicator is cleaned up even if no reply was sent
1114
+ if (!firstReplyFired && onFirstReply) {
1115
+ await onFirstReply().catch(() => {});
1116
+ }
1050
1117
  }
1051
1118
 
1052
1119
  // 10. Record activity