@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/README.md +86 -1421
- package/package.json +17 -4
- package/src/ack-reaction-service.ts +45 -27
- package/src/card/card-action-handler.ts +62 -0
- package/src/card/card-run-registry.ts +118 -0
- package/src/card/card-stop-handler.ts +94 -0
- package/src/card/card-template.ts +20 -0
- package/src/card-callback-service.ts +90 -8
- package/src/card-draft-controller.ts +270 -52
- package/src/card-service.ts +198 -138
- package/src/channel.ts +21 -7
- package/src/command/card-stop-command.ts +96 -0
- package/src/config-schema.ts +0 -18
- package/src/config.ts +28 -11
- package/src/feedback-learning-service.ts +4 -6
- package/src/inbound-handler.ts +193 -29
- package/src/message-context-store.ts +74 -0
- package/src/message-utils.ts +22 -0
- package/src/onboarding.ts +4 -77
- package/src/reply-strategy-card.ts +46 -35
- package/src/reply-strategy.ts +4 -3
- package/src/send-service.ts +42 -64
- package/src/targeting/agent-routing.ts +12 -6
- package/src/types.ts +10 -28
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
|
|
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, ...
|
|
42
|
-
|
|
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
|
|
147
|
-
|
|
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
|
|
152
|
-
|
|
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: {
|
package/src/inbound-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
288
|
+
const robotCode = resolveRobotCode(config);
|
|
289
|
+
if (!robotCode) {
|
|
275
290
|
if (log?.error) {
|
|
276
|
-
log.error("[DingTalk] downloadMedia requires
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
const
|
|
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
|
+
}
|
package/src/message-utils.ts
CHANGED
|
@@ -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) {
|