@soimy/dingtalk 3.1.4 → 3.2.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/README.md +155 -12
- package/package.json +2 -2
- package/src/card-service.ts +31 -51
- package/src/channel.ts +317 -94
- package/src/config-schema.ts +9 -1
- package/src/connection-manager.ts +14 -1
- package/src/inbound-handler.ts +221 -177
- package/src/media-utils.ts +517 -3
- package/src/onboarding.ts +47 -3
- package/src/send-service.ts +73 -27
- package/src/session-lock.ts +32 -0
- package/src/types.ts +15 -0
package/src/send-service.ts
CHANGED
|
@@ -2,15 +2,13 @@ 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";
|
|
11
|
+
import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
|
|
14
12
|
import { detectMarkdownAndExtractTitle } from "./message-utils";
|
|
15
13
|
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
16
14
|
import {
|
|
@@ -20,6 +18,7 @@ import {
|
|
|
20
18
|
} from "./proactive-risk-registry";
|
|
21
19
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
22
20
|
import type {
|
|
21
|
+
AICardInstance,
|
|
23
22
|
AxiosResponse,
|
|
24
23
|
DingTalkConfig,
|
|
25
24
|
Logger,
|
|
@@ -31,6 +30,26 @@ import { AICardStatus } from "./types";
|
|
|
31
30
|
|
|
32
31
|
export { detectMediaTypeFromExtension } from "./media-utils";
|
|
33
32
|
|
|
33
|
+
function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
|
|
34
|
+
const prev = previous ?? "";
|
|
35
|
+
if (!prev) {
|
|
36
|
+
return incoming;
|
|
37
|
+
}
|
|
38
|
+
if (!incoming) {
|
|
39
|
+
return prev;
|
|
40
|
+
}
|
|
41
|
+
if (incoming.startsWith(prev)) {
|
|
42
|
+
return incoming;
|
|
43
|
+
}
|
|
44
|
+
if (prev.endsWith(incoming)) {
|
|
45
|
+
return prev;
|
|
46
|
+
}
|
|
47
|
+
if (prev.endsWith("\n") || incoming.startsWith("\n")) {
|
|
48
|
+
return `${prev}${incoming}`;
|
|
49
|
+
}
|
|
50
|
+
return `${prev}${incoming}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
function extractErrorCodeFromResponseData(data: unknown): string | null {
|
|
35
54
|
if (!data || typeof data !== "object") {
|
|
36
55
|
return null;
|
|
@@ -82,7 +101,6 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
82
101
|
options: SendMessageOptions = {},
|
|
83
102
|
): Promise<AxiosResponse> {
|
|
84
103
|
const log = options.log || getLogger();
|
|
85
|
-
const token = await getAccessToken(config, log);
|
|
86
104
|
|
|
87
105
|
// Support group:/user: prefix and restore original case-sensitive conversationId.
|
|
88
106
|
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
@@ -95,6 +113,22 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
95
113
|
? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
|
|
96
114
|
: "";
|
|
97
115
|
|
|
116
|
+
// In card mode, use card API to avoid oToMessages/batchSend permission requirement.
|
|
117
|
+
const messageType = config.messageType || "markdown";
|
|
118
|
+
if (messageType === "card" && config.cardTemplateId) {
|
|
119
|
+
log?.debug?.(
|
|
120
|
+
`[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
|
|
121
|
+
);
|
|
122
|
+
const result = await sendProactiveCardText(config, resolvedTarget, text, log);
|
|
123
|
+
if (result.ok) {
|
|
124
|
+
return {} as AxiosResponse; // Return empty response for compatibility
|
|
125
|
+
}
|
|
126
|
+
log?.warn?.(
|
|
127
|
+
`[DingTalk] Proactive card send failed, fallback to proactive template API: ${result.error || "unknown"}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const token = await getAccessToken(config, log);
|
|
98
132
|
const url = isGroup
|
|
99
133
|
? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
|
100
134
|
: "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
|
|
@@ -207,7 +241,8 @@ export async function sendProactiveMedia(
|
|
|
207
241
|
msgParam = JSON.stringify({ photoURL: mediaId });
|
|
208
242
|
} else if (mediaType === "voice") {
|
|
209
243
|
msgKey = "sampleAudio";
|
|
210
|
-
|
|
244
|
+
const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log);
|
|
245
|
+
msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
|
|
211
246
|
} else {
|
|
212
247
|
// sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
|
|
213
248
|
const filename = path.basename(mediaPath);
|
|
@@ -293,7 +328,8 @@ export async function sendBySession(
|
|
|
293
328
|
if (options.mediaType === "image") {
|
|
294
329
|
body = { msgtype: "image", image: { media_id: mediaId } };
|
|
295
330
|
} else if (options.mediaType === "voice") {
|
|
296
|
-
|
|
331
|
+
const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log);
|
|
332
|
+
body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
|
|
297
333
|
} else if (options.mediaType === "video") {
|
|
298
334
|
body = { msgtype: "video", video: { media_id: mediaId } };
|
|
299
335
|
} else if (options.mediaType === "file") {
|
|
@@ -345,32 +381,42 @@ export async function sendMessage(
|
|
|
345
381
|
config: DingTalkConfig,
|
|
346
382
|
conversationId: string,
|
|
347
383
|
text: string,
|
|
348
|
-
options: SendMessageOptions & { sessionWebhook?: string;
|
|
384
|
+
options: SendMessageOptions & { sessionWebhook?: string; card?: AICardInstance } = {},
|
|
349
385
|
): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
|
|
350
386
|
try {
|
|
351
387
|
const messageType = config.messageType || "markdown";
|
|
352
388
|
const log = options.log || getLogger();
|
|
353
389
|
|
|
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();
|
|
390
|
+
if (messageType === "card" && options.card) {
|
|
391
|
+
const card = options.card;
|
|
392
|
+
if (isCardInTerminalState(card.state)) {
|
|
393
|
+
if (options.sessionWebhook) {
|
|
394
|
+
await sendBySession(config, options.sessionWebhook, text, options);
|
|
395
|
+
return { ok: true };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (config.cardTemplateId) {
|
|
399
|
+
const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
|
|
400
|
+
if (!proactiveResult.ok) {
|
|
401
|
+
return { ok: false, error: proactiveResult.error || "Card send failed" };
|
|
371
402
|
}
|
|
372
|
-
|
|
373
|
-
|
|
403
|
+
return { ok: true };
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
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);
|
|
414
|
+
return { ok: true };
|
|
415
|
+
} catch (err: any) {
|
|
416
|
+
log?.warn?.(`[DingTalk] AI Card streaming failed: ${err.message}`);
|
|
417
|
+
card.state = AICardStatus.FAILED;
|
|
418
|
+
card.lastUpdated = Date.now();
|
|
419
|
+
return { ok: false, error: err.message };
|
|
374
420
|
}
|
|
375
421
|
}
|
|
376
422
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Acquire a per-session lock. Returns a release function that MUST be called
|
|
9
|
+
* (typically in a `finally` block) to unblock the next queued caller.
|
|
10
|
+
*
|
|
11
|
+
* Different session keys run in parallel; same key runs serially.
|
|
12
|
+
*/
|
|
13
|
+
export async function acquireSessionLock(sessionKey: string): Promise<() => void> {
|
|
14
|
+
let release!: () => void;
|
|
15
|
+
const gate = new Promise<void>((r) => {
|
|
16
|
+
release = r;
|
|
17
|
+
});
|
|
18
|
+
const prev = locks.get(sessionKey) ?? Promise.resolve();
|
|
19
|
+
locks.set(sessionKey, gate);
|
|
20
|
+
await prev;
|
|
21
|
+
return () => {
|
|
22
|
+
if (locks.get(sessionKey) === gate) {
|
|
23
|
+
locks.delete(sessionKey);
|
|
24
|
+
}
|
|
25
|
+
release();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Visible for testing only. */
|
|
30
|
+
export function _getLocksMapForTest(): Map<string, Promise<void>> {
|
|
31
|
+
return locks;
|
|
32
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface DingTalkConfig extends OpenClawConfig {
|
|
|
40
40
|
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
41
41
|
groupPolicy?: "open" | "allowlist";
|
|
42
42
|
allowFrom?: string[];
|
|
43
|
+
mediaUrlAllowlist?: string[];
|
|
43
44
|
showThinking?: boolean;
|
|
44
45
|
debug?: boolean;
|
|
45
46
|
messageType?: "markdown" | "card";
|
|
@@ -54,6 +55,10 @@ export interface DingTalkConfig extends OpenClawConfig {
|
|
|
54
55
|
reconnectJitter?: number;
|
|
55
56
|
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
56
57
|
maxReconnectCycles?: number;
|
|
58
|
+
/** Whether to use ConnectionManager; when false, use DWClient native keepAlive+autoReconnect */
|
|
59
|
+
useConnectionManager?: boolean;
|
|
60
|
+
/** Maximum inbound media file size in MB (overrides runtime default when set) */
|
|
61
|
+
mediaMaxMb?: number;
|
|
57
62
|
proactivePermissionHint?: {
|
|
58
63
|
enabled?: boolean;
|
|
59
64
|
cooldownHours?: number;
|
|
@@ -74,6 +79,7 @@ export interface DingTalkChannelConfig {
|
|
|
74
79
|
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
75
80
|
groupPolicy?: "open" | "allowlist";
|
|
76
81
|
allowFrom?: string[];
|
|
82
|
+
mediaUrlAllowlist?: string[];
|
|
77
83
|
showThinking?: boolean;
|
|
78
84
|
debug?: boolean;
|
|
79
85
|
messageType?: "markdown" | "card";
|
|
@@ -87,6 +93,10 @@ export interface DingTalkChannelConfig {
|
|
|
87
93
|
reconnectJitter?: number;
|
|
88
94
|
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
89
95
|
maxReconnectCycles?: number;
|
|
96
|
+
/** Whether to use ConnectionManager; when false, use DWClient native keepAlive+autoReconnect */
|
|
97
|
+
useConnectionManager?: boolean;
|
|
98
|
+
/** Maximum inbound media file size in MB (overrides runtime default when set) */
|
|
99
|
+
mediaMaxMb?: number;
|
|
90
100
|
proactivePermissionHint?: {
|
|
91
101
|
enabled?: boolean;
|
|
92
102
|
cooldownHours?: number;
|
|
@@ -214,6 +224,8 @@ export interface SendMessageOptions {
|
|
|
214
224
|
mediaUrl?: string;
|
|
215
225
|
mediaType?: "image" | "voice" | "video" | "file";
|
|
216
226
|
accountId?: string;
|
|
227
|
+
cardUpdateMode?: "replace" | "append" | "finalize";
|
|
228
|
+
cardFinalize?: boolean;
|
|
217
229
|
}
|
|
218
230
|
|
|
219
231
|
/**
|
|
@@ -446,6 +458,7 @@ export interface AICardInstance {
|
|
|
446
458
|
lastUpdated: number;
|
|
447
459
|
state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, FAILED
|
|
448
460
|
config?: DingTalkConfig; // Store config reference for token refresh
|
|
461
|
+
lastStreamedContent?: string;
|
|
449
462
|
}
|
|
450
463
|
|
|
451
464
|
/**
|
|
@@ -567,6 +580,8 @@ export function resolveDingTalkAccount(
|
|
|
567
580
|
maxReconnectDelay: dingtalk?.maxReconnectDelay,
|
|
568
581
|
reconnectJitter: dingtalk?.reconnectJitter,
|
|
569
582
|
maxReconnectCycles: dingtalk?.maxReconnectCycles,
|
|
583
|
+
useConnectionManager: dingtalk?.useConnectionManager,
|
|
584
|
+
mediaMaxMb: dingtalk?.mediaMaxMb,
|
|
570
585
|
proactivePermissionHint: dingtalk?.proactivePermissionHint,
|
|
571
586
|
};
|
|
572
587
|
return {
|