@marshulll/openclaw-wecom 0.1.22 → 0.1.24
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.en.md +2 -0
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/docs/INSTALL.md +1 -0
- package/docs/wecom.config.full.example.json +6 -1
- package/package.json +1 -1
- package/wecom/src/config-schema.ts +12 -0
- package/wecom/src/media-auto.ts +115 -14
- package/wecom/src/types.ts +6 -0
- package/wecom/src/wecom-app.ts +144 -0
package/README.en.md
CHANGED
|
@@ -93,10 +93,12 @@ Install guide: `docs/INSTALL.md`
|
|
|
93
93
|
- `/sendfile`: send files from server (multiple absolute paths)
|
|
94
94
|
- Directories are zipped automatically
|
|
95
95
|
- Example: `/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
96
|
+
- Natural language also works: "send me this file image-xxx.jpg" (default match in `media.tempDir`)
|
|
96
97
|
|
|
97
98
|
## Media auto recognition (optional)
|
|
98
99
|
- **Voice send/receive does NOT require API**; only auto transcription needs an OpenAI-compatible API
|
|
99
100
|
- **Video recognition requires ffmpeg** (install on server, then set `media.auto.video.enabled = true`)
|
|
101
|
+
- Video recognition supports **light / full** modes (default: light)
|
|
100
102
|
- Small text files can be previewed automatically
|
|
101
103
|
|
|
102
104
|
## Send queue & operation logs (optional)
|
package/README.md
CHANGED
|
@@ -95,10 +95,12 @@ openclaw gateway restart
|
|
|
95
95
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
|
+
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
98
99
|
|
|
99
100
|
## 多媒体自动识别(可选)
|
|
100
101
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
|
101
102
|
- **视频识别需要 ffmpeg**(服务器已安装后,将 `media.auto.video.enabled` 设为 `true`)
|
|
103
|
+
- 视频识别支持 **light / full** 两种模式(默认 light)
|
|
102
104
|
- 文本文件可自动预览(小文件直接读入)
|
|
103
105
|
|
|
104
106
|
## 发送队列与操作日志(可选)
|
package/README.zh.md
CHANGED
|
@@ -95,10 +95,12 @@ openclaw gateway restart
|
|
|
95
95
|
- `/sendfile`:发送服务器文件(支持多个绝对路径)
|
|
96
96
|
- 支持目录:自动打包为 zip 后发送
|
|
97
97
|
- 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
|
|
98
|
+
- 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
|
|
98
99
|
|
|
99
100
|
## 多媒体自动识别(可选)
|
|
100
101
|
- **语音收发不需要 API**,只有开启“语音自动转写”才需要 OpenAI 兼容接口
|
|
101
102
|
- **视频识别需要 ffmpeg**(服务器已安装后,将 `media.auto.video.enabled` 设为 `true`)
|
|
103
|
+
- 视频识别支持 **light / full** 两种模式(默认 light)
|
|
102
104
|
- 文本文件可自动预览(小文件直接读入)
|
|
103
105
|
|
|
104
106
|
## 发送队列与操作日志(可选)
|
package/docs/INSTALL.md
CHANGED
package/package.json
CHANGED
|
@@ -79,6 +79,12 @@ const accountSchema = z.object({
|
|
|
79
79
|
enabled: z.boolean().optional(),
|
|
80
80
|
ffmpegPath: z.string().optional(),
|
|
81
81
|
maxBytes: z.number().optional(),
|
|
82
|
+
mode: z.enum(["light", "full"]).optional(),
|
|
83
|
+
frames: z.number().optional(),
|
|
84
|
+
intervalSec: z.number().optional(),
|
|
85
|
+
maxDurationSec: z.number().optional(),
|
|
86
|
+
maxFrames: z.number().optional(),
|
|
87
|
+
includeAudio: z.boolean().optional(),
|
|
82
88
|
}).optional(),
|
|
83
89
|
}).optional(),
|
|
84
90
|
}).optional(),
|
|
@@ -155,6 +161,12 @@ export const WecomConfigSchema = ensureJsonSchema(z.object({
|
|
|
155
161
|
enabled: z.boolean().optional(),
|
|
156
162
|
ffmpegPath: z.string().optional(),
|
|
157
163
|
maxBytes: z.number().optional(),
|
|
164
|
+
mode: z.enum(["light", "full"]).optional(),
|
|
165
|
+
frames: z.number().optional(),
|
|
166
|
+
intervalSec: z.number().optional(),
|
|
167
|
+
maxDurationSec: z.number().optional(),
|
|
168
|
+
maxFrames: z.number().optional(),
|
|
169
|
+
includeAudio: z.boolean().optional(),
|
|
158
170
|
}).optional(),
|
|
159
171
|
}).optional(),
|
|
160
172
|
}).optional(),
|
package/wecom/src/media-auto.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, stat, writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
1
|
+
import { readFile, stat, writeFile, mkdtemp, rm, readdir } from "node:fs/promises";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, extname, join } from "node:path";
|
|
@@ -49,6 +49,12 @@ export type ResolvedAutoFileConfig = {
|
|
|
49
49
|
export type ResolvedAutoVideoConfig = {
|
|
50
50
|
ffmpegPath: string;
|
|
51
51
|
maxBytes?: number;
|
|
52
|
+
mode: "light" | "full";
|
|
53
|
+
frames: number;
|
|
54
|
+
intervalSec: number;
|
|
55
|
+
maxDurationSec: number;
|
|
56
|
+
maxFrames: number;
|
|
57
|
+
includeAudio: boolean;
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
export function resolveAutoAudioConfig(cfg: WecomAccountConfig): ResolvedAutoAudioConfig | null {
|
|
@@ -86,9 +92,29 @@ export function resolveAutoFileConfig(cfg: WecomAccountConfig): ResolvedAutoFile
|
|
|
86
92
|
export function resolveAutoVideoConfig(cfg: WecomAccountConfig): ResolvedAutoVideoConfig | null {
|
|
87
93
|
const video = cfg.media?.auto?.video;
|
|
88
94
|
if (!cfg.media?.auto?.enabled || !video?.enabled) return null;
|
|
95
|
+
const mode = video.mode === "full" ? "full" : "light";
|
|
96
|
+
const maxDurationSec = typeof video.maxDurationSec === "number" && video.maxDurationSec > 0
|
|
97
|
+
? video.maxDurationSec
|
|
98
|
+
: mode === "full" ? 120 : 60;
|
|
99
|
+
const frames = typeof video.frames === "number" && video.frames > 0
|
|
100
|
+
? video.frames
|
|
101
|
+
: mode === "full" ? 12 : 5;
|
|
102
|
+
const intervalSec = typeof video.intervalSec === "number" && video.intervalSec > 0
|
|
103
|
+
? video.intervalSec
|
|
104
|
+
: Math.max(1, Math.round(maxDurationSec / Math.max(frames, 1)));
|
|
105
|
+
const maxFrames = typeof video.maxFrames === "number" && video.maxFrames > 0
|
|
106
|
+
? video.maxFrames
|
|
107
|
+
: mode === "full" ? 30 : frames;
|
|
108
|
+
const includeAudio = video.includeAudio === true;
|
|
89
109
|
return {
|
|
90
110
|
ffmpegPath: video.ffmpegPath?.trim() || "ffmpeg",
|
|
91
111
|
maxBytes: typeof video.maxBytes === "number" && video.maxBytes > 0 ? video.maxBytes : undefined,
|
|
112
|
+
mode,
|
|
113
|
+
frames,
|
|
114
|
+
intervalSec,
|
|
115
|
+
maxDurationSec,
|
|
116
|
+
maxFrames,
|
|
117
|
+
includeAudio,
|
|
92
118
|
};
|
|
93
119
|
}
|
|
94
120
|
|
|
@@ -182,6 +208,39 @@ async function runFfmpegExtractFrame(params: {
|
|
|
182
208
|
});
|
|
183
209
|
}
|
|
184
210
|
|
|
211
|
+
async function runFfmpegExtractFrames(params: {
|
|
212
|
+
ffmpegPath: string;
|
|
213
|
+
videoPath: string;
|
|
214
|
+
outputPattern: string;
|
|
215
|
+
fps: number;
|
|
216
|
+
maxDurationSec: number;
|
|
217
|
+
}): Promise<void> {
|
|
218
|
+
await new Promise<void>((resolve, reject) => {
|
|
219
|
+
const proc = spawn(params.ffmpegPath, [
|
|
220
|
+
"-y",
|
|
221
|
+
"-i",
|
|
222
|
+
params.videoPath,
|
|
223
|
+
"-t",
|
|
224
|
+
String(params.maxDurationSec),
|
|
225
|
+
"-vf",
|
|
226
|
+
`fps=${params.fps}`,
|
|
227
|
+
"-q:v",
|
|
228
|
+
"2",
|
|
229
|
+
params.outputPattern,
|
|
230
|
+
]);
|
|
231
|
+
proc.on("error", reject);
|
|
232
|
+
proc.on("close", (code) => {
|
|
233
|
+
if (code === 0) resolve();
|
|
234
|
+
else reject(new Error(`ffmpeg exited with code ${code ?? "unknown"}`));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function truncateText(text: string, maxChars: number): string {
|
|
240
|
+
if (text.length <= maxChars) return text;
|
|
241
|
+
return `${text.slice(0, maxChars)}…`;
|
|
242
|
+
}
|
|
243
|
+
|
|
185
244
|
export async function summarizeVideoWithVision(params: {
|
|
186
245
|
cfg: ResolvedAutoVideoConfig;
|
|
187
246
|
account: WecomAccountConfig;
|
|
@@ -195,19 +254,61 @@ export async function summarizeVideoWithVision(params: {
|
|
|
195
254
|
const tempDir = await mkdtemp(join(tmpdir(), "openclaw-wecom-frame-"));
|
|
196
255
|
const framePath = join(tempDir, `${basename(params.videoPath)}.jpg`);
|
|
197
256
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
257
|
+
const summaries: string[] = [];
|
|
258
|
+
if (params.cfg.mode === "light") {
|
|
259
|
+
const fps = Math.max(0.05, params.cfg.frames / Math.max(params.cfg.maxDurationSec, 1));
|
|
260
|
+
await runFfmpegExtractFrames({
|
|
261
|
+
ffmpegPath: params.cfg.ffmpegPath,
|
|
262
|
+
videoPath: params.videoPath,
|
|
263
|
+
outputPattern: join(tempDir, "frame-%03d.jpg"),
|
|
264
|
+
fps,
|
|
265
|
+
maxDurationSec: params.cfg.maxDurationSec,
|
|
266
|
+
});
|
|
267
|
+
const frames = (await readdir(tempDir))
|
|
268
|
+
.filter((name) => name.startsWith("frame-") && name.endsWith(".jpg"))
|
|
269
|
+
.sort()
|
|
270
|
+
.slice(0, params.cfg.maxFrames);
|
|
271
|
+
for (const frame of frames) {
|
|
272
|
+
const buffer = await readFile(join(tempDir, frame));
|
|
273
|
+
if (!buffer.length) continue;
|
|
274
|
+
const summary = await describeImageWithVision({
|
|
275
|
+
config: visionConfig,
|
|
276
|
+
buffer,
|
|
277
|
+
mimeType: "image/jpeg",
|
|
278
|
+
});
|
|
279
|
+
if (summary) summaries.push(summary);
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
const fps = Math.max(0.1, 1 / Math.max(params.cfg.intervalSec, 1));
|
|
283
|
+
await runFfmpegExtractFrames({
|
|
284
|
+
ffmpegPath: params.cfg.ffmpegPath,
|
|
285
|
+
videoPath: params.videoPath,
|
|
286
|
+
outputPattern: join(tempDir, "frame-%03d.jpg"),
|
|
287
|
+
fps,
|
|
288
|
+
maxDurationSec: params.cfg.maxDurationSec,
|
|
289
|
+
});
|
|
290
|
+
const frames = (await readdir(tempDir))
|
|
291
|
+
.filter((name) => name.startsWith("frame-") && name.endsWith(".jpg"))
|
|
292
|
+
.sort()
|
|
293
|
+
.slice(0, params.cfg.maxFrames);
|
|
294
|
+
for (const frame of frames) {
|
|
295
|
+
const buffer = await readFile(join(tempDir, frame));
|
|
296
|
+
if (!buffer.length) continue;
|
|
297
|
+
const summary = await describeImageWithVision({
|
|
298
|
+
config: visionConfig,
|
|
299
|
+
buffer,
|
|
300
|
+
mimeType: "image/jpeg",
|
|
301
|
+
});
|
|
302
|
+
if (summary) summaries.push(summary);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (summaries.length === 0) return null;
|
|
307
|
+
const unique = Array.from(new Set(summaries.map((s) => s.trim()).filter(Boolean)));
|
|
308
|
+
const maxChars = 1600;
|
|
309
|
+
const lines = unique.slice(0, params.cfg.maxFrames).map((s, idx) => `${idx + 1}. ${s}`);
|
|
310
|
+
const joined = truncateText(lines.join("\n"), maxChars);
|
|
311
|
+
return `关键帧概述(${unique.length}帧)\n${joined}`;
|
|
211
312
|
} catch {
|
|
212
313
|
return null;
|
|
213
314
|
} finally {
|
package/wecom/src/types.ts
CHANGED
|
@@ -79,6 +79,12 @@ export type WecomAccountConfig = {
|
|
|
79
79
|
enabled?: boolean;
|
|
80
80
|
ffmpegPath?: string;
|
|
81
81
|
maxBytes?: number;
|
|
82
|
+
mode?: "light" | "full";
|
|
83
|
+
frames?: number;
|
|
84
|
+
intervalSec?: number;
|
|
85
|
+
maxDurationSec?: number;
|
|
86
|
+
maxFrames?: number;
|
|
87
|
+
includeAudio?: boolean;
|
|
82
88
|
};
|
|
83
89
|
};
|
|
84
90
|
};
|
package/wecom/src/wecom-app.ts
CHANGED
|
@@ -130,6 +130,139 @@ function sleep(ms: number): Promise<void> {
|
|
|
130
130
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
function resolveSendIntervalMs(target: WecomWebhookTarget): number {
|
|
134
|
+
const interval = target.account.config.sendQueue?.intervalMs;
|
|
135
|
+
return typeof interval === "number" && interval >= 0 ? interval : 400;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractFilenameCandidates(text: string): string[] {
|
|
139
|
+
const candidates = new Set<string>();
|
|
140
|
+
const normalized = text.replace(/[,,;;|]/g, " ");
|
|
141
|
+
const regex = /(?:\/|file:\/\/)?[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}/g;
|
|
142
|
+
for (const match of normalized.matchAll(regex)) {
|
|
143
|
+
const value = match[0];
|
|
144
|
+
if (value) candidates.add(value.replace(/^file:\/\//, ""));
|
|
145
|
+
}
|
|
146
|
+
return Array.from(candidates);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function tryHandleNaturalFileSend(params: {
|
|
150
|
+
target: WecomWebhookTarget;
|
|
151
|
+
text: string;
|
|
152
|
+
fromUser: string;
|
|
153
|
+
chatId?: string;
|
|
154
|
+
isGroup: boolean;
|
|
155
|
+
}): Promise<boolean> {
|
|
156
|
+
const { target, text, fromUser, chatId, isGroup } = params;
|
|
157
|
+
if (!text || text.trim().startsWith("/")) return false;
|
|
158
|
+
if (!/(发给我|发送给我|发我|给我)/.test(text)) return false;
|
|
159
|
+
const names = extractFilenameCandidates(text);
|
|
160
|
+
if (names.length === 0) return false;
|
|
161
|
+
|
|
162
|
+
const tempDir = resolveMediaTempDir(target);
|
|
163
|
+
let dirEntries: string[] = [];
|
|
164
|
+
try {
|
|
165
|
+
dirEntries = await readdir(tempDir);
|
|
166
|
+
} catch {
|
|
167
|
+
dirEntries = [];
|
|
168
|
+
}
|
|
169
|
+
const dirSet = new Set(dirEntries);
|
|
170
|
+
|
|
171
|
+
const resolved: string[] = [];
|
|
172
|
+
const missing: string[] = [];
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
let fullPath = "";
|
|
175
|
+
if (name.startsWith("/")) {
|
|
176
|
+
fullPath = name;
|
|
177
|
+
} else if (dirSet.has(name)) {
|
|
178
|
+
fullPath = join(tempDir, name);
|
|
179
|
+
}
|
|
180
|
+
if (!fullPath) {
|
|
181
|
+
missing.push(name);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const info = await stat(fullPath);
|
|
186
|
+
if (info.isFile()) {
|
|
187
|
+
resolved.push(fullPath);
|
|
188
|
+
} else {
|
|
189
|
+
missing.push(name);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
missing.push(name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (resolved.length === 0) {
|
|
197
|
+
const hint = dirEntries.length ? `可用文件示例:${dirEntries.slice(0, 5).join(", ")}` : "当前目录无可用文件";
|
|
198
|
+
await sendWecomText({
|
|
199
|
+
account: target.account,
|
|
200
|
+
toUser: fromUser,
|
|
201
|
+
chatId: isGroup ? chatId : undefined,
|
|
202
|
+
text: `未找到指定文件:${missing.join(", ")}。\n${hint}`,
|
|
203
|
+
});
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
208
|
+
const intervalMs = resolveSendIntervalMs(target);
|
|
209
|
+
let sent = 0;
|
|
210
|
+
const failed: string[] = [];
|
|
211
|
+
|
|
212
|
+
for (const path of resolved) {
|
|
213
|
+
try {
|
|
214
|
+
const info = await stat(path);
|
|
215
|
+
if (maxBytes && info.size > maxBytes) {
|
|
216
|
+
failed.push(`${basename(path)}(过大)`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const buffer = await readFile(path);
|
|
220
|
+
const filename = basename(path) || "file.bin";
|
|
221
|
+
const mediaId = await uploadWecomMedia({
|
|
222
|
+
account: target.account,
|
|
223
|
+
type: "file",
|
|
224
|
+
buffer,
|
|
225
|
+
filename,
|
|
226
|
+
});
|
|
227
|
+
await sendWecomFile({
|
|
228
|
+
account: target.account,
|
|
229
|
+
toUser: fromUser,
|
|
230
|
+
chatId: isGroup ? chatId : undefined,
|
|
231
|
+
mediaId,
|
|
232
|
+
});
|
|
233
|
+
sent += 1;
|
|
234
|
+
await appendOperationLog(target, {
|
|
235
|
+
action: "natural-sendfile",
|
|
236
|
+
accountId: target.account.accountId,
|
|
237
|
+
toUser: fromUser,
|
|
238
|
+
chatId,
|
|
239
|
+
path,
|
|
240
|
+
size: info.size,
|
|
241
|
+
});
|
|
242
|
+
if (intervalMs) await sleep(intervalMs);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
failed.push(basename(path));
|
|
245
|
+
await appendOperationLog(target, {
|
|
246
|
+
action: "natural-sendfile",
|
|
247
|
+
accountId: target.account.accountId,
|
|
248
|
+
toUser: fromUser,
|
|
249
|
+
chatId,
|
|
250
|
+
path,
|
|
251
|
+
error: String(err),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const summary = `已发送 ${sent} 个文件${failed.length ? `,失败:${failed.join(", ")}` : ""}${missing.length ? `,未找到:${missing.join(", ")}` : ""}`;
|
|
257
|
+
await sendWecomText({
|
|
258
|
+
account: target.account,
|
|
259
|
+
toUser: fromUser,
|
|
260
|
+
chatId: isGroup ? chatId : undefined,
|
|
261
|
+
text: summary,
|
|
262
|
+
});
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
133
266
|
async function appendOperationLog(target: WecomWebhookTarget, entry: Record<string, unknown>): Promise<void> {
|
|
134
267
|
const logPath = target.account.config.operations?.logPath?.trim();
|
|
135
268
|
if (!logPath) return;
|
|
@@ -861,6 +994,17 @@ async function processAppMessage(params: {
|
|
|
861
994
|
if (handled) return;
|
|
862
995
|
}
|
|
863
996
|
|
|
997
|
+
if (msgType === "text") {
|
|
998
|
+
const handled = await tryHandleNaturalFileSend({
|
|
999
|
+
target,
|
|
1000
|
+
text: messageText,
|
|
1001
|
+
fromUser,
|
|
1002
|
+
chatId,
|
|
1003
|
+
isGroup,
|
|
1004
|
+
});
|
|
1005
|
+
if (handled) return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
864
1008
|
try {
|
|
865
1009
|
await startAgentForApp({
|
|
866
1010
|
target,
|