gewe-openclaw 2026.1.29
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/LICENSE +21 -0
- package/README.md +56 -0
- package/assets/gewe-rs_logo.jpeg +0 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +39 -0
- package/src/accounts.ts +164 -0
- package/src/api.ts +53 -0
- package/src/channel.ts +465 -0
- package/src/config-schema.ts +105 -0
- package/src/delivery.ts +837 -0
- package/src/download-queue.ts +74 -0
- package/src/download.ts +84 -0
- package/src/inbound.ts +660 -0
- package/src/media-server.ts +154 -0
- package/src/monitor.ts +351 -0
- package/src/normalize.ts +19 -0
- package/src/policy.ts +185 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +171 -0
- package/src/types.ts +137 -0
- package/src/xml.ts +59 -0
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import type { OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
|
|
7
|
+
import { extractOriginalFilename, extensionForMime } from "openclaw/plugin-sdk";
|
|
8
|
+
import { getGeweRuntime } from "./runtime.js";
|
|
9
|
+
import {
|
|
10
|
+
sendFileGewe,
|
|
11
|
+
sendImageGewe,
|
|
12
|
+
sendLinkGewe,
|
|
13
|
+
sendTextGewe,
|
|
14
|
+
sendVideoGewe,
|
|
15
|
+
sendVoiceGewe,
|
|
16
|
+
} from "./send.js";
|
|
17
|
+
import type { GeweSendResult, ResolvedGeweAccount } from "./types.js";
|
|
18
|
+
|
|
19
|
+
type GeweChannelData = {
|
|
20
|
+
ats?: string;
|
|
21
|
+
link?: {
|
|
22
|
+
title: string;
|
|
23
|
+
desc: string;
|
|
24
|
+
linkUrl: string;
|
|
25
|
+
thumbUrl?: string;
|
|
26
|
+
};
|
|
27
|
+
video?: {
|
|
28
|
+
thumbUrl: string;
|
|
29
|
+
videoDuration: number;
|
|
30
|
+
};
|
|
31
|
+
voiceDuration?: number;
|
|
32
|
+
voiceDurationMs?: number;
|
|
33
|
+
fileName?: string;
|
|
34
|
+
forceFile?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type ResolvedMedia = {
|
|
38
|
+
publicUrl: string;
|
|
39
|
+
contentType?: string;
|
|
40
|
+
fileName?: string;
|
|
41
|
+
localPath?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const LINK_THUMB_MAX_BYTES = 50 * 1024;
|
|
45
|
+
const LINK_THUMB_FETCH_MAX_BYTES = 2 * 1024 * 1024;
|
|
46
|
+
const LINK_THUMB_MAX_SIDE = 320;
|
|
47
|
+
const LINK_THUMB_QUALITY_STEPS = [80, 70, 60, 50, 40];
|
|
48
|
+
const DEFAULT_VOICE_SAMPLE_RATE = 24000;
|
|
49
|
+
const DEFAULT_VOICE_FFMPEG = "ffmpeg";
|
|
50
|
+
const DEFAULT_VOICE_SILK = "silk-encoder";
|
|
51
|
+
const DEFAULT_VOICE_TIMEOUT_MS = 30_000;
|
|
52
|
+
const DEFAULT_VIDEO_FFMPEG = "ffmpeg";
|
|
53
|
+
const DEFAULT_VIDEO_FFPROBE = "ffprobe";
|
|
54
|
+
const DEFAULT_VIDEO_TIMEOUT_MS = 30_000;
|
|
55
|
+
const DEFAULT_VIDEO_THUMB_SECONDS = 0.5;
|
|
56
|
+
const PCM_BYTES_PER_SAMPLE = 2;
|
|
57
|
+
const DEFAULT_LINK_THUMB_PATH = fileURLToPath(
|
|
58
|
+
new URL("../assets/gewe-rs_logo.jpeg", import.meta.url),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
function looksLikeHttpUrl(value: string): boolean {
|
|
62
|
+
return /^https?:\/\//i.test(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isFileUrl(value: string): boolean {
|
|
66
|
+
return /^file:\/\//i.test(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeFileUrl(value: string): string {
|
|
70
|
+
if (!isFileUrl(value)) return value;
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(value);
|
|
73
|
+
return url.pathname ? decodeURIComponent(url.pathname) : value;
|
|
74
|
+
} catch {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function looksLikeTtsVoiceMediaUrl(value: string): boolean {
|
|
80
|
+
if (!value || looksLikeHttpUrl(value)) return false;
|
|
81
|
+
const localPath = normalizeFileUrl(value);
|
|
82
|
+
const base = path.basename(localPath).toLowerCase();
|
|
83
|
+
const parent = path.basename(path.dirname(localPath)).toLowerCase();
|
|
84
|
+
if (!/^voice-\d+/.test(base)) return false;
|
|
85
|
+
return parent.startsWith("tts-");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildPublicUrl(baseUrl: string, id: string): string {
|
|
89
|
+
const trimmed = baseUrl.replace(/\/$/, "");
|
|
90
|
+
return `${trimmed}/${encodeURIComponent(id)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveMediaMaxBytes(account: ResolvedGeweAccount): number {
|
|
94
|
+
const maxMb = account.config.mediaMaxMb;
|
|
95
|
+
if (typeof maxMb === "number" && maxMb > 0) return Math.floor(maxMb * 1024 * 1024);
|
|
96
|
+
return 20 * 1024 * 1024;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveGeweData(payload: ReplyPayload): GeweChannelData | undefined {
|
|
100
|
+
const data = payload.channelData as { gewe?: GeweChannelData } | undefined;
|
|
101
|
+
return data?.gewe;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isSilkAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
|
105
|
+
if (opts.contentType?.toLowerCase().includes("silk")) return true;
|
|
106
|
+
return opts.fileName?.toLowerCase().endsWith(".silk") ?? false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveVoiceDurationMs(geweData?: GeweChannelData): number | undefined {
|
|
110
|
+
const ms =
|
|
111
|
+
typeof geweData?.voiceDurationMs === "number"
|
|
112
|
+
? geweData.voiceDurationMs
|
|
113
|
+
: typeof geweData?.voiceDuration === "number"
|
|
114
|
+
? geweData.voiceDuration
|
|
115
|
+
: undefined;
|
|
116
|
+
if (!ms || ms <= 0) return undefined;
|
|
117
|
+
return Math.floor(ms);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveVoiceSampleRate(account: ResolvedGeweAccount): number {
|
|
121
|
+
const rate = account.config.voiceSampleRate;
|
|
122
|
+
if (typeof rate === "number" && rate > 0) return Math.floor(rate);
|
|
123
|
+
return DEFAULT_VOICE_SAMPLE_RATE;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveVideoFfmpegPath(account: ResolvedGeweAccount): string {
|
|
127
|
+
return (
|
|
128
|
+
account.config.videoFfmpegPath?.trim() ||
|
|
129
|
+
account.config.voiceFfmpegPath?.trim() ||
|
|
130
|
+
DEFAULT_VIDEO_FFMPEG
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveVideoFfprobePath(account: ResolvedGeweAccount, ffmpegPath: string): string {
|
|
135
|
+
const configured = account.config.videoFfprobePath?.trim();
|
|
136
|
+
if (configured) return configured;
|
|
137
|
+
if (ffmpegPath.endsWith("ffmpeg")) {
|
|
138
|
+
return `${ffmpegPath.slice(0, -"ffmpeg".length)}ffprobe`;
|
|
139
|
+
}
|
|
140
|
+
return DEFAULT_VIDEO_FFPROBE;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function probeVideoDurationSeconds(params: {
|
|
144
|
+
account: ResolvedGeweAccount;
|
|
145
|
+
sourcePath: string;
|
|
146
|
+
}): Promise<number | null> {
|
|
147
|
+
const core = getGeweRuntime();
|
|
148
|
+
const logger = core.logging.getChildLogger({ channel: "gewe", module: "video" });
|
|
149
|
+
const ffmpegPath = resolveVideoFfmpegPath(params.account);
|
|
150
|
+
const ffprobePath = resolveVideoFfprobePath(params.account, ffmpegPath);
|
|
151
|
+
const args = [
|
|
152
|
+
"-v",
|
|
153
|
+
"error",
|
|
154
|
+
"-show_entries",
|
|
155
|
+
"format=duration",
|
|
156
|
+
"-of",
|
|
157
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
158
|
+
params.sourcePath,
|
|
159
|
+
];
|
|
160
|
+
const result = await core.system.runCommandWithTimeout([ffprobePath, ...args], {
|
|
161
|
+
timeoutMs: DEFAULT_VIDEO_TIMEOUT_MS,
|
|
162
|
+
});
|
|
163
|
+
if (result.code !== 0) {
|
|
164
|
+
logger.warn?.(
|
|
165
|
+
`gewe video probe failed: ${result.stderr.trim() || `exit code ${result.code ?? "?"}`}`,
|
|
166
|
+
);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const raw = result.stdout.trim();
|
|
170
|
+
const seconds = Number.parseFloat(raw);
|
|
171
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
172
|
+
logger.warn?.(`gewe video probe returned invalid duration: "${raw}"`);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return Math.max(1, Math.round(seconds));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function generateVideoThumbBuffer(params: {
|
|
179
|
+
account: ResolvedGeweAccount;
|
|
180
|
+
sourcePath: string;
|
|
181
|
+
}): Promise<Buffer | null> {
|
|
182
|
+
const core = getGeweRuntime();
|
|
183
|
+
const logger = core.logging.getChildLogger({ channel: "gewe", module: "video" });
|
|
184
|
+
const ffmpegPath = resolveVideoFfmpegPath(params.account);
|
|
185
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-gewe-video-"));
|
|
186
|
+
const thumbPath = path.join(tmpDir, "thumb.png");
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const args = [
|
|
190
|
+
"-y",
|
|
191
|
+
"-ss",
|
|
192
|
+
String(DEFAULT_VIDEO_THUMB_SECONDS),
|
|
193
|
+
"-i",
|
|
194
|
+
params.sourcePath,
|
|
195
|
+
"-frames:v",
|
|
196
|
+
"1",
|
|
197
|
+
"-vf",
|
|
198
|
+
`scale=${LINK_THUMB_MAX_SIDE}:-1:force_original_aspect_ratio=decrease`,
|
|
199
|
+
thumbPath,
|
|
200
|
+
];
|
|
201
|
+
const result = await core.system.runCommandWithTimeout([ffmpegPath, ...args], {
|
|
202
|
+
timeoutMs: DEFAULT_VIDEO_TIMEOUT_MS,
|
|
203
|
+
});
|
|
204
|
+
if (result.code !== 0) {
|
|
205
|
+
logger.warn?.(
|
|
206
|
+
`gewe video thumb failed: ${result.stderr.trim() || `exit code ${result.code ?? "?"}`}`,
|
|
207
|
+
);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const buffer = await fs.readFile(thumbPath);
|
|
211
|
+
if (!buffer.length) {
|
|
212
|
+
logger.warn?.("gewe video thumb generated empty output");
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return buffer;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logger.warn?.(`gewe video thumb failed: ${String(err)}`);
|
|
218
|
+
return null;
|
|
219
|
+
} finally {
|
|
220
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resolveSilkArgs(params: {
|
|
225
|
+
template: string[];
|
|
226
|
+
input: string;
|
|
227
|
+
output: string;
|
|
228
|
+
sampleRate: number;
|
|
229
|
+
}): string[] {
|
|
230
|
+
const { template, input, output, sampleRate } = params;
|
|
231
|
+
const mapped = template.map((entry) =>
|
|
232
|
+
entry
|
|
233
|
+
.replace(/\{input\}/g, input)
|
|
234
|
+
.replace(/\{output\}/g, output)
|
|
235
|
+
.replace(/\{sampleRate\}/g, String(sampleRate)),
|
|
236
|
+
);
|
|
237
|
+
const hasInput = template.some((entry) => entry.includes("{input}"));
|
|
238
|
+
const hasOutput = template.some((entry) => entry.includes("{output}"));
|
|
239
|
+
const next = [...mapped];
|
|
240
|
+
if (!hasInput) next.unshift(input);
|
|
241
|
+
if (!hasOutput) next.push(output);
|
|
242
|
+
return next;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function convertAudioToSilk(params: {
|
|
246
|
+
account: ResolvedGeweAccount;
|
|
247
|
+
sourcePath: string;
|
|
248
|
+
}): Promise<{ buffer: Buffer; durationMs: number } | null> {
|
|
249
|
+
const core = getGeweRuntime();
|
|
250
|
+
const logger = core.logging.getChildLogger({ channel: "gewe", module: "voice" });
|
|
251
|
+
if (!params.account.config.voiceAutoConvert) return null;
|
|
252
|
+
|
|
253
|
+
const sampleRate = resolveVoiceSampleRate(params.account);
|
|
254
|
+
const ffmpegPath = params.account.config.voiceFfmpegPath?.trim() || DEFAULT_VOICE_FFMPEG;
|
|
255
|
+
const silkPath = params.account.config.voiceSilkPath?.trim() || DEFAULT_VOICE_SILK;
|
|
256
|
+
const customArgs =
|
|
257
|
+
params.account.config.voiceSilkArgs?.length ? [params.account.config.voiceSilkArgs] : [];
|
|
258
|
+
const fallbackArgs = [
|
|
259
|
+
["-i", "{input}", "-o", "{output}", "-rate", "{sampleRate}"],
|
|
260
|
+
["{input}", "{output}", "-rate", "{sampleRate}"],
|
|
261
|
+
["{input}", "{output}", "{sampleRate}"],
|
|
262
|
+
["{input}", "{output}"],
|
|
263
|
+
];
|
|
264
|
+
const argTemplates = customArgs.length ? customArgs : fallbackArgs;
|
|
265
|
+
|
|
266
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-gewe-voice-"));
|
|
267
|
+
const pcmPath = path.join(tmpDir, "voice.pcm");
|
|
268
|
+
const silkOutPath = path.join(tmpDir, "voice.silk");
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const ffmpegArgs = [
|
|
272
|
+
"-y",
|
|
273
|
+
"-i",
|
|
274
|
+
params.sourcePath,
|
|
275
|
+
"-ac",
|
|
276
|
+
"1",
|
|
277
|
+
"-ar",
|
|
278
|
+
String(sampleRate),
|
|
279
|
+
"-f",
|
|
280
|
+
"s16le",
|
|
281
|
+
pcmPath,
|
|
282
|
+
];
|
|
283
|
+
const ffmpegResult = await core.system.runCommandWithTimeout(
|
|
284
|
+
[ffmpegPath, ...ffmpegArgs],
|
|
285
|
+
{ timeoutMs: DEFAULT_VOICE_TIMEOUT_MS },
|
|
286
|
+
);
|
|
287
|
+
if (ffmpegResult.code !== 0) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`ffmpeg failed: code=${ffmpegResult.code ?? "?"} stderr=${ffmpegResult.stderr.trim()}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let pcmStat = await fs.stat(pcmPath);
|
|
294
|
+
const frameSamples = sampleRate % 50 === 0 ? sampleRate / 50 : 0; // 20ms frames
|
|
295
|
+
const frameBytes = frameSamples > 0 ? frameSamples * PCM_BYTES_PER_SAMPLE : 0;
|
|
296
|
+
if (frameBytes > 0 && pcmStat.size % frameBytes !== 0) {
|
|
297
|
+
const trimmedSize = pcmStat.size - (pcmStat.size % frameBytes);
|
|
298
|
+
if (trimmedSize <= 0) {
|
|
299
|
+
throw new Error("ffmpeg produced empty PCM after frame trim");
|
|
300
|
+
}
|
|
301
|
+
await fs.truncate(pcmPath, trimmedSize);
|
|
302
|
+
pcmStat = await fs.stat(pcmPath);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const durationMs = Math.max(
|
|
306
|
+
1,
|
|
307
|
+
Math.round((pcmStat.size / (sampleRate * PCM_BYTES_PER_SAMPLE)) * 1000),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
let encoded = false;
|
|
311
|
+
let lastError: string | null = null;
|
|
312
|
+
for (const template of argTemplates) {
|
|
313
|
+
const args = resolveSilkArgs({
|
|
314
|
+
template,
|
|
315
|
+
input: pcmPath,
|
|
316
|
+
output: silkOutPath,
|
|
317
|
+
sampleRate,
|
|
318
|
+
});
|
|
319
|
+
const result = await core.system.runCommandWithTimeout([silkPath, ...args], {
|
|
320
|
+
timeoutMs: DEFAULT_VOICE_TIMEOUT_MS,
|
|
321
|
+
});
|
|
322
|
+
if (result.code === 0) {
|
|
323
|
+
const outStat = await fs.stat(silkOutPath).catch(() => null);
|
|
324
|
+
if (outStat?.isFile()) {
|
|
325
|
+
encoded = true;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lastError = result.stderr.trim() || `exit code ${result.code ?? "?"}`;
|
|
330
|
+
}
|
|
331
|
+
if (!encoded) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`silk encoder failed (${silkPath}): ${lastError ?? "unknown error"}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const buffer = await fs.readFile(silkOutPath);
|
|
338
|
+
if (!buffer.length) {
|
|
339
|
+
throw new Error("silk encoder produced empty output");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { buffer, durationMs };
|
|
343
|
+
} catch (err) {
|
|
344
|
+
logger.warn?.(`gewe voice convert failed: ${String(err)}`);
|
|
345
|
+
return null;
|
|
346
|
+
} finally {
|
|
347
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function normalizeThumbBuffer(params: {
|
|
352
|
+
buffer: Buffer;
|
|
353
|
+
contentType?: string;
|
|
354
|
+
}): Promise<{ buffer: Buffer; contentType: string }> {
|
|
355
|
+
const core = getGeweRuntime();
|
|
356
|
+
const contentType = params.contentType?.split(";")[0]?.trim();
|
|
357
|
+
if (
|
|
358
|
+
params.buffer.byteLength <= LINK_THUMB_MAX_BYTES &&
|
|
359
|
+
contentType &&
|
|
360
|
+
contentType.startsWith("image/")
|
|
361
|
+
) {
|
|
362
|
+
return { buffer: params.buffer, contentType };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let working = params.buffer;
|
|
366
|
+
for (const maxSide of [LINK_THUMB_MAX_SIDE, 240, 200, 160]) {
|
|
367
|
+
for (const quality of LINK_THUMB_QUALITY_STEPS) {
|
|
368
|
+
const resized = await core.media.resizeToJpeg({
|
|
369
|
+
buffer: working,
|
|
370
|
+
maxSide,
|
|
371
|
+
quality,
|
|
372
|
+
withoutEnlargement: true,
|
|
373
|
+
});
|
|
374
|
+
if (resized.byteLength <= LINK_THUMB_MAX_BYTES) {
|
|
375
|
+
return { buffer: resized, contentType: "image/jpeg" };
|
|
376
|
+
}
|
|
377
|
+
working = resized;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { buffer: working, contentType: "image/jpeg" };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function loadThumbSource(params: {
|
|
385
|
+
url: string;
|
|
386
|
+
}): Promise<{ buffer: Buffer; contentType?: string; fileName?: string }> {
|
|
387
|
+
const core = getGeweRuntime();
|
|
388
|
+
if (looksLikeHttpUrl(params.url)) {
|
|
389
|
+
return await core.channel.media.fetchRemoteMedia({
|
|
390
|
+
url: params.url,
|
|
391
|
+
maxBytes: LINK_THUMB_FETCH_MAX_BYTES,
|
|
392
|
+
filePathHint: params.url,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const localPath = normalizeFileUrl(params.url);
|
|
397
|
+
const stat = await fs.stat(localPath);
|
|
398
|
+
if (!stat.isFile()) {
|
|
399
|
+
throw new Error("thumbUrl is not a file");
|
|
400
|
+
}
|
|
401
|
+
if (stat.size > LINK_THUMB_FETCH_MAX_BYTES) {
|
|
402
|
+
throw new Error("thumbUrl exceeds 2MB limit");
|
|
403
|
+
}
|
|
404
|
+
const buffer = await fs.readFile(localPath);
|
|
405
|
+
const contentType = await core.media.detectMime({ buffer, filePath: localPath });
|
|
406
|
+
return { buffer, contentType, fileName: path.basename(localPath) };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function stageThumbBuffer(params: {
|
|
410
|
+
account: ResolvedGeweAccount;
|
|
411
|
+
buffer: Buffer;
|
|
412
|
+
contentType?: string;
|
|
413
|
+
fileName?: string;
|
|
414
|
+
}): Promise<string> {
|
|
415
|
+
const core = getGeweRuntime();
|
|
416
|
+
const publicBase = params.account.config.mediaPublicUrl?.trim();
|
|
417
|
+
if (!publicBase) {
|
|
418
|
+
throw new Error("mediaPublicUrl not configured (required for link thumbnails)");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const normalized = await normalizeThumbBuffer({
|
|
422
|
+
buffer: params.buffer,
|
|
423
|
+
contentType: params.contentType,
|
|
424
|
+
});
|
|
425
|
+
if (normalized.buffer.byteLength > LINK_THUMB_MAX_BYTES) {
|
|
426
|
+
throw new Error("link thumbnail exceeds 50KB after resize");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
430
|
+
normalized.buffer,
|
|
431
|
+
normalized.contentType,
|
|
432
|
+
"outbound",
|
|
433
|
+
LINK_THUMB_MAX_BYTES,
|
|
434
|
+
params.fileName,
|
|
435
|
+
);
|
|
436
|
+
return buildPublicUrl(publicBase, saved.id);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function resolveLinkThumbUrl(params: {
|
|
440
|
+
account: ResolvedGeweAccount;
|
|
441
|
+
thumbUrl?: string;
|
|
442
|
+
}): Promise<string> {
|
|
443
|
+
const core = getGeweRuntime();
|
|
444
|
+
const logger = core.logging.getChildLogger({ channel: "gewe", module: "thumb" });
|
|
445
|
+
const fallbackBuffer = await fs.readFile(DEFAULT_LINK_THUMB_PATH);
|
|
446
|
+
const fallbackUrl = await stageThumbBuffer({
|
|
447
|
+
account: params.account,
|
|
448
|
+
buffer: fallbackBuffer,
|
|
449
|
+
contentType: "image/jpeg",
|
|
450
|
+
fileName: "gewe-thumb.jpeg",
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const raw = params.thumbUrl?.trim();
|
|
454
|
+
if (!raw) return fallbackUrl;
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const source = await loadThumbSource({ url: raw });
|
|
458
|
+
const normalized = await normalizeThumbBuffer({
|
|
459
|
+
buffer: source.buffer,
|
|
460
|
+
contentType: source.contentType,
|
|
461
|
+
});
|
|
462
|
+
if (normalized.buffer.byteLength > LINK_THUMB_MAX_BYTES) {
|
|
463
|
+
return fallbackUrl;
|
|
464
|
+
}
|
|
465
|
+
return await stageThumbBuffer({
|
|
466
|
+
account: params.account,
|
|
467
|
+
buffer: normalized.buffer,
|
|
468
|
+
contentType: normalized.contentType,
|
|
469
|
+
fileName: source.fileName ?? "gewe-thumb.jpeg",
|
|
470
|
+
});
|
|
471
|
+
} catch (err) {
|
|
472
|
+
logger.warn?.(`gewe link thumb fallback: ${String(err)}`);
|
|
473
|
+
return fallbackUrl;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function stageMedia(params: {
|
|
478
|
+
account: ResolvedGeweAccount;
|
|
479
|
+
cfg: OpenClawConfig;
|
|
480
|
+
mediaUrl: string;
|
|
481
|
+
allowRemote: boolean;
|
|
482
|
+
}): Promise<ResolvedMedia> {
|
|
483
|
+
const core = getGeweRuntime();
|
|
484
|
+
const rawUrl = params.mediaUrl.trim();
|
|
485
|
+
if (!rawUrl) throw new Error("mediaUrl is empty");
|
|
486
|
+
|
|
487
|
+
if (looksLikeHttpUrl(rawUrl) && params.allowRemote) {
|
|
488
|
+
const contentType = await core.media.detectMime({ filePath: rawUrl });
|
|
489
|
+
const fileName = path.basename(new URL(rawUrl).pathname || "");
|
|
490
|
+
return { publicUrl: rawUrl, contentType: contentType ?? undefined, fileName };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const publicBase = params.account.config.mediaPublicUrl?.trim();
|
|
494
|
+
if (!publicBase) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
"mediaPublicUrl not configured (required for local media or forced proxy)",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const maxBytes = resolveMediaMaxBytes(params.account);
|
|
501
|
+
let buffer: Buffer;
|
|
502
|
+
let contentType: string | undefined;
|
|
503
|
+
let fileName: string | undefined;
|
|
504
|
+
|
|
505
|
+
if (looksLikeHttpUrl(rawUrl)) {
|
|
506
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
507
|
+
url: rawUrl,
|
|
508
|
+
maxBytes,
|
|
509
|
+
filePathHint: rawUrl,
|
|
510
|
+
});
|
|
511
|
+
buffer = fetched.buffer;
|
|
512
|
+
contentType = fetched.contentType ?? undefined;
|
|
513
|
+
fileName = fetched.fileName;
|
|
514
|
+
} else {
|
|
515
|
+
const localPath = normalizeFileUrl(rawUrl);
|
|
516
|
+
buffer = await fs.readFile(localPath);
|
|
517
|
+
contentType = await core.media.detectMime({ buffer, filePath: localPath });
|
|
518
|
+
fileName = path.basename(localPath);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
522
|
+
buffer,
|
|
523
|
+
contentType,
|
|
524
|
+
"outbound",
|
|
525
|
+
maxBytes,
|
|
526
|
+
fileName,
|
|
527
|
+
);
|
|
528
|
+
const resolvedFileName = fileName || extractOriginalFilename(saved.path);
|
|
529
|
+
let resolvedId = saved.id;
|
|
530
|
+
let resolvedPath = saved.path;
|
|
531
|
+
const desiredExt =
|
|
532
|
+
extensionForMime(contentType ?? saved.contentType) ||
|
|
533
|
+
path.extname(resolvedFileName);
|
|
534
|
+
if (desiredExt && !path.extname(resolvedId)) {
|
|
535
|
+
const nextId = `${resolvedId}${desiredExt}`;
|
|
536
|
+
const nextPath = path.join(path.dirname(saved.path), nextId);
|
|
537
|
+
await fs.rename(saved.path, nextPath).catch(() => {});
|
|
538
|
+
resolvedId = nextId;
|
|
539
|
+
resolvedPath = nextPath;
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
publicUrl: buildPublicUrl(publicBase, resolvedId),
|
|
543
|
+
contentType: contentType ?? saved.contentType,
|
|
544
|
+
fileName: resolvedFileName || resolvedId,
|
|
545
|
+
localPath: resolvedPath,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function resolvePublicUrl(params: {
|
|
550
|
+
account: ResolvedGeweAccount;
|
|
551
|
+
cfg: OpenClawConfig;
|
|
552
|
+
url: string;
|
|
553
|
+
allowRemote: boolean;
|
|
554
|
+
}): Promise<string> {
|
|
555
|
+
const staged = await stageMedia({
|
|
556
|
+
account: params.account,
|
|
557
|
+
cfg: params.cfg,
|
|
558
|
+
mediaUrl: params.url,
|
|
559
|
+
allowRemote: params.allowRemote,
|
|
560
|
+
});
|
|
561
|
+
return staged.publicUrl;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function deliverGewePayload(params: {
|
|
565
|
+
payload: ReplyPayload;
|
|
566
|
+
account: ResolvedGeweAccount;
|
|
567
|
+
cfg: OpenClawConfig;
|
|
568
|
+
toWxid: string;
|
|
569
|
+
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
|
570
|
+
}): Promise<GeweSendResult | null> {
|
|
571
|
+
const { payload, account, cfg, toWxid, statusSink } = params;
|
|
572
|
+
const core = getGeweRuntime();
|
|
573
|
+
const geweData = resolveGeweData(payload);
|
|
574
|
+
|
|
575
|
+
const trimmedText = payload.text?.trim() ?? "";
|
|
576
|
+
const mediaUrl =
|
|
577
|
+
payload.mediaUrl?.trim() || payload.mediaUrls?.[0]?.trim() || "";
|
|
578
|
+
|
|
579
|
+
if (geweData?.link) {
|
|
580
|
+
const link = geweData.link;
|
|
581
|
+
const thumbUrl = await resolveLinkThumbUrl({
|
|
582
|
+
account,
|
|
583
|
+
thumbUrl: link.thumbUrl,
|
|
584
|
+
});
|
|
585
|
+
const result = await sendLinkGewe({
|
|
586
|
+
account,
|
|
587
|
+
toWxid,
|
|
588
|
+
title: link.title,
|
|
589
|
+
desc: link.desc,
|
|
590
|
+
linkUrl: link.linkUrl,
|
|
591
|
+
thumbUrl,
|
|
592
|
+
});
|
|
593
|
+
core.channel.activity.record({
|
|
594
|
+
channel: "gewe",
|
|
595
|
+
accountId: account.accountId,
|
|
596
|
+
direction: "outbound",
|
|
597
|
+
});
|
|
598
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
599
|
+
return result;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (mediaUrl) {
|
|
603
|
+
const audioAsVoice = payload.audioAsVoice === true;
|
|
604
|
+
const forceFile = geweData?.forceFile === true;
|
|
605
|
+
const ttsVoiceHint = !forceFile && looksLikeTtsVoiceMediaUrl(mediaUrl);
|
|
606
|
+
const wantsVoice = !forceFile && (audioAsVoice || ttsVoiceHint);
|
|
607
|
+
const staged = await stageMedia({
|
|
608
|
+
account,
|
|
609
|
+
cfg,
|
|
610
|
+
mediaUrl,
|
|
611
|
+
allowRemote: !wantsVoice,
|
|
612
|
+
});
|
|
613
|
+
const contentType = staged.contentType;
|
|
614
|
+
const fileName = staged.fileName;
|
|
615
|
+
const kind = core.media.mediaKindFromMime(contentType);
|
|
616
|
+
|
|
617
|
+
if (wantsVoice && kind === "audio") {
|
|
618
|
+
const declaredDuration = resolveVoiceDurationMs(geweData);
|
|
619
|
+
if (isSilkAudio({ contentType, fileName })) {
|
|
620
|
+
if (declaredDuration) {
|
|
621
|
+
const result = await sendVoiceGewe({
|
|
622
|
+
account,
|
|
623
|
+
toWxid,
|
|
624
|
+
voiceUrl: staged.publicUrl,
|
|
625
|
+
voiceDuration: declaredDuration,
|
|
626
|
+
});
|
|
627
|
+
core.channel.activity.record({
|
|
628
|
+
channel: "gewe",
|
|
629
|
+
accountId: account.accountId,
|
|
630
|
+
direction: "outbound",
|
|
631
|
+
});
|
|
632
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
633
|
+
return result;
|
|
634
|
+
}
|
|
635
|
+
} else if (staged.localPath) {
|
|
636
|
+
const converted = await convertAudioToSilk({
|
|
637
|
+
account,
|
|
638
|
+
sourcePath: staged.localPath,
|
|
639
|
+
});
|
|
640
|
+
if (converted) {
|
|
641
|
+
const voiceDuration = declaredDuration ?? converted.durationMs;
|
|
642
|
+
const publicBase = account.config.mediaPublicUrl?.trim();
|
|
643
|
+
if (!publicBase) {
|
|
644
|
+
throw new Error("mediaPublicUrl not configured (required for silk voice)");
|
|
645
|
+
}
|
|
646
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
647
|
+
converted.buffer,
|
|
648
|
+
"audio/silk",
|
|
649
|
+
"outbound",
|
|
650
|
+
resolveMediaMaxBytes(account),
|
|
651
|
+
"voice.silk",
|
|
652
|
+
);
|
|
653
|
+
const result = await sendVoiceGewe({
|
|
654
|
+
account,
|
|
655
|
+
toWxid,
|
|
656
|
+
voiceUrl: buildPublicUrl(publicBase, saved.id),
|
|
657
|
+
voiceDuration,
|
|
658
|
+
});
|
|
659
|
+
core.channel.activity.record({
|
|
660
|
+
channel: "gewe",
|
|
661
|
+
accountId: account.accountId,
|
|
662
|
+
direction: "outbound",
|
|
663
|
+
});
|
|
664
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!forceFile && kind === "image") {
|
|
671
|
+
const result = await sendImageGewe({
|
|
672
|
+
account,
|
|
673
|
+
toWxid,
|
|
674
|
+
imgUrl: staged.publicUrl,
|
|
675
|
+
});
|
|
676
|
+
core.channel.activity.record({
|
|
677
|
+
channel: "gewe",
|
|
678
|
+
accountId: account.accountId,
|
|
679
|
+
direction: "outbound",
|
|
680
|
+
});
|
|
681
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
682
|
+
return result;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (!forceFile && kind === "video") {
|
|
686
|
+
const logger = core.logging.getChildLogger({ channel: "gewe", module: "video" });
|
|
687
|
+
const video = geweData?.video;
|
|
688
|
+
let thumbUrl = video?.thumbUrl;
|
|
689
|
+
const fallbackThumbUrl = account.config.videoThumbUrl?.trim() || undefined;
|
|
690
|
+
let videoDuration =
|
|
691
|
+
typeof video?.videoDuration === "number" ? Math.floor(video.videoDuration) : undefined;
|
|
692
|
+
let stagedVideo = staged;
|
|
693
|
+
|
|
694
|
+
if ((!thumbUrl || typeof videoDuration !== "number") && !stagedVideo.localPath) {
|
|
695
|
+
try {
|
|
696
|
+
stagedVideo = await stageMedia({
|
|
697
|
+
account,
|
|
698
|
+
cfg,
|
|
699
|
+
mediaUrl,
|
|
700
|
+
allowRemote: false,
|
|
701
|
+
});
|
|
702
|
+
} catch {
|
|
703
|
+
// ignore; we'll fall back to file send below
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (typeof videoDuration !== "number" && stagedVideo.localPath) {
|
|
708
|
+
const probed = await probeVideoDurationSeconds({
|
|
709
|
+
account,
|
|
710
|
+
sourcePath: stagedVideo.localPath,
|
|
711
|
+
});
|
|
712
|
+
if (typeof probed === "number") {
|
|
713
|
+
videoDuration = probed;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!thumbUrl && stagedVideo.localPath) {
|
|
718
|
+
const buffer = await generateVideoThumbBuffer({
|
|
719
|
+
account,
|
|
720
|
+
sourcePath: stagedVideo.localPath,
|
|
721
|
+
});
|
|
722
|
+
if (buffer) {
|
|
723
|
+
const normalized = await normalizeThumbBuffer({
|
|
724
|
+
buffer,
|
|
725
|
+
contentType: "image/png",
|
|
726
|
+
});
|
|
727
|
+
if (normalized.buffer.byteLength <= LINK_THUMB_MAX_BYTES) {
|
|
728
|
+
thumbUrl = await stageThumbBuffer({
|
|
729
|
+
account,
|
|
730
|
+
buffer: normalized.buffer,
|
|
731
|
+
contentType: normalized.contentType,
|
|
732
|
+
fileName: "gewe-video-thumb.png",
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!thumbUrl && fallbackThumbUrl) {
|
|
739
|
+
thumbUrl = fallbackThumbUrl;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (thumbUrl && typeof videoDuration === "number") {
|
|
743
|
+
const thumbPublicUrl = await resolvePublicUrl({
|
|
744
|
+
account,
|
|
745
|
+
cfg,
|
|
746
|
+
url: thumbUrl,
|
|
747
|
+
allowRemote: true,
|
|
748
|
+
});
|
|
749
|
+
try {
|
|
750
|
+
const result = await sendVideoGewe({
|
|
751
|
+
account,
|
|
752
|
+
toWxid,
|
|
753
|
+
videoUrl: stagedVideo.publicUrl,
|
|
754
|
+
thumbUrl: thumbPublicUrl,
|
|
755
|
+
videoDuration: Math.floor(videoDuration),
|
|
756
|
+
});
|
|
757
|
+
core.channel.activity.record({
|
|
758
|
+
channel: "gewe",
|
|
759
|
+
accountId: account.accountId,
|
|
760
|
+
direction: "outbound",
|
|
761
|
+
});
|
|
762
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
763
|
+
return result;
|
|
764
|
+
} catch (err) {
|
|
765
|
+
if (fallbackThumbUrl && fallbackThumbUrl !== thumbUrl) {
|
|
766
|
+
logger.warn?.(
|
|
767
|
+
`gewe video send failed with primary thumb, retrying fallback: ${String(err)}`,
|
|
768
|
+
);
|
|
769
|
+
const fallbackPublicUrl = await resolvePublicUrl({
|
|
770
|
+
account,
|
|
771
|
+
cfg,
|
|
772
|
+
url: fallbackThumbUrl,
|
|
773
|
+
allowRemote: true,
|
|
774
|
+
});
|
|
775
|
+
const result = await sendVideoGewe({
|
|
776
|
+
account,
|
|
777
|
+
toWxid,
|
|
778
|
+
videoUrl: stagedVideo.publicUrl,
|
|
779
|
+
thumbUrl: fallbackPublicUrl,
|
|
780
|
+
videoDuration: Math.floor(videoDuration),
|
|
781
|
+
});
|
|
782
|
+
core.channel.activity.record({
|
|
783
|
+
channel: "gewe",
|
|
784
|
+
accountId: account.accountId,
|
|
785
|
+
direction: "outbound",
|
|
786
|
+
});
|
|
787
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
throw err;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const fallbackName =
|
|
796
|
+
geweData?.fileName ||
|
|
797
|
+
fileName ||
|
|
798
|
+
(contentType ? `file${contentType.includes("/") ? `.${contentType.split("/")[1]}` : ""}` : "file");
|
|
799
|
+
const result = await sendFileGewe({
|
|
800
|
+
account,
|
|
801
|
+
toWxid,
|
|
802
|
+
fileUrl: staged.publicUrl,
|
|
803
|
+
fileName: fallbackName,
|
|
804
|
+
});
|
|
805
|
+
core.channel.activity.record({
|
|
806
|
+
channel: "gewe",
|
|
807
|
+
accountId: account.accountId,
|
|
808
|
+
direction: "outbound",
|
|
809
|
+
});
|
|
810
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
811
|
+
return result;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (trimmedText) {
|
|
815
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
816
|
+
cfg,
|
|
817
|
+
channel: "gewe",
|
|
818
|
+
accountId: account.accountId,
|
|
819
|
+
});
|
|
820
|
+
const content = core.channel.text.convertMarkdownTables(trimmedText, tableMode);
|
|
821
|
+
const result = await sendTextGewe({
|
|
822
|
+
account,
|
|
823
|
+
toWxid,
|
|
824
|
+
content,
|
|
825
|
+
ats: geweData?.ats,
|
|
826
|
+
});
|
|
827
|
+
core.channel.activity.record({
|
|
828
|
+
channel: "gewe",
|
|
829
|
+
accountId: account.accountId,
|
|
830
|
+
direction: "outbound",
|
|
831
|
+
});
|
|
832
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
833
|
+
return result;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return null;
|
|
837
|
+
}
|