@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 +21 -0
- package/package.json +1 -1
- package/src/api.ts +264 -0
- package/src/config-schema.ts +9 -0
- package/src/monitor.ts +230 -6
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
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/config-schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
700
|
-
|
|
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(
|
|
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
|