@sunnoy/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/webhook.js ADDED
@@ -0,0 +1,273 @@
1
+ import { WecomCrypto } from "./crypto.js";
2
+ import { logger } from "./logger.js";
3
+ import { MessageDeduplicator, randomString } 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
+ constructor(config) {
20
+ this.config = config;
21
+ this.crypto = new WecomCrypto(config.token, config.encodingAesKey);
22
+ logger.debug("WecomWebhook initialized (AI Bot mode)");
23
+ }
24
+
25
+ // =========================================================================
26
+ // URL Verification (GET request)
27
+ // =========================================================================
28
+ handleVerify(query) {
29
+ const signature = query.msg_signature;
30
+ const timestamp = query.timestamp;
31
+ const nonce = query.nonce;
32
+ const echostr = query.echostr;
33
+
34
+ if (!signature || !timestamp || !nonce || !echostr) {
35
+ logger.warn("Missing parameters in verify request", { query });
36
+ return null;
37
+ }
38
+
39
+ logger.debug("Handling verify request", { timestamp, nonce });
40
+
41
+ const calcSignature = this.crypto.getSignature(timestamp, nonce, echostr);
42
+ if (calcSignature !== signature) {
43
+ logger.error("Signature mismatch in verify", {
44
+ expected: signature,
45
+ calculated: calcSignature,
46
+ });
47
+ return null;
48
+ }
49
+
50
+ try {
51
+ const result = this.crypto.decrypt(echostr);
52
+ logger.info("URL verification successful");
53
+ return result.message;
54
+ }
55
+ catch (e) {
56
+ logger.error("Decrypt failed in verify", {
57
+ error: e instanceof Error ? e.message : String(e),
58
+ });
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // =========================================================================
64
+ // Message Handling (POST request)
65
+ // AI Bot uses JSON format, not XML
66
+ // =========================================================================
67
+ async handleMessage(query, body) {
68
+ const signature = query.msg_signature;
69
+ const timestamp = query.timestamp;
70
+ const nonce = query.nonce;
71
+
72
+ if (!signature || !timestamp || !nonce) {
73
+ logger.warn("Missing parameters in message request", { query });
74
+ return null;
75
+ }
76
+
77
+ // 1. Parse JSON body to get encrypt field
78
+ let encrypt;
79
+ try {
80
+ const jsonBody = JSON.parse(body);
81
+ encrypt = jsonBody.encrypt;
82
+ logger.debug("Parsed request body", { hasEncrypt: !!encrypt });
83
+ }
84
+ catch (e) {
85
+ logger.error("Failed to parse request body as JSON", {
86
+ error: e instanceof Error ? e.message : String(e),
87
+ body: body.substring(0, 200),
88
+ });
89
+ return null;
90
+ }
91
+
92
+ if (!encrypt) {
93
+ logger.error("No encrypt field in body");
94
+ return null;
95
+ }
96
+
97
+ // 2. Verify signature
98
+ const calcSignature = this.crypto.getSignature(timestamp, nonce, encrypt);
99
+ if (calcSignature !== signature) {
100
+ logger.error("Signature mismatch in message", {
101
+ expected: signature,
102
+ calculated: calcSignature,
103
+ });
104
+ return null;
105
+ }
106
+
107
+ // 3. Decrypt
108
+ let decryptedContent;
109
+ try {
110
+ const result = this.crypto.decrypt(encrypt);
111
+ decryptedContent = result.message;
112
+ logger.debug("Decrypted content", { content: decryptedContent.substring(0, 300) });
113
+ }
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", { msgtype: data.msgtype, keys: Object.keys(data), text: JSON.stringify(data.text) });
126
+ }
127
+ catch (e) {
128
+ logger.error("Failed to parse decrypted content as JSON", {
129
+ error: e instanceof Error ? e.message : String(e),
130
+ content: decryptedContent.substring(0, 200),
131
+ });
132
+ return null;
133
+ }
134
+
135
+ // 5. Process based on message type
136
+ const msgtype = data.msgtype;
137
+
138
+ if (msgtype === "text") {
139
+ // AI Bot format: text.content
140
+ const content = data.text?.content || "";
141
+ const msgId = data.msgid || `msg_${Date.now()}`;
142
+ const fromUser = data.from?.userid || ""; // Note: "userid" not "user_id"
143
+ const responseUrl = data.response_url || "";
144
+ const chatType = data.chattype || "single"; // "single" 或 "group"
145
+ const chatId = data.chatid || ""; // 群聊 ID(仅群聊时存在)
146
+ const aibotId = data.aibotid || ""; // 机器人 ID
147
+
148
+ // 解析引用消息(可选)
149
+ const quote = data.quote ? {
150
+ msgType: data.quote.msgtype,
151
+ content: data.quote.text?.content || data.quote.image?.url || "",
152
+ } : null;
153
+
154
+ // Check for duplicates
155
+ if (this.deduplicator.isDuplicate(msgId)) {
156
+ logger.debug("Duplicate message ignored", { msgId });
157
+ return null;
158
+ }
159
+
160
+ logger.info("Received text message", {
161
+ fromUser,
162
+ chatType,
163
+ chatId: chatId || "(private)",
164
+ content: content.substring(0, 50)
165
+ });
166
+
167
+ return {
168
+ message: {
169
+ msgId,
170
+ msgType: "text",
171
+ content,
172
+ fromUser,
173
+ chatType,
174
+ chatId, // 群聊 ID
175
+ aibotId, // 机器人 ID
176
+ quote, // 引用消息
177
+ responseUrl, // For async response
178
+ },
179
+ query: { timestamp, nonce },
180
+ };
181
+ }
182
+ else if (msgtype === "stream") {
183
+ // Stream continuation request from WeCom
184
+ const streamId = data.stream?.id;
185
+ logger.debug("Received stream refresh request", { streamId });
186
+ return {
187
+ stream: {
188
+ id: streamId,
189
+ },
190
+ query: { timestamp, nonce },
191
+ rawData: data, // 保留完整数据用于调试
192
+ };
193
+ }
194
+ else if (msgtype === "image") {
195
+ const imageUrl = data.image?.url;
196
+ const msgId = data.msgid || `msg_${Date.now()}`;
197
+ const fromUser = data.from?.userid || "";
198
+ const responseUrl = data.response_url || "";
199
+ logger.info("Received image message", { fromUser, imageUrl });
200
+
201
+ return {
202
+ message: {
203
+ msgId,
204
+ msgType: "image",
205
+ imageUrl,
206
+ fromUser,
207
+ responseUrl,
208
+ },
209
+ query: { timestamp, nonce },
210
+ };
211
+ }
212
+ else if (msgtype === "event") {
213
+ logger.info("Received event", { event: data.event });
214
+ return {
215
+ event: data.event,
216
+ query: { timestamp, nonce },
217
+ };
218
+ }
219
+ else if (msgtype === "mixed") {
220
+ logger.warn("Mixed message type not fully supported", { data });
221
+ return null;
222
+ }
223
+ else {
224
+ logger.warn("Unknown message type", { msgtype });
225
+ return null;
226
+ }
227
+ }
228
+
229
+ // =========================================================================
230
+ // Build Stream Response (AI Bot format)
231
+ // 完整支持企业微信流式消息所有字段
232
+ // =========================================================================
233
+ buildStreamResponse(streamId, content, finish, timestamp, nonce, options = {}) {
234
+ const stream = {
235
+ id: streamId,
236
+ finish: finish,
237
+ content: content, // 最长20480字节,utf8编码
238
+ };
239
+
240
+ // 可选: 图文混排消息列表 (仅在finish=true时支持image)
241
+ if (options.msgItem && options.msgItem.length > 0) {
242
+ stream.msg_item = options.msgItem;
243
+ }
244
+
245
+ // 可选: 用户反馈追踪ID (首次回复时设置,最长256字节)
246
+ if (options.feedbackId) {
247
+ stream.feedback = { id: options.feedbackId };
248
+ }
249
+
250
+ const plain = {
251
+ msgtype: "stream",
252
+ stream: stream,
253
+ };
254
+
255
+ const plainStr = JSON.stringify(plain);
256
+ const encrypted = this.crypto.encrypt(plainStr);
257
+ const signature = this.crypto.getSignature(timestamp, nonce, encrypted);
258
+
259
+ return JSON.stringify({
260
+ encrypt: encrypted,
261
+ msgsignature: signature,
262
+ timestamp: timestamp,
263
+ nonce: nonce,
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Build success acknowledgment (no reply)
269
+ */
270
+ buildSuccessAck() {
271
+ return "success";
272
+ }
273
+ }