@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.
- package/README.md +465 -144
- package/crypto.js +110 -83
- package/dynamic-agent.js +70 -87
- package/image-processor.js +86 -93
- package/index.js +16 -1068
- package/logger.js +48 -49
- package/package.json +5 -6
- package/stream-manager.js +316 -265
- package/utils.js +76 -238
- package/webhook.js +434 -287
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +433 -0
- package/wecom/allow-from.js +58 -0
- package/wecom/channel-plugin.js +638 -0
- package/wecom/commands.js +85 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +519 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +175 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +82 -0
- package/wecom/stream-utils.js +124 -0
- package/wecom/target.js +57 -0
- package/wecom/webhook-bot.js +155 -0
- package/wecom/webhook-targets.js +28 -0
- package/wecom/workspace-template.js +165 -0
- package/wecom/xml-parser.js +126 -0
- package/README_ZH.md +0 -289
- package/client.js +0 -127
package/webhook.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { WecomCrypto } from "./crypto.js";
|
|
2
2
|
import { logger } from "./logger.js";
|
|
3
|
-
import { MessageDeduplicator
|
|
3
|
+
import { MessageDeduplicator } from "./utils.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* WeCom AI Bot Webhook Handler
|
|
@@ -12,306 +12,453 @@ import { MessageDeduplicator, randomString } from "./utils.js";
|
|
|
12
12
|
* - Response uses stream message format
|
|
13
13
|
*/
|
|
14
14
|
export class WecomWebhook {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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;
|
|
23
40
|
}
|
|
24
41
|
|
|
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 });
|
|
42
|
+
logger.debug("Handling verify request", { timestamp, nonce });
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}
|
|
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;
|
|
61
51
|
}
|
|
62
52
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (!encrypt) {
|
|
93
|
-
logger.error("No encrypt field in body");
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
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
|
+
}
|
|
96
78
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
|
106
92
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
}
|
|
93
|
+
if (!encrypt) {
|
|
94
|
+
logger.error("No encrypt field in body");
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
120
97
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
content: decryptedContent.substring(0, 200),
|
|
131
|
-
});
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
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
|
+
}
|
|
134
107
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 === "voice") {
|
|
213
|
-
// Voice message (single chat only) - WeCom automatically transcribes to text
|
|
214
|
-
const content = data.voice?.content || "";
|
|
215
|
-
const msgId = data.msgid || `msg_${Date.now()}`;
|
|
216
|
-
const fromUser = data.from?.userid || "";
|
|
217
|
-
const responseUrl = data.response_url || "";
|
|
218
|
-
const chatType = data.chattype || "single";
|
|
219
|
-
const chatId = data.chatid || "";
|
|
220
|
-
|
|
221
|
-
// Check for duplicates
|
|
222
|
-
if (this.deduplicator.isDuplicate(msgId)) {
|
|
223
|
-
logger.debug("Duplicate voice message ignored", { msgId });
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Validate content
|
|
228
|
-
if (!content.trim()) {
|
|
229
|
-
logger.warn("Empty voice message received", { msgId, fromUser });
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
logger.info("Received voice message (auto-transcribed by WeCom)", {
|
|
234
|
-
fromUser,
|
|
235
|
-
chatType,
|
|
236
|
-
chatId: chatId || "(private)",
|
|
237
|
-
originalType: "voice",
|
|
238
|
-
transcribedLength: content.length,
|
|
239
|
-
preview: content.substring(0, 50)
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// Treat voice as text since WeCom already transcribed it
|
|
243
|
-
return {
|
|
244
|
-
message: {
|
|
245
|
-
msgId,
|
|
246
|
-
msgType: "text",
|
|
247
|
-
content,
|
|
248
|
-
fromUser,
|
|
249
|
-
chatType,
|
|
250
|
-
chatId,
|
|
251
|
-
responseUrl,
|
|
252
|
-
},
|
|
253
|
-
query: { timestamp, nonce },
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
else if (msgtype === "event") {
|
|
257
|
-
logger.info("Received event", { event: data.event });
|
|
258
|
-
return {
|
|
259
|
-
event: data.event,
|
|
260
|
-
query: { timestamp, nonce },
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
else if (msgtype === "mixed") {
|
|
264
|
-
logger.warn("Mixed message type not fully supported", { data });
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
logger.warn("Unknown message type", { msgtype });
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
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;
|
|
271
119
|
}
|
|
272
120
|
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
|
288
137
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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);
|
|
292
296
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
// Optional mixed media list (images are valid on finished responses).
|
|
439
|
+
if (options.msgItem && options.msgItem.length > 0) {
|
|
440
|
+
stream.msg_item = options.msgItem;
|
|
309
441
|
}
|
|
310
442
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
buildSuccessAck() {
|
|
315
|
-
return "success";
|
|
443
|
+
// Optional feedback tracking id.
|
|
444
|
+
if (options.feedbackId) {
|
|
445
|
+
stream.feedback = { id: options.feedbackId };
|
|
316
446
|
}
|
|
447
|
+
|
|
448
|
+
const plain = {
|
|
449
|
+
msgtype: "stream",
|
|
450
|
+
stream: stream,
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const plainStr = JSON.stringify(plain);
|
|
454
|
+
const encrypted = this.crypto.encrypt(plainStr);
|
|
455
|
+
const signature = this.crypto.getSignature(timestamp, nonce, encrypted);
|
|
456
|
+
|
|
457
|
+
return JSON.stringify({
|
|
458
|
+
encrypt: encrypted,
|
|
459
|
+
msgsignature: signature,
|
|
460
|
+
timestamp: timestamp,
|
|
461
|
+
nonce: nonce,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
317
464
|
}
|