gewe-openclaw 2026.3.11 → 2026.3.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gewe-openclaw",
3
- "version": "2026.3.11",
3
+ "version": "2026.3.12",
4
4
  "type": "module",
5
5
  "description": "OpenClaw GeWe channel plugin",
6
6
  "license": "MIT",
@@ -0,0 +1,188 @@
1
+ import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { buildAgentMediaPayload } from "openclaw/plugin-sdk";
3
+
4
+ import type { GeweInboundMessage } from "./types.js";
5
+
6
+ const CHANNEL_ID = "gewe-openclaw" as const;
7
+ const DEFAULT_GEWE_INBOUND_DEBOUNCE_MS = 1000;
8
+
9
+ type DebounceBuffer = {
10
+ messages: GeweInboundMessage[];
11
+ timeout: ReturnType<typeof setTimeout> | null;
12
+ debounceMs: number;
13
+ };
14
+
15
+ function normalizeMs(value: unknown): number | undefined {
16
+ if (typeof value !== "number" || !Number.isFinite(value)) {
17
+ return undefined;
18
+ }
19
+ return Math.max(0, Math.trunc(value));
20
+ }
21
+
22
+ function resolveGroupConversationId(message: GeweInboundMessage): string | undefined {
23
+ if (message.fromId.endsWith("@chatroom")) {
24
+ return message.fromId;
25
+ }
26
+ if (message.toId.endsWith("@chatroom")) {
27
+ return message.toId;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ export function resolveGeweInboundDebounceMs(cfg: OpenClawConfig): number {
33
+ const inbound = cfg.messages?.inbound;
34
+ const byChannel = normalizeMs(inbound?.byChannel?.[CHANNEL_ID]);
35
+ const global = normalizeMs(inbound?.debounceMs);
36
+ return byChannel ?? global ?? DEFAULT_GEWE_INBOUND_DEBOUNCE_MS;
37
+ }
38
+
39
+ export function buildGeweInboundDebounceKey(params: {
40
+ accountId: string;
41
+ message: GeweInboundMessage;
42
+ }): string | null {
43
+ const conversationId = params.message.isGroupChat
44
+ ? resolveGroupConversationId(params.message)
45
+ : params.message.senderId || params.message.fromId || params.message.toId;
46
+ const senderId = params.message.senderId?.trim();
47
+ const accountId = params.accountId.trim();
48
+ if (!accountId || !conversationId || !senderId) {
49
+ return null;
50
+ }
51
+ return `${CHANNEL_ID}:${accountId}:${conversationId}:${senderId}`;
52
+ }
53
+
54
+ export function resolveGeweInboundDebounceText(message: GeweInboundMessage): string {
55
+ const text = message.text?.trim() ?? "";
56
+ if (text) {
57
+ return text;
58
+ }
59
+ if (message.msgType === 3) return "<media:image>";
60
+ if (message.msgType === 34) return "<media:audio>";
61
+ if (message.msgType === 43) return "<media:video>";
62
+ if (message.msgType === 49) return "<media:document>";
63
+ return "";
64
+ }
65
+
66
+ export function buildGeweInboundMessageMeta(messages: GeweInboundMessage[]): {
67
+ messageSid?: string;
68
+ messageSidFull?: string;
69
+ messageSids?: string[];
70
+ messageSidFirst?: string;
71
+ messageSidLast?: string;
72
+ timestamp?: number;
73
+ } {
74
+ const ids = messages
75
+ .map((message) => message.newMessageId?.trim() || message.messageId?.trim())
76
+ .filter(Boolean) as string[];
77
+ const lastMessage = messages.at(-1);
78
+ const firstId = ids[0];
79
+ const lastId = ids.at(-1);
80
+
81
+ return {
82
+ messageSid: lastId,
83
+ messageSidFull: lastId,
84
+ messageSids: ids.length > 1 ? ids : undefined,
85
+ messageSidFirst: ids.length > 1 ? firstId : undefined,
86
+ messageSidLast: ids.length > 1 ? lastId : undefined,
87
+ timestamp: lastMessage?.timestamp,
88
+ };
89
+ }
90
+
91
+ export function buildGeweInboundMediaPayload(
92
+ mediaList: Array<{ path: string; contentType?: string | null }>,
93
+ ): AgentMediaPayload {
94
+ if (mediaList.length === 0) {
95
+ return {};
96
+ }
97
+ return buildAgentMediaPayload(mediaList);
98
+ }
99
+
100
+ export function createGeweInboundDebouncer(params: {
101
+ cfg: OpenClawConfig;
102
+ accountId: string;
103
+ isControlCommand: (text: string) => boolean;
104
+ onFlush: (messages: GeweInboundMessage[]) => Promise<void>;
105
+ onError?: (err: unknown, messages: GeweInboundMessage[]) => void;
106
+ }) {
107
+ const buffers = new Map<string, DebounceBuffer>();
108
+
109
+ const flushBuffer = async (key: string, buffer: DebounceBuffer) => {
110
+ buffers.delete(key);
111
+ if (buffer.timeout) {
112
+ clearTimeout(buffer.timeout);
113
+ buffer.timeout = null;
114
+ }
115
+ if (buffer.messages.length === 0) {
116
+ return;
117
+ }
118
+ try {
119
+ await params.onFlush(buffer.messages);
120
+ } catch (err) {
121
+ params.onError?.(err, buffer.messages);
122
+ }
123
+ };
124
+
125
+ const flushKey = async (key: string) => {
126
+ const buffer = buffers.get(key);
127
+ if (!buffer) {
128
+ return;
129
+ }
130
+ await flushBuffer(key, buffer);
131
+ };
132
+
133
+ const scheduleFlush = (key: string, buffer: DebounceBuffer) => {
134
+ if (buffer.timeout) {
135
+ clearTimeout(buffer.timeout);
136
+ }
137
+ buffer.timeout = setTimeout(() => {
138
+ void flushBuffer(key, buffer);
139
+ }, buffer.debounceMs);
140
+ buffer.timeout.unref?.();
141
+ };
142
+
143
+ const enqueue = async (message: GeweInboundMessage) => {
144
+ const key = buildGeweInboundDebounceKey({
145
+ accountId: params.accountId,
146
+ message,
147
+ });
148
+ const debounceMs = resolveGeweInboundDebounceMs(params.cfg);
149
+ const canDebounce =
150
+ debounceMs > 0 && !params.isControlCommand(resolveGeweInboundDebounceText(message));
151
+
152
+ if (!canDebounce || !key) {
153
+ if (key && buffers.has(key)) {
154
+ await flushKey(key);
155
+ }
156
+ try {
157
+ await params.onFlush([message]);
158
+ } catch (err) {
159
+ params.onError?.(err, [message]);
160
+ }
161
+ return;
162
+ }
163
+
164
+ const existing = buffers.get(key);
165
+ if (existing) {
166
+ existing.messages.push(message);
167
+ existing.debounceMs = debounceMs;
168
+ scheduleFlush(key, existing);
169
+ return;
170
+ }
171
+
172
+ const buffer: DebounceBuffer = {
173
+ messages: [message],
174
+ timeout: null,
175
+ debounceMs,
176
+ };
177
+ buffers.set(key, buffer);
178
+ scheduleFlush(key, buffer);
179
+ };
180
+
181
+ const flushAll = async () => {
182
+ for (const [key, buffer] of [...buffers.entries()]) {
183
+ await flushBuffer(key, buffer);
184
+ }
185
+ };
186
+
187
+ return { enqueue, flushKey, flushAll };
188
+ }
package/src/inbound.ts CHANGED
@@ -6,6 +6,10 @@ import path from "node:path";
6
6
  import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
7
7
  import { logInboundDrop, resolveControlCommandGate } from "openclaw/plugin-sdk";
8
8
 
9
+ import {
10
+ buildGeweInboundMediaPayload,
11
+ buildGeweInboundMessageMeta,
12
+ } from "./inbound-batch.js";
9
13
  import type { GeweDownloadQueue } from "./download-queue.js";
10
14
  import { downloadGeweFile, downloadGeweImage, downloadGeweVideo, downloadGeweVoice } from "./download.js";
11
15
  import { deliverGewePayload } from "./delivery.js";
@@ -37,9 +41,21 @@ type PreparedInbound = {
37
41
  storePath: string;
38
42
  toWxid: string;
39
43
  messageSid: string;
44
+ messageSids?: string[];
45
+ messageSidFirst?: string;
46
+ messageSidLast?: string;
40
47
  timestamp?: number;
41
48
  };
42
49
 
50
+ type NormalizedInboundEntry = {
51
+ message: GeweInboundMessage;
52
+ rawBody: string;
53
+ download?: {
54
+ msgType: number;
55
+ xml: string;
56
+ };
57
+ };
58
+
43
59
  const DEFAULT_VOICE_SAMPLE_RATE = 24000;
44
60
  const DEFAULT_VOICE_DECODE_TIMEOUT_MS = 30_000;
45
61
  const SILK_HEADER = "#!SILK_V3";
@@ -364,15 +380,145 @@ function resolveMediaMaxBytes(account: ResolvedGeweAccount): number {
364
380
  return 20 * 1024 * 1024;
365
381
  }
366
382
 
383
+ function resolveGroupConversationId(message: GeweInboundMessage): string | undefined {
384
+ if (message.fromId.endsWith("@chatroom")) {
385
+ return message.fromId;
386
+ }
387
+ if (message.toId.endsWith("@chatroom")) {
388
+ return message.toId;
389
+ }
390
+ return undefined;
391
+ }
392
+
393
+ function normalizeInboundEntry(params: {
394
+ message: GeweInboundMessage;
395
+ runtime: RuntimeEnv;
396
+ }): NormalizedInboundEntry | null {
397
+ const { message, runtime } = params;
398
+ const msgType = message.msgType;
399
+ if (![1, 3, 34, 43, 49].includes(msgType)) {
400
+ runtime.log?.(`gewe: skip unsupported msgType ${msgType}`);
401
+ return null;
402
+ }
403
+
404
+ const { text, xml } = resolveInboundText(message);
405
+ const rawBodyCandidate = (msgType === 1 ? text.trim() : "") || resolveMediaPlaceholder(msgType);
406
+ if (!rawBodyCandidate.trim()) {
407
+ runtime.log?.("gewe: skip empty message");
408
+ return null;
409
+ }
410
+
411
+ if (msgType === 49 && xml) {
412
+ const appType = extractAppMsgType(xml);
413
+ if (appType === 5) {
414
+ return {
415
+ message,
416
+ rawBody: resolveLinkBody(xml) || rawBodyCandidate,
417
+ };
418
+ }
419
+ if (appType === 74) {
420
+ runtime.log?.("gewe: file notification received (skip download)");
421
+ return null;
422
+ }
423
+ if (appType !== 6) {
424
+ runtime.log?.(`gewe: unhandled appmsg type ${appType ?? "unknown"}`);
425
+ return null;
426
+ }
427
+ }
428
+
429
+ return {
430
+ message,
431
+ rawBody: rawBodyCandidate,
432
+ download:
433
+ (msgType === 3 || msgType === 34 || msgType === 43 || msgType === 49) && xml
434
+ ? { msgType, xml }
435
+ : undefined,
436
+ };
437
+ }
438
+
439
+ async function downloadInboundMediaEntry(params: {
440
+ entry: NormalizedInboundEntry;
441
+ account: ResolvedGeweAccount;
442
+ maxBytes: number;
443
+ }): Promise<{ path: string; contentType?: string | null } | null> {
444
+ const { entry, account, maxBytes } = params;
445
+ const core = getGeweRuntime();
446
+ if (!entry.download) {
447
+ return null;
448
+ }
449
+
450
+ const { msgType, xml } = entry.download;
451
+ let fileUrl: string | null = null;
452
+ if (msgType === 3) {
453
+ try {
454
+ fileUrl = await downloadGeweImage({ account, xml, type: 2 });
455
+ } catch {
456
+ try {
457
+ fileUrl = await downloadGeweImage({ account, xml, type: 1 });
458
+ } catch {
459
+ fileUrl = await downloadGeweImage({ account, xml, type: 3 });
460
+ }
461
+ }
462
+ } else if (msgType === 34) {
463
+ fileUrl = await downloadGeweVoice({
464
+ account,
465
+ xml,
466
+ msgId: Number(entry.message.messageId),
467
+ });
468
+ } else if (msgType === 43) {
469
+ fileUrl = await downloadGeweVideo({ account, xml });
470
+ } else if (msgType === 49) {
471
+ fileUrl = await downloadGeweFile({ account, xml });
472
+ }
473
+
474
+ if (!fileUrl) {
475
+ return null;
476
+ }
477
+
478
+ const fetched = await core.channel.media.fetchRemoteMedia({
479
+ url: fileUrl,
480
+ maxBytes,
481
+ filePathHint: fileUrl,
482
+ });
483
+ let buffer = fetched.buffer;
484
+ let contentType = fetched.contentType;
485
+ let originalFilename = msgType === 49 ? extractFileName(xml) : fetched.fileName;
486
+
487
+ if (msgType === 34 && looksLikeSilkVoice({ buffer, contentType, fileName: originalFilename })) {
488
+ const decoded = await decodeSilkVoice({
489
+ account,
490
+ buffer,
491
+ fileName: originalFilename,
492
+ });
493
+ if (decoded) {
494
+ buffer = decoded.buffer;
495
+ contentType = decoded.contentType;
496
+ originalFilename = decoded.fileName;
497
+ }
498
+ }
499
+
500
+ const saved = await core.channel.media.saveMediaBuffer(
501
+ buffer,
502
+ contentType,
503
+ "inbound",
504
+ maxBytes,
505
+ originalFilename,
506
+ );
507
+ return {
508
+ path: saved.path,
509
+ contentType: saved.contentType,
510
+ };
511
+ }
512
+
367
513
  async function dispatchGeweInbound(params: {
368
514
  prepared: PreparedInbound;
369
515
  account: ResolvedGeweAccount;
370
516
  config: CoreConfig;
371
517
  runtime: RuntimeEnv;
372
- media?: { path?: string; contentType?: string };
518
+ mediaList?: Array<{ path: string; contentType?: string | null }>;
373
519
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
374
520
  }): Promise<void> {
375
- const { prepared, account, config, runtime, media, statusSink } = params;
521
+ const { prepared, account, config, runtime, mediaList = [], statusSink } = params;
376
522
  const core = getGeweRuntime();
377
523
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
378
524
  const previousTimestamp = core.channel.session.readSessionUpdatedAt({
@@ -387,6 +533,7 @@ async function dispatchGeweInbound(params: {
387
533
  envelope: envelopeOptions,
388
534
  body: prepared.rawBody,
389
535
  });
536
+ const mediaPayload = buildGeweInboundMediaPayload(mediaList);
390
537
 
391
538
  const ctxPayload = core.channel.reply.finalizeInboundContext({
392
539
  Body: body,
@@ -409,9 +556,10 @@ async function dispatchGeweInbound(params: {
409
556
  Surface: CHANNEL_ID,
410
557
  MessageSid: prepared.messageSid,
411
558
  MessageSidFull: prepared.messageSid,
412
- MediaPath: media?.path,
413
- MediaType: media?.contentType,
414
- MediaUrl: media?.path,
559
+ MessageSids: prepared.messageSids,
560
+ MessageSidFirst: prepared.messageSidFirst,
561
+ MessageSidLast: prepared.messageSidLast,
562
+ ...mediaPayload,
415
563
  GroupSystemPrompt: prepared.groupSystemPrompt,
416
564
  OriginatingChannel: CHANNEL_ID,
417
565
  OriginatingTo: `${CHANNEL_ID}:${prepared.toWxid}`,
@@ -455,19 +603,52 @@ export async function handleGeweInbound(params: {
455
603
  downloadQueue: GeweDownloadQueue;
456
604
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
457
605
  }): Promise<void> {
458
- const { message, account, config, runtime, downloadQueue, statusSink } = params;
606
+ await handleGeweInboundBatch({
607
+ messages: [params.message],
608
+ account: params.account,
609
+ config: params.config,
610
+ runtime: params.runtime,
611
+ downloadQueue: params.downloadQueue,
612
+ statusSink: params.statusSink,
613
+ });
614
+ }
615
+
616
+ export async function handleGeweInboundBatch(params: {
617
+ messages: GeweInboundMessage[];
618
+ account: ResolvedGeweAccount;
619
+ config: CoreConfig;
620
+ runtime: RuntimeEnv;
621
+ downloadQueue: GeweDownloadQueue;
622
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
623
+ }): Promise<void> {
624
+ const { messages, account, config, runtime, downloadQueue, statusSink } = params;
625
+ if (messages.length === 0) {
626
+ return;
627
+ }
628
+
459
629
  const core = getGeweRuntime();
630
+ const entries = messages
631
+ .map((message) => normalizeInboundEntry({ message, runtime }))
632
+ .filter((entry): entry is NormalizedInboundEntry => Boolean(entry));
633
+ if (entries.length === 0) {
634
+ return;
635
+ }
460
636
 
461
- const msgType = message.msgType;
462
- if (![1, 3, 34, 43, 49].includes(msgType)) {
463
- runtime.log?.(`gewe: skip unsupported msgType ${msgType}`);
637
+ const lastMessage = entries.at(-1)!.message;
638
+ const isGroup = lastMessage.isGroupChat;
639
+ const senderId = lastMessage.senderId;
640
+ const senderName = lastMessage.senderName;
641
+ const groupId = isGroup ? resolveGroupConversationId(lastMessage) : undefined;
642
+ const toWxid = isGroup ? groupId ?? lastMessage.fromId : senderId;
643
+ const rawBodyCandidate = entries
644
+ .map((entry) => entry.rawBody.trim())
645
+ .filter(Boolean)
646
+ .join("\n")
647
+ .trim();
648
+ if (!rawBodyCandidate) {
649
+ runtime.log?.("gewe: skip empty batch");
464
650
  return;
465
651
  }
466
- const isGroup = message.isGroupChat;
467
- const senderId = message.senderId;
468
- const senderName = message.senderName;
469
- const groupId = isGroup ? message.fromId : undefined;
470
- const toWxid = isGroup ? message.fromId : senderId;
471
652
 
472
653
  statusSink?.({ lastInboundAt: Date.now() });
473
654
 
@@ -515,14 +696,6 @@ export async function handleGeweInbound(params: {
515
696
  senderId,
516
697
  senderName,
517
698
  }).allowed;
518
- const { text } = resolveInboundText(message);
519
- const isPlainText = msgType === 1;
520
- const rawBodyCandidate =
521
- (isPlainText ? text.trim() : "") || resolveMediaPlaceholder(msgType);
522
- if (!rawBodyCandidate.trim()) {
523
- runtime.log?.("gewe: skip empty message");
524
- return;
525
- }
526
699
  const hasControlCommand = core.channel.text.hasControlCommand(
527
700
  rawBodyCandidate,
528
701
  config as OpenClawConfig,
@@ -639,6 +812,7 @@ export async function handleGeweInbound(params: {
639
812
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
640
813
  agentId: route.agentId,
641
814
  });
815
+ const messageMeta = buildGeweInboundMessageMeta(entries.map((entry) => entry.message));
642
816
 
643
817
  const prepared: PreparedInbound = {
644
818
  rawBody: rawBodyCandidate,
@@ -652,8 +826,11 @@ export async function handleGeweInbound(params: {
652
826
  route,
653
827
  storePath,
654
828
  toWxid,
655
- messageSid: message.newMessageId,
656
- timestamp: message.timestamp,
829
+ messageSid: messageMeta.messageSid ?? lastMessage.newMessageId,
830
+ messageSids: messageMeta.messageSids,
831
+ messageSidFirst: messageMeta.messageSidFirst,
832
+ messageSidLast: messageMeta.messageSidLast,
833
+ timestamp: messageMeta.timestamp ?? lastMessage.timestamp,
657
834
  };
658
835
 
659
836
  core.channel.activity.record({
@@ -662,133 +839,55 @@ export async function handleGeweInbound(params: {
662
839
  direction: "inbound",
663
840
  });
664
841
 
665
- const xml = message.xml;
666
- const maxBytes = resolveMediaMaxBytes(account);
667
- const needsDownload =
668
- msgType === 3 || msgType === 34 || msgType === 43 || msgType === 49;
669
-
670
- if (msgType === 49 && xml) {
671
- const appType = extractAppMsgType(xml);
672
- if (appType === 5) {
673
- const linkBody = resolveLinkBody(xml);
674
- prepared.rawBody = linkBody || prepared.rawBody;
675
- await dispatchGeweInbound({
676
- prepared,
677
- account,
678
- config,
679
- runtime,
680
- statusSink,
681
- });
682
- return;
683
- }
684
- if (appType === 74) {
685
- runtime.log?.("gewe: file notification received (skip download)");
686
- return;
687
- }
688
- if (appType !== 6) {
689
- runtime.log?.(`gewe: unhandled appmsg type ${appType ?? "unknown"}`);
690
- return;
691
- }
692
- }
693
-
694
- if (!needsDownload || !xml) {
842
+ const downloadEntries = entries.filter((entry) => Boolean(entry.download));
843
+ if (downloadEntries.length === 0) {
695
844
  await dispatchGeweInbound({
696
845
  prepared,
697
846
  account,
698
847
  config,
699
848
  runtime,
700
849
  statusSink,
850
+ mediaList: [],
701
851
  });
702
852
  return;
703
853
  }
704
854
 
705
- const jobKey = `${message.appId}:${message.newMessageId}`;
855
+ const maxBytes = resolveMediaMaxBytes(account);
856
+ const messageIds = entries.map((entry) => entry.message.newMessageId);
857
+ const jobKey = `${lastMessage.appId}:${messageIds[0]}:${messageIds.at(-1)}:${messageIds.length}`;
706
858
  const enqueued = downloadQueue.enqueue({
707
859
  key: jobKey,
708
860
  run: async () => {
709
- try {
710
- let fileUrl: string | null = null;
711
- if (msgType === 3) {
712
- try {
713
- fileUrl = await downloadGeweImage({ account, xml, type: 2 });
714
- } catch {
715
- try {
716
- fileUrl = await downloadGeweImage({ account, xml, type: 1 });
717
- } catch {
718
- fileUrl = await downloadGeweImage({ account, xml, type: 3 });
719
- }
720
- }
721
- } else if (msgType === 34) {
722
- fileUrl = await downloadGeweVoice({ account, xml, msgId: Number(message.messageId) });
723
- } else if (msgType === 43) {
724
- fileUrl = await downloadGeweVideo({ account, xml });
725
- } else if (msgType === 49) {
726
- fileUrl = await downloadGeweFile({ account, xml });
727
- }
728
-
729
- if (!fileUrl) {
730
- await dispatchGeweInbound({
731
- prepared,
732
- account,
733
- config,
734
- runtime,
735
- statusSink,
736
- });
737
- return;
738
- }
739
-
740
- const fetched = await core.channel.media.fetchRemoteMedia({
741
- url: fileUrl,
742
- maxBytes,
743
- filePathHint: fileUrl,
744
- });
745
- let buffer = fetched.buffer;
746
- let contentType = fetched.contentType;
747
- let originalFilename = msgType === 49 ? extractFileName(xml) : fetched.fileName;
748
-
749
- if (msgType === 34 && looksLikeSilkVoice({ buffer, contentType, fileName: originalFilename })) {
750
- const decoded = await decodeSilkVoice({
861
+ const mediaList: Array<{ path: string; contentType?: string | null }> = [];
862
+ for (const entry of downloadEntries) {
863
+ try {
864
+ const saved = await downloadInboundMediaEntry({
865
+ entry,
751
866
  account,
752
- buffer,
753
- fileName: originalFilename,
867
+ maxBytes,
754
868
  });
755
- if (decoded) {
756
- buffer = decoded.buffer;
757
- contentType = decoded.contentType;
758
- originalFilename = decoded.fileName;
869
+ if (saved) {
870
+ mediaList.push(saved);
759
871
  }
872
+ } catch (err) {
873
+ runtime.error?.(
874
+ `gewe: media download failed for ${entry.message.newMessageId}: ${String(err)}`,
875
+ );
760
876
  }
761
-
762
- const saved = await core.channel.media.saveMediaBuffer(
763
- buffer,
764
- contentType,
765
- "inbound",
766
- maxBytes,
767
- originalFilename,
768
- );
769
-
770
- await dispatchGeweInbound({
771
- prepared,
772
- account,
773
- config,
774
- runtime,
775
- statusSink,
776
- media: { path: saved.path, contentType: saved.contentType },
777
- });
778
- } catch (err) {
779
- runtime.error?.(`gewe: media download failed: ${String(err)}`);
780
- await dispatchGeweInbound({
781
- prepared,
782
- account,
783
- config,
784
- runtime,
785
- statusSink,
786
- });
787
877
  }
878
+
879
+ await dispatchGeweInbound({
880
+ prepared,
881
+ account,
882
+ config,
883
+ runtime,
884
+ statusSink,
885
+ mediaList,
886
+ });
788
887
  },
789
888
  });
790
889
 
791
890
  if (!enqueued) {
792
- runtime.log?.(`gewe: duplicate message ${jobKey} skipped`);
891
+ runtime.log?.(`gewe: duplicate inbound batch ${jobKey} skipped`);
793
892
  }
794
893
  }
package/src/monitor.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
2
 
3
- import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
4
4
 
5
5
  import { resolveGeweAccount } from "./accounts.js";
6
6
  import { GeweDownloadQueue } from "./download-queue.js";
7
- import { handleGeweInbound } from "./inbound.js";
7
+ import { createGeweInboundDebouncer } from "./inbound-batch.js";
8
+ import { handleGeweInboundBatch } from "./inbound.js";
8
9
  import { createGeweMediaServer, DEFAULT_MEDIA_HOST, DEFAULT_MEDIA_PATH, DEFAULT_MEDIA_PORT } from "./media-server.js";
9
10
  import { getGeweRuntime } from "./runtime.js";
10
11
  import type {
@@ -280,6 +281,22 @@ export async function monitorGeweProvider(
280
281
  minDelayMs: account.config.downloadMinDelayMs,
281
282
  maxDelayMs: account.config.downloadMaxDelayMs,
282
283
  });
284
+ const debouncer = createGeweInboundDebouncer({
285
+ cfg: cfg as OpenClawConfig,
286
+ accountId: account.accountId,
287
+ isControlCommand: (text) => core.channel.text.hasControlCommand(text, cfg as OpenClawConfig),
288
+ onFlush: async (messages) => {
289
+ await handleGeweInboundBatch({
290
+ messages,
291
+ account,
292
+ config: cfg,
293
+ runtime,
294
+ downloadQueue,
295
+ statusSink: opts.statusSink,
296
+ });
297
+ },
298
+ onError: (err) => runtime.error?.(`gewe inbound debounce flush failed: ${String(err)}`),
299
+ });
283
300
 
284
301
  const webhookServer = createGeweWebhookServer({
285
302
  port,
@@ -292,15 +309,9 @@ export async function monitorGeweProvider(
292
309
 
293
310
  const dedupeKey = `${message.appId}:${message.newMessageId}`;
294
311
  if (isDuplicate(dedupeKey)) return;
312
+ opts.statusSink?.({ lastInboundAt: Date.now() });
295
313
 
296
- await handleGeweInbound({
297
- message,
298
- account,
299
- config: cfg,
300
- runtime,
301
- downloadQueue,
302
- statusSink: opts.statusSink,
303
- });
314
+ await debouncer.enqueue(message);
304
315
  },
305
316
  onError: (err) => runtime.error?.(`gewe webhook error: ${String(err)}`),
306
317
  abortSignal: opts.abortSignal,
@@ -340,6 +351,7 @@ export async function monitorGeweProvider(
340
351
  });
341
352
 
342
353
  const stop = () => {
354
+ void debouncer.flushAll();
343
355
  webhookServer.stop();
344
356
  if (mediaStop) mediaStop();
345
357
  resolveRunning?.();