@soimy/dingtalk 3.4.2 → 3.5.1

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/src/config.ts CHANGED
@@ -17,19 +17,24 @@ function normalizeLearningConfig(
17
17
  config: DingTalkConfig,
18
18
  options: { applyDefaults: boolean },
19
19
  ): DingTalkConfig {
20
- const learningEnabled = config.learningEnabled ?? config.feedbackLearningEnabled;
21
- const learningAutoApply = config.learningAutoApply ?? config.feedbackLearningAutoApply;
22
- const learningNoteTtlMs = config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs;
23
20
  return {
24
21
  ...config,
25
- learningEnabled: options.applyDefaults ? learningEnabled ?? false : learningEnabled,
26
- learningAutoApply: options.applyDefaults ? learningAutoApply ?? false : learningAutoApply,
22
+ learningEnabled: options.applyDefaults ? config.learningEnabled ?? false : config.learningEnabled,
23
+ learningAutoApply: options.applyDefaults
24
+ ? config.learningAutoApply ?? false
25
+ : config.learningAutoApply,
27
26
  learningNoteTtlMs: options.applyDefaults
28
- ? learningNoteTtlMs ?? DEFAULT_LEARNING_NOTE_TTL_MS
29
- : learningNoteTtlMs,
27
+ ? config.learningNoteTtlMs ?? DEFAULT_LEARNING_NOTE_TTL_MS
28
+ : config.learningNoteTtlMs,
30
29
  };
31
30
  }
32
31
 
