@soimy/dingtalk 3.2.0 → 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 +657 -32
- package/index.ts +62 -0
- package/package.json +3 -1
- 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 +776 -24
- package/src/channel.ts +408 -135
- package/src/config-schema.ts +40 -4
- package/src/config.ts +136 -4
- 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 +1100 -159
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +38 -0
- 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 +177 -43
- 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/types.ts +152 -24
- package/src/utils.ts +231 -12
package/src/channel.ts
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
2
|
+
import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
|
|
3
3
|
import type {
|
|
4
4
|
ChannelMessageActionAdapter,
|
|
5
5
|
OpenClawConfig,
|
|
6
6
|
} from "openclaw/plugin-sdk";
|
|
7
7
|
import * as pluginSdk from "openclaw/plugin-sdk";
|
|
8
8
|
import { getAccessToken } from "./auth";
|
|
9
|
-
import {
|
|
10
|
-
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";
|
|
11
24
|
import { DingTalkConfigSchema } from "./config-schema.js";
|
|
12
25
|
import { ConnectionManager } from "./connection-manager";
|
|
13
26
|
import { isMessageProcessed, markMessageProcessed } from "./dedup";
|
|
@@ -15,8 +28,20 @@ import { handleDingTalkMessage } from "./inbound-handler";
|
|
|
15
28
|
import { getLogger } from "./logger-context";
|
|
16
29
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
17
30
|
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
18
|
-
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
19
|
-
import {
|
|
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";
|
|
38
|
+
import {
|
|
39
|
+
sendMessage,
|
|
40
|
+
sendProactiveMedia,
|
|
41
|
+
sendProactiveTextOrMarkdown,
|
|
42
|
+
sendBySession,
|
|
43
|
+
uploadMedia,
|
|
44
|
+
} from "./send-service";
|
|
20
45
|
import type {
|
|
21
46
|
DingTalkInboundMessage,
|
|
22
47
|
GatewayStartContext,
|
|
@@ -24,12 +49,87 @@ import type {
|
|
|
24
49
|
ConnectionManagerConfig,
|
|
25
50
|
DingTalkChannelPlugin,
|
|
26
51
|
ResolvedAccount,
|
|
52
|
+
StreamClientFactory,
|
|
27
53
|
} from "./types";
|
|
28
54
|
import { ConnectionState } from "./types";
|
|
29
|
-
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
|
+
}
|
|
91
|
+
|
|
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
|
+
}
|
|
30
129
|
|
|
31
130
|
const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
|
|
32
131
|
const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
|
|
132
|
+
export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
|
|
33
133
|
const inboundCountersByAccount = new Map<
|
|
34
134
|
string,
|
|
35
135
|
{
|
|
@@ -142,28 +242,36 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
|
|
|
142
242
|
const config = getConfig(cfg, accountId ?? undefined);
|
|
143
243
|
|
|
144
244
|
if (hasMedia && mediaInput) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
mediaPath
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
});
|
|
155
260
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
261
|
+
if (!result.ok) {
|
|
262
|
+
throw new Error(result.error || "send media failed");
|
|
263
|
+
}
|
|
159
264
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
}
|
|
167
275
|
}
|
|
168
276
|
|
|
169
277
|
if (asVoice) {
|
|
@@ -231,7 +339,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
231
339
|
const config = getConfig(cfg);
|
|
232
340
|
const id = accountId || "default";
|
|
233
341
|
const account = config.accounts?.[id];
|
|
234
|
-
const resolvedConfig = account
|
|
342
|
+
const resolvedConfig = account
|
|
343
|
+
? mergeAccountWithDefaults(config, account)
|
|
344
|
+
: config;
|
|
235
345
|
const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
|
|
236
346
|
return {
|
|
237
347
|
accountId: id,
|
|
@@ -295,20 +405,34 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
295
405
|
},
|
|
296
406
|
sendText: async ({ cfg, to, text, accountId, log }: any) => {
|
|
297
407
|
const config = getConfig(cfg, accountId);
|
|
408
|
+
const rt = getDingTalkRuntime();
|
|
409
|
+
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
410
|
+
agentId: accountId,
|
|
411
|
+
});
|
|
298
412
|
try {
|
|
299
|
-
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
|
+
});
|
|
300
419
|
getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
|
|
301
420
|
if (!result.ok) {
|
|
302
421
|
throw new Error(result.error || "sendText failed");
|
|
303
422
|
}
|
|
304
423
|
const data = result.data as any;
|
|
305
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;
|
|
306
432
|
return {
|
|
307
433
|
channel: "dingtalk",
|
|
308
434
|
messageId,
|
|
309
|
-
meta
|
|
310
|
-
? { data: result.data as unknown as Record<string, unknown> }
|
|
311
|
-
: undefined,
|
|
435
|
+
meta,
|
|
312
436
|
};
|
|
313
437
|
} catch (err: any) {
|
|
314
438
|
if (err?.response?.data !== undefined) {
|
|
@@ -334,6 +458,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
334
458
|
log,
|
|
335
459
|
}: any) => {
|
|
336
460
|
const config = getConfig(cfg, accountId);
|
|
461
|
+
const rt = getDingTalkRuntime();
|
|
462
|
+
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
463
|
+
agentId: accountId,
|
|
464
|
+
});
|
|
337
465
|
if (!config.clientId) {
|
|
338
466
|
throw new Error("DingTalk not configured");
|
|
339
467
|
}
|
|
@@ -388,6 +516,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
388
516
|
result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
|
|
389
517
|
log,
|
|
390
518
|
accountId,
|
|
519
|
+
storePath,
|
|
520
|
+
conversationId: to,
|
|
391
521
|
});
|
|
392
522
|
} catch (err: any) {
|
|
393
523
|
if (err?.response?.data !== undefined) {
|
|
@@ -439,118 +569,227 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
439
569
|
if (!config.clientId || !config.clientSecret) {
|
|
440
570
|
throw new Error("DingTalk clientId and clientSecret are required");
|
|
441
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
|
+
}
|
|
442
581
|
|
|
443
582
|
ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
|
|
444
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
|
+
|
|
445
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
|
+
}
|
|
446
608
|
|
|
447
609
|
const useConnectionManager = config.useConnectionManager ?? true;
|
|
448
610
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
+
};
|
|
455
628
|
|
|
456
|
-
|
|
629
|
+
instrumentConnectionStages(c);
|
|
457
630
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
+
};
|
|
466
650
|
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
|
-
sessionWebhook: data.sessionWebhook,
|
|
491
|
-
log: ctx.log,
|
|
492
|
-
dingtalkConfig: config,
|
|
493
|
-
});
|
|
494
|
-
stats.processed += 1;
|
|
495
|
-
acknowledge();
|
|
496
|
-
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
497
|
-
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
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;
|
|
498
674
|
}
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
675
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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}`);
|
|
508
724
|
}
|
|
725
|
+
});
|
|
509
726
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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) {
|
|
513
736
|
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}`,
|
|
737
|
+
`[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
|
|
520
738
|
);
|
|
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
739
|
}
|
|
527
|
-
}
|
|
740
|
+
};
|
|
528
741
|
|
|
529
|
-
processingDedupKeys.set(dedupKey, Date.now());
|
|
530
742
|
try {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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)}`,
|
|
747
|
+
);
|
|
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
|
+
}
|
|
544
779
|
}
|
|
780
|
+
} catch (error: any) {
|
|
781
|
+
ctx.log?.error?.(
|
|
782
|
+
`[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
|
|
783
|
+
);
|
|
545
784
|
} finally {
|
|
546
|
-
|
|
785
|
+
acknowledge();
|
|
547
786
|
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
return c;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const client = createStreamClient();
|
|
554
793
|
|
|
555
794
|
// Guard against duplicate stop paths (abort signal + explicit stop).
|
|
556
795
|
let stopped = false;
|
|
@@ -566,6 +805,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
566
805
|
}
|
|
567
806
|
stopped = true;
|
|
568
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
|
+
});
|
|
569
819
|
if (useConnectionManager) {
|
|
570
820
|
connectionManager?.stop();
|
|
571
821
|
} else {
|
|
@@ -627,7 +877,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
627
877
|
await nativeStopPromise;
|
|
628
878
|
}
|
|
629
879
|
} catch (err: any) {
|
|
630
|
-
ctx.log?.error?.(
|
|
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
|
+
);
|
|
631
888
|
ctx.setStatus({
|
|
632
889
|
...ctx.getStatus(),
|
|
633
890
|
running: false,
|
|
@@ -649,6 +906,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
649
906
|
maxDelay: config.maxReconnectDelay ?? 60000,
|
|
650
907
|
jitter: config.reconnectJitter ?? 0.3,
|
|
651
908
|
maxReconnectCycles: config.maxReconnectCycles,
|
|
909
|
+
reconnectDeadlineMs: config.reconnectDeadlineMs,
|
|
652
910
|
onStateChange: (state: ConnectionState, error?: string) => {
|
|
653
911
|
if (stopped) {
|
|
654
912
|
return;
|
|
@@ -700,6 +958,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
700
958
|
account.accountId,
|
|
701
959
|
connectionConfig,
|
|
702
960
|
ctx.log,
|
|
961
|
+
createStreamClient,
|
|
703
962
|
);
|
|
704
963
|
|
|
705
964
|
try {
|
|
@@ -722,7 +981,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
722
981
|
);
|
|
723
982
|
}
|
|
724
983
|
} catch (err: any) {
|
|
725
|
-
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
|
+
);
|
|
726
992
|
|
|
727
993
|
ctx.setStatus({
|
|
728
994
|
...ctx.getStatus(),
|
|
@@ -743,6 +1009,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
743
1009
|
defaultRuntime: {
|
|
744
1010
|
accountId: "default",
|
|
745
1011
|
running: false,
|
|
1012
|
+
lastEventAt: null,
|
|
746
1013
|
lastStartAt: null,
|
|
747
1014
|
lastStopAt: null,
|
|
748
1015
|
lastError: null,
|
|
@@ -788,18 +1055,24 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
788
1055
|
return { ok: false, error: error.message };
|
|
789
1056
|
}
|
|
790
1057
|
},
|
|
791
|
-
buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) =>
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
+
},
|
|
803
1076
|
},
|
|
804
1077
|
};
|
|
805
1078
|
|