@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/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 = `![image](${mediaUrl})`;
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
- // List supported actions for this channel - SDK uses this to tell agent what's available
353
- listActions({ cfg }: { cfg: any }) {
354
- return ['send', 'sendAttachment'];
355
- },
356
-
357
- supportsAction({ action }: { action: string }) {
358
- return action === 'sendAttachment' || action === 'send';
359
- },
360
-
361
- async handleAction(ctx: any) {
362
- const { action, params, cfg, accountId, conversationTarget } = ctx;
363
-
364
- // Only handle sendAttachment action
365
- if (action !== 'sendAttachment') {
366
- return null; // Let SDK handle other actions
367
- }
368
-
369
- const buffer = params?.buffer;
370
- const filename = params?.filename || 'attachment.bin';
371
-
372
- // Accept both 'target' and 'to' parameters (agent may use either)
373
- // Also try to infer from conversation context if neither provided
374
- let target = params?.target || params?.to;
375
-
376
- // Try to get target from conversation context if not explicitly provided
377
- if (!target && conversationTarget) {
378
- target = conversationTarget;
379
- console.log(`[dingtalk] sendAttachment: inferred target from context: ${target}`);
380
- }
381
-
382
- if (!buffer) {
383
- console.warn('[dingtalk] sendAttachment: missing buffer parameter');
384
- return { ok: false, error: 'Missing buffer parameter. Use base64-encoded file content.' };
385
- }
386
-
387
- if (!target) {
388
- console.warn('[dingtalk] sendAttachment: missing target/to parameter');
389
- return {
390
- ok: false,
391
- error: 'Missing target parameter. Use "to" or "target" with format: "dm:userId" or "group:conversationId" or just "userId" for DM.'
392
- };
393
- }
394
-
395
- const account = resolveDingTalkAccount({ cfg, accountId });
396
- const { type, id } = parseOutboundTo(target);
397
-
398
- // Decode base64 buffer
399
- let fileBuffer: Buffer;
400
- try {
401
- fileBuffer = Buffer.from(buffer, 'base64');
402
- } catch {
403
- return { ok: false, error: 'Invalid base64 buffer' };
404
- }
405
-
406
- // Add UTF-8 BOM for text files
407
- const isTextFile = /\.(txt|md|json|csv|xml|html?)$/i.test(filename);
408
- if (isTextFile) {
409
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
410
- // Check if BOM already exists
411
- if (fileBuffer[0] !== 0xEF || fileBuffer[1] !== 0xBB || fileBuffer[2] !== 0xBF) {
412
- fileBuffer = Buffer.concat([bom, fileBuffer]);
413
- }
414
- }
415
-
416
- // Upload file
417
- const uploadResult = await uploadMediaFile({
418
- clientId: account.clientId,
419
- clientSecret: account.clientSecret,
420
- robotCode: account.robotCode || account.clientId,
421
- fileBuffer,
422
- fileName: filename,
423
- fileType: 'file',
424
- });
425
-
426
- if (!uploadResult.mediaId) {
427
- console.warn(`[dingtalk] sendAttachment upload failed: ${uploadResult.error}`);
428
- return { ok: false, error: uploadResult.error };
429
- }
430
-
431
- // Send file
432
- const sendResult = await sendFileMessage({
433
- clientId: account.clientId,
434
- clientSecret: account.clientSecret,
435
- robotCode: account.robotCode || account.clientId,
436
- userId: type === 'dm' ? id : undefined,
437
- conversationId: type === 'group' ? id : undefined,
438
- mediaId: uploadResult.mediaId,
439
- fileName: filename,
440
- });
441
-
442
- if (!sendResult.ok) {
443
- console.warn(`[dingtalk] sendAttachment send failed: ${sendResult.error}`);
444
- return { ok: false, error: sendResult.error };
445
- }
446
-
447
- console.log(`[dingtalk] sendAttachment success: ${filename}`);
448
- // Return format compatible with SDK expectations
449
- return {
450
- ok: true,
451
- channel: 'dingtalk',
452
- filename,
453
- // SDK expects content array format for tool results
454
- content: [{ type: 'text', text: JSON.stringify({ ok: true, filename }) }],
455
- };
456
- },
457
- },
458
-
459
- gateway: {
460
- async startAccount({ account, signal, setStatus }) {
461
- const runtime = getDingTalkRuntime();
462
- const log = runtime.log?.child?.({ channel: 'dingtalk', account: account.accountId }) ?? runtime.log ?? console;
463
- const cfg = runtime.config;
464
-
465
- log.info?.('[dingtalk] Starting Stream connection...');
466
-
467
- // Record start activity
468
- (runtime as any).channel?.activity?.record?.('dingtalk', account.accountId, 'start');
469
-
470
- // Record stop activity on abort
471
- if (signal) {
472
- signal.addEventListener('abort', () => {
473
- (runtime as any).channel?.activity?.record?.('dingtalk', account.accountId, 'stop');
474
- }, { once: true });
475
- }
476
-
477
- try {
478
- await startDingTalkMonitor({
479
- account,
480
- cfg,
481
- abortSignal: signal,
482
- log,
483
- setStatus,
484
- });
485
-
486
- log.info?.('[dingtalk] Stream connection started successfully');
487
- } catch (err) {
488
- log.error?.('[dingtalk] Failed to start Stream', err);
489
- throw err;
490
- }
491
- },
492
- },
493
-
494
- status: {
495
- async probeAccount(account) {
496
- if (!account.configured || !account.clientId || !account.clientSecret) {
497
- return { ok: false, error: 'Not configured' };
498
- }
499
-
500
- return await probeDingTalk(account.clientId, account.clientSecret);
501
- },
502
- },
503
-
504
- onboarding: {
505
- async run(ctx: any) {
506
- const { onboardDingTalk } = await import('./onboarding.js');
507
- return onboardDingTalk(ctx);
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 = `![image](${mediaUrl})`;
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
+ };