@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.
- package/README.md +89 -9
- package/index.js +16 -0
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -3
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/wecom/accounts.js +19 -0
- package/wecom/agent-api.js +7 -6
- package/wecom/callback-crypto.js +80 -0
- package/wecom/callback-inbound.js +718 -0
- package/wecom/callback-media.js +76 -0
- package/wecom/channel-plugin.js +129 -126
- package/wecom/constants.js +84 -3
- package/wecom/mcp-config.js +146 -0
- package/wecom/media-uploader.js +208 -0
- package/wecom/openclaw-compat.js +302 -0
- package/wecom/reqid-store.js +146 -0
- package/wecom/workspace-template.js +107 -21
- package/wecom/ws-monitor.js +687 -326
- package/image-processor.js +0 -175
|
@@ -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
|
+
};
|