@sunnoy/wecom 2.0.2 → 2.2.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.
@@ -0,0 +1,718 @@
1
+ /**
2
+ * WeCom self-built app HTTP callback inbound channel.
3
+ *
4
+ * Registers an HTTP endpoint that:
5
+ * - Answers WeCom's GET URL-verification requests
6
+ * - Receives POST message callbacks, decrypts them, and dispatches to the LLM
7
+ *
8
+ * Reply is sent via the Agent API (agentSendText / agentSendMedia) instead of
9
+ * the WebSocket, so this path works independently of the AI Bot WS connection.
10
+ */
11
+
12
+ import path from "node:path";
13
+ import { logger } from "../logger.js";
14
+ import { agentSendText, agentUploadMedia, agentSendMedia } from "./agent-api.js";
15
+ import { checkDmPolicy } from "./dm-policy.js";
16
+ import { checkGroupPolicy } from "./group-policy.js";
17
+ import { resolveWecomCommandAuthorized } from "./allow-from.js";
18
+ import { checkCommandAllowlist, getCommandConfig, isWecomAdmin } from "./commands.js";
19
+ import {
20
+ extractGroupMessageContent,
21
+ generateAgentId,
22
+ getDynamicAgentConfig,
23
+ shouldTriggerGroupResponse,
24
+ shouldUseDynamicAgent,
25
+ } from "../dynamic-agent.js";
26
+ import { ensureDynamicAgentListed } from "./workspace-template.js";
27
+ import { normalizeThinkingTags } from "../think-parser.js";
28
+ import { MessageDeduplicator, splitTextByByteLimit } from "../utils.js";
29
+ import { recordInboundMessage, recordOutboundActivity } from "./runtime-telemetry.js";
30
+ import { setConfigProxyUrl } from "./http.js";
31
+ import { setApiBaseUrl } from "./constants.js";
32
+ import { dispatchLocks, streamContext } from "./state.js";
33
+ import {
34
+ CHANNEL_ID,
35
+ CALLBACK_INBOUND_MAX_BODY_BYTES,
36
+ CALLBACK_TIMESTAMP_TOLERANCE_S,
37
+ TEXT_CHUNK_LIMIT,
38
+ MESSAGE_PROCESS_TIMEOUT_MS,
39
+ MEDIA_IMAGE_PLACEHOLDER,
40
+ MEDIA_DOCUMENT_PLACEHOLDER,
41
+ } from "./constants.js";
42
+ import { verifyCallbackSignature, decryptCallbackMessage } from "./callback-crypto.js";
43
+ import { downloadCallbackMedia } from "./callback-media.js";
44
+ import { assertPathInsideSandbox } from "./sandbox.js";
45
+ import {
46
+ buildInboundContext,
47
+ ensureDefaultSessionReasoningLevel,
48
+ resolveChannelCore,
49
+ normalizeReplyPayload,
50
+ normalizeReplyMediaUrlForLoad,
51
+ resolveReplyMediaLocalRoots,
52
+ } from "./ws-monitor.js";
53
+
54
+ const callbackDeduplicator = new MessageDeduplicator();
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function withCallbackTimeout(promise, timeoutMs, label) {
61
+ let timer;
62
+ const timeout = new Promise((_, reject) => {
63
+ timer = setTimeout(() => reject(new Error(label ?? `timed out after ${timeoutMs}ms`)), timeoutMs);
64
+ });
65
+ promise.catch(() => {});
66
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
67
+ }
68
+
69
+ /**
70
+ * Read the POST body up to maxBytes. Returns null if the body exceeded the limit.
71
+ */
72
+ async function readBody(req, maxBytes) {
73
+ return new Promise((resolve) => {
74
+ const chunks = [];
75
+ let total = 0;
76
+ let oversize = false;
77
+
78
+ req.on("data", (chunk) => {
79
+ if (oversize) return;
80
+ total += chunk.length;
81
+ if (total > maxBytes) {
82
+ oversize = true;
83
+ req.destroy();
84
+ resolve(null);
85
+ return;
86
+ }
87
+ chunks.push(chunk);
88
+ });
89
+
90
+ req.on("end", () => {
91
+ if (!oversize) {
92
+ resolve(Buffer.concat(chunks).toString("utf8"));
93
+ }
94
+ });
95
+
96
+ req.on("error", () => resolve(null));
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Extract a CDATA or plain element value from a simple WeCom XML string.
102
+ * WeCom callback XML is well-defined; a full parser is not required.
103
+ */
104
+ function extractXmlValue(xml, tag) {
105
+ const cdata = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`));
106
+ if (cdata) return cdata[1];
107
+ const plain = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
108
+ return plain ? plain[1] ?? null : null;
109
+ }
110
+
111
+ /**
112
+ * Parse a decrypted WeCom callback XML message into a normalised structure.
113
+ * Returns null for event frames (enter_chat, etc.) that carry no user content.
114
+ *
115
+ * @param {string} xml - Decrypted inner XML
116
+ * @returns {{ msgId, senderId, chatId, isGroupChat, text, mediaId, mediaType, voiceRecognition } | null}
117
+ */
118
+ export function parseCallbackMessageXml(xml) {
119
+ const msgType = extractXmlValue(xml, "MsgType");
120
+
121
+ // Events (subscribe, click, enter_chat …) are not user messages
122
+ if (!msgType || msgType === "event") {
123
+ return null;
124
+ }
125
+
126
+ const msgId = extractXmlValue(xml, "MsgId") ?? String(Date.now());
127
+ const senderId = extractXmlValue(xml, "FromUserName") ?? "";
128
+ if (!senderId) return null;
129
+
130
+ // Self-built app basic callback: group chats are not natively supported;
131
+ // treat every message as a direct message.
132
+ const isGroupChat = false;
133
+ const chatId = senderId;
134
+
135
+ let text = null;
136
+ let mediaId = null;
137
+ let mediaType = null;
138
+ let voiceRecognition = null;
139
+
140
+ if (msgType === "text") {
141
+ text = extractXmlValue(xml, "Content") ?? "";
142
+ } else if (msgType === "image") {
143
+ mediaId = extractXmlValue(xml, "MediaId");
144
+ mediaType = "image";
145
+ } else if (msgType === "voice") {
146
+ mediaId = extractXmlValue(xml, "MediaId");
147
+ mediaType = "voice";
148
+ // `Recognition` is populated when WeCom ASR is enabled for the app
149
+ voiceRecognition = extractXmlValue(xml, "Recognition");
150
+ text = voiceRecognition || null;
151
+ } else if (msgType === "file") {
152
+ mediaId = extractXmlValue(xml, "MediaId");
153
+ mediaType = "file";
154
+ } else if (msgType === "video") {
155
+ mediaId = extractXmlValue(xml, "MediaId");
156
+ mediaType = "file"; // treat video as generic file attachment
157
+ } else {
158
+ // Unknown type: log and skip
159
+ logger.debug(`[CB] Unsupported callback MsgType="${msgType}", ignoring`);
160
+ return null;
161
+ }
162
+
163
+ return { msgId, senderId, chatId, isGroupChat, text, mediaId, mediaType, voiceRecognition };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Load a local reply-media file (LLM-generated MEDIA:/FILE: directives)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ async function loadLocalReplyMedia(mediaUrl, config, agentId, runtime) {
171
+ const normalized = String(mediaUrl ?? "").trim();
172
+ if (!normalized.startsWith("/") && !normalized.startsWith("sandbox:")) {
173
+ throw new Error(`Unsupported callback reply media URL scheme: ${mediaUrl}`);
174
+ }
175
+ const normalizedLocalPath = normalizeReplyMediaUrlForLoad(normalized, config, agentId);
176
+ if (!normalizedLocalPath) {
177
+ throw new Error(`Invalid callback reply media path: ${mediaUrl}`);
178
+ }
179
+
180
+ if (typeof runtime?.media?.loadWebMedia === "function") {
181
+ const localRoots = resolveReplyMediaLocalRoots(config, agentId);
182
+ const loaded = await runtime.media.loadWebMedia(normalizedLocalPath, { localRoots });
183
+ const filename = loaded.fileName || path.basename(normalizedLocalPath) || "file";
184
+ return { buffer: loaded.buffer, filename, contentType: loaded.contentType || "" };
185
+ }
186
+
187
+ // Fallback when runtime.media is unavailable — enforce local roots check manually
188
+ const localRoots = resolveReplyMediaLocalRoots(config, agentId);
189
+ const resolvedPath = path.resolve(normalizedLocalPath);
190
+ await assertPathInsideSandbox(resolvedPath, localRoots);
191
+ const { readFile } = await import("node:fs/promises");
192
+ const buffer = await readFile(resolvedPath);
193
+ return { buffer, filename: path.basename(resolvedPath) || "file", contentType: "" };
194
+ }
195
+
196
+ function resolveCallbackFinalText(accumulatedText, replyMediaUrls = []) {
197
+ const normalizedText = normalizeThinkingTags(String(accumulatedText ?? "").trim());
198
+ if (normalizedText) {
199
+ return normalizedText;
200
+ }
201
+ if (replyMediaUrls.length > 0) {
202
+ return "";
203
+ }
204
+ return "模型暂时无法响应,请稍后重试。";
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Core dispatch
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Process a parsed callback message: route, dispatch to LLM, and reply via
213
+ * Agent API.
214
+ *
215
+ * @param {object} params
216
+ * @param {object} params.parsedMsg - Output of parseCallbackMessageXml()
217
+ * @param {object} params.account - Resolved account object (from accounts.js)
218
+ * @param {object} params.config - Full OpenClaw config
219
+ * @param {object} params.runtime - OpenClaw runtime
220
+ */
221
+ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
222
+ const { msgId, senderId, chatId, isGroupChat, text: rawText, mediaId, mediaType } = parsedMsg;
223
+ const core = resolveChannelCore(runtime);
224
+
225
+ // Deduplication (separate namespace from WS deduplicator to avoid cross-path conflicts)
226
+ const dedupKey = `cb:${account.accountId}:${msgId}`;
227
+ if (callbackDeduplicator.isDuplicate(dedupKey)) {
228
+ logger.debug(`[CB:${account.accountId}] Duplicate message ignored`, { msgId, senderId });
229
+ return;
230
+ }
231
+
232
+ recordInboundMessage({ accountId: account.accountId, chatId });
233
+
234
+ logger.info(`[CB:${account.accountId}] ← inbound`, {
235
+ senderId,
236
+ chatId,
237
+ msgId,
238
+ mediaType: mediaId ? mediaType : null,
239
+ textLength: rawText?.length ?? 0,
240
+ preview: rawText?.slice(0, 80) || (mediaId ? `[${mediaType}]` : ""),
241
+ });
242
+
243
+ // --- Policy checks ---
244
+
245
+ if (isGroupChat) {
246
+ const groupResult = checkGroupPolicy({ chatId, senderId, account, config });
247
+ if (!groupResult.allowed) return;
248
+ }
249
+
250
+ const dmResult = await checkDmPolicy({
251
+ senderId,
252
+ isGroup: isGroupChat,
253
+ account,
254
+ wsClient: null,
255
+ frame: null,
256
+ core,
257
+ sendReply: async ({ text }) => {
258
+ if (account.agentCredentials) {
259
+ await agentSendText({ agent: account.agentCredentials, toUser: senderId, text }).catch((err) =>
260
+ logger.warn(`[CB:${account.accountId}] DM policy reply failed: ${err.message}`),
261
+ );
262
+ }
263
+ },
264
+ });
265
+ if (!dmResult.allowed) return;
266
+
267
+ let text = rawText ?? "";
268
+
269
+ // Group mention gating (not typically reached since isGroupChat=false, but kept for future)
270
+ if (isGroupChat) {
271
+ if (!shouldTriggerGroupResponse(text, account.config)) {
272
+ return;
273
+ }
274
+ text = extractGroupMessageContent(text, account.config);
275
+ }
276
+
277
+ // --- Command allowlist ---
278
+ const senderIsAdmin = isWecomAdmin(senderId, account.config);
279
+ const commandAuthorized = resolveWecomCommandAuthorized({
280
+ cfg: config,
281
+ accountId: account.accountId,
282
+ senderId,
283
+ });
284
+ const commandCheck = checkCommandAllowlist(text, account.config);
285
+ if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
286
+ if (account.agentCredentials) {
287
+ const blockMsg = getCommandConfig(account.config).blockMessage;
288
+ await agentSendText({ agent: account.agentCredentials, toUser: senderId, text: blockMsg }).catch(
289
+ (err) => logger.warn(`[CB:${account.accountId}] Command block reply failed: ${err.message}`),
290
+ );
291
+ }
292
+ return;
293
+ }
294
+
295
+ // --- Inbound media download ---
296
+ const mediaList = [];
297
+ if (mediaId && account.agentCredentials) {
298
+ try {
299
+ const downloaded = await downloadCallbackMedia({
300
+ agent: account.agentCredentials,
301
+ mediaId,
302
+ type: mediaType === "image" ? "image" : mediaType === "voice" ? "voice" : "file",
303
+ runtime,
304
+ config,
305
+ });
306
+ mediaList.push(downloaded);
307
+ } catch (error) {
308
+ logger.error(`[CB:${account.accountId}] Inbound media download failed: ${error.message}`);
309
+ }
310
+ }
311
+
312
+ const effectiveText = text;
313
+
314
+ // --- Route resolution ---
315
+ const peerKind = isGroupChat ? "group" : "dm";
316
+ const peerId = isGroupChat ? chatId : senderId;
317
+ const dynamicConfig = getDynamicAgentConfig(account.config);
318
+ const dynamicAgentId =
319
+ dynamicConfig.enabled &&
320
+ shouldUseDynamicAgent({ chatType: peerKind, config: account.config, senderIsAdmin })
321
+ ? generateAgentId(peerKind, peerId, account.accountId)
322
+ : null;
323
+
324
+ if (dynamicAgentId) {
325
+ await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate);
326
+ }
327
+
328
+ const route = core.routing.resolveAgentRoute({
329
+ cfg: config,
330
+ channel: CHANNEL_ID,
331
+ accountId: account.accountId,
332
+ peer: { kind: peerKind, id: peerId },
333
+ });
334
+
335
+ const hasExplicitBinding =
336
+ Array.isArray(config?.bindings) &&
337
+ config.bindings.some(
338
+ (b) => b.match?.channel === CHANNEL_ID && b.match?.accountId === account.accountId,
339
+ );
340
+ if (dynamicAgentId && !hasExplicitBinding) {
341
+ route.agentId = dynamicAgentId;
342
+ route.sessionKey = `agent:${dynamicAgentId}:${peerKind}:${peerId}`;
343
+ }
344
+
345
+ // Build a body object that mirrors the WS frame.body structure expected by
346
+ // buildInboundContext, so we can reuse that shared helper directly.
347
+ const syntheticBody = {
348
+ msgid: msgId,
349
+ from: { userid: senderId },
350
+ chatid: isGroupChat ? chatId : senderId,
351
+ chattype: isGroupChat ? "group" : "single",
352
+ text: effectiveText ? { content: effectiveText } : undefined,
353
+ };
354
+
355
+ const { ctxPayload, storePath } = buildInboundContext({
356
+ runtime,
357
+ config,
358
+ account,
359
+ frame: null, // no WS frame on callback path
360
+ body: syntheticBody,
361
+ text: effectiveText,
362
+ mediaList,
363
+ route,
364
+ senderId,
365
+ chatId,
366
+ isGroupChat,
367
+ });
368
+ ctxPayload.CommandAuthorized = commandAuthorized;
369
+
370
+ await ensureDefaultSessionReasoningLevel({
371
+ core,
372
+ storePath,
373
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
374
+ ctx: ctxPayload,
375
+ channelTag: "CB",
376
+ });
377
+
378
+ // --- Dispatch ---
379
+ const dispatchStartedAt = Date.now();
380
+ const logPerf = (event, extra = {}) => {
381
+ logger.info(`[CB:${account.accountId}] ${event}`, {
382
+ msgId,
383
+ senderId,
384
+ chatId,
385
+ routeAgentId: route.agentId,
386
+ sessionKey: route.sessionKey,
387
+ elapsedMs: Date.now() - dispatchStartedAt,
388
+ ...extra,
389
+ });
390
+ };
391
+
392
+ const state = {
393
+ accumulatedText: "",
394
+ replyMediaUrls: [],
395
+ deliveryCount: 0,
396
+ firstDeliveryAt: 0,
397
+ };
398
+ const streamId = `cb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
399
+
400
+ const runDispatch = async () => {
401
+ try {
402
+ logPerf("dispatch_start", {
403
+ mediaCount: mediaList.length,
404
+ hasText: Boolean(effectiveText),
405
+ streamId,
406
+ });
407
+ await streamContext.run(
408
+ { streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
409
+ async () => {
410
+ await core.reply.dispatchReplyWithBufferedBlockDispatcher({
411
+ ctx: ctxPayload,
412
+ cfg: config,
413
+ // Disable block-streaming since Agent API replies are sent atomically
414
+ replyOptions: { disableBlockStreaming: true },
415
+ dispatcherOptions: {
416
+ deliver: async (payload, info = {}) => {
417
+ const normalized = normalizeReplyPayload(payload);
418
+ state.deliveryCount += 1;
419
+ if (!state.firstDeliveryAt) {
420
+ state.firstDeliveryAt = Date.now();
421
+ logPerf("first_reply_block_received", {
422
+ kind: info.kind ?? "unknown",
423
+ textLength: normalized.text.length,
424
+ mediaCount: normalized.mediaUrls.length,
425
+ deliveryCount: state.deliveryCount,
426
+ });
427
+ }
428
+ state.accumulatedText += normalized.text;
429
+ for (const mediaUrl of normalized.mediaUrls) {
430
+ if (!state.replyMediaUrls.includes(mediaUrl)) {
431
+ state.replyMediaUrls.push(mediaUrl);
432
+ }
433
+ }
434
+ },
435
+ onError: (error, info) => {
436
+ logger.error(`[CB] ${info.kind} reply block failed: ${error.message}`);
437
+ },
438
+ },
439
+ });
440
+ },
441
+ );
442
+
443
+ logPerf("dispatch_returned", {
444
+ totalOutputChars: state.accumulatedText.length,
445
+ replyMediaCount: state.replyMediaUrls.length,
446
+ deliveryCount: state.deliveryCount,
447
+ });
448
+
449
+ const finalText = resolveCallbackFinalText(state.accumulatedText, state.replyMediaUrls);
450
+
451
+ if (!account.agentCredentials) {
452
+ logger.warn(`[CB:${account.accountId}] No agent credentials configured; callback reply skipped`);
453
+ return;
454
+ }
455
+
456
+ const target = isGroupChat ? { chatId } : { toUser: senderId };
457
+
458
+ // Send reply text (chunked to stay within WeCom message size limits)
459
+ const chunks = finalText ? splitTextByByteLimit(finalText, TEXT_CHUNK_LIMIT) : [];
460
+ if (chunks.length > 0) {
461
+ logger.info(`[CB:${account.accountId}] → outbound`, {
462
+ senderId,
463
+ chatId,
464
+ format: account.agentReplyFormat,
465
+ chunks: chunks.length,
466
+ totalLength: finalText.length,
467
+ preview: finalText.slice(0, 80),
468
+ });
469
+ for (const chunk of chunks) {
470
+ await agentSendText({
471
+ agent: account.agentCredentials,
472
+ ...target,
473
+ text: chunk,
474
+ format: account.agentReplyFormat,
475
+ });
476
+ }
477
+ recordOutboundActivity({ accountId: account.accountId });
478
+ } else {
479
+ logger.info(`[CB:${account.accountId}] → outbound text skipped`, {
480
+ senderId,
481
+ chatId,
482
+ reason: state.replyMediaUrls.length > 0 ? "media_only_reply" : "empty_reply",
483
+ });
484
+ }
485
+
486
+ // Send any LLM-generated media (MEDIA:/FILE: directives in reply)
487
+ for (const mediaUrl of state.replyMediaUrls) {
488
+ try {
489
+ const { buffer, filename, contentType } = await loadLocalReplyMedia(
490
+ mediaUrl,
491
+ config,
492
+ route.agentId,
493
+ runtime,
494
+ );
495
+ const agentMediaType = contentType.startsWith("image/") ? "image" : "file";
496
+ const uploadedMediaId = await agentUploadMedia({
497
+ agent: account.agentCredentials,
498
+ type: agentMediaType,
499
+ buffer,
500
+ filename,
501
+ });
502
+ await agentSendMedia({
503
+ agent: account.agentCredentials,
504
+ ...target,
505
+ mediaId: uploadedMediaId,
506
+ mediaType: agentMediaType,
507
+ });
508
+ recordOutboundActivity({ accountId: account.accountId });
509
+ } catch (mediaError) {
510
+ logger.error(`[CB:${account.accountId}] Failed to send reply media: ${mediaError.message}`);
511
+ }
512
+ }
513
+ logPerf("dispatch_complete", {
514
+ totalOutputChars: state.accumulatedText.length,
515
+ replyMediaCount: state.replyMediaUrls.length,
516
+ deliveryCount: state.deliveryCount,
517
+ });
518
+ } catch (error) {
519
+ logger.error(`[CB:${account.accountId}] Dispatch error: ${error.message}`);
520
+ logPerf("dispatch_failed", {
521
+ error: error.message,
522
+ totalOutputChars: state.accumulatedText.length,
523
+ replyMediaCount: state.replyMediaUrls.length,
524
+ deliveryCount: state.deliveryCount,
525
+ });
526
+ if (account.agentCredentials) {
527
+ const target = isGroupChat ? { chatId } : { toUser: senderId };
528
+ try {
529
+ await agentSendText({
530
+ agent: account.agentCredentials,
531
+ ...target,
532
+ text: "处理消息时出错,请稍后再试。",
533
+ format: "text",
534
+ });
535
+ } catch (sendErr) {
536
+ logger.error(`[CB:${account.accountId}] Error fallback reply failed: ${sendErr.message}`);
537
+ }
538
+ }
539
+ }
540
+ };
541
+
542
+ // Serialise per-sender to prevent concurrent replies to the same user
543
+ const lockKey = `${account.accountId}:${peerId}`;
544
+ const queuedAt = Date.now();
545
+ logPerf("dispatch_enqueued", { lockKey });
546
+ const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
547
+ const current = previous.then(
548
+ async () => {
549
+ const queueWaitMs = Date.now() - queuedAt;
550
+ if (queueWaitMs >= 50) {
551
+ logPerf("dispatch_lock_acquired", { queueWaitMs });
552
+ }
553
+ return await runDispatch();
554
+ },
555
+ async () => {
556
+ const queueWaitMs = Date.now() - queuedAt;
557
+ if (queueWaitMs >= 50) {
558
+ logPerf("dispatch_lock_acquired", { queueWaitMs, previousFailed: true });
559
+ }
560
+ return await runDispatch();
561
+ },
562
+ );
563
+ dispatchLocks.set(lockKey, current);
564
+ return await current.finally(() => {
565
+ if (dispatchLocks.get(lockKey) === current) {
566
+ dispatchLocks.delete(lockKey);
567
+ }
568
+ });
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // HTTP handler factory
573
+ // ---------------------------------------------------------------------------
574
+
575
+ /**
576
+ * Create an HTTP handler for a single WeCom account's callback endpoint.
577
+ *
578
+ * The handler is registered via `api.registerHttpRoute({ auth: "plugin" })` so
579
+ * WeCom's servers can POST to it directly without gateway authentication.
580
+ *
581
+ * @param {object} params
582
+ * @param {object} params.account - Resolved account object with callbackConfig
583
+ * @param {object} params.config - Full OpenClaw config
584
+ * @param {object} params.runtime - OpenClaw runtime
585
+ * @returns {Function} HTTP handler: (req, res) => Promise<boolean|void>
586
+ */
587
+ export function createCallbackHandler({ account, config, runtime }) {
588
+ const { token, encodingAESKey, corpId } = account.callbackConfig;
589
+
590
+ // Apply network config so wecomFetch uses the right proxy/base URL
591
+ const network = account.config.network ?? {};
592
+ setConfigProxyUrl(network.egressProxyUrl ?? "");
593
+ setApiBaseUrl(network.apiBaseUrl ?? "");
594
+
595
+ return async function callbackHandler(req, res) {
596
+ const rawUrl = req.url ?? "/";
597
+ const urlObj = new URL(rawUrl, "http://localhost");
598
+
599
+ const signature = urlObj.searchParams.get("msg_signature") ?? "";
600
+ const timestamp = urlObj.searchParams.get("timestamp") ?? "";
601
+ const nonce = urlObj.searchParams.get("nonce") ?? "";
602
+
603
+ // --- GET: WeCom URL ownership verification ---
604
+ if (req.method === "GET") {
605
+ const echostrCipher = urlObj.searchParams.get("echostr") ?? "";
606
+ if (!echostrCipher) {
607
+ res.writeHead(400);
608
+ res.end("missing echostr");
609
+ return true;
610
+ }
611
+ if (!verifyCallbackSignature({ token, timestamp, nonce, msgEncrypt: echostrCipher, signature })) {
612
+ logger.warn(`[CB:${account.accountId}] GET signature mismatch`);
613
+ res.writeHead(403);
614
+ res.end("forbidden");
615
+ return true;
616
+ }
617
+ try {
618
+ const { xml: plainEchostr } = decryptCallbackMessage({ encodingAESKey, encrypted: echostrCipher });
619
+ res.writeHead(200, { "Content-Type": "text/plain" });
620
+ res.end(plainEchostr);
621
+ } catch (err) {
622
+ logger.error(`[CB:${account.accountId}] GET echostr decrypt failed: ${err.message}`);
623
+ res.writeHead(500);
624
+ res.end("error");
625
+ }
626
+ return true;
627
+ }
628
+
629
+ // --- POST: message callback ---
630
+ if (req.method !== "POST") {
631
+ res.writeHead(405);
632
+ res.end("method not allowed");
633
+ return true;
634
+ }
635
+
636
+ const body = await readBody(req, CALLBACK_INBOUND_MAX_BODY_BYTES);
637
+ if (body === null) {
638
+ res.writeHead(413);
639
+ res.end("request body too large");
640
+ return true;
641
+ }
642
+
643
+ // Extract the encrypted payload from the outer XML wrapper
644
+ const encryptMatch = body.match(/<Encrypt><!\[CDATA\[([\s\S]*?)\]\]><\/Encrypt>/);
645
+ const msgEncrypt = encryptMatch?.[1];
646
+ if (!msgEncrypt) {
647
+ logger.warn(`[CB:${account.accountId}] No <Encrypt> field in POST body`);
648
+ res.writeHead(400);
649
+ res.end("bad request");
650
+ return true;
651
+ }
652
+
653
+ // Replay-attack protection: reject requests older than 5 minutes
654
+ const tsNum = Number(timestamp);
655
+ if (!Number.isFinite(tsNum) || Math.abs(Date.now() / 1000 - tsNum) > CALLBACK_TIMESTAMP_TOLERANCE_S) {
656
+ logger.warn(`[CB:${account.accountId}] Timestamp out of tolerance`, { timestamp });
657
+ res.writeHead(403);
658
+ res.end("forbidden");
659
+ return true;
660
+ }
661
+
662
+ // Signature verification
663
+ if (!verifyCallbackSignature({ token, timestamp, nonce, msgEncrypt, signature })) {
664
+ logger.warn(`[CB:${account.accountId}] POST signature mismatch`);
665
+ res.writeHead(403);
666
+ res.end("forbidden");
667
+ return true;
668
+ }
669
+
670
+ // Decrypt
671
+ let decryptedXml;
672
+ let callbackCorpId;
673
+ try {
674
+ const result = decryptCallbackMessage({ encodingAESKey, encrypted: msgEncrypt });
675
+ decryptedXml = result.xml;
676
+ callbackCorpId = result.corpId;
677
+ } catch (err) {
678
+ logger.error(`[CB:${account.accountId}] Decryption failed: ${err.message}`);
679
+ res.writeHead(400);
680
+ res.end("bad request");
681
+ return true;
682
+ }
683
+
684
+ // CorpId integrity check
685
+ if (callbackCorpId !== corpId) {
686
+ logger.warn(`[CB:${account.accountId}] CorpId mismatch (expected=${corpId} got=${callbackCorpId})`);
687
+ res.writeHead(403);
688
+ res.end("forbidden");
689
+ return true;
690
+ }
691
+
692
+ // Respond to WeCom immediately (WeCom requires a fast HTTP response)
693
+ res.writeHead(200, { "Content-Type": "text/plain" });
694
+ res.end("success");
695
+
696
+ // Process asynchronously so we don't block the HTTP response
697
+ const parsedMsg = parseCallbackMessageXml(decryptedXml);
698
+ if (!parsedMsg) {
699
+ // Event frame or unsupported type, already logged in parseCallbackMessageXml
700
+ return true;
701
+ }
702
+
703
+ withCallbackTimeout(
704
+ processCallbackMessage({ parsedMsg, account, config, runtime }),
705
+ MESSAGE_PROCESS_TIMEOUT_MS,
706
+ `Callback message processing timed out (msgId=${parsedMsg.msgId})`,
707
+ ).catch((err) => {
708
+ logger.error(`[CB:${account.accountId}] Failed to process callback message: ${err.message}`);
709
+ });
710
+
711
+ return true;
712
+ };
713
+ }
714
+
715
+ export const callbackInboundTesting = {
716
+ loadLocalReplyMedia,
717
+ resolveCallbackFinalText,
718
+ };