@adongguo/dingtalk 0.1.3

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/bot.ts ADDED
@@ -0,0 +1,403 @@
1
+ import type { DWClient } from "dingtalk-stream";
2
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import {
4
+ buildPendingHistoryContextFromMap,
5
+ recordPendingHistoryEntryIfEnabled,
6
+ clearHistoryEntriesIfEnabled,
7
+ DEFAULT_GROUP_HISTORY_LIMIT,
8
+ type HistoryEntry,
9
+ } from "openclaw/plugin-sdk";
10
+ import type { DingTalkConfig, DingTalkMessageContext, DingTalkMediaInfo, DingTalkIncomingMessage } from "./types.js";
11
+ import { getDingTalkRuntime } from "./runtime.js";
12
+ import {
13
+ resolveDingTalkGroupConfig,
14
+ resolveDingTalkReplyPolicy,
15
+ resolveDingTalkAllowlistMatch,
16
+ isDingTalkGroupAllowed,
17
+ } from "./policy.js";
18
+ import { createDingTalkReplyDispatcher } from "./reply-dispatcher.js";
19
+ import { downloadMediaDingTalk } from "./media.js";
20
+
21
+ function parseMessageContent(message: DingTalkIncomingMessage): string {
22
+ if (message.msgtype === "text" && message.text?.content) {
23
+ return message.text.content.trim();
24
+ }
25
+ if (message.msgtype === "richText" && message.content) {
26
+ // For richText, try to extract text content
27
+ try {
28
+ const parsed = JSON.parse(message.content);
29
+ return extractRichTextContent(parsed);
30
+ } catch {
31
+ return message.content;
32
+ }
33
+ }
34
+ // For other message types, return a placeholder
35
+ return `[${message.msgtype}]`;
36
+ }
37
+
38
+ function extractRichTextContent(richText: unknown): string {
39
+ if (!richText || typeof richText !== "object") return "";
40
+ const parts: string[] = [];
41
+
42
+ function traverse(node: unknown): void {
43
+ if (!node || typeof node !== "object") return;
44
+ if (Array.isArray(node)) {
45
+ for (const item of node) {
46
+ traverse(item);
47
+ }
48
+ return;
49
+ }
50
+ const obj = node as Record<string, unknown>;
51
+ if (obj.text && typeof obj.text === "string") {
52
+ parts.push(obj.text);
53
+ }
54
+ if (obj.content) {
55
+ traverse(obj.content);
56
+ }
57
+ }
58
+
59
+ traverse(richText);
60
+ return parts.join("").trim() || "[富文本消息]";
61
+ }
62
+
63
+ function checkBotMentioned(message: DingTalkIncomingMessage): boolean {
64
+ // In DingTalk, if the bot is mentioned, isInAtList will be true
65
+ if (message.isInAtList) return true;
66
+ // Also check atUsers array
67
+ if (message.atUsers && message.atUsers.length > 0) return true;
68
+ return false;
69
+ }
70
+
71
+ function stripBotMention(text: string): string {
72
+ // DingTalk mentions are typically @bot_name format
73
+ // The text content usually already has mentions stripped in some cases
74
+ // But let's clean up any remaining @mentions at the start
75
+ return text.replace(/^@\S+\s*/g, "").trim();
76
+ }
77
+
78
+ function inferPlaceholder(msgtype: string): string {
79
+ switch (msgtype) {
80
+ case "image":
81
+ case "picture":
82
+ return "<media:image>";
83
+ case "file":
84
+ return "<media:document>";
85
+ case "voice":
86
+ return "<media:audio>";
87
+ case "video":
88
+ return "<media:video>";
89
+ default:
90
+ return "<media:document>";
91
+ }
92
+ }
93
+
94
+ async function resolveDingTalkMediaList(params: {
95
+ cfg: ClawdbotConfig;
96
+ message: DingTalkIncomingMessage;
97
+ maxBytes: number;
98
+ log?: (msg: string) => void;
99
+ client?: DWClient;
100
+ }): Promise<DingTalkMediaInfo[]> {
101
+ const { cfg, message, maxBytes, log, client } = params;
102
+
103
+ // Only process media message types
104
+ const mediaTypes = ["image", "picture", "file", "voice", "video"];
105
+ if (!mediaTypes.includes(message.msgtype)) {
106
+ return [];
107
+ }
108
+
109
+ // DingTalk requires downloadCode to download media
110
+ if (!message.downloadCode) {
111
+ log?.(`dingtalk: no downloadCode for ${message.msgtype} message`);
112
+ return [];
113
+ }
114
+
115
+ const out: DingTalkMediaInfo[] = [];
116
+ const core = getDingTalkRuntime();
117
+
118
+ try {
119
+ const result = await downloadMediaDingTalk({
120
+ cfg,
121
+ downloadCode: message.downloadCode,
122
+ robotCode: message.robotCode,
123
+ client,
124
+ });
125
+
126
+ if (!result) {
127
+ log?.(`dingtalk: failed to download ${message.msgtype} media`);
128
+ return [];
129
+ }
130
+
131
+ let contentType = result.contentType;
132
+ if (!contentType) {
133
+ contentType = await core.media.detectMime({ buffer: result.buffer });
134
+ }
135
+
136
+ const saved = await core.channel.media.saveMediaBuffer(
137
+ result.buffer,
138
+ contentType,
139
+ "inbound",
140
+ maxBytes,
141
+ );
142
+
143
+ out.push({
144
+ path: saved.path,
145
+ contentType: saved.contentType,
146
+ placeholder: inferPlaceholder(message.msgtype),
147
+ });
148
+
149
+ log?.(`dingtalk: downloaded ${message.msgtype} media, saved to ${saved.path}`);
150
+ } catch (err) {
151
+ log?.(`dingtalk: failed to download ${message.msgtype} media: ${String(err)}`);
152
+ }
153
+
154
+ return out;
155
+ }
156
+
157
+ function buildDingTalkMediaPayload(
158
+ mediaList: DingTalkMediaInfo[],
159
+ ): {
160
+ MediaPath?: string;
161
+ MediaType?: string;
162
+ MediaUrl?: string;
163
+ MediaPaths?: string[];
164
+ MediaUrls?: string[];
165
+ MediaTypes?: string[];
166
+ } {
167
+ const first = mediaList[0];
168
+ const mediaPaths = mediaList.map((media) => media.path);
169
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
170
+ return {
171
+ MediaPath: first?.path,
172
+ MediaType: first?.contentType,
173
+ MediaUrl: first?.path,
174
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
175
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
176
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
177
+ };
178
+ }
179
+
180
+ export function parseDingTalkMessage(message: DingTalkIncomingMessage): DingTalkMessageContext {
181
+ const rawContent = parseMessageContent(message);
182
+ const mentionedBot = checkBotMentioned(message);
183
+ const content = stripBotMention(rawContent);
184
+ const isGroup = message.conversationType === "2";
185
+
186
+ return {
187
+ conversationId: message.conversationId,
188
+ messageId: message.msgId,
189
+ senderId: message.senderStaffId || "",
190
+ senderNick: message.senderNick,
191
+ chatType: isGroup ? "group" : "p2p",
192
+ mentionedBot,
193
+ sessionWebhook: message.sessionWebhook,
194
+ sessionWebhookExpiredTime: message.sessionWebhookExpiredTime,
195
+ content,
196
+ contentType: message.msgtype,
197
+ robotCode: message.robotCode,
198
+ chatbotCorpId: message.chatbotCorpId,
199
+ isAdmin: message.isAdmin,
200
+ };
201
+ }
202
+
203
+ export async function handleDingTalkMessage(params: {
204
+ cfg: ClawdbotConfig;
205
+ message: DingTalkIncomingMessage;
206
+ runtime?: RuntimeEnv;
207
+ chatHistories?: Map<string, HistoryEntry[]>;
208
+ client?: DWClient;
209
+ }): Promise<void> {
210
+ const { cfg, message, runtime, chatHistories, client } = params;
211
+ const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
212
+ const log = runtime?.log ?? console.log;
213
+ const error = runtime?.error ?? console.error;
214
+
215
+ const ctx = parseDingTalkMessage(message);
216
+ const isGroup = ctx.chatType === "group";
217
+
218
+ log(`dingtalk: received message from ${ctx.senderNick} (${ctx.senderId}) in ${ctx.conversationId} (${ctx.chatType})`);
219
+
220
+ const historyLimit = Math.max(
221
+ 0,
222
+ dingtalkCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
223
+ );
224
+
225
+ if (isGroup) {
226
+ const groupPolicy = dingtalkCfg?.groupPolicy ?? "open";
227
+ const groupAllowFrom = dingtalkCfg?.groupAllowFrom ?? [];
228
+ const groupConfig = resolveDingTalkGroupConfig({ cfg: dingtalkCfg, groupId: ctx.conversationId });
229
+
230
+ const senderAllowFrom = groupConfig?.allowFrom ?? groupAllowFrom;
231
+ const allowed = isDingTalkGroupAllowed({
232
+ groupPolicy,
233
+ allowFrom: senderAllowFrom,
234
+ senderId: ctx.senderId,
235
+ senderName: ctx.senderNick,
236
+ });
237
+
238
+ if (!allowed) {
239
+ log(`dingtalk: sender ${ctx.senderId} not in group allowlist`);
240
+ return;
241
+ }
242
+
243
+ const { requireMention } = resolveDingTalkReplyPolicy({
244
+ isDirectMessage: false,
245
+ globalConfig: dingtalkCfg,
246
+ groupConfig,
247
+ });
248
+
249
+ if (requireMention && !ctx.mentionedBot) {
250
+ log(`dingtalk: message in group ${ctx.conversationId} did not mention bot, recording to history`);
251
+ if (chatHistories) {
252
+ recordPendingHistoryEntryIfEnabled({
253
+ historyMap: chatHistories,
254
+ historyKey: ctx.conversationId,
255
+ limit: historyLimit,
256
+ entry: {
257
+ sender: ctx.senderNick || ctx.senderId,
258
+ body: ctx.content,
259
+ timestamp: Date.now(),
260
+ messageId: ctx.messageId,
261
+ },
262
+ });
263
+ }
264
+ return;
265
+ }
266
+ } else {
267
+ const dmPolicy = dingtalkCfg?.dmPolicy ?? "pairing";
268
+ const allowFrom = dingtalkCfg?.allowFrom ?? [];
269
+
270
+ if (dmPolicy === "allowlist") {
271
+ const match = resolveDingTalkAllowlistMatch({
272
+ allowFrom,
273
+ senderId: ctx.senderId,
274
+ });
275
+ if (!match.allowed) {
276
+ log(`dingtalk: sender ${ctx.senderId} not in DM allowlist`);
277
+ return;
278
+ }
279
+ }
280
+ }
281
+
282
+ try {
283
+ const core = getDingTalkRuntime();
284
+
285
+ const dingtalkFrom = isGroup ? `dingtalk:group:${ctx.conversationId}` : `dingtalk:${ctx.senderId}`;
286
+ const dingtalkTo = isGroup ? `chat:${ctx.conversationId}` : `user:${ctx.senderId}`;
287
+
288
+ const route = core.channel.routing.resolveAgentRoute({
289
+ cfg,
290
+ channel: "dingtalk",
291
+ peer: {
292
+ kind: isGroup ? "group" : "dm",
293
+ id: isGroup ? ctx.conversationId : ctx.senderId,
294
+ },
295
+ });
296
+
297
+ const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
298
+ const inboundLabel = isGroup
299
+ ? `DingTalk message in group ${ctx.conversationId}`
300
+ : `DingTalk DM from ${ctx.senderNick}`;
301
+
302
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
303
+ sessionKey: route.sessionKey,
304
+ contextKey: `dingtalk:message:${ctx.conversationId}:${ctx.messageId}`,
305
+ });
306
+
307
+ // Resolve media from message
308
+ const mediaMaxBytes = (dingtalkCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
309
+ const mediaList = await resolveDingTalkMediaList({
310
+ cfg,
311
+ message,
312
+ maxBytes: mediaMaxBytes,
313
+ log,
314
+ client,
315
+ });
316
+ const mediaPayload = buildDingTalkMediaPayload(mediaList);
317
+
318
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
319
+
320
+ const body = core.channel.reply.formatAgentEnvelope({
321
+ channel: "DingTalk",
322
+ from: isGroup ? ctx.conversationId : ctx.senderNick || ctx.senderId,
323
+ timestamp: new Date(),
324
+ envelope: envelopeOptions,
325
+ body: ctx.content,
326
+ });
327
+
328
+ let combinedBody = body;
329
+ const historyKey = isGroup ? ctx.conversationId : undefined;
330
+
331
+ if (isGroup && historyKey && chatHistories) {
332
+ combinedBody = buildPendingHistoryContextFromMap({
333
+ historyMap: chatHistories,
334
+ historyKey,
335
+ limit: historyLimit,
336
+ currentMessage: combinedBody,
337
+ formatEntry: (entry) =>
338
+ core.channel.reply.formatAgentEnvelope({
339
+ channel: "DingTalk",
340
+ from: ctx.conversationId,
341
+ timestamp: entry.timestamp,
342
+ body: `${entry.sender}: ${entry.body}`,
343
+ envelope: envelopeOptions,
344
+ }),
345
+ });
346
+ }
347
+
348
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
349
+ Body: combinedBody,
350
+ RawBody: ctx.content,
351
+ CommandBody: ctx.content,
352
+ From: dingtalkFrom,
353
+ To: dingtalkTo,
354
+ SessionKey: route.sessionKey,
355
+ AccountId: route.accountId,
356
+ ChatType: isGroup ? "group" : "direct",
357
+ GroupSubject: isGroup ? ctx.conversationId : undefined,
358
+ SenderName: ctx.senderNick || ctx.senderId,
359
+ SenderId: ctx.senderId,
360
+ Provider: "dingtalk" as const,
361
+ Surface: "dingtalk" as const,
362
+ MessageSid: ctx.messageId,
363
+ Timestamp: Date.now(),
364
+ WasMentioned: ctx.mentionedBot,
365
+ CommandAuthorized: true,
366
+ OriginatingChannel: "dingtalk" as const,
367
+ OriginatingTo: dingtalkTo,
368
+ ...mediaPayload,
369
+ });
370
+
371
+ const { dispatcher, replyOptions, markDispatchIdle } = createDingTalkReplyDispatcher({
372
+ cfg,
373
+ agentId: route.agentId,
374
+ runtime: runtime as RuntimeEnv,
375
+ conversationId: ctx.conversationId,
376
+ sessionWebhook: ctx.sessionWebhook,
377
+ client,
378
+ });
379
+
380
+ log(`dingtalk: dispatching to agent (session=${route.sessionKey})`);
381
+
382
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
383
+ ctx: ctxPayload,
384
+ cfg,
385
+ dispatcher,
386
+ replyOptions,
387
+ });
388
+
389
+ markDispatchIdle();
390
+
391
+ if (isGroup && historyKey && chatHistories) {
392
+ clearHistoryEntriesIfEnabled({
393
+ historyMap: chatHistories,
394
+ historyKey,
395
+ limit: historyLimit,
396
+ });
397
+ }
398
+
399
+ log(`dingtalk: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
400
+ } catch (err) {
401
+ error(`dingtalk: failed to dispatch message: ${String(err)}`);
402
+ }
403
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,220 @@
1
+ import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
+ import type { ResolvedDingTalkAccount, DingTalkConfig } from "./types.js";
4
+ import { resolveDingTalkAccount, resolveDingTalkCredentials } from "./accounts.js";
5
+ import { dingtalkOutbound } from "./outbound.js";
6
+ import { probeDingTalk } from "./probe.js";
7
+ import { resolveDingTalkGroupToolPolicy } from "./policy.js";
8
+ import { normalizeDingTalkTarget, looksLikeDingTalkId } from "./targets.js";
9
+ import {
10
+ listDingTalkDirectoryPeers,
11
+ listDingTalkDirectoryGroups,
12
+ listDingTalkDirectoryPeersLive,
13
+ listDingTalkDirectoryGroupsLive,
14
+ } from "./directory.js";
15
+ import { dingtalkOnboardingAdapter } from "./onboarding.js";
16
+
17
+ const meta = {
18
+ id: "dingtalk",
19
+ label: "DingTalk",
20
+ selectionLabel: "DingTalk (钉钉)",
21
+ docsPath: "/channels/dingtalk",
22
+ docsLabel: "dingtalk",
23
+ blurb: "钉钉/DingTalk enterprise messaging.",
24
+ aliases: ["dingding"],
25
+ order: 70,
26
+ } as const;
27
+
28
+ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
29
+ id: "dingtalk",
30
+ meta: {
31
+ ...meta,
32
+ },
33
+ pairing: {
34
+ idLabel: "dingtalkUserId",
35
+ normalizeAllowEntry: (entry) => entry.replace(/^(dingtalk|user|staff):/i, ""),
36
+ notifyApproval: async ({ cfg, id }) => {
37
+ // Note: DingTalk pairing approval requires sessionWebhook which is only available
38
+ // during active message handling. This is a limitation of the DingTalk API.
39
+ console.log(`[dingtalk] Pairing approved for user ${id}. Cannot send notification without sessionWebhook.`);
40
+ },
41
+ },
42
+ capabilities: {
43
+ chatTypes: ["direct", "channel"],
44
+ polls: false,
45
+ threads: false, // DingTalk has limited thread support
46
+ media: true,
47
+ reactions: false, // DingTalk doesn't support reactions via bot API
48
+ edit: false, // DingTalk doesn't support message editing via sessionWebhook
49
+ reply: true,
50
+ },
51
+ agentPrompt: {
52
+ messageToolHints: () => [
53
+ "- DingTalk targeting: messages are sent via sessionWebhook to the current conversation.",
54
+ "- DingTalk supports text, markdown, and ActionCard message types.",
55
+ ],
56
+ },
57
+ groups: {
58
+ resolveToolPolicy: resolveDingTalkGroupToolPolicy,
59
+ },
60
+ reload: { configPrefixes: ["channels.dingtalk"] },
61
+ configSchema: {
62
+ schema: {
63
+ type: "object",
64
+ additionalProperties: false,
65
+ properties: {
66
+ enabled: { type: "boolean" },
67
+ appKey: { type: "string" },
68
+ appSecret: { type: "string" },
69
+ robotCode: { type: "string" },
70
+ connectionMode: { type: "string", enum: ["stream", "webhook"] },
71
+ webhookPath: { type: "string" },
72
+ webhookPort: { type: "integer", minimum: 1 },
73
+ dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
74
+ allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
75
+ groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
76
+ groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
77
+ requireMention: { type: "boolean" },
78
+ historyLimit: { type: "integer", minimum: 0 },
79
+ dmHistoryLimit: { type: "integer", minimum: 0 },
80
+ textChunkLimit: { type: "integer", minimum: 1 },
81
+ chunkMode: { type: "string", enum: ["length", "newline"] },
82
+ mediaMaxMb: { type: "number", minimum: 0 },
83
+ renderMode: { type: "string", enum: ["auto", "raw", "card"] },
84
+ cooldownMs: { type: "integer", minimum: 0 },
85
+ },
86
+ },
87
+ },
88
+ config: {
89
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
90
+ resolveAccount: (cfg) => resolveDingTalkAccount({ cfg }),
91
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
92
+ setAccountEnabled: ({ cfg, enabled }) => ({
93
+ ...cfg,
94
+ channels: {
95
+ ...cfg.channels,
96
+ dingtalk: {
97
+ ...cfg.channels?.dingtalk,
98
+ enabled,
99
+ },
100
+ },
101
+ }),
102
+ deleteAccount: ({ cfg }) => {
103
+ const next = { ...cfg } as ClawdbotConfig;
104
+ const nextChannels = { ...cfg.channels };
105
+ delete (nextChannels as Record<string, unknown>).dingtalk;
106
+ if (Object.keys(nextChannels).length > 0) {
107
+ next.channels = nextChannels;
108
+ } else {
109
+ delete next.channels;
110
+ }
111
+ return next;
112
+ },
113
+ isConfigured: (_account, cfg) =>
114
+ Boolean(resolveDingTalkCredentials(cfg.channels?.dingtalk as DingTalkConfig | undefined)),
115
+ describeAccount: (account) => ({
116
+ accountId: account.accountId,
117
+ enabled: account.enabled,
118
+ configured: account.configured,
119
+ }),
120
+ resolveAllowFrom: ({ cfg }) =>
121
+ (cfg.channels?.dingtalk as DingTalkConfig | undefined)?.allowFrom ?? [],
122
+ formatAllowFrom: ({ allowFrom }) =>
123
+ allowFrom
124
+ .map((entry) => String(entry).trim())
125
+ .filter(Boolean)
126
+ .map((entry) => entry.toLowerCase()),
127
+ },
128
+ security: {
129
+ collectWarnings: ({ cfg }) => {
130
+ const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
131
+ const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
132
+ const groupPolicy = dingtalkCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
133
+ if (groupPolicy !== "open") return [];
134
+ return [
135
+ `- DingTalk groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.dingtalk.groupPolicy="allowlist" + channels.dingtalk.groupAllowFrom to restrict senders.`,
136
+ ];
137
+ },
138
+ },
139
+ setup: {
140
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
141
+ applyAccountConfig: ({ cfg }) => ({
142
+ ...cfg,
143
+ channels: {
144
+ ...cfg.channels,
145
+ dingtalk: {
146
+ ...cfg.channels?.dingtalk,
147
+ enabled: true,
148
+ },
149
+ },
150
+ }),
151
+ },
152
+ onboarding: dingtalkOnboardingAdapter,
153
+ messaging: {
154
+ normalizeTarget: normalizeDingTalkTarget,
155
+ targetResolver: {
156
+ looksLikeId: looksLikeDingTalkId,
157
+ hint: "<conversationId|user:staffId>",
158
+ },
159
+ },
160
+ directory: {
161
+ self: async () => null,
162
+ listPeers: async ({ cfg, query, limit }) =>
163
+ listDingTalkDirectoryPeers({ cfg, query, limit }),
164
+ listGroups: async ({ cfg, query, limit }) =>
165
+ listDingTalkDirectoryGroups({ cfg, query, limit }),
166
+ listPeersLive: async ({ cfg, query, limit }) =>
167
+ listDingTalkDirectoryPeersLive({ cfg, query, limit }),
168
+ listGroupsLive: async ({ cfg, query, limit }) =>
169
+ listDingTalkDirectoryGroupsLive({ cfg, query, limit }),
170
+ },
171
+ outbound: dingtalkOutbound,
172
+ status: {
173
+ defaultRuntime: {
174
+ accountId: DEFAULT_ACCOUNT_ID,
175
+ running: false,
176
+ lastStartAt: null,
177
+ lastStopAt: null,
178
+ lastError: null,
179
+ port: null,
180
+ },
181
+ buildChannelSummary: ({ snapshot }) => ({
182
+ configured: snapshot.configured ?? false,
183
+ running: snapshot.running ?? false,
184
+ lastStartAt: snapshot.lastStartAt ?? null,
185
+ lastStopAt: snapshot.lastStopAt ?? null,
186
+ lastError: snapshot.lastError ?? null,
187
+ port: snapshot.port ?? null,
188
+ probe: snapshot.probe,
189
+ lastProbeAt: snapshot.lastProbeAt ?? null,
190
+ }),
191
+ probeAccount: async ({ cfg }) =>
192
+ await probeDingTalk(cfg.channels?.dingtalk as DingTalkConfig | undefined),
193
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
194
+ accountId: account.accountId,
195
+ enabled: account.enabled,
196
+ configured: account.configured,
197
+ running: runtime?.running ?? false,
198
+ lastStartAt: runtime?.lastStartAt ?? null,
199
+ lastStopAt: runtime?.lastStopAt ?? null,
200
+ lastError: runtime?.lastError ?? null,
201
+ port: runtime?.port ?? null,
202
+ probe,
203
+ }),
204
+ },
205
+ gateway: {
206
+ startAccount: async (ctx) => {
207
+ const { monitorDingTalkProvider } = await import("./monitor.js");
208
+ const dingtalkCfg = ctx.cfg.channels?.dingtalk as DingTalkConfig | undefined;
209
+ const port = dingtalkCfg?.webhookPort ?? null;
210
+ ctx.setStatus({ accountId: ctx.accountId, port });
211
+ ctx.log?.info(`starting dingtalk provider (mode: ${dingtalkCfg?.connectionMode ?? "stream"})`);
212
+ return monitorDingTalkProvider({
213
+ config: ctx.cfg,
214
+ runtime: ctx.runtime,
215
+ abortSignal: ctx.abortSignal,
216
+ accountId: ctx.accountId,
217
+ });
218
+ },
219
+ },
220
+ };
package/src/client.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { DWClient } from "dingtalk-stream";
2
+ import type { DingTalkConfig } from "./types.js";
3
+ import { resolveDingTalkCredentials } from "./accounts.js";
4
+
5
+ let cachedClient: DWClient | null = null;
6
+ let cachedConfig: { appKey: string; appSecret: string } | null = null;
7
+
8
+ export function createDingTalkClient(cfg: DingTalkConfig): DWClient {
9
+ const creds = resolveDingTalkCredentials(cfg);
10
+ if (!creds) {
11
+ throw new Error("DingTalk credentials not configured (appKey, appSecret required)");
12
+ }
13
+
14
+ if (
15
+ cachedClient &&
16
+ cachedConfig &&
17
+ cachedConfig.appKey === creds.appKey &&
18
+ cachedConfig.appSecret === creds.appSecret
19
+ ) {
20
+ return cachedClient;
21
+ }
22
+
23
+ const client = new DWClient({
24
+ clientId: creds.appKey,
25
+ clientSecret: creds.appSecret,
26
+ });
27
+
28
+ cachedClient = client;
29
+ cachedConfig = { appKey: creds.appKey, appSecret: creds.appSecret };
30
+
31
+ return client;
32
+ }
33
+
34
+ export function clearClientCache() {
35
+ if (cachedClient) {
36
+ try {
37
+ cachedClient.disconnect();
38
+ } catch {
39
+ // Ignore disconnect errors
40
+ }
41
+ }
42
+ cachedClient = null;
43
+ cachedConfig = null;
44
+ }
45
+
46
+ export async function getAccessToken(cfg: DingTalkConfig): Promise<string> {
47
+ const client = createDingTalkClient(cfg);
48
+ return await client.getAccessToken();
49
+ }