@soimy/dingtalk 3.1.4 → 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
 
@@ -109,6 +110,11 @@ function applyAccountConfig(params: {
109
110
  ...(typeof input.maxReconnectCycles === "number"
110
111
  ? { maxReconnectCycles: input.maxReconnectCycles }
111
112
  : {}),
113
+ ...(typeof input.useConnectionManager === "boolean"
114
+ ? { useConnectionManager: input.useConnectionManager }
115
+ : {}),
116
+ ...(typeof input.mediaMaxMb === "number" ? { mediaMaxMb: input.mediaMaxMb } : {}),
117
+ ...(typeof input.journalTTLDays === "number" ? { journalTTLDays: input.journalTTLDays } : {}),
112
118
  };
113
119
 
114
120
  if (useDefault) {
@@ -271,10 +277,10 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
271
277
  String(
272
278
  await prompter.text({
273
279
  message: "Card Template Key (content field name)",
274
- placeholder: "msgContent",
275
- initialValue: resolved.cardTemplateKey ?? "msgContent",
280
+ placeholder: "content",
281
+ initialValue: resolved.cardTemplateKey ?? "content",
276
282
  }),
277
- ).trim() || "msgContent";
283
+ ).trim() || "content";
278
284
 
279
285
  messageType = "card";
280
286
  }
@@ -298,6 +304,14 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
298
304
  allowFrom = parsed.length > 0 ? parsed : undefined;
299
305
  }
300
306
 
307
+ const mediaUrlAllowlistEntry = await prompter.text({
308
+ message: "Media URL allowlist (comma-separated host/IP/CIDR, optional)",
309
+ placeholder: "cdn.example.com, 192.168.1.23, 10.0.0.0/8",
310
+ initialValue: (resolved.mediaUrlAllowlist || []).join(", ") || undefined,
311
+ });
312
+ const mediaUrlAllowlistParsed = parseList(String(mediaUrlAllowlistEntry ?? ""));
313
+ const mediaUrlAllowlist = mediaUrlAllowlistParsed.length > 0 ? mediaUrlAllowlistParsed : undefined;
314
+
301
315
  const groupPolicyValue = await prompter.select({
302
316
  message: "Group message policy",
303
317
  options: [
@@ -336,6 +350,71 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
336
350
  maxReconnectCycles = Number.isInteger(parsedCycles) && parsedCycles > 0 ? parsedCycles : 10;
337
351
  }
338
352
 
353
+ let mediaMaxMb: number | undefined;
354
+ const wantsMediaMax = await prompter.confirm({
355
+ message: "Configure inbound media max size in MB? (optional)",
356
+ initialValue: typeof resolved.mediaMaxMb === "number",
357
+ });
358
+ if (wantsMediaMax) {
359
+ const parsedMediaMax = Number(
360
+ String(
361
+ await prompter.text({
362
+ message: "Max inbound media size (MB)",
363
+ placeholder: "20",
364
+ initialValue:
365
+ typeof resolved.mediaMaxMb === "number" ? String(resolved.mediaMaxMb) : "20",
366
+ validate: (value) => {
367
+ const raw = String(value ?? "").trim();
368
+ const num = Number(raw);
369
+ if (!raw) {
370
+ return "Required";
371
+ }
372
+ if (!Number.isInteger(num) || num < 1) {
373
+ return "Must be an integer >= 1";
374
+ }
375
+ return undefined;
376
+ },
377
+ }),
378
+ ).trim(),
379
+ );
380
+ mediaMaxMb = Number.isInteger(parsedMediaMax) && parsedMediaMax > 0 ? parsedMediaMax : 20;
381
+ }
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
+
339
418
  const next = applyAccountConfig({
340
419
  cfg,
341
420
  accountId,
@@ -348,10 +427,13 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
348
427
  dmPolicy: dmPolicyValue as "open" | "allowlist",
349
428
  groupPolicy: groupPolicyValue as "open" | "allowlist",
350
429
  allowFrom,
430
+ mediaUrlAllowlist,
351
431
  messageType,
352
432
  cardTemplateId,
353
433
  cardTemplateKey,
354
434
  maxReconnectCycles,
435
+ mediaMaxMb,
436
+ journalTTLDays,
355
437
  },
356
438
  });
357
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
  }