@soimy/dingtalk 3.1.4 → 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 +808 -40
- package/index.ts +62 -0
- package/package.json +4 -2
- 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 +794 -62
- package/src/channel.ts +675 -179
- package/src/config-schema.ts +49 -5
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +356 -36
- 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 +1191 -206
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +566 -8
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +85 -3
- 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 +239 -59
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +34 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +165 -22
- package/src/utils.ts +231 -12
package/src/inbound-handler.ts
CHANGED
|
@@ -1,32 +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
|
-
cleanupCardCache,
|
|
6
6
|
createAICard,
|
|
7
|
+
findCardContent,
|
|
7
8
|
finishAICard,
|
|
8
9
|
formatContentForCard,
|
|
9
|
-
|
|
10
|
-
getCardById,
|
|
10
|
+
getCardContentByProcessQueryKey,
|
|
11
11
|
isCardInTerminalState,
|
|
12
|
-
streamAICard,
|
|
13
12
|
} from "./card-service";
|
|
14
|
-
import {
|
|
13
|
+
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
14
|
+
import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
|
|
15
15
|
import { formatGroupMembers, noteGroupMember } from "./group-members-store";
|
|
16
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";
|
|
17
31
|
import { extractMessageContent } from "./message-utils";
|
|
32
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
18
33
|
import { registerPeerId } from "./peer-id-registry";
|
|
19
34
|
import {
|
|
20
35
|
clearProactiveRiskObservationsForTest,
|
|
21
36
|
getProactiveRiskObservationForAny,
|
|
22
37
|
} from "./proactive-risk-registry";
|
|
38
|
+
import {
|
|
39
|
+
appendQuoteJournalEntry,
|
|
40
|
+
DEFAULT_JOURNAL_TTL_DAYS,
|
|
41
|
+
resolveQuotedMessageById,
|
|
42
|
+
} from "./quote-journal";
|
|
23
43
|
import { getDingTalkRuntime } from "./runtime";
|
|
24
|
-
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";
|
|
25
47
|
import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
|
|
26
48
|
import { AICardStatus } from "./types";
|
|
49
|
+
import { createCardDraftController } from "./card-draft-controller";
|
|
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";
|
|
27
79
|
import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
|
|
28
80
|
|
|
29
81
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
82
|
+
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
83
|
+
const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
|
|
30
84
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
31
85
|
|
|
32
86
|
export function resetProactivePermissionHintStateForTest(): void {
|
|
@@ -38,6 +92,7 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
38
92
|
isDirect: boolean;
|
|
39
93
|
accountId: string;
|
|
40
94
|
senderId: string;
|
|
95
|
+
senderOriginalId?: string;
|
|
41
96
|
senderStaffId?: string;
|
|
42
97
|
config: DingTalkConfig;
|
|
43
98
|
nowMs: number;
|
|
@@ -56,11 +111,14 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
56
111
|
return false;
|
|
57
112
|
}
|
|
58
113
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
64
122
|
if (!riskObservation || riskObservation.source !== "proactive-api") {
|
|
65
123
|
return false;
|
|
66
124
|
}
|
|
@@ -80,14 +138,43 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
80
138
|
return true;
|
|
81
139
|
}
|
|
82
140
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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();
|
|
89
153
|
}
|
|
90
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");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type ReplyStreamPayload = {
|
|
171
|
+
text?: string;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
type ReplyChunkInfo = {
|
|
175
|
+
kind?: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
91
178
|
/**
|
|
92
179
|
* Download DingTalk media file via runtime media service (sandbox-compatible).
|
|
93
180
|
* Files are stored in the global media inbound directory.
|
|
@@ -148,8 +235,11 @@ export async function downloadMedia(
|
|
|
148
235
|
const contentType = mediaResponse.headers["content-type"] || "application/octet-stream";
|
|
149
236
|
const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
|
|
150
237
|
|
|
151
|
-
|
|
152
|
-
|
|
238
|
+
const maxBytes =
|
|
239
|
+
config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
|
|
240
|
+
const saved = maxBytes
|
|
241
|
+
? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
|
|
242
|
+
: await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
|
|
153
243
|
log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
|
|
154
244
|
return { path: saved.path, mimeType: saved.contentType ?? contentType };
|
|
155
245
|
} catch (err: any) {
|
|
@@ -183,10 +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:"
|
|
187
|
-
|
|
188
|
-
// Clean up old terminal cards opportunistically on inbound traffic.
|
|
189
|
-
cleanupCardCache();
|
|
276
|
+
log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
|
|
190
277
|
|
|
191
278
|
// 1) Ignore self messages from bot.
|
|
192
279
|
if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
|
|
@@ -194,13 +281,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
194
281
|
return;
|
|
195
282
|
}
|
|
196
283
|
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
284
|
+
const extractedContent = extractMessageContent(data);
|
|
285
|
+
if (!extractedContent.text) {
|
|
199
286
|
return;
|
|
200
287
|
}
|
|
201
288
|
|
|
202
289
|
const isDirect = data.conversationType === "1";
|
|
203
|
-
const
|
|
290
|
+
const senderOriginalId = (data.senderId || "").trim();
|
|
291
|
+
const senderStaffId = (data.senderStaffId || "").trim();
|
|
292
|
+
const senderId = senderStaffId || senderOriginalId;
|
|
204
293
|
const senderName = data.senderNick || "Unknown";
|
|
205
294
|
const groupId = data.conversationId;
|
|
206
295
|
const groupName = data.conversationTitle || "Group";
|
|
@@ -215,7 +304,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
215
304
|
isDirect,
|
|
216
305
|
accountId,
|
|
217
306
|
senderId,
|
|
218
|
-
|
|
307
|
+
senderOriginalId,
|
|
308
|
+
senderStaffId,
|
|
219
309
|
config: dingtalkConfig,
|
|
220
310
|
nowMs: Date.now(),
|
|
221
311
|
})
|
|
@@ -314,11 +404,29 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
314
404
|
}
|
|
315
405
|
}
|
|
316
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
|
+
});
|
|
317
425
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
318
426
|
cfg,
|
|
319
427
|
channel: "dingtalk",
|
|
320
428
|
accountId,
|
|
321
|
-
peer: { kind:
|
|
429
|
+
peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
|
|
322
430
|
});
|
|
323
431
|
|
|
324
432
|
// Route resolved before media download for session context and routing metadata.
|
|
@@ -326,6 +434,482 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
326
434
|
agentId: route.agentId,
|
|
327
435
|
});
|
|
328
436
|
|
|
437
|
+
const to = isDirect ? senderId : groupId;
|
|
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
|
+
}
|
|
835
|
+
// 3) Select response mode (card vs markdown).
|
|
836
|
+
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
837
|
+
// feedback while large files are still being downloaded.
|
|
838
|
+
let useCardMode = dingtalkConfig.messageType === "card";
|
|
839
|
+
let currentAICard = undefined;
|
|
840
|
+
|
|
841
|
+
if (useCardMode) {
|
|
842
|
+
try {
|
|
843
|
+
log?.debug?.(
|
|
844
|
+
`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
|
|
845
|
+
);
|
|
846
|
+
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
847
|
+
accountId,
|
|
848
|
+
storePath: accountStorePath,
|
|
849
|
+
});
|
|
850
|
+
if (aiCard) {
|
|
851
|
+
currentAICard = aiCard;
|
|
852
|
+
} else {
|
|
853
|
+
useCardMode = false;
|
|
854
|
+
log?.warn?.(
|
|
855
|
+
"[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
} catch (err: any) {
|
|
859
|
+
useCardMode = false;
|
|
860
|
+
log?.warn?.(
|
|
861
|
+
`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
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
|
+
|
|
329
913
|
let mediaPath: string | undefined;
|
|
330
914
|
let mediaType: string | undefined;
|
|
331
915
|
if (content.mediaPath && dingtalkConfig.robotCode) {
|
|
@@ -335,6 +919,264 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
335
919
|
mediaType = media.mimeType;
|
|
336
920
|
}
|
|
337
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
|
+
});
|
|
338
1180
|
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
339
1181
|
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
340
1182
|
storePath,
|
|
@@ -343,11 +1185,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
343
1185
|
|
|
344
1186
|
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
345
1187
|
// GroupSystemPrompt is injected every turn (not only first-turn intro).
|
|
346
|
-
const
|
|
347
|
-
? [
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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;
|
|
351
1200
|
|
|
352
1201
|
if (!isDirect) {
|
|
353
1202
|
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
@@ -359,18 +1208,17 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
359
1208
|
channel: "DingTalk",
|
|
360
1209
|
from: fromLabel,
|
|
361
1210
|
timestamp: data.createAt,
|
|
362
|
-
body:
|
|
1211
|
+
body: inboundText,
|
|
363
1212
|
chatType: isDirect ? "direct" : "group",
|
|
364
1213
|
sender: { name: senderName, id: senderId },
|
|
365
1214
|
previousTimestamp,
|
|
366
1215
|
envelope: envelopeOptions,
|
|
367
1216
|
});
|
|
368
1217
|
|
|
369
|
-
const to = isDirect ? senderId : groupId;
|
|
370
1218
|
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
371
1219
|
Body: body,
|
|
372
|
-
RawBody:
|
|
373
|
-
CommandBody:
|
|
1220
|
+
RawBody: inboundText,
|
|
1221
|
+
CommandBody: inboundText,
|
|
374
1222
|
From: to,
|
|
375
1223
|
To: to,
|
|
376
1224
|
SessionKey: route.sessionKey,
|
|
@@ -388,7 +1236,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
388
1236
|
MediaType: mediaType,
|
|
389
1237
|
MediaUrl: mediaPath,
|
|
390
1238
|
GroupMembers: groupMembers,
|
|
391
|
-
GroupSystemPrompt:
|
|
1239
|
+
GroupSystemPrompt: extraSystemPrompt,
|
|
392
1240
|
GroupChannel: isDirect ? undefined : route.sessionKey,
|
|
393
1241
|
CommandAuthorized: commandAuthorized,
|
|
394
1242
|
OriginatingChannel: "dingtalk",
|
|
@@ -407,199 +1255,336 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
407
1255
|
|
|
408
1256
|
log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
|
|
409
1257
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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;
|
|
414
1271
|
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
log
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (aiCard) {
|
|
428
|
-
currentAICard = aiCard;
|
|
429
|
-
} else {
|
|
430
|
-
log?.warn?.(
|
|
431
|
-
"[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
} catch (err: any) {
|
|
435
|
-
log?.warn?.(
|
|
436
|
-
`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
|
|
437
|
-
);
|
|
438
|
-
}
|
|
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();
|
|
439
1284
|
}
|
|
440
1285
|
}
|
|
441
1286
|
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
lastCardContent = thinkingText;
|
|
450
|
-
await sendMessage(dingtalkConfig, to, thinkingText, {
|
|
451
|
-
sessionWebhook,
|
|
452
|
-
atUserId: !isDirect ? senderId : null,
|
|
453
|
-
log,
|
|
454
|
-
accountId,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
} catch (err: any) {
|
|
458
|
-
log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
|
|
459
|
-
if (err?.response?.data !== undefined) {
|
|
460
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
|
|
461
|
-
}
|
|
1287
|
+
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
1288
|
+
// from receiving concurrent dispatch calls on the same session key, which
|
|
1289
|
+
// causes empty replies for all but the first caller.
|
|
1290
|
+
const releaseSessionLock = await acquireSessionLock(route.sessionKey);
|
|
1291
|
+
try {
|
|
1292
|
+
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
1293
|
+
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
462
1294
|
}
|
|
463
|
-
}
|
|
464
1295
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
deliver: async (payload: any, info?: { kind: string }) => {
|
|
471
|
-
try {
|
|
472
|
-
const textToSend = payload.markdown || payload.text;
|
|
473
|
-
if (!textToSend) {
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
1296
|
+
const controller = useCardMode && currentAICard
|
|
1297
|
+
? createCardDraftController({ card: currentAICard, log })
|
|
1298
|
+
: undefined;
|
|
1299
|
+
let cardFinalized = false;
|
|
1300
|
+
let finalTextForFallback: string | undefined;
|
|
476
1301
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1302
|
+
try {
|
|
1303
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1304
|
+
ctx,
|
|
1305
|
+
cfg,
|
|
1306
|
+
dispatcherOptions: {
|
|
1307
|
+
responsePrefix: "",
|
|
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
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
481
1348
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
: [];
|
|
486
1360
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
1369
|
+
}
|
|
495
1370
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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.
|
|
1377
|
+
if (useCardMode && currentAICard && info?.kind === "final") {
|
|
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
|
+
}
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// ---- card mode: tool ----
|
|
1396
|
+
if (useCardMode && currentAICard && info?.kind === "tool") {
|
|
1397
|
+
if (controller!.isFailed() || isCardInTerminalState(currentAICard.state)) {
|
|
1398
|
+
log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
await controller!.flush();
|
|
1402
|
+
await controller!.waitForInFlight();
|
|
1403
|
+
log?.info?.(
|
|
1404
|
+
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
1405
|
+
);
|
|
1406
|
+
const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
|
|
1407
|
+
if (toolText) {
|
|
1408
|
+
const sendResult = await sendMessage(dingtalkConfig, to, toolText, {
|
|
1409
|
+
sessionWebhook,
|
|
1410
|
+
atUserId: !isDirect ? senderId : null,
|
|
1411
|
+
log,
|
|
1412
|
+
card: currentAICard,
|
|
1413
|
+
accountId,
|
|
1414
|
+
storePath,
|
|
1415
|
+
conversationId: groupId,
|
|
1416
|
+
cardUpdateMode: "append",
|
|
1417
|
+
});
|
|
1418
|
+
if (!sendResult.ok) {
|
|
1419
|
+
throw new Error(sendResult.error || "Tool stream send failed");
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ---- media delivery (all modes) ----
|
|
1426
|
+
if (mediaUrls.length > 0) {
|
|
1427
|
+
await deliverMediaAttachments(mediaUrls);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
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
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
} catch (err: any) {
|
|
1448
|
+
log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
|
|
1449
|
+
if (err?.response?.data !== undefined) {
|
|
1450
|
+
log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
|
|
1451
|
+
}
|
|
1452
|
+
throw err;
|
|
503
1453
|
}
|
|
504
|
-
}
|
|
1454
|
+
},
|
|
1455
|
+
},
|
|
1456
|
+
replyOptions: {
|
|
1457
|
+
disableBlockStreaming: dingtalkConfig.cardRealTimeStream && controller ? true : undefined,
|
|
505
1458
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
: undefined,
|
|
1470
|
+
|
|
1471
|
+
onReasoningStream: controller
|
|
1472
|
+
? (payload: ReplyStreamPayload) => {
|
|
1473
|
+
if (payload.text) {
|
|
1474
|
+
controller.updateReasoning(payload.text);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
: undefined,
|
|
1478
|
+
},
|
|
1479
|
+
});
|
|
1480
|
+
} catch (dispatchErr: any) {
|
|
1481
|
+
if (useCardMode && currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
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();
|
|
517
1491
|
}
|
|
518
|
-
throw err;
|
|
519
|
-
}
|
|
520
|
-
},
|
|
521
|
-
},
|
|
522
|
-
replyOptions: {
|
|
523
|
-
// Real-time reasoning stream support for card mode.
|
|
524
|
-
onReasoningStream: async (payload: any) => {
|
|
525
|
-
if (!useCardMode || !currentAICard) {
|
|
526
|
-
return;
|
|
527
1492
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1493
|
+
}
|
|
1494
|
+
throw dispatchErr;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
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) {
|
|
1510
|
+
try {
|
|
1511
|
+
if (currentAICard.state === AICardStatus.FINISHED) {
|
|
1512
|
+
log?.info?.(
|
|
1513
|
+
`[DingTalk][Finalize] Skipping — card already FINISHED`,
|
|
531
1514
|
);
|
|
532
1515
|
return;
|
|
533
1516
|
}
|
|
534
|
-
|
|
535
|
-
if (
|
|
1517
|
+
|
|
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
|
+
}
|
|
1536
|
+
} else {
|
|
1537
|
+
log?.debug?.("[DingTalk] Card failed but no content to fallback with");
|
|
1538
|
+
}
|
|
536
1539
|
return;
|
|
537
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);
|
|
1554
|
+
} catch (err: any) {
|
|
1555
|
+
log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
|
|
1556
|
+
if (err?.response?.data !== undefined) {
|
|
1557
|
+
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
|
|
1558
|
+
}
|
|
538
1559
|
try {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (err?.response?.data !== undefined) {
|
|
543
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
|
|
1560
|
+
if (currentAICard.state !== AICardStatus.FINISHED) {
|
|
1561
|
+
currentAICard.state = AICardStatus.FAILED;
|
|
1562
|
+
currentAICard.lastUpdated = Date.now();
|
|
544
1563
|
}
|
|
1564
|
+
} catch (stateErr: any) {
|
|
1565
|
+
log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
|
|
545
1566
|
}
|
|
546
|
-
},
|
|
547
|
-
},
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
// 5) Finalize card stream if card mode is active.
|
|
551
|
-
if (useCardMode && currentAICard) {
|
|
552
|
-
try {
|
|
553
|
-
if (isCardInTerminalState(currentAICard.state)) {
|
|
554
|
-
log?.debug?.(
|
|
555
|
-
`[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
|
|
556
|
-
);
|
|
557
|
-
return;
|
|
558
1567
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
hasLastCardContent && typeof lastCardContent === "string"
|
|
569
|
-
? lastCardContent
|
|
570
|
-
: typeof queuedFinal === "string"
|
|
571
|
-
? queuedFinal
|
|
572
|
-
: "";
|
|
573
|
-
if (isUnhandledStopReasonText(finalContentCandidate)) {
|
|
574
|
-
log?.warn?.(
|
|
575
|
-
`[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
|
|
576
|
-
);
|
|
577
|
-
currentAICard.state = AICardStatus.FINISHED;
|
|
578
|
-
currentAICard.lastUpdated = Date.now();
|
|
579
|
-
return;
|
|
1568
|
+
}
|
|
1569
|
+
} finally {
|
|
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));
|
|
580
1577
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1578
|
+
await recallNativeAckReactionWithRetry(
|
|
1579
|
+
dingtalkConfig,
|
|
1580
|
+
{
|
|
1581
|
+
msgId: data.msgId,
|
|
1582
|
+
conversationId: groupId,
|
|
1583
|
+
reactionName: resolvedAckReaction,
|
|
1584
|
+
},
|
|
1585
|
+
log,
|
|
587
1586
|
);
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
} catch (err: any) {
|
|
591
|
-
log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
|
|
592
|
-
if (err?.response?.data !== undefined) {
|
|
593
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
|
|
594
|
-
}
|
|
595
|
-
try {
|
|
596
|
-
if (currentAICard.state !== AICardStatus.FINISHED) {
|
|
597
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
598
|
-
currentAICard.lastUpdated = Date.now();
|
|
599
|
-
}
|
|
600
|
-
} catch (stateErr: any) {
|
|
601
|
-
log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
|
|
602
|
-
}
|
|
1587
|
+
})();
|
|
603
1588
|
}
|
|
604
1589
|
}
|
|
605
1590
|
}
|