32
+ function stripRemovedLegacyFields(config: DingTalkConfig): DingTalkConfig {
33
+ const { verboseRealtimeStream: _verboseRealtimeStream, ...rest } =
34
+ config as DingTalkConfig & { verboseRealtimeStream?: unknown };
35
+ return rest as DingTalkConfig;
36
+ }
37
+
33
38
  /**
34
39
  * Merge channel-level defaults into an account-specific config.
35
40
  * Account-level values take precedence; `accounts` key is excluded to avoid recursion.
@@ -38,8 +43,12 @@ export function mergeAccountWithDefaults(
38
43
  channelCfg: DingTalkConfig,
39
44
  accountCfg: DingTalkConfig,
40
45
  ): DingTalkConfig {
41
- const { accounts: _accounts, ...defaults } = channelCfg;
42
- const normalizedAccountCfg = normalizeLearningConfig(accountCfg, { applyDefaults: false });
46
+ const { accounts: _accounts, ...defaultCandidate } =
47
+ channelCfg as DingTalkConfig & { accounts?: unknown; verboseRealtimeStream?: unknown };
48
+ const defaults = stripRemovedLegacyFields(defaultCandidate as DingTalkConfig);
49
+ const normalizedAccountCfg = stripRemovedLegacyFields(
50
+ normalizeLearningConfig(accountCfg, { applyDefaults: false }),
51
+ );
43
52
  const overrides: Partial<DingTalkConfig> = {};
44
53
  for (const [key, value] of Object.entries(normalizedAccountCfg)) {
45
54
  if (value !== undefined) {
@@ -71,14 +80,14 @@ export function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConf
71
80
  }
72
81
 
73
82
  if (accountId) {
74
- return normalizeLearningConfig(dingtalkCfg, { applyDefaults: true });
83
+ return stripRemovedLegacyFields(normalizeLearningConfig(dingtalkCfg, { applyDefaults: true }));
75
84
  }
76
85
 
77
86
  if (dingtalkCfg.accounts && Object.keys(dingtalkCfg.accounts).length > 0) {
78
87
  return dingtalkCfg;
79
88
  }
80
89
 
81
- return normalizeLearningConfig(dingtalkCfg, { applyDefaults: true });
90
+ return stripRemovedLegacyFields(normalizeLearningConfig(dingtalkCfg, { applyDefaults: true }));
82
91
  }
83
92
 
84
93
  export function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
@@ -142,6 +151,14 @@ export function resolveRelativePath(input: string): string {
142
151
 
143
152
  export const resolveUserPath = resolveRelativePath;
144
153
 
154
+ /**
155
+ * Resolve the robot code used by DingTalk APIs.
156
+ * DingTalk robotCode is always equal to clientId; this helper trims whitespace.
157
+ */
158
+ export function resolveRobotCode(config: Pick<DingTalkConfig, "clientId">): string {
159
+ return (config.clientId || "").trim();
160
+ }
161
+
145
162
  export function resolveGroupConfig(
146
163
  cfg: DingTalkConfig,
147
164
  groupId: string,
@@ -143,14 +143,12 @@ function updateLearnedRule(
143
143
  upsertLearnedRule({ storePath, accountId, rule });
144
144
  }
145
145
 
146
- export function isFeedbackLearningEnabled(config: DingTalkConfig | undefined): boolean {
147
- const typed = config as (DingTalkConfig & { learningEnabled?: boolean; feedbackLearningEnabled?: boolean }) | undefined;
148
- return Boolean(typed?.learningEnabled ?? typed?.feedbackLearningEnabled);
146
+ export function isLearningEnabled(config: DingTalkConfig | undefined): boolean {
147
+ return Boolean(config?.learningEnabled);
149
148
  }
150
149
 
151
- export function isFeedbackLearningAutoApplyEnabled(config: DingTalkConfig | undefined): boolean {
152
- const typed = config as (DingTalkConfig & { learningAutoApply?: boolean; feedbackLearningAutoApply?: boolean }) | undefined;
153
- return Boolean(typed?.learningAutoApply ?? typed?.feedbackLearningAutoApply);
150
+ export function isLearningAutoApplyEnabled(config: DingTalkConfig | undefined): boolean {
151
+ return Boolean(config?.learningAutoApply);
154
152
  }
155
153
 
156
154
  export function recordOutboundReplyForLearning(params: {
@@ -6,8 +6,14 @@ import { attachNativeAckReaction } from "./ack-reaction-service";
6
6
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
7
7
  import { extractAttachmentText } from "./attachment-text-extractor";
8
8
  import { getAccessToken } from "./auth";
9
- import { createAICard } from "./card-service";
10
- import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
9
+ import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
10
+ import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
11
+ import { AICardStatus } from "./types";
12
+ import {
13
+ isCardRunStopRequested,
14
+ registerCardRun,
15
+ removeCardRun,
16
+ } from "./card/card-run-registry";
11
17
  import {
12
18
  applyManualTargetLearningRule,
13
19
  applyManualTargetsLearningRule,
@@ -18,7 +24,7 @@ import {
18
24
  createOrUpdateTargetSet,
19
25
  deleteManualRule,
20
26
  disableManualRule,
21
- isFeedbackLearningEnabled,
27
+ isLearningEnabled,
22
28
  listLearningTargetSets,
23
29
  listScopedLearningRules,
24
30
  resolveManualForcedReply,
@@ -85,10 +91,12 @@ import {
85
91
  } from "./targeting/target-directory-store";
86
92
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
87
93
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
94
+ import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
88
95
 
89
96
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
90
97
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
91
98
  const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
99
+ const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
92
100
  const proactiveHintLastSentAt = new Map<string, number>();
93
101
 
94
102
  function resolvePinnedMainDmOwner(params: {
@@ -237,6 +245,9 @@ type ReplyChunkInfo = {
237
245
  kind?: string;
238
246
  };
239
247
 
248
+ const INBOUND_MEDIA_DOWNLOAD_TIMEOUT_MS = 15_000;
249
+ const DINGTALK_API_HOST = "api.dingtalk.com";
250
+
240
251
  /**
241
252
  * Download DingTalk media file via runtime media service (sandbox-compatible).
242
253
  * Files are stored in the global media inbound directory.
@@ -247,6 +258,9 @@ export async function downloadMedia(
247
258
  log?: any,
248
259
  ): Promise<MediaFile | null> {
249
260
  const rt = getDingTalkRuntime();
261
+ let downloadUrl: string | undefined;
262
+ let requestStage = "auth";
263
+ let requestHost = DINGTALK_API_HOST;
250
264
  const formatAxiosErrorData = (value: unknown): string | undefined => {
251
265
  if (value === null || value === undefined) {
252
266
  return undefined;
@@ -271,21 +285,26 @@ export async function downloadMedia(
271
285
  log?.error?.("[DingTalk] downloadMedia requires downloadCode to be provided.");
272
286
  return null;
273
287
  }
274
- if (!config.robotCode) {
288
+ const robotCode = resolveRobotCode(config);
289
+ if (!robotCode) {
275
290
  if (log?.error) {
276
- log.error("[DingTalk] downloadMedia requires robotCode to be configured.");
291
+ log.error("[DingTalk] downloadMedia requires clientId to be configured.");
277
292
  }
278
293
  return null;
279
294
  }
280
295
  try {
296
+ requestStage = "auth";
297
+ requestHost = DINGTALK_API_HOST;
281
298
  const token = await getAccessToken(config, log);
299
+ requestStage = "exchange";
300
+ requestHost = DINGTALK_API_HOST;
282
301
  const response = await axios.post(
283
302
  "https://api.dingtalk.com/v1.0/robot/messageFiles/download",
284
- { downloadCode, robotCode: config.robotCode },
303
+ { downloadCode, robotCode },
285
304
  { headers: { "x-acs-dingtalk-access-token": token } },
286
305
  );
287
306
  const payload = response.data as Record<string, any>;
288
- const downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
307
+ downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
289
308
  if (!downloadUrl) {
290
309
  const payloadDetail = formatAxiosErrorData(payload);
291
310
  log?.error?.(
@@ -293,7 +312,18 @@ export async function downloadMedia(
293
312
  );
294
313
  return null;
295
314
  }
296
- const mediaResponse = await axios.get(downloadUrl, { responseType: "arraybuffer" });
315
+ requestStage = "download";
316
+ requestHost = (() => {
317
+ try {
318
+ return new URL(downloadUrl).host || "unknown";
319
+ } catch {
320
+ return "unknown";
321
+ }
322
+ })();
323
+ const mediaResponse = await axios.get(downloadUrl, {
324
+ responseType: "arraybuffer",
325
+ timeout: INBOUND_MEDIA_DOWNLOAD_TIMEOUT_MS,
326
+ });
297
327
  const contentType = mediaResponse.headers["content-type"] || "application/octet-stream";
298
328
  const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
299
329
 
@@ -313,7 +343,7 @@ export async function downloadMedia(
313
343
  const code = err.code ? ` code=${err.code}` : "";
314
344
  const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
315
345
  log.error(
316
- `[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`,
346
+ `[DingTalk] Failed to download media: stage=${requestStage} host=${requestHost}${statusLabel}${code} message=${err.message}`,
317
347
  );
318
348
  if (err.response?.data !== undefined) {
319
349
  log.error(formatDingTalkErrorPayloadLog("inbound.downloadMedia", err.response.data));
@@ -321,7 +351,9 @@ export async function downloadMedia(
321
351
  log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
322
352
  }
323
353
  } else {
324
- log.error(`[DingTalk] Failed to download media: ${err.message}`);
354
+ log.error(
355
+ `[DingTalk] Failed to download media: stage=${requestStage} host=${requestHost} message=${err.message}`,
356
+ );
325
357
  }
326
358
  }
327
359
  return null;
@@ -1019,7 +1051,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1019
1051
  // Card creation runs BEFORE media download so the user sees immediate visual
1020
1052
  // feedback while large files are still being downloaded.
1021
1053
  let useCardMode = dingtalkConfig.messageType === "card";
1022
- let currentAICard = undefined;
1054
+ let currentAICard: import("./types").AICardInstance | undefined;
1023
1055
 
1024
1056
  if (useCardMode) {
1025
1057
  try {
@@ -1033,6 +1065,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1033
1065
  });
1034
1066
  if (aiCard) {
1035
1067
  currentAICard = aiCard;
1068
+ if (aiCard.outTrackId) {
1069
+ registerCardRun(aiCard.outTrackId, {
1070
+ accountId,
1071
+ sessionKey: route.sessionKey,
1072
+ agentId: route.agentId,
1073
+ ownerUserId: senderId,
1074
+ card: aiCard,
1075
+ });
1076
+ }
1036
1077
  } else {
1037
1078
  useCardMode = false;
1038
1079
  log?.warn?.(
@@ -1098,6 +1139,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1098
1139
  log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
1099
1140
  }
1100
1141
 
1142
+ const robotCode = resolveRobotCode(dingtalkConfig);
1101
1143
  let mediaPath: string | undefined;
1102
1144
  let mediaType: string | undefined;
1103
1145
  let attachmentContextMsgId = data.msgId;
@@ -1109,7 +1151,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1109
1151
  if (preDownloadedMedia?.mediaPath) {
1110
1152
  mediaPath = preDownloadedMedia.mediaPath;
1111
1153
  mediaType = preDownloadedMedia.mediaType;
1112
- } else if (content.mediaPath && dingtalkConfig.robotCode) {
1154
+ } else if (content.mediaPath && robotCode) {
1113
1155
  // Download media only if not pre-downloaded
1114
1156
  const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
1115
1157
  if (media) {
@@ -1253,7 +1295,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1253
1295
  };
1254
1296
 
1255
1297
  // Quoted picture: download via existing downloadMedia.
1256
- if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
1298
+ if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1257
1299
  const media =
1258
1300
  (await tryDownloadFromRecord(quotedRecord)) ||
1259
1301
  (await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
@@ -1274,20 +1316,40 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1274
1316
  }
1275
1317
  }
1276
1318
 
1277
- // Quoted file/video/audio (unknownMsgType): cache-first, then group file API fallback.
1319
+ // Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
1320
+ // Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
1278
1321
  if (!mediaPath && content.quoted?.isQuotedFile) {
1279
1322
  let fileResolved = false;
1280
1323
 
1324
+ // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
1325
+ if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
1326
+ const media = await downloadMedia(dingtalkConfig, content.quoted.fileDownloadCode, log);
1327
+ if (media) {
1328
+ mediaPath = media.path;
1329
+ mediaType = media.mimeType;
1330
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1331
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1332
+ attachmentContextMessageType = content.quoted.previewMessageType || "file";
1333
+ attachmentContextFileName = content.quoted.previewFileName;
1334
+ fileResolved = true;
1335
+ log?.debug?.(
1336
+ `[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
1337
+ );
1338
+ }
1339
+ }
1340
+
1281
1341
  // 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;
1342
+ if (!fileResolved) {
1343
+ const cachedMedia = await tryDownloadFromRecord(quotedRecord);
1344
+ if (cachedMedia) {
1345
+ mediaPath = cachedMedia.path;
1346
+ mediaType = cachedMedia.mimeType;
1347
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1348
+ attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1349
+ attachmentContextMessageType = quotedRecord?.messageType || "file";
1350
+ attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1351
+ fileResolved = true;
1352
+ }
1291
1353
  }
1292
1354
 
1293
1355
  // Step 2 (group only): Cache miss → fall back to group file API time-based matching.
@@ -1417,6 +1479,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1417
1479
  }
1418
1480
  }
1419
1481
 
1482
+ let attachmentExtractedText: string | undefined;
1420
1483
  if (mediaPath) {
1421
1484
  try {
1422
1485
  const extracted = await extractAttachmentText({
@@ -1439,18 +1502,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1439
1502
  ttlMs: ttlDaysToMs(journalTTLDays),
1440
1503
  topic: null,
1441
1504
  });
1505
+ attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1442
1506
  }
1443
1507
  } catch (err: any) {
1444
1508
  log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
1445
1509
  }
1446
1510
  }
1447
1511
 
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);
1512
+ const inboundBody = content.text;
1513
+ const inboundText = attachmentExtractedText
1514
+ ? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
1515
+ : inboundBody;
1516
+ const learningEnabled = isLearningEnabled(dingtalkConfig);
1454
1517
  const learningContextBlock = buildLearningContextBlock({
1455
1518
  enabled: learningEnabled,
1456
1519
  storePath: accountStorePath,
@@ -1565,6 +1628,81 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1565
1628
 
1566
1629
  log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
1567
1630
 
1631
+ // ---- Pre-lock abort: bypass session lock for stop requests ----
1632
+ // isAbortRequestText matches "/stop", "停止", "stop", "esc", etc.
1633
+ // Calling dispatchReplyWithBufferedBlockDispatcher without holding the lock lets
1634
+ // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1635
+ // rather than waiting for it to finish before the stop message is processed.
1636
+ //
1637
+ // In group chats, DingTalk typically strips @BotName from text.content at the
1638
+ // protocol level before delivery, but as a defensive measure we also strip leading
1639
+ // @mention tokens here (e.g. "@Bot 停止" → "停止") to match the SDK's own behavior
1640
+ // in tryFastAbortFromMessage (which calls stripMentions for group messages).
1641
+ const textForAbortCheck = !isDirect
1642
+ ? inboundText.replace(/^(?:@\S+\s+)*/u, "").trim()
1643
+ : inboundText;
1644
+ if (isAbortRequestText(textForAbortCheck)) {
1645
+ log?.info?.(
1646
+ `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1647
+ );
1648
+ // In card mode: capture the abort confirmation text so we can write it into
1649
+ // the card (instead of sending a separate plain text message).
1650
+ let abortConfirmationText: string | undefined;
1651
+ try {
1652
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1653
+ ctx,
1654
+ cfg,
1655
+ dispatcherOptions: {
1656
+ responsePrefix: "",
1657
+ deliver: async (payload) => {
1658
+ if (!payload.text) {
1659
+ log?.debug?.(`[DingTalk] Abort deliver received non-text payload, skipping`);
1660
+ return;
1661
+ }
1662
+ if (currentAICard) {
1663
+ // Card mode: capture text — will be written to card after dispatch.
1664
+ abortConfirmationText = payload.text;
1665
+ } else {
1666
+ try {
1667
+ if (sessionWebhook) {
1668
+ await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
1669
+ log,
1670
+ accountId,
1671
+ storePath: accountStorePath,
1672
+ });
1673
+ } else {
1674
+ await sendMessage(dingtalkConfig, to, payload.text, {
1675
+ log,
1676
+ accountId,
1677
+ storePath: accountStorePath,
1678
+ conversationId: groupId,
1679
+ });
1680
+ }
1681
+ } catch (deliverErr) {
1682
+ log?.warn?.(
1683
+ `[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
1684
+ );
1685
+ }
1686
+ }
1687
+ },
1688
+ },
1689
+ });
1690
+ } catch (abortErr) {
1691
+ log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
1692
+ }
1693
+ // Finalize the card that was created for this message before the abort check.
1694
+ // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1695
+ if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1696
+ try {
1697
+ await finishAICard(currentAICard, abortConfirmationText ?? "已停止", log);
1698
+ } catch (cardErr) {
1699
+ log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1700
+ currentAICard.state = AICardStatus.FAILED;
1701
+ }
1702
+ }
1703
+ return;
1704
+ }
1705
+
1568
1706
  const ackReaction =
1569
1707
  typeof dingtalkConfig.ackReaction === "string"
1570
1708
  ? dingtalkConfig.ackReaction.trim()
@@ -1674,6 +1812,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1674
1812
  // causes empty replies for all but the first caller.
1675
1813
  // Each sub-agent call acquires its own lock since sub-agent sessions have
1676
1814
  // different session keys (different agentId), so no deadlock risk.
1815
+ const currentOutTrackId = currentAICard?.outTrackId;
1677
1816
  const shouldTrackDynamicAckReaction =
1678
1817
  (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
1679
1818
  && shouldAttachAckReaction;
@@ -1702,6 +1841,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1702
1841
  if (!ackReactionAttached && shouldAttachAckReaction) {
1703
1842
  log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
1704
1843
  }
1844
+ const isCurrentCardStopRequested = () =>
1845
+ Boolean(
1846
+ currentAICard
1847
+ && (
1848
+ currentAICard.state === AICardStatus.STOPPED
1849
+ || (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
1850
+ ),
1851
+ );
1852
+
1853
+ if (isCurrentCardStopRequested()) {
1854
+ log?.info?.("[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired");
1855
+ return;
1856
+ }
1705
1857
 
1706
1858
  // ---- Create reply strategy (card or markdown) ----
1707
1859
  const strategy = createReplyStrategy({
@@ -1718,6 +1870,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1718
1870
  log,
1719
1871
  replyQuotedRef,
1720
1872
  deliverMedia: deliverMediaAttachments,
1873
+ isStopRequested: isCurrentCardStopRequested,
1721
1874
  });
1722
1875
 
1723
1876
  try {
@@ -1727,6 +1880,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1727
1880
  dispatcherOptions: {
1728
1881
  responsePrefix: subAgentOptions?.responsePrefix || "",
1729
1882
  deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
1883
+ if (isCurrentCardStopRequested()) {
1884
+ log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1885
+ return;
1886
+ }
1730
1887
  try {
1731
1888
  const mediaUrls = extractMediaUrls(payload);
1732
1889
  await strategy.deliver({
@@ -1754,6 +1911,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1754
1911
 
1755
1912
  await strategy.finalize();
1756
1913
  } finally {
1914
+ // Only remove the registry entry if no stop was requested. When a stop is
1915
+ // in progress, card-stop-handler may still be running async operations
1916
+ // (finalize card, hide button, gateway abort) that read the record.
1917
+ // In that case, let the 30-minute TTL sweep handle cleanup.
1918
+ if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
1919
+ removeCardRun(currentOutTrackId);
1920
+ }
1757
1921
  await waitForDynamicAckDispose({
1758
1922
  dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
1759
1923
  log,
@@ -13,6 +13,15 @@ const MAX_RECORDS_PER_SCOPE = 1000;
13
13
  export type MessageContextDirection = "inbound" | "outbound";
14
14
  export type MessageAliasKind = "inboundMsgId" | "messageId" | "processQueryKey" | "outTrackId" | "cardInstanceId";
15
15
  export type MessageDeliveryKind = "session" | "proactive-text" | "proactive-card" | "proactive-media";
16
+ export const DEFAULT_OUTBOUND_SENDER = {
17
+ senderId: "bot",
18
+ senderName: "OpenClaw",
19
+ } as const;
20
+
21
+ /** DingTalk conversation ids usually start with "cid" for group chats; treat this as a heuristic. */
22
+ export function inferConversationChatType(conversationId: string): "direct" | "group" {
23
+ return conversationId.startsWith("cid") ? "group" : "direct";
24
+ }
16
25
 
17
26
  export interface MessageRecord {
18
27
  msgId: string;
@@ -30,6 +39,12 @@ export interface MessageRecord {
30
39
  attachmentTextTruncated?: boolean;
31
40
  attachmentFileName?: string;
32
41
  quotedRef?: QuotedRef;
42
+ senderId?: string;
43
+ senderName?: string;
44
+ mentions?: string[];
45
+ chatType?: "direct" | "group";
46
+ /** Flat quoted target for summary/history lookups; quotedRef remains the authoritative structured link. */
47
+ quotedMessageId?: string;
33
48
  media?: {
34
49
  downloadCode?: string;
35
50
  spaceId?: string;
@@ -74,6 +89,11 @@ interface BaseUpsertParams {
74
89
  attachmentTextTruncated?: boolean;
75
90
  attachmentFileName?: string;
76
91
  quotedRef?: QuotedRef;
92
+ senderId?: string;
93
+ senderName?: string;
94
+ mentions?: string[];
95
+ chatType?: "direct" | "group";
96
+ quotedMessageId?: string;
77
97
  media?: {
78
98
  downloadCode?: string;
79
99
  spaceId?: string;
@@ -214,6 +234,15 @@ function normalizeDelivery(value: unknown): MessageRecord["delivery"] | undefine
214
234
  return { messageId, processQueryKey, outTrackId, cardInstanceId, kind };
215
235
  }
216
236
 
237
+ function normalizeMentions(value: unknown): string[] | undefined {
238
+ if (!Array.isArray(value)) {
239
+ return undefined;
240
+ }
241
+ // Preserve the original mention token casing because DingTalk ids may be case-sensitive.
242
+ const normalized = [...new Set(value.map((item) => String(item || "").trim()).filter(Boolean))];
243
+ return normalized.length > 0 ? normalized : undefined;
244
+ }
245
+
217
246
  function normalizeMessageRecord(value: unknown): MessageRecord | null {
218
247
  const candidate = asRecord(value);
219
248
  if (!candidate) {
@@ -256,6 +285,14 @@ function normalizeMessageRecord(value: unknown): MessageRecord | null {
256
285
  attachmentFileName:
257
286
  typeof candidate.attachmentFileName === "string" ? candidate.attachmentFileName : undefined,
258
287
  quotedRef: normalizeQuotedRef(candidate.quotedRef),
288
+ senderId: typeof candidate.senderId === "string" && candidate.senderId.trim() ? candidate.senderId.trim() : undefined,
289
+ senderName: typeof candidate.senderName === "string" && candidate.senderName.trim() ? candidate.senderName.trim() : undefined,
290
+ mentions: normalizeMentions(candidate.mentions),
291
+ chatType: candidate.chatType === "direct" || candidate.chatType === "group" ? candidate.chatType : undefined,
292
+ quotedMessageId:
293
+ typeof candidate.quotedMessageId === "string" && candidate.quotedMessageId.trim()
294
+ ? candidate.quotedMessageId.trim()
295
+ : undefined,
259
296
  media: normalizeMedia(candidate.media),
260
297
  delivery: normalizeDelivery(candidate.delivery),
261
298
  };
@@ -411,6 +448,20 @@ function mergeQuotedRef(existing: QuotedRef | undefined, next: QuotedRef | undef
411
448
  };
412
449
  }
413
450
 
451
+ function mergeStringField(existing: string | undefined, next: string | undefined): string | undefined {
452
+ if (typeof next !== "string" || !next.trim()) {
453
+ return existing;
454
+ }
455
+ return next.trim();
456
+ }
457
+
458
+ function mergeMentions(existing: string[] | undefined, next: string[] | undefined): string[] | undefined {
459
+ if (!next) {
460
+ return existing;
461
+ }
462
+ return normalizeMentions(next) || existing;
463
+ }
464
+
414
465
  function mergeMedia(
415
466
  existing: MessageRecord["media"] | undefined,
416
467
  next: MessageRecord["media"] | undefined,
@@ -556,6 +607,11 @@ function upsertRecord(
556
607
  attachmentTextTruncated?: boolean;
557
608
  attachmentFileName?: string;
558
609
  quotedRef?: QuotedRef;
610
+ senderId?: string;
611
+ senderName?: string;
612
+ mentions?: string[];
613
+ chatType?: "direct" | "group";
614
+ quotedMessageId?: string;
559
615
  media?: MessageRecord["media"];
560
616
  delivery?: MessageRecord["delivery"];
561
617
  cleanupCreatedAtTtlDays?: number;
@@ -611,6 +667,11 @@ function upsertRecord(
611
667
  params.attachmentFileName,
612
668
  ),
613
669
  quotedRef: mergeQuotedRef(existing?.quotedRef, normalizedQuotedRef),
670
+ senderId: mergeStringField(existing?.senderId, params.senderId),
671
+ senderName: mergeStringField(existing?.senderName, params.senderName),
672
+ mentions: mergeMentions(existing?.mentions, params.mentions),
673
+ chatType: params.chatType || existing?.chatType,
674
+ quotedMessageId: mergeStringField(existing?.quotedMessageId, params.quotedMessageId),
614
675
  media: mergeMedia(existing?.media, params.media),
615
676
  delivery: mergeDelivery(existing?.delivery, params.delivery),
616
677
  };
@@ -785,3 +846,16 @@ export function cleanupExpiredMessageContexts(
785
846
  export function clearMessageContextCacheForTest(): void {
786
847
  stateCache.clear();
787
848
  }
849
+
850
+ /**
851
+ * Lists non-expired message-context records for one account/conversation scope in createdAt ascending order.
852
+ */
853
+ export function listMessageContexts(
854
+ params: ScopeParams & { nowMs?: number },
855
+ ): MessageRecord[] {
856
+ const nowMs = params.nowMs ?? Date.now();
857
+ const state = loadState(params, nowMs);
858
+ return state.recentByCreatedAt
859
+ .map((msgId) => state.records[msgId])
860
+ .filter((record): record is MessageRecord => Boolean(record) && !isRecordExpired(record, nowMs));
861
+ }
@@ -188,6 +188,18 @@ function buildRepliedMessagePreview(params: {
188
188
  };
189
189
  }
190
190
 
191
+ if (repliedMsgType === "file" || repliedMsgType === "audio" || repliedMsgType === "video") {
192
+ const hasFileName = repliedMsgType === "file";
193
+ return {
194
+ isQuotedFile: true,
195
+ fileCreatedAt: repliedMsg.createdAt,
196
+ previewText: buildQuotedMessageTypePlaceholder(repliedMsgType, hasFileName ? fileName : undefined),
197
+ previewMessageType: repliedMsgType,
198
+ ...(hasFileName ? { previewFileName: fileName } : {}),
199
+ previewSenderId: trimString(repliedMsg.senderId),
200
+ };
201
+ }
202
+
191
203
  if (repliedMsgType === "interactiveCard") {
192
204
  const isBotCard = repliedMsg.senderId === data.chatbotUserId;
193
205
  if (isBotCard) {
@@ -369,6 +381,16 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
369
381
  };
370
382
  }
371
383
 
384
+ if (repliedMsgType === "file" || repliedMsgType === "audio" || repliedMsgType === "video") {
385
+ return {
386
+ isQuotedFile: true,
387
+ fileCreatedAt: repliedMsg.createdAt,
388
+ fileDownloadCode: trimString(content?.downloadCode),
389
+ msgId: repliedMsgId,
390
+ ...repliedPreview,
391
+ };
392
+ }
393
+
372
394
  if (repliedMsgType === "interactiveCard") {
373
395
  const isBotCard = repliedMsg.senderId === data.chatbotUserId;
374
396
  if (isBotCard) {