@xuanyue202/shared 2026.3.21

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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * File utilities for categorizing and resolving file extensions
3
+ * @module @xuanyue202/shared/file
4
+ */
5
+
6
+ /**
7
+ * File category for processing strategy
8
+ */
9
+ export type FileCategory =
10
+ | "image"
11
+ | "audio"
12
+ | "video"
13
+ | "document"
14
+ | "archive"
15
+ | "code"
16
+ | "other";
17
+
18
+ /**
19
+ * MIME type to extension mapping
20
+ */
21
+ const MIME_TO_EXTENSION: Record<string, string> = {
22
+ // Images
23
+ "image/jpeg": ".jpg",
24
+ "image/png": ".png",
25
+ "image/gif": ".gif",
26
+ "image/webp": ".webp",
27
+ "image/bmp": ".bmp",
28
+
29
+ // Audio
30
+ "audio/mpeg": ".mp3",
31
+ "audio/wav": ".wav",
32
+ "audio/ogg": ".ogg",
33
+ "audio/amr": ".amr",
34
+ "audio/x-m4a": ".m4a",
35
+
36
+ // Video
37
+ "video/mp4": ".mp4",
38
+ "video/quicktime": ".mov",
39
+ "video/x-msvideo": ".avi",
40
+ "video/webm": ".webm",
41
+
42
+ // Documents
43
+ "application/pdf": ".pdf",
44
+ "application/msword": ".doc",
45
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
46
+ ".docx",
47
+ "application/vnd.ms-excel": ".xls",
48
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
49
+ "application/vnd.ms-powerpoint": ".ppt",
50
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation":
51
+ ".pptx",
52
+ "application/rtf": ".rtf",
53
+ "application/vnd.oasis.opendocument.text": ".odt",
54
+ "application/vnd.oasis.opendocument.spreadsheet": ".ods",
55
+ "text/plain": ".txt",
56
+ "text/markdown": ".md",
57
+ "text/csv": ".csv",
58
+
59
+ // Archives
60
+ "application/zip": ".zip",
61
+ "application/x-rar-compressed": ".rar",
62
+ "application/vnd.rar": ".rar",
63
+ "application/x-7z-compressed": ".7z",
64
+ "application/x-tar": ".tar",
65
+ "application/gzip": ".gz",
66
+ "application/x-gzip": ".gz",
67
+ "application/x-bzip2": ".bz2",
68
+
69
+ // Code
70
+ "application/json": ".json",
71
+ "application/xml": ".xml",
72
+ "text/xml": ".xml",
73
+ "text/html": ".html",
74
+ "text/css": ".css",
75
+ "text/javascript": ".js",
76
+ "application/javascript": ".js",
77
+ "text/x-python": ".py",
78
+ "text/x-java-source": ".java",
79
+ "text/x-c": ".c",
80
+ "text/x-yaml": ".yaml",
81
+ "application/x-yaml": ".yaml",
82
+ };
83
+
84
+ /**
85
+ * MIME type to category mapping for non-prefix-based types
86
+ */
87
+ const CATEGORY_BY_MIME: Record<string, FileCategory> = {
88
+ // Documents
89
+ "application/pdf": "document",
90
+ "application/msword": "document",
91
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
92
+ "document",
93
+ "application/vnd.ms-excel": "document",
94
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
95
+ "document",
96
+ "application/vnd.ms-powerpoint": "document",
97
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation":
98
+ "document",
99
+ "application/rtf": "document",
100
+ "application/vnd.oasis.opendocument.text": "document",
101
+ "application/vnd.oasis.opendocument.spreadsheet": "document",
102
+ "text/plain": "document",
103
+ "text/markdown": "document",
104
+ "text/csv": "document",
105
+ // Archives
106
+ "application/zip": "archive",
107
+ "application/x-rar-compressed": "archive",
108
+ "application/vnd.rar": "archive",
109
+ "application/x-7z-compressed": "archive",
110
+ "application/x-tar": "archive",
111
+ "application/gzip": "archive",
112
+ "application/x-gzip": "archive",
113
+ "application/x-bzip2": "archive",
114
+ // Code
115
+ "application/json": "code",
116
+ "application/xml": "code",
117
+ "text/xml": "code",
118
+ "text/html": "code",
119
+ "text/css": "code",
120
+ "text/javascript": "code",
121
+ "application/javascript": "code",
122
+ "text/x-python": "code",
123
+ "text/x-java-source": "code",
124
+ "text/x-c": "code",
125
+ "text/x-yaml": "code",
126
+ "application/x-yaml": "code",
127
+ };
128
+
129
+ /**
130
+ * Extension to category mapping
131
+ */
132
+ const CATEGORY_BY_EXTENSION: Record<string, FileCategory> = {
133
+ // Images
134
+ ".jpg": "image",
135
+ ".jpeg": "image",
136
+ ".png": "image",
137
+ ".gif": "image",
138
+ ".webp": "image",
139
+ ".bmp": "image",
140
+ // Audio
141
+ ".mp3": "audio",
142
+ ".wav": "audio",
143
+ ".ogg": "audio",
144
+ ".m4a": "audio",
145
+ ".amr": "audio",
146
+ // Video
147
+ ".mp4": "video",
148
+ ".mov": "video",
149
+ ".avi": "video",
150
+ ".mkv": "video",
151
+ ".webm": "video",
152
+ // Documents
153
+ ".pdf": "document",
154
+ ".doc": "document",
155
+ ".docx": "document",
156
+ ".txt": "document",
157
+ ".md": "document",
158
+ ".rtf": "document",
159
+ ".odt": "document",
160
+ ".xls": "document",
161
+ ".xlsx": "document",
162
+ ".csv": "document",
163
+ ".ods": "document",
164
+ ".ppt": "document",
165
+ ".pptx": "document",
166
+ // Archives
167
+ ".zip": "archive",
168
+ ".rar": "archive",
169
+ ".7z": "archive",
170
+ ".tar": "archive",
171
+ ".gz": "archive",
172
+ ".bz2": "archive",
173
+ // Code
174
+ ".py": "code",
175
+ ".js": "code",
176
+ ".ts": "code",
177
+ ".jsx": "code",
178
+ ".tsx": "code",
179
+ ".java": "code",
180
+ ".cpp": "code",
181
+ ".c": "code",
182
+ ".go": "code",
183
+ ".rs": "code",
184
+ ".json": "code",
185
+ ".xml": "code",
186
+ ".yaml": "code",
187
+ ".yml": "code",
188
+ ".html": "code",
189
+ ".css": "code",
190
+ };
191
+
192
+ /**
193
+ * Extract file extension from a file name
194
+ * @param fileName - The file name to extract extension from
195
+ * @returns The extension with leading dot (e.g., ".jpg") or empty string if none
196
+ */
197
+ function extractExtension(fileName: string): string {
198
+ const lastDot = fileName.lastIndexOf(".");
199
+ if (lastDot === -1 || lastDot === fileName.length - 1) {
200
+ return "";
201
+ }
202
+ return fileName.slice(lastDot).toLowerCase();
203
+ }
204
+
205
+ /**
206
+ * Categorize a file based on MIME type and extension
207
+ *
208
+ * Priority:
209
+ * 1. Check MIME type prefix (image/, audio/, video/)
210
+ * 2. Check exact MIME type mapping (document, archive, code)
211
+ * 3. Check file extension from fileName
212
+ * 4. Return 'other' if no match
213
+ *
214
+ * @param contentType - MIME type string
215
+ * @param fileName - Optional file name for extension-based fallback
216
+ * @returns File category
217
+ */
218
+ export function resolveFileCategory(
219
+ contentType: string,
220
+ fileName?: string
221
+ ): FileCategory {
222
+ // Normalize content type (remove parameters like charset)
223
+ const mimeType = contentType.split(";")[0].trim().toLowerCase();
224
+
225
+ // Check MIME type prefix first (image/, audio/, video/)
226
+ if (mimeType.startsWith("image/")) {
227
+ return "image";
228
+ }
229
+ if (mimeType.startsWith("audio/")) {
230
+ return "audio";
231
+ }
232
+ if (mimeType.startsWith("video/")) {
233
+ return "video";
234
+ }
235
+
236
+ // Check exact MIME type mapping (document, archive, code)
237
+ if (mimeType in CATEGORY_BY_MIME) {
238
+ return CATEGORY_BY_MIME[mimeType];
239
+ }
240
+
241
+ // Check file extension if fileName is provided
242
+ if (fileName) {
243
+ const ext = extractExtension(fileName);
244
+ if (ext && ext in CATEGORY_BY_EXTENSION) {
245
+ return CATEGORY_BY_EXTENSION[ext];
246
+ }
247
+ }
248
+
249
+ return "other";
250
+ }
251
+
252
+ /**
253
+ * Resolve file extension from MIME type or fileName
254
+ *
255
+ * Priority:
256
+ * 1. fileName extension (if provided and has extension)
257
+ * 2. MIME type mapping
258
+ * 3. ".bin" default
259
+ *
260
+ * @param contentType - MIME type string
261
+ * @param fileName - Optional file name to extract extension from (takes precedence)
262
+ * @returns Extension with leading dot (e.g., ".jpg") or ".bin" if unknown
263
+ */
264
+ export function resolveExtension(
265
+ contentType: string,
266
+ fileName?: string
267
+ ): string {
268
+ // Priority 1: fileName extension
269
+ if (fileName) {
270
+ const ext = extractExtension(fileName);
271
+ if (ext) {
272
+ return ext;
273
+ }
274
+ }
275
+
276
+ // Priority 2: MIME type mapping
277
+ const mimeType = contentType.split(";")[0].trim().toLowerCase();
278
+ if (mimeType in MIME_TO_EXTENSION) {
279
+ return MIME_TO_EXTENSION[mimeType];
280
+ }
281
+
282
+ // Priority 3: Default
283
+ return ".bin";
284
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * File utilities module
3
+ * @module @xuanyue202/shared/file
4
+ */
5
+
6
+ export {
7
+ type FileCategory,
8
+ resolveFileCategory,
9
+ resolveExtension,
10
+ } from "./file-utils.js";
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 通用 HTTP 客户端封装
3
+ *
4
+ * 提供带超时和错误处理的 HTTP 请求功能
5
+ */
6
+
7
+ /**
8
+ * HTTP 请求选项
9
+ */
10
+ export interface HttpRequestOptions {
11
+ /** 请求超时时间(毫秒),默认 30000 */
12
+ timeout?: number;
13
+ /** 请求头 */
14
+ headers?: Record<string, string>;
15
+ }
16
+
17
+ /**
18
+ * HTTP 错误
19
+ */
20
+ export class HttpError extends Error {
21
+ constructor(
22
+ message: string,
23
+ public readonly status: number,
24
+ public readonly body?: string
25
+ ) {
26
+ super(message);
27
+ this.name = "HttpError";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * 超时错误
33
+ */
34
+ export class TimeoutError extends Error {
35
+ constructor(message: string, public readonly timeoutMs: number) {
36
+ super(message);
37
+ this.name = "TimeoutError";
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 发送 HTTP POST 请求
43
+ *
44
+ * @param url 请求 URL
45
+ * @param body 请求体
46
+ * @param options 请求选项
47
+ * @returns 响应数据
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const data = await httpPost("https://api.example.com/token", { key: "value" }, { timeout: 10000 });
52
+ * ```
53
+ */
54
+ export async function httpPost<T = unknown>(
55
+ url: string,
56
+ body: unknown,
57
+ options?: HttpRequestOptions
58
+ ): Promise<T> {
59
+ const { timeout = 30000, headers = {} } = options ?? {};
60
+
61
+ const controller = new AbortController();
62
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
63
+
64
+ try {
65
+ const response = await fetch(url, {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ ...headers,
70
+ },
71
+ body: JSON.stringify(body),
72
+ signal: controller.signal,
73
+ });
74
+
75
+ if (!response.ok) {
76
+ const responseBody = await response.text().catch(() => "");
77
+ throw new HttpError(
78
+ `HTTP ${response.status}: ${response.statusText}`,
79
+ response.status,
80
+ responseBody
81
+ );
82
+ }
83
+
84
+ return (await response.json()) as T;
85
+ } catch (err) {
86
+ if (err instanceof Error && err.name === "AbortError") {
87
+ throw new TimeoutError(`Request timeout after ${timeout}ms`, timeout);
88
+ }
89
+ throw err;
90
+ } finally {
91
+ clearTimeout(timeoutId);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 发送 HTTP GET 请求
97
+ *
98
+ * @param url 请求 URL
99
+ * @param options 请求选项
100
+ * @returns 响应数据
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * const data = await httpGet("https://api.example.com/data", { timeout: 10000 });
105
+ * ```
106
+ */
107
+ export async function httpGet<T = unknown>(
108
+ url: string,
109
+ options?: HttpRequestOptions
110
+ ): Promise<T> {
111
+ const { timeout = 30000, headers = {} } = options ?? {};
112
+
113
+ const controller = new AbortController();
114
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
115
+
116
+ try {
117
+ const response = await fetch(url, {
118
+ method: "GET",
119
+ headers,
120
+ signal: controller.signal,
121
+ });
122
+
123
+ if (!response.ok) {
124
+ const responseBody = await response.text().catch(() => "");
125
+ throw new HttpError(
126
+ `HTTP ${response.status}: ${response.statusText}`,
127
+ response.status,
128
+ responseBody
129
+ );
130
+ }
131
+
132
+ return (await response.json()) as T;
133
+ } catch (err) {
134
+ if (err instanceof Error && err.name === "AbortError") {
135
+ throw new TimeoutError(`Request timeout after ${timeout}ms`, timeout);
136
+ }
137
+ throw err;
138
+ } finally {
139
+ clearTimeout(timeoutId);
140
+ }
141
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./client.js";
2
+ export * from "./retry.js";
@@ -0,0 +1,110 @@
1
+ /**
2
+ * HTTP 重试策略
3
+ *
4
+ * 提供可配置的重试逻辑
5
+ */
6
+
7
+ /**
8
+ * 重试选项
9
+ */
10
+ export interface RetryOptions {
11
+ /** 最大重试次数,默认 3 */
12
+ maxRetries?: number;
13
+ /** 初始延迟时间(毫秒),默认 1000 */
14
+ initialDelay?: number;
15
+ /** 最大延迟时间(毫秒),默认 10000 */
16
+ maxDelay?: number;
17
+ /** 延迟倍数(指数退避),默认 2 */
18
+ backoffMultiplier?: number;
19
+ /** 判断是否应该重试的函数 */
20
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
21
+ }
22
+
23
+ /**
24
+ * 默认的重试判断函数
25
+ * 对于网络错误和 5xx 错误进行重试
26
+ */
27
+ export function defaultShouldRetry(error: unknown): boolean {
28
+ if (error instanceof Error) {
29
+ // 网络错误
30
+ if (error.name === "TypeError" || error.name === "TimeoutError") {
31
+ return true;
32
+ }
33
+ // HTTP 5xx 错误
34
+ if ("status" in error && typeof (error as { status: number }).status === "number") {
35
+ const status = (error as { status: number }).status;
36
+ return status >= 500 && status < 600;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
43
+ * 计算延迟时间(指数退避)
44
+ */
45
+ function calculateDelay(
46
+ attempt: number,
47
+ initialDelay: number,
48
+ maxDelay: number,
49
+ backoffMultiplier: number
50
+ ): number {
51
+ const delay = initialDelay * Math.pow(backoffMultiplier, attempt - 1);
52
+ return Math.min(delay, maxDelay);
53
+ }
54
+
55
+ /**
56
+ * 延迟执行
57
+ */
58
+ function sleep(ms: number): Promise<void> {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ /**
63
+ * 带重试的异步函数执行器
64
+ *
65
+ * @param fn 要执行的异步函数
66
+ * @param options 重试选项
67
+ * @returns 函数执行结果
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const result = await withRetry(
72
+ * () => httpPost(url, body),
73
+ * { maxRetries: 3, initialDelay: 1000 }
74
+ * );
75
+ * ```
76
+ */
77
+ export async function withRetry<T>(
78
+ fn: () => Promise<T>,
79
+ options?: RetryOptions
80
+ ): Promise<T> {
81
+ const {
82
+ maxRetries = 3,
83
+ initialDelay = 1000,
84
+ maxDelay = 10000,
85
+ backoffMultiplier = 2,
86
+ shouldRetry = defaultShouldRetry,
87
+ } = options ?? {};
88
+
89
+ let lastError: unknown;
90
+
91
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
92
+ try {
93
+ return await fn();
94
+ } catch (error) {
95
+ lastError = error;
96
+
97
+ // 如果是最后一次尝试或不应该重试,直接抛出错误
98
+ if (attempt > maxRetries || !shouldRetry(error, attempt)) {
99
+ throw error;
100
+ }
101
+
102
+ // 计算延迟并等待
103
+ const delay = calculateDelay(attempt, initialDelay, maxDelay, backoffMultiplier);
104
+ await sleep(delay);
105
+ }
106
+ }
107
+
108
+ // 理论上不会到达这里,但为了类型安全
109
+ throw lastError;
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ // @xuanyue202/shared
2
+ // 共享工具模块
3
+
4
+ export * from "./logger/index.js";
5
+ export * from "./policy/index.js";
6
+ export * from "./http/index.js";
7
+ export * from "./types/common.js";
8
+ export * from "./file/index.js";
9
+ export * from "./media/index.js";
10
+ export * from "./cron/index.js";
11
+ export * from "./asr/index.js";
12
+ export * from "./cli/index.js";
@@ -0,0 +1 @@
1
+ export * from "./logger.js";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 通用日志工具
3
+ *
4
+ * 提供分级日志功能:
5
+ * - info: 关键业务日志(默认显示)
6
+ * - debug: 调试日志(带 [DEBUG] 标记)
7
+ * - error: 错误日志
8
+ * - warn: 警告日志
9
+ */
10
+
11
+ export type LogLevel = "debug" | "info" | "warn" | "error";
12
+
13
+ export interface Logger {
14
+ debug: (msg: string) => void;
15
+ info: (msg: string) => void;
16
+ warn: (msg: string) => void;
17
+ error: (msg: string) => void;
18
+ }
19
+
20
+ export interface LoggerOptions {
21
+ log?: (msg: string) => void;
22
+ error?: (msg: string) => void;
23
+ }
24
+
25
+ /**
26
+ * 创建带前缀的日志器
27
+ *
28
+ * @param prefix 日志前缀(如 "dingtalk", "feishu")
29
+ * @param opts 可选的日志输出函数
30
+ * @returns Logger 实例
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const logger = createLogger("dingtalk");
35
+ * logger.debug("connecting..."); // [dingtalk] [DEBUG] connecting...
36
+ * logger.info("connected"); // [dingtalk] connected
37
+ * logger.warn("slow response"); // [dingtalk] [WARN] slow response
38
+ * logger.error("failed"); // [dingtalk] [ERROR] failed
39
+ * ```
40
+ */
41
+ export function createLogger(prefix: string, opts?: LoggerOptions): Logger {
42
+ const logFn = opts?.log ?? console.log;
43
+ const errorFn = opts?.error ?? console.error;
44
+
45
+ return {
46
+ debug: (msg: string) => logFn(`[${prefix}] [DEBUG] ${msg}`),
47
+ info: (msg: string) => logFn(`[${prefix}] ${msg}`),
48
+ warn: (msg: string) => logFn(`[${prefix}] [WARN] ${msg}`),
49
+ error: (msg: string) => errorFn(`[${prefix}] [ERROR] ${msg}`),
50
+ };
51
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 媒体处理模块
3
+ *
4
+ * 提供统一的媒体解析、路径处理和文件读取功能
5
+ *
6
+ * @module @xuanyue202/shared/media
7
+ */
8
+
9
+ // 媒体解析
10
+ export {
11
+ // 类型
12
+ type MediaType,
13
+ type ExtractedMedia,
14
+ type MediaParseResult,
15
+ type MediaParseOptions,
16
+ // 常量
17
+ IMAGE_EXTENSIONS,
18
+ AUDIO_EXTENSIONS,
19
+ VIDEO_EXTENSIONS,
20
+ NON_IMAGE_EXTENSIONS,
21
+ // 路径处理函数
22
+ isHttpUrl,
23
+ isFileUrl,
24
+ isLocalReference,
25
+ normalizeLocalPath,
26
+ stripTitleFromUrl,
27
+ getExtension,
28
+ isImagePath,
29
+ isNonImageFilePath,
30
+ detectMediaType,
31
+ // 媒体提取函数
32
+ extractMediaFromText,
33
+ extractImagesFromText,
34
+ extractFilesFromText,
35
+ } from "./media-parser.js";
36
+
37
+ // 媒体 IO
38
+ export {
39
+ // 类型
40
+ type MediaReadResult,
41
+ type MediaReadOptions,
42
+ type DownloadToTempFileResult,
43
+ type DownloadToTempFileOptions,
44
+ type FinalizeInboundMediaOptions,
45
+ type PruneInboundMediaDirOptions,
46
+ type PathSecurityOptions,
47
+ // 错误类
48
+ FileSizeLimitError,
49
+ MediaTimeoutError,
50
+ PathSecurityError,
51
+ // 路径安全
52
+ validatePathSecurity,
53
+ getDefaultAllowedPrefixes,
54
+ // MIME 类型
55
+ getMimeType,
56
+ // 媒体读取函数
57
+ fetchMediaFromUrl,
58
+ readMediaFromLocal,
59
+ readMedia,
60
+ readMediaBatch,
61
+ downloadToTempFile,
62
+ finalizeInboundMediaFile,
63
+ pruneInboundMediaDir,
64
+ cleanupFileSafe,
65
+ } from "./media-io.js";