@soimy/dingtalk 3.5.3 → 3.6.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/src/channel.ts CHANGED
@@ -1,333 +1,20 @@
1
- import { randomUUID } from "node:crypto";
2
- import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
3
- import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
4
- import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
5
1
  import { buildChannelConfigSchema, type OpenClawConfig } from "openclaw/plugin-sdk/core";
6
- import { readStringParam } from "openclaw/plugin-sdk/param-readers";
7
- import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
8
- import { getAccessToken } from "./auth";
9
- import { analyzeCardCallback } from "./card-callback-service";
10
- import { handleCardAction } from "./card/card-action-handler";
11
- import {
12
- createAICard,
13
- streamAICard,
14
- finishAICard,
15
- finalizeActiveCardsForAccount,
16
- recoverPendingCardsForAccount,
17
- } from "./card-service";
18
- import {
19
- getConfig,
20
- isConfigured,
21
- mergeAccountWithDefaults,
22
- resolveGroupConfig,
23
- resolveRelativePath,
24
- resolveRobotCode,
25
- stripTargetPrefix,
26
- } from "./config";
2
+ import { getConfig, isConfigured, mergeAccountWithDefaults, resolveGroupConfig } from "./config";
27
3
  import { DingTalkConfigSchema } from "./config-schema.js";
28
- import { ConnectionManager } from "./connection-manager";
29
- import { isMessageProcessed, markMessageProcessed } from "./dedup";
30
4
  import {
31
- isLearningAutoApplyEnabled,
32
- isLearningEnabled,
33
- recordExplicitFeedbackLearning,
34
- } from "./feedback-learning-service";
35
- import { handleDingTalkMessage } from "./inbound-handler";
36
- import { getLogger, setCurrentLogger } from "./logger-context";
37
- import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
5
+ CHANNEL_INFLIGHT_NAMESPACE_POLICY,
6
+ createDingTalkGateway,
7
+ } from "./gateway/channel-gateway";
38
8
  import { dingtalkSetupAdapter, dingtalkSetupWizard } from "./onboarding.js";
39
- import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
40
- import { getDingTalkRuntime } from "./runtime";
41
- import {
42
- sendMessage,
43
- sendProactiveMedia,
44
- sendProactiveTextOrMarkdown,
45
- sendBySession,
46
- uploadMedia,
47
- } from "./send-service";
9
+ import { createDingTalkMessageActions } from "./messaging/channel-actions";
10
+ import { createDingTalkOutbound } from "./messaging/channel-outbound";
11
+ import { createDingTalkStatus } from "./platform/channel-status";
48
12
  import {
49
13
  listDingTalkDirectoryGroups,
50
14
  listDingTalkDirectoryUsers,
51
- normalizeResolvedDingTalkTarget,
52
15
  } from "./targeting/target-directory-adapter";
53
16
  import { looksLikeDingTalkTargetId, normalizeDingTalkTarget } from "./targeting/target-input";
