@mocrane/wecom 2026.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/clawdbot.plugin.json +10 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +81 -0
- package/src/accounts.ts +72 -0
- package/src/agent/api-client.ts +336 -0
- package/src/agent/handler.ts +566 -0
- package/src/agent/index.ts +12 -0
- package/src/channel.ts +259 -0
- package/src/config/accounts.ts +99 -0
- package/src/config/index.ts +12 -0
- package/src/config/media.ts +14 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +104 -0
- package/src/config-schema.ts +41 -0
- package/src/crypto/aes.ts +108 -0
- package/src/crypto/index.ts +24 -0
- package/src/crypto/signature.ts +43 -0
- package/src/crypto/xml.ts +49 -0
- package/src/crypto.test.ts +32 -0
- package/src/crypto.ts +176 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +55 -0
- package/src/media.ts +55 -0
- package/src/monitor/state.queue.test.ts +185 -0
- package/src/monitor/state.ts +514 -0
- package/src/monitor/types.ts +136 -0
- package/src/monitor.active.test.ts +239 -0
- package/src/monitor.integration.test.ts +207 -0
- package/src/monitor.ts +1802 -0
- package/src/monitor.webhook.test.ts +311 -0
- package/src/onboarding.ts +472 -0
- package/src/outbound.test.ts +143 -0
- package/src/outbound.ts +200 -0
- package/src/runtime.ts +14 -0
- package/src/shared/command-auth.ts +101 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/xml-parser.test.ts +30 -0
- package/src/shared/xml-parser.ts +183 -0
- package/src/target.ts +80 -0
- package/src/types/account.ts +76 -0
- package/src/types/config.ts +88 -0
- package/src/types/constants.ts +42 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/index.ts +38 -0
- package/src/types/message.ts +185 -0
- package/src/types.ts +159 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom Agent Webhook 处理器
|
|
3
|
+
* 处理 XML 格式回调
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
10
|
+
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
11
|
+
import { LIMITS } from "../types/constants.js";
|
|
12
|
+
import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
|
|
13
|
+
import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
|
|
14
|
+
import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId, extractMsgId, extractFileName } from "../shared/xml-parser.js";
|
|
15
|
+
import { sendText, downloadMedia } from "./api-client.js";
|
|
16
|
+
import { getWecomRuntime } from "../runtime.js";
|
|
17
|
+
import type { WecomAgentInboundMessage } from "../types/index.js";
|
|
18
|
+
import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
|
|
19
|
+
import { resolveWecomMediaMaxBytes } from "../config/index.js";
|
|
20
|
+
|
|
21
|
+
/** 错误提示信息 */
|
|
22
|
+
const ERROR_HELP = "";
|
|
23
|
+
|
|
24
|
+
// Agent webhook 幂等去重池(防止企微回调重试导致重复回复)
|
|
25
|
+
// 注意:这是进程内内存去重,重启会清空;但足以覆盖企微的短周期重试。
|
|
26
|
+
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
|
|
27
|
+
const recentAgentMsgIds = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
function rememberAgentMsgId(msgId: string): boolean {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const existing = recentAgentMsgIds.get(msgId);
|
|
32
|
+
if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
|
|
33
|
+
recentAgentMsgIds.set(msgId, now);
|
|
34
|
+
// 简单清理:只在写入时做一次线性 prune,避免无界增长
|
|
35
|
+
for (const [k, ts] of recentAgentMsgIds) {
|
|
36
|
+
if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function looksLikeTextFile(buffer: Buffer): boolean {
|
|
42
|
+
const sampleSize = Math.min(buffer.length, 4096);
|
|
43
|
+
if (sampleSize === 0) return true;
|
|
44
|
+
let bad = 0;
|
|
45
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
46
|
+
const b = buffer[i]!;
|
|
47
|
+
const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d; // \t \n \r
|
|
48
|
+
const isPrintable = b >= 0x20 && b !== 0x7f;
|
|
49
|
+
if (!isWhitespace && !isPrintable) bad++;
|
|
50
|
+
}
|
|
51
|
+
// 非可打印字符占比太高,基本可判断为二进制
|
|
52
|
+
return bad / sampleSize <= 0.02;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function analyzeTextHeuristic(buffer: Buffer): { sampleSize: number; badCount: number; badRatio: number } {
|
|
56
|
+
const sampleSize = Math.min(buffer.length, 4096);
|
|
57
|
+
if (sampleSize === 0) return { sampleSize: 0, badCount: 0, badRatio: 0 };
|
|
58
|
+
let badCount = 0;
|
|
59
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
60
|
+
const b = buffer[i]!;
|
|
61
|
+
const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d;
|
|
62
|
+
const isPrintable = b >= 0x20 && b !== 0x7f;
|
|
63
|
+
if (!isWhitespace && !isPrintable) badCount++;
|
|
64
|
+
}
|
|
65
|
+
return { sampleSize, badCount, badRatio: badCount / sampleSize };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function previewHex(buffer: Buffer, maxBytes = 32): string {
|
|
69
|
+
const n = Math.min(buffer.length, maxBytes);
|
|
70
|
+
if (n <= 0) return "";
|
|
71
|
+
return buffer
|
|
72
|
+
.subarray(0, n)
|
|
73
|
+
.toString("hex")
|
|
74
|
+
.replace(/(..)/g, "$1 ")
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefined {
|
|
79
|
+
if (!looksLikeTextFile(buffer)) return undefined;
|
|
80
|
+
const text = buffer.toString("utf8");
|
|
81
|
+
if (!text.trim()) return undefined;
|
|
82
|
+
const truncated = text.length > maxChars ? `${text.slice(0, maxChars)}\n…(已截断)` : text;
|
|
83
|
+
return truncated;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* **AgentWebhookParams (Webhook 处理器参数)**
|
|
88
|
+
*
|
|
89
|
+
* 传递给 Agent Webhook 处理函数的上下文参数集合。
|
|
90
|
+
* @property req Node.js 原始请求对象
|
|
91
|
+
* @property res Node.js 原始响应对象
|
|
92
|
+
* @property agent 解析后的 Agent 账号信息
|
|
93
|
+
* @property config 全局插件配置
|
|
94
|
+
* @property core OpenClaw 插件运行时
|
|
95
|
+
* @property log 可选日志输出函数
|
|
96
|
+
* @property error 可选错误输出函数
|
|
97
|
+
*/
|
|
98
|
+
export type AgentWebhookParams = {
|
|
99
|
+
req: IncomingMessage;
|
|
100
|
+
res: ServerResponse;
|
|
101
|
+
agent: ResolvedAgentAccount;
|
|
102
|
+
config: OpenClawConfig;
|
|
103
|
+
core: PluginRuntime;
|
|
104
|
+
log?: (msg: string) => void;
|
|
105
|
+
error?: (msg: string) => void;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* **resolveQueryParams (解析查询参数)**
|
|
110
|
+
*
|
|
111
|
+
* 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
|
|
112
|
+
*/
|
|
113
|
+
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
|
|
114
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
115
|
+
return url.searchParams;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* **readRawBody (读取原始请求体)**
|
|
120
|
+
*
|
|
121
|
+
* 异步读取 HTTP POST 请求的原始 BODY 数据(XML 字符串)。
|
|
122
|
+
* 包含最大体积限制检查,防止内存溢出攻击。
|
|
123
|
+
*/
|
|
124
|
+
async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_REQUEST_BODY_SIZE): Promise<string> {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const chunks: Buffer[] = [];
|
|
127
|
+
let size = 0;
|
|
128
|
+
|
|
129
|
+
req.on("data", (chunk: Buffer) => {
|
|
130
|
+
size += chunk.length;
|
|
131
|
+
if (size > maxSize) {
|
|
132
|
+
reject(new Error("Request body too large"));
|
|
133
|
+
req.destroy();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
chunks.push(chunk);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
req.on("end", () => {
|
|
140
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
req.on("error", reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* **handleUrlVerification (处理 URL 验证)**
|
|
149
|
+
*
|
|
150
|
+
* 处理企业微信 Agent 配置时的 GET 请求验证。
|
|
151
|
+
* 流程:
|
|
152
|
+
* 1. 验证 msg_signature 签名。
|
|
153
|
+
* 2. 解密 echostr 参数。
|
|
154
|
+
* 3. 返回解密后的明文 echostr。
|
|
155
|
+
*/
|
|
156
|
+
async function handleUrlVerification(
|
|
157
|
+
req: IncomingMessage,
|
|
158
|
+
res: ServerResponse,
|
|
159
|
+
agent: ResolvedAgentAccount,
|
|
160
|
+
): Promise<boolean> {
|
|
161
|
+
const query = resolveQueryParams(req);
|
|
162
|
+
const timestamp = query.get("timestamp") ?? "";
|
|
163
|
+
const nonce = query.get("nonce") ?? "";
|
|
164
|
+
const echostr = query.get("echostr") ?? "";
|
|
165
|
+
const signature = query.get("msg_signature") ?? "";
|
|
166
|
+
const remote = req.socket?.remoteAddress ?? "unknown";
|
|
167
|
+
|
|
168
|
+
// 不输出敏感参数内容,仅输出存在性
|
|
169
|
+
// 用于排查:是否有请求打到 /wecom/agent
|
|
170
|
+
// 以及是否带齐 timestamp/nonce/msg_signature/echostr
|
|
171
|
+
// eslint-disable-next-line no-unused-vars
|
|
172
|
+
const _debug = { remote, hasTimestamp: Boolean(timestamp), hasNonce: Boolean(nonce), hasSig: Boolean(signature), hasEchostr: Boolean(echostr) };
|
|
173
|
+
|
|
174
|
+
const valid = verifyWecomSignature({
|
|
175
|
+
token: agent.token,
|
|
176
|
+
timestamp,
|
|
177
|
+
nonce,
|
|
178
|
+
encrypt: echostr,
|
|
179
|
+
signature,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!valid) {
|
|
183
|
+
res.statusCode = 401;
|
|
184
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
185
|
+
res.end(`unauthorized - 签名验证失败,请检查 Token 配置${ERROR_HELP}`);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const plain = decryptWecomEncrypted({
|
|
191
|
+
encodingAESKey: agent.encodingAESKey,
|
|
192
|
+
receiveId: agent.corpId,
|
|
193
|
+
encrypt: echostr,
|
|
194
|
+
});
|
|
195
|
+
res.statusCode = 200;
|
|
196
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
197
|
+
res.end(plain);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
res.statusCode = 400;
|
|
201
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
202
|
+
res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey 配置${ERROR_HELP}`);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 处理消息回调 (POST)
|
|
209
|
+
*/
|
|
210
|
+
async function handleMessageCallback(params: AgentWebhookParams): Promise<boolean> {
|
|
211
|
+
const { req, res, agent, config, core, log, error } = params;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
|
|
215
|
+
const rawXml = await readRawBody(req);
|
|
216
|
+
log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
|
|
217
|
+
const encrypted = extractEncryptFromXml(rawXml);
|
|
218
|
+
log?.(`[wecom-agent] inbound: hasEncrypt=${Boolean(encrypted)} encryptLen=${encrypted ? String(encrypted).length : 0}`);
|
|
219
|
+
|
|
220
|
+
const query = resolveQueryParams(req);
|
|
221
|
+
const timestamp = query.get("timestamp") ?? "";
|
|
222
|
+
const nonce = query.get("nonce") ?? "";
|
|
223
|
+
const signature = query.get("msg_signature") ?? "";
|
|
224
|
+
log?.(
|
|
225
|
+
`[wecom-agent] inbound: query timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"}`,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// 验证签名
|
|
229
|
+
const valid = verifyWecomSignature({
|
|
230
|
+
token: agent.token,
|
|
231
|
+
timestamp,
|
|
232
|
+
nonce,
|
|
233
|
+
encrypt: encrypted,
|
|
234
|
+
signature,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!valid) {
|
|
238
|
+
error?.(`[wecom-agent] inbound: signature invalid`);
|
|
239
|
+
res.statusCode = 401;
|
|
240
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
241
|
+
res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 解密
|
|
246
|
+
const decrypted = decryptWecomEncrypted({
|
|
247
|
+
encodingAESKey: agent.encodingAESKey,
|
|
248
|
+
receiveId: agent.corpId,
|
|
249
|
+
encrypt: encrypted,
|
|
250
|
+
});
|
|
251
|
+
log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);
|
|
252
|
+
|
|
253
|
+
// 解析 XML
|
|
254
|
+
const msg = parseXml(decrypted);
|
|
255
|
+
const msgType = extractMsgType(msg);
|
|
256
|
+
const fromUser = extractFromUser(msg);
|
|
257
|
+
const chatId = extractChatId(msg);
|
|
258
|
+
const msgId = extractMsgId(msg);
|
|
259
|
+
if (msgId) {
|
|
260
|
+
const ok = rememberAgentMsgId(msgId);
|
|
261
|
+
if (!ok) {
|
|
262
|
+
log?.(`[wecom-agent] duplicate msgId=${msgId} from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}; skipped`);
|
|
263
|
+
res.statusCode = 200;
|
|
264
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
265
|
+
res.end("success");
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const content = String(extractContent(msg) ?? "");
|
|
270
|
+
|
|
271
|
+
const preview = content.length > 100 ? `${content.slice(0, 100)}…` : content;
|
|
272
|
+
log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} msgId=${msgId ?? "N/A"} content=${preview}`);
|
|
273
|
+
|
|
274
|
+
// 先返回 success (Agent 模式使用 API 发送回复,不用被动回复)
|
|
275
|
+
res.statusCode = 200;
|
|
276
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
277
|
+
res.end("success");
|
|
278
|
+
|
|
279
|
+
// 异步处理消息
|
|
280
|
+
processAgentMessage({
|
|
281
|
+
agent,
|
|
282
|
+
config,
|
|
283
|
+
core,
|
|
284
|
+
fromUser,
|
|
285
|
+
chatId,
|
|
286
|
+
msgType,
|
|
287
|
+
content,
|
|
288
|
+
msg,
|
|
289
|
+
log,
|
|
290
|
+
error,
|
|
291
|
+
}).catch((err) => {
|
|
292
|
+
error?.(`[wecom-agent] process failed: ${String(err)}`);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return true;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
error?.(`[wecom-agent] callback failed: ${String(err)}`);
|
|
298
|
+
res.statusCode = 400;
|
|
299
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
300
|
+
res.end(`error - 回调处理失败${ERROR_HELP}`);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* **processAgentMessage (处理 Agent 消息)**
|
|
307
|
+
*
|
|
308
|
+
* 异步处理解密后的消息内容,并触发 OpenClaw Agent。
|
|
309
|
+
* 流程:
|
|
310
|
+
* 1. 路由解析:根据 userid或群ID 确定 Agent 路由。
|
|
311
|
+
* 2. 媒体处理:如果是图片/文件等,下载资源。
|
|
312
|
+
* 3. 上下文构建:创建 Inbound Context。
|
|
313
|
+
* 4. 会话记录:更新 Session 状态。
|
|
314
|
+
* 5. 调度回复:将 Agent 的响应通过 `api-client` 发送回企业微信。
|
|
315
|
+
*/
|
|
316
|
+
async function processAgentMessage(params: {
|
|
317
|
+
agent: ResolvedAgentAccount;
|
|
318
|
+
config: OpenClawConfig;
|
|
319
|
+
core: PluginRuntime;
|
|
320
|
+
fromUser: string;
|
|
321
|
+
chatId?: string;
|
|
322
|
+
msgType: string;
|
|
323
|
+
content: string;
|
|
324
|
+
msg: WecomAgentInboundMessage;
|
|
325
|
+
log?: (msg: string) => void;
|
|
326
|
+
error?: (msg: string) => void;
|
|
327
|
+
}): Promise<void> {
|
|
328
|
+
const { agent, config, core, fromUser, chatId, content, msg, msgType, log, error } = params;
|
|
329
|
+
|
|
330
|
+
const isGroup = Boolean(chatId);
|
|
331
|
+
const peerId = isGroup ? chatId! : fromUser;
|
|
332
|
+
const mediaMaxBytes = resolveWecomMediaMaxBytes(config);
|
|
333
|
+
|
|
334
|
+
// 处理媒体文件
|
|
335
|
+
const attachments: any[] = []; // TODO: define specific type
|
|
336
|
+
let finalContent = content;
|
|
337
|
+
let mediaPath: string | undefined;
|
|
338
|
+
let mediaType: string | undefined;
|
|
339
|
+
|
|
340
|
+
if (["image", "voice", "video", "file"].includes(msgType)) {
|
|
341
|
+
const mediaId = extractMediaId(msg);
|
|
342
|
+
if (mediaId) {
|
|
343
|
+
try {
|
|
344
|
+
log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
|
|
345
|
+
const { buffer, contentType, filename: headerFileName } = await downloadMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
|
|
346
|
+
const xmlFileName = extractFileName(msg);
|
|
347
|
+
const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
|
|
348
|
+
const heuristic = analyzeTextHeuristic(buffer);
|
|
349
|
+
|
|
350
|
+
// 推断文件名后缀
|
|
351
|
+
const extMap: Record<string, string> = {
|
|
352
|
+
"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
|
|
353
|
+
"audio/amr": "amr", "audio/speex": "speex", "video/mp4": "mp4",
|
|
354
|
+
};
|
|
355
|
+
const textPreview = msgType === "file" ? buildTextFilePreview(buffer, 12_000) : undefined;
|
|
356
|
+
const looksText = Boolean(textPreview);
|
|
357
|
+
const originalExt = path.extname(originalFileName).toLowerCase();
|
|
358
|
+
const normalizedContentType =
|
|
359
|
+
looksText && originalExt === ".md" ? "text/markdown" :
|
|
360
|
+
looksText && (!contentType || contentType === "application/octet-stream")
|
|
361
|
+
? "text/plain; charset=utf-8"
|
|
362
|
+
: contentType;
|
|
363
|
+
|
|
364
|
+
const ext = extMap[normalizedContentType] || (looksText ? "txt" : "bin");
|
|
365
|
+
const filename = `${mediaId}.${ext}`;
|
|
366
|
+
|
|
367
|
+
log?.(
|
|
368
|
+
`[wecom-agent] file meta: msgType=${msgType} mediaId=${mediaId} size=${buffer.length} maxBytes=${mediaMaxBytes} ` +
|
|
369
|
+
`contentType=${contentType} normalizedContentType=${normalizedContentType} originalFileName=${originalFileName} ` +
|
|
370
|
+
`xmlFileName=${xmlFileName ?? "N/A"} headerFileName=${headerFileName ?? "N/A"} ` +
|
|
371
|
+
`textHeuristic(sample=${heuristic.sampleSize}, bad=${heuristic.badCount}, ratio=${heuristic.badRatio.toFixed(4)}) ` +
|
|
372
|
+
`headHex="${previewHex(buffer)}"`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// 使用 Core SDK 保存媒体文件
|
|
376
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
377
|
+
buffer,
|
|
378
|
+
normalizedContentType,
|
|
379
|
+
"inbound", // context/scope
|
|
380
|
+
mediaMaxBytes, // limit
|
|
381
|
+
originalFileName
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
log?.(`[wecom-agent] media saved to: ${saved.path}`);
|
|
385
|
+
mediaPath = saved.path;
|
|
386
|
+
mediaType = normalizedContentType;
|
|
387
|
+
|
|
388
|
+
// 构建附件
|
|
389
|
+
attachments.push({
|
|
390
|
+
name: originalFileName,
|
|
391
|
+
mimeType: normalizedContentType,
|
|
392
|
+
url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// 更新文本提示
|
|
396
|
+
if (textPreview) {
|
|
397
|
+
finalContent = [
|
|
398
|
+
content,
|
|
399
|
+
"",
|
|
400
|
+
"文件内容预览:",
|
|
401
|
+
"```",
|
|
402
|
+
textPreview,
|
|
403
|
+
"```",
|
|
404
|
+
`(已下载 ${buffer.length} 字节)`,
|
|
405
|
+
].join("\n");
|
|
406
|
+
} else {
|
|
407
|
+
if (msgType === "file") {
|
|
408
|
+
finalContent = [
|
|
409
|
+
content,
|
|
410
|
+
"",
|
|
411
|
+
`已收到文件:${originalFileName}`,
|
|
412
|
+
`文件类型:${normalizedContentType || contentType || "未知"}`,
|
|
413
|
+
"提示:当前仅对文本/Markdown/JSON/CSV/HTML/PDF(可选)做内容抽取;其他二进制格式请转为 PDF 或复制文本内容。",
|
|
414
|
+
`(已下载 ${buffer.length} 字节)`,
|
|
415
|
+
].join("\n");
|
|
416
|
+
} else {
|
|
417
|
+
finalContent = `${content} (已下载 ${buffer.length} 字节)`;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
log?.(`[wecom-agent] file preview: enabled=${looksText} finalContentLen=${finalContent.length} attachments=${attachments.length}`);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
error?.(`[wecom-agent] media processing failed: ${String(err)}`);
|
|
423
|
+
finalContent = [
|
|
424
|
+
content,
|
|
425
|
+
"",
|
|
426
|
+
`媒体处理失败:${String(err)}`,
|
|
427
|
+
`提示:可在 OpenClaw 配置中提高 channels.wecom.media.maxBytes(当前=${mediaMaxBytes})`,
|
|
428
|
+
`例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
|
|
429
|
+
].join("\n");
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
const keys = Object.keys((msg as unknown as Record<string, unknown>) ?? {}).slice(0, 50).join(",");
|
|
433
|
+
error?.(`[wecom-agent] mediaId not found for ${msgType}; keys=${keys}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 解析路由
|
|
438
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
439
|
+
cfg: config,
|
|
440
|
+
channel: "wecom",
|
|
441
|
+
accountId: agent.accountId,
|
|
442
|
+
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// 构建上下文
|
|
446
|
+
const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
|
|
447
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
448
|
+
agentId: route.agentId,
|
|
449
|
+
});
|
|
450
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
451
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
452
|
+
storePath,
|
|
453
|
+
sessionKey: route.sessionKey,
|
|
454
|
+
});
|
|
455
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
456
|
+
channel: "WeCom",
|
|
457
|
+
from: fromLabel,
|
|
458
|
+
previousTimestamp,
|
|
459
|
+
envelope: envelopeOptions,
|
|
460
|
+
body: finalContent,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const authz = await resolveWecomCommandAuthorization({
|
|
464
|
+
core,
|
|
465
|
+
cfg: config,
|
|
466
|
+
// Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
|
|
467
|
+
accountConfig: agent.config as any,
|
|
468
|
+
rawBody: finalContent,
|
|
469
|
+
senderUserId: fromUser,
|
|
470
|
+
});
|
|
471
|
+
log?.(`[wecom-agent] authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${fromUser.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(authz.commandAuthorized)}`);
|
|
472
|
+
|
|
473
|
+
// 命令门禁:未授权时必须明确回复(Agent 侧用私信提示)
|
|
474
|
+
if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
|
|
475
|
+
const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: fromUser, dmPolicy: authz.dmPolicy, scope: "agent" });
|
|
476
|
+
try {
|
|
477
|
+
await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
|
|
478
|
+
log?.(`[wecom-agent] unauthorized command: replied via DM to ${fromUser}`);
|
|
479
|
+
} catch (err: unknown) {
|
|
480
|
+
error?.(`[wecom-agent] unauthorized command reply failed: ${String(err)}`);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
486
|
+
Body: body,
|
|
487
|
+
RawBody: finalContent,
|
|
488
|
+
CommandBody: finalContent,
|
|
489
|
+
Attachments: attachments.length > 0 ? attachments : undefined,
|
|
490
|
+
From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
|
|
491
|
+
To: `wecom:${peerId}`,
|
|
492
|
+
SessionKey: route.sessionKey,
|
|
493
|
+
AccountId: route.accountId,
|
|
494
|
+
ChatType: isGroup ? "group" : "direct",
|
|
495
|
+
ConversationLabel: fromLabel,
|
|
496
|
+
SenderName: fromUser,
|
|
497
|
+
SenderId: fromUser,
|
|
498
|
+
Provider: "wecom",
|
|
499
|
+
Surface: "wecom",
|
|
500
|
+
OriginatingChannel: "wecom",
|
|
501
|
+
// 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
|
|
502
|
+
// - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
|
|
503
|
+
// - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
|
|
504
|
+
OriginatingTo: `wecom-agent:${fromUser}`,
|
|
505
|
+
CommandAuthorized: authz.commandAuthorized ?? true,
|
|
506
|
+
MediaPath: mediaPath,
|
|
507
|
+
MediaType: mediaType,
|
|
508
|
+
MediaUrl: mediaPath,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// 记录会话
|
|
512
|
+
await core.channel.session.recordInboundSession({
|
|
513
|
+
storePath,
|
|
514
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
515
|
+
ctx: ctxPayload,
|
|
516
|
+
onRecordError: (err: unknown) => {
|
|
517
|
+
error?.(`[wecom-agent] session record failed: ${String(err)}`);
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// 调度回复
|
|
522
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
523
|
+
ctx: ctxPayload,
|
|
524
|
+
cfg: config,
|
|
525
|
+
dispatcherOptions: {
|
|
526
|
+
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
527
|
+
const text = payload.text ?? "";
|
|
528
|
+
if (!text) return;
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
// 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
|
|
532
|
+
await sendText({ agent, toUser: fromUser, chatId: undefined, text });
|
|
533
|
+
log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
|
|
534
|
+
} catch (err: unknown) {
|
|
535
|
+
error?.(`[wecom-agent] reply failed: ${String(err)}`);
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
onError: (err: unknown, info: { kind: string }) => {
|
|
539
|
+
error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
replyOptions: {
|
|
543
|
+
disableBlockStreaming: true,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* **handleAgentWebhook (Agent Webhook 入口)**
|
|
550
|
+
*
|
|
551
|
+
* 统一处理 Agent 模式的 Webhook 请求。
|
|
552
|
+
* 根据 HTTP 方法分发到 URL 验证 (GET) 或 消息处理 (POST)。
|
|
553
|
+
*/
|
|
554
|
+
export async function handleAgentWebhook(params: AgentWebhookParams): Promise<boolean> {
|
|
555
|
+
const { req } = params;
|
|
556
|
+
|
|
557
|
+
if (req.method === "GET") {
|
|
558
|
+
return handleUrlVerification(req, params.res, params.agent);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (req.method === "POST") {
|
|
562
|
+
return handleMessageCallback(params);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return false;
|
|
566
|
+
}
|