@wecom/wecom-openclaw-plugin 2026.3.30 → 2026.3.31-beta.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.
- 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 +562 -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/dynamic-agent.d.ts +37 -0
- package/dist/src/dynamic-agent.js +60 -0
- package/dist/src/dynamic-routing.d.ts +75 -0
- package/dist/src/dynamic-routing.js +124 -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 +875 -0
- package/dist/src/onboarding.js +175 -0
- package/dist/src/openclaw-compat.d.ts +47 -0
- package/dist/src/openclaw-compat.js +477 -0
- package/dist/src/reqid-store.js +83 -0
- package/dist/src/routing.d.ts +24 -0
- package/dist/src/routing.js +34 -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-dir-resolve.d.ts +2 -0
- package/dist/src/state-dir-resolve.js +9 -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 +100 -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 +1185 -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,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'>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信消息发送模块
|
|
3
|
+
*
|
|
4
|
+
* 负责通过 WSClient 发送回复消息,包含超时保护
|
|
5
|
+
*/
|
|
6
|
+
import { generateReqId } from "@wecom/aibot-node-sdk";
|
|
7
|
+
import { REPLY_SEND_TIMEOUT_MS } from "./const.js";
|
|
8
|
+
import { withTimeout } from "./timeout.js";
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// 流式过期错误(errcode 846608)
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/** 流式回复超时错误码(>6分钟未更新,服务端拒绝继续流式更新) */
|
|
13
|
+
export const STREAM_EXPIRED_ERRCODE = 846608;
|
|
14
|
+
/**
|
|
15
|
+
* 流式回复过期错误
|
|
16
|
+
* 当服务端返回 errcode=846608 时抛出,表示流式消息已超过6分钟无法更新,
|
|
17
|
+
* 调用方需降级为主动发送(sendMessage)方式回复。
|
|
18
|
+
*/
|
|
19
|
+
export class StreamExpiredError extends Error {
|
|
20
|
+
errcode = STREAM_EXPIRED_ERRCODE;
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message ?? `Stream message update expired (errcode=${STREAM_EXPIRED_ERRCODE})`);
|
|
23
|
+
this.name = "StreamExpiredError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// 消息发送
|
|
28
|
+
// ============================================================================
|
|
29
|
+
/**
|
|
30
|
+
* 发送企业微信回复消息
|
|
31
|
+
* 供 monitor 内部和 channel outbound 使用
|
|
32
|
+
*
|
|
33
|
+
* @returns messageId (streamId)
|
|
34
|
+
*/
|
|
35
|
+
export async function sendWeComReply(params) {
|
|
36
|
+
const { wsClient, frame, text, runtime, finish = true, streamId: existingStreamId } = params;
|
|
37
|
+
if (!text) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
const streamId = existingStreamId || generateReqId("stream");
|
|
41
|
+
if (!wsClient.isConnected) {
|
|
42
|
+
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
|
|
43
|
+
throw new Error("WSClient not connected");
|
|
44
|
+
}
|
|
45
|
+
const body = frame.body;
|
|
46
|
+
// 事件回调(aibot_event_callback)没有可用于 replyStream 的有效 req_id,
|
|
47
|
+
// 对该场景改用主动发送 sendMessage,避免 846605 invalid req_id。
|
|
48
|
+
if (body.msgtype === "event") {
|
|
49
|
+
// 中间帧(thinking / 流式增量)直接跳过,仅在最终帧主动发一次文本。
|
|
50
|
+
if (!finish) {
|
|
51
|
+
runtime.log?.(`[plugin -> server] skip non-final stream for event callback, streamId=${streamId}`);
|
|
52
|
+
return streamId;
|
|
53
|
+
}
|
|
54
|
+
const chatId = body.chatid || body.from?.userid;
|
|
55
|
+
if (!chatId) {
|
|
56
|
+
throw new Error("Missing chatId for event callback reply");
|
|
57
|
+
}
|
|
58
|
+
await withTimeout(wsClient.sendMessage(chatId, {
|
|
59
|
+
msgtype: "markdown",
|
|
60
|
+
markdown: { content: text },
|
|
61
|
+
}), REPLY_SEND_TIMEOUT_MS, `Event reply send timed out (streamId=${streamId})`);
|
|
62
|
+
runtime.log?.(`[plugin -> server] event-active-send chatId=${chatId}, finish=${finish}`);
|
|
63
|
+
return streamId;
|
|
64
|
+
}
|
|
65
|
+
// 非事件消息,继续使用 replyStream(被动回复)
|
|
66
|
+
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
67
|
+
try {
|
|
68
|
+
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// 服务端返回 846608:流式消息超过6分钟无法更新,需降级为主动发送
|
|
72
|
+
const errMsg = err?.errmsg || err?.message || String(err);
|
|
73
|
+
if (err?.errcode === STREAM_EXPIRED_ERRCODE ||
|
|
74
|
+
errMsg.includes(String(STREAM_EXPIRED_ERRCODE))) {
|
|
75
|
+
throw new StreamExpiredError(errMsg);
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}`);
|
|
80
|
+
return streamId;
|
|
81
|
+
}
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// 非阻塞流式发送(用于 onPartialReply 场景)
|
|
84
|
+
// ============================================================================
|
|
85
|
+
/**
|
|
86
|
+
* 非阻塞流式文本回复
|
|
87
|
+
*
|
|
88
|
+
* 基于 SDK 的 replyStreamNonBlocking 方法:
|
|
89
|
+
* - 如果上一条同 reqId 的消息尚未收到 ack,则跳过本次发送(返回 'skipped'),
|
|
90
|
+
* 避免流式中间帧排队积压导致延迟。
|
|
91
|
+
* - finish=true 的最终帧不受此限制,始终保证发送。
|
|
92
|
+
*
|
|
93
|
+
* @returns 'skipped' 表示被跳过,否则返回 streamId
|
|
94
|
+
*/
|
|
95
|
+
export async function sendWeComReplyNonBlocking(params) {
|
|
96
|
+
const { wsClient, frame, text, runtime, streamId, finish = false } = params;
|
|
97
|
+
if (!text) {
|
|
98
|
+
return 'skipped';
|
|
99
|
+
}
|
|
100
|
+
if (!wsClient.isConnected) {
|
|
101
|
+
return 'skipped';
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
// @ts-expect-error -- replyStreamNonBlocking exists at runtime but is missing from SDK type declarations
|
|
105
|
+
const result = await wsClient.replyStreamNonBlocking(frame, streamId, text, finish);
|
|
106
|
+
if (result === 'skipped') {
|
|
107
|
+
return 'skipped';
|
|
108
|
+
}
|
|
109
|
+
return streamId;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// 服务端返回 846608:流式消息超过6分钟无法更新,需降级为主动发送
|
|
113
|
+
const errMsg = err?.errmsg || err?.message || String(err);
|
|
114
|
+
if (err?.errcode === STREAM_EXPIRED_ERRCODE ||
|
|
115
|
+
errMsg.includes(String(STREAM_EXPIRED_ERRCODE))) {
|
|
116
|
+
throw new StreamExpiredError(errMsg);
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|