@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.4.10",
3
+ "version": "1.4.11",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for Clawdbot with Stream Mode support",
6
6
  "license": "MIT",
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
+ }
@@ -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 ![](url)