gewe-openclaw 2026.2.3 → 2026.3.2
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 +54 -18
- package/gewe-openclaw-2026.2.4.tgz +0 -0
- package/package.json +3 -1
- package/src/config-schema.ts +44 -0
- package/src/delivery.ts +396 -47
- package/src/inbound.ts +112 -0
- package/src/onboarding.ts +104 -3
- package/src/s3.ts +149 -0
- package/src/silk.ts +13 -3
- package/src/types.ts +13 -0
package/README.md
CHANGED
|
@@ -32,21 +32,31 @@ openclaw plugins install ./gewe-openclaw.tgz
|
|
|
32
32
|
|
|
33
33
|
> 安装或启用插件后需要重启 Gateway。
|
|
34
34
|
|
|
35
|
+
## 配置方式(二选一)
|
|
36
|
+
|
|
37
|
+
安装完成后可任选一种方式完成配置:
|
|
38
|
+
|
|
39
|
+
### 方式 A:Onboarding 向导
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
openclaw onboard
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
在通道列表中选择 **GeWe**,按提示填写 `token`、`appId`、`webhook`,以及可选的 `mediaPublicUrl`/`S3` 媒体配置。
|
|
46
|
+
|
|
47
|
+
### 方式 B:直接编辑配置文件
|
|
48
|
+
|
|
49
|
+
直接编辑 `~/.openclaw/openclaw.json` 的 `channels.gewe-openclaw` 段落(见下方示例)。
|
|
50
|
+
|
|
35
51
|
## 配置
|
|
36
52
|
|
|
37
|
-
插件配置放在 `~/.openclaw/openclaw.json` 的 `channels.gewe-openclaw
|
|
53
|
+
插件配置放在 `~/.openclaw/openclaw.json` 的 `channels.gewe-openclaw`,并确保通道开启(示例仅保留必填/常用字段):
|
|
38
54
|
|
|
39
55
|
```json5
|
|
40
56
|
{
|
|
41
|
-
"plugins": {
|
|
42
|
-
"entries": {
|
|
43
|
-
"gewe-openclaw": { "enabled": true }
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
57
|
"channels": {
|
|
47
58
|
"gewe-openclaw": {
|
|
48
59
|
"enabled": true,
|
|
49
|
-
"apiBaseUrl": "https://www.geweapi.com",
|
|
50
60
|
"token": "<gewe-token>",
|
|
51
61
|
"appId": "<gewe-app-id>",
|
|
52
62
|
"webhookHost": "0.0.0.0",
|
|
@@ -56,21 +66,33 @@ openclaw plugins install ./gewe-openclaw.tgz
|
|
|
56
66
|
"mediaPort": 4400,
|
|
57
67
|
"mediaPath": "/gewe-media",
|
|
58
68
|
"mediaPublicUrl": "https://your-public-domain/gewe-media",
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
69
|
+
"s3Enabled": true,
|
|
70
|
+
"s3Endpoint": "https://s3.amazonaws.com",
|
|
71
|
+
"s3Region": "us-east-1",
|
|
72
|
+
"s3Bucket": "your-bucket",
|
|
73
|
+
"s3AccessKeyId": "<access-key-id>",
|
|
74
|
+
"s3SecretAccessKey": "<secret-access-key>",
|
|
75
|
+
"s3UrlMode": "public",
|
|
76
|
+
"s3PublicBaseUrl": "https://cdn.example.com/gewe-media",
|
|
77
|
+
"s3KeyPrefix": "gewe-openclaw/outbound",
|
|
78
|
+
"allowFrom": ["wxid_xxx"]
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
82
|
```
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
完整参数说明:
|
|
71
85
|
- `webhookHost/webhookPort/webhookPath`:GeWe 回调入口(需公网可达,常配合 FRP)。
|
|
72
86
|
- `mediaPath`:本地媒体服务的路由前缀(默认 `/gewe-media`)。
|
|
73
|
-
- `mediaPublicUrl
|
|
87
|
+
- `mediaPublicUrl`:本地反代回退时的公网地址前缀(可选)。配置后会自动拼接媒体 ID;通常应与 `mediaPath` 对齐。
|
|
88
|
+
- `s3Enabled`:是否启用 S3 兼容上传。
|
|
89
|
+
- `s3Endpoint/s3Region/s3Bucket/s3AccessKeyId/s3SecretAccessKey`:S3 兼容服务连接参数。
|
|
90
|
+
- `s3SessionToken`:临时凭证可选字段。
|
|
91
|
+
- `s3ForcePathStyle`:是否启用 path-style(部分 S3 兼容服务需要)。
|
|
92
|
+
- `s3UrlMode`:`public` 或 `presigned`(默认 `public`)。
|
|
93
|
+
- `s3PublicBaseUrl`:`public` 模式下用于拼接可访问 URL(必填)。
|
|
94
|
+
- `s3PresignExpiresSec`:`presigned` 模式签名有效期(默认 3600 秒)。
|
|
95
|
+
- `s3KeyPrefix`:对象 key 前缀(默认 `gewe-openclaw/outbound`)。
|
|
74
96
|
- `allowFrom`:允许私聊触发的微信 ID(或在群里走 allowlist 规则)。
|
|
75
97
|
- `voiceAutoConvert`:自动将音频转为 silk(默认开启;设为 `false` 可关闭)。
|
|
76
98
|
- `silkAutoDownload`:自动下载 `rust-silk`(默认开启;可关闭后自行配置 `voiceSilkPath` / `voiceDecodePath`)。
|
|
@@ -79,18 +101,32 @@ openclaw plugins install ./gewe-openclaw.tgz
|
|
|
79
101
|
- `silkInstallDir`:自定义安装目录(默认 `~/.openclaw/tools/rust-silk/<version>`)。
|
|
80
102
|
- `silkAllowUnverified`:校验文件缺失时是否允许继续(默认 `false`)。
|
|
81
103
|
- `silkSha256`:手动指定下载包 SHA256(用于私有源或校验文件缺失场景)。
|
|
104
|
+
- `apiBaseUrl`:GeWe API 地址(默认 `https://www.geweapi.com`)。
|
|
105
|
+
- `voiceFfmpegPath`/`videoFfmpegPath`/`videoFfprobePath`:自定义 ffmpeg/ffprobe 路径。
|
|
106
|
+
- `voiceSilkPath`/`voiceSilkArgs`:自定义 silk 编码器路径和参数(不使用自动下载时)。
|
|
107
|
+
- `voiceSilkPipe`:是否启用 ffmpeg+rust-silk 的 stdin/stdout 管道(默认关闭;失败会回退到临时文件)。
|
|
108
|
+
- 低频/非高并发且磁盘压力不高时,推荐临时文件方案(更稳定/更快)。
|
|
109
|
+
- 高频/多并发或磁盘压力大时,推荐 pipe 方案(减少磁盘 IO)。
|
|
110
|
+
- `voiceDecodePath`/`voiceDecodeArgs`/`voiceDecodeOutput`:自定义 silk 解码器(入站语音转写用)。
|
|
111
|
+
- `mediaMaxMb`:上传媒体大小上限(默认 20MB)。
|
|
112
|
+
- `downloadMinDelayMs`/`downloadMaxDelayMs`:入站媒体下载节流。
|
|
113
|
+
|
|
114
|
+
发送媒体时的 URL 策略:
|
|
115
|
+
- 本地文件:优先上传 S3,失败回退 `mediaPublicUrl` 本地反代。
|
|
116
|
+
- 公网 URL:先尝试原 URL 发送,失败后再尝试上传 S3,仍失败回退本地反代。
|
|
82
117
|
|
|
83
118
|
> 配置变更后需重启 Gateway。
|
|
84
119
|
|
|
85
|
-
##
|
|
120
|
+
## 高级用法:让未安装插件也出现在 onboarding 列表
|
|
86
121
|
|
|
87
|
-
|
|
122
|
+
默认情况下,**只有已安装的插件**会出现在 onboarding 列表中。
|
|
123
|
+
如果你希望“未安装时也能在列表中展示”,需要配置本地 catalog:
|
|
88
124
|
|
|
89
125
|
```
|
|
90
126
|
~/.openclaw/plugins/catalog.json
|
|
91
127
|
```
|
|
92
128
|
|
|
93
|
-
|
|
129
|
+
示例(添加一次即可):
|
|
94
130
|
|
|
95
131
|
```json
|
|
96
132
|
{
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gewe-openclaw",
|
|
3
|
-
"version": "2026.2
|
|
3
|
+
"version": "2026.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw GeWe channel plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@aws-sdk/client-s3": "^3.922.0",
|
|
36
|
+
"@aws-sdk/s3-request-presigner": "^3.922.0",
|
|
35
37
|
"zod": "^4.3.6"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
package/src/config-schema.ts
CHANGED
|
@@ -40,10 +40,23 @@ export const GeweAccountSchemaBase = z
|
|
|
40
40
|
mediaPath: z.string().optional(),
|
|
41
41
|
mediaPublicUrl: z.string().optional(),
|
|
42
42
|
mediaMaxMb: z.number().positive().optional(),
|
|
43
|
+
s3Enabled: z.boolean().optional(),
|
|
44
|
+
s3Endpoint: z.string().optional(),
|
|
45
|
+
s3Region: z.string().optional(),
|
|
46
|
+
s3Bucket: z.string().optional(),
|
|
47
|
+
s3AccessKeyId: z.string().optional(),
|
|
48
|
+
s3SecretAccessKey: z.string().optional(),
|
|
49
|
+
s3SessionToken: z.string().optional(),
|
|
50
|
+
s3ForcePathStyle: z.boolean().optional(),
|
|
51
|
+
s3PublicBaseUrl: z.string().optional(),
|
|
52
|
+
s3KeyPrefix: z.string().optional(),
|
|
53
|
+
s3UrlMode: z.enum(["public", "presigned"]).optional(),
|
|
54
|
+
s3PresignExpiresSec: z.number().int().positive().optional(),
|
|
43
55
|
voiceAutoConvert: z.boolean().optional(),
|
|
44
56
|
voiceFfmpegPath: z.string().optional(),
|
|
45
57
|
voiceSilkPath: z.string().optional(),
|
|
46
58
|
voiceSilkArgs: z.array(z.string()).optional(),
|
|
59
|
+
voiceSilkPipe: z.boolean().optional(),
|
|
47
60
|
voiceSampleRate: z.number().int().positive().optional(),
|
|
48
61
|
voiceDecodePath: z.string().optional(),
|
|
49
62
|
voiceDecodeArgs: z.array(z.string()).optional(),
|
|
@@ -84,6 +97,37 @@ export const GeweAccountSchemaBase = z
|
|
|
84
97
|
message: "downloadMaxDelayMs must be >= downloadMinDelayMs",
|
|
85
98
|
});
|
|
86
99
|
}
|
|
100
|
+
|
|
101
|
+
if (value.s3Enabled === true) {
|
|
102
|
+
const required: Array<keyof typeof value> = [
|
|
103
|
+
"s3Endpoint",
|
|
104
|
+
"s3Region",
|
|
105
|
+
"s3Bucket",
|
|
106
|
+
"s3AccessKeyId",
|
|
107
|
+
"s3SecretAccessKey",
|
|
108
|
+
];
|
|
109
|
+
for (const key of required) {
|
|
110
|
+
const raw = value[key];
|
|
111
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
112
|
+
ctx.addIssue({
|
|
113
|
+
code: z.ZodIssueCode.custom,
|
|
114
|
+
path: [key],
|
|
115
|
+
message: `${String(key)} is required when s3Enabled=true`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const mode = value.s3UrlMode ?? "public";
|
|
120
|
+
if (mode === "public") {
|
|
121
|
+
const base = value.s3PublicBaseUrl?.trim();
|
|
122
|
+
if (!base) {
|
|
123
|
+
ctx.addIssue({
|
|
124
|
+
code: z.ZodIssueCode.custom,
|
|
125
|
+
path: ["s3PublicBaseUrl"],
|
|
126
|
+
message: "s3PublicBaseUrl is required when s3UrlMode=public",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
87
131
|
});
|
|
88
132
|
|
|
89
133
|
export const GeweAccountSchema = GeweAccountSchemaBase.superRefine((value, ctx) => {
|
package/src/delivery.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { PassThrough } from "node:stream";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
|
|
6
8
|
import type { OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
|
|
7
9
|
import { extractOriginalFilename, extensionForMime } from "openclaw/plugin-sdk";
|
|
8
10
|
import { CHANNEL_ID } from "./constants.js";
|
|
9
11
|
import { getGeweRuntime } from "./runtime.js";
|
|
12
|
+
import { resolveS3Config, uploadToS3 } from "./s3.js";
|
|
10
13
|
import { ensureRustSilkBinary } from "./silk.js";
|
|
11
14
|
import {
|
|
12
15
|
sendFileGewe,
|
|
@@ -41,6 +44,9 @@ type ResolvedMedia = {
|
|
|
41
44
|
contentType?: string;
|
|
42
45
|
fileName?: string;
|
|
43
46
|
localPath?: string;
|
|
47
|
+
sourceKind: "remote" | "local";
|
|
48
|
+
sourceUrl: string;
|
|
49
|
+
provider: "direct" | "s3" | "proxy";
|
|
44
50
|
};
|
|
45
51
|
|
|
46
52
|
const LINK_THUMB_MAX_BYTES = 50 * 1024;
|
|
@@ -92,6 +98,21 @@ function buildPublicUrl(baseUrl: string, id: string): string {
|
|
|
92
98
|
return `${trimmed}/${encodeURIComponent(id)}`;
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
function hasProxyBase(account: ResolvedGeweAccount): boolean {
|
|
102
|
+
return Boolean(account.config.mediaPublicUrl?.trim());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hasS3(account: ResolvedGeweAccount): boolean {
|
|
106
|
+
return account.config.s3Enabled === true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveFallbackProviders(account: ResolvedGeweAccount): Array<"s3" | "proxy"> {
|
|
110
|
+
const providers: Array<"s3" | "proxy"> = [];
|
|
111
|
+
if (hasS3(account)) providers.push("s3");
|
|
112
|
+
if (hasProxyBase(account)) providers.push("proxy");
|
|
113
|
+
return providers;
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
function resolveMediaMaxBytes(account: ResolvedGeweAccount): number {
|
|
96
117
|
const maxMb = account.config.mediaMaxMb;
|
|
97
118
|
if (typeof maxMb === "number" && maxMb > 0) return Math.floor(maxMb * 1024 * 1024);
|
|
@@ -246,6 +267,80 @@ function resolveSilkArgs(params: {
|
|
|
246
267
|
return next;
|
|
247
268
|
}
|
|
248
269
|
|
|
270
|
+
async function encodeSilkWithPipes(params: {
|
|
271
|
+
ffmpegPath: string;
|
|
272
|
+
ffmpegArgs: string[];
|
|
273
|
+
silkPath: string;
|
|
274
|
+
silkArgs: string[];
|
|
275
|
+
timeoutMs: number;
|
|
276
|
+
sampleRate: number;
|
|
277
|
+
}): Promise<{ buffer: Buffer; durationMs: number }> {
|
|
278
|
+
const ffmpeg = spawn(params.ffmpegPath, params.ffmpegArgs, {
|
|
279
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
280
|
+
});
|
|
281
|
+
const silk = spawn(params.silkPath, params.silkArgs, {
|
|
282
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
let pcmBytes = 0;
|
|
286
|
+
const pass = new PassThrough();
|
|
287
|
+
ffmpeg.stdout?.pipe(pass);
|
|
288
|
+
pass.on("data", (chunk) => {
|
|
289
|
+
pcmBytes += chunk.length;
|
|
290
|
+
});
|
|
291
|
+
pass.pipe(silk.stdin!);
|
|
292
|
+
|
|
293
|
+
const silkChunks: Buffer[] = [];
|
|
294
|
+
let ffmpegErr = "";
|
|
295
|
+
let silkErr = "";
|
|
296
|
+
|
|
297
|
+
ffmpeg.stderr?.on("data", (d) => {
|
|
298
|
+
ffmpegErr += d.toString();
|
|
299
|
+
});
|
|
300
|
+
silk.stderr?.on("data", (d) => {
|
|
301
|
+
silkErr += d.toString();
|
|
302
|
+
});
|
|
303
|
+
silk.stdout?.on("data", (d) => {
|
|
304
|
+
silkChunks.push(Buffer.from(d));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const timer = setTimeout(() => {
|
|
308
|
+
ffmpeg.kill("SIGKILL");
|
|
309
|
+
silk.kill("SIGKILL");
|
|
310
|
+
}, params.timeoutMs);
|
|
311
|
+
|
|
312
|
+
const [ffmpegRes, silkRes] = await Promise.all([
|
|
313
|
+
new Promise<{ code: number | null }>((resolve, reject) => {
|
|
314
|
+
ffmpeg.on("error", reject);
|
|
315
|
+
ffmpeg.on("close", (code) => resolve({ code }));
|
|
316
|
+
}),
|
|
317
|
+
new Promise<{ code: number | null }>((resolve, reject) => {
|
|
318
|
+
silk.on("error", reject);
|
|
319
|
+
silk.on("close", (code) => resolve({ code }));
|
|
320
|
+
}),
|
|
321
|
+
]).finally(() => {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (ffmpegRes.code !== 0) {
|
|
326
|
+
throw new Error(`ffmpeg failed: ${ffmpegErr.trim() || `exit code ${ffmpegRes.code ?? "?"}`}`);
|
|
327
|
+
}
|
|
328
|
+
if (silkRes.code !== 0) {
|
|
329
|
+
throw new Error(`silk encoder failed: ${silkErr.trim() || `exit code ${silkRes.code ?? "?"}`}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const buffer = Buffer.concat(silkChunks);
|
|
333
|
+
if (!buffer.length) {
|
|
334
|
+
throw new Error("silk encoder produced empty output");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const durationMs = Math.max(
|
|
338
|
+
1,
|
|
339
|
+
Math.round((pcmBytes / (params.sampleRate * PCM_BYTES_PER_SAMPLE)) * 1000),
|
|
340
|
+
);
|
|
341
|
+
return { buffer, durationMs };
|
|
342
|
+
}
|
|
343
|
+
|
|
249
344
|
async function convertAudioToSilk(params: {
|
|
250
345
|
account: ResolvedGeweAccount;
|
|
251
346
|
sourcePath: string;
|
|
@@ -278,11 +373,52 @@ async function convertAudioToSilk(params: {
|
|
|
278
373
|
params.account.config.voiceSilkArgs?.length ? [params.account.config.voiceSilkArgs] : [];
|
|
279
374
|
let silkPath = customPath || DEFAULT_VOICE_SILK;
|
|
280
375
|
let argTemplates = customArgs.length ? customArgs : fallbackArgs;
|
|
376
|
+
const pipeEnabled = params.account.config.voiceSilkPipe === true;
|
|
377
|
+
let usePipe = false;
|
|
281
378
|
if (!customPath) {
|
|
282
379
|
const rustSilk = await ensureRustSilkBinary(params.account);
|
|
283
380
|
if (rustSilk) {
|
|
284
381
|
silkPath = rustSilk;
|
|
285
382
|
argTemplates = [rustArgs];
|
|
383
|
+
usePipe = pipeEnabled && customArgs.length === 0;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (usePipe) {
|
|
388
|
+
try {
|
|
389
|
+
const ffmpegArgs = [
|
|
390
|
+
"-y",
|
|
391
|
+
"-i",
|
|
392
|
+
params.sourcePath,
|
|
393
|
+
"-ac",
|
|
394
|
+
"1",
|
|
395
|
+
"-ar",
|
|
396
|
+
String(sampleRate),
|
|
397
|
+
"-f",
|
|
398
|
+
"s16le",
|
|
399
|
+
"pipe:1",
|
|
400
|
+
];
|
|
401
|
+
const silkArgs = [
|
|
402
|
+
"encode",
|
|
403
|
+
"-i",
|
|
404
|
+
"-",
|
|
405
|
+
"-o",
|
|
406
|
+
"-",
|
|
407
|
+
"--sample-rate",
|
|
408
|
+
String(sampleRate),
|
|
409
|
+
"--tencent",
|
|
410
|
+
"--quiet",
|
|
411
|
+
];
|
|
412
|
+
return await encodeSilkWithPipes({
|
|
413
|
+
ffmpegPath,
|
|
414
|
+
ffmpegArgs,
|
|
415
|
+
silkPath,
|
|
416
|
+
silkArgs,
|
|
417
|
+
timeoutMs: DEFAULT_VOICE_TIMEOUT_MS,
|
|
418
|
+
sampleRate,
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
logger.warn?.(`gewe voice convert pipe failed: ${String(err)}`);
|
|
286
422
|
}
|
|
287
423
|
}
|
|
288
424
|
|
|
@@ -436,11 +572,6 @@ async function stageThumbBuffer(params: {
|
|
|
436
572
|
fileName?: string;
|
|
437
573
|
}): Promise<string> {
|
|
438
574
|
const core = getGeweRuntime();
|
|
439
|
-
const publicBase = params.account.config.mediaPublicUrl?.trim();
|
|
440
|
-
if (!publicBase) {
|
|
441
|
-
throw new Error("mediaPublicUrl not configured (required for link thumbnails)");
|
|
442
|
-
}
|
|
443
|
-
|
|
444
575
|
const normalized = await normalizeThumbBuffer({
|
|
445
576
|
buffer: params.buffer,
|
|
446
577
|
contentType: params.contentType,
|
|
@@ -449,6 +580,31 @@ async function stageThumbBuffer(params: {
|
|
|
449
580
|
throw new Error("link thumbnail exceeds 50KB after resize");
|
|
450
581
|
}
|
|
451
582
|
|
|
583
|
+
if (hasS3(params.account)) {
|
|
584
|
+
try {
|
|
585
|
+
const s3Config = resolveS3Config(params.account.config);
|
|
586
|
+
if (!s3Config) throw new Error("s3 not configured");
|
|
587
|
+
const uploaded = await uploadToS3({
|
|
588
|
+
config: s3Config,
|
|
589
|
+
accountId: params.account.accountId,
|
|
590
|
+
buffer: normalized.buffer,
|
|
591
|
+
contentType: normalized.contentType,
|
|
592
|
+
fileName: params.fileName,
|
|
593
|
+
});
|
|
594
|
+
return uploaded.url;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (!hasProxyBase(params.account)) {
|
|
597
|
+
throw new Error(`s3 thumb upload failed and proxy fallback unavailable: ${String(err)}`);
|
|
598
|
+
}
|
|
599
|
+
const logger = core.logging.getChildLogger({ channel: CHANNEL_ID, module: "thumb" });
|
|
600
|
+
logger.warn?.(`gewe thumb s3 upload failed, fallback proxy: ${String(err)}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const publicBase = params.account.config.mediaPublicUrl?.trim();
|
|
605
|
+
if (!publicBase) {
|
|
606
|
+
throw new Error("mediaPublicUrl not configured (required for thumbnail fallback)");
|
|
607
|
+
}
|
|
452
608
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
453
609
|
normalized.buffer,
|
|
454
610
|
normalized.contentType,
|
|
@@ -521,26 +677,27 @@ async function stageMedia(params: {
|
|
|
521
677
|
const core = getGeweRuntime();
|
|
522
678
|
const rawUrl = normalizeMediaToken(params.mediaUrl);
|
|
523
679
|
if (!rawUrl) throw new Error("mediaUrl is empty");
|
|
524
|
-
|
|
525
|
-
if (
|
|
680
|
+
const isRemote = looksLikeHttpUrl(rawUrl);
|
|
681
|
+
if (isRemote && params.allowRemote) {
|
|
526
682
|
const contentType = await core.media.detectMime({ filePath: rawUrl });
|
|
527
683
|
const fileName = path.basename(new URL(rawUrl).pathname || "");
|
|
528
|
-
return {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
684
|
+
return {
|
|
685
|
+
publicUrl: rawUrl,
|
|
686
|
+
contentType: contentType ?? undefined,
|
|
687
|
+
fileName,
|
|
688
|
+
sourceKind: "remote",
|
|
689
|
+
sourceUrl: rawUrl,
|
|
690
|
+
provider: "direct",
|
|
691
|
+
};
|
|
536
692
|
}
|
|
537
693
|
|
|
538
694
|
const maxBytes = resolveMediaMaxBytes(params.account);
|
|
539
695
|
let buffer: Buffer;
|
|
540
696
|
let contentType: string | undefined;
|
|
541
697
|
let fileName: string | undefined;
|
|
698
|
+
let sourceLocalPath: string | undefined;
|
|
542
699
|
|
|
543
|
-
if (
|
|
700
|
+
if (isRemote) {
|
|
544
701
|
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
545
702
|
url: rawUrl,
|
|
546
703
|
maxBytes,
|
|
@@ -554,6 +711,41 @@ async function stageMedia(params: {
|
|
|
554
711
|
buffer = await fs.readFile(localPath);
|
|
555
712
|
contentType = await core.media.detectMime({ buffer, filePath: localPath });
|
|
556
713
|
fileName = path.basename(localPath);
|
|
714
|
+
sourceLocalPath = localPath;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (hasS3(params.account)) {
|
|
718
|
+
try {
|
|
719
|
+
const s3Config = resolveS3Config(params.account.config);
|
|
720
|
+
if (!s3Config) throw new Error("s3 not configured");
|
|
721
|
+
const uploaded = await uploadToS3({
|
|
722
|
+
config: s3Config,
|
|
723
|
+
accountId: params.account.accountId,
|
|
724
|
+
buffer,
|
|
725
|
+
contentType,
|
|
726
|
+
fileName,
|
|
727
|
+
});
|
|
728
|
+
return {
|
|
729
|
+
publicUrl: uploaded.url,
|
|
730
|
+
contentType,
|
|
731
|
+
fileName,
|
|
732
|
+
localPath: sourceLocalPath,
|
|
733
|
+
sourceKind: isRemote ? "remote" : "local",
|
|
734
|
+
sourceUrl: rawUrl,
|
|
735
|
+
provider: "s3",
|
|
736
|
+
};
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (!hasProxyBase(params.account)) {
|
|
739
|
+
throw new Error(`s3 upload failed and proxy fallback unavailable: ${String(err)}`);
|
|
740
|
+
}
|
|
741
|
+
const logger = core.logging.getChildLogger({ channel: CHANNEL_ID, module: "media" });
|
|
742
|
+
logger.warn?.(`gewe s3 upload failed, fallback to proxy: ${String(err)}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const publicBase = params.account.config.mediaPublicUrl?.trim();
|
|
747
|
+
if (!publicBase) {
|
|
748
|
+
throw new Error("mediaPublicUrl not configured (required for proxy fallback)");
|
|
557
749
|
}
|
|
558
750
|
|
|
559
751
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
@@ -567,8 +759,7 @@ async function stageMedia(params: {
|
|
|
567
759
|
let resolvedId = saved.id;
|
|
568
760
|
let resolvedPath = saved.path;
|
|
569
761
|
const desiredExt =
|
|
570
|
-
extensionForMime(contentType ?? saved.contentType) ||
|
|
571
|
-
path.extname(resolvedFileName);
|
|
762
|
+
extensionForMime(contentType ?? saved.contentType) || path.extname(resolvedFileName);
|
|
572
763
|
if (desiredExt && !path.extname(resolvedId)) {
|
|
573
764
|
const nextId = `${resolvedId}${desiredExt}`;
|
|
574
765
|
const nextPath = path.join(path.dirname(saved.path), nextId);
|
|
@@ -580,7 +771,10 @@ async function stageMedia(params: {
|
|
|
580
771
|
publicUrl: buildPublicUrl(publicBase, resolvedId),
|
|
581
772
|
contentType: contentType ?? saved.contentType,
|
|
582
773
|
fileName: resolvedFileName || resolvedId,
|
|
583
|
-
localPath: resolvedPath,
|
|
774
|
+
localPath: sourceLocalPath ?? resolvedPath,
|
|
775
|
+
sourceKind: isRemote ? "remote" : "local",
|
|
776
|
+
sourceUrl: rawUrl,
|
|
777
|
+
provider: "proxy",
|
|
584
778
|
};
|
|
585
779
|
}
|
|
586
780
|
|
|
@@ -599,6 +793,29 @@ async function resolvePublicUrl(params: {
|
|
|
599
793
|
return staged.publicUrl;
|
|
600
794
|
}
|
|
601
795
|
|
|
796
|
+
function shouldRetryWithStagedFallback(params: {
|
|
797
|
+
originalUrl: string;
|
|
798
|
+
staged: ResolvedMedia;
|
|
799
|
+
account: ResolvedGeweAccount;
|
|
800
|
+
}): boolean {
|
|
801
|
+
if (!looksLikeHttpUrl(params.originalUrl)) return false;
|
|
802
|
+
if (params.staged.provider !== "direct") return false;
|
|
803
|
+
return resolveFallbackProviders(params.account).length > 0;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function stageFallbackFromRemote(params: {
|
|
807
|
+
account: ResolvedGeweAccount;
|
|
808
|
+
cfg: OpenClawConfig;
|
|
809
|
+
originalUrl: string;
|
|
810
|
+
}): Promise<ResolvedMedia> {
|
|
811
|
+
return await stageMedia({
|
|
812
|
+
account: params.account,
|
|
813
|
+
cfg: params.cfg,
|
|
814
|
+
mediaUrl: params.originalUrl,
|
|
815
|
+
allowRemote: false,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
602
819
|
export async function deliverGewePayload(params: {
|
|
603
820
|
payload: ReplyPayload;
|
|
604
821
|
account: ResolvedGeweAccount;
|
|
@@ -657,12 +874,34 @@ export async function deliverGewePayload(params: {
|
|
|
657
874
|
const declaredDuration = resolveVoiceDurationMs(geweData);
|
|
658
875
|
if (isSilkAudio({ contentType, fileName })) {
|
|
659
876
|
if (declaredDuration) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
877
|
+
let result: GeweSendResult;
|
|
878
|
+
try {
|
|
879
|
+
result = await sendVoiceGewe({
|
|
880
|
+
account,
|
|
881
|
+
toWxid,
|
|
882
|
+
voiceUrl: staged.publicUrl,
|
|
883
|
+
voiceDuration: declaredDuration,
|
|
884
|
+
});
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (!shouldRetryWithStagedFallback({
|
|
887
|
+
originalUrl: normalizedMediaUrl,
|
|
888
|
+
staged,
|
|
889
|
+
account,
|
|
890
|
+
})) {
|
|
891
|
+
throw err;
|
|
892
|
+
}
|
|
893
|
+
const fallback = await stageFallbackFromRemote({
|
|
894
|
+
account,
|
|
895
|
+
cfg,
|
|
896
|
+
originalUrl: normalizedMediaUrl,
|
|
897
|
+
});
|
|
898
|
+
result = await sendVoiceGewe({
|
|
899
|
+
account,
|
|
900
|
+
toWxid,
|
|
901
|
+
voiceUrl: fallback.publicUrl,
|
|
902
|
+
voiceDuration: declaredDuration,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
666
905
|
core.channel.activity.record({
|
|
667
906
|
channel: CHANNEL_ID,
|
|
668
907
|
accountId: account.accountId,
|
|
@@ -678,21 +917,56 @@ export async function deliverGewePayload(params: {
|
|
|
678
917
|
});
|
|
679
918
|
if (converted) {
|
|
680
919
|
const voiceDuration = declaredDuration ?? converted.durationMs;
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
|
|
920
|
+
let voiceUrl: string;
|
|
921
|
+
if (hasS3(account)) {
|
|
922
|
+
try {
|
|
923
|
+
const s3Config = resolveS3Config(account.config);
|
|
924
|
+
if (!s3Config) throw new Error("s3 not configured");
|
|
925
|
+
const uploaded = await uploadToS3({
|
|
926
|
+
config: s3Config,
|
|
927
|
+
accountId: account.accountId,
|
|
928
|
+
buffer: converted.buffer,
|
|
929
|
+
contentType: "audio/silk",
|
|
930
|
+
fileName: "voice.silk",
|
|
931
|
+
});
|
|
932
|
+
voiceUrl = uploaded.url;
|
|
933
|
+
} catch (err) {
|
|
934
|
+
if (!hasProxyBase(account)) {
|
|
935
|
+
throw new Error(
|
|
936
|
+
`s3 silk upload failed and proxy fallback unavailable: ${String(err)}`,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
const publicBase = account.config.mediaPublicUrl?.trim();
|
|
940
|
+
if (!publicBase) {
|
|
941
|
+
throw new Error("mediaPublicUrl not configured (required for silk voice)");
|
|
942
|
+
}
|
|
943
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
944
|
+
converted.buffer,
|
|
945
|
+
"audio/silk",
|
|
946
|
+
"outbound",
|
|
947
|
+
resolveMediaMaxBytes(account),
|
|
948
|
+
"voice.silk",
|
|
949
|
+
);
|
|
950
|
+
voiceUrl = buildPublicUrl(publicBase, saved.id);
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
const publicBase = account.config.mediaPublicUrl?.trim();
|
|
954
|
+
if (!publicBase) {
|
|
955
|
+
throw new Error("mediaPublicUrl not configured (required for silk voice)");
|
|
956
|
+
}
|
|
957
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
958
|
+
converted.buffer,
|
|
959
|
+
"audio/silk",
|
|
960
|
+
"outbound",
|
|
961
|
+
resolveMediaMaxBytes(account),
|
|
962
|
+
"voice.silk",
|
|
963
|
+
);
|
|
964
|
+
voiceUrl = buildPublicUrl(publicBase, saved.id);
|
|
684
965
|
}
|
|
685
|
-
const saved = await core.channel.media.saveMediaBuffer(
|
|
686
|
-
converted.buffer,
|
|
687
|
-
"audio/silk",
|
|
688
|
-
"outbound",
|
|
689
|
-
resolveMediaMaxBytes(account),
|
|
690
|
-
"voice.silk",
|
|
691
|
-
);
|
|
692
966
|
const result = await sendVoiceGewe({
|
|
693
967
|
account,
|
|
694
968
|
toWxid,
|
|
695
|
-
voiceUrl
|
|
969
|
+
voiceUrl,
|
|
696
970
|
voiceDuration,
|
|
697
971
|
});
|
|
698
972
|
core.channel.activity.record({
|
|
@@ -707,11 +981,32 @@ export async function deliverGewePayload(params: {
|
|
|
707
981
|
}
|
|
708
982
|
|
|
709
983
|
if (!forceFile && kind === "image") {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
984
|
+
let result: GeweSendResult;
|
|
985
|
+
try {
|
|
986
|
+
result = await sendImageGewe({
|
|
987
|
+
account,
|
|
988
|
+
toWxid,
|
|
989
|
+
imgUrl: staged.publicUrl,
|
|
990
|
+
});
|
|
991
|
+
} catch (err) {
|
|
992
|
+
if (!shouldRetryWithStagedFallback({
|
|
993
|
+
originalUrl: normalizedMediaUrl,
|
|
994
|
+
staged,
|
|
995
|
+
account,
|
|
996
|
+
})) {
|
|
997
|
+
throw err;
|
|
998
|
+
}
|
|
999
|
+
const fallback = await stageFallbackFromRemote({
|
|
1000
|
+
account,
|
|
1001
|
+
cfg,
|
|
1002
|
+
originalUrl: normalizedMediaUrl,
|
|
1003
|
+
});
|
|
1004
|
+
result = await sendImageGewe({
|
|
1005
|
+
account,
|
|
1006
|
+
toWxid,
|
|
1007
|
+
imgUrl: fallback.publicUrl,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
715
1010
|
core.channel.activity.record({
|
|
716
1011
|
channel: CHANNEL_ID,
|
|
717
1012
|
accountId: account.accountId,
|
|
@@ -785,6 +1080,11 @@ export async function deliverGewePayload(params: {
|
|
|
785
1080
|
url: thumbUrl,
|
|
786
1081
|
allowRemote: true,
|
|
787
1082
|
});
|
|
1083
|
+
const canRetryMediaFallback = shouldRetryWithStagedFallback({
|
|
1084
|
+
originalUrl: normalizedMediaUrl,
|
|
1085
|
+
staged: stagedVideo,
|
|
1086
|
+
account,
|
|
1087
|
+
});
|
|
788
1088
|
try {
|
|
789
1089
|
const result = await sendVideoGewe({
|
|
790
1090
|
account,
|
|
@@ -801,6 +1101,33 @@ export async function deliverGewePayload(params: {
|
|
|
801
1101
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
802
1102
|
return result;
|
|
803
1103
|
} catch (err) {
|
|
1104
|
+
if (canRetryMediaFallback) {
|
|
1105
|
+
const fallbackVideo = await stageFallbackFromRemote({
|
|
1106
|
+
account,
|
|
1107
|
+
cfg,
|
|
1108
|
+
originalUrl: normalizedMediaUrl,
|
|
1109
|
+
});
|
|
1110
|
+
const fallbackThumb = await resolvePublicUrl({
|
|
1111
|
+
account,
|
|
1112
|
+
cfg,
|
|
1113
|
+
url: thumbUrl,
|
|
1114
|
+
allowRemote: false,
|
|
1115
|
+
});
|
|
1116
|
+
const result = await sendVideoGewe({
|
|
1117
|
+
account,
|
|
1118
|
+
toWxid,
|
|
1119
|
+
videoUrl: fallbackVideo.publicUrl,
|
|
1120
|
+
thumbUrl: fallbackThumb,
|
|
1121
|
+
videoDuration: Math.floor(videoDuration),
|
|
1122
|
+
});
|
|
1123
|
+
core.channel.activity.record({
|
|
1124
|
+
channel: CHANNEL_ID,
|
|
1125
|
+
accountId: account.accountId,
|
|
1126
|
+
direction: "outbound",
|
|
1127
|
+
});
|
|
1128
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
1129
|
+
return result;
|
|
1130
|
+
}
|
|
804
1131
|
if (fallbackThumbUrl && fallbackThumbUrl !== thumbUrl) {
|
|
805
1132
|
logger.warn?.(
|
|
806
1133
|
`gewe video send failed with primary thumb, retrying fallback: ${String(err)}`,
|
|
@@ -835,12 +1162,34 @@ export async function deliverGewePayload(params: {
|
|
|
835
1162
|
geweData?.fileName ||
|
|
836
1163
|
fileName ||
|
|
837
1164
|
(contentType ? `file${contentType.includes("/") ? `.${contentType.split("/")[1]}` : ""}` : "file");
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1165
|
+
let result: GeweSendResult;
|
|
1166
|
+
try {
|
|
1167
|
+
result = await sendFileGewe({
|
|
1168
|
+
account,
|
|
1169
|
+
toWxid,
|
|
1170
|
+
fileUrl: staged.publicUrl,
|
|
1171
|
+
fileName: fallbackName,
|
|
1172
|
+
});
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
if (!shouldRetryWithStagedFallback({
|
|
1175
|
+
originalUrl: normalizedMediaUrl,
|
|
1176
|
+
staged,
|
|
1177
|
+
account,
|
|
1178
|
+
})) {
|
|
1179
|
+
throw err;
|
|
1180
|
+
}
|
|
1181
|
+
const fallback = await stageFallbackFromRemote({
|
|
1182
|
+
account,
|
|
1183
|
+
cfg,
|
|
1184
|
+
originalUrl: normalizedMediaUrl,
|
|
1185
|
+
});
|
|
1186
|
+
result = await sendFileGewe({
|
|
1187
|
+
account,
|
|
1188
|
+
toWxid,
|
|
1189
|
+
fileUrl: fallback.publicUrl,
|
|
1190
|
+
fileName: fallbackName,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
844
1193
|
core.channel.activity.record({
|
|
845
1194
|
channel: CHANNEL_ID,
|
|
846
1195
|
accountId: account.accountId,
|
package/src/inbound.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -97,6 +98,71 @@ function resolveDecodeArgs(params: {
|
|
|
97
98
|
return next;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
async function decodeSilkWithPipes(params: {
|
|
102
|
+
silkPath: string;
|
|
103
|
+
silkArgs: string[];
|
|
104
|
+
ffmpegPath: string;
|
|
105
|
+
ffmpegArgs: string[];
|
|
106
|
+
input: Buffer;
|
|
107
|
+
timeoutMs: number;
|
|
108
|
+
}): Promise<Buffer> {
|
|
109
|
+
const silk = spawn(params.silkPath, params.silkArgs, {
|
|
110
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
111
|
+
});
|
|
112
|
+
const ffmpeg = spawn(params.ffmpegPath, params.ffmpegArgs, {
|
|
113
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
let silkErr = "";
|
|
117
|
+
let ffmpegErr = "";
|
|
118
|
+
const chunks: Buffer[] = [];
|
|
119
|
+
|
|
120
|
+
silk.stderr?.on("data", (d) => {
|
|
121
|
+
silkErr += d.toString();
|
|
122
|
+
});
|
|
123
|
+
ffmpeg.stderr?.on("data", (d) => {
|
|
124
|
+
ffmpegErr += d.toString();
|
|
125
|
+
});
|
|
126
|
+
ffmpeg.stdout?.on("data", (d) => {
|
|
127
|
+
chunks.push(Buffer.from(d));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
silk.stdout?.pipe(ffmpeg.stdin!);
|
|
131
|
+
silk.stdin?.write(params.input);
|
|
132
|
+
silk.stdin?.end();
|
|
133
|
+
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
silk.kill("SIGKILL");
|
|
136
|
+
ffmpeg.kill("SIGKILL");
|
|
137
|
+
}, params.timeoutMs);
|
|
138
|
+
|
|
139
|
+
const [silkRes, ffmpegRes] = await Promise.all([
|
|
140
|
+
new Promise<{ code: number | null }>((resolve, reject) => {
|
|
141
|
+
silk.on("error", reject);
|
|
142
|
+
silk.on("close", (code) => resolve({ code }));
|
|
143
|
+
}),
|
|
144
|
+
new Promise<{ code: number | null }>((resolve, reject) => {
|
|
145
|
+
ffmpeg.on("error", reject);
|
|
146
|
+
ffmpeg.on("close", (code) => resolve({ code }));
|
|
147
|
+
}),
|
|
148
|
+
]).finally(() => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (silkRes.code !== 0) {
|
|
153
|
+
throw new Error(`silk decode failed: ${silkErr.trim() || `exit code ${silkRes.code ?? "?"}`}`);
|
|
154
|
+
}
|
|
155
|
+
if (ffmpegRes.code !== 0) {
|
|
156
|
+
throw new Error(`ffmpeg failed: ${ffmpegErr.trim() || `exit code ${ffmpegRes.code ?? "?"}`}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const buffer = Buffer.concat(chunks);
|
|
160
|
+
if (!buffer.length) {
|
|
161
|
+
throw new Error("ffmpeg produced empty output");
|
|
162
|
+
}
|
|
163
|
+
return buffer;
|
|
164
|
+
}
|
|
165
|
+
|
|
100
166
|
async function decodeSilkVoice(params: {
|
|
101
167
|
account: ResolvedGeweAccount;
|
|
102
168
|
buffer: Buffer;
|
|
@@ -129,6 +195,52 @@ async function decodeSilkVoice(params: {
|
|
|
129
195
|
];
|
|
130
196
|
if (decodeOutput === "wav") rustArgs.push("--wav");
|
|
131
197
|
const rustSilk = customPath ? null : await ensureRustSilkBinary(params.account);
|
|
198
|
+
const pipeEnabled = params.account.config.voiceSilkPipe === true;
|
|
199
|
+
const usePipe = pipeEnabled && !!rustSilk && !customPath && customArgs.length === 0;
|
|
200
|
+
if (usePipe) {
|
|
201
|
+
try {
|
|
202
|
+
const silkArgs = [
|
|
203
|
+
"decode",
|
|
204
|
+
"-i",
|
|
205
|
+
"-",
|
|
206
|
+
"-o",
|
|
207
|
+
"-",
|
|
208
|
+
"--sample-rate",
|
|
209
|
+
String(sampleRate),
|
|
210
|
+
"--quiet",
|
|
211
|
+
];
|
|
212
|
+
const ffmpegArgs = [
|
|
213
|
+
"-y",
|
|
214
|
+
"-f",
|
|
215
|
+
"s16le",
|
|
216
|
+
"-ar",
|
|
217
|
+
String(sampleRate),
|
|
218
|
+
"-ac",
|
|
219
|
+
"1",
|
|
220
|
+
"-i",
|
|
221
|
+
"pipe:0",
|
|
222
|
+
"-f",
|
|
223
|
+
"wav",
|
|
224
|
+
"pipe:1",
|
|
225
|
+
];
|
|
226
|
+
const buffer = await decodeSilkWithPipes({
|
|
227
|
+
silkPath: rustSilk!,
|
|
228
|
+
silkArgs,
|
|
229
|
+
ffmpegPath,
|
|
230
|
+
ffmpegArgs,
|
|
231
|
+
input: params.buffer,
|
|
232
|
+
timeoutMs: DEFAULT_VOICE_DECODE_TIMEOUT_MS,
|
|
233
|
+
});
|
|
234
|
+
return {
|
|
235
|
+
buffer,
|
|
236
|
+
contentType: "audio/wav",
|
|
237
|
+
fileName: "voice.wav",
|
|
238
|
+
};
|
|
239
|
+
} catch (err) {
|
|
240
|
+
logger.warn?.(`gewe voice decode pipe failed: ${String(err)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
132
244
|
const argTemplates = customArgs.length
|
|
133
245
|
? customArgs
|
|
134
246
|
: rustSilk
|
package/src/onboarding.ts
CHANGED
|
@@ -171,7 +171,7 @@ export const geweOnboarding: GeweOnboardingAdapter = {
|
|
|
171
171
|
"You will need:",
|
|
172
172
|
"- GeWe token + appId",
|
|
173
173
|
"- Public webhook endpoint (FRP or reverse proxy)",
|
|
174
|
-
"- Public media base URL (
|
|
174
|
+
"- Public media base URL (optional proxy fallback)",
|
|
175
175
|
].join("\n"),
|
|
176
176
|
"GeWe setup",
|
|
177
177
|
);
|
|
@@ -217,9 +217,109 @@ export const geweOnboarding: GeweOnboardingAdapter = {
|
|
|
217
217
|
message: "Media public URL (prefix)",
|
|
218
218
|
placeholder: "https://your-domain/gewe-media",
|
|
219
219
|
initialValue: existing.mediaPublicUrl,
|
|
220
|
-
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
221
220
|
});
|
|
222
221
|
|
|
222
|
+
const enableS3 = await ctx.prompter.confirm({
|
|
223
|
+
message: "Enable S3-compatible media delivery?",
|
|
224
|
+
initialValue: existing.s3Enabled === true,
|
|
225
|
+
});
|
|
226
|
+
let s3Patch: Partial<GeweAccountConfig> = {};
|
|
227
|
+
if (enableS3) {
|
|
228
|
+
const s3Endpoint = await ctx.prompter.text({
|
|
229
|
+
message: "S3 endpoint",
|
|
230
|
+
placeholder: "https://s3.amazonaws.com",
|
|
231
|
+
initialValue: existing.s3Endpoint,
|
|
232
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
233
|
+
});
|
|
234
|
+
const s3Region = await ctx.prompter.text({
|
|
235
|
+
message: "S3 region",
|
|
236
|
+
placeholder: "us-east-1",
|
|
237
|
+
initialValue: existing.s3Region,
|
|
238
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
239
|
+
});
|
|
240
|
+
const s3Bucket = await ctx.prompter.text({
|
|
241
|
+
message: "S3 bucket",
|
|
242
|
+
initialValue: existing.s3Bucket,
|
|
243
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
244
|
+
});
|
|
245
|
+
const s3AccessKeyId = await ctx.prompter.text({
|
|
246
|
+
message: "S3 access key id",
|
|
247
|
+
initialValue: existing.s3AccessKeyId,
|
|
248
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
249
|
+
});
|
|
250
|
+
const s3SecretAccessKey = await ctx.prompter.text({
|
|
251
|
+
message: "S3 secret access key",
|
|
252
|
+
initialValue: existing.s3SecretAccessKey,
|
|
253
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
254
|
+
});
|
|
255
|
+
const s3SessionToken = await ctx.prompter.text({
|
|
256
|
+
message: "S3 session token (optional)",
|
|
257
|
+
initialValue: existing.s3SessionToken,
|
|
258
|
+
});
|
|
259
|
+
const s3ForcePathStyle = await ctx.prompter.confirm({
|
|
260
|
+
message: "Use path-style for S3 endpoint?",
|
|
261
|
+
initialValue: existing.s3ForcePathStyle === true,
|
|
262
|
+
});
|
|
263
|
+
const s3KeyPrefix = await ctx.prompter.text({
|
|
264
|
+
message: "S3 key prefix (optional)",
|
|
265
|
+
placeholder: "gewe-openclaw/outbound",
|
|
266
|
+
initialValue: existing.s3KeyPrefix,
|
|
267
|
+
});
|
|
268
|
+
const s3UrlMode = await ctx.prompter.select({
|
|
269
|
+
message: "S3 URL mode",
|
|
270
|
+
options: [
|
|
271
|
+
{ value: "public", label: "public (default)" },
|
|
272
|
+
{ value: "presigned", label: "presigned" },
|
|
273
|
+
],
|
|
274
|
+
initialValue: existing.s3UrlMode ?? "public",
|
|
275
|
+
});
|
|
276
|
+
const s3PublicBaseUrl =
|
|
277
|
+
s3UrlMode === "public"
|
|
278
|
+
? await ctx.prompter.text({
|
|
279
|
+
message: "S3 public base URL",
|
|
280
|
+
placeholder: "https://cdn.example.com/gewe-media",
|
|
281
|
+
initialValue: existing.s3PublicBaseUrl,
|
|
282
|
+
validate: (value) => (value.trim() ? undefined : "Required"),
|
|
283
|
+
})
|
|
284
|
+
: await ctx.prompter.text({
|
|
285
|
+
message: "S3 public base URL (optional in presigned mode)",
|
|
286
|
+
initialValue: existing.s3PublicBaseUrl,
|
|
287
|
+
});
|
|
288
|
+
const s3PresignExpiresSecRaw =
|
|
289
|
+
s3UrlMode === "presigned"
|
|
290
|
+
? await ctx.prompter.text({
|
|
291
|
+
message: "Presigned URL expire seconds",
|
|
292
|
+
initialValue: String(existing.s3PresignExpiresSec ?? 3600),
|
|
293
|
+
validate: (value) => {
|
|
294
|
+
const parsed = Number(value);
|
|
295
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
296
|
+
return "Must be a positive integer";
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
: "";
|
|
302
|
+
s3Patch = {
|
|
303
|
+
s3Enabled: true,
|
|
304
|
+
s3Endpoint: s3Endpoint.trim(),
|
|
305
|
+
s3Region: s3Region.trim(),
|
|
306
|
+
s3Bucket: s3Bucket.trim(),
|
|
307
|
+
s3AccessKeyId: s3AccessKeyId.trim(),
|
|
308
|
+
s3SecretAccessKey: s3SecretAccessKey.trim(),
|
|
309
|
+
s3SessionToken: s3SessionToken.trim() || undefined,
|
|
310
|
+
s3ForcePathStyle,
|
|
311
|
+
s3KeyPrefix: s3KeyPrefix.trim() || undefined,
|
|
312
|
+
s3UrlMode,
|
|
313
|
+
s3PublicBaseUrl: s3PublicBaseUrl.trim() || undefined,
|
|
314
|
+
s3PresignExpiresSec:
|
|
315
|
+
s3UrlMode === "presigned" ? Number(s3PresignExpiresSecRaw) : undefined,
|
|
316
|
+
};
|
|
317
|
+
} else {
|
|
318
|
+
s3Patch = {
|
|
319
|
+
s3Enabled: false,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
223
323
|
let allowFrom = existing.allowFrom;
|
|
224
324
|
let dmPolicy: GeweAccountConfig["dmPolicy"] | undefined;
|
|
225
325
|
if (ctx.forceAllowFrom) {
|
|
@@ -255,7 +355,8 @@ export const geweOnboarding: GeweOnboardingAdapter = {
|
|
|
255
355
|
mediaHost: existing.mediaHost ?? DEFAULT_MEDIA_HOST,
|
|
256
356
|
mediaPort: existing.mediaPort ?? DEFAULT_MEDIA_PORT,
|
|
257
357
|
mediaPath: existing.mediaPath ?? DEFAULT_MEDIA_PATH,
|
|
258
|
-
mediaPublicUrl: mediaPublicUrl.trim(),
|
|
358
|
+
mediaPublicUrl: mediaPublicUrl.trim() || undefined,
|
|
359
|
+
...s3Patch,
|
|
259
360
|
...(allowFrom ? { allowFrom } : {}),
|
|
260
361
|
...(dmPolicy ? { dmPolicy } : {}),
|
|
261
362
|
});
|
package/src/s3.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
5
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
6
|
+
import { extensionForMime } from "openclaw/plugin-sdk";
|
|
7
|
+
|
|
8
|
+
import type { GeweAccountConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_S3_KEY_PREFIX = "gewe-openclaw/outbound";
|
|
11
|
+
const DEFAULT_PRESIGN_EXPIRES_SEC = 3600;
|
|
12
|
+
|
|
13
|
+
export type ResolvedS3Config = {
|
|
14
|
+
endpoint: string;
|
|
15
|
+
region: string;
|
|
16
|
+
bucket: string;
|
|
17
|
+
accessKeyId: string;
|
|
18
|
+
secretAccessKey: string;
|
|
19
|
+
sessionToken?: string;
|
|
20
|
+
forcePathStyle: boolean;
|
|
21
|
+
publicBaseUrl?: string;
|
|
22
|
+
keyPrefix: string;
|
|
23
|
+
urlMode: "public" | "presigned";
|
|
24
|
+
presignExpiresSec: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function trimTrailingSlash(value: string): string {
|
|
28
|
+
return value.replace(/\/+$/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizePrefix(value?: string): string {
|
|
32
|
+
const raw = value?.trim() || DEFAULT_S3_KEY_PREFIX;
|
|
33
|
+
return raw.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveS3Config(config: GeweAccountConfig): ResolvedS3Config | null {
|
|
37
|
+
if (config.s3Enabled !== true) return null;
|
|
38
|
+
const endpoint = config.s3Endpoint?.trim();
|
|
39
|
+
const region = config.s3Region?.trim();
|
|
40
|
+
const bucket = config.s3Bucket?.trim();
|
|
41
|
+
const accessKeyId = config.s3AccessKeyId?.trim();
|
|
42
|
+
const secretAccessKey = config.s3SecretAccessKey?.trim();
|
|
43
|
+
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
|
44
|
+
throw new Error("s3Enabled=true but S3 credentials or endpoint is incomplete");
|
|
45
|
+
}
|
|
46
|
+
const urlMode = config.s3UrlMode ?? "public";
|
|
47
|
+
const publicBaseUrl = config.s3PublicBaseUrl?.trim();
|
|
48
|
+
if (urlMode === "public" && !publicBaseUrl) {
|
|
49
|
+
throw new Error("s3PublicBaseUrl is required when s3UrlMode=public");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
endpoint: trimTrailingSlash(endpoint),
|
|
53
|
+
region,
|
|
54
|
+
bucket,
|
|
55
|
+
accessKeyId,
|
|
56
|
+
secretAccessKey,
|
|
57
|
+
sessionToken: config.s3SessionToken?.trim() || undefined,
|
|
58
|
+
forcePathStyle: config.s3ForcePathStyle === true,
|
|
59
|
+
publicBaseUrl: publicBaseUrl ? trimTrailingSlash(publicBaseUrl) : undefined,
|
|
60
|
+
keyPrefix: normalizePrefix(config.s3KeyPrefix),
|
|
61
|
+
urlMode,
|
|
62
|
+
presignExpiresSec:
|
|
63
|
+
config.s3PresignExpiresSec && config.s3PresignExpiresSec > 0
|
|
64
|
+
? Math.floor(config.s3PresignExpiresSec)
|
|
65
|
+
: DEFAULT_PRESIGN_EXPIRES_SEC,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createClient(config: ResolvedS3Config): S3Client {
|
|
70
|
+
return new S3Client({
|
|
71
|
+
endpoint: config.endpoint,
|
|
72
|
+
region: config.region,
|
|
73
|
+
forcePathStyle: config.forcePathStyle,
|
|
74
|
+
credentials: {
|
|
75
|
+
accessKeyId: config.accessKeyId,
|
|
76
|
+
secretAccessKey: config.secretAccessKey,
|
|
77
|
+
...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inferExtension(fileName?: string, contentType?: string): string {
|
|
83
|
+
const byName = fileName ? path.extname(fileName).toLowerCase() : "";
|
|
84
|
+
if (byName) return byName;
|
|
85
|
+
const byMime = contentType ? extensionForMime(contentType) : "";
|
|
86
|
+
return byMime || "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildS3ObjectKey(params: {
|
|
90
|
+
accountId: string;
|
|
91
|
+
config: ResolvedS3Config;
|
|
92
|
+
fileName?: string;
|
|
93
|
+
contentType?: string;
|
|
94
|
+
}): string {
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const yyyy = String(now.getUTCFullYear());
|
|
97
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
98
|
+
const dd = String(now.getUTCDate()).padStart(2, "0");
|
|
99
|
+
const ext = inferExtension(params.fileName, params.contentType);
|
|
100
|
+
return [
|
|
101
|
+
params.config.keyPrefix,
|
|
102
|
+
params.accountId,
|
|
103
|
+
yyyy,
|
|
104
|
+
mm,
|
|
105
|
+
dd,
|
|
106
|
+
`${randomUUID()}${ext}`,
|
|
107
|
+
].join("/");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildPublicUrl(config: ResolvedS3Config, key: string): string {
|
|
111
|
+
if (!config.publicBaseUrl) {
|
|
112
|
+
throw new Error("s3PublicBaseUrl missing");
|
|
113
|
+
}
|
|
114
|
+
return `${config.publicBaseUrl}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function uploadToS3(params: {
|
|
118
|
+
config: ResolvedS3Config;
|
|
119
|
+
accountId: string;
|
|
120
|
+
buffer: Buffer;
|
|
121
|
+
contentType?: string;
|
|
122
|
+
fileName?: string;
|
|
123
|
+
}): Promise<{ key: string; url: string }> {
|
|
124
|
+
const key = buildS3ObjectKey({
|
|
125
|
+
accountId: params.accountId,
|
|
126
|
+
config: params.config,
|
|
127
|
+
fileName: params.fileName,
|
|
128
|
+
contentType: params.contentType,
|
|
129
|
+
});
|
|
130
|
+
const client = createClient(params.config);
|
|
131
|
+
const put = new PutObjectCommand({
|
|
132
|
+
Bucket: params.config.bucket,
|
|
133
|
+
Key: key,
|
|
134
|
+
Body: params.buffer,
|
|
135
|
+
...(params.contentType ? { ContentType: params.contentType } : {}),
|
|
136
|
+
});
|
|
137
|
+
await client.send(put);
|
|
138
|
+
if (params.config.urlMode === "public") {
|
|
139
|
+
return { key, url: buildPublicUrl(params.config, key) };
|
|
140
|
+
}
|
|
141
|
+
const getCommand = new GetObjectCommand({
|
|
142
|
+
Bucket: params.config.bucket,
|
|
143
|
+
Key: key,
|
|
144
|
+
});
|
|
145
|
+
const url = await getSignedUrl(client, getCommand, {
|
|
146
|
+
expiresIn: params.config.presignExpiresSec,
|
|
147
|
+
});
|
|
148
|
+
return { key, url };
|
|
149
|
+
}
|
package/src/silk.ts
CHANGED
|
@@ -30,6 +30,7 @@ type ResolvedVersion = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
const installCache = new Map<string, Promise<string | null>>();
|
|
33
|
+
const resolvedPathCache = new Map<string, string | null>();
|
|
33
34
|
|
|
34
35
|
export function buildRustSilkEncodeArgs(params: {
|
|
35
36
|
input: string;
|
|
@@ -94,6 +95,10 @@ export async function ensureRustSilkBinary(
|
|
|
94
95
|
process.arch,
|
|
95
96
|
].join("|");
|
|
96
97
|
|
|
98
|
+
if (resolvedPathCache.has(cacheKey)) {
|
|
99
|
+
return resolvedPathCache.get(cacheKey) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
97
102
|
if (installCache.has(cacheKey)) {
|
|
98
103
|
return installCache.get(cacheKey) ?? null;
|
|
99
104
|
}
|
|
@@ -106,9 +111,14 @@ export async function ensureRustSilkBinary(
|
|
|
106
111
|
folder,
|
|
107
112
|
isLatest: resolved.isLatest,
|
|
108
113
|
resolvedTag: resolved.resolvedTag,
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
})
|
|
115
|
+
.then((result) => {
|
|
116
|
+
resolvedPathCache.set(cacheKey, result);
|
|
117
|
+
return result;
|
|
118
|
+
})
|
|
119
|
+
.finally(() => {
|
|
120
|
+
installCache.delete(cacheKey);
|
|
121
|
+
});
|
|
112
122
|
installCache.set(cacheKey, installPromise);
|
|
113
123
|
return installPromise;
|
|
114
124
|
}
|
package/src/types.ts
CHANGED
|
@@ -33,10 +33,23 @@ export type GeweAccountConfig = {
|
|
|
33
33
|
mediaPath?: string;
|
|
34
34
|
mediaPublicUrl?: string;
|
|
35
35
|
mediaMaxMb?: number;
|
|
36
|
+
s3Enabled?: boolean;
|
|
37
|
+
s3Endpoint?: string;
|
|
38
|
+
s3Region?: string;
|
|
39
|
+
s3Bucket?: string;
|
|
40
|
+
s3AccessKeyId?: string;
|
|
41
|
+
s3SecretAccessKey?: string;
|
|
42
|
+
s3SessionToken?: string;
|
|
43
|
+
s3ForcePathStyle?: boolean;
|
|
44
|
+
s3PublicBaseUrl?: string;
|
|
45
|
+
s3KeyPrefix?: string;
|
|
46
|
+
s3UrlMode?: "public" | "presigned";
|
|
47
|
+
s3PresignExpiresSec?: number;
|
|
36
48
|
voiceAutoConvert?: boolean;
|
|
37
49
|
voiceFfmpegPath?: string;
|
|
38
50
|
voiceSilkPath?: string;
|
|
39
51
|
voiceSilkArgs?: string[];
|
|
52
|
+
voiceSilkPipe?: boolean;
|
|
40
53
|
voiceSampleRate?: number;
|
|
41
54
|
voiceDecodePath?: string;
|
|
42
55
|
voiceDecodeArgs?: string[];
|