@mocrane/wecom 2026.3.14-beta.0 → 2026.3.19
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/index.ts +37 -0
- package/package.json +4 -3
- package/skills/wecom-contact-lookup/SKILL.md +162 -0
- package/skills/wecom-doc-manager/SKILL.md +64 -0
- package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
- package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
- package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
- package/skills/wecom-edit-todo/SKILL.md +249 -0
- package/skills/wecom-get-todo-detail/SKILL.md +143 -0
- package/skills/wecom-get-todo-list/SKILL.md +127 -0
- package/skills/wecom-meeting-create/SKILL.md +158 -0
- package/skills/wecom-meeting-create/references/example-full.md +30 -0
- package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
- package/skills/wecom-meeting-create/references/example-security.md +22 -0
- package/skills/wecom-meeting-manage/SKILL.md +136 -0
- package/skills/wecom-meeting-query/SKILL.md +330 -0
- package/skills/wecom-preflight/SKILL.md +141 -0
- package/skills/wecom-schedule/SKILL.md +159 -0
- package/skills/wecom-schedule/references/api-check-availability.md +56 -0
- package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
- package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
- package/skills/wecom-schedule/references/ref-reminders.md +24 -0
- package/skills/wecom-smartsheet-data/SKILL.md +71 -0
- package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
- package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
- package/src/agent/handler.ts +105 -14
- package/src/mcp/index.ts +7 -0
- package/src/mcp/schema.ts +108 -0
- package/src/mcp/tool.ts +226 -0
- package/src/mcp/transport.ts +561 -0
- package/src/media/const.ts +24 -0
- package/src/media/index.ts +15 -0
- package/src/media/uploader.ts +240 -0
- package/src/monitor.ts +293 -12
- package/src/outbound.ts +116 -46
- package/src/types/index.ts +1 -0
- package/src/types/message.ts +10 -1
- package/src/ws-adapter.ts +4 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom WS 模式媒体上传工具模块
|
|
3
|
+
*
|
|
4
|
+
* 接收 deliver callback 已加载的 Buffer,执行:
|
|
5
|
+
* detectWeComMediaType → applyFileSizeLimits → wsClient.uploadMedia → wsClient.sendMediaMessage
|
|
6
|
+
*
|
|
7
|
+
* 不含 resolveMediaFile(由 deliver callback 提供 Buffer)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { WeComMediaType, WSClient } from "@wecom/aibot-node-sdk";
|
|
11
|
+
import {
|
|
12
|
+
IMAGE_MAX_BYTES,
|
|
13
|
+
VIDEO_MAX_BYTES,
|
|
14
|
+
VOICE_MAX_BYTES,
|
|
15
|
+
ABSOLUTE_MAX_BYTES,
|
|
16
|
+
VOICE_SUPPORTED_MIMES,
|
|
17
|
+
} from "./const.js";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// 类型定义
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/** 文件大小检查结果 */
|
|
24
|
+
export interface FileSizeCheckResult {
|
|
25
|
+
/** 最终确定的企微媒体类型(可能被降级) */
|
|
26
|
+
finalType: WeComMediaType;
|
|
27
|
+
/** 是否需要拒绝(超过绝对限制) */
|
|
28
|
+
shouldReject: boolean;
|
|
29
|
+
/** 拒绝原因(仅 shouldReject=true 时有值) */
|
|
30
|
+
rejectReason?: string;
|
|
31
|
+
/** 是否发生了降级 */
|
|
32
|
+
downgraded: boolean;
|
|
33
|
+
/** 降级说明(仅 downgraded=true 时有值) */
|
|
34
|
+
downgradeNote?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** uploadAndSendMediaBuffer 的参数 */
|
|
38
|
+
export interface UploadAndSendMediaBufferOptions {
|
|
39
|
+
/** WSClient 实例 */
|
|
40
|
+
wsClient: WSClient;
|
|
41
|
+
/** 文件数据(deliver callback 已读取) */
|
|
42
|
+
buffer: Buffer;
|
|
43
|
+
/** MIME 类型(deliver callback 已检测) */
|
|
44
|
+
contentType: string;
|
|
45
|
+
/** 文件名(deliver callback 已提取) */
|
|
46
|
+
fileName: string;
|
|
47
|
+
/** 目标会话 ID(单聊为 userid,群聊为 chatid) */
|
|
48
|
+
chatId: string;
|
|
49
|
+
/** 日志函数 */
|
|
50
|
+
log?: (msg: string) => void;
|
|
51
|
+
/** 错误日志函数 */
|
|
52
|
+
errorLog?: (msg: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** uploadAndSendMediaBuffer 的返回结果 */
|
|
56
|
+
export interface UploadAndSendMediaResult {
|
|
57
|
+
/** 是否发送成功 */
|
|
58
|
+
ok: boolean;
|
|
59
|
+
/** 最终的企微媒体类型 */
|
|
60
|
+
finalType?: WeComMediaType;
|
|
61
|
+
/** 是否被拒绝(文件过大) */
|
|
62
|
+
rejected?: boolean;
|
|
63
|
+
/** 拒绝原因 */
|
|
64
|
+
rejectReason?: string;
|
|
65
|
+
/** 是否发生了降级 */
|
|
66
|
+
downgraded?: boolean;
|
|
67
|
+
/** 降级说明 */
|
|
68
|
+
downgradeNote?: string;
|
|
69
|
+
/** 错误信息 */
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// MIME → 企微媒体类型映射
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 根据 MIME 类型检测企微媒体类型
|
|
79
|
+
*/
|
|
80
|
+
export function detectWeComMediaType(mimeType: string): WeComMediaType {
|
|
81
|
+
const mime = mimeType.toLowerCase();
|
|
82
|
+
|
|
83
|
+
if (mime.startsWith("image/")) return "image";
|
|
84
|
+
if (mime.startsWith("video/")) return "video";
|
|
85
|
+
if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
|
|
86
|
+
|
|
87
|
+
return "file";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// 文件大小检查与降级
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 检查文件大小并执行降级策略
|
|
96
|
+
*
|
|
97
|
+
* 降级规则:
|
|
98
|
+
* - voice 非 AMR 格式 → 降级为 file
|
|
99
|
+
* - image 超过 10MB → 降级为 file
|
|
100
|
+
* - video 超过 10MB → 降级为 file
|
|
101
|
+
* - voice 超过 2MB → 降级为 file
|
|
102
|
+
* - file 超过 20MB → 拒绝发送
|
|
103
|
+
*/
|
|
104
|
+
export function applyFileSizeLimits(
|
|
105
|
+
fileSize: number,
|
|
106
|
+
detectedType: WeComMediaType,
|
|
107
|
+
contentType?: string,
|
|
108
|
+
): FileSizeCheckResult {
|
|
109
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
110
|
+
|
|
111
|
+
// 绝对上限 20MB
|
|
112
|
+
if (fileSize > ABSOLUTE_MAX_BYTES) {
|
|
113
|
+
return {
|
|
114
|
+
finalType: detectedType,
|
|
115
|
+
shouldReject: true,
|
|
116
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过企业微信最大限制 20MB,无法发送`,
|
|
117
|
+
downgraded: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
switch (detectedType) {
|
|
122
|
+
case "image":
|
|
123
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
124
|
+
return {
|
|
125
|
+
finalType: "file",
|
|
126
|
+
shouldReject: false,
|
|
127
|
+
downgraded: true,
|
|
128
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
case "video":
|
|
134
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
135
|
+
return {
|
|
136
|
+
finalType: "file",
|
|
137
|
+
shouldReject: false,
|
|
138
|
+
downgraded: true,
|
|
139
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
case "voice":
|
|
145
|
+
// 企微语音仅支持 AMR 格式
|
|
146
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
147
|
+
return {
|
|
148
|
+
finalType: "file",
|
|
149
|
+
shouldReject: false,
|
|
150
|
+
downgraded: true,
|
|
151
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
155
|
+
return {
|
|
156
|
+
finalType: "file",
|
|
157
|
+
shouldReject: false,
|
|
158
|
+
downgraded: true,
|
|
159
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case "file":
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
finalType: detectedType,
|
|
170
|
+
shouldReject: false,
|
|
171
|
+
downgraded: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// 核心:接收 Buffer → 上传 → 发送
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 接收已有 Buffer,通过 WSClient 上传临时素材并发送媒体消息
|
|
181
|
+
*
|
|
182
|
+
* 流程:detectWeComMediaType → applyFileSizeLimits → uploadMedia → sendMediaMessage
|
|
183
|
+
*/
|
|
184
|
+
export async function uploadAndSendMediaBuffer(
|
|
185
|
+
options: UploadAndSendMediaBufferOptions,
|
|
186
|
+
): Promise<UploadAndSendMediaResult> {
|
|
187
|
+
const { wsClient, buffer, contentType, fileName, chatId, log, errorLog } = options;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// 1. 检测企微媒体类型
|
|
191
|
+
const detectedType = detectWeComMediaType(contentType);
|
|
192
|
+
log?.(`media-upload: type=${detectedType} contentType=${contentType} size=${buffer.length} fileName=${fileName}`);
|
|
193
|
+
|
|
194
|
+
// 2. 文件大小检查与降级
|
|
195
|
+
const sizeCheck = applyFileSizeLimits(buffer.length, detectedType, contentType);
|
|
196
|
+
|
|
197
|
+
if (sizeCheck.shouldReject) {
|
|
198
|
+
errorLog?.(`media-upload: rejected — ${sizeCheck.rejectReason}`);
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
rejected: true,
|
|
202
|
+
rejectReason: sizeCheck.rejectReason,
|
|
203
|
+
finalType: sizeCheck.finalType,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const finalType = sizeCheck.finalType;
|
|
208
|
+
|
|
209
|
+
if (sizeCheck.downgraded) {
|
|
210
|
+
log?.(`media-upload: downgraded ${detectedType}→${finalType} — ${sizeCheck.downgradeNote}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. 分片上传获取 media_id
|
|
214
|
+
log?.(`media-upload: uploading ${finalType} (${buffer.length} bytes)...`);
|
|
215
|
+
const uploadResult = await wsClient.uploadMedia(buffer, {
|
|
216
|
+
type: finalType,
|
|
217
|
+
filename: fileName,
|
|
218
|
+
});
|
|
219
|
+
log?.(`media-upload: uploaded media_id=${uploadResult.media_id}`);
|
|
220
|
+
|
|
221
|
+
// 4. 通过 sendMediaMessage 主动发送
|
|
222
|
+
const videoOptions = finalType === "video" ? { title: fileName } : undefined;
|
|
223
|
+
await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id, videoOptions);
|
|
224
|
+
log?.(`media-upload: sent to chatId=${chatId} type=${finalType}`);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
finalType,
|
|
229
|
+
downgraded: sizeCheck.downgraded,
|
|
230
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
231
|
+
};
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const errMsg = String(err);
|
|
234
|
+
errorLog?.(`media-upload: failed — ${errMsg}`);
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
error: errMsg,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/monitor.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, com
|
|
|
12
12
|
import { extractEncryptFromXml } from "./crypto/xml.js";
|
|
13
13
|
import { getWecomRuntime } from "./runtime.js";
|
|
14
14
|
import { decryptWecomMediaWithMeta } from "./media.js";
|
|
15
|
+
import { uploadAndSendMediaBuffer } from "./media/index.js";
|
|
16
|
+
import { getWsClient } from "./ws-adapter.js";
|
|
15
17
|
import { WEBHOOK_PATHS, LIMITS as WECOM_LIMITS } from "./types/constants.js";
|
|
16
18
|
import { handleAgentWebhook } from "./agent/index.js";
|
|
17
19
|
import { resolveWecomAccount, resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "./config/index.js";
|
|
@@ -548,8 +550,10 @@ function extractLocalFilePathsFromText(text: string): string[] {
|
|
|
548
550
|
if (!text.trim()) return [];
|
|
549
551
|
|
|
550
552
|
// Conservative: only accept common absolute paths for macOS/Linux hosts.
|
|
551
|
-
// This is primarily for
|
|
552
|
-
|
|
553
|
+
// This is primarily for "send local file" style requests (operator/debug usage).
|
|
554
|
+
// Exclude CJK characters, CJK punctuation (,。!?;:), and other non-path chars
|
|
555
|
+
// to avoid swallowing trailing Chinese text as part of the path.
|
|
556
|
+
const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>\u3000-\u303F\uFF00-\uFFEF\u4E00-\u9FFF\u3400-\u4DBF]+)`, "g");
|
|
553
557
|
const found = new Set<string>();
|
|
554
558
|
let m: RegExpExecArray | null;
|
|
555
559
|
while ((m = re.exec(text))) {
|
|
@@ -958,6 +962,7 @@ export async function processInboundMessage(target: WecomWebhookTarget, msg: Wec
|
|
|
958
962
|
const maxBytes = resolveWecomMediaMaxBytes(target.config);
|
|
959
963
|
const proxyUrl = resolveWecomEgressProxyUrl(target.config);
|
|
960
964
|
|
|
965
|
+
|
|
961
966
|
// 图片消息处理:如果存在 url 且配置了 aesKey,则尝试解密下载
|
|
962
967
|
if (msgtype === "image") {
|
|
963
968
|
const url = String((msg as any).image?.url ?? "").trim();
|
|
@@ -1028,6 +1033,42 @@ export async function processInboundMessage(target: WecomWebhookTarget, msg: Wec
|
|
|
1028
1033
|
}
|
|
1029
1034
|
}
|
|
1030
1035
|
|
|
1036
|
+
// 视频消息处理:与文件消息类似,下载并解密视频
|
|
1037
|
+
if (msgtype === "video") {
|
|
1038
|
+
const url = String((msg as any).video?.url ?? "").trim();
|
|
1039
|
+
const aesKey = globalAesKey || (msg as any).video?.aeskey || "";
|
|
1040
|
+
logVerbose(target, `video: url=${url ? url.substring(0, 80) + "..." : "(empty)"} aesKey=${aesKey ? "(present)" : "(empty)"}`);
|
|
1041
|
+
if (url && aesKey) {
|
|
1042
|
+
try {
|
|
1043
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
1044
|
+
const inferred = inferInboundMediaMeta({
|
|
1045
|
+
kind: "file",
|
|
1046
|
+
buffer: decrypted.buffer,
|
|
1047
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
1048
|
+
sourceContentType: decrypted.sourceContentType,
|
|
1049
|
+
sourceFilename: decrypted.sourceFilename,
|
|
1050
|
+
explicitFilename: pickBotFileName(msg),
|
|
1051
|
+
});
|
|
1052
|
+
return {
|
|
1053
|
+
body: `[video] 视频文件已保存,文件名: ${inferred.filename}`,
|
|
1054
|
+
media: {
|
|
1055
|
+
buffer: decrypted.buffer,
|
|
1056
|
+
contentType: inferred.contentType,
|
|
1057
|
+
filename: inferred.filename,
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
target.runtime.error?.(
|
|
1062
|
+
`Failed to decrypt inbound video: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
|
|
1063
|
+
);
|
|
1064
|
+
const errorMessage = typeof err === 'object' && err
|
|
1065
|
+
? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
|
|
1066
|
+
: String(err);
|
|
1067
|
+
return { body: `[video] (decryption failed: ${errorMessage})` };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1031
1072
|
// Mixed message handling: extract first media if available
|
|
1032
1073
|
if (msgtype === "mixed") {
|
|
1033
1074
|
const items = (msg as any).mixed?.msg_item;
|
|
@@ -1252,6 +1293,61 @@ async function startAgentForStream(params: {
|
|
|
1252
1293
|
|
|
1253
1294
|
// 1) 图片:优先 Bot 群内/原会话交付(被动/流式 msg_item)
|
|
1254
1295
|
if (imagePaths.length > 0 && otherPaths.length === 0) {
|
|
1296
|
+
// WS 模式:走 uploadMedia + sendMediaMessage,避免大图 base64 单帧超限
|
|
1297
|
+
if (isWsMode) {
|
|
1298
|
+
const wsClient = getWsClient(account.accountId);
|
|
1299
|
+
const sentFiles: string[] = [];
|
|
1300
|
+
const failedFiles: string[] = [];
|
|
1301
|
+
|
|
1302
|
+
if (wsClient && chatId && chatId !== "unknown") {
|
|
1303
|
+
for (const p of imagePaths) {
|
|
1304
|
+
try {
|
|
1305
|
+
const buf = await fs.readFile(p);
|
|
1306
|
+
const fname = pathModule.basename(p);
|
|
1307
|
+
const ext = pathModule.extname(p).slice(1).toLowerCase();
|
|
1308
|
+
const mimeMap: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
|
|
1309
|
+
const guessedType = mimeMap[ext] ?? "image/png";
|
|
1310
|
+
const result = await uploadAndSendMediaBuffer({
|
|
1311
|
+
wsClient,
|
|
1312
|
+
buffer: buf,
|
|
1313
|
+
contentType: guessedType,
|
|
1314
|
+
fileName: fname,
|
|
1315
|
+
chatId,
|
|
1316
|
+
log: (m) => logVerbose(target, m),
|
|
1317
|
+
errorLog: (m) => target.runtime.error?.(m),
|
|
1318
|
+
});
|
|
1319
|
+
if (result.ok) {
|
|
1320
|
+
sentFiles.push(fname);
|
|
1321
|
+
logVerbose(target, `local-path: WS 图片上传发送成功 path=${p} type=${result.finalType}`);
|
|
1322
|
+
} else {
|
|
1323
|
+
failedFiles.push(fname);
|
|
1324
|
+
logVerbose(target, `local-path: WS 图片上传发送失败 path=${p} reason=${result.rejectReason ?? result.error}`);
|
|
1325
|
+
}
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
const fname = p.split("/").pop() || p;
|
|
1328
|
+
failedFiles.push(fname);
|
|
1329
|
+
target.runtime.error?.(`local-path: WS 图片读取/发送失败 path=${p}: ${String(err)}`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
} else {
|
|
1333
|
+
logVerbose(target, `local-path: WS 模式但 WSClient 不可用或缺少 chatId,跳过图片发送`);
|
|
1334
|
+
failedFiles.push(...imagePaths.map((p) => p.split("/").pop() || p));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const summary = sentFiles.length > 0
|
|
1338
|
+
? (sentFiles.length === 1 ? `已发送图片(${sentFiles[0]})` : `已发送 ${sentFiles.length} 张图片`)
|
|
1339
|
+
+ (failedFiles.length > 0 ? `(失败:${failedFiles.join(", ")})` : "")
|
|
1340
|
+
: `图片发送失败:${failedFiles.join(", ")}`;
|
|
1341
|
+
|
|
1342
|
+
streamStore.updateStream(streamId, (s) => {
|
|
1343
|
+
s.finished = true;
|
|
1344
|
+
s.content = summary;
|
|
1345
|
+
});
|
|
1346
|
+
streamStore.onStreamFinished(streamId);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Webhook 模式:原有 base64 msgItems 路径
|
|
1255
1351
|
const loaded: Array<{ base64: string; md5: string; path: string }> = [];
|
|
1256
1352
|
for (const p of imagePaths) {
|
|
1257
1353
|
try {
|
|
@@ -1373,11 +1469,54 @@ async function startAgentForStream(params: {
|
|
|
1373
1469
|
// 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
|
|
1374
1470
|
if (otherPaths.length > 0) {
|
|
1375
1471
|
if (isWsMode) {
|
|
1376
|
-
// WS
|
|
1377
|
-
const
|
|
1472
|
+
// WS 模式:通过 WSClient uploadMedia + sendMediaMessage 发送文件
|
|
1473
|
+
const wsClient = getWsClient(account.accountId);
|
|
1474
|
+
const sentFiles: string[] = [];
|
|
1475
|
+
const failedFiles: string[] = [];
|
|
1476
|
+
|
|
1477
|
+
if (wsClient && chatId && chatId !== "unknown") {
|
|
1478
|
+
for (const p of otherPaths) {
|
|
1479
|
+
try {
|
|
1480
|
+
const fsm = await import("node:fs/promises");
|
|
1481
|
+
const pathModule = await import("node:path");
|
|
1482
|
+
const buf = await fsm.readFile(p);
|
|
1483
|
+
const fname = pathModule.basename(p);
|
|
1484
|
+
const ext = pathModule.extname(p).slice(1).toLowerCase();
|
|
1485
|
+
const guessedType = MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
1486
|
+
const result = await uploadAndSendMediaBuffer({
|
|
1487
|
+
wsClient,
|
|
1488
|
+
buffer: buf,
|
|
1489
|
+
contentType: guessedType,
|
|
1490
|
+
fileName: fname,
|
|
1491
|
+
chatId,
|
|
1492
|
+
log: (m) => logVerbose(target, m),
|
|
1493
|
+
errorLog: (m) => target.runtime.error?.(m),
|
|
1494
|
+
});
|
|
1495
|
+
if (result.ok) {
|
|
1496
|
+
sentFiles.push(fname);
|
|
1497
|
+
logVerbose(target, `local-path: WS 文件发送成功 path=${p} type=${result.finalType}`);
|
|
1498
|
+
} else {
|
|
1499
|
+
failedFiles.push(fname);
|
|
1500
|
+
logVerbose(target, `local-path: WS 文件发送失败 path=${p} reason=${result.rejectReason ?? result.error}`);
|
|
1501
|
+
}
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
const fname = p.split("/").pop() || p;
|
|
1504
|
+
failedFiles.push(fname);
|
|
1505
|
+
target.runtime.error?.(`local-path: WS 文件读取/发送失败 path=${p}: ${String(err)}`);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
} else {
|
|
1509
|
+
logVerbose(target, `local-path: WS 模式但 WSClient 不可用或缺少 chatId,跳过文件发送`);
|
|
1510
|
+
failedFiles.push(...otherPaths.map((p) => p.split("/").pop() || p));
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const summary = sentFiles.length > 0
|
|
1514
|
+
? `已发送文件:${sentFiles.join(", ")}${failedFiles.length > 0 ? `(失败:${failedFiles.join(", ")})` : ""}`
|
|
1515
|
+
: `文件发送失败:${failedFiles.join(", ")}`;
|
|
1516
|
+
|
|
1378
1517
|
streamStore.updateStream(streamId, (s) => {
|
|
1379
1518
|
s.finished = true;
|
|
1380
|
-
s.content =
|
|
1519
|
+
s.content = summary;
|
|
1381
1520
|
});
|
|
1382
1521
|
streamStore.onStreamFinished(streamId);
|
|
1383
1522
|
return;
|
|
@@ -1469,6 +1608,34 @@ async function startAgentForStream(params: {
|
|
|
1469
1608
|
}
|
|
1470
1609
|
}
|
|
1471
1610
|
|
|
1611
|
+
// 3. 如果是视频,尝试用 ffmpeg 提取第一帧作为图片,让 LLM 能"看到"视频内容
|
|
1612
|
+
let videoFirstFramePath: string | undefined;
|
|
1613
|
+
if (mediaPath && mediaType?.startsWith("video/")) {
|
|
1614
|
+
try {
|
|
1615
|
+
const pathModule = await import("node:path");
|
|
1616
|
+
const { execFile } = await import("node:child_process");
|
|
1617
|
+
const { promisify } = await import("node:util");
|
|
1618
|
+
const execFileAsync = promisify(execFile);
|
|
1619
|
+
const framePath = mediaPath.replace(/\.[^.]+$/, "_frame1.jpg");
|
|
1620
|
+
await execFileAsync("ffmpeg", [
|
|
1621
|
+
"-i", mediaPath,
|
|
1622
|
+
"-vframes", "1",
|
|
1623
|
+
"-q:v", "2",
|
|
1624
|
+
"-y",
|
|
1625
|
+
framePath,
|
|
1626
|
+
], { timeout: 10_000 });
|
|
1627
|
+
// 确认文件存在且非空
|
|
1628
|
+
const fs = await import("node:fs/promises");
|
|
1629
|
+
const stat = await fs.stat(framePath);
|
|
1630
|
+
if (stat.size > 0) {
|
|
1631
|
+
videoFirstFramePath = framePath;
|
|
1632
|
+
logVerbose(target, `video: 提取第一帧成功 ${framePath} (${stat.size} bytes)`);
|
|
1633
|
+
}
|
|
1634
|
+
} catch (err) {
|
|
1635
|
+
logVerbose(target, `video: 提取第一帧失败(ffmpeg 可能不可用): ${String(err)}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1472
1639
|
const route = core.channel.routing.resolveAgentRoute({
|
|
1473
1640
|
cfg: config,
|
|
1474
1641
|
channel: "wecom",
|
|
@@ -1572,12 +1739,22 @@ async function startAgentForStream(params: {
|
|
|
1572
1739
|
const isResetCommand = /^\/(new|reset)(?:\s|$)/i.test(rawBodyNormalized);
|
|
1573
1740
|
const resetCommandKind = isResetCommand ? (rawBodyNormalized.match(/^\/(new|reset)/i)?.[1]?.toLowerCase() ?? "new") : null;
|
|
1574
1741
|
|
|
1575
|
-
const attachments = mediaPath ? [{
|
|
1742
|
+
const attachments: Array<{ name: string; mimeType?: string; url: string }> | undefined = mediaPath ? [{
|
|
1576
1743
|
name: media?.filename || "file",
|
|
1577
1744
|
mimeType: mediaType,
|
|
1578
1745
|
url: pathToFileURL(mediaPath).href
|
|
1579
1746
|
}] : undefined;
|
|
1580
1747
|
|
|
1748
|
+
// 如果提取到了视频第一帧,追加为附件让 LLM 能看到视频画面
|
|
1749
|
+
if (videoFirstFramePath && attachments) {
|
|
1750
|
+
const pathModule = await import("node:path");
|
|
1751
|
+
attachments.push({
|
|
1752
|
+
name: pathModule.basename(videoFirstFramePath),
|
|
1753
|
+
mimeType: "image/jpeg",
|
|
1754
|
+
url: pathToFileURL(videoFirstFramePath).href,
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1581
1758
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
1582
1759
|
Body: body,
|
|
1583
1760
|
RawBody: rawBody,
|
|
@@ -1839,7 +2016,35 @@ async function startAgentForStream(params: {
|
|
|
1839
2016
|
return;
|
|
1840
2017
|
}
|
|
1841
2018
|
|
|
1842
|
-
|
|
2019
|
+
// ── 解析 LLM 输出文本中的 MEDIA: /path 指令 ──
|
|
2020
|
+
// OpenClaw 核心的 splitMediaFromOutput 通常已提取并剥离 MEDIA: 行,
|
|
2021
|
+
// 此处兜底处理核心未覆盖的边界情况(如旧版本核心、特殊格式等)。
|
|
2022
|
+
const mediaDirectivePaths: string[] = [];
|
|
2023
|
+
const mediaDirectiveRe = /^MEDIA:\s*`?([^\n`]+?)`?\s*$/gm;
|
|
2024
|
+
let _mdMatch: RegExpExecArray | null;
|
|
2025
|
+
while ((_mdMatch = mediaDirectiveRe.exec(text)) !== null) {
|
|
2026
|
+
let p = (_mdMatch[1] ?? "").trim();
|
|
2027
|
+
if (!p) continue;
|
|
2028
|
+
// 展开 ~ 为 HOME 目录
|
|
2029
|
+
if (p.startsWith("~/") || p === "~") {
|
|
2030
|
+
const home = process.env.HOME || "/root";
|
|
2031
|
+
p = p.replace(/^~/, home);
|
|
2032
|
+
}
|
|
2033
|
+
if (!mediaDirectivePaths.includes(p)) {
|
|
2034
|
+
mediaDirectivePaths.push(p);
|
|
2035
|
+
logVerbose(target, `media: 检测到 MEDIA: 指令 path=${p}`);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
// 从回复文本中移除 MEDIA: 指令行,不展示给用户
|
|
2039
|
+
if (mediaDirectivePaths.length > 0) {
|
|
2040
|
+
text = text.replace(/^MEDIA:\s*`?[^\n`]+?`?\s*$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const mediaUrls = Array.from(new Set([
|
|
2044
|
+
...(payload.mediaUrls || []),
|
|
2045
|
+
...(payload.mediaUrl ? [payload.mediaUrl] : []),
|
|
2046
|
+
...mediaDirectivePaths,
|
|
2047
|
+
]));
|
|
1843
2048
|
for (const mediaPath of mediaUrls) {
|
|
1844
2049
|
let contentType: string | undefined;
|
|
1845
2050
|
let filename = mediaPath.split("/").pop() || "attachment";
|
|
@@ -1859,11 +2064,50 @@ async function startAgentForStream(params: {
|
|
|
1859
2064
|
buf = await fs.readFile(mediaPath);
|
|
1860
2065
|
filename = pathModule.basename(mediaPath);
|
|
1861
2066
|
const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
|
|
1862
|
-
|
|
1863
|
-
contentType = imageExts[ext] ?? "application/octet-stream";
|
|
2067
|
+
contentType = MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
1864
2068
|
}
|
|
1865
2069
|
|
|
1866
2070
|
if (contentType?.startsWith("image/")) {
|
|
2071
|
+
if (isWsMode) {
|
|
2072
|
+
// WS 模式:图片也通过 uploadAndSendMediaBuffer 作为独立 image 消息发送
|
|
2073
|
+
// 避免嵌入流式回复 base64 导致企微客户端可能不显示
|
|
2074
|
+
if (current.agentMediaKeys.includes(mediaPath)) {
|
|
2075
|
+
logVerbose(target, `media: WS 模式跳过已发送的图片 path=${mediaPath}`);
|
|
2076
|
+
continue;
|
|
2077
|
+
}
|
|
2078
|
+
const wsClient = getWsClient(account.accountId);
|
|
2079
|
+
if (wsClient && current.chatId) {
|
|
2080
|
+
const result = await uploadAndSendMediaBuffer({
|
|
2081
|
+
wsClient,
|
|
2082
|
+
buffer: buf,
|
|
2083
|
+
contentType: contentType ?? "image/jpeg",
|
|
2084
|
+
fileName: filename,
|
|
2085
|
+
chatId: current.chatId,
|
|
2086
|
+
log: (m) => logVerbose(target, m),
|
|
2087
|
+
errorLog: (m) => target.runtime.error?.(m),
|
|
2088
|
+
});
|
|
2089
|
+
if (result.ok) {
|
|
2090
|
+
logVerbose(target, `media: WS 图片上传发送成功 type=${result.finalType} filename=${filename}`);
|
|
2091
|
+
streamStore.updateStream(streamId, (s) => {
|
|
2092
|
+
s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
|
|
2093
|
+
});
|
|
2094
|
+
} else {
|
|
2095
|
+
// 降级:如果上传失败,回退到 base64 嵌入方式
|
|
2096
|
+
logVerbose(target, `media: WS 图片上传失败,回退到 base64 嵌入 filename=${filename}`);
|
|
2097
|
+
const base64 = buf.toString("base64");
|
|
2098
|
+
const md5 = crypto.createHash("md5").update(buf).digest("hex");
|
|
2099
|
+
current.images.push({ base64, md5 });
|
|
2100
|
+
}
|
|
2101
|
+
} else {
|
|
2102
|
+
// WSClient 不可用时回退到 base64
|
|
2103
|
+
const base64 = buf.toString("base64");
|
|
2104
|
+
const md5 = crypto.createHash("md5").update(buf).digest("hex");
|
|
2105
|
+
current.images.push({ base64, md5 });
|
|
2106
|
+
logVerbose(target, `media: WS 模式但 WSClient 不可用,回退到 base64 嵌入 filename=${filename}`);
|
|
2107
|
+
}
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
// 非 WS 模式:保持原有 base64 嵌入方式
|
|
1867
2111
|
const base64 = buf.toString("base64");
|
|
1868
2112
|
const md5 = crypto.createHash("md5").update(buf).digest("hex");
|
|
1869
2113
|
current.images.push({ base64, md5 });
|
|
@@ -1871,8 +2115,37 @@ async function startAgentForStream(params: {
|
|
|
1871
2115
|
} else {
|
|
1872
2116
|
// Non-image media: Bot 不支持原样发送(尤其群聊)
|
|
1873
2117
|
if (isWsMode) {
|
|
1874
|
-
//
|
|
1875
|
-
|
|
2118
|
+
// 去重:如果这个媒体路径已经发送过,跳过
|
|
2119
|
+
if (current.agentMediaKeys.includes(mediaPath)) {
|
|
2120
|
+
logVerbose(target, `media: WS 模式跳过已发送的媒体 path=${mediaPath}`);
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
// WS 模式:通过 WSClient uploadMedia + sendMediaMessage 发送非图片媒体
|
|
2124
|
+
const wsClient = getWsClient(account.accountId);
|
|
2125
|
+
if (wsClient && current.chatId) {
|
|
2126
|
+
const result = await uploadAndSendMediaBuffer({
|
|
2127
|
+
wsClient,
|
|
2128
|
+
buffer: buf,
|
|
2129
|
+
contentType: contentType ?? "application/octet-stream",
|
|
2130
|
+
fileName: filename,
|
|
2131
|
+
chatId: current.chatId,
|
|
2132
|
+
log: (m) => logVerbose(target, m),
|
|
2133
|
+
errorLog: (m) => target.runtime.error?.(m),
|
|
2134
|
+
});
|
|
2135
|
+
if (result.ok) {
|
|
2136
|
+
logVerbose(target, `media: WS 上传发送成功 type=${result.finalType}${result.downgraded ? ` (降级: ${result.downgradeNote})` : ""}`);
|
|
2137
|
+
// 记录已发送,防止后续 deliver 调用时重复发送
|
|
2138
|
+
streamStore.updateStream(streamId, (s) => {
|
|
2139
|
+
s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
|
|
2140
|
+
});
|
|
2141
|
+
} else if (result.rejected) {
|
|
2142
|
+
logVerbose(target, `media: 文件被拒绝 ${result.rejectReason}`);
|
|
2143
|
+
} else {
|
|
2144
|
+
target.runtime.error?.(`media: WS 上传发送失败 ${result.error}`);
|
|
2145
|
+
}
|
|
2146
|
+
} else {
|
|
2147
|
+
logVerbose(target, `media: WS 模式但 WSClient 不可用或缺少 chatId,跳过 filename=${filename}`);
|
|
2148
|
+
}
|
|
1876
2149
|
continue;
|
|
1877
2150
|
}
|
|
1878
2151
|
|
|
@@ -2015,7 +2288,13 @@ async function startAgentForStream(params: {
|
|
|
2015
2288
|
|
|
2016
2289
|
streamStore.updateStream(streamId, (s) => {
|
|
2017
2290
|
if (!s.content.trim() && !(s.images?.length ?? 0)) {
|
|
2018
|
-
s.
|
|
2291
|
+
const hasMediaDelivered = (s.agentMediaKeys?.length ?? 0) > 0;
|
|
2292
|
+
const hasFallback = Boolean(s.fallbackMode);
|
|
2293
|
+
if (hasMediaDelivered) {
|
|
2294
|
+
s.content = "✅ 文件已发送。";
|
|
2295
|
+
} else if (!hasFallback) {
|
|
2296
|
+
s.content = "✅ 已处理完成。";
|
|
2297
|
+
}
|
|
2019
2298
|
}
|
|
2020
2299
|
});
|
|
2021
2300
|
|
|
@@ -2093,6 +2372,7 @@ function formatQuote(quote: WecomInboundQuote): string {
|
|
|
2093
2372
|
}
|
|
2094
2373
|
if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
|
|
2095
2374
|
if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
|
|
2375
|
+
if (type === "video") return `[引用: 视频] ${quote.video?.url || ""}`;
|
|
2096
2376
|
return "";
|
|
2097
2377
|
}
|
|
2098
2378
|
|
|
@@ -2114,6 +2394,7 @@ export function buildInboundBody(msg: WecomInboundMessage): string {
|
|
|
2114
2394
|
} else body = "[mixed]";
|
|
2115
2395
|
} else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
|
|
2116
2396
|
else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
|
|
2397
|
+
else if (msgtype === "video") body = `[video] ${(msg as any).video?.url || ""}`;
|
|
2117
2398
|
else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
|
|
2118
2399
|
else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
|
|
2119
2400
|
else body = msgtype ? `[${msgtype}]` : "";
|