54
- import type {
55
- DingTalkInboundMessage,
56
- GatewayStartContext,
57
- GatewayStopResult,
58
- ConnectionManagerConfig,
59
- DingTalkChannelPlugin,
60
- ResolvedAccount,
61
- StreamClientFactory,
62
- } from "./types";
63
- import { ConnectionState } from "./types";
64
- import {
65
- closePluginDebugLog,
66
- cleanupOrphanedTempFiles,
67
- createResolve4FallbackLookup,
68
- formatDingTalkConnectionErrorLog,
69
- formatDingTalkErrorPayloadLog,
70
- getCurrentTimestamp,
71
- parseBooleanLike,
72
- resolvePluginDebugLog,
73
- } from "./utils";
74
-
75
- type InstrumentedDWClient = {
76
- getEndpoint?: () => Promise<unknown>;
77
- _connect?: () => Promise<unknown>;
78
- config?: Record<string, unknown> & { endpoint?: { endpoint?: string } | string };
79
- dw_url?: string;
80
- };
81
-
82
- function attachConnectionErrorContext(
83
- err: unknown,
84
- stage: "connect.open" | "connect.websocket",
85
- endpoint?: string,
86
- ): void {
87
- if (!err || typeof err !== "object") {
88
- return;
89
- }
90
- const target = err as Record<string, unknown>;
91
- if (typeof target.dingtalkConnectionStage !== "string") {
92
- target.dingtalkConnectionStage = stage;
93
- }
94
- if (endpoint && typeof target.dingtalkConnectionEndpoint !== "string") {
95
- target.dingtalkConnectionEndpoint = endpoint;
96
- }
97
- }
98
-
99
- function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefined {
100
- if (typeof client.dw_url === "string" && client.dw_url.length > 0) {
101
- return client.dw_url;
102
- }
103
-
104
- const endpointConfig = client.config?.endpoint;
105
- if (typeof endpointConfig === "string") {
106
- return endpointConfig;
107
- }
108
- if (
109
- endpointConfig &&
110
- typeof endpointConfig === "object" &&
111
- typeof endpointConfig.endpoint === "string"
112
- ) {
113
- return endpointConfig.endpoint;
114
- }
115
- return undefined;
116
- }
117
-
118
- function instrumentConnectionStages(client: DWClient): void {
119
- const instrumented = client as unknown as InstrumentedDWClient;
120
- if (
121
- typeof instrumented.getEndpoint !== "function" ||
122
- typeof instrumented._connect !== "function"
123
- ) {
124
- return;
125
- }
126
-
127
- const originalGetEndpoint = instrumented.getEndpoint.bind(instrumented);
128
- const originalSocketConnect = instrumented._connect.bind(instrumented);
129
-
130
- instrumented.getEndpoint = async () => {
131
- try {
132
- return await originalGetEndpoint();
133
- } catch (err) {
134
- attachConnectionErrorContext(err, "connect.open");
135
- throw err;
136
- }
137
- };
138
-
139
- instrumented._connect = async () => {
140
- try {
141
- return await originalSocketConnect();
142
- } catch (err) {
143
- attachConnectionErrorContext(err, "connect.websocket", getInstrumentedEndpoint(instrumented));
144
- throw err;
145
- }
146
- };
147
- }
148
-
149
- const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
150
- const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
151
- export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
152
- const inboundCountersByAccount = new Map<
153
- string,
154
- {
155
- received: number;
156
- acked: number;
157
- dedupSkipped: number;
158
- inflightSkipped: number;
159
- processed: number;
160
- failed: number;
161
- noMessageId: number;
162
- }
163
- >();
164
- const INBOUND_COUNTER_LOG_EVERY = 10;
165
-
166
- function getInboundCounters(accountId: string) {
167
- const existing = inboundCountersByAccount.get(accountId);
168
- if (existing) {
169
- return existing;
170
- }
171
- const created = {
172
- received: 0,
173
- acked: 0,
174
- dedupSkipped: 0,
175
- inflightSkipped: 0,
176
- processed: 0,
177
- failed: 0,
178
- noMessageId: 0,
179
- };
180
- inboundCountersByAccount.set(accountId, created);
181
- return created;
182
- }
183
-
184
- function logInboundCounters(log: any, accountId: string, reason: string): void {
185
- const stats = getInboundCounters(accountId);
186
- log?.info?.(
187
- `[${accountId}] Inbound counters (${reason}): received=${stats.received}, acked=${stats.acked}, processed=${stats.processed}, dedupSkipped=${stats.dedupSkipped}, inflightSkipped=${stats.inflightSkipped}, failed=${stats.failed}, noMessageId=${stats.noMessageId}`,
188
- );
189
- }
190
-
191
- function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
192
- return parseBooleanLike(params[key]);
193
- }
194
-
195
- function readSharedAudioAsVoiceParam(params: Record<string, unknown>): boolean {
196
- const sharedValue = readBooleanLikeParam(params, "audioAsVoice");
197
- if (sharedValue !== undefined) {
198
- return sharedValue;
199
- }
200
- return readBooleanLikeParam(params, "asVoice") === true;
201
- }
202
-
203
- function describeDingTalkMessageTool(cfg: OpenClawConfig): {
204
- actions: readonly ["send"] | readonly [];
205
- capabilities: readonly ["cards"] | readonly [];
206
- schema: null;
207
- } {
208
- const config = getConfig(cfg);
209
- const configured = Boolean(config.clientId && config.clientSecret);
210
- if (!configured && !(config.accounts && Object.keys(config.accounts).length > 0)) {
211
- return { actions: [], capabilities: [], schema: null };
212
- }
213
- const hasCardMode =
214
- config.messageType === "card" ||
215
- (config.accounts && Object.values(config.accounts).some((a) => a?.messageType === "card"));
216
- return {
217
- actions: ["send"] as const,
218
- capabilities: hasCardMode ? (["cards"] as const) : [],
219
- schema: null,
220
- };
221
- }
222
-
223
- const dingtalkMessageActions: ChannelMessageActionAdapter = {
224
- describeMessageTool: ({ cfg }) => describeDingTalkMessageTool(cfg),
225
- supportsAction: ({ action }) => action === "send",
226
- extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
227
- handleAction: async ({ action, params, cfg, accountId, dryRun, mediaLocalRoots }) => {
228
- if (action !== "send") {
229
- throw new Error(`Action ${action} is not supported for provider dingtalk.`);
230
- }
231
-
232
- const to = readStringParam(params, "to", { required: true });
233
- const mediaInput =
234
- readStringParam(params, "media", { trim: false }) ??
235
- readStringParam(params, "path", { trim: false }) ??
236
- readStringParam(params, "filePath", { trim: false }) ??
237
- readStringParam(params, "mediaUrl", { trim: false });
238
-
239
- const hasMedia = Boolean(mediaInput && mediaInput.trim());
240
- const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
241
- let message =
242
- readStringParam(params, "message", {
243
- required: !hasMedia,
244
- allowEmpty: true,
245
- }) ?? "";
246
-
247
- if (!message.trim() && caption.trim()) {
248
- message = caption;
249
- }
250
-
251
- const asVoice = readSharedAudioAsVoiceParam(params);
252
- const requestedMediaType = readStringParam(params, "mediaType");
253
-
254
- const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
255
-
256
- if (dryRun) {
257
- return jsonResult({
258
- ok: true,
259
- dryRun: true,
260
- to: target,
261
- hasMedia,
262
- asVoice,
263
- });
264
- }
265
-
266
- const log = getLogger();
267
- const config = getConfig(cfg, accountId ?? undefined);
268
-
269
- if (hasMedia && mediaInput) {
270
- let preparedMedia;
271
- try {
272
- preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
273
- const mediaPath = preparedMedia.cleanup
274
- ? preparedMedia.path
275
- : resolveRelativePath(preparedMedia.path);
276
- const mediaType = resolveOutboundMediaType({
277
- mediaType: requestedMediaType ?? undefined,
278
- mediaPath,
279
- asVoice,
280
- });
281
- const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
282
- log,
283
- accountId: accountId ?? undefined,
284
- mediaLocalRoots: mediaLocalRoots ? [...mediaLocalRoots] : undefined,
285
- });
286
-
287
- if (!result.ok) {
288
- throw new Error(result.error || "send media failed");
289
- }
290
-
291
- return jsonResult({
292
- ok: true,
293
- to: target,
294
- mediaType,
295
- messageId: result.messageId ?? null,
296
- result: result.data ?? null,
297
- });
298
- } finally {
299
- await preparedMedia?.cleanup?.();
300
- }
301
- }
302
-
303
- if (asVoice) {
304
- throw new Error(
305
- "DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
306
- );
307
- }
308
-
309
- if (!message.trim()) {
310
- throw new Error("send requires message when media is not provided");
311
- }
312
-
313
- const result = await sendMessage(config, target, message, {
314
- log,
315
- accountId: accountId ?? undefined,
316
- });
317
-
318
- if (!result.ok) {
319
- throw new Error(result.error || "send message failed");
320
- }
321
-
322
- const data = result.data as any;
323
- return jsonResult({
324
- ok: true,
325
- to: target,
326
- messageId: data?.processQueryKey || data?.messageId || null,
327
- result: data ?? null,
328
- });
329
- },
330
- };
17
+ import type { DingTalkChannelPlugin, ResolvedAccount } from "./types";
331
18
 
