@x.ken/wecom 1.0.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 +268 -0
- package/clawdbot.plugin.json +11 -0
- package/index.ts +17 -0
- package/package.json +17 -0
- package/src/access-token.ts +56 -0
- package/src/channel.ts +114 -0
- package/src/client.ts +124 -0
- package/src/config-schema.ts +19 -0
- package/src/crypto.ts +113 -0
- package/src/gateway.ts +413 -0
- package/src/message-parser.ts +195 -0
- package/src/multipart.ts +70 -0
- package/src/official-api.ts +150 -0
- package/src/runtime.ts +12 -0
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createDecipheriv, createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function calculateSignature(
|
|
4
|
+
token: string,
|
|
5
|
+
timestamp: string,
|
|
6
|
+
nonce: string,
|
|
7
|
+
encrypt: string
|
|
8
|
+
): string {
|
|
9
|
+
const arr = [token, timestamp, nonce, encrypt].sort();
|
|
10
|
+
const str = arr.join("");
|
|
11
|
+
return createHash("sha1").update(str).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function verifySignature(
|
|
15
|
+
token: string,
|
|
16
|
+
timestamp: string,
|
|
17
|
+
nonce: string,
|
|
18
|
+
encrypt: string,
|
|
19
|
+
signature: string
|
|
20
|
+
): boolean {
|
|
21
|
+
const calculated = calculateSignature(token, timestamp, nonce, encrypt);
|
|
22
|
+
return calculated === signature;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decryptMessage(
|
|
26
|
+
encodingAESKey: string,
|
|
27
|
+
encryptedMsg: string,
|
|
28
|
+
corpId: string
|
|
29
|
+
): string {
|
|
30
|
+
const aesKeyBase64 = encodingAESKey + "=";
|
|
31
|
+
const aesKey = Buffer.from(aesKeyBase64, "base64");
|
|
32
|
+
|
|
33
|
+
if (aesKey.length !== 32) {
|
|
34
|
+
throw new Error(`Invalid AES key length: ${aesKey.length}, expected 32`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const encryptedBuffer = Buffer.from(encryptedMsg, "base64");
|
|
38
|
+
|
|
39
|
+
const iv = aesKey.slice(0, 16);
|
|
40
|
+
const decipher = createDecipheriv("aes-256-cbc", aesKey, iv);
|
|
41
|
+
decipher.setAutoPadding(false);
|
|
42
|
+
|
|
43
|
+
const decrypted = Buffer.concat([
|
|
44
|
+
decipher.update(encryptedBuffer),
|
|
45
|
+
decipher.final(),
|
|
46
|
+
] as Buffer[]) as Buffer;
|
|
47
|
+
|
|
48
|
+
if (decrypted.length < 20) {
|
|
49
|
+
throw new Error("Decrypted message too short");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let offset = 16;
|
|
53
|
+
|
|
54
|
+
const msgLen = decrypted.readUInt32BE(offset);
|
|
55
|
+
offset += 4;
|
|
56
|
+
|
|
57
|
+
if (offset + msgLen > decrypted.length) {
|
|
58
|
+
throw new Error("Invalid message length");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const msg = decrypted.slice(offset, offset + msgLen).toString("utf8");
|
|
62
|
+
|
|
63
|
+
// WeCom CorpId 格式是固定的 18 字节 (如 ww21aeaac549e9a9f5)
|
|
64
|
+
// 正确的消息格式: [16B随机数][4B msgLen][msgLen B 消息][18B CorpId][4B随机数]
|
|
65
|
+
const expectedCorpIdLen = 18;
|
|
66
|
+
|
|
67
|
+
// 计算 corpId 的起始位置
|
|
68
|
+
const corpIdOffset = offset + msgLen;
|
|
69
|
+
|
|
70
|
+
// 检查剩余 buffer 是否足够长
|
|
71
|
+
if (corpIdOffset + expectedCorpIdLen > decrypted.length) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Invalid message: not enough bytes for CorpId. corpIdOffset=${corpIdOffset}, decryptedLen=${decrypted.length}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const receivedCorpIdBuffer = decrypted.slice(corpIdOffset, corpIdOffset + expectedCorpIdLen);
|
|
78
|
+
const receivedCorpId = receivedCorpIdBuffer.toString("utf8").trim();
|
|
79
|
+
|
|
80
|
+
// 从配置中清理 corpId(去除可能的隐藏字符和空格)
|
|
81
|
+
const cleanCorpId = corpId.trim();
|
|
82
|
+
|
|
83
|
+
if (receivedCorpId !== cleanCorpId) {
|
|
84
|
+
console.error("[WeCom Crypto] CorpId mismatch debug:");
|
|
85
|
+
console.error(" Expected (config, cleaned):", JSON.stringify(cleanCorpId), "len:", cleanCorpId.length);
|
|
86
|
+
console.error(" Received (from message):", JSON.stringify(receivedCorpId), "len:", receivedCorpId.length);
|
|
87
|
+
console.error(" Expected bytes:", Buffer.from(cleanCorpId).toString("hex"));
|
|
88
|
+
console.error(" Received bytes:", receivedCorpIdBuffer.toString("hex"));
|
|
89
|
+
|
|
90
|
+
throw new Error(
|
|
91
|
+
`CorpId mismatch: expected ${cleanCorpId}, got ${receivedCorpId}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return msg;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removePKCS7Padding(buffer: Buffer | Buffer[]): Buffer {
|
|
99
|
+
const buf = Array.isArray(buffer) ? Buffer.concat(buffer) : buffer;
|
|
100
|
+
const paddingLength = buf[buf.length - 1];
|
|
101
|
+
|
|
102
|
+
if (paddingLength < 1 || paddingLength > 32) {
|
|
103
|
+
throw new Error("Invalid PKCS7 padding");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < paddingLength; i++) {
|
|
107
|
+
if (buf[buf.length - 1 - i] !== paddingLength) {
|
|
108
|
+
throw new Error("Invalid PKCS7 padding");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return buf.slice(0, buf.length - paddingLength);
|
|
113
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { registerPluginHttpRoute, type ChannelGatewayContext } from "clawdbot/plugin-sdk";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { wecomClient, type WeComMessage } from "./client.js";
|
|
4
|
+
import { parseMultipart } from "./multipart.js";
|
|
5
|
+
import { writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { verifySignature, decryptMessage, calculateSignature } from "./crypto.js";
|
|
10
|
+
import { parseWeComMessage, formatMessageForClawdbot } from "./message-parser.js";
|
|
11
|
+
import { XMLParser } from "fast-xml-parser";
|
|
12
|
+
import { getWeComRuntime } from "./runtime.js";
|
|
13
|
+
|
|
14
|
+
async function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
15
|
+
const chunks: Buffer[] = [];
|
|
16
|
+
for await (const chunk of req) {
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
}
|
|
19
|
+
return Buffer.concat(chunks);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function startWeComAccount(ctx: ChannelGatewayContext) {
|
|
23
|
+
const accountId = ctx.account.accountId;
|
|
24
|
+
const config = ctx.account.config as any;
|
|
25
|
+
|
|
26
|
+
const unregisterMessage = registerPluginHttpRoute({
|
|
27
|
+
pluginId: "wecom",
|
|
28
|
+
accountId,
|
|
29
|
+
path: "/wecom/message",
|
|
30
|
+
handler: async (req, res) => {
|
|
31
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
32
|
+
|
|
33
|
+
if (req.method === "GET") {
|
|
34
|
+
const msgSignature = url.searchParams.get("msg_signature");
|
|
35
|
+
const timestamp = url.searchParams.get("timestamp");
|
|
36
|
+
const nonce = url.searchParams.get("nonce");
|
|
37
|
+
const echostr = url.searchParams.get("echostr");
|
|
38
|
+
|
|
39
|
+
const token = config.token;
|
|
40
|
+
|
|
41
|
+
if (!token) {
|
|
42
|
+
res.statusCode = 500;
|
|
43
|
+
res.end("Token not configured");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!msgSignature || !timestamp || !nonce || !echostr) {
|
|
48
|
+
res.statusCode = 400;
|
|
49
|
+
res.end("Missing required parameters");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const expectedSignature = calculateSignature(token, timestamp, nonce, echostr);
|
|
55
|
+
if (expectedSignature !== msgSignature) {
|
|
56
|
+
res.statusCode = 403;
|
|
57
|
+
res.end("Invalid signature");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const encodingAESKey = config.encodingAESKey;
|
|
62
|
+
const corpid = config.corpid;
|
|
63
|
+
|
|
64
|
+
if (!encodingAESKey || !corpid) {
|
|
65
|
+
res.statusCode = 500;
|
|
66
|
+
res.end("encodingAESKey or corpid not configured");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const decryptedEchoStr = decryptMessage(encodingAESKey, echostr, corpid);
|
|
71
|
+
res.statusCode = 200;
|
|
72
|
+
res.setHeader("Content-Type", "text/plain");
|
|
73
|
+
res.end(decryptedEchoStr);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error("WeChat verification failed:", error);
|
|
76
|
+
res.statusCode = 500;
|
|
77
|
+
res.end(String(error));
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (req.method !== "POST") {
|
|
83
|
+
res.statusCode = 405;
|
|
84
|
+
res.end("Method Not Allowed");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const contentType = req.headers["content-type"] || "";
|
|
90
|
+
|
|
91
|
+
const isEncryptedWeComMessage =
|
|
92
|
+
contentType.includes("text/xml") ||
|
|
93
|
+
contentType.includes("application/xml") ||
|
|
94
|
+
url.searchParams.has("msg_signature");
|
|
95
|
+
|
|
96
|
+
if (isEncryptedWeComMessage) {
|
|
97
|
+
await handleEncryptedWeComMessage(req, res, url, ctx, accountId);
|
|
98
|
+
} else {
|
|
99
|
+
await handleLegacyMessage(req, res, contentType, ctx, accountId);
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error("WeCom handler error:", e);
|
|
103
|
+
if (!res.writableEnded) {
|
|
104
|
+
res.statusCode = 500;
|
|
105
|
+
res.end(String(e));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const unregisterPoll = registerPluginHttpRoute({
|
|
112
|
+
pluginId: "wecom",
|
|
113
|
+
accountId,
|
|
114
|
+
path: "/wecom/messages",
|
|
115
|
+
handler: async (req, res) => {
|
|
116
|
+
if (req.method !== "GET") {
|
|
117
|
+
res.statusCode = 405;
|
|
118
|
+
res.end("Method Not Allowed");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
123
|
+
const email = url.searchParams.get("email");
|
|
124
|
+
|
|
125
|
+
if (!email) {
|
|
126
|
+
res.statusCode = 400;
|
|
127
|
+
res.end("Missing email param");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const messages = wecomClient.getPendingMessages(email);
|
|
132
|
+
res.setHeader("Content-Type", "application/json");
|
|
133
|
+
res.end(JSON.stringify({ messages }));
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
stop: () => {
|
|
139
|
+
unregisterMessage();
|
|
140
|
+
unregisterPoll();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleEncryptedWeComMessage(
|
|
146
|
+
req: IncomingMessage,
|
|
147
|
+
res: any,
|
|
148
|
+
url: URL,
|
|
149
|
+
ctx: ChannelGatewayContext,
|
|
150
|
+
accountId: string
|
|
151
|
+
) {
|
|
152
|
+
const config = ctx.account.config as any;
|
|
153
|
+
|
|
154
|
+
const msgSignature = url.searchParams.get("msg_signature");
|
|
155
|
+
const timestamp = url.searchParams.get("timestamp");
|
|
156
|
+
const nonce = url.searchParams.get("nonce");
|
|
157
|
+
|
|
158
|
+
const token = config.token;
|
|
159
|
+
const encodingAESKey = config.encodingAESKey;
|
|
160
|
+
const corpid = config.corpid;
|
|
161
|
+
|
|
162
|
+
if (!token || !encodingAESKey || !corpid) {
|
|
163
|
+
res.statusCode = 500;
|
|
164
|
+
res.end("Token, encodingAESKey or corpid not configured");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!msgSignature || !timestamp || !nonce) {
|
|
169
|
+
res.statusCode = 400;
|
|
170
|
+
res.end("Missing signature parameters");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const rawBody = await readBody(req);
|
|
175
|
+
const xmlString = rawBody.toString("utf8");
|
|
176
|
+
|
|
177
|
+
console.log("=== Received WeChat Encrypted Message ===");
|
|
178
|
+
console.log("XML:", xmlString);
|
|
179
|
+
|
|
180
|
+
const parser = new XMLParser({
|
|
181
|
+
ignoreAttributes: false,
|
|
182
|
+
parseTagValue: false,
|
|
183
|
+
trimValues: true,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const xmlObj = parser.parse(xmlString);
|
|
187
|
+
const encryptedMsg = xmlObj.xml?.Encrypt || xmlObj.xml?.encrypt;
|
|
188
|
+
|
|
189
|
+
if (!encryptedMsg) {
|
|
190
|
+
res.statusCode = 400;
|
|
191
|
+
res.end("Missing Encrypt field in XML");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const isValid = verifySignature(token, timestamp, nonce, encryptedMsg, msgSignature);
|
|
196
|
+
if (!isValid) {
|
|
197
|
+
console.error("Invalid message signature");
|
|
198
|
+
res.statusCode = 403;
|
|
199
|
+
res.end("Invalid signature");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let decryptedXml: string;
|
|
204
|
+
try {
|
|
205
|
+
decryptedXml = decryptMessage(encodingAESKey, encryptedMsg, corpid);
|
|
206
|
+
console.log("=== Decrypted Message XML ===");
|
|
207
|
+
console.log(decryptedXml);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error("Decryption failed:", error);
|
|
210
|
+
res.statusCode = 500;
|
|
211
|
+
res.end("Decryption failed");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const wecomMessage = parseWeComMessage(decryptedXml);
|
|
216
|
+
console.log("=== Parsed WeChat Message ===");
|
|
217
|
+
console.log(JSON.stringify(wecomMessage, null, 2));
|
|
218
|
+
|
|
219
|
+
const { text, mediaUrls } = formatMessageForClawdbot(wecomMessage);
|
|
220
|
+
const userId = wecomMessage.FromUserName;
|
|
221
|
+
|
|
222
|
+
console.log("=== WeCom Context to Agent ===");
|
|
223
|
+
console.log("From:", userId);
|
|
224
|
+
console.log("Body:", text);
|
|
225
|
+
console.log("MediaUrls:", mediaUrls);
|
|
226
|
+
console.log("===================================");
|
|
227
|
+
|
|
228
|
+
res.statusCode = 200;
|
|
229
|
+
res.setHeader("Content-Type", "text/plain");
|
|
230
|
+
res.end("success");
|
|
231
|
+
|
|
232
|
+
const systemPrompt = config.systemPrompt?.trim() || undefined;
|
|
233
|
+
|
|
234
|
+
const core = getWeComRuntime();
|
|
235
|
+
|
|
236
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
237
|
+
ctx: {
|
|
238
|
+
From: userId,
|
|
239
|
+
Body: text,
|
|
240
|
+
AccountId: accountId,
|
|
241
|
+
SessionKey: `wecom:${accountId}:${userId}`,
|
|
242
|
+
MediaUrls: mediaUrls,
|
|
243
|
+
GroupSystemPrompt: systemPrompt,
|
|
244
|
+
},
|
|
245
|
+
cfg: ctx.cfg,
|
|
246
|
+
dispatcherOptions: {
|
|
247
|
+
responsePrefix: "",
|
|
248
|
+
deliver: async (payload) => {
|
|
249
|
+
console.log("=== WeCom Deliver Payload ===");
|
|
250
|
+
console.log("Text:", payload.text);
|
|
251
|
+
console.log("MediaUrl:", payload.mediaUrl);
|
|
252
|
+
console.log("================================");
|
|
253
|
+
|
|
254
|
+
// 配置验证
|
|
255
|
+
if (!config.corpid) {
|
|
256
|
+
console.error("[WeCom Error] corpid not configured! Please add to clawdbot.json:");
|
|
257
|
+
console.error(" channels.wecom.corpid: 'ww...'");
|
|
258
|
+
}
|
|
259
|
+
if (!config.corpsecret) {
|
|
260
|
+
console.error("[WeCom Error] corpsecret not configured! Please add to clawdbot.json:");
|
|
261
|
+
console.error(" channels.wecom.corpsecret: 'your-corp-secret'");
|
|
262
|
+
}
|
|
263
|
+
if (!config.agentid) {
|
|
264
|
+
console.error("[WeCom Error] agentid not configured! Please add to clawdbot.json:");
|
|
265
|
+
console.error(" channels.wecom.agentid: 1000006");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const msg: WeComMessage = {
|
|
269
|
+
text: payload.text,
|
|
270
|
+
mediaUrl: payload.mediaUrl
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
await wecomClient.sendMessage(userId, msg, {
|
|
274
|
+
corpid: config.corpid,
|
|
275
|
+
corpsecret: config.corpsecret,
|
|
276
|
+
agentid: config.agentid,
|
|
277
|
+
token: config.token,
|
|
278
|
+
encodingAESKey: config.encodingAESKey
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
onError: (err) => {
|
|
282
|
+
console.error("WeCom dispatch error:", err);
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
replyOptions: {},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function handleLegacyMessage(
|
|
290
|
+
req: IncomingMessage,
|
|
291
|
+
res: any,
|
|
292
|
+
contentType: string,
|
|
293
|
+
ctx: ChannelGatewayContext,
|
|
294
|
+
accountId: string
|
|
295
|
+
) {
|
|
296
|
+
let email: string | undefined;
|
|
297
|
+
let text: string | undefined;
|
|
298
|
+
let imageUrl: string | undefined;
|
|
299
|
+
let sync = false;
|
|
300
|
+
const files: Array<{ filename: string; path: string; mimetype: string }> = [];
|
|
301
|
+
|
|
302
|
+
if (contentType.includes("application/json")) {
|
|
303
|
+
const raw = await readBody(req);
|
|
304
|
+
if (raw.length === 0) {
|
|
305
|
+
res.statusCode = 400;
|
|
306
|
+
res.end("Empty body");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const body = JSON.parse(raw.toString());
|
|
310
|
+
email = body.email;
|
|
311
|
+
text = body.text;
|
|
312
|
+
imageUrl = body.imageUrl;
|
|
313
|
+
sync = Boolean(body.sync);
|
|
314
|
+
} else if (contentType.includes("multipart/form-data")) {
|
|
315
|
+
const boundary = contentType.split("boundary=")[1]?.split(";")[0];
|
|
316
|
+
if (!boundary) throw new Error("No boundary");
|
|
317
|
+
const buffer = await readBody(req);
|
|
318
|
+
const result = parseMultipart(buffer, boundary);
|
|
319
|
+
|
|
320
|
+
email = result.fields.email;
|
|
321
|
+
text = result.fields.text;
|
|
322
|
+
sync = result.fields.sync === "true";
|
|
323
|
+
|
|
324
|
+
for (const file of result.files) {
|
|
325
|
+
const tempPath = join(tmpdir(), `wecom-${randomUUID()}-${file.filename}`);
|
|
326
|
+
await writeFile(tempPath, file.data);
|
|
327
|
+
files.push({
|
|
328
|
+
filename: file.filename,
|
|
329
|
+
path: tempPath,
|
|
330
|
+
mimetype: file.mimetype,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!email) {
|
|
336
|
+
res.statusCode = 400;
|
|
337
|
+
res.end("Missing email");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (sync) {
|
|
342
|
+
wecomClient.registerPendingRequest(email, res);
|
|
343
|
+
} else {
|
|
344
|
+
res.statusCode = 200;
|
|
345
|
+
res.setHeader("Content-Type", "application/json");
|
|
346
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const mediaUrls: string[] = [];
|
|
350
|
+
if (imageUrl) mediaUrls.push(imageUrl);
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
mediaUrls.push(`file://${file.path}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let enrichedText = text || "";
|
|
356
|
+
if (files.length > 0) {
|
|
357
|
+
enrichedText += "\n\n[上传的文件]";
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
enrichedText += `\n- ${file.filename}: ${file.path}`;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
console.log("=== WeCom Context to Agent ===");
|
|
364
|
+
console.log("From:", email);
|
|
365
|
+
console.log("Body:", enrichedText);
|
|
366
|
+
console.log("MediaUrls:", mediaUrls);
|
|
367
|
+
console.log("Files count:", files.length);
|
|
368
|
+
console.log("===================================");
|
|
369
|
+
|
|
370
|
+
const config = ctx.account.config as any;
|
|
371
|
+
const systemPrompt = config.systemPrompt?.trim() || undefined;
|
|
372
|
+
|
|
373
|
+
const core = getWeComRuntime();
|
|
374
|
+
|
|
375
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
376
|
+
ctx: {
|
|
377
|
+
From: email,
|
|
378
|
+
Body: enrichedText,
|
|
379
|
+
AccountId: accountId,
|
|
380
|
+
SessionKey: `wecom:${accountId}:${email}`,
|
|
381
|
+
MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
|
382
|
+
GroupSystemPrompt: systemPrompt,
|
|
383
|
+
},
|
|
384
|
+
cfg: ctx.cfg,
|
|
385
|
+
dispatcherOptions: {
|
|
386
|
+
responsePrefix: "",
|
|
387
|
+
deliver: async (payload) => {
|
|
388
|
+
console.log("=== WeCom Deliver Payload ===");
|
|
389
|
+
console.log("Text:", payload.text);
|
|
390
|
+
console.log("MediaUrl:", payload.mediaUrl);
|
|
391
|
+
console.log("================================");
|
|
392
|
+
|
|
393
|
+
const config = ctx.account.config as any;
|
|
394
|
+
const msg: WeComMessage = {
|
|
395
|
+
text: payload.text,
|
|
396
|
+
mediaUrl: payload.mediaUrl
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
await wecomClient.sendMessage(email!, msg, {
|
|
400
|
+
corpid: config.corpid,
|
|
401
|
+
corpsecret: config.corpsecret,
|
|
402
|
+
agentid: config.agentid,
|
|
403
|
+
token: config.token,
|
|
404
|
+
encodingAESKey: config.encodingAESKey
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
onError: (err) => {
|
|
408
|
+
console.error("WeCom dispatch error:", err);
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
replyOptions: {},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
|
|
3
|
+
export type WeComMessageType = "text" | "image" | "voice" | "video" | "location" | "link" | "event";
|
|
4
|
+
|
|
5
|
+
export interface WeComMessageBase {
|
|
6
|
+
ToUserName: string;
|
|
7
|
+
FromUserName: string;
|
|
8
|
+
CreateTime: number;
|
|
9
|
+
MsgType: WeComMessageType;
|
|
10
|
+
AgentID: number;
|
|
11
|
+
MsgId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WeComTextMessage extends WeComMessageBase {
|
|
15
|
+
MsgType: "text";
|
|
16
|
+
Content: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WeComImageMessage extends WeComMessageBase {
|
|
20
|
+
MsgType: "image";
|
|
21
|
+
PicUrl: string;
|
|
22
|
+
MediaId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WeComVoiceMessage extends WeComMessageBase {
|
|
26
|
+
MsgType: "voice";
|
|
27
|
+
MediaId: string;
|
|
28
|
+
Format: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WeComVideoMessage extends WeComMessageBase {
|
|
32
|
+
MsgType: "video";
|
|
33
|
+
MediaId: string;
|
|
34
|
+
ThumbMediaId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WeComLocationMessage extends WeComMessageBase {
|
|
38
|
+
MsgType: "location";
|
|
39
|
+
Location_X: number;
|
|
40
|
+
Location_Y: number;
|
|
41
|
+
Scale: number;
|
|
42
|
+
Label: string;
|
|
43
|
+
AppType?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WeComLinkMessage extends WeComMessageBase {
|
|
47
|
+
MsgType: "link";
|
|
48
|
+
Title: string;
|
|
49
|
+
Description: string;
|
|
50
|
+
Url: string;
|
|
51
|
+
PicUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WeComMessage =
|
|
55
|
+
| WeComTextMessage
|
|
56
|
+
| WeComImageMessage
|
|
57
|
+
| WeComVoiceMessage
|
|
58
|
+
| WeComVideoMessage
|
|
59
|
+
| WeComLocationMessage
|
|
60
|
+
| WeComLinkMessage;
|
|
61
|
+
|
|
62
|
+
export function parseWeComMessage(xml: string): WeComMessage {
|
|
63
|
+
const parser = new XMLParser({
|
|
64
|
+
ignoreAttributes: false,
|
|
65
|
+
parseTagValue: true,
|
|
66
|
+
parseAttributeValue: true,
|
|
67
|
+
trimValues: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = parser.parse(xml);
|
|
71
|
+
|
|
72
|
+
if (!result.xml) {
|
|
73
|
+
throw new Error("Invalid WeChat message XML: missing <xml> root");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const msg = result.xml;
|
|
77
|
+
|
|
78
|
+
if (!msg.ToUserName || !msg.FromUserName || !msg.MsgType) {
|
|
79
|
+
throw new Error("Invalid WeChat message: missing required fields");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const baseMessage = {
|
|
83
|
+
ToUserName: String(msg.ToUserName),
|
|
84
|
+
FromUserName: String(msg.FromUserName),
|
|
85
|
+
CreateTime: Number(msg.CreateTime) || 0,
|
|
86
|
+
MsgType: msg.MsgType as WeComMessageType,
|
|
87
|
+
AgentID: Number(msg.AgentID) || 0,
|
|
88
|
+
MsgId: msg.MsgId ? String(msg.MsgId) : undefined,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
switch (msg.MsgType) {
|
|
92
|
+
case "text":
|
|
93
|
+
return {
|
|
94
|
+
...baseMessage,
|
|
95
|
+
MsgType: "text",
|
|
96
|
+
Content: String(msg.Content || ""),
|
|
97
|
+
} as WeComTextMessage;
|
|
98
|
+
|
|
99
|
+
case "image":
|
|
100
|
+
return {
|
|
101
|
+
...baseMessage,
|
|
102
|
+
MsgType: "image",
|
|
103
|
+
PicUrl: String(msg.PicUrl || ""),
|
|
104
|
+
MediaId: String(msg.MediaId || ""),
|
|
105
|
+
} as WeComImageMessage;
|
|
106
|
+
|
|
107
|
+
case "voice":
|
|
108
|
+
return {
|
|
109
|
+
...baseMessage,
|
|
110
|
+
MsgType: "voice",
|
|
111
|
+
MediaId: String(msg.MediaId || ""),
|
|
112
|
+
Format: String(msg.Format || ""),
|
|
113
|
+
} as WeComVoiceMessage;
|
|
114
|
+
|
|
115
|
+
case "video":
|
|
116
|
+
return {
|
|
117
|
+
...baseMessage,
|
|
118
|
+
MsgType: "video",
|
|
119
|
+
MediaId: String(msg.MediaId || ""),
|
|
120
|
+
ThumbMediaId: String(msg.ThumbMediaId || ""),
|
|
121
|
+
} as WeComVideoMessage;
|
|
122
|
+
|
|
123
|
+
case "location":
|
|
124
|
+
return {
|
|
125
|
+
...baseMessage,
|
|
126
|
+
MsgType: "location",
|
|
127
|
+
Location_X: Number(msg.Location_X) || 0,
|
|
128
|
+
Location_Y: Number(msg.Location_Y) || 0,
|
|
129
|
+
Scale: Number(msg.Scale) || 0,
|
|
130
|
+
Label: String(msg.Label || ""),
|
|
131
|
+
AppType: msg.AppType ? String(msg.AppType) : undefined,
|
|
132
|
+
} as WeComLocationMessage;
|
|
133
|
+
|
|
134
|
+
case "link":
|
|
135
|
+
return {
|
|
136
|
+
...baseMessage,
|
|
137
|
+
MsgType: "link",
|
|
138
|
+
Title: String(msg.Title || ""),
|
|
139
|
+
Description: String(msg.Description || ""),
|
|
140
|
+
Url: String(msg.Url || ""),
|
|
141
|
+
PicUrl: String(msg.PicUrl || ""),
|
|
142
|
+
} as WeComLinkMessage;
|
|
143
|
+
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`Unsupported message type: ${msg.MsgType}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatMessageForClawdbot(message: WeComMessage): {
|
|
150
|
+
text: string;
|
|
151
|
+
mediaUrls?: string[];
|
|
152
|
+
} {
|
|
153
|
+
const mediaUrls: string[] = [];
|
|
154
|
+
let text = "";
|
|
155
|
+
|
|
156
|
+
switch (message.MsgType) {
|
|
157
|
+
case "text":
|
|
158
|
+
text = message.Content;
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case "image":
|
|
162
|
+
text = "[图片消息]";
|
|
163
|
+
if (message.PicUrl) {
|
|
164
|
+
mediaUrls.push(message.PicUrl);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
|
|
168
|
+
case "voice":
|
|
169
|
+
text = `[语音消息]\n格式: ${message.Format}\nMediaId: ${message.MediaId}`;
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case "video":
|
|
173
|
+
text = `[视频消息]\nMediaId: ${message.MediaId}`;
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case "location":
|
|
177
|
+
text = `[位置消息]\n位置: ${message.Label}\n坐标: ${message.Location_X}, ${message.Location_Y}\n缩放: ${message.Scale}`;
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
case "link":
|
|
181
|
+
text = `[链接消息]\n标题: ${message.Title}\n描述: ${message.Description}\n链接: ${message.Url}`;
|
|
182
|
+
if (message.PicUrl) {
|
|
183
|
+
mediaUrls.push(message.PicUrl);
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
default:
|
|
188
|
+
text = "[未知消息类型]";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
text,
|
|
193
|
+
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
|
194
|
+
};
|
|
195
|
+
}
|