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