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 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
- "allowFrom": ["wxid_xxx"],
60
- "silkAutoDownload": true,
61
- "silkVersion": "latest",
62
- "silkBaseUrl": "https://github.com/Wangnov/rust-silk/releases/download",
63
- "silkInstallDir": "~/.openclaw/tools/rust-silk",
64
- "silkAllowUnverified": false
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`:公网访问地址的“基础前缀”,会自动拼接媒体 ID。通常应与 `mediaPath` 对齐,例如 `mediaPath="/gewe-media"` 时,`mediaPublicUrl` 也应包含 `/gewe-media`。
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
- ## onboarding 列表中显示(可选)
120
+ ## 高级用法:让未安装插件也出现在 onboarding 列表
86
121
 
87
- OpenClaw 支持外部插件目录(catalog)。放置到以下路径即可被 onboarding 读取:
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",
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": {
@@ -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 (looksLikeHttpUrl(rawUrl) && params.allowRemote) {
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 { publicUrl: rawUrl, contentType: contentType ?? undefined, fileName };
529
- }
530
-
531
- const publicBase = params.account.config.mediaPublicUrl?.trim();
532
- if (!publicBase) {
533
- throw new Error(
534
- "mediaPublicUrl not configured (required for local media or forced proxy)",
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 (looksLikeHttpUrl(rawUrl)) {
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
- const result = await sendVoiceGewe({
661
- account,
662
- toWxid,
663
- voiceUrl: staged.publicUrl,
664
- voiceDuration: declaredDuration,
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
- const publicBase = account.config.mediaPublicUrl?.trim();
682
- if (!publicBase) {
683
- throw new Error("mediaPublicUrl not configured (required for silk voice)");
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: buildPublicUrl(publicBase, saved.id),
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
- const result = await sendImageGewe({
711
- account,
712
- toWxid,
713
- imgUrl: staged.publicUrl,
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
- const result = await sendFileGewe({
839
- account,
840
- toWxid,
841
- fileUrl: staged.publicUrl,
842
- fileName: fallbackName,
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 (for sending voice/media)",
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
- }).finally(() => {
110
- installCache.delete(cacheKey);
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[];