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 +1 -1
- package/src/inbound-batch.ts +188 -0
- package/src/inbound.ts +229 -130
- package/src/monitor.ts +22 -10
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
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:
|
|
656
|
-
|
|
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
|
|
666
|
-
|
|
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
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
753
|
-
fileName: originalFilename,
|
|
867
|
+
maxBytes,
|
|
754
868
|
});
|
|
755
|
-
if (
|
|
756
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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?.();
|