@soimy/dingtalk 3.4.2 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts CHANGED
@@ -20,14 +20,15 @@ import {
20
20
  mergeAccountWithDefaults,
21
21
  resolveGroupConfig,
22
22
  resolveRelativePath,
23
+ resolveRobotCode,
23
24
  stripTargetPrefix,
24
25
  } from "./config";
25
26
  import { DingTalkConfigSchema } from "./config-schema.js";
26
27
  import { ConnectionManager } from "./connection-manager";
27
28
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
28
29
  import {
29
- isFeedbackLearningAutoApplyEnabled,
30
- isFeedbackLearningEnabled,
30
+ isLearningAutoApplyEnabled,
31
+ isLearningEnabled,
31
32
  recordExplicitFeedbackLearning,
32
33
  } from "./feedback-learning-service";
33
34
  import { handleDingTalkMessage } from "./inbound-handler";
@@ -708,7 +709,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
708
709
  try {
709
710
  const data = JSON.parse(res.data) as DingTalkInboundMessage;
710
711
 
711
- const robotKey = config.robotCode || config.clientId || account.accountId;
712
+ const robotKey = resolveRobotCode(config) || account.accountId;
712
713
  const msgId = data.msgId || messageId;
713
714
  const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
714
715
 
@@ -807,15 +808,15 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
807
808
 
808
809
  if (analysis.feedbackTarget && analysis.feedbackAckText) {
809
810
  recordExplicitFeedbackLearning({
810
- enabled: isFeedbackLearningEnabled(config),
811
- autoApply: isFeedbackLearningAutoApplyEnabled(config),
811
+ enabled: isLearningEnabled(config),
812
+ autoApply: isLearningAutoApplyEnabled(config),
812
813
  storePath: accountStorePath,
813
814
  accountId: account.accountId,
814
815
  targetId: analysis.feedbackTarget,
815
816
  feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
816
817
  userId: analysis.userId,
817
818
  processQueryKey: analysis.processQueryKey,
818
- noteTtlMs: config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs,
819
+ noteTtlMs: config.learningNoteTtlMs,
819
820
  });
820
821
  try {
821
822
  await sendProactiveTextOrMarkdown(
@@ -984,7 +985,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
984
985
  // Clear stale in-flight locks for this account on disconnect.
985
986
  // DingTalk will redeliver unacknowledged messages on reconnect; without
986
987
  // this cleanup the redelivered messages would be silently skipped forever.
987
- const robotKey = config.robotCode || config.clientId || account.accountId;
988
+ const robotKey = resolveRobotCode(config) || account.accountId;
988
989
  let cleared = 0;
989
990
  for (const key of processingDedupKeys.keys()) {
990
991
  if (key.startsWith(`${robotKey}:`)) {
@@ -20,15 +20,6 @@ const DingTalkAccountConfigShape = {
20
20
  /** DingTalk App Secret (Client Secret) - required for authentication */
21
21
  clientSecret: z.string().optional(),
22
22
 
23
- /** DingTalk Robot Code for media download */
24
- robotCode: z.string().optional(),
25
-
26
- /** DingTalk Corporation ID */
27
- corpId: z.string().optional(),
28
-
29
- /** DingTalk Application ID (Agent ID) */
30
- agentId: z.union([z.string(), z.number()]).optional(),
31
-
32
23
  /** Direct message policy: open, pairing, or allowlist */
33
24
  dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
34
25
 
@@ -134,15 +125,6 @@ const DingTalkAccountConfigShape = {
134
125
  /** Session learning note TTL in milliseconds (default: 6 hours) */
135
126
  learningNoteTtlMs: z.number().int().min(60_000).optional(),
136
127
 
137
- /** @deprecated Use learningEnabled */
138
- feedbackLearningEnabled: z.boolean().optional(),
139
-
140
- /** @deprecated Use learningAutoApply */
141
- feedbackLearningAutoApply: z.boolean().optional(),
142
-
143
- /** @deprecated Use learningNoteTtlMs */
144
- feedbackLearningNoteTtlMs: z.number().int().min(60_000).optional(),
145
-
146
128
  /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
147
129
  convertMarkdownTables: z.boolean().optional().default(true),
148
130
 
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,8 @@ 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
11
  import {
12
12
  applyManualTargetLearningRule,
13
13
  applyManualTargetsLearningRule,
@@ -18,7 +18,7 @@ import {
18
18
  createOrUpdateTargetSet,
19
19
  deleteManualRule,
20
20
  disableManualRule,
21
- isFeedbackLearningEnabled,
21
+ isLearningEnabled,
22
22
  listLearningTargetSets,
23
23
  listScopedLearningRules,
24
24
  resolveManualForcedReply,
@@ -83,12 +83,15 @@ import {
83
83
  upsertObservedGroupTarget,
84
84
  upsertObservedUserTarget,
85
85
  } from "./targeting/target-directory-store";
86
+ import { AICardStatus } from "./types";
86
87
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
87
88
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
89
+ import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
88
90
 
89
91
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
90
92
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
91
93
  const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
94
+ const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
92
95
  const proactiveHintLastSentAt = new Map<string, number>();
93
96
 
94
97
  function resolvePinnedMainDmOwner(params: {
@@ -237,6 +240,9 @@ type ReplyChunkInfo = {
237
240
  kind?: string;
238
241
  };
239
242
 
243
+ const INBOUND_MEDIA_DOWNLOAD_TIMEOUT_MS = 15_000;
244
+ const DINGTALK_API_HOST = "api.dingtalk.com";
245
+
240
246
  /**
241
247
  * Download DingTalk media file via runtime media service (sandbox-compatible).
242
248
  * Files are stored in the global media inbound directory.
@@ -247,6 +253,9 @@ export async function downloadMedia(
247
253
  log?: any,
248
254
  ): Promise<MediaFile | null> {
249
255
  const rt = getDingTalkRuntime();
256
+ let downloadUrl: string | undefined;
257
+ let requestStage = "auth";
258
+ let requestHost = DINGTALK_API_HOST;
250
259
  const formatAxiosErrorData = (value: unknown): string | undefined => {
251
260
  if (value === null || value === undefined) {
252
261
  return undefined;
@@ -271,21 +280,26 @@ export async function downloadMedia(
271
280
  log?.error?.("[DingTalk] downloadMedia requires downloadCode to be provided.");
272
281
  return null;
273
282
  }
274
- if (!config.robotCode) {
283
+ const robotCode = resolveRobotCode(config);
284
+ if (!robotCode) {
275
285
  if (log?.error) {
276
- log.error("[DingTalk] downloadMedia requires robotCode to be configured.");
286
+ log.error("[DingTalk] downloadMedia requires clientId to be configured.");
277
287
  }
278
288
  return null;
279
289
  }
280
290
  try {
291
+ requestStage = "auth";
292
+ requestHost = DINGTALK_API_HOST;
281
293
  const token = await getAccessToken(config, log);
294
+ requestStage = "exchange";
295
+ requestHost = DINGTALK_API_HOST;
282
296
  const response = await axios.post(
283
297
  "https://api.dingtalk.com/v1.0/robot/messageFiles/download",
284
- { downloadCode, robotCode: config.robotCode },
298
+ { downloadCode, robotCode },
285
299
  { headers: { "x-acs-dingtalk-access-token": token } },
286
300
  );
287
301
  const payload = response.data as Record<string, any>;
288
- const downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
302
+ downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
289
303
  if (!downloadUrl) {
290
304
  const payloadDetail = formatAxiosErrorData(payload);
291
305
  log?.error?.(
@@ -293,7 +307,18 @@ export async function downloadMedia(
293
307
  );
294
308
  return null;
295
309
  }
296
- const mediaResponse = await axios.get(downloadUrl, { responseType: "arraybuffer" });
310
+ requestStage = "download";
311
+ requestHost = (() => {
312
+ try {
313
+ return new URL(downloadUrl).host || "unknown";
314
+ } catch {
315
+ return "unknown";
316
+ }
317
+ })();
318
+ const mediaResponse = await axios.get(downloadUrl, {
319
+ responseType: "arraybuffer",
320
+ timeout: INBOUND_MEDIA_DOWNLOAD_TIMEOUT_MS,
321
+ });
297
322
  const contentType = mediaResponse.headers["content-type"] || "application/octet-stream";
298
323
  const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
299
324
 
@@ -313,7 +338,7 @@ export async function downloadMedia(
313
338
  const code = err.code ? ` code=${err.code}` : "";
314
339
  const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
315
340
  log.error(
316
- `[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`,
341
+ `[DingTalk] Failed to download media: stage=${requestStage} host=${requestHost}${statusLabel}${code} message=${err.message}`,
317
342
  );
318
343
  if (err.response?.data !== undefined) {
319
344
  log.error(formatDingTalkErrorPayloadLog("inbound.downloadMedia", err.response.data));
@@ -321,7 +346,9 @@ export async function downloadMedia(
321
346
  log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
322
347
  }
323
348
  } else {
324
- log.error(`[DingTalk] Failed to download media: ${err.message}`);
349
+ log.error(
350
+ `[DingTalk] Failed to download media: stage=${requestStage} host=${requestHost} message=${err.message}`,
351
+ );
325
352
  }
326
353
  }
327
354
  return null;
@@ -1098,6 +1125,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1098
1125
  log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
1099
1126
  }
1100
1127
 
1128
+ const robotCode = resolveRobotCode(dingtalkConfig);
1101
1129
  let mediaPath: string | undefined;
1102
1130
  let mediaType: string | undefined;
1103
1131
  let attachmentContextMsgId = data.msgId;
@@ -1109,7 +1137,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1109
1137
  if (preDownloadedMedia?.mediaPath) {
1110
1138
  mediaPath = preDownloadedMedia.mediaPath;
1111
1139
  mediaType = preDownloadedMedia.mediaType;
1112
- } else if (content.mediaPath && dingtalkConfig.robotCode) {
1140
+ } else if (content.mediaPath && robotCode) {
1113
1141
  // Download media only if not pre-downloaded
1114
1142
  const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
1115
1143
  if (media) {
@@ -1253,7 +1281,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1253
1281
  };
1254
1282
 
1255
1283
  // Quoted picture: download via existing downloadMedia.
1256
- if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
1284
+ if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1257
1285
  const media =
1258
1286
  (await tryDownloadFromRecord(quotedRecord)) ||
1259
1287
  (await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
@@ -1274,20 +1302,40 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1274
1302
  }
1275
1303
  }
1276
1304
 
1277
- // Quoted file/video/audio (unknownMsgType): cache-first, then group file API fallback.
1305
+ // Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
1306
+ // Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
1278
1307
  if (!mediaPath && content.quoted?.isQuotedFile) {
1279
1308
  let fileResolved = false;
1280
1309
 
1310
+ // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
1311
+ if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
1312
+ const media = await downloadMedia(dingtalkConfig, content.quoted.fileDownloadCode, log);
1313
+ if (media) {
1314
+ mediaPath = media.path;
1315
+ mediaType = media.mimeType;
1316
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1317
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1318
+ attachmentContextMessageType = content.quoted.previewMessageType || "file";
1319
+ attachmentContextFileName = content.quoted.previewFileName;
1320
+ fileResolved = true;
1321
+ log?.debug?.(
1322
+ `[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
1323
+ );
1324
+ }
1325
+ }
1326
+
1281
1327
  // 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;
1328
+ if (!fileResolved) {
1329
+ const cachedMedia = await tryDownloadFromRecord(quotedRecord);
1330
+ if (cachedMedia) {
1331
+ mediaPath = cachedMedia.path;
1332
+ mediaType = cachedMedia.mimeType;
1333
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1334
+ attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1335
+ attachmentContextMessageType = quotedRecord?.messageType || "file";
1336
+ attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1337
+ fileResolved = true;
1338
+ }
1291
1339
  }
1292
1340
 
1293
1341
  // Step 2 (group only): Cache miss → fall back to group file API time-based matching.
@@ -1417,6 +1465,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1417
1465
  }
1418
1466
  }
1419
1467
 
1468
+ let attachmentExtractedText: string | undefined;
1420
1469
  if (mediaPath) {
1421
1470
  try {
1422
1471
  const extracted = await extractAttachmentText({
@@ -1439,18 +1488,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1439
1488
  ttlMs: ttlDaysToMs(journalTTLDays),
1440
1489
  topic: null,
1441
1490
  });
1491
+ attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1442
1492
  }
1443
1493
  } catch (err: any) {
1444
1494
  log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
1445
1495
  }
1446
1496
  }
1447
1497
 
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);
1498
+ const inboundBody = content.text;
1499
+ const inboundText = attachmentExtractedText
1500
+ ? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
1501
+ : inboundBody;
1502
+ const learningEnabled = isLearningEnabled(dingtalkConfig);
1454
1503
  const learningContextBlock = buildLearningContextBlock({
1455
1504
  enabled: learningEnabled,
1456
1505
  storePath: accountStorePath,
@@ -1565,6 +1614,81 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1565
1614
 
1566
1615
  log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
1567
1616
 
1617
+ // ---- Pre-lock abort: bypass session lock for stop requests ----
1618
+ // isAbortRequestText matches "/stop", "停止", "stop", "esc", etc.
1619
+ // Calling dispatchReplyWithBufferedBlockDispatcher without holding the lock lets
1620
+ // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1621
+ // rather than waiting for it to finish before the stop message is processed.
1622
+ //
1623
+ // In group chats, DingTalk typically strips @BotName from text.content at the
1624
+ // protocol level before delivery, but as a defensive measure we also strip leading
1625
+ // @mention tokens here (e.g. "@Bot 停止" → "停止") to match the SDK's own behavior
1626
+ // in tryFastAbortFromMessage (which calls stripMentions for group messages).
1627
+ const textForAbortCheck = !isDirect
1628
+ ? inboundText.replace(/^(?:@\S+\s+)*/u, "").trim()
1629
+ : inboundText;
1630
+ if (isAbortRequestText(textForAbortCheck)) {
1631
+ log?.info?.(
1632
+ `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1633
+ );
1634
+ // In card mode: capture the abort confirmation text so we can write it into
1635
+ // the card (instead of sending a separate plain text message).
1636
+ let abortConfirmationText: string | undefined;
1637
+ try {
1638
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1639
+ ctx,
1640
+ cfg,
1641
+ dispatcherOptions: {
1642
+ responsePrefix: "",
1643
+ deliver: async (payload) => {
1644
+ if (!payload.text) {
1645
+ log?.debug?.(`[DingTalk] Abort deliver received non-text payload, skipping`);
1646
+ return;
1647
+ }
1648
+ if (currentAICard) {
1649
+ // Card mode: capture text — will be written to card after dispatch.
1650
+ abortConfirmationText = payload.text;
1651
+ } else {
1652
+ try {
1653
+ if (sessionWebhook) {
1654
+ await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
1655
+ log,
1656
+ accountId,
1657
+ storePath: accountStorePath,
1658
+ });
1659
+ } else {
1660
+ await sendMessage(dingtalkConfig, to, payload.text, {
1661
+ log,
1662
+ accountId,
1663
+ storePath: accountStorePath,
1664
+ conversationId: groupId,
1665
+ });
1666
+ }
1667
+ } catch (deliverErr) {
1668
+ log?.warn?.(
1669
+ `[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
1670
+ );
1671
+ }
1672
+ }
1673
+ },
1674
+ },
1675
+ });
1676
+ } catch (abortErr) {
1677
+ log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
1678
+ }
1679
+ // Finalize the card that was created for this message before the abort check.
1680
+ // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1681
+ if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1682
+ try {
1683
+ await finishAICard(currentAICard, abortConfirmationText ?? "已停止", log);
1684
+ } catch (cardErr) {
1685
+ log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1686
+ currentAICard.state = AICardStatus.FAILED;
1687
+ }
1688
+ }
1689
+ return;
1690
+ }
1691
+
1568
1692
  const ackReaction =
1569
1693
  typeof dingtalkConfig.ackReaction === "string"
1570
1694
  ? dingtalkConfig.ackReaction.trim()
@@ -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) {