gewe-openclaw 2026.2.4 → 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
@@ -42,7 +42,7 @@ openclaw plugins install ./gewe-openclaw.tgz
42
42
  openclaw onboard
43
43
  ```
44
44
 
45
- 在通道列表中选择 **GeWe**,按提示填写 `token`、`appId`、`webhook` `mediaPublicUrl` 等信息。
45
+ 在通道列表中选择 **GeWe**,按提示填写 `token`、`appId`、`webhook`,以及可选的 `mediaPublicUrl`/`S3` 媒体配置。
46
46
 
47
47
  ### 方式 B:直接编辑配置文件
48
48
 
@@ -66,6 +66,15 @@ openclaw onboard
66
66
  "mediaPort": 4400,
67
67
  "mediaPath": "/gewe-media",
68
68
  "mediaPublicUrl": "https://your-public-domain/gewe-media",
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",
69
78
  "allowFrom": ["wxid_xxx"]
70
79
  }
71
80
  }
@@ -75,7 +84,15 @@ openclaw onboard
75
84
  完整参数说明:
76
85
  - `webhookHost/webhookPort/webhookPath`:GeWe 回调入口(需公网可达,常配合 FRP)。
77
86
  - `mediaPath`:本地媒体服务的路由前缀(默认 `/gewe-media`)。
78
- - `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`)。
79
96
  - `allowFrom`:允许私聊触发的微信 ID(或在群里走 allowlist 规则)。
80
97
  - `voiceAutoConvert`:自动将音频转为 silk(默认开启;设为 `false` 可关闭)。
81
98
  - `silkAutoDownload`:自动下载 `rust-silk`(默认开启;可关闭后自行配置 `voiceSilkPath` / `voiceDecodePath`)。
@@ -94,6 +111,10 @@ openclaw onboard
94
111
  - `mediaMaxMb`:上传媒体大小上限(默认 20MB)。
95
112
  - `downloadMinDelayMs`/`downloadMaxDelayMs`:入站媒体下载节流。
96
113
 
114
+ 发送媒体时的 URL 策略:
115
+ - 本地文件:优先上传 S3,失败回退 `mediaPublicUrl` 本地反代。
116
+ - 公网 URL:先尝试原 URL 发送,失败后再尝试上传 S3,仍失败回退本地反代。
117
+
97
118
  > 配置变更后需重启 Gateway。
98
119
 
99
120
  ## 高级用法:让未安装插件也出现在 onboarding 列表
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gewe-openclaw",
3
- "version": "2026.2.4",
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,6 +40,18 @@ 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(),
@@ -85,6 +97,37 @@ export const GeweAccountSchemaBase = z
85
97
  message: "downloadMaxDelayMs must be >= downloadMinDelayMs",
86
98
  });
87
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
+ }
88
131
  });
89
132
 
90
133
  export const GeweAccountSchema = GeweAccountSchemaBase.superRefine((value, ctx) => {
package/src/delivery.ts CHANGED
@@ -9,6 +9,7 @@ import type { OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
9
9
  import { extractOriginalFilename, extensionForMime } from "openclaw/plugin-sdk";
10
10
  import { CHANNEL_ID } from "./constants.js";
11
11
  import { getGeweRuntime } from "./runtime.js";
12
+ import { resolveS3Config, uploadToS3 } from "./s3.js";
12
13
  import { ensureRustSilkBinary } from "./silk.js";
13
14
  import {
14
15
  sendFileGewe,
@@ -43,6 +44,9 @@ type ResolvedMedia = {
43
44
  contentType?: string;
44
45
  fileName?: string;
45
46
  localPath?: string;
47
+ sourceKind: "remote" | "local";
48
+ sourceUrl: string;
49
+ provider: "direct" | "s3" | "proxy";
46
50
  };
47
51
 
48
52
  const LINK_THUMB_MAX_BYTES = 50 * 1024;
@@ -94,6 +98,21 @@ function buildPublicUrl(baseUrl: string, id: string): string {
94
98
  return `${trimmed}/${encodeURIComponent(id)}`;
95
99
  }
96
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
+
97
116
  function resolveMediaMaxBytes(account: ResolvedGeweAccount): number {
98
117
  const maxMb = account.config.mediaMaxMb;
99
118
  if (typeof maxMb === "number" && maxMb > 0) return Math.floor(maxMb * 1024 * 1024);
@@ -553,11 +572,6 @@ async function stageThumbBuffer(params: {
553
572
  fileName?: string;
554
573
  }): Promise<string> {
555
574
  const core = getGeweRuntime();
556
- const publicBase = params.account.config.mediaPublicUrl?.trim();
557
- if (!publicBase) {
558
- throw new Error("mediaPublicUrl not configured (required for link thumbnails)");
559
- }
560
-
561
575
  const normalized = await normalizeThumbBuffer({
562
576
  buffer: params.buffer,
563
577
  contentType: params.contentType,
@@ -566,6 +580,31 @@ async function stageThumbBuffer(params: {
566
580
  throw new Error("link thumbnail exceeds 50KB after resize");
567
581
  }
568
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
+ }
569
608
  const saved = await core.channel.media.saveMediaBuffer(
570
609
  normalized.buffer,
571
610
  normalized.contentType,
@@ -638,26 +677,27 @@ async function stageMedia(params: {
638
677
  const core = getGeweRuntime();
639
678
  const rawUrl = normalizeMediaToken(params.mediaUrl);
640
679
  if (!rawUrl) throw new Error("mediaUrl is empty");
641
-
642
- if (looksLikeHttpUrl(rawUrl) && params.allowRemote) {
680
+ const isRemote = looksLikeHttpUrl(rawUrl);
681
+ if (isRemote && params.allowRemote) {
643
682
  const contentType = await core.media.detectMime({ filePath: rawUrl });
644
683
  const fileName = path.basename(new URL(rawUrl).pathname || "");
645
- return { publicUrl: rawUrl, contentType: contentType ?? undefined, fileName };
646
- }
647
-
648
- const publicBase = params.account.config.mediaPublicUrl?.trim();
649
- if (!publicBase) {
650
- throw new Error(
651
- "mediaPublicUrl not configured (required for local media or forced proxy)",
652
- );
684
+ return {
685
+ publicUrl: rawUrl,
686
+ contentType: contentType ?? undefined,
687
+ fileName,
688
+ sourceKind: "remote",
689
+ sourceUrl: rawUrl,
690
+ provider: "direct",
691
+ };
653
692
  }
654
693
 
655
694
  const maxBytes = resolveMediaMaxBytes(params.account);
656
695
  let buffer: Buffer;
657
696
  let contentType: string | undefined;
658
697
  let fileName: string | undefined;
698
+ let sourceLocalPath: string | undefined;
659
699
 
660
- if (looksLikeHttpUrl(rawUrl)) {
700
+ if (isRemote) {
661
701
  const fetched = await core.channel.media.fetchRemoteMedia({
662
702
  url: rawUrl,
663
703
  maxBytes,
@@ -671,6 +711,41 @@ async function stageMedia(params: {
671
711
  buffer = await fs.readFile(localPath);
672
712
  contentType = await core.media.detectMime({ buffer, filePath: localPath });
673
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)");
674
749
  }
675
750
 
676
751
  const saved = await core.channel.media.saveMediaBuffer(
@@ -684,8 +759,7 @@ async function stageMedia(params: {
684
759
  let resolvedId = saved.id;
685
760
  let resolvedPath = saved.path;
686
761
  const desiredExt =
687
- extensionForMime(contentType ?? saved.contentType) ||
688
- path.extname(resolvedFileName);
762
+ extensionForMime(contentType ?? saved.contentType) || path.extname(resolvedFileName);
689
763
  if (desiredExt && !path.extname(resolvedId)) {
690
764
  const nextId = `${resolvedId}${desiredExt}`;
691
765
  const nextPath = path.join(path.dirname(saved.path), nextId);
@@ -697,7 +771,10 @@ async function stageMedia(params: {
697
771
  publicUrl: buildPublicUrl(publicBase, resolvedId),
698
772
  contentType: contentType ?? saved.contentType,
699
773
  fileName: resolvedFileName || resolvedId,
700
- localPath: resolvedPath,
774
+ localPath: sourceLocalPath ?? resolvedPath,
775
+ sourceKind: isRemote ? "remote" : "local",
776
+ sourceUrl: rawUrl,
777
+ provider: "proxy",
701
778
  };
702
779
  }
703
780
 
@@ -716,6 +793,29 @@ async function resolvePublicUrl(params: {
716
793
  return staged.publicUrl;
717
794
  }
718
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
+
719
819
  export async function deliverGewePayload(params: {
720
820
  payload: ReplyPayload;
721
821
  account: ResolvedGeweAccount;
@@ -774,12 +874,34 @@ export async function deliverGewePayload(params: {
774
874
  const declaredDuration = resolveVoiceDurationMs(geweData);
775
875
  if (isSilkAudio({ contentType, fileName })) {
776
876
  if (declaredDuration) {
777
- const result = await sendVoiceGewe({
778
- account,
779
- toWxid,
780
- voiceUrl: staged.publicUrl,
781
- voiceDuration: declaredDuration,
782
- });
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
+ }
783
905
  core.channel.activity.record({
784
906
  channel: CHANNEL_ID,
785
907
  accountId: account.accountId,
@@ -795,21 +917,56 @@ export async function deliverGewePayload(params: {
795
917
  });
796
918
  if (converted) {
797
919
  const voiceDuration = declaredDuration ?? converted.durationMs;
798
- const publicBase = account.config.mediaPublicUrl?.trim();
799
- if (!publicBase) {
800
- 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);
801
965
  }
802
- const saved = await core.channel.media.saveMediaBuffer(
803
- converted.buffer,
804
- "audio/silk",
805
- "outbound",
806
- resolveMediaMaxBytes(account),
807
- "voice.silk",
808
- );
809
966
  const result = await sendVoiceGewe({
810
967
  account,
811
968
  toWxid,
812
- voiceUrl: buildPublicUrl(publicBase, saved.id),
969
+ voiceUrl,
813
970
  voiceDuration,
814
971
  });
815
972
  core.channel.activity.record({
@@ -824,11 +981,32 @@ export async function deliverGewePayload(params: {
824
981
  }
825
982
 
826
983
  if (!forceFile && kind === "image") {
827
- const result = await sendImageGewe({
828
- account,
829
- toWxid,
830
- imgUrl: staged.publicUrl,
831
- });
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
+ }
832
1010
  core.channel.activity.record({
833
1011
  channel: CHANNEL_ID,
834
1012
  accountId: account.accountId,
@@ -902,6 +1080,11 @@ export async function deliverGewePayload(params: {
902
1080
  url: thumbUrl,
903
1081
  allowRemote: true,
904
1082
  });
1083
+ const canRetryMediaFallback = shouldRetryWithStagedFallback({
1084
+ originalUrl: normalizedMediaUrl,
1085
+ staged: stagedVideo,
1086
+ account,
1087
+ });
905
1088
  try {
906
1089
  const result = await sendVideoGewe({
907
1090
  account,
@@ -918,6 +1101,33 @@ export async function deliverGewePayload(params: {
918
1101
  statusSink?.({ lastOutboundAt: Date.now() });
919
1102
  return result;
920
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
+ }
921
1131
  if (fallbackThumbUrl && fallbackThumbUrl !== thumbUrl) {
922
1132
  logger.warn?.(
923
1133
  `gewe video send failed with primary thumb, retrying fallback: ${String(err)}`,
@@ -952,12 +1162,34 @@ export async function deliverGewePayload(params: {
952
1162
  geweData?.fileName ||
953
1163
  fileName ||
954
1164
  (contentType ? `file${contentType.includes("/") ? `.${contentType.split("/")[1]}` : ""}` : "file");
955
- const result = await sendFileGewe({
956
- account,
957
- toWxid,
958
- fileUrl: staged.publicUrl,
959
- fileName: fallbackName,
960
- });
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
+ }
961
1193
  core.channel.activity.record({
962
1194
  channel: CHANNEL_ID,
963
1195
  accountId: account.accountId,
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/types.ts CHANGED
@@ -33,6 +33,18 @@ 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;