@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.
@@ -9,18 +9,20 @@ import {
9
9
  import { stripTargetPrefix } from "./config";
10
10
  import { getLogger } from "./logger-context";
11
11
  import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
12
- import { detectMarkdownAndExtractTitle } from "./message-utils";
12
+ import { convertMarkdownTablesToPlainText, detectMarkdownAndExtractTitle } from "./message-utils";
13
13
  import { resolveOriginalPeerId } from "./peer-id-registry";
14
+ import { appendOutboundToQuoteJournal, appendProactiveOutboundJournal } from "./quote-journal";
14
15
  import {
15
16
  deleteProactiveRiskObservation,
16
17
  getProactiveRiskObservation,
17
18
  recordProactiveRiskObservation,
18
19
  } from "./proactive-risk-registry";
19
- import { formatDingTalkErrorPayloadLog } from "./utils";
20
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
20
21
  import type {
21
22
  AICardInstance,
22
23
  AxiosResponse,
23
24
  DingTalkConfig,
25
+ DingTalkTrackingMetadata,
24
26
  Logger,
25
27
  ProactiveMessagePayload,
26
28
  SendMessageOptions,
@@ -30,6 +32,32 @@ import { AICardStatus } from "./types";
30
32
 
31
33
  export { detectMediaTypeFromExtension } from "./media-utils";
32
34
 
35
+ type ProactiveTextSendResult = AxiosResponse | { tracking: DingTalkTrackingMetadata };
36
+
37
+ function isTrackingResult(result: ProactiveTextSendResult): result is { tracking: DingTalkTrackingMetadata } {
38
+ return "tracking" in result;
39
+ }
40
+
41
+ function extractOutboundMessageId(payload: unknown): string | undefined {
42
+ if (!payload || typeof payload !== "object") {
43
+ return undefined;
44
+ }
45
+ const data = payload as Record<string, unknown>;
46
+ const tracking =
47
+ data.tracking && typeof data.tracking === "object"
48
+ ? (data.tracking as Record<string, unknown>)
49
+ : undefined;
50
+ const value =
51
+ data.processQueryKey ??
52
+ data.messageId ??
53
+ data.msgid ??
54
+ tracking?.processQueryKey ??
55
+ tracking?.messageId ??
56
+ tracking?.msgid ??
57
+ tracking?.outTrackId;
58
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
59
+ }
60
+
33
61
  function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
34
62
  const prev = previous ?? "";
35
63
  if (!prev) {
@@ -50,6 +78,37 @@ function composeCardContentForAppend(previous: string | undefined, incoming: str
50
78
  return `${prev}${incoming}`;
51
79
  }
52
80
 
81
+ const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
82
+
83
+ function splitMarkdownChunks(text: string, limit = DINGTALK_TEXT_CHUNK_LIMIT): string[] {
84
+ if (!text || text.length <= limit) {
85
+ return [text];
86
+ }
87
+ const chunks: string[] = [];
88
+ let buf = "";
89
+ const lines = text.split("\n");
90
+ let inCode = false;
91
+
92
+ for (const line of lines) {
93
+ const fenceCount = (line.match(/```/g) || []).length;
94
+ if (buf.length + line.length + 1 > limit && buf.length > 0) {
95
+ if (inCode) {
96
+ buf += "\n```";
97
+ }
98
+ chunks.push(buf);
99
+ buf = inCode ? "```\n" : "";
100
+ }
101
+ buf += (buf ? "\n" : "") + line;
102
+ if (fenceCount % 2 === 1) {
103
+ inCode = !inCode;
104
+ }
105
+ }
106
+ if (buf) {
107
+ chunks.push(buf);
108
+ }
109
+ return chunks;
110
+ }
111
+
53
112
  function extractErrorCodeFromResponseData(data: unknown): string | null {
54
113
  if (!data || typeof data !== "object") {
55
114
  return null;
@@ -99,7 +158,7 @@ export async function sendProactiveTextOrMarkdown(
99
158
  target: string,
100
159
  text: string,
101
160
  options: SendMessageOptions = {},
102
- ): Promise<AxiosResponse> {
161
+ ): Promise<ProactiveTextSendResult> {
103
162
  const log = options.log || getLogger();
104
163
 
105
164
  // Support group:/user: prefix and restore original case-sensitive conversationId.
@@ -121,7 +180,16 @@ export async function sendProactiveTextOrMarkdown(
121
180
  );
122
181
  const result = await sendProactiveCardText(config, resolvedTarget, text, log);
123
182
  if (result.ok) {
124
- return {} as AxiosResponse; // Return empty response for compatibility
183
+ if (options.accountId) {
184
+ deleteProactiveRiskObservation(options.accountId, resolvedTarget);
185
+ }
186
+ return {
187
+ tracking: {
188
+ processQueryKey: result.processQueryKey,
189
+ outTrackId: result.outTrackId,
190
+ cardInstanceId: result.cardInstanceId,
191
+ },
192
+ };
125
193
  }
126
194
  log?.warn?.(
127
195
  `[DingTalk] Proactive card send failed, fallback to proactive template API: ${result.error || "unknown"}`,
@@ -133,7 +201,8 @@ export async function sendProactiveTextOrMarkdown(
133
201
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
134
202
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
135
203
 
136
- const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
204
+ const normalizedText = convertMarkdownTablesToPlainText(text);
205
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
137
206
 
138
207
  log?.debug?.(
139
208
  `[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"${proactiveRiskTag}`,
@@ -142,8 +211,8 @@ export async function sendProactiveTextOrMarkdown(
142
211
  // DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
143
212
  const msgKey = useMarkdown ? "sampleMarkdown" : "sampleText";
144
213
  const msgParam = useMarkdown
145
- ? JSON.stringify({ title, text })
146
- : JSON.stringify({ content: text });
214
+ ? JSON.stringify({ title, text: normalizedText })
215
+ : JSON.stringify({ content: normalizedText });
147
216
 
148
217
  const payload: ProactiveMessagePayload = {
149
218
  robotCode: config.robotCode || config.clientId,
@@ -163,6 +232,7 @@ export async function sendProactiveTextOrMarkdown(
163
232
  method: "POST",
164
233
  data: payload,
165
234
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
235
+ ...getProxyBypassOption(config),
166
236
  });
167
237
  if (options.accountId) {
168
238
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
@@ -273,12 +343,24 @@ export async function sendProactiveMedia(
273
343
  method: "POST",
274
344
  data: payload,
275
345
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
346
+ ...getProxyBypassOption(config),
276
347
  });
277
348
  if (options.accountId) {
278
349
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
279
350
  }
280
351
 
281
- const messageId = result.data?.processQueryKey || result.data?.messageId;
352
+ const messageId = extractOutboundMessageId(result.data);
353
+ if (options.storePath && options.accountId) {
354
+ await appendProactiveOutboundJournal({
355
+ storePath: options.storePath,
356
+ accountId: options.accountId,
357
+ conversationId: options.conversationId || resolvedTarget,
358
+ messageId,
359
+ text: `[media:${mediaType}] ${mediaPath}`,
360
+ messageType: "outbound-proactive-media",
361
+ log,
362
+ });
363
+ }
282
364
  return { ok: true, data: result.data, messageId };
283
365
  } catch (err: any) {
284
366
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
@@ -306,7 +388,20 @@ export async function sendProactiveMedia(
306
388
  log?.error?.(`[DingTalk] Proactive media response${statusLabel}${proactiveRiskTag}`);
307
389
  log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
308
390
  }
309
- return { ok: false, error: err.message };
391
+
392
+ // Fallback: ensure user still gets a usable link/path text.
393
+ const fallback = await sendProactiveTextOrMarkdown(
394
+ config,
395
+ target,
396
+ `📎 媒体发送失败,兜底链接/路径:${mediaPath}`,
397
+ options,
398
+ ).catch((fallbackErr: any) => ({ __fallbackError: fallbackErr }));
399
+
400
+ if ((fallback as any)?.__fallbackError) {
401
+ return { ok: false, error: `${err.message}; fallback failed: ${(fallback as any).__fallbackError?.message || "unknown"}` };
402
+ }
403
+
404
+ return { ok: true, data: fallback, messageId: (fallback as any)?.processQueryKey || (fallback as any)?.messageId };
310
405
  }
311
406
  }
312
407
 
@@ -342,47 +437,57 @@ export async function sendBySession(
342
437
  method: "POST",
343
438
  data: body,
344
439
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
440
+ ...getProxyBypassOption(config),
345
441
  });
346
442
  return result.data;
347
443
  }
348
444
  } else {
445
+ const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
446
+ text = `${text}\n\n📎 媒体发送失败,兜底链接/路径:${mediaHint}`.trim();
349
447
  log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
350
448
  }
351
449
  }
352
450
 
353
451
  // Fallback to text/markdown reply payload.
354
- const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "Clawdbot 消息");
452
+ const normalizedText = convertMarkdownTablesToPlainText(text);
453
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
454
+ const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
455
+
456
+ let lastResult: any = null;
457
+ for (const [idx, chunk] of chunks.entries()) {
458
+ let body: SessionWebhookResponse;
459
+ if (useMarkdown) {
460
+ let finalText = chunk;
461
+ if (options.atUserId && idx === chunks.length - 1) {
462
+ finalText = `${finalText} @${options.atUserId}`;
463
+ }
464
+ body = { msgtype: "markdown", markdown: { title: chunks.length > 1 ? `${title} (${idx + 1}/${chunks.length})` : title, text: finalText } };
465
+ } else {
466
+ body = { msgtype: "text", text: { content: chunk } };
467
+ }
355
468
 
356
- let body: SessionWebhookResponse;
357
- if (useMarkdown) {
358
- let finalText = text;
359
- if (options.atUserId) {
360
- finalText = `${finalText} @${options.atUserId}`;
469
+ if (options.atUserId && idx === chunks.length - 1) {
470
+ body.at = { atUserIds: [options.atUserId], isAtAll: false };
361
471
  }
362
- body = { msgtype: "markdown", markdown: { title, text: finalText } };
363
- } else {
364
- body = { msgtype: "text", text: { content: text } };
365
- }
366
472
 
367
- if (options.atUserId) {
368
- body.at = { atUserIds: [options.atUserId], isAtAll: false };
473
+ const result = await axios({
474
+ url: sessionWebhook,
475
+ method: "POST",
476
+ data: body,
477
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
478
+ ...getProxyBypassOption(config),
479
+ });
480
+ lastResult = result.data;
369
481
  }
370
-
371
- const result = await axios({
372
- url: sessionWebhook,
373
- method: "POST",
374
- data: body,
375
- headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
376
- });
377
- return result.data;
482
+ return lastResult;
378
483
  }
379
484
 
380
485
  export async function sendMessage(
381
486
  config: DingTalkConfig,
382
487
  conversationId: string,
383
488
  text: string,
384
- options: SendMessageOptions & { sessionWebhook?: string; card?: AICardInstance } = {},
385
- ): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
489
+ options: SendMessageOptions & { sessionWebhook?: string; card?: AICardInstance; accountId?: string } = {},
490
+ ): Promise<{ ok: boolean; error?: string; data?: AxiosResponse; messageId?: string; tracking?: DingTalkTrackingMetadata }> {
386
491
  try {
387
492
  const messageType = config.messageType || "markdown";
388
493
  const log = options.log || getLogger();
@@ -400,17 +505,19 @@ export async function sendMessage(
400
505
  if (!proactiveResult.ok) {
401
506
  return { ok: false, error: proactiveResult.error || "Card send failed" };
402
507
  }
403
- return { ok: true };
508
+ return {
509
+ ok: true,
510
+ tracking: {
511
+ processQueryKey: proactiveResult.processQueryKey,
512
+ outTrackId: proactiveResult.outTrackId,
513
+ cardInstanceId: proactiveResult.cardInstanceId,
514
+ },
515
+ };
404
516
  }
405
- } else {
517
+ } else if (options.cardUpdateMode === "append") {
406
518
  try {
407
- const mode = options.cardUpdateMode || "replace";
408
- const shouldFinalize = mode === "finalize" || options.cardFinalize === true;
409
- const nextContent =
410
- mode === "append"
411
- ? composeCardContentForAppend(card.lastStreamedContent, text)
412
- : text;
413
- await streamAICard(card, nextContent, shouldFinalize, log);
519
+ const nextContent = composeCardContentForAppend(card.lastStreamedContent, text);
520
+ await streamAICard(card, nextContent, false, log);
414
521
  return { ok: true };
415
522
  } catch (err: any) {
416
523
  log?.warn?.(`[DingTalk] AI Card streaming failed: ${err.message}`);
@@ -422,12 +529,39 @@ export async function sendMessage(
422
529
  }
423
530
 
424
531
  if (options.sessionWebhook) {
425
- await sendBySession(config, options.sessionWebhook, text, options);
426
- return { ok: true };
532
+ const data = await sendBySession(config, options.sessionWebhook, text, options);
533
+ const messageId = extractOutboundMessageId(data);
534
+ if (options.storePath && options.accountId) {
535
+ await appendOutboundToQuoteJournal({
536
+ storePath: options.storePath,
537
+ accountId: options.accountId,
538
+ conversationId: options.conversationId || conversationId,
539
+ messageId,
540
+ text,
541
+ messageType: "outbound",
542
+ log,
543
+ });
544
+ }
545
+ return { ok: true, data, messageId };
427
546
  }
428
547
 
429
548
  const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
430
- return { ok: true, data: result };
549
+ const messageId = extractOutboundMessageId(result);
550
+ if (options.storePath && options.accountId) {
551
+ await appendProactiveOutboundJournal({
552
+ storePath: options.storePath,
553
+ accountId: options.accountId,
554
+ conversationId: options.conversationId || conversationId,
555
+ messageId,
556
+ text,
557
+ messageType: "outbound-proactive",
558
+ log,
559
+ });
560
+ }
561
+ if (isTrackingResult(result)) {
562
+ return { ok: true, tracking: result.tracking };
563
+ }
564
+ return { ok: true, data: result, messageId };
431
565
  } catch (err: any) {
432
566
  options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
433
567
  if (err?.response?.data !== undefined) {
@@ -0,0 +1,147 @@
1
+ export interface ParsedSessionCommand {
2
+ scope:
3
+ | "session-alias-show"
4
+ | "session-alias-set"
5
+ | "session-alias-clear"
6
+ | "session-alias-bind"
7
+ | "session-alias-unbind"
8
+ | "unknown";
9
+ peerId?: string;
10
+ sourceKind?: "direct" | "group";
11
+ sourceId?: string;
12
+ }
13
+
14
+ const SESSION_ALIAS_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
15
+
16
+ export function parseSessionCommand(text: string | undefined): ParsedSessionCommand {
17
+ const raw = String(text || "").trim();
18
+ if (!raw) {
19
+ return { scope: "unknown" };
20
+ }
21
+ const sessionAliasBindMatch = raw.match(/^\/session-alias\s+(bind|unbind)\s+(direct|group)\s+(\S+)(?:\s+(.+))?$/i);
22
+ if (sessionAliasBindMatch) {
23
+ const action = sessionAliasBindMatch[1]?.toLowerCase();
24
+ const sourceKind = sessionAliasBindMatch[2]?.toLowerCase() as "direct" | "group";
25
+ const sourceId = sessionAliasBindMatch[3]?.trim();
26
+ const rawPeerId = sessionAliasBindMatch[4]?.trim();
27
+ if (action === "bind") {
28
+ return sourceId && rawPeerId
29
+ ? { scope: "session-alias-bind", sourceKind, sourceId, peerId: rawPeerId }
30
+ : { scope: "unknown" };
31
+ }
32
+ if (action === "unbind") {
33
+ return sourceId
34
+ ? { scope: "session-alias-unbind", sourceKind, sourceId }
35
+ : { scope: "unknown" };
36
+ }
37
+ }
38
+ const sessionAliasMatch = raw.match(/^\/session-alias\s+(show|clear|set)(?:\s+(.+))?$/i);
39
+ if (!sessionAliasMatch) {
40
+ return { scope: "unknown" };
41
+ }
42
+ const action = sessionAliasMatch[1]?.toLowerCase();
43
+ const rawPeerId = sessionAliasMatch[2]?.trim();
44
+ if (action === "show") {
45
+ return { scope: "session-alias-show" };
46
+ }
47
+ if (action === "clear") {
48
+ return { scope: "session-alias-clear" };
49
+ }
50
+ if (action === "set") {
51
+ return rawPeerId ? { scope: "session-alias-set", peerId: rawPeerId } : { scope: "unknown" };
52
+ }
53
+ return { scope: "unknown" };
54
+ }
55
+
56
+ export function validateSessionAlias(peerId: string | undefined): string | null {
57
+ const value = String(peerId || "").trim();
58
+ if (!value) {
59
+ return "共享会话别名不能为空。";
60
+ }
61
+ if (!SESSION_ALIAS_PATTERN.test(value)) {
62
+ return "共享会话别名仅允许 [a-zA-Z0-9_-]{1,64}。";
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function formatSessionAliasReply(params: {
68
+ sourceKind: "direct" | "group";
69
+ sourceId: string;
70
+ peerId: string;
71
+ aliasSource: "default" | "override";
72
+ }): string {
73
+ return [
74
+ "当前会话别名:",
75
+ "",
76
+ `- source: \`${params.sourceKind}\``,
77
+ `- sourceId: \`${params.sourceId}\``,
78
+ `- peerId: \`${params.peerId}\``,
79
+ `- mode: \`${params.aliasSource}\``,
80
+ ].join("\n");
81
+ }
82
+
83
+ export function formatSessionAliasSetReply(params: {
84
+ sourceKind: "direct" | "group";
85
+ sourceId: string;
86
+ peerId: string;
87
+ }): string {
88
+ return [
89
+ "已更新当前会话共享会话别名。",
90
+ "",
91
+ `- source: \`${params.sourceKind}\``,
92
+ `- sourceId: \`${params.sourceId}\``,
93
+ `- peerId: \`${params.peerId}\``,
94
+ "",
95
+ "将其他私聊或群也设置为同一个 peerId 后,这些会话会共用同一条会话。",
96
+ ].join("\n");
97
+ }
98
+
99
+ export function formatSessionAliasValidationErrorReply(error: string): string {
100
+ return [
101
+ "共享会话别名不合法。",
102
+ "",
103
+ `- 原因:${error}`,
104
+ "- 允许规则:`[a-zA-Z0-9_-]{1,64}`",
105
+ "- 示例:`shared-dev`、`ops_shared`",
106
+ ].join("\n");
107
+ }
108
+
109
+ export function formatSessionAliasClearedReply(params: {
110
+ sourceKind: "direct" | "group";
111
+ sourceId: string;
112
+ }): string {
113
+ return [
114
+ "已清除当前会话共享会话别名。",
115
+ "",
116
+ `- source: \`${params.sourceKind}\``,
117
+ `- sourceId: \`${params.sourceId}\``,
118
+ `- peerId: 恢复为当前${params.sourceKind === "direct" ? " senderId" : " conversationId"}`,
119
+ ].join("\n");
120
+ }
121
+
122
+ export function formatSessionAliasBoundReply(params: {
123
+ sourceKind: "direct" | "group";
124
+ sourceId: string;
125
+ peerId: string;
126
+ }): string {
127
+ return [
128
+ "已绑定共享会话别名。",
129
+ "",
130
+ `- source: \`${params.sourceKind}\``,
131
+ `- sourceId: \`${params.sourceId}\``,
132
+ `- peerId: \`${params.peerId}\``,
133
+ ].join("\n");
134
+ }
135
+
136
+ export function formatSessionAliasUnboundReply(params: {
137
+ sourceKind: "direct" | "group";
138
+ sourceId: string;
139
+ existed: boolean;
140
+ }): string {
141
+ return [
142
+ params.existed ? "已解除共享会话别名绑定。" : "未找到对应的共享会话别名绑定。",
143
+ "",
144
+ `- source: \`${params.sourceKind}\``,
145
+ `- sourceId: \`${params.sourceId}\``,
146
+ ].join("\n");
147
+ }
@@ -4,6 +4,8 @@
4
4
 
5
5
  const locks = new Map<string, Promise<void>>();
6
6
 
7
+ export const SESSION_LOCK_NAMESPACE_POLICY = "memory-only" as const;
8
+
7
9
  /**
8
10
  * Acquire a per-session lock. Returns a release function that MUST be called
9
11
  * (typically in a `finally` block) to unblock the next queued caller.
@@ -0,0 +1,77 @@
1
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
2
+
3
+ const SESSION_PEER_OVERRIDE_NAMESPACE = "session-peer-overrides";
4
+
5
+ export type SessionPeerSourceKind = "direct" | "group";
6
+
7
+ interface SessionPeerOverrideBucket {
8
+ peers: Record<string, string>;
9
+ }
10
+
11
+ function buildSourceKey(sourceKind: SessionPeerSourceKind, sourceId: string): string {
12
+ return `${sourceKind}:${sourceId}`;
13
+ }
14
+
15
+ function readBucket(storePath: string, accountId: string): SessionPeerOverrideBucket {
16
+ return readNamespaceJson<SessionPeerOverrideBucket>(SESSION_PEER_OVERRIDE_NAMESPACE, {
17
+ storePath,
18
+ scope: { accountId },
19
+ fallback: { peers: {} },
20
+ });
21
+ }
22
+
23
+ function writeBucket(storePath: string, accountId: string, bucket: SessionPeerOverrideBucket): void {
24
+ writeNamespaceJsonAtomic(SESSION_PEER_OVERRIDE_NAMESPACE, {
25
+ storePath,
26
+ scope: { accountId },
27
+ data: bucket,
28
+ });
29
+ }
30
+
31
+ export function getSessionPeerOverride(params: {
32
+ storePath: string;
33
+ accountId: string;
34
+ sourceKind: SessionPeerSourceKind;
35
+ sourceId: string;
36
+ }): string | undefined {
37
+ const bucket = readBucket(params.storePath, params.accountId);
38
+ const sourceKey = buildSourceKey(params.sourceKind, params.sourceId);
39
+ const legacyKey = params.sourceKind === "group" ? params.sourceId : undefined;
40
+ const value = bucket.peers[sourceKey] ?? (legacyKey ? bucket.peers[legacyKey] : undefined);
41
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
42
+ }
43
+
44
+ export function setSessionPeerOverride(params: {
45
+ storePath: string;
46
+ accountId: string;
47
+ sourceKind: SessionPeerSourceKind;
48
+ sourceId: string;
49
+ peerId: string;
50
+ }): void {
51
+ const bucket = readBucket(params.storePath, params.accountId);
52
+ const sourceKey = buildSourceKey(params.sourceKind, params.sourceId);
53
+ bucket.peers[sourceKey] = params.peerId.trim();
54
+ writeBucket(params.storePath, params.accountId, bucket);
55
+ }
56
+
57
+ export function clearSessionPeerOverride(params: {
58
+ storePath: string;
59
+ accountId: string;
60
+ sourceKind: SessionPeerSourceKind;
61
+ sourceId: string;
62
+ }): boolean {
63
+ const bucket = readBucket(params.storePath, params.accountId);
64
+ const sourceKey = buildSourceKey(params.sourceKind, params.sourceId);
65
+ const legacyKey = params.sourceKind === "group" ? params.sourceId : undefined;
66
+ const existed = Object.prototype.hasOwnProperty.call(bucket.peers, sourceKey)
67
+ || (legacyKey ? Object.prototype.hasOwnProperty.call(bucket.peers, legacyKey) : false);
68
+ if (!existed) {
69
+ return false;
70
+ }
71
+ delete bucket.peers[sourceKey];
72
+ if (legacyKey) {
73
+ delete bucket.peers[legacyKey];
74
+ }
75
+ writeBucket(params.storePath, params.accountId, bucket);
76
+ return true;
77
+ }
@@ -0,0 +1,33 @@
1
+ import type { DingTalkConfig } from "./types";
2
+
3
+ export interface ResolveDingTalkSessionPeerParams {
4
+ isDirect: boolean;
5
+ senderId: string;
6
+ conversationId: string;
7
+ peerIdOverride?: string;
8
+ config: DingTalkConfig;
9
+ }
10
+
11
+ export interface ResolvedDingTalkSessionPeer {
12
+ kind: "direct" | "group";
13
+ peerId: string;
14
+ }
15
+
16
+ // Keep DingTalk aligned with Feishu's explicit peerId -> sessionKey model:
17
+ // resolve a stable peer identity first, then let OpenClaw build the final session key.
18
+ export function resolveDingTalkSessionPeer(
19
+ params: ResolveDingTalkSessionPeerParams,
20
+ ): ResolvedDingTalkSessionPeer {
21
+ const normalizedPeerIdOverride = params.peerIdOverride?.trim();
22
+ if (params.isDirect) {
23
+ return {
24
+ kind: "direct",
25
+ peerId: normalizedPeerIdOverride || params.senderId,
26
+ };
27
+ }
28
+
29
+ return {
30
+ kind: "group",
31
+ peerId: normalizedPeerIdOverride || params.conversationId,
32
+ };
33
+ }