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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { detectMime } from "openclaw/plugin-sdk";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_MEDIA_HOST = "0.0.0.0";
|
|
10
|
+
export const DEFAULT_MEDIA_PORT = 18787;
|
|
11
|
+
export const DEFAULT_MEDIA_PATH = "/gewe-media";
|
|
12
|
+
|
|
13
|
+
function normalizePath(value: string): string {
|
|
14
|
+
const trimmed = value.trim() || "/";
|
|
15
|
+
if (trimmed === "/") return "/";
|
|
16
|
+
return trimmed.startsWith("/") ? trimmed.replace(/\/+$/, "") : `/${trimmed.replace(/\/+$/, "")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveUserPath(input: string): string {
|
|
20
|
+
const trimmed = input.trim();
|
|
21
|
+
if (!trimmed) return trimmed;
|
|
22
|
+
if (trimmed.startsWith("~")) {
|
|
23
|
+
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
|
|
24
|
+
return path.resolve(expanded);
|
|
25
|
+
}
|
|
26
|
+
return path.resolve(trimmed);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveConfigDir(
|
|
30
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
31
|
+
homedir: () => string = os.homedir,
|
|
32
|
+
): string {
|
|
33
|
+
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
34
|
+
if (override) return resolveUserPath(override);
|
|
35
|
+
const legacyDirs = [".clawdbot", ".moltbot", ".moldbot"].map((dir) =>
|
|
36
|
+
path.join(homedir(), dir),
|
|
37
|
+
);
|
|
38
|
+
const newDir = path.join(homedir(), ".openclaw");
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(newDir)) return newDir;
|
|
41
|
+
const existingLegacy = legacyDirs.find((dir) => {
|
|
42
|
+
try {
|
|
43
|
+
return existsSync(dir);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
if (existingLegacy) return existingLegacy;
|
|
49
|
+
} catch {
|
|
50
|
+
// best-effort
|
|
51
|
+
}
|
|
52
|
+
return newDir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveMediaDir() {
|
|
56
|
+
return path.join(resolveConfigDir(), "media");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveBaseUrl(req: IncomingMessage): string {
|
|
60
|
+
const host = req.headers.host || "localhost";
|
|
61
|
+
return `http://${host}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isSafeMediaId(id: string): boolean {
|
|
65
|
+
if (!id) return false;
|
|
66
|
+
if (id.includes("..")) return false;
|
|
67
|
+
return !id.includes("/") && !id.includes("\\");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type GeweMediaServerOptions = {
|
|
71
|
+
host?: string;
|
|
72
|
+
port?: number;
|
|
73
|
+
path?: string;
|
|
74
|
+
abortSignal?: AbortSignal;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function createGeweMediaServer(
|
|
78
|
+
opts: GeweMediaServerOptions,
|
|
79
|
+
): { server: Server; start: () => Promise<void>; stop: () => void } {
|
|
80
|
+
const host = opts.host ?? DEFAULT_MEDIA_HOST;
|
|
81
|
+
const port = opts.port ?? DEFAULT_MEDIA_PORT;
|
|
82
|
+
const basePath = normalizePath(opts.path ?? DEFAULT_MEDIA_PATH);
|
|
83
|
+
const mediaBaseDir = path.join(resolveMediaDir(), "outbound");
|
|
84
|
+
|
|
85
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
86
|
+
if (!req.url) {
|
|
87
|
+
res.writeHead(404);
|
|
88
|
+
res.end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
92
|
+
res.writeHead(405);
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const url = new URL(req.url, resolveBaseUrl(req));
|
|
98
|
+
if (!url.pathname.startsWith(`${basePath}/`)) {
|
|
99
|
+
res.writeHead(404);
|
|
100
|
+
res.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const id = decodeURIComponent(url.pathname.slice(basePath.length + 1));
|
|
105
|
+
if (!isSafeMediaId(id)) {
|
|
106
|
+
res.writeHead(400);
|
|
107
|
+
res.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const filePath = path.join(mediaBaseDir, id);
|
|
112
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
113
|
+
if (!stat || !stat.isFile()) {
|
|
114
|
+
res.writeHead(404);
|
|
115
|
+
res.end();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const contentType = await detectMime({ filePath }).catch(() => undefined);
|
|
120
|
+
const headers: Record<string, string> = {
|
|
121
|
+
"Content-Length": String(stat.size),
|
|
122
|
+
"Cache-Control": "private, max-age=60",
|
|
123
|
+
};
|
|
124
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
125
|
+
|
|
126
|
+
res.writeHead(200, headers);
|
|
127
|
+
if (req.method === "HEAD") {
|
|
128
|
+
res.end();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const stream = createReadStream(filePath);
|
|
133
|
+
stream.on("error", () => {
|
|
134
|
+
if (!res.headersSent) res.writeHead(500);
|
|
135
|
+
res.end();
|
|
136
|
+
});
|
|
137
|
+
stream.pipe(res);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const start = (): Promise<void> =>
|
|
141
|
+
new Promise((resolve) => {
|
|
142
|
+
server.listen(port, host, () => resolve());
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const stop = () => {
|
|
146
|
+
server.close();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (opts.abortSignal) {
|
|
150
|
+
opts.abortSignal.addEventListener("abort", stop, { once: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { server, start, stop };
|
|
154
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
import { resolveGeweAccount } from "./accounts.js";
|
|
6
|
+
import { GeweDownloadQueue } from "./download-queue.js";
|
|
7
|
+
import { handleGeweInbound } from "./inbound.js";
|
|
8
|
+
import { createGeweMediaServer, DEFAULT_MEDIA_HOST, DEFAULT_MEDIA_PATH, DEFAULT_MEDIA_PORT } from "./media-server.js";
|
|
9
|
+
import { getGeweRuntime } from "./runtime.js";
|
|
10
|
+
import type {
|
|
11
|
+
CoreConfig,
|
|
12
|
+
GeweCallbackPayload,
|
|
13
|
+
GeweInboundMessage,
|
|
14
|
+
GeweWebhookServerOptions,
|
|
15
|
+
ResolvedGeweAccount,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_WEBHOOK_PORT = 18786;
|
|
19
|
+
const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
|
|
20
|
+
const DEFAULT_WEBHOOK_PATH = "/gewe-webhook";
|
|
21
|
+
const HEALTH_PATH = "/healthz";
|
|
22
|
+
const DEDUPE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
const SEEN_MESSAGES = new Map<string, number>();
|
|
25
|
+
|
|
26
|
+
function cleanupSeen() {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [key, ts] of SEEN_MESSAGES.entries()) {
|
|
29
|
+
if (now - ts > DEDUPE_TTL_MS) {
|
|
30
|
+
SEEN_MESSAGES.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDuplicate(key: string): boolean {
|
|
36
|
+
cleanupSeen();
|
|
37
|
+
if (SEEN_MESSAGES.has(key)) return true;
|
|
38
|
+
SEEN_MESSAGES.set(key, Date.now());
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatError(err: unknown): string {
|
|
43
|
+
if (err instanceof Error) return err.message;
|
|
44
|
+
return typeof err === "string" ? err : JSON.stringify(err);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const chunks: Buffer[] = [];
|
|
50
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
51
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
52
|
+
req.on("error", reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveWebhookToken(req: IncomingMessage): string | undefined {
|
|
57
|
+
const headers = req.headers as Record<string, string | string[] | undefined>;
|
|
58
|
+
const candidates = [
|
|
59
|
+
headers["x-gewe-callback-token"],
|
|
60
|
+
headers["x-webhook-token"],
|
|
61
|
+
headers["x-gewe-token"],
|
|
62
|
+
];
|
|
63
|
+
for (const value of candidates) {
|
|
64
|
+
if (!value) continue;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
const first = value[0]?.trim();
|
|
67
|
+
if (first) return first;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const trimmed = value.trim();
|
|
71
|
+
if (trimmed) return trimmed;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateWebhookSecret(
|
|
77
|
+
req: IncomingMessage,
|
|
78
|
+
path: string,
|
|
79
|
+
secret?: string,
|
|
80
|
+
): { ok: boolean; reason?: string } {
|
|
81
|
+
if (!secret) return { ok: true };
|
|
82
|
+
const headerToken = resolveWebhookToken(req);
|
|
83
|
+
if (headerToken && headerToken === secret) return { ok: true };
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host || "localhost"}`);
|
|
86
|
+
const token = url.searchParams.get("token");
|
|
87
|
+
if (token && token === secret) return { ok: true };
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore URL parse errors
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: `Missing or invalid webhook token for ${path}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function splitGroupContent(raw: string): { senderId?: string; body: string } {
|
|
98
|
+
const marker = ":\n";
|
|
99
|
+
const index = raw.indexOf(marker);
|
|
100
|
+
if (index > 0) {
|
|
101
|
+
const sender = raw.slice(0, index).trim();
|
|
102
|
+
const body = raw.slice(index + marker.length);
|
|
103
|
+
if (sender) return { senderId: sender, body };
|
|
104
|
+
}
|
|
105
|
+
return { body: raw };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveSenderName(pushContent?: string): string | undefined {
|
|
109
|
+
const value = pushContent?.trim();
|
|
110
|
+
if (!value) return undefined;
|
|
111
|
+
const index = value.indexOf(" : ");
|
|
112
|
+
if (index > 0) {
|
|
113
|
+
return value.slice(0, index).trim() || undefined;
|
|
114
|
+
}
|
|
115
|
+
const altIndex = value.indexOf(": ");
|
|
116
|
+
if (altIndex > 0) {
|
|
117
|
+
return value.slice(0, altIndex).trim() || undefined;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseWebhookPayload(body: string): GeweCallbackPayload | null {
|
|
123
|
+
try {
|
|
124
|
+
const data = JSON.parse(body);
|
|
125
|
+
return data as GeweCallbackPayload;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function payloadToInboundMessage(payload: GeweCallbackPayload): GeweInboundMessage | null {
|
|
132
|
+
const appId = payload.Appid?.trim() ?? "";
|
|
133
|
+
const botWxid = payload.Wxid?.trim() ?? "";
|
|
134
|
+
const data = payload.Data;
|
|
135
|
+
if (!data || !appId || !botWxid) return null;
|
|
136
|
+
|
|
137
|
+
const fromId = data.FromUserName?.string?.trim() ?? "";
|
|
138
|
+
const toId = data.ToUserName?.string?.trim() ?? "";
|
|
139
|
+
const msgType = typeof data.MsgType === "number" ? data.MsgType : -1;
|
|
140
|
+
const content = data.Content?.string ?? "";
|
|
141
|
+
const msgId = data.MsgId ?? data.NewMsgId ?? 0;
|
|
142
|
+
const newMsgId = data.NewMsgId ?? data.MsgId ?? 0;
|
|
143
|
+
const createTime = data.CreateTime ?? 0;
|
|
144
|
+
const timestamp = createTime ? createTime * 1000 : Date.now();
|
|
145
|
+
if (!fromId || !toId || msgType < 0) return null;
|
|
146
|
+
|
|
147
|
+
const isGroupChat = fromId.endsWith("@chatroom") || toId.endsWith("@chatroom");
|
|
148
|
+
const groupParsed = isGroupChat ? splitGroupContent(content) : { body: content };
|
|
149
|
+
const senderId = (isGroupChat ? groupParsed.senderId : fromId) ?? fromId;
|
|
150
|
+
const text = groupParsed.body?.trim() ?? "";
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
messageId: String(msgId),
|
|
154
|
+
newMessageId: String(newMsgId),
|
|
155
|
+
appId,
|
|
156
|
+
botWxid,
|
|
157
|
+
fromId,
|
|
158
|
+
toId,
|
|
159
|
+
senderId,
|
|
160
|
+
senderName: resolveSenderName(data.PushContent),
|
|
161
|
+
text,
|
|
162
|
+
msgType,
|
|
163
|
+
xml: text,
|
|
164
|
+
timestamp,
|
|
165
|
+
isGroupChat,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createGeweWebhookServer(opts: GeweWebhookServerOptions): {
|
|
170
|
+
server: Server;
|
|
171
|
+
start: () => Promise<void>;
|
|
172
|
+
stop: () => void;
|
|
173
|
+
} {
|
|
174
|
+
const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
|
|
175
|
+
|
|
176
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
177
|
+
if (req.url === HEALTH_PATH) {
|
|
178
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
179
|
+
res.end("ok");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (req.url?.split("?")[0] !== path || req.method !== "POST") {
|
|
184
|
+
res.writeHead(404);
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const auth = validateWebhookSecret(req, path, secret);
|
|
190
|
+
if (!auth.ok) {
|
|
191
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
192
|
+
res.end(JSON.stringify({ error: auth.reason || "Unauthorized" }));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const body = await readBody(req);
|
|
198
|
+
const payload = parseWebhookPayload(body);
|
|
199
|
+
if (!payload) {
|
|
200
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
201
|
+
res.end(JSON.stringify({ error: "Invalid JSON payload" }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const message = payloadToInboundMessage(payload);
|
|
205
|
+
if (!message) {
|
|
206
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
207
|
+
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
res.writeHead(200);
|
|
212
|
+
res.end();
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await onMessage(message);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
onError?.(err instanceof Error ? err : new Error(formatError(err)));
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const error = err instanceof Error ? err : new Error(formatError(err));
|
|
221
|
+
onError?.(error);
|
|
222
|
+
if (!res.headersSent) {
|
|
223
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
224
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const start = (): Promise<void> => {
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
server.listen(port, host, () => resolve());
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const stop = () => {
|
|
236
|
+
server.close();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (abortSignal) {
|
|
240
|
+
abortSignal.addEventListener("abort", stop, { once: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { server, start, stop };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type GeweMonitorOptions = {
|
|
247
|
+
accountId?: string;
|
|
248
|
+
account?: ResolvedGeweAccount;
|
|
249
|
+
config?: CoreConfig;
|
|
250
|
+
runtime?: RuntimeEnv;
|
|
251
|
+
abortSignal?: AbortSignal;
|
|
252
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export async function monitorGeweProvider(
|
|
256
|
+
opts: GeweMonitorOptions,
|
|
257
|
+
): Promise<{ stop: () => void }> {
|
|
258
|
+
const core = getGeweRuntime();
|
|
259
|
+
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
|
|
260
|
+
const account = opts.account ?? resolveGeweAccount({ cfg, accountId: opts.accountId });
|
|
261
|
+
const runtime: RuntimeEnv = opts.runtime ?? {
|
|
262
|
+
log: (message: string) => core.logging.getChildLogger().info(message),
|
|
263
|
+
error: (message: string) => core.logging.getChildLogger().error(message),
|
|
264
|
+
exit: () => {
|
|
265
|
+
throw new Error("Runtime exit not available");
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (!account.token || !account.appId) {
|
|
270
|
+
throw new Error(`GeWe not configured for account "${account.accountId}" (token/appId missing)`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
|
|
274
|
+
const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
|
|
275
|
+
const rawPath = account.config.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
|
276
|
+
const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
277
|
+
const secret = account.config.webhookSecret?.trim() || undefined;
|
|
278
|
+
|
|
279
|
+
const downloadQueue = new GeweDownloadQueue({
|
|
280
|
+
minDelayMs: account.config.downloadMinDelayMs,
|
|
281
|
+
maxDelayMs: account.config.downloadMaxDelayMs,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const webhookServer = createGeweWebhookServer({
|
|
285
|
+
port,
|
|
286
|
+
host,
|
|
287
|
+
path,
|
|
288
|
+
secret,
|
|
289
|
+
onMessage: async (message) => {
|
|
290
|
+
const isSelf = message.fromId === message.botWxid || message.senderId === message.botWxid;
|
|
291
|
+
if (isSelf) return;
|
|
292
|
+
|
|
293
|
+
const dedupeKey = `${message.appId}:${message.newMessageId}`;
|
|
294
|
+
if (isDuplicate(dedupeKey)) return;
|
|
295
|
+
|
|
296
|
+
await handleGeweInbound({
|
|
297
|
+
message,
|
|
298
|
+
account,
|
|
299
|
+
config: cfg,
|
|
300
|
+
runtime,
|
|
301
|
+
downloadQueue,
|
|
302
|
+
statusSink: opts.statusSink,
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
onError: (err) => runtime.error?.(`gewe webhook error: ${String(err)}`),
|
|
306
|
+
abortSignal: opts.abortSignal,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await webhookServer.start();
|
|
310
|
+
runtime.log?.(`[${account.accountId}] GeWe webhook server listening on ${host}:${port}${path}`);
|
|
311
|
+
|
|
312
|
+
let mediaStop: (() => void) | undefined;
|
|
313
|
+
const shouldStartMedia =
|
|
314
|
+
Boolean(account.config.mediaPublicUrl) ||
|
|
315
|
+
Boolean(account.config.mediaPort || account.config.mediaHost || account.config.mediaPath);
|
|
316
|
+
|
|
317
|
+
if (shouldStartMedia) {
|
|
318
|
+
const mediaServer = createGeweMediaServer({
|
|
319
|
+
host: account.config.mediaHost ?? DEFAULT_MEDIA_HOST,
|
|
320
|
+
port: account.config.mediaPort ?? DEFAULT_MEDIA_PORT,
|
|
321
|
+
path: account.config.mediaPath ?? DEFAULT_MEDIA_PATH,
|
|
322
|
+
abortSignal: opts.abortSignal,
|
|
323
|
+
});
|
|
324
|
+
await mediaServer.start();
|
|
325
|
+
mediaStop = mediaServer.stop;
|
|
326
|
+
runtime.log?.(
|
|
327
|
+
`[${account.accountId}] GeWe media server listening on ${account.config.mediaHost ?? DEFAULT_MEDIA_HOST}:${account.config.mediaPort ?? DEFAULT_MEDIA_PORT}${account.config.mediaPath ?? DEFAULT_MEDIA_PATH}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let resolveRunning: (() => void) | undefined;
|
|
332
|
+
const runningPromise = new Promise<void>((resolve) => {
|
|
333
|
+
resolveRunning = resolve;
|
|
334
|
+
if (!opts.abortSignal) return;
|
|
335
|
+
if (opts.abortSignal.aborted) {
|
|
336
|
+
resolve();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
opts.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const stop = () => {
|
|
343
|
+
webhookServer.stop();
|
|
344
|
+
if (mediaStop) mediaStop();
|
|
345
|
+
resolveRunning?.();
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
await runningPromise;
|
|
349
|
+
|
|
350
|
+
return { stop };
|
|
351
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function normalizeGeweMessagingTarget(target: string): string | null {
|
|
2
|
+
const trimmed = target.trim();
|
|
3
|
+
if (!trimmed) return null;
|
|
4
|
+
return trimmed
|
|
5
|
+
.replace(/^gewe:(group:|user:)?/i, "")
|
|
6
|
+
.replace(/^wechat:(group:|user:)?/i, "")
|
|
7
|
+
.replace(/^wx:/i, "")
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function looksLikeGeweTargetId(id: string): boolean {
|
|
12
|
+
const trimmed = id?.trim();
|
|
13
|
+
if (!trimmed) return false;
|
|
14
|
+
if (/^gewe:/i.test(trimmed)) return true;
|
|
15
|
+
if (/@chatroom$/i.test(trimmed)) return true;
|
|
16
|
+
if (/^wxid_/i.test(trimmed)) return true;
|
|
17
|
+
if (/^gh_/i.test(trimmed)) return true;
|
|
18
|
+
return trimmed.length >= 3;
|
|
19
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
buildChannelKeyCandidates,
|
|
4
|
+
normalizeChannelSlug,
|
|
5
|
+
resolveChannelEntryMatchWithFallback,
|
|
6
|
+
resolveMentionGatingWithBypass,
|
|
7
|
+
resolveNestedAllowlistDecision,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
9
|
+
|
|
10
|
+
import type { GeweGroupConfig } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function normalizeAllowEntry(raw: string): string {
|
|
13
|
+
return raw.trim().toLowerCase().replace(/^(gewe|wechat|wx):/i, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeGeweAllowlist(values: Array<string | number> | undefined): string[] {
|
|
17
|
+
return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveGeweAllowlistMatch(params: {
|
|
21
|
+
allowFrom: Array<string | number> | undefined;
|
|
22
|
+
senderId: string;
|
|
23
|
+
senderName?: string | null;
|
|
24
|
+
}): AllowlistMatch<"wildcard" | "id" | "name"> {
|
|
25
|
+
const allowFrom = normalizeGeweAllowlist(params.allowFrom);
|
|
26
|
+
if (allowFrom.length === 0) return { allowed: false };
|
|
27
|
+
if (allowFrom.includes("*")) {
|
|
28
|
+
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
|
29
|
+
}
|
|
30
|
+
const senderId = normalizeAllowEntry(params.senderId);
|
|
31
|
+
if (allowFrom.includes(senderId)) {
|
|
32
|
+
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
|
33
|
+
}
|
|
34
|
+
const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
|
35
|
+
if (senderName && allowFrom.includes(senderName)) {
|
|
36
|
+
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
|
37
|
+
}
|
|
38
|
+
return { allowed: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type GeweGroupMatch = {
|
|
42
|
+
groupConfig?: GeweGroupConfig;
|
|
43
|
+
wildcardConfig?: GeweGroupConfig;
|
|
44
|
+
groupKey?: string;
|
|
45
|
+
matchSource?: "direct" | "parent" | "wildcard";
|
|
46
|
+
allowed: boolean;
|
|
47
|
+
allowlistConfigured: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function resolveGeweGroupMatch(params: {
|
|
51
|
+
groups?: Record<string, GeweGroupConfig>;
|
|
52
|
+
groupId: string;
|
|
53
|
+
groupName?: string | null;
|
|
54
|
+
}): GeweGroupMatch {
|
|
55
|
+
const groups = params.groups ?? {};
|
|
56
|
+
const allowlistConfigured = Object.keys(groups).length > 0;
|
|
57
|
+
const groupName = params.groupName?.trim() || undefined;
|
|
58
|
+
const candidates = buildChannelKeyCandidates(
|
|
59
|
+
params.groupId,
|
|
60
|
+
groupName,
|
|
61
|
+
groupName ? normalizeChannelSlug(groupName) : undefined,
|
|
62
|
+
);
|
|
63
|
+
const match = resolveChannelEntryMatchWithFallback({
|
|
64
|
+
entries: groups,
|
|
65
|
+
keys: candidates,
|
|
66
|
+
wildcardKey: "*",
|
|
67
|
+
normalizeKey: normalizeChannelSlug,
|
|
68
|
+
});
|
|
69
|
+
const groupConfig = match.entry;
|
|
70
|
+
const allowed = resolveNestedAllowlistDecision({
|
|
71
|
+
outerConfigured: allowlistConfigured,
|
|
72
|
+
outerMatched: Boolean(groupConfig),
|
|
73
|
+
innerConfigured: false,
|
|
74
|
+
innerMatched: false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
groupConfig,
|
|
79
|
+
wildcardConfig: match.wildcardEntry,
|
|
80
|
+
groupKey: match.matchKey ?? match.key,
|
|
81
|
+
matchSource: match.matchSource,
|
|
82
|
+
allowed,
|
|
83
|
+
allowlistConfigured,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveGeweGroupToolPolicy(
|
|
88
|
+
params: ChannelGroupContext,
|
|
89
|
+
): GeweGroupConfig["tools"] | undefined {
|
|
90
|
+
const cfg = params.cfg as {
|
|
91
|
+
channels?: {
|
|
92
|
+
gewe?: {
|
|
93
|
+
groups?: Record<string, GeweGroupConfig>;
|
|
94
|
+
accounts?: Record<string, { groups?: Record<string, GeweGroupConfig> }>;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const groupId = params.groupId?.trim();
|
|
99
|
+
if (!groupId) return undefined;
|
|
100
|
+
const groupName = params.groupChannel?.trim() || undefined;
|
|
101
|
+
const accountGroups =
|
|
102
|
+
params.accountId && cfg.channels?.gewe?.accounts?.[params.accountId]?.groups
|
|
103
|
+
? cfg.channels?.gewe?.accounts?.[params.accountId]?.groups
|
|
104
|
+
: undefined;
|
|
105
|
+
const groups = accountGroups ?? cfg.channels?.gewe?.groups;
|
|
106
|
+
const match = resolveGeweGroupMatch({
|
|
107
|
+
groups,
|
|
108
|
+
groupId,
|
|
109
|
+
groupName,
|
|
110
|
+
});
|
|
111
|
+
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveGeweRequireMention(params: {
|
|
115
|
+
groupConfig?: GeweGroupConfig;
|
|
116
|
+
wildcardConfig?: GeweGroupConfig;
|
|
117
|
+
}): boolean {
|
|
118
|
+
if (typeof params.groupConfig?.requireMention === "boolean") {
|
|
119
|
+
return params.groupConfig.requireMention;
|
|
120
|
+
}
|
|
121
|
+
if (typeof params.wildcardConfig?.requireMention === "boolean") {
|
|
122
|
+
return params.wildcardConfig.requireMention;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function resolveGeweGroupAllow(params: {
|
|
128
|
+
groupPolicy: GroupPolicy;
|
|
129
|
+
outerAllowFrom: Array<string | number> | undefined;
|
|
130
|
+
innerAllowFrom: Array<string | number> | undefined;
|
|
131
|
+
senderId: string;
|
|
132
|
+
senderName?: string | null;
|
|
133
|
+
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
|
|
134
|
+
if (params.groupPolicy === "disabled") {
|
|
135
|
+
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
|
136
|
+
}
|
|
137
|
+
if (params.groupPolicy === "open") {
|
|
138
|
+
return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const outerAllow = normalizeGeweAllowlist(params.outerAllowFrom);
|
|
142
|
+
const innerAllow = normalizeGeweAllowlist(params.innerAllowFrom);
|
|
143
|
+
if (outerAllow.length === 0 && innerAllow.length === 0) {
|
|
144
|
+
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const outerMatch = resolveGeweAllowlistMatch({
|
|
148
|
+
allowFrom: params.outerAllowFrom,
|
|
149
|
+
senderId: params.senderId,
|
|
150
|
+
senderName: params.senderName,
|
|
151
|
+
});
|
|
152
|
+
const innerMatch = resolveGeweAllowlistMatch({
|
|
153
|
+
allowFrom: params.innerAllowFrom,
|
|
154
|
+
senderId: params.senderId,
|
|
155
|
+
senderName: params.senderName,
|
|
156
|
+
});
|
|
157
|
+
const allowed = resolveNestedAllowlistDecision({
|
|
158
|
+
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
|
|
159
|
+
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
|
|
160
|
+
innerConfigured: innerAllow.length > 0,
|
|
161
|
+
innerMatched: innerMatch.allowed,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return { allowed, outerMatch, innerMatch };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveGeweMentionGate(params: {
|
|
168
|
+
isGroup: boolean;
|
|
169
|
+
requireMention: boolean;
|
|
170
|
+
wasMentioned: boolean;
|
|
171
|
+
allowTextCommands: boolean;
|
|
172
|
+
hasControlCommand: boolean;
|
|
173
|
+
commandAuthorized: boolean;
|
|
174
|
+
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
|
|
175
|
+
const result = resolveMentionGatingWithBypass({
|
|
176
|
+
isGroup: params.isGroup,
|
|
177
|
+
requireMention: params.requireMention,
|
|
178
|
+
canDetectMention: true,
|
|
179
|
+
wasMentioned: params.wasMentioned,
|
|
180
|
+
allowTextCommands: params.allowTextCommands,
|
|
181
|
+
hasControlCommand: params.hasControlCommand,
|
|
182
|
+
commandAuthorized: params.commandAuthorized,
|
|
183
|
+
});
|
|
184
|
+
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
|
|
185
|
+
}
|