@soimy/dingtalk 3.2.0 → 3.4.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -9,20 +9,23 @@ 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
+ import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS, upsertOutboundMessageContext } from "./message-context-store";
13
14
  import { resolveOriginalPeerId } from "./peer-id-registry";
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,
28
+ QuotedRef,
26
29
  SendMessageOptions,
27
30
  SessionWebhookResponse,
28
31
  } from "./types";
@@ -30,6 +33,91 @@ import { AICardStatus } from "./types";
30
33
 
31
34
  export { detectMediaTypeFromExtension } from "./media-utils";
32
35
 
36
+ type ProactiveTextSendResult = AxiosResponse | { tracking: DingTalkTrackingMetadata };
37
+
38
+ function isTrackingResult(result: ProactiveTextSendResult): result is { tracking: DingTalkTrackingMetadata } {
39
+ return "tracking" in result;
40
+ }
41
+
42
+ function firstTrimmedString(...candidates: unknown[]): string | undefined {
43
+ for (const candidate of candidates) {
44
+ if (typeof candidate === "string" && candidate.trim()) {
45
+ return candidate.trim();
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function extractOutboundDeliveryMetadata(payload: unknown): {
52
+ messageId?: string;
53
+ processQueryKey?: string;
54
+ outTrackId?: string;
55
+ cardInstanceId?: string;
56
+ } {
57
+ if (!payload || typeof payload !== "object") {
58
+ return {};
59
+ }
60
+ const data = payload as Record<string, unknown>;
61
+ const tracking =
62
+ data.tracking && typeof data.tracking === "object"
63
+ ? (data.tracking as Record<string, unknown>)
64
+ : undefined;
65
+ const messageId = firstTrimmedString(data.messageId, data.msgid, tracking?.messageId, tracking?.msgid);
66
+ const processQueryKey = firstTrimmedString(data.processQueryKey, tracking?.processQueryKey);
67
+ const outTrackId = firstTrimmedString(data.outTrackId, tracking?.outTrackId);
68
+ const cardInstanceId = firstTrimmedString(data.cardInstanceId, tracking?.cardInstanceId);
69
+ return { messageId, processQueryKey, outTrackId, cardInstanceId };
70
+ }
71
+
72
+ function persistOutboundMessageContext(params: {
73
+ storePath?: string;
74
+ accountId?: string;
75
+ conversationId: string;
76
+ text?: string;
77
+ messageType?: string;
78
+ createdAt?: number;
79
+ quotedRef?: QuotedRef;
80
+ log?: Logger;
81
+ delivery: {
82
+ messageId?: string;
83
+ processQueryKey?: string;
84
+ outTrackId?: string;
85
+ cardInstanceId?: string;
86
+ kind?: "session" | "proactive-text" | "proactive-card" | "proactive-media";
87
+ };
88
+ }): void {
89
+ if (!params.storePath || !params.accountId) {
90
+ return;
91
+ }
92
+ params.log?.debug?.(
93
+ `[DingTalk][QuotedRef][Persist] direction=outbound scope=${params.conversationId} ` +
94
+ `messageType=${params.messageType || "(none)"} processQueryKey=${params.delivery.processQueryKey || "(none)"} ` +
95
+ `messageId=${params.delivery.messageId || "(none)"} quotedRef=${params.quotedRef ? JSON.stringify(params.quotedRef) : "(none)"}`,
96
+ );
97
+ upsertOutboundMessageContext({
98
+ storePath: params.storePath,
99
+ accountId: params.accountId,
100
+ conversationId: params.conversationId,
101
+ createdAt: params.createdAt ?? Date.now(),
102
+ text: params.text,
103
+ messageType: params.messageType,
104
+ ttlMs: DEFAULT_MESSAGE_CONTEXT_TTL_DAYS * 24 * 60 * 60 * 1000,
105
+ topic: null,
106
+ quotedRef: params.quotedRef,
107
+ delivery: params.delivery,
108
+ });
109
+ }
110
+
111
+ function buildPersistedOutboundText(text: string, options: SendMessageOptions): string {
112
+ if (text) {
113
+ return text;
114
+ }
115
+ if (options.mediaPath && options.mediaType) {
116
+ return `[media:${options.mediaType}] ${options.mediaPath}`;
117
+ }
118
+ return text;
119
+ }
120
+
33
121
  function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
34
122
  const prev = previous ?? "";
35
123
  if (!prev) {
@@ -50,6 +138,37 @@ function composeCardContentForAppend(previous: string | undefined, incoming: str
50
138
  return `${prev}${incoming}`;
51
139
  }
52
140
 
141
+ const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
142
+
143
+ function splitMarkdownChunks(text: string, limit = DINGTALK_TEXT_CHUNK_LIMIT): string[] {
144
+ if (!text || text.length <= limit) {
145
+ return [text];
146
+ }
147
+ const chunks: string[] = [];
148
+ let buf = "";
149
+ const lines = text.split("\n");
150
+ let inCode = false;
151
+
152
+ for (const line of lines) {
153
+ const fenceCount = (line.match(/```/g) || []).length;
154
+ if (buf.length + line.length + 1 > limit && buf.length > 0) {
155
+ if (inCode) {
156
+ buf += "\n```";
157
+ }
158
+ chunks.push(buf);
159
+ buf = inCode ? "```\n" : "";
160
+ }
161
+ buf += (buf ? "\n" : "") + line;
162
+ if (fenceCount % 2 === 1) {
163
+ inCode = !inCode;
164
+ }
165
+ }
166
+ if (buf) {
167
+ chunks.push(buf);
168
+ }
169
+ return chunks;
170
+ }
171
+
53
172
  function extractErrorCodeFromResponseData(data: unknown): string | null {
54
173
  if (!data || typeof data !== "object") {
55
174
  return null;
@@ -99,7 +218,7 @@ export async function sendProactiveTextOrMarkdown(
99
218
  target: string,
100
219
  text: string,
101
220
  options: SendMessageOptions = {},
102
- ): Promise<AxiosResponse> {
221
+ ): Promise<ProactiveTextSendResult> {
103
222
  const log = options.log || getLogger();
104
223
 
105
224
  // Support group:/user: prefix and restore original case-sensitive conversationId.
@@ -115,13 +234,22 @@ export async function sendProactiveTextOrMarkdown(
115
234
 
116
235
  // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
117
236
  const messageType = config.messageType || "markdown";
118
- if (messageType === "card" && config.cardTemplateId) {
237
+ if (messageType === "card" && config.cardTemplateId && !options.forceMarkdown) {
119
238
  log?.debug?.(
120
239
  `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
121
240
  );
122
241
  const result = await sendProactiveCardText(config, resolvedTarget, text, log);
123
242
  if (result.ok) {
124
- return {} as AxiosResponse; // Return empty response for compatibility
243
+ if (options.accountId) {
244
+ deleteProactiveRiskObservation(options.accountId, resolvedTarget);
245
+ }
246
+ return {
247
+ tracking: {
248
+ processQueryKey: result.processQueryKey,
249
+ outTrackId: result.outTrackId,
250
+ cardInstanceId: result.cardInstanceId,
251
+ },
252
+ };
125
253
  }
126
254
  log?.warn?.(
127
255
  `[DingTalk] Proactive card send failed, fallback to proactive template API: ${result.error || "unknown"}`,
@@ -133,7 +261,8 @@ export async function sendProactiveTextOrMarkdown(
133
261
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
134
262
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
135
263
 
136
- const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
264
+ const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
265
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
137
266
 
138
267
  log?.debug?.(
139
268
  `[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"${proactiveRiskTag}`,
@@ -142,8 +271,8 @@ export async function sendProactiveTextOrMarkdown(
142
271
  // DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
143
272
  const msgKey = useMarkdown ? "sampleMarkdown" : "sampleText";
144
273
  const msgParam = useMarkdown
145
- ? JSON.stringify({ title, text })
146
- : JSON.stringify({ content: text });
274
+ ? JSON.stringify({ title, text: normalizedText })
275
+ : JSON.stringify({ content: normalizedText });
147
276
 
148
277
  const payload: ProactiveMessagePayload = {
149
278
  robotCode: config.robotCode || config.clientId,
@@ -163,6 +292,7 @@ export async function sendProactiveTextOrMarkdown(
163
292
  method: "POST",
164
293
  data: payload,
165
294
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
295
+ ...getProxyBypassOption(config),
166
296
  });
167
297
  if (options.accountId) {
168
298
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
@@ -273,12 +403,27 @@ export async function sendProactiveMedia(
273
403
  method: "POST",
274
404
  data: payload,
275
405
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
406
+ ...getProxyBypassOption(config),
276
407
  });
277
408
  if (options.accountId) {
278
409
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
279
410
  }
280
411
 
281
- const messageId = result.data?.processQueryKey || result.data?.messageId;
412
+ const delivery = extractOutboundDeliveryMetadata(result.data);
413
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
414
+ persistOutboundMessageContext({
415
+ storePath: options.storePath,
416
+ accountId: options.accountId,
417
+ conversationId: options.conversationId || resolvedTarget,
418
+ text: `[media:${mediaType}] ${mediaPath}`,
419
+ messageType: "outbound-proactive-media",
420
+ quotedRef: options.quotedRef,
421
+ log,
422
+ delivery: {
423
+ ...delivery,
424
+ kind: "proactive-media",
425
+ },
426
+ });
282
427
  return { ok: true, data: result.data, messageId };
283
428
  } catch (err: any) {
284
429
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
@@ -306,7 +451,38 @@ export async function sendProactiveMedia(
306
451
  log?.error?.(`[DingTalk] Proactive media response${statusLabel}${proactiveRiskTag}`);
307
452
  log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
308
453
  }
309
- return { ok: false, error: err.message };
454
+
455
+ // Fallback: ensure user still gets a usable link/path text.
456
+ const fallbackDisplayText = `📎 媒体发送失败,兜底链接/路径:${mediaPath}`;
457
+ const fallbackPersistedText = `媒体发送失败,兜底链接/路径:${mediaPath}`;
458
+ const fallback = await sendProactiveTextOrMarkdown(
459
+ config,
460
+ target,
461
+ fallbackDisplayText,
462
+ options,
463
+ ).catch((fallbackErr: any) => ({ __fallbackError: fallbackErr }));
464
+
465
+ if ((fallback as any)?.__fallbackError) {
466
+ return { ok: false, error: `${err.message}; fallback failed: ${(fallback as any).__fallbackError?.message || "unknown"}` };
467
+ }
468
+
469
+ const fallbackDelivery = extractOutboundDeliveryMetadata(fallback);
470
+ const fallbackMessageId =
471
+ fallbackDelivery.messageId || fallbackDelivery.processQueryKey || fallbackDelivery.outTrackId;
472
+ persistOutboundMessageContext({
473
+ storePath: options.storePath,
474
+ accountId: options.accountId,
475
+ conversationId: options.conversationId || normalizedTarget,
476
+ text: fallbackPersistedText,
477
+ messageType: "outbound-proactive-fallback",
478
+ quotedRef: options.quotedRef,
479
+ log,
480
+ delivery: {
481
+ ...fallbackDelivery,
482
+ kind: isTrackingResult(fallback as ProactiveTextSendResult) ? "proactive-card" : "proactive-text",
483
+ },
484
+ });
485
+ return { ok: true, data: fallback, messageId: fallbackMessageId };
310
486
  }
311
487
  }
312
488
 
@@ -342,52 +518,62 @@ export async function sendBySession(
342
518
  method: "POST",
343
519
  data: body,
344
520
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
521
+ ...getProxyBypassOption(config),
345
522
  });
346
523
  return result.data;
347
524
  }
348
525
  } else {
526
+ const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
527
+ text = `${text}\n\n📎 媒体发送失败,兜底链接/路径:${mediaHint}`.trim();
349
528
  log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
350
529
  }
351
530
  }
352
531
 
353
532
  // Fallback to text/markdown reply payload.
354
- const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "Clawdbot 消息");
533
+ const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
534
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
535
+ const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
536
+
537
+ let lastResult: any = null;
538
+ for (const [idx, chunk] of chunks.entries()) {
539
+ let body: SessionWebhookResponse;
540
+ if (useMarkdown) {
541
+ let finalText = chunk;
542
+ if (options.atUserId && idx === chunks.length - 1) {
543
+ finalText = `${finalText} @${options.atUserId}`;
544
+ }
545
+ body = { msgtype: "markdown", markdown: { title: chunks.length > 1 ? `${title} (${idx + 1}/${chunks.length})` : title, text: finalText } };
546
+ } else {
547
+ body = { msgtype: "text", text: { content: chunk } };
548
+ }
355
549
 
356
- let body: SessionWebhookResponse;
357
- if (useMarkdown) {
358
- let finalText = text;
359
- if (options.atUserId) {
360
- finalText = `${finalText} @${options.atUserId}`;
550
+ if (options.atUserId && idx === chunks.length - 1) {
551
+ body.at = { atUserIds: [options.atUserId], isAtAll: false };
361
552
  }
362
- body = { msgtype: "markdown", markdown: { title, text: finalText } };
363
- } else {
364
- body = { msgtype: "text", text: { content: text } };
365
- }
366
553
 
367
- if (options.atUserId) {
368
- body.at = { atUserIds: [options.atUserId], isAtAll: false };
554
+ const result = await axios({
555
+ url: sessionWebhook,
556
+ method: "POST",
557
+ data: body,
558
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
559
+ ...getProxyBypassOption(config),
560
+ });
561
+ lastResult = result.data;
369
562
  }
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;
563
+ return lastResult;
378
564
  }
379
565
 
380
566
  export async function sendMessage(
381
567
  config: DingTalkConfig,
382
568
  conversationId: string,
383
569
  text: string,
384
- options: SendMessageOptions & { sessionWebhook?: string; card?: AICardInstance } = {},
385
- ): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
570
+ options: SendMessageOptions & { sessionWebhook?: string; card?: AICardInstance; accountId?: string } = {},
571
+ ): Promise<{ ok: boolean; error?: string; data?: AxiosResponse; messageId?: string; tracking?: DingTalkTrackingMetadata }> {
386
572
  try {
387
573
  const messageType = config.messageType || "markdown";
388
574
  const log = options.log || getLogger();
389
575
 
390
- if (messageType === "card" && options.card) {
576
+ if (messageType === "card" && options.card && !options.forceMarkdown) {
391
577
  const card = options.card;
392
578
  if (isCardInTerminalState(card.state)) {
393
579
  if (options.sessionWebhook) {
@@ -400,17 +586,19 @@ export async function sendMessage(
400
586
  if (!proactiveResult.ok) {
401
587
  return { ok: false, error: proactiveResult.error || "Card send failed" };
402
588
  }
403
- return { ok: true };
589
+ return {
590
+ ok: true,
591
+ tracking: {
592
+ processQueryKey: proactiveResult.processQueryKey,
593
+ outTrackId: proactiveResult.outTrackId,
594
+ cardInstanceId: proactiveResult.cardInstanceId,
595
+ },
596
+ };
404
597
  }
405
- } else {
598
+ } else if (options.cardUpdateMode === "append") {
406
599
  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);
600
+ const nextContent = composeCardContentForAppend(card.lastStreamedContent, text);
601
+ await streamAICard(card, nextContent, false, log);
414
602
  return { ok: true };
415
603
  } catch (err: any) {
416
604
  log?.warn?.(`[DingTalk] AI Card streaming failed: ${err.message}`);
@@ -422,12 +610,46 @@ export async function sendMessage(
422
610
  }
423
611
 
424
612
  if (options.sessionWebhook) {
425
- await sendBySession(config, options.sessionWebhook, text, options);
426
- return { ok: true };
613
+ const data = await sendBySession(config, options.sessionWebhook, text, options);
614
+ const delivery = extractOutboundDeliveryMetadata(data);
615
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
616
+ const persistedText = buildPersistedOutboundText(text, options);
617
+ persistOutboundMessageContext({
618
+ storePath: options.storePath,
619
+ accountId: options.accountId,
620
+ conversationId: options.conversationId || conversationId,
621
+ text: persistedText,
622
+ messageType: options.mediaPath && options.mediaType ? "outbound-media" : "outbound",
623
+ quotedRef: options.quotedRef,
624
+ log,
625
+ delivery: {
626
+ ...delivery,
627
+ kind: "session",
628
+ },
629
+ });
630
+ return { ok: true, data, messageId };
427
631
  }
428
632
 
429
633
  const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
430
- return { ok: true, data: result };
634
+ const delivery = extractOutboundDeliveryMetadata(result);
635
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
636
+ persistOutboundMessageContext({
637
+ storePath: options.storePath,
638
+ accountId: options.accountId,
639
+ conversationId: options.conversationId || conversationId,
640
+ text,
641
+ messageType: "outbound-proactive",
642
+ quotedRef: options.quotedRef,
643
+ log,
644
+ delivery: {
645
+ ...delivery,
646
+ kind: isTrackingResult(result) ? "proactive-card" : "proactive-text",
647
+ },
648
+ });
649
+ if (isTrackingResult(result)) {
650
+ return { ok: true, tracking: result.tracking };
651
+ }
652
+ return { ok: true, data: result, messageId };
431
653
  } catch (err: any) {
432
654
  options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
433
655
  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
+ }