gewe-openclaw 2026.2.2 → 2026.2.4

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` 等信息。
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,18 +66,13 @@ 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
+ "allowFrom": ["wxid_xxx"]
65
70
  }
66
71
  }
67
72
  }
68
73
  ```
69
74
 
70
- 说明:
75
+ 完整参数说明:
71
76
  - `webhookHost/webhookPort/webhookPath`:GeWe 回调入口(需公网可达,常配合 FRP)。
72
77
  - `mediaPath`:本地媒体服务的路由前缀(默认 `/gewe-media`)。
73
78
  - `mediaPublicUrl`:公网访问地址的“基础前缀”,会自动拼接媒体 ID。通常应与 `mediaPath` 对齐,例如 `mediaPath="/gewe-media"` 时,`mediaPublicUrl` 也应包含 `/gewe-media`。
@@ -79,18 +84,28 @@ openclaw plugins install ./gewe-openclaw.tgz
79
84
  - `silkInstallDir`:自定义安装目录(默认 `~/.openclaw/tools/rust-silk/<version>`)。
80
85
  - `silkAllowUnverified`:校验文件缺失时是否允许继续(默认 `false`)。
81
86
  - `silkSha256`:手动指定下载包 SHA256(用于私有源或校验文件缺失场景)。
87
+ - `apiBaseUrl`:GeWe API 地址(默认 `https://www.geweapi.com`)。
88
+ - `voiceFfmpegPath`/`videoFfmpegPath`/`videoFfprobePath`:自定义 ffmpeg/ffprobe 路径。
89
+ - `voiceSilkPath`/`voiceSilkArgs`:自定义 silk 编码器路径和参数(不使用自动下载时)。
90
+ - `voiceSilkPipe`:是否启用 ffmpeg+rust-silk 的 stdin/stdout 管道(默认关闭;失败会回退到临时文件)。
91
+ - 低频/非高并发且磁盘压力不高时,推荐临时文件方案(更稳定/更快)。
92
+ - 高频/多并发或磁盘压力大时,推荐 pipe 方案(减少磁盘 IO)。
93
+ - `voiceDecodePath`/`voiceDecodeArgs`/`voiceDecodeOutput`:自定义 silk 解码器(入站语音转写用)。
94
+ - `mediaMaxMb`:上传媒体大小上限(默认 20MB)。
95
+ - `downloadMinDelayMs`/`downloadMaxDelayMs`:入站媒体下载节流。
82
96
 
83
97
  > 配置变更后需重启 Gateway。
84
98
 
85
- ## onboarding 列表中显示(可选)
99
+ ## 高级用法:让未安装插件也出现在 onboarding 列表
86
100
 
87
- OpenClaw 支持外部插件目录(catalog)。放置到以下路径即可被 onboarding 读取:
101
+ 默认情况下,**只有已安装的插件**会出现在 onboarding 列表中。
102
+ 如果你希望“未安装时也能在列表中展示”,需要配置本地 catalog:
88
103
 
89
104
  ```
90
105
  ~/.openclaw/plugins/catalog.json
91
106
  ```
92
107
 
93
- 示例(只需添加一次):
108
+ 示例(添加一次即可):
94
109
 
