@yaoyuanchao/dingtalk 1.5.10 → 1.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/package.json +1 -1
- package/src/channel.ts +515 -510
- package/src/monitor.ts +312 -32
- package/src/quoted-file-service.ts +404 -0
- package/src/quoted-msg-cache.ts +213 -0
package/src/channel.ts
CHANGED
|
@@ -1,510 +1,515 @@
|
|
|
1
|
-
import { getDingTalkRuntime } from './runtime.js';
|
|
2
|
-
import { resolveDingTalkAccount } from './accounts.js';
|
|
3
|
-
import { startDingTalkMonitor } from './monitor.js';
|
|
4
|
-
import { sendDingTalkRestMessage, uploadMediaFile, sendFileMessage, textToMarkdownFile } from './api.js';
|
|
5
|
-
import { probeDingTalk } from './probe.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Parse outbound `to` address, stripping optional channel prefix.
|
|
9
|
-
* Handles: "dm:id", "group:id", "dingtalk:dm:id", "dingtalk:group:id",
|
|
10
|
-
* and bare "id" (treated as DM userId).
|
|
11
|
-
*/
|
|
12
|
-
function parseOutboundTo(to: string): { type: string; id: string } {
|
|
13
|
-
const parts = to.split(':');
|
|
14
|
-
// Strip channel prefix: "dingtalk:dm:id" → "dm:id"
|
|
15
|
-
if (parts[0] === 'dingtalk' && parts.length > 2) {
|
|
16
|
-
parts.shift();
|
|
17
|
-
}
|
|
18
|
-
// Known types
|
|
19
|
-
if (parts[0] === 'dm' || parts[0] === 'group') {
|
|
20
|
-
return { type: parts[0], id: parts.slice(1).join(':') };
|
|
21
|
-
}
|
|
22
|
-
// Bare ID (no type prefix) — treat as DM userId
|
|
23
|
-
return { type: 'dm', id: to };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const dingtalkPlugin = {
|
|
27
|
-
id: 'dingtalk',
|
|
28
|
-
|
|
29
|
-
meta: {
|
|
30
|
-
label: 'DingTalk',
|
|
31
|
-
selectionLabel: 'DingTalk (钉钉)',
|
|
32
|
-
detailLabel: 'DingTalk',
|
|
33
|
-
blurb: 'DingTalk bot via Stream Mode (WebSocket)',
|
|
34
|
-
aliases: ['dingding', 'dd'],
|
|
35
|
-
order: 75,
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
capabilities: {
|
|
39
|
-
chatTypes: ['direct', 'group'],
|
|
40
|
-
media: true, // Supports images via markdown in sessionWebhook replies
|
|
41
|
-
files: true, // Supports file upload and sending
|
|
42
|
-
threads: false,
|
|
43
|
-
reactions: false,
|
|
44
|
-
mentions: true,
|
|
45
|
-
},
|
|
46
|
-
|
|
47
|
-
config: {
|
|
48
|
-
schema: {
|
|
49
|
-
type: 'object',
|
|
50
|
-
properties: {
|
|
51
|
-
enabled: {
|
|
52
|
-
type: 'boolean',
|
|
53
|
-
title: 'Enable DingTalk',
|
|
54
|
-
default: true,
|
|
55
|
-
},
|
|
56
|
-
clientId: {
|
|
57
|
-
type: 'string',
|
|
58
|
-
title: 'Client ID (AppKey)',
|
|
59
|
-
description: 'DingTalk application AppKey',
|
|
60
|
-
},
|
|
61
|
-
clientSecret: {
|
|
62
|
-
type: 'string',
|
|
63
|
-
title: 'Client Secret (AppSecret)',
|
|
64
|
-
description: 'DingTalk application AppSecret',
|
|
65
|
-
secret: true,
|
|
66
|
-
},
|
|
67
|
-
robotCode: {
|
|
68
|
-
type: 'string',
|
|
69
|
-
title: 'Robot Code (Optional)',
|
|
70
|
-
description: 'Optional robot code, defaults to Client ID',
|
|
71
|
-
},
|
|
72
|
-
dm: {
|
|
73
|
-
type: 'object',
|
|
74
|
-
title: 'Direct Message Settings',
|
|
75
|
-
properties: {
|
|
76
|
-
enabled: {
|
|
77
|
-
type: 'boolean',
|
|
78
|
-
title: 'Enable DM',
|
|
79
|
-
default: true,
|
|
80
|
-
},
|
|
81
|
-
policy: {
|
|
82
|
-
type: 'string',
|
|
83
|
-
title: 'DM Access Policy',
|
|
84
|
-
enum: ['disabled', 'pairing', 'allowlist', 'open'],
|
|
85
|
-
default: 'pairing',
|
|
86
|
-
description: 'disabled=no DM, pairing=show staffId to add, allowlist=only allowed users, open=everyone',
|
|
87
|
-
},
|
|
88
|
-
allowFrom: {
|
|
89
|
-
type: 'array',
|
|
90
|
-
title: 'Allowed Staff IDs',
|
|
91
|
-
items: { type: 'string' },
|
|
92
|
-
default: [],
|
|
93
|
-
description: 'List of staff IDs allowed to DM the bot',
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
groupPolicy: {
|
|
98
|
-
type: 'string',
|
|
99
|
-
title: 'Group Chat Policy',
|
|
100
|
-
enum: ['disabled', 'allowlist', 'open'],
|
|
101
|
-
default: 'allowlist',
|
|
102
|
-
description: 'disabled=no groups, allowlist=specific groups, open=all groups',
|
|
103
|
-
},
|
|
104
|
-
groupAllowlist: {
|
|
105
|
-
type: 'array',
|
|
106
|
-
title: 'Allowed Group IDs',
|
|
107
|
-
items: { type: 'string' },
|
|
108
|
-
default: [],
|
|
109
|
-
description: 'List of conversation IDs for allowed groups (only used when groupPolicy is "allowlist")',
|
|
110
|
-
},
|
|
111
|
-
requireMention: {
|
|
112
|
-
type: 'boolean',
|
|
113
|
-
title: 'Require @ Mention in Groups',
|
|
114
|
-
default: true,
|
|
115
|
-
description: 'If true, bot only responds when @mentioned in group chats',
|
|
116
|
-
},
|
|
117
|
-
messageFormat: {
|
|
118
|
-
type: 'string',
|
|
119
|
-
title: 'Message Format',
|
|
120
|
-
enum: ['text', 'markdown', 'auto'],
|
|
121
|
-
default: 'text',
|
|
122
|
-
description: 'text=plain text, markdown=always markdown, auto=detect markdown features in response',
|
|
123
|
-
},
|
|
124
|
-
showThinking: {
|
|
125
|
-
type: 'boolean',
|
|
126
|
-
title: 'Show Thinking Indicator',
|
|
127
|
-
default: false,
|
|
128
|
-
description: 'Send "正在思考..." feedback before AI processing begins',
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
required: ['clientId', 'clientSecret'],
|
|
132
|
-
},
|
|
133
|
-
|
|
134
|
-
listAccountIds(cfg) {
|
|
135
|
-
const channel = cfg?.channels?.dingtalk ?? {};
|
|
136
|
-
if (channel.clientId) return ['default'];
|
|
137
|
-
return [];
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
resolveAccount(cfg, accountId) {
|
|
141
|
-
return resolveDingTalkAccount({ cfg, accountId });
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
defaultAccountId() {
|
|
145
|
-
return 'default';
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
setAccountEnabled({ cfg, accountId, enabled }) {
|
|
149
|
-
const runtime = getDingTalkRuntime();
|
|
150
|
-
runtime.config.set('channels.dingtalk.enabled', enabled);
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
deleteAccount({ cfg, accountId }) {
|
|
154
|
-
const runtime = getDingTalkRuntime();
|
|
155
|
-
runtime.config.delete('channels.dingtalk');
|
|
156
|
-
},
|
|
157
|
-
|
|
158
|
-
isConfigured(account) {
|
|
159
|
-
return !!(account.clientId && account.clientSecret);
|
|
160
|
-
},
|
|
161
|
-
|
|
162
|
-
describeAccount(account) {
|
|
163
|
-
return {
|
|
164
|
-
accountId: account.accountId,
|
|
165
|
-
name: account.name || 'DingTalk Bot',
|
|
166
|
-
enabled: account.enabled,
|
|
167
|
-
configured: !!(account.clientId && account.clientSecret),
|
|
168
|
-
credentialSource: account.credentialSource,
|
|
169
|
-
};
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
|
|
173
|
-
security: {
|
|
174
|
-
resolveDmPolicy({ cfg, accountId, account }) {
|
|
175
|
-
const dm = account.config.dm ?? {};
|
|
176
|
-
return {
|
|
177
|
-
policy: dm.policy ?? 'pairing',
|
|
178
|
-
allowFrom: dm.allowFrom ?? [],
|
|
179
|
-
};
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
outbound: {
|
|
184
|
-
deliveryMode: 'buffer',
|
|
185
|
-
textChunkLimit: 2000,
|
|
186
|
-
|
|
187
|
-
async sendText({ to, text, accountId, cfg }) {
|
|
188
|
-
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
189
|
-
const { type, id } = parseOutboundTo(to);
|
|
190
|
-
|
|
191
|
-
// Check longTextMode config
|
|
192
|
-
const longTextMode = account.config?.longTextMode ?? 'chunk';
|
|
193
|
-
const longTextThreshold = account.config?.longTextThreshold ?? 4000;
|
|
194
|
-
|
|
195
|
-
// If longTextMode is 'file' and text exceeds threshold, send as file
|
|
196
|
-
if (longTextMode === 'file' && text.length > longTextThreshold) {
|
|
197
|
-
console.log(`[dingtalk] Outbound text exceeds threshold (${text.length} > ${longTextThreshold}), sending as file`);
|
|
198
|
-
|
|
199
|
-
const { buffer, fileName } = textToMarkdownFile(text, 'AI Response');
|
|
200
|
-
|
|
201
|
-
// Upload file
|
|
202
|
-
const uploadResult = await uploadMediaFile({
|
|
203
|
-
clientId: account.clientId,
|
|
204
|
-
clientSecret: account.clientSecret,
|
|
205
|
-
robotCode: account.robotCode || account.clientId,
|
|
206
|
-
fileBuffer: buffer,
|
|
207
|
-
fileName,
|
|
208
|
-
fileType: 'file',
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
if (uploadResult.mediaId) {
|
|
212
|
-
// Send file message
|
|
213
|
-
const sendResult = await sendFileMessage({
|
|
214
|
-
clientId: account.clientId,
|
|
215
|
-
clientSecret: account.clientSecret,
|
|
216
|
-
robotCode: account.robotCode || account.clientId,
|
|
217
|
-
userId: type === 'dm' ? id : undefined,
|
|
218
|
-
conversationId: type === 'group' ? id : undefined,
|
|
219
|
-
mediaId: uploadResult.mediaId,
|
|
220
|
-
fileName,
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
if (sendResult.ok) {
|
|
224
|
-
console.log(`[dingtalk] File sent successfully via outbound: ${fileName}`);
|
|
225
|
-
return { channel: 'dingtalk', ok: true };
|
|
226
|
-
}
|
|
227
|
-
console.log(`[dingtalk] File send failed, falling back to text: ${sendResult.error}`);
|
|
228
|
-
} else {
|
|
229
|
-
console.log(`[dingtalk] File upload failed, falling back to text: ${uploadResult.error}`);
|
|
230
|
-
}
|
|
231
|
-
// Fall through to text sending if file send fails
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (type === 'dm') {
|
|
235
|
-
await sendDingTalkRestMessage({
|
|
236
|
-
clientId: account.clientId,
|
|
237
|
-
clientSecret: account.clientSecret,
|
|
238
|
-
robotCode: account.robotCode || account.clientId,
|
|
239
|
-
userId: id,
|
|
240
|
-
text,
|
|
241
|
-
});
|
|
242
|
-
} else if (type === 'group') {
|
|
243
|
-
await sendDingTalkRestMessage({
|
|
244
|
-
clientId: account.clientId,
|
|
245
|
-
clientSecret: account.clientSecret,
|
|
246
|
-
robotCode: account.robotCode || account.clientId,
|
|
247
|
-
conversationId: id,
|
|
248
|
-
text,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return { channel: 'dingtalk', ok: true };
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
async sendFile({ to, content, fileName, accountId, cfg }) {
|
|
256
|
-
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
257
|
-
const { type, id } = parseOutboundTo(to);
|
|
258
|
-
|
|
259
|
-
// Convert content to buffer if it's a string
|
|
260
|
-
let fileBuffer: Buffer;
|
|
261
|
-
if (typeof content === 'string') {
|
|
262
|
-
// Add UTF-8 BOM for text files (better Chinese display)
|
|
263
|
-
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
264
|
-
const textContent = Buffer.from(content, 'utf-8');
|
|
265
|
-
fileBuffer = Buffer.concat([bom, textContent]);
|
|
266
|
-
} else if (Buffer.isBuffer(content)) {
|
|
267
|
-
fileBuffer = content;
|
|
268
|
-
} else {
|
|
269
|
-
throw new Error('content must be a string or Buffer');
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Upload file to DingTalk
|
|
273
|
-
const uploadResult = await uploadMediaFile({
|
|
274
|
-
clientId: account.clientId,
|
|
275
|
-
clientSecret: account.clientSecret,
|
|
276
|
-
robotCode: account.robotCode || account.clientId,
|
|
277
|
-
fileBuffer,
|
|
278
|
-
fileName: fileName || 'file.txt',
|
|
279
|
-
fileType: 'file',
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
if (!uploadResult.mediaId) {
|
|
283
|
-
throw new Error(`File upload failed: ${uploadResult.error}`);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Send file message
|
|
287
|
-
const sendResult = await sendFileMessage({
|
|
288
|
-
clientId: account.clientId,
|
|
289
|
-
clientSecret: account.clientSecret,
|
|
290
|
-
robotCode: account.robotCode || account.clientId,
|
|
291
|
-
userId: type === 'dm' ? id : undefined,
|
|
292
|
-
conversationId: type === 'group' ? id : undefined,
|
|
293
|
-
mediaId: uploadResult.mediaId,
|
|
294
|
-
fileName: fileName || 'file.txt',
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
if (!sendResult.ok) {
|
|
298
|
-
throw new Error(`File send failed: ${sendResult.error}`);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
console.log(`[dingtalk] File sent via outbound.sendFile: ${fileName}`);
|
|
302
|
-
return { channel: 'dingtalk', ok: true };
|
|
303
|
-
},
|
|
304
|
-
|
|
305
|
-
async sendMedia({ to, text, mediaUrl, accountId, cfg }) {
|
|
306
|
-
// Note: DingTalk REST API (oToMessages/groupMessages) doesn't support markdown or images
|
|
307
|
-
// Images can only be sent via sessionWebhook (when replying to messages)
|
|
308
|
-
// For now, we send the image URL as a text link
|
|
309
|
-
|
|
310
|
-
if (!mediaUrl) {
|
|
311
|
-
throw new Error('mediaUrl is required for sending media on DingTalk');
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Check if URL is accessible (basic check)
|
|
315
|
-
if (!mediaUrl.startsWith('http://') && !mediaUrl.startsWith('https://')) {
|
|
316
|
-
throw new Error('DingTalk requires publicly accessible image URLs (http:// or https://)');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
320
|
-
const { type, id } = parseOutboundTo(to);
|
|
321
|
-
|
|
322
|
-
// Build text message with image URL as markdown image
|
|
323
|
-
const imageMarkdown = ``;
|
|
324
|
-
const textMessage = text
|
|
325
|
-
? `${text}\n\n${imageMarkdown}`
|
|
326
|
-
: imageMarkdown;
|
|
327
|
-
|
|
328
|
-
if (type === 'dm') {
|
|
329
|
-
await sendDingTalkRestMessage({
|
|
330
|
-
clientId: account.clientId,
|
|
331
|
-
clientSecret: account.clientSecret,
|
|
332
|
-
robotCode: account.robotCode || account.clientId,
|
|
333
|
-
userId: id,
|
|
334
|
-
text: textMessage,
|
|
335
|
-
});
|
|
336
|
-
} else if (type === 'group') {
|
|
337
|
-
await sendDingTalkRestMessage({
|
|
338
|
-
clientId: account.clientId,
|
|
339
|
-
clientSecret: account.clientSecret,
|
|
340
|
-
robotCode: account.robotCode || account.clientId,
|
|
341
|
-
conversationId: id,
|
|
342
|
-
text: textMessage,
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return { channel: 'dingtalk', ok: true };
|
|
347
|
-
},
|
|
348
|
-
},
|
|
349
|
-
|
|
350
|
-
// Handle message actions (sendAttachment, etc.)
|
|
351
|
-
actions: {
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
return ['send', 'sendAttachment'];
|
|
355
|
-
},
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (!
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (!
|
|
388
|
-
console.warn('[dingtalk] sendAttachment: missing
|
|
389
|
-
return {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1
|
+
import { getDingTalkRuntime } from './runtime.js';
|
|
2
|
+
import { resolveDingTalkAccount } from './accounts.js';
|
|
3
|
+
import { startDingTalkMonitor } from './monitor.js';
|
|
4
|
+
import { sendDingTalkRestMessage, uploadMediaFile, sendFileMessage, textToMarkdownFile } from './api.js';
|
|
5
|
+
import { probeDingTalk } from './probe.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse outbound `to` address, stripping optional channel prefix.
|
|
9
|
+
* Handles: "dm:id", "group:id", "dingtalk:dm:id", "dingtalk:group:id",
|
|
10
|
+
* and bare "id" (treated as DM userId).
|
|
11
|
+
*/
|
|
12
|
+
function parseOutboundTo(to: string): { type: string; id: string } {
|
|
13
|
+
const parts = to.split(':');
|
|
14
|
+
// Strip channel prefix: "dingtalk:dm:id" → "dm:id"
|
|
15
|
+
if (parts[0] === 'dingtalk' && parts.length > 2) {
|
|
16
|
+
parts.shift();
|
|
17
|
+
}
|
|
18
|
+
// Known types
|
|
19
|
+
if (parts[0] === 'dm' || parts[0] === 'group') {
|
|
20
|
+
return { type: parts[0], id: parts.slice(1).join(':') };
|
|
21
|
+
}
|
|
22
|
+
// Bare ID (no type prefix) — treat as DM userId
|
|
23
|
+
return { type: 'dm', id: to };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const dingtalkPlugin = {
|
|
27
|
+
id: 'dingtalk',
|
|
28
|
+
|
|
29
|
+
meta: {
|
|
30
|
+
label: 'DingTalk',
|
|
31
|
+
selectionLabel: 'DingTalk (钉钉)',
|
|
32
|
+
detailLabel: 'DingTalk',
|
|
33
|
+
blurb: 'DingTalk bot via Stream Mode (WebSocket)',
|
|
34
|
+
aliases: ['dingding', 'dd'],
|
|
35
|
+
order: 75,
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
capabilities: {
|
|
39
|
+
chatTypes: ['direct', 'group'],
|
|
40
|
+
media: true, // Supports images via markdown in sessionWebhook replies
|
|
41
|
+
files: true, // Supports file upload and sending
|
|
42
|
+
threads: false,
|
|
43
|
+
reactions: false,
|
|
44
|
+
mentions: true,
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
config: {
|
|
48
|
+
schema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
enabled: {
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
title: 'Enable DingTalk',
|
|
54
|
+
default: true,
|
|
55
|
+
},
|
|
56
|
+
clientId: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
title: 'Client ID (AppKey)',
|
|
59
|
+
description: 'DingTalk application AppKey',
|
|
60
|
+
},
|
|
61
|
+
clientSecret: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
title: 'Client Secret (AppSecret)',
|
|
64
|
+
description: 'DingTalk application AppSecret',
|
|
65
|
+
secret: true,
|
|
66
|
+
},
|
|
67
|
+
robotCode: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
title: 'Robot Code (Optional)',
|
|
70
|
+
description: 'Optional robot code, defaults to Client ID',
|
|
71
|
+
},
|
|
72
|
+
dm: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
title: 'Direct Message Settings',
|
|
75
|
+
properties: {
|
|
76
|
+
enabled: {
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
title: 'Enable DM',
|
|
79
|
+
default: true,
|
|
80
|
+
},
|
|
81
|
+
policy: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
title: 'DM Access Policy',
|
|
84
|
+
enum: ['disabled', 'pairing', 'allowlist', 'open'],
|
|
85
|
+
default: 'pairing',
|
|
86
|
+
description: 'disabled=no DM, pairing=show staffId to add, allowlist=only allowed users, open=everyone',
|
|
87
|
+
},
|
|
88
|
+
allowFrom: {
|
|
89
|
+
type: 'array',
|
|
90
|
+
title: 'Allowed Staff IDs',
|
|
91
|
+
items: { type: 'string' },
|
|
92
|
+
default: [],
|
|
93
|
+
description: 'List of staff IDs allowed to DM the bot',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
groupPolicy: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
title: 'Group Chat Policy',
|
|
100
|
+
enum: ['disabled', 'allowlist', 'open'],
|
|
101
|
+
default: 'allowlist',
|
|
102
|
+
description: 'disabled=no groups, allowlist=specific groups, open=all groups',
|
|
103
|
+
},
|
|
104
|
+
groupAllowlist: {
|
|
105
|
+
type: 'array',
|
|
106
|
+
title: 'Allowed Group IDs',
|
|
107
|
+
items: { type: 'string' },
|
|
108
|
+
default: [],
|
|
109
|
+
description: 'List of conversation IDs for allowed groups (only used when groupPolicy is "allowlist")',
|
|
110
|
+
},
|
|
111
|
+
requireMention: {
|
|
112
|
+
type: 'boolean',
|
|
113
|
+
title: 'Require @ Mention in Groups',
|
|
114
|
+
default: true,
|
|
115
|
+
description: 'If true, bot only responds when @mentioned in group chats',
|
|
116
|
+
},
|
|
117
|
+
messageFormat: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
title: 'Message Format',
|
|
120
|
+
enum: ['text', 'markdown', 'auto'],
|
|
121
|
+
default: 'text',
|
|
122
|
+
description: 'text=plain text, markdown=always markdown, auto=detect markdown features in response',
|
|
123
|
+
},
|
|
124
|
+
showThinking: {
|
|
125
|
+
type: 'boolean',
|
|
126
|
+
title: 'Show Thinking Indicator',
|
|
127
|
+
default: false,
|
|
128
|
+
description: 'Send "正在思考..." feedback before AI processing begins',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ['clientId', 'clientSecret'],
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
listAccountIds(cfg) {
|
|
135
|
+
const channel = cfg?.channels?.dingtalk ?? {};
|
|
136
|
+
if (channel.clientId) return ['default'];
|
|
137
|
+
return [];
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
resolveAccount(cfg, accountId) {
|
|
141
|
+
return resolveDingTalkAccount({ cfg, accountId });
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
defaultAccountId() {
|
|
145
|
+
return 'default';
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
setAccountEnabled({ cfg, accountId, enabled }) {
|
|
149
|
+
const runtime = getDingTalkRuntime();
|
|
150
|
+
runtime.config.set('channels.dingtalk.enabled', enabled);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
deleteAccount({ cfg, accountId }) {
|
|
154
|
+
const runtime = getDingTalkRuntime();
|
|
155
|
+
runtime.config.delete('channels.dingtalk');
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
isConfigured(account) {
|
|
159
|
+
return !!(account.clientId && account.clientSecret);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
describeAccount(account) {
|
|
163
|
+
return {
|
|
164
|
+
accountId: account.accountId,
|
|
165
|
+
name: account.name || 'DingTalk Bot',
|
|
166
|
+
enabled: account.enabled,
|
|
167
|
+
configured: !!(account.clientId && account.clientSecret),
|
|
168
|
+
credentialSource: account.credentialSource,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
security: {
|
|
174
|
+
resolveDmPolicy({ cfg, accountId, account }) {
|
|
175
|
+
const dm = account.config.dm ?? {};
|
|
176
|
+
return {
|
|
177
|
+
policy: dm.policy ?? 'pairing',
|
|
178
|
+
allowFrom: dm.allowFrom ?? [],
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
outbound: {
|
|
184
|
+
deliveryMode: 'buffer',
|
|
185
|
+
textChunkLimit: 2000,
|
|
186
|
+
|
|
187
|
+
async sendText({ to, text, accountId, cfg }) {
|
|
188
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
189
|
+
const { type, id } = parseOutboundTo(to);
|
|
190
|
+
|
|
191
|
+
// Check longTextMode config
|
|
192
|
+
const longTextMode = account.config?.longTextMode ?? 'chunk';
|
|
193
|
+
const longTextThreshold = account.config?.longTextThreshold ?? 4000;
|
|
194
|
+
|
|
195
|
+
// If longTextMode is 'file' and text exceeds threshold, send as file
|
|
196
|
+
if (longTextMode === 'file' && text.length > longTextThreshold) {
|
|
197
|
+
console.log(`[dingtalk] Outbound text exceeds threshold (${text.length} > ${longTextThreshold}), sending as file`);
|
|
198
|
+
|
|
199
|
+
const { buffer, fileName } = textToMarkdownFile(text, 'AI Response');
|
|
200
|
+
|
|
201
|
+
// Upload file
|
|
202
|
+
const uploadResult = await uploadMediaFile({
|
|
203
|
+
clientId: account.clientId,
|
|
204
|
+
clientSecret: account.clientSecret,
|
|
205
|
+
robotCode: account.robotCode || account.clientId,
|
|
206
|
+
fileBuffer: buffer,
|
|
207
|
+
fileName,
|
|
208
|
+
fileType: 'file',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (uploadResult.mediaId) {
|
|
212
|
+
// Send file message
|
|
213
|
+
const sendResult = await sendFileMessage({
|
|
214
|
+
clientId: account.clientId,
|
|
215
|
+
clientSecret: account.clientSecret,
|
|
216
|
+
robotCode: account.robotCode || account.clientId,
|
|
217
|
+
userId: type === 'dm' ? id : undefined,
|
|
218
|
+
conversationId: type === 'group' ? id : undefined,
|
|
219
|
+
mediaId: uploadResult.mediaId,
|
|
220
|
+
fileName,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (sendResult.ok) {
|
|
224
|
+
console.log(`[dingtalk] File sent successfully via outbound: ${fileName}`);
|
|
225
|
+
return { channel: 'dingtalk', ok: true };
|
|
226
|
+
}
|
|
227
|
+
console.log(`[dingtalk] File send failed, falling back to text: ${sendResult.error}`);
|
|
228
|
+
} else {
|
|
229
|
+
console.log(`[dingtalk] File upload failed, falling back to text: ${uploadResult.error}`);
|
|
230
|
+
}
|
|
231
|
+
// Fall through to text sending if file send fails
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (type === 'dm') {
|
|
235
|
+
await sendDingTalkRestMessage({
|
|
236
|
+
clientId: account.clientId,
|
|
237
|
+
clientSecret: account.clientSecret,
|
|
238
|
+
robotCode: account.robotCode || account.clientId,
|
|
239
|
+
userId: id,
|
|
240
|
+
text,
|
|
241
|
+
});
|
|
242
|
+
} else if (type === 'group') {
|
|
243
|
+
await sendDingTalkRestMessage({
|
|
244
|
+
clientId: account.clientId,
|
|
245
|
+
clientSecret: account.clientSecret,
|
|
246
|
+
robotCode: account.robotCode || account.clientId,
|
|
247
|
+
conversationId: id,
|
|
248
|
+
text,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { channel: 'dingtalk', ok: true };
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async sendFile({ to, content, fileName, accountId, cfg }) {
|
|
256
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
257
|
+
const { type, id } = parseOutboundTo(to);
|
|
258
|
+
|
|
259
|
+
// Convert content to buffer if it's a string
|
|
260
|
+
let fileBuffer: Buffer;
|
|
261
|
+
if (typeof content === 'string') {
|
|
262
|
+
// Add UTF-8 BOM for text files (better Chinese display)
|
|
263
|
+
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
264
|
+
const textContent = Buffer.from(content, 'utf-8');
|
|
265
|
+
fileBuffer = Buffer.concat([bom, textContent]);
|
|
266
|
+
} else if (Buffer.isBuffer(content)) {
|
|
267
|
+
fileBuffer = content;
|
|
268
|
+
} else {
|
|
269
|
+
throw new Error('content must be a string or Buffer');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Upload file to DingTalk
|
|
273
|
+
const uploadResult = await uploadMediaFile({
|
|
274
|
+
clientId: account.clientId,
|
|
275
|
+
clientSecret: account.clientSecret,
|
|
276
|
+
robotCode: account.robotCode || account.clientId,
|
|
277
|
+
fileBuffer,
|
|
278
|
+
fileName: fileName || 'file.txt',
|
|
279
|
+
fileType: 'file',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!uploadResult.mediaId) {
|
|
283
|
+
throw new Error(`File upload failed: ${uploadResult.error}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Send file message
|
|
287
|
+
const sendResult = await sendFileMessage({
|
|
288
|
+
clientId: account.clientId,
|
|
289
|
+
clientSecret: account.clientSecret,
|
|
290
|
+
robotCode: account.robotCode || account.clientId,
|
|
291
|
+
userId: type === 'dm' ? id : undefined,
|
|
292
|
+
conversationId: type === 'group' ? id : undefined,
|
|
293
|
+
mediaId: uploadResult.mediaId,
|
|
294
|
+
fileName: fileName || 'file.txt',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!sendResult.ok) {
|
|
298
|
+
throw new Error(`File send failed: ${sendResult.error}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console.log(`[dingtalk] File sent via outbound.sendFile: ${fileName}`);
|
|
302
|
+
return { channel: 'dingtalk', ok: true };
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async sendMedia({ to, text, mediaUrl, accountId, cfg }) {
|
|
306
|
+
// Note: DingTalk REST API (oToMessages/groupMessages) doesn't support markdown or images
|
|
307
|
+
// Images can only be sent via sessionWebhook (when replying to messages)
|
|
308
|
+
// For now, we send the image URL as a text link
|
|
309
|
+
|
|
310
|
+
if (!mediaUrl) {
|
|
311
|
+
throw new Error('mediaUrl is required for sending media on DingTalk');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Check if URL is accessible (basic check)
|
|
315
|
+
if (!mediaUrl.startsWith('http://') && !mediaUrl.startsWith('https://')) {
|
|
316
|
+
throw new Error('DingTalk requires publicly accessible image URLs (http:// or https://)');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
320
|
+
const { type, id } = parseOutboundTo(to);
|
|
321
|
+
|
|
322
|
+
// Build text message with image URL as markdown image
|
|
323
|
+
const imageMarkdown = ``;
|
|
324
|
+
const textMessage = text
|
|
325
|
+
? `${text}\n\n${imageMarkdown}`
|
|
326
|
+
: imageMarkdown;
|
|
327
|
+
|
|
328
|
+
if (type === 'dm') {
|
|
329
|
+
await sendDingTalkRestMessage({
|
|
330
|
+
clientId: account.clientId,
|
|
331
|
+
clientSecret: account.clientSecret,
|
|
332
|
+
robotCode: account.robotCode || account.clientId,
|
|
333
|
+
userId: id,
|
|
334
|
+
text: textMessage,
|
|
335
|
+
});
|
|
336
|
+
} else if (type === 'group') {
|
|
337
|
+
await sendDingTalkRestMessage({
|
|
338
|
+
clientId: account.clientId,
|
|
339
|
+
clientSecret: account.clientSecret,
|
|
340
|
+
robotCode: account.robotCode || account.clientId,
|
|
341
|
+
conversationId: id,
|
|
342
|
+
text: textMessage,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { channel: 'dingtalk', ok: true };
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
// Handle message actions (sendAttachment, etc.)
|
|
351
|
+
actions: {
|
|
352
|
+
// New SDK interface (2026.3.22+): replaces listActions
|
|
353
|
+
describeMessageTool({ cfg }: { cfg: any }) {
|
|
354
|
+
return { actions: ['send', 'sendAttachment'] };
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
// Legacy - kept for compatibility
|
|
358
|
+
listActions({ cfg }: { cfg: any }) {
|
|
359
|
+
return ['send', 'sendAttachment'];
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
supportsAction({ action }: { action: string }) {
|
|
363
|
+
return action === 'sendAttachment' || action === 'send';
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
async handleAction(ctx: any) {
|
|
367
|
+
const { action, params, cfg, accountId, conversationTarget } = ctx;
|
|
368
|
+
|
|
369
|
+
// Only handle sendAttachment action
|
|
370
|
+
if (action !== 'sendAttachment') {
|
|
371
|
+
return null; // Let SDK handle other actions
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const buffer = params?.buffer;
|
|
375
|
+
const filename = params?.filename || 'attachment.bin';
|
|
376
|
+
|
|
377
|
+
// Accept both 'target' and 'to' parameters (agent may use either)
|
|
378
|
+
// Also try to infer from conversation context if neither provided
|
|
379
|
+
let target = params?.target || params?.to;
|
|
380
|
+
|
|
381
|
+
// Try to get target from conversation context if not explicitly provided
|
|
382
|
+
if (!target && conversationTarget) {
|
|
383
|
+
target = conversationTarget;
|
|
384
|
+
console.log(`[dingtalk] sendAttachment: inferred target from context: ${target}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!buffer) {
|
|
388
|
+
console.warn('[dingtalk] sendAttachment: missing buffer parameter');
|
|
389
|
+
return { ok: false, error: 'Missing buffer parameter. Use base64-encoded file content.' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!target) {
|
|
393
|
+
console.warn('[dingtalk] sendAttachment: missing target/to parameter');
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: 'Missing target parameter. Use "to" or "target" with format: "dm:userId" or "group:conversationId" or just "userId" for DM.'
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
401
|
+
const { type, id } = parseOutboundTo(target);
|
|
402
|
+
|
|
403
|
+
// Decode base64 buffer
|
|
404
|
+
let fileBuffer: Buffer;
|
|
405
|
+
try {
|
|
406
|
+
fileBuffer = Buffer.from(buffer, 'base64');
|
|
407
|
+
} catch {
|
|
408
|
+
return { ok: false, error: 'Invalid base64 buffer' };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Add UTF-8 BOM for text files
|
|
412
|
+
const isTextFile = /\.(txt|md|json|csv|xml|html?)$/i.test(filename);
|
|
413
|
+
if (isTextFile) {
|
|
414
|
+
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
415
|
+
// Check if BOM already exists
|
|
416
|
+
if (fileBuffer[0] !== 0xEF || fileBuffer[1] !== 0xBB || fileBuffer[2] !== 0xBF) {
|
|
417
|
+
fileBuffer = Buffer.concat([bom, fileBuffer]);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Upload file
|
|
422
|
+
const uploadResult = await uploadMediaFile({
|
|
423
|
+
clientId: account.clientId,
|
|
424
|
+
clientSecret: account.clientSecret,
|
|
425
|
+
robotCode: account.robotCode || account.clientId,
|
|
426
|
+
fileBuffer,
|
|
427
|
+
fileName: filename,
|
|
428
|
+
fileType: 'file',
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (!uploadResult.mediaId) {
|
|
432
|
+
console.warn(`[dingtalk] sendAttachment upload failed: ${uploadResult.error}`);
|
|
433
|
+
return { ok: false, error: uploadResult.error };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Send file
|
|
437
|
+
const sendResult = await sendFileMessage({
|
|
438
|
+
clientId: account.clientId,
|
|
439
|
+
clientSecret: account.clientSecret,
|
|
440
|
+
robotCode: account.robotCode || account.clientId,
|
|
441
|
+
userId: type === 'dm' ? id : undefined,
|
|
442
|
+
conversationId: type === 'group' ? id : undefined,
|
|
443
|
+
mediaId: uploadResult.mediaId,
|
|
444
|
+
fileName: filename,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
if (!sendResult.ok) {
|
|
448
|
+
console.warn(`[dingtalk] sendAttachment send failed: ${sendResult.error}`);
|
|
449
|
+
return { ok: false, error: sendResult.error };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
console.log(`[dingtalk] sendAttachment success: ${filename}`);
|
|
453
|
+
// Return format compatible with SDK expectations
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
channel: 'dingtalk',
|
|
457
|
+
filename,
|
|
458
|
+
// SDK expects content array format for tool results
|
|
459
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, filename }) }],
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
gateway: {
|
|
465
|
+
async startAccount({ account, signal, setStatus }) {
|
|
466
|
+
const runtime = getDingTalkRuntime();
|
|
467
|
+
const log = runtime.log?.child?.({ channel: 'dingtalk', account: account.accountId }) ?? runtime.log ?? console;
|
|
468
|
+
const cfg = runtime.config;
|
|
469
|
+
|
|
470
|
+
log.info?.('[dingtalk] Starting Stream connection...');
|
|
471
|
+
|
|
472
|
+
// Record start activity
|
|
473
|
+
(runtime as any).channel?.activity?.record?.('dingtalk', account.accountId, 'start');
|
|
474
|
+
|
|
475
|
+
// Record stop activity on abort
|
|
476
|
+
if (signal) {
|
|
477
|
+
signal.addEventListener('abort', () => {
|
|
478
|
+
(runtime as any).channel?.activity?.record?.('dingtalk', account.accountId, 'stop');
|
|
479
|
+
}, { once: true });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
await startDingTalkMonitor({
|
|
484
|
+
account,
|
|
485
|
+
cfg,
|
|
486
|
+
abortSignal: signal,
|
|
487
|
+
log,
|
|
488
|
+
setStatus,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
log.info?.('[dingtalk] Stream connection started successfully');
|
|
492
|
+
} catch (err) {
|
|
493
|
+
log.error?.('[dingtalk] Failed to start Stream', err);
|
|
494
|
+
throw err;
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
status: {
|
|
500
|
+
async probeAccount(account) {
|
|
501
|
+
if (!account.configured || !account.clientId || !account.clientSecret) {
|
|
502
|
+
return { ok: false, error: 'Not configured' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return await probeDingTalk(account.clientId, account.clientSecret);
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
onboarding: {
|
|
510
|
+
async run(ctx: any) {
|
|
511
|
+
const { onboardDingTalk } = await import('./onboarding.js');
|
|
512
|
+
return onboardDingTalk(ctx);
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
};
|