@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.
- package/LICENSE +21 -0
- package/README.md +657 -32
- package/index.ts +62 -0
- package/package.json +3 -1
- package/src/access-control.ts +18 -0
- package/src/ack-reaction-classifier.ts +62 -0
- package/src/ack-reaction-service.ts +135 -0
- package/src/attachment-text-extractor.ts +147 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +776 -24
- package/src/channel.ts +408 -135
- package/src/config-schema.ts +40 -4
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1100 -159
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +38 -0
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quote-journal.ts +242 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/quoted-msg-cache.ts +226 -0
- package/src/send-service.ts +177 -43
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +152 -24
- package/src/utils.ts +231 -12
package/src/send-service.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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 {
|
|
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
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/session-lock.ts
CHANGED
|
@@ -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
|
+
}
|