95
110
  ```json
96
111
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gewe-openclaw",
3
- "version": "2026.2.2",
3
+ "version": "2026.2.4",
4
4
  "type": "module",
5
5
  "description": "OpenClaw GeWe channel plugin",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@ export const GeweAccountSchemaBase = z
44
44
  voiceFfmpegPath: z.string().optional(),
45
45
  voiceSilkPath: z.string().optional(),
46
46
  voiceSilkArgs: z.array(z.string()).optional(),
47
+ voiceSilkPipe: z.boolean().optional(),
47
48
  voiceSampleRate: z.number().int().positive().optional(),
48
49
  voiceDecodePath: z.string().optional(),
49
50
  voiceDecodeArgs: z.array(z.string()).optional(),
package/src/delivery.ts CHANGED
@@ -1,6 +1,8 @@
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";
@@ -246,6 +248,80 @@ function resolveSilkArgs(params: {
246
248
  return next;
247
249
  }
248
250
 
251
+ async function encodeSilkWithPipes(params: {
252
+ ffmpegPath: string;
253
+ ffmpegArgs: string[];
254
+ silkPath: string;
255
+ silkArgs: string[];
256
+ timeoutMs: number;
257
+ sampleRate: number;
258
+ }): Promise<{ buffer: Buffer; durationMs: number }> {
259
+ const ffmpeg = spawn(params.ffmpegPath, params.ffmpegArgs, {
260
+ stdio: ["ignore", "pipe", "pipe"],
261
+ });
262
+ const silk = spawn(params.silkPath, params.silkArgs, {
263
+ stdio: ["pipe", "pipe", "pipe"],
264
+ });
265
+
266
+ let pcmBytes = 0;
267
+ const pass = new PassThrough();
268
+ ffmpeg.stdout?.pipe(pass);
269
+ pass.on("data", (chunk) => {
270
+ pcmBytes += chunk.length;
271
+ });
272
+ pass.pipe(silk.stdin!);
273
+
274
+ const silkChunks: Buffer[] = [];
275
+ let ffmpegErr = "";
276
+ let silkErr = "";
277
+
278
+ ffmpeg.stderr?.on("data", (d) => {
279
+ ffmpegErr += d.toString();
280
+ });
281
+ silk.stderr?.on("data", (d) => {
282
+ silkErr += d.toString();
283
+ });
284
+ silk.stdout?.on("data", (d) => {
285
+ silkChunks.push(Buffer.from(d));
286
+ });
287
+
288
+ const timer = setTimeout(() => {
289
+ ffmpeg.kill("SIGKILL");
290
+ silk.kill("SIGKILL");
291
+ }, params.timeoutMs);
292
+
293
+ const [ffmpegRes, silkRes] = await Promise.all([
294
+ new Promise<{ code: number | null }>((resolve, reject) => {
295
+ ffmpeg.on("error", reject);
296
+ ffmpeg.on("close", (code) => resolve({ code }));
297
+ }),
298
+ new Promise<{ code: number | null }>((resolve, reject) => {
299
+ silk.on("error", reject);
300
+ silk.on("close", (code) => resolve({ code }));
301
+ }),
302
+ ]).finally(() => {
303
+ clearTimeout(timer);
304
+ });
305
+
306
+ if (ffmpegRes.code !== 0) {
307
+ throw new Error(`ffmpeg failed: ${ffmpegErr.trim() || `exit code ${ffmpegRes.code ?? "?"}`}`);
308
+ }
309
+ if (silkRes.code !== 0) {
310
+ throw new Error(`silk encoder failed: ${silkErr.trim() || `exit code ${silkRes.code ?? "?"}`}`);
311
+ }
312
+
313
+ const buffer = Buffer.concat(silkChunks);
314
+ if (!buffer.length) {
315
+ throw new Error("silk encoder produced empty output");
316
+ }
317
+
318
+ const durationMs = Math.max(
319
+ 1,
320
+ Math.round((pcmBytes / (params.sampleRate * PCM_BYTES_PER_SAMPLE)) * 1000),
321
+ );
322
+ return { buffer, durationMs };
323
+ }
324
+
249
325
  async function convertAudioToSilk(params: {
250
326
  account: ResolvedGeweAccount;
251
327
  sourcePath: string;
@@ -278,11 +354,52 @@ async function convertAudioToSilk(params: {
278
354
  params.account.config.voiceSilkArgs?.length ? [params.account.config.voiceSilkArgs] : [];
279
355
  let silkPath = customPath || DEFAULT_VOICE_SILK;
280
356
  let argTemplates = customArgs.length ? customArgs : fallbackArgs;
357
+ const pipeEnabled = params.account.config.voiceSilkPipe === true;
358
+ let usePipe = false;
281
359
  if (!customPath) {
282
360
  const rustSilk = await ensureRustSilkBinary(params.account);
283
361
  if (rustSilk) {
284
362
  silkPath = rustSilk;
285
363
  argTemplates = [rustArgs];
364
+ usePipe = pipeEnabled && customArgs.length === 0;
365
+ }
366
+ }
367
+
368
+ if (usePipe) {
369
+ try {
370
+ const ffmpegArgs = [
371
+ "-y",
372
+ "-i",
373
+ params.sourcePath,
374
+ "-ac",
375
+ "1",
376
+ "-ar",
377
+ String(sampleRate),
378
+ "-f",
379
+ "s16le",
380
+ "pipe:1",
381
+ ];
382
+ const silkArgs = [
383
+ "encode",
384
+ "-i",
385
+ "-",
386
+ "-o",
387
+ "-",
388
+ "--sample-rate",
389
+ String(sampleRate),
390
+ "--tencent",
391
+ "--quiet",
392
+ ];
393
+ return await encodeSilkWithPipes({
394
+ ffmpegPath,
395
+ ffmpegArgs,
396
+ silkPath,
397
+ silkArgs,
398
+ timeoutMs: DEFAULT_VOICE_TIMEOUT_MS,
399
+ sampleRate,
400
+ });
401
+ } catch (err) {
402
+ logger.warn?.(`gewe voice convert pipe failed: ${String(err)}`);
286
403
  }
287
404
  }
288
405
 
@@ -497,6 +614,21 @@ async function resolveLinkThumbUrl(params: {
497
614
  }
498
615
  }
499
616
 
617
+ function normalizeMediaToken(raw: string): string {
618
+ let value = raw.trim();
619
+ if (value.toUpperCase().startsWith("MEDIA:")) {
620
+ value = value.slice("MEDIA:".length).trim();
621
+ }
622
+ if (
623
+ (value.startsWith("`") && value.endsWith("`")) ||
624
+ (value.startsWith("\"") && value.endsWith("\"")) ||
625
+ (value.startsWith("'") && value.endsWith("'"))
626
+ ) {
627
+ value = value.slice(1, -1).trim();
628
+ }
629
+ return value;
630
+ }
631
+
500
632
  async function stageMedia(params: {
501
633
  account: ResolvedGeweAccount;
502
634
  cfg: OpenClawConfig;
@@ -504,7 +636,7 @@ async function stageMedia(params: {
504
636
  allowRemote: boolean;
505
637
  }): Promise<ResolvedMedia> {
506
638
  const core = getGeweRuntime();
507
- const rawUrl = params.mediaUrl.trim();
639
+ const rawUrl = normalizeMediaToken(params.mediaUrl);
508
640
  if (!rawUrl) throw new Error("mediaUrl is empty");
509
641
 
510
642
  if (looksLikeHttpUrl(rawUrl) && params.allowRemote) {
@@ -598,6 +730,7 @@ export async function deliverGewePayload(params: {
598
730
  const trimmedText = payload.text?.trim() ?? "";
599
731
  const mediaUrl =
600
732
  payload.mediaUrl?.trim() || payload.mediaUrls?.[0]?.trim() || "";
733
+ const normalizedMediaUrl = normalizeMediaToken(mediaUrl);
601
734
 
602
735
  if (geweData?.link) {
603
736
  const link = geweData.link;
@@ -625,12 +758,12 @@ export async function deliverGewePayload(params: {
625
758
  if (mediaUrl) {
626
759
  const audioAsVoice = payload.audioAsVoice === true;
627
760
  const forceFile = geweData?.forceFile === true;
628
- const ttsVoiceHint = !forceFile && looksLikeTtsVoiceMediaUrl(mediaUrl);
761
+ const ttsVoiceHint = !forceFile && looksLikeTtsVoiceMediaUrl(normalizedMediaUrl);
629
762
  const wantsVoice = !forceFile && (audioAsVoice || ttsVoiceHint);
630
763
  const staged = await stageMedia({
631
764
  account,
632
765
  cfg,
633
- mediaUrl,
766
+ mediaUrl: normalizedMediaUrl,
634
767
  allowRemote: !wantsVoice,
635
768
  });
636
769
  const contentType = staged.contentType;
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/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
@@ -37,6 +37,7 @@ export type GeweAccountConfig = {
37
37
  voiceFfmpegPath?: string;
38
38
  voiceSilkPath?: string;
39
39
  voiceSilkArgs?: string[];
40
+ voiceSilkPipe?: boolean;
40
41
  voiceSampleRate?: number;
41
42
  voiceDecodePath?: string;
42
43
  voiceDecodeArgs?: string[];