@zhin.js/adapter-wecom 1.1.17 → 1.1.19
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 +65 -0
- package/README.md +15 -3
- package/adapters/wecom/index.js +3 -17
- package/adapters/wecom/index.ts +2 -17
- package/commands/wecom/endpoint/definition.js +1 -2
- package/commands/wecom/endpoint/definition.ts +1 -2
- package/lib/adapter.d.ts +19 -0
- package/lib/adapter.js +26 -0
- package/lib/client.d.ts +67 -11
- package/lib/client.js +344 -2
- package/lib/console-spec.d.ts +12 -0
- package/lib/console-spec.js +14 -0
- package/lib/console.d.ts +8 -0
- package/lib/console.js +13 -0
- package/lib/endpoint.d.ts +11 -58
- package/lib/endpoint.js +30 -315
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -2
- package/lib/media-upload.d.ts +5 -1
- package/lib/media-upload.js +6 -6
- package/lib/protocol.d.ts +4 -0
- package/lib/protocol.js +3 -0
- package/package.json +25 -17
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +3 -4
- package/schema.json +11 -0
- package/src/adapter.ts +30 -0
- package/src/client.ts +414 -10
- package/src/console-spec.ts +19 -0
- package/src/console.ts +18 -0
- package/src/endpoint.ts +33 -386
- package/src/index.ts +4 -3
- package/src/media-upload.ts +10 -5
- package/src/protocol.ts +8 -1
- package/lib/wecom-runtime-state.d.ts +0 -1
- package/lib/wecom-runtime-state.js +0 -6
- package/src/wecom-runtime-state.ts +0 -7
package/lib/endpoint.js
CHANGED
|
@@ -1,317 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 };
|
|
40
|
-
#refreshPromise = null;
|
|
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;
|
|
48
|
-
}
|
|
49
|
-
/** Used by webhook handler. */
|
|
50
|
-
get isOpen() {
|
|
51
|
-
return this.#open;
|
|
52
|
-
}
|
|
53
|
-
get config() {
|
|
54
|
-
return this.#options.config;
|
|
55
|
-
}
|
|
56
|
-
async start() {
|
|
57
|
-
if (this.#started)
|
|
58
|
-
return;
|
|
59
|
-
this.#started = true;
|
|
60
|
-
try {
|
|
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
|
-
}));
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
await this.stop();
|
|
71
|
-
this.#logger.error('Failed to connect WeCom endpoint:', error);
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
open() {
|
|
76
|
-
this.#open = true;
|
|
77
|
-
}
|
|
78
|
-
close() {
|
|
79
|
-
this.#open = false;
|
|
80
|
-
}
|
|
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' }));
|
|
87
|
-
}
|
|
88
|
-
async send({ conversation, payload, locale }) {
|
|
89
|
-
const materialized = await this.#materializeOutboundMedia(payload);
|
|
90
|
-
const content = formatOutboundBody(materialized, locale);
|
|
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})`);
|
|
100
|
-
}
|
|
101
|
-
this.#logger.debug(formatCompact({ op: 'send', to: `${conversation.kind}:${conversation.id}`,
|
|
102
|
-
}));
|
|
103
|
-
return data.msgid || `${Date.now()}`;
|
|
104
|
-
}
|
|
105
|
-
async recallMessage(messageId) {
|
|
106
|
-
if (!messageId)
|
|
107
|
-
return;
|
|
108
|
-
await this.#request('/cgi-bin/message/recall', {
|
|
109
|
-
method: 'POST',
|
|
110
|
-
body: { msgid: messageId },
|
|
111
|
-
});
|
|
112
|
-
}
|
|
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;
|
|
138
|
-
}
|
|
139
|
-
if (media.kind === 'file') {
|
|
140
|
-
return { type: 'image', data: { media_id: media.value } };
|
|
141
|
-
}
|
|
142
|
-
try {
|
|
143
|
-
const mediaId = await this.#uploadMedia('image', media);
|
|
144
|
-
return { type: 'image', data: { media_id: mediaId } };
|
|
145
|
-
}
|
|
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),
|
|
151
|
-
}));
|
|
152
|
-
const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
|
|
153
|
-
return { type: 'text', data: { text: alt } };
|
|
154
|
-
}
|
|
155
|
-
}));
|
|
156
|
-
return items.filter((item) => item !== null);
|
|
157
|
-
}
|
|
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 ?? '?'})`);
|
|
169
|
-
}
|
|
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
|
-
});
|
|
206
|
-
}
|
|
207
|
-
async #getUserInfo(userId) {
|
|
208
|
-
try {
|
|
209
|
-
const data = await this.#request('/cgi-bin/user/get', {
|
|
210
|
-
params: { userid: userId },
|
|
211
|
-
});
|
|
212
|
-
if (data.errcode === 0)
|
|
213
|
-
return data;
|
|
214
|
-
throw new Error(`Failed to get user info: ${data.errmsg}`);
|
|
215
|
-
}
|
|
216
|
-
catch (error) {
|
|
217
|
-
this.#logger.error('Failed to get user info:', error);
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
async #getDepartmentUsers(deptId) {
|
|
222
|
-
try {
|
|
223
|
-
const data = await this.#request('/cgi-bin/user/simplelist', {
|
|
224
|
-
params: { department_id: deptId },
|
|
225
|
-
});
|
|
226
|
-
if (data.errcode === 0)
|
|
227
|
-
return data.userlist || [];
|
|
228
|
-
throw new Error(`Failed to get department users: ${data.errmsg}`);
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
this.#logger.error('Failed to get department users:', error);
|
|
232
|
-
return [];
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
async #getDepartmentList(deptId = 1) {
|
|
236
|
-
try {
|
|
237
|
-
const data = await this.#request('/cgi-bin/department/list', {
|
|
238
|
-
params: { id: deptId },
|
|
239
|
-
});
|
|
240
|
-
if (data.errcode === 0)
|
|
241
|
-
return data.department || [];
|
|
242
|
-
throw new Error(`Failed to get department list: ${data.errmsg}`);
|
|
243
|
-
}
|
|
244
|
-
catch (error) {
|
|
245
|
-
this.#logger.error('Failed to get department list:', error);
|
|
246
|
-
return [];
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
async #sendTextMessage(userId, content) {
|
|
250
|
-
try {
|
|
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,
|
|
257
|
-
},
|
|
258
|
-
payload: content,
|
|
259
|
-
});
|
|
260
|
-
return true;
|
|
261
|
-
}
|
|
262
|
-
catch (error) {
|
|
263
|
-
this.#logger.error('Failed to send text message:', error);
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
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})`);
|
|
1
|
+
import { createEndpointSpecification, endpointSpec } from 'zhin.js/adapter';
|
|
2
|
+
import { WecomClient } from './client.js';
|
|
3
|
+
/** Actual protocol Client; framework lifecycle and admission stay in their symbol namespace. */
|
|
4
|
+
export class WecomEndpoint extends WecomClient {
|
|
5
|
+
[endpointSpec] = this.#createSpecification();
|
|
6
|
+
constructor(options) { super(options); }
|
|
7
|
+
#createSpecification() {
|
|
8
|
+
const hooks = {
|
|
9
|
+
start: (signal) => this.#startFramework(signal),
|
|
10
|
+
open: () => this.openInput(), close: () => this.closeInput(), stop: () => this.disconnectTransport(),
|
|
11
|
+
send: (request) => this.sendPayload(request),
|
|
12
|
+
name: this.configName(),
|
|
13
|
+
control: this.endpointControl,
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(hooks, 'transportState', { get: () => this.transportState });
|
|
16
|
+
return createEndpointSpecification(this, hooks);
|
|
17
|
+
}
|
|
18
|
+
#startFramework(signal) { return this.connectTransport(signal); }
|
|
19
|
+
receiveInboundCallback(receive) {
|
|
20
|
+
return this[endpointSpec].events.run(receive);
|
|
21
|
+
}
|
|
22
|
+
publishEvent(name, payload) {
|
|
23
|
+
return this[endpointSpec].events.emit(name, payload);
|
|
24
|
+
}
|
|
25
|
+
publishAccepted(name, payload) {
|
|
26
|
+
return this[endpointSpec].events.emitAccepted(name, payload).then(() => undefined);
|
|
27
|
+
}
|
|
28
|
+
async publishPlatform(name, event) {
|
|
29
|
+
await super.publishPlatform(name, event);
|
|
30
|
+
return this[endpointSpec].events.platform(name, event);
|
|
316
31
|
}
|
|
317
32
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { buildSendRequestBody, decryptMessage, extractEncryptFromXml, formatInboundContent, formatOutboundBody, getAesKey, normalizeEchostrParam, normalizeWebhookPath, parseXmlMessage, queryParam, readTextBody, resolveChatType, resolveWecomConfig, verifySignature, type AccessToken, type ResolvedWecomConfig, type WecomEndpointConfig, type WecomApiResponse, type WecomMessage, type WecomSendBody, type WecomWireSegment, } from './protocol.js';
|
|
2
|
-
export {
|
|
2
|
+
export { type WecomClientEventMap } from './client.js';
|
|
3
3
|
export { checkWecomPlatformPermit, normalizeWecomSenderForPermit, platformPermit, wecomGroupPermitResolver, } from './platform-permit.js';
|
|
4
|
-
export {
|
|
4
|
+
export { WecomEndpoint, type WecomEndpointOptions, type WecomFetch, } from './endpoint.js';
|
|
5
5
|
export { buildMediaUploadForm, readOutboundImageMedia, resolveMediaBinary, type MediaBinary, type WecomMediaUploadResult, } from './media-upload.js';
|
|
6
6
|
export { registerWecomWebhookRoutes, handleWecomVerificationRequest, handleWecomWebhookRequest, type WecomWebhookHandler, } from './webhook.js';
|
|
7
|
+
export { WecomClient, type WecomClientApi } from './client.js';
|
|
8
|
+
export { WecomAdapter } from './adapter.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildSendRequestBody, decryptMessage, extractEncryptFromXml, formatInboundContent, formatOutboundBody, getAesKey, normalizeEchostrParam, normalizeWebhookPath, parseXmlMessage, queryParam, readTextBody, resolveChatType, resolveWecomConfig, verifySignature, } from './protocol.js';
|
|
2
|
-
export { wecomClient } from './client.js';
|
|
3
2
|
export { checkWecomPlatformPermit, normalizeWecomSenderForPermit, platformPermit, wecomGroupPermitResolver, } from './platform-permit.js';
|
|
4
|
-
export {
|
|
3
|
+
export { WecomEndpoint, } from './endpoint.js';
|
|
5
4
|
export { buildMediaUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
|
|
6
5
|
export { registerWecomWebhookRoutes, handleWecomVerificationRequest, handleWecomWebhookRequest, } from './webhook.js';
|
|
6
|
+
export { WecomClient } from './client.js';
|
|
7
|
+
export { WecomAdapter } from './adapter.js';
|
package/lib/media-upload.d.ts
CHANGED
|
@@ -12,7 +12,11 @@ export interface WecomMediaUploadResult {
|
|
|
12
12
|
readonly created_at?: string;
|
|
13
13
|
}
|
|
14
14
|
/** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
|
|
15
|
-
export
|
|
15
|
+
export interface MediaResolveOptions {
|
|
16
|
+
readonly download?: (url: string, signal?: AbortSignal) => Promise<Buffer>;
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolveMediaBinary(media: MediaRef, { download, signal }?: MediaResolveOptions): Promise<MediaBinary>;
|
|
16
20
|
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
17
21
|
export declare function buildMediaUploadForm(binary: MediaBinary): FormData;
|
|
18
22
|
/**
|
package/lib/media-upload.js
CHANGED
|
@@ -13,8 +13,8 @@ const MIME_EXT = {
|
|
|
13
13
|
'image/webp': 'webp',
|
|
14
14
|
'image/bmp': 'bmp',
|
|
15
15
|
};
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
export async function resolveMediaBinary(media, { download = defaultDownload, signal } = {}) {
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
18
|
const mimeType = media.mime_type ?? 'image/png';
|
|
19
19
|
const ext = MIME_EXT[mimeType] ?? 'png';
|
|
20
20
|
if (media.kind === 'base64') {
|
|
@@ -25,16 +25,16 @@ export async function resolveMediaBinary(media, download = defaultDownload) {
|
|
|
25
25
|
}
|
|
26
26
|
if (media.kind === 'path') {
|
|
27
27
|
const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
28
|
-
return { data: await readFile(path), mimeType, fileName: basename(path) };
|
|
28
|
+
return { data: await readFile(path, { signal }), mimeType, fileName: basename(path) };
|
|
29
29
|
}
|
|
30
30
|
if (media.kind === 'url') {
|
|
31
|
-
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
31
|
+
return { data: await download(media.value, signal), mimeType, fileName: `image.${ext}` };
|
|
32
32
|
}
|
|
33
33
|
// kind=file 为平台不透明引用(media_id),无二进制可解,调用方应直用 value
|
|
34
34
|
throw new Error(`cannot resolve binary from media kind: ${media.kind}`);
|
|
35
35
|
}
|
|
36
|
-
async function defaultDownload(url) {
|
|
37
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
36
|
+
async function defaultDownload(url, signal) {
|
|
37
|
+
const response = await fetch(url, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) });
|
|
38
38
|
if (!response.ok)
|
|
39
39
|
throw new Error(`download failed: HTTP ${response.status}`);
|
|
40
40
|
return Buffer.from(await response.arrayBuffer());
|
package/lib/protocol.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ConversationRef } from '@zhin.js/im-contract';
|
|
|
5
5
|
export interface WecomEndpointConfig {
|
|
6
6
|
readonly id: string;
|
|
7
7
|
readonly corpId: string;
|
|
8
|
+
readonly agentId: string;
|
|
8
9
|
readonly agentSecret: string;
|
|
9
10
|
readonly token: string;
|
|
10
11
|
readonly encodingAESKey: string;
|
|
@@ -15,6 +16,7 @@ export interface ResolvedWecomConfig {
|
|
|
15
16
|
readonly context: 'wecom';
|
|
16
17
|
readonly id: string;
|
|
17
18
|
readonly corpId: string;
|
|
19
|
+
readonly agentId: string;
|
|
18
20
|
readonly agentSecret: string;
|
|
19
21
|
readonly token: string;
|
|
20
22
|
readonly encodingAESKey: string;
|
|
@@ -22,6 +24,8 @@ export interface ResolvedWecomConfig {
|
|
|
22
24
|
readonly apiBaseUrl: string;
|
|
23
25
|
}
|
|
24
26
|
export interface WecomMessage {
|
|
27
|
+
/** Complete decrypted callback XML; preserved before field projection. */
|
|
28
|
+
readonly rawXml?: string;
|
|
25
29
|
readonly ToUserName: string;
|
|
26
30
|
readonly FromUserName: string;
|
|
27
31
|
readonly CreateTime: number;
|
package/lib/protocol.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createHash, createDecipheriv, timingSafeEqual } from 'node:crypto';
|
|
|
7
7
|
export function resolveWecomConfig(config) {
|
|
8
8
|
const id = requiredEndpointField(config.id, 'id');
|
|
9
9
|
const corpId = requiredEndpointField(config.corpId, 'corpId');
|
|
10
|
+
const agentId = requiredEndpointField(config.agentId, 'agentId');
|
|
10
11
|
const agentSecret = requiredEndpointField(config.agentSecret, 'agentSecret');
|
|
11
12
|
const token = requiredEndpointField(config.token, 'token');
|
|
12
13
|
const encodingAESKey = requiredEndpointField(config.encodingAESKey, 'encodingAESKey');
|
|
@@ -14,6 +15,7 @@ export function resolveWecomConfig(config) {
|
|
|
14
15
|
context: 'wecom',
|
|
15
16
|
id,
|
|
16
17
|
corpId,
|
|
18
|
+
agentId,
|
|
17
19
|
agentSecret,
|
|
18
20
|
token,
|
|
19
21
|
encodingAESKey,
|
|
@@ -100,6 +102,7 @@ export function parseXmlMessage(xml) {
|
|
|
100
102
|
if (!msgType)
|
|
101
103
|
return null;
|
|
102
104
|
const msg = {
|
|
105
|
+
rawXml: xml,
|
|
103
106
|
ToUserName: get('ToUserName') || '',
|
|
104
107
|
FromUserName: get('FromUserName') || '',
|
|
105
108
|
CreateTime: Number(get('CreateTime') || Date.now()),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-wecom",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.19",
|
|
4
4
|
"description": "Zhin.js WeCom (企业微信) adapter for Plugin Runtime (HTTP webhook)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,24 +33,27 @@
|
|
|
33
33
|
"directory": "plugins/adapters/wecom"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zhin.js/adapter": "1.1.
|
|
37
|
-
"@zhin.js/agent-feature": "1.1.
|
|
38
|
-
"@zhin.js/
|
|
39
|
-
"@zhin.js/
|
|
40
|
-
"@zhin.js/
|
|
41
|
-
"@zhin.js/
|
|
36
|
+
"@zhin.js/adapter": "1.1.26",
|
|
37
|
+
"@zhin.js/agent-feature": "1.1.11",
|
|
38
|
+
"@zhin.js/client": "1.1.22",
|
|
39
|
+
"@zhin.js/console-contract": "1.1.3",
|
|
40
|
+
"@zhin.js/core": "1.1.54",
|
|
41
|
+
"@zhin.js/feature-kit": "1.1.10",
|
|
42
|
+
"@zhin.js/host-http": "1.1.12",
|
|
43
|
+
"@zhin.js/im-contract": "1.1.9",
|
|
42
44
|
"@zhin.js/logger": "1.1.2",
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
+
"@zhin.js/page": "1.1.11",
|
|
46
|
+
"@zhin.js/skill": "1.1.11",
|
|
47
|
+
"@zhin.js/tool": "1.1.13"
|
|
45
48
|
},
|
|
46
49
|
"peerDependencies": {
|
|
47
50
|
"zod": "^4.0.0",
|
|
48
|
-
"@zhin.js/adapter": "^1.1.
|
|
49
|
-
"@zhin.js/command": "^1.1.
|
|
50
|
-
"@zhin.js/core": "^1.1.
|
|
51
|
-
"@zhin.js/host-http": "^1.1.
|
|
52
|
-
"@zhin.js/permission": "^1.1.
|
|
53
|
-
"zhin.js": "^1.1.
|
|
51
|
+
"@zhin.js/adapter": "^1.1.26",
|
|
52
|
+
"@zhin.js/command": "^1.1.12",
|
|
53
|
+
"@zhin.js/core": "^1.1.54",
|
|
54
|
+
"@zhin.js/host-http": "^1.1.12",
|
|
55
|
+
"@zhin.js/permission": "^1.1.10",
|
|
56
|
+
"zhin.js": "^1.1.19"
|
|
54
57
|
},
|
|
55
58
|
"peerDependenciesMeta": {
|
|
56
59
|
"@zhin.js/command": {
|
|
@@ -68,11 +71,12 @@
|
|
|
68
71
|
"typescript": "^6.0.3",
|
|
69
72
|
"vitest": "^4.1.10",
|
|
70
73
|
"zod": "^4.4.3",
|
|
71
|
-
"zhin.js": "1.1.
|
|
74
|
+
"zhin.js": "1.1.19"
|
|
72
75
|
},
|
|
73
76
|
"files": [
|
|
74
77
|
"adapters",
|
|
75
78
|
"commands",
|
|
79
|
+
"pages",
|
|
76
80
|
"plugin.js",
|
|
77
81
|
"schema.json",
|
|
78
82
|
"src",
|
|
@@ -99,7 +103,7 @@
|
|
|
99
103
|
"features": [
|
|
100
104
|
{
|
|
101
105
|
"package": "@zhin.js/adapter",
|
|
102
|
-
"api": "^
|
|
106
|
+
"api": "^2.0.0"
|
|
103
107
|
},
|
|
104
108
|
{
|
|
105
109
|
"package": "@zhin.js/command",
|
|
@@ -116,6 +120,10 @@
|
|
|
116
120
|
{
|
|
117
121
|
"package": "@zhin.js/agent-feature",
|
|
118
122
|
"api": "^1.0.0"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"package": "@zhin.js/page",
|
|
126
|
+
"api": "^1.0.0"
|
|
119
127
|
}
|
|
120
128
|
],
|
|
121
129
|
"plugins": []
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { definePage } from '@zhin.js/console-contract';
|
|
3
|
+
import { createAdapterConsolePage } from '@zhin.js/client';
|
|
4
|
+
export const meta = definePage({
|
|
5
|
+
title: '企业微信',
|
|
6
|
+
titleI18n: { en: 'WeCom' },
|
|
7
|
+
icon: 'Building',
|
|
8
|
+
order: 76,
|
|
9
|
+
requiredPermissions: ['wecom:read'],
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* Convention page: `pages/index/index.ts` → `/wecom`(插件路径)。
|
|
13
|
+
* 连接状态、连接测试、endpoint 增删;登录待办来自 LoginAssist(ICQQ 等)。
|
|
14
|
+
*/
|
|
15
|
+
export default createAdapterConsolePage('wecom');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { definePage } from '@zhin.js/console-contract';
|
|
2
|
+
import { createAdapterConsolePage } from '@zhin.js/client';
|
|
3
|
+
|
|
4
|
+
export const meta = definePage({
|
|
5
|
+
title: '企业微信',
|
|
6
|
+
titleI18n: { en: 'WeCom' },
|
|
7
|
+
icon: 'Building',
|
|
8
|
+
order: 76,
|
|
9
|
+
requiredPermissions: ['wecom:read'],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convention page: `pages/index/index.ts` → `/wecom`(插件路径)。
|
|
14
|
+
* 连接状态、连接测试、endpoint 增删;登录待办来自 LoginAssist(ICQQ 等)。
|
|
15
|
+
*/
|
|
16
|
+
export default createAdapterConsolePage('wecom');
|
package/plugin.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
-
import {
|
|
3
|
-
import { definePlugin } from 'zhin.js';
|
|
2
|
+
import { definePlugin, pluginConsoleRpcToken } from 'zhin.js';
|
|
4
3
|
import { permissionHostToken } from '@zhin.js/permission';
|
|
5
4
|
import { checkWecomPlatformPermit } from "./lib/platform-permit.js";
|
|
6
|
-
import {
|
|
5
|
+
import { createWecomConsoleRpc } from "./lib/console.js";
|
|
7
6
|
export default definePlugin({
|
|
8
7
|
name: 'wecom',
|
|
9
8
|
metadata: {
|
|
10
9
|
displayName: 'WeCom (企业微信) Adapter',
|
|
11
10
|
},
|
|
12
11
|
setup(context) {
|
|
13
|
-
context.resources.provide(
|
|
12
|
+
context.resources.provide(pluginConsoleRpcToken, createWecomConsoleRpc(context.resources, () => context.config.get()));
|
|
14
13
|
if (context.resources.has(permissionHostToken)) {
|
|
15
14
|
const host = context.resources.use(permissionHostToken);
|
|
16
15
|
return host.registerPlatform('wecom', checkWecomPlatformPermit);
|
package/schema.json
CHANGED
|
@@ -103,11 +103,22 @@
|
|
|
103
103
|
"id": {
|
|
104
104
|
"type": "string",
|
|
105
105
|
"description": "WeCom bot name"
|
|
106
|
+
},
|
|
107
|
+
"agentId": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"minLength": 1,
|
|
110
|
+
"description": "企业微信应用 AgentId(非应用 Secret)",
|
|
111
|
+
"x-i18n": {
|
|
112
|
+
"en": {
|
|
113
|
+
"description": "WeCom application AgentId (not the application secret)"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
106
116
|
}
|
|
107
117
|
},
|
|
108
118
|
"required": [
|
|
109
119
|
"id",
|
|
110
120
|
"corpId",
|
|
121
|
+
"agentId",
|
|
111
122
|
"agentSecret",
|
|
112
123
|
"token",
|
|
113
124
|
"encodingAESKey"
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { WecomClient, type WecomClientEventMap } from './client.js';
|
|
2
|
+
import { Adapter, type AdapterContext, type EndpointCreationContext } from 'zhin.js/adapter';
|
|
3
|
+
import { WecomEndpoint } from './endpoint.js';
|
|
4
|
+
import { resolveWecomConfig, type WecomEndpointConfig } from './protocol.js';
|
|
5
|
+
import { httpHostToken } from '@zhin.js/host-http';
|
|
6
|
+
|
|
7
|
+
export class WecomAdapter extends Adapter<WecomEndpoint, WecomEndpointConfig, WecomClientEventMap> {
|
|
8
|
+
readonly #context: AdapterContext<WecomEndpointConfig>;
|
|
9
|
+
constructor(context: AdapterContext<WecomEndpointConfig>) { super(); this.#context = context; }
|
|
10
|
+
override get configurationIdentityFields(): readonly string[] { return ['id', 'corpId', 'agentId']; }
|
|
11
|
+
override get configurationUpdateFields(): readonly string[] { return ['agentSecret', 'token', 'encodingAESKey', 'webhookPath', 'apiBaseUrl']; }
|
|
12
|
+
override get configurationIdentityProbeFields(): readonly string[] { return ['agentSecret', 'apiBaseUrl']; }
|
|
13
|
+
protected override async identifyConfiguration(input: WecomEndpointConfig, signal: AbortSignal, _changed: readonly string[]): Promise<string | undefined> {
|
|
14
|
+
const config = resolveWecomConfig(input);
|
|
15
|
+
const fetch = async (url: string, init?: Parameters<typeof globalThis.fetch>[1]) => { signal.throwIfAborted(); return globalThis.fetch(url, { ...init, signal }); };
|
|
16
|
+
const client = new WecomClient({ id: this.descriptor.id, config, fetch });
|
|
17
|
+
const response = await client.getApplicationInfo();
|
|
18
|
+
if (typeof response.agentid !== 'string' && typeof response.agentid !== 'number') throw new Error('WeCom identity verification returned no agentid');
|
|
19
|
+
return JSON.stringify(['wecom', new URL(config.apiBaseUrl).origin, config.corpId, String(response.agentid)]);
|
|
20
|
+
}
|
|
21
|
+
protected override validateConfiguration(config: WecomEndpointConfig): void { resolveWecomConfig(config); }
|
|
22
|
+
protected createEndpoint(input: WecomEndpointConfig, execution: EndpointCreationContext): WecomEndpoint {
|
|
23
|
+
const config = resolveWecomConfig(input);
|
|
24
|
+
return new WecomEndpoint({ id: execution.canonicalId, config, http: this.#context.use(httpHostToken) });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare module '@zhin.js/feature-kit' {
|
|
29
|
+
interface AdapterRegistry { readonly 'wecom': WecomAdapter; }
|
|
30
|
+
}
|