@sunnoy/wecom 2.1.0 → 2.2.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 +6 -2
- package/index.js +2 -0
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -3
- package/skills/wecom-contact-lookup/SKILL.md +167 -0
- package/skills/wecom-doc-manager/SKILL.md +106 -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 +254 -0
- package/skills/wecom-get-todo-detail/SKILL.md +148 -0
- package/skills/wecom-get-todo-list/SKILL.md +132 -0
- package/skills/wecom-meeting-create/SKILL.md +163 -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 +141 -0
- package/skills/wecom-meeting-query/SKILL.md +335 -0
- package/skills/wecom-preflight/SKILL.md +103 -0
- package/skills/wecom-schedule/SKILL.md +164 -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 +76 -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 +96 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
- package/wecom/accounts.js +1 -0
- package/wecom/callback-inbound.js +133 -33
- package/wecom/channel-plugin.js +107 -125
- package/wecom/constants.js +83 -3
- package/wecom/mcp-config.js +146 -0
- package/wecom/mcp-tool.js +660 -0
- package/wecom/media-uploader.js +208 -0
- package/wecom/openclaw-compat.js +302 -0
- package/wecom/reqid-store.js +146 -0
- package/wecom/target.js +3 -2
- package/wecom/workspace-template.js +107 -21
- package/wecom/ws-monitor.js +778 -328
- package/image-processor.js +0 -175
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
3
|
+
import { loadOutboundMediaFromUrl, detectMime, getExtendedMediaLocalRoots } from "./openclaw-compat.js";
|
|
4
|
+
import {
|
|
5
|
+
IMAGE_MAX_BYTES,
|
|
6
|
+
VIDEO_MAX_BYTES,
|
|
7
|
+
VOICE_MAX_BYTES,
|
|
8
|
+
ABSOLUTE_MAX_BYTES,
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
|
|
11
|
+
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
|
|
12
|
+
|
|
13
|
+
const MIME_TO_EXT = {
|
|
14
|
+
"image/jpeg": ".jpg",
|
|
15
|
+
"image/png": ".png",
|
|
16
|
+
"image/gif": ".gif",
|
|
17
|
+
"image/webp": ".webp",
|
|
18
|
+
"image/bmp": ".bmp",
|
|
19
|
+
"image/svg+xml": ".svg",
|
|
20
|
+
"video/mp4": ".mp4",
|
|
21
|
+
"video/quicktime": ".mov",
|
|
22
|
+
"video/x-msvideo": ".avi",
|
|
23
|
+
"video/webm": ".webm",
|
|
24
|
+
"audio/mpeg": ".mp3",
|
|
25
|
+
"audio/ogg": ".ogg",
|
|
26
|
+
"audio/wav": ".wav",
|
|
27
|
+
"audio/amr": ".amr",
|
|
28
|
+
"audio/aac": ".aac",
|
|
29
|
+
"application/pdf": ".pdf",
|
|
30
|
+
"application/zip": ".zip",
|
|
31
|
+
"application/msword": ".doc",
|
|
32
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
33
|
+
"application/vnd.ms-excel": ".xls",
|
|
34
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
35
|
+
"text/plain": ".txt",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function detectWeComMediaType(mimeType) {
|
|
39
|
+
const mime = String(mimeType ?? "").toLowerCase();
|
|
40
|
+
if (mime.startsWith("image/")) return "image";
|
|
41
|
+
if (mime.startsWith("video/")) return "video";
|
|
42
|
+
if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
|
|
43
|
+
return "file";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mimeToExtension(mime) {
|
|
47
|
+
return MIME_TO_EXT[mime] || ".bin";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function extractFileName(mediaUrl, providedFileName, contentType) {
|
|
51
|
+
if (providedFileName) return providedFileName;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const urlObj = new URL(mediaUrl, "file://");
|
|
55
|
+
const lastPart = urlObj.pathname.split("/").pop();
|
|
56
|
+
if (lastPart?.includes(".")) return decodeURIComponent(lastPart);
|
|
57
|
+
} catch {
|
|
58
|
+
const lastPart = String(mediaUrl).split("/").pop();
|
|
59
|
+
if (lastPart?.includes(".")) return lastPart;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return `media_${Date.now()}${mimeToExtension(contentType || "application/octet-stream")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function applyFileSizeLimits(fileSize, detectedType, contentType) {
|
|
66
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
67
|
+
|
|
68
|
+
if (fileSize > ABSOLUTE_MAX_BYTES) {
|
|
69
|
+
return {
|
|
70
|
+
finalType: detectedType,
|
|
71
|
+
shouldReject: true,
|
|
72
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
|
|
73
|
+
downgraded: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
switch (detectedType) {
|
|
78
|
+
case "image":
|
|
79
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
80
|
+
return {
|
|
81
|
+
finalType: "file",
|
|
82
|
+
shouldReject: false,
|
|
83
|
+
downgraded: true,
|
|
84
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
case "video":
|
|
89
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
90
|
+
return {
|
|
91
|
+
finalType: "file",
|
|
92
|
+
shouldReject: false,
|
|
93
|
+
downgraded: true,
|
|
94
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case "voice":
|
|
99
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
100
|
+
return {
|
|
101
|
+
finalType: "file",
|
|
102
|
+
shouldReject: false,
|
|
103
|
+
downgraded: true,
|
|
104
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
108
|
+
return {
|
|
109
|
+
finalType: "file",
|
|
110
|
+
shouldReject: false,
|
|
111
|
+
downgraded: true,
|
|
112
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { finalType: detectedType, shouldReject: false, downgraded: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function resolveMediaFile(mediaUrl, mediaLocalRoots, includeDefaultMediaLocalRoots = true) {
|
|
122
|
+
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
123
|
+
maxBytes: ABSOLUTE_MAX_BYTES,
|
|
124
|
+
mediaLocalRoots,
|
|
125
|
+
includeDefaultMediaLocalRoots,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!result.buffer || result.buffer.length === 0) {
|
|
129
|
+
throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let contentType = result.contentType || "application/octet-stream";
|
|
133
|
+
if (contentType === "application/octet-stream" || contentType === "text/plain") {
|
|
134
|
+
const detected = await detectMime(result.buffer);
|
|
135
|
+
if (detected) contentType = detected;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
buffer: result.buffer,
|
|
140
|
+
contentType,
|
|
141
|
+
fileName: extractFileName(mediaUrl, result.fileName, contentType),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildMediaErrorSummary(mediaUrl, result) {
|
|
146
|
+
if (result.error?.includes("LocalMediaAccessError")) {
|
|
147
|
+
return `文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
|
|
148
|
+
}
|
|
149
|
+
if (result.rejectReason) {
|
|
150
|
+
return `文件发送失败:${result.rejectReason}`;
|
|
151
|
+
}
|
|
152
|
+
return `文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function uploadAndSendMedia({
|
|
156
|
+
wsClient,
|
|
157
|
+
mediaUrl,
|
|
158
|
+
chatId,
|
|
159
|
+
mediaLocalRoots,
|
|
160
|
+
includeDefaultMediaLocalRoots = true,
|
|
161
|
+
log,
|
|
162
|
+
errorLog,
|
|
163
|
+
}) {
|
|
164
|
+
try {
|
|
165
|
+
log?.(`[wecom] Uploading media: url=${mediaUrl}`);
|
|
166
|
+
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots, includeDefaultMediaLocalRoots);
|
|
167
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
168
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
169
|
+
|
|
170
|
+
if (sizeCheck.shouldReject) {
|
|
171
|
+
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
rejected: true,
|
|
175
|
+
rejectReason: sizeCheck.rejectReason,
|
|
176
|
+
finalType: sizeCheck.finalType,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const finalType = sizeCheck.finalType;
|
|
181
|
+
const uploadResult = await wsClient.uploadMedia(media.buffer, {
|
|
182
|
+
type: finalType,
|
|
183
|
+
filename: media.fileName,
|
|
184
|
+
});
|
|
185
|
+
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
|
|
186
|
+
|
|
187
|
+
const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
|
|
188
|
+
const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
|
|
189
|
+
log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
messageId,
|
|
194
|
+
finalType,
|
|
195
|
+
downgraded: sizeCheck.downgraded,
|
|
196
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
197
|
+
};
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const errMsg = String(err);
|
|
200
|
+
errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
|
|
201
|
+
return { ok: false, error: errMsg };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const mediaUploaderTesting = {
|
|
206
|
+
resolveMediaFile,
|
|
207
|
+
VOICE_SUPPORTED_MIMES,
|
|
208
|
+
};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { basename, extname, join, parse, resolve } from "node:path";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
const sdkReady = import("openclaw/plugin-sdk")
|
|
7
|
+
.then((sdk) => ({
|
|
8
|
+
loadOutboundMediaFromUrl:
|
|
9
|
+
typeof sdk.loadOutboundMediaFromUrl === "function" ? sdk.loadOutboundMediaFromUrl.bind(sdk) : undefined,
|
|
10
|
+
detectMime: typeof sdk.detectMime === "function" ? sdk.detectMime.bind(sdk) : undefined,
|
|
11
|
+
getDefaultMediaLocalRoots:
|
|
12
|
+
typeof sdk.getDefaultMediaLocalRoots === "function" ? sdk.getDefaultMediaLocalRoots.bind(sdk) : undefined,
|
|
13
|
+
}))
|
|
14
|
+
.catch(() => ({}));
|
|
15
|
+
|
|
16
|
+
const MIME_BY_EXT = {
|
|
17
|
+
".aac": "audio/aac",
|
|
18
|
+
".amr": "audio/amr",
|
|
19
|
+
".avi": "video/x-msvideo",
|
|
20
|
+
".bmp": "image/bmp",
|
|
21
|
+
".csv": "text/csv",
|
|
22
|
+
".doc": "application/msword",
|
|
23
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".gz": "application/gzip",
|
|
26
|
+
".heic": "image/heic",
|
|
27
|
+
".heif": "image/heif",
|
|
28
|
+
".jpeg": "image/jpeg",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".json": "application/json",
|
|
31
|
+
".m4a": "audio/x-m4a",
|
|
32
|
+
".md": "text/markdown",
|
|
33
|
+
".mov": "video/quicktime",
|
|
34
|
+
".mp3": "audio/mpeg",
|
|
35
|
+
".mp4": "video/mp4",
|
|
36
|
+
".ogg": "audio/ogg",
|
|
37
|
+
".pdf": "application/pdf",
|
|
38
|
+
".png": "image/png",
|
|
39
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
40
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
41
|
+
".rar": "application/vnd.rar",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
".tar": "application/x-tar",
|
|
44
|
+
".txt": "text/plain",
|
|
45
|
+
".wav": "audio/wav",
|
|
46
|
+
".webm": "video/webm",
|
|
47
|
+
".webp": "image/webp",
|
|
48
|
+
".xls": "application/vnd.ms-excel",
|
|
49
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
50
|
+
".zip": "application/zip",
|
|
51
|
+
".7z": "application/x-7z-compressed",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function resolveUserPath(value) {
|
|
55
|
+
if (!value.startsWith("~")) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
return join(homedir(), value.slice(1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeRootEntry(entry) {
|
|
62
|
+
const value = String(entry ?? "").trim();
|
|
63
|
+
if (!value) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return resolve(resolveUserPath(value));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeMediaReference(mediaUrl) {
|
|
70
|
+
let value = String(mediaUrl ?? "").trim();
|
|
71
|
+
if (!value) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
value = value.replace(/^\s*(?:MEDIA|FILE)\s*:\s*/i, "");
|
|
75
|
+
if (value.startsWith("sandbox:")) {
|
|
76
|
+
value = value.replace(/^sandbox:\/{0,2}/, "");
|
|
77
|
+
if (!value.startsWith("/")) {
|
|
78
|
+
value = `/${value}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveStateDir() {
|
|
85
|
+
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
86
|
+
if (override) {
|
|
87
|
+
return resolve(resolveUserPath(override));
|
|
88
|
+
}
|
|
89
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
|
90
|
+
return join(tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
|
|
91
|
+
}
|
|
92
|
+
return join(homedir(), ".openclaw");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function sniffMimeFromBuffer(buffer) {
|
|
96
|
+
try {
|
|
97
|
+
const { fileTypeFromBuffer } = await import("file-type");
|
|
98
|
+
const type = await fileTypeFromBuffer(buffer);
|
|
99
|
+
return type?.mime ?? undefined;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function detectMimeFallback(options) {
|
|
106
|
+
const ext = options.filePath ? extname(options.filePath).toLowerCase() : "";
|
|
107
|
+
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
|
108
|
+
const sniffed = options.buffer ? await sniffMimeFromBuffer(options.buffer) : undefined;
|
|
109
|
+
const headerMime = options.headerMime?.split(";")?.[0]?.trim().toLowerCase();
|
|
110
|
+
const isGeneric = (value) => !value || value === "application/octet-stream" || value === "application/zip";
|
|
111
|
+
|
|
112
|
+
if (sniffed && (!isGeneric(sniffed) || !extMime)) {
|
|
113
|
+
return sniffed;
|
|
114
|
+
}
|
|
115
|
+
if (extMime) {
|
|
116
|
+
return extMime;
|
|
117
|
+
}
|
|
118
|
+
if (headerMime && !isGeneric(headerMime)) {
|
|
119
|
+
return headerMime;
|
|
120
|
+
}
|
|
121
|
+
return sniffed || headerMime || undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasExplicitMediaRoots(options = {}) {
|
|
125
|
+
return Boolean(
|
|
126
|
+
(Array.isArray(options.mediaLocalRoots) && options.mediaLocalRoots.length > 0) ||
|
|
127
|
+
(Array.isArray(options.accountConfig?.mediaLocalRoots) && options.accountConfig.mediaLocalRoots.length > 0),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isLocalMediaAccessError(error) {
|
|
132
|
+
const message = String(error?.message ?? error ?? "");
|
|
133
|
+
return /Local media path is not under an allowed directory|LocalMediaAccessError/i.test(message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function shouldFallbackFromLocalAccessError(error, options) {
|
|
137
|
+
return isLocalMediaAccessError(error) && !hasExplicitMediaRoots(options);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readLocalMediaFile(filePath, { maxBytes } = {}) {
|
|
141
|
+
const info = await stat(filePath);
|
|
142
|
+
if (!info.isFile()) {
|
|
143
|
+
throw new Error(`Local media path is not a file: ${filePath}`);
|
|
144
|
+
}
|
|
145
|
+
const buffer = await readFile(filePath);
|
|
146
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
147
|
+
throw new Error(`Local media exceeds max size (${buffer.length} > ${maxBytes})`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
buffer,
|
|
151
|
+
contentType: (await detectMimeFallback({ buffer, filePath })) || "",
|
|
152
|
+
fileName: basename(filePath) || "file",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fetchRemoteMedia(url, { maxBytes, fetchImpl } = {}) {
|
|
157
|
+
const response = await (fetchImpl ?? fetch)(url, { redirect: "follow" });
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(`failed to download media: ${response.status}`);
|
|
160
|
+
}
|
|
161
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
162
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
163
|
+
throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
|
|
164
|
+
}
|
|
165
|
+
const disposition = response.headers.get("content-disposition");
|
|
166
|
+
let fileName = "";
|
|
167
|
+
if (disposition) {
|
|
168
|
+
const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
|
|
169
|
+
if (match?.[1]) {
|
|
170
|
+
try {
|
|
171
|
+
fileName = basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
|
|
172
|
+
} catch {
|
|
173
|
+
fileName = basename(match[1].replace(/["']/g, "").trim());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!fileName) {
|
|
178
|
+
try {
|
|
179
|
+
fileName = basename(new URL(url).pathname) || "file";
|
|
180
|
+
} catch {
|
|
181
|
+
fileName = "file";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const headerMime = response.headers.get("content-type") || "";
|
|
185
|
+
return {
|
|
186
|
+
buffer,
|
|
187
|
+
contentType: (await detectMimeFallback({ buffer, headerMime, filePath: fileName || url })) || headerMime || "",
|
|
188
|
+
fileName,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function asLocalPath(mediaRef) {
|
|
193
|
+
if (!mediaRef) {
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
if (mediaRef.startsWith("file://")) {
|
|
197
|
+
return fileURLToPath(mediaRef);
|
|
198
|
+
}
|
|
199
|
+
if (mediaRef.startsWith("/") || mediaRef.startsWith("~")) {
|
|
200
|
+
return resolve(resolveUserPath(mediaRef));
|
|
201
|
+
}
|
|
202
|
+
return "";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function detectMime(bufferOrOptions) {
|
|
206
|
+
const sdk = await sdkReady;
|
|
207
|
+
const options = Buffer.isBuffer(bufferOrOptions) ? { buffer: bufferOrOptions } : bufferOrOptions;
|
|
208
|
+
if (sdk.detectMime) {
|
|
209
|
+
try {
|
|
210
|
+
return await sdk.detectMime(options);
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
return detectMimeFallback(options);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function getDefaultMediaLocalRoots() {
|
|
217
|
+
const sdk = await sdkReady;
|
|
218
|
+
if (sdk.getDefaultMediaLocalRoots) {
|
|
219
|
+
try {
|
|
220
|
+
return await sdk.getDefaultMediaLocalRoots();
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const stateDir = resolveStateDir();
|
|
225
|
+
return [
|
|
226
|
+
join(stateDir, "media"),
|
|
227
|
+
join(stateDir, "agents"),
|
|
228
|
+
join(stateDir, "workspace"),
|
|
229
|
+
join(stateDir, "sandboxes"),
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function getExtendedMediaLocalRoots({
|
|
234
|
+
accountConfig,
|
|
235
|
+
mediaLocalRoots,
|
|
236
|
+
includeDefaultMediaLocalRoots = true,
|
|
237
|
+
} = {}) {
|
|
238
|
+
const defaults = includeDefaultMediaLocalRoots ? await getDefaultMediaLocalRoots() : [];
|
|
239
|
+
const roots = [
|
|
240
|
+
...defaults,
|
|
241
|
+
...(Array.isArray(accountConfig?.mediaLocalRoots) ? accountConfig.mediaLocalRoots : []),
|
|
242
|
+
...(Array.isArray(mediaLocalRoots) ? mediaLocalRoots : []),
|
|
243
|
+
]
|
|
244
|
+
.map(normalizeRootEntry)
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
|
|
247
|
+
return [...new Set(roots)];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
|
|
251
|
+
const normalized = normalizeMediaReference(mediaUrl);
|
|
252
|
+
const filePath = asLocalPath(normalized);
|
|
253
|
+
const localRoots = await getExtendedMediaLocalRoots(options);
|
|
254
|
+
const sdk = await sdkReady;
|
|
255
|
+
|
|
256
|
+
if (filePath) {
|
|
257
|
+
if (typeof options.runtimeLoadMedia === "function" && localRoots.length > 0) {
|
|
258
|
+
try {
|
|
259
|
+
const loaded = await options.runtimeLoadMedia(filePath, { localRoots });
|
|
260
|
+
return {
|
|
261
|
+
buffer: loaded.buffer,
|
|
262
|
+
contentType: loaded.contentType || "",
|
|
263
|
+
fileName: loaded.fileName || basename(filePath) || "file",
|
|
264
|
+
};
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (!shouldFallbackFromLocalAccessError(error, options)) {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (sdk.loadOutboundMediaFromUrl) {
|
|
273
|
+
try {
|
|
274
|
+
return await sdk.loadOutboundMediaFromUrl(filePath, {
|
|
275
|
+
maxBytes: options.maxBytes,
|
|
276
|
+
mediaLocalRoots: localRoots,
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (!shouldFallbackFromLocalAccessError(error, options)) {
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return readLocalMediaFile(filePath, options);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (sdk.loadOutboundMediaFromUrl && !options.fetchImpl) {
|
|
289
|
+
return sdk.loadOutboundMediaFromUrl(normalized, {
|
|
290
|
+
maxBytes: options.maxBytes,
|
|
291
|
+
mediaLocalRoots: localRoots,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return fetchRemoteMedia(normalized, options);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { resolveStateDir };
|
|
299
|
+
|
|
300
|
+
export const openclawCompatTesting = {
|
|
301
|
+
normalizeMediaReference,
|
|
302
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
4
|
+
import { REQID_TTL_MS, REQID_MAX_SIZE, REQID_FLUSH_DEBOUNCE_MS } from "./constants.js";
|
|
5
|
+
import { resolveStateDir } from "./openclaw-compat.js";
|
|
6
|
+
|
|
7
|
+
function getStorePath(accountId) {
|
|
8
|
+
return path.join(resolveStateDir(), "wecomConfig", `reqids-${accountId}.json`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readJsonFile(filePath) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.code === "ENOENT") return {};
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function writeJsonFileAtomically(filePath, value) {
|
|
21
|
+
const dir = path.dirname(filePath);
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
24
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
25
|
+
await rename(tempPath, filePath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPersistentReqIdStore(accountId, options = {}) {
|
|
29
|
+
const maxSize = options.maxSize ?? REQID_MAX_SIZE;
|
|
30
|
+
const ttlMs = options.ttlMs ?? REQID_TTL_MS;
|
|
31
|
+
const debounceMs = options.debounceMs ?? REQID_FLUSH_DEBOUNCE_MS;
|
|
32
|
+
const storePath = options.storePath ?? getStorePath(accountId);
|
|
33
|
+
const writeJson = options.writeJsonFileAtomically ?? writeJsonFileAtomically;
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
let dirty = false;
|
|
36
|
+
let dirtyVersion = 0;
|
|
37
|
+
let flushTimer = null;
|
|
38
|
+
let flushPromise = null;
|
|
39
|
+
|
|
40
|
+
function evictOldest() {
|
|
41
|
+
if (cache.size <= maxSize) return;
|
|
42
|
+
const entries = [...cache.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
43
|
+
const toRemove = entries.length - maxSize;
|
|
44
|
+
for (let i = 0; i < toRemove; i++) {
|
|
45
|
+
cache.delete(entries[i][0]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scheduleFlush() {
|
|
50
|
+
if (flushTimer) return;
|
|
51
|
+
flushTimer = setTimeout(async () => {
|
|
52
|
+
flushTimer = null;
|
|
53
|
+
try {
|
|
54
|
+
await store.flush();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.warn(`[ReqIdStore:${accountId}] Debounced flush failed: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
}, debounceMs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const store = {
|
|
62
|
+
set(chatId, reqId) {
|
|
63
|
+
cache.set(chatId, { reqId, updatedAt: Date.now() });
|
|
64
|
+
dirty = true;
|
|
65
|
+
dirtyVersion += 1;
|
|
66
|
+
evictOldest();
|
|
67
|
+
scheduleFlush();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
getSync(chatId) {
|
|
71
|
+
const entry = cache.get(chatId);
|
|
72
|
+
if (!entry) return undefined;
|
|
73
|
+
if (Date.now() - entry.updatedAt > ttlMs) {
|
|
74
|
+
cache.delete(chatId);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return entry.reqId;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async warmup() {
|
|
81
|
+
try {
|
|
82
|
+
const data = await readJsonFile(storePath);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
for (const [chatId, entry] of Object.entries(data)) {
|
|
85
|
+
if (entry?.reqId && entry?.updatedAt && now - entry.updatedAt <= ttlMs) {
|
|
86
|
+
cache.set(chatId, entry);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
evictOldest();
|
|
90
|
+
logger.info(`[ReqIdStore:${accountId}] Warmed up ${cache.size} entries from ${storePath}`);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.warn(`[ReqIdStore:${accountId}] Warmup failed (non-fatal): ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async flush() {
|
|
97
|
+
if (flushPromise) {
|
|
98
|
+
await flushPromise;
|
|
99
|
+
if (!dirty) return;
|
|
100
|
+
}
|
|
101
|
+
if (!dirty) return;
|
|
102
|
+
const currentFlush = (async () => {
|
|
103
|
+
const snapshot = Object.fromEntries(cache);
|
|
104
|
+
const snapshotVersion = dirtyVersion;
|
|
105
|
+
try {
|
|
106
|
+
await writeJson(storePath, snapshot);
|
|
107
|
+
if (dirtyVersion === snapshotVersion) {
|
|
108
|
+
dirty = false;
|
|
109
|
+
} else {
|
|
110
|
+
scheduleFlush();
|
|
111
|
+
}
|
|
112
|
+
logger.debug(`[ReqIdStore:${accountId}] Flushed ${cache.size} entries to disk`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// Keep dirty = true so a subsequent set() or scheduled flush can retry
|
|
115
|
+
logger.warn(`[ReqIdStore:${accountId}] Flush failed, will retry on next trigger: ${error.message}`);
|
|
116
|
+
scheduleFlush();
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
flushPromise = currentFlush;
|
|
120
|
+
try {
|
|
121
|
+
await currentFlush;
|
|
122
|
+
} finally {
|
|
123
|
+
if (flushPromise === currentFlush) {
|
|
124
|
+
flushPromise = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
destroy() {
|
|
130
|
+
if (flushTimer) {
|
|
131
|
+
clearTimeout(flushTimer);
|
|
132
|
+
flushTimer = null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
get size() {
|
|
137
|
+
return cache.size;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return store;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const reqIdStoreTesting = {
|
|
145
|
+
getStorePath,
|
|
146
|
+
};
|
package/wecom/target.js
CHANGED
|
@@ -47,8 +47,9 @@ export function resolveWecomTarget(raw) {
|
|
|
47
47
|
if (/^(wr|wc)/i.test(clean)) {
|
|
48
48
|
return { chatId: clean };
|
|
49
49
|
}
|
|
50
|
-
//
|
|
51
|
-
|
|
50
|
+
// Short pure-digit strings (≤6 digits) are department (party) IDs.
|
|
51
|
+
// Longer digit strings (phone numbers, external IDs) fall through to toUser.
|
|
52
|
+
if (/^\d{1,6}$/.test(clean)) {
|
|
52
53
|
return { toParty: clean };
|
|
53
54
|
}
|
|
54
55
|
|