@soimy/dingtalk 3.3.0 → 3.4.1

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.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
@@ -5,7 +5,6 @@
5
5
  * Provides functions for media type detection and file upload to DingTalk media servers.
6
6
  */
7
7
 
8
- import * as fs from "node:fs";
9
8
  import { randomUUID } from "node:crypto";
10
9
  import * as os from "node:os";
11
10
  import * as path from "node:path";
@@ -16,17 +15,35 @@ import axios from "axios";
16
15
  import FormData from "form-data";
17
16
  import type { DingTalkConfig, Logger } from "./types";
18
17
  import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
18
+ import { getDingTalkRuntime } from "./runtime";
19
+
20
+ /**
21
+ * Extended PluginRuntime with media bridge support.
22
+ * The `media.loadWebMedia` method resolves sandbox/container paths
23
+ * through the runtime bridge when direct host filesystem access fails.
24
+ */
25
+ interface PluginRuntimeWithMedia {
26
+ media?: {
27
+ loadWebMedia(
28
+ mediaPath: string,
29
+ options?: { mediaLocalRoots?: string[] },
30
+ ): Promise<{ buffer: Buffer | ArrayBuffer; fileName?: string; contentType?: string } | null>;
31
+ };
32
+ [key: string]: unknown;
33
+ }
19
34
 
20
35
  /**
21
36
  * Calculate MP3 duration in seconds by parsing MPEG frame headers
22
37
  * Supports CBR and VBR MP3 files
23
- * @param filePath Path to the MP3 file
38
+ * @param filePathOrBuffer Path to the MP3 file, or a pre-read Buffer
24
39
  * @param log Optional logger
25
40
  * @returns Duration in seconds (0 if parsing fails)
26
41
  */
27
- export async function getMp3DurationSeconds(filePath: string, log?: Logger): Promise<number> {
42
+ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, log?: Logger): Promise<number> {
28
43
  try {
29
- const buffer = await fsPromises.readFile(filePath);
44
+ const buffer = typeof filePathOrBuffer === "string"
45
+ ? await fsPromises.readFile(filePathOrBuffer)
46
+ : filePathOrBuffer;
30
47
  let offset = 0;
31
48
 
32
49
  // Skip ID3v2 tag if present
@@ -181,7 +198,7 @@ export async function getMp3DurationSeconds(filePath: string, log?: Logger): Pro
181
198
  return Math.floor(duration);
182
199
  }
183
200
 
184
- log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${filePath} (found ${frameCount} frames)`);
201
+ log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${typeof filePathOrBuffer === "string" ? filePathOrBuffer : "<buffer>"} (found ${frameCount} frames)`);
185
202
  return 0;
