@marshulll/wecom-dual 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +98 -0
- package/README.md +98 -0
- package/README.zh.md +98 -0
- package/docs/INSTALL.md +103 -0
- package/docs/wecom.config.example.json +19 -0
- package/docs/wecom.config.full.example.json +55 -0
- package/package.json +59 -0
- package/wecom/index.ts +23 -0
- package/wecom/openclaw.plugin.json +9 -0
- package/wecom/package.json +45 -0
- package/wecom/src/accounts.ts +136 -0
- package/wecom/src/channel.ts +221 -0
- package/wecom/src/commands.ts +96 -0
- package/wecom/src/config-schema.ts +84 -0
- package/wecom/src/crypto.ts +129 -0
- package/wecom/src/format.ts +58 -0
- package/wecom/src/monitor.ts +72 -0
- package/wecom/src/runtime.ts +14 -0
- package/wecom/src/types.ts +140 -0
- package/wecom/src/wecom-api.ts +366 -0
- package/wecom/src/wecom-app.ts +635 -0
- package/wecom/src/wecom-bot.ts +645 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { XMLParser } from "fast-xml-parser";
|
|
3
|
+
import { mkdir, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { WecomWebhookTarget } from "./monitor.js";
|
|
8
|
+
import { decryptWecomEncrypted, verifyWecomSignature } from "./crypto.js";
|
|
9
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
10
|
+
import { handleCommand } from "./commands.js";
|
|
11
|
+
import { markdownToWecomText } from "./format.js";
|
|
12
|
+
import { downloadWecomMedia, fetchMediaFromUrl, sendWecomFile, sendWecomImage, sendWecomText, sendWecomVideo, sendWecomVoice, uploadWecomMedia } from "./wecom-api.js";
|
|
13
|
+
|
|
14
|
+
const xmlParser = new XMLParser({
|
|
15
|
+
ignoreAttributes: false,
|
|
16
|
+
trimValues: true,
|
|
17
|
+
processEntities: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
function parseIncomingXml(xml: string): Record<string, any> {
|
|
23
|
+
const obj = xmlParser.parse(xml);
|
|
24
|
+
const root = (obj as any)?.xml ?? obj;
|
|
25
|
+
return root ?? {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
|
|
29
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
30
|
+
return url.searchParams;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveSignatureParam(params: URLSearchParams): string {
|
|
34
|
+
return (
|
|
35
|
+
params.get("msg_signature") ??
|
|
36
|
+
params.get("msgsignature") ??
|
|
37
|
+
params.get("signature") ??
|
|
38
|
+
""
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shouldHandleApp(target: WecomWebhookTarget): boolean {
|
|
43
|
+
const mode = target.account.mode;
|
|
44
|
+
return mode === "app" || mode === "both";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readRequestBody(req: IncomingMessage, maxSize = MAX_REQUEST_BODY_SIZE): Promise<string> {
|
|
48
|
+
return await new Promise((resolve, reject) => {
|
|
49
|
+
const chunks: Buffer[] = [];
|
|
50
|
+
let totalSize = 0;
|
|
51
|
+
|
|
52
|
+
req.on("data", (c) => {
|
|
53
|
+
const chunk = Buffer.isBuffer(c) ? c : Buffer.from(c);
|
|
54
|
+
totalSize += chunk.length;
|
|
55
|
+
if (totalSize > maxSize) {
|
|
56
|
+
reject(new Error(`Request body too large (limit: ${maxSize} bytes)`));
|
|
57
|
+
req.destroy();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
});
|
|
62
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
63
|
+
req.on("error", reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function logVerbose(target: WecomWebhookTarget, message: string): void {
|
|
68
|
+
target.runtime.log?.(`[wecom] ${message}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isTextCommand(text: string): boolean {
|
|
72
|
+
return text.trim().startsWith("/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveExtFromContentType(contentType: string, fallback: string): string {
|
|
76
|
+
if (!contentType) return fallback;
|
|
77
|
+
if (contentType.includes("png")) return "png";
|
|
78
|
+
if (contentType.includes("gif")) return "gif";
|
|
79
|
+
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
|
80
|
+
if (contentType.includes("mp4")) return "mp4";
|
|
81
|
+
if (contentType.includes("amr")) return "amr";
|
|
82
|
+
if (contentType.includes("wav")) return "wav";
|
|
83
|
+
if (contentType.includes("mp3")) return "mp3";
|
|
84
|
+
return fallback;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cleanupExecuted = new Set<string>();
|
|
88
|
+
|
|
89
|
+
async function cleanupMediaDir(
|
|
90
|
+
dir: string,
|
|
91
|
+
retentionHours?: number,
|
|
92
|
+
cleanupOnStart?: boolean,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
if (cleanupOnStart === false) return;
|
|
95
|
+
if (!retentionHours || retentionHours <= 0) return;
|
|
96
|
+
if (cleanupExecuted.has(dir)) return;
|
|
97
|
+
cleanupExecuted.add(dir);
|
|
98
|
+
const cutoff = Date.now() - retentionHours * 3600 * 1000;
|
|
99
|
+
try {
|
|
100
|
+
const entries = await readdir(dir);
|
|
101
|
+
await Promise.all(entries.map(async (entry) => {
|
|
102
|
+
const full = join(dir, entry);
|
|
103
|
+
try {
|
|
104
|
+
const info = await stat(full);
|
|
105
|
+
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
106
|
+
await rm(full, { force: true });
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// ignore
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveMediaTempDir(target: WecomWebhookTarget): string {
|
|
118
|
+
return target.account.config.media?.tempDir?.trim()
|
|
119
|
+
|| join(tmpdir(), "openclaw-wecom");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
|
|
123
|
+
const maxBytes = target.account.config.media?.maxBytes;
|
|
124
|
+
return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
const value = raw.toLowerCase();
|
|
130
|
+
if (value === "image" || value === "voice" || value === "video" || value === "file") return value;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sanitizeFilename(name: string, fallback: string): string {
|
|
135
|
+
const base = name.split(/[/\\\\]/).pop() ?? "";
|
|
136
|
+
const trimmed = base.trim();
|
|
137
|
+
const safe = trimmed
|
|
138
|
+
.replace(/[^\w.\-() ]+/g, "_")
|
|
139
|
+
.replace(/\s+/g, " ")
|
|
140
|
+
.trim();
|
|
141
|
+
const finalName = safe.slice(0, 120);
|
|
142
|
+
return finalName || fallback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async function startAgentForApp(params: {
|
|
147
|
+
target: WecomWebhookTarget;
|
|
148
|
+
fromUser: string;
|
|
149
|
+
chatId?: string;
|
|
150
|
+
isGroup: boolean;
|
|
151
|
+
messageText: string;
|
|
152
|
+
}): Promise<void> {
|
|
153
|
+
const { target, fromUser, chatId, isGroup, messageText } = params;
|
|
154
|
+
const core = getWecomRuntime();
|
|
155
|
+
const config = target.config;
|
|
156
|
+
const account = target.account;
|
|
157
|
+
|
|
158
|
+
const peerId = isGroup ? (chatId || "unknown") : fromUser;
|
|
159
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
160
|
+
cfg: config,
|
|
161
|
+
channel: "wecom",
|
|
162
|
+
accountId: account.accountId,
|
|
163
|
+
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
|
|
167
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
168
|
+
agentId: route.agentId,
|
|
169
|
+
});
|
|
170
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
171
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
172
|
+
storePath,
|
|
173
|
+
sessionKey: route.sessionKey,
|
|
174
|
+
});
|
|
175
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
176
|
+
channel: "WeCom",
|
|
177
|
+
from: fromLabel,
|
|
178
|
+
previousTimestamp,
|
|
179
|
+
envelope: envelopeOptions,
|
|
180
|
+
body: messageText,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
184
|
+
Body: body,
|
|
185
|
+
RawBody: messageText,
|
|
186
|
+
CommandBody: messageText,
|
|
187
|
+
From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
|
|
188
|
+
To: `wecom:${peerId}`,
|
|
189
|
+
SessionKey: route.sessionKey,
|
|
190
|
+
AccountId: route.accountId,
|
|
191
|
+
ChatType: isGroup ? "group" : "direct",
|
|
192
|
+
ConversationLabel: fromLabel,
|
|
193
|
+
SenderName: fromUser,
|
|
194
|
+
SenderId: fromUser,
|
|
195
|
+
Provider: "wecom",
|
|
196
|
+
Surface: "wecom",
|
|
197
|
+
MessageSid: `wecom-${Date.now()}`,
|
|
198
|
+
OriginatingChannel: "wecom",
|
|
199
|
+
OriginatingTo: `wecom:${peerId}`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await core.channel.session.recordInboundSession({
|
|
203
|
+
storePath,
|
|
204
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
205
|
+
ctx: ctxPayload,
|
|
206
|
+
onRecordError: (err) => {
|
|
207
|
+
target.runtime.error?.(`wecom: failed updating session meta: ${String(err)}`);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
(core.channel as any)?.activity?.record?.({
|
|
212
|
+
channel: "wecom",
|
|
213
|
+
accountId: account.accountId,
|
|
214
|
+
direction: "inbound",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
218
|
+
cfg: config,
|
|
219
|
+
channel: "wecom",
|
|
220
|
+
accountId: account.accountId,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
224
|
+
ctx: ctxPayload,
|
|
225
|
+
cfg: config,
|
|
226
|
+
dispatcherOptions: {
|
|
227
|
+
deliver: async (payload, info) => {
|
|
228
|
+
const maybeMediaUrl = (payload as any).mediaUrl as string | undefined;
|
|
229
|
+
const maybeMediaType = (payload as any).mediaType as string | undefined;
|
|
230
|
+
if (maybeMediaUrl) {
|
|
231
|
+
try {
|
|
232
|
+
const media = await fetchMediaFromUrl(maybeMediaUrl, account);
|
|
233
|
+
const type = normalizeMediaType(maybeMediaType) ?? "file";
|
|
234
|
+
const ext = resolveExtFromContentType(media.contentType, type);
|
|
235
|
+
const mediaId = await uploadWecomMedia({
|
|
236
|
+
account,
|
|
237
|
+
type: type as "image" | "voice" | "video" | "file",
|
|
238
|
+
buffer: media.buffer,
|
|
239
|
+
filename: `${type}.${ext}`,
|
|
240
|
+
});
|
|
241
|
+
if (type === "image") {
|
|
242
|
+
await sendWecomImage({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId });
|
|
243
|
+
logVerbose(target, `app image reply delivered (${info.kind}) to ${fromUser}`);
|
|
244
|
+
} else if (type === "voice") {
|
|
245
|
+
await sendWecomVoice({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId });
|
|
246
|
+
logVerbose(target, `app voice reply delivered (${info.kind}) to ${fromUser}`);
|
|
247
|
+
} else if (type === "video") {
|
|
248
|
+
const title = (payload as any).title as string | undefined;
|
|
249
|
+
const description = (payload as any).description as string | undefined;
|
|
250
|
+
await sendWecomVideo({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId, title, description });
|
|
251
|
+
logVerbose(target, `app video reply delivered (${info.kind}) to ${fromUser}`);
|
|
252
|
+
} else if (type === "file") {
|
|
253
|
+
await sendWecomFile({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, mediaId });
|
|
254
|
+
logVerbose(target, `app file reply delivered (${info.kind}) to ${fromUser}`);
|
|
255
|
+
}
|
|
256
|
+
target.statusSink?.({ lastOutboundAt: Date.now() });
|
|
257
|
+
} catch (err) {
|
|
258
|
+
target.runtime.error?.(`wecom app media reply failed: ${String(err)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const text = markdownToWecomText(core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode));
|
|
263
|
+
if (!text) return;
|
|
264
|
+
await sendWecomText({ account, toUser: fromUser, chatId: isGroup ? chatId : undefined, text });
|
|
265
|
+
(core.channel as any)?.activity?.record?.({
|
|
266
|
+
channel: "wecom",
|
|
267
|
+
accountId: account.accountId,
|
|
268
|
+
direction: "outbound",
|
|
269
|
+
});
|
|
270
|
+
target.statusSink?.({ lastOutboundAt: Date.now() });
|
|
271
|
+
logVerbose(target, `app reply delivered (${info.kind}) to ${fromUser}`);
|
|
272
|
+
},
|
|
273
|
+
onError: (err, info) => {
|
|
274
|
+
target.runtime.error?.(`[${account.accountId}] wecom app ${info.kind} reply failed: ${String(err)}`);
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
replyOptions: {
|
|
278
|
+
disableBlockStreaming: true,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function processAppMessage(params: {
|
|
284
|
+
target: WecomWebhookTarget;
|
|
285
|
+
decryptedXml: string;
|
|
286
|
+
msgObj: Record<string, any>;
|
|
287
|
+
}): Promise<void> {
|
|
288
|
+
const { target, msgObj } = params;
|
|
289
|
+
const msgType = String(msgObj?.MsgType ?? "").toLowerCase();
|
|
290
|
+
const fromUser = String(msgObj?.FromUserName ?? "");
|
|
291
|
+
const chatId = msgObj?.ChatId ? String(msgObj.ChatId) : "";
|
|
292
|
+
const isGroup = Boolean(chatId);
|
|
293
|
+
const summary = msgObj?.Content ? String(msgObj.Content).slice(0, 120) : "";
|
|
294
|
+
logVerbose(target, `app inbound: MsgType=${msgType} From=${fromUser} ChatId=${chatId || "N/A"} Content=${summary}`);
|
|
295
|
+
|
|
296
|
+
if (!fromUser) return;
|
|
297
|
+
|
|
298
|
+
let messageText = "";
|
|
299
|
+
let tempImagePath: string | null = null;
|
|
300
|
+
|
|
301
|
+
if (msgType === "text") {
|
|
302
|
+
messageText = String(msgObj?.Content ?? "");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (msgType === "voice") {
|
|
306
|
+
const recognition = String(msgObj?.Recognition ?? "").trim();
|
|
307
|
+
if (recognition) {
|
|
308
|
+
messageText = `[语音消息转写] ${recognition}`;
|
|
309
|
+
} else {
|
|
310
|
+
const mediaId = String(msgObj?.MediaId ?? "");
|
|
311
|
+
if (mediaId) {
|
|
312
|
+
try {
|
|
313
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
314
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
315
|
+
if (maxBytes && media.buffer.length > maxBytes) {
|
|
316
|
+
messageText = "[语音消息过大,未处理]\n\n请发送更短的语音消息。";
|
|
317
|
+
} else {
|
|
318
|
+
const ext = resolveExtFromContentType(media.contentType, "amr");
|
|
319
|
+
const tempDir = resolveMediaTempDir(target);
|
|
320
|
+
await mkdir(tempDir, { recursive: true });
|
|
321
|
+
await cleanupMediaDir(
|
|
322
|
+
tempDir,
|
|
323
|
+
target.account.config.media?.retentionHours,
|
|
324
|
+
target.account.config.media?.cleanupOnStart,
|
|
325
|
+
);
|
|
326
|
+
const tempVoicePath = join(tempDir, `voice-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
327
|
+
await writeFile(tempVoicePath, media.buffer);
|
|
328
|
+
messageText = `[用户发送了一条语音消息,已保存到: ${tempVoicePath}]\n\n请根据语音内容回复用户。`;
|
|
329
|
+
}
|
|
330
|
+
} catch (err) {
|
|
331
|
+
target.runtime.error?.(`wecom app voice download failed: ${String(err)}`);
|
|
332
|
+
messageText = "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。";
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
messageText = "[用户发送了一条语音消息]\n\n请告诉用户语音处理暂时不可用。";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (msgType === "image") {
|
|
341
|
+
const mediaId = String(msgObj?.MediaId ?? "");
|
|
342
|
+
const picUrl = String(msgObj?.PicUrl ?? "");
|
|
343
|
+
try {
|
|
344
|
+
let buffer: Buffer | null = null;
|
|
345
|
+
let contentType = "";
|
|
346
|
+
if (mediaId) {
|
|
347
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
348
|
+
buffer = media.buffer;
|
|
349
|
+
contentType = media.contentType;
|
|
350
|
+
} else if (picUrl) {
|
|
351
|
+
const media = await fetchMediaFromUrl(picUrl, target.account);
|
|
352
|
+
buffer = media.buffer;
|
|
353
|
+
contentType = media.contentType;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (buffer) {
|
|
357
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
358
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
359
|
+
messageText = "[图片过大,未处理]\n\n请发送更小的图片。";
|
|
360
|
+
} else {
|
|
361
|
+
const ext = resolveExtFromContentType(contentType, "jpg");
|
|
362
|
+
const tempDir = resolveMediaTempDir(target);
|
|
363
|
+
await mkdir(tempDir, { recursive: true });
|
|
364
|
+
await cleanupMediaDir(
|
|
365
|
+
tempDir,
|
|
366
|
+
target.account.config.media?.retentionHours,
|
|
367
|
+
target.account.config.media?.cleanupOnStart,
|
|
368
|
+
);
|
|
369
|
+
tempImagePath = join(tempDir, `image-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
370
|
+
await writeFile(tempImagePath, buffer);
|
|
371
|
+
messageText = `[用户发送了一张图片,已保存到: ${tempImagePath}]\n\n请根据图片内容回复用户。`;
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
target.runtime.error?.(`wecom app image download failed: ${String(err)}`);
|
|
378
|
+
messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (msgType === "link") {
|
|
383
|
+
const title = String(msgObj?.Title ?? "(无标题)");
|
|
384
|
+
const desc = String(msgObj?.Description ?? "(无描述)");
|
|
385
|
+
const url = String(msgObj?.Url ?? "(无链接)");
|
|
386
|
+
messageText = `[用户分享了一个链接]\n标题: ${title}\n描述: ${desc}\n链接: ${url}\n\n请根据链接内容回复用户。`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (msgType === "video") {
|
|
390
|
+
const mediaId = String(msgObj?.MediaId ?? "");
|
|
391
|
+
if (mediaId) {
|
|
392
|
+
try {
|
|
393
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
394
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
395
|
+
if (maxBytes && media.buffer.length > maxBytes) {
|
|
396
|
+
messageText = "[视频过大,未处理]\n\n请发送更小的视频。";
|
|
397
|
+
} else {
|
|
398
|
+
const ext = resolveExtFromContentType(media.contentType, "mp4");
|
|
399
|
+
const tempDir = resolveMediaTempDir(target);
|
|
400
|
+
await mkdir(tempDir, { recursive: true });
|
|
401
|
+
await cleanupMediaDir(
|
|
402
|
+
tempDir,
|
|
403
|
+
target.account.config.media?.retentionHours,
|
|
404
|
+
target.account.config.media?.cleanupOnStart,
|
|
405
|
+
);
|
|
406
|
+
const tempVideoPath = join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
407
|
+
await writeFile(tempVideoPath, media.buffer);
|
|
408
|
+
messageText = `[用户发送了一个视频文件,已保存到: ${tempVideoPath}]\n\n请根据视频内容回复用户。`;
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
target.runtime.error?.(`wecom app video download failed: ${String(err)}`);
|
|
412
|
+
messageText = "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (msgType === "file") {
|
|
418
|
+
const mediaId = String(msgObj?.MediaId ?? "");
|
|
419
|
+
const fileName = String(msgObj?.FileName ?? "");
|
|
420
|
+
if (mediaId) {
|
|
421
|
+
try {
|
|
422
|
+
const media = await downloadWecomMedia({ account: target.account, mediaId });
|
|
423
|
+
const maxBytes = resolveMediaMaxBytes(target);
|
|
424
|
+
if (maxBytes && media.buffer.length > maxBytes) {
|
|
425
|
+
messageText = "[文件过大,未处理]\n\n请发送更小的文件。";
|
|
426
|
+
} else {
|
|
427
|
+
const ext = fileName.includes(".") ? fileName.split(".").pop() : resolveExtFromContentType(media.contentType, "bin");
|
|
428
|
+
const tempDir = resolveMediaTempDir(target);
|
|
429
|
+
await mkdir(tempDir, { recursive: true });
|
|
430
|
+
await cleanupMediaDir(
|
|
431
|
+
tempDir,
|
|
432
|
+
target.account.config.media?.retentionHours,
|
|
433
|
+
target.account.config.media?.cleanupOnStart,
|
|
434
|
+
);
|
|
435
|
+
const safeName = sanitizeFilename(fileName, `file-${Date.now()}.${ext}`);
|
|
436
|
+
const tempFilePath = join(tempDir, safeName);
|
|
437
|
+
await writeFile(tempFilePath, media.buffer);
|
|
438
|
+
messageText = `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请根据文件内容回复用户。`;
|
|
439
|
+
}
|
|
440
|
+
} catch (err) {
|
|
441
|
+
target.runtime.error?.(`wecom app file download failed: ${String(err)}`);
|
|
442
|
+
messageText = "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。";
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!messageText) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (msgType === "text" && isTextCommand(messageText)) {
|
|
452
|
+
const handled = await handleCommand(messageText, {
|
|
453
|
+
account: target.account,
|
|
454
|
+
fromUser,
|
|
455
|
+
chatId,
|
|
456
|
+
isGroup,
|
|
457
|
+
cfg: target.config,
|
|
458
|
+
log: target.runtime.log,
|
|
459
|
+
statusSink: target.statusSink,
|
|
460
|
+
});
|
|
461
|
+
if (handled) return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await startAgentForApp({
|
|
466
|
+
target,
|
|
467
|
+
fromUser,
|
|
468
|
+
chatId,
|
|
469
|
+
isGroup,
|
|
470
|
+
messageText,
|
|
471
|
+
});
|
|
472
|
+
} catch (err) {
|
|
473
|
+
target.runtime.error?.(`wecom app agent failed: ${String(err)}`);
|
|
474
|
+
try {
|
|
475
|
+
await sendWecomText({
|
|
476
|
+
account: target.account,
|
|
477
|
+
toUser: fromUser,
|
|
478
|
+
chatId: isGroup ? chatId : undefined,
|
|
479
|
+
text: "抱歉,处理您的消息时出现错误,请稍后重试。",
|
|
480
|
+
});
|
|
481
|
+
} catch {
|
|
482
|
+
// ignore
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
if (tempImagePath) {
|
|
486
|
+
unlink(tempImagePath).catch(() => {});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function handleWecomAppWebhook(params: {
|
|
492
|
+
req: IncomingMessage;
|
|
493
|
+
res: ServerResponse;
|
|
494
|
+
targets: WecomWebhookTarget[];
|
|
495
|
+
}): Promise<boolean> {
|
|
496
|
+
const { req, res, targets } = params;
|
|
497
|
+
const query = resolveQueryParams(req);
|
|
498
|
+
const timestamp = query.get("timestamp") ?? "";
|
|
499
|
+
const nonce = query.get("nonce") ?? "";
|
|
500
|
+
const signature = resolveSignatureParam(query);
|
|
501
|
+
|
|
502
|
+
if (req.method === "GET") {
|
|
503
|
+
const echostr = query.get("echostr") ?? "";
|
|
504
|
+
if (!timestamp || !nonce || !signature || !echostr) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const target = targets.find((candidate) => {
|
|
509
|
+
if (!shouldHandleApp(candidate)) return false;
|
|
510
|
+
const token = candidate.account.callbackToken ?? "";
|
|
511
|
+
const aesKey = candidate.account.callbackAesKey ?? "";
|
|
512
|
+
if (!token || !aesKey) return false;
|
|
513
|
+
return verifyWecomSignature({
|
|
514
|
+
token,
|
|
515
|
+
timestamp,
|
|
516
|
+
nonce,
|
|
517
|
+
encrypt: echostr,
|
|
518
|
+
signature,
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
if (!target || !target.account.callbackAesKey) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const plain = decryptWecomEncrypted({
|
|
528
|
+
encodingAESKey: target.account.callbackAesKey,
|
|
529
|
+
receiveId: target.account.corpId ?? "",
|
|
530
|
+
encrypt: echostr,
|
|
531
|
+
});
|
|
532
|
+
res.statusCode = 200;
|
|
533
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
534
|
+
res.end(plain);
|
|
535
|
+
return true;
|
|
536
|
+
} catch (err) {
|
|
537
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
538
|
+
res.statusCode = 400;
|
|
539
|
+
res.end(msg || "decrypt failed");
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (req.method !== "POST") {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!timestamp || !nonce || !signature) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let rawXml = "";
|
|
553
|
+
try {
|
|
554
|
+
rawXml = await readRequestBody(req, MAX_REQUEST_BODY_SIZE);
|
|
555
|
+
} catch {
|
|
556
|
+
res.statusCode = 413;
|
|
557
|
+
res.end("payload too large");
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!rawXml.trim().startsWith("<")) {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let incoming: Record<string, any>;
|
|
566
|
+
try {
|
|
567
|
+
incoming = parseIncomingXml(rawXml);
|
|
568
|
+
} catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const encrypt = String(incoming?.Encrypt ?? "");
|
|
573
|
+
if (!encrypt) {
|
|
574
|
+
res.statusCode = 400;
|
|
575
|
+
res.end("Missing Encrypt");
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const target = targets.find((candidate) => {
|
|
580
|
+
if (!shouldHandleApp(candidate)) return false;
|
|
581
|
+
const token = candidate.account.callbackToken ?? "";
|
|
582
|
+
const aesKey = candidate.account.callbackAesKey ?? "";
|
|
583
|
+
if (!token || !aesKey) return false;
|
|
584
|
+
return verifyWecomSignature({
|
|
585
|
+
token,
|
|
586
|
+
timestamp,
|
|
587
|
+
nonce,
|
|
588
|
+
encrypt,
|
|
589
|
+
signature,
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
if (!target) {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!target.account.callbackAesKey || !target.account.callbackToken) {
|
|
598
|
+
res.statusCode = 500;
|
|
599
|
+
res.end("wecom app not configured");
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
res.statusCode = 200;
|
|
604
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
605
|
+
res.end("success");
|
|
606
|
+
|
|
607
|
+
let decryptedXml = "";
|
|
608
|
+
try {
|
|
609
|
+
decryptedXml = decryptWecomEncrypted({
|
|
610
|
+
encodingAESKey: target.account.callbackAesKey,
|
|
611
|
+
receiveId: target.account.corpId ?? "",
|
|
612
|
+
encrypt,
|
|
613
|
+
});
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
616
|
+
target.runtime.error?.(`wecom app decrypt failed: ${msg}`);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let msgObj: Record<string, any> = {};
|
|
621
|
+
try {
|
|
622
|
+
msgObj = parseIncomingXml(decryptedXml);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
target.runtime.error?.(`wecom app parse xml failed: ${String(err)}`);
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
629
|
+
|
|
630
|
+
processAppMessage({ target, decryptedXml, msgObj }).catch((err) => {
|
|
631
|
+
target.runtime.error?.(`wecom app async processing failed: ${String(err)}`);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
return true;
|
|
635
|
+
}
|