332
19
  // DingTalk Channel Definition (assembly layer).
333
20
  // Heavy logic is delegated to service modules for maintainability.
@@ -430,769 +117,20 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
430
117
  listPeers: async (params) => listDingTalkDirectoryUsers(params),
431
118
  listPeersLive: async (params) => listDingTalkDirectoryUsers(params),
432
119
  },
433
- actions: dingtalkMessageActions,
434
- outbound: {
435
- deliveryMode: "direct" as const,
436
- resolveTarget: ({ to }: any) => {
437
- const trimmed = to?.trim();
438
- if (!trimmed) {
439
- return {
440
- ok: false as const,
441
- error: new Error("DingTalk message requires --to <conversationId>"),
442
- };
443
- }
444
- return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
445
- },
446
- sendText: async ({ cfg, to, text, accountId, log }: any) => {
447
- const config = getConfig(cfg, accountId);
448
- const rt = getDingTalkRuntime();
449
- const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
450
- agentId: accountId,
451
- });
452
- const effectiveLog = getLogger(accountId) || log;
453
- try {
454
- const result = await sendMessage(config, to, text, {
455
- log: effectiveLog,
456
- accountId,
457
- storePath,
458
- conversationId: to,
459
- });
460
- effectiveLog?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
461
- if (!result.ok) {
462
- throw new Error(result.error || "sendText failed");
463
- }
464
- const data = result.data as any;
465
- const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
466
- const meta =
467
- result.data || result.tracking
468
- ? {
469
- ...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
470
- ...(result.tracking ? { tracking: result.tracking } : {}),
471
- }
472
- : undefined;
473
- return {
474
- channel: "dingtalk",
475
- messageId,
476
- meta,
477
- };
478
- } catch (err: any) {
479
- if (err?.response?.data !== undefined) {
480
- effectiveLog?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
481
- }
482
- throw new Error(
483
- typeof err?.response?.data === "string"
484
- ? err.response.data
485
- : err?.message || "sendText failed",
486
- { cause: err },
487
- );
488
- }
489
- },
490
- sendMedia: async ({
491
- cfg,
492
- to,
493
- mediaPath,
494
- filePath,
495
- mediaUrl,
496
- mediaType: providedMediaType,
497
- audioAsVoice,
498
- asVoice,
499
- accountId,
500
- mediaLocalRoots,
501
- log,
502
- }: any) => {
503
- const config = getConfig(cfg, accountId);
504
- const rt = getDingTalkRuntime();
505
- const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
506
- agentId: accountId,
507
- });
508
- const effectiveLog = getLogger(accountId) || log;
509
- if (!config.clientId) {
510
- throw new Error("DingTalk not configured");
511
- }
512
-
513
- // Support mediaPath/filePath/mediaUrl aliases for better CLI compatibility.
514
- const rawMediaPath = mediaPath || filePath || mediaUrl;
515
-
516
- effectiveLog?.debug?.(
517
- `[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, rawMediaPath=${rawMediaPath}`,
518
- );
519
-
520
- if (!rawMediaPath) {
521
- throw new Error(
522
- `mediaPath, filePath, or mediaUrl is required. Received: ${JSON.stringify({
523
- to,
524
- mediaPath,
525
- filePath,
526
- mediaUrl,
527
- })}`,
528
- );
529
- }
530
-
531
- let preparedMedia;
532
- try {
533
- try {
534
- preparedMedia = await prepareMediaInput(rawMediaPath, effectiveLog, config.mediaUrlAllowlist);
535
- } catch (err: any) {
536
- if (err?.response?.data !== undefined) {
537
- effectiveLog?.error?.(
538
- formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data),
539
- );
540
- }
541
- const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
542
- throw new Error(
543
- `remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`,
544
- {
545
- cause: err,
546
- },
547
- );
548
- }
549
-
550
- const actualMediaPath = preparedMedia.cleanup
551
- ? preparedMedia.path
552
- : resolveRelativePath(preparedMedia.path);
553
-
554
- effectiveLog?.debug?.(
555
- `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
556
- );
557
-
558
- const mediaType = resolveOutboundMediaType({
559
- mediaType: typeof providedMediaType === "string" ? providedMediaType : undefined,
560
- mediaPath: actualMediaPath,
561
- asVoice: readSharedAudioAsVoiceParam({ audioAsVoice, asVoice }),
562
- });
563
- let result;
564
- try {
565
- result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
566
- log: effectiveLog,
567
- accountId,
568
- storePath,
569
- conversationId: to,
570
- mediaLocalRoots,
571
- });
572
- } catch (err: any) {
573
- if (err?.response?.data !== undefined) {
574
- effectiveLog?.error?.(
575
- formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data),
576
- );
577
- }
578
- throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
579
- cause: err,
580
- });
581
- }
582
- effectiveLog?.debug?.(
583
- `[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
584
- );
585
-
586
- if (result.ok) {
587
- const data = result.data;
588
- const messageId = String(
589
- result.messageId || data?.processQueryKey || data?.messageId || randomUUID(),
590
- );
591
- return {
592
- channel: "dingtalk",
593
- messageId,
594
- meta: result.data
595
- ? { data: result.data as unknown as Record<string, unknown> }
596
- : undefined,
597
- };
598
- }
599
- throw new Error(
600
- typeof result.error === "string" ? result.error : JSON.stringify(result.error),
601
- );
602
- } catch (err: any) {
603
- if (err?.response?.data !== undefined) {
604
- effectiveLog?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia", err.response.data));
605
- }
606
- throw new Error(
607
- typeof err?.response?.data === "string"
608
- ? err.response.data
609
- : err?.message || "sendMedia failed",
610
- { cause: err },
611
- );
612
- } finally {
613
- await preparedMedia?.cleanup?.();
614
- }
615
- },
616
- },
617
- gateway: {
618
- startAccount: async (ctx: GatewayStartContext): Promise<GatewayStopResult> => {
619
- const { account, cfg, abortSignal } = ctx;
620
- const config = account.config;
621
- if (!config.clientId || !config.clientSecret) {
622
- throw new Error("DingTalk clientId and clientSecret are required");
623
- }
624
- let accountStorePath: string | undefined;
625
- try {
626
- const rt = getDingTalkRuntime();
627
- accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
628
- agentId: account.accountId,
629
- });
630
- } catch {
631
- accountStorePath = undefined;
632
- }
633
-
634
- const pluginLog = resolvePluginDebugLog({
635
- accountId: account.accountId,
636
- storePath: accountStorePath,
637
- debug: config.debug,
638
- baseLog: ctx.log,
639
- });
640
- setCurrentLogger(pluginLog, account.accountId);
641
-
642
- pluginLog?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
643
-
644
- // Preload known peer IDs from sessions so outbound delivery (e.g. cron
645
- // jobs that fire immediately after startup) can resolve the original
646
- // case-sensitive conversationId before any inbound message has arrived.
647
- preloadPeerIdsFromSessions();
648
- pluginLog?.debug?.(`[${account.accountId}] Peer ID registry preloaded from sessions`);
649
-
650
- cleanupOrphanedTempFiles(pluginLog);
651
- try {
652
- const recovered = await recoverPendingCardsForAccount(
653
- config,
654
- account.accountId,
655
- accountStorePath,
656
- pluginLog,
657
- );
658
- if (recovered > 0) {
659
- pluginLog?.info?.(
660
- `[${account.accountId}] Recovered and finalized ${recovered} unfinished card(s) from previous runtime`,
661
- );
662
- }
663
- } catch (err: any) {
664
- pluginLog?.warn?.(
665
- `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
666
- );
667
- }
668
-
669
- const useConnectionManager = config.useConnectionManager ?? true;
670
- const applyStatusPatch = (patch: Record<string, unknown>) => {
671
- ctx.setStatus({
672
- ...ctx.getStatus(),
673
- ...patch,
674
- });
675
- };
676
-
677
- // Factory that creates a fresh DWClient with the TOPIC_ROBOT callback
678
- // already registered. Each client captures its own reference for
679
- // socketCallBackResponse so acks are sent on the correct socket.
680
- // ConnectionManager uses this to create new clients during warm
681
- // reconnection, minimizing the message-loss window when the DingTalk
682
- // server initiates a disconnect for load balancing.
683
- const createStreamClient: StreamClientFactory = () => {
684
- const c = new DWClient({
685
- clientId: config.clientId,
686
- clientSecret: config.clientSecret,
687
- debug: config.debug || false,
688
- keepAlive: config.keepAlive ?? !useConnectionManager,
689
- });
690
- (c as any).sslopts = {
691
- ...(c as any).sslopts,
692
- lookup: createResolve4FallbackLookup(pluginLog, account.accountId),
693
- };
694
-
695
- instrumentConnectionStages(c);
696
-
697
- (c as any).config.autoReconnect = !useConnectionManager;
698
-
699
- c.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
700
- const messageId = res.headers?.messageId;
701
- const stats = getInboundCounters(account.accountId);
702
- stats.received += 1;
703
- const acknowledge = () => {
704
- if (!messageId) {
705
- return;
706
- }
707
- try {
708
- c.socketCallBackResponse(messageId, { success: true });
709
- stats.acked += 1;
710
- } catch (ackError: any) {
711
- pluginLog?.warn?.(
712
- `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
713
- );
714
- }
715
- };
716
- try {
717
- const data = JSON.parse(res.data) as DingTalkInboundMessage;
718
- // Record the latest inbound callback arrival for status/UI projection.
719
- // This intentionally tracks "message reached the plugin callback" rather
720
- // than "message passed dedup and completed processing".
721
- applyStatusPatch({
722
- connected: true,
723
- lastInboundAt: getCurrentTimestamp(),
724
- lastEventAt: getCurrentTimestamp(),
725
- });
726
-
727
- const robotKey = resolveRobotCode(config) || account.accountId;
728
- const msgId = data.msgId || messageId;
729
- const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
730
-
731
- if (!dedupKey) {
732
- ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
733
- stats.noMessageId += 1;
734
- acknowledge();
735
- await handleDingTalkMessage({
736
- cfg,
737
- accountId: account.accountId,
738
- data,
739
- sessionWebhook: data.sessionWebhook,
740
- log: pluginLog,
741
- dingtalkConfig: config,
742
- });
743
- stats.processed += 1;
744
- if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
745
- logInboundCounters(pluginLog, account.accountId, "periodic");
746
- }
747
- return;
748
- }
749
-
750
- if (isMessageProcessed(dedupKey)) {
751
- pluginLog?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
752
- stats.dedupSkipped += 1;
753
- acknowledge();
754
- logInboundCounters(pluginLog, account.accountId, "dedup-skipped");
755
- return;
756
- }
757
-
758
- const inflightSince = processingDedupKeys.get(dedupKey);
759
- if (inflightSince !== undefined) {
760
- if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
761
- pluginLog?.warn?.(
762
- `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
763
- );
764
- processingDedupKeys.delete(dedupKey);
765
- } else {
766
- pluginLog?.debug?.(
767
- `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
768
- );
769
- stats.inflightSkipped += 1;
770
- acknowledge();
771
- logInboundCounters(pluginLog, account.accountId, "inflight-skipped");
772
- return;
773
- }
774
- }
775
-
776
- acknowledge();
777
- processingDedupKeys.set(dedupKey, Date.now());
778
- try {
779
- await handleDingTalkMessage({
780
- cfg,
781
- accountId: account.accountId,
782
- data,
783
- sessionWebhook: data.sessionWebhook,
784
- log: pluginLog,
785
- dingtalkConfig: config,
786
- });
787
- stats.processed += 1;
788
- markMessageProcessed(dedupKey);
789
- if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
790
- logInboundCounters(pluginLog, account.accountId, "periodic");
791
- }
792
- } finally {
793
- processingDedupKeys.delete(dedupKey);
794
- }
795
- } catch (error: any) {
796
- stats.failed += 1;
797
- logInboundCounters(pluginLog, account.accountId, "failed");
798
- pluginLog?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
799
- }
800
- });
801
-
802
- c.registerCallbackListener(TOPIC_CARD, async (res: any) => {
803
- const messageId = res.headers?.messageId;
804
- const acknowledge = () => {
805
- if (!messageId) {
806
- return;
807
- }
808
- try {
809
- c.socketCallBackResponse(messageId, { success: true });
810
- } catch (ackError: any) {
811
- pluginLog?.warn?.(
812
- `[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
813
- );
814
- }
815
- };
816
-
817
- try {
818
- const payload = JSON.parse(res.data);
819
- const analysis = analyzeCardCallback(payload);
820
- pluginLog?.info?.(
821
- `[${account.accountId}] [DingTalk][CardCallback] action=${analysis.summary} raw=${JSON.stringify(payload)}`,
822
- );
823
-
824
- if (analysis.feedbackTarget && analysis.feedbackAckText) {
825
- recordExplicitFeedbackLearning({
826
- enabled: isLearningEnabled(config),
827
- autoApply: isLearningAutoApplyEnabled(config),
828
- storePath: accountStorePath,
829
- accountId: account.accountId,
830
- targetId: analysis.feedbackTarget,
831
- feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
832
- userId: analysis.userId,
833
- processQueryKey: analysis.processQueryKey,
834
- noteTtlMs: config.learningNoteTtlMs,
835
- });
836
- try {
837
- await sendProactiveTextOrMarkdown(
838
- config,
839
- analysis.feedbackTarget,
840
- analysis.feedbackAckText,
841
- {
842
- accountId: account.accountId,
843
- log: pluginLog,
844
- },
845
- );
846
- pluginLog?.info?.(
847
- `[${account.accountId}] [DingTalk][CardCallback] feedback ack sent to ${analysis.feedbackTarget}`,
848
- );
849
- } catch (sendErr: any) {
850
- pluginLog?.warn?.(
851
- `[${account.accountId}] [DingTalk][CardCallback] Failed to send feedback ack: ${sendErr?.message || String(sendErr)}`,
852
- );
853
- }
854
- }
855
- const actionResult = await handleCardAction({
856
- analysis,
857
- cfg,
858
- accountId: account.accountId,
859
- config,
860
- log: pluginLog,
861
- });
862
- if (!actionResult.handled && analysis.actionId && analysis.actionId !== "feedback_up" && analysis.actionId !== "feedback_down") {
863
- pluginLog?.debug?.(
864
- `[${account.accountId}] [DingTalk][CardCallback] Unhandled actionId=${analysis.actionId}`,
865
- );
866
- }
867
- } catch (error: any) {
868
- pluginLog?.error?.(
869
- `[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
870
- );
871
- } finally {
872
- acknowledge();
873
- }
874
- });
875
-
876
- return c;
877
- };
878
-
879
- const client = createStreamClient();
880
-
881
- // Guard against duplicate stop paths (abort signal + explicit stop).
882
- let stopped = false;
883
- let nativeStopResolve: (() => void) | undefined;
884
- const nativeStopPromise = new Promise<void>((resolve) => {
885
- nativeStopResolve = resolve;
886
- });
887
- let connectionManager: ConnectionManager | undefined;
888
-
889
- const stopClient = () => {
890
- if (stopped) {
891
- return;
892
- }
893
- stopped = true;
894
- pluginLog?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
895
- void finalizeActiveCardsForAccount(
896
- config,
897
- account.accountId,
898
- "⚠️ 服务正在重启,当前回复已中断。请重新发送你的问题。",
899
- accountStorePath,
900
- pluginLog,
901
- ).catch((err: any) => {
902
- pluginLog?.debug?.(
903
- `[${account.accountId}] Failed to finalize active cards during stop: ${err.message}`,
904
- );
905
- });
906
- if (useConnectionManager) {
907
- connectionManager?.stop();
908
- } else {
909
- try {
910
- client.disconnect();
911
- } catch (err: any) {
912
- pluginLog?.warn?.(`[${account.accountId}] Error during disconnect: ${err.message}`);
913
- }
914
- nativeStopResolve?.();
915
- }
916
-
917
- applyStatusPatch({
918
- running: false,
919
- connected: false,
920
- lastEventAt: getCurrentTimestamp(),
921
- lastStopAt: getCurrentTimestamp(),
922
- });
923
-
924
- pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
925
- closePluginDebugLog({
926
- accountId: account.accountId,
927
- storePath: accountStorePath,
928
- });
929
- };
930
-
931
- if (abortSignal) {
932
- if (abortSignal.aborted) {
933
- pluginLog?.warn?.(
934
- `[${account.accountId}] Abort signal already active, skipping connection`,
935
- );
936
-
937
- applyStatusPatch({
938
- running: false,
939
- connected: false,
940
- lastEventAt: getCurrentTimestamp(),
941
- lastStopAt: getCurrentTimestamp(),
942
- lastError: "Connection aborted before start",
943
- });
944
-
945
- throw new Error("Connection aborted before start");
946
- }
947
-
948
- abortSignal.addEventListener("abort", () => {
949
- if (stopped) {
950
- return;
951
- }
952
- pluginLog?.info?.(
953
- `[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
954
- );
955
- stopClient();
956
- });
957
- }
958
-
959
- if (!useConnectionManager) {
960
- try {
961
- await client.connect();
962
- if (!stopped) {
963
- applyStatusPatch({
964
- running: true,
965
- connected: true,
966
- lastConnectedAt: getCurrentTimestamp(),
967
- lastEventAt: getCurrentTimestamp(),
968
- lastStartAt: getCurrentTimestamp(),
969
- lastError: null,
970
- });
971
- pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
972
- await nativeStopPromise;
973
- }
974
- } catch (err: any) {
975
- pluginLog?.error?.(
976
- formatDingTalkConnectionErrorLog(
977
- // Use connect.open as base scope; instrumentation can override to connect.websocket
978
- "connect.open",
979
- err,
980
- `[${account.accountId}] Failed to establish connection: ${err.message}`,
981
- ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
982
- );
983
- applyStatusPatch({
984
- running: false,
985
- connected: false,
986
- lastEventAt: getCurrentTimestamp(),
987
- lastError: err.message || "Connection failed",
988
- });
989
- throw err;
990
- }
991
-
992
- return {
993
- stop: () => {
994
- stopClient();
995
- },
996
- };
997
- }
998
-
999
- const connectionConfig: ConnectionManagerConfig = {
1000
- maxAttempts: config.maxConnectionAttempts ?? 10,
1001
- initialDelay: config.initialReconnectDelay ?? 1000,
1002
- maxDelay: config.maxReconnectDelay ?? 60000,
1003
- jitter: config.reconnectJitter ?? 0.3,
1004
- maxReconnectCycles: config.maxReconnectCycles,
1005
- reconnectDeadlineMs: config.reconnectDeadlineMs,
1006
- onStateChange: (state: ConnectionState, error?: string) => {
1007
- if (stopped) {
1008
- return;
1009
- }
1010
- pluginLog?.debug?.(
1011
- `[${account.accountId}] Connection state changed to: ${state}${error ? ` (${error})` : ""}`,
1012
- );
1013
- if (state === ConnectionState.CONNECTED) {
1014
- applyStatusPatch({
1015
- running: true,
1016
- connected: true,
1017
- lastConnectedAt: getCurrentTimestamp(),
1018
- lastEventAt: getCurrentTimestamp(),
1019
- lastStartAt: getCurrentTimestamp(),
1020
- lastError: null,
1021
- });
1022
- } else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
1023
- // Clear stale in-flight locks for this account on disconnect.
1024
- // DingTalk will redeliver unacknowledged messages on reconnect; without
1025
- // this cleanup the redelivered messages would be silently skipped forever.
1026
- const robotKey = resolveRobotCode(config) || account.accountId;
1027
- let cleared = 0;
1028
- for (const key of processingDedupKeys.keys()) {
1029
- if (key.startsWith(`${robotKey}:`)) {
1030
- processingDedupKeys.delete(key);
1031
- cleared++;
1032
- }
1033
- }
1034
- if (cleared > 0) {
1035
- pluginLog?.info?.(
1036
- `[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
1037
- );
1038
- }
1039
- applyStatusPatch({
1040
- running: false,
1041
- connected: false,
1042
- lastEventAt: getCurrentTimestamp(),
1043
- lastError: error || `Connection ${state.toLowerCase()}`,
1044
- });
1045
- }
1046
- },
1047
- };
1048
-
1049
- pluginLog?.debug?.(
1050
- `[${account.accountId}] Connection config: maxAttempts=${connectionConfig.maxAttempts}, ` +
1051
- `initialDelay=${connectionConfig.initialDelay}ms, maxDelay=${connectionConfig.maxDelay}ms, ` +
1052
- `jitter=${connectionConfig.jitter}`,
1053
- );
1054
-
1055
- connectionManager = new ConnectionManager(
1056
- client,
1057
- account.accountId,
1058
- connectionConfig,
1059
- pluginLog,
1060
- createStreamClient,
1061
- );
1062
-
1063
- try {
1064
- await connectionManager.connect();
1065
-
1066
- if (!stopped && connectionManager.isConnected()) {
1067
- applyStatusPatch({
1068
- running: true,
1069
- connected: true,
1070
- lastConnectedAt: getCurrentTimestamp(),
1071
- lastEventAt: getCurrentTimestamp(),
1072
- lastStartAt: getCurrentTimestamp(),
1073
- lastError: null,
1074
- });
1075
- pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
1076
-
1077
- await connectionManager.waitForStop();
1078
- } else {
1079
- pluginLog?.info?.(
1080
- `[${account.accountId}] DingTalk Stream client connect() completed but channel is ` +
1081
- `not running (stopped=${stopped}, connected=${connectionManager.isConnected()})`,
1082
- );
1083
- }
1084
- } catch (err: any) {
1085
- pluginLog?.error?.(
1086
- formatDingTalkConnectionErrorLog(
1087
- // Use connect.open as base scope; instrumentation can override to connect.websocket
1088
- "connect.open",
1089
- err,
1090
- `[${account.accountId}] Failed to establish connection: ${err.message}`,
1091
- ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
1092
- );
1093
-
1094
- applyStatusPatch({
1095
- running: false,
1096
- connected: false,
1097
- lastEventAt: getCurrentTimestamp(),
1098
- lastError: err.message || "Connection failed",
1099
- });
1100
- throw err;
1101
- }
1102
-
1103
- return {
1104
- stop: () => {
1105
- stopClient();
1106
- },
1107
- };
1108
- },
1109
- },
1110
- status: {
1111
- defaultRuntime: {
1112
- accountId: "default",
1113
- running: false,
1114
- connected: false,
1115
- lastEventAt: null,
1116
- lastConnectedAt: null,
1117
- lastInboundAt: null,
1118
- lastStartAt: null,
1119
- lastStopAt: null,
1120
- lastError: null,
1121
- },
1122
- collectStatusIssues: (accounts: any[]) => {
1123
- return accounts.flatMap((account) => {
1124
- if (!account.configured) {
1125
- return [
1126
- {
1127
- channel: "dingtalk",
1128
- accountId: account.accountId,
1129
- kind: "config" as const,
1130
- message: "Account not configured (missing clientId or clientSecret)",
1131
- },
1132
- ];
1133
- }
1134
- return [];
1135
- });
1136
- },
1137
- buildChannelSummary: ({ snapshot }: any) => ({
1138
- configured: snapshot?.configured ?? false,
1139
- running: snapshot?.running ?? false,
1140
- lastStartAt: snapshot?.lastStartAt ?? null,
1141
- lastStopAt: snapshot?.lastStopAt ?? null,
1142
- lastError: snapshot?.lastError ?? null,
1143
- }),
1144
- probeAccount: async ({ account, timeoutMs }: any) => {
1145
- if (!account.configured || !account.config?.clientId || !account.config?.clientSecret) {
1146
- return { ok: false, error: "Not configured" };
1147
- }
1148
- try {
1149
- const controller = new AbortController();
1150
- const timeoutId = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
1151
- try {
1152
- await getAccessToken(account.config);
1153
- return { ok: true, details: { clientId: account.config.clientId } };
1154
- } finally {
1155
- if (timeoutId) {
1156
- clearTimeout(timeoutId);
1157
- }
1158
- }
1159
- } catch (error: any) {
1160
- return { ok: false, error: error.message };
1161
- }
1162
- },
1163
- buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => {
1164
- const running = runtime?.running ?? snapshot?.running ?? false;
1165
- const persistedLastEventAt = runtime?.lastEventAt ?? snapshot?.lastEventAt ?? null;
1166
-
1167
- return {
1168
- accountId: account.accountId,
1169
- name: account.name,
1170
- enabled: account.enabled,
1171
- configured: account.configured,
1172
- clientId: account.config?.clientId ?? null,
1173
- running,
1174
- connected: runtime?.connected ?? snapshot?.connected ?? null,
1175
- lastEventAt: running ? getCurrentTimestamp() : persistedLastEventAt,
1176
- lastConnectedAt: runtime?.lastConnectedAt ?? snapshot?.lastConnectedAt ?? null,
1177
- lastInboundAt: runtime?.lastInboundAt ?? snapshot?.lastInboundAt ?? null,
1178
- lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
1179
- lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
1180
- lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
1181
- probe,
1182
- };
1183
- },
1184
- },
120
+ actions: createDingTalkMessageActions(),
121
+ outbound: createDingTalkOutbound(),
122
+ gateway: createDingTalkGateway(),
123
+ status: createDingTalkStatus(),
1185
124
  };
1186
125
 
126
+ export { CHANNEL_INFLIGHT_NAMESPACE_POLICY };
127
+ export { getAccessToken } from "./auth";
128
+ export { createAICard, finishAICard, streamAICard } from "./card-service";
129
+ export { detectMediaTypeFromExtension } from "./media-utils";
130
+ export { getLogger } from "./logger-context";
1187
131
  export {
1188
132
  sendBySession,
1189
- createAICard,
1190
- streamAICard,
1191
- finishAICard,
1192
133
  sendMessage,
1193
- uploadMedia,
1194
134
  sendProactiveMedia,
1195
- getAccessToken,
1196
- getLogger,
1197
- };
1198
- export { detectMediaTypeFromExtension } from "./media-utils";
135
+ uploadMedia,
136
+ } from "./send-service";