@wecom/wecom-openclaw-plugin 2026.3.30-beta.2 → 2026.3.31-beta.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.
- package/README.md +251 -4
- package/dist/index.d.ts +2 -4
- package/dist/index.js +113 -0
- package/dist/src/accounts.d.ts +57 -0
- package/dist/src/accounts.js +222 -0
- package/dist/src/agent/api-client.d.ts +94 -0
- package/dist/src/agent/api-client.js +292 -0
- package/dist/src/agent/handler.d.ts +63 -0
- package/dist/src/agent/handler.js +532 -0
- package/dist/src/agent/index.d.ts +5 -0
- package/dist/src/agent/index.js +5 -0
- package/dist/src/agent/webhook.d.ts +23 -0
- package/dist/src/agent/webhook.js +226 -0
- package/dist/src/agent/xml.d.ts +21 -0
- package/dist/src/agent/xml.js +41 -0
- package/dist/src/channel.js +564 -0
- package/dist/src/chat-queue.d.ts +31 -0
- package/dist/src/chat-queue.js +45 -0
- package/dist/src/const.d.ts +46 -0
- package/dist/src/const.js +157 -0
- package/dist/src/dm-policy.js +84 -0
- package/dist/src/group-policy.js +126 -0
- package/dist/src/http.d.ts +26 -0
- package/dist/src/http.js +93 -0
- package/dist/src/interface.d.ts +0 -2
- package/dist/src/interface.js +4 -0
- package/dist/src/mcp/index.js +6 -0
- package/dist/src/mcp/interceptors/biz-error.js +64 -0
- package/dist/src/mcp/interceptors/index.js +66 -0
- package/dist/src/mcp/interceptors/msg-media.js +133 -0
- package/dist/src/mcp/interceptors/smartpage-create.js +132 -0
- package/dist/src/mcp/interceptors/smartpage-export.js +87 -0
- package/dist/src/mcp/interceptors/types.js +7 -0
- package/dist/src/mcp/schema.js +99 -0
- package/dist/src/mcp/tool.js +202 -0
- package/dist/src/mcp/transport.js +410 -0
- package/dist/src/media-handler.js +122 -0
- package/dist/src/media-uploader.js +333 -0
- package/dist/src/message-parser.js +129 -0
- package/dist/src/message-sender.d.ts +18 -0
- package/dist/src/message-sender.js +120 -0
- package/dist/src/monitor.js +832 -0
- package/dist/src/onboarding.js +175 -0
- package/dist/src/openclaw-compat.d.ts +49 -0
- package/dist/src/openclaw-compat.js +487 -0
- package/dist/src/reqid-store.js +83 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/shared/command-auth.d.ts +23 -0
- package/dist/src/shared/command-auth.js +73 -0
- package/dist/src/shared/xml-parser.d.ts +46 -0
- package/dist/src/shared/xml-parser.js +210 -0
- package/dist/src/state-manager.js +216 -0
- package/dist/src/target.d.ts +35 -0
- package/dist/src/target.js +67 -0
- package/dist/src/template-card-manager.d.ts +55 -0
- package/dist/src/template-card-manager.js +205 -0
- package/dist/src/template-card-parser.js +647 -0
- package/dist/src/timeout.js +36 -0
- package/dist/src/types/account.d.ts +29 -0
- package/dist/src/types/account.js +4 -0
- package/dist/src/types/config.d.ts +80 -0
- package/dist/src/types/config.js +7 -0
- package/dist/src/types/constants.d.ts +42 -0
- package/dist/src/types/constants.js +42 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.js +5 -0
- package/dist/src/types/message.d.ts +238 -0
- package/dist/src/types/message.js +5 -0
- package/dist/src/utils.d.ts +39 -0
- package/dist/src/utils.js +70 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +24 -0
- package/dist/src/webhook/command-auth.d.ts +47 -0
- package/dist/src/webhook/command-auth.js +96 -0
- package/dist/src/webhook/gateway.d.ts +36 -0
- package/dist/src/webhook/gateway.js +227 -0
- package/dist/src/webhook/handler.d.ts +18 -0
- package/dist/src/webhook/handler.js +420 -0
- package/dist/src/webhook/helpers.d.ts +156 -0
- package/dist/src/webhook/helpers.js +833 -0
- package/dist/src/webhook/http.d.ts +26 -0
- package/dist/src/webhook/http.js +94 -0
- package/dist/src/webhook/index.d.ts +11 -0
- package/dist/src/webhook/index.js +14 -0
- package/dist/src/webhook/media.d.ts +29 -0
- package/dist/src/webhook/media.js +88 -0
- package/dist/src/webhook/monitor.d.ts +59 -0
- package/dist/src/webhook/monitor.js +1147 -0
- package/dist/src/webhook/state.d.ts +220 -0
- package/dist/src/webhook/state.js +491 -0
- package/dist/src/webhook/target.d.ts +41 -0
- package/dist/src/webhook/target.js +132 -0
- package/dist/src/webhook/types.d.ts +347 -0
- package/dist/src/webhook/types.js +33 -0
- package/package.json +15 -7
- package/dist/index.cjs.js +0 -4953
- package/dist/index.cjs.js.map +0 -1
- package/dist/index.esm.js +0 -4926
- package/dist/index.esm.js.map +0 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信媒体(图片)下载和保存模块
|
|
3
|
+
*
|
|
4
|
+
* 负责下载、检测格式、保存图片到本地,包含超时保护
|
|
5
|
+
*/
|
|
6
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
7
|
+
import { getWeComRuntime } from "./runtime.js";
|
|
8
|
+
import { IMAGE_DOWNLOAD_TIMEOUT_MS, FILE_DOWNLOAD_TIMEOUT_MS, DEFAULT_MEDIA_MAX_MB } from "./const.js";
|
|
9
|
+
import { withTimeout } from "./timeout.js";
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// 图片格式检测辅助函数(基于 file-type 包)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* 检查 Buffer 是否为有效的图片格式
|
|
15
|
+
*/
|
|
16
|
+
async function isImageBuffer(data) {
|
|
17
|
+
const type = await fileTypeFromBuffer(data);
|
|
18
|
+
return type?.mime.startsWith("image/") ?? false;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 检测 Buffer 的图片内容类型
|
|
22
|
+
*/
|
|
23
|
+
async function detectImageContentType(data) {
|
|
24
|
+
const type = await fileTypeFromBuffer(data);
|
|
25
|
+
if (type?.mime.startsWith("image/")) {
|
|
26
|
+
return type.mime;
|
|
27
|
+
}
|
|
28
|
+
return "application/octet-stream";
|
|
29
|
+
}
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// 图片下载和保存
|
|
32
|
+
// ============================================================================
|
|
33
|
+
/**
|
|
34
|
+
* 下载并保存所有图片到本地,每张图片的下载带超时保护
|
|
35
|
+
*/
|
|
36
|
+
export async function downloadAndSaveImages(params) {
|
|
37
|
+
const { imageUrls, config, runtime, wsClient } = params;
|
|
38
|
+
const core = getWeComRuntime();
|
|
39
|
+
const mediaList = [];
|
|
40
|
+
for (const imageUrl of imageUrls) {
|
|
41
|
+
try {
|
|
42
|
+
runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
|
|
43
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
44
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
45
|
+
let imageBuffer;
|
|
46
|
+
let imageContentType;
|
|
47
|
+
let originalFilename;
|
|
48
|
+
const imageAesKey = params.imageAesKeys?.get(imageUrl);
|
|
49
|
+
try {
|
|
50
|
+
// 优先使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
51
|
+
const result = await withTimeout(wsClient.downloadFile(imageUrl, imageAesKey), IMAGE_DOWNLOAD_TIMEOUT_MS, `Image download timed out: ${imageUrl}`);
|
|
52
|
+
imageBuffer = result.buffer;
|
|
53
|
+
originalFilename = result.filename;
|
|
54
|
+
imageContentType = await detectImageContentType(imageBuffer);
|
|
55
|
+
runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
56
|
+
}
|
|
57
|
+
catch (sdkError) {
|
|
58
|
+
// 如果 SDK 方法失败,回退到原有方式(带超时保护)
|
|
59
|
+
runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
|
|
60
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
|
|
61
|
+
runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
62
|
+
imageBuffer = fetched.buffer;
|
|
63
|
+
imageContentType = fetched.contentType ?? "application/octet-stream";
|
|
64
|
+
const isValidImage = await isImageBuffer(fetched.buffer);
|
|
65
|
+
if (!isValidImage) {
|
|
66
|
+
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
|
|
70
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
71
|
+
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return mediaList;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 下载并保存所有文件到本地,每个文件的下载带超时保护
|
|
81
|
+
*/
|
|
82
|
+
export async function downloadAndSaveFiles(params) {
|
|
83
|
+
const { fileUrls, config, runtime, wsClient } = params;
|
|
84
|
+
const core = getWeComRuntime();
|
|
85
|
+
const mediaList = [];
|
|
86
|
+
for (const fileUrl of fileUrls) {
|
|
87
|
+
try {
|
|
88
|
+
runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
|
|
89
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
90
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
91
|
+
let fileBuffer;
|
|
92
|
+
let fileContentType;
|
|
93
|
+
let originalFilename;
|
|
94
|
+
const fileAesKey = params.fileAesKeys?.get(fileUrl);
|
|
95
|
+
try {
|
|
96
|
+
// 使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
97
|
+
const result = await withTimeout(wsClient.downloadFile(fileUrl, fileAesKey), FILE_DOWNLOAD_TIMEOUT_MS, `File download timed out: ${fileUrl}`);
|
|
98
|
+
fileBuffer = result.buffer;
|
|
99
|
+
originalFilename = result.filename;
|
|
100
|
+
// 检测文件类型
|
|
101
|
+
const type = await fileTypeFromBuffer(fileBuffer);
|
|
102
|
+
fileContentType = type?.mime ?? "application/octet-stream";
|
|
103
|
+
runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
104
|
+
}
|
|
105
|
+
catch (sdkError) {
|
|
106
|
+
// 如果 SDK 方法失败,回退到 fetchRemoteMedia(带超时保护)
|
|
107
|
+
runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
|
|
108
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
|
|
109
|
+
runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
110
|
+
fileBuffer = fetched.buffer;
|
|
111
|
+
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
112
|
+
}
|
|
113
|
+
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
|
|
114
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
115
|
+
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return mediaList;
|
|
122
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信出站媒体上传工具模块
|
|
3
|
+
*
|
|
4
|
+
* 负责:
|
|
5
|
+
* - 从 mediaUrl 加载文件 buffer(远程 URL 或本地路径均支持)
|
|
6
|
+
* - 检测 MIME 类型并映射为企微媒体类型
|
|
7
|
+
* - 文件大小检查与降级策略
|
|
8
|
+
*/
|
|
9
|
+
import { loadOutboundMediaFromUrl, detectMime, } from "./openclaw-compat.js";
|
|
10
|
+
import { IMAGE_MAX_BYTES, VIDEO_MAX_BYTES, VOICE_MAX_BYTES, ABSOLUTE_MAX_BYTES, } from "./const.js";
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// MIME → 企微媒体类型映射
|
|
13
|
+
// ============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* 根据 MIME 类型检测企微媒体类型
|
|
16
|
+
*
|
|
17
|
+
* @param mimeType - MIME 类型字符串
|
|
18
|
+
* @returns 企微媒体类型
|
|
19
|
+
*/
|
|
20
|
+
export function detectWeComMediaType(mimeType) {
|
|
21
|
+
const mime = mimeType.toLowerCase();
|
|
22
|
+
// 图片类型
|
|
23
|
+
if (mime.startsWith("image/")) {
|
|
24
|
+
return "image";
|
|
25
|
+
}
|
|
26
|
+
// 视频类型
|
|
27
|
+
if (mime.startsWith("video/")) {
|
|
28
|
+
return "video";
|
|
29
|
+
}
|
|
30
|
+
// 语音类型
|
|
31
|
+
if (mime.startsWith("audio/") ||
|
|
32
|
+
mime === "application/ogg" // OGG 音频容器
|
|
33
|
+
) {
|
|
34
|
+
return "voice";
|
|
35
|
+
}
|
|
36
|
+
// 其他类型默认为文件
|
|
37
|
+
return "file";
|
|
38
|
+
}
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// 媒体文件加载
|
|
41
|
+
// ============================================================================
|
|
42
|
+
/**
|
|
43
|
+
* 从 mediaUrl 加载媒体文件
|
|
44
|
+
*
|
|
45
|
+
* 支持远程 URL(http/https)和本地路径(file:// 或绝对路径),
|
|
46
|
+
* 利用 openclaw plugin-sdk 的 loadOutboundMediaFromUrl 统一处理。
|
|
47
|
+
*
|
|
48
|
+
* @param mediaUrl - 媒体文件的 URL 或本地路径
|
|
49
|
+
* @param mediaLocalRoots - 允许读取本地文件的安全白名单目录
|
|
50
|
+
* @returns 解析后的媒体文件信息
|
|
51
|
+
*/
|
|
52
|
+
export async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
|
|
53
|
+
// 使用兼容层加载媒体文件(优先 SDK,不可用时 fallback)
|
|
54
|
+
// 传入足够大的 maxBytes,由我们自己在后续步骤做大小检查
|
|
55
|
+
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
56
|
+
maxBytes: ABSOLUTE_MAX_BYTES,
|
|
57
|
+
mediaLocalRoots,
|
|
58
|
+
});
|
|
59
|
+
if (!result.buffer || result.buffer.length === 0) {
|
|
60
|
+
throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
|
|
61
|
+
}
|
|
62
|
+
// 检测真实 MIME 类型
|
|
63
|
+
let contentType = result.contentType || "application/octet-stream";
|
|
64
|
+
// 如果没有返回准确的 contentType,尝试通过 buffer 魔术字节检测
|
|
65
|
+
if (contentType === "application/octet-stream" ||
|
|
66
|
+
contentType === "text/plain") {
|
|
67
|
+
const detected = await detectMime(result.buffer);
|
|
68
|
+
if (detected) {
|
|
69
|
+
contentType = detected;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 提取文件名
|
|
73
|
+
const fileName = extractFileName(mediaUrl, result.fileName, contentType);
|
|
74
|
+
return {
|
|
75
|
+
buffer: result.buffer,
|
|
76
|
+
contentType,
|
|
77
|
+
fileName,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// 文件大小检查与降级
|
|
82
|
+
// ============================================================================
|
|
83
|
+
/** 企微语音消息仅支持 AMR 格式 */
|
|
84
|
+
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
|
|
85
|
+
/**
|
|
86
|
+
* 检查文件大小并执行降级策略
|
|
87
|
+
*
|
|
88
|
+
* 降级规则:
|
|
89
|
+
* - voice 非 AMR 格式 → 降级为 file(企微后台仅支持 AMR)
|
|
90
|
+
* - image 超过 10MB → 降级为 file
|
|
91
|
+
* - video 超过 10MB → 降级为 file
|
|
92
|
+
* - voice 超过 2MB → 降级为 file
|
|
93
|
+
* - file 超过 20MB → 拒绝发送
|
|
94
|
+
*
|
|
95
|
+
* @param fileSize - 文件大小(字节)
|
|
96
|
+
* @param detectedType - 检测到的企微媒体类型
|
|
97
|
+
* @param contentType - 文件的 MIME 类型(用于语音格式校验)
|
|
98
|
+
* @returns 大小检查结果
|
|
99
|
+
*/
|
|
100
|
+
export function applyFileSizeLimits(fileSize, detectedType, contentType) {
|
|
101
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
102
|
+
// 先检查绝对上限(20MB)
|
|
103
|
+
if (fileSize > ABSOLUTE_MAX_BYTES) {
|
|
104
|
+
return {
|
|
105
|
+
finalType: detectedType,
|
|
106
|
+
shouldReject: true,
|
|
107
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
|
|
108
|
+
downgraded: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// 按类型检查大小限制
|
|
112
|
+
switch (detectedType) {
|
|
113
|
+
case "image":
|
|
114
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
115
|
+
return {
|
|
116
|
+
finalType: "file",
|
|
117
|
+
shouldReject: false,
|
|
118
|
+
downgraded: true,
|
|
119
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case "video":
|
|
124
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
125
|
+
return {
|
|
126
|
+
finalType: "file",
|
|
127
|
+
shouldReject: false,
|
|
128
|
+
downgraded: true,
|
|
129
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
case "voice":
|
|
134
|
+
// 企微语音消息仅支持 AMR 格式,非 AMR 一律降级为文件
|
|
135
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
136
|
+
return {
|
|
137
|
+
finalType: "file",
|
|
138
|
+
shouldReject: false,
|
|
139
|
+
downgraded: true,
|
|
140
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
144
|
+
return {
|
|
145
|
+
finalType: "file",
|
|
146
|
+
shouldReject: false,
|
|
147
|
+
downgraded: true,
|
|
148
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case "file":
|
|
153
|
+
// file 类型在绝对上限内即可
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
// 无需降级
|
|
157
|
+
return {
|
|
158
|
+
finalType: detectedType,
|
|
159
|
+
shouldReject: false,
|
|
160
|
+
downgraded: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// 辅助函数
|
|
165
|
+
// ============================================================================
|
|
166
|
+
/**
|
|
167
|
+
* 从 URL/路径中提取文件名
|
|
168
|
+
*/
|
|
169
|
+
function extractFileName(mediaUrl, providedFileName, contentType) {
|
|
170
|
+
// 优先使用提供的文件名
|
|
171
|
+
if (providedFileName) {
|
|
172
|
+
return providedFileName;
|
|
173
|
+
}
|
|
174
|
+
// 尝试从 URL 中提取
|
|
175
|
+
try {
|
|
176
|
+
const urlObj = new URL(mediaUrl, "file://");
|
|
177
|
+
const pathParts = urlObj.pathname.split("/");
|
|
178
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
179
|
+
if (lastPart && lastPart.includes(".")) {
|
|
180
|
+
return decodeURIComponent(lastPart);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// 尝试作为普通路径处理
|
|
185
|
+
const parts = mediaUrl.split("/");
|
|
186
|
+
const lastPart = parts[parts.length - 1];
|
|
187
|
+
if (lastPart && lastPart.includes(".")) {
|
|
188
|
+
return lastPart;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// 使用 MIME 类型生成默认文件名
|
|
192
|
+
const ext = mimeToExtension(contentType || "application/octet-stream");
|
|
193
|
+
return `media_${Date.now()}${ext}`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* MIME 类型转文件扩展名
|
|
197
|
+
*/
|
|
198
|
+
function mimeToExtension(mime) {
|
|
199
|
+
const map = {
|
|
200
|
+
"image/jpeg": ".jpg",
|
|
201
|
+
"image/png": ".png",
|
|
202
|
+
"image/gif": ".gif",
|
|
203
|
+
"image/webp": ".webp",
|
|
204
|
+
"image/bmp": ".bmp",
|
|
205
|
+
"image/svg+xml": ".svg",
|
|
206
|
+
"video/mp4": ".mp4",
|
|
207
|
+
"video/quicktime": ".mov",
|
|
208
|
+
"video/x-msvideo": ".avi",
|
|
209
|
+
"video/webm": ".webm",
|
|
210
|
+
"audio/mpeg": ".mp3",
|
|
211
|
+
"audio/ogg": ".ogg",
|
|
212
|
+
"audio/wav": ".wav",
|
|
213
|
+
"audio/amr": ".amr",
|
|
214
|
+
"audio/aac": ".aac",
|
|
215
|
+
"application/pdf": ".pdf",
|
|
216
|
+
"application/zip": ".zip",
|
|
217
|
+
"application/msword": ".doc",
|
|
218
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
219
|
+
"application/vnd.ms-excel": ".xls",
|
|
220
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
221
|
+
"text/plain": ".txt",
|
|
222
|
+
};
|
|
223
|
+
return map[mime] || ".bin";
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 公共媒体上传+发送流程
|
|
227
|
+
*
|
|
228
|
+
* 统一处理:resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
|
|
229
|
+
* 媒体消息统一走 aibot_send_msg 主动发送,避免多文件场景下 reqId 只能用一次的问题。
|
|
230
|
+
* channel.ts 的 sendMedia 和 monitor.ts 的 deliver 回调都使用此函数。
|
|
231
|
+
*/
|
|
232
|
+
export async function uploadAndSendMedia(options) {
|
|
233
|
+
const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
|
|
234
|
+
try {
|
|
235
|
+
// 1. 加载媒体文件
|
|
236
|
+
log?.(`[wecom] Uploading media: url=${mediaUrl}`);
|
|
237
|
+
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
|
|
238
|
+
// 2. 检测企微媒体类型
|
|
239
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
240
|
+
// 3. 文件大小检查与降级策略
|
|
241
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
242
|
+
if (sizeCheck.shouldReject) {
|
|
243
|
+
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
rejected: true,
|
|
247
|
+
rejectReason: sizeCheck.rejectReason,
|
|
248
|
+
finalType: sizeCheck.finalType,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const finalType = sizeCheck.finalType;
|
|
252
|
+
// 4. 分片上传获取 media_id
|
|
253
|
+
const uploadResult = await wsClient.uploadMedia(media.buffer, {
|
|
254
|
+
type: finalType,
|
|
255
|
+
filename: media.fileName,
|
|
256
|
+
});
|
|
257
|
+
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
|
|
258
|
+
// 5. 统一通过 aibot_send_msg 主动发送媒体消息
|
|
259
|
+
const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
|
|
260
|
+
const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
|
|
261
|
+
log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
messageId,
|
|
265
|
+
finalType,
|
|
266
|
+
downgraded: sizeCheck.downgraded,
|
|
267
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
const errMsg = String(err);
|
|
272
|
+
errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: errMsg,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 被动回复媒体上传+发送流程
|
|
281
|
+
*
|
|
282
|
+
* 统一处理:resolveMediaFile → detectType → sizeCheck → uploadMedia → replyMedia
|
|
283
|
+
* 通过 aibot_respond_msg 被动回复通道发送媒体消息,可以覆盖之前的 THINKING_MESSAGE。
|
|
284
|
+
*
|
|
285
|
+
* 适用场景:回包只有媒体没有文本时,第一个媒体文件用此方法发送以清理 thinking 状态。
|
|
286
|
+
*/
|
|
287
|
+
export async function uploadAndReplyMedia(options) {
|
|
288
|
+
const { wsClient, mediaUrl, frame, mediaLocalRoots, log, errorLog } = options;
|
|
289
|
+
try {
|
|
290
|
+
// 1. 加载媒体文件
|
|
291
|
+
log?.(`[wecom] Uploading media (reply mode): url=${mediaUrl}`);
|
|
292
|
+
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
|
|
293
|
+
// 2. 检测企微媒体类型
|
|
294
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
295
|
+
// 3. 文件大小检查与降级策略
|
|
296
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
297
|
+
if (sizeCheck.shouldReject) {
|
|
298
|
+
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
rejected: true,
|
|
302
|
+
rejectReason: sizeCheck.rejectReason,
|
|
303
|
+
finalType: sizeCheck.finalType,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const finalType = sizeCheck.finalType;
|
|
307
|
+
// 4. 分片上传获取 media_id
|
|
308
|
+
const uploadResult = await wsClient.uploadMedia(media.buffer, {
|
|
309
|
+
type: finalType,
|
|
310
|
+
filename: media.fileName,
|
|
311
|
+
});
|
|
312
|
+
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
|
|
313
|
+
// 5. 通过 aibot_respond_msg 被动回复发送媒体消息(会覆盖 THINKING_MESSAGE)
|
|
314
|
+
const result = await wsClient.replyMedia(frame, finalType, uploadResult.media_id);
|
|
315
|
+
const messageId = result?.headers?.req_id ?? `wecom-reply-media-${Date.now()}`;
|
|
316
|
+
log?.(`[wecom] Media sent via replyMedia (passive reply): type=${finalType}`);
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
messageId,
|
|
320
|
+
finalType,
|
|
321
|
+
downgraded: sizeCheck.downgraded,
|
|
322
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
const errMsg = String(err);
|
|
327
|
+
errorLog?.(`[wecom] Failed to upload/reply media: url=${mediaUrl}, error=${errMsg}`);
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
error: errMsg,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信消息内容解析模块
|
|
3
|
+
*
|
|
4
|
+
* 负责从 WsFrame 中提取文本、图片、引用等内容
|
|
5
|
+
*/
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// 解析函数
|
|
8
|
+
// ============================================================================
|
|
9
|
+
/**
|
|
10
|
+
* 将模板卡片事件回调格式化为可继续路由给大模型的文本。
|
|
11
|
+
*
|
|
12
|
+
* 这样后续 Agent 可以直接从 question_key / option_id 中理解用户的真实选择。
|
|
13
|
+
*/
|
|
14
|
+
function buildTemplateCardEventText(body) {
|
|
15
|
+
const templateCardEvent = body.event?.template_card_event;
|
|
16
|
+
if (body.msgtype !== "event" ||
|
|
17
|
+
body.event?.eventtype !== "template_card_event" ||
|
|
18
|
+
!templateCardEvent) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const selectedItems = templateCardEvent.selected_items?.selected_item ?? [];
|
|
22
|
+
const selectedLines = selectedItems.map((item) => {
|
|
23
|
+
const questionKey = item.question_key?.trim() || "unknown_question";
|
|
24
|
+
const optionIds = item.option_ids?.option_id?.filter(Boolean) ?? [];
|
|
25
|
+
return `- ${questionKey}: ${optionIds.length > 0 ? optionIds.join(", ") : "(未选择)"}`;
|
|
26
|
+
});
|
|
27
|
+
const senderUserId = body.from?.userid || "";
|
|
28
|
+
const senderCorpId = body.from?.corpid || "";
|
|
29
|
+
const chatId = body.chatid || senderUserId;
|
|
30
|
+
return [
|
|
31
|
+
"[企业微信模板卡片回调]",
|
|
32
|
+
`event_type(事件类型): template_card_event`,
|
|
33
|
+
body.msgid ? `msgid(消息 id): ${body.msgid}` : undefined,
|
|
34
|
+
body.aibotid ? `aibotid(机器人 id): ${body.aibotid}` : undefined,
|
|
35
|
+
body.chattype ? `chat_type(会话类型): ${body.chattype}` : undefined,
|
|
36
|
+
chatId ? `chat_id(会话 id): ${chatId}` : undefined,
|
|
37
|
+
senderCorpId ? `from.corpid(企业 id): ${senderCorpId}` : undefined,
|
|
38
|
+
senderUserId ? `from.userid(发送人 id): ${senderUserId}` : undefined,
|
|
39
|
+
senderUserId ? `sender_userid(发送人 id): ${senderUserId}` : undefined,
|
|
40
|
+
templateCardEvent.card_type ? `card_type(卡片类型): ${templateCardEvent.card_type}` : undefined,
|
|
41
|
+
templateCardEvent.event_key ? `event_key(事件 key): ${templateCardEvent.event_key}` : undefined,
|
|
42
|
+
templateCardEvent.task_id ? `task_id(任务 id): ${templateCardEvent.task_id}` : undefined,
|
|
43
|
+
selectedLines.length > 0 ? "selected_items(选择项):" : "selected_items(选择项): []",
|
|
44
|
+
...selectedLines,
|
|
45
|
+
]
|
|
46
|
+
.filter((line) => Boolean(line))
|
|
47
|
+
.join("\n");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 解析消息内容(支持单条消息、图文混排、事件回调和引用消息)
|
|
51
|
+
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
52
|
+
*/
|
|
53
|
+
export function parseMessageContent(body) {
|
|
54
|
+
const textParts = [];
|
|
55
|
+
const imageUrls = [];
|
|
56
|
+
const imageAesKeys = new Map();
|
|
57
|
+
const fileUrls = [];
|
|
58
|
+
const fileAesKeys = new Map();
|
|
59
|
+
let quoteContent;
|
|
60
|
+
// 处理模板卡片事件回调
|
|
61
|
+
if (body.msgtype === "event") {
|
|
62
|
+
const eventText = buildTemplateCardEventText(body);
|
|
63
|
+
if (eventText) {
|
|
64
|
+
textParts.push(eventText);
|
|
65
|
+
}
|
|
66
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
67
|
+
}
|
|
68
|
+
// 处理图文混排消息
|
|
69
|
+
if (body.msgtype === "mixed" && body.mixed?.msg_item) {
|
|
70
|
+
for (const item of body.mixed.msg_item) {
|
|
71
|
+
if (item.msgtype === "text" && item.text?.content) {
|
|
72
|
+
textParts.push(item.text.content);
|
|
73
|
+
}
|
|
74
|
+
else if (item.msgtype === "image" && item.image?.url) {
|
|
75
|
+
imageUrls.push(item.image.url);
|
|
76
|
+
if (item.image.aeskey) {
|
|
77
|
+
imageAesKeys.set(item.image.url, item.image.aeskey);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// 处理单条消息
|
|
84
|
+
if (body.text?.content) {
|
|
85
|
+
textParts.push(body.text.content);
|
|
86
|
+
}
|
|
87
|
+
// 处理语音消息(语音转文字后的文本内容)
|
|
88
|
+
if (body.msgtype === "voice" && body.voice?.content) {
|
|
89
|
+
textParts.push(body.voice.content);
|
|
90
|
+
}
|
|
91
|
+
if (body.image?.url) {
|
|
92
|
+
imageUrls.push(body.image.url);
|
|
93
|
+
if (body.image.aeskey) {
|
|
94
|
+
imageAesKeys.set(body.image.url, body.image.aeskey);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 处理文件消息
|
|
98
|
+
if (body.msgtype === "file" && body.file?.url) {
|
|
99
|
+
fileUrls.push(body.file.url);
|
|
100
|
+
if (body.file.aeskey) {
|
|
101
|
+
fileAesKeys.set(body.file.url, body.file.aeskey);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// 处理引用消息
|
|
106
|
+
if (body.quote) {
|
|
107
|
+
if (body.quote.msgtype === "text" && body.quote.text?.content) {
|
|
108
|
+
quoteContent = body.quote.text.content;
|
|
109
|
+
}
|
|
110
|
+
else if (body.quote.msgtype === "voice" && body.quote.voice?.content) {
|
|
111
|
+
quoteContent = body.quote.voice.content;
|
|
112
|
+
}
|
|
113
|
+
else if (body.quote.msgtype === "image" && body.quote.image?.url) {
|
|
114
|
+
// 引用的图片消息:将图片 URL 加入下载列表
|
|
115
|
+
imageUrls.push(body.quote.image.url);
|
|
116
|
+
if (body.quote.image.aeskey) {
|
|
117
|
+
imageAesKeys.set(body.quote.image.url, body.quote.image.aeskey);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (body.quote.msgtype === "file" && body.quote.file?.url) {
|
|
121
|
+
// 引用的文件消息:将文件 URL 加入下载列表
|
|
122
|
+
fileUrls.push(body.quote.file.url);
|
|
123
|
+
if (body.quote.file.aeskey) {
|
|
124
|
+
fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
129
|
+
}
|
|
@@ -32,3 +32,21 @@ export declare function sendWeComReply(params: {
|
|
|
32
32
|
/** 指定 streamId,用于流式回复时保持相同的 streamId */
|
|
33
33
|
streamId?: string;
|
|
34
34
|
}): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* 非阻塞流式文本回复
|
|
37
|
+
*
|
|
38
|
+
* 基于 SDK 的 replyStreamNonBlocking 方法:
|
|
39
|
+
* - 如果上一条同 reqId 的消息尚未收到 ack,则跳过本次发送(返回 'skipped'),
|
|
40
|
+
* 避免流式中间帧排队积压导致延迟。
|
|
41
|
+
* - finish=true 的最终帧不受此限制,始终保证发送。
|
|
42
|
+
*
|
|
43
|
+
* @returns 'skipped' 表示被跳过,否则返回 streamId
|
|
44
|
+
*/
|
|
45
|
+
export declare function sendWeComReplyNonBlocking(params: {
|
|
46
|
+
wsClient: WSClient;
|
|
47
|
+
frame: WsFrame;
|
|
48
|
+
text: string;
|
|
49
|
+
runtime: RuntimeEnv;
|
|
50
|
+
streamId: string;
|
|
51
|
+
finish?: boolean;
|
|
52
|
+
}): Promise<string | 'skipped'>;
|