gewe-openclaw 2026.1.29
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/LICENSE +21 -0
- package/README.md +56 -0
- package/assets/gewe-rs_logo.jpeg +0 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +39 -0
- package/src/accounts.ts +164 -0
- package/src/api.ts +53 -0
- package/src/channel.ts +465 -0
- package/src/config-schema.ts +105 -0
- package/src/delivery.ts +837 -0
- package/src/download-queue.ts +74 -0
- package/src/download.ts +84 -0
- package/src/inbound.ts +660 -0
- package/src/media-server.ts +154 -0
- package/src/monitor.ts +351 -0
- package/src/normalize.ts +19 -0
- package/src/policy.ts +185 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +171 -0
- package/src/types.ts +137 -0
- package/src/xml.ts +59 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
6
|
+
import { logInboundDrop, resolveControlCommandGate } from "openclaw/plugin-sdk";
|
|
7
|
+
|
|
8
|
+
import type { GeweDownloadQueue } from "./download-queue.js";
|
|
9
|
+
import { downloadGeweFile, downloadGeweImage, downloadGeweVideo, downloadGeweVoice } from "./download.js";
|
|
10
|
+
import { deliverGewePayload } from "./delivery.js";
|
|
11
|
+
import { getGeweRuntime } from "./runtime.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeGeweAllowlist,
|
|
14
|
+
resolveGeweAllowlistMatch,
|
|
15
|
+
resolveGeweGroupAllow,
|
|
16
|
+
resolveGeweGroupMatch,
|
|
17
|
+
resolveGeweMentionGate,
|
|
18
|
+
resolveGeweRequireMention,
|
|
19
|
+
} from "./policy.js";
|
|
20
|
+
import type { CoreConfig, GeweInboundMessage, ResolvedGeweAccount } from "./types.js";
|
|
21
|
+
import { extractAppMsgType, extractFileName, extractLinkDetails } from "./xml.js";
|
|
22
|
+
|
|
23
|
+
const CHANNEL_ID = "gewe" as const;
|
|
24
|
+
|
|
25
|
+
type PreparedInbound = {
|
|
26
|
+
rawBody: string;
|
|
27
|
+
commandAuthorized: boolean;
|
|
28
|
+
isGroup: boolean;
|
|
29
|
+
senderId: string;
|
|
30
|
+
senderName?: string;
|
|
31
|
+
groupId?: string;
|
|
32
|
+
groupName?: string;
|
|
33
|
+
groupSystemPrompt?: string;
|
|
34
|
+
route: ReturnType<ReturnType<typeof getGeweRuntime>["channel"]["routing"]["resolveAgentRoute"]>;
|
|
35
|
+
storePath: string;
|
|
36
|
+
toWxid: string;
|
|
37
|
+
messageSid: string;
|
|
38
|
+
timestamp?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DEFAULT_VOICE_SAMPLE_RATE = 24000;
|
|
42
|
+
const DEFAULT_VOICE_DECODE_TIMEOUT_MS = 30_000;
|
|
43
|
+
const SILK_HEADER = "#!SILK_V3";
|
|
44
|
+
|
|
45
|
+
function resolveMediaPlaceholder(msgType: number): string {
|
|
46
|
+
if (msgType === 3) return "<media:image>";
|
|
47
|
+
if (msgType === 34) return "<media:audio>";
|
|
48
|
+
if (msgType === 43) return "<media:video>";
|
|
49
|
+
if (msgType === 49) return "<media:document>";
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function looksLikeSilkVoice(params: {
|
|
54
|
+
buffer: Buffer;
|
|
55
|
+
contentType?: string | null;
|
|
56
|
+
fileName?: string | null;
|
|
57
|
+
}): boolean {
|
|
58
|
+
const contentType = params.contentType?.toLowerCase() ?? "";
|
|
59
|
+
if (contentType.includes("silk")) return true;
|
|
60
|
+
const fileName = params.fileName?.toLowerCase() ?? "";
|
|
61
|
+
if (fileName.endsWith(".silk")) return true;
|
|
62
|
+
if (params.buffer.length < SILK_HEADER.length) return false;
|
|
63
|
+
const header = params.buffer.subarray(0, SILK_HEADER.length).toString("utf8");
|
|
64
|
+
return header === SILK_HEADER;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveVoiceDecodeSampleRate(account: ResolvedGeweAccount): number {
|
|
68
|
+
const configured =
|
|
69
|
+
account.config.voiceDecodeSampleRate ?? account.config.voiceSampleRate;
|
|
70
|
+
if (typeof configured === "number" && configured > 0) return Math.floor(configured);
|
|
71
|
+
return DEFAULT_VOICE_SAMPLE_RATE;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type DecodedVoice = {
|
|
75
|
+
buffer: Buffer;
|
|
76
|
+
contentType: string;
|
|
77
|
+
fileName: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function resolveDecodeArgs(params: {
|
|
81
|
+
template: string[];
|
|
82
|
+
input: string;
|
|
83
|
+
output: string;
|
|
84
|
+
sampleRate: number;
|
|
85
|
+
}): string[] {
|
|
86
|
+
const mapped = params.template.map((entry) =>
|
|
87
|
+
entry
|
|
88
|
+
.replace(/\{input\}/g, params.input)
|
|
89
|
+
.replace(/\{output\}/g, params.output)
|
|
90
|
+
.replace(/\{sampleRate\}/g, String(params.sampleRate)),
|
|
91
|
+
);
|
|
92
|
+
const hasInput = params.template.some((entry) => entry.includes("{input}"));
|
|
93
|
+
const hasOutput = params.template.some((entry) => entry.includes("{output}"));
|
|
94
|
+
const next = [...mapped];
|
|
95
|
+
if (!hasInput) next.unshift(params.input);
|
|
96
|
+
if (!hasOutput) next.push(params.output);
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function decodeSilkVoice(params: {
|
|
101
|
+
account: ResolvedGeweAccount;
|
|
102
|
+
buffer: Buffer;
|
|
103
|
+
fileName?: string | null;
|
|
104
|
+
}): Promise<DecodedVoice | null> {
|
|
105
|
+
const core = getGeweRuntime();
|
|
106
|
+
const logger = core.logging.getChildLogger({ channel: "gewe", module: "voice" });
|
|
107
|
+
const decodeOutput = params.account.config.voiceDecodeOutput ?? "pcm";
|
|
108
|
+
const sampleRate = resolveVoiceDecodeSampleRate(params.account);
|
|
109
|
+
const ffmpegPath = params.account.config.voiceFfmpegPath?.trim() || "ffmpeg";
|
|
110
|
+
const customPath = params.account.config.voiceDecodePath?.trim();
|
|
111
|
+
const customArgs = params.account.config.voiceDecodeArgs?.length
|
|
112
|
+
? [params.account.config.voiceDecodeArgs]
|
|
113
|
+
: [];
|
|
114
|
+
const fallbackArgs = [
|
|
115
|
+
["{input}", "{output}"],
|
|
116
|
+
["-i", "{input}", "-o", "{output}"],
|
|
117
|
+
["{input}", "-o", "{output}"],
|
|
118
|
+
["-i", "{input}", "{output}"],
|
|
119
|
+
];
|
|
120
|
+
const argTemplates = customArgs.length ? customArgs : fallbackArgs;
|
|
121
|
+
const candidates = customPath
|
|
122
|
+
? [customPath]
|
|
123
|
+
: ["silk-decoder", "silk-v3-decoder", "decoder"];
|
|
124
|
+
|
|
125
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gewe-voice-in-"));
|
|
126
|
+
const silkPath = path.join(tmpDir, "voice.silk");
|
|
127
|
+
const decodePath = path.join(tmpDir, decodeOutput === "wav" ? "voice.wav" : "voice.pcm");
|
|
128
|
+
const wavPath = decodeOutput === "wav" ? decodePath : path.join(tmpDir, "voice.wav");
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await fs.writeFile(silkPath, params.buffer);
|
|
132
|
+
let decoded = false;
|
|
133
|
+
let lastError: string | null = null;
|
|
134
|
+
for (const bin of candidates) {
|
|
135
|
+
for (const template of argTemplates) {
|
|
136
|
+
const args = resolveDecodeArgs({
|
|
137
|
+
template,
|
|
138
|
+
input: silkPath,
|
|
139
|
+
output: decodePath,
|
|
140
|
+
sampleRate,
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
const result = await core.system.runCommandWithTimeout([bin, ...args], {
|
|
144
|
+
timeoutMs: DEFAULT_VOICE_DECODE_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
if (result.code === 0) {
|
|
147
|
+
const stat = await fs.stat(decodePath).catch(() => null);
|
|
148
|
+
if (stat?.isFile() && stat.size > 0) {
|
|
149
|
+
decoded = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
lastError = result.stderr.trim() || `exit code ${result.code ?? "?"}`;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
lastError = String(err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (decoded) break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!decoded) {
|
|
162
|
+
logger.warn?.(`gewe voice decode failed: ${lastError ?? "decoder not available"}`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (decodeOutput !== "wav") {
|
|
167
|
+
const ffmpegArgs = [
|
|
168
|
+
"-y",
|
|
169
|
+
"-f",
|
|
170
|
+
"s16le",
|
|
171
|
+
"-ar",
|
|
172
|
+
String(sampleRate),
|
|
173
|
+
"-ac",
|
|
174
|
+
"1",
|
|
175
|
+
"-i",
|
|
176
|
+
decodePath,
|
|
177
|
+
wavPath,
|
|
178
|
+
];
|
|
179
|
+
const ffmpegResult = await core.system.runCommandWithTimeout(
|
|
180
|
+
[ffmpegPath, ...ffmpegArgs],
|
|
181
|
+
{ timeoutMs: DEFAULT_VOICE_DECODE_TIMEOUT_MS },
|
|
182
|
+
);
|
|
183
|
+
if (ffmpegResult.code !== 0) {
|
|
184
|
+
logger.warn?.(
|
|
185
|
+
`gewe voice ffmpeg decode failed: ${
|
|
186
|
+
ffmpegResult.stderr.trim() || `exit code ${ffmpegResult.code ?? "?"}`
|
|
187
|
+
}`,
|
|
188
|
+
);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const wavStat = await fs.stat(wavPath).catch(() => null);
|
|
192
|
+
if (!wavStat?.isFile() || wavStat.size === 0) {
|
|
193
|
+
logger.warn?.("gewe voice ffmpeg decode produced empty output");
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const buffer = await fs.readFile(wavPath);
|
|
199
|
+
if (!buffer.length) return null;
|
|
200
|
+
return {
|
|
201
|
+
buffer,
|
|
202
|
+
contentType: "audio/wav",
|
|
203
|
+
fileName: "voice.wav",
|
|
204
|
+
};
|
|
205
|
+
} catch (err) {
|
|
206
|
+
logger.warn?.(`gewe voice decode failed: ${String(err)}`);
|
|
207
|
+
return null;
|
|
208
|
+
} finally {
|
|
209
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function resolveInboundText(message: GeweInboundMessage): { text: string; xml?: string } {
|
|
214
|
+
const content = message.text ?? "";
|
|
215
|
+
if (!content) return { text: "" };
|
|
216
|
+
const trimmed = content.trim();
|
|
217
|
+
if (!trimmed) return { text: "" };
|
|
218
|
+
return { text: trimmed, xml: message.xml };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resolveLinkBody(xml: string): string {
|
|
222
|
+
const details = extractLinkDetails(xml);
|
|
223
|
+
const parts = [];
|
|
224
|
+
if (details.title) parts.push(`[Link] ${details.title}`);
|
|
225
|
+
if (details.desc) parts.push(details.desc);
|
|
226
|
+
if (details.linkUrl) parts.push(details.linkUrl);
|
|
227
|
+
return parts.join("\n").trim();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resolveMediaMaxBytes(account: ResolvedGeweAccount): number {
|
|
231
|
+
const maxMb = account.config.mediaMaxMb;
|
|
232
|
+
if (typeof maxMb === "number" && maxMb > 0) return Math.floor(maxMb * 1024 * 1024);
|
|
233
|
+
return 20 * 1024 * 1024;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function dispatchGeweInbound(params: {
|
|
237
|
+
prepared: PreparedInbound;
|
|
238
|
+
account: ResolvedGeweAccount;
|
|
239
|
+
config: CoreConfig;
|
|
240
|
+
runtime: RuntimeEnv;
|
|
241
|
+
media?: { path?: string; contentType?: string };
|
|
242
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
243
|
+
}): Promise<void> {
|
|
244
|
+
const { prepared, account, config, runtime, media, statusSink } = params;
|
|
245
|
+
const core = getGeweRuntime();
|
|
246
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
|
|
247
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
248
|
+
storePath: prepared.storePath,
|
|
249
|
+
sessionKey: prepared.route.sessionKey,
|
|
250
|
+
});
|
|
251
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
252
|
+
channel: "WeChat",
|
|
253
|
+
from: prepared.groupId ? `group:${prepared.groupId}` : prepared.senderName || prepared.senderId,
|
|
254
|
+
timestamp: prepared.timestamp,
|
|
255
|
+
previousTimestamp,
|
|
256
|
+
envelope: envelopeOptions,
|
|
257
|
+
body: prepared.rawBody,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
261
|
+
Body: body,
|
|
262
|
+
RawBody: prepared.rawBody,
|
|
263
|
+
CommandBody: prepared.rawBody,
|
|
264
|
+
From: prepared.groupId ? `gewe:group:${prepared.groupId}` : `gewe:${prepared.senderId}`,
|
|
265
|
+
To: `gewe:${prepared.toWxid}`,
|
|
266
|
+
SessionKey: prepared.route.sessionKey,
|
|
267
|
+
AccountId: prepared.route.accountId,
|
|
268
|
+
ChatType: prepared.isGroup ? "group" : "direct",
|
|
269
|
+
ConversationLabel: prepared.groupId
|
|
270
|
+
? prepared.groupName || `group:${prepared.groupId}`
|
|
271
|
+
: prepared.senderName || `user:${prepared.senderId}`,
|
|
272
|
+
SenderName: prepared.senderName || undefined,
|
|
273
|
+
SenderId: prepared.senderId,
|
|
274
|
+
CommandAuthorized: prepared.commandAuthorized,
|
|
275
|
+
Provider: "gewe",
|
|
276
|
+
Surface: "gewe",
|
|
277
|
+
MessageSid: prepared.messageSid,
|
|
278
|
+
MessageSidFull: prepared.messageSid,
|
|
279
|
+
MediaPath: media?.path,
|
|
280
|
+
MediaType: media?.contentType,
|
|
281
|
+
MediaUrl: media?.path,
|
|
282
|
+
GroupSystemPrompt: prepared.groupSystemPrompt,
|
|
283
|
+
OriginatingChannel: "gewe",
|
|
284
|
+
OriginatingTo: `gewe:${prepared.toWxid}`,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await core.channel.session.recordInboundSession({
|
|
288
|
+
storePath: prepared.storePath,
|
|
289
|
+
sessionKey: ctxPayload.SessionKey ?? prepared.route.sessionKey,
|
|
290
|
+
ctx: ctxPayload,
|
|
291
|
+
onRecordError: (err) => {
|
|
292
|
+
runtime.error?.(`gewe: failed updating session meta: ${String(err)}`);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
297
|
+
ctx: ctxPayload,
|
|
298
|
+
cfg: config as OpenClawConfig,
|
|
299
|
+
dispatcherOptions: {
|
|
300
|
+
deliver: async (payload: ReplyPayload) => {
|
|
301
|
+
await deliverGewePayload({
|
|
302
|
+
payload,
|
|
303
|
+
account,
|
|
304
|
+
cfg: config as OpenClawConfig,
|
|
305
|
+
toWxid: prepared.toWxid,
|
|
306
|
+
statusSink: (patch) => statusSink?.(patch),
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
onError: (err, info) => {
|
|
310
|
+
runtime.error?.(`[${account.accountId}] GeWe ${info.kind} reply failed: ${String(err)}`);
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function handleGeweInbound(params: {
|
|
317
|
+
message: GeweInboundMessage;
|
|
318
|
+
account: ResolvedGeweAccount;
|
|
319
|
+
config: CoreConfig;
|
|
320
|
+
runtime: RuntimeEnv;
|
|
321
|
+
downloadQueue: GeweDownloadQueue;
|
|
322
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
323
|
+
}): Promise<void> {
|
|
324
|
+
const { message, account, config, runtime, downloadQueue, statusSink } = params;
|
|
325
|
+
const core = getGeweRuntime();
|
|
326
|
+
|
|
327
|
+
const msgType = message.msgType;
|
|
328
|
+
if (![1, 3, 34, 43, 49].includes(msgType)) {
|
|
329
|
+
runtime.log?.(`gewe: skip unsupported msgType ${msgType}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const isGroup = message.isGroupChat;
|
|
333
|
+
const senderId = message.senderId;
|
|
334
|
+
const senderName = message.senderName;
|
|
335
|
+
const groupId = isGroup ? message.fromId : undefined;
|
|
336
|
+
const toWxid = isGroup ? message.fromId : senderId;
|
|
337
|
+
|
|
338
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
339
|
+
|
|
340
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
341
|
+
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
|
342
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
343
|
+
|
|
344
|
+
const configAllowFrom = normalizeGeweAllowlist(account.config.allowFrom);
|
|
345
|
+
const configGroupAllowFrom = normalizeGeweAllowlist(account.config.groupAllowFrom);
|
|
346
|
+
const storeAllowFrom = await core.channel.pairing
|
|
347
|
+
.readAllowFromStore(CHANNEL_ID)
|
|
348
|
+
.catch(() => []);
|
|
349
|
+
const storeAllowList = normalizeGeweAllowlist(storeAllowFrom);
|
|
350
|
+
|
|
351
|
+
const groupMatch = isGroup
|
|
352
|
+
? resolveGeweGroupMatch({
|
|
353
|
+
groups: account.config.groups,
|
|
354
|
+
groupId: groupId ?? "",
|
|
355
|
+
groupName: undefined,
|
|
356
|
+
})
|
|
357
|
+
: undefined;
|
|
358
|
+
|
|
359
|
+
if (isGroup && groupMatch && !groupMatch.allowed) {
|
|
360
|
+
runtime.log?.(`gewe: drop group ${groupId} (not allowlisted)`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (groupMatch?.groupConfig?.enabled === false) {
|
|
364
|
+
runtime.log?.(`gewe: drop group ${groupId} (disabled)`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const roomAllowFrom = normalizeGeweAllowlist(groupMatch?.groupConfig?.allowFrom);
|
|
369
|
+
const baseGroupAllowFrom =
|
|
370
|
+
configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
|
|
371
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
|
|
372
|
+
const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean);
|
|
373
|
+
|
|
374
|
+
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
375
|
+
cfg: config as OpenClawConfig,
|
|
376
|
+
surface: CHANNEL_ID,
|
|
377
|
+
});
|
|
378
|
+
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
379
|
+
const senderAllowedForCommands = resolveGeweAllowlistMatch({
|
|
380
|
+
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
|
381
|
+
senderId,
|
|
382
|
+
senderName,
|
|
383
|
+
}).allowed;
|
|
384
|
+
const { text } = resolveInboundText(message);
|
|
385
|
+
const isPlainText = msgType === 1;
|
|
386
|
+
const rawBodyCandidate =
|
|
387
|
+
(isPlainText ? text.trim() : "") || resolveMediaPlaceholder(msgType);
|
|
388
|
+
if (!rawBodyCandidate.trim()) {
|
|
389
|
+
runtime.log?.("gewe: skip empty message");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const hasControlCommand = core.channel.text.hasControlCommand(
|
|
393
|
+
rawBodyCandidate,
|
|
394
|
+
config as OpenClawConfig,
|
|
395
|
+
);
|
|
396
|
+
const commandGate = resolveControlCommandGate({
|
|
397
|
+
useAccessGroups,
|
|
398
|
+
authorizers: [
|
|
399
|
+
{
|
|
400
|
+
configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
|
|
401
|
+
allowed: senderAllowedForCommands,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
allowTextCommands,
|
|
405
|
+
hasControlCommand,
|
|
406
|
+
});
|
|
407
|
+
const commandAuthorized = commandGate.commandAuthorized;
|
|
408
|
+
|
|
409
|
+
if (isGroup) {
|
|
410
|
+
const groupAllow = resolveGeweGroupAllow({
|
|
411
|
+
groupPolicy,
|
|
412
|
+
outerAllowFrom: effectiveGroupAllowFrom,
|
|
413
|
+
innerAllowFrom: roomAllowFrom,
|
|
414
|
+
senderId,
|
|
415
|
+
senderName,
|
|
416
|
+
});
|
|
417
|
+
if (!groupAllow.allowed) {
|
|
418
|
+
runtime.log?.(`gewe: drop group sender ${senderId} (policy=${groupPolicy})`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
if (dmPolicy === "disabled") {
|
|
423
|
+
runtime.log?.(`gewe: drop DM sender=${senderId} (dmPolicy=disabled)`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (dmPolicy !== "open") {
|
|
427
|
+
const dmAllowed = resolveGeweAllowlistMatch({
|
|
428
|
+
allowFrom: effectiveAllowFrom,
|
|
429
|
+
senderId,
|
|
430
|
+
senderName,
|
|
431
|
+
}).allowed;
|
|
432
|
+
if (!dmAllowed) {
|
|
433
|
+
if (dmPolicy === "pairing") {
|
|
434
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
435
|
+
channel: CHANNEL_ID,
|
|
436
|
+
id: senderId,
|
|
437
|
+
meta: { name: senderName || undefined },
|
|
438
|
+
});
|
|
439
|
+
if (created) {
|
|
440
|
+
try {
|
|
441
|
+
await deliverGewePayload({
|
|
442
|
+
payload: { text: core.channel.pairing.buildPairingReply({
|
|
443
|
+
channel: CHANNEL_ID,
|
|
444
|
+
idLine: `Your WeChat id: ${senderId}`,
|
|
445
|
+
code,
|
|
446
|
+
}) },
|
|
447
|
+
account,
|
|
448
|
+
cfg: config as OpenClawConfig,
|
|
449
|
+
toWxid,
|
|
450
|
+
statusSink: (patch) => statusSink?.(patch),
|
|
451
|
+
});
|
|
452
|
+
} catch (err) {
|
|
453
|
+
runtime.error?.(`gewe: pairing reply failed for ${senderId}: ${String(err)}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
runtime.log?.(`gewe: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (isGroup && commandGate.shouldBlock) {
|
|
464
|
+
logInboundDrop({
|
|
465
|
+
log: (msg) => runtime.log?.(msg),
|
|
466
|
+
channel: CHANNEL_ID,
|
|
467
|
+
reason: "control command (unauthorized)",
|
|
468
|
+
target: senderId,
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
|
|
474
|
+
const wasMentioned = mentionRegexes.length
|
|
475
|
+
? core.channel.mentions.matchesMentionPatterns(rawBodyCandidate, mentionRegexes)
|
|
476
|
+
: false;
|
|
477
|
+
const shouldRequireMention = isGroup
|
|
478
|
+
? resolveGeweRequireMention({
|
|
479
|
+
groupConfig: groupMatch?.groupConfig,
|
|
480
|
+
wildcardConfig: groupMatch?.wildcardConfig,
|
|
481
|
+
})
|
|
482
|
+
: false;
|
|
483
|
+
const mentionGate = resolveGeweMentionGate({
|
|
484
|
+
isGroup,
|
|
485
|
+
requireMention: shouldRequireMention,
|
|
486
|
+
wasMentioned,
|
|
487
|
+
allowTextCommands,
|
|
488
|
+
hasControlCommand,
|
|
489
|
+
commandAuthorized,
|
|
490
|
+
});
|
|
491
|
+
if (isGroup && mentionGate.shouldSkip) {
|
|
492
|
+
runtime.log?.(`gewe: drop group ${groupId} (no mention)`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
497
|
+
cfg: config as OpenClawConfig,
|
|
498
|
+
channel: CHANNEL_ID,
|
|
499
|
+
accountId: account.accountId,
|
|
500
|
+
peer: {
|
|
501
|
+
kind: isGroup ? "group" : "dm",
|
|
502
|
+
id: isGroup ? groupId ?? "" : senderId,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
506
|
+
agentId: route.agentId,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const prepared: PreparedInbound = {
|
|
510
|
+
rawBody: rawBodyCandidate,
|
|
511
|
+
commandAuthorized,
|
|
512
|
+
isGroup,
|
|
513
|
+
senderId,
|
|
514
|
+
senderName: senderName || undefined,
|
|
515
|
+
groupId,
|
|
516
|
+
groupName: undefined,
|
|
517
|
+
groupSystemPrompt: groupMatch?.groupConfig?.systemPrompt?.trim() || undefined,
|
|
518
|
+
route,
|
|
519
|
+
storePath,
|
|
520
|
+
toWxid,
|
|
521
|
+
messageSid: message.newMessageId,
|
|
522
|
+
timestamp: message.timestamp,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
core.channel.activity.record({
|
|
526
|
+
channel: "gewe",
|
|
527
|
+
accountId: account.accountId,
|
|
528
|
+
direction: "inbound",
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const xml = message.xml;
|
|
532
|
+
const maxBytes = resolveMediaMaxBytes(account);
|
|
533
|
+
const needsDownload =
|
|
534
|
+
msgType === 3 || msgType === 34 || msgType === 43 || msgType === 49;
|
|
535
|
+
|
|
536
|
+
if (msgType === 49 && xml) {
|
|
537
|
+
const appType = extractAppMsgType(xml);
|
|
538
|
+
if (appType === 5) {
|
|
539
|
+
const linkBody = resolveLinkBody(xml);
|
|
540
|
+
prepared.rawBody = linkBody || prepared.rawBody;
|
|
541
|
+
await dispatchGeweInbound({
|
|
542
|
+
prepared,
|
|
543
|
+
account,
|
|
544
|
+
config,
|
|
545
|
+
runtime,
|
|
546
|
+
statusSink,
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (appType === 74) {
|
|
551
|
+
runtime.log?.("gewe: file notification received (skip download)");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (appType !== 6) {
|
|
555
|
+
runtime.log?.(`gewe: unhandled appmsg type ${appType ?? "unknown"}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (!needsDownload || !xml) {
|
|
561
|
+
await dispatchGeweInbound({
|
|
562
|
+
prepared,
|
|
563
|
+
account,
|
|
564
|
+
config,
|
|
565
|
+
runtime,
|
|
566
|
+
statusSink,
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const jobKey = `${message.appId}:${message.newMessageId}`;
|
|
572
|
+
const enqueued = downloadQueue.enqueue({
|
|
573
|
+
key: jobKey,
|
|
574
|
+
run: async () => {
|
|
575
|
+
try {
|
|
576
|
+
let fileUrl: string | null = null;
|
|
577
|
+
if (msgType === 3) {
|
|
578
|
+
try {
|
|
579
|
+
fileUrl = await downloadGeweImage({ account, xml, type: 2 });
|
|
580
|
+
} catch {
|
|
581
|
+
try {
|
|
582
|
+
fileUrl = await downloadGeweImage({ account, xml, type: 1 });
|
|
583
|
+
} catch {
|
|
584
|
+
fileUrl = await downloadGeweImage({ account, xml, type: 3 });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} else if (msgType === 34) {
|
|
588
|
+
fileUrl = await downloadGeweVoice({ account, xml, msgId: Number(message.messageId) });
|
|
589
|
+
} else if (msgType === 43) {
|
|
590
|
+
fileUrl = await downloadGeweVideo({ account, xml });
|
|
591
|
+
} else if (msgType === 49) {
|
|
592
|
+
fileUrl = await downloadGeweFile({ account, xml });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!fileUrl) {
|
|
596
|
+
await dispatchGeweInbound({
|
|
597
|
+
prepared,
|
|
598
|
+
account,
|
|
599
|
+
config,
|
|
600
|
+
runtime,
|
|
601
|
+
statusSink,
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
607
|
+
url: fileUrl,
|
|
608
|
+
maxBytes,
|
|
609
|
+
filePathHint: fileUrl,
|
|
610
|
+
});
|
|
611
|
+
let buffer = fetched.buffer;
|
|
612
|
+
let contentType = fetched.contentType;
|
|
613
|
+
let originalFilename = msgType === 49 ? extractFileName(xml) : fetched.fileName;
|
|
614
|
+
|
|
615
|
+
if (msgType === 34 && looksLikeSilkVoice({ buffer, contentType, fileName: originalFilename })) {
|
|
616
|
+
const decoded = await decodeSilkVoice({
|
|
617
|
+
account,
|
|
618
|
+
buffer,
|
|
619
|
+
fileName: originalFilename,
|
|
620
|
+
});
|
|
621
|
+
if (decoded) {
|
|
622
|
+
buffer = decoded.buffer;
|
|
623
|
+
contentType = decoded.contentType;
|
|
624
|
+
originalFilename = decoded.fileName;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
629
|
+
buffer,
|
|
630
|
+
contentType,
|
|
631
|
+
"inbound",
|
|
632
|
+
maxBytes,
|
|
633
|
+
originalFilename,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
await dispatchGeweInbound({
|
|
637
|
+
prepared,
|
|
638
|
+
account,
|
|
639
|
+
config,
|
|
640
|
+
runtime,
|
|
641
|
+
statusSink,
|
|
642
|
+
media: { path: saved.path, contentType: saved.contentType },
|
|
643
|
+
});
|
|
644
|
+
} catch (err) {
|
|
645
|
+
runtime.error?.(`gewe: media download failed: ${String(err)}`);
|
|
646
|
+
await dispatchGeweInbound({
|
|
647
|
+
prepared,
|
|
648
|
+
account,
|
|
649
|
+
config,
|
|
650
|
+
runtime,
|
|
651
|
+
statusSink,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (!enqueued) {
|
|
658
|
+
runtime.log?.(`gewe: duplicate message ${jobKey} skipped`);
|
|
659
|
+
}
|
|
660
|
+
}
|