@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/README.md +86 -1421
- package/package.json +11 -4
- package/src/ack-reaction-service.ts +45 -27
- package/src/card-draft-controller.ts +270 -52
- package/src/card-service.ts +10 -11
- package/src/channel.ts +8 -7
- 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 +152 -28
- package/src/message-context-store.ts +74 -0
- package/src/message-utils.ts +22 -0
- package/src/onboarding.ts +0 -45
- package/src/reply-strategy-card.ts +25 -31
- package/src/reply-strategy.ts +3 -3
- package/src/send-service.ts +30 -50
- package/src/targeting/agent-routing.ts +12 -6
- package/src/types.ts +4 -27
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
|
-
|
|
30
|
-
|
|
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
|
|
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:
|
|
811
|
-
autoApply:
|
|
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
|
|
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
|
|
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}:`)) {
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
|
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,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
|
-
|
|
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
|
-
|
|
283
|
+
const robotCode = resolveRobotCode(config);
|
|
284
|
+
if (!robotCode) {
|
|
275
285
|
if (log?.error) {
|
|
276
|
-
log.error("[DingTalk] downloadMedia requires
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
const
|
|
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
|
+
}
|
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) {
|