@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -1,31 +1,153 @@
1
1
  import axios from "axios";
2
- import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
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
- createAICard,
6
- finishAICard,
7
- formatContentForCard,
8
- isCardInTerminalState,
9
- } from "./card-service";
10
- import { resolveGroupConfig } from "./config";
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 type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
22
- import { AICardStatus } from "./types";
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 { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
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
- [params.senderId, params.senderStaffId],
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 isUnhandledStopReasonText(value: string): boolean {
81
- const normalized = value.trim();
82
- if (!normalized) {
83
- return false;
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 String(value);
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:", JSON.stringify(maskSensitiveData(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
- const content = extractMessageContent(data);
195
- if (!content.text) {
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 senderId = data.senderStaffId || data.senderId;
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
- senderStaffId: data.senderStaffId,
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?.(formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data));
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?.(formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data));
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 groupPolicy = dingtalkConfig.groupPolicy || "open";
278
- const allowFrom = dingtalkConfig.allowFrom || [];
279
-
280
- if (groupPolicy === "allowlist") {
281
- const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
282
- const isAllowed = isSenderGroupAllowed({ allow: normalizedAllowFrom, groupId });
283
-
284
- if (!isAllowed) {
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
- try {
290
- await sendBySession(
291
- dingtalkConfig,
292
- sessionWebhook,
293
- `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
294
- { log, atUserId: senderId },
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 authorized: conversationId=${groupId} senderId=${senderId} in allowlist`,
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
- const route = rt.channel.routing.resolveAgentRoute({
315
- cfg,
316
- channel: "dingtalk",
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
- peer: { kind: isDirect ? "direct" : "group", id: isDirect ? senderId : groupId },
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
- const useCardMode = dingtalkConfig.messageType === "card";
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
- if (content.mediaPath && dingtalkConfig.robotCode) {
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 groupSystemPrompt = !isDirect
373
- ? [`DingTalk group context: conversationId=${groupId}`, groupConfig?.systemPrompt?.trim()]
374
- .filter(Boolean)
375
- .join("\n")
376
- : undefined;
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: content.text,
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: content.text,
398
- CommandBody: content.text,
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: 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: { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId },
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
- // Serialize dispatchReply + card finalize per session to prevent the runtime
436
- // from receiving concurrent dispatch calls on the same session key, which
437
- // causes empty replies for all but the first caller.
438
- const releaseSessionLock = await acquireSessionLock(route.sessionKey);
439
- try {
440
- // 4) Optional "thinking..." feedback for non-card mode.
441
- if (dingtalkConfig.showThinking !== false) {
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 thinkingText = "🤔 思考中,请稍候...";
444
- if (useCardMode && currentAICard) {
445
- log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
446
- } else {
447
- lastCardContent = thinkingText;
448
- const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
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
- atUserId: !isDirect ? senderId : null,
1622
+ mediaPath: actualMediaPath,
1623
+ mediaType: outMediaType,
451
1624
  log,
452
- card: currentAICard,
1625
+ accountId,
1626
+ storePath: accountStorePath,
1627
+ conversationId: groupId,
1628
+ quotedRef: replyQuotedRef,
453
1629
  });
454
1630
  if (!sendResult.ok) {
455
- throw new Error(sendResult.error || "Thinking message send failed");
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
- } catch (err: any) {
459
- log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
460
- if (err?.response?.data !== undefined) {
461
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
462
- }
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
- const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1724
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
469
1725
  ctx,
470
1726
  cfg,
471
1727
  dispatcherOptions: {
472
- responsePrefix: "",
473
- deliver: async (payload: any, info?: { kind: string }) => {
1728
+ responsePrefix: subAgentOptions?.responsePrefix || "",
1729
+ deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
474
1730
  try {
475
- const textToSend = payload.markdown || payload.text;
476
- if (!textToSend) {
477
- return;
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
- if (!sendResult.ok) {
526
- throw new Error(sendResult.error || "Reply send failed");
527
- }
528
- } catch (err: any) {
529
- log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
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
- queuedFinal = dispatchResult?.queuedFinal;
573
- } catch (dispatchErr: any) {
574
- if (useCardMode && currentAICard && !isCardInTerminalState(currentAICard.state)) {
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
- // 5) Finalize card stream if card mode is active.
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
  }