@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.
@@ -2,26 +2,27 @@ import * as path from "node:path";
2
2
  import axios from "axios";
3
3
  import { getAccessToken } from "./auth";
4
4
  import {
5
- deleteActiveCardByTarget,
6
- getActiveCardIdByTarget,
7
- getCardById,
8
5
  isCardInTerminalState,
6
+ sendProactiveCardText,
9
7
  streamAICard,
10
8
  } from "./card-service";
11
9
  import { stripTargetPrefix } from "./config";
12
10
  import { getLogger } from "./logger-context";
13
- import { uploadMedia as uploadMediaUtil } from "./media-utils";
14
- import { detectMarkdownAndExtractTitle } from "./message-utils";
11
+ import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
12
+ import { convertMarkdownTablesToPlainText, detectMarkdownAndExtractTitle } from "./message-utils";
15
13
  import { resolveOriginalPeerId } from "./peer-id-registry";
14
+ import { appendOutboundToQuoteJournal, appendProactiveOutboundJournal } from "./quote-journal";
16
15
  import {
17
16
  deleteProactiveRiskObservation,
18
17
  getProactiveRiskObservation,
19
18
  recordProactiveRiskObservation,
20
19
  } from "./proactive-risk-registry";
21
- import { formatDingTalkErrorPayloadLog } from "./utils";
20
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
22
21
  import type {
22
+ AICardInstance,
23
23
  AxiosResponse,
24
24
  DingTalkConfig,
25
+ DingTalkTrackingMetadata,
25
26
  Logger,
26
27
  ProactiveMessagePayload,
27
28
  SendMessageOptions,
@@ -31,6 +32,83 @@ import { AICardStatus } from "./types";
31
32
 
32
33
  export { detectMediaTypeFromExtension } from "./media-utils";
33
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
+
61
+ function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
62
+ const prev = previous ?? "";
63
+ if (!prev) {
64
+ return incoming;
65
+ }
66
+ if (!incoming) {
67
+ return prev;
68
+ }
69
+ if (incoming.startsWith(prev)) {
70
+ return incoming;
71
+ }
72
+ if (prev.endsWith(incoming)) {
73
+ return prev;
74
+ }
75
+ if (prev.endsWith("\n") || incoming.startsWith("\n")) {
76
+ return `${prev}${incoming}`;
77
+ }
78
+ return `${prev}${incoming}`;
79
+ }
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
+
34
112
  function extractErrorCodeFromResponseData(data: unknown): string | null {
35
113
  if (!data || typeof data !== "object") {
36
114
  return null;
@@ -80,9 +158,8 @@ export async function sendProactiveTextOrMarkdown(
80
158
  target: string,
81
159
  text: string,
82
160
  options: SendMessageOptions = {},
83
- ): Promise<AxiosResponse> {
161
+ ): Promise<ProactiveTextSendResult> {
84
162
  const log = options.log || getLogger();
85
- const token = await getAccessToken(config, log);
86
163
 
87
164
  // Support group:/user: prefix and restore original case-sensitive conversationId.
88
165
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -95,11 +172,37 @@ export async function sendProactiveTextOrMarkdown(
95
172
  ? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
96
173
  : "";
97
174
 
175
+ // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
176
+ const messageType = config.messageType || "markdown";
177
+ if (messageType === "card" && config.cardTemplateId) {
178
+ log?.debug?.(
179
+ `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
180
+ );
181
+ const result = await sendProactiveCardText(config, resolvedTarget, text, log);
182
+ if (result.ok) {
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
+ };
193
+ }
194
+ log?.warn?.(
195
+ `[DingTalk] Proactive card send failed, fallback to proactive template API: ${result.error || "unknown"}`,
196
+ );
197
+ }
198
+
199
+ const token = await getAccessToken(config, log);
98
200
  const url = isGroup
99
201
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
100
202
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
101
203
 
102
- const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
204
+ const normalizedText = convertMarkdownTablesToPlainText(text);
205
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
103
206
 
104
207
  log?.debug?.(
105
208
  `[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"${proactiveRiskTag}`,
@@ -108,8 +211,8 @@ export async function sendProactiveTextOrMarkdown(
108
211
  // DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
109
212
  const msgKey = useMarkdown ? "sampleMarkdown" : "sampleText";
110
213
  const msgParam = useMarkdown
111
- ? JSON.stringify({ title, text })
112
- : JSON.stringify({ content: text });
214
+ ? JSON.stringify({ title, text: normalizedText })
215
+ : JSON.stringify({ content: normalizedText });
113
216
 
114
217
  const payload: ProactiveMessagePayload = {
115
218
  robotCode: config.robotCode || config.clientId,
@@ -129,6 +232,7 @@ export async function sendProactiveTextOrMarkdown(
129
232
  method: "POST",
130
233
  data: payload,
131
234
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
235
+ ...getProxyBypassOption(config),
132
236
  });
133
237
  if (options.accountId) {
134
238
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
@@ -207,7 +311,8 @@ export async function sendProactiveMedia(
207
311
  msgParam = JSON.stringify({ photoURL: mediaId });
208
312
  } else if (mediaType === "voice") {
209
313
  msgKey = "sampleAudio";
210
- msgParam = JSON.stringify({ mediaId, duration: "0" });
314
+ const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log);
315
+ msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
211
316
  } else {
212
317
  // sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
213
318
  const filename = path.basename(mediaPath);
@@ -238,12 +343,24 @@ export async function sendProactiveMedia(
238
343
  method: "POST",
239
344
  data: payload,
240
345
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
346
+ ...getProxyBypassOption(config),
241
347
  });
242
348
  if (options.accountId) {
243
349
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
244
350
  }
245
351
 
246
- 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
+ }
247
364
  return { ok: true, data: result.data, messageId };
248
365
  } catch (err: any) {
249
366
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
@@ -271,7 +388,20 @@ export async function sendProactiveMedia(
271
388
  log?.error?.(`[DingTalk] Proactive media response${statusLabel}${proactiveRiskTag}`);
272
389
  log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
273
390
  }
274
- 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 };
275
405
  }
276
406
  }
277
407
 
@@ -293,7 +423,8 @@ export async function sendBySession(
293
423
  if (options.mediaType === "image") {
294
424
  body = { msgtype: "image", image: { media_id: mediaId } };
295
425
  } else if (options.mediaType === "voice") {
296
- body = { msgtype: "voice", voice: { media_id: mediaId } };
426
+ const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log);
427
+ body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
297
428
  } else if (options.mediaType === "video") {
298
429
  body = { msgtype: "video", video: { media_id: mediaId } };
299
430
  } else if (options.mediaType === "file") {
@@ -306,82 +437,131 @@ export async function sendBySession(
306
437
  method: "POST",
307
438
  data: body,
308
439
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
440
+ ...getProxyBypassOption(config),
309
441
  });
310
442
  return result.data;
311
443
  }
312
444
  } else {
445
+ const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
446
+ text = `${text}\n\n📎 媒体发送失败,兜底链接/路径:${mediaHint}`.trim();
313
447
  log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
314
448
  }
315
449
  }
316
450
 
317
451
  // Fallback to text/markdown reply payload.
318
- 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
+ }
319
468
 
