@soimy/dingtalk 3.1.3 → 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 +3 -2
- package/src/card-service.ts +31 -51
- package/src/channel.ts +331 -96
- 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/channel.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
ChannelMessageActionAdapter,
|
|
5
|
+
OpenClawConfig,
|
|
6
|
+
} from "openclaw/plugin-sdk";
|
|
7
|
+
import * as pluginSdk from "openclaw/plugin-sdk";
|
|
5
8
|
import { getAccessToken } from "./auth";
|
|
6
9
|
import { createAICard, streamAICard, finishAICard } from "./card-service";
|
|
7
10
|
import { getConfig, isConfigured, resolveRelativePath, stripTargetPrefix } from "./config";
|
|
@@ -10,15 +13,10 @@ import { ConnectionManager } from "./connection-manager";
|
|
|
10
13
|
import { isMessageProcessed, markMessageProcessed } from "./dedup";
|
|
11
14
|
import { handleDingTalkMessage } from "./inbound-handler";
|
|
12
15
|
import { getLogger } from "./logger-context";
|
|
16
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
13
17
|
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
14
18
|
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
15
|
-
import {
|
|
16
|
-
detectMediaTypeFromExtension,
|
|
17
|
-
sendMessage,
|
|
18
|
-
sendProactiveMedia,
|
|
19
|
-
sendBySession,
|
|
20
|
-
uploadMedia,
|
|
21
|
-
} from "./send-service";
|
|
19
|
+
import { sendMessage, sendProactiveMedia, sendBySession, uploadMedia } from "./send-service";
|
|
22
20
|
import type {
|
|
23
21
|
DingTalkInboundMessage,
|
|
24
22
|
GatewayStartContext,
|
|
@@ -30,7 +28,8 @@ import type {
|
|
|
30
28
|
import { ConnectionState } from "./types";
|
|
31
29
|
import { cleanupOrphanedTempFiles, formatDingTalkErrorPayloadLog, getCurrentTimestamp } from "./utils";
|
|
32
30
|
|
|
33
|
-
const
|
|
31
|
+
const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
|
|
32
|
+
const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
|
|
34
33
|
const inboundCountersByAccount = new Map<
|
|
35
34
|
string,
|
|
36
35
|
{
|
|
@@ -70,6 +69,132 @@ function logInboundCounters(log: any, accountId: string, reason: string): void {
|
|
|
70
69
|
);
|
|
71
70
|
}
|
|
72
71
|
|
|
72
|
+
function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
73
|
+
const value = params[key];
|
|
74
|
+
if (typeof value === "boolean") {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === "number") {
|
|
78
|
+
if (value === 1) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (value === 0) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (typeof value === "string") {
|
|
87
|
+
const normalized = value.trim().toLowerCase();
|
|
88
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dingtalkMessageActions: ChannelMessageActionAdapter = {
|
|
99
|
+
listActions: () => ["send"],
|
|
100
|
+
supportsAction: ({ action }) => action === "send",
|
|
101
|
+
extractToolSend: ({ args }) => pluginSdk.extractToolSend(args, "sendMessage"),
|
|
102
|
+
handleAction: async ({ action, params, cfg, accountId, dryRun }) => {
|
|
103
|
+
if (action !== "send") {
|
|
104
|
+
throw new Error(`Action ${action} is not supported for provider dingtalk.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const to = pluginSdk.readStringParam(params, "to", { required: true });
|
|
108
|
+
const mediaInput =
|
|
109
|
+
pluginSdk.readStringParam(params, "media", { trim: false }) ??
|
|
110
|
+
pluginSdk.readStringParam(params, "path", { trim: false }) ??
|
|
111
|
+
pluginSdk.readStringParam(params, "filePath", { trim: false }) ??
|
|
112
|
+
pluginSdk.readStringParam(params, "mediaUrl", { trim: false });
|
|
113
|
+
|
|
114
|
+
const hasMedia = Boolean(mediaInput && mediaInput.trim());
|
|
115
|
+
const caption = pluginSdk.readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
|
116
|
+
let message =
|
|
117
|
+
pluginSdk.readStringParam(params, "message", {
|
|
118
|
+
required: !hasMedia,
|
|
119
|
+
allowEmpty: true,
|
|
120
|
+
}) ?? "";
|
|
121
|
+
|
|
122
|
+
if (!message.trim() && caption.trim()) {
|
|
123
|
+
message = caption;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const asVoice = readBooleanLikeParam(params, "asVoice") === true;
|
|
127
|
+
const requestedMediaType = pluginSdk.readStringParam(params, "mediaType");
|
|
128
|
+
|
|
129
|
+
const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
|
|
130
|
+
|
|
131
|
+
if (dryRun) {
|
|
132
|
+
return pluginSdk.jsonResult({
|
|
133
|
+
ok: true,
|
|
134
|
+
dryRun: true,
|
|
135
|
+
to: target,
|
|
136
|
+
hasMedia,
|
|
137
|
+
asVoice,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const log = getLogger();
|
|
142
|
+
const config = getConfig(cfg, accountId ?? undefined);
|
|
143
|
+
|
|
144
|
+
if (hasMedia && mediaInput) {
|
|
145
|
+
const mediaPath = resolveRelativePath(mediaInput);
|
|
146
|
+
const mediaType = resolveOutboundMediaType({
|
|
147
|
+
mediaType: requestedMediaType ?? undefined,
|
|
148
|
+
mediaPath,
|
|
149
|
+
asVoice,
|
|
150
|
+
});
|
|
151
|
+
const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
|
|
152
|
+
log,
|
|
153
|
+
accountId: accountId ?? undefined,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
throw new Error(result.error || "send media failed");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return pluginSdk.jsonResult({
|
|
161
|
+
ok: true,
|
|
162
|
+
to: target,
|
|
163
|
+
mediaType,
|
|
164
|
+
messageId: result.messageId ?? null,
|
|
165
|
+
result: result.data ?? null,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (asVoice) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!message.trim()) {
|
|
176
|
+
throw new Error("send requires message when media is not provided");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = await sendMessage(config, target, message, {
|
|
180
|
+
log,
|
|
181
|
+
accountId: accountId ?? undefined,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
throw new Error(result.error || "send message failed");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const data = result.data as any;
|
|
189
|
+
return pluginSdk.jsonResult({
|
|
190
|
+
ok: true,
|
|
191
|
+
to: target,
|
|
192
|
+
messageId: data?.processQueryKey || data?.messageId || null,
|
|
193
|
+
result: data ?? null,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
73
198
|
// DingTalk Channel Definition (assembly layer).
|
|
74
199
|
// Heavy logic is delegated to service modules for maintainability.
|
|
75
200
|
export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
@@ -82,7 +207,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
82
207
|
blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
|
|
83
208
|
aliases: ["dd", "ding"],
|
|
84
209
|
},
|
|
85
|
-
configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
|
|
210
|
+
configSchema: pluginSdk.buildChannelConfigSchema(DingTalkConfigSchema),
|
|
86
211
|
onboarding: dingtalkOnboardingAdapter,
|
|
87
212
|
capabilities: {
|
|
88
213
|
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
|
@@ -153,6 +278,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
153
278
|
hint: "<conversationId>",
|
|
154
279
|
},
|
|
155
280
|
},
|
|
281
|
+
actions: dingtalkMessageActions,
|
|
156
282
|
outbound: {
|
|
157
283
|
deliveryMode: "direct" as const,
|
|
158
284
|
resolveTarget: ({ to }: any) => {
|
|
@@ -172,20 +298,18 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
172
298
|
try {
|
|
173
299
|
const result = await sendMessage(config, to, text, { log, accountId });
|
|
174
300
|
getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
|
|
175
|
-
if (result.ok) {
|
|
176
|
-
|
|
177
|
-
const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
|
|
178
|
-
return {
|
|
179
|
-
channel: "dingtalk",
|
|
180
|
-
messageId,
|
|
181
|
-
meta: result.data
|
|
182
|
-
? { data: result.data as unknown as Record<string, unknown> }
|
|
183
|
-
: undefined,
|
|
184
|
-
};
|
|
301
|
+
if (!result.ok) {
|
|
302
|
+
throw new Error(result.error || "sendText failed");
|
|
185
303
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
304
|
+
const data = result.data as any;
|
|
305
|
+
const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
|
|
306
|
+
return {
|
|
307
|
+
channel: "dingtalk",
|
|
308
|
+
messageId,
|
|
309
|
+
meta: result.data
|
|
310
|
+
? { data: result.data as unknown as Record<string, unknown> }
|
|
311
|
+
: undefined,
|
|
312
|
+
};
|
|
189
313
|
} catch (err: any) {
|
|
190
314
|
if (err?.response?.data !== undefined) {
|
|
191
315
|
log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
|
|
@@ -205,6 +329,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
205
329
|
filePath,
|
|
206
330
|
mediaUrl,
|
|
207
331
|
mediaType: providedMediaType,
|
|
332
|
+
asVoice,
|
|
208
333
|
accountId,
|
|
209
334
|
log,
|
|
210
335
|
}: any) => {
|
|
@@ -231,18 +356,47 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
231
356
|
);
|
|
232
357
|
}
|
|
233
358
|
|
|
234
|
-
|
|
359
|
+
let preparedMedia;
|
|
360
|
+
try {
|
|
361
|
+
try {
|
|
362
|
+
preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
|
|
363
|
+
} catch (err: any) {
|
|
364
|
+
if (err?.response?.data !== undefined) {
|
|
365
|
+
log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
|
|
366
|
+
}
|
|
367
|
+
const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
|
|
368
|
+
throw new Error(`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`, {
|
|
369
|
+
cause: err,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
235
372
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
373
|
+
const actualMediaPath = preparedMedia.cleanup
|
|
374
|
+
? preparedMedia.path
|
|
375
|
+
: resolveRelativePath(preparedMedia.path);
|
|
239
376
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
377
|
+
getLogger()?.debug?.(
|
|
378
|
+
`[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const mediaType = resolveOutboundMediaType({
|
|
382
|
+
mediaType: typeof providedMediaType === "string" ? providedMediaType : undefined,
|
|
383
|
+
mediaPath: actualMediaPath,
|
|
384
|
+
asVoice: asVoice === true,
|
|
245
385
|
});
|
|
386
|
+
let result;
|
|
387
|
+
try {
|
|
388
|
+
result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
|
|
389
|
+
log,
|
|
390
|
+
accountId,
|
|
391
|
+
});
|
|
392
|
+
} catch (err: any) {
|
|
393
|
+
if (err?.response?.data !== undefined) {
|
|
394
|
+
log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
|
|
397
|
+
cause: err,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
246
400
|
getLogger()?.debug?.(
|
|
247
401
|
`[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
|
|
248
402
|
);
|
|
@@ -273,6 +427,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
273
427
|
: err?.message || "sendMedia failed",
|
|
274
428
|
{ cause: err },
|
|
275
429
|
);
|
|
430
|
+
} finally {
|
|
431
|
+
await preparedMedia?.cleanup?.();
|
|
276
432
|
}
|
|
277
433
|
},
|
|
278
434
|
},
|
|
@@ -288,25 +444,35 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
288
444
|
|
|
289
445
|
cleanupOrphanedTempFiles(ctx.log);
|
|
290
446
|
|
|
447
|
+
const useConnectionManager = config.useConnectionManager ?? true;
|
|
448
|
+
|
|
291
449
|
const client = new DWClient({
|
|
292
450
|
clientId: config.clientId,
|
|
293
451
|
clientSecret: config.clientSecret,
|
|
294
452
|
debug: config.debug || false,
|
|
295
|
-
keepAlive:
|
|
453
|
+
keepAlive: !useConnectionManager,
|
|
296
454
|
});
|
|
297
455
|
|
|
298
|
-
|
|
299
|
-
(client as any).config.autoReconnect = false;
|
|
456
|
+
(client as any).config.autoReconnect = !useConnectionManager;
|
|
300
457
|
|
|
301
458
|
client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
302
459
|
const messageId = res.headers?.messageId;
|
|
303
460
|
const stats = getInboundCounters(account.accountId);
|
|
304
461
|
stats.received += 1;
|
|
305
|
-
|
|
306
|
-
if (messageId) {
|
|
462
|
+
const acknowledge = () => {
|
|
463
|
+
if (!messageId) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
307
467
|
client.socketCallBackResponse(messageId, { success: true });
|
|
308
468
|
stats.acked += 1;
|
|
469
|
+
} catch (ackError: any) {
|
|
470
|
+
ctx.log?.warn?.(
|
|
471
|
+
`[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
|
|
472
|
+
);
|
|
309
473
|
}
|
|
474
|
+
};
|
|
475
|
+
try {
|
|
310
476
|
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
311
477
|
|
|
312
478
|
// Message deduplication key is bot-scoped to avoid cross-account conflicts.
|
|
@@ -326,6 +492,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
326
492
|
dingtalkConfig: config,
|
|
327
493
|
});
|
|
328
494
|
stats.processed += 1;
|
|
495
|
+
acknowledge();
|
|
329
496
|
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
330
497
|
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
331
498
|
}
|
|
@@ -335,20 +502,31 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
335
502
|
if (isMessageProcessed(dedupKey)) {
|
|
336
503
|
ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
|
|
337
504
|
stats.dedupSkipped += 1;
|
|
505
|
+
acknowledge();
|
|
338
506
|
logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
|
|
339
507
|
return;
|
|
340
508
|
}
|
|
341
509
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
510
|
+
const inflightSince = processingDedupKeys.get(dedupKey);
|
|
511
|
+
if (inflightSince !== undefined) {
|
|
512
|
+
if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
|
|
513
|
+
ctx.log?.warn?.(
|
|
514
|
+
`[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
|
|
515
|
+
);
|
|
516
|
+
processingDedupKeys.delete(dedupKey);
|
|
517
|
+
} else {
|
|
518
|
+
ctx.log?.debug?.(
|
|
519
|
+
`[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
|
|
520
|
+
);
|
|
521
|
+
stats.inflightSkipped += 1;
|
|
522
|
+
// Do not acknowledge in-flight duplicates before the original handler succeeds.
|
|
523
|
+
// If the original later fails, early-acking the duplicate can suppress server redelivery.
|
|
524
|
+
logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
349
527
|
}
|
|
350
528
|
|
|
351
|
-
processingDedupKeys.
|
|
529
|
+
processingDedupKeys.set(dedupKey, Date.now());
|
|
352
530
|
try {
|
|
353
531
|
await handleDingTalkMessage({
|
|
354
532
|
cfg,
|
|
@@ -360,6 +538,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
360
538
|
});
|
|
361
539
|
stats.processed += 1;
|
|
362
540
|
markMessageProcessed(dedupKey);
|
|
541
|
+
acknowledge();
|
|
363
542
|
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
364
543
|
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
365
544
|
}
|
|
@@ -375,6 +554,94 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
375
554
|
|
|
376
555
|
// Guard against duplicate stop paths (abort signal + explicit stop).
|
|
377
556
|
let stopped = false;
|
|
557
|
+
let nativeStopResolve: (() => void) | undefined;
|
|
558
|
+
const nativeStopPromise = new Promise<void>((resolve) => {
|
|
559
|
+
nativeStopResolve = resolve;
|
|
560
|
+
});
|
|
561
|
+
let connectionManager: ConnectionManager | undefined;
|
|
562
|
+
|
|
563
|
+
const stopClient = () => {
|
|
564
|
+
if (stopped) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
stopped = true;
|
|
568
|
+
ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
|
|
569
|
+
if (useConnectionManager) {
|
|
570
|
+
connectionManager?.stop();
|
|
571
|
+
} else {
|
|
572
|
+
try {
|
|
573
|
+
client.disconnect();
|
|
574
|
+
} catch (err: any) {
|
|
575
|
+
ctx.log?.warn?.(`[${account.accountId}] Error during disconnect: ${err.message}`);
|
|
576
|
+
}
|
|
577
|
+
nativeStopResolve?.();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
ctx.setStatus({
|
|
581
|
+
...ctx.getStatus(),
|
|
582
|
+
running: false,
|
|
583
|
+
lastStopAt: getCurrentTimestamp(),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
if (abortSignal) {
|
|
590
|
+
if (abortSignal.aborted) {
|
|
591
|
+
ctx.log?.warn?.(
|
|
592
|
+
`[${account.accountId}] Abort signal already active, skipping connection`,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
ctx.setStatus({
|
|
596
|
+
...ctx.getStatus(),
|
|
597
|
+
running: false,
|
|
598
|
+
lastStopAt: getCurrentTimestamp(),
|
|
599
|
+
lastError: "Connection aborted before start",
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
throw new Error("Connection aborted before start");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
abortSignal.addEventListener("abort", () => {
|
|
606
|
+
if (stopped) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
ctx.log?.info?.(
|
|
610
|
+
`[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
|
|
611
|
+
);
|
|
612
|
+
stopClient();
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!useConnectionManager) {
|
|
617
|
+
try {
|
|
618
|
+
await client.connect();
|
|
619
|
+
if (!stopped) {
|
|
620
|
+
ctx.setStatus({
|
|
621
|
+
...ctx.getStatus(),
|
|
622
|
+
running: true,
|
|
623
|
+
lastStartAt: getCurrentTimestamp(),
|
|
624
|
+
lastError: null,
|
|
625
|
+
});
|
|
626
|
+
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
|
|
627
|
+
await nativeStopPromise;
|
|
628
|
+
}
|
|
629
|
+
} catch (err: any) {
|
|
630
|
+
ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
|
|
631
|
+
ctx.setStatus({
|
|
632
|
+
...ctx.getStatus(),
|
|
633
|
+
running: false,
|
|
634
|
+
lastError: err.message || "Connection failed",
|
|
635
|
+
});
|
|
636
|
+
throw err;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
stop: () => {
|
|
641
|
+
stopClient();
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
378
645
|
|
|
379
646
|
const connectionConfig: ConnectionManagerConfig = {
|
|
380
647
|
maxAttempts: config.maxConnectionAttempts ?? 10,
|
|
@@ -397,6 +664,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
397
664
|
lastError: null,
|
|
398
665
|
});
|
|
399
666
|
} else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
|
|
667
|
+
// Clear stale in-flight locks for this account on disconnect.
|
|
668
|
+
// DingTalk will redeliver unacknowledged messages on reconnect; without
|
|
669
|
+
// this cleanup the redelivered messages would be silently skipped forever.
|
|
670
|
+
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
671
|
+
let cleared = 0;
|
|
672
|
+
for (const key of processingDedupKeys.keys()) {
|
|
673
|
+
if (key.startsWith(`${robotKey}:`)) {
|
|
674
|
+
processingDedupKeys.delete(key);
|
|
675
|
+
cleared++;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (cleared > 0) {
|
|
679
|
+
ctx.log?.info?.(
|
|
680
|
+
`[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
400
683
|
ctx.setStatus({
|
|
401
684
|
...ctx.getStatus(),
|
|
402
685
|
running: false,
|
|
@@ -412,48 +695,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
412
695
|
`jitter=${connectionConfig.jitter}`,
|
|
413
696
|
);
|
|
414
697
|
|
|
415
|
-
|
|
698
|
+
connectionManager = new ConnectionManager(
|
|
416
699
|
client,
|
|
417
700
|
account.accountId,
|
|
418
701
|
connectionConfig,
|
|
419
702
|
ctx.log,
|
|
420
703
|
);
|
|
421
704
|
|
|
422
|
-
// Register abort listener before connect() so startup can be cancelled safely.
|
|
423
|
-
if (abortSignal) {
|
|
424
|
-
if (abortSignal.aborted) {
|
|
425
|
-
ctx.log?.warn?.(
|
|
426
|
-
`[${account.accountId}] Abort signal already active, skipping connection`,
|
|
427
|
-
);
|
|
428
|
-
|
|
429
|
-
ctx.setStatus({
|
|
430
|
-
...ctx.getStatus(),
|
|
431
|
-
running: false,
|
|
432
|
-
lastStopAt: getCurrentTimestamp(),
|
|
433
|
-
lastError: "Connection aborted before start",
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
throw new Error("Connection aborted before start");
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
abortSignal.addEventListener("abort", () => {
|
|
440
|
-
if (stopped) {
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
stopped = true;
|
|
444
|
-
ctx.log?.info?.(
|
|
445
|
-
`[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
|
|
446
|
-
);
|
|
447
|
-
connectionManager.stop();
|
|
448
|
-
|
|
449
|
-
ctx.setStatus({
|
|
450
|
-
...ctx.getStatus(),
|
|
451
|
-
running: false,
|
|
452
|
-
lastStopAt: getCurrentTimestamp(),
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
705
|
try {
|
|
458
706
|
await connectionManager.connect();
|
|
459
707
|
|
|
@@ -486,20 +734,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
486
734
|
|
|
487
735
|
return {
|
|
488
736
|
stop: () => {
|
|
489
|
-
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
stopped = true;
|
|
493
|
-
ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
|
|
494
|
-
connectionManager.stop();
|
|
495
|
-
|
|
496
|
-
ctx.setStatus({
|
|
497
|
-
...ctx.getStatus(),
|
|
498
|
-
running: false,
|
|
499
|
-
lastStopAt: getCurrentTimestamp(),
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
|
|
737
|
+
stopClient();
|
|
503
738
|
},
|
|
504
739
|
};
|
|
505
740
|
},
|
package/src/config-schema.ts
CHANGED
|
@@ -31,6 +31,8 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
31
31
|
/** List of allowed user IDs for allowlist policy */
|
|
32
32
|
allowFrom: z.array(z.string()).optional(),
|
|
33
33
|
|
|
34
|
+
mediaUrlAllowlist: z.array(z.string()).optional(),
|
|
35
|
+
|
|
34
36
|
/** Show thinking indicator while processing */
|
|
35
37
|
showThinking: z.boolean().optional().default(true),
|
|
36
38
|
|
|
@@ -47,7 +49,7 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
47
49
|
cardTemplateId: z.string().optional(),
|
|
48
50
|
|
|
49
51
|
/** Card template key for streaming updates
|
|
50
|
-
* Default: '
|
|
52
|
+
* Default: 'content' - maps to the content field in the card template
|
|
51
53
|
* This key is used in the streaming API to update specific fields in the card.
|
|
52
54
|
*/
|
|
53
55
|
cardTemplateKey: z.string().optional().default("content"),
|
|
@@ -79,6 +81,12 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
79
81
|
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
80
82
|
maxReconnectCycles: z.number().int().min(1).optional().default(10),
|
|
81
83
|
|
|
84
|
+
/** Whether to use ConnectionManager (default: true). When false, rely on DWClient native keepAlive+autoReconnect. */
|
|
85
|
+
useConnectionManager: z.boolean().optional().default(true),
|
|
86
|
+
|
|
87
|
+
/** Maximum inbound media file size in MB (overrides runtime default when set) */
|
|
88
|
+
mediaMaxMb: z.number().int().min(1).optional(),
|
|
89
|
+
|
|
82
90
|
proactivePermissionHint: z
|
|
83
91
|
.object({
|
|
84
92
|
enabled: z.boolean().optional().default(true),
|
|
@@ -129,7 +129,20 @@ export class ConnectionManager {
|
|
|
129
129
|
);
|
|
130
130
|
|
|
131
131
|
try {
|
|
132
|
-
//
|
|
132
|
+
// Ensure previous connection resources (heartbeat timers, old sockets) are
|
|
133
|
+
// fully cleaned up before establishing a new connection. The DWClient
|
|
134
|
+
// _connect() method does not clear its internal heartbeat interval, so a
|
|
135
|
+
// stale timer from a prior session can terminate the newly created socket
|
|
136
|
+
// before it finishes the handshake (manifests as code-1006 / "WebSocket was
|
|
137
|
+
// closed before the connection was established").
|
|
138
|
+
try {
|
|
139
|
+
this.client.disconnect();
|
|
140
|
+
} catch (disconnectErr: any) {
|
|
141
|
+
this.log?.debug?.(
|
|
142
|
+
`[${this.accountId}] pre-connect cleanup disconnect failed: ${disconnectErr.message}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
await this.client.connect();
|
|
134
147
|
|
|
135
148
|
// Re-check stopped flag after async connect() completes
|