@soimy/dingtalk 3.2.0 → 3.3.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.
@@ -1,4 +1,101 @@
1
- import type { DingTalkInboundMessage, MessageContent, SendMessageOptions } from "./types";
1
+ import type { DingTalkInboundMessage, MessageContent, QuotedInfo, SendMessageOptions } from "./types";
2
+
3
+ interface DingTalkDocMeta {
4
+ spaceId: string;
5
+ fileId: string;
6
+ }
7
+
8
+ function parseBizCustomActionUrl(url: string | undefined): DingTalkDocMeta | null {
9
+ if (!url || typeof url !== "string") {
10
+ return null;
11
+ }
12
+
13
+ const queryIndex = url.indexOf("?");
14
+ if (queryIndex < 0 || queryIndex === url.length - 1) {
15
+ return null;
16
+ }
17
+
18
+ try {
19
+ const params = new URLSearchParams(url.slice(queryIndex + 1));
20
+ const route = params.get("route");
21
+ const type = params.get("type");
22
+ const spaceId = params.get("spaceId");
23
+ const fileId = params.get("fileId");
24
+ if (route !== "previewDentry" || type !== "file" || !spaceId || !fileId) {
25
+ return null;
26
+ }
27
+ return { spaceId, fileId };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function extractRichTextQuoteParts(
34
+ richText: Array<Record<string, unknown>> | undefined,
35
+ ): { summary: string; pictureDownloadCode?: string; pictureDownloadCodes?: string[] } | null {
36
+ if (!Array.isArray(richText) || richText.length === 0) {
37
+ return null;
38
+ }
39
+
40
+ const textParts: string[] = [];
41
+ const pictureDownloadCodes: string[] = [];
42
+
43
+ for (const part of richText) {
44
+ const partType =
45
+ typeof part.msgType === "string"
46
+ ? part.msgType
47
+ : typeof part.type === "string"
48
+ ? part.type
49
+ : undefined;
50
+ const textValue =
51
+ typeof part.content === "string"
52
+ ? part.content
53
+ : typeof part.text === "string"
54
+ ? part.text
55
+ : undefined;
56
+
57
+ if ((partType === "text" || partType === undefined) && textValue) {
58
+ textParts.push(textValue);
59
+ continue;
60
+ }
61
+ if (partType === "emoji" && textValue) {
62
+ textParts.push(textValue);
63
+ continue;
64
+ }
65
+ if (partType === "picture") {
66
+ textParts.push("[图片]");
67
+ if (typeof part.downloadCode === "string" && part.downloadCode.trim()) {
68
+ pictureDownloadCodes.push(part.downloadCode.trim());
69
+ }
70
+ continue;
71
+ }
72
+ if (partType === "at") {
73
+ const atName =
74
+ typeof part.atName === "string"
75
+ ? part.atName
76
+ : typeof textValue === "string"
77
+ ? textValue
78
+ : "某人";
79
+ textParts.push(`@${atName}`);
80
+ continue;
81
+ }
82
+ if (textValue) {
83
+ textParts.push(textValue);
84
+ }
85
+ }
86
+
87
+ const summary = textParts.join("").trim();
88
+ const uniquePictureDownloadCodes = [...new Set(pictureDownloadCodes)];
89
+ const pictureDownloadCode = uniquePictureDownloadCodes[0];
90
+ if (!summary && !pictureDownloadCode) {
91
+ return null;
92
+ }
93
+ return {
94
+ summary,
95
+ pictureDownloadCode,
96
+ pictureDownloadCodes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
97
+ };
98
+ }
2
99
 
3
100
  /**
4
101
  * Auto-detect markdown usage and derive message title.
@@ -24,76 +121,199 @@ export function detectMarkdownAndExtractTitle(
24
121
  return { useMarkdown, title };
25
122
  }
26
123
 
124
+ function isMarkdownTableSeparator(line: string): boolean {
125
+ const normalized = line.trim();
126
+ if (!normalized.includes("-")) {
127
+ return false;
128
+ }
129
+ const cells = normalized
130
+ .replace(/^\|/, "")
131
+ .replace(/\|$/, "")
132
+ .split("|")
133
+ .map((cell) => cell.trim());
134
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
135
+ }
136
+
137
+ function isMarkdownTableRow(line: string): boolean {
138
+ const trimmed = line.trim();
139
+ return trimmed.includes("|") && !trimmed.startsWith("```");
140
+ }
141
+
142
+ function parseMarkdownTableRow(line: string): string[] {
143
+ return line
144
+ .trim()
145
+ .replace(/^\|/, "")
146
+ .replace(/\|$/, "")
147
+ .split("|")
148
+ .map((cell) => cell.trim());
149
+ }
150
+
151
+ function renderMarkdownTable(lines: string[]): string {
152
+ const rows = lines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
153
+ return rows.map((cells) => cells.join(" | ")).join("\n");
154
+ }
155
+
156
+ export function convertMarkdownTablesToPlainText(text: string): string {
157
+ const lines = text.split("\n");
158
+ const output: string[] = [];
159
+ let index = 0;
160
+ let inCodeFence = false;
161
+
162
+ while (index < lines.length) {
163
+ const line = lines[index] || "";
164
+ if (line.trim().startsWith("```")) {
165
+ inCodeFence = !inCodeFence;
166
+ output.push(line);
167
+ index += 1;
168
+ continue;
169
+ }
170
+
171
+ if (
172
+ !inCodeFence &&
173
+ index + 1 < lines.length &&
174
+ isMarkdownTableRow(line) &&
175
+ isMarkdownTableSeparator(lines[index + 1] || "")
176
+ ) {
177
+ const tableLines = [line];
178
+ index += 2;
179
+ while (index < lines.length && isMarkdownTableRow(lines[index] || "")) {
180
+ tableLines.push(lines[index] || "");
181
+ index += 1;
182
+ }
183
+ output.push(renderMarkdownTable(tableLines));
184
+ continue;
185
+ }
186
+
187
+ output.push(line);
188
+ index += 1;
189
+ }
190
+
191
+ return output.join("\n");
192
+ }
193
+
27
194
  export function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
28
195
  const msgtype = data.msgtype || "text";
29
196
 
30
- // Normalize quote/reply metadata into a readable text prefix so the agent can understand message context.
31
- const formatQuotedContent = (): string => {
32
- const textField = data.text as any;
197
+ const formatQuotedContent = (): QuotedInfo | null => {
198
+ const textField = data.text;
33
199
 
34
200
  if (textField?.isReplyMsg && textField?.repliedMsg) {
35
201
  const repliedMsg = textField.repliedMsg;
36
- const content = repliedMsg?.content;
202
+ const repliedMsgType = repliedMsg.msgType;
203
+ const content = repliedMsg.content;
204
+
205
+ if (repliedMsgType === "text" && content?.text?.trim()) {
206
+ return { prefix: `[引用消息: "${content.text.trim()}"]\n\n` };
207
+ }
37
208
 
38
- if (content?.text) {
39
- const quoteText = content.text.trim();
40
- if (quoteText) {
41
- return `[引用消息: "${quoteText}"]\n\n`;
209
+ if (repliedMsgType === "picture" && content?.downloadCode) {
210
+ return {
211
+ prefix: "[引用图片]\n\n",
212
+ mediaDownloadCode: content.downloadCode,
213
+ mediaType: "image",
214
+ };
215
+ }
216
+
217
+ if (repliedMsgType === "richText") {
218
+ const richTextQuote = extractRichTextQuoteParts(content?.richText);
219
+ if (richTextQuote) {
220
+ const quoteImageCount = richTextQuote.pictureDownloadCodes?.length || 0;
221
+ const prefix =
222
+ richTextQuote.summary && richTextQuote.summary !== "[图片]"
223
+ ? `[引用消息: "${richTextQuote.summary}"]${quoteImageCount > 1 ? ` [含${quoteImageCount}张引用图片]` : ""}\n\n`
224
+ : "[引用图片]\n\n";
225
+ return {
226
+ prefix,
227
+ mediaDownloadCode: richTextQuote.pictureDownloadCode,
228
+ mediaType: richTextQuote.pictureDownloadCode ? "image" : undefined,
229
+ };
42
230
  }
43
231
  }
44
232
 
45
- if (content?.richText && Array.isArray(content.richText)) {
46
- const textParts: string[] = [];
47
- for (const part of content.richText) {
48
- if (part.msgType === "text" && part.content) {
49
- textParts.push(part.content);
50
- } else if (part.msgType === "emoji" || part.type === "emoji") {
51
- textParts.push(part.content || "[表情]");
52
- } else if (part.msgType === "picture" || part.type === "picture") {
53
- textParts.push("[图片]");
54
- } else if (part.msgType === "at" || part.type === "at") {
55
- textParts.push(`@${part.content || part.atName || "某人"}`);
56
- } else if (part.text) {
57
- textParts.push(part.text);
58
- }
233
+ if (repliedMsgType === "unknownMsgType") {
234
+ return {
235
+ prefix: "[引用文件]\n\n",
236
+ isQuotedFile: true,
237
+ fileCreatedAt: repliedMsg.createdAt,
238
+ msgId: repliedMsg.msgId,
239
+ };
240
+ }
241
+
242
+ if (repliedMsgType === "interactiveCard") {
243
+ const isBotCard = repliedMsg.senderId === data.chatbotUserId;
244
+ if (isBotCard) {
245
+ return {
246
+ prefix: "[引用了机器人的回复]\n\n",
247
+ isQuotedCard: true,
248
+ cardCreatedAt: repliedMsg.createdAt,
249
+ processQueryKey: data.originalProcessQueryKey,
250
+ msgId: repliedMsg.msgId,
251
+ };
59
252
  }
60
- const quoteText = textParts.join("").trim();
61
- if (quoteText) {
62
- return `[引用消息: "${quoteText}"]\n\n`;
253
+
254
+ return {
255
+ prefix: "[引用了钉钉文档]\n\n",
256
+ isQuotedDocCard: true,
257
+ fileCreatedAt: repliedMsg.createdAt,
258
+ msgId: repliedMsg.msgId,
259
+ };
260
+ }
261
+
262
+ // Has msgType but not one we handle — generic fallback.
263
+ if (repliedMsgType) {
264
+ const idPart = repliedMsg.msgId ? `,原消息ID: ${repliedMsg.msgId}` : "";
265
+ return { prefix: `[引用消息不可见: msgType=${repliedMsgType}${idPart}]\n\n` };
266
+ }
267
+
268
+ // No msgType — backward compat: extract text or richText from content.
269
+ if (content?.text?.trim()) {
270
+ return { prefix: `[引用消息: "${content.text.trim()}"]\n\n` };
271
+ }
272
+
273
+ if (content?.richText && Array.isArray(content.richText)) {
274
+ const richTextQuote = extractRichTextQuoteParts(content.richText);
275
+ if (richTextQuote?.summary) {
276
+ return {
277
+ prefix: `[引用消息: "${richTextQuote.summary}"]\n\n`,
278
+ mediaDownloadCode: richTextQuote.pictureDownloadCode,
279
+ mediaType: richTextQuote.pictureDownloadCode ? "image" : undefined,
280
+ };
63
281
  }
64
282
  }
65
283
  }
66
284
 
67
- // Some clients only send originalMsgId for rich media reply messages.
68
285
  if (textField?.isReplyMsg && !textField?.repliedMsg && data.originalMsgId) {
69
- return `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`;
286
+ return {
287
+ prefix: `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`,
288
+ msgId: data.originalMsgId,
289
+ };
70
290
  }
71
291
 
72
292
  if (data.quoteMessage) {
73
293
  const quoteText = data.quoteMessage.text?.content?.trim() || "";
74
294
  if (quoteText) {
75
- return `[引用消息: "${quoteText}"]\n\n`;
295
+ return { prefix: `[引用消息: "${quoteText}"]\n\n` };
76
296
  }
77
297
  }
78
298
 
79
299
  if (data.content?.quoteContent) {
80
- return `[引用消息: "${data.content.quoteContent}"]\n\n`;
300
+ return { prefix: `[引用消息: "${data.content.quoteContent}"]\n\n` };
81
301
  }
82
302
 
83
- return "";
303
+ return null;
84
304
  };
85
305
 
86
- const quotedPrefix = formatQuotedContent();
306
+ const quoted = formatQuotedContent();
307
+ const quotedPrefix = quoted?.prefix || "";
87
308
 
88
- // Unified extraction by DingTalk msgtype for downstream routing/agent processing.
89
309
  if (msgtype === "text") {
90
- return { text: quotedPrefix + (data.text?.content?.trim() || ""), messageType: "text" };
310
+ return { text: quotedPrefix + (data.text?.content?.trim() || ""), messageType: "text", quoted: quoted ?? undefined };
91
311
  }
92
312
 
93
313
  if (msgtype === "richText") {
94
314
  const richTextParts = data.content?.richText || [];
95
315
  let text = "";
96
- let pictureDownloadCode: string | undefined;
316
+ const pictureDownloadCodes: string[] = [];
97
317
  // Keep first image downloadCode while preserving readable text and @mention parts.
98
318
  for (const part of richTextParts) {
99
319
  if (part.text && (part.type === "text" || part.type === undefined)) {
@@ -102,16 +322,21 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
102
322
  if (part.type === "at" && part.atName) {
103
323
  text += `@${part.atName} `;
104
324
  }
105
- if (part.type === "picture" && part.downloadCode && !pictureDownloadCode) {
106
- pictureDownloadCode = part.downloadCode;
325
+ if (part.type === "picture" && part.downloadCode) {
326
+ pictureDownloadCodes.push(part.downloadCode);
107
327
  }
108
328
  }
329
+ const uniquePictureDownloadCodes = [...new Set(pictureDownloadCodes)];
330
+ const pictureDownloadCode = uniquePictureDownloadCodes[0];
109
331
  return {
110
332
  text:
111
333
  quotedPrefix + (text.trim() || (pictureDownloadCode ? "<media:image>" : "[富文本消息]")),
112
334
  mediaPath: pictureDownloadCode,
335
+ mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
113
336
  mediaType: pictureDownloadCode ? "image" : undefined,
337
+ mediaTypes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes.map(() => "image") : undefined,
114
338
  messageType: "richText",
339
+ quoted: quoted ?? undefined,
115
340
  };
116
341
  }
117
342
 
@@ -151,6 +376,43 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
151
376
  };
152
377
  }
153
378
 
154
- // Fallback: preserve unknown msgtype as readable marker.
155
- return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype };
379
+ if (msgtype === "interactiveCard") {
380
+ const docMeta = parseBizCustomActionUrl(data.content?.biz_custom_action_url);
381
+ if (docMeta) {
382
+ return {
383
+ text: "[钉钉文档]\n\n",
384
+ messageType: "interactiveCardFile",
385
+ docSpaceId: docMeta.spaceId,
386
+ docFileId: docMeta.fileId,
387
+ quoted: quoted ?? undefined,
388
+ };
389
+ }
390
+ return {
391
+ text: data.text?.content?.trim() || "[interactiveCard消息]",
392
+ messageType: msgtype,
393
+ quoted: quoted ?? undefined,
394
+ };
395
+ }
396
+ if (msgtype === "chatRecord") {
397
+ const content = data.content as Record<string, unknown> | undefined;
398
+ const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
399
+ const rawRecord = content?.chatRecord;
400
+ if (
401
+ summary === "[]" ||
402
+ (typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
403
+ (Array.isArray(rawRecord) && rawRecord.length === 0)
404
+ ) {
405
+ return {
406
+ text: "[系统提示] 没有读到引用记录(chatRecord 为空)。请改用逐条转发、复制原文,或重新转发非空聊天记录。",
407
+ messageType: "chatRecord",
408
+ quoted: quoted ?? undefined,
409
+ };
410
+ }
411
+ if (summary) {
412
+ return { text: `[聊天记录摘要] ${summary}`, messageType: "chatRecord", quoted: quoted ?? undefined };
413
+ }
414
+ return { text: "[chatRecord消息: 无可读内容]", messageType: "chatRecord", quoted: quoted ?? undefined };
415
+ }
416
+
417
+ return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype, quoted: quoted ?? undefined };
156
418
  }
package/src/onboarding.ts CHANGED
@@ -2,6 +2,7 @@ import type { OpenClawConfig, ChannelOnboardingAdapter, WizardPrompter } from "o
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId, formatDocsLink } from "openclaw/plugin-sdk";
3
3
  import type { DingTalkConfig, DingTalkChannelConfig } from "./types.js";
4
4
  import { listDingTalkAccountIds, resolveDingTalkAccount } from "./types.js";
5
+ import { DEFAULT_JOURNAL_TTL_DAYS } from "./quote-journal.js";
5
6
 
6
7
  const channel = "dingtalk" as const;
7
8
 
@@ -113,6 +114,7 @@ function applyAccountConfig(params: {
113
114
  ? { useConnectionManager: input.useConnectionManager }
114
115
  : {}),
115
116
  ...(typeof input.mediaMaxMb === "number" ? { mediaMaxMb: input.mediaMaxMb } : {}),
117
+ ...(typeof input.journalTTLDays === "number" ? { journalTTLDays: input.journalTTLDays } : {}),
116
118
  };
117
119
 
118
120
  if (useDefault) {
@@ -378,6 +380,41 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
378
380
  mediaMaxMb = Number.isInteger(parsedMediaMax) && parsedMediaMax > 0 ? parsedMediaMax : 20;
379
381
  }
380
382
 
383
+ let journalTTLDays: number | undefined;
384
+ const wantsJournalTTL = await prompter.confirm({
385
+ message: "Configure quote journal retention in days?",
386
+ initialValue: typeof resolved.journalTTLDays === "number",
387
+ });
388
+ if (wantsJournalTTL) {
389
+ const parsedJournalTTL = Number(
390
+ String(
391
+ await prompter.text({
392
+ message: "Quote journal retention days",
393
+ placeholder: String(DEFAULT_JOURNAL_TTL_DAYS),
394
+ initialValue:
395
+ typeof resolved.journalTTLDays === "number"
396
+ ? String(resolved.journalTTLDays)
397
+ : String(DEFAULT_JOURNAL_TTL_DAYS),
398
+ validate: (value) => {
399
+ const raw = String(value ?? "").trim();
400
+ const num = Number(raw);
401
+ if (!raw) {
402
+ return "Required";
403
+ }
404
+ if (!Number.isInteger(num) || num < 1) {
405
+ return "Must be an integer >= 1";
406
+ }
407
+ return undefined;
408
+ },
409
+ }),
410
+ ).trim(),
411
+ );
412
+ journalTTLDays =
413
+ Number.isInteger(parsedJournalTTL) && parsedJournalTTL > 0
414
+ ? parsedJournalTTL
415
+ : DEFAULT_JOURNAL_TTL_DAYS;
416
+ }
417
+
381
418
  const next = applyAccountConfig({
382
419
  cfg,
383
420
  accountId,
@@ -396,6 +433,7 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
396
433
  cardTemplateKey,
397
434
  maxReconnectCycles,
398
435
  mediaMaxMb,
436
+ journalTTLDays,
399
437
  },
400
438
  });
401
439
 
@@ -8,7 +8,13 @@
8
8
  * can be delivered correctly.
9
9
  */
10
10
 
11
+ import { readFileSync, readdirSync } from "fs";
12
+ import * as os from "os";
13
+ import { join } from "path";
14
+ import { getLogger } from "./logger-context";
15
+
11
16
  const peerIdMap = new Map<string, string>();
17
+ let preloaded = false;
12
18
 
13
19
  /**
14
20
  * Register an original peer ID, keyed by its lowercased form.
@@ -20,14 +26,31 @@ export function registerPeerId(originalId: string): void {
20
26
  peerIdMap.set(originalId.toLowerCase(), originalId);
21
27
  }
22
28
 
29
+ function maybeRegisterDingTalkGroupPeerId(value: unknown): void {
30
+ // DingTalk group openConversationId values are base64-like and consistently start with "cid".
31
+ // User IDs in DM contexts do not follow this pattern and do not require case restoration.
32
+ if (typeof value === "string" && value.startsWith("cid")) {
33
+ registerPeerId(value);
34
+ }
35
+ }
36
+
23
37
  /**
24
38
  * Resolve a possibly-lowercased peer ID back to its original casing.
39
+ *
40
+ * If registry is empty at first outbound call (for example, cron/delivery queue
41
+ * fires before inbound callbacks), perform a one-time lazy preload from sessions.
42
+ *
25
43
  * Returns the original if found, otherwise returns the input as-is.
26
44
  */
27
45
  export function resolveOriginalPeerId(id: string): string {
28
46
  if (!id) {
29
47
  return id;
30
48
  }
49
+
50
+ if (!preloaded) {
51
+ preloadPeerIdsFromSessions();
52
+ }
53
+
31
54
  return peerIdMap.get(id.toLowerCase()) || id;
32
55
  }
33
56
 
@@ -36,4 +59,83 @@ export function resolveOriginalPeerId(id: string): string {
36
59
  */
37
60
  export function clearPeerIdRegistry(): void {
38
61
  peerIdMap.clear();
62
+ preloaded = false;
63
+ }
64
+
65
+ /**
66
+ * Preload known peer IDs from all agents' sessions.json files.
67
+ *
68
+ * Safe to call repeatedly:
69
+ * - without explicit homeDir: runs once, then no-ops;
70
+ * - with explicit homeDir: always runs (useful for tests).
71
+ */
72
+ export function preloadPeerIdsFromSessions(homeDir?: string): void {
73
+ if (!homeDir && preloaded) {
74
+ return;
75
+ }
76
+
77
+ const home = homeDir || os.homedir();
78
+ const agentsDir = join(home, ".openclaw", "agents");
79
+ const log = getLogger();
80
+ let preloadCompleted = false;
81
+
82
+ try {
83
+ const agentDirs = readdirSync(agentsDir, { withFileTypes: true })
84
+ .filter((entry) => entry.isDirectory())
85
+ .map((entry) => entry.name);
86
+
87
+ for (const agentName of agentDirs) {
88
+ const sessionsPath = join(agentsDir, agentName, "sessions", "sessions.json");
89
+
90
+ try {
91
+ const raw = readFileSync(sessionsPath, "utf-8");
92
+ const parsed = JSON.parse(raw);
93
+ if (!parsed || typeof parsed !== "object") {
94
+ continue;
95
+ }
96
+
97
+ for (const session of Object.values(parsed as Record<string, unknown>)) {
98
+ if (!session || typeof session !== "object") {
99
+ continue;
100
+ }
101
+
102
+ const sessionRecord = session as Record<string, unknown>;
103
+ maybeRegisterDingTalkGroupPeerId(sessionRecord.lastTo);
104
+
105
+ const origin = sessionRecord.origin;
106
+ if (!origin || typeof origin !== "object") {
107
+ continue;
108
+ }
109
+
110
+ const originRecord = origin as Record<string, unknown>;
111
+ maybeRegisterDingTalkGroupPeerId(originRecord.from);
112
+ maybeRegisterDingTalkGroupPeerId(originRecord.to);
113
+ }
114
+ } catch (err) {
115
+ // sessions.json may be missing or malformed for some agents; ignore per file.
116
+ log?.debug?.(
117
+ `[DingTalk][PeerIdRegistry] Failed to parse preload sessions file ${sessionsPath}: ${
118
+ err instanceof Error ? err.message : String(err)
119
+ }`,
120
+ );
121
+ }
122
+ }
123
+ preloadCompleted = true;
124
+ } catch (err) {
125
+ const errorCode = (err as NodeJS.ErrnoException | undefined)?.code;
126
+ if (errorCode === "ENOENT") {
127
+ // agents directory may not exist yet; this is a stable no-op state.
128
+ preloadCompleted = true;
129
+ } else {
130
+ log?.debug?.(
131
+ `[DingTalk][PeerIdRegistry] Failed to scan preload directory ${agentsDir}: ${
132
+ err instanceof Error ? err.message : String(err)
133
+ }`,
134
+ );
135
+ }
136
+ }
137
+
138
+ if (!homeDir && preloadCompleted) {
139
+ preloaded = true;
140
+ }
39
141
  }