@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.
- package/LICENSE +21 -0
- package/README.md +796 -42
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +83 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +75 -0
- package/src/ack-reaction-service.ts +182 -0
- package/src/attachment-text-extractor.ts +148 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +666 -26
- package/src/channel.ts +455 -150
- package/src/config-schema.ts +64 -6
- package/src/config.ts +161 -5
- 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 +1374 -259
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +487 -46
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +96 -1
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/send-service.ts +267 -45
- 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/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +261 -28
- package/src/utils.ts +231 -12
package/src/send-service.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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 {
|
|
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
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
+
}
|