@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.
- package/LICENSE +21 -0
- package/README.md +808 -40
- package/index.ts +62 -0
- package/package.json +4 -2
- 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 +794 -62
- package/src/channel.ts +675 -179
- package/src/config-schema.ts +49 -5
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +356 -36
- 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 +1191 -206
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +566 -8
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +85 -3
- 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 +239 -59
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +34 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +165 -22
- package/src/utils.ts +231 -12
package/src/send-service.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|