@zhin.js/adapter-wecom 2.0.1 → 2.0.3
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/CHANGELOG.md +48 -0
- package/README.md +79 -216
- package/adapters/wecom.ts +27 -0
- package/agent/tools/get_dept_users.ts +18 -0
- package/agent/tools/get_user.ts +17 -0
- package/agent/tools/list_departments.ts +18 -0
- package/agent/tools/send_text.ts +19 -0
- package/lib/endpoint.d.ts +38 -35
- package/lib/endpoint.js +148 -545
- package/lib/index.d.ts +5 -15
- package/lib/index.js +5 -115
- package/lib/platform-permit.d.ts +1 -2
- package/lib/platform-permit.js +4 -2
- package/lib/protocol.d.ts +96 -0
- package/lib/protocol.js +252 -0
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +86 -0
- package/lib/wecom-agent-deps.d.ts +17 -0
- package/lib/wecom-agent-deps.js +30 -0
- package/package.json +51 -16
- package/plugin.ts +12 -0
- package/schema.json +24 -0
- package/src/endpoint.ts +196 -584
- package/src/index.ts +49 -130
- package/src/platform-permit.ts +1 -2
- package/src/protocol.ts +362 -0
- package/src/webhook.ts +130 -0
- package/src/wecom-agent-deps.ts +46 -0
- package/lib/adapter.d.ts +0 -17
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -22
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/platform-permit.d.ts.map +0 -1
- package/lib/platform-permit.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/lib/types.d.ts +0 -48
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -28
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -51
- /package/{skills/wecom → agent}/PERMITS.md +0 -0
- /package/{skills/wecom/SKILL.md → agent/skills/wecom.md} +0 -0
package/lib/endpoint.js
CHANGED
|
@@ -1,542 +1,108 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import { createHash, createDecipheriv } from 'node:crypto';
|
|
7
|
-
import { normalizeWecomSenderForPermit } from './platform-permit.js';
|
|
8
|
-
import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { registerWecomAgentEndpoint } from './wecom-agent-deps.js';
|
|
3
|
+
import { buildSendRequestBody, formatInboundContent, formatOutboundBody, resolveChatType, } from './protocol.js';
|
|
4
|
+
import { registerWecomWebhookRoutes } from './webhook.js';
|
|
5
|
+
const logger = getLogger('wecom');
|
|
9
6
|
export class WecomEndpoint {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
accessToken;
|
|
15
|
-
baseURL;
|
|
16
|
-
aesKey;
|
|
17
|
-
corpId;
|
|
7
|
+
#options;
|
|
8
|
+
#fetch;
|
|
9
|
+
#routeReleases = [];
|
|
10
|
+
#accessToken = { access_token: '', expires_in: 0, timestamp: 0 };
|
|
18
11
|
#refreshPromise = null;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (this
|
|
35
|
-
throw new Error(`encodingAESKey must produce a 32-byte key, got ${this.aesKey.length} bytes`);
|
|
36
|
-
}
|
|
37
|
-
this.setupWebhookRoute();
|
|
38
|
-
}
|
|
39
|
-
// ── HTTP helpers ──
|
|
40
|
-
async request(path, options = {}) {
|
|
41
|
-
await this.ensureAccessToken();
|
|
42
|
-
const { method = 'GET', params = {}, body } = options;
|
|
43
|
-
const urlParams = new URLSearchParams({
|
|
44
|
-
...params,
|
|
45
|
-
access_token: this.accessToken.access_token,
|
|
46
|
-
});
|
|
47
|
-
const url = `${this.baseURL}${path}?${urlParams.toString()}`;
|
|
48
|
-
const fetchOptions = {
|
|
49
|
-
method,
|
|
50
|
-
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
51
|
-
};
|
|
52
|
-
if (body && method === 'POST') {
|
|
53
|
-
fetchOptions.body = JSON.stringify(body);
|
|
54
|
-
}
|
|
55
|
-
const response = await fetch(url, fetchOptions);
|
|
56
|
-
if (!response.ok) {
|
|
57
|
-
const text = await response.text().catch(() => '');
|
|
58
|
-
throw new Error(`WeCom API error ${response.status}: ${text}`);
|
|
59
|
-
}
|
|
60
|
-
return await response.json();
|
|
61
|
-
}
|
|
62
|
-
// ── Webhook route ──
|
|
63
|
-
setupWebhookRoute() {
|
|
64
|
-
const webhookPath = this.$config.webhookPath || '/wecom/callback';
|
|
65
|
-
registerFetchRoute(this.router, 'GET', webhookPath, (ctx) => {
|
|
66
|
-
void this.handleVerification(ctx);
|
|
67
|
-
});
|
|
68
|
-
registerFetchRoute(this.router, 'POST', webhookPath, (ctx) => {
|
|
69
|
-
void this.handleWebhook(ctx);
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
// ── URL 验证(企业微信首次配置回调 URL 时的 GET 请求)──
|
|
73
|
-
handleVerification(ctx) {
|
|
74
|
-
try {
|
|
75
|
-
const { msg_signature, timestamp, nonce, echostr } = ctx.query;
|
|
76
|
-
if (!msg_signature || !timestamp || !nonce || !echostr) {
|
|
77
|
-
ctx.status = 400;
|
|
78
|
-
ctx.body = 'Missing required query parameters';
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (!this.verifySignature(msg_signature, timestamp, nonce, echostr)) {
|
|
82
|
-
this.logger.warn(formatCompact({ op: 'verify', ok: false, error: 'invalid signature' }));
|
|
83
|
-
ctx.status = 403;
|
|
84
|
-
ctx.body = 'Forbidden';
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
// 解密 echostr 并返回明文
|
|
88
|
-
const decrypted = this.decryptMessage(echostr);
|
|
89
|
-
if (!decrypted) {
|
|
90
|
-
ctx.status = 400;
|
|
91
|
-
ctx.body = 'Decryption failed';
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
ctx.status = 200;
|
|
95
|
-
ctx.body = decrypted;
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
this.logger.error('URL verification error:', error);
|
|
99
|
-
ctx.status = 500;
|
|
100
|
-
ctx.body = 'Internal Server Error';
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
// ── 消息回调处理 ──
|
|
104
|
-
async handleWebhook(ctx) {
|
|
105
|
-
try {
|
|
106
|
-
const query = ctx.query;
|
|
107
|
-
const msgSignature = query.msg_signature;
|
|
108
|
-
const timestamp = query.timestamp;
|
|
109
|
-
const nonce = query.nonce;
|
|
110
|
-
// 解析 XML body
|
|
111
|
-
const rawBody = typeof ctx.request.body === 'string'
|
|
112
|
-
? ctx.request.body
|
|
113
|
-
: String(ctx.request.body || '');
|
|
114
|
-
// 从 XML 中提取 Encrypt 字段(使用字符类避免 CodeQL 多项式正则警告)
|
|
115
|
-
const encryptMatch = rawBody.match(/<Encrypt><!\[CDATA\[([^[\]]+)]\]><\/Encrypt>/);
|
|
116
|
-
if (!encryptMatch) {
|
|
117
|
-
this.logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'no Encrypt field' }));
|
|
118
|
-
ctx.status = 200;
|
|
119
|
-
ctx.body = 'success';
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const encrypted = encryptMatch[1];
|
|
123
|
-
// 验证签名
|
|
124
|
-
if (!this.verifySignature(msgSignature, timestamp, nonce, encrypted)) {
|
|
125
|
-
this.logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
|
|
126
|
-
ctx.status = 403;
|
|
127
|
-
ctx.body = 'Forbidden';
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
// 解密消息
|
|
131
|
-
const decryptedXml = this.decryptMessage(encrypted);
|
|
132
|
-
if (!decryptedXml) {
|
|
133
|
-
ctx.status = 200;
|
|
134
|
-
ctx.body = 'success';
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const message = this.parseXmlMessage(decryptedXml);
|
|
138
|
-
if (message) {
|
|
139
|
-
await this.handleMessage(message);
|
|
140
|
-
}
|
|
141
|
-
ctx.status = 200;
|
|
142
|
-
ctx.body = 'success';
|
|
143
|
-
}
|
|
144
|
-
catch (error) {
|
|
145
|
-
this.logger.error('Webhook error:', error);
|
|
146
|
-
ctx.status = 200;
|
|
147
|
-
ctx.body = 'success';
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// ── 签名验证(SHA1 排序拼接)──
|
|
151
|
-
verifySignature(signature, timestamp, nonce, encrypt) {
|
|
152
|
-
try {
|
|
153
|
-
const arr = [this.$config.token, timestamp, nonce, encrypt].sort();
|
|
154
|
-
const str = arr.join('');
|
|
155
|
-
const hash = createHash('sha1').update(str).digest('hex');
|
|
156
|
-
return hash === signature;
|
|
157
|
-
}
|
|
158
|
-
catch (error) {
|
|
159
|
-
this.logger.error('Signature verification error:', error);
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// ── AES-CBC 解密 ──
|
|
164
|
-
decryptMessage(encrypted) {
|
|
165
|
-
const buf = Buffer.from(encrypted, 'base64');
|
|
166
|
-
const iv = this.aesKey.subarray(0, 16);
|
|
167
|
-
const decipher = createDecipheriv('aes-256-cbc', this.aesKey, iv);
|
|
168
|
-
decipher.setAutoPadding(false);
|
|
169
|
-
const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]);
|
|
170
|
-
// PKCS7 unpad
|
|
171
|
-
const pad = decrypted[decrypted.length - 1];
|
|
172
|
-
const content = decrypted.subarray(0, decrypted.length - pad);
|
|
173
|
-
// 提取: 16 字节随机数 + 4 字节消息长度 + 消息体 + corpId
|
|
174
|
-
const msgLen = content.readUInt32BE(16);
|
|
175
|
-
const msg = content.subarray(20, 20 + msgLen).toString('utf8');
|
|
176
|
-
const extractedCorpId = content.subarray(20 + msgLen).toString('utf8');
|
|
177
|
-
if (extractedCorpId !== this.corpId) {
|
|
178
|
-
this.logger.warn(formatCompact({ op: 'decrypt', ok: false, error: 'corpId mismatch', expected: this.corpId, got: extractedCorpId }));
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
return msg;
|
|
182
|
-
}
|
|
183
|
-
// ── XML 解析(简易,无外部依赖)──
|
|
184
|
-
parseXmlMessage(xml) {
|
|
185
|
-
try {
|
|
186
|
-
const get = (tag) => {
|
|
187
|
-
// 使用字符类避免 CodeQL 多项式正则警告
|
|
188
|
-
const m = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([^\\[\\]]*)\\]\\]><\\/${tag}>`))
|
|
189
|
-
|| xml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`));
|
|
190
|
-
return m ? m[1] : undefined;
|
|
191
|
-
};
|
|
192
|
-
const msgType = get('MsgType');
|
|
193
|
-
if (!msgType)
|
|
194
|
-
return null;
|
|
195
|
-
const msg = {
|
|
196
|
-
ToUserName: get('ToUserName') || '',
|
|
197
|
-
FromUserName: get('FromUserName') || '',
|
|
198
|
-
CreateTime: Number(get('CreateTime') || Date.now()),
|
|
199
|
-
MsgType: msgType,
|
|
200
|
-
MsgId: get('MsgId'),
|
|
201
|
-
AgentID: get('AgentID'),
|
|
202
|
-
};
|
|
203
|
-
switch (msgType) {
|
|
204
|
-
case 'text':
|
|
205
|
-
msg.Content = get('Content');
|
|
206
|
-
break;
|
|
207
|
-
case 'image':
|
|
208
|
-
msg.PicUrl = get('PicUrl');
|
|
209
|
-
msg.MediaId = get('MediaId');
|
|
210
|
-
break;
|
|
211
|
-
case 'voice':
|
|
212
|
-
msg.MediaId = get('MediaId');
|
|
213
|
-
msg.Format = get('Format');
|
|
214
|
-
msg.Recognition = get('Recognition');
|
|
215
|
-
break;
|
|
216
|
-
case 'video':
|
|
217
|
-
case 'shortvideo':
|
|
218
|
-
msg.MediaId = get('MediaId');
|
|
219
|
-
msg.ThumbMediaId = get('ThumbMediaId');
|
|
220
|
-
break;
|
|
221
|
-
case 'location':
|
|
222
|
-
msg.Location_X = get('Location_X');
|
|
223
|
-
msg.Location_Y = get('Location_Y');
|
|
224
|
-
msg.Scale = get('Scale');
|
|
225
|
-
msg.Label = get('Label');
|
|
226
|
-
break;
|
|
227
|
-
case 'link':
|
|
228
|
-
msg.Title = get('Title');
|
|
229
|
-
msg.Description = get('Description');
|
|
230
|
-
msg.Url = get('Url');
|
|
231
|
-
break;
|
|
232
|
-
case 'event':
|
|
233
|
-
msg.Event = get('Event');
|
|
234
|
-
msg.EventKey = get('EventKey');
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
237
|
-
return msg;
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
this.logger.error('Failed to parse XML message:', error);
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
// ── 消息处理 ──
|
|
245
|
-
async handleMessage(msg) {
|
|
246
|
-
const formatted = this.$formatMessage(msg);
|
|
247
|
-
this.adapter.emit('message.receive', formatted);
|
|
248
|
-
this.logger.debug(formatCompact({
|
|
249
|
-
op: 'recv',
|
|
250
|
-
endpoint: this.$config.name,
|
|
251
|
-
channel: formatted.$channel.type,
|
|
252
|
-
id: formatted.$channel.id,
|
|
253
|
-
len: segment.raw(formatted.$content).length,
|
|
254
|
-
}));
|
|
255
|
-
}
|
|
256
|
-
// ── Access Token 管理 ──
|
|
257
|
-
async ensureAccessToken() {
|
|
258
|
-
const now = Date.now();
|
|
259
|
-
if (this.accessToken.access_token &&
|
|
260
|
-
now < this.accessToken.timestamp + (this.accessToken.expires_in - 300) * 1000) {
|
|
12
|
+
#open = false;
|
|
13
|
+
#started = false;
|
|
14
|
+
#unregisterAgent;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.#options = options;
|
|
17
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
/** Used by webhook handler. */
|
|
20
|
+
get isOpen() {
|
|
21
|
+
return this.#open;
|
|
22
|
+
}
|
|
23
|
+
get config() {
|
|
24
|
+
return this.#options.config;
|
|
25
|
+
}
|
|
26
|
+
async start() {
|
|
27
|
+
if (this.#started)
|
|
261
28
|
return;
|
|
262
|
-
|
|
263
|
-
if (this.#refreshPromise) {
|
|
264
|
-
await this.#refreshPromise;
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
this.#refreshPromise = this.refreshAccessToken()
|
|
268
|
-
.then(() => this.accessToken.access_token)
|
|
269
|
-
.finally(() => { this.#refreshPromise = null; });
|
|
270
|
-
await this.#refreshPromise;
|
|
271
|
-
}
|
|
272
|
-
async refreshAccessToken() {
|
|
29
|
+
this.#started = true;
|
|
273
30
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
};
|
|
283
|
-
this.logger.debug('Access token refreshed successfully');
|
|
284
|
-
}
|
|
285
|
-
else {
|
|
286
|
-
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
287
|
-
}
|
|
31
|
+
await this.#refreshAccessToken();
|
|
32
|
+
this.#unregisterAgent = registerWecomAgentEndpoint(this.#options.config.name, this);
|
|
33
|
+
this.#routeReleases.push(...registerWecomWebhookRoutes(this.#options.http, this));
|
|
34
|
+
logger.debug(formatCompact({
|
|
35
|
+
endpoint: this.#options.config.name,
|
|
36
|
+
op: 'webhook',
|
|
37
|
+
path: this.#options.config.webhookPath,
|
|
38
|
+
}));
|
|
288
39
|
}
|
|
289
40
|
catch (error) {
|
|
290
|
-
this.
|
|
41
|
+
await this.stop();
|
|
42
|
+
logger.error('Failed to connect WeCom endpoint:', error);
|
|
291
43
|
throw error;
|
|
292
44
|
}
|
|
293
45
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
$content: content,
|
|
318
|
-
$raw: JSON.stringify(msg),
|
|
319
|
-
$timestamp: (msg.CreateTime || Math.floor(Date.now() / 1000)) * 1000,
|
|
320
|
-
$recall: async () => {
|
|
321
|
-
await this.$recallMessage(msg.MsgId || '');
|
|
322
|
-
},
|
|
323
|
-
$reply: async (content) => {
|
|
324
|
-
return await this.adapter.sendMessage({
|
|
325
|
-
context: 'wecom',
|
|
326
|
-
endpoint: this.$config.name,
|
|
327
|
-
id: msg.FromUserName,
|
|
328
|
-
type: chatType,
|
|
329
|
-
content: content,
|
|
330
|
-
});
|
|
331
|
-
},
|
|
46
|
+
open() {
|
|
47
|
+
this.#open = true;
|
|
48
|
+
}
|
|
49
|
+
close() {
|
|
50
|
+
this.#open = false;
|
|
51
|
+
}
|
|
52
|
+
async stop() {
|
|
53
|
+
this.#open = false;
|
|
54
|
+
for (const release of this.#routeReleases.splice(0))
|
|
55
|
+
release();
|
|
56
|
+
this.#unregisterAgent?.();
|
|
57
|
+
this.#unregisterAgent = undefined;
|
|
58
|
+
this.#started = false;
|
|
59
|
+
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
60
|
+
}
|
|
61
|
+
async send({ target, payload }) {
|
|
62
|
+
const content = formatOutboundBody(payload);
|
|
63
|
+
const body = buildSendRequestBody(target, content,
|
|
64
|
+
// Legacy field: historically written into agentid (preserved for cutover).
|
|
65
|
+
this.#options.config.agentSecret);
|
|
66
|
+
const data = await this.#request('/cgi-bin/message/send', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
body,
|
|
332
69
|
});
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const content = [];
|
|
336
|
-
if (!msg.MsgType)
|
|
337
|
-
return content;
|
|
338
|
-
try {
|
|
339
|
-
switch (msg.MsgType) {
|
|
340
|
-
case 'text':
|
|
341
|
-
if (msg.Content) {
|
|
342
|
-
content.push(segment('text', { content: msg.Content }));
|
|
343
|
-
}
|
|
344
|
-
break;
|
|
345
|
-
case 'image':
|
|
346
|
-
if (msg.MediaId) {
|
|
347
|
-
content.push(segment('image', {
|
|
348
|
-
file: msg.MediaId,
|
|
349
|
-
url: msg.PicUrl || '',
|
|
350
|
-
}));
|
|
351
|
-
}
|
|
352
|
-
break;
|
|
353
|
-
case 'voice':
|
|
354
|
-
if (msg.MediaId) {
|
|
355
|
-
content.push(segment('audio', {
|
|
356
|
-
file: msg.MediaId,
|
|
357
|
-
}));
|
|
358
|
-
// 语音识别结果
|
|
359
|
-
if (msg.Recognition) {
|
|
360
|
-
content.push(segment('text', { content: msg.Recognition }));
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
break;
|
|
364
|
-
case 'video':
|
|
365
|
-
case 'shortvideo':
|
|
366
|
-
if (msg.MediaId) {
|
|
367
|
-
content.push(segment('video', {
|
|
368
|
-
file: msg.MediaId,
|
|
369
|
-
}));
|
|
370
|
-
}
|
|
371
|
-
break;
|
|
372
|
-
case 'location':
|
|
373
|
-
content.push(segment('text', {
|
|
374
|
-
content: `[位置] ${msg.Label || ''} (${msg.Location_X}, ${msg.Location_Y})`,
|
|
375
|
-
}));
|
|
376
|
-
break;
|
|
377
|
-
case 'link':
|
|
378
|
-
content.push(segment('link', {
|
|
379
|
-
title: msg.Title || '',
|
|
380
|
-
content: msg.Description || '',
|
|
381
|
-
url: msg.Url || '',
|
|
382
|
-
}));
|
|
383
|
-
break;
|
|
384
|
-
case 'event':
|
|
385
|
-
content.push(segment('text', {
|
|
386
|
-
content: `[事件] ${msg.Event || ''} ${msg.EventKey || ''}`,
|
|
387
|
-
}));
|
|
388
|
-
break;
|
|
389
|
-
default:
|
|
390
|
-
content.push(segment('text', {
|
|
391
|
-
content: `[不支持的消息类型: ${msg.MsgType}]`,
|
|
392
|
-
}));
|
|
393
|
-
break;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
catch (error) {
|
|
397
|
-
this.logger.error('Failed to parse message content:', error);
|
|
398
|
-
content.push(segment('text', { content: '[消息解析失败]' }));
|
|
399
|
-
}
|
|
400
|
-
return content;
|
|
401
|
-
}
|
|
402
|
-
// ── $sendMessage ──
|
|
403
|
-
async $sendMessage(options) {
|
|
404
|
-
const targetId = options.id;
|
|
405
|
-
const canonical = expandInteractiveSegmentsInContent(options.content);
|
|
406
|
-
const wire = fromCanonicalSegments(canonical);
|
|
407
|
-
const content = this.formatSendContent(wire);
|
|
408
|
-
try {
|
|
409
|
-
const body = {
|
|
410
|
-
touser: targetId,
|
|
411
|
-
msgtype: content.msgtype,
|
|
412
|
-
agentid: this.$config.agentSecret,
|
|
413
|
-
[content.msgtype]: content.data,
|
|
414
|
-
};
|
|
415
|
-
// 群消息使用 chatid
|
|
416
|
-
if (targetId.endsWith('@chatroom')) {
|
|
417
|
-
delete body.touser;
|
|
418
|
-
body.chatid = targetId;
|
|
419
|
-
}
|
|
420
|
-
const data = await this.request('/cgi-bin/message/send', {
|
|
421
|
-
method: 'POST',
|
|
422
|
-
body,
|
|
423
|
-
});
|
|
424
|
-
if (data.errcode !== 0) {
|
|
425
|
-
throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
|
|
426
|
-
}
|
|
427
|
-
this.logger.debug(formatCompact({ op: 'send', endpoint: this.$config.name, to: targetId }));
|
|
428
|
-
return data.msgid || Date.now().toString();
|
|
429
|
-
}
|
|
430
|
-
catch (error) {
|
|
431
|
-
this.logger.error('Failed to send message:', error);
|
|
432
|
-
throw error;
|
|
70
|
+
if (data.errcode !== 0) {
|
|
71
|
+
throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
|
|
433
72
|
}
|
|
73
|
+
logger.debug(formatCompact({ op: 'send', endpoint: this.#options.config.name, to: target }));
|
|
74
|
+
return data.msgid || `${Date.now()}`;
|
|
434
75
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
this
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// 企业微信 @ 格式: <@userid>
|
|
462
|
-
const userId = seg.data.id || seg.data.userId;
|
|
463
|
-
if (userId)
|
|
464
|
-
textParts.push(`<@${userId}>`);
|
|
465
|
-
break;
|
|
466
|
-
case 'image':
|
|
467
|
-
if (!hasMedia) {
|
|
468
|
-
hasMedia = true;
|
|
469
|
-
mediaType = 'image';
|
|
470
|
-
mediaData = { media_id: seg.data.file || seg.data.url };
|
|
471
|
-
}
|
|
472
|
-
else {
|
|
473
|
-
droppedMediaCount++;
|
|
474
|
-
}
|
|
475
|
-
break;
|
|
476
|
-
case 'markdown':
|
|
477
|
-
if (!hasMedia) {
|
|
478
|
-
hasMedia = true;
|
|
479
|
-
mediaType = 'markdown';
|
|
480
|
-
mediaData = { content: seg.data.content || seg.data.text };
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
droppedMediaCount++;
|
|
484
|
-
}
|
|
485
|
-
break;
|
|
486
|
-
case 'link':
|
|
487
|
-
if (!hasMedia) {
|
|
488
|
-
hasMedia = true;
|
|
489
|
-
mediaType = 'news';
|
|
490
|
-
mediaData = {
|
|
491
|
-
articles: [{
|
|
492
|
-
title: seg.data.title || '链接',
|
|
493
|
-
description: seg.data.text || seg.data.content || '',
|
|
494
|
-
url: seg.data.url,
|
|
495
|
-
picurl: seg.data.picUrl,
|
|
496
|
-
}],
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
droppedMediaCount++;
|
|
501
|
-
}
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
if (droppedMediaCount > 0) {
|
|
506
|
-
this.logger.warn(formatCompact({
|
|
507
|
-
op: 'formatSend',
|
|
508
|
-
droppedMedia: droppedMediaCount,
|
|
509
|
-
note: 'WeCom API only supports one media segment per message',
|
|
510
|
-
}));
|
|
511
|
-
}
|
|
512
|
-
if (hasMedia && mediaData) {
|
|
513
|
-
return { msgtype: mediaType, data: mediaData };
|
|
514
|
-
}
|
|
515
|
-
return { msgtype: 'text', data: { content: textParts.join('') } };
|
|
516
|
-
}
|
|
517
|
-
return { msgtype: 'text', data: { content: String(content) } };
|
|
518
|
-
}
|
|
519
|
-
// ── 生命周期 ──
|
|
520
|
-
async $connect() {
|
|
521
|
-
try {
|
|
522
|
-
await this.refreshAccessToken();
|
|
523
|
-
this.$connected = true;
|
|
524
|
-
this.logger.info(formatCompact({ op: 'connect', endpoint: this.$config.name }));
|
|
525
|
-
this.logger.info(formatCompact({ op: 'webhook', path: this.$config.webhookPath || '/wecom/callback' }));
|
|
526
|
-
}
|
|
527
|
-
catch (error) {
|
|
528
|
-
this.logger.error('Failed to connect WeCom bot:', error);
|
|
529
|
-
throw error;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
async $disconnect() {
|
|
533
|
-
this.$connected = false;
|
|
534
|
-
this.logger.info(formatCompact({ op: 'disconnect', endpoint: this.$config.name }));
|
|
76
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
77
|
+
admit(msg) {
|
|
78
|
+
if (!this.#open)
|
|
79
|
+
return;
|
|
80
|
+
const chatType = resolveChatType(msg.FromUserName);
|
|
81
|
+
void this.#options.gateway.receive({
|
|
82
|
+
adapter: this.#options.id,
|
|
83
|
+
target: msg.FromUserName,
|
|
84
|
+
content: formatInboundContent(msg),
|
|
85
|
+
sender: msg.FromUserName,
|
|
86
|
+
id: msg.MsgId || `${msg.CreateTime}`,
|
|
87
|
+
metadata: Object.freeze({
|
|
88
|
+
msgType: msg.MsgType,
|
|
89
|
+
event: msg.Event,
|
|
90
|
+
chatType,
|
|
91
|
+
endpoint: this.#options.config.name,
|
|
92
|
+
toUserName: msg.ToUserName,
|
|
93
|
+
agentId: msg.AgentID,
|
|
94
|
+
}),
|
|
95
|
+
}).catch((err) => {
|
|
96
|
+
logger.warn(formatCompact({
|
|
97
|
+
op: 'wecom_gateway_receive_failed',
|
|
98
|
+
target: msg.FromUserName,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err),
|
|
100
|
+
}));
|
|
101
|
+
});
|
|
535
102
|
}
|
|
536
|
-
// ── 企业微信特有 API ──
|
|
537
103
|
async getUserInfo(userId) {
|
|
538
104
|
try {
|
|
539
|
-
const data = await this
|
|
105
|
+
const data = await this.#request('/cgi-bin/user/get', {
|
|
540
106
|
params: { userid: userId },
|
|
541
107
|
});
|
|
542
108
|
if (data.errcode === 0)
|
|
@@ -544,13 +110,13 @@ export class WecomEndpoint {
|
|
|
544
110
|
throw new Error(`Failed to get user info: ${data.errmsg}`);
|
|
545
111
|
}
|
|
546
112
|
catch (error) {
|
|
547
|
-
|
|
113
|
+
logger.error('Failed to get user info:', error);
|
|
548
114
|
return null;
|
|
549
115
|
}
|
|
550
116
|
}
|
|
551
117
|
async getDepartmentUsers(deptId) {
|
|
552
118
|
try {
|
|
553
|
-
const data = await this
|
|
119
|
+
const data = await this.#request('/cgi-bin/user/simplelist', {
|
|
554
120
|
params: { department_id: deptId },
|
|
555
121
|
});
|
|
556
122
|
if (data.errcode === 0)
|
|
@@ -558,13 +124,13 @@ export class WecomEndpoint {
|
|
|
558
124
|
throw new Error(`Failed to get department users: ${data.errmsg}`);
|
|
559
125
|
}
|
|
560
126
|
catch (error) {
|
|
561
|
-
|
|
127
|
+
logger.error('Failed to get department users:', error);
|
|
562
128
|
return [];
|
|
563
129
|
}
|
|
564
130
|
}
|
|
565
131
|
async getDepartmentList(deptId = 1) {
|
|
566
132
|
try {
|
|
567
|
-
const data = await this
|
|
133
|
+
const data = await this.#request('/cgi-bin/department/list', {
|
|
568
134
|
params: { id: deptId },
|
|
569
135
|
});
|
|
570
136
|
if (data.errcode === 0)
|
|
@@ -572,31 +138,68 @@ export class WecomEndpoint {
|
|
|
572
138
|
throw new Error(`Failed to get department list: ${data.errmsg}`);
|
|
573
139
|
}
|
|
574
140
|
catch (error) {
|
|
575
|
-
|
|
141
|
+
logger.error('Failed to get department list:', error);
|
|
576
142
|
return [];
|
|
577
143
|
}
|
|
578
144
|
}
|
|
579
145
|
async sendTextMessage(userId, content) {
|
|
580
146
|
try {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
body: {
|
|
584
|
-
touser: userId,
|
|
585
|
-
msgtype: 'text',
|
|
586
|
-
agentid: this.$config.agentSecret,
|
|
587
|
-
text: { content },
|
|
588
|
-
},
|
|
589
|
-
});
|
|
590
|
-
if (data.errcode === 0) {
|
|
591
|
-
this.logger.debug('Text message sent successfully');
|
|
592
|
-
return true;
|
|
593
|
-
}
|
|
594
|
-
throw new Error(`Failed to send text message: ${data.errmsg}`);
|
|
147
|
+
await this.send({ target: userId, payload: content });
|
|
148
|
+
return true;
|
|
595
149
|
}
|
|
596
150
|
catch (error) {
|
|
597
|
-
|
|
151
|
+
logger.error('Failed to send text message:', error);
|
|
598
152
|
return false;
|
|
599
153
|
}
|
|
600
154
|
}
|
|
155
|
+
async #request(path, options = {}) {
|
|
156
|
+
await this.#ensureAccessToken();
|
|
157
|
+
const { method = 'GET', params = {}, body } = options;
|
|
158
|
+
const urlParams = new URLSearchParams({
|
|
159
|
+
...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
|
|
160
|
+
access_token: this.#accessToken.access_token,
|
|
161
|
+
});
|
|
162
|
+
const url = `${this.#options.config.apiBaseUrl}${path}?${urlParams.toString()}`;
|
|
163
|
+
const response = await this.#fetch(url, {
|
|
164
|
+
method,
|
|
165
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
166
|
+
body: body && method === 'POST' ? JSON.stringify(body) : undefined,
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const text = await response.text().catch(() => '');
|
|
170
|
+
throw new Error(`WeCom API error ${response.status}: ${text}`);
|
|
171
|
+
}
|
|
172
|
+
return await response.json();
|
|
173
|
+
}
|
|
174
|
+
async #ensureAccessToken() {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (this.#accessToken.access_token
|
|
177
|
+
&& now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (this.#refreshPromise) {
|
|
181
|
+
await this.#refreshPromise;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.#refreshPromise = this.#refreshAccessToken()
|
|
185
|
+
.then(() => this.#accessToken.access_token)
|
|
186
|
+
.finally(() => { this.#refreshPromise = null; });
|
|
187
|
+
await this.#refreshPromise;
|
|
188
|
+
}
|
|
189
|
+
async #refreshAccessToken() {
|
|
190
|
+
const { corpId, agentSecret, apiBaseUrl } = this.#options.config;
|
|
191
|
+
const url = `${apiBaseUrl}/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${agentSecret}`;
|
|
192
|
+
const response = await this.#fetch(url);
|
|
193
|
+
const data = await response.json();
|
|
194
|
+
if (data.errcode === 0 && data.access_token) {
|
|
195
|
+
this.#accessToken = {
|
|
196
|
+
access_token: data.access_token,
|
|
197
|
+
expires_in: data.expires_in ?? 7200,
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
};
|
|
200
|
+
logger.debug('Access token refreshed successfully');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
204
|
+
}
|
|
601
205
|
}
|
|
602
|
-
//# sourceMappingURL=endpoint.js.map
|