@sunnoy/wecom 1.1.2 → 1.3.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,251 @@
1
+ /**
2
+ * WeCom Agent API Client
3
+ * Manages AccessToken caching and API calls for self-built applications.
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+ import { logger } from "../logger.js";
8
+ import {
9
+ AGENT_API_ENDPOINTS,
10
+ AGENT_API_REQUEST_TIMEOUT_MS,
11
+ TOKEN_REFRESH_BUFFER_MS,
12
+ } from "./constants.js";
13
+
14
+ /**
15
+ * Token cache: Map<corpId:agentId, { token, expiresAt, refreshPromise }>
16
+ */
17
+ const tokenCaches = new Map();
18
+
19
+ /**
20
+ * Get a valid AccessToken, with caching and concurrent-refresh protection.
21
+ * @param {object} agent - { corpId, corpSecret, agentId }
22
+ * @returns {Promise<string>}
23
+ */
24
+ export async function getAccessToken(agent) {
25
+ const cacheKey = `${agent.corpId}:${agent.agentId}`;
26
+ let cache = tokenCaches.get(cacheKey);
27
+
28
+ if (!cache) {
29
+ cache = { token: "", expiresAt: 0, refreshPromise: null };
30
+ tokenCaches.set(cacheKey, cache);
31
+ }
32
+
33
+ const now = Date.now();
34
+ if (cache.token && cache.expiresAt > now + TOKEN_REFRESH_BUFFER_MS) {
35
+ return cache.token;
36
+ }
37
+
38
+ // Reuse in-flight refresh to prevent concurrent token requests.
39
+ if (cache.refreshPromise) {
40
+ return cache.refreshPromise;
41
+ }
42
+
43
+ cache.refreshPromise = (async () => {
44
+ try {
45
+ const url = `${AGENT_API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
46
+ const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
47
+ const json = await res.json();
48
+
49
+ if (!json?.access_token) {
50
+ throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`);
51
+ }
52
+
53
+ cache.token = json.access_token;
54
+ cache.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
55
+ return cache.token;
56
+ } finally {
57
+ cache.refreshPromise = null;
58
+ }
59
+ })();
60
+
61
+ return cache.refreshPromise;
62
+ }
63
+
64
+ /**
65
+ * Send a text message via Agent API.
66
+ *
67
+ * Uses `message/send` for user/party/tag targets, `appchat/send` for group chats.
68
+ *
69
+ * @param {object} params
70
+ * @param {object} params.agent - { corpId, corpSecret, agentId }
71
+ * @param {string} [params.toUser]
72
+ * @param {string} [params.toParty]
73
+ * @param {string} [params.toTag]
74
+ * @param {string} [params.chatId]
75
+ * @param {string} params.text
76
+ */
77
+ export async function agentSendText(params) {
78
+ const { agent, toUser, toParty, toTag, chatId, text } = params;
79
+ const token = await getAccessToken(agent);
80
+
81
+ const useChat = Boolean(chatId);
82
+ const url = useChat
83
+ ? `${AGENT_API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
84
+ : `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
85
+
86
+ const body = useChat
87
+ ? { chatid: chatId, msgtype: "text", text: { content: text } }
88
+ : {
89
+ touser: toUser,
90
+ toparty: toParty,
91
+ totag: toTag,
92
+ msgtype: "text",
93
+ agentid: agent.agentId,
94
+ text: { content: text },
95
+ };
96
+
97
+ const res = await fetch(url, {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify(body),
101
+ signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
102
+ });
103
+ const json = await res.json();
104
+
105
+ if (json?.errcode !== 0) {
106
+ throw new Error(`agent send text failed: ${json?.errcode} ${json?.errmsg}`);
107
+ }
108
+
109
+ if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
110
+ const details = [
111
+ json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
112
+ json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
113
+ json.invalidtag ? `invalidtag=${json.invalidtag}` : "",
114
+ ]
115
+ .filter(Boolean)
116
+ .join(", ");
117
+ throw new Error(`agent send text partial failure: ${details}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Upload a temporary media file to WeCom.
123
+ *
124
+ * @param {object} params
125
+ * @param {object} params.agent - { corpId, corpSecret, agentId }
126
+ * @param {"image"|"voice"|"video"|"file"} params.type
127
+ * @param {Buffer} params.buffer
128
+ * @param {string} params.filename
129
+ * @returns {Promise<string>} media_id
130
+ */
131
+ export async function agentUploadMedia(params) {
132
+ const { agent, type, buffer, filename } = params;
133
+ const token = await getAccessToken(agent);
134
+ const url = `${AGENT_API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}`;
135
+
136
+ // Manually construct multipart/form-data (no extra dependencies).
137
+ const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
138
+
139
+ const contentTypeMap = {
140
+ jpg: "image/jpeg",
141
+ jpeg: "image/jpeg",
142
+ png: "image/png",
143
+ gif: "image/gif",
144
+ bmp: "image/bmp",
145
+ amr: "voice/amr",
146
+ mp4: "video/mp4",
147
+ };
148
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
149
+ const fileContentType = contentTypeMap[ext] || "application/octet-stream";
150
+
151
+ const header = Buffer.from(
152
+ `--${boundary}\r\n` +
153
+ `Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
154
+ `Content-Type: ${fileContentType}\r\n\r\n`,
155
+ );
156
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
157
+ const body = Buffer.concat([header, buffer, footer]);
158
+
159
+ const res = await fetch(url, {
160
+ method: "POST",
161
+ headers: {
162
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
163
+ "Content-Length": String(body.length),
164
+ },
165
+ body,
166
+ signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
167
+ });
168
+ const json = await res.json();
169
+
170
+ if (!json?.media_id) {
171
+ throw new Error(`agent upload media failed: ${json?.errcode} ${json?.errmsg}`);
172
+ }
173
+ return json.media_id;
174
+ }
175
+
176
+ /**
177
+ * Send a media message (image/voice/video/file) via Agent API.
178
+ *
179
+ * @param {object} params
180
+ * @param {object} params.agent
181
+ * @param {string} [params.toUser]
182
+ * @param {string} [params.toParty]
183
+ * @param {string} [params.toTag]
184
+ * @param {string} [params.chatId]
185
+ * @param {string} params.mediaId
186
+ * @param {"image"|"voice"|"video"|"file"} params.mediaType
187
+ */
188
+ export async function agentSendMedia(params) {
189
+ const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType } = params;
190
+ const token = await getAccessToken(agent);
191
+
192
+ const useChat = Boolean(chatId);
193
+ const url = useChat
194
+ ? `${AGENT_API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
195
+ : `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
196
+
197
+ const mediaPayload = { media_id: mediaId };
198
+ const body = useChat
199
+ ? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
200
+ : {
201
+ touser: toUser,
202
+ toparty: toParty,
203
+ totag: toTag,
204
+ msgtype: mediaType,
205
+ agentid: agent.agentId,
206
+ [mediaType]: mediaPayload,
207
+ };
208
+
209
+ const res = await fetch(url, {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" },
212
+ body: JSON.stringify(body),
213
+ signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
214
+ });
215
+ const json = await res.json();
216
+
217
+ if (json?.errcode !== 0) {
218
+ throw new Error(`agent send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Download a temporary media file from WeCom by media_id.
224
+ *
225
+ * @param {object} params
226
+ * @param {object} params.agent
227
+ * @param {string} params.mediaId
228
+ * @returns {Promise<{ buffer: Buffer, contentType: string }>}
229
+ */
230
+ export async function agentDownloadMedia(params) {
231
+ const { agent, mediaId } = params;
232
+ const token = await getAccessToken(agent);
233
+ const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
234
+
235
+ const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
236
+
237
+ if (!res.ok) {
238
+ throw new Error(`agent download media failed: ${res.status}`);
239
+ }
240
+
241
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
242
+
243
+ // WeCom may return an error JSON body instead of binary media.
244
+ if (contentType.includes("application/json")) {
245
+ const json = await res.json();
246
+ throw new Error(`agent download media failed: ${json?.errcode} ${json?.errmsg}`);
247
+ }
248
+
249
+ const arrayBuf = await res.arrayBuffer();
250
+ return { buffer: Buffer.from(arrayBuf), contentType };
251
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * WeCom Agent Inbound Handler
3
+ *
4
+ * Handles XML-format callbacks from WeCom self-built applications (自建应用).
5
+ * - GET /webhooks/app → URL verification (echostr decrypt)
6
+ * - POST /webhooks/app → Message callback (decrypt → parse → dispatch to LLM)
7
+ *
8
+ * Replies are sent asynchronously via Agent API (not passive stream response).
9
+ * Uses the same sessionKey format as Bot mode for unified session management.
10
+ */
11
+
12
+ import { logger } from "../logger.js";
13
+ import { WecomCrypto } from "../crypto.js";
14
+ import {
15
+ generateAgentId,
16
+ getDynamicAgentConfig,
17
+ shouldUseDynamicAgent,
18
+ } from "../dynamic-agent.js";
19
+ import { agentSendText, agentDownloadMedia } from "./agent-api.js";
20
+ import { resolveWecomCommandAuthorized } from "./allow-from.js";
21
+ import { checkCommandAllowlist, getCommandConfig, isWecomAdmin } from "./commands.js";
22
+ import { MAX_REQUEST_BODY_SIZE } from "./constants.js";
23
+ import { getRuntime, resolveAgentConfig } from "./state.js";
24
+ import { ensureDynamicAgentListed } from "./workspace-template.js";
25
+ import {
26
+ extractEncryptFromXml,
27
+ parseXml,
28
+ extractMsgType,
29
+ extractFromUser,
30
+ extractChatId,
31
+ extractContent,
32
+ extractMediaId,
33
+ extractMsgId,
34
+ extractFileName,
35
+ } from "./xml-parser.js";
36
+
37
+ // ── Message deduplication ──────────────────────────────────────────────
38
+
39
+ const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
40
+ const recentAgentMsgIds = new Map();
41
+
42
+ function rememberAgentMsgId(msgId) {
43
+ const now = Date.now();
44
+ const existing = recentAgentMsgIds.get(msgId);
45
+ if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
46
+ recentAgentMsgIds.set(msgId, now);
47
+ // Prune expired entries on write
48
+ for (const [k, ts] of recentAgentMsgIds) {
49
+ if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
50
+ }
51
+ return true;
52
+ }
53
+
54
+ // ── HTTP body reader ───────────────────────────────────────────────────
55
+
56
+ async function readRawBody(req, maxSize = MAX_REQUEST_BODY_SIZE) {
57
+ return new Promise((resolve, reject) => {
58
+ const chunks = [];
59
+ let size = 0;
60
+
61
+ req.on("data", (chunk) => {
62
+ size += chunk.length;
63
+ if (size > maxSize) {
64
+ reject(new Error("Request body too large"));
65
+ req.destroy();
66
+ return;
67
+ }
68
+ chunks.push(chunk);
69
+ });
70
+
71
+ req.on("end", () => {
72
+ resolve(Buffer.concat(chunks).toString("utf8"));
73
+ });
74
+
75
+ req.on("error", reject);
76
+ });
77
+ }
78
+
79
+ // ── URL Verification (GET) ─────────────────────────────────────────────
80
+
81
+ /**
82
+ * Handle WeCom URL verification during callback configuration.
83
+ * Verify signature → decrypt echostr → return plaintext.
84
+ */
85
+ function handleUrlVerification(req, res, crypto) {
86
+ const url = new URL(req.url || "", "http://localhost");
87
+ const timestamp = url.searchParams.get("timestamp") || "";
88
+ const nonce = url.searchParams.get("nonce") || "";
89
+ const echostr = url.searchParams.get("echostr") || "";
90
+ const msgSignature = url.searchParams.get("msg_signature") || "";
91
+
92
+ // Verify signature
93
+ const expectedSig = crypto.getSignature(timestamp, nonce, echostr);
94
+ if (expectedSig !== msgSignature) {
95
+ logger.warn("[agent-inbound] URL verification: signature mismatch");
96
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
97
+ res.end("unauthorized - 签名验证失败,请检查 Token 配置");
98
+ return true;
99
+ }
100
+
101
+ // Decrypt echostr
102
+ try {
103
+ const { message: plainEchostr } = crypto.decrypt(echostr);
104
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
105
+ res.end(plainEchostr);
106
+ logger.info("[agent-inbound] URL verification successful");
107
+ return true;
108
+ } catch (err) {
109
+ logger.error("[agent-inbound] URL verification: decrypt failed", { error: err.message });
110
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
111
+ res.end("decrypt failed - 解密失败,请检查 EncodingAESKey 配置");
112
+ return true;
113
+ }
114
+ }
115
+
116
+ // ── Message Callback (POST) ────────────────────────────────────────────
117
+
118
+ /**
119
+ * Handle WeCom message callback.
120
+ * Read XML → extract Encrypt → verify → decrypt → parse → dedup → respond 200 → async process.
121
+ */
122
+ async function handleMessageCallback(req, res, crypto, agentConfig, config) {
123
+ try {
124
+ const rawXml = await readRawBody(req);
125
+ logger.debug("[agent-inbound] received callback", { bodyBytes: Buffer.byteLength(rawXml, "utf8") });
126
+
127
+ const encrypted = extractEncryptFromXml(rawXml);
128
+
129
+ const url = new URL(req.url || "", "http://localhost");
130
+ const timestamp = url.searchParams.get("timestamp") || "";
131
+ const nonce = url.searchParams.get("nonce") || "";
132
+ const msgSignature = url.searchParams.get("msg_signature") || "";
133
+
134
+ // Verify signature
135
+ const expectedSig = crypto.getSignature(timestamp, nonce, encrypted);
136
+ if (expectedSig !== msgSignature) {
137
+ logger.warn("[agent-inbound] message callback: signature mismatch");
138
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
139
+ res.end("unauthorized - 签名验证失败");
140
+ return true;
141
+ }
142
+
143
+ // Decrypt
144
+ const { message: decryptedXml } = crypto.decrypt(encrypted);
145
+ logger.debug("[agent-inbound] decrypted", { bytes: Buffer.byteLength(decryptedXml, "utf8") });
146
+
147
+ // Parse XML
148
+ const msg = parseXml(decryptedXml);
149
+ const msgType = extractMsgType(msg);
150
+ const fromUser = extractFromUser(msg);
151
+ const chatId = extractChatId(msg);
152
+ const msgId = extractMsgId(msg);
153
+ const content = extractContent(msg);
154
+
155
+ // Deduplication
156
+ if (msgId) {
157
+ if (!rememberAgentMsgId(msgId)) {
158
+ logger.debug("[agent-inbound] duplicate msgId, skipping", { msgId, fromUser });
159
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
160
+ res.end("success");
161
+ return true;
162
+ }
163
+ }
164
+
165
+ logger.info("[agent-inbound] message received", {
166
+ msgType,
167
+ fromUser,
168
+ chatId: chatId || "N/A",
169
+ msgId: msgId || "N/A",
170
+ contentPreview: content.substring(0, 100),
171
+ });
172
+
173
+ // Respond immediately (Agent mode uses API for replies, not passive response)
174
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
175
+ res.end("success");
176
+
177
+ // Async message processing
178
+ processAgentMessage({
179
+ agentConfig,
180
+ config,
181
+ fromUser,
182
+ chatId,
183
+ msgType,
184
+ content,
185
+ msg,
186
+ }).catch((err) => {
187
+ logger.error("[agent-inbound] async processing failed", { error: err.message });
188
+ });
189
+
190
+ return true;
191
+ } catch (err) {
192
+ logger.error("[agent-inbound] callback failed", { error: err.message });
193
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
194
+ res.end("error - 回调处理失败");
195
+ return true;
196
+ }
197
+ }
198
+
199
+ // ── Async Message Processing ───────────────────────────────────────────
200
+
201
+ /**
202
+ * Process a decrypted Agent message and dispatch to the LLM.
203
+ * Uses the same dynamic agent routing and sessionKey format as Bot mode
204
+ * to ensure unified session management.
205
+ */
206
+ async function processAgentMessage({
207
+ agentConfig,
208
+ config,
209
+ fromUser,
210
+ chatId,
211
+ msgType,
212
+ content,
213
+ msg,
214
+ }) {
215
+ const runtime = getRuntime();
216
+ const core = runtime.channel;
217
+
218
+ const isGroup = Boolean(chatId);
219
+ const peerId = isGroup ? chatId : fromUser;
220
+ const peerKind = isGroup ? "group" : "dm";
221
+
222
+ let finalContent = content;
223
+ const mediaPaths = [];
224
+ const mediaTypes = [];
225
+
226
+ // ── Media processing ──────────────────────────────────────────
227
+
228
+ if (["image", "voice", "video", "file"].includes(msgType)) {
229
+ const mediaId = extractMediaId(msg);
230
+ if (mediaId) {
231
+ try {
232
+ logger.debug("[agent-inbound] downloading media", { mediaId, msgType });
233
+ const { buffer, contentType } = await agentDownloadMedia({
234
+ agent: agentConfig,
235
+ mediaId,
236
+ });
237
+ const originalFileName = extractFileName(msg) || `${mediaId}.bin`;
238
+
239
+ // Save media via core SDK
240
+ const saved = await core.media.saveMediaBuffer(
241
+ buffer,
242
+ contentType,
243
+ "inbound",
244
+ 25 * 1024 * 1024,
245
+ originalFileName,
246
+ );
247
+ logger.info("[agent-inbound] media saved", { path: saved.path, size: buffer.length });
248
+
249
+ mediaPaths.push(saved.path);
250
+ mediaTypes.push(contentType);
251
+ finalContent = `${content} (已下载 ${buffer.length} 字节)`;
252
+
253
+ // For image-only messages, set a placeholder body.
254
+ if (!content.trim() || content.startsWith("[图片]")) {
255
+ finalContent = "[用户发送了一张图片]";
256
+ }
257
+ } catch (err) {
258
+ logger.error("[agent-inbound] media download failed", { error: err.message });
259
+ finalContent = `${content}\n\n媒体处理失败:${err.message}`;
260
+ }
261
+ }
262
+ }
263
+
264
+ // ── Command allowlist ─────────────────────────────────────────
265
+
266
+ const senderIsAdmin = isWecomAdmin(fromUser, config);
267
+ const commandCheck = checkCommandAllowlist(finalContent, config);
268
+
269
+ if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
270
+ const cmdConfig = getCommandConfig(config);
271
+ logger.warn("[agent-inbound] blocked command", { command: commandCheck.command, from: fromUser });
272
+ try {
273
+ await agentSendText({ agent: agentConfig, toUser: fromUser, text: cmdConfig.blockMessage });
274
+ } catch (err) {
275
+ logger.error("[agent-inbound] failed to send block message", { error: err.message });
276
+ }
277
+ return;
278
+ }
279
+
280
+ // ── Dynamic agent routing ─────────────────────────────────────
281
+
282
+ const dynamicConfig = getDynamicAgentConfig(config);
283
+ const targetAgentId =
284
+ dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config })
285
+ ? generateAgentId(peerKind, peerId)
286
+ : null;
287
+
288
+ if (targetAgentId) {
289
+ await ensureDynamicAgentListed(targetAgentId);
290
+ logger.debug("[agent-inbound] dynamic agent", { agentId: targetAgentId, peerId });
291
+ }
292
+
293
+ // ── Route resolution ──────────────────────────────────────────
294
+
295
+ const route = core.routing.resolveAgentRoute({
296
+ cfg: config,
297
+ channel: "wecom",
298
+ accountId: "default",
299
+ peer: { kind: peerKind, id: peerId },
300
+ });
301
+
302
+ if (targetAgentId) {
303
+ route.agentId = targetAgentId;
304
+ route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
305
+ }
306
+
307
+ // ── Build inbound context ─────────────────────────────────────
308
+
309
+ const fromLabel = isGroup ? `[${fromUser}]` : fromUser;
310
+ const storePath = core.session.resolveStorePath(config.session?.store, {
311
+ agentId: route.agentId,
312
+ });
313
+ const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
314
+ const previousTimestamp = core.session.readSessionUpdatedAt({
315
+ storePath,
316
+ sessionKey: route.sessionKey,
317
+ });
318
+
319
+ const body = core.reply.formatAgentEnvelope({
320
+ channel: isGroup ? "Enterprise WeChat Group" : "Enterprise WeChat",
321
+ from: fromLabel,
322
+ timestamp: Date.now(),
323
+ previousTimestamp,
324
+ envelope: envelopeOptions,
325
+ body: finalContent,
326
+ });
327
+
328
+ const commandAuthorized = resolveWecomCommandAuthorized({
329
+ cfg: config,
330
+ accountId: "default",
331
+ senderId: fromUser,
332
+ });
333
+
334
+ const conversationId = isGroup ? `wecom:group:${chatId}` : `wecom:${fromUser}`;
335
+
336
+ const ctxPayload = core.reply.finalizeInboundContext({
337
+ Body: body,
338
+ RawBody: finalContent,
339
+ CommandBody: finalContent,
340
+ From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
341
+ To: conversationId,
342
+ SessionKey: route.sessionKey,
343
+ AccountId: route.accountId,
344
+ ChatType: isGroup ? "group" : "direct",
345
+ ConversationLabel: isGroup ? `Group ${chatId}` : fromUser,
346
+ SenderName: fromUser,
347
+ SenderId: fromUser,
348
+ Provider: "wecom",
349
+ Surface: "wecom",
350
+ OriginatingChannel: "wecom",
351
+ OriginatingTo: `wecom-agent:${fromUser}`,
352
+ CommandAuthorized: commandAuthorized,
353
+ ...(mediaPaths.length > 0 && { MediaPaths: mediaPaths }),
354
+ ...(mediaTypes.length > 0 && { MediaTypes: mediaTypes }),
355
+ });
356
+
357
+ // ── Record session ────────────────────────────────────────────
358
+
359
+ void core.session
360
+ .recordSessionMetaFromInbound({
361
+ storePath,
362
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
363
+ ctx: ctxPayload,
364
+ })
365
+ .catch((err) => {
366
+ logger.error("[agent-inbound] session record failed", { error: err.message });
367
+ });
368
+
369
+ // ── Dispatch to LLM ──────────────────────────────────────────
370
+
371
+ await core.reply.dispatchReplyWithBufferedBlockDispatcher({
372
+ ctx: ctxPayload,
373
+ cfg: config,
374
+ replyOptions: {
375
+ disableBlockStreaming: true,
376
+ },
377
+ dispatcherOptions: {
378
+ deliver: async (payload, info) => {
379
+ const text = payload.text ?? "";
380
+ if (!text.trim()) return;
381
+
382
+ try {
383
+ // Agent mode: reply via API to the sender (DM, even for group messages)
384
+ await agentSendText({ agent: agentConfig, toUser: fromUser, text });
385
+ logger.info("[agent-inbound] reply delivered", {
386
+ kind: info.kind,
387
+ to: fromUser,
388
+ contentPreview: text.substring(0, 50),
389
+ });
390
+ } catch (err) {
391
+ logger.error("[agent-inbound] reply delivery failed", { error: err.message });
392
+ }
393
+ },
394
+ onError: (err, info) => {
395
+ logger.error("[agent-inbound] dispatch error", { kind: info.kind, error: err.message });
396
+ },
397
+ },
398
+ });
399
+ }
400
+
401
+ // ── Public Entry Point ─────────────────────────────────────────────────
402
+
403
+ /**
404
+ * Handle Agent inbound webhook request.
405
+ * Routes GET → URL verification, POST → message callback.
406
+ *
407
+ * @param {object} params
408
+ * @param {import("http").IncomingMessage} params.req
409
+ * @param {import("http").ServerResponse} params.res
410
+ * @param {object} params.agentAccount - { token, encodingAesKey, corpId, corpSecret, agentId }
411
+ * @param {object} params.config - Full openclaw config
412
+ * @returns {Promise<boolean>} Whether the request was handled
413
+ */
414
+ export async function handleAgentInbound({ req, res, agentAccount, config }) {
415
+ const crypto = new WecomCrypto(agentAccount.token, agentAccount.encodingAesKey);
416
+ const agentConfig = {
417
+ corpId: agentAccount.corpId,
418
+ corpSecret: agentAccount.corpSecret,
419
+ agentId: agentAccount.agentId,
420
+ };
421
+
422
+ if (req.method === "GET") {
423
+ return handleUrlVerification(req, res, crypto);
424
+ }
425
+
426
+ if (req.method === "POST") {
427
+ return handleMessageCallback(req, res, crypto, agentConfig, config);
428
+ }
429
+
430
+ res.writeHead(405, { "Content-Type": "text/plain" });
431
+ res.end("Method Not Allowed");
432
+ return true;
433
+ }