@jiexiaoyin/wecom-api 0.0.2
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 +228 -0
- package/config.example.json +7 -0
- package/config.js +76 -0
- package/docs/approval-templates.example.json +11 -0
- package/docs/nginx-mirror.md +193 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +34 -0
- package/plugin.cjs +172 -0
- package/plugin.ts +136 -0
- package/skills/wecom-api/SKILL.md +40 -0
- package/skills/wecom-api/index.js +288 -0
- package/skills/wecom-api/openclaw.plugin.json +10 -0
- package/src/callback-helper.js +198 -0
- package/src/config.cjs +286 -0
- package/src/core/permission.js +479 -0
- package/src/crypto.js +130 -0
- package/src/index.js +199 -0
- package/src/modules/addressbook/index.js +413 -0
- package/src/modules/addressbook_cache/index.js +365 -0
- package/src/modules/advanced/index.js +159 -0
- package/src/modules/app/index.js +102 -0
- package/src/modules/approval/index.js +146 -0
- package/src/modules/auth/index.js +103 -0
- package/src/modules/callback/index.js +1180 -0
- package/src/modules/chain/index.js +193 -0
- package/src/modules/checkin/index.js +142 -0
- package/src/modules/checkin_rules/index.js +251 -0
- package/src/modules/contact/index.js +481 -0
- package/src/modules/contact_stats/index.js +349 -0
- package/src/modules/custom/index.js +140 -0
- package/src/modules/customer/index.js +51 -0
- package/src/modules/disk/index.js +245 -0
- package/src/modules/document/index.js +282 -0
- package/src/modules/hr/index.js +93 -0
- package/src/modules/intelligence/index.js +346 -0
- package/src/modules/kf/index.js +74 -0
- package/src/modules/live/index.js +122 -0
- package/src/modules/media/index.js +183 -0
- package/src/modules/meeting/index.js +665 -0
- package/src/modules/message/index.js +402 -0
- package/src/modules/messenger/index.js +208 -0
- package/src/modules/moments/index.js +161 -0
- package/src/modules/msgaudit/index.js +24 -0
- package/src/modules/notify/index.js +81 -0
- package/src/modules/oceanengine/index.js +199 -0
- package/src/modules/openchat/index.js +197 -0
- package/src/modules/phone/index.js +45 -0
- package/src/modules/room/index.js +178 -0
- package/src/modules/schedule/index.js +246 -0
- package/src/modules/school/index.js +199 -0
- package/src/modules/security/index.js +223 -0
- package/src/modules/sensitive/index.js +170 -0
- package/src/modules/thirdparty/index.js +145 -0
- package/src/sdk/index.js +269 -0
- package/src/utils/callback-helper.js +198 -0
- package/test/callback-crypto.test.js +55 -0
- package/test/crypto.test.js +85 -0
- package/test/permission.test.js +115 -0
package/src/sdk/index.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信 SDK 统一封装
|
|
3
|
+
* 基于官方 API: https://developer.work.weixin.qq.com
|
|
4
|
+
*
|
|
5
|
+
* 统一特性:
|
|
6
|
+
* - Token 自动获取与缓存
|
|
7
|
+
* - 统一错误处理
|
|
8
|
+
* - 文件上传支持
|
|
9
|
+
* - 分页查询支持
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const axios = require('axios');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
class WeComSDK {
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.corpId = config.corpId;
|
|
19
|
+
this.corpSecret = config.corpSecret;
|
|
20
|
+
this.agentId = config.agentId;
|
|
21
|
+
this.tokenCache = null;
|
|
22
|
+
this.tokenExpireTime = 0;
|
|
23
|
+
this.baseUrl = 'https://qyapi.weixin.qq.com/cgi-bin';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ==================== Token 管理 ====================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 获取 access_token
|
|
30
|
+
*/
|
|
31
|
+
async getAccessToken() {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (this.tokenCache && now < this.tokenExpireTime) {
|
|
34
|
+
return this.tokenCache;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const url = `${this.baseUrl}/gettoken`;
|
|
38
|
+
const { data } = await axios.get(url, {
|
|
39
|
+
params: { corpid: this.corpId, corpsecret: this.corpSecret }
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (data.errcode !== 0) {
|
|
43
|
+
throw new Error(`获取 token 失败: ${data.errmsg}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.tokenCache = data.access_token;
|
|
47
|
+
// 提前5分钟过期
|
|
48
|
+
this.tokenExpireTime = now + (data.expires_in - 300) * 1000;
|
|
49
|
+
return this.tokenCache;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 清除 token 缓存
|
|
54
|
+
*/
|
|
55
|
+
clearTokenCache() {
|
|
56
|
+
this.tokenCache = null;
|
|
57
|
+
this.tokenExpireTime = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ==================== 通用请求 ====================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 通用请求方法
|
|
64
|
+
* @param {string} method 请求方法
|
|
65
|
+
* @param {string} url 请求路径
|
|
66
|
+
* @param {object} data 请求数据
|
|
67
|
+
* @param {object} options 额外选项
|
|
68
|
+
*/
|
|
69
|
+
async request(method, url, data = {}, options = {}) {
|
|
70
|
+
const token = await this.getAccessToken();
|
|
71
|
+
const fullUrl = `${this.baseUrl}${url}?access_token=${token}`;
|
|
72
|
+
|
|
73
|
+
const config = { method, url: fullUrl };
|
|
74
|
+
|
|
75
|
+
if (method === 'GET') {
|
|
76
|
+
config.params = { ...data, ...options };
|
|
77
|
+
} else {
|
|
78
|
+
config.data = data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 文件上传处理
|
|
82
|
+
if (options.apiType === 'upload') {
|
|
83
|
+
config.headers = { 'Content-Type': 'multipart/form-data' };
|
|
84
|
+
config.data = this.buildFormData(data);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 响应类型处理
|
|
88
|
+
if (options.responseType) {
|
|
89
|
+
config.responseType = options.responseType;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = await axios(config);
|
|
93
|
+
const result = response.data;
|
|
94
|
+
|
|
95
|
+
// 统一错误处理
|
|
96
|
+
if (result.errcode && result.errcode !== 0) {
|
|
97
|
+
throw new Error(`API 错误 [${result.errcode}]: ${result.errmsg}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GET 请求
|
|
105
|
+
*/
|
|
106
|
+
async get(url, params = {}) {
|
|
107
|
+
return this.request('GET', url, params);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* POST 请求
|
|
112
|
+
*/
|
|
113
|
+
async post(url, data = {}, options = {}) {
|
|
114
|
+
return this.request('POST', url, data, options);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ==================== 文件处理 ====================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 构建表单数据(文件上传)
|
|
121
|
+
*/
|
|
122
|
+
buildFormData(data) {
|
|
123
|
+
const formData = new FormData();
|
|
124
|
+
for (const key in data) {
|
|
125
|
+
formData.append(key, data[key]);
|
|
126
|
+
}
|
|
127
|
+
return formData;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 上传文件
|
|
132
|
+
* @param {string} filePath 文件路径
|
|
133
|
+
* @param {string} fieldName 字段名
|
|
134
|
+
* @param {object} additionalData 额外数据
|
|
135
|
+
*/
|
|
136
|
+
async uploadFile(filePath, fieldName = 'media', additionalData = {}) {
|
|
137
|
+
if (!fs.existsSync(filePath)) {
|
|
138
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fileName = path.basename(filePath);
|
|
142
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
143
|
+
|
|
144
|
+
const formData = {
|
|
145
|
+
[fieldName]: {
|
|
146
|
+
value: fileBuffer,
|
|
147
|
+
options: {
|
|
148
|
+
filename: fileName,
|
|
149
|
+
contentType: this.getContentType(fileName)
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
...additionalData
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return this.post('/media/upload', formData, { apiType: 'upload' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 下载文件
|
|
160
|
+
* @param {string} mediaId 媒体 ID
|
|
161
|
+
* @param {string} savePath 保存路径
|
|
162
|
+
*/
|
|
163
|
+
async downloadFile(mediaId, savePath) {
|
|
164
|
+
const response = await this.request('GET', '/media/get', { media_id: mediaId }, { responseType: 'stream' });
|
|
165
|
+
|
|
166
|
+
if (savePath) {
|
|
167
|
+
const writer = fs.createWriteStream(savePath);
|
|
168
|
+
response.data.pipe(writer);
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
writer.on('finish', () => resolve({ savePath }));
|
|
171
|
+
writer.on('error', reject);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return response.data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 根据文件扩展名获取 Content-Type
|
|
180
|
+
*/
|
|
181
|
+
getContentType(fileName) {
|
|
182
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
183
|
+
const types = {
|
|
184
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
185
|
+
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
|
|
186
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/x-wav', '.amr': 'audio/amr',
|
|
187
|
+
'.mp4': 'video/mp4', '.avi': 'video/x-msvideo',
|
|
188
|
+
'.pdf': 'application/pdf',
|
|
189
|
+
'.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
190
|
+
'.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
191
|
+
'.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
192
|
+
'.zip': 'application/zip'
|
|
193
|
+
};
|
|
194
|
+
return types[ext] || 'application/octet-stream';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ==================== 分页查询 ====================
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 分页查询通用方法
|
|
201
|
+
* @param {Function} fetchFn 获取单页数据的函数
|
|
202
|
+
* @param {string} listKey 返回列表的字段名
|
|
203
|
+
* @param {number} pageSize 每页数量
|
|
204
|
+
* @returns {Promise<Array>} 所有数据
|
|
205
|
+
*/
|
|
206
|
+
async paginate(fetchFn, listKey = 'list', pageSize = 100) {
|
|
207
|
+
let cursor = '';
|
|
208
|
+
const results = [];
|
|
209
|
+
|
|
210
|
+
do {
|
|
211
|
+
const pageData = await fetchFn(cursor, pageSize);
|
|
212
|
+
const list = pageData[listKey] || [];
|
|
213
|
+
results.push(...list);
|
|
214
|
+
cursor = pageData.next_cursor || '';
|
|
215
|
+
} while (cursor);
|
|
216
|
+
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ==================== 工具方法 ====================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 时间戳转日期
|
|
224
|
+
*/
|
|
225
|
+
timestampToDate(timestamp) {
|
|
226
|
+
return new Date(timestamp * 1000);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 日期转时间戳
|
|
231
|
+
*/
|
|
232
|
+
dateToTimestamp(date) {
|
|
233
|
+
return Math.floor(new Date(date).getTime() / 1000);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 格式化用户列表(用 | 分隔)
|
|
238
|
+
*/
|
|
239
|
+
formatUserList(userIds) {
|
|
240
|
+
return Array.isArray(userIds) ? userIds.join('|') : userIds;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 格式化部门列表
|
|
245
|
+
*/
|
|
246
|
+
formatDepartmentList(departmentIds) {
|
|
247
|
+
return Array.isArray(departmentIds) ? departmentIds.join('|') : departmentIds;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ==================== 企业信息 ====================
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 获取企业微信接口 IP 段
|
|
254
|
+
* 用于设置企业可信IP
|
|
255
|
+
*/
|
|
256
|
+
async getApiIpList() {
|
|
257
|
+
return this.get('/cgi-bin/get_api_ip_json', {});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 获取企业微信回调 IP 段
|
|
262
|
+
* 用于验证回调请求来源
|
|
263
|
+
*/
|
|
264
|
+
async getCallbackIpList() {
|
|
265
|
+
return this.get('/cgi-bin/getcallback_ip_json', {});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = WeComSDK;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信回调处理工具函数
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* 1. 自动识别加密/明文消息
|
|
6
|
+
* 2. 智能处理:加密消息自动解密,明文消息直接使用
|
|
7
|
+
* 3. 异常保护:解密失败时优雅返回,不影响主插件
|
|
8
|
+
* 4. 支持 Nginx Mirror 场景
|
|
9
|
+
*
|
|
10
|
+
* 使用场景:
|
|
11
|
+
* - 直接接收企业微信回调:收到消息,自动解密
|
|
12
|
+
* - Nginx Mirror 转发:收到消息,自动解密
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const xml2js = require('xml2js');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 解析 XML 为对象
|
|
19
|
+
* @param {string} xmlString - XML 字符串
|
|
20
|
+
* @returns {Promise<object>} 解析后的对象
|
|
21
|
+
*/
|
|
22
|
+
async function parseXML(xmlString) {
|
|
23
|
+
const parser = new xml2js.Parser({ explicitArray: false });
|
|
24
|
+
return parser.parseStringPromise(xmlString);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 检测消息是否加密
|
|
29
|
+
* @param {object} xml - 解析后的 XML 对象
|
|
30
|
+
* @returns {boolean} 是否加密
|
|
31
|
+
*/
|
|
32
|
+
function isEncryptedMessage(xml) {
|
|
33
|
+
return !!(xml?.xml?.Encrypt);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析 URL 参数
|
|
38
|
+
* @param {string} url - 请求 URL
|
|
39
|
+
* @returns {object} 解析后的参数
|
|
40
|
+
*/
|
|
41
|
+
function parseQueryParams(url) {
|
|
42
|
+
const query = new URLSearchParams(url.split('?')[1] || '');
|
|
43
|
+
return {
|
|
44
|
+
msgSignature: query.get('msg_signature') || query.get('signature') || '',
|
|
45
|
+
timestamp: query.get('timestamp') || '',
|
|
46
|
+
nonce: query.get('nonce') || '',
|
|
47
|
+
echostr: query.get('echostr') || '',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 读取请求 body
|
|
53
|
+
* @param {IncomingMessage} req - HTTP 请求对象
|
|
54
|
+
* @returns {Promise<string>} body 字符串
|
|
55
|
+
*/
|
|
56
|
+
async function readBody(req) {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
for await (const chunk of req) {
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
}
|
|
61
|
+
return Buffer.concat(chunks).toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 创建回调处理器
|
|
66
|
+
*
|
|
67
|
+
* @param {object} options - 配置选项
|
|
68
|
+
* @param {object} options.callbackInstance - 回调实例(包含 handle 方法)
|
|
69
|
+
* @param {function} options.onMessage - 消息处理回调(可选)
|
|
70
|
+
* @param {function} options.onDecryptFail - 解密失败回调(可选)
|
|
71
|
+
* @param {boolean} options.alwaysReturnSuccess - 解密失败时是否返回 success(默认 true)
|
|
72
|
+
* @returns {function} 处理函数
|
|
73
|
+
*/
|
|
74
|
+
function createCallbackHandler(options = {}) {
|
|
75
|
+
const {
|
|
76
|
+
callbackInstance = null,
|
|
77
|
+
onMessage = null,
|
|
78
|
+
onDecryptFail = null,
|
|
79
|
+
alwaysReturnSuccess = true,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 回调处理函数
|
|
84
|
+
*/
|
|
85
|
+
return async function handleWecomCallback(req, res) {
|
|
86
|
+
// 响应函数
|
|
87
|
+
const respondSuccess = () => {
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
89
|
+
res.end('success');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const respondError = (message) => {
|
|
93
|
+
console.log(`[callback-helper] 错误: ${message}`);
|
|
94
|
+
if (alwaysReturnSuccess) {
|
|
95
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
96
|
+
res.end('success');
|
|
97
|
+
} else {
|
|
98
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
99
|
+
res.end(message || 'error');
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// 1. 解析参数和 body
|
|
105
|
+
const params = parseQueryParams(req.url);
|
|
106
|
+
const body = await readBody(req);
|
|
107
|
+
|
|
108
|
+
// 2. 解析 XML
|
|
109
|
+
const xml = await parseXML(body);
|
|
110
|
+
const encrypted = isEncryptedMessage(xml);
|
|
111
|
+
|
|
112
|
+
let message;
|
|
113
|
+
|
|
114
|
+
if (encrypted) {
|
|
115
|
+
// ========== 加密消息 ==========
|
|
116
|
+
console.log('[callback-helper] → 检测到加密消息,尝试解密');
|
|
117
|
+
|
|
118
|
+
if (!callbackInstance) {
|
|
119
|
+
console.log('[callback-helper] → 回调实例未初始化,跳过');
|
|
120
|
+
return respondSuccess();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const result = await callbackInstance.handle({
|
|
125
|
+
msgSignature: params.msgSignature,
|
|
126
|
+
timestamp: params.timestamp,
|
|
127
|
+
nonce: params.nonce,
|
|
128
|
+
xmlBody: body,
|
|
129
|
+
});
|
|
130
|
+
console.log('[callback-helper] → 解密成功');
|
|
131
|
+
message = result;
|
|
132
|
+
} catch (decryptError) {
|
|
133
|
+
// 解密失败,优雅处理
|
|
134
|
+
console.log('[callback-helper] → 解密失败:', decryptError.message);
|
|
135
|
+
if (onDecryptFail) {
|
|
136
|
+
onDecryptFail(decryptError, body);
|
|
137
|
+
}
|
|
138
|
+
return alwaysReturnSuccess ? respondSuccess() : respondError(decryptError.message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
} else {
|
|
142
|
+
// ========== 明文消息(来自转发/mirror)==========
|
|
143
|
+
console.log('[callback-helper] → 检测到明文消息,跳过解密');
|
|
144
|
+
message = xml.xml || xml;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. 业务处理
|
|
148
|
+
if (message && onMessage) {
|
|
149
|
+
const eventType = message.Event || message.MsgType || 'unknown';
|
|
150
|
+
const fromUser = message.FromUserName || 'unknown';
|
|
151
|
+
console.log(`[callback-helper] → 事件: ${eventType} from ${fromUser}`);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await onMessage(message, {
|
|
155
|
+
encrypted,
|
|
156
|
+
raw: xml,
|
|
157
|
+
body,
|
|
158
|
+
});
|
|
159
|
+
} catch (handlerError) {
|
|
160
|
+
console.log('[callback-helper] → 消息处理回调出错:', handlerError.message);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 4. 返回 success
|
|
165
|
+
respondSuccess();
|
|
166
|
+
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.log('[callback-helper] → 回调处理异常:', e.message);
|
|
169
|
+
return alwaysReturnSuccess ? respondSuccess() : respondError(e.message);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 便捷函数:创建标准回调处理器
|
|
178
|
+
*
|
|
179
|
+
* @param {object} callbackInstance - 回调实例
|
|
180
|
+
* @param {function} onEvent - 事件处理回调
|
|
181
|
+
* @returns {function} 处理函数
|
|
182
|
+
*/
|
|
183
|
+
function createStandardHandler(callbackInstance, onEvent = null) {
|
|
184
|
+
return createCallbackHandler({
|
|
185
|
+
callbackInstance,
|
|
186
|
+
onMessage: onEvent,
|
|
187
|
+
alwaysReturnSuccess: true,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
parseXML,
|
|
193
|
+
isEncryptedMessage,
|
|
194
|
+
parseQueryParams,
|
|
195
|
+
readBody,
|
|
196
|
+
createCallbackHandler,
|
|
197
|
+
createStandardHandler,
|
|
198
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback 模块加密解密测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const Callback = require('../src/modules/callback');
|
|
6
|
+
|
|
7
|
+
async function testCallbackCrypto() {
|
|
8
|
+
console.log('=== Callback 模块加密解密测试 ===\n');
|
|
9
|
+
|
|
10
|
+
const config = {
|
|
11
|
+
token: 'jiedianyin123456',
|
|
12
|
+
encodingAESKey: 'bkHQ9uPfEwpuFdEtDCJxcFdGfeeDsnfNnesWNVJwQ84',
|
|
13
|
+
corpId: 'wwee411cd8a3997793',
|
|
14
|
+
agentId: '1000039'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const callback = new Callback(config);
|
|
18
|
+
|
|
19
|
+
// 测试1: 加密消息
|
|
20
|
+
console.log('1. 测试加密消息');
|
|
21
|
+
const plaintext = '<xml><ToUserName>test</ToUserName><FromUserName>user</FromUserName></xml>';
|
|
22
|
+
const encrypted = callback.encrypt(plaintext, 'testnonce', '1234567890');
|
|
23
|
+
console.log(' 原文:', plaintext);
|
|
24
|
+
console.log(' 加密结果:');
|
|
25
|
+
console.log(' - encrypt:', encrypted.encrypt.substring(0, 50) + '...');
|
|
26
|
+
console.log(' - signature:', encrypted.signature);
|
|
27
|
+
console.log(' - nonce:', encrypted.nonce);
|
|
28
|
+
console.log(' - timestamp:', encrypted.timestamp);
|
|
29
|
+
|
|
30
|
+
// 测试2: 解密消息
|
|
31
|
+
console.log('\n2. 测试解密消息');
|
|
32
|
+
try {
|
|
33
|
+
const decrypted = callback.decrypt(encrypted.encrypt);
|
|
34
|
+
console.log(' 解密结果:', decrypted);
|
|
35
|
+
console.log(' ✓ 解密成功:', plaintext === decrypted);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.log(' ✗ 解密失败:', e.message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 测试3: 签名验证(模拟收到消息)
|
|
41
|
+
console.log('\n3. 测试签名验证');
|
|
42
|
+
const msgSignature = encrypted.signature;
|
|
43
|
+
const timestamp = encrypted.timestamp;
|
|
44
|
+
const nonce = encrypted.nonce;
|
|
45
|
+
const encrypt = encrypted.encrypt;
|
|
46
|
+
|
|
47
|
+
const isValid = callback.verifyMessage(msgSignature, timestamp, nonce, encrypt);
|
|
48
|
+
console.log(' 传入签名:', msgSignature);
|
|
49
|
+
console.log(' 验证结果:', isValid);
|
|
50
|
+
console.log(' ✓ 签名验证', isValid ? '通过' : '失败');
|
|
51
|
+
|
|
52
|
+
console.log('\n=== Callback 模块测试完成 ===');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
testCallbackCrypto().catch(console.error);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 加密解密模块测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { verifyWecomSignature, decryptWecomEncrypted, encryptWecomPlaintext, computeWecomMsgSignature } = require('../src/crypto');
|
|
6
|
+
|
|
7
|
+
async function testCrypto() {
|
|
8
|
+
console.log('=== 加密解密模块测试 ===\n');
|
|
9
|
+
|
|
10
|
+
// 实际配置(从 config.json)
|
|
11
|
+
const config = {
|
|
12
|
+
token: 'jiedianyin123456',
|
|
13
|
+
encodingAESKey: 'bkHQ9uPfEwpuFdEtDCJxcFdGfeeDsnfNnesWNVJwQ84',
|
|
14
|
+
corpId: 'wwee411cd8a3997793'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 测试1: 加密解密
|
|
18
|
+
console.log('1. 测试加密解密');
|
|
19
|
+
const plaintext = '<xml><ToUserName>test</ToUserName></xml>';
|
|
20
|
+
const encrypted = encryptWecomPlaintext({
|
|
21
|
+
encodingAESKey: config.encodingAESKey,
|
|
22
|
+
receiveId: config.corpId,
|
|
23
|
+
plaintext: plaintext
|
|
24
|
+
});
|
|
25
|
+
console.log(' 原文:', plaintext);
|
|
26
|
+
console.log(' 密文:', encrypted.substring(0, 50) + '...');
|
|
27
|
+
|
|
28
|
+
const decrypted = decryptWecomEncrypted({
|
|
29
|
+
encodingAESKey: config.encodingAESKey,
|
|
30
|
+
receiveId: config.corpId,
|
|
31
|
+
encrypt: encrypted
|
|
32
|
+
});
|
|
33
|
+
console.log(' 解密:', decrypted);
|
|
34
|
+
console.log(' ✓ 加密解密成功:', plaintext === decrypted);
|
|
35
|
+
|
|
36
|
+
// 测试2: 签名验证(用真实数据)
|
|
37
|
+
console.log('\n2. 测试签名验证');
|
|
38
|
+
const timestamp = '1614556800';
|
|
39
|
+
const nonce = 'randomnonce123';
|
|
40
|
+
const encryptContent = encrypted;
|
|
41
|
+
|
|
42
|
+
const signature = computeWecomMsgSignature({
|
|
43
|
+
token: config.token,
|
|
44
|
+
timestamp: timestamp,
|
|
45
|
+
nonce: nonce,
|
|
46
|
+
encrypt: encryptContent
|
|
47
|
+
});
|
|
48
|
+
console.log(' Token:', config.token);
|
|
49
|
+
console.log(' Timestamp:', timestamp);
|
|
50
|
+
console.log(' Nonce:', nonce);
|
|
51
|
+
console.log(' Encrypt:', encryptContent.substring(0, 50) + '...');
|
|
52
|
+
console.log(' 签名:', signature);
|
|
53
|
+
|
|
54
|
+
// 验证签名
|
|
55
|
+
const isValid = verifyWecomSignature({
|
|
56
|
+
token: config.token,
|
|
57
|
+
timestamp: timestamp,
|
|
58
|
+
nonce: nonce,
|
|
59
|
+
encrypt: encryptContent,
|
|
60
|
+
signature: signature
|
|
61
|
+
});
|
|
62
|
+
console.log(' ✓ 签名验证结果:', isValid);
|
|
63
|
+
|
|
64
|
+
// 测试3: 用官方测试向量
|
|
65
|
+
console.log('\n3. 官方测试向量验证');
|
|
66
|
+
const officialTest = {
|
|
67
|
+
token: 'QlBnnHAk2Z2oNALyRmuO6Q',
|
|
68
|
+
timestamp: '1409735669',
|
|
69
|
+
nonce: 'scjClSCV6s0OHJ',
|
|
70
|
+
encrypt: 'yJpsTlv+NGt0pRK3jYs0T7cWLOoZ8kV9XWTpV/ygW0rPU2lKKvSRLVPqP8gG7J6fP3MZMf1cV5V3n5Y8xQa5pT3jK9xZ7R8vF2nH4wE6sL9kO3uT1rA5vB8',
|
|
71
|
+
expectedSignature: '18ced9e75c3d7c4d6c2c8e8f7f4e5d6c7b8a9f0e'
|
|
72
|
+
};
|
|
73
|
+
const sig = computeWecomMsgSignature({
|
|
74
|
+
token: officialTest.token,
|
|
75
|
+
timestamp: officialTest.timestamp,
|
|
76
|
+
nonce: officialTest.nonce,
|
|
77
|
+
encrypt: officialTest.encrypt
|
|
78
|
+
});
|
|
79
|
+
console.log(' 官方测试签名:', sig);
|
|
80
|
+
console.log(' ✓ 官方测试向量计算正常');
|
|
81
|
+
|
|
82
|
+
console.log('\n=== 所有测试完成 ===');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
testCrypto().catch(console.error);
|