@soimy/dingtalk 3.2.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/LICENSE +21 -0
- package/README.md +796 -42
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +83 -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 +75 -0
- package/src/ack-reaction-service.ts +182 -0
- package/src/attachment-text-extractor.ts +148 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +666 -26
- package/src/channel.ts +455 -150
- package/src/config-schema.ts +64 -6
- package/src/config.ts +161 -5
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1374 -259
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +487 -46
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +96 -1
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quoted-file-service.ts +385 -0
- 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 +267 -45
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/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 +261 -28
- package/src/utils.ts +231 -12
package/src/inbound-handler.ts
CHANGED
|
@@ -1,31 +1,153 @@
|
|
|
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";
|
|
7
|
+
import { extractAttachmentText } from "./attachment-text-extractor";
|
|
3
8
|
import { getAccessToken } from "./auth";
|
|
9
|
+
import { createAICard } from "./card-service";
|
|
10
|
+
import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
|
|
4
11
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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";
|
|
11
26
|
import { formatGroupMembers, noteGroupMember } from "./group-members-store";
|
|
27
|
+
import {
|
|
28
|
+
formatLearnAppliedReply,
|
|
29
|
+
formatLearnCommandHelp,
|
|
30
|
+
formatLearnDeletedReply,
|
|
31
|
+
formatLearnDisabledReply,
|
|
32
|
+
formatLearnListReply,
|
|
33
|
+
formatOwnerOnlyDeniedReply,
|
|
34
|
+
formatOwnerStatusReply,
|
|
35
|
+
formatTargetSetSavedReply,
|
|
36
|
+
formatWhereAmIReply,
|
|
37
|
+
formatWhoAmIReply,
|
|
38
|
+
isLearningOwner,
|
|
39
|
+
parseLearnCommand,
|
|
40
|
+
} from "./learning-command-service";
|
|
12
41
|
import { setCurrentLogger } from "./logger-context";
|
|
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";
|
|
13
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";
|
|
14
55
|
import { registerPeerId } from "./peer-id-registry";
|
|
15
56
|
import {
|
|
16
57
|
clearProactiveRiskObservationsForTest,
|
|
17
58
|
getProactiveRiskObservationForAny,
|
|
18
59
|
} from "./proactive-risk-registry";
|
|
60
|
+
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
|
|
61
|
+
import { createReplyStrategy } from "./reply-strategy";
|
|
62
|
+
import type { DeliverPayload } from "./reply-strategy";
|
|
19
63
|
import { getDingTalkRuntime } from "./runtime";
|
|
20
|
-
import { sendBySession, sendMessage } from "./send-service";
|
|
21
|
-
import
|
|
22
|
-
|
|
64
|
+
import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
|
|
65
|
+
import {
|
|
66
|
+
formatSessionAliasBoundReply,
|
|
67
|
+
formatSessionAliasClearedReply,
|
|
68
|
+
formatSessionAliasReply,
|
|
69
|
+
formatSessionAliasSetReply,
|
|
70
|
+
formatSessionAliasUnboundReply,
|
|
71
|
+
formatSessionAliasValidationErrorReply,
|
|
72
|
+
parseSessionCommand,
|
|
73
|
+
validateSessionAlias,
|
|
74
|
+
} from "./session-command-service";
|
|
23
75
|
import { acquireSessionLock } from "./session-lock";
|
|
24
|
-
import {
|
|
76
|
+
import {
|
|
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";
|
|
25
88
|
|
|
26
89
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
90
|
+
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
91
|
+
const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
|
|
27
92
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
28
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
|
+
|
|
29
151
|
export function resetProactivePermissionHintStateForTest(): void {
|
|
30
152
|
proactiveHintLastSentAt.clear();
|
|
31
153
|
clearProactiveRiskObservationsForTest();
|
|
@@ -35,6 +157,7 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
35
157
|
isDirect: boolean;
|
|
36
158
|
accountId: string;
|
|
37
159
|
senderId: string;
|
|
160
|
+
senderOriginalId?: string;
|
|
38
161
|
senderStaffId?: string;
|
|
39
162
|
config: DingTalkConfig;
|
|
40
163
|
nowMs: number;
|
|
@@ -53,9 +176,16 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
53
176
|
return false;
|
|
54
177
|
}
|
|
55
178
|
|
|
179
|
+
const riskTargets = [params.senderId, params.senderOriginalId, params.senderStaffId]
|
|
180
|
+
.map((id) => (id || "").trim())
|
|
181
|
+
.filter((id, index, arr) => Boolean(id) && arr.indexOf(id) === index);
|
|
182
|
+
if (riskTargets.length === 0) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
56
186
|
const riskObservation = getProactiveRiskObservationForAny(
|
|
57
187
|
params.accountId,
|
|
58
|
-
|
|
188
|
+
riskTargets,
|
|
59
189
|
params.nowMs,
|
|
60
190
|
);
|
|
61
191
|
if (!riskObservation || riskObservation.source !== "proactive-api") {
|
|
@@ -77,14 +207,36 @@ function shouldSendProactivePermissionHint(params: {
|
|
|
77
207
|
return true;
|
|
78
208
|
}
|
|
79
209
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return /^Unhandled stop reason:\s*[A-Za-z0-9_-]+/i.test(normalized);
|
|
210
|
+
function sanitizeGroupPromptName(value?: string): string {
|
|
211
|
+
return (value || "")
|
|
212
|
+
.replace(/[\r\n,=]/g, " ")
|
|
213
|
+
.replace(/\s+/g, " ")
|
|
214
|
+
.trim();
|
|
86
215
|
}
|
|
87
216
|
|
|
217
|
+
function buildGroupTurnContextPrompt(params: {
|
|
218
|
+
conversationId: string;
|
|
219
|
+
senderDingtalkId: string;
|
|
220
|
+
senderName?: string;
|
|
221
|
+
}): string {
|
|
222
|
+
const sanitizedSenderName = sanitizeGroupPromptName(params.senderName) || "Unknown";
|
|
223
|
+
return [
|
|
224
|
+
"Current DingTalk group turn context:",
|
|
225
|
+
`- conversationId: ${params.conversationId}`,
|
|
226
|
+
`- senderDingtalkId: ${params.senderDingtalkId}`,
|
|
227
|
+
`- senderName: ${sanitizedSenderName}`,
|
|
228
|
+
"Treat senderDingtalkId and senderName as the authoritative sender for this turn. Do not guess the current sender from GroupMembers.",
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type ReplyStreamPayload = {
|
|
233
|
+
text?: string;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
type ReplyChunkInfo = {
|
|
237
|
+
kind?: string;
|
|
238
|
+
};
|
|
239
|
+
|
|
88
240
|
/**
|
|
89
241
|
* Download DingTalk media file via runtime media service (sandbox-compatible).
|
|
90
242
|
* Files are stored in the global media inbound directory.
|
|
@@ -111,7 +263,7 @@ export async function downloadMedia(
|
|
|
111
263
|
try {
|
|
112
264
|
return JSON.stringify(maskSensitiveData(value));
|
|
113
265
|
} catch {
|
|
114
|
-
return
|
|
266
|
+
return `[unstringifiable ${typeof value}]`;
|
|
115
267
|
}
|
|
116
268
|
};
|
|
117
269
|
|
|
@@ -177,13 +329,13 @@ export async function downloadMedia(
|
|
|
177
329
|
}
|
|
178
330
|
|
|
179
331
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
180
|
-
const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params;
|
|
332
|
+
const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig, subAgentOptions, preDownloadedMedia } = params;
|
|
181
333
|
const rt = getDingTalkRuntime();
|
|
182
334
|
|
|
183
335
|
// Save logger globally so shared services can log consistently without threading log everywhere.
|
|
184
336
|
setCurrentLogger(log);
|
|
185
337
|
|
|
186
|
-
log?.debug?.("[DingTalk] Full Inbound Data:"
|
|
338
|
+
log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
|
|
187
339
|
|
|
188
340
|
// 1) Ignore self messages from bot.
|
|
189
341
|
if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
|
|
@@ -191,13 +343,24 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
191
343
|
return;
|
|
192
344
|
}
|
|
193
345
|
|
|
194
|
-
|
|
195
|
-
|
|
346
|
+
// Shallow copy: only .text is reassigned below; nested arrays (atMentions, mediaTypes) are read-only downstream.
|
|
347
|
+
const extractedContent = { ...extractMessageContent(data) };
|
|
348
|
+
if (!extractedContent.text) {
|
|
196
349
|
return;
|
|
197
350
|
}
|
|
198
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
|
+
|
|
199
359
|
const isDirect = data.conversationType === "1";
|
|
200
|
-
const
|
|
360
|
+
const isGroup = !isDirect;
|
|
361
|
+
const senderOriginalId = (data.senderId || "").trim();
|
|
362
|
+
const senderStaffId = (data.senderStaffId || "").trim();
|
|
363
|
+
const senderId = senderStaffId || senderOriginalId;
|
|
201
364
|
const senderName = data.senderNick || "Unknown";
|
|
202
365
|
const groupId = data.conversationId;
|
|
203
366
|
const groupName = data.conversationTitle || "Group";
|
|
@@ -212,7 +375,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
212
375
|
isDirect,
|
|
213
376
|
accountId,
|
|
214
377
|
senderId,
|
|
215
|
-
|
|
378
|
+
senderOriginalId,
|
|
379
|
+
senderStaffId,
|
|
216
380
|
config: dingtalkConfig,
|
|
217
381
|
nowMs: Date.now(),
|
|
218
382
|
})
|
|
@@ -227,7 +391,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
227
391
|
} catch (err: any) {
|
|
228
392
|
log?.debug?.(`[DingTalk] Failed to send proactive permission hint: ${err.message}`);
|
|
229
393
|
if (err?.response?.data !== undefined) {
|
|
230
|
-
log?.debug?.(
|
|
394
|
+
log?.debug?.(
|
|
395
|
+
formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data),
|
|
396
|
+
);
|
|
231
397
|
}
|
|
232
398
|
}
|
|
233
399
|
}
|
|
@@ -259,7 +425,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
259
425
|
} catch (err: any) {
|
|
260
426
|
log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
|
|
261
427
|
if (err?.response?.data !== undefined) {
|
|
262
|
-
log?.debug?.(
|
|
428
|
+
log?.debug?.(
|
|
429
|
+
formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data),
|
|
430
|
+
);
|
|
263
431
|
}
|
|
264
432
|
}
|
|
265
433
|
|
|
@@ -274,93 +442,1022 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
274
442
|
commandAuthorized = true;
|
|
275
443
|
}
|
|
276
444
|
} else {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
log?.debug?.(
|
|
286
|
-
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} not in allowlist (groupPolicy=allowlist)`,
|
|
287
|
-
);
|
|
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
|
+
});
|
|
288
453
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
);
|
|
296
|
-
} catch (err: any) {
|
|
297
|
-
log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
|
|
298
|
-
if (err?.response?.data !== undefined) {
|
|
299
|
-
log?.debug?.(
|
|
300
|
-
formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
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
|
+
}
|
|
304
460
|
|
|
461
|
+
if (!groupAccess.allowed) {
|
|
462
|
+
if (groupAccess.reason === "disabled") {
|
|
463
|
+
log?.debug?.(`[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`);
|
|
305
464
|
return;
|
|
306
465
|
}
|
|
307
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
|
+
|
|
308
471
|
log?.debug?.(
|
|
309
|
-
`[DingTalk] Group
|
|
472
|
+
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
|
|
310
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;
|
|
311
492
|
}
|
|
493
|
+
|
|
494
|
+
log?.debug?.(
|
|
495
|
+
`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
|
|
496
|
+
);
|
|
312
497
|
}
|
|
313
498
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
499
|
+
// Calculate account store path and session peer (for session alias feature)
|
|
500
|
+
const accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
501
|
+
agentId: accountId,
|
|
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
|
+
}
|
|
529
|
+
const currentSessionSourceKind = isDirect ? "direct" : "group";
|
|
530
|
+
const currentSessionSourceId = isDirect ? senderId : groupId;
|
|
531
|
+
const peerIdOverride = getSessionPeerOverride({
|
|
532
|
+
storePath: accountStorePath,
|
|
317
533
|
accountId,
|
|
318
|
-
|
|
534
|
+
sourceKind: currentSessionSourceKind,
|
|
535
|
+
sourceId: currentSessionSourceId,
|
|
536
|
+
});
|
|
537
|
+
const sessionPeer = resolveDingTalkSessionPeer({
|
|
538
|
+
isDirect,
|
|
539
|
+
senderId,
|
|
540
|
+
conversationId: groupId,
|
|
541
|
+
peerIdOverride,
|
|
542
|
+
config: dingtalkConfig,
|
|
319
543
|
});
|
|
320
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
|
+
}
|
|
592
|
+
|
|
321
593
|
// Route resolved before media download for session context and routing metadata.
|
|
322
594
|
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
323
595
|
agentId: route.agentId,
|
|
324
596
|
});
|
|
325
597
|
|
|
326
598
|
const to = isDirect ? senderId : groupId;
|
|
327
|
-
|
|
599
|
+
const parsedLearnCommand = parseLearnCommand(extractedContent.text);
|
|
600
|
+
const parsedSessionCommand = parseSessionCommand(extractedContent.text);
|
|
601
|
+
const isOwner = isLearningOwner({
|
|
602
|
+
cfg,
|
|
603
|
+
config: dingtalkConfig,
|
|
604
|
+
senderId,
|
|
605
|
+
rawSenderId: data.senderId,
|
|
606
|
+
});
|
|
607
|
+
if (isDirect && parsedLearnCommand.scope === "whoami") {
|
|
608
|
+
await sendBySession(
|
|
609
|
+
dingtalkConfig,
|
|
610
|
+
sessionWebhook,
|
|
611
|
+
formatWhoAmIReply({
|
|
612
|
+
senderId,
|
|
613
|
+
rawSenderId: data.senderId,
|
|
614
|
+
senderStaffId: data.senderStaffId,
|
|
615
|
+
isOwner,
|
|
616
|
+
}),
|
|
617
|
+
{ log },
|
|
618
|
+
);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (parsedLearnCommand.scope === "whereami") {
|
|
622
|
+
await sendBySession(
|
|
623
|
+
dingtalkConfig,
|
|
624
|
+
sessionWebhook,
|
|
625
|
+
formatWhereAmIReply({
|
|
626
|
+
conversationId: data.conversationId,
|
|
627
|
+
conversationType: isDirect ? "dm" : "group",
|
|
628
|
+
peerId: sessionPeer.peerId,
|
|
629
|
+
}),
|
|
630
|
+
{ log },
|
|
631
|
+
);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (isDirect && parsedLearnCommand.scope === "owner-status") {
|
|
635
|
+
await sendBySession(
|
|
636
|
+
dingtalkConfig,
|
|
637
|
+
sessionWebhook,
|
|
638
|
+
formatOwnerStatusReply({
|
|
639
|
+
senderId,
|
|
640
|
+
rawSenderId: data.senderId,
|
|
641
|
+
isOwner,
|
|
642
|
+
}),
|
|
643
|
+
{ log },
|
|
644
|
+
);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (parsedLearnCommand.scope === "help") {
|
|
648
|
+
await sendBySession(dingtalkConfig, sessionWebhook, formatLearnCommandHelp(), { log });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (
|
|
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
|
|
668
|
+
) {
|
|
669
|
+
await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (isOwner) {
|
|
673
|
+
if (parsedSessionCommand.scope === "session-alias-show") {
|
|
674
|
+
await sendBySession(
|
|
675
|
+
dingtalkConfig,
|
|
676
|
+
sessionWebhook,
|
|
677
|
+
formatSessionAliasReply({
|
|
678
|
+
sourceKind: currentSessionSourceKind,
|
|
679
|
+
sourceId: currentSessionSourceId,
|
|
680
|
+
peerId: sessionPeer.peerId,
|
|
681
|
+
aliasSource: peerIdOverride ? "override" : "default",
|
|
682
|
+
}),
|
|
683
|
+
{ log },
|
|
684
|
+
);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (parsedSessionCommand.scope === "session-alias-set" && parsedSessionCommand.peerId) {
|
|
688
|
+
const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
|
|
689
|
+
if (aliasValidationError) {
|
|
690
|
+
await sendBySession(
|
|
691
|
+
dingtalkConfig,
|
|
692
|
+
sessionWebhook,
|
|
693
|
+
formatSessionAliasValidationErrorReply(aliasValidationError),
|
|
694
|
+
{ log },
|
|
695
|
+
);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
setSessionPeerOverride({
|
|
699
|
+
storePath: accountStorePath,
|
|
700
|
+
accountId,
|
|
701
|
+
sourceKind: currentSessionSourceKind,
|
|
702
|
+
sourceId: currentSessionSourceId,
|
|
703
|
+
peerId: parsedSessionCommand.peerId,
|
|
704
|
+
});
|
|
705
|
+
await sendBySession(
|
|
706
|
+
dingtalkConfig,
|
|
707
|
+
sessionWebhook,
|
|
708
|
+
formatSessionAliasSetReply({
|
|
709
|
+
sourceKind: currentSessionSourceKind,
|
|
710
|
+
sourceId: currentSessionSourceId,
|
|
711
|
+
peerId: parsedSessionCommand.peerId,
|
|
712
|
+
}),
|
|
713
|
+
{ log },
|
|
714
|
+
);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (parsedSessionCommand.scope === "session-alias-clear") {
|
|
718
|
+
clearSessionPeerOverride({
|
|
719
|
+
storePath: accountStorePath,
|
|
720
|
+
accountId,
|
|
721
|
+
sourceKind: currentSessionSourceKind,
|
|
722
|
+
sourceId: currentSessionSourceId,
|
|
723
|
+
});
|
|
724
|
+
await sendBySession(
|
|
725
|
+
dingtalkConfig,
|
|
726
|
+
sessionWebhook,
|
|
727
|
+
formatSessionAliasClearedReply({
|
|
728
|
+
sourceKind: currentSessionSourceKind,
|
|
729
|
+
sourceId: currentSessionSourceId,
|
|
730
|
+
}),
|
|
731
|
+
{ log },
|
|
732
|
+
);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (
|
|
736
|
+
parsedSessionCommand.scope === "session-alias-bind" &&
|
|
737
|
+
parsedSessionCommand.sourceKind &&
|
|
738
|
+
parsedSessionCommand.sourceId &&
|
|
739
|
+
parsedSessionCommand.peerId
|
|
740
|
+
) {
|
|
741
|
+
const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
|
|
742
|
+
if (aliasValidationError) {
|
|
743
|
+
await sendBySession(
|
|
744
|
+
dingtalkConfig,
|
|
745
|
+
sessionWebhook,
|
|
746
|
+
formatSessionAliasValidationErrorReply(aliasValidationError),
|
|
747
|
+
{ log },
|
|
748
|
+
);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
setSessionPeerOverride({
|
|
752
|
+
storePath: accountStorePath,
|
|
753
|
+
accountId,
|
|
754
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
755
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
756
|
+
peerId: parsedSessionCommand.peerId,
|
|
757
|
+
});
|
|
758
|
+
await sendBySession(
|
|
759
|
+
dingtalkConfig,
|
|
760
|
+
sessionWebhook,
|
|
761
|
+
formatSessionAliasBoundReply({
|
|
762
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
763
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
764
|
+
peerId: parsedSessionCommand.peerId,
|
|
765
|
+
}),
|
|
766
|
+
{ log },
|
|
767
|
+
);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (
|
|
771
|
+
parsedSessionCommand.scope === "session-alias-unbind" &&
|
|
772
|
+
parsedSessionCommand.sourceKind &&
|
|
773
|
+
parsedSessionCommand.sourceId
|
|
774
|
+
) {
|
|
775
|
+
const existed = clearSessionPeerOverride({
|
|
776
|
+
storePath: accountStorePath,
|
|
777
|
+
accountId,
|
|
778
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
779
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
780
|
+
});
|
|
781
|
+
await sendBySession(
|
|
782
|
+
dingtalkConfig,
|
|
783
|
+
sessionWebhook,
|
|
784
|
+
formatSessionAliasUnboundReply({
|
|
785
|
+
sourceKind: parsedSessionCommand.sourceKind,
|
|
786
|
+
sourceId: parsedSessionCommand.sourceId,
|
|
787
|
+
existed,
|
|
788
|
+
}),
|
|
789
|
+
{ log },
|
|
790
|
+
);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (parsedLearnCommand.scope === "global" && parsedLearnCommand.instruction) {
|
|
794
|
+
const applied = applyManualGlobalLearningRule({
|
|
795
|
+
storePath: accountStorePath,
|
|
796
|
+
accountId,
|
|
797
|
+
instruction: parsedLearnCommand.instruction,
|
|
798
|
+
});
|
|
799
|
+
await sendBySession(
|
|
800
|
+
dingtalkConfig,
|
|
801
|
+
sessionWebhook,
|
|
802
|
+
formatLearnAppliedReply({
|
|
803
|
+
scope: "global",
|
|
804
|
+
instruction: parsedLearnCommand.instruction,
|
|
805
|
+
ruleId: applied?.ruleId,
|
|
806
|
+
}),
|
|
807
|
+
{ log },
|
|
808
|
+
);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (parsedLearnCommand.scope === "session" && parsedLearnCommand.instruction) {
|
|
812
|
+
applyManualSessionLearningNote({
|
|
813
|
+
storePath: accountStorePath,
|
|
814
|
+
accountId,
|
|
815
|
+
targetId: data.conversationId,
|
|
816
|
+
instruction: parsedLearnCommand.instruction,
|
|
817
|
+
});
|
|
818
|
+
await sendBySession(
|
|
819
|
+
dingtalkConfig,
|
|
820
|
+
sessionWebhook,
|
|
821
|
+
formatLearnAppliedReply({
|
|
822
|
+
scope: "session",
|
|
823
|
+
instruction: parsedLearnCommand.instruction,
|
|
824
|
+
}),
|
|
825
|
+
{ log },
|
|
826
|
+
);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (parsedLearnCommand.scope === "here" && parsedLearnCommand.instruction) {
|
|
830
|
+
const applied = applyManualTargetLearningRule({
|
|
831
|
+
storePath: accountStorePath,
|
|
832
|
+
accountId,
|
|
833
|
+
targetId: data.conversationId,
|
|
834
|
+
instruction: parsedLearnCommand.instruction,
|
|
835
|
+
});
|
|
836
|
+
await sendBySession(
|
|
837
|
+
dingtalkConfig,
|
|
838
|
+
sessionWebhook,
|
|
839
|
+
formatLearnAppliedReply({
|
|
840
|
+
scope: "target",
|
|
841
|
+
targetId: data.conversationId,
|
|
842
|
+
instruction: parsedLearnCommand.instruction,
|
|
843
|
+
ruleId: applied?.ruleId,
|
|
844
|
+
}),
|
|
845
|
+
{ log },
|
|
846
|
+
);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (
|
|
850
|
+
parsedLearnCommand.scope === "target" &&
|
|
851
|
+
parsedLearnCommand.targetId &&
|
|
852
|
+
parsedLearnCommand.instruction
|
|
853
|
+
) {
|
|
854
|
+
const applied = applyManualTargetLearningRule({
|
|
855
|
+
storePath: accountStorePath,
|
|
856
|
+
accountId,
|
|
857
|
+
targetId: parsedLearnCommand.targetId,
|
|
858
|
+
instruction: parsedLearnCommand.instruction,
|
|
859
|
+
});
|
|
860
|
+
await sendBySession(
|
|
861
|
+
dingtalkConfig,
|
|
862
|
+
sessionWebhook,
|
|
863
|
+
formatLearnAppliedReply({
|
|
864
|
+
scope: "target",
|
|
865
|
+
targetId: parsedLearnCommand.targetId,
|
|
866
|
+
instruction: parsedLearnCommand.instruction,
|
|
867
|
+
ruleId: applied?.ruleId,
|
|
868
|
+
}),
|
|
869
|
+
{ log },
|
|
870
|
+
);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (
|
|
874
|
+
parsedLearnCommand.scope === "targets" &&
|
|
875
|
+
parsedLearnCommand.targetIds?.length &&
|
|
876
|
+
parsedLearnCommand.instruction
|
|
877
|
+
) {
|
|
878
|
+
const applied = applyManualTargetsLearningRule({
|
|
879
|
+
storePath: accountStorePath,
|
|
880
|
+
accountId,
|
|
881
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
882
|
+
instruction: parsedLearnCommand.instruction,
|
|
883
|
+
});
|
|
884
|
+
await sendBySession(
|
|
885
|
+
dingtalkConfig,
|
|
886
|
+
sessionWebhook,
|
|
887
|
+
formatLearnAppliedReply({
|
|
888
|
+
scope: "targets",
|
|
889
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
890
|
+
instruction: parsedLearnCommand.instruction,
|
|
891
|
+
ruleId: applied[0]?.ruleId,
|
|
892
|
+
}),
|
|
893
|
+
{ log },
|
|
894
|
+
);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (
|
|
898
|
+
parsedLearnCommand.scope === "target-set-create" &&
|
|
899
|
+
parsedLearnCommand.setName &&
|
|
900
|
+
parsedLearnCommand.targetIds?.length
|
|
901
|
+
) {
|
|
902
|
+
const saved = createOrUpdateTargetSet({
|
|
903
|
+
storePath: accountStorePath,
|
|
904
|
+
accountId,
|
|
905
|
+
name: parsedLearnCommand.setName,
|
|
906
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
907
|
+
});
|
|
908
|
+
await sendBySession(
|
|
909
|
+
dingtalkConfig,
|
|
910
|
+
sessionWebhook,
|
|
911
|
+
saved
|
|
912
|
+
? formatTargetSetSavedReply({
|
|
913
|
+
setName: parsedLearnCommand.setName,
|
|
914
|
+
targetIds: parsedLearnCommand.targetIds,
|
|
915
|
+
})
|
|
916
|
+
: "目标组保存失败,请检查名称和目标列表。",
|
|
917
|
+
{ log },
|
|
918
|
+
);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (
|
|
922
|
+
parsedLearnCommand.scope === "target-set-apply" &&
|
|
923
|
+
parsedLearnCommand.setName &&
|
|
924
|
+
parsedLearnCommand.instruction
|
|
925
|
+
) {
|
|
926
|
+
const applied = applyTargetSetLearningRule({
|
|
927
|
+
storePath: accountStorePath,
|
|
928
|
+
accountId,
|
|
929
|
+
name: parsedLearnCommand.setName,
|
|
930
|
+
instruction: parsedLearnCommand.instruction,
|
|
931
|
+
});
|
|
932
|
+
await sendBySession(
|
|
933
|
+
dingtalkConfig,
|
|
934
|
+
sessionWebhook,
|
|
935
|
+
applied.length > 0
|
|
936
|
+
? formatLearnAppliedReply({
|
|
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
|
+
})
|
|
943
|
+
: `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
|
|
944
|
+
{ log },
|
|
945
|
+
);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (parsedLearnCommand.scope === "list") {
|
|
949
|
+
const rules = listScopedLearningRules({ storePath: accountStorePath, accountId })
|
|
950
|
+
.slice(0, 20)
|
|
951
|
+
.map((rule) => {
|
|
952
|
+
const scope = rule.scope === "target" ? `target(${rule.targetId})` : "global";
|
|
953
|
+
const status = rule.enabled ? "enabled" : "disabled";
|
|
954
|
+
return `- [${scope}] ${rule.ruleId} (${status}) => ${rule.instruction}`;
|
|
955
|
+
});
|
|
956
|
+
const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
|
|
957
|
+
.slice(0, 10)
|
|
958
|
+
.map(
|
|
959
|
+
(targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`,
|
|
960
|
+
);
|
|
961
|
+
await sendBySession(
|
|
962
|
+
dingtalkConfig,
|
|
963
|
+
sessionWebhook,
|
|
964
|
+
formatLearnListReply([...rules, ...targetSets]),
|
|
965
|
+
{ log },
|
|
966
|
+
);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (parsedLearnCommand.scope === "disable" && parsedLearnCommand.ruleId) {
|
|
970
|
+
const result = disableManualRule({
|
|
971
|
+
storePath: accountStorePath,
|
|
972
|
+
accountId,
|
|
973
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
974
|
+
});
|
|
975
|
+
await sendBySession(
|
|
976
|
+
dingtalkConfig,
|
|
977
|
+
sessionWebhook,
|
|
978
|
+
formatLearnDisabledReply({
|
|
979
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
980
|
+
existed: result.existed,
|
|
981
|
+
scope: result.scope,
|
|
982
|
+
targetId: result.targetId,
|
|
983
|
+
}),
|
|
984
|
+
{ log },
|
|
985
|
+
);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (parsedLearnCommand.scope === "delete" && parsedLearnCommand.ruleId) {
|
|
989
|
+
const result = deleteManualRule({
|
|
990
|
+
storePath: accountStorePath,
|
|
991
|
+
accountId,
|
|
992
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
993
|
+
});
|
|
994
|
+
await sendBySession(
|
|
995
|
+
dingtalkConfig,
|
|
996
|
+
sessionWebhook,
|
|
997
|
+
formatLearnDeletedReply({
|
|
998
|
+
ruleId: parsedLearnCommand.ruleId,
|
|
999
|
+
existed: result.existed,
|
|
1000
|
+
scope: result.scope,
|
|
1001
|
+
targetId: result.targetId,
|
|
1002
|
+
}),
|
|
1003
|
+
{ log },
|
|
1004
|
+
);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const manualForcedReply = resolveManualForcedReply({
|
|
1009
|
+
storePath: accountStorePath,
|
|
1010
|
+
accountId,
|
|
1011
|
+
targetId: data.conversationId,
|
|
1012
|
+
content: extractedContent,
|
|
1013
|
+
});
|
|
1014
|
+
if (manualForcedReply) {
|
|
1015
|
+
await sendBySession(dingtalkConfig, sessionWebhook, manualForcedReply, { log });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
328
1018
|
// 3) Select response mode (card vs markdown).
|
|
329
1019
|
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
330
1020
|
// feedback while large files are still being downloaded.
|
|
331
|
-
|
|
1021
|
+
let useCardMode = dingtalkConfig.messageType === "card";
|
|
332
1022
|
let currentAICard = undefined;
|
|
333
|
-
let lastCardContent = "";
|
|
334
1023
|
|
|
335
1024
|
if (useCardMode) {
|
|
336
1025
|
try {
|
|
337
1026
|
log?.debug?.(
|
|
338
1027
|
`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
|
|
339
1028
|
);
|
|
340
|
-
const aiCard = await createAICard(dingtalkConfig, to, log
|
|
1029
|
+
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
1030
|
+
accountId,
|
|
1031
|
+
storePath: accountStorePath,
|
|
1032
|
+
contextConversationId: groupId,
|
|
1033
|
+
});
|
|
341
1034
|
if (aiCard) {
|
|
342
1035
|
currentAICard = aiCard;
|
|
343
1036
|
} else {
|
|
1037
|
+
useCardMode = false;
|
|
344
1038
|
log?.warn?.(
|
|
345
1039
|
"[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
|
|
346
1040
|
);
|
|
347
1041
|
}
|
|
348
1042
|
} catch (err: any) {
|
|
1043
|
+
useCardMode = false;
|
|
349
1044
|
log?.warn?.(
|
|
350
1045
|
`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
|
|
351
1046
|
);
|
|
352
1047
|
}
|
|
353
1048
|
}
|
|
354
1049
|
|
|
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
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
try {
|
|
1083
|
+
upsertInboundMessageContext({
|
|
1084
|
+
storePath: accountStorePath,
|
|
1085
|
+
accountId,
|
|
1086
|
+
conversationId: groupId,
|
|
1087
|
+
msgId: data.msgId,
|
|
1088
|
+
messageType: content.messageType,
|
|
1089
|
+
text: content.text,
|
|
1090
|
+
quotedRef,
|
|
1091
|
+
createdAt: data.createAt,
|
|
1092
|
+
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
1093
|
+
ttlReferenceMs: data.createAt,
|
|
1094
|
+
cleanupCreatedAtTtlDays: journalTTLDays,
|
|
1095
|
+
topic: null,
|
|
1096
|
+
});
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
355
1101
|
let mediaPath: string | undefined;
|
|
356
1102
|
let mediaType: string | undefined;
|
|
357
|
-
|
|
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
|
|
358
1114
|
const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
|
|
359
1115
|
if (media) {
|
|
360
1116
|
mediaPath = media.path;
|
|
361
1117
|
mediaType = media.mimeType;
|
|
362
1118
|
}
|
|
363
1119
|
}
|
|
1120
|
+
|
|
1121
|
+
// Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
|
|
1122
|
+
if (content.mediaPath && data.msgId) {
|
|
1123
|
+
upsertInboundMessageContext({
|
|
1124
|
+
storePath: accountStorePath,
|
|
1125
|
+
accountId,
|
|
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
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
|
|
1141
|
+
// during the original message turn, and try downloading immediately in DM.
|
|
1142
|
+
if (
|
|
1143
|
+
content.messageType === "interactiveCardFile" &&
|
|
1144
|
+
data.msgId &&
|
|
1145
|
+
content.docSpaceId &&
|
|
1146
|
+
content.docFileId
|
|
1147
|
+
) {
|
|
1148
|
+
upsertInboundMessageContext({
|
|
1149
|
+
storePath: accountStorePath,
|
|
1150
|
+
accountId,
|
|
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
|
+
});
|
|
1162
|
+
|
|
1163
|
+
if (!mediaPath && isDirect && data.senderStaffId) {
|
|
1164
|
+
try {
|
|
1165
|
+
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
1166
|
+
const docMedia = await downloadGroupFile(
|
|
1167
|
+
dingtalkConfig,
|
|
1168
|
+
content.docSpaceId,
|
|
1169
|
+
content.docFileId,
|
|
1170
|
+
unionId,
|
|
1171
|
+
log,
|
|
1172
|
+
);
|
|
1173
|
+
if (docMedia) {
|
|
1174
|
+
mediaPath = docMedia.path;
|
|
1175
|
+
mediaType = docMedia.mimeType;
|
|
1176
|
+
}
|
|
1177
|
+
} catch (err: any) {
|
|
1178
|
+
log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
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
|
+
|
|
1208
|
+
// Try downloading a quoted file from cached downloadCode/spaceId+fileId.
|
|
1209
|
+
const tryDownloadFromRecord = async (
|
|
1210
|
+
record: {
|
|
1211
|
+
msgId?: string;
|
|
1212
|
+
media?: {
|
|
1213
|
+
downloadCode?: string;
|
|
1214
|
+
spaceId?: string;
|
|
1215
|
+
fileId?: string;
|
|
1216
|
+
};
|
|
1217
|
+
} | null,
|
|
1218
|
+
): Promise<MediaFile | null> => {
|
|
1219
|
+
if (!record?.media) {
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
let media: MediaFile | null = null;
|
|
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
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
|
|
1233
|
+
try {
|
|
1234
|
+
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
1235
|
+
media = await downloadGroupFile(
|
|
1236
|
+
dingtalkConfig,
|
|
1237
|
+
record.media.spaceId,
|
|
1238
|
+
record.media.fileId,
|
|
1239
|
+
unionId,
|
|
1240
|
+
log,
|
|
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
|
+
}
|
|
1248
|
+
} catch (err: any) {
|
|
1249
|
+
log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return media;
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
// Quoted picture: download via existing downloadMedia.
|
|
1256
|
+
if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
|
|
1257
|
+
const media =
|
|
1258
|
+
(await tryDownloadFromRecord(quotedRecord)) ||
|
|
1259
|
+
(await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
|
|
1260
|
+
if (media) {
|
|
1261
|
+
if (!quotedRecord) {
|
|
1262
|
+
log?.debug?.(
|
|
1263
|
+
`[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
mediaPath = media.path;
|
|
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;
|
|
1272
|
+
} else {
|
|
1273
|
+
content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Quoted file/video/audio (unknownMsgType): cache-first, then group file API fallback.
|
|
1278
|
+
if (!mediaPath && content.quoted?.isQuotedFile) {
|
|
1279
|
+
let fileResolved = false;
|
|
1280
|
+
|
|
1281
|
+
// Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
|
|
1282
|
+
const cachedMedia = await tryDownloadFromRecord(quotedRecord);
|
|
1283
|
+
if (cachedMedia) {
|
|
1284
|
+
mediaPath = cachedMedia.path;
|
|
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;
|
|
1290
|
+
fileResolved = true;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Step 2 (group only): Cache miss → fall back to group file API time-based matching.
|
|
1294
|
+
if (!fileResolved && !isDirect) {
|
|
1295
|
+
const resolved = await resolveQuotedFile(
|
|
1296
|
+
dingtalkConfig,
|
|
1297
|
+
{
|
|
1298
|
+
openConversationId: data.conversationId,
|
|
1299
|
+
senderStaffId: data.senderStaffId,
|
|
1300
|
+
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1301
|
+
},
|
|
1302
|
+
log,
|
|
1303
|
+
);
|
|
1304
|
+
if (resolved) {
|
|
1305
|
+
mediaPath = resolved.media.path;
|
|
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;
|
|
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
|
+
);
|
|
1316
|
+
if (content.quoted.msgId) {
|
|
1317
|
+
upsertInboundMessageContext({
|
|
1318
|
+
storePath: accountStorePath,
|
|
1319
|
+
accountId,
|
|
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
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (!fileResolved) {
|
|
1337
|
+
log?.warn?.(
|
|
1338
|
+
`[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1339
|
+
);
|
|
1340
|
+
const hint = isDirect
|
|
1341
|
+
? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
|
|
1342
|
+
: "[引用了一个文件,但无法获取内容]\n\n";
|
|
1343
|
+
content.text = `${hint}${content.text}`;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Quoted DingTalk doc / Drive file card:
|
|
1348
|
+
// 1) Prefer msgId-based cached metadata captured when the original doc card
|
|
1349
|
+
// message was seen.
|
|
1350
|
+
// 2) In group chats, if the bot never saw the original doc card message,
|
|
1351
|
+
// reuse the same group-file fallback chain as ordinary quoted files.
|
|
1352
|
+
if (!mediaPath && content.quoted?.isQuotedDocCard) {
|
|
1353
|
+
let docResolved = false;
|
|
1354
|
+
|
|
1355
|
+
const cachedDocMedia = await tryDownloadFromRecord(quotedRecord);
|
|
1356
|
+
if (cachedDocMedia) {
|
|
1357
|
+
mediaPath = cachedDocMedia.path;
|
|
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;
|
|
1364
|
+
docResolved = true;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
|
|
1368
|
+
const resolved = await resolveQuotedFile(
|
|
1369
|
+
dingtalkConfig,
|
|
1370
|
+
{
|
|
1371
|
+
openConversationId: data.conversationId,
|
|
1372
|
+
senderStaffId: data.senderStaffId,
|
|
1373
|
+
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1374
|
+
},
|
|
1375
|
+
log,
|
|
1376
|
+
);
|
|
1377
|
+
if (resolved) {
|
|
1378
|
+
mediaPath = resolved.media.path;
|
|
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;
|
|
1384
|
+
docResolved = true;
|
|
1385
|
+
log?.debug?.(
|
|
1386
|
+
`[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
|
|
1387
|
+
`scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1388
|
+
);
|
|
1389
|
+
if (content.quoted.msgId) {
|
|
1390
|
+
upsertInboundMessageContext({
|
|
1391
|
+
storePath: accountStorePath,
|
|
1392
|
+
accountId,
|
|
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
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (!docResolved) {
|
|
1410
|
+
log?.warn?.(
|
|
1411
|
+
`[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1412
|
+
);
|
|
1413
|
+
const hint = isDirect
|
|
1414
|
+
? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
|
|
1415
|
+
: "[引用了钉钉文档,但无法获取内容]\n\n";
|
|
1416
|
+
content.text = `${hint}${content.text}`;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (mediaPath) {
|
|
1421
|
+
try {
|
|
1422
|
+
const extracted = await extractAttachmentText({
|
|
1423
|
+
path: mediaPath,
|
|
1424
|
+
mimeType: mediaType,
|
|
1425
|
+
fileName: attachmentContextFileName || data.content?.fileName,
|
|
1426
|
+
});
|
|
1427
|
+
if (extracted?.text) {
|
|
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
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
} catch (err: any) {
|
|
1444
|
+
log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const inboundBody =
|
|
1449
|
+
mediaPath && /<media:[^>]+>/.test(content.text)
|
|
1450
|
+
? `${content.text}\n[media_path: ${mediaPath}]\n[media_type: ${mediaType || "unknown"}]`
|
|
1451
|
+
: content.text;
|
|
1452
|
+
const inboundText = inboundBody;
|
|
1453
|
+
const learningEnabled = isFeedbackLearningEnabled(dingtalkConfig);
|
|
1454
|
+
const learningContextBlock = buildLearningContextBlock({
|
|
1455
|
+
enabled: learningEnabled,
|
|
1456
|
+
storePath: accountStorePath,
|
|
1457
|
+
accountId,
|
|
1458
|
+
targetId: data.conversationId,
|
|
1459
|
+
content,
|
|
1460
|
+
});
|
|
364
1461
|
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
365
1462
|
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
366
1463
|
storePath,
|
|
@@ -369,11 +1466,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
369
1466
|
|
|
370
1467
|
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
371
1468
|
// GroupSystemPrompt is injected every turn (not only first-turn intro).
|
|
372
|
-
const
|
|
373
|
-
? [
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1469
|
+
const groupSystemPromptParts = !isDirect
|
|
1470
|
+
? [
|
|
1471
|
+
buildGroupTurnContextPrompt({
|
|
1472
|
+
conversationId: groupId,
|
|
1473
|
+
senderDingtalkId: senderId,
|
|
1474
|
+
senderName,
|
|
1475
|
+
}),
|
|
1476
|
+
groupConfig?.systemPrompt?.trim(),
|
|
1477
|
+
]
|
|
1478
|
+
: [];
|
|
1479
|
+
const extraSystemPrompt =
|
|
1480
|
+
[...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
|
|
377
1481
|
|
|
378
1482
|
if (!isDirect) {
|
|
379
1483
|
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
@@ -385,7 +1489,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
385
1489
|
channel: "DingTalk",
|
|
386
1490
|
from: fromLabel,
|
|
387
1491
|
timestamp: data.createAt,
|
|
388
|
-
body:
|
|
1492
|
+
body: inboundText,
|
|
389
1493
|
chatType: isDirect ? "direct" : "group",
|
|
390
1494
|
sender: { name: senderName, id: senderId },
|
|
391
1495
|
previousTimestamp,
|
|
@@ -394,8 +1498,17 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
394
1498
|
|
|
395
1499
|
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
396
1500
|
Body: body,
|
|
397
|
-
RawBody:
|
|
398
|
-
CommandBody:
|
|
1501
|
+
RawBody: inboundText,
|
|
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,
|
|
399
1512
|
From: to,
|
|
400
1513
|
To: to,
|
|
401
1514
|
SessionKey: route.sessionKey,
|
|
@@ -413,7 +1526,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
413
1526
|
MediaType: mediaType,
|
|
414
1527
|
MediaUrl: mediaPath,
|
|
415
1528
|
GroupMembers: groupMembers,
|
|
416
|
-
GroupSystemPrompt:
|
|
1529
|
+
GroupSystemPrompt: extraSystemPrompt,
|
|
417
1530
|
GroupChannel: isDirect ? undefined : route.sessionKey,
|
|
418
1531
|
CommandAuthorized: commandAuthorized,
|
|
419
1532
|
OriginatingChannel: "dingtalk",
|
|
@@ -424,7 +1537,27 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
424
1537
|
storePath,
|
|
425
1538
|
sessionKey: ctx.SessionKey || route.sessionKey,
|
|
426
1539
|
ctx,
|
|
427
|
-
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
|
+
})(),
|
|
428
1561
|
onRecordError: (err: unknown) => {
|
|
429
1562
|
log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
|
|
430
1563
|
},
|
|
@@ -432,218 +1565,200 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
432
1565
|
|
|
433
1566
|
log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
|
|
434
1567
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
1568
|
+
const ackReaction =
|
|
1569
|
+
typeof dingtalkConfig.ackReaction === "string"
|
|
1570
|
+
? dingtalkConfig.ackReaction.trim()
|
|
1571
|
+
: resolveAckReactionSetting({
|
|
1572
|
+
cfg,
|
|
1573
|
+
accountId,
|
|
1574
|
+
agentId: route.agentId,
|
|
1575
|
+
});
|
|
1576
|
+
const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
|
|
1577
|
+
const resolvedAckReaction =
|
|
1578
|
+
normalizedAckReaction === "kaomoji"
|
|
1579
|
+
? classifyAckReactionEmoji(content.text).emoji
|
|
1580
|
+
: normalizedAckReaction === "emoji"
|
|
1581
|
+
? "🤔思考中"
|
|
1582
|
+
: normalizedAckReaction;
|
|
1583
|
+
const shouldAttachAckReaction = Boolean(resolvedAckReaction);
|
|
1584
|
+
let ackReactionAttached = false;
|
|
1585
|
+
let ackReactionAttachedAt = 0;
|
|
1586
|
+
|
|
1587
|
+
if (shouldAttachAckReaction) {
|
|
1588
|
+
ackReactionAttached = await attachNativeAckReaction(
|
|
1589
|
+
dingtalkConfig,
|
|
1590
|
+
{
|
|
1591
|
+
msgId: data.msgId,
|
|
1592
|
+
conversationId: groupId,
|
|
1593
|
+
reactionName: resolvedAckReaction,
|
|
1594
|
+
},
|
|
1595
|
+
log,
|
|
1596
|
+
);
|
|
1597
|
+
if (ackReactionAttached) {
|
|
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
|
+
);
|
|
442
1613
|
try {
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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, "", {
|
|
449
1621
|
sessionWebhook,
|
|
450
|
-
|
|
1622
|
+
mediaPath: actualMediaPath,
|
|
1623
|
+
mediaType: outMediaType,
|
|
451
1624
|
log,
|
|
452
|
-
|
|
1625
|
+
accountId,
|
|
1626
|
+
storePath: accountStorePath,
|
|
1627
|
+
conversationId: groupId,
|
|
1628
|
+
quotedRef: replyQuotedRef,
|
|
453
1629
|
});
|
|
454
1630
|
if (!sendResult.ok) {
|
|
455
|
-
throw new Error(sendResult.error || "
|
|
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");
|
|
456
1649
|
}
|
|
457
1650
|
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (err?.response?.data !== undefined) {
|
|
461
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
|
|
462
|
-
}
|
|
1651
|
+
} finally {
|
|
1652
|
+
await preparedMedia.cleanup?.();
|
|
463
1653
|
}
|
|
464
1654
|
}
|
|
1655
|
+
}
|
|
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
|
+
|
|
1672
|
+
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
1673
|
+
// from receiving concurrent dispatch calls on the same session key, which
|
|
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;
|
|
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
|
+
});
|
|
1701
|
+
try {
|
|
1702
|
+
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
1703
|
+
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
1704
|
+
}
|
|
1705
|
+
|
|
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
|
+
});
|
|
465
1722
|
|
|
466
|
-
let queuedFinal: unknown;
|
|
467
1723
|
try {
|
|
468
|
-
|
|
1724
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
469
1725
|
ctx,
|
|
470
1726
|
cfg,
|
|
471
1727
|
dispatcherOptions: {
|
|
472
|
-
responsePrefix: "",
|
|
473
|
-
deliver: async (payload:
|
|
1728
|
+
responsePrefix: subAgentOptions?.responsePrefix || "",
|
|
1729
|
+
deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
|
|
474
1730
|
try {
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
|
|
481
|
-
log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (useCardMode && currentAICard && info?.kind === "final") {
|
|
486
|
-
lastCardContent = textToSend;
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
if (useCardMode && currentAICard && info?.kind === "tool") {
|
|
491
|
-
if (isCardInTerminalState(currentAICard.state)) {
|
|
492
|
-
log?.debug?.(
|
|
493
|
-
`[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
|
|
494
|
-
);
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
log?.info?.(
|
|
499
|
-
`[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
|
|
500
|
-
);
|
|
501
|
-
const toolText = formatContentForCard(textToSend, "tool");
|
|
502
|
-
if (toolText) {
|
|
503
|
-
const sendResult = await sendMessage(dingtalkConfig, to, toolText, {
|
|
504
|
-
sessionWebhook,
|
|
505
|
-
atUserId: !isDirect ? senderId : null,
|
|
506
|
-
log,
|
|
507
|
-
card: currentAICard,
|
|
508
|
-
cardUpdateMode: "append",
|
|
509
|
-
});
|
|
510
|
-
if (!sendResult.ok) {
|
|
511
|
-
throw new Error(sendResult.error || "Tool stream send failed");
|
|
512
|
-
}
|
|
513
|
-
lastCardContent = currentAICard.lastStreamedContent || toolText;
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
lastCardContent = textToSend;
|
|
519
|
-
const sendResult = await sendMessage(dingtalkConfig, to, textToSend, {
|
|
520
|
-
sessionWebhook,
|
|
521
|
-
atUserId: !isDirect ? senderId : null,
|
|
522
|
-
log,
|
|
523
|
-
card: currentAICard,
|
|
1731
|
+
const mediaUrls = extractMediaUrls(payload);
|
|
1732
|
+
await strategy.deliver({
|
|
1733
|
+
text: payload.text,
|
|
1734
|
+
mediaUrls,
|
|
1735
|
+
kind: (info?.kind as DeliverPayload["kind"]) || "block",
|
|
524
1736
|
});
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (err?.response?.data !== undefined) {
|
|
531
|
-
log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
|
|
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));
|
|
532
1742
|
}
|
|
533
1743
|
throw err;
|
|
534
1744
|
}
|
|
535
1745
|
},
|
|
536
1746
|
},
|
|
537
|
-
replyOptions:
|
|
538
|
-
onReasoningStream: async (payload: any) => {
|
|
539
|
-
if (!useCardMode || !currentAICard) {
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
if (isCardInTerminalState(currentAICard.state)) {
|
|
543
|
-
log?.debug?.(
|
|
544
|
-
`[DingTalk] Skipping thinking stream update because card is terminal: state=${currentAICard.state}`,
|
|
545
|
-
);
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
const thinkingText = formatContentForCard(payload.text, "thinking");
|
|
549
|
-
if (!thinkingText) {
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
try {
|
|
553
|
-
const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
|
|
554
|
-
sessionWebhook,
|
|
555
|
-
atUserId: !isDirect ? senderId : null,
|
|
556
|
-
log,
|
|
557
|
-
card: currentAICard,
|
|
558
|
-
cardUpdateMode: "append",
|
|
559
|
-
});
|
|
560
|
-
if (!sendResult.ok) {
|
|
561
|
-
throw new Error(sendResult.error || "Thinking stream send failed");
|
|
562
|
-
}
|
|
563
|
-
} catch (err: any) {
|
|
564
|
-
log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
|
|
565
|
-
if (err?.response?.data !== undefined) {
|
|
566
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
},
|
|
570
|
-
},
|
|
1747
|
+
replyOptions: strategy.getReplyOptions(),
|
|
571
1748
|
});
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
try {
|
|
576
|
-
await finishAICard(currentAICard, "❌ 处理失败", log);
|
|
577
|
-
} catch (cardCloseErr: any) {
|
|
578
|
-
log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${cardCloseErr.message}`);
|
|
579
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
580
|
-
currentAICard.lastUpdated = Date.now();
|
|
581
|
-
}
|
|
582
|
-
}
|
|
1749
|
+
} catch (dispatchErr: unknown) {
|
|
1750
|
+
const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
|
1751
|
+
await strategy.abort(error);
|
|
583
1752
|
throw dispatchErr;
|
|
584
1753
|
}
|
|
585
1754
|
|
|
586
|
-
|
|
587
|
-
if (useCardMode && currentAICard) {
|
|
588
|
-
try {
|
|
589
|
-
if (isCardInTerminalState(currentAICard.state)) {
|
|
590
|
-
log?.debug?.(
|
|
591
|
-
`[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
|
|
592
|
-
);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const isNonEmptyString = (value: any): boolean =>
|
|
597
|
-
typeof value === "string" && value.trim().length > 0;
|
|
598
|
-
|
|
599
|
-
const hasLastCardContent = isNonEmptyString(lastCardContent);
|
|
600
|
-
const hasQueuedFinalString = isNonEmptyString(queuedFinal);
|
|
601
|
-
|
|
602
|
-
if (hasLastCardContent || hasQueuedFinalString) {
|
|
603
|
-
const finalContentCandidate =
|
|
604
|
-
hasLastCardContent && typeof lastCardContent === "string"
|
|
605
|
-
? lastCardContent
|
|
606
|
-
: typeof queuedFinal === "string"
|
|
607
|
-
? queuedFinal
|
|
608
|
-
: "";
|
|
609
|
-
if (isUnhandledStopReasonText(finalContentCandidate)) {
|
|
610
|
-
log?.warn?.(
|
|
611
|
-
`[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
|
|
612
|
-
);
|
|
613
|
-
currentAICard.state = AICardStatus.FINISHED;
|
|
614
|
-
currentAICard.lastUpdated = Date.now();
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
const finalContent = finalContentCandidate;
|
|
618
|
-
await finishAICard(currentAICard, finalContent, log);
|
|
619
|
-
} else {
|
|
620
|
-
const lastStreamed = currentAICard.lastStreamedContent;
|
|
621
|
-
if (typeof lastStreamed === "string" && lastStreamed.trim().length > 0) {
|
|
622
|
-
await finishAICard(currentAICard, lastStreamed, log);
|
|
623
|
-
} else {
|
|
624
|
-
const defaultFinalContent = "✅ Done";
|
|
625
|
-
log?.debug?.(
|
|
626
|
-
"[DingTalk] No textual content was produced; finalizing AI Card with default completion content.",
|
|
627
|
-
);
|
|
628
|
-
await finishAICard(currentAICard, defaultFinalContent, log);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
} catch (err: any) {
|
|
632
|
-
log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
|
|
633
|
-
if (err?.response?.data !== undefined) {
|
|
634
|
-
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
|
|
635
|
-
}
|
|
636
|
-
try {
|
|
637
|
-
if (currentAICard.state !== AICardStatus.FINISHED) {
|
|
638
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
639
|
-
currentAICard.lastUpdated = Date.now();
|
|
640
|
-
}
|
|
641
|
-
} catch (stateErr: any) {
|
|
642
|
-
log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
1755
|
+
await strategy.finalize();
|
|
646
1756
|
} finally {
|
|
1757
|
+
await waitForDynamicAckDispose({
|
|
1758
|
+
dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
|
|
1759
|
+
log,
|
|
1760
|
+
sessionKey: route.sessionKey,
|
|
1761
|
+
});
|
|
647
1762
|
releaseSessionLock();
|
|
648
1763
|
}
|
|
649
1764
|
}
|