@zhin.js/adapter-wecom 0.0.1

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