@sunnoy/wecom 1.9.0 → 2.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/webhook.js DELETED
@@ -1,469 +0,0 @@
1
- import { WecomCrypto } from "./crypto.js";
2
- import { logger } from "./logger.js";
3
- import { MessageDeduplicator } from "./utils.js";
4
-
5
- /**
6
- * WeCom AI Bot Webhook Handler
7
- * Based on official demo: https://developer.work.weixin.qq.com/document/path/101039
8
- *
9
- * Key differences from legacy mode:
10
- * - Messages are JSON format, not XML
11
- * - receiveid is empty string for AI Bot
12
- * - Response uses stream message format
13
- */
14
- export class WecomWebhook {
15
- config;
16
- crypto;
17
- deduplicator = new MessageDeduplicator();
18
-
19
- /** Sentinel returned when a message is a duplicate (caller should ACK 200). */
20
- static DUPLICATE = Symbol.for("wecom.duplicate");
21
-
22
- constructor(config) {
23
- this.config = config;
24
- this.crypto = new WecomCrypto(config.token, config.encodingAesKey);
25
- logger.debug("WecomWebhook initialized (AI Bot mode)");
26
- }
27
-
28
- // =========================================================================
29
- // URL Verification (GET request)
30
- // =========================================================================
31
- handleVerify(query) {
32
- const signature = query.msg_signature;
33
- const timestamp = query.timestamp;
34
- const nonce = query.nonce;
35
- const echostr = query.echostr;
36
-
37
- if (!signature || !timestamp || !nonce || !echostr) {
38
- logger.warn("Missing parameters in verify request", { query });
39
- return null;
40
- }
41
-
42
- logger.debug("Handling verify request", { timestamp, nonce });
43
-
44
- const calcSignature = this.crypto.getSignature(timestamp, nonce, echostr);
45
- if (calcSignature !== signature) {
46
- logger.error("Signature mismatch in verify", {
47
- expected: signature,
48
- calculated: calcSignature,
49
- });
50
- return null;
51
- }
52
-
53
- try {
54
- const result = this.crypto.decrypt(echostr);
55
- logger.info("URL verification successful");
56
- return result.message;
57
- } catch (e) {
58
- logger.error("Decrypt failed in verify", {
59
- error: e instanceof Error ? e.message : String(e),
60
- });
61
- return null;
62
- }
63
- }
64
-
65
- // =========================================================================
66
- // Message Handling (POST request)
67
- // AI Bot uses JSON format, not XML
68
- // =========================================================================
69
- async handleMessage(query, body) {
70
- const signature = query.msg_signature;
71
- const timestamp = query.timestamp;
72
- const nonce = query.nonce;
73
-
74
- if (!signature || !timestamp || !nonce) {
75
- logger.warn("Missing parameters in message request", { query });
76
- return null;
77
- }
78
-
79
- // 1. Parse JSON body to get encrypt field
80
- let encrypt;
81
- try {
82
- const jsonBody = JSON.parse(body);
83
- encrypt = jsonBody.encrypt;
84
- logger.debug("Parsed request body", { hasEncrypt: !!encrypt });
85
- } catch (e) {
86
- logger.error("Failed to parse request body as JSON", {
87
- error: e instanceof Error ? e.message : String(e),
88
- body: body.substring(0, 200),
89
- });
90
- return null;
91
- }
92
-
93
- if (!encrypt) {
94
- logger.error("No encrypt field in body");
95
- return null;
96
- }
97
-
98
- // 2. Verify signature
99
- const calcSignature = this.crypto.getSignature(timestamp, nonce, encrypt);
100
- if (calcSignature !== signature) {
101
- logger.error("Signature mismatch in message", {
102
- expected: signature,
103
- calculated: calcSignature,
104
- });
105
- return null;
106
- }
107
-
108
- // 3. Decrypt
109
- let decryptedContent;
110
- try {
111
- const result = this.crypto.decrypt(encrypt);
112
- decryptedContent = result.message;
113
- logger.debug("Decrypted content", { content: decryptedContent.substring(0, 300) });
114
- } catch (e) {
115
- logger.error("Message decrypt failed", {
116
- error: e instanceof Error ? e.message : String(e),
117
- });
118
- return null;
119
- }
120
-
121
- // 4. Parse decrypted JSON content (AI Bot format)
122
- let data;
123
- try {
124
- data = JSON.parse(decryptedContent);
125
- logger.debug("Parsed message data", {
126
- msgtype: data.msgtype,
127
- keys: Object.keys(data),
128
- text: JSON.stringify(data.text),
129
- });
130
- } catch (e) {
131
- logger.error("Failed to parse decrypted content as JSON", {
132
- error: e instanceof Error ? e.message : String(e),
133
- content: decryptedContent.substring(0, 200),
134
- });
135
- return null;
136
- }
137
-
138
- // 5. Process based on message type
139
- const msgtype = data.msgtype;
140
-
141
- if (msgtype === "text") {
142
- // AI Bot format: text.content
143
- const content = data.text?.content || "";
144
- const msgId = data.msgid || `msg_${Date.now()}`;
145
- const fromUser = data.from?.userid || ""; // Note: "userid" not "user_id"
146
- const responseUrl = data.response_url || "";
147
- const chatType = data.chattype || "single";
148
- const chatId = data.chatid || "";
149
- const aibotId = data.aibotid || "";
150
-
151
- // Parse quoted message metadata when present.
152
- const quote = data.quote
153
- ? {
154
- msgType: data.quote.msgtype,
155
- content: data.quote.text?.content || data.quote.image?.url || "",
156
- }
157
- : null;
158
-
159
- // Check for duplicates
160
- if (this.deduplicator.isDuplicate(msgId)) {
161
- logger.debug("Duplicate message ignored", { msgId });
162
- return WecomWebhook.DUPLICATE;
163
- }
164
-
165
- logger.info("Received text message", {
166
- fromUser,
167
- chatType,
168
- chatId: chatId || "(private)",
169
- content: content.substring(0, 50),
170
- });
171
-
172
- return {
173
- message: {
174
- msgId,
175
- msgType: "text",
176
- content,
177
- fromUser,
178
- chatType,
179
- chatId,
180
- aibotId,
181
- quote,
182
- responseUrl,
183
- },
184
- query: { timestamp, nonce },
185
- };
186
- } else if (msgtype === "stream") {
187
- // Stream continuation request from WeCom
188
- const streamId = data.stream?.id;
189
- logger.debug("Received stream refresh request", { streamId });
190
- return {
191
- stream: {
192
- id: streamId,
193
- },
194
- query: { timestamp, nonce },
195
- rawData: data,
196
- };
197
- } else if (msgtype === "image") {
198
- const imageUrl = data.image?.url;
199
- const msgId = data.msgid || `msg_${Date.now()}`;
200
- const fromUser = data.from?.userid || "";
201
- const responseUrl = data.response_url || "";
202
- const chatType = data.chattype || "single";
203
- const chatId = data.chatid || "";
204
-
205
- if (this.deduplicator.isDuplicate(msgId)) {
206
- logger.debug("Duplicate image message ignored", { msgId });
207
- return WecomWebhook.DUPLICATE;
208
- }
209
-
210
- logger.info("Received image message", { fromUser, chatType, imageUrl });
211
-
212
- return {
213
- message: {
214
- msgId,
215
- msgType: "image",
216
- imageUrl,
217
- fromUser,
218
- chatType,
219
- chatId,
220
- responseUrl,
221
- },
222
- query: { timestamp, nonce },
223
- };
224
- } else if (msgtype === "voice") {
225
- // Voice message (single chat only) - WeCom automatically transcribes to text
226
- const content = data.voice?.content || "";
227
- const msgId = data.msgid || `msg_${Date.now()}`;
228
- const fromUser = data.from?.userid || "";
229
- const responseUrl = data.response_url || "";
230
- const chatType = data.chattype || "single";
231
- const chatId = data.chatid || "";
232
-
233
- // Check for duplicates
234
- if (this.deduplicator.isDuplicate(msgId)) {
235
- logger.debug("Duplicate voice message ignored", { msgId });
236
- return WecomWebhook.DUPLICATE;
237
- }
238
-
239
- // Validate content
240
- if (!content.trim()) {
241
- logger.warn("Empty voice message received", { msgId, fromUser });
242
- return null;
243
- }
244
-
245
- logger.info("Received voice message (auto-transcribed by WeCom)", {
246
- fromUser,
247
- chatType,
248
- chatId: chatId || "(private)",
249
- originalType: "voice",
250
- transcribedLength: content.length,
251
- preview: content.substring(0, 50),
252
- });
253
-
254
- // Treat voice as text since WeCom already transcribed it
255
- return {
256
- message: {
257
- msgId,
258
- msgType: "text",
259
- content,
260
- fromUser,
261
- chatType,
262
- chatId,
263
- responseUrl,
264
- },
265
- query: { timestamp, nonce },
266
- };
267
- } else if (msgtype === "event") {
268
- logger.info("Received event", { event: data.event });
269
- return {
270
- event: data.event,
271
- query: { timestamp, nonce },
272
- };
273
- } else if (msgtype === "mixed") {
274
- // Mixed message: array of text + image items.
275
- const msgId = data.msgid || `msg_${Date.now()}`;
276
- const fromUser = data.from?.userid || "";
277
- const responseUrl = data.response_url || "";
278
- const chatType = data.chattype || "single";
279
- const chatId = data.chatid || "";
280
- const aibotId = data.aibotid || "";
281
-
282
- if (this.deduplicator.isDuplicate(msgId)) {
283
- logger.debug("Duplicate mixed message ignored", { msgId });
284
- return WecomWebhook.DUPLICATE;
285
- }
286
-
287
- const msgItems = data.mixed?.msg_item || [];
288
- const textParts = [];
289
- const imageUrls = [];
290
-
291
- for (const item of msgItems) {
292
- if (item.msgtype === "text" && item.text?.content) {
293
- textParts.push(item.text.content);
294
- } else if (item.msgtype === "image" && item.image?.url) {
295
- imageUrls.push(item.image.url);
296
- }
297
- }
298
-
299
- const content = textParts.join("\n");
300
-
301
- logger.info("Received mixed message", {
302
- fromUser,
303
- chatType,
304
- chatId: chatId || "(private)",
305
- textParts: textParts.length,
306
- imageCount: imageUrls.length,
307
- contentPreview: content.substring(0, 50),
308
- });
309
-
310
- return {
311
- message: {
312
- msgId,
313
- msgType: "mixed",
314
- content,
315
- imageUrls,
316
- fromUser,
317
- chatType,
318
- chatId,
319
- aibotId,
320
- responseUrl,
321
- },
322
- query: { timestamp, nonce },
323
- };
324
- } else if (msgtype === "file") {
325
- const fileUrl = data.file?.url || "";
326
- const fileName = data.file?.name || data.file?.filename || "";
327
- const msgId = data.msgid || `msg_${Date.now()}`;
328
- const fromUser = data.from?.userid || "";
329
- const responseUrl = data.response_url || "";
330
- const chatType = data.chattype || "single";
331
- const chatId = data.chatid || "";
332
-
333
- if (this.deduplicator.isDuplicate(msgId)) {
334
- logger.debug("Duplicate file message ignored", { msgId });
335
- return WecomWebhook.DUPLICATE;
336
- }
337
-
338
- logger.info("Received file message", { fromUser, fileName, fileUrl: fileUrl.substring(0, 80) });
339
-
340
- return {
341
- message: {
342
- msgId,
343
- msgType: "file",
344
- fileUrl,
345
- fileName,
346
- fromUser,
347
- chatType,
348
- chatId,
349
- responseUrl,
350
- },
351
- query: { timestamp, nonce },
352
- };
353
- } else if (msgtype === "location") {
354
- const msgId = data.msgid || `msg_${Date.now()}`;
355
- const fromUser = data.from?.userid || "";
356
- const responseUrl = data.response_url || "";
357
- const chatType = data.chattype || "single";
358
- const chatId = data.chatid || "";
359
- const latitude = data.location?.latitude || "";
360
- const longitude = data.location?.longitude || "";
361
- const name = data.location?.name || data.location?.label || "";
362
-
363
- if (this.deduplicator.isDuplicate(msgId)) {
364
- logger.debug("Duplicate location message ignored", { msgId });
365
- return WecomWebhook.DUPLICATE;
366
- }
367
-
368
- const content = name
369
- ? `[位置] ${name} (${latitude}, ${longitude})`
370
- : `[位置] ${latitude}, ${longitude}`;
371
-
372
- logger.info("Received location message", { fromUser, latitude, longitude, name });
373
-
374
- return {
375
- message: {
376
- msgId,
377
- msgType: "text",
378
- content,
379
- fromUser,
380
- chatType,
381
- chatId,
382
- responseUrl,
383
- },
384
- query: { timestamp, nonce },
385
- };
386
- } else if (msgtype === "link") {
387
- const msgId = data.msgid || `msg_${Date.now()}`;
388
- const fromUser = data.from?.userid || "";
389
- const responseUrl = data.response_url || "";
390
- const chatType = data.chattype || "single";
391
- const chatId = data.chatid || "";
392
- const title = data.link?.title || "";
393
- const description = data.link?.description || "";
394
- const url = data.link?.url || "";
395
-
396
- if (this.deduplicator.isDuplicate(msgId)) {
397
- logger.debug("Duplicate link message ignored", { msgId });
398
- return WecomWebhook.DUPLICATE;
399
- }
400
-
401
- const parts = [];
402
- if (title) parts.push(`[链接] ${title}`);
403
- if (description) parts.push(description);
404
- if (url) parts.push(url);
405
- const content = parts.join("\n") || "[链接]";
406
-
407
- logger.info("Received link message", { fromUser, title, url: url.substring(0, 80) });
408
-
409
- return {
410
- message: {
411
- msgId,
412
- msgType: "text",
413
- content,
414
- fromUser,
415
- chatType,
416
- chatId,
417
- responseUrl,
418
- },
419
- query: { timestamp, nonce },
420
- };
421
- } else {
422
- logger.warn("Unknown message type", { msgtype });
423
- return null;
424
- }
425
- }
426
-
427
- // =========================================================================
428
- // Build Stream Response (AI Bot format)
429
- // Supports all core WeCom stream response fields used by this plugin.
430
- // =========================================================================
431
- buildStreamResponse(streamId, content, finish, timestamp, nonce, options = {}) {
432
- const stream = {
433
- id: streamId,
434
- finish: finish,
435
- content: content,
436
- };
437
-
438
- // Thinking content for model reasoning display (collapsible in WeCom client).
439
- if (options.thinkingContent) {
440
- stream.thinking_content = options.thinkingContent;
441
- }
442
-
443
- // Optional mixed media list (images are valid only on finished responses).
444
- if (options.msgItem && options.msgItem.length > 0) {
445
- stream.msg_item = options.msgItem;
446
- }
447
-
448
- // Optional feedback tracking id.
449
- if (options.feedbackId) {
450
- stream.feedback = { id: options.feedbackId };
451
- }
452
-
453
- const plain = {
454
- msgtype: "stream",
455
- stream: stream,
456
- };
457
-
458
- const plainStr = JSON.stringify(plain);
459
- const encrypted = this.crypto.encrypt(plainStr);
460
- const signature = this.crypto.getSignature(timestamp, nonce, encrypted);
461
-
462
- return JSON.stringify({
463
- encrypt: encrypted,
464
- msgsignature: signature,
465
- timestamp: timestamp,
466
- nonce: nonce,
467
- });
468
- }
469
- }