@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/channel.ts
CHANGED
|
@@ -1,22 +1,51 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
3
|
-
import type {
|
|
4
|
-
ChannelMessageActionAdapter,
|
|
5
|
-
OpenClawConfig,
|
|
6
|
-
} from "openclaw/plugin-sdk";
|
|
2
|
+
import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
|
|
3
|
+
import type { ChannelMessageActionAdapter, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
4
|
import * as pluginSdk from "openclaw/plugin-sdk";
|
|
8
5
|
import { getAccessToken } from "./auth";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { analyzeCardCallback } from "./card-callback-service";
|
|
7
|
+
import {
|
|
8
|
+
createAICard,
|
|
9
|
+
streamAICard,
|
|
10
|
+
finishAICard,
|
|
11
|
+
finalizeActiveCardsForAccount,
|
|
12
|
+
recoverPendingCardsForAccount,
|
|
13
|
+
} from "./card-service";
|
|
14
|
+
import {
|
|
15
|
+
getConfig,
|
|
16
|
+
isConfigured,
|
|
17
|
+
mergeAccountWithDefaults,
|
|
18
|
+
resolveGroupConfig,
|
|
19
|
+
resolveRelativePath,
|
|
20
|
+
stripTargetPrefix,
|
|
21
|
+
} from "./config";
|
|
11
22
|
import { DingTalkConfigSchema } from "./config-schema.js";
|
|
12
23
|
import { ConnectionManager } from "./connection-manager";
|
|
13
24
|
import { isMessageProcessed, markMessageProcessed } from "./dedup";
|
|
25
|
+
import {
|
|
26
|
+
isFeedbackLearningAutoApplyEnabled,
|
|
27
|
+
isFeedbackLearningEnabled,
|
|
28
|
+
recordExplicitFeedbackLearning,
|
|
29
|
+
} from "./feedback-learning-service";
|
|
14
30
|
import { handleDingTalkMessage } from "./inbound-handler";
|
|
15
31
|
import { getLogger } from "./logger-context";
|
|
16
32
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
17
33
|
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
18
|
-
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
19
|
-
import {
|
|
34
|
+
import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
|
|
35
|
+
import { getDingTalkRuntime } from "./runtime";
|
|
36
|
+
import {
|
|
37
|
+
sendMessage,
|
|
38
|
+
sendProactiveMedia,
|
|
39
|
+
sendProactiveTextOrMarkdown,
|
|
40
|
+
sendBySession,
|
|
41
|
+
uploadMedia,
|
|
42
|
+
} from "./send-service";
|
|
43
|
+
import {
|
|
44
|
+
listDingTalkDirectoryGroups,
|
|
45
|
+
listDingTalkDirectoryUsers,
|
|
46
|
+
normalizeResolvedDingTalkTarget,
|
|
47
|
+
} from "./targeting/target-directory-adapter";
|
|
48
|
+
import { looksLikeDingTalkTargetId, normalizeDingTalkTarget } from "./targeting/target-input";
|
|
20
49
|
import type {
|
|
21
50
|
DingTalkInboundMessage,
|
|
22
51
|
GatewayStartContext,
|
|
@@ -24,12 +53,94 @@ import type {
|
|
|
24
53
|
ConnectionManagerConfig,
|
|
25
54
|
DingTalkChannelPlugin,
|
|
26
55
|
ResolvedAccount,
|
|
56
|
+
StreamClientFactory,
|
|
27
57
|
} from "./types";
|
|
28
58
|
import { ConnectionState } from "./types";
|
|
29
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
cleanupOrphanedTempFiles,
|
|
61
|
+
createResolve4FallbackLookup,
|
|
62
|
+
formatDingTalkConnectionErrorLog,
|
|
63
|
+
formatDingTalkErrorPayloadLog,
|
|
64
|
+
getCurrentTimestamp,
|
|
65
|
+
} from "./utils";
|
|
66
|
+
|
|
67
|
+
type InstrumentedDWClient = {
|
|
68
|
+
getEndpoint?: () => Promise<unknown>;
|
|
69
|
+
_connect?: () => Promise<unknown>;
|
|
70
|
+
config?: Record<string, unknown> & { endpoint?: { endpoint?: string } | string };
|
|
71
|
+
dw_url?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function attachConnectionErrorContext(
|
|
75
|
+
err: unknown,
|
|
76
|
+
stage: "connect.open" | "connect.websocket",
|
|
77
|
+
endpoint?: string,
|
|
78
|
+
): void {
|
|
79
|
+
if (!err || typeof err !== "object") {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const target = err as Record<string, unknown>;
|
|
83
|
+
if (typeof target.dingtalkConnectionStage !== "string") {
|
|
84
|
+
target.dingtalkConnectionStage = stage;
|
|
85
|
+
}
|
|
86
|
+
if (endpoint && typeof target.dingtalkConnectionEndpoint !== "string") {
|
|
87
|
+
target.dingtalkConnectionEndpoint = endpoint;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefined {
|
|
92
|
+
if (typeof client.dw_url === "string" && client.dw_url.length > 0) {
|
|
93
|
+
return client.dw_url;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const endpointConfig = client.config?.endpoint;
|
|
97
|
+
if (typeof endpointConfig === "string") {
|
|
98
|
+
return endpointConfig;
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
endpointConfig &&
|
|
102
|
+
typeof endpointConfig === "object" &&
|
|
103
|
+
typeof endpointConfig.endpoint === "string"
|
|
104
|
+
) {
|
|
105
|
+
return endpointConfig.endpoint;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function instrumentConnectionStages(client: DWClient): void {
|
|
111
|
+
const instrumented = client as unknown as InstrumentedDWClient;
|
|
112
|
+
if (
|
|
113
|
+
typeof instrumented.getEndpoint !== "function" ||
|
|
114
|
+
typeof instrumented._connect !== "function"
|
|
115
|
+
) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const originalGetEndpoint = instrumented.getEndpoint.bind(instrumented);
|
|
120
|
+
const originalSocketConnect = instrumented._connect.bind(instrumented);
|
|
121
|
+
|
|
122
|
+
instrumented.getEndpoint = async () => {
|
|
123
|
+
try {
|
|
124
|
+
return await originalGetEndpoint();
|
|
125
|
+
} catch (err) {
|
|
126
|
+
attachConnectionErrorContext(err, "connect.open");
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
instrumented._connect = async () => {
|
|
132
|
+
try {
|
|
133
|
+
return await originalSocketConnect();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
attachConnectionErrorContext(err, "connect.websocket", getInstrumentedEndpoint(instrumented));
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
30
140
|
|
|
31
141
|
const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
|
|
32
142
|
const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
|
|
143
|
+
export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
|
|
33
144
|
const inboundCountersByAccount = new Map<
|
|
34
145
|
string,
|
|
35
146
|
{
|
|
@@ -142,28 +253,36 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
|
|
|
142
253
|
const config = getConfig(cfg, accountId ?? undefined);
|
|
143
254
|
|
|
144
255
|
if (hasMedia && mediaInput) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
mediaPath
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
256
|
+
let preparedMedia;
|
|
257
|
+
try {
|
|
258
|
+
preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
|
|
259
|
+
const mediaPath = preparedMedia.cleanup
|
|
260
|
+
? preparedMedia.path
|
|
261
|
+
: resolveRelativePath(preparedMedia.path);
|
|
262
|
+
const mediaType = resolveOutboundMediaType({
|
|
263
|
+
mediaType: requestedMediaType ?? undefined,
|
|
264
|
+
mediaPath,
|
|
265
|
+
asVoice,
|
|
266
|
+
});
|
|
267
|
+
const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
|
|
268
|
+
log,
|
|
269
|
+
accountId: accountId ?? undefined,
|
|
270
|
+
});
|
|
155
271
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
272
|
+
if (!result.ok) {
|
|
273
|
+
throw new Error(result.error || "send media failed");
|
|
274
|
+
}
|
|
159
275
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
276
|
+
return pluginSdk.jsonResult({
|
|
277
|
+
ok: true,
|
|
278
|
+
to: target,
|
|
279
|
+
mediaType,
|
|
280
|
+
messageId: result.messageId ?? null,
|
|
281
|
+
result: result.data ?? null,
|
|
282
|
+
});
|
|
283
|
+
} finally {
|
|
284
|
+
await preparedMedia?.cleanup?.();
|
|
285
|
+
}
|
|
167
286
|
}
|
|
168
287
|
|
|
169
288
|
if (asVoice) {
|
|
@@ -231,7 +350,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
231
350
|
const config = getConfig(cfg);
|
|
232
351
|
const id = accountId || "default";
|
|
233
352
|
const account = config.accounts?.[id];
|
|
234
|
-
const resolvedConfig = account
|
|
353
|
+
const resolvedConfig = account ? mergeAccountWithDefaults(config, account) : config;
|
|
235
354
|
const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
|
|
236
355
|
return {
|
|
237
356
|
accountId: id,
|
|
@@ -262,7 +381,16 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
262
381
|
}),
|
|
263
382
|
},
|
|
264
383
|
groups: {
|
|
265
|
-
resolveRequireMention: ({ cfg }: any): boolean =>
|
|
384
|
+
resolveRequireMention: ({ cfg, groupId }: any): boolean => {
|
|
385
|
+
const config = getConfig(cfg);
|
|
386
|
+
if (groupId) {
|
|
387
|
+
const groupCfg = resolveGroupConfig(config, groupId);
|
|
388
|
+
if (groupCfg?.requireMention !== undefined) {
|
|
389
|
+
return groupCfg.requireMention;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return config.groupPolicy !== "open";
|
|
393
|
+
},
|
|
266
394
|
resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
|
|
267
395
|
const parts = [`conversationId=${groupId}`];
|
|
268
396
|
if (groupChannel) {
|
|
@@ -272,12 +400,20 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
272
400
|
},
|
|
273
401
|
},
|
|
274
402
|
messaging: {
|
|
275
|
-
normalizeTarget: (raw: string) => (raw ? raw
|
|
403
|
+
normalizeTarget: (raw: string) => (raw ? normalizeDingTalkTarget(raw) : undefined),
|
|
276
404
|
targetResolver: {
|
|
277
|
-
looksLikeId: (
|
|
278
|
-
|
|
405
|
+
looksLikeId: (raw: string, normalized?: string): boolean =>
|
|
406
|
+
looksLikeDingTalkTargetId(raw, normalized),
|
|
407
|
+
hint: "<displayName|conversationId|user:staffId|user:+861...>",
|
|
279
408
|
},
|
|
280
409
|
},
|
|
410
|
+
directory: {
|
|
411
|
+
self: async () => null,
|
|
412
|
+
listGroups: async (params) => listDingTalkDirectoryGroups(params),
|
|
413
|
+
listGroupsLive: async (params) => listDingTalkDirectoryGroups(params),
|
|
414
|
+
listPeers: async (params) => listDingTalkDirectoryUsers(params),
|
|
415
|
+
listPeersLive: async (params) => listDingTalkDirectoryUsers(params),
|
|
416
|
+
},
|
|
281
417
|
actions: dingtalkMessageActions,
|
|
282
418
|
outbound: {
|
|
283
419
|
deliveryMode: "direct" as const,
|
|
@@ -289,26 +425,38 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
289
425
|
error: new Error("DingTalk message requires --to <conversationId>"),
|
|
290
426
|
};
|
|
291
427
|
}
|
|
292
|
-
|
|
293
|
-
const resolved = resolveOriginalPeerId(targetId);
|
|
294
|
-
return { ok: true as const, to: resolved };
|
|
428
|
+
return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
|
|
295
429
|
},
|
|
296
430
|
sendText: async ({ cfg, to, text, accountId, log }: any) => {
|
|
297
431
|
const config = getConfig(cfg, accountId);
|
|
432
|
+
const rt = getDingTalkRuntime();
|
|
433
|
+
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
434
|
+
agentId: accountId,
|
|
435
|
+
});
|
|
298
436
|
try {
|
|
299
|
-
const result = await sendMessage(config, to, text, {
|
|
437
|
+
const result = await sendMessage(config, to, text, {
|
|
438
|
+
log,
|
|
439
|
+
accountId,
|
|
440
|
+
storePath,
|
|
441
|
+
conversationId: to,
|
|
442
|
+
});
|
|
300
443
|
getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
|
|
301
444
|
if (!result.ok) {
|
|
302
445
|
throw new Error(result.error || "sendText failed");
|
|
303
446
|
}
|
|
304
447
|
const data = result.data as any;
|
|
305
448
|
const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
|
|
449
|
+
const meta =
|
|
450
|
+
result.data || result.tracking
|
|
451
|
+
? {
|
|
452
|
+
...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
|
|
453
|
+
...(result.tracking ? { tracking: result.tracking } : {}),
|
|
454
|
+
}
|
|
455
|
+
: undefined;
|
|
306
456
|
return {
|
|
307
457
|
channel: "dingtalk",
|
|
308
458
|
messageId,
|
|
309
|
-
meta
|
|
310
|
-
? { data: result.data as unknown as Record<string, unknown> }
|
|
311
|
-
: undefined,
|
|
459
|
+
meta,
|
|
312
460
|
};
|
|
313
461
|
} catch (err: any) {
|
|
314
462
|
if (err?.response?.data !== undefined) {
|
|
@@ -334,6 +482,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
334
482
|
log,
|
|
335
483
|
}: any) => {
|
|
336
484
|
const config = getConfig(cfg, accountId);
|
|
485
|
+
const rt = getDingTalkRuntime();
|
|
486
|
+
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
487
|
+
agentId: accountId,
|
|
488
|
+
});
|
|
337
489
|
if (!config.clientId) {
|
|
338
490
|
throw new Error("DingTalk not configured");
|
|
339
491
|
}
|
|
@@ -362,12 +514,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
362
514
|
preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
|
|
363
515
|
} catch (err: any) {
|
|
364
516
|
if (err?.response?.data !== undefined) {
|
|
365
|
-
log?.error?.(
|
|
517
|
+
log?.error?.(
|
|
518
|
+
formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data),
|
|
519
|
+
);
|
|
366
520
|
}
|
|
367
521
|
const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
|
|
368
|
-
throw new Error(
|
|
369
|
-
|
|
370
|
-
|
|
522
|
+
throw new Error(
|
|
523
|
+
`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`,
|
|
524
|
+
{
|
|
525
|
+
cause: err,
|
|
526
|
+
},
|
|
527
|
+
);
|
|
371
528
|
}
|
|
372
529
|
|
|
373
530
|
const actualMediaPath = preparedMedia.cleanup
|
|
@@ -388,10 +545,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
388
545
|
result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
|
|
389
546
|
log,
|
|
390
547
|
accountId,
|
|
548
|
+
storePath,
|
|
549
|
+
conversationId: to,
|
|
391
550
|
});
|
|
392
551
|
} catch (err: any) {
|
|
393
552
|
if (err?.response?.data !== undefined) {
|
|
394
|
-
log?.error?.(
|
|
553
|
+
log?.error?.(
|
|
554
|
+
formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data),
|
|
555
|
+
);
|
|
395
556
|
}
|
|
396
557
|
throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
|
|
397
558
|
cause: err,
|
|
@@ -439,118 +600,228 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
439
600
|
if (!config.clientId || !config.clientSecret) {
|
|
440
601
|
throw new Error("DingTalk clientId and clientSecret are required");
|
|
441
602
|
}
|
|
603
|
+
let accountStorePath: string | undefined;
|
|
604
|
+
try {
|
|
605
|
+
const rt = getDingTalkRuntime();
|
|
606
|
+
accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
607
|
+
agentId: account.accountId,
|
|
608
|
+
});
|
|
609
|
+
} catch {
|
|
610
|
+
accountStorePath = undefined;
|
|
611
|
+
}
|
|
442
612
|
|
|
443
613
|
ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
|
|
444
614
|
|
|
615
|
+
// Preload known peer IDs from sessions so outbound delivery (e.g. cron
|
|
616
|
+
// jobs that fire immediately after startup) can resolve the original
|
|
617
|
+
// case-sensitive conversationId before any inbound message has arrived.
|
|
618
|
+
preloadPeerIdsFromSessions();
|
|
619
|
+
ctx.log?.debug?.(`[${account.accountId}] Peer ID registry preloaded from sessions`);
|
|
620
|
+
|
|
445
621
|
cleanupOrphanedTempFiles(ctx.log);
|
|
622
|
+
try {
|
|
623
|
+
const recovered = await recoverPendingCardsForAccount(
|
|
624
|
+
config,
|
|
625
|
+
account.accountId,
|
|
626
|
+
accountStorePath,
|
|
627
|
+
ctx.log,
|
|
628
|
+
);
|
|
629
|
+
if (recovered > 0) {
|
|
630
|
+
ctx.log?.info?.(
|
|
631
|
+
`[${account.accountId}] Recovered and finalized ${recovered} unfinished card(s) from previous runtime`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
} catch (err: any) {
|
|
635
|
+
ctx.log?.warn?.(
|
|
636
|
+
`[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
446
639
|
|
|
447
640
|
const useConnectionManager = config.useConnectionManager ?? true;
|
|
448
641
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
642
|
+
// Factory that creates a fresh DWClient with the TOPIC_ROBOT callback
|
|
643
|
+
// already registered. Each client captures its own reference for
|
|
644
|
+
// socketCallBackResponse so acks are sent on the correct socket.
|
|
645
|
+
// ConnectionManager uses this to create new clients during warm
|
|
646
|
+
// reconnection, minimizing the message-loss window when the DingTalk
|
|
647
|
+
// server initiates a disconnect for load balancing.
|
|
648
|
+
const createStreamClient: StreamClientFactory = () => {
|
|
649
|
+
const c = new DWClient({
|
|
650
|
+
clientId: config.clientId,
|
|
651
|
+
clientSecret: config.clientSecret,
|
|
652
|
+
debug: config.debug || false,
|
|
653
|
+
keepAlive: config.keepAlive ?? !useConnectionManager,
|
|
654
|
+
});
|
|
655
|
+
(c as any).sslopts = {
|
|
656
|
+
...(c as any).sslopts,
|
|
657
|
+
lookup: createResolve4FallbackLookup(ctx.log, account.accountId),
|
|
658
|
+
};
|
|
455
659
|
|
|
456
|
-
|
|
660
|
+
instrumentConnectionStages(c);
|
|
457
661
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
662
|
+
(c as any).config.autoReconnect = !useConnectionManager;
|
|
663
|
+
|
|
664
|
+
c.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
665
|
+
const messageId = res.headers?.messageId;
|
|
666
|
+
const stats = getInboundCounters(account.accountId);
|
|
667
|
+
stats.received += 1;
|
|
668
|
+
const acknowledge = () => {
|
|
669
|
+
if (!messageId) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
c.socketCallBackResponse(messageId, { success: true });
|
|
674
|
+
stats.acked += 1;
|
|
675
|
+
} catch (ackError: any) {
|
|
676
|
+
ctx.log?.warn?.(
|
|
677
|
+
`[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
466
681
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
682
|
+
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
683
|
+
|
|
684
|
+
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
685
|
+
const msgId = data.msgId || messageId;
|
|
686
|
+
const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
|
|
687
|
+
|
|
688
|
+
if (!dedupKey) {
|
|
689
|
+
ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
|
|
690
|
+
stats.noMessageId += 1;
|
|
691
|
+
acknowledge();
|
|
692
|
+
await handleDingTalkMessage({
|
|
693
|
+
cfg,
|
|
694
|
+
accountId: account.accountId,
|
|
695
|
+
data,
|
|
696
|
+
sessionWebhook: data.sessionWebhook,
|
|
697
|
+
log: ctx.log,
|
|
698
|
+
dingtalkConfig: config,
|
|
699
|
+
});
|
|
700
|
+
stats.processed += 1;
|
|
701
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
702
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (isMessageProcessed(dedupKey)) {
|
|
708
|
+
ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
|
|
709
|
+
stats.dedupSkipped += 1;
|
|
710
|
+
acknowledge();
|
|
711
|
+
logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const inflightSince = processingDedupKeys.get(dedupKey);
|
|
716
|
+
if (inflightSince !== undefined) {
|
|
717
|
+
if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
|
|
718
|
+
ctx.log?.warn?.(
|
|
719
|
+
`[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
|
|
720
|
+
);
|
|
721
|
+
processingDedupKeys.delete(dedupKey);
|
|
722
|
+
} else {
|
|
723
|
+
ctx.log?.debug?.(
|
|
724
|
+
`[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
|
|
725
|
+
);
|
|
726
|
+
stats.inflightSkipped += 1;
|
|
727
|
+
acknowledge();
|
|
728
|
+
logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
498
731
|
}
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
732
|
|
|
502
|
-
if (isMessageProcessed(dedupKey)) {
|
|
503
|
-
ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
|
|
504
|
-
stats.dedupSkipped += 1;
|
|
505
733
|
acknowledge();
|
|
506
|
-
|
|
507
|
-
|
|
734
|
+
processingDedupKeys.set(dedupKey, Date.now());
|
|
735
|
+
try {
|
|
736
|
+
await handleDingTalkMessage({
|
|
737
|
+
cfg,
|
|
738
|
+
accountId: account.accountId,
|
|
739
|
+
data,
|
|
740
|
+
sessionWebhook: data.sessionWebhook,
|
|
741
|
+
log: ctx.log,
|
|
742
|
+
dingtalkConfig: config,
|
|
743
|
+
});
|
|
744
|
+
stats.processed += 1;
|
|
745
|
+
markMessageProcessed(dedupKey);
|
|
746
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
747
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
processingDedupKeys.delete(dedupKey);
|
|
751
|
+
}
|
|
752
|
+
} catch (error: any) {
|
|
753
|
+
stats.failed += 1;
|
|
754
|
+
logInboundCounters(ctx.log, account.accountId, "failed");
|
|
755
|
+
ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
|
|
508
756
|
}
|
|
757
|
+
});
|
|
509
758
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
759
|
+
c.registerCallbackListener(TOPIC_CARD, async (res: any) => {
|
|
760
|
+
const messageId = res.headers?.messageId;
|
|
761
|
+
const acknowledge = () => {
|
|
762
|
+
if (!messageId) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
c.socketCallBackResponse(messageId, { success: true });
|
|
767
|
+
} catch (ackError: any) {
|
|
513
768
|
ctx.log?.warn?.(
|
|
514
|
-
`[${account.accountId}]
|
|
515
|
-
);
|
|
516
|
-
processingDedupKeys.delete(dedupKey);
|
|
517
|
-
} else {
|
|
518
|
-
ctx.log?.debug?.(
|
|
519
|
-
`[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
|
|
769
|
+
`[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
|
|
520
770
|
);
|
|
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
771
|
}
|
|
527
|
-
}
|
|
772
|
+
};
|
|
528
773
|
|
|
529
|
-
processingDedupKeys.set(dedupKey, Date.now());
|
|
530
774
|
try {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
775
|
+
const payload = JSON.parse(res.data);
|
|
776
|
+
const analysis = analyzeCardCallback(payload);
|
|
777
|
+
ctx.log?.info?.(
|
|
778
|
+
`[${account.accountId}] [DingTalk][CardCallback] action=${analysis.summary} raw=${JSON.stringify(payload)}`,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
if (analysis.feedbackTarget && analysis.feedbackAckText) {
|
|
782
|
+
recordExplicitFeedbackLearning({
|
|
783
|
+
enabled: isFeedbackLearningEnabled(config),
|
|
784
|
+
autoApply: isFeedbackLearningAutoApplyEnabled(config),
|
|
785
|
+
storePath: accountStorePath,
|
|
786
|
+
accountId: account.accountId,
|
|
787
|
+
targetId: analysis.feedbackTarget,
|
|
788
|
+
feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
|
|
789
|
+
userId: analysis.userId,
|
|
790
|
+
processQueryKey: analysis.processQueryKey,
|
|
791
|
+
noteTtlMs: config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs,
|
|
792
|
+
});
|
|
793
|
+
try {
|
|
794
|
+
await sendProactiveTextOrMarkdown(
|
|
795
|
+
config,
|
|
796
|
+
analysis.feedbackTarget,
|
|
797
|
+
analysis.feedbackAckText,
|
|
798
|
+
{
|
|
799
|
+
accountId: account.accountId,
|
|
800
|
+
log: ctx.log,
|
|
801
|
+
},
|
|
802
|
+
);
|
|
803
|
+
ctx.log?.info?.(
|
|
804
|
+
`[${account.accountId}] [DingTalk][CardCallback] feedback ack sent to ${analysis.feedbackTarget}`,
|
|
805
|
+
);
|
|
806
|
+
} catch (sendErr: any) {
|
|
807
|
+
ctx.log?.warn?.(
|
|
808
|
+
`[${account.accountId}] [DingTalk][CardCallback] Failed to send feedback ack: ${sendErr?.message || String(sendErr)}`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
544
811
|
}
|
|
812
|
+
} catch (error: any) {
|
|
813
|
+
ctx.log?.error?.(
|
|
814
|
+
`[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
|
|
815
|
+
);
|
|
545
816
|
} finally {
|
|
546
|
-
|
|
817
|
+
acknowledge();
|
|
547
818
|
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
return c;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const client = createStreamClient();
|
|
554
825
|
|
|
555
826
|
// Guard against duplicate stop paths (abort signal + explicit stop).
|
|
556
827
|
let stopped = false;
|
|
@@ -566,6 +837,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
566
837
|
}
|
|
567
838
|
stopped = true;
|
|
568
839
|
ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
|
|
840
|
+
void finalizeActiveCardsForAccount(
|
|
841
|
+
config,
|
|
842
|
+
account.accountId,
|
|
843
|
+
"⚠️ 服务正在重启,当前回复已中断。请重新发送你的问题。",
|
|
844
|
+
accountStorePath,
|
|
845
|
+
ctx.log,
|
|
846
|
+
).catch((err: any) => {
|
|
847
|
+
ctx.log?.debug?.(
|
|
848
|
+
`[${account.accountId}] Failed to finalize active cards during stop: ${err.message}`,
|
|
849
|
+
);
|
|
850
|
+
});
|
|
569
851
|
if (useConnectionManager) {
|
|
570
852
|
connectionManager?.stop();
|
|
571
853
|
} else {
|
|
@@ -627,7 +909,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
627
909
|
await nativeStopPromise;
|
|
628
910
|
}
|
|
629
911
|
} catch (err: any) {
|
|
630
|
-
ctx.log?.error?.(
|
|
912
|
+
ctx.log?.error?.(
|
|
913
|
+
formatDingTalkConnectionErrorLog(
|
|
914
|
+
// Use connect.open as base scope; instrumentation can override to connect.websocket
|
|
915
|
+
"connect.open",
|
|
916
|
+
err,
|
|
917
|
+
`[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
918
|
+
) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
919
|
+
);
|
|
631
920
|
ctx.setStatus({
|
|
632
921
|
...ctx.getStatus(),
|
|
633
922
|
running: false,
|
|
@@ -649,6 +938,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
649
938
|
maxDelay: config.maxReconnectDelay ?? 60000,
|
|
650
939
|
jitter: config.reconnectJitter ?? 0.3,
|
|
651
940
|
maxReconnectCycles: config.maxReconnectCycles,
|
|
941
|
+
reconnectDeadlineMs: config.reconnectDeadlineMs,
|
|
652
942
|
onStateChange: (state: ConnectionState, error?: string) => {
|
|
653
943
|
if (stopped) {
|
|
654
944
|
return;
|
|
@@ -700,6 +990,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
700
990
|
account.accountId,
|
|
701
991
|
connectionConfig,
|
|
702
992
|
ctx.log,
|
|
993
|
+
createStreamClient,
|
|
703
994
|
);
|
|
704
995
|
|
|
705
996
|
try {
|
|
@@ -722,7 +1013,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
722
1013
|
);
|
|
723
1014
|
}
|
|
724
1015
|
} catch (err: any) {
|
|
725
|
-
ctx.log?.error?.(
|
|
1016
|
+
ctx.log?.error?.(
|
|
1017
|
+
formatDingTalkConnectionErrorLog(
|
|
1018
|
+
// Use connect.open as base scope; instrumentation can override to connect.websocket
|
|
1019
|
+
"connect.open",
|
|
1020
|
+
err,
|
|
1021
|
+
`[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
1022
|
+
) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
|
|
1023
|
+
);
|
|
726
1024
|
|
|
727
1025
|
ctx.setStatus({
|
|
728
1026
|
...ctx.getStatus(),
|
|
@@ -743,6 +1041,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
743
1041
|
defaultRuntime: {
|
|
744
1042
|
accountId: "default",
|
|
745
1043
|
running: false,
|
|
1044
|
+
lastEventAt: null,
|
|
746
1045
|
lastStartAt: null,
|
|
747
1046
|
lastStopAt: null,
|
|
748
1047
|
lastError: null,
|
|
@@ -788,18 +1087,24 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
788
1087
|
return { ok: false, error: error.message };
|
|
789
1088
|
}
|
|
790
1089
|
},
|
|
791
|
-
buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) =>
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1090
|
+
buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => {
|
|
1091
|
+
const running = runtime?.running ?? snapshot?.running ?? false;
|
|
1092
|
+
const persistedLastEventAt = runtime?.lastEventAt ?? snapshot?.lastEventAt ?? null;
|
|
1093
|
+
|
|
1094
|
+
return {
|
|
1095
|
+
accountId: account.accountId,
|
|
1096
|
+
name: account.name,
|
|
1097
|
+
enabled: account.enabled,
|
|
1098
|
+
configured: account.configured,
|
|
1099
|
+
clientId: account.config?.clientId ?? null,
|
|
1100
|
+
running,
|
|
1101
|
+
lastEventAt: running ? getCurrentTimestamp() : persistedLastEventAt,
|
|
1102
|
+
lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
|
|
1103
|
+
lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
|
|
1104
|
+
lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
|
|
1105
|
+
probe,
|
|
1106
|
+
};
|
|
1107
|
+
},
|
|
803
1108
|
},
|
|
804
1109
|
};
|
|
805
1110
|
|