@wwlocal/aibot-plugin-node 20260409.20.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.
Files changed (93) hide show
  1. package/README.md +489 -0
  2. package/config.example.json +169 -0
  3. package/dist/cjs/index.js +76 -0
  4. package/dist/cjs/src/adapters/anthropic-adapter.js +534 -0
  5. package/dist/cjs/src/adapters/base-adapter.js +176 -0
  6. package/dist/cjs/src/adapters/deepseek-adapter.js +328 -0
  7. package/dist/cjs/src/adapters/dify-adapter.js +636 -0
  8. package/dist/cjs/src/adapters/index.js +131 -0
  9. package/dist/cjs/src/adapters/openai-adapter.js +361 -0
  10. package/dist/cjs/src/adapters/webhook-adapter.js +260 -0
  11. package/dist/cjs/src/agent-forwarder.js +87 -0
  12. package/dist/cjs/src/ca-cert.js +162 -0
  13. package/dist/cjs/src/config.js +169 -0
  14. package/dist/cjs/src/const.js +124 -0
  15. package/dist/cjs/src/conversation-manager.js +147 -0
  16. package/dist/cjs/src/dm-policy.js +46 -0
  17. package/dist/cjs/src/group-policy.js +95 -0
  18. package/dist/cjs/src/media-handler.js +136 -0
  19. package/dist/cjs/src/media-loader.js +271 -0
  20. package/dist/cjs/src/media-storage.js +165 -0
  21. package/dist/cjs/src/media-uploader.js +203 -0
  22. package/dist/cjs/src/message-parser.js +133 -0
  23. package/dist/cjs/src/message-sender.js +87 -0
  24. package/dist/cjs/src/monitor.js +849 -0
  25. package/dist/cjs/src/reqid-store.js +87 -0
  26. package/dist/cjs/src/server.js +72 -0
  27. package/dist/cjs/src/service-manager.js +135 -0
  28. package/dist/cjs/src/state-manager.js +143 -0
  29. package/dist/cjs/src/template-card-parser.js +498 -0
  30. package/dist/cjs/src/timeout.js +41 -0
  31. package/dist/cjs/src/version.js +25 -0
  32. package/dist/esm/index.js +74 -0
  33. package/dist/esm/src/adapters/anthropic-adapter.js +512 -0
  34. package/dist/esm/src/adapters/base-adapter.js +174 -0
  35. package/dist/esm/src/adapters/deepseek-adapter.js +326 -0
  36. package/dist/esm/src/adapters/dify-adapter.js +634 -0
  37. package/dist/esm/src/adapters/index.js +123 -0
  38. package/dist/esm/src/adapters/openai-adapter.js +339 -0
  39. package/dist/esm/src/adapters/webhook-adapter.js +258 -0
  40. package/dist/esm/src/agent-forwarder.js +84 -0
  41. package/dist/esm/src/ca-cert.js +136 -0
  42. package/dist/esm/src/config.js +145 -0
  43. package/dist/esm/src/const.js +100 -0
  44. package/dist/esm/src/conversation-manager.js +144 -0
  45. package/dist/esm/src/dm-policy.js +44 -0
  46. package/dist/esm/src/group-policy.js +92 -0
  47. package/dist/esm/src/media-handler.js +133 -0
  48. package/dist/esm/src/media-loader.js +246 -0
  49. package/dist/esm/src/media-storage.js +143 -0
  50. package/dist/esm/src/media-uploader.js +198 -0
  51. package/dist/esm/src/message-parser.js +131 -0
  52. package/dist/esm/src/message-sender.js +83 -0
  53. package/dist/esm/src/monitor.js +841 -0
  54. package/dist/esm/src/reqid-store.js +85 -0
  55. package/dist/esm/src/server.js +69 -0
  56. package/dist/esm/src/service-manager.js +133 -0
  57. package/dist/esm/src/state-manager.js +134 -0
  58. package/dist/esm/src/template-card-parser.js +495 -0
  59. package/dist/esm/src/timeout.js +38 -0
  60. package/dist/esm/src/version.js +22 -0
  61. package/dist/esm/types/index.d.ts +14 -0
  62. package/dist/esm/types/src/adapters/anthropic-adapter.d.ts +93 -0
  63. package/dist/esm/types/src/adapters/base-adapter.d.ts +76 -0
  64. package/dist/esm/types/src/adapters/deepseek-adapter.d.ts +87 -0
  65. package/dist/esm/types/src/adapters/dify-adapter.d.ts +100 -0
  66. package/dist/esm/types/src/adapters/index.d.ts +60 -0
  67. package/dist/esm/types/src/adapters/openai-adapter.d.ts +82 -0
  68. package/dist/esm/types/src/adapters/types.d.ts +373 -0
  69. package/dist/esm/types/src/adapters/webhook-adapter.d.ts +54 -0
  70. package/dist/esm/types/src/agent-forwarder.d.ts +32 -0
  71. package/dist/esm/types/src/ca-cert.d.ts +53 -0
  72. package/dist/esm/types/src/config.d.ts +29 -0
  73. package/dist/esm/types/src/const.d.ts +74 -0
  74. package/dist/esm/types/src/conversation-manager.d.ts +81 -0
  75. package/dist/esm/types/src/dm-policy.d.ts +27 -0
  76. package/dist/esm/types/src/group-policy.d.ts +28 -0
  77. package/dist/esm/types/src/interface.d.ts +332 -0
  78. package/dist/esm/types/src/media-handler.d.ts +36 -0
  79. package/dist/esm/types/src/media-loader.d.ts +47 -0
  80. package/dist/esm/types/src/media-storage.d.ts +35 -0
  81. package/dist/esm/types/src/media-uploader.d.ts +65 -0
  82. package/dist/esm/types/src/message-parser.d.ts +89 -0
  83. package/dist/esm/types/src/message-sender.d.ts +34 -0
  84. package/dist/esm/types/src/monitor.d.ts +30 -0
  85. package/dist/esm/types/src/reqid-store.d.ts +23 -0
  86. package/dist/esm/types/src/server.d.ts +23 -0
  87. package/dist/esm/types/src/service-manager.d.ts +52 -0
  88. package/dist/esm/types/src/state-manager.d.ts +76 -0
  89. package/dist/esm/types/src/template-card-parser.d.ts +18 -0
  90. package/dist/esm/types/src/timeout.d.ts +20 -0
  91. package/dist/esm/types/src/version.d.ts +2 -0
  92. package/dist/index.d.ts +2 -0
  93. package/package.json +51 -0
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ var fs = require('node:fs/promises');
4
+ var path = require('node:path');
5
+ var os = require('node:os');
6
+ var node_url = require('node:url');
7
+
8
+ function _interopNamespaceDefault(e) {
9
+ var n = Object.create(null);
10
+ if (e) {
11
+ Object.keys(e).forEach(function (k) {
12
+ if (k !== 'default') {
13
+ var d = Object.getOwnPropertyDescriptor(e, k);
14
+ Object.defineProperty(n, k, d.get ? d : {
15
+ enumerable: true,
16
+ get: function () { return e[k]; }
17
+ });
18
+ }
19
+ });
20
+ }
21
+ n.default = e;
22
+ return Object.freeze(n);
23
+ }
24
+
25
+ var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
26
+ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
27
+ var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
28
+
29
+ /**
30
+ * 出站媒体文件加载器
31
+ *
32
+ * 内联 openclaw-compat.ts 的 fallback 实现:
33
+ * - loadOutboundMediaFromUrl(支持远程 URL 和本地文件)
34
+ * - detectMime(buffer 嗅探 + 扩展名映射)
35
+ * - assertLocalMediaAllowed(安全白名单检查)
36
+ *
37
+ * 不再有 OpenClaw SDK 探测逻辑,直接使用独立实现。
38
+ */
39
+ // ============================================================================
40
+ // MIME 检测
41
+ // ============================================================================
42
+ const MIME_BY_EXT = {
43
+ ".heic": "image/heic",
44
+ ".heif": "image/heif",
45
+ ".jpg": "image/jpeg",
46
+ ".jpeg": "image/jpeg",
47
+ ".png": "image/png",
48
+ ".webp": "image/webp",
49
+ ".gif": "image/gif",
50
+ ".ogg": "audio/ogg",
51
+ ".mp3": "audio/mpeg",
52
+ ".m4a": "audio/x-m4a",
53
+ ".mp4": "video/mp4",
54
+ ".mov": "video/quicktime",
55
+ ".pdf": "application/pdf",
56
+ ".json": "application/json",
57
+ ".zip": "application/zip",
58
+ ".gz": "application/gzip",
59
+ ".tar": "application/x-tar",
60
+ ".7z": "application/x-7z-compressed",
61
+ ".rar": "application/vnd.rar",
62
+ ".doc": "application/msword",
63
+ ".xls": "application/vnd.ms-excel",
64
+ ".ppt": "application/vnd.ms-powerpoint",
65
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
66
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
67
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
68
+ ".csv": "text/csv",
69
+ ".txt": "text/plain",
70
+ ".md": "text/markdown",
71
+ ".amr": "audio/amr",
72
+ ".aac": "audio/aac",
73
+ ".wav": "audio/wav",
74
+ ".webm": "video/webm",
75
+ ".avi": "video/x-msvideo",
76
+ ".bmp": "image/bmp",
77
+ ".svg": "image/svg+xml",
78
+ };
79
+ /** 通过 buffer 魔术字节嗅探 MIME 类型 */
80
+ async function sniffMimeFromBuffer(buffer) {
81
+ try {
82
+ const { fileTypeFromBuffer } = await import('file-type');
83
+ const type = await fileTypeFromBuffer(buffer);
84
+ return type?.mime ?? undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ /**
91
+ * 检测 MIME 类型
92
+ *
93
+ * 支持两种调用签名:
94
+ * - detectMime(buffer) → 仅 buffer 嗅探
95
+ * - detectMime({ buffer, headerMime, filePath }) → 完整参数
96
+ */
97
+ async function detectMime(bufferOrOpts) {
98
+ const opts = Buffer.isBuffer(bufferOrOpts)
99
+ ? { buffer: bufferOrOpts }
100
+ : bufferOrOpts;
101
+ const ext = opts.filePath ? path__namespace.extname(opts.filePath).toLowerCase() : undefined;
102
+ const extMime = ext ? MIME_BY_EXT[ext] : undefined;
103
+ const sniffed = opts.buffer ? await sniffMimeFromBuffer(opts.buffer) : undefined;
104
+ const isGeneric = (m) => !m || m === "application/octet-stream" || m === "application/zip";
105
+ if (sniffed && (!isGeneric(sniffed) || !extMime)) {
106
+ return sniffed;
107
+ }
108
+ if (extMime) {
109
+ return extMime;
110
+ }
111
+ const headerMime = opts.headerMime?.split(";")?.[0]?.trim().toLowerCase();
112
+ if (headerMime && !isGeneric(headerMime)) {
113
+ return headerMime;
114
+ }
115
+ if (sniffed) {
116
+ return sniffed;
117
+ }
118
+ if (headerMime) {
119
+ return headerMime;
120
+ }
121
+ return undefined;
122
+ }
123
+ // ============================================================================
124
+ // 本地文件安全校验
125
+ // ============================================================================
126
+ /**
127
+ * 校验本地媒体文件路径是否在允许的根目录列表内
128
+ */
129
+ async function assertLocalMediaAllowed(mediaPath, localRoots) {
130
+ if (!localRoots || localRoots.length === 0) {
131
+ throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
132
+ }
133
+ let resolved;
134
+ try {
135
+ resolved = await fs__namespace.realpath(mediaPath);
136
+ }
137
+ catch {
138
+ resolved = path__namespace.resolve(mediaPath);
139
+ }
140
+ for (const root of localRoots) {
141
+ let resolvedRoot;
142
+ try {
143
+ resolvedRoot = await fs__namespace.realpath(root);
144
+ }
145
+ catch {
146
+ resolvedRoot = path__namespace.resolve(root);
147
+ }
148
+ if (resolvedRoot === path__namespace.parse(resolvedRoot).root) {
149
+ continue;
150
+ }
151
+ if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path__namespace.sep)) {
152
+ return;
153
+ }
154
+ }
155
+ throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
156
+ }
157
+ // ============================================================================
158
+ // 远程媒体下载
159
+ // ============================================================================
160
+ /** 从远程 URL 获取媒体 */
161
+ async function fetchRemoteMedia(url, maxBytes) {
162
+ const res = await fetch(url, { redirect: "follow" });
163
+ if (!res.ok) {
164
+ throw new Error(`Failed to fetch media from ${url}: HTTP ${res.status} ${res.statusText}`);
165
+ }
166
+ const buffer = Buffer.from(await res.arrayBuffer());
167
+ if (maxBytes && buffer.length > maxBytes) {
168
+ throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
169
+ }
170
+ const headerMime = res.headers.get("content-type")?.split(";")?.[0]?.trim();
171
+ let fileName;
172
+ const disposition = res.headers.get("content-disposition");
173
+ if (disposition) {
174
+ const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
175
+ if (match?.[1]) {
176
+ try {
177
+ fileName = path__namespace.basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
178
+ }
179
+ catch {
180
+ fileName = path__namespace.basename(match[1].replace(/["']/g, "").trim());
181
+ }
182
+ }
183
+ }
184
+ if (!fileName) {
185
+ try {
186
+ const parsed = new URL(url);
187
+ const base = path__namespace.basename(parsed.pathname);
188
+ if (base && base.includes("."))
189
+ fileName = base;
190
+ }
191
+ catch { /* ignore */ }
192
+ }
193
+ const contentType = await detectMime({ buffer, headerMime, filePath: fileName ?? url });
194
+ return { buffer, contentType, fileName };
195
+ }
196
+ /** 展开 ~ 为用户主目录 */
197
+ function resolveUserPath(p) {
198
+ if (p.startsWith("~")) {
199
+ return path__namespace.join(os__namespace.homedir(), p.slice(1));
200
+ }
201
+ return p;
202
+ }
203
+ // ============================================================================
204
+ // 公开 API
205
+ // ============================================================================
206
+ /**
207
+ * 从 URL 或本地路径加载媒体文件
208
+ *
209
+ * 支持:
210
+ * - 远程 URL (http:// / https://)
211
+ * - 本地文件路径 (file:// 或绝对路径)
212
+ * - ~ 路径展开
213
+ */
214
+ async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
215
+ const { maxBytes, mediaLocalRoots } = options;
216
+ // 去除 MEDIA: 前缀
217
+ mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
218
+ // 处理 file:// URL
219
+ if (mediaUrl.startsWith("file://")) {
220
+ try {
221
+ mediaUrl = node_url.fileURLToPath(mediaUrl);
222
+ }
223
+ catch {
224
+ throw new Error(`Invalid file:// URL: ${mediaUrl}`);
225
+ }
226
+ }
227
+ // 远程 URL
228
+ if (/^https?:\/\//i.test(mediaUrl)) {
229
+ const fetched = await fetchRemoteMedia(mediaUrl, maxBytes);
230
+ return {
231
+ buffer: fetched.buffer,
232
+ contentType: fetched.contentType,
233
+ fileName: fetched.fileName,
234
+ };
235
+ }
236
+ // 展开 ~ 路径
237
+ if (mediaUrl.startsWith("~")) {
238
+ mediaUrl = resolveUserPath(mediaUrl);
239
+ }
240
+ // 本地文件:安全校验
241
+ await assertLocalMediaAllowed(mediaUrl, mediaLocalRoots);
242
+ // 读取本地文件
243
+ let data;
244
+ try {
245
+ const stat = await fs__namespace.stat(mediaUrl);
246
+ if (!stat.isFile()) {
247
+ throw new Error(`Local media path is not a file: ${mediaUrl}`);
248
+ }
249
+ data = await fs__namespace.readFile(mediaUrl);
250
+ }
251
+ catch (err) {
252
+ if (err?.code === "ENOENT") {
253
+ throw new Error(`Local media file not found: ${mediaUrl}`);
254
+ }
255
+ throw err;
256
+ }
257
+ if (maxBytes && data.length > maxBytes) {
258
+ throw new Error(`Local media exceeds max size (${data.length} > ${maxBytes})`);
259
+ }
260
+ const mime = await detectMime({ buffer: data, filePath: mediaUrl });
261
+ const fileName = path__namespace.basename(mediaUrl) || undefined;
262
+ return {
263
+ buffer: data,
264
+ contentType: mime,
265
+ fileName,
266
+ };
267
+ }
268
+
269
+ exports.assertLocalMediaAllowed = assertLocalMediaAllowed;
270
+ exports.detectMime = detectMime;
271
+ exports.loadOutboundMediaFromUrl = loadOutboundMediaFromUrl;
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ var fs = require('node:fs');
4
+ var path = require('node:path');
5
+ var fileType = require('file-type');
6
+
7
+ function _interopNamespaceDefault(e) {
8
+ var n = Object.create(null);
9
+ if (e) {
10
+ Object.keys(e).forEach(function (k) {
11
+ if (k !== 'default') {
12
+ var d = Object.getOwnPropertyDescriptor(e, k);
13
+ Object.defineProperty(n, k, d.get ? d : {
14
+ enumerable: true,
15
+ get: function () { return e[k]; }
16
+ });
17
+ }
18
+ });
19
+ }
20
+ n.default = e;
21
+ return Object.freeze(n);
22
+ }
23
+
24
+ var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
25
+ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
26
+
27
+ /**
28
+ * 本地文件媒体存储模块
29
+ *
30
+ * 替代 OpenClaw 的 core.channel.media.saveMediaBuffer
31
+ * 将媒体文件存储到本地文件系统,按日期/方向/类型组织目录
32
+ */
33
+ // ============================================================================
34
+ // 公开 API
35
+ // ============================================================================
36
+ /**
37
+ * 将媒体文件保存到本地文件系统
38
+ *
39
+ * 存储路径: {dataDir}/media/{direction}/{YYYY-MM-DD}/{filename}
40
+ *
41
+ * @param buffer - 文件数据
42
+ * @param contentType - 内容类型(如 image/jpeg)
43
+ * @param direction - 方向:inbound(入站)或 outbound(出站)
44
+ * @param maxBytes - 最大字节数限制
45
+ * @param originalFilename - 原始文件名(可选)
46
+ * @param dataDir - 数据目录路径
47
+ * @returns 保存结果
48
+ */
49
+ async function saveMediaBuffer(buffer, contentType, direction, maxBytes, originalFilename, dataDir) {
50
+ // 大小检查
51
+ if (buffer.length > maxBytes) {
52
+ throw new Error(`Media file exceeds max size: ${buffer.length} bytes > ${maxBytes} bytes limit`);
53
+ }
54
+ // 如果 contentType 不够准确,尝试通过 buffer 嗅探
55
+ let finalContentType = contentType;
56
+ if (!contentType || contentType === "application/octet-stream") {
57
+ try {
58
+ const detected = await fileType.fileTypeFromBuffer(buffer);
59
+ if (detected?.mime) {
60
+ finalContentType = detected.mime;
61
+ }
62
+ }
63
+ catch {
64
+ // 嗅探失败,保持原始类型
65
+ }
66
+ }
67
+ // 生成文件名
68
+ // SDK downloadFile 返回的 filename 可能只是服务器内部标识(如 "aaaa"),没有扩展名,
69
+ // 此时根据 contentType 补上正确的扩展名,保证文件可被正确识别
70
+ const fileName = normalizeFileName(originalFilename, finalContentType);
71
+ // 构建存储路径
72
+ const dateStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
73
+ const dirPath = path__namespace.join(dataDir, "media", direction, dateStr);
74
+ // 确保目录存在
75
+ if (!fs__namespace.existsSync(dirPath)) {
76
+ fs__namespace.mkdirSync(dirPath, { recursive: true });
77
+ }
78
+ // 避免文件名冲突
79
+ const uniqueFileName = ensureUniqueFileName(dirPath, fileName);
80
+ const filePath = path__namespace.join(dirPath, uniqueFileName);
81
+ // 写入文件
82
+ fs__namespace.writeFileSync(filePath, buffer);
83
+ return {
84
+ path: filePath,
85
+ contentType: finalContentType,
86
+ fileName: uniqueFileName,
87
+ };
88
+ }
89
+ // ============================================================================
90
+ // 内部辅助函数
91
+ // ============================================================================
92
+ /**
93
+ * 规范化文件名:
94
+ * - 如果没有 originalFilename,自动生成带扩展名的文件名
95
+ * - 如果有 originalFilename 但缺少扩展名,根据 contentType 补上
96
+ */
97
+ function normalizeFileName(originalFilename, contentType) {
98
+ if (!originalFilename) {
99
+ return generateFileName(contentType);
100
+ }
101
+ // 如果已有扩展名,直接使用
102
+ const ext = path__namespace.extname(originalFilename);
103
+ if (ext) {
104
+ return originalFilename;
105
+ }
106
+ // 没有扩展名,根据 contentType 补上
107
+ const inferredExt = mimeToExt(contentType);
108
+ return `${originalFilename}${inferredExt}`;
109
+ }
110
+ /**
111
+ * 根据 MIME 类型生成文件名
112
+ */
113
+ function generateFileName(contentType) {
114
+ const ext = mimeToExt(contentType);
115
+ const rand = Math.random().toString(36).slice(2, 8);
116
+ return `media_${Date.now()}_${rand}${ext}`;
117
+ }
118
+ /**
119
+ * 确保文件名在目录中唯一
120
+ */
121
+ function ensureUniqueFileName(dirPath, fileName) {
122
+ let candidate = fileName;
123
+ let counter = 1;
124
+ while (fs__namespace.existsSync(path__namespace.join(dirPath, candidate))) {
125
+ const ext = path__namespace.extname(fileName);
126
+ const base = path__namespace.basename(fileName, ext);
127
+ candidate = `${base}_${counter}${ext}`;
128
+ counter++;
129
+ }
130
+ return candidate;
131
+ }
132
+ /**
133
+ * MIME 类型转文件扩展名
134
+ */
135
+ function mimeToExt(mime) {
136
+ const map = {
137
+ "image/jpeg": ".jpg",
138
+ "image/png": ".png",
139
+ "image/gif": ".gif",
140
+ "image/webp": ".webp",
141
+ "image/bmp": ".bmp",
142
+ "image/svg+xml": ".svg",
143
+ "image/heic": ".heic",
144
+ "image/heif": ".heif",
145
+ "video/mp4": ".mp4",
146
+ "video/quicktime": ".mov",
147
+ "video/x-msvideo": ".avi",
148
+ "video/webm": ".webm",
149
+ "audio/mpeg": ".mp3",
150
+ "audio/ogg": ".ogg",
151
+ "audio/wav": ".wav",
152
+ "audio/amr": ".amr",
153
+ "audio/aac": ".aac",
154
+ "application/pdf": ".pdf",
155
+ "application/zip": ".zip",
156
+ "application/msword": ".doc",
157
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
158
+ "application/vnd.ms-excel": ".xls",
159
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
160
+ "text/plain": ".txt",
161
+ };
162
+ return map[mime] || ".bin";
163
+ }
164
+
165
+ exports.saveMediaBuffer = saveMediaBuffer;
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ var mediaLoader = require('./media-loader.js');
4
+ var _const = require('./const.js');
5
+
6
+ /**
7
+ * 出站媒体上传工具模块
8
+ *
9
+ * 负责:
10
+ * - 从 mediaUrl 加载文件 buffer(远程 URL 或本地路径均支持)
11
+ * - 检测 MIME 类型并映射为企微媒体类型
12
+ * - 文件大小检查与降级策略
13
+ * - 分片上传 + 发送
14
+ */
15
+ // ============================================================================
16
+ // MIME → 企微媒体类型映射
17
+ // ============================================================================
18
+ function detectWeComMediaType(mimeType) {
19
+ const mime = mimeType.toLowerCase();
20
+ if (mime.startsWith("image/"))
21
+ return "image";
22
+ if (mime.startsWith("video/"))
23
+ return "video";
24
+ if (mime.startsWith("audio/") || mime === "application/ogg")
25
+ return "voice";
26
+ return "file";
27
+ }
28
+ // ============================================================================
29
+ // 媒体文件加载
30
+ // ============================================================================
31
+ async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
32
+ const result = await mediaLoader.loadOutboundMediaFromUrl(mediaUrl, {
33
+ maxBytes: _const.ABSOLUTE_MAX_BYTES,
34
+ mediaLocalRoots,
35
+ });
36
+ if (!result.buffer || result.buffer.length === 0) {
37
+ throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
38
+ }
39
+ let contentType = result.contentType || "application/octet-stream";
40
+ if (contentType === "application/octet-stream" || contentType === "text/plain") {
41
+ const detected = await mediaLoader.detectMime(result.buffer);
42
+ if (detected) {
43
+ contentType = detected;
44
+ }
45
+ }
46
+ const fileName = extractFileName(mediaUrl, result.fileName, contentType);
47
+ return { buffer: result.buffer, contentType, fileName };
48
+ }
49
+ // ============================================================================
50
+ // 文件大小检查与降级
51
+ // ============================================================================
52
+ const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
53
+ function applyFileSizeLimits(fileSize, detectedType, contentType) {
54
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
55
+ if (fileSize > _const.ABSOLUTE_MAX_BYTES) {
56
+ return {
57
+ finalType: detectedType,
58
+ shouldReject: true,
59
+ rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信私有部署允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
60
+ downgraded: false,
61
+ };
62
+ }
63
+ switch (detectedType) {
64
+ case "image":
65
+ if (fileSize > _const.IMAGE_MAX_BYTES) {
66
+ return {
67
+ finalType: "file",
68
+ shouldReject: false,
69
+ downgraded: true,
70
+ downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
71
+ };
72
+ }
73
+ break;
74
+ case "video":
75
+ if (fileSize > _const.VIDEO_MAX_BYTES) {
76
+ return {
77
+ finalType: "file",
78
+ shouldReject: false,
79
+ downgraded: true,
80
+ downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
81
+ };
82
+ }
83
+ break;
84
+ case "voice":
85
+ if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
86
+ return {
87
+ finalType: "file",
88
+ shouldReject: false,
89
+ downgraded: true,
90
+ downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
91
+ };
92
+ }
93
+ if (fileSize > _const.VOICE_MAX_BYTES) {
94
+ return {
95
+ finalType: "file",
96
+ shouldReject: false,
97
+ downgraded: true,
98
+ downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
99
+ };
100
+ }
101
+ break;
102
+ }
103
+ return { finalType: detectedType, shouldReject: false, downgraded: false };
104
+ }
105
+ // ============================================================================
106
+ // 辅助函数
107
+ // ============================================================================
108
+ function extractFileName(mediaUrl, providedFileName, contentType) {
109
+ if (providedFileName)
110
+ return providedFileName;
111
+ try {
112
+ const urlObj = new URL(mediaUrl, "file://");
113
+ const pathParts = urlObj.pathname.split("/");
114
+ const lastPart = pathParts[pathParts.length - 1];
115
+ if (lastPart && lastPart.includes(".")) {
116
+ return decodeURIComponent(lastPart);
117
+ }
118
+ }
119
+ catch {
120
+ const parts = mediaUrl.split("/");
121
+ const lastPart = parts[parts.length - 1];
122
+ if (lastPart && lastPart.includes("."))
123
+ return lastPart;
124
+ }
125
+ const ext = mimeToExtension(contentType || "application/octet-stream");
126
+ return `media_${Date.now()}${ext}`;
127
+ }
128
+ function mimeToExtension(mime) {
129
+ const map = {
130
+ "image/jpeg": ".jpg",
131
+ "image/png": ".png",
132
+ "image/gif": ".gif",
133
+ "image/webp": ".webp",
134
+ "image/bmp": ".bmp",
135
+ "image/svg+xml": ".svg",
136
+ "video/mp4": ".mp4",
137
+ "video/quicktime": ".mov",
138
+ "video/x-msvideo": ".avi",
139
+ "video/webm": ".webm",
140
+ "audio/mpeg": ".mp3",
141
+ "audio/ogg": ".ogg",
142
+ "audio/wav": ".wav",
143
+ "audio/amr": ".amr",
144
+ "audio/aac": ".aac",
145
+ "application/pdf": ".pdf",
146
+ "application/zip": ".zip",
147
+ "application/msword": ".doc",
148
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
149
+ "application/vnd.ms-excel": ".xls",
150
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
151
+ "text/plain": ".txt",
152
+ };
153
+ return map[mime] || ".bin";
154
+ }
155
+ /**
156
+ * 公共媒体上传+发送流程
157
+ *
158
+ * resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
159
+ */
160
+ async function uploadAndSendMedia(options) {
161
+ const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
162
+ try {
163
+ log?.(`[wecom] Uploading media: url=${mediaUrl}`);
164
+ const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
165
+ const detectedType = detectWeComMediaType(media.contentType);
166
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
167
+ if (sizeCheck.shouldReject) {
168
+ errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
169
+ return {
170
+ ok: false,
171
+ rejected: true,
172
+ rejectReason: sizeCheck.rejectReason,
173
+ finalType: sizeCheck.finalType,
174
+ };
175
+ }
176
+ const finalType = sizeCheck.finalType;
177
+ const uploadResult = await wsClient.uploadMedia(media.buffer, {
178
+ type: finalType,
179
+ filename: media.fileName,
180
+ });
181
+ log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
182
+ const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
183
+ const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
184
+ log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
185
+ return {
186
+ ok: true,
187
+ messageId,
188
+ finalType,
189
+ downgraded: sizeCheck.downgraded,
190
+ downgradeNote: sizeCheck.downgradeNote,
191
+ };
192
+ }
193
+ catch (err) {
194
+ const errMsg = String(err);
195
+ errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
196
+ return { ok: false, error: errMsg };
197
+ }
198
+ }
199
+
200
+ exports.applyFileSizeLimits = applyFileSizeLimits;
201
+ exports.detectWeComMediaType = detectWeComMediaType;
202
+ exports.resolveMediaFile = resolveMediaFile;
203
+ exports.uploadAndSendMedia = uploadAndSendMedia;