320
- let body: SessionWebhookResponse;
321
- if (useMarkdown) {
322
- let finalText = text;
323
- if (options.atUserId) {
324
- finalText = `${finalText} @${options.atUserId}`;
469
+ if (options.atUserId && idx === chunks.length - 1) {
470
+ body.at = { atUserIds: [options.atUserId], isAtAll: false };
325
471
  }
326
- body = { msgtype: "markdown", markdown: { title, text: finalText } };
327
- } else {
328
- body = { msgtype: "text", text: { content: text } };
329
- }
330
472
 
331
- if (options.atUserId) {
332
- 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;
333
481
  }
334
-
335
- const result = await axios({
336
- url: sessionWebhook,
337
- method: "POST",
338
- data: body,
339
- headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
340
- });
341
- return result.data;
482
+ return lastResult;
342
483
  }
343
484
 
344
485
  export async function sendMessage(
345
486
  config: DingTalkConfig,
346
487
  conversationId: string,
347
488
  text: string,
348
- options: SendMessageOptions & { sessionWebhook?: string; accountId?: string } = {},
349
- ): 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 }> {
350
491
  try {
351
492
  const messageType = config.messageType || "markdown";
352
493
  const log = options.log || getLogger();
353
494
 
354
- // Card mode: stream into active card if exists; otherwise fallback to markdown/session send.
355
- if (messageType === "card" && options.accountId) {
356
- const targetKey = `${options.accountId}:${conversationId}`;
357
- const activeCardId = getActiveCardIdByTarget(targetKey);
358
- if (activeCardId) {
359
- const activeCard = getCardById(activeCardId);
360
- if (activeCard && !isCardInTerminalState(activeCard.state)) {
361
- try {
362
- await streamAICard(activeCard, text, false, log);
363
- return { ok: true };
364
- } catch (err: any) {
365
- // Mark failed and continue to markdown fallback to avoid message loss.
366
- log?.warn?.(
367
- `[DingTalk] AI Card streaming failed, fallback to markdown: ${err.message}`,
368
- );
369
- activeCard.state = AICardStatus.FAILED;
370
- activeCard.lastUpdated = Date.now();
495
+ if (messageType === "card" && options.card) {
496
+ const card = options.card;
497
+ if (isCardInTerminalState(card.state)) {
498
+ if (options.sessionWebhook) {
499
+ await sendBySession(config, options.sessionWebhook, text, options);
500
+ return { ok: true };
501
+ }
502
+
503
+ if (config.cardTemplateId) {
504
+ const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
505
+ if (!proactiveResult.ok) {
506
+ return { ok: false, error: proactiveResult.error || "Card send failed" };
371
507
  }
372
- } else {
373
- deleteActiveCardByTarget(targetKey);
508
+ return {
509
+ ok: true,
510
+ tracking: {
511
+ processQueryKey: proactiveResult.processQueryKey,
512
+ outTrackId: proactiveResult.outTrackId,
513
+ cardInstanceId: proactiveResult.cardInstanceId,
514
+ },
515
+ };
516
+ }
517
+ } else if (options.cardUpdateMode === "append") {
518
+ try {
519
+ const nextContent = composeCardContentForAppend(card.lastStreamedContent, text);
520
+ await streamAICard(card, nextContent, false, log);
521
+ return { ok: true };
522
+ } catch (err: any) {
523
+ log?.warn?.(`[DingTalk] AI Card streaming failed: ${err.message}`);
524
+ card.state = AICardStatus.FAILED;
525
+ card.lastUpdated = Date.now();
526
+ return { ok: false, error: err.message };
374
527
  }
375
528
  }
376
529
  }
377
530
 
378
531
  if (options.sessionWebhook) {
379
- await sendBySession(config, options.sessionWebhook, text, options);
380
- 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 };
381
546
  }
382
547
 
383
548
  const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
384
- 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 };
385
565
  } catch (err: any) {
386
566
  options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
387
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
+ }
@@ -0,0 +1,34 @@
1
+ // Per-session mutex to serialize dispatchReply calls for the same session.
2
+ // Prevents concurrent dispatch from producing empty replies when the runtime
3
+ // cannot handle overlapping requests on the same session key.
4
+
5
+ const locks = new Map<string, Promise<void>>();
6
+
7
+ export const SESSION_LOCK_NAMESPACE_POLICY = "memory-only" as const;
8
+
9
+ /**
10
+ * Acquire a per-session lock. Returns a release function that MUST be called
11
+ * (typically in a `finally` block) to unblock the next queued caller.
12
+ *
13
+ * Different session keys run in parallel; same key runs serially.
14
+ */
15
+ export async function acquireSessionLock(sessionKey: string): Promise<() => void> {
16
+ let release!: () => void;
17
+ const gate = new Promise<void>((r) => {
18
+ release = r;
19
+ });
20
+ const prev = locks.get(sessionKey) ?? Promise.resolve();
21
+ locks.set(sessionKey, gate);
22
+ await prev;
23
+ return () => {
24
+ if (locks.get(sessionKey) === gate) {
25
+ locks.delete(sessionKey);
26
+ }
27
+ release();
28
+ };
29
+ }
30
+
31
+ /** Visible for testing only. */
32
+ export function _getLocksMapForTest(): Map<string, Promise<void>> {
33
+ return locks;
34
+ }
@@ -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
+ }