@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/channel.ts
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
2
|
+
import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
|
|
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
|
-
import {
|
|
7
|
-
import {
|
|
9
|
+
import { analyzeCardCallback } from "./card-callback-service";
|
|
10
|
+
import {
|
|
11
|
+
createAICard,
|
|
12
|
+
streamAICard,
|
|
13
|
+
finishAICard,
|
|
14
|
+
finalizeActiveCardsForAccount,
|
|
15
|
+
recoverPendingCardsForAccount,
|
|
16
|
+
} from "./card-service";
|
|
17
|
+
import {
|
|
18
|
+
getConfig,
|
|
19
|
+
isConfigured,
|
|
20
|
+
mergeAccountWithDefaults,
|
|
21
|
+
resolveRelativePath,
|
|
22
|
+
stripTargetPrefix,
|
|
23
|
+
} from "./config";
|
|
8
24
|
import { DingTalkConfigSchema } from "./config-schema.js";
|
|
9
25
|
import { ConnectionManager } from "./connection-manager";
|
|
10
26
|
import { isMessageProcessed, markMessageProcessed } from "./dedup";
|
|
11
27
|
import { handleDingTalkMessage } from "./inbound-handler";
|
|
12
28
|
import { getLogger } from "./logger-context";
|
|
29
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
13
30
|
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
14
|
-
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
31
|
+
import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
|
|
32
|
+
import { getDingTalkRuntime } from "./runtime";
|
|
33
|
+
import {
|
|
34
|
+
isFeedbackLearningAutoApplyEnabled,
|
|
35
|
+
isFeedbackLearningEnabled,
|
|
36
|
+
recordExplicitFeedbackLearning,
|
|
37
|
+
} from "./feedback-learning-service";
|
|
15
38
|
import {
|
|
16
|
-
detectMediaTypeFromExtension,
|
|
17
39
|
sendMessage,
|
|
18
40
|
sendProactiveMedia,
|
|
41
|
+
sendProactiveTextOrMarkdown,
|
|
19
42
|
sendBySession,
|
|
20
43
|
uploadMedia,
|
|
21
44
|
} from "./send-service";
|
|
@@ -26,11 +49,87 @@ import type {
|
|
|
26
49
|
ConnectionManagerConfig,
|
|
27
50
|
DingTalkChannelPlugin,
|
|
28
51
|
ResolvedAccount,
|
|
52
|
+
StreamClientFactory,
|
|
29
53
|
} from "./types";
|
|
30
54
|
import { ConnectionState } from "./types";
|
|
31
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
cleanupOrphanedTempFiles,
|
|
57
|
+
createResolve4FallbackLookup,
|
|
58
|
+
formatDingTalkConnectionErrorLog,
|
|
59
|
+
formatDingTalkErrorPayloadLog,
|
|
60
|
+
getCurrentTimestamp,
|
|
61
|
+
} from "./utils";
|
|
62
|
+
|
|
63
|
+
type InstrumentedDWClient = {
|
|
64
|
+
getEndpoint?: () => Promise<unknown>;
|
|
65
|
+
_connect?: () => Promise<unknown>;
|
|
66
|
+
config?: Record<string, unknown> & { endpoint?: { endpoint?: string } | string };
|
|
67
|
+
dw_url?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function attachConnectionErrorContext(
|
|
71
|
+
err: unknown,
|
|
72
|
+
stage: "connect.open" | "connect.websocket",
|
|
73
|
+
endpoint?: string,
|
|
74
|
+
): void {
|
|
75
|
+
if (!err || typeof err !== "object") {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const target = err as Record<string, unknown>;
|
|
79
|
+
if (typeof target.dingtalkConnectionStage !== "string") {
|
|
80
|
+
target.dingtalkConnectionStage = stage;
|
|
81
|
+
}
|
|
82
|
+
if (endpoint && typeof target.dingtalkConnectionEndpoint !== "string") {
|
|
83
|
+
target.dingtalkConnectionEndpoint = endpoint;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefined {
|
|
88
|
+
if (typeof client.dw_url === "string" && client.dw_url.length > 0) {
|
|
89
|
+
return client.dw_url;
|
|
90
|
+
}
|
|
32
91
|
|
|
33
|
-
const
|
|
92
|
+
const endpointConfig = client.config?.endpoint;
|
|
93
|
+
if (typeof endpointConfig === "string") {
|
|
94
|
+
return endpointConfig;
|
|
95
|
+
}
|
|
96
|
+
if (endpointConfig && typeof endpointConfig === "object" && typeof endpointConfig.endpoint === "string") {
|
|
97
|
+
return endpointConfig.endpoint;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function instrumentConnectionStages(client: DWClient): void {
|
|
103
|
+
const instrumented = client as unknown as InstrumentedDWClient;
|
|
104
|
+
if (typeof instrumented.getEndpoint !== "function" || typeof instrumented._connect !== "function") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const originalGetEndpoint = instrumented.getEndpoint.bind(instrumented);
|
|
109
|
+
const originalSocketConnect = instrumented._connect.bind(instrumented);
|
|
110
|
+
|
|
111
|
+
instrumented.getEndpoint = async () => {
|
|
112
|
+
try {
|
|
113
|
+
return await originalGetEndpoint();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
attachConnectionErrorContext(err, "connect.open");
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
instrumented._connect = async () => {
|
|
121
|
+
try {
|
|
122
|
+
return await originalSocketConnect();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
attachConnectionErrorContext(err, "connect.websocket", getInstrumentedEndpoint(instrumented));
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
|
|
131
|
+
const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
|
|
132
|
+
export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
|
|
34
133
|
const inboundCountersByAccount = new Map<
|
|
35
134
|
string,
|
|
36
135
|
{
|
|
@@ -70,6 +169,140 @@ function logInboundCounters(log: any, accountId: string, reason: string): void {
|
|
|
70
169
|
);
|
|
71
170
|
}
|
|
72
171
|
|
|
172
|
+
function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
173
|
+
const value = params[key];
|
|
174
|
+
if (typeof value === "boolean") {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
if (typeof value === "number") {
|
|
178
|
+
if (value === 1) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (value === 0) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (typeof value === "string") {
|
|
187
|
+
const normalized = value.trim().toLowerCase();
|
|
188
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const dingtalkMessageActions: ChannelMessageActionAdapter = {
|
|
199
|
+
listActions: () => ["send"],
|
|
200
|
+
supportsAction: ({ action }) => action === "send",
|
|
201
|
+
extractToolSend: ({ args }) => pluginSdk.extractToolSend(args, "sendMessage"),
|
|
202
|
+
handleAction: async ({ action, params, cfg, accountId, dryRun }) => {
|
|
203
|
+
if (action !== "send") {
|
|
204
|
+
throw new Error(`Action ${action} is not supported for provider dingtalk.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const to = pluginSdk.readStringParam(params, "to", { required: true });
|
|
208
|
+
const mediaInput =
|
|
209
|
+
pluginSdk.readStringParam(params, "media", { trim: false }) ??
|
|
210
|
+
pluginSdk.readStringParam(params, "path", { trim: false }) ??
|
|
211
|
+
pluginSdk.readStringParam(params, "filePath", { trim: false }) ??
|
|
212
|
+
pluginSdk.readStringParam(params, "mediaUrl", { trim: false });
|
|
213
|
+
|
|
214
|
+
const hasMedia = Boolean(mediaInput && mediaInput.trim());
|
|
215
|
+
const caption = pluginSdk.readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
|
216
|
+
let message =
|
|
217
|
+
pluginSdk.readStringParam(params, "message", {
|
|
218
|
+
required: !hasMedia,
|
|
219
|
+
allowEmpty: true,
|
|
220
|
+
}) ?? "";
|
|
221
|
+
|
|
222
|
+
if (!message.trim() && caption.trim()) {
|
|
223
|
+
message = caption;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const asVoice = readBooleanLikeParam(params, "asVoice") === true;
|
|
227
|
+
const requestedMediaType = pluginSdk.readStringParam(params, "mediaType");
|
|
228
|
+
|
|
229
|
+
const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
|
|
230
|
+
|
|
231
|
+
if (dryRun) {
|
|
232
|
+
return pluginSdk.jsonResult({
|
|
233
|
+
ok: true,
|
|
234
|
+
dryRun: true,
|
|
235
|
+
to: target,
|
|
236
|
+
hasMedia,
|
|
237
|
+
asVoice,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const log = getLogger();
|
|
242
|
+
const config = getConfig(cfg, accountId ?? undefined);
|
|
243
|
+
|
|
244
|
+
if (hasMedia && mediaInput) {
|
|
245
|
+
let preparedMedia;
|
|
246
|
+
try {
|
|
247
|
+
preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
|
|
248
|
+
const mediaPath = preparedMedia.cleanup
|
|
249
|
+
? preparedMedia.path
|
|
250
|
+
: resolveRelativePath(preparedMedia.path);
|
|
251
|
+
const mediaType = resolveOutboundMediaType({
|
|
252
|
+
mediaType: requestedMediaType ?? undefined,
|
|
253
|
+
mediaPath,
|
|
254
|
+
asVoice,
|
|
255
|
+
});
|
|
256
|
+
const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
|
|
257
|
+
log,
|
|
258
|
+
accountId: accountId ?? undefined,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (!result.ok) {
|
|
262
|
+
throw new Error(result.error || "send media failed");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return pluginSdk.jsonResult({
|
|
266
|
+
ok: true,
|
|
267
|
+
to: target,
|
|
268
|
+
mediaType,
|
|
269
|
+
messageId: result.messageId ?? null,
|
|
270
|
+
result: result.data ?? null,
|
|
271
|
+
});
|
|
272
|
+
} finally {
|
|
273
|
+
await preparedMedia?.cleanup?.();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (asVoice) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!message.trim()) {
|
|
284
|
+
throw new Error("send requires message when media is not provided");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const result = await sendMessage(config, target, message, {
|
|
288
|
+
log,
|
|
289
|
+
accountId: accountId ?? undefined,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!result.ok) {
|
|
293
|
+
throw new Error(result.error || "send message failed");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const data = result.data as any;
|
|
297
|
+
return pluginSdk.jsonResult({
|
|
298
|
+
ok: true,
|
|
299
|
+
to: target,
|
|
300
|
+
messageId: data?.processQueryKey || data?.messageId || null,
|
|
301
|
+
result: data ?? null,
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
73
306
|
// DingTalk Channel Definition (assembly layer).
|
|
74
307
|
// Heavy logic is delegated to service modules for maintainability.
|
|
75
308
|
export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
@@ -82,7 +315,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
82
315
|
blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
|
|
83
316
|
aliases: ["dd", "ding"],
|
|
84
317
|
},
|
|
85
|
-
configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
|
|
318
|
+
configSchema: pluginSdk.buildChannelConfigSchema(DingTalkConfigSchema),
|
|
86
319
|
onboarding: dingtalkOnboardingAdapter,
|
|
87
320
|
capabilities: {
|
|
88
321
|
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
|
@@ -106,7 +339,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
106
339
|
const config = getConfig(cfg);
|
|
107
340
|
const id = accountId || "default";
|
|
108
341
|
const account = config.accounts?.[id];
|
|
109
|
-
const resolvedConfig = account
|
|
342
|
+
const resolvedConfig = account
|
|
343
|
+
? mergeAccountWithDefaults(config, account)
|
|
344
|
+
: config;
|
|
110
345
|
const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
|
|
111
346
|
return {
|
|
112
347
|
accountId: id,
|
|
@@ -153,6 +388,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
153
388
|
hint: "<conversationId>",
|
|
154
389
|
},
|
|
155
390
|
},
|
|
391
|
+
actions: dingtalkMessageActions,
|
|
156
392
|
outbound: {
|
|
157
393
|
deliveryMode: "direct" as const,
|
|
158
394
|
resolveTarget: ({ to }: any) => {
|
|
@@ -169,23 +405,35 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
169
405
|
},
|
|
170
406
|
sendText: async ({ cfg, to, text, accountId, log }: any) => {
|
|
171
407
|
const config = getConfig(cfg, accountId);
|
|
408
|
+
const rt = getDingTalkRuntime();
|
|
409
|
+
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
410
|
+
agentId: accountId,
|
|
411
|
+
});
|
|
172
412
|
try {
|
|
173
|
-
const result = await sendMessage(config, to, text, {
|
|
413
|
+
const result = await sendMessage(config, to, text, {
|
|
414
|
+
log,
|
|
415
|
+
accountId,
|
|
416
|
+
storePath,
|
|
417
|
+
conversationId: to,
|
|
418
|
+
});
|
|
174
419
|
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
|
-
};
|
|
420
|
+
if (!result.ok) {
|
|
421
|
+
throw new Error(result.error || "sendText failed");
|
|
185
422
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
423
|
+
const data = result.data as any;
|
|
424
|
+
const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
|
|
425
|
+
const meta =
|
|
426
|
+
result.data || result.tracking
|
|
427
|
+
? {
|
|
428
|
+
...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
|
|
429
|
+
...(result.tracking ? { tracking: result.tracking } : {}),
|
|
430
|
+
}
|
|
431
|
+
: undefined;
|
|
432
|
+
return {
|
|
433
|
+
channel: "dingtalk",
|
|
434
|
+
messageId,
|
|
435
|
+
meta,
|
|
436
|
+
};
|
|
189
437
|
} catch (err: any) {
|
|
190
438
|
if (err?.response?.data !== undefined) {
|
|
191
439
|
log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
|
|
@@ -205,10 +453,15 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
205
453
|
filePath,
|
|
206
454
|
mediaUrl,
|
|
207
455
|
mediaType: providedMediaType,
|
|
456
|
+
asVoice,
|
|
208
457
|
accountId,
|
|
209
458
|
log,
|
|
210
459
|
}: any) => {
|
|
211
460
|
const config = getConfig(cfg, accountId);
|
|
461
|
+
const rt = getDingTalkRuntime();
|
|
462
|
+
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
463
|
+
agentId: accountId,
|
|
464
|
+
});
|
|
212
465
|
if (!config.clientId) {
|
|
213
466
|
throw new Error("DingTalk not configured");
|
|
214
467
|
}
|
|
@@ -231,18 +484,49 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
231
484
|
);
|
|
232
485
|
}
|
|
233
486
|
|
|
234
|
-
|
|
487
|
+
let preparedMedia;
|
|
488
|
+
try {
|
|
489
|
+
try {
|
|
490
|
+
preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
|
|
491
|
+
} catch (err: any) {
|
|
492
|
+
if (err?.response?.data !== undefined) {
|
|
493
|
+
log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
|
|
494
|
+
}
|
|
495
|
+
const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
|
|
496
|
+
throw new Error(`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`, {
|
|
497
|
+
cause: err,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const actualMediaPath = preparedMedia.cleanup
|
|
502
|
+
? preparedMedia.path
|
|
503
|
+
: resolveRelativePath(preparedMedia.path);
|
|
235
504
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
505
|
+
getLogger()?.debug?.(
|
|
506
|
+
`[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
|
|
507
|
+
);
|
|
239
508
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
accountId,
|
|
509
|
+
const mediaType = resolveOutboundMediaType({
|
|
510
|
+
mediaType: typeof providedMediaType === "string" ? providedMediaType : undefined,
|
|
511
|
+
mediaPath: actualMediaPath,
|
|
512
|
+
asVoice: asVoice === true,
|
|
245
513
|
});
|
|
514
|
+
let result;
|
|
515
|
+
try {
|
|
516
|
+
result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
|
|
517
|
+
log,
|
|
518
|
+
accountId,
|
|
519
|
+
storePath,
|
|
520
|
+
conversationId: to,
|
|
521
|
+
});
|
|
522
|
+
} catch (err: any) {
|
|
523
|
+
if (err?.response?.data !== undefined) {
|
|
524
|
+
log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
|
|
525
|
+
}
|
|
526
|
+
throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
|
|
527
|
+
cause: err,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
246
530
|
getLogger()?.debug?.(
|
|
247
531
|
`[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
|
|
248
532
|
);
|
|
@@ -273,6 +557,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
273
557
|
: err?.message || "sendMedia failed",
|
|
274
558
|
{ cause: err },
|
|
275
559
|
);
|
|
560
|
+
} finally {
|
|
561
|
+
await preparedMedia?.cleanup?.();
|
|
276
562
|
}
|
|
277
563
|
},
|
|
278
564
|
},
|
|
@@ -283,110 +569,336 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
283
569
|
if (!config.clientId || !config.clientSecret) {
|
|
284
570
|
throw new Error("DingTalk clientId and clientSecret are required");
|
|
285
571
|
}
|
|
572
|
+
let accountStorePath: string | undefined;
|
|
573
|
+
try {
|
|
574
|
+
const rt = getDingTalkRuntime();
|
|
575
|
+
accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
576
|
+
agentId: account.accountId,
|
|
577
|
+
});
|
|
578
|
+
} catch {
|
|
579
|
+
accountStorePath = undefined;
|
|
580
|
+
}
|
|
286
581
|
|
|
287
582
|
ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
|
|
288
583
|
|
|
584
|
+
// Preload known peer IDs from sessions so outbound delivery (e.g. cron
|
|
585
|
+
// jobs that fire immediately after startup) can resolve the original
|
|
586
|
+
// case-sensitive conversationId before any inbound message has arrived.
|
|
587
|
+
preloadPeerIdsFromSessions();
|
|
588
|
+
ctx.log?.debug?.(`[${account.accountId}] Peer ID registry preloaded from sessions`);
|
|
589
|
+
|
|
289
590
|
cleanupOrphanedTempFiles(ctx.log);
|
|
591
|
+
try {
|
|
592
|
+
const recovered = await recoverPendingCardsForAccount(
|
|
593
|
+
config,
|
|
594
|
+
account.accountId,
|
|
595
|
+
accountStorePath,
|
|
596
|
+
ctx.log,
|
|
597
|
+
);
|
|
598
|
+
if (recovered > 0) {
|
|
599
|
+
ctx.log?.info?.(
|
|
600
|
+
`[${account.accountId}] Recovered and finalized ${recovered} unfinished card(s) from previous runtime`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
} catch (err: any) {
|
|
604
|
+
ctx.log?.warn?.(
|
|
605
|
+
`[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
290
608
|
|
|
291
|
-
const
|
|
292
|
-
clientId: config.clientId,
|
|
293
|
-
clientSecret: config.clientSecret,
|
|
294
|
-
debug: config.debug || false,
|
|
295
|
-
keepAlive: false,
|
|
296
|
-
});
|
|
609
|
+
const useConnectionManager = config.useConnectionManager ?? true;
|
|
297
610
|
|
|
298
|
-
//
|
|
299
|
-
|
|
611
|
+
// Factory that creates a fresh DWClient with the TOPIC_ROBOT callback
|
|
612
|
+
// already registered. Each client captures its own reference for
|
|
613
|
+
// socketCallBackResponse so acks are sent on the correct socket.
|
|
614
|
+
// ConnectionManager uses this to create new clients during warm
|
|
615
|
+
// reconnection, minimizing the message-loss window when the DingTalk
|
|
616
|
+
// server initiates a disconnect for load balancing.
|
|
617
|
+
const createStreamClient: StreamClientFactory = () => {
|
|
618
|
+
const c = new DWClient({
|
|
619
|
+
clientId: config.clientId,
|
|
620
|
+
clientSecret: config.clientSecret,
|
|
621
|
+
debug: config.debug || false,
|
|
622
|
+
keepAlive: config.keepAlive ?? !useConnectionManager,
|
|
623
|
+
});
|
|
624
|
+
(c as any).sslopts = {
|
|
625
|
+
...(c as any).sslopts,
|
|
626
|
+
lookup: createResolve4FallbackLookup(ctx.log, account.accountId),
|
|
627
|
+
};
|
|
300
628
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
629
|
+
instrumentConnectionStages(c);
|
|
630
|
+
|
|
631
|
+
(c as any).config.autoReconnect = !useConnectionManager;
|
|
632
|
+
|
|
633
|
+
c.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
634
|
+
const messageId = res.headers?.messageId;
|
|
635
|
+
const stats = getInboundCounters(account.accountId);
|
|
636
|
+
stats.received += 1;
|
|
637
|
+
const acknowledge = () => {
|
|
638
|
+
if (!messageId) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
c.socketCallBackResponse(messageId, { success: true });
|
|
643
|
+
stats.acked += 1;
|
|
644
|
+
} catch (ackError: any) {
|
|
645
|
+
ctx.log?.warn?.(
|
|
646
|
+
`[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
try {
|
|
651
|
+
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
652
|
+
|
|
653
|
+
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
654
|
+
const msgId = data.msgId || messageId;
|
|
655
|
+
const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
|
|
656
|
+
|
|
657
|
+
if (!dedupKey) {
|
|
658
|
+
ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
|
|
659
|
+
stats.noMessageId += 1;
|
|
660
|
+
await handleDingTalkMessage({
|
|
661
|
+
cfg,
|
|
662
|
+
accountId: account.accountId,
|
|
663
|
+
data,
|
|
664
|
+
sessionWebhook: data.sessionWebhook,
|
|
665
|
+
log: ctx.log,
|
|
666
|
+
dingtalkConfig: config,
|
|
667
|
+
});
|
|
668
|
+
stats.processed += 1;
|
|
669
|
+
acknowledge();
|
|
670
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
671
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (isMessageProcessed(dedupKey)) {
|
|
677
|
+
ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
|
|
678
|
+
stats.dedupSkipped += 1;
|
|
679
|
+
acknowledge();
|
|
680
|
+
logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const inflightSince = processingDedupKeys.get(dedupKey);
|
|
685
|
+
if (inflightSince !== undefined) {
|
|
686
|
+
if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
|
|
687
|
+
ctx.log?.warn?.(
|
|
688
|
+
`[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
|
|
689
|
+
);
|
|
690
|
+
processingDedupKeys.delete(dedupKey);
|
|
691
|
+
} else {
|
|
692
|
+
ctx.log?.debug?.(
|
|
693
|
+
`[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
|
|
694
|
+
);
|
|
695
|
+
stats.inflightSkipped += 1;
|
|
696
|
+
logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
processingDedupKeys.set(dedupKey, Date.now());
|
|
702
|
+
try {
|
|
703
|
+
await handleDingTalkMessage({
|
|
704
|
+
cfg,
|
|
705
|
+
accountId: account.accountId,
|
|
706
|
+
data,
|
|
707
|
+
sessionWebhook: data.sessionWebhook,
|
|
708
|
+
log: ctx.log,
|
|
709
|
+
dingtalkConfig: config,
|
|
710
|
+
});
|
|
711
|
+
stats.processed += 1;
|
|
712
|
+
markMessageProcessed(dedupKey);
|
|
713
|
+
acknowledge();
|
|
714
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
715
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
716
|
+
}
|
|
717
|
+
} finally {
|
|
718
|
+
processingDedupKeys.delete(dedupKey);
|
|
719
|
+
}
|
|
720
|
+
} catch (error: any) {
|
|
721
|
+
stats.failed += 1;
|
|
722
|
+
logInboundCounters(ctx.log, account.accountId, "failed");
|
|
723
|
+
ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
|
|
308
724
|
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
c.registerCallbackListener(TOPIC_CARD, async (res: any) => {
|
|
728
|
+
const messageId = res.headers?.messageId;
|
|
729
|
+
const acknowledge = () => {
|
|
730
|
+
if (!messageId) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
c.socketCallBackResponse(messageId, { success: true });
|
|
735
|
+
} catch (ackError: any) {
|
|
736
|
+
ctx.log?.warn?.(
|
|
737
|
+
`[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
309
742
|
try {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
`[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
|
|
743
|
+
const payload = JSON.parse(res.data);
|
|
744
|
+
const analysis = analyzeCardCallback(payload);
|
|
745
|
+
ctx.log?.info?.(
|
|
746
|
+
`[${account.accountId}] [DingTalk][CardCallback] action=${analysis.summary} raw=${JSON.stringify(payload)}`,
|
|
315
747
|
);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
748
|
+
|
|
749
|
+
if (analysis.feedbackTarget && analysis.feedbackAckText) {
|
|
750
|
+
recordExplicitFeedbackLearning({
|
|
751
|
+
enabled: isFeedbackLearningEnabled(config),
|
|
752
|
+
autoApply: isFeedbackLearningAutoApplyEnabled(config),
|
|
753
|
+
storePath: accountStorePath,
|
|
754
|
+
accountId: account.accountId,
|
|
755
|
+
targetId: analysis.feedbackTarget,
|
|
756
|
+
feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
|
|
757
|
+
userId: analysis.userId,
|
|
758
|
+
processQueryKey: analysis.processQueryKey,
|
|
759
|
+
noteTtlMs: config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs,
|
|
760
|
+
});
|
|
761
|
+
try {
|
|
762
|
+
await sendProactiveTextOrMarkdown(
|
|
763
|
+
config,
|
|
764
|
+
analysis.feedbackTarget,
|
|
765
|
+
analysis.feedbackAckText,
|
|
766
|
+
{
|
|
767
|
+
accountId: account.accountId,
|
|
768
|
+
log: ctx.log,
|
|
769
|
+
},
|
|
770
|
+
);
|
|
771
|
+
ctx.log?.info?.(
|
|
772
|
+
`[${account.accountId}] [DingTalk][CardCallback] feedback ack sent to ${analysis.feedbackTarget}`,
|
|
773
|
+
);
|
|
774
|
+
} catch (sendErr: any) {
|
|
775
|
+
ctx.log?.warn?.(
|
|
776
|
+
`[${account.accountId}] [DingTalk][CardCallback] Failed to send feedback ack: ${sendErr?.message || String(sendErr)}`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
341
779
|
}
|
|
342
|
-
|
|
780
|
+
} catch (error: any) {
|
|
781
|
+
ctx.log?.error?.(
|
|
782
|
+
`[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
|
|
783
|
+
);
|
|
784
|
+
} finally {
|
|
785
|
+
acknowledge();
|
|
343
786
|
}
|
|
787
|
+
});
|
|
344
788
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
789
|
+
return c;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const client = createStreamClient();
|
|
793
|
+
|
|
794
|
+
// Guard against duplicate stop paths (abort signal + explicit stop).
|
|
795
|
+
let stopped = false;
|
|
796
|
+
let nativeStopResolve: (() => void) | undefined;
|
|
797
|
+
const nativeStopPromise = new Promise<void>((resolve) => {
|
|
798
|
+
nativeStopResolve = resolve;
|
|
799
|
+
});
|
|
800
|
+
let connectionManager: ConnectionManager | undefined;
|
|
801
|
+
|
|
802
|
+
const stopClient = () => {
|
|
803
|
+
if (stopped) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
stopped = true;
|
|
807
|
+
ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
|
|
808
|
+
void finalizeActiveCardsForAccount(
|
|
809
|
+
config,
|
|
810
|
+
account.accountId,
|
|
811
|
+
"⚠️ 服务正在重启,当前回复已中断。请重新发送你的问题。",
|
|
812
|
+
accountStorePath,
|
|
813
|
+
ctx.log,
|
|
814
|
+
).catch((err: any) => {
|
|
815
|
+
ctx.log?.debug?.(
|
|
816
|
+
`[${account.accountId}] Failed to finalize active cards during stop: ${err.message}`,
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
if (useConnectionManager) {
|
|
820
|
+
connectionManager?.stop();
|
|
821
|
+
} else {
|
|
822
|
+
try {
|
|
823
|
+
client.disconnect();
|
|
824
|
+
} catch (err: any) {
|
|
825
|
+
ctx.log?.warn?.(`[${account.accountId}] Error during disconnect: ${err.message}`);
|
|
351
826
|
}
|
|
827
|
+
nativeStopResolve?.();
|
|
828
|
+
}
|
|
352
829
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
830
|
+
ctx.setStatus({
|
|
831
|
+
...ctx.getStatus(),
|
|
832
|
+
running: false,
|
|
833
|
+
lastStopAt: getCurrentTimestamp(),
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
if (abortSignal) {
|
|
840
|
+
if (abortSignal.aborted) {
|
|
841
|
+
ctx.log?.warn?.(
|
|
842
|
+
`[${account.accountId}] Abort signal already active, skipping connection`,
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
ctx.setStatus({
|
|
846
|
+
...ctx.getStatus(),
|
|
847
|
+
running: false,
|
|
848
|
+
lastStopAt: getCurrentTimestamp(),
|
|
849
|
+
lastError: "Connection aborted before start",
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
throw new Error("Connection aborted before start");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
abortSignal.addEventListener("abort", () => {
|
|
856
|
+
if (stopped) {
|
|
359
857
|
return;
|
|
360
858
|
}
|
|
859
|
+
ctx.log?.info?.(
|
|
860
|
+
`[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
|
|
861
|
+
);
|
|
862
|
+
stopClient();
|
|
863
|
+
});
|
|
864
|
+
}
|
|
361
865
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
866
|
+
if (!useConnectionManager) {
|
|
867
|
+
try {
|
|
868
|
+
await client.connect();
|
|
869
|
+
if (!stopped) {
|
|
870
|
+
ctx.setStatus({
|
|
871
|
+
...ctx.getStatus(),
|
|
872
|
+
running: true,
|
|
873
|
+
lastStartAt: getCurrentTimestamp(),
|
|
874
|
+
lastError: null,
|
|
371
875
|
});
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
acknowledge();
|
|
375
|
-
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
376
|
-
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
377
|
-
}
|
|
378
|
-
} finally {
|
|
379
|
-
processingDedupKeys.delete(dedupKey);
|
|
876
|
+
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
|
|
877
|
+
await nativeStopPromise;
|
|
380
878
|
}
|
|
381
|
-
} catch (
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
879
|
+
} catch (err: any) {
|
|
880
|
+
ctx.log?.error?.(
|
|
881
|
+
formatDingTalkConnectionErrorLog(
|
|
882
|
+
// Use connect.open as base scope; instrumentation can override to connect.websocket
|
|
883
|
+
"connect.open",
|
|
884
|
+
err,
|
|
885
|
+
`[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
886
|
+
) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
887
|
+
);
|
|
888
|
+
ctx.setStatus({
|
|
889
|
+
...ctx.getStatus(),
|
|
890
|
+
running: false,
|
|
891
|
+
lastError: err.message || "Connection failed",
|
|
892
|
+
});
|
|
893
|
+
throw err;
|
|
385
894
|
}
|
|
386
|
-
});
|
|
387
895
|
|
|
388
|
-
|
|
389
|
-
|
|
896
|
+
return {
|
|
897
|
+
stop: () => {
|
|
898
|
+
stopClient();
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
}
|
|
390
902
|
|
|
391
903
|
const connectionConfig: ConnectionManagerConfig = {
|
|
392
904
|
maxAttempts: config.maxConnectionAttempts ?? 10,
|
|
@@ -394,6 +906,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
394
906
|
maxDelay: config.maxReconnectDelay ?? 60000,
|
|
395
907
|
jitter: config.reconnectJitter ?? 0.3,
|
|
396
908
|
maxReconnectCycles: config.maxReconnectCycles,
|
|
909
|
+
reconnectDeadlineMs: config.reconnectDeadlineMs,
|
|
397
910
|
onStateChange: (state: ConnectionState, error?: string) => {
|
|
398
911
|
if (stopped) {
|
|
399
912
|
return;
|
|
@@ -409,6 +922,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
409
922
|
lastError: null,
|
|
410
923
|
});
|
|
411
924
|
} else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
|
|
925
|
+
// Clear stale in-flight locks for this account on disconnect.
|
|
926
|
+
// DingTalk will redeliver unacknowledged messages on reconnect; without
|
|
927
|
+
// this cleanup the redelivered messages would be silently skipped forever.
|
|
928
|
+
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
929
|
+
let cleared = 0;
|
|
930
|
+
for (const key of processingDedupKeys.keys()) {
|
|
931
|
+
if (key.startsWith(`${robotKey}:`)) {
|
|
932
|
+
processingDedupKeys.delete(key);
|
|
933
|
+
cleared++;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (cleared > 0) {
|
|
937
|
+
ctx.log?.info?.(
|
|
938
|
+
`[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
412
941
|
ctx.setStatus({
|
|
413
942
|
...ctx.getStatus(),
|
|
414
943
|
running: false,
|
|
@@ -424,48 +953,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
424
953
|
`jitter=${connectionConfig.jitter}`,
|
|
425
954
|
);
|
|
426
955
|
|
|
427
|
-
|
|
956
|
+
connectionManager = new ConnectionManager(
|
|
428
957
|
client,
|
|
429
958
|
account.accountId,
|
|
430
959
|
connectionConfig,
|
|
431
960
|
ctx.log,
|
|
961
|
+
createStreamClient,
|
|
432
962
|
);
|
|
433
963
|
|
|
434
|
-
// Register abort listener before connect() so startup can be cancelled safely.
|
|
435
|
-
if (abortSignal) {
|
|
436
|
-
if (abortSignal.aborted) {
|
|
437
|
-
ctx.log?.warn?.(
|
|
438
|
-
`[${account.accountId}] Abort signal already active, skipping connection`,
|
|
439
|
-
);
|
|
440
|
-
|
|
441
|
-
ctx.setStatus({
|
|
442
|
-
...ctx.getStatus(),
|
|
443
|
-
running: false,
|
|
444
|
-
lastStopAt: getCurrentTimestamp(),
|
|
445
|
-
lastError: "Connection aborted before start",
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
throw new Error("Connection aborted before start");
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
abortSignal.addEventListener("abort", () => {
|
|
452
|
-
if (stopped) {
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
stopped = true;
|
|
456
|
-
ctx.log?.info?.(
|
|
457
|
-
`[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
|
|
458
|
-
);
|
|
459
|
-
connectionManager.stop();
|
|
460
|
-
|
|
461
|
-
ctx.setStatus({
|
|
462
|
-
...ctx.getStatus(),
|
|
463
|
-
running: false,
|
|
464
|
-
lastStopAt: getCurrentTimestamp(),
|
|
465
|
-
});
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
|
|
469
964
|
try {
|
|
470
965
|
await connectionManager.connect();
|
|
471
966
|
|
|
@@ -486,7 +981,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
486
981
|
);
|
|
487
982
|
}
|
|
488
983
|
} catch (err: any) {
|
|
489
|
-
ctx.log?.error?.(
|
|
984
|
+
ctx.log?.error?.(
|
|
985
|
+
formatDingTalkConnectionErrorLog(
|
|
986
|
+
// Use connect.open as base scope; instrumentation can override to connect.websocket
|
|
987
|
+
"connect.open",
|
|
988
|
+
err,
|
|
989
|
+
`[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
990
|
+
) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
991
|
+
);
|
|
490
992
|
|
|
491
993
|
ctx.setStatus({
|
|
492
994
|
...ctx.getStatus(),
|
|
@@ -498,20 +1000,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
498
1000
|
|
|
499
1001
|
return {
|
|
500
1002
|
stop: () => {
|
|
501
|
-
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
stopped = true;
|
|
505
|
-
ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
|
|
506
|
-
connectionManager.stop();
|
|
507
|
-
|
|
508
|
-
ctx.setStatus({
|
|
509
|
-
...ctx.getStatus(),
|
|
510
|
-
running: false,
|
|
511
|
-
lastStopAt: getCurrentTimestamp(),
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
|
|
1003
|
+
stopClient();
|
|
515
1004
|
},
|
|
516
1005
|
};
|
|
517
1006
|
},
|
|
@@ -520,6 +1009,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
520
1009
|
defaultRuntime: {
|
|
521
1010
|
accountId: "default",
|
|
522
1011
|
running: false,
|
|
1012
|
+
lastEventAt: null,
|
|
523
1013
|
lastStartAt: null,
|
|
524
1014
|
lastStopAt: null,
|
|
525
1015
|
lastError: null,
|
|
@@ -565,18 +1055,24 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
565
1055
|
return { ok: false, error: error.message };
|
|
566
1056
|
}
|
|
567
1057
|
},
|
|
568
|
-
buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) =>
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1058
|
+
buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => {
|
|
1059
|
+
const running = runtime?.running ?? snapshot?.running ?? false;
|
|
1060
|
+
const persistedLastEventAt = runtime?.lastEventAt ?? snapshot?.lastEventAt ?? null;
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
accountId: account.accountId,
|
|
1064
|
+
name: account.name,
|
|
1065
|
+
enabled: account.enabled,
|
|
1066
|
+
configured: account.configured,
|
|
1067
|
+
clientId: account.config?.clientId ?? null,
|
|
1068
|
+
running,
|
|
1069
|
+
lastEventAt: running ? getCurrentTimestamp() : persistedLastEventAt,
|
|
1070
|
+
lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
|
|
1071
|
+
lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
|
|
1072
|
+
lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
|
|
1073
|
+
probe,
|
|
1074
|
+
};
|
|
1075
|
+
},
|
|
580
1076
|
},
|
|
581
1077
|
};
|
|
582
1078
|
|