@soimy/dingtalk 3.3.0 → 3.4.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/README.md +141 -12
- package/package.json +2 -2
- package/src/access-control.ts +65 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +17 -4
- package/src/ack-reaction-service.ts +66 -19
- package/src/attachment-text-extractor.ts +2 -1
- package/src/card-service.ts +145 -257
- package/src/channel.ts +60 -28
- package/src/config-schema.ts +28 -6
- package/src/config.ts +29 -5
- package/src/inbound-handler.ts +694 -520
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +221 -42
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +62 -5
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/send-service.ts +140 -52
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +114 -9
- package/src/quote-journal.ts +0 -242
- package/src/quoted-msg-cache.ts +0 -226
package/src/inbound-handler.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
-
import { normalizeAllowFrom, isSenderAllowed,
|
|
2
|
+
import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
|
|
3
|
+
import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
|
|
4
|
+
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
5
|
+
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
6
|
+
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
3
7
|
import { extractAttachmentText } from "./attachment-text-extractor";
|
|
4
8
|
import { getAccessToken } from "./auth";
|
|
5
|
-
import {
|
|
6
|
-
createAICard,
|
|
7
|
-
findCardContent,
|
|
8
|
-
finishAICard,
|
|
9
|
-
formatContentForCard,
|
|
10
|
-
getCardContentByProcessQueryKey,
|
|
11
|
-
isCardInTerminalState,
|
|
12
|
-
} from "./card-service";
|
|
13
|
-
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
9
|
+
import { createAICard } from "./card-service";
|
|
14
10
|
import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
|
|
11
|
+
import {
|
|
12
|
+
applyManualTargetLearningRule,
|
|
13
|
+
applyManualTargetsLearningRule,
|
|
14
|
+
applyManualGlobalLearningRule,
|
|
15
|
+
applyManualSessionLearningNote,
|
|
16
|
+
applyTargetSetLearningRule,
|
|
17
|
+
buildLearningContextBlock,
|
|
18
|
+
createOrUpdateTargetSet,
|
|
19
|
+
deleteManualRule,
|
|
20
|
+
disableManualRule,
|
|
21
|
+
isFeedbackLearningEnabled,
|
|
22
|
+
listLearningTargetSets,
|
|
23
|
+
listScopedLearningRules,
|
|
24
|
+
resolveManualForcedReply,
|
|
25
|
+
} from "./feedback-learning-service";
|
|
15
26
|
import { formatGroupMembers, noteGroupMember } from "./group-members-store";
|
|
16
|
-
import { setCurrentLogger } from "./logger-context";
|
|
17
27
|
import {
|
|
18
28
|
formatLearnAppliedReply,
|
|
19
29
|
formatLearnCommandHelp,
|
|
@@ -28,28 +38,30 @@ import {
|
|
|
28
38
|
isLearningOwner,
|
|
29
39
|
parseLearnCommand,
|
|
30
40
|
} from "./learning-command-service";
|
|
31
|
-
import {
|
|
41
|
+
import { setCurrentLogger } from "./logger-context";
|
|
32
42
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
45
|
+
DEFAULT_MESSAGE_CONTEXT_TTL_DAYS,
|
|
46
|
+
upsertInboundMessageContext,
|
|
47
|
+
} from "./message-context-store";
|
|
48
|
+
import { extractMessageContent } from "./message-utils";
|
|
49
|
+
import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
|
|
50
|
+
import {
|
|
51
|
+
buildInboundQuotedRef,
|
|
52
|
+
createReplyQuotedRef,
|
|
53
|
+
resolveQuotedRecord,
|
|
54
|
+
} from "./messaging/quoted-ref";
|
|
33
55
|
import { registerPeerId } from "./peer-id-registry";
|
|
34
56
|
import {
|
|
35
57
|
clearProactiveRiskObservationsForTest,
|
|
36
58
|
getProactiveRiskObservationForAny,
|
|
37
59
|
} from "./proactive-risk-registry";
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
resolveQuotedMessageById,
|
|
42
|
-
} from "./quote-journal";
|
|
60
|
+
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
|
|
61
|
+
import { createReplyStrategy } from "./reply-strategy";
|
|
62
|
+
import type { DeliverPayload } from "./reply-strategy";
|
|
43
63
|
import { getDingTalkRuntime } from "./runtime";
|
|
44
64
|
import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
|
|
45
|
-
import { clearSessionPeerOverride, getSessionPeerOverride, setSessionPeerOverride } from "./session-peer-store";
|
|
46
|
-
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
47
|
-
import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
|
|
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
65
|
import {
|
|
54
66
|
formatSessionAliasBoundReply,
|
|
55
67
|
formatSessionAliasClearedReply,
|
|
@@ -60,29 +72,82 @@ import {
|
|
|
60
72
|
parseSessionCommand,
|
|
61
73
|
validateSessionAlias,
|
|
62
74
|
} from "./session-command-service";
|
|
75
|
+
import { acquireSessionLock } from "./session-lock";
|
|
63
76
|
import {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
listScopedLearningRules,
|
|
76
|
-
resolveManualForcedReply,
|
|
77
|
-
} from "./feedback-learning-service";
|
|
78
|
-
import { attachNativeAckReaction, recallNativeAckReactionWithRetry } from "./ack-reaction-service";
|
|
79
|
-
import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
|
|
77
|
+
clearSessionPeerOverride,
|
|
78
|
+
getSessionPeerOverride,
|
|
79
|
+
setSessionPeerOverride,
|
|
80
|
+
} from "./session-peer-store";
|
|
81
|
+
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
82
|
+
import {
|
|
83
|
+
upsertObservedGroupTarget,
|
|
84
|
+
upsertObservedUserTarget,
|
|
85
|
+
} from "./targeting/target-directory-store";
|
|
86
|
+
import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
|
|
87
|
+
import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
|
|
80
88
|
|
|
81
89
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
82
90
|
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
83
|
-
const
|
|
91
|
+
const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
|
|
84
92
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
85
93
|
|
|
94
|
+
function resolvePinnedMainDmOwner(params: {
|
|
95
|
+
dmScope?: string;
|
|
96
|
+
allowFrom?: string[];
|
|
97
|
+
}): string | null {
|
|
98
|
+
if ((params.dmScope ?? "main") !== "main") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const allow = normalizeAllowFrom(params.allowFrom);
|
|
102
|
+
if (allow.hasWildcard) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return allow.entries.length === 1 ? allow.entries[0] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ttlDaysToMs(ttlDays: number | undefined): number | undefined {
|
|
109
|
+
if (typeof ttlDays !== "number" || !Number.isFinite(ttlDays) || ttlDays <= 0) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return ttlDays * 24 * 60 * 60 * 1000;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function waitForDynamicAckDispose(params: {
|
|
116
|
+
dispose: () => Promise<void>;
|
|
117
|
+
log?: { debug?: (message: string) => void; warn?: (message: string) => void };
|
|
118
|
+
sessionKey: string;
|
|
119
|
+
}): Promise<void> {
|
|
120
|
+
let timedOut = false;
|
|
121
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
122
|
+
const disposePromise = params.dispose().catch((err: unknown) => {
|
|
123
|
+
params.log?.warn?.(
|
|
124
|
+
`[DingTalk] Dynamic ack reaction cleanup failed for session ${params.sessionKey}: ${getErrorMessage(err)}`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await Promise.race([
|
|
130
|
+
disposePromise,
|
|
131
|
+
new Promise<void>((resolve) => {
|
|
132
|
+
timeoutHandle = setTimeout(() => {
|
|
133
|
+
timedOut = true;
|
|
134
|
+
resolve();
|
|
135
|
+
}, MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS);
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
} finally {
|
|
139
|
+
if (timeoutHandle) {
|
|
140
|
+
clearTimeout(timeoutHandle);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (timedOut) {
|
|
145
|
+
params.log?.debug?.(
|
|
146
|
+
`[DingTalk] Dynamic ack reaction cleanup timed out after ${MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS}ms; releasing session lock for ${params.sessionKey}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
86
151
|
export function resetProactivePermissionHintStateForTest(): void {
|
|
87
152
|
proactiveHintLastSentAt.clear();
|
|
88
153
|
clearProactiveRiskObservationsForTest();
|
|
@@ -118,7 +183,11 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
118
183
|
return false;
|
|
119
184
|
}
|
|
120
185
|
|
|
121
|
-
const riskObservation = getProactiveRiskObservationForAny(
|
|
186
|
+
const riskObservation = getProactiveRiskObservationForAny(
|
|
187
|
+
params.accountId,
|
|
188
|
+
riskTargets,
|
|
189
|
+
params.nowMs,
|
|
190
|
+
);
|
|
122
191
|
if (!riskObservation || riskObservation.source !== "proactive-api") {
|
|
123
192
|
return false;
|
|
124
193
|
}
|
|
@@ -138,13 +207,6 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
138
207
|
return true;
|
|
139
208
|
}
|
|
140
209
|
|
|
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
210
|
function sanitizeGroupPromptName(value?: string): string {
|
|
149
211
|
return (value || "")
|
|
150
212
|
.replace(/[\r\n,=]/g, " ")
|
|
@@ -201,7 +263,7 @@ export async function downloadMedia(
|
|
|
201
263
|
try {
|
|
202
264
|
return JSON.stringify(maskSensitiveData(value));
|
|
203
265
|
} catch {
|
|
204
|
-
return
|
|
266
|
+
return `[unstringifiable ${typeof value}]`;
|
|
205
267
|
}
|
|
206
268
|
};
|
|
207
269
|
|
|
@@ -267,7 +329,7 @@ export async function downloadMedia(
|
|
|
267
329
|
}
|
|
268
330
|
|
|
269
331
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
270
|
-
const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params;
|
|
332
|
+
const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig, subAgentOptions, preDownloadedMedia } = params;
|
|
271
333
|
const rt = getDingTalkRuntime();
|
|
272
334
|
|
|
273
335
|
// Save logger globally so shared services can log consistently without threading log everywhere.
|
|
@@ -281,12 +343,21 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
281
343
|
return;
|
|
282
344
|
}
|
|
283
345
|
|
|
284
|
-
|
|
346
|
+
// Shallow copy: only .text is reassigned below; nested arrays (atMentions, mediaTypes) are read-only downstream.
|
|
347
|
+
const extractedContent = { ...extractMessageContent(data) };
|
|
285
348
|
if (!extractedContent.text) {
|
|
286
349
|
return;
|
|
287
350
|
}
|
|
288
351
|
|
|
352
|
+
// Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
|
|
353
|
+
if (subAgentOptions) {
|
|
354
|
+
const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
355
|
+
const contextHint = `[你被 @ 为"${subAgentOptions.matchedName}"]\n\n`;
|
|
356
|
+
extractedContent.text = contextHint + cleanText;
|
|
357
|
+
}
|
|
358
|
+
|
|
289
359
|
const isDirect = data.conversationType === "1";
|
|
360
|
+
const isGroup = !isDirect;
|
|
290
361
|
const senderOriginalId = (data.senderId || "").trim();
|
|
291
362
|
const senderStaffId = (data.senderStaffId || "").trim();
|
|
292
363
|
const senderId = senderStaffId || senderOriginalId;
|
|
@@ -320,7 +391,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
320
391
|
} catch (err: any) {
|
|
321
392
|
log?.debug?.(`[DingTalk] Failed to send proactive permission hint: ${err.message}`);
|
|
322
393
|
if (err?.response?.data !== undefined) {
|
|
323
|
-
log?.debug?.(
|
|
394
|
+
log?.debug?.(
|
|
395
|
+
formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data),
|
|
396
|
+
);
|
|
324
397
|
}
|
|
325
398
|
}
|
|
326
399
|
}
|
|
@@ -352,7 +425,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
352
425
|
} catch (err: any) {
|
|
353
426
|
log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
|
|
354
427
|
if (err?.response?.data !== undefined) {
|
|
355
|
-
log?.debug?.(
|
|
428
|
+
log?.debug?.(
|
|
429
|
+
formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data),
|
|
430
|
+
);
|
|
356
431
|
}
|
|
357
432
|
}
|
|
358
433
|
|
|
@@ -367,46 +442,90 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
367
442
|
commandAuthorized = true;
|
|
368
443
|
}
|
|
369
444
|
} else {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
log?.debug?.(
|
|
379
|
-
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} not in allowlist (groupPolicy=allowlist)`,
|
|
380
|
-
);
|
|
445
|
+
const groupAccess = resolveGroupAccess({
|
|
446
|
+
groupPolicy: dingtalkConfig.groupPolicy || "open",
|
|
447
|
+
groupId,
|
|
448
|
+
senderId,
|
|
449
|
+
groups: dingtalkConfig.groups,
|
|
450
|
+
groupAllowFrom: dingtalkConfig.groupAllowFrom,
|
|
451
|
+
allowFrom: dingtalkConfig.allowFrom,
|
|
452
|
+
});
|
|
381
453
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
);
|
|
389
|
-
} catch (err: any) {
|
|
390
|
-
log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
|
|
391
|
-
if (err?.response?.data !== undefined) {
|
|
392
|
-
log?.debug?.(
|
|
393
|
-
formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
454
|
+
if (groupAccess.legacyFallback) {
|
|
455
|
+
log?.info?.(
|
|
456
|
+
`[DingTalk] DEPRECATED: groupPolicy=allowlist is using "allowFrom" for group access control. ` +
|
|
457
|
+
`Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
397
460
|
|
|
461
|
+
if (!groupAccess.allowed) {
|
|
462
|
+
if (groupAccess.reason === "disabled") {
|
|
463
|
+
log?.debug?.(`[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`);
|
|
398
464
|
return;
|
|
399
465
|
}
|
|
400
466
|
|
|
467
|
+
const denyMessage = groupAccess.reason === "sender_not_allowed"
|
|
468
|
+
? `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到群聊允许列表中。`
|
|
469
|
+
: `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`;
|
|
470
|
+
|
|
401
471
|
log?.debug?.(
|
|
402
|
-
`[DingTalk] Group
|
|
472
|
+
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
|
|
403
473
|
);
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
await sendBySession(
|
|
477
|
+
dingtalkConfig,
|
|
478
|
+
sessionWebhook,
|
|
479
|
+
denyMessage,
|
|
480
|
+
{ log, atUserId: senderId },
|
|
481
|
+
);
|
|
482
|
+
} catch (err: any) {
|
|
483
|
+
log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
|
|
484
|
+
if (err?.response?.data !== undefined) {
|
|
485
|
+
log?.debug?.(
|
|
486
|
+
formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return;
|
|
404
492
|
}
|
|
493
|
+
|
|
494
|
+
log?.debug?.(
|
|
495
|
+
`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
|
|
496
|
+
);
|
|
405
497
|
}
|
|
406
498
|
|
|
499
|
+
// Calculate account store path and session peer (for session alias feature)
|
|
407
500
|
const accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
408
501
|
agentId: accountId,
|
|
409
502
|
});
|
|
503
|
+
try {
|
|
504
|
+
if (!isDirect && groupId) {
|
|
505
|
+
upsertObservedGroupTarget({
|
|
506
|
+
storePath: accountStorePath,
|
|
507
|
+
accountId,
|
|
508
|
+
conversationId: groupId,
|
|
509
|
+
title: groupName,
|
|
510
|
+
seenAt: data.createAt,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
if (senderId || senderOriginalId) {
|
|
514
|
+
upsertObservedUserTarget({
|
|
515
|
+
storePath: accountStorePath,
|
|
516
|
+
accountId,
|
|
517
|
+
senderId: senderOriginalId || senderId,
|
|
518
|
+
staffId: senderStaffId || undefined,
|
|
519
|
+
displayName: senderName,
|
|
520
|
+
conversationId: groupId,
|
|
521
|
+
seenAt: data.createAt,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
} catch (err) {
|
|
525
|
+
log?.warn?.(
|
|
526
|
+
`[DingTalk] Target directory observe failed: accountId=${accountId} groupId=${groupId || "-"} senderId=${senderOriginalId || senderId || "-"} storePath=${accountStorePath || "-"} error=${String(err)}`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
410
529
|
const currentSessionSourceKind = isDirect ? "direct" : "group";
|
|
411
530
|
const currentSessionSourceId = isDirect ? senderId : groupId;
|
|
412
531
|
const peerIdOverride = getSessionPeerOverride({
|
|
@@ -422,12 +541,54 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
422
541
|
peerIdOverride,
|
|
423
542
|
config: dingtalkConfig,
|
|
424
543
|
});
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
544
|
+
|
|
545
|
+
const route = subAgentOptions
|
|
546
|
+
? {
|
|
547
|
+
agentId: subAgentOptions.agentId,
|
|
548
|
+
sessionKey: buildAgentSessionKey({
|
|
549
|
+
rt,
|
|
550
|
+
cfg,
|
|
551
|
+
accountId,
|
|
552
|
+
agentId: subAgentOptions.agentId,
|
|
553
|
+
peerKind: sessionPeer.kind,
|
|
554
|
+
peerId: sessionPeer.peerId,
|
|
555
|
+
}),
|
|
556
|
+
mainSessionKey: "",
|
|
557
|
+
}
|
|
558
|
+
: rt.channel.routing.resolveAgentRoute({
|
|
559
|
+
cfg,
|
|
560
|
+
channel: "dingtalk",
|
|
561
|
+
accountId,
|
|
562
|
+
peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// @Sub-Agent routing: resolve @mentions to agents (skip in recursive sub-agent calls)
|
|
566
|
+
if (!subAgentOptions) {
|
|
567
|
+
const subAgentRoute = await resolveSubAgentRoute({
|
|
568
|
+
extractedContent,
|
|
569
|
+
cfg,
|
|
570
|
+
isGroup,
|
|
571
|
+
dingtalkConfig,
|
|
572
|
+
sessionWebhook,
|
|
573
|
+
senderId,
|
|
574
|
+
log,
|
|
575
|
+
});
|
|
576
|
+
if (subAgentRoute) {
|
|
577
|
+
await dispatchSubAgents({
|
|
578
|
+
...subAgentRoute,
|
|
579
|
+
cfg,
|
|
580
|
+
accountId,
|
|
581
|
+
data,
|
|
582
|
+
dingtalkConfig,
|
|
583
|
+
sessionWebhook,
|
|
584
|
+
extractedContent,
|
|
585
|
+
handleMessage: handleDingTalkMessage,
|
|
586
|
+
downloadMedia,
|
|
587
|
+
log,
|
|
588
|
+
});
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
431
592
|
|
|
432
593
|
// Route resolved before media download for session context and routing metadata.
|
|
433
594
|
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
@@ -488,22 +649,22 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
488
649
|
return;
|
|
489
650
|
}
|
|
490
651
|
if (
|
|
491
|
-
(parsedLearnCommand.scope === "global"
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
652
|
+
(parsedLearnCommand.scope === "global" ||
|
|
653
|
+
parsedLearnCommand.scope === "session" ||
|
|
654
|
+
parsedLearnCommand.scope === "here" ||
|
|
655
|
+
parsedLearnCommand.scope === "target" ||
|
|
656
|
+
parsedLearnCommand.scope === "targets" ||
|
|
657
|
+
parsedLearnCommand.scope === "list" ||
|
|
658
|
+
parsedLearnCommand.scope === "disable" ||
|
|
659
|
+
parsedLearnCommand.scope === "delete" ||
|
|
660
|
+
parsedLearnCommand.scope === "target-set-create" ||
|
|
661
|
+
parsedLearnCommand.scope === "target-set-apply" ||
|
|
662
|
+
parsedSessionCommand.scope === "session-alias-show" ||
|
|
663
|
+
parsedSessionCommand.scope === "session-alias-set" ||
|
|
664
|
+
parsedSessionCommand.scope === "session-alias-clear" ||
|
|
665
|
+
parsedSessionCommand.scope === "session-alias-bind" ||
|
|
666
|
+
parsedSessionCommand.scope === "session-alias-unbind") &&
|
|
667
|
+
!isOwner
|
|
507
668
|
) {
|
|
508
669
|
await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
|
|
509
670
|
return;
|
|
@@ -571,10 +732,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
571
732
|
);
|
|
572
733
|
return;
|
|
573
734
|
}
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
735
|
+
if (
|
|
736
|
+
parsedSessionCommand.scope === "session-alias-bind" &&
|
|
737
|
+
parsedSessionCommand.sourceKind &&
|
|
738
|
+
parsedSessionCommand.sourceId &&
|
|
739
|
+
parsedSessionCommand.peerId
|
|
740
|
+
) {
|
|
578
741
|
const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
|
|
579
742
|
if (aliasValidationError) {
|
|
580
743
|
await sendBySession(
|
|
@@ -604,9 +767,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
604
767
|
);
|
|
605
768
|
return;
|
|
606
769
|
}
|
|
607
|
-
if (
|
|
608
|
-
|
|
609
|
-
|
|
770
|
+
if (
|
|
771
|
+
parsedSessionCommand.scope === "session-alias-unbind" &&
|
|
772
|
+
parsedSessionCommand.sourceKind &&
|
|
773
|
+
parsedSessionCommand.sourceId
|
|
774
|
+
) {
|
|
610
775
|
const existed = clearSessionPeerOverride({
|
|
611
776
|
storePath: accountStorePath,
|
|
612
777
|
accountId,
|
|
@@ -681,7 +846,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
681
846
|
);
|
|
682
847
|
return;
|
|
683
848
|
}
|
|
684
|
-
if (
|
|
849
|
+
if (
|
|
850
|
+
parsedLearnCommand.scope === "target" &&
|
|
851
|
+
parsedLearnCommand.targetId &&
|
|
852
|
+
parsedLearnCommand.instruction
|
|
853
|
+
) {
|
|
685
854
|
const applied = applyManualTargetLearningRule({
|
|
686
855
|
storePath: accountStorePath,
|
|
687
856
|
accountId,
|
|
@@ -701,7 +870,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
701
870
|
);
|
|
702
871
|
return;
|
|
703
872
|
}
|
|
704
|
-
if (
|
|
873
|
+
if (
|
|
874
|
+
parsedLearnCommand.scope === "targets" &&
|
|
875
|
+
parsedLearnCommand.targetIds?.length &&
|
|
876
|
+
parsedLearnCommand.instruction
|
|
877
|
+
) {
|
|
705
878
|
const applied = applyManualTargetsLearningRule({
|
|
706
879
|
storePath: accountStorePath,
|
|
707
880
|
accountId,
|
|
@@ -721,7 +894,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
721
894
|
);
|
|
722
895
|
return;
|
|
723
896
|
}
|
|
724
|
-
if (
|
|
897
|
+
if (
|
|
898
|
+
parsedLearnCommand.scope === "target-set-create" &&
|
|
899
|
+
parsedLearnCommand.setName &&
|
|
900
|
+
parsedLearnCommand.targetIds?.length
|
|
901
|
+
) {
|
|
725
902
|
const saved = createOrUpdateTargetSet({
|
|
726
903
|
storePath: accountStorePath,
|
|
727
904
|
accountId,
|
|
@@ -733,15 +910,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
733
910
|
sessionWebhook,
|
|
734
911
|
saved
|
|
735
912
|
? formatTargetSetSavedReply({
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
913
|
+
setName: parsedLearnCommand.setName,
|
|
914
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
915
|
+
})
|
|
739
916
|
: "目标组保存失败,请检查名称和目标列表。",
|
|
740
917
|
{ log },
|
|
741
918
|
);
|
|
742
919
|
return;
|
|
743
920
|
}
|
|
744
|
-
if (
|
|
921
|
+
if (
|
|
922
|
+
parsedLearnCommand.scope === "target-set-apply" &&
|
|
923
|
+
parsedLearnCommand.setName &&
|
|
924
|
+
parsedLearnCommand.instruction
|
|
925
|
+
) {
|
|
745
926
|
const applied = applyTargetSetLearningRule({
|
|
746
927
|
storePath: accountStorePath,
|
|
747
928
|
accountId,
|
|
@@ -753,12 +934,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
753
934
|
sessionWebhook,
|
|
754
935
|
applied.length > 0
|
|
755
936
|
? formatLearnAppliedReply({
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
937
|
+
scope: "target-set",
|
|
938
|
+
setName: parsedLearnCommand.setName,
|
|
939
|
+
targetIds: applied.map((item) => item.targetId),
|
|
940
|
+
instruction: parsedLearnCommand.instruction,
|
|
941
|
+
ruleId: applied[0]?.ruleId,
|
|
942
|
+
})
|
|
762
943
|
: `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
|
|
763
944
|
{ log },
|
|
764
945
|
);
|
|
@@ -774,7 +955,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
774
955
|
});
|
|
775
956
|
const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
|
|
776
957
|
.slice(0, 10)
|
|
777
|
-
.map(
|
|
958
|
+
.map(
|
|
959
|
+
(targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`,
|
|
960
|
+
);
|
|
778
961
|
await sendBySession(
|
|
779
962
|
dingtalkConfig,
|
|
780
963
|
sessionWebhook,
|
|
@@ -846,6 +1029,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
846
1029
|
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
847
1030
|
accountId,
|
|
848
1031
|
storePath: accountStorePath,
|
|
1032
|
+
contextConversationId: groupId,
|
|
849
1033
|
});
|
|
850
1034
|
if (aiCard) {
|
|
851
1035
|
currentAICard = aiCard;
|
|
@@ -863,56 +1047,70 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
863
1047
|
}
|
|
864
1048
|
}
|
|
865
1049
|
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1050
|
+
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
|
|
1051
|
+
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
1052
|
+
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
1053
|
+
const content = extractedContent;
|
|
1054
|
+
const hasLegacyQuoteContent =
|
|
1055
|
+
typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
|
|
1056
|
+
|
|
1057
|
+
if (hasLegacyQuoteContent && !quotedRef) {
|
|
1058
|
+
log?.debug?.(
|
|
1059
|
+
`[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
|
|
1060
|
+
`conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
|
|
1061
|
+
`msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (quotedRef) {
|
|
1065
|
+
log?.debug?.(
|
|
1066
|
+
`[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
|
|
1067
|
+
`quotedRef=${JSON.stringify(quotedRef)}`,
|
|
1068
|
+
);
|
|
1069
|
+
} else if (
|
|
1070
|
+
data.text?.isReplyMsg ||
|
|
1071
|
+
data.originalMsgId ||
|
|
1072
|
+
data.originalProcessQueryKey ||
|
|
1073
|
+
content.quoted
|
|
1074
|
+
) {
|
|
1075
|
+
log?.debug?.(
|
|
1076
|
+
`[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
|
|
1077
|
+
`msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
|
|
1078
|
+
`originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
|
|
1079
|
+
);
|
|
896
1080
|
}
|
|
897
1081
|
|
|
898
1082
|
try {
|
|
899
|
-
|
|
900
|
-
storePath,
|
|
1083
|
+
upsertInboundMessageContext({
|
|
1084
|
+
storePath: accountStorePath,
|
|
901
1085
|
accountId,
|
|
902
1086
|
conversationId: groupId,
|
|
903
1087
|
msgId: data.msgId,
|
|
904
1088
|
messageType: content.messageType,
|
|
905
|
-
text:
|
|
1089
|
+
text: content.text,
|
|
1090
|
+
quotedRef,
|
|
906
1091
|
createdAt: data.createAt,
|
|
907
|
-
|
|
1092
|
+
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
1093
|
+
ttlReferenceMs: data.createAt,
|
|
1094
|
+
cleanupCreatedAtTtlDays: journalTTLDays,
|
|
1095
|
+
topic: null,
|
|
908
1096
|
});
|
|
909
1097
|
} catch (err) {
|
|
910
|
-
log?.warn?.(`[DingTalk]
|
|
1098
|
+
log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
|
|
911
1099
|
}
|
|
912
1100
|
|
|
913
1101
|
let mediaPath: string | undefined;
|
|
914
1102
|
let mediaType: string | undefined;
|
|
915
|
-
|
|
1103
|
+
let attachmentContextMsgId = data.msgId;
|
|
1104
|
+
let attachmentContextCreatedAt = data.createAt;
|
|
1105
|
+
let attachmentContextMessageType = content.messageType;
|
|
1106
|
+
let attachmentContextFileName = data.content?.fileName;
|
|
1107
|
+
|
|
1108
|
+
// Use pre-downloaded media if available (from sub-agent outer call)
|
|
1109
|
+
if (preDownloadedMedia?.mediaPath) {
|
|
1110
|
+
mediaPath = preDownloadedMedia.mediaPath;
|
|
1111
|
+
mediaType = preDownloadedMedia.mediaType;
|
|
1112
|
+
} else if (content.mediaPath && dingtalkConfig.robotCode) {
|
|
1113
|
+
// Download media only if not pre-downloaded
|
|
916
1114
|
const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
|
|
917
1115
|
if (media) {
|
|
918
1116
|
mediaPath = media.path;
|
|
@@ -922,15 +1120,21 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
922
1120
|
|
|
923
1121
|
// Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
|
|
924
1122
|
if (content.mediaPath && data.msgId) {
|
|
925
|
-
|
|
1123
|
+
upsertInboundMessageContext({
|
|
1124
|
+
storePath: accountStorePath,
|
|
926
1125
|
accountId,
|
|
927
|
-
data.conversationId,
|
|
928
|
-
data.msgId,
|
|
929
|
-
|
|
930
|
-
content.messageType,
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1126
|
+
conversationId: data.conversationId,
|
|
1127
|
+
msgId: data.msgId,
|
|
1128
|
+
createdAt: data.createAt,
|
|
1129
|
+
messageType: content.messageType,
|
|
1130
|
+
media: {
|
|
1131
|
+
downloadCode: content.mediaPath,
|
|
1132
|
+
spaceId: data.content?.spaceId,
|
|
1133
|
+
fileId: data.content?.fileId,
|
|
1134
|
+
},
|
|
1135
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1136
|
+
topic: null,
|
|
1137
|
+
});
|
|
934
1138
|
}
|
|
935
1139
|
|
|
936
1140
|
// User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
|
|
@@ -941,15 +1145,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
941
1145
|
content.docSpaceId &&
|
|
942
1146
|
content.docFileId
|
|
943
1147
|
) {
|
|
944
|
-
|
|
1148
|
+
upsertInboundMessageContext({
|
|
1149
|
+
storePath: accountStorePath,
|
|
945
1150
|
accountId,
|
|
946
|
-
data.conversationId,
|
|
947
|
-
data.msgId,
|
|
948
|
-
|
|
949
|
-
content.messageType,
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1151
|
+
conversationId: data.conversationId,
|
|
1152
|
+
msgId: data.msgId,
|
|
1153
|
+
createdAt: data.createAt,
|
|
1154
|
+
messageType: content.messageType,
|
|
1155
|
+
media: {
|
|
1156
|
+
spaceId: content.docSpaceId,
|
|
1157
|
+
fileId: content.docFileId,
|
|
1158
|
+
},
|
|
1159
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1160
|
+
topic: null,
|
|
1161
|
+
});
|
|
953
1162
|
|
|
954
1163
|
if (!mediaPath && isDirect && data.senderStaffId) {
|
|
955
1164
|
try {
|
|
@@ -971,31 +1180,71 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
971
1180
|
}
|
|
972
1181
|
}
|
|
973
1182
|
|
|
1183
|
+
const quotedRecord = resolveQuotedRecord({
|
|
1184
|
+
storePath: accountStorePath,
|
|
1185
|
+
accountId,
|
|
1186
|
+
conversationId: data.conversationId,
|
|
1187
|
+
quotedRef,
|
|
1188
|
+
log,
|
|
1189
|
+
});
|
|
1190
|
+
const quotedRuntimeContext = resolveQuotedRuntimeContext({
|
|
1191
|
+
storePath: accountStorePath,
|
|
1192
|
+
accountId,
|
|
1193
|
+
conversationId: data.conversationId,
|
|
1194
|
+
quotedRef,
|
|
1195
|
+
firstRecord: quotedRecord,
|
|
1196
|
+
firstPreview:
|
|
1197
|
+
content.quoted?.previewText ||
|
|
1198
|
+
content.quoted?.previewMessageType
|
|
1199
|
+
? {
|
|
1200
|
+
text: content.quoted.previewText,
|
|
1201
|
+
messageType: content.quoted.previewMessageType,
|
|
1202
|
+
senderId: content.quoted.previewSenderId,
|
|
1203
|
+
}
|
|
1204
|
+
: undefined,
|
|
1205
|
+
log,
|
|
1206
|
+
});
|
|
1207
|
+
|
|
974
1208
|
// Try downloading a quoted file from cached downloadCode/spaceId+fileId.
|
|
975
|
-
const
|
|
976
|
-
|
|
1209
|
+
const tryDownloadFromRecord = async (
|
|
1210
|
+
record: {
|
|
1211
|
+
msgId?: string;
|
|
1212
|
+
media?: {
|
|
1213
|
+
downloadCode?: string;
|
|
1214
|
+
spaceId?: string;
|
|
1215
|
+
fileId?: string;
|
|
1216
|
+
};
|
|
1217
|
+
} | null,
|
|
977
1218
|
): Promise<MediaFile | null> => {
|
|
978
|
-
if (!
|
|
979
|
-
return null;
|
|
980
|
-
}
|
|
981
|
-
const cached = getCachedDownloadCode(accountId, data.conversationId, quotedMsgId, storePath);
|
|
982
|
-
if (!cached) {
|
|
1219
|
+
if (!record?.media) {
|
|
983
1220
|
return null;
|
|
984
1221
|
}
|
|
985
1222
|
let media: MediaFile | null = null;
|
|
986
|
-
if (
|
|
987
|
-
media = await downloadMedia(dingtalkConfig,
|
|
1223
|
+
if (record.media.downloadCode) {
|
|
1224
|
+
media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log);
|
|
1225
|
+
if (media) {
|
|
1226
|
+
log?.debug?.(
|
|
1227
|
+
`[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
|
|
1228
|
+
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
988
1231
|
}
|
|
989
|
-
if (!media &&
|
|
1232
|
+
if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
|
|
990
1233
|
try {
|
|
991
1234
|
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
992
1235
|
media = await downloadGroupFile(
|
|
993
1236
|
dingtalkConfig,
|
|
994
|
-
|
|
995
|
-
|
|
1237
|
+
record.media.spaceId,
|
|
1238
|
+
record.media.fileId,
|
|
996
1239
|
unionId,
|
|
997
1240
|
log,
|
|
998
1241
|
);
|
|
1242
|
+
if (media) {
|
|
1243
|
+
log?.debug?.(
|
|
1244
|
+
`[DingTalk][QuotedRef] Recovered quoted media from cached spaceId/fileId ` +
|
|
1245
|
+
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
999
1248
|
} catch (err: any) {
|
|
1000
1249
|
log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
|
|
1001
1250
|
}
|
|
@@ -1005,15 +1254,23 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1005
1254
|
|
|
1006
1255
|
// Quoted picture: download via existing downloadMedia.
|
|
1007
1256
|
if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
|
|
1008
|
-
const media =
|
|
1257
|
+
const media =
|
|
1258
|
+
(await tryDownloadFromRecord(quotedRecord)) ||
|
|
1259
|
+
(await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
|
|
1009
1260
|
if (media) {
|
|
1261
|
+
if (!quotedRecord) {
|
|
1262
|
+
log?.debug?.(
|
|
1263
|
+
`[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1010
1266
|
mediaPath = media.path;
|
|
1011
1267
|
mediaType = media.mimeType;
|
|
1268
|
+
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1269
|
+
attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
|
|
1270
|
+
attachmentContextMessageType = quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
|
|
1271
|
+
attachmentContextFileName = content.quoted.previewFileName;
|
|
1012
1272
|
} else {
|
|
1013
|
-
content.text = content.text
|
|
1014
|
-
content.quoted.prefix,
|
|
1015
|
-
"[引用了一张图片,但下载失败]\n\n",
|
|
1016
|
-
);
|
|
1273
|
+
content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
|
|
1017
1274
|
}
|
|
1018
1275
|
}
|
|
1019
1276
|
|
|
@@ -1021,11 +1278,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1021
1278
|
if (!mediaPath && content.quoted?.isQuotedFile) {
|
|
1022
1279
|
let fileResolved = false;
|
|
1023
1280
|
|
|
1024
|
-
// Step 1:
|
|
1025
|
-
const cachedMedia = await
|
|
1281
|
+
// Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
|
|
1282
|
+
const cachedMedia = await tryDownloadFromRecord(quotedRecord);
|
|
1026
1283
|
if (cachedMedia) {
|
|
1027
1284
|
mediaPath = cachedMedia.path;
|
|
1028
1285
|
mediaType = cachedMedia.mimeType;
|
|
1286
|
+
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1287
|
+
attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
|
|
1288
|
+
attachmentContextMessageType = quotedRecord?.messageType || "file";
|
|
1289
|
+
attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
|
|
1029
1290
|
fileResolved = true;
|
|
1030
1291
|
}
|
|
1031
1292
|
|
|
@@ -1043,17 +1304,31 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1043
1304
|
if (resolved) {
|
|
1044
1305
|
mediaPath = resolved.media.path;
|
|
1045
1306
|
mediaType = resolved.media.mimeType;
|
|
1307
|
+
attachmentContextMsgId = content.quoted.msgId || data.msgId;
|
|
1308
|
+
attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
|
|
1309
|
+
attachmentContextMessageType = "file";
|
|
1310
|
+
attachmentContextFileName = resolved.name || content.quoted.previewFileName;
|
|
1046
1311
|
fileResolved = true;
|
|
1312
|
+
log?.debug?.(
|
|
1313
|
+
`[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
|
|
1314
|
+
`scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1315
|
+
);
|
|
1047
1316
|
if (content.quoted.msgId) {
|
|
1048
|
-
|
|
1317
|
+
upsertInboundMessageContext({
|
|
1318
|
+
storePath: accountStorePath,
|
|
1049
1319
|
accountId,
|
|
1050
|
-
data.conversationId,
|
|
1051
|
-
content.quoted.msgId,
|
|
1052
|
-
|
|
1053
|
-
"file",
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1320
|
+
conversationId: data.conversationId,
|
|
1321
|
+
msgId: content.quoted.msgId,
|
|
1322
|
+
createdAt: content.quoted.fileCreatedAt || Date.now(),
|
|
1323
|
+
messageType: "file",
|
|
1324
|
+
media: {
|
|
1325
|
+
spaceId: resolved.spaceId,
|
|
1326
|
+
fileId: resolved.fileId,
|
|
1327
|
+
},
|
|
1328
|
+
attachmentFileName: resolved.name,
|
|
1329
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1330
|
+
topic: null,
|
|
1331
|
+
});
|
|
1057
1332
|
}
|
|
1058
1333
|
}
|
|
1059
1334
|
}
|
|
@@ -1065,7 +1340,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1065
1340
|
const hint = isDirect
|
|
1066
1341
|
? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
|
|
1067
1342
|
: "[引用了一个文件,但无法获取内容]\n\n";
|
|
1068
|
-
content.text = content.text
|
|
1343
|
+
content.text = `${hint}${content.text}`;
|
|
1069
1344
|
}
|
|
1070
1345
|
}
|
|
1071
1346
|
|
|
@@ -1077,12 +1352,16 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1077
1352
|
if (!mediaPath && content.quoted?.isQuotedDocCard) {
|
|
1078
1353
|
let docResolved = false;
|
|
1079
1354
|
|
|
1080
|
-
const cachedDocMedia = await
|
|
1355
|
+
const cachedDocMedia = await tryDownloadFromRecord(quotedRecord);
|
|
1081
1356
|
if (cachedDocMedia) {
|
|
1082
1357
|
mediaPath = cachedDocMedia.path;
|
|
1083
1358
|
mediaType = cachedDocMedia.mimeType;
|
|
1359
|
+
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1360
|
+
attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
|
|
1361
|
+
attachmentContextMessageType =
|
|
1362
|
+
quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
|
|
1363
|
+
attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
|
|
1084
1364
|
docResolved = true;
|
|
1085
|
-
content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
|
|
1086
1365
|
}
|
|
1087
1366
|
|
|
1088
1367
|
if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
|
|
@@ -1098,18 +1377,31 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1098
1377
|
if (resolved) {
|
|
1099
1378
|
mediaPath = resolved.media.path;
|
|
1100
1379
|
mediaType = resolved.media.mimeType;
|
|
1380
|
+
attachmentContextMsgId = content.quoted.msgId || data.msgId;
|
|
1381
|
+
attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
|
|
1382
|
+
attachmentContextMessageType = "interactiveCardFile";
|
|
1383
|
+
attachmentContextFileName = resolved.name || content.quoted.previewFileName;
|
|
1101
1384
|
docResolved = true;
|
|
1102
|
-
|
|
1385
|
+
log?.debug?.(
|
|
1386
|
+
`[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
|
|
1387
|
+
`scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1388
|
+
);
|
|
1103
1389
|
if (content.quoted.msgId) {
|
|
1104
|
-
|
|
1390
|
+
upsertInboundMessageContext({
|
|
1391
|
+
storePath: accountStorePath,
|
|
1105
1392
|
accountId,
|
|
1106
|
-
data.conversationId,
|
|
1107
|
-
content.quoted.msgId,
|
|
1108
|
-
|
|
1109
|
-
"interactiveCardFile",
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1393
|
+
conversationId: data.conversationId,
|
|
1394
|
+
msgId: content.quoted.msgId,
|
|
1395
|
+
createdAt: content.quoted.fileCreatedAt || Date.now(),
|
|
1396
|
+
messageType: "interactiveCardFile",
|
|
1397
|
+
media: {
|
|
1398
|
+
spaceId: resolved.spaceId,
|
|
1399
|
+
fileId: resolved.fileId,
|
|
1400
|
+
},
|
|
1401
|
+
attachmentFileName: resolved.name,
|
|
1402
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1403
|
+
topic: null,
|
|
1404
|
+
});
|
|
1113
1405
|
}
|
|
1114
1406
|
}
|
|
1115
1407
|
}
|
|
@@ -1121,43 +1413,32 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1121
1413
|
const hint = isDirect
|
|
1122
1414
|
? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
|
|
1123
1415
|
: "[引用了钉钉文档,但无法获取内容]\n\n";
|
|
1124
|
-
content.text = content.text
|
|
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
|
-
);
|
|
1416
|
+
content.text = `${hint}${content.text}`;
|
|
1147
1417
|
}
|
|
1148
|
-
// Card cache miss: prefix already contains "[引用了机器人的回复]", keep as-is.
|
|
1149
1418
|
}
|
|
1150
1419
|
|
|
1151
|
-
let attachmentExtractedText: string | undefined;
|
|
1152
1420
|
if (mediaPath) {
|
|
1153
1421
|
try {
|
|
1154
1422
|
const extracted = await extractAttachmentText({
|
|
1155
1423
|
path: mediaPath,
|
|
1156
1424
|
mimeType: mediaType,
|
|
1157
|
-
fileName: data.content?.fileName,
|
|
1425
|
+
fileName: attachmentContextFileName || data.content?.fileName,
|
|
1158
1426
|
});
|
|
1159
1427
|
if (extracted?.text) {
|
|
1160
|
-
|
|
1428
|
+
upsertInboundMessageContext({
|
|
1429
|
+
storePath: accountStorePath,
|
|
1430
|
+
accountId,
|
|
1431
|
+
conversationId: data.conversationId,
|
|
1432
|
+
msgId: attachmentContextMsgId,
|
|
1433
|
+
createdAt: attachmentContextCreatedAt,
|
|
1434
|
+
messageType: attachmentContextMessageType,
|
|
1435
|
+
attachmentText: extracted.text,
|
|
1436
|
+
attachmentTextSource: extracted.sourceType,
|
|
1437
|
+
attachmentTextTruncated: extracted.truncated,
|
|
1438
|
+
attachmentFileName: attachmentContextFileName,
|
|
1439
|
+
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
1440
|
+
topic: null,
|
|
1441
|
+
});
|
|
1161
1442
|
}
|
|
1162
1443
|
} catch (err: any) {
|
|
1163
1444
|
log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
|
|
@@ -1168,7 +1449,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1168
1449
|
mediaPath && /<media:[^>]+>/.test(content.text)
|
|
1169
1450
|
? `${content.text}\n[media_path: ${mediaPath}]\n[media_type: ${mediaType || "unknown"}]`
|
|
1170
1451
|
: content.text;
|
|
1171
|
-
const inboundText =
|
|
1452
|
+
const inboundText = inboundBody;
|
|
1172
1453
|
const learningEnabled = isFeedbackLearningEnabled(dingtalkConfig);
|
|
1173
1454
|
const learningContextBlock = buildLearningContextBlock({
|
|
1174
1455
|
enabled: learningEnabled,
|
|
@@ -1219,6 +1500,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1219
1500
|
Body: body,
|
|
1220
1501
|
RawBody: inboundText,
|
|
1221
1502
|
CommandBody: inboundText,
|
|
1503
|
+
QuotedRef: quotedRef,
|
|
1504
|
+
QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
|
|
1505
|
+
ReplyToId: quotedRuntimeContext?.replyToId,
|
|
1506
|
+
ReplyToBody: quotedRuntimeContext?.replyToBody,
|
|
1507
|
+
ReplyToSender: quotedRuntimeContext?.replyToSender,
|
|
1508
|
+
ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
|
|
1509
|
+
UntrustedContext: quotedRuntimeContext?.untrustedContext
|
|
1510
|
+
? [quotedRuntimeContext.untrustedContext]
|
|
1511
|
+
: undefined,
|
|
1222
1512
|
From: to,
|
|
1223
1513
|
To: to,
|
|
1224
1514
|
SessionKey: route.sessionKey,
|
|
@@ -1247,7 +1537,27 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1247
1537
|
storePath,
|
|
1248
1538
|
sessionKey: ctx.SessionKey || route.sessionKey,
|
|
1249
1539
|
ctx,
|
|
1250
|
-
updateLastRoute:
|
|
1540
|
+
updateLastRoute: (() => {
|
|
1541
|
+
if (!isDirect) {
|
|
1542
|
+
return undefined;
|
|
1543
|
+
}
|
|
1544
|
+
const pinnedMainDmOwner = resolvePinnedMainDmOwner({
|
|
1545
|
+
dmScope: cfg.session?.dmScope,
|
|
1546
|
+
allowFrom: dingtalkConfig.allowFrom,
|
|
1547
|
+
});
|
|
1548
|
+
const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
|
|
1549
|
+
if (
|
|
1550
|
+
pinnedMainDmOwner
|
|
1551
|
+
&& senderRecipient
|
|
1552
|
+
&& pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
|
|
1553
|
+
) {
|
|
1554
|
+
log?.debug?.(
|
|
1555
|
+
`[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
|
|
1556
|
+
);
|
|
1557
|
+
return undefined;
|
|
1558
|
+
}
|
|
1559
|
+
return { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId };
|
|
1560
|
+
})(),
|
|
1251
1561
|
onRecordError: (err: unknown) => {
|
|
1252
1562
|
log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
|
|
1253
1563
|
},
|
|
@@ -1263,8 +1573,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1263
1573
|
accountId,
|
|
1264
1574
|
agentId: route.agentId,
|
|
1265
1575
|
});
|
|
1576
|
+
const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
|
|
1266
1577
|
const resolvedAckReaction =
|
|
1267
|
-
|
|
1578
|
+
normalizedAckReaction === "kaomoji"
|
|
1579
|
+
? classifyAckReactionEmoji(content.text).emoji
|
|
1580
|
+
: normalizedAckReaction === "emoji"
|
|
1581
|
+
? "🤔思考中"
|
|
1582
|
+
: normalizedAckReaction;
|
|
1268
1583
|
const shouldAttachAckReaction = Boolean(resolvedAckReaction);
|
|
1269
1584
|
let ackReactionAttached = false;
|
|
1270
1585
|
let ackReactionAttachedAt = 0;
|
|
@@ -1281,310 +1596,169 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1281
1596
|
);
|
|
1282
1597
|
if (ackReactionAttached) {
|
|
1283
1598
|
ackReactionAttachedAt = Date.now();
|
|
1599
|
+
log?.debug?.(
|
|
1600
|
+
`[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ---- Shared media delivery helper ----
|
|
1606
|
+
async function deliverMediaAttachments(urls: string[]) {
|
|
1607
|
+
for (const rawMediaUrl of urls) {
|
|
1608
|
+
const preparedMedia = await prepareMediaInput(
|
|
1609
|
+
rawMediaUrl,
|
|
1610
|
+
log,
|
|
1611
|
+
dingtalkConfig.mediaUrlAllowlist,
|
|
1612
|
+
);
|
|
1613
|
+
try {
|
|
1614
|
+
const actualMediaPath = preparedMedia.path;
|
|
1615
|
+
const outMediaType = resolveOutboundMediaType({
|
|
1616
|
+
mediaPath: actualMediaPath,
|
|
1617
|
+
asVoice: false,
|
|
1618
|
+
});
|
|
1619
|
+
if (sessionWebhook) {
|
|
1620
|
+
const sendResult = await sendMessage(dingtalkConfig, to, "", {
|
|
1621
|
+
sessionWebhook,
|
|
1622
|
+
mediaPath: actualMediaPath,
|
|
1623
|
+
mediaType: outMediaType,
|
|
1624
|
+
log,
|
|
1625
|
+
accountId,
|
|
1626
|
+
storePath: accountStorePath,
|
|
1627
|
+
conversationId: groupId,
|
|
1628
|
+
quotedRef: replyQuotedRef,
|
|
1629
|
+
});
|
|
1630
|
+
if (!sendResult.ok) {
|
|
1631
|
+
throw new Error(sendResult.error || "Media reply send failed");
|
|
1632
|
+
}
|
|
1633
|
+
} else {
|
|
1634
|
+
const sendResult = await sendProactiveMedia(
|
|
1635
|
+
dingtalkConfig,
|
|
1636
|
+
to,
|
|
1637
|
+
actualMediaPath,
|
|
1638
|
+
outMediaType,
|
|
1639
|
+
{
|
|
1640
|
+
accountId,
|
|
1641
|
+
log,
|
|
1642
|
+
storePath: accountStorePath,
|
|
1643
|
+
conversationId: groupId,
|
|
1644
|
+
quotedRef: replyQuotedRef,
|
|
1645
|
+
},
|
|
1646
|
+
);
|
|
1647
|
+
if (!sendResult.ok) {
|
|
1648
|
+
throw new Error(sendResult.error || "Media reply send failed");
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
} finally {
|
|
1652
|
+
await preparedMedia.cleanup?.();
|
|
1653
|
+
}
|
|
1284
1654
|
}
|
|
1285
1655
|
}
|
|
1286
1656
|
|
|
1657
|
+
// ---- Extract mediaUrls from runtime payload ----
|
|
1658
|
+
function extractMediaUrls(payload: ReplyStreamPayload): string[] {
|
|
1659
|
+
const richPayload = payload as typeof payload & {
|
|
1660
|
+
mediaUrl?: string;
|
|
1661
|
+
mediaUrls?: string[];
|
|
1662
|
+
};
|
|
1663
|
+
return Array.isArray(richPayload.mediaUrls)
|
|
1664
|
+
? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
|
|
1665
|
+
: richPayload.mediaUrl &&
|
|
1666
|
+
typeof richPayload.mediaUrl === "string" &&
|
|
1667
|
+
richPayload.mediaUrl.trim()
|
|
1668
|
+
? [richPayload.mediaUrl]
|
|
1669
|
+
: [];
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1287
1672
|
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
1288
1673
|
// from receiving concurrent dispatch calls on the same session key, which
|
|
1289
1674
|
// causes empty replies for all but the first caller.
|
|
1675
|
+
// Each sub-agent call acquires its own lock since sub-agent sessions have
|
|
1676
|
+
// different session keys (different agentId), so no deadlock risk.
|
|
1677
|
+
const shouldTrackDynamicAckReaction =
|
|
1678
|
+
(normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
|
|
1679
|
+
&& shouldAttachAckReaction;
|
|
1680
|
+
const runtimeEvents = (rt as typeof rt & {
|
|
1681
|
+
events?: {
|
|
1682
|
+
onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
|
|
1683
|
+
};
|
|
1684
|
+
}).events;
|
|
1290
1685
|
const releaseSessionLock = await acquireSessionLock(route.sessionKey);
|
|
1686
|
+
const dynamicAckReactionController = createDynamicAckReactionController({
|
|
1687
|
+
enabled: shouldTrackDynamicAckReaction,
|
|
1688
|
+
initialReaction: resolvedAckReaction || "",
|
|
1689
|
+
initialAttached: ackReactionAttached,
|
|
1690
|
+
initialAttachedAt: ackReactionAttachedAt,
|
|
1691
|
+
dingtalkConfig,
|
|
1692
|
+
msgId: data.msgId,
|
|
1693
|
+
conversationId: groupId,
|
|
1694
|
+
sessionKey: route.sessionKey,
|
|
1695
|
+
log,
|
|
1696
|
+
runtimeEvents,
|
|
1697
|
+
onReactionDisposed: () => {
|
|
1698
|
+
ackReactionAttached = false;
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1291
1701
|
try {
|
|
1292
1702
|
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
1293
1703
|
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
1294
1704
|
}
|
|
1295
1705
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
:
|
|
1299
|
-
|
|
1300
|
-
|
|
1706
|
+
// ---- Create reply strategy (card or markdown) ----
|
|
1707
|
+
const strategy = createReplyStrategy({
|
|
1708
|
+
config: dingtalkConfig,
|
|
1709
|
+
card: currentAICard,
|
|
1710
|
+
useCardMode: useCardMode && !!currentAICard,
|
|
1711
|
+
to,
|
|
1712
|
+
sessionWebhook,
|
|
1713
|
+
senderId,
|
|
1714
|
+
isDirect,
|
|
1715
|
+
accountId,
|
|
1716
|
+
storePath: accountStorePath,
|
|
1717
|
+
groupId,
|
|
1718
|
+
log,
|
|
1719
|
+
replyQuotedRef,
|
|
1720
|
+
deliverMedia: deliverMediaAttachments,
|
|
1721
|
+
});
|
|
1301
1722
|
|
|
1302
1723
|
try {
|
|
1303
1724
|
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1304
1725
|
ctx,
|
|
1305
1726
|
cfg,
|
|
1306
1727
|
dispatcherOptions: {
|
|
1307
|
-
responsePrefix: "",
|
|
1728
|
+
responsePrefix: subAgentOptions?.responsePrefix || "",
|
|
1308
1729
|
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
|
-
}
|
|
1348
|
-
|
|
1349
1730
|
try {
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
}
|
|
1369
|
-
}
|
|
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.
|
|
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));
|
|
1731
|
+
const mediaUrls = extractMediaUrls(payload);
|
|
1732
|
+
await strategy.deliver({
|
|
1733
|
+
text: payload.text,
|
|
1734
|
+
mediaUrls,
|
|
1735
|
+
kind: (info?.kind as DeliverPayload["kind"]) || "block",
|
|
1736
|
+
});
|
|
1737
|
+
} catch (err: unknown) {
|
|
1738
|
+
log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
|
|
1739
|
+
const responseData = getErrorResponseData(err);
|
|
1740
|
+
if (responseData !== undefined) {
|
|
1741
|
+
log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
|
|
1451
1742
|
}
|
|
1452
1743
|
throw err;
|
|
1453
1744
|
}
|
|
1454
1745
|
},
|
|
1455
1746
|
},
|
|
1456
|
-
replyOptions:
|
|
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
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
: undefined,
|
|
1470
|
-
|
|
1471
|
-
onReasoningStream: controller
|
|
1472
|
-
? (payload: ReplyStreamPayload) => {
|
|
1473
|
-
if (payload.text) {
|
|
1474
|
-
controller.updateReasoning(payload.text);
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
: undefined,
|
|
1478
|
-
},
|
|
1747
|
+
replyOptions: strategy.getReplyOptions(),
|
|
1479
1748
|
});
|
|
1480
|
-
} catch (dispatchErr:
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1749
|
+
} catch (dispatchErr: unknown) {
|
|
1750
|
+
const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
|
1751
|
+
await strategy.abort(error);
|
|
1494
1752
|
throw dispatchErr;
|
|
1495
1753
|
}
|
|
1496
1754
|
|
|
1497
|
-
|
|
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`,
|
|
1514
|
-
);
|
|
1515
|
-
return;
|
|
1516
|
-
}
|
|
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
|
-
}
|
|
1539
|
-
return;
|
|
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
|
-
}
|
|
1559
|
-
try {
|
|
1560
|
-
if (currentAICard.state !== AICardStatus.FINISHED) {
|
|
1561
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
1562
|
-
currentAICard.lastUpdated = Date.now();
|
|
1563
|
-
}
|
|
1564
|
-
} catch (stateErr: any) {
|
|
1565
|
-
log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1755
|
+
await strategy.finalize();
|
|
1569
1756
|
} finally {
|
|
1757
|
+
await waitForDynamicAckDispose({
|
|
1758
|
+
dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
|
|
1759
|
+
log,
|
|
1760
|
+
sessionKey: route.sessionKey,
|
|
1761
|
+
});
|
|
1570
1762
|
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
|
-
}
|
|
1589
1763
|
}
|
|
1590
1764
|
}
|