@soimy/dingtalk 3.2.0 → 3.3.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/LICENSE +21 -0
- package/README.md +657 -32
- package/index.ts +62 -0
- package/package.json +3 -1
- package/src/access-control.ts +18 -0
- package/src/ack-reaction-classifier.ts +62 -0
- package/src/ack-reaction-service.ts +135 -0
- package/src/attachment-text-extractor.ts +147 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +776 -24
- package/src/channel.ts +408 -135
- package/src/config-schema.ts +40 -4
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1100 -159
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +38 -0
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quote-journal.ts +242 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/quoted-msg-cache.ts +226 -0
- package/src/send-service.ts +177 -43
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +152 -24
- package/src/utils.ts +231 -12
package/src/inbound-handler.ts
CHANGED
|
@@ -1,29 +1,86 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
|
|
3
|
+
import { extractAttachmentText } from "./attachment-text-extractor";
|
|
3
4
|
import { getAccessToken } from "./auth";
|
|
4
5
|
import {
|
|
5
6
|
createAICard,
|
|
7
|
+
findCardContent,
|
|
6
8
|
finishAICard,
|
|
7
9
|
formatContentForCard,
|
|
10
|
+
getCardContentByProcessQueryKey,
|
|
8
11
|
isCardInTerminalState,
|
|
9
12
|
} from "./card-service";
|
|
10
|
-
import {
|
|
13
|
+
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
14
|
+
import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
|
|
11
15
|
import { formatGroupMembers, noteGroupMember } from "./group-members-store";
|
|
12
16
|
import { setCurrentLogger } from "./logger-context";
|
|
17
|
+
import {
|
|
18
|
+
formatLearnAppliedReply,
|
|
19
|
+
formatLearnCommandHelp,
|
|
20
|
+
formatLearnDeletedReply,
|
|
21
|
+
formatLearnDisabledReply,
|
|
22
|
+
formatLearnListReply,
|
|
23
|
+
formatOwnerOnlyDeniedReply,
|
|
24
|
+
formatOwnerStatusReply,
|
|
25
|
+
formatTargetSetSavedReply,
|
|
26
|
+
formatWhereAmIReply,
|
|
27
|
+
formatWhoAmIReply,
|
|
28
|
+
isLearningOwner,
|
|
29
|
+
parseLearnCommand,
|
|
30
|
+
} from "./learning-command-service";
|
|
13
31
|
import { extractMessageContent } from "./message-utils";
|
|
32
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
14
33
|
import { registerPeerId } from "./peer-id-registry";
|
|
15
34
|
import {
|
|
16
35
|
clearProactiveRiskObservationsForTest,
|
|
17
36
|
getProactiveRiskObservationForAny,
|
|
18
37
|
} from "./proactive-risk-registry";
|
|
38
|
+
import {
|
|
39
|
+
appendQuoteJournalEntry,
|
|
40
|
+
DEFAULT_JOURNAL_TTL_DAYS,
|
|
41
|
+
resolveQuotedMessageById,
|
|
42
|
+
} from "./quote-journal";
|
|
19
43
|
import { getDingTalkRuntime } from "./runtime";
|
|
20
|
-
import { sendBySession, sendMessage } from "./send-service";
|
|
44
|
+
import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
|
|
45
|
+
import { clearSessionPeerOverride, getSessionPeerOverride, setSessionPeerOverride } from "./session-peer-store";
|
|
46
|
+
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
21
47
|
import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
|
|
22
48
|
import { AICardStatus } from "./types";
|
|
49
|
+
import { createCardDraftController } from "./card-draft-controller";
|
|
23
50
|
import { acquireSessionLock } from "./session-lock";
|
|
51
|
+
import { cacheInboundDownloadCode, getCachedDownloadCode } from "./quoted-msg-cache";
|
|
52
|
+
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
|
|
53
|
+
import {
|
|
54
|
+
formatSessionAliasBoundReply,
|
|
55
|
+
formatSessionAliasClearedReply,
|
|
56
|
+
formatSessionAliasReply,
|
|
57
|
+
formatSessionAliasSetReply,
|
|
58
|
+
formatSessionAliasUnboundReply,
|
|
59
|
+
formatSessionAliasValidationErrorReply,
|
|
60
|
+
parseSessionCommand,
|
|
61
|
+
validateSessionAlias,
|
|
62
|
+
} from "./session-command-service";
|
|
63
|
+
import {
|
|
64
|
+
applyManualTargetLearningRule,
|
|
65
|
+
applyManualTargetsLearningRule,
|
|
66
|
+
applyManualGlobalLearningRule,
|
|
67
|
+
applyManualSessionLearningNote,
|
|
68
|
+
applyTargetSetLearningRule,
|
|
69
|
+
buildLearningContextBlock,
|
|
70
|
+
createOrUpdateTargetSet,
|
|
71
|
+
deleteManualRule,
|
|
72
|
+
disableManualRule,
|
|
73
|
+
isFeedbackLearningEnabled,
|
|
74
|
+
listLearningTargetSets,
|
|
75
|
+
listScopedLearningRules,
|
|
76
|
+
resolveManualForcedReply,
|
|
77
|
+
} from "./feedback-learning-service";
|
|
78
|
+
import { attachNativeAckReaction, recallNativeAckReactionWithRetry } from "./ack-reaction-service";
|
|
24
79
|
import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
|
|
25
80
|
|
|
26
81
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
82
|
+
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
83
|
+
const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
|
|
27
84
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
28
85
|
|
|
29
86
|
export function resetProactivePermissionHintStateForTest(): void {
|
|
@@ -35,6 +92,7 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
35
92
|
isDirect: boolean;
|
|
36
93
|
accountId: string;
|
|
37
94
|
senderId: string;
|
|
95
|
+
senderOriginalId?: string;
|
|
38
96
|
senderStaffId?: string;
|
|
39
97
|
config: DingTalkConfig;
|
|
40
98
|
nowMs: number;
|
|
@@ -53,11 +111,14 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
53
111
|
return false;
|
|
54
112
|
}
|
|
55
113
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
114
|
+
const riskTargets = [params.senderId, params.senderOriginalId, params.senderStaffId]
|
|
115
|
+
.map((id) => (id || "").trim())
|
|
116
|
+
.filter((id, index, arr) => Boolean(id) && arr.indexOf(id) === index);
|
|
117
|
+
if (riskTargets.length === 0) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const riskObservation = getProactiveRiskObservationForAny(params.accountId, riskTargets, params.nowMs);
|
|
61
122
|
if (!riskObservation || riskObservation.source !== "proactive-api") {
|
|
62
123
|
return false;
|
|
63
124
|
}
|
|
@@ -77,14 +138,43 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
77
138
|
return true;
|
|
78
139
|
}
|
|
79
140
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
141
|
+
function stripQuotedPrefixForJournal(value: string): string {
|
|
142
|
+
return value
|
|
143
|
+
.replace(/^\[引用消息: .*?\]\n\n/s, "")
|
|
144
|
+
.replace(/^\[这是一条引用消息,原消息ID: .*?\]\n\n/s, "")
|
|
145
|
+
.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sanitizeGroupPromptName(value?: string): string {
|
|
149
|
+
return (value || "")
|
|
150
|
+
.replace(/[\r\n,=]/g, " ")
|
|
151
|
+
.replace(/\s+/g, " ")
|
|
152
|
+
.trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildGroupTurnContextPrompt(params: {
|
|
156
|
+
conversationId: string;
|
|
157
|
+
senderDingtalkId: string;
|
|
158
|
+
senderName?: string;
|
|
159
|
+
}): string {
|
|
160
|
+
const sanitizedSenderName = sanitizeGroupPromptName(params.senderName) || "Unknown";
|
|
161
|
+
return [
|
|
162
|
+
"Current DingTalk group turn context:",
|
|
163
|
+
`- conversationId: ${params.conversationId}`,
|
|
164
|
+
`- senderDingtalkId: ${params.senderDingtalkId}`,
|
|
165
|
+
`- senderName: ${sanitizedSenderName}`,
|
|
166
|
+
"Treat senderDingtalkId and senderName as the authoritative sender for this turn. Do not guess the current sender from GroupMembers.",
|
|
167
|
+
].join("\n");
|
|
86
168
|
}
|
|
87
169
|
|
|
170
|
+
type ReplyStreamPayload = {
|
|
171
|
+
text?: string;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
type ReplyChunkInfo = {
|
|
175
|
+
kind?: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
88
178
|
/**
|
|
89
179
|
* Download DingTalk media file via runtime media service (sandbox-compatible).
|
|
90
180
|
* Files are stored in the global media inbound directory.
|
|
@@ -183,7 +273,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
183
273
|
// Save logger globally so shared services can log consistently without threading log everywhere.
|
|
184
274
|
setCurrentLogger(log);
|
|
185
275
|
|
|
186
|
-
log?.debug?.("[DingTalk] Full Inbound Data:"
|
|
276
|
+
log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
|
|
187
277
|
|
|
188
278
|
// 1) Ignore self messages from bot.
|
|
189
279
|
if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
|
|
@@ -191,13 +281,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
191
281
|
return;
|
|
192
282
|
}
|
|
193
283
|
|
|
194
|
-
const
|
|
195
|
-
if (!
|
|
284
|
+
const extractedContent = extractMessageContent(data);
|
|
285
|
+
if (!extractedContent.text) {
|
|
196
286
|
return;
|
|
197
287
|
}
|
|
198
288
|
|
|
199
289
|
const isDirect = data.conversationType === "1";
|
|
200
|
-
const
|
|
290
|
+
const senderOriginalId = (data.senderId || "").trim();
|
|
291
|
+
const senderStaffId = (data.senderStaffId || "").trim();
|
|
292
|
+
const senderId = senderStaffId || senderOriginalId;
|
|
201
293
|
const senderName = data.senderNick || "Unknown";
|
|
202
294
|
const groupId = data.conversationId;
|
|
203
295
|
const groupName = data.conversationTitle || "Group";
|
|
@@ -212,7 +304,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
212
304
|
isDirect,
|
|
213
305
|
accountId,
|
|
214
306
|
senderId,
|
|
215
|
-
|
|
307
|
+
senderOriginalId,
|
|
308
|
+
senderStaffId,
|
|
216
309
|
config: dingtalkConfig,
|
|
217
310
|
nowMs: Date.now(),
|
|
218
311
|
})
|
|
@@ -311,11 +404,29 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
311
404
|
}
|
|
312
405
|
}
|
|
313
406
|
|
|
407
|
+
const accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
408
|
+
agentId: accountId,
|
|
409
|
+
});
|
|
410
|
+
const currentSessionSourceKind = isDirect ? "direct" : "group";
|
|
411
|
+
const currentSessionSourceId = isDirect ? senderId : groupId;
|
|
412
|
+
const peerIdOverride = getSessionPeerOverride({
|
|
413
|
+
storePath: accountStorePath,
|
|
414
|
+
accountId,
|
|
415
|
+
sourceKind: currentSessionSourceKind,
|
|
416
|
+
sourceId: currentSessionSourceId,
|
|
417
|
+
});
|
|
418
|
+
const sessionPeer = resolveDingTalkSessionPeer({
|
|
419
|
+
isDirect,
|
|
420
|
+
senderId,
|
|
421
|
+
conversationId: groupId,
|
|
422
|
+
peerIdOverride,
|
|
423
|
+
config: dingtalkConfig,
|
|
424
|
+
});
|
|
314
425
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
315
426
|
cfg,
|
|
316
427
|
channel: "dingtalk",
|
|
317
428
|
accountId,
|
|
318
|
-
peer: { kind:
|
|
429
|
+
peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
|
|
319
430
|
});
|
|
320
431
|
|
|
321
432
|
// Route resolved before media download for session context and routing metadata.
|
|
@@ -324,34 +435,481 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
324
435
|
});
|
|
325
436
|
|
|
326
437
|
const to = isDirect ? senderId : groupId;
|
|
327
|
-
|
|
438
|
+
const parsedLearnCommand = parseLearnCommand(extractedContent.text);
|
|
439
|
+
const parsedSessionCommand = parseSessionCommand(extractedContent.text);
|
|
440
|
+
const isOwner = isLearningOwner({
|
|
441
|
+
cfg,
|
|
442
|
+
config: dingtalkConfig,
|
|
443
|
+
senderId,
|
|
444
|
+
rawSenderId: data.senderId,
|
|
445
|
+
});
|
|
446
|
+
if (isDirect && parsedLearnCommand.scope === "whoami") {
|
|
447
|
+
await sendBySession(
|
|
448
|
+
dingtalkConfig,
|
|
449
|
+
sessionWebhook,
|
|
450
|
+
formatWhoAmIReply({
|
|
451
|
+
senderId,
|
|
452
|
+
rawSenderId: data.senderId,
|
|
453
|
+
senderStaffId: data.senderStaffId,
|
|
454
|
+
isOwner,
|
|
455
|
+
}),
|
|
456
|
+
{ log },
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (parsedLearnCommand.scope === "whereami") {
|
|
461
|
+
await sendBySession(
|
|
462
|
+
dingtalkConfig,
|
|
463
|
+
sessionWebhook,
|
|
464
|
+
formatWhereAmIReply({
|
|
465
|
+
conversationId: data.conversationId,
|
|
466
|
+
conversationType: isDirect ? "dm" : "group",
|
|
467
|
+
peerId: sessionPeer.peerId,
|
|
468
|
+
}),
|
|
469
|
+
{ log },
|
|
470
|
+
);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (isDirect && parsedLearnCommand.scope === "owner-status") {
|
|
474
|
+
await sendBySession(
|
|
475
|
+
dingtalkConfig,
|
|
476
|
+
sessionWebhook,
|
|
477
|
+
formatOwnerStatusReply({
|
|
478
|
+
senderId,
|
|
479
|
+
rawSenderId: data.senderId,
|
|
480
|
+
isOwner,
|
|
481
|
+
}),
|
|
482
|
+
{ log },
|
|
483
|
+
);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (parsedLearnCommand.scope === "help") {
|
|
487
|
+
await sendBySession(dingtalkConfig, sessionWebhook, formatLearnCommandHelp(), { log });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (
|
|
491
|
+
(parsedLearnCommand.scope === "global"
|
|
492
|
+
|| parsedLearnCommand.scope === "session"
|
|
493
|
+
|| parsedLearnCommand.scope === "here"
|
|
494
|
+
|| parsedLearnCommand.scope === "target"
|
|
495
|
+
|| parsedLearnCommand.scope === "targets"
|
|
496
|
+
|| parsedLearnCommand.scope === "list"
|
|
497
|
+
|| parsedLearnCommand.scope === "disable"
|
|
498
|
+
|| parsedLearnCommand.scope === "delete"
|
|
499
|
+
|| parsedLearnCommand.scope === "target-set-create"
|
|
500
|
+
|| parsedLearnCommand.scope === "target-set-apply"
|
|
501
|
+
|| parsedSessionCommand.scope === "session-alias-show"
|
|
502
|
+
|| parsedSessionCommand.scope === "session-alias-set"
|
|
503
|
+
|| parsedSessionCommand.scope === "session-alias-clear"
|
|
504
|
+
|| parsedSessionCommand.scope === "session-alias-bind"
|
|
505
|
+
|| parsedSessionCommand.scope === "session-alias-unbind")
|
|
506
|
+
&& !isOwner
|
|
507
|
+
) {
|
|
508
|
+
await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (isOwner) {
|
|
512
|
+
if (parsedSessionCommand.scope === "session-alias-show") {
|
|
513
|
+
await sendBySession(
|
|
514
|
+
dingtalkConfig,
|
|
515
|
+
sessionWebhook,
|
|
516
|
+
formatSessionAliasReply({
|
|
517
|
+
sourceKind: currentSessionSourceKind,
|
|
518
|
+
sourceId: currentSessionSourceId,
|
|
519
|
+
peerId: sessionPeer.peerId,
|
|
520
|
+
aliasSource: peerIdOverride ? "override" : "default",
|
|
521
|
+
}),
|
|
522
|
+
{ log },
|
|
523
|
+
);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (parsedSessionCommand.scope === "session-alias-set" && parsedSessionCommand.peerId) {
|
|
527
|
+
const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
|
|
528
|
+
if (aliasValidationError) {
|
|
529
|
+
await sendBySession(
|
|
530
|
+
dingtalkConfig,
|
|
531
|
+
sessionWebhook,
|
|
532
|
+
formatSessionAliasValidationErrorReply(aliasValidationError),
|
|
533
|
+
{ log },
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
setSessionPeerOverride({
|
|
538
|
+
storePath: accountStorePath,
|
|
539
|
+
accountId,
|
|
540
|
+
sourceKind: currentSessionSourceKind,
|
|
541
|
+
sourceId: currentSessionSourceId,
|
|
542
|
+
peerId: parsedSessionCommand.peerId,
|
|
543
|
+
});
|
|
544
|
+
await sendBySession(
|
|
545
|
+
dingtalkConfig,
|
|
546
|
+
sessionWebhook,
|
|
547
|
+
formatSessionAliasSetReply({
|
|
548
|
+
sourceKind: currentSessionSourceKind,
|
|
549
|
+
sourceId: currentSessionSourceId,
|
|
550
|
+
peerId: parsedSessionCommand.peerId,
|
|
551
|
+
}),
|
|
552
|
+
{ log },
|
|
553
|
+
);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (parsedSessionCommand.scope === "session-alias-clear") {
|
|
557
|
+
clearSessionPeerOverride({
|
|
558
|
+
storePath: accountStorePath,
|
|
559
|
+
accountId,
|
|
560
|
+
sourceKind: currentSessionSourceKind,
|
|
561
|
+
sourceId: currentSessionSourceId,
|
|
562
|
+
});
|
|
563
|
+
await sendBySession(
|
|
564
|
+
dingtalkConfig,
|
|
565
|
+
sessionWebhook,
|
|
566
|
+
formatSessionAliasClearedReply({
|
|
567
|
+
sourceKind: currentSessionSourceKind,
|
|
568
|
+
sourceId: currentSessionSourceId,
|
|
569
|
+
}),
|
|
570
|
+
{ log },
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (parsedSessionCommand.scope === "session-alias-bind"
|
|
575
|
+
&& parsedSessionCommand.sourceKind
|
|
576
|
+
&& parsedSessionCommand.sourceId
|
|
577
|
+
&& parsedSessionCommand.peerId) {
|
|
578
|
+
const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
|
|
579
|
+
if (aliasValidationError) {
|
|
580
|
+
await sendBySession(
|
|
581
|
+
dingtalkConfig,
|
|
582
|
+
sessionWebhook,
|
|
583
|
+
formatSessionAliasValidationErrorReply(aliasValidationError),
|
|
584
|
+
{ log },
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
setSessionPeerOverride({
|
|
589
|
+
storePath: accountStorePath,
|
|
590
|
+
accountId,
|
|
591
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
592
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
593
|
+
peerId: parsedSessionCommand.peerId,
|
|
594
|
+
});
|
|
595
|
+
await sendBySession(
|
|
596
|
+
dingtalkConfig,
|
|
597
|
+
sessionWebhook,
|
|
598
|
+
formatSessionAliasBoundReply({
|
|
599
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
600
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
601
|
+
peerId: parsedSessionCommand.peerId,
|
|
602
|
+
}),
|
|
603
|
+
{ log },
|
|
604
|
+
);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (parsedSessionCommand.scope === "session-alias-unbind"
|
|
608
|
+
&& parsedSessionCommand.sourceKind
|
|
609
|
+
&& parsedSessionCommand.sourceId) {
|
|
610
|
+
const existed = clearSessionPeerOverride({
|
|
611
|
+
storePath: accountStorePath,
|
|
612
|
+
accountId,
|
|
613
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
614
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
615
|
+
});
|
|
616
|
+
await sendBySession(
|
|
617
|
+
dingtalkConfig,
|
|
618
|
+
sessionWebhook,
|
|
619
|
+
formatSessionAliasUnboundReply({
|
|
620
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
621
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
622
|
+
existed,
|
|
623
|
+
}),
|
|
624
|
+
{ log },
|
|
625
|
+
);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (parsedLearnCommand.scope === "global" && parsedLearnCommand.instruction) {
|
|
629
|
+
const applied = applyManualGlobalLearningRule({
|
|
630
|
+
storePath: accountStorePath,
|
|
631
|
+
accountId,
|
|
632
|
+
instruction: parsedLearnCommand.instruction,
|
|
633
|
+
});
|
|
634
|
+
await sendBySession(
|
|
635
|
+
dingtalkConfig,
|
|
636
|
+
sessionWebhook,
|
|
637
|
+
formatLearnAppliedReply({
|
|
638
|
+
scope: "global",
|
|
639
|
+
instruction: parsedLearnCommand.instruction,
|
|
640
|
+
ruleId: applied?.ruleId,
|
|
641
|
+
}),
|
|
642
|
+
{ log },
|
|
643
|
+
);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (parsedLearnCommand.scope === "session" && parsedLearnCommand.instruction) {
|
|
647
|
+
applyManualSessionLearningNote({
|
|
648
|
+
storePath: accountStorePath,
|
|
649
|
+
accountId,
|
|
650
|
+
targetId: data.conversationId,
|
|
651
|
+
instruction: parsedLearnCommand.instruction,
|
|
652
|
+
});
|
|
653
|
+
await sendBySession(
|
|
654
|
+
dingtalkConfig,
|
|
655
|
+
sessionWebhook,
|
|
656
|
+
formatLearnAppliedReply({
|
|
657
|
+
scope: "session",
|
|
658
|
+
instruction: parsedLearnCommand.instruction,
|
|
659
|
+
}),
|
|
660
|
+
{ log },
|
|
661
|
+
);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (parsedLearnCommand.scope === "here" && parsedLearnCommand.instruction) {
|
|
665
|
+
const applied = applyManualTargetLearningRule({
|
|
666
|
+
storePath: accountStorePath,
|
|
667
|
+
accountId,
|
|
668
|
+
targetId: data.conversationId,
|
|
669
|
+
instruction: parsedLearnCommand.instruction,
|
|
670
|
+
});
|
|
671
|
+
await sendBySession(
|
|
672
|
+
dingtalkConfig,
|
|
673
|
+
sessionWebhook,
|
|
674
|
+
formatLearnAppliedReply({
|
|
675
|
+
scope: "target",
|
|
676
|
+
targetId: data.conversationId,
|
|
677
|
+
instruction: parsedLearnCommand.instruction,
|
|
678
|
+
ruleId: applied?.ruleId,
|
|
679
|
+
}),
|
|
680
|
+
{ log },
|
|
681
|
+
);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (parsedLearnCommand.scope === "target" && parsedLearnCommand.targetId && parsedLearnCommand.instruction) {
|
|
685
|
+
const applied = applyManualTargetLearningRule({
|
|
686
|
+
storePath: accountStorePath,
|
|
687
|
+
accountId,
|
|
688
|
+
targetId: parsedLearnCommand.targetId,
|
|
689
|
+
instruction: parsedLearnCommand.instruction,
|
|
690
|
+
});
|
|
691
|
+
await sendBySession(
|
|
692
|
+
dingtalkConfig,
|
|
693
|
+
sessionWebhook,
|
|
694
|
+
formatLearnAppliedReply({
|
|
695
|
+
scope: "target",
|
|
696
|
+
targetId: parsedLearnCommand.targetId,
|
|
697
|
+
instruction: parsedLearnCommand.instruction,
|
|
698
|
+
ruleId: applied?.ruleId,
|
|
699
|
+
}),
|
|
700
|
+
{ log },
|
|
701
|
+
);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (parsedLearnCommand.scope === "targets" && parsedLearnCommand.targetIds?.length && parsedLearnCommand.instruction) {
|
|
705
|
+
const applied = applyManualTargetsLearningRule({
|
|
706
|
+
storePath: accountStorePath,
|
|
707
|
+
accountId,
|
|
708
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
709
|
+
instruction: parsedLearnCommand.instruction,
|
|
710
|
+
});
|
|
711
|
+
await sendBySession(
|
|
712
|
+
dingtalkConfig,
|
|
713
|
+
sessionWebhook,
|
|
714
|
+
formatLearnAppliedReply({
|
|
715
|
+
scope: "targets",
|
|
716
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
717
|
+
instruction: parsedLearnCommand.instruction,
|
|
718
|
+
ruleId: applied[0]?.ruleId,
|
|
719
|
+
}),
|
|
720
|
+
{ log },
|
|
721
|
+
);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (parsedLearnCommand.scope === "target-set-create" && parsedLearnCommand.setName && parsedLearnCommand.targetIds?.length) {
|
|
725
|
+
const saved = createOrUpdateTargetSet({
|
|
726
|
+
storePath: accountStorePath,
|
|
727
|
+
accountId,
|
|
728
|
+
name: parsedLearnCommand.setName,
|
|
729
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
730
|
+
});
|
|
731
|
+
await sendBySession(
|
|
732
|
+
dingtalkConfig,
|
|
733
|
+
sessionWebhook,
|
|
734
|
+
saved
|
|
735
|
+
? formatTargetSetSavedReply({
|
|
736
|
+
setName: parsedLearnCommand.setName,
|
|
737
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
738
|
+
})
|
|
739
|
+
: "目标组保存失败,请检查名称和目标列表。",
|
|
740
|
+
{ log },
|
|
741
|
+
);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (parsedLearnCommand.scope === "target-set-apply" && parsedLearnCommand.setName && parsedLearnCommand.instruction) {
|
|
745
|
+
const applied = applyTargetSetLearningRule({
|
|
746
|
+
storePath: accountStorePath,
|
|
747
|
+
accountId,
|
|
748
|
+
name: parsedLearnCommand.setName,
|
|
749
|
+
instruction: parsedLearnCommand.instruction,
|
|
750
|
+
});
|
|
751
|
+
await sendBySession(
|
|
752
|
+
dingtalkConfig,
|
|
753
|
+
sessionWebhook,
|
|
754
|
+
applied.length > 0
|
|
755
|
+
? formatLearnAppliedReply({
|
|
756
|
+
scope: "target-set",
|
|
757
|
+
setName: parsedLearnCommand.setName,
|
|
758
|
+
targetIds: applied.map((item) => item.targetId),
|
|
759
|
+
instruction: parsedLearnCommand.instruction,
|
|
760
|
+
ruleId: applied[0]?.ruleId,
|
|
761
|
+
})
|
|
762
|
+
: `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
|
|
763
|
+
{ log },
|
|
764
|
+
);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (parsedLearnCommand.scope === "list") {
|
|
768
|
+
const rules = listScopedLearningRules({ storePath: accountStorePath, accountId })
|
|
769
|
+
.slice(0, 20)
|
|
770
|
+
.map((rule) => {
|
|
771
|
+
const scope = rule.scope === "target" ? `target(${rule.targetId})` : "global";
|
|
772
|
+
const status = rule.enabled ? "enabled" : "disabled";
|
|
773
|
+
return `- [${scope}] ${rule.ruleId} (${status}) => ${rule.instruction}`;
|
|
774
|
+
});
|
|
775
|
+
const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
|
|
776
|
+
.slice(0, 10)
|
|
777
|
+
.map((targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`);
|
|
778
|
+
await sendBySession(
|
|
779
|
+
dingtalkConfig,
|
|
780
|
+
sessionWebhook,
|
|
781
|
+
formatLearnListReply([...rules, ...targetSets]),
|
|
782
|
+
{ log },
|
|
783
|
+
);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (parsedLearnCommand.scope === "disable" && parsedLearnCommand.ruleId) {
|
|
787
|
+
const result = disableManualRule({
|
|
788
|
+
storePath: accountStorePath,
|
|
789
|
+
accountId,
|
|
790
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
791
|
+
});
|
|
792
|
+
await sendBySession(
|
|
793
|
+
dingtalkConfig,
|
|
794
|
+
sessionWebhook,
|
|
795
|
+
formatLearnDisabledReply({
|
|
796
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
797
|
+
existed: result.existed,
|
|
798
|
+
scope: result.scope,
|
|
799
|
+
targetId: result.targetId,
|
|
800
|
+
}),
|
|
801
|
+
{ log },
|
|
802
|
+
);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (parsedLearnCommand.scope === "delete" && parsedLearnCommand.ruleId) {
|
|
806
|
+
const result = deleteManualRule({
|
|
807
|
+
storePath: accountStorePath,
|
|
808
|
+
accountId,
|
|
809
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
810
|
+
});
|
|
811
|
+
await sendBySession(
|
|
812
|
+
dingtalkConfig,
|
|
813
|
+
sessionWebhook,
|
|
814
|
+
formatLearnDeletedReply({
|
|
815
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
816
|
+
existed: result.existed,
|
|
817
|
+
scope: result.scope,
|
|
818
|
+
targetId: result.targetId,
|
|
819
|
+
}),
|
|
820
|
+
{ log },
|
|
821
|
+
);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const manualForcedReply = resolveManualForcedReply({
|
|
826
|
+
storePath: accountStorePath,
|
|
827
|
+
accountId,
|
|
828
|
+
targetId: data.conversationId,
|
|
829
|
+
content: extractedContent,
|
|
830
|
+
});
|
|
831
|
+
if (manualForcedReply) {
|
|
832
|
+
await sendBySession(dingtalkConfig, sessionWebhook, manualForcedReply, { log });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
328
835
|
// 3) Select response mode (card vs markdown).
|
|
329
836
|
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
330
837
|
// feedback while large files are still being downloaded.
|
|
331
|
-
|
|
838
|
+
let useCardMode = dingtalkConfig.messageType === "card";
|
|
332
839
|
let currentAICard = undefined;
|
|
333
|
-
let lastCardContent = "";
|
|
334
840
|
|
|
335
841
|
if (useCardMode) {
|
|
336
842
|
try {
|
|
337
843
|
log?.debug?.(
|
|
338
844
|
`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
|
|
339
845
|
);
|
|
340
|
-
const aiCard = await createAICard(dingtalkConfig, to, log
|
|
846
|
+
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
847
|
+
accountId,
|
|
848
|
+
storePath: accountStorePath,
|
|
849
|
+
});
|
|
341
850
|
if (aiCard) {
|
|
342
851
|
currentAICard = aiCard;
|
|
343
852
|
} else {
|
|
853
|
+
useCardMode = false;
|
|
344
854
|
log?.warn?.(
|
|
345
855
|
"[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
|
|
346
856
|
);
|
|
347
857
|
}
|
|
348
858
|
} catch (err: any) {
|
|
859
|
+
useCardMode = false;
|
|
349
860
|
log?.warn?.(
|
|
350
861
|
`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
|
|
351
862
|
);
|
|
352
863
|
}
|
|
353
864
|
}
|
|
354
865
|
|
|
866
|
+
const hasConcreteQuotedPayload =
|
|
867
|
+
!!extractedContent.quoted?.mediaDownloadCode ||
|
|
868
|
+
!!extractedContent.quoted?.isQuotedFile ||
|
|
869
|
+
!!extractedContent.quoted?.isQuotedCard ||
|
|
870
|
+
extractedContent.quoted?.prefix.startsWith('[引用消息: "') === true;
|
|
871
|
+
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_JOURNAL_TTL_DAYS;
|
|
872
|
+
let content = extractedContent;
|
|
873
|
+
|
|
874
|
+
if (data.text?.isReplyMsg && data.originalMsgId && !hasConcreteQuotedPayload) {
|
|
875
|
+
try {
|
|
876
|
+
const quoted = resolveQuotedMessageById({
|
|
877
|
+
storePath,
|
|
878
|
+
accountId,
|
|
879
|
+
conversationId: groupId,
|
|
880
|
+
originalMsgId: data.originalMsgId,
|
|
881
|
+
ttlDays: journalTTLDays,
|
|
882
|
+
});
|
|
883
|
+
if (quoted?.text?.trim()) {
|
|
884
|
+
const cleanedText = extractedContent.text.replace(
|
|
885
|
+
/^\[这是一条引用消息,原消息ID: [^\]]+\]\n\n/,
|
|
886
|
+
"",
|
|
887
|
+
);
|
|
888
|
+
content = {
|
|
889
|
+
...extractedContent,
|
|
890
|
+
text: `[引用消息: "${quoted.text.trim()}"]\n\n${cleanedText}`,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
} catch (err) {
|
|
894
|
+
log?.debug?.(`[DingTalk] Quote journal lookup failed: ${String(err)}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
try {
|
|
899
|
+
appendQuoteJournalEntry({
|
|
900
|
+
storePath,
|
|
901
|
+
accountId,
|
|
902
|
+
conversationId: groupId,
|
|
903
|
+
msgId: data.msgId,
|
|
904
|
+
messageType: content.messageType,
|
|
905
|
+
text: stripQuotedPrefixForJournal(content.text),
|
|
906
|
+
createdAt: data.createAt,
|
|
907
|
+
ttlDays: journalTTLDays,
|
|
908
|
+
});
|
|
909
|
+
} catch (err) {
|
|
910
|
+
log?.warn?.(`[DingTalk] Quote journal append failed: ${String(err)}`);
|
|
911
|
+
}
|
|
912
|
+
|
|
355
913
|
let mediaPath: string | undefined;
|
|
356
914
|
let mediaType: string | undefined;
|
|
357
915
|
if (content.mediaPath && dingtalkConfig.robotCode) {
|
|
@@ -361,6 +919,264 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
361
919
|
mediaType = media.mimeType;
|
|
362
920
|
}
|
|
363
921
|
}
|
|
922
|
+
|
|
923
|
+
// Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
|
|
924
|
+
if (content.mediaPath && data.msgId) {
|
|
925
|
+
cacheInboundDownloadCode(
|
|
926
|
+
accountId,
|
|
927
|
+
data.conversationId,
|
|
928
|
+
data.msgId,
|
|
929
|
+
content.mediaPath,
|
|
930
|
+
content.messageType,
|
|
931
|
+
data.createAt,
|
|
932
|
+
{ spaceId: data.content?.spaceId, fileId: data.content?.fileId, storePath },
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
|
|
937
|
+
// during the original message turn, and try downloading immediately in DM.
|
|
938
|
+
if (
|
|
939
|
+
content.messageType === "interactiveCardFile" &&
|
|
940
|
+
data.msgId &&
|
|
941
|
+
content.docSpaceId &&
|
|
942
|
+
content.docFileId
|
|
943
|
+
) {
|
|
944
|
+
cacheInboundDownloadCode(
|
|
945
|
+
accountId,
|
|
946
|
+
data.conversationId,
|
|
947
|
+
data.msgId,
|
|
948
|
+
undefined,
|
|
949
|
+
content.messageType,
|
|
950
|
+
data.createAt,
|
|
951
|
+
{ spaceId: content.docSpaceId, fileId: content.docFileId, storePath },
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
if (!mediaPath && isDirect && data.senderStaffId) {
|
|
955
|
+
try {
|
|
956
|
+
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
957
|
+
const docMedia = await downloadGroupFile(
|
|
958
|
+
dingtalkConfig,
|
|
959
|
+
content.docSpaceId,
|
|
960
|
+
content.docFileId,
|
|
961
|
+
unionId,
|
|
962
|
+
log,
|
|
963
|
+
);
|
|
964
|
+
if (docMedia) {
|
|
965
|
+
mediaPath = docMedia.path;
|
|
966
|
+
mediaType = docMedia.mimeType;
|
|
967
|
+
}
|
|
968
|
+
} catch (err: any) {
|
|
969
|
+
log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Try downloading a quoted file from cached downloadCode/spaceId+fileId.
|
|
975
|
+
const tryDownloadFromCache = async (
|
|
976
|
+
quotedMsgId: string | undefined,
|
|
977
|
+
): Promise<MediaFile | null> => {
|
|
978
|
+
if (!quotedMsgId) {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
const cached = getCachedDownloadCode(accountId, data.conversationId, quotedMsgId, storePath);
|
|
982
|
+
if (!cached) {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
let media: MediaFile | null = null;
|
|
986
|
+
if (cached.downloadCode) {
|
|
987
|
+
media = await downloadMedia(dingtalkConfig, cached.downloadCode, log);
|
|
988
|
+
}
|
|
989
|
+
if (!media && cached.spaceId && cached.fileId && data.senderStaffId) {
|
|
990
|
+
try {
|
|
991
|
+
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
992
|
+
media = await downloadGroupFile(
|
|
993
|
+
dingtalkConfig,
|
|
994
|
+
cached.spaceId,
|
|
995
|
+
cached.fileId,
|
|
996
|
+
unionId,
|
|
997
|
+
log,
|
|
998
|
+
);
|
|
999
|
+
} catch (err: any) {
|
|
1000
|
+
log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return media;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// Quoted picture: download via existing downloadMedia.
|
|
1007
|
+
if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
|
|
1008
|
+
const media = await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log);
|
|
1009
|
+
if (media) {
|
|
1010
|
+
mediaPath = media.path;
|
|
1011
|
+
mediaType = media.mimeType;
|
|
1012
|
+
} else {
|
|
1013
|
+
content.text = content.text.replace(
|
|
1014
|
+
content.quoted.prefix,
|
|
1015
|
+
"[引用了一张图片,但下载失败]\n\n",
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Quoted file/video/audio (unknownMsgType): cache-first, then group file API fallback.
|
|
1021
|
+
if (!mediaPath && content.quoted?.isQuotedFile) {
|
|
1022
|
+
let fileResolved = false;
|
|
1023
|
+
|
|
1024
|
+
// Step 1: Try msgId-based cache (works for both DM and group if bot saw the original message).
|
|
1025
|
+
const cachedMedia = await tryDownloadFromCache(content.quoted.msgId);
|
|
1026
|
+
if (cachedMedia) {
|
|
1027
|
+
mediaPath = cachedMedia.path;
|
|
1028
|
+
mediaType = cachedMedia.mimeType;
|
|
1029
|
+
fileResolved = true;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Step 2 (group only): Cache miss → fall back to group file API time-based matching.
|
|
1033
|
+
if (!fileResolved && !isDirect) {
|
|
1034
|
+
const resolved = await resolveQuotedFile(
|
|
1035
|
+
dingtalkConfig,
|
|
1036
|
+
{
|
|
1037
|
+
openConversationId: data.conversationId,
|
|
1038
|
+
senderStaffId: data.senderStaffId,
|
|
1039
|
+
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1040
|
+
},
|
|
1041
|
+
log,
|
|
1042
|
+
);
|
|
1043
|
+
if (resolved) {
|
|
1044
|
+
mediaPath = resolved.media.path;
|
|
1045
|
+
mediaType = resolved.media.mimeType;
|
|
1046
|
+
fileResolved = true;
|
|
1047
|
+
if (content.quoted.msgId) {
|
|
1048
|
+
cacheInboundDownloadCode(
|
|
1049
|
+
accountId,
|
|
1050
|
+
data.conversationId,
|
|
1051
|
+
content.quoted.msgId,
|
|
1052
|
+
undefined,
|
|
1053
|
+
"file",
|
|
1054
|
+
content.quoted.fileCreatedAt || Date.now(),
|
|
1055
|
+
{ storePath, spaceId: resolved.spaceId, fileId: resolved.fileId },
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (!fileResolved) {
|
|
1062
|
+
log?.warn?.(
|
|
1063
|
+
`[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1064
|
+
);
|
|
1065
|
+
const hint = isDirect
|
|
1066
|
+
? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
|
|
1067
|
+
: "[引用了一个文件,但无法获取内容]\n\n";
|
|
1068
|
+
content.text = content.text.replace(content.quoted.prefix, hint);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Quoted DingTalk doc / Drive file card:
|
|
1073
|
+
// 1) Prefer msgId-based cached metadata captured when the original doc card
|
|
1074
|
+
// message was seen.
|
|
1075
|
+
// 2) In group chats, if the bot never saw the original doc card message,
|
|
1076
|
+
// reuse the same group-file fallback chain as ordinary quoted files.
|
|
1077
|
+
if (!mediaPath && content.quoted?.isQuotedDocCard) {
|
|
1078
|
+
let docResolved = false;
|
|
1079
|
+
|
|
1080
|
+
const cachedDocMedia = await tryDownloadFromCache(content.quoted.msgId);
|
|
1081
|
+
if (cachedDocMedia) {
|
|
1082
|
+
mediaPath = cachedDocMedia.path;
|
|
1083
|
+
mediaType = cachedDocMedia.mimeType;
|
|
1084
|
+
docResolved = true;
|
|
1085
|
+
content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
|
|
1089
|
+
const resolved = await resolveQuotedFile(
|
|
1090
|
+
dingtalkConfig,
|
|
1091
|
+
{
|
|
1092
|
+
openConversationId: data.conversationId,
|
|
1093
|
+
senderStaffId: data.senderStaffId,
|
|
1094
|
+
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1095
|
+
},
|
|
1096
|
+
log,
|
|
1097
|
+
);
|
|
1098
|
+
if (resolved) {
|
|
1099
|
+
mediaPath = resolved.media.path;
|
|
1100
|
+
mediaType = resolved.media.mimeType;
|
|
1101
|
+
docResolved = true;
|
|
1102
|
+
content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
|
|
1103
|
+
if (content.quoted.msgId) {
|
|
1104
|
+
cacheInboundDownloadCode(
|
|
1105
|
+
accountId,
|
|
1106
|
+
data.conversationId,
|
|
1107
|
+
content.quoted.msgId,
|
|
1108
|
+
undefined,
|
|
1109
|
+
"interactiveCardFile",
|
|
1110
|
+
content.quoted.fileCreatedAt || Date.now(),
|
|
1111
|
+
{ storePath, spaceId: resolved.spaceId, fileId: resolved.fileId },
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (!docResolved) {
|
|
1118
|
+
log?.warn?.(
|
|
1119
|
+
`[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1120
|
+
);
|
|
1121
|
+
const hint = isDirect
|
|
1122
|
+
? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
|
|
1123
|
+
: "[引用了钉钉文档,但无法获取内容]\n\n";
|
|
1124
|
+
content.text = content.text.replace(content.quoted.prefix, hint);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Quoted AI card: prefer deterministic processQueryKey lookup, and only
|
|
1129
|
+
// fall back to the legacy createdAt matcher when the callback omits that key.
|
|
1130
|
+
if (content.quoted?.isQuotedCard) {
|
|
1131
|
+
const cardContent = content.quoted.processQueryKey
|
|
1132
|
+
? getCardContentByProcessQueryKey(
|
|
1133
|
+
accountId,
|
|
1134
|
+
to,
|
|
1135
|
+
content.quoted.processQueryKey,
|
|
1136
|
+
accountStorePath,
|
|
1137
|
+
)
|
|
1138
|
+
: content.quoted.cardCreatedAt
|
|
1139
|
+
? findCardContent(accountId, to, content.quoted.cardCreatedAt, accountStorePath)
|
|
1140
|
+
: null;
|
|
1141
|
+
if (cardContent) {
|
|
1142
|
+
const preview = cardContent.length > 50 ? cardContent.slice(0, 50) + "..." : cardContent;
|
|
1143
|
+
content.text = content.text.replace(
|
|
1144
|
+
content.quoted.prefix,
|
|
1145
|
+
`[引用机器人回复: "${preview}"]\n\n`,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
// Card cache miss: prefix already contains "[引用了机器人的回复]", keep as-is.
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let attachmentExtractedText: string | undefined;
|
|
1152
|
+
if (mediaPath) {
|
|
1153
|
+
try {
|
|
1154
|
+
const extracted = await extractAttachmentText({
|
|
1155
|
+
path: mediaPath,
|
|
1156
|
+
mimeType: mediaType,
|
|
1157
|
+
fileName: data.content?.fileName,
|
|
1158
|
+
});
|
|
1159
|
+
if (extracted?.text) {
|
|
1160
|
+
attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
|
|
1161
|
+
}
|
|
1162
|
+
} catch (err: any) {
|
|
1163
|
+
log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const inboundBody =
|
|
1168
|
+
mediaPath && /<media:[^>]+>/.test(content.text)
|
|
1169
|
+
? `${content.text}\n[media_path: ${mediaPath}]\n[media_type: ${mediaType || "unknown"}]`
|
|
1170
|
+
: content.text;
|
|
1171
|
+
const inboundText = attachmentExtractedText ? `${inboundBody}\n\n${attachmentExtractedText}` : inboundBody;
|
|
1172
|
+
const learningEnabled = isFeedbackLearningEnabled(dingtalkConfig);
|
|
1173
|
+
const learningContextBlock = buildLearningContextBlock({
|
|
1174
|
+
enabled: learningEnabled,
|
|
1175
|
+
storePath: accountStorePath,
|
|
1176
|
+
accountId,
|
|
1177
|
+
targetId: data.conversationId,
|
|
1178
|
+
content,
|
|
1179
|
+
});
|
|
364
1180
|
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
365
1181
|
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
366
1182
|
storePath,
|
|
@@ -369,11 +1185,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
369
1185
|
|
|
370
1186
|
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
371
1187
|
// GroupSystemPrompt is injected every turn (not only first-turn intro).
|
|
372
|
-
const
|
|
373
|
-
? [
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1188
|
+
const groupSystemPromptParts = !isDirect
|
|
1189
|
+
? [
|
|
1190
|
+
buildGroupTurnContextPrompt({
|
|
1191
|
+
conversationId: groupId,
|
|
1192
|
+
senderDingtalkId: senderId,
|
|
1193
|
+
senderName,
|
|
1194
|
+
}),
|
|
1195
|
+
groupConfig?.systemPrompt?.trim(),
|
|
1196
|
+
]
|
|
1197
|
+
: [];
|
|
1198
|
+
const extraSystemPrompt =
|
|
1199
|
+
[...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
|
|
377
1200
|
|
|
378
1201
|
if (!isDirect) {
|
|
379
1202
|
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
@@ -385,7 +1208,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
385
1208
|
channel: "DingTalk",
|
|
386
1209
|
from: fromLabel,
|
|
387
1210
|
timestamp: data.createAt,
|
|
388
|
-
body:
|
|
1211
|
+
body: inboundText,
|
|
389
1212
|
chatType: isDirect ? "direct" : "group",
|
|
390
1213
|
sender: { name: senderName, id: senderId },
|
|
391
1214
|
previousTimestamp,
|
|
@@ -394,8 +1217,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
394
1217
|
|
|
395
1218
|
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
396
1219
|
Body: body,
|
|
397
|
-
RawBody:
|
|
398
|
-
CommandBody:
|
|
1220
|
+
RawBody: inboundText,
|
|
1221
|
+
CommandBody: inboundText,
|
|
399
1222
|
From: to,
|
|
400
1223
|
To: to,
|
|
401
1224
|
SessionKey: route.sessionKey,
|
|
@@ -413,7 +1236,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
413
1236
|
MediaType: mediaType,
|
|
414
1237
|
MediaUrl: mediaPath,
|
|
415
1238
|
GroupMembers: groupMembers,
|
|
416
|
-
GroupSystemPrompt:
|
|
1239
|
+
GroupSystemPrompt: extraSystemPrompt,
|
|
417
1240
|
GroupChannel: isDirect ? undefined : route.sessionKey,
|
|
418
1241
|
CommandAuthorized: commandAuthorized,
|
|
419
1242
|
OriginatingChannel: "dingtalk",
|
|
@@ -432,98 +1255,194 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
432
1255
|
|
|
433
1256
|
log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
|
|
434
1257
|
|
|
1258
|
+
const ackReaction =
|
|
1259
|
+
typeof dingtalkConfig.ackReaction === "string"
|
|
1260
|
+
? dingtalkConfig.ackReaction.trim()
|
|
1261
|
+
: resolveAckReactionSetting({
|
|
1262
|
+
cfg,
|
|
1263
|
+
accountId,
|
|
1264
|
+
agentId: route.agentId,
|
|
1265
|
+
});
|
|
1266
|
+
const resolvedAckReaction =
|
|
1267
|
+
ackReaction === "emoji" ? classifyAckReactionEmoji(content.text).emoji : ackReaction;
|
|
1268
|
+
const shouldAttachAckReaction = Boolean(resolvedAckReaction);
|
|
1269
|
+
let ackReactionAttached = false;
|
|
1270
|
+
let ackReactionAttachedAt = 0;
|
|
1271
|
+
|
|
1272
|
+
if (shouldAttachAckReaction) {
|
|
1273
|
+
ackReactionAttached = await attachNativeAckReaction(
|
|
1274
|
+
dingtalkConfig,
|
|
1275
|
+
{
|
|
1276
|
+
msgId: data.msgId,
|
|
1277
|
+
conversationId: groupId,
|
|
1278
|
+
reactionName: resolvedAckReaction,
|
|
1279
|
+
},
|
|
1280
|
+
log,
|
|
1281
|
+
);
|
|
1282
|
+
if (ackReactionAttached) {
|
|
1283
|
+
ackReactionAttachedAt = Date.now();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
435
1287
|
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
436
1288
|
// from receiving concurrent dispatch calls on the same session key, which
|
|
437
1289
|
// causes empty replies for all but the first caller.
|
|
438
1290
|
const releaseSessionLock = await acquireSessionLock(route.sessionKey);
|
|
439
1291
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
const thinkingText = "🤔 思考中,请稍候...";
|
|
444
|
-
if (useCardMode && currentAICard) {
|
|
445
|
-
log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
|
|
446
|
-
} else {
|
|
447
|
-
lastCardContent = thinkingText;
|
|
448
|
-
const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
|
|
449
|
-
sessionWebhook,
|
|
450
|
-
atUserId: !isDirect ? senderId : null,
|
|
451
|
-
log,
|
|
452
|
-
card: currentAICard,
|
|
453
|
-
});
|
|
454
|
-
if (!sendResult.ok) {
|
|
455
|
-
throw new Error(sendResult.error || "Thinking message send failed");
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
} catch (err: any) {
|
|
459
|
-
log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
|
|
460
|
-
if (err?.response?.data !== undefined) {
|
|
461
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
|
|
462
|
-
}
|
|
463
|
-
}
|
|
1292
|
+
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
1293
|
+
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
464
1294
|
}
|
|
465
1295
|
|
|
466
|
-
|
|
1296
|
+
const controller = useCardMode && currentAICard
|
|
1297
|
+
? createCardDraftController({ card: currentAICard, log })
|
|
1298
|
+
: undefined;
|
|
1299
|
+
let cardFinalized = false;
|
|
1300
|
+
let finalTextForFallback: string | undefined;
|
|
1301
|
+
|
|
467
1302
|
try {
|
|
468
|
-
|
|
1303
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
469
1304
|
ctx,
|
|
470
1305
|
cfg,
|
|
471
1306
|
dispatcherOptions: {
|
|
472
1307
|
responsePrefix: "",
|
|
473
|
-
deliver: async (payload:
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
1308
|
+
deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
|
|
1309
|
+
async function deliverMediaAttachments(urls: string[]) {
|
|
1310
|
+
for (const rawMediaUrl of urls) {
|
|
1311
|
+
const preparedMedia = await prepareMediaInput(
|
|
1312
|
+
rawMediaUrl,
|
|
1313
|
+
log,
|
|
1314
|
+
dingtalkConfig.mediaUrlAllowlist,
|
|
1315
|
+
);
|
|
1316
|
+
try {
|
|
1317
|
+
const actualMediaPath = preparedMedia.path;
|
|
1318
|
+
const mediaType = resolveOutboundMediaType({
|
|
1319
|
+
mediaPath: actualMediaPath,
|
|
1320
|
+
asVoice: false,
|
|
1321
|
+
});
|
|
1322
|
+
if (sessionWebhook) {
|
|
1323
|
+
await sendBySession(dingtalkConfig, sessionWebhook, "", {
|
|
1324
|
+
mediaPath: actualMediaPath,
|
|
1325
|
+
mediaType,
|
|
1326
|
+
log,
|
|
1327
|
+
});
|
|
1328
|
+
} else {
|
|
1329
|
+
const sendResult = await sendProactiveMedia(
|
|
1330
|
+
dingtalkConfig,
|
|
1331
|
+
to,
|
|
1332
|
+
actualMediaPath,
|
|
1333
|
+
mediaType,
|
|
1334
|
+
{
|
|
1335
|
+
accountId,
|
|
1336
|
+
log,
|
|
1337
|
+
},
|
|
1338
|
+
);
|
|
1339
|
+
if (!sendResult.ok) {
|
|
1340
|
+
throw new Error(sendResult.error || "Media reply send failed");
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
} finally {
|
|
1344
|
+
await preparedMedia.cleanup?.();
|
|
1345
|
+
}
|
|
478
1346
|
}
|
|
1347
|
+
}
|
|
479
1348
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
1349
|
+
try {
|
|
1350
|
+
const richPayload = payload as typeof payload & {
|
|
1351
|
+
mediaUrl?: string;
|
|
1352
|
+
mediaUrls?: string[];
|
|
1353
|
+
};
|
|
1354
|
+
const textToSend = payload.text;
|
|
1355
|
+
const mediaUrls = Array.isArray(richPayload.mediaUrls)
|
|
1356
|
+
? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
|
|
1357
|
+
: richPayload.mediaUrl && typeof richPayload.mediaUrl === "string" && richPayload.mediaUrl.trim()
|
|
1358
|
+
? [richPayload.mediaUrl]
|
|
1359
|
+
: [];
|
|
1360
|
+
|
|
1361
|
+
// In card mode, deliver(final) must always reach finalize even with empty text
|
|
1362
|
+
// (e.g. bot sent a file via tool with no accompanying text).
|
|
1363
|
+
if ((typeof textToSend !== "string" || textToSend.length === 0) && mediaUrls.length === 0) {
|
|
1364
|
+
if (useCardMode && currentAICard && info?.kind === "final") {
|
|
1365
|
+
// fall through to card finalize below
|
|
1366
|
+
} else {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
483
1369
|
}
|
|
484
1370
|
|
|
1371
|
+
// ---- card mode: final ----
|
|
1372
|
+
// Do NOT finalize or stop the controller here — runtime calls
|
|
1373
|
+
// deliver(final) once per assistant turn, so there may be more
|
|
1374
|
+
// turns coming after tool calls. Finalization is deferred to
|
|
1375
|
+
// step 5 (post-dispatch) where the full accumulated content
|
|
1376
|
+
// is available.
|
|
485
1377
|
if (useCardMode && currentAICard && info?.kind === "final") {
|
|
486
|
-
|
|
1378
|
+
log?.info?.(
|
|
1379
|
+
`[DingTalk][Finalize] deliver(final) received — cardState=${currentAICard.state} ` +
|
|
1380
|
+
`textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
|
|
1381
|
+
`mediaUrls=${mediaUrls.length} ` +
|
|
1382
|
+
`lastAnswer="${(controller?.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
|
|
1383
|
+
`lastContent="${(controller?.getLastContent() ?? "").slice(0, 80)}"`,
|
|
1384
|
+
);
|
|
1385
|
+
if (mediaUrls.length > 0) {
|
|
1386
|
+
await deliverMediaAttachments(mediaUrls);
|
|
1387
|
+
}
|
|
1388
|
+
const rawFinalText = typeof textToSend === "string" ? textToSend : "";
|
|
1389
|
+
if (rawFinalText) {
|
|
1390
|
+
finalTextForFallback = rawFinalText;
|
|
1391
|
+
}
|
|
487
1392
|
return;
|
|
488
1393
|
}
|
|
489
1394
|
|
|
1395
|
+
// ---- card mode: tool ----
|
|
490
1396
|
if (useCardMode && currentAICard && info?.kind === "tool") {
|
|
491
|
-
if (isCardInTerminalState(currentAICard.state)) {
|
|
492
|
-
log?.debug?.(
|
|
493
|
-
`[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
|
|
494
|
-
);
|
|
1397
|
+
if (controller!.isFailed() || isCardInTerminalState(currentAICard.state)) {
|
|
1398
|
+
log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
|
|
495
1399
|
return;
|
|
496
1400
|
}
|
|
497
|
-
|
|
1401
|
+
await controller!.flush();
|
|
1402
|
+
await controller!.waitForInFlight();
|
|
498
1403
|
log?.info?.(
|
|
499
|
-
`[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
|
|
1404
|
+
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
500
1405
|
);
|
|
501
|
-
const toolText = formatContentForCard(textToSend, "tool");
|
|
1406
|
+
const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
|
|
502
1407
|
if (toolText) {
|
|
503
1408
|
const sendResult = await sendMessage(dingtalkConfig, to, toolText, {
|
|
504
1409
|
sessionWebhook,
|
|
505
1410
|
atUserId: !isDirect ? senderId : null,
|
|
506
1411
|
log,
|
|
507
1412
|
card: currentAICard,
|
|
1413
|
+
accountId,
|
|
1414
|
+
storePath,
|
|
1415
|
+
conversationId: groupId,
|
|
508
1416
|
cardUpdateMode: "append",
|
|
509
1417
|
});
|
|
510
1418
|
if (!sendResult.ok) {
|
|
511
1419
|
throw new Error(sendResult.error || "Tool stream send failed");
|
|
512
1420
|
}
|
|
513
|
-
lastCardContent = currentAICard.lastStreamedContent || toolText;
|
|
514
|
-
return;
|
|
515
1421
|
}
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ---- media delivery (all modes) ----
|
|
1426
|
+
if (mediaUrls.length > 0) {
|
|
1427
|
+
await deliverMediaAttachments(mediaUrls);
|
|
516
1428
|
}
|
|
517
1429
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
1430
|
+
// ---- non-card mode (markdown/text) ----
|
|
1431
|
+
if (!useCardMode || !currentAICard) {
|
|
1432
|
+
if (typeof textToSend !== "string" || textToSend.length === 0) {
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const sendResult = await sendMessage(dingtalkConfig, to, textToSend, {
|
|
1436
|
+
sessionWebhook,
|
|
1437
|
+
atUserId: !isDirect ? senderId : null,
|
|
1438
|
+
log,
|
|
1439
|
+
accountId,
|
|
1440
|
+
storePath,
|
|
1441
|
+
conversationId: groupId,
|
|
1442
|
+
});
|
|
1443
|
+
if (!sendResult.ok) {
|
|
1444
|
+
throw new Error(sendResult.error || "Reply send failed");
|
|
1445
|
+
}
|
|
527
1446
|
}
|
|
528
1447
|
} catch (err: any) {
|
|
529
1448
|
log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
|
|
@@ -535,99 +1454,103 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
535
1454
|
},
|
|
536
1455
|
},
|
|
537
1456
|
replyOptions: {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (!thinkingText) {
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
try {
|
|
553
|
-
const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
|
|
554
|
-
sessionWebhook,
|
|
555
|
-
atUserId: !isDirect ? senderId : null,
|
|
556
|
-
log,
|
|
557
|
-
card: currentAICard,
|
|
558
|
-
cardUpdateMode: "append",
|
|
559
|
-
});
|
|
560
|
-
if (!sendResult.ok) {
|
|
561
|
-
throw new Error(sendResult.error || "Thinking stream send failed");
|
|
1457
|
+
disableBlockStreaming: dingtalkConfig.cardRealTimeStream && controller ? true : undefined,
|
|
1458
|
+
|
|
1459
|
+
onAssistantMessageStart: controller
|
|
1460
|
+
? () => { controller.notifyNewAssistantTurn(); }
|
|
1461
|
+
: undefined,
|
|
1462
|
+
|
|
1463
|
+
onPartialReply: dingtalkConfig.cardRealTimeStream && controller
|
|
1464
|
+
? (payload: ReplyStreamPayload) => {
|
|
1465
|
+
if (payload.text) {
|
|
1466
|
+
controller.updateAnswer(payload.text);
|
|
1467
|
+
}
|
|
562
1468
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1469
|
+
: undefined,
|
|
1470
|
+
|
|
1471
|
+
onReasoningStream: controller
|
|
1472
|
+
? (payload: ReplyStreamPayload) => {
|
|
1473
|
+
if (payload.text) {
|
|
1474
|
+
controller.updateReasoning(payload.text);
|
|
1475
|
+
}
|
|
567
1476
|
}
|
|
568
|
-
|
|
569
|
-
},
|
|
1477
|
+
: undefined,
|
|
570
1478
|
},
|
|
571
1479
|
});
|
|
572
|
-
queuedFinal = dispatchResult?.queuedFinal;
|
|
573
1480
|
} catch (dispatchErr: any) {
|
|
574
1481
|
if (useCardMode && currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1482
|
+
controller!.stop();
|
|
1483
|
+
await controller!.waitForInFlight();
|
|
1484
|
+
if (!cardFinalized) {
|
|
1485
|
+
try {
|
|
1486
|
+
await finishAICard(currentAICard, "❌ 处理失败", log);
|
|
1487
|
+
} catch (cardCloseErr: any) {
|
|
1488
|
+
log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${cardCloseErr.message}`);
|
|
1489
|
+
currentAICard.state = AICardStatus.FAILED;
|
|
1490
|
+
currentAICard.lastUpdated = Date.now();
|
|
1491
|
+
}
|
|
581
1492
|
}
|
|
582
1493
|
}
|
|
583
1494
|
throw dispatchErr;
|
|
584
1495
|
}
|
|
585
1496
|
|
|
586
|
-
// 5)
|
|
587
|
-
|
|
1497
|
+
// 5) Post-dispatch card finalization.
|
|
1498
|
+
// This is the sole finalize path — deliver(final) defers here because
|
|
1499
|
+
// runtime may call deliver(final) multiple times (once per assistant turn).
|
|
1500
|
+
log?.info?.(
|
|
1501
|
+
`[DingTalk][Finalize] Step 5 entry — useCardMode=${useCardMode} ` +
|
|
1502
|
+
`hasCard=${!!currentAICard} cardFinalized=${cardFinalized} ` +
|
|
1503
|
+
`cardState=${currentAICard?.state ?? "N/A"} ` +
|
|
1504
|
+
`controllerFailed=${controller?.isFailed() ?? "N/A"} ` +
|
|
1505
|
+
`finalTextForFallback="${(finalTextForFallback ?? "").slice(0, 80)}" ` +
|
|
1506
|
+
`lastAnswer="${(controller?.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
|
|
1507
|
+
`lastContent="${(controller?.getLastContent() ?? "").slice(0, 80)}"`,
|
|
1508
|
+
);
|
|
1509
|
+
if (useCardMode && currentAICard && !cardFinalized) {
|
|
588
1510
|
try {
|
|
589
|
-
if (
|
|
590
|
-
log?.
|
|
591
|
-
`[DingTalk] Skipping
|
|
1511
|
+
if (currentAICard.state === AICardStatus.FINISHED) {
|
|
1512
|
+
log?.info?.(
|
|
1513
|
+
`[DingTalk][Finalize] Skipping — card already FINISHED`,
|
|
592
1514
|
);
|
|
593
1515
|
return;
|
|
594
1516
|
}
|
|
595
1517
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
?
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
currentAICard.lastUpdated = Date.now();
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
const finalContent = finalContentCandidate;
|
|
618
|
-
await finishAICard(currentAICard, finalContent, log);
|
|
619
|
-
} else {
|
|
620
|
-
const lastStreamed = currentAICard.lastStreamedContent;
|
|
621
|
-
if (typeof lastStreamed === "string" && lastStreamed.trim().length > 0) {
|
|
622
|
-
await finishAICard(currentAICard, lastStreamed, log);
|
|
1518
|
+
if (currentAICard.state === AICardStatus.FAILED || controller!.isFailed()) {
|
|
1519
|
+
const fallbackText = finalTextForFallback
|
|
1520
|
+
|| controller!.getLastAnswerContent()
|
|
1521
|
+
|| controller!.getLastContent()
|
|
1522
|
+
|| currentAICard.lastStreamedContent;
|
|
1523
|
+
if (fallbackText) {
|
|
1524
|
+
log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
|
|
1525
|
+
const sendResult = await sendMessage(dingtalkConfig, to, fallbackText, {
|
|
1526
|
+
sessionWebhook,
|
|
1527
|
+
atUserId: !isDirect ? senderId : null,
|
|
1528
|
+
log,
|
|
1529
|
+
accountId,
|
|
1530
|
+
storePath,
|
|
1531
|
+
conversationId: groupId,
|
|
1532
|
+
});
|
|
1533
|
+
if (!sendResult.ok) {
|
|
1534
|
+
throw new Error(sendResult.error || "Markdown fallback send failed after card failure — user received no reply");
|
|
1535
|
+
}
|
|
623
1536
|
} else {
|
|
624
|
-
|
|
625
|
-
log?.debug?.(
|
|
626
|
-
"[DingTalk] No textual content was produced; finalizing AI Card with default completion content.",
|
|
627
|
-
);
|
|
628
|
-
await finishAICard(currentAICard, defaultFinalContent, log);
|
|
1537
|
+
log?.debug?.("[DingTalk] Card failed but no content to fallback with");
|
|
629
1538
|
}
|
|
1539
|
+
return;
|
|
630
1540
|
}
|
|
1541
|
+
|
|
1542
|
+
await controller!.flush();
|
|
1543
|
+
await controller!.waitForInFlight();
|
|
1544
|
+
controller!.stop();
|
|
1545
|
+
const finalText = controller!.getLastAnswerContent()
|
|
1546
|
+
|| finalTextForFallback
|
|
1547
|
+
|| "✅ Done";
|
|
1548
|
+
log?.info?.(
|
|
1549
|
+
`[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
|
|
1550
|
+
`source=${controller!.getLastAnswerContent() ? "lastAnswerContent" : finalTextForFallback ? "finalTextForFallback" : "fallbackDone"} ` +
|
|
1551
|
+
`preview="${finalText.slice(0, 120)}"`,
|
|
1552
|
+
);
|
|
1553
|
+
await finishAICard(currentAICard, finalText, log);
|
|
631
1554
|
} catch (err: any) {
|
|
632
1555
|
log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
|
|
633
1556
|
if (err?.response?.data !== undefined) {
|
|
@@ -645,5 +1568,23 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
645
1568
|
}
|
|
646
1569
|
} finally {
|
|
647
1570
|
releaseSessionLock();
|
|
1571
|
+
if (ackReactionAttached) {
|
|
1572
|
+
void (async () => {
|
|
1573
|
+
const elapsedMs = ackReactionAttachedAt > 0 ? Date.now() - ackReactionAttachedAt : 0;
|
|
1574
|
+
const remainingVisibleMs = MIN_THINKING_REACTION_VISIBLE_MS - elapsedMs;
|
|
1575
|
+
if (remainingVisibleMs > 0) {
|
|
1576
|
+
await new Promise(resolve => setTimeout(resolve, remainingVisibleMs));
|
|
1577
|
+
}
|
|
1578
|
+
await recallNativeAckReactionWithRetry(
|
|
1579
|
+
dingtalkConfig,
|
|
1580
|
+
{
|
|
1581
|
+
msgId: data.msgId,
|
|
1582
|
+
conversationId: groupId,
|
|
1583
|
+
reactionName: resolvedAckReaction,
|
|
1584
|
+
},
|
|
1585
|
+
log,
|
|
1586
|
+
);
|
|
1587
|
+
})();
|
|
1588
|
+
}
|
|
648
1589
|
}
|
|
649
1590
|
}
|