186
203
  } catch (err: unknown) {
187
204
  log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err instanceof Error ? err.message : String(err)}`);
@@ -195,6 +212,7 @@ export async function getVoiceDurationMs(
195
212
  filePath: string,
196
213
  mediaType: DingTalkMediaType,
197
214
  log?: Logger,
215
+ options?: { mediaLocalRoots?: string[]; preReadBuffer?: Buffer },
198
216
  ): Promise<number> {
199
217
  if (mediaType !== "voice") {
200
218
  return DEFAULT_VOICE_DURATION_MS;
@@ -203,7 +221,15 @@ export async function getVoiceDurationMs(
203
221
  const ext = path.extname(filePath).toLowerCase();
204
222
 
205
223
  if (ext === ".mp3") {
206
- const durationSec = await getMp3DurationSeconds(filePath, log);
224
+ let durationSec: number;
225
+ try {
226
+ // Reuse pre-read buffer from uploadMedia when available to avoid double read
227
+ const buffer = options?.preReadBuffer
228
+ ?? (await readMediaBuffer(filePath, options, log)).buffer;
229
+ durationSec = await getMp3DurationSeconds(buffer, log);
230
+ } catch {
231
+ durationSec = 0;
232
+ }
207
233
  if (durationSec > 0) {
208
234
  return Math.max(1, Math.round(durationSec * 1000));
209
235
  }
@@ -607,36 +633,80 @@ const FILE_SIZE_LIMITS: Record<DingTalkMediaType, number> = {
607
633
  };
608
634
 
609
635
  /**
610
- * Upload media file to DingTalk and get media_id
611
- * Uses DingTalk's media upload API: https://oapi.dingtalk.com/media/upload
636
+ * Read a media file, resolving sandbox/container paths via the runtime bridge
637
+ * when direct host filesystem access fails.
612
638
  *
613
- * Note: Media files are stored temporarily by DingTalk (not in permanent storage).
614
- * The media_id can be used in subsequent message sends.
615
- *
616
- * @param config DingTalk configuration
617
- * @param mediaPath Local path to the media file
618
- * @param mediaType Type of media: 'image' | 'voice' | 'video' | 'file'
619
- * @param getAccessToken Function to get DingTalk access token
620
- * @param log Optional logger
621
- * @returns media_id on success, null on failure
639
+ * Precedence:
640
+ * 1. Direct fs.readFile (works for host-local paths)
641
+ * 2. rt.media.loadWebMedia (resolves sandbox workspace paths via bridge)
622
642
  */
643
+ async function readMediaBuffer(
644
+ mediaPath: string,
645
+ options?: { mediaLocalRoots?: string[] },
646
+ log?: Logger,
647
+ ): Promise<{ buffer: Buffer; size: number }> {
648
+ // Try direct host filesystem first
649
+ try {
650
+ const buffer = await fsPromises.readFile(mediaPath);
651
+ return { buffer, size: buffer.length };
652
+ } catch (err: unknown) {
653
+ const errno = err as NodeJS.ErrnoException;
654
+ if (errno.code !== "ENOENT") {
655
+ throw err; // Permission errors etc. should propagate immediately
656
+ }
657
+ }
658
+
659
+ // File not found on host — try runtime media bridge (sandbox/container paths)
660
+ log?.debug?.(`[DingTalk] File not found on host, trying runtime media bridge: ${mediaPath}`);
661
+ const rt = getDingTalkRuntime() as PluginRuntimeWithMedia;
662
+ if (!rt.media?.loadWebMedia) {
663
+ throw Object.assign(
664
+ new Error(`File not found and runtime media bridge unavailable: ${mediaPath}`),
665
+ { code: "ENOENT" },
666
+ );
667
+ }
668
+
669
+ const media = await rt.media.loadWebMedia(mediaPath, {
670
+ mediaLocalRoots: options?.mediaLocalRoots,
671
+ });
672
+
673
+ if (!media || !media.buffer) {
674
+ throw Object.assign(
675
+ new Error(`Runtime media bridge returned no data for: ${mediaPath}`),
676
+ { code: "ENOENT" },
677
+ );
678
+ }
679
+
680
+ const buffer = Buffer.isBuffer(media.buffer)
681
+ ? media.buffer
682
+ : Buffer.from(media.buffer);
683
+ return { buffer, size: buffer.length };
684
+ }
685
+
686
+ export interface UploadMediaResult {
687
+ mediaId: string;
688
+ /** The file buffer read during upload, reusable for voice duration parsing etc. */
689
+ buffer: Buffer;
690
+ }
691
+
623
692
  export async function uploadMedia(
624
693
  config: DingTalkConfig,
625
694
  mediaPath: string,
626
695
  mediaType: DingTalkMediaType,
627
696
  getAccessToken: (config: DingTalkConfig, log?: Logger) => Promise<string>,
628
697
  log?: Logger,
629
- ): Promise<string | null> {
630
- let fileStream: fs.ReadStream | null = null;
631
-
698
+ options?: { mediaLocalRoots?: string[] },
699
+ ): Promise<UploadMediaResult | null> {
632
700
  try {
633
701
  const token = await getAccessToken(config, log);
634
702
 
635
- // Check file size (stat will throw if file doesn't exist)
636
- const stats = await fsPromises.stat(mediaPath);
703
+ // Read file via sandbox-aware bridge (falls back to direct fs for host paths)
704
+ const { buffer, size } = await readMediaBuffer(mediaPath, options, log);
705
+
706
+ // Check file size
637
707
  const sizeLimit = FILE_SIZE_LIMITS[mediaType];
638
- if (stats.size > sizeLimit) {
639
- const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
708
+ if (size > sizeLimit) {
709
+ const sizeMB = (size / (1024 * 1024)).toFixed(2);
640
710
  const limitMB = (sizeLimit / (1024 * 1024)).toFixed(2);
641
711
  log?.error?.(
642
712
  `[DingTalk] Media file too large: ${sizeMB}MB exceeds ${limitMB}MB limit for ${mediaType}`,
@@ -644,17 +714,15 @@ export async function uploadMedia(
644
714
  return null;
645
715
  }
646
716
 
647
- // Read file as a stream for better memory efficiency
648
- fileStream = fs.createReadStream(mediaPath);
649
717
  const filename = path.basename(mediaPath);
650
718
 
651
719
  // Upload to DingTalk's media server using form-data
652
720
  const form = new FormData();
653
- form.append("media", fileStream, { filename });
721
+ form.append("media", buffer, { filename });
654
722
 
655
723
  const uploadUrl = `https://oapi.dingtalk.com/media/upload?access_token=${token}&type=${mediaType}`;
656
724
 
657
- log?.debug?.(`[DingTalk] Uploading media: ${filename} (${stats.size} bytes) as ${mediaType}`);
725
+ log?.debug?.(`[DingTalk] Uploading media: ${filename} (${size} bytes) as ${mediaType}`);
658
726
 
659
727
  const response = await axios.post(uploadUrl, form, {
660
728
  headers: form.getHeaders(),
@@ -665,9 +733,9 @@ export async function uploadMedia(
665
733
 
666
734
  if (response.data?.errcode === 0 && response.data?.media_id) {
667
735
  log?.debug?.(
668
- `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${stats.size} bytes)`,
736
+ `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${size} bytes)`,
669
737
  );
670
- return response.data.media_id;
738
+ return { mediaId: response.data.media_id, buffer };
671
739
  } else {
672
740
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
673
741
  return null;
@@ -676,7 +744,7 @@ export async function uploadMedia(
676
744
  // Handle file system errors (e.g., file not found, permission denied)
677
745
  const errno = err as NodeJS.ErrnoException;
678
746
  if (errno.code === "ENOENT") {
679
- log?.error?.(`[DingTalk] Media file not found: ${mediaPath}`);
747
+ log?.error?.(`[DingTalk] Media file not found (host and sandbox): ${mediaPath}`);
680
748
  } else if (errno.code === "EACCES") {
681
749
  log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
682
750
  } else {
@@ -690,10 +758,5 @@ export async function uploadMedia(
690
758
  }
691
759
  }
692
760
  return null;
693
- } finally {
694
- // Ensure file stream is closed even on error
695
- if (fileStream) {
696
- fileStream.destroy();
697
- }
698
761
  }
699
762
  }