@zhin.js/adapter-wecom 0.0.1 → 1.1.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/CHANGELOG.md +624 -0
- package/README.md +80 -216
- package/adapters/wecom.js +34 -0
- package/adapters/wecom.ts +39 -0
- package/{skills/wecom → agent}/PERMITS.md +1 -1
- package/agent/tools/get_dept_users.ts +16 -0
- package/agent/tools/get_user.ts +15 -0
- package/agent/tools/list_departments.ts +16 -0
- package/agent/tools/send_text.ts +17 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +12 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +55 -37
- package/lib/endpoint.js +247 -516
- package/lib/index.d.ts +6 -15
- package/lib/index.js +6 -115
- package/lib/media-upload.d.ts +23 -0
- package/lib/media-upload.js +58 -0
- package/lib/platform-permit.d.ts +2 -4
- package/lib/platform-permit.js +5 -11
- package/lib/protocol.d.ts +102 -0
- package/lib/protocol.js +268 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +63 -0
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +86 -0
- package/lib/wecom-endpoint-commands.d.ts +1 -0
- package/lib/wecom-endpoint-commands.js +19 -0
- package/lib/wecom-runtime-state.d.ts +1 -0
- package/lib/wecom-runtime-state.js +6 -0
- package/package.json +64 -16
- package/plugin.js +19 -0
- package/schema.json +96 -0
- package/src/client.ts +16 -0
- package/src/endpoint.ts +311 -559
- package/src/index.ts +52 -131
- package/src/media-upload.ts +79 -0
- package/src/platform-permit.ts +6 -11
- package/src/protocol.ts +379 -0
- package/src/side-event-dispatch.ts +70 -0
- package/src/webhook.ts +130 -0
- package/src/wecom-endpoint-commands.ts +20 -0
- package/src/wecom-runtime-state.ts +7 -0
- package/lib/adapter.d.ts +0 -15
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -20
- 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/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 -26
- package/src/types.ts +0 -51
- /package/{skills/wecom/SKILL.md → agent/skills/wecom.md} +0 -0
package/lib/endpoint.js
CHANGED
|
@@ -1,526 +1,212 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* WecomEndpoint — lifecycle, outbound send, inbound admit, OpenAPI helpers for agent tools.
|
|
3
4
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
5
|
+
import { createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
6
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
7
|
+
import { buildMediaUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
|
|
8
|
+
import { buildSendRequestBody, formatInboundContent, formatOutboundBody, resolveChatType, wecomInboundConversation, } from './protocol.js';
|
|
9
|
+
import { registerWecomWebhookRoutes } from './webhook.js';
|
|
10
|
+
import { receiveWecomSideEvent } from './side-event-dispatch.js';
|
|
11
|
+
/** WeCom API surface exposed to plugin events without Endpoint lifecycle methods. */
|
|
12
|
+
export class WecomClient {
|
|
13
|
+
api;
|
|
14
|
+
constructor(api) {
|
|
15
|
+
this.api = api;
|
|
16
|
+
}
|
|
17
|
+
getUserInfo = (userId) => this.api.getUserInfo(userId);
|
|
18
|
+
getDepartmentUsers = (deptId) => this.api.getDepartmentUsers(deptId);
|
|
19
|
+
getDepartmentList = (deptId) => this.api.getDepartmentList(deptId);
|
|
20
|
+
sendTextMessage = (userId, content) => this.api.sendTextMessage(userId, content);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 企业微信服务端 API 无 bot 群列表/群成员列表接口(客户群接口属「客户联系」
|
|
24
|
+
* 独立授权域,非 bot 社交面),好友/频道概念亦不存在;
|
|
25
|
+
* 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
|
|
26
|
+
*/
|
|
27
|
+
export class WecomEndpoint extends Endpoint {
|
|
28
|
+
client = new WecomClient({
|
|
29
|
+
getUserInfo: (userId) => this.#getUserInfo(userId),
|
|
30
|
+
getDepartmentUsers: (deptId) => this.#getDepartmentUsers(deptId),
|
|
31
|
+
getDepartmentList: (deptId) => this.#getDepartmentList(deptId),
|
|
32
|
+
sendTextMessage: (userId, content) => this.#sendTextMessage(userId, content),
|
|
33
|
+
});
|
|
34
|
+
#logger;
|
|
35
|
+
#options;
|
|
36
|
+
control = createRecallEndpointControl((id) => this.recallMessage(id));
|
|
37
|
+
#fetch;
|
|
38
|
+
#routeReleases = [];
|
|
39
|
+
#accessToken = { access_token: '', expires_in: 0, timestamp: 0 };
|
|
17
40
|
#refreshPromise = null;
|
|
18
|
-
|
|
19
|
-
|
|
41
|
+
#open = false;
|
|
42
|
+
#started = false;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
super();
|
|
45
|
+
this.#logger = getAdapterLogger('wecom', options.config.id);
|
|
46
|
+
this.#options = options;
|
|
47
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
20
48
|
}
|
|
21
|
-
|
|
22
|
-
|
|
49
|
+
/** Used by webhook handler. */
|
|
50
|
+
get isOpen() {
|
|
51
|
+
return this.#open;
|
|
23
52
|
}
|
|
24
|
-
|
|
25
|
-
this.
|
|
26
|
-
this.$config = $config;
|
|
27
|
-
this.router = router;
|
|
28
|
-
this.$connected = false;
|
|
29
|
-
this.accessToken = { access_token: '', expires_in: 0, timestamp: 0 };
|
|
30
|
-
this.baseURL = $config.apiBaseUrl || 'https://qyapi.weixin.qq.com';
|
|
31
|
-
this.corpId = $config.corpId;
|
|
32
|
-
this.aesKey = Buffer.from($config.encodingAESKey + '=', 'base64');
|
|
33
|
-
if (this.aesKey.length !== 32) {
|
|
34
|
-
throw new Error(`encodingAESKey must produce a 32-byte key, got ${this.aesKey.length} bytes`);
|
|
35
|
-
}
|
|
36
|
-
this.setupWebhookRoute();
|
|
53
|
+
get config() {
|
|
54
|
+
return this.#options.config;
|
|
37
55
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const urlParams = new URLSearchParams({
|
|
43
|
-
...params,
|
|
44
|
-
access_token: this.accessToken.access_token,
|
|
45
|
-
});
|
|
46
|
-
const url = `${this.baseURL}${path}?${urlParams.toString()}`;
|
|
47
|
-
const fetchOptions = {
|
|
48
|
-
method,
|
|
49
|
-
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
50
|
-
};
|
|
51
|
-
if (body && method === 'POST') {
|
|
52
|
-
fetchOptions.body = JSON.stringify(body);
|
|
53
|
-
}
|
|
54
|
-
const response = await fetch(url, fetchOptions);
|
|
55
|
-
if (!response.ok) {
|
|
56
|
-
const text = await response.text().catch(() => '');
|
|
57
|
-
throw new Error(`WeCom API error ${response.status}: ${text}`);
|
|
58
|
-
}
|
|
59
|
-
return await response.json();
|
|
60
|
-
}
|
|
61
|
-
// ── Webhook route ──
|
|
62
|
-
setupWebhookRoute() {
|
|
63
|
-
const webhookPath = this.$config.webhookPath || '/wecom/callback';
|
|
64
|
-
registerFetchRoute(this.router, 'GET', webhookPath, (ctx) => {
|
|
65
|
-
void this.handleVerification(ctx);
|
|
66
|
-
});
|
|
67
|
-
registerFetchRoute(this.router, 'POST', webhookPath, (ctx) => {
|
|
68
|
-
void this.handleWebhook(ctx);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
// ── URL 验证(企业微信首次配置回调 URL 时的 GET 请求)──
|
|
72
|
-
handleVerification(ctx) {
|
|
56
|
+
async start() {
|
|
57
|
+
if (this.#started)
|
|
58
|
+
return;
|
|
59
|
+
this.#started = true;
|
|
73
60
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.logger.warn(formatCompact({ op: 'verify', ok: false, error: 'invalid signature' }));
|
|
82
|
-
ctx.status = 403;
|
|
83
|
-
ctx.body = 'Forbidden';
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
// 解密 echostr 并返回明文
|
|
87
|
-
const decrypted = this.decryptMessage(echostr);
|
|
88
|
-
ctx.status = 200;
|
|
89
|
-
ctx.body = decrypted;
|
|
61
|
+
await this.#refreshAccessToken();
|
|
62
|
+
this.#routeReleases.push(...registerWecomWebhookRoutes(this.#options.http, this));
|
|
63
|
+
this.#logger.debug(formatCompact({
|
|
64
|
+
endpoint: this.#options.config.id,
|
|
65
|
+
op: 'webhook',
|
|
66
|
+
path: this.#options.config.webhookPath,
|
|
67
|
+
}));
|
|
90
68
|
}
|
|
91
69
|
catch (error) {
|
|
92
|
-
this.
|
|
93
|
-
|
|
94
|
-
|
|
70
|
+
await this.stop();
|
|
71
|
+
this.#logger.error('Failed to connect WeCom endpoint:', error);
|
|
72
|
+
throw error;
|
|
95
73
|
}
|
|
96
74
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
const query = ctx.query;
|
|
101
|
-
const msgSignature = query.msg_signature;
|
|
102
|
-
const timestamp = query.timestamp;
|
|
103
|
-
const nonce = query.nonce;
|
|
104
|
-
// 解析 XML body
|
|
105
|
-
const rawBody = typeof ctx.request.body === 'string'
|
|
106
|
-
? ctx.request.body
|
|
107
|
-
: String(ctx.request.body || '');
|
|
108
|
-
// 从 XML 中提取 Encrypt 字段
|
|
109
|
-
const encryptMatch = rawBody.match(/<Encrypt><!\[CDATA\[(.+?)\]\]><\/Encrypt>/);
|
|
110
|
-
if (!encryptMatch) {
|
|
111
|
-
this.logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'no Encrypt field' }));
|
|
112
|
-
ctx.status = 200;
|
|
113
|
-
ctx.body = 'success';
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const encrypted = encryptMatch[1];
|
|
117
|
-
// 验证签名
|
|
118
|
-
if (!this.verifySignature(msgSignature, timestamp, nonce, encrypted)) {
|
|
119
|
-
this.logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
|
|
120
|
-
ctx.status = 403;
|
|
121
|
-
ctx.body = 'Forbidden';
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
// 解密消息
|
|
125
|
-
const decryptedXml = this.decryptMessage(encrypted);
|
|
126
|
-
const message = this.parseXmlMessage(decryptedXml);
|
|
127
|
-
if (message) {
|
|
128
|
-
await this.handleMessage(message);
|
|
129
|
-
}
|
|
130
|
-
ctx.status = 200;
|
|
131
|
-
ctx.body = 'success';
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
this.logger.error('Webhook error:', error);
|
|
135
|
-
ctx.status = 200;
|
|
136
|
-
ctx.body = 'success';
|
|
137
|
-
}
|
|
75
|
+
open() {
|
|
76
|
+
this.#open = true;
|
|
138
77
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
const arr = [this.$config.token, timestamp, nonce, encrypt].sort();
|
|
143
|
-
const str = arr.join('');
|
|
144
|
-
const hash = createHash('sha1').update(str).digest('hex');
|
|
145
|
-
return hash === signature;
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
this.logger.error('Signature verification error:', error);
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
78
|
+
close() {
|
|
79
|
+
this.#open = false;
|
|
151
80
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]);
|
|
159
|
-
// PKCS7 unpad
|
|
160
|
-
const pad = decrypted[decrypted.length - 1];
|
|
161
|
-
const content = decrypted.subarray(0, decrypted.length - pad);
|
|
162
|
-
// 提取: 16 字节随机数 + 4 字节消息长度 + 消息体 + corpId
|
|
163
|
-
const msgLen = content.readUInt32BE(16);
|
|
164
|
-
const msg = content.subarray(20, 20 + msgLen).toString('utf8');
|
|
165
|
-
const extractedCorpId = content.subarray(20 + msgLen).toString('utf8');
|
|
166
|
-
if (extractedCorpId !== this.corpId) {
|
|
167
|
-
this.logger.warn(formatCompact({ op: 'decrypt', ok: false, error: 'corpId mismatch' }));
|
|
168
|
-
}
|
|
169
|
-
return msg;
|
|
81
|
+
async stop() {
|
|
82
|
+
this.#open = false;
|
|
83
|
+
for (const release of this.#routeReleases.splice(0))
|
|
84
|
+
release();
|
|
85
|
+
this.#started = false;
|
|
86
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
170
87
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
ToUserName: get('ToUserName') || '',
|
|
184
|
-
FromUserName: get('FromUserName') || '',
|
|
185
|
-
CreateTime: Number(get('CreateTime') || Date.now()),
|
|
186
|
-
MsgType: msgType,
|
|
187
|
-
MsgId: get('MsgId'),
|
|
188
|
-
AgentID: get('AgentID'),
|
|
189
|
-
};
|
|
190
|
-
switch (msgType) {
|
|
191
|
-
case 'text':
|
|
192
|
-
msg.Content = get('Content');
|
|
193
|
-
break;
|
|
194
|
-
case 'image':
|
|
195
|
-
msg.PicUrl = get('PicUrl');
|
|
196
|
-
msg.MediaId = get('MediaId');
|
|
197
|
-
break;
|
|
198
|
-
case 'voice':
|
|
199
|
-
msg.MediaId = get('MediaId');
|
|
200
|
-
msg.Format = get('Format');
|
|
201
|
-
msg.Recognition = get('Recognition');
|
|
202
|
-
break;
|
|
203
|
-
case 'video':
|
|
204
|
-
case 'shortvideo':
|
|
205
|
-
msg.MediaId = get('MediaId');
|
|
206
|
-
msg.ThumbMediaId = get('ThumbMediaId');
|
|
207
|
-
break;
|
|
208
|
-
case 'location':
|
|
209
|
-
msg.Location_X = get('Location_X');
|
|
210
|
-
msg.Location_Y = get('Location_Y');
|
|
211
|
-
msg.Scale = get('Scale');
|
|
212
|
-
msg.Label = get('Label');
|
|
213
|
-
break;
|
|
214
|
-
case 'link':
|
|
215
|
-
msg.Title = get('Title');
|
|
216
|
-
msg.Description = get('Description');
|
|
217
|
-
msg.Url = get('Url');
|
|
218
|
-
break;
|
|
219
|
-
case 'event':
|
|
220
|
-
msg.Event = get('Event');
|
|
221
|
-
msg.EventKey = get('EventKey');
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
return msg;
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
this.logger.error('Failed to parse XML message:', error);
|
|
228
|
-
return null;
|
|
88
|
+
async send({ conversation, payload }) {
|
|
89
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
90
|
+
const content = formatOutboundBody(materialized);
|
|
91
|
+
const body = buildSendRequestBody(conversation.id, content,
|
|
92
|
+
// Legacy field: historically written into agentid (preserved for cutover).
|
|
93
|
+
this.#options.config.agentSecret);
|
|
94
|
+
const data = await this.#request('/cgi-bin/message/send', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
if (data.errcode !== 0) {
|
|
99
|
+
throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
|
|
229
100
|
}
|
|
230
|
-
|
|
231
|
-
// ── 消息处理 ──
|
|
232
|
-
async handleMessage(msg) {
|
|
233
|
-
const formatted = this.$formatMessage(msg);
|
|
234
|
-
this.adapter.emit('message.receive', formatted);
|
|
235
|
-
this.logger.debug(formatCompact({
|
|
236
|
-
op: 'recv',
|
|
237
|
-
endpoint: this.$config.name,
|
|
238
|
-
channel: formatted.$channel.type,
|
|
239
|
-
id: formatted.$channel.id,
|
|
240
|
-
len: segment.raw(formatted.$content).length,
|
|
101
|
+
this.#logger.debug(formatCompact({ op: 'send', to: `${conversation.kind}:${conversation.id}`,
|
|
241
102
|
}));
|
|
103
|
+
return data.msgid || `${Date.now()}`;
|
|
242
104
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const now = Date.now();
|
|
246
|
-
if (this.accessToken.access_token &&
|
|
247
|
-
now < this.accessToken.timestamp + (this.accessToken.expires_in - 300) * 1000) {
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
if (this.#refreshPromise) {
|
|
251
|
-
await this.#refreshPromise;
|
|
105
|
+
async recallMessage(messageId) {
|
|
106
|
+
if (!messageId)
|
|
252
107
|
return;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
.finally(() => { this.#refreshPromise = null; });
|
|
257
|
-
await this.#refreshPromise;
|
|
258
|
-
}
|
|
259
|
-
async refreshAccessToken() {
|
|
260
|
-
try {
|
|
261
|
-
const url = `${this.baseURL}/cgi-bin/gettoken?corpid=${this.corpId}&corpsecret=${this.$config.agentSecret}`;
|
|
262
|
-
const response = await fetch(url);
|
|
263
|
-
const data = await response.json();
|
|
264
|
-
if (data.errcode === 0) {
|
|
265
|
-
this.accessToken = {
|
|
266
|
-
access_token: data.access_token,
|
|
267
|
-
expires_in: data.expires_in,
|
|
268
|
-
timestamp: Date.now(),
|
|
269
|
-
};
|
|
270
|
-
this.logger.debug('Access token refreshed successfully');
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
catch (error) {
|
|
277
|
-
this.logger.error('Failed to refresh access token:', error);
|
|
278
|
-
throw error;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
// ── $formatMessage ──
|
|
282
|
-
$formatMessage(msg) {
|
|
283
|
-
const content = this.parseMessageContent(msg);
|
|
284
|
-
// 企业微信中群消息与私聊消息的判断:
|
|
285
|
-
// 群消息 FromUserName 以 @chatroom 结尾
|
|
286
|
-
const chatType = msg.FromUserName.endsWith('@chatroom') ? 'group' : 'private';
|
|
287
|
-
// TODO: v1: look up group admin status via WeCom API
|
|
288
|
-
const permit = normalizeWecomSenderForPermit({ isAdmin: false, isOwner: false });
|
|
289
|
-
return Message.from(msg, {
|
|
290
|
-
$id: msg.MsgId || Date.now().toString(),
|
|
291
|
-
$adapter: 'wecom',
|
|
292
|
-
$endpoint: this.$config.name,
|
|
293
|
-
$sender: {
|
|
294
|
-
id: msg.FromUserName,
|
|
295
|
-
name: msg.FromUserName,
|
|
296
|
-
role: permit.role,
|
|
297
|
-
permissions: permit.permissions,
|
|
298
|
-
},
|
|
299
|
-
$channel: {
|
|
300
|
-
id: chatType === 'group' ? msg.FromUserName : msg.FromUserName,
|
|
301
|
-
type: chatType,
|
|
302
|
-
},
|
|
303
|
-
$content: content,
|
|
304
|
-
$raw: JSON.stringify(msg),
|
|
305
|
-
$timestamp: (msg.CreateTime || Math.floor(Date.now() / 1000)) * 1000,
|
|
306
|
-
$recall: async () => {
|
|
307
|
-
await this.$recallMessage(msg.MsgId || '');
|
|
308
|
-
},
|
|
309
|
-
$reply: async (content) => {
|
|
310
|
-
return await this.adapter.sendMessage({
|
|
311
|
-
context: 'wecom',
|
|
312
|
-
endpoint: this.$config.name,
|
|
313
|
-
id: msg.FromUserName,
|
|
314
|
-
type: chatType,
|
|
315
|
-
content: content,
|
|
316
|
-
});
|
|
317
|
-
},
|
|
108
|
+
await this.#request('/cgi-bin/message/recall', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body: { msgid: messageId },
|
|
318
111
|
});
|
|
319
112
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (msg.Recognition) {
|
|
346
|
-
content.push(segment('text', { content: msg.Recognition }));
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
break;
|
|
350
|
-
case 'video':
|
|
351
|
-
case 'shortvideo':
|
|
352
|
-
if (msg.MediaId) {
|
|
353
|
-
content.push(segment('video', {
|
|
354
|
-
file: msg.MediaId,
|
|
355
|
-
}));
|
|
356
|
-
}
|
|
357
|
-
break;
|
|
358
|
-
case 'location':
|
|
359
|
-
content.push(segment('text', {
|
|
360
|
-
content: `[位置] ${msg.Label || ''} (${msg.Location_X}, ${msg.Location_Y})`,
|
|
361
|
-
}));
|
|
362
|
-
break;
|
|
363
|
-
case 'link':
|
|
364
|
-
content.push(segment('link', {
|
|
365
|
-
title: msg.Title || '',
|
|
366
|
-
content: msg.Description || '',
|
|
367
|
-
url: msg.Url || '',
|
|
368
|
-
}));
|
|
369
|
-
break;
|
|
370
|
-
case 'event':
|
|
371
|
-
content.push(segment('text', {
|
|
372
|
-
content: `[事件] ${msg.Event || ''} ${msg.EventKey || ''}`,
|
|
373
|
-
}));
|
|
374
|
-
break;
|
|
375
|
-
default:
|
|
376
|
-
content.push(segment('text', {
|
|
377
|
-
content: `[不支持的消息类型: ${msg.MsgType}]`,
|
|
378
|
-
}));
|
|
379
|
-
break;
|
|
113
|
+
/**
|
|
114
|
+
* message/send 的 image 段只接受 media_id:canonical MediaRef 按 kind 投递——
|
|
115
|
+
* file(平台不透明引用)直用为 media_id;base64/本地路径/URL 先经
|
|
116
|
+
* /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
|
|
117
|
+
* 无 canonical MediaRef 的媒体段 warn + 丢弃。
|
|
118
|
+
*/
|
|
119
|
+
async #materializeOutboundMedia(payload) {
|
|
120
|
+
if (!Array.isArray(payload))
|
|
121
|
+
return payload;
|
|
122
|
+
const items = await Promise.all(payload.map(async (item) => {
|
|
123
|
+
if (typeof item === 'string' || !item || typeof item !== 'object')
|
|
124
|
+
return item;
|
|
125
|
+
const seg = item;
|
|
126
|
+
if (seg.type !== 'image')
|
|
127
|
+
return item;
|
|
128
|
+
const data = seg.data ?? {};
|
|
129
|
+
const media = readOutboundImageMedia(data);
|
|
130
|
+
if (!media) {
|
|
131
|
+
this.#logger.warn(formatCompact({
|
|
132
|
+
op: 'wecom_outbound_media_dropped',
|
|
133
|
+
endpoint: this.#options.config.id,
|
|
134
|
+
type: 'image',
|
|
135
|
+
reason: 'missing_media_ref',
|
|
136
|
+
}));
|
|
137
|
+
return null;
|
|
380
138
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
this.logger.error('Failed to parse message content:', error);
|
|
384
|
-
content.push(segment('text', { content: '[消息解析失败]' }));
|
|
385
|
-
}
|
|
386
|
-
return content;
|
|
387
|
-
}
|
|
388
|
-
// ── $sendMessage ──
|
|
389
|
-
async $sendMessage(options) {
|
|
390
|
-
const targetId = options.id;
|
|
391
|
-
const content = this.formatSendContent(options.content);
|
|
392
|
-
try {
|
|
393
|
-
const body = {
|
|
394
|
-
touser: targetId,
|
|
395
|
-
msgtype: content.msgtype,
|
|
396
|
-
agentid: this.$config.agentSecret,
|
|
397
|
-
[content.msgtype]: content.data,
|
|
398
|
-
};
|
|
399
|
-
// 群消息使用 chatid
|
|
400
|
-
if (targetId.endsWith('@chatroom')) {
|
|
401
|
-
delete body.touser;
|
|
402
|
-
body.chatid = targetId;
|
|
139
|
+
if (media.kind === 'file') {
|
|
140
|
+
return { type: 'image', data: { media_id: media.value } };
|
|
403
141
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
});
|
|
408
|
-
if (data.errcode !== 0) {
|
|
409
|
-
throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
|
|
142
|
+
try {
|
|
143
|
+
const mediaId = await this.#uploadMedia('image', media);
|
|
144
|
+
return { type: 'image', data: { media_id: mediaId } };
|
|
410
145
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
throw error;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
// ── $recallMessage (企业微信不支持机器人撤回) ──
|
|
420
|
-
async $recallMessage(_id) {
|
|
421
|
-
this.logger.warn(formatCompact({ op: 'recall', ok: false, error: 'not supported by wecom' }));
|
|
422
|
-
}
|
|
423
|
-
// ── 发送内容格式化 ──
|
|
424
|
-
formatSendContent(content) {
|
|
425
|
-
if (typeof content === 'string') {
|
|
426
|
-
return { msgtype: 'text', data: { content } };
|
|
427
|
-
}
|
|
428
|
-
if (Array.isArray(content)) {
|
|
429
|
-
const textParts = [];
|
|
430
|
-
let hasMedia = false;
|
|
431
|
-
let mediaType = '';
|
|
432
|
-
let mediaData = null;
|
|
433
|
-
let droppedMediaCount = 0;
|
|
434
|
-
for (const item of content) {
|
|
435
|
-
if (typeof item === 'string') {
|
|
436
|
-
textParts.push(item);
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
const seg = item;
|
|
440
|
-
switch (seg.type) {
|
|
441
|
-
case 'text':
|
|
442
|
-
textParts.push(seg.data.content || seg.data.text || '');
|
|
443
|
-
break;
|
|
444
|
-
case 'at':
|
|
445
|
-
// 企业微信 @ 格式: <@userid>
|
|
446
|
-
const userId = seg.data.id || seg.data.userId;
|
|
447
|
-
if (userId)
|
|
448
|
-
textParts.push(`<@${userId}>`);
|
|
449
|
-
break;
|
|
450
|
-
case 'image':
|
|
451
|
-
if (!hasMedia) {
|
|
452
|
-
hasMedia = true;
|
|
453
|
-
mediaType = 'image';
|
|
454
|
-
mediaData = { media_id: seg.data.file || seg.data.url };
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
droppedMediaCount++;
|
|
458
|
-
}
|
|
459
|
-
break;
|
|
460
|
-
case 'markdown':
|
|
461
|
-
if (!hasMedia) {
|
|
462
|
-
hasMedia = true;
|
|
463
|
-
mediaType = 'markdown';
|
|
464
|
-
mediaData = { content: seg.data.content || seg.data.text };
|
|
465
|
-
}
|
|
466
|
-
else {
|
|
467
|
-
droppedMediaCount++;
|
|
468
|
-
}
|
|
469
|
-
break;
|
|
470
|
-
case 'link':
|
|
471
|
-
if (!hasMedia) {
|
|
472
|
-
hasMedia = true;
|
|
473
|
-
mediaType = 'news';
|
|
474
|
-
mediaData = {
|
|
475
|
-
articles: [{
|
|
476
|
-
title: seg.data.title || '链接',
|
|
477
|
-
description: seg.data.text || seg.data.content || '',
|
|
478
|
-
url: seg.data.url,
|
|
479
|
-
picurl: seg.data.picUrl,
|
|
480
|
-
}],
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
else {
|
|
484
|
-
droppedMediaCount++;
|
|
485
|
-
}
|
|
486
|
-
break;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
if (droppedMediaCount > 0) {
|
|
490
|
-
this.logger.warn(formatCompact({
|
|
491
|
-
op: 'formatSend',
|
|
492
|
-
droppedMedia: droppedMediaCount,
|
|
493
|
-
note: 'WeCom API only supports one media segment per message',
|
|
146
|
+
catch (error) {
|
|
147
|
+
this.#logger.warn(formatCompact({
|
|
148
|
+
op: 'wecom_media_upload_failed',
|
|
149
|
+
endpoint: this.#options.config.id,
|
|
150
|
+
error: error instanceof Error ? error.message : String(error),
|
|
494
151
|
}));
|
|
152
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
|
|
153
|
+
return { type: 'text', data: { text: alt } };
|
|
495
154
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
return { msgtype: 'text', data: { content: textParts.join('') } };
|
|
500
|
-
}
|
|
501
|
-
return { msgtype: 'text', data: { content: String(content) } };
|
|
155
|
+
}));
|
|
156
|
+
return items.filter((item) => item !== null);
|
|
502
157
|
}
|
|
503
|
-
|
|
504
|
-
async
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
158
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
159
|
+
async #uploadMedia(type, media) {
|
|
160
|
+
await this.#ensureAccessToken();
|
|
161
|
+
const binary = await resolveMediaBinary(media);
|
|
162
|
+
const form = buildMediaUploadForm(binary);
|
|
163
|
+
const url = `${this.#options.config.apiBaseUrl}/cgi-bin/media/upload?access_token=${this.#accessToken.access_token}&type=${type}`;
|
|
164
|
+
const response = await this.#fetch(url, { method: 'POST', body: form });
|
|
165
|
+
const data = await response.json();
|
|
166
|
+
if (data.errcode === 0 && data.media_id)
|
|
167
|
+
return data.media_id;
|
|
168
|
+
throw new Error(`WeCom media upload failed: ${data.errmsg ?? 'unknown'} (${data.errcode ?? '?'})`);
|
|
515
169
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
170
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
171
|
+
admit(msg) {
|
|
172
|
+
if (!this.#open)
|
|
173
|
+
return;
|
|
174
|
+
void this.emitPlatform(msg.Event || msg.MsgType || 'event', msg).catch((error) => {
|
|
175
|
+
this.#logger.warn(formatCompact({
|
|
176
|
+
op: 'wecom_platform_event_failed',
|
|
177
|
+
event: msg.Event || msg.MsgType,
|
|
178
|
+
error: error instanceof Error ? error.message : String(error),
|
|
179
|
+
}));
|
|
180
|
+
});
|
|
181
|
+
if (receiveWecomSideEvent((name, payload) => this.emit(name, payload), String(this.#options.id), this.#options.config.id, msg, this.#logger)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const chatType = resolveChatType(msg.FromUserName);
|
|
185
|
+
const conversation = wecomInboundConversation(String(this.#options.id), msg);
|
|
186
|
+
void this.emit('message.receive', {
|
|
187
|
+
conversation,
|
|
188
|
+
message: { conversation, id: msg.MsgId || `${msg.CreateTime}` },
|
|
189
|
+
content: formatInboundContent(msg),
|
|
190
|
+
sender: { id: msg.FromUserName },
|
|
191
|
+
endpointId: this.#options.config.id,
|
|
192
|
+
metadata: Object.freeze({
|
|
193
|
+
msgType: msg.MsgType,
|
|
194
|
+
event: msg.Event,
|
|
195
|
+
chatType,
|
|
196
|
+
toUserName: msg.ToUserName,
|
|
197
|
+
agentId: msg.AgentID,
|
|
198
|
+
}),
|
|
199
|
+
}).catch((err) => {
|
|
200
|
+
this.#logger.warn(formatCompact({
|
|
201
|
+
op: 'wecom_gateway_receive_failed',
|
|
202
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
203
|
+
error: err instanceof Error ? err.message : String(err),
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
519
206
|
}
|
|
520
|
-
|
|
521
|
-
async getUserInfo(userId) {
|
|
207
|
+
async #getUserInfo(userId) {
|
|
522
208
|
try {
|
|
523
|
-
const data = await this
|
|
209
|
+
const data = await this.#request('/cgi-bin/user/get', {
|
|
524
210
|
params: { userid: userId },
|
|
525
211
|
});
|
|
526
212
|
if (data.errcode === 0)
|
|
@@ -528,13 +214,13 @@ export class WecomEndpoint {
|
|
|
528
214
|
throw new Error(`Failed to get user info: ${data.errmsg}`);
|
|
529
215
|
}
|
|
530
216
|
catch (error) {
|
|
531
|
-
this
|
|
217
|
+
this.#logger.error('Failed to get user info:', error);
|
|
532
218
|
return null;
|
|
533
219
|
}
|
|
534
220
|
}
|
|
535
|
-
async getDepartmentUsers(deptId) {
|
|
221
|
+
async #getDepartmentUsers(deptId) {
|
|
536
222
|
try {
|
|
537
|
-
const data = await this
|
|
223
|
+
const data = await this.#request('/cgi-bin/user/simplelist', {
|
|
538
224
|
params: { department_id: deptId },
|
|
539
225
|
});
|
|
540
226
|
if (data.errcode === 0)
|
|
@@ -542,13 +228,13 @@ export class WecomEndpoint {
|
|
|
542
228
|
throw new Error(`Failed to get department users: ${data.errmsg}`);
|
|
543
229
|
}
|
|
544
230
|
catch (error) {
|
|
545
|
-
this
|
|
231
|
+
this.#logger.error('Failed to get department users:', error);
|
|
546
232
|
return [];
|
|
547
233
|
}
|
|
548
234
|
}
|
|
549
|
-
async getDepartmentList(deptId = 1) {
|
|
235
|
+
async #getDepartmentList(deptId = 1) {
|
|
550
236
|
try {
|
|
551
|
-
const data = await this
|
|
237
|
+
const data = await this.#request('/cgi-bin/department/list', {
|
|
552
238
|
params: { id: deptId },
|
|
553
239
|
});
|
|
554
240
|
if (data.errcode === 0)
|
|
@@ -556,31 +242,76 @@ export class WecomEndpoint {
|
|
|
556
242
|
throw new Error(`Failed to get department list: ${data.errmsg}`);
|
|
557
243
|
}
|
|
558
244
|
catch (error) {
|
|
559
|
-
this
|
|
245
|
+
this.#logger.error('Failed to get department list:', error);
|
|
560
246
|
return [];
|
|
561
247
|
}
|
|
562
248
|
}
|
|
563
|
-
async sendTextMessage(userId, content) {
|
|
249
|
+
async #sendTextMessage(userId, content) {
|
|
564
250
|
try {
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
text: { content },
|
|
251
|
+
const endpointKey = String(this.#options.id);
|
|
252
|
+
await this.send({
|
|
253
|
+
conversation: {
|
|
254
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
255
|
+
kind: resolveChatType(userId),
|
|
256
|
+
id: userId,
|
|
572
257
|
},
|
|
258
|
+
payload: content,
|
|
573
259
|
});
|
|
574
|
-
|
|
575
|
-
this.logger.debug('Text message sent successfully');
|
|
576
|
-
return true;
|
|
577
|
-
}
|
|
578
|
-
throw new Error(`Failed to send text message: ${data.errmsg}`);
|
|
260
|
+
return true;
|
|
579
261
|
}
|
|
580
262
|
catch (error) {
|
|
581
|
-
this
|
|
263
|
+
this.#logger.error('Failed to send text message:', error);
|
|
582
264
|
return false;
|
|
583
265
|
}
|
|
584
266
|
}
|
|
267
|
+
async #request(path, options = {}) {
|
|
268
|
+
await this.#ensureAccessToken();
|
|
269
|
+
const { method = 'GET', params = {}, body } = options;
|
|
270
|
+
const urlParams = new URLSearchParams({
|
|
271
|
+
...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
|
|
272
|
+
access_token: this.#accessToken.access_token,
|
|
273
|
+
});
|
|
274
|
+
const url = `${this.#options.config.apiBaseUrl}${path}?${urlParams.toString()}`;
|
|
275
|
+
const response = await this.#fetch(url, {
|
|
276
|
+
method,
|
|
277
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
278
|
+
body: body && method === 'POST' ? JSON.stringify(body) : undefined,
|
|
279
|
+
});
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
const text = await response.text().catch(() => '');
|
|
282
|
+
throw new Error(`WeCom API error ${response.status}: ${text}`);
|
|
283
|
+
}
|
|
284
|
+
return await response.json();
|
|
285
|
+
}
|
|
286
|
+
async #ensureAccessToken() {
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
if (this.#accessToken.access_token
|
|
289
|
+
&& now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (this.#refreshPromise) {
|
|
293
|
+
await this.#refreshPromise;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.#refreshPromise = this.#refreshAccessToken()
|
|
297
|
+
.then(() => this.#accessToken.access_token)
|
|
298
|
+
.finally(() => { this.#refreshPromise = null; });
|
|
299
|
+
await this.#refreshPromise;
|
|
300
|
+
}
|
|
301
|
+
async #refreshAccessToken() {
|
|
302
|
+
const { corpId, agentSecret, apiBaseUrl } = this.#options.config;
|
|
303
|
+
const url = `${apiBaseUrl}/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${agentSecret}`;
|
|
304
|
+
const response = await this.#fetch(url);
|
|
305
|
+
const data = await response.json();
|
|
306
|
+
if (data.errcode === 0 && data.access_token) {
|
|
307
|
+
this.#accessToken = {
|
|
308
|
+
access_token: data.access_token,
|
|
309
|
+
expires_in: data.expires_in ?? 7200,
|
|
310
|
+
timestamp: Date.now(),
|
|
311
|
+
};
|
|
312
|
+
this.#logger.debug('Access token refreshed successfully');
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
316
|
+
}
|
|
585
317
|
}
|
|
586
|
-
//# sourceMappingURL=endpoint.js.map
|