@yaoyuanchao/dingtalk 1.4.10 → 1.4.11
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/api.ts +191 -0
- package/src/config-schema.ts +12 -0
- package/src/monitor.ts +71 -2
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -494,3 +494,194 @@ export function cleanupOldMedia(): void {
|
|
|
494
494
|
|
|
495
495
|
/** @deprecated Use cleanupOldMedia() instead */
|
|
496
496
|
export const cleanupOldPictures = cleanupOldMedia;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Upload a file to DingTalk and get media_id
|
|
500
|
+
* Uses the robot messageFiles upload API
|
|
501
|
+
*/
|
|
502
|
+
export async function uploadMediaFile(params: {
|
|
503
|
+
clientId: string;
|
|
504
|
+
clientSecret: string;
|
|
505
|
+
robotCode: string;
|
|
506
|
+
fileBuffer: Buffer;
|
|
507
|
+
fileName: string;
|
|
508
|
+
fileType?: 'image' | 'voice' | 'video' | 'file';
|
|
509
|
+
}): Promise<{ mediaId?: string; error?: string }> {
|
|
510
|
+
try {
|
|
511
|
+
const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
|
|
512
|
+
|
|
513
|
+
// Build multipart form data manually
|
|
514
|
+
const boundary = `----DingTalkBoundary${Date.now()}`;
|
|
515
|
+
const fileType = params.fileType || 'file';
|
|
516
|
+
|
|
517
|
+
// Construct multipart body
|
|
518
|
+
const parts: Buffer[] = [];
|
|
519
|
+
|
|
520
|
+
// Add robotCode field
|
|
521
|
+
parts.push(Buffer.from(
|
|
522
|
+
`--${boundary}\r\n` +
|
|
523
|
+
`Content-Disposition: form-data; name="robotCode"\r\n\r\n` +
|
|
524
|
+
`${params.robotCode}\r\n`
|
|
525
|
+
));
|
|
526
|
+
|
|
527
|
+
// Add file field
|
|
528
|
+
parts.push(Buffer.from(
|
|
529
|
+
`--${boundary}\r\n` +
|
|
530
|
+
`Content-Disposition: form-data; name="media"; filename="${params.fileName}"\r\n` +
|
|
531
|
+
`Content-Type: application/octet-stream\r\n\r\n`
|
|
532
|
+
));
|
|
533
|
+
parts.push(params.fileBuffer);
|
|
534
|
+
parts.push(Buffer.from('\r\n'));
|
|
535
|
+
|
|
536
|
+
// End boundary
|
|
537
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
538
|
+
|
|
539
|
+
const body = Buffer.concat(parts);
|
|
540
|
+
|
|
541
|
+
// Make request
|
|
542
|
+
const url = `${DINGTALK_API_BASE}/robot/messageFiles/upload?type=${fileType}`;
|
|
543
|
+
|
|
544
|
+
return new Promise((resolve) => {
|
|
545
|
+
const urlObj = new URL(url);
|
|
546
|
+
const req = https.request(urlObj, {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
headers: {
|
|
549
|
+
'x-acs-dingtalk-access-token': token,
|
|
550
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
551
|
+
'Content-Length': body.length,
|
|
552
|
+
},
|
|
553
|
+
timeout: 60000, // 60 second timeout for upload
|
|
554
|
+
family: 4,
|
|
555
|
+
}, (res) => {
|
|
556
|
+
let buf = '';
|
|
557
|
+
res.on('data', (chunk: any) => { buf += chunk; });
|
|
558
|
+
res.on('end', () => {
|
|
559
|
+
try {
|
|
560
|
+
const json = JSON.parse(buf);
|
|
561
|
+
if (json.mediaId) {
|
|
562
|
+
console.log(`[dingtalk] File uploaded successfully: mediaId=${json.mediaId}`);
|
|
563
|
+
resolve({ mediaId: json.mediaId });
|
|
564
|
+
} else {
|
|
565
|
+
console.warn(`[dingtalk] File upload failed:`, json);
|
|
566
|
+
resolve({ error: json.message || json.errmsg || 'Upload failed' });
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
resolve({ error: `Invalid response: ${buf}` });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
req.on('error', (err) => {
|
|
575
|
+
console.warn(`[dingtalk] File upload error:`, err);
|
|
576
|
+
resolve({ error: String(err) });
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
req.on('timeout', () => {
|
|
580
|
+
req.destroy();
|
|
581
|
+
resolve({ error: 'Upload timeout' });
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
req.write(body);
|
|
585
|
+
req.end();
|
|
586
|
+
});
|
|
587
|
+
} catch (err) {
|
|
588
|
+
console.warn(`[dingtalk] Error uploading file:`, err);
|
|
589
|
+
return { error: String(err) };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Send a file message via REST API
|
|
595
|
+
* Requires mediaId from uploadMediaFile
|
|
596
|
+
*/
|
|
597
|
+
export async function sendFileMessage(params: {
|
|
598
|
+
clientId: string;
|
|
599
|
+
clientSecret: string;
|
|
600
|
+
robotCode: string;
|
|
601
|
+
userId?: string;
|
|
602
|
+
conversationId?: string;
|
|
603
|
+
mediaId: string;
|
|
604
|
+
fileName: string;
|
|
605
|
+
}): Promise<{ ok: boolean; error?: string }> {
|
|
606
|
+
try {
|
|
607
|
+
const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
|
|
608
|
+
const headers = { 'x-acs-dingtalk-access-token': token };
|
|
609
|
+
|
|
610
|
+
const msgParam = JSON.stringify({
|
|
611
|
+
mediaId: params.mediaId,
|
|
612
|
+
fileName: params.fileName,
|
|
613
|
+
fileType: getFileExtension(params.fileName),
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
if (params.userId) {
|
|
617
|
+
// Send to DM
|
|
618
|
+
const res = await jsonPost(
|
|
619
|
+
`${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
|
|
620
|
+
{
|
|
621
|
+
robotCode: params.robotCode,
|
|
622
|
+
userIds: [params.userId],
|
|
623
|
+
msgKey: 'sampleFile',
|
|
624
|
+
msgParam,
|
|
625
|
+
},
|
|
626
|
+
headers,
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
if (res?.code || res?.errcode) {
|
|
630
|
+
console.warn(`[dingtalk] File send (DM) failed:`, res);
|
|
631
|
+
return { ok: false, error: res.message || res.errmsg };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
console.log(`[dingtalk] File sent to DM: ${params.fileName}`);
|
|
635
|
+
return { ok: true };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (params.conversationId) {
|
|
639
|
+
// Send to group
|
|
640
|
+
const res = await jsonPost(
|
|
641
|
+
`${DINGTALK_API_BASE}/robot/groupMessages/send`,
|
|
642
|
+
{
|
|
643
|
+
robotCode: params.robotCode,
|
|
644
|
+
openConversationId: params.conversationId,
|
|
645
|
+
msgKey: 'sampleFile',
|
|
646
|
+
msgParam,
|
|
647
|
+
},
|
|
648
|
+
headers,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
if (res?.code || res?.errcode) {
|
|
652
|
+
console.warn(`[dingtalk] File send (group) failed:`, res);
|
|
653
|
+
return { ok: false, error: res.message || res.errmsg };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
console.log(`[dingtalk] File sent to group: ${params.fileName}`);
|
|
657
|
+
return { ok: true };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return { ok: false, error: 'Either userId or conversationId required' };
|
|
661
|
+
} catch (err) {
|
|
662
|
+
console.warn(`[dingtalk] Error sending file:`, err);
|
|
663
|
+
return { ok: false, error: String(err) };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Get file extension from filename */
|
|
668
|
+
function getFileExtension(fileName: string): string {
|
|
669
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
670
|
+
return ext ? ext.slice(1) : 'bin';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Convert text to a markdown file buffer with UTF-8 BOM
|
|
675
|
+
* BOM is needed for proper Chinese display in DingTalk/Windows
|
|
676
|
+
*/
|
|
677
|
+
export function textToMarkdownFile(text: string, title?: string): { buffer: Buffer; fileName: string } {
|
|
678
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
679
|
+
const fileName = title ? `${title}.md` : `reply_${timestamp}.md`;
|
|
680
|
+
|
|
681
|
+
// Add UTF-8 BOM for proper Chinese display
|
|
682
|
+
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
683
|
+
const content = Buffer.from(text, 'utf-8');
|
|
684
|
+
const buffer = Buffer.concat([bom, content]);
|
|
685
|
+
|
|
686
|
+
return { buffer, fileName };
|
|
687
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto
|
|
|
13
13
|
description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features)',
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
export const longTextModeSchema = z.enum(['chunk', 'file']);
|
|
17
|
+
|
|
16
18
|
// DingTalk 配置 Schema
|
|
17
19
|
export const dingTalkConfigSchema = z.object({
|
|
18
20
|
enabled: z.boolean().default(true).describe('Enable DingTalk channel'),
|
|
@@ -71,6 +73,16 @@ export const dingTalkConfigSchema = z.object({
|
|
|
71
73
|
// 高级配置(可选)
|
|
72
74
|
textChunkLimit: z.number().int().positive().default(2000).optional()
|
|
73
75
|
.describe('Text chunk size limit for long messages'),
|
|
76
|
+
|
|
77
|
+
// 长文本处理
|
|
78
|
+
longTextMode: longTextModeSchema.default('chunk')
|
|
79
|
+
.describe(
|
|
80
|
+
'How to handle long text messages:\n' +
|
|
81
|
+
' - chunk: Split into multiple messages (default, same as official channels)\n' +
|
|
82
|
+
' - file: Convert to .md file and send as attachment'
|
|
83
|
+
),
|
|
84
|
+
longTextThreshold: z.number().int().positive().default(4000).optional()
|
|
85
|
+
.describe('Character threshold for longTextMode=file (default 4000)'),
|
|
74
86
|
}).strict();
|
|
75
87
|
|
|
76
88
|
// 导出配置类型
|
package/src/monitor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
|
|
2
|
-
import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia } from "./api.js";
|
|
2
|
+
import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile } from "./api.js";
|
|
3
3
|
import { getDingTalkRuntime } from "./runtime.js";
|
|
4
4
|
|
|
5
5
|
export interface DingTalkMonitorContext {
|
|
@@ -890,8 +890,26 @@ function resolveDeliverText(payload: any, log?: any): string | undefined {
|
|
|
890
890
|
|
|
891
891
|
async function deliverReply(target: any, text: string, log?: any): Promise<void> {
|
|
892
892
|
const now = Date.now();
|
|
893
|
-
const chunkLimit = 2000;
|
|
893
|
+
const chunkLimit = target.account.config.textChunkLimit ?? 2000;
|
|
894
894
|
const messageFormat = target.account.config.messageFormat ?? "text";
|
|
895
|
+
const longTextMode = target.account.config.longTextMode ?? "chunk";
|
|
896
|
+
const longTextThreshold = target.account.config.longTextThreshold ?? 4000;
|
|
897
|
+
|
|
898
|
+
// Check if we should send as file instead of text
|
|
899
|
+
if (longTextMode === 'file' && text.length > longTextThreshold) {
|
|
900
|
+
log?.info?.("[dingtalk] Text exceeds threshold (" + text.length + " > " + longTextThreshold + "), sending as file");
|
|
901
|
+
|
|
902
|
+
// Only attempt file send if we have credentials (REST API required)
|
|
903
|
+
if (target.account.clientId && target.account.clientSecret) {
|
|
904
|
+
const fileSent = await sendTextAsFile(target, text, log);
|
|
905
|
+
if (fileSent) {
|
|
906
|
+
return; // Successfully sent as file
|
|
907
|
+
}
|
|
908
|
+
log?.info?.("[dingtalk] File send failed, falling back to chunked text");
|
|
909
|
+
} else {
|
|
910
|
+
log?.info?.("[dingtalk] No credentials for file send, falling back to chunked text");
|
|
911
|
+
}
|
|
912
|
+
}
|
|
895
913
|
|
|
896
914
|
// Determine if this message should use markdown format
|
|
897
915
|
let isMarkdown: boolean;
|
|
@@ -976,6 +994,57 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
976
994
|
}
|
|
977
995
|
}
|
|
978
996
|
|
|
997
|
+
/**
|
|
998
|
+
* Helper function to send text as a markdown file
|
|
999
|
+
* Used when longTextMode is 'file' and text exceeds threshold
|
|
1000
|
+
*/
|
|
1001
|
+
async function sendTextAsFile(target: any, text: string, log?: any): Promise<boolean> {
|
|
1002
|
+
try {
|
|
1003
|
+
// Generate markdown file with UTF-8 BOM for proper Chinese display
|
|
1004
|
+
const { buffer, fileName } = textToMarkdownFile(text, "AI Response");
|
|
1005
|
+
log?.info?.("[dingtalk] Converting text to file: " + fileName + " (" + buffer.length + " bytes)");
|
|
1006
|
+
|
|
1007
|
+
// Upload the file
|
|
1008
|
+
const uploadResult = await uploadMediaFile({
|
|
1009
|
+
clientId: target.account.clientId,
|
|
1010
|
+
clientSecret: target.account.clientSecret,
|
|
1011
|
+
robotCode: target.account.robotCode || target.account.clientId,
|
|
1012
|
+
fileBuffer: buffer,
|
|
1013
|
+
fileName: fileName,
|
|
1014
|
+
fileType: 'file',
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
if (!uploadResult.mediaId) {
|
|
1018
|
+
log?.info?.("[dingtalk] File upload failed: " + (uploadResult.error || "no mediaId returned"));
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
log?.info?.("[dingtalk] File uploaded, mediaId=" + uploadResult.mediaId);
|
|
1023
|
+
|
|
1024
|
+
// Send the file message
|
|
1025
|
+
const sendResult = await sendFileMessage({
|
|
1026
|
+
clientId: target.account.clientId,
|
|
1027
|
+
clientSecret: target.account.clientSecret,
|
|
1028
|
+
robotCode: target.account.robotCode || target.account.clientId,
|
|
1029
|
+
userId: target.isDm ? target.senderId : undefined,
|
|
1030
|
+
conversationId: !target.isDm ? target.conversationId : undefined,
|
|
1031
|
+
mediaId: uploadResult.mediaId,
|
|
1032
|
+
fileName: fileName,
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
if (!sendResult.ok) {
|
|
1036
|
+
log?.info?.("[dingtalk] File send failed: " + (sendResult.error || "unknown error"));
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
log?.info?.("[dingtalk] File sent successfully");
|
|
1041
|
+
return true;
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
log?.info?.("[dingtalk] sendTextAsFile error: " + (err instanceof Error ? err.message : String(err)));
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
979
1048
|
/**
|
|
980
1049
|
* Convert bare image URLs to markdown image syntax
|
|
981
1050
|
* Detects patterns like "图1: https://..." or "https://...png" and converts to 
|