@zhin.js/adapter-wecom 1.1.18 → 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/lib/client.js CHANGED
@@ -1,2 +1,344 @@
1
- import { defineEndpointClient } from 'zhin.js/adapter';
2
- export const wecomClient = defineEndpointClient('wecom');
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * WecomClient — lifecycle, outbound send, inbound admit, OpenAPI helpers for agent tools.
4
+ */
5
+ import { createRecallEndpointControl, createEndpointOperation, } 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
+ /**
13
+ * 企业微信服务端 API 无 bot 群列表/群成员列表接口(客户群接口属「客户联系」
14
+ * 独立授权域,非 bot 社交面),好友/频道概念亦不存在;
15
+ * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
16
+ */
17
+ export class WecomClient extends EventEmitter {
18
+ #logger;
19
+ #options;
20
+ endpointControl = createRecallEndpointControl((id, signal) => this.recallMessage(id, { signal }));
21
+ #fetch;
22
+ #routeReleases = [];
23
+ #accessToken = { access_token: '', expires_in: 0, timestamp: 0 };
24
+ #refreshPromise = null;
25
+ #open = false;
26
+ #started = false;
27
+ configName() { return this.#options.config.id; }
28
+ get transportState() { return undefined; }
29
+ executionSignal;
30
+ setExecutionSignal(signal) { this.executionSignal = signal; }
31
+ /** Native protocol work runs here; Endpoint attaches the framework receipt without replacing SDK APIs. */
32
+ receiveInboundCallback(receive) {
33
+ try {
34
+ return Promise.resolve(receive());
35
+ }
36
+ catch (error) {
37
+ return Promise.reject(error);
38
+ }
39
+ }
40
+ publishEvent(name, payload) { this.emit(name, payload); return Promise.resolve(); }
41
+ publishAccepted(name, payload) { return this.publishEvent(name, payload).then(() => undefined); }
42
+ publishPlatform(name, event) { this.emit(name, event); return Promise.resolve(); }
43
+ constructor(options) {
44
+ super();
45
+ this.#logger = getAdapterLogger('wecom', options.config.id);
46
+ this.#options = options;
47
+ const transportFetch = options.fetch ?? globalThis.fetch;
48
+ this.#fetch = (url, init) => {
49
+ this.executionSignal?.throwIfAborted();
50
+ return transportFetch(url, { ...init, ...(this.executionSignal ? { signal: init?.signal ? AbortSignal.any([this.executionSignal, init.signal]) : this.executionSignal } : {}) });
51
+ };
52
+ }
53
+ /** Used by webhook handler. */
54
+ get isOpen() {
55
+ return this.#open;
56
+ }
57
+ get config() {
58
+ return this.#options.config;
59
+ }
60
+ async connectTransport(signal) {
61
+ if (signal) {
62
+ this.setExecutionSignal(signal);
63
+ signal.throwIfAborted();
64
+ }
65
+ if (this.#started)
66
+ return;
67
+ this.#started = true;
68
+ try {
69
+ await this.#refreshAccessToken();
70
+ this.executionSignal?.throwIfAborted();
71
+ if (!this.#options.http)
72
+ throw new Error('WeCom webhook requires HttpHost');
73
+ this.#routeReleases.push(...registerWecomWebhookRoutes(this.#options.http, this));
74
+ this.#logger.debug(formatCompact({
75
+ endpoint: this.#options.config.id,
76
+ op: 'webhook',
77
+ path: this.#options.config.webhookPath,
78
+ }));
79
+ }
80
+ catch (error) {
81
+ await this.disconnectTransport();
82
+ this.#logger.error('Failed to connect WeCom endpoint:', error);
83
+ throw error;
84
+ }
85
+ }
86
+ openInput() {
87
+ this.#open = true;
88
+ }
89
+ closeInput() {
90
+ this.#open = false;
91
+ }
92
+ async disconnectTransport() {
93
+ this.#open = false;
94
+ for (const release of this.#routeReleases.splice(0))
95
+ release();
96
+ this.#started = false;
97
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
98
+ }
99
+ async sendPayload({ conversation, payload, locale, signal }) {
100
+ const materialized = await createEndpointOperation({ signal }).prepare(() => this.#materializeOutboundMedia(payload, { signal }));
101
+ const content = formatOutboundBody(materialized, locale);
102
+ const body = buildSendRequestBody(conversation.id, content, this.#options.config.agentId);
103
+ const data = await this.#request('/cgi-bin/message/send', {
104
+ method: 'POST',
105
+ body,
106
+ signal,
107
+ });
108
+ if (data.errcode !== 0) {
109
+ throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
110
+ }
111
+ this.#logger.debug(formatCompact({ op: 'send', to: `${conversation.kind}:${conversation.id}`,
112
+ }));
113
+ return data.msgid || `${Date.now()}`;
114
+ }
115
+ async recallMessage(messageId, { signal } = {}) {
116
+ if (!messageId)
117
+ return;
118
+ await this.#request('/cgi-bin/message/recall', {
119
+ method: 'POST',
120
+ body: { msgid: messageId },
121
+ signal,
122
+ });
123
+ }
124
+ /**
125
+ * message/send 的 image 段只接受 media_id:canonical MediaRef 按 kind 投递——
126
+ * file(平台不透明引用)直用为 media_id;base64/本地路径/URL 先经
127
+ * /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
128
+ * 无 canonical MediaRef 的媒体段 warn + 丢弃。
129
+ */
130
+ async #materializeOutboundMedia(payload, request) {
131
+ if (!Array.isArray(payload))
132
+ return payload;
133
+ const items = await Promise.all(payload.map(async (item) => {
134
+ if (typeof item === 'string' || !item || typeof item !== 'object')
135
+ return item;
136
+ const seg = item;
137
+ if (seg.type !== 'image')
138
+ return item;
139
+ const data = seg.data ?? {};
140
+ const media = readOutboundImageMedia(data);
141
+ if (!media) {
142
+ this.#logger.warn(formatCompact({
143
+ op: 'wecom_outbound_media_dropped',
144
+ endpoint: this.#options.config.id,
145
+ type: 'image',
146
+ reason: 'missing_media_ref',
147
+ }));
148
+ return null;
149
+ }
150
+ if (media.kind === 'file') {
151
+ return { type: 'image', data: { media_id: media.value } };
152
+ }
153
+ try {
154
+ const mediaId = await this.#uploadMedia({ media, signal: request.signal });
155
+ return { type: 'image', data: { media_id: mediaId } };
156
+ }
157
+ catch (error) {
158
+ if (request.signal?.aborted)
159
+ throw error;
160
+ this.#logger.warn(formatCompact({
161
+ op: 'wecom_media_upload_failed',
162
+ endpoint: this.#options.config.id,
163
+ error: error instanceof Error ? error.message : String(error),
164
+ }));
165
+ const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
166
+ return { type: 'text', data: { text: alt } };
167
+ }
168
+ }));
169
+ return items.filter((item) => item !== null);
170
+ }
171
+ /** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
172
+ async #uploadMedia({ media, signal }) {
173
+ await createEndpointOperation({ signal }).prepare(() => this.#ensureAccessToken());
174
+ const binary = await createEndpointOperation({ signal }).prepare(() => resolveMediaBinary(media, { signal }));
175
+ const form = buildMediaUploadForm(binary);
176
+ const url = `${this.#options.config.apiBaseUrl}/cgi-bin/media/upload?access_token=${this.#accessToken.access_token}&type=image`;
177
+ const response = await this.#fetch(url, { method: 'POST', body: form, signal });
178
+ const data = await response.json();
179
+ if (data.errcode === 0 && data.media_id)
180
+ return data.media_id;
181
+ throw new Error(`WeCom media upload failed: ${data.errmsg ?? 'unknown'} (${data.errcode ?? '?'})`);
182
+ }
183
+ /** Test / internal: admit a parsed message when open (non-webhook path). */
184
+ admit(msg) {
185
+ if (!this.#open)
186
+ return;
187
+ void this.receiveInboundCallback(() => this.#admitMessage(msg)).catch(error => this.#logger.warn(formatCompact({ op: 'wecom_gateway_receive_failed', error: error instanceof Error ? error.message : String(error) })));
188
+ }
189
+ #admitMessage(msg) {
190
+ if (!this.#open)
191
+ return;
192
+ void this.publishPlatform(msg.Event || msg.MsgType || 'event', msg.rawXml ?? msg).catch((error) => {
193
+ this.#logger.warn(formatCompact({
194
+ op: 'wecom_platform_event_failed',
195
+ event: msg.Event || msg.MsgType,
196
+ error: error instanceof Error ? error.message : String(error),
197
+ }));
198
+ });
199
+ if (receiveWecomSideEvent((name, payload) => this.publishEvent(name, payload), String(this.#options.id), this.#options.config.id, msg, this.#logger)) {
200
+ return;
201
+ }
202
+ const chatType = resolveChatType(msg.FromUserName);
203
+ const conversation = wecomInboundConversation(String(this.#options.id), msg);
204
+ void this.publishEvent('message.receive', {
205
+ event: msg.rawXml ?? msg,
206
+ conversation,
207
+ message: { conversation, id: msg.MsgId || `${msg.CreateTime}` },
208
+ content: formatInboundContent(msg),
209
+ sender: { id: msg.FromUserName },
210
+ endpointId: this.#options.config.id,
211
+ metadata: Object.freeze({
212
+ msgType: msg.MsgType,
213
+ event: msg.Event,
214
+ chatType,
215
+ toUserName: msg.ToUserName,
216
+ agentId: msg.AgentID,
217
+ }),
218
+ }).catch((err) => {
219
+ this.#logger.warn(formatCompact({
220
+ op: 'wecom_gateway_receive_failed',
221
+ target: `${conversation.kind}:${conversation.id}`,
222
+ error: err instanceof Error ? err.message : String(err),
223
+ }));
224
+ });
225
+ }
226
+ async getApplicationInfo() {
227
+ const response = await this.#request('/cgi-bin/agent/get', { params: { agentid: this.config.agentId } });
228
+ if (response.errcode !== 0)
229
+ throw new Error('WeCom application identity verification failed');
230
+ return response;
231
+ }
232
+ async getUserInfo(userId) {
233
+ try {
234
+ const data = await this.#request('/cgi-bin/user/get', {
235
+ params: { userid: userId },
236
+ });
237
+ if (data.errcode === 0)
238
+ return data;
239
+ throw new Error(`Failed to get user info: ${data.errmsg}`);
240
+ }
241
+ catch (error) {
242
+ this.#logger.error('Failed to get user info:', error);
243
+ return null;
244
+ }
245
+ }
246
+ async getDepartmentUsers(deptId) {
247
+ try {
248
+ const data = await this.#request('/cgi-bin/user/simplelist', {
249
+ params: { department_id: deptId },
250
+ });
251
+ if (data.errcode === 0)
252
+ return data.userlist || [];
253
+ throw new Error(`Failed to get department users: ${data.errmsg}`);
254
+ }
255
+ catch (error) {
256
+ this.#logger.error('Failed to get department users:', error);
257
+ return [];
258
+ }
259
+ }
260
+ async getDepartmentList(deptId = 1) {
261
+ try {
262
+ const data = await this.#request('/cgi-bin/department/list', {
263
+ params: { id: deptId },
264
+ });
265
+ if (data.errcode === 0)
266
+ return data.department || [];
267
+ throw new Error(`Failed to get department list: ${data.errmsg}`);
268
+ }
269
+ catch (error) {
270
+ this.#logger.error('Failed to get department list:', error);
271
+ return [];
272
+ }
273
+ }
274
+ async sendTextMessage(userId, content) {
275
+ try {
276
+ const endpointKey = String(this.#options.id);
277
+ await this.sendPayload({
278
+ conversation: {
279
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
280
+ kind: resolveChatType(userId),
281
+ id: userId,
282
+ },
283
+ payload: content,
284
+ });
285
+ return true;
286
+ }
287
+ catch (error) {
288
+ this.#logger.error('Failed to send text message:', error);
289
+ return false;
290
+ }
291
+ }
292
+ async #request(path, options = {}) {
293
+ await createEndpointOperation(options).prepare(() => this.#ensureAccessToken());
294
+ options.signal?.throwIfAborted();
295
+ const { method = 'GET', params = {}, body } = options;
296
+ const urlParams = new URLSearchParams({
297
+ ...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
298
+ access_token: this.#accessToken.access_token,
299
+ });
300
+ const url = `${this.#options.config.apiBaseUrl}${path}?${urlParams.toString()}`;
301
+ const response = await this.#fetch(url, {
302
+ method,
303
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
304
+ body: body && method === 'POST' ? JSON.stringify(body) : undefined,
305
+ signal: options.signal,
306
+ });
307
+ if (!response.ok) {
308
+ const text = await response.text().catch(() => '');
309
+ throw new Error(`WeCom API error ${response.status}: ${text}`);
310
+ }
311
+ return await response.json();
312
+ }
313
+ async #ensureAccessToken() {
314
+ const now = Date.now();
315
+ if (this.#accessToken.access_token
316
+ && now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000) {
317
+ return;
318
+ }
319
+ if (this.#refreshPromise) {
320
+ await this.#refreshPromise;
321
+ return;
322
+ }
323
+ this.#refreshPromise = this.#refreshAccessToken()
324
+ .then(() => this.#accessToken.access_token)
325
+ .finally(() => { this.#refreshPromise = null; });
326
+ await this.#refreshPromise;
327
+ }
328
+ async #refreshAccessToken() {
329
+ const { corpId, agentSecret, apiBaseUrl } = this.#options.config;
330
+ const url = `${apiBaseUrl}/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${agentSecret}`;
331
+ const response = await this.#fetch(url);
332
+ const data = await response.json();
333
+ if (data.errcode === 0 && data.access_token) {
334
+ this.#accessToken = {
335
+ access_token: data.access_token,
336
+ expires_in: data.expires_in ?? 7200,
337
+ timestamp: Date.now(),
338
+ };
339
+ this.#logger.debug('Access token refreshed successfully');
340
+ return;
341
+ }
342
+ throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
343
+ }
344
+ }
@@ -3,6 +3,7 @@ export const wecomConsoleSpec = {
3
3
  adapterDisplayName: '企业微信',
4
4
  fields: [
5
5
  { key: 'corpId', required: true, env: true, description: '企业 corpId', descriptionI18n: { en: 'WeCom corporation ID' } },
6
+ { key: 'agentId', required: true, description: '应用 AgentId', descriptionI18n: { en: 'WeCom application AgentId' } },
6
7
  { key: 'agentSecret', required: true, env: true, description: '应用 secret', descriptionI18n: { en: 'Application secret' } },
7
8
  { key: 'token', required: true, env: true, description: '回调 token', descriptionI18n: { en: 'Callback token' } },
8
9
  { key: 'encodingAESKey', required: true, env: true, description: '回调加解密 key', descriptionI18n: { en: 'Callback encryption key' } },
package/lib/console.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  */
6
6
  import { type AdapterConsoleResources } from 'zhin.js/adapter';
7
7
  import type { PluginConsoleRpc } from 'zhin.js';
8
- export declare function createWecomConsoleRpc(resources: AdapterConsoleResources, getConfig: () => unknown): PluginConsoleRpc;
8
+ export declare function createWecomConsoleRpc(_resources: AdapterConsoleResources, getConfig: () => unknown): PluginConsoleRpc;
package/lib/console.js CHANGED
@@ -4,13 +4,10 @@
4
4
  * 页面绑定 `pages/index`,读取 / 写入分别要求 `<adapter>:read` / `<adapter>:write`。
5
5
  */
6
6
  import { createAdapterConsoleRpc } from 'zhin.js/adapter';
7
- import { wecomRuntimeStateToken } from './wecom-runtime-state.js';
8
7
  import { wecomConsoleSpec } from './console-spec.js';
9
- export function createWecomConsoleRpc(resources, getConfig) {
10
- const state = resources.use(wecomRuntimeStateToken);
8
+ export function createWecomConsoleRpc(_resources, getConfig) {
11
9
  return createAdapterConsoleRpc({
12
10
  ...wecomConsoleSpec,
13
- running: () => state.endpoints.values(),
14
11
  getConfig,
15
- }, resources);
12
+ });
16
13
  }
package/lib/endpoint.d.ts CHANGED
@@ -1,61 +1,14 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
- /**
3
- * WecomEndpoint — lifecycle, outbound send, inbound admit, OpenAPI helpers for agent tools.
4
- */
5
- import { type EndpointControl, type EndpointSendRequest } from 'zhin.js/adapter';
6
- import type { HttpHost } from '@zhin.js/host-http';
7
- import type { CapabilityId } from 'zhin.js';
8
- import { type ResolvedWecomConfig, type WecomMessage } from './protocol.js';
9
- export type WecomFetch = (url: string, init?: {
10
- readonly method?: string;
11
- readonly headers?: Record<string, string>;
12
- readonly body?: string | FormData;
13
- }) => Promise<{
14
- readonly ok: boolean;
15
- readonly status: number;
16
- text(): Promise<string>;
17
- json(): Promise<unknown>;
18
- }>;
19
- export interface WecomEndpointOptions {
20
- readonly id: CapabilityId;
21
- readonly http: HttpHost;
22
- readonly config: ResolvedWecomConfig;
23
- readonly fetch?: WecomFetch;
24
- }
25
- export interface WecomClientApi {
26
- getUserInfo(userId: string): Promise<unknown>;
27
- getDepartmentUsers(deptId: number): Promise<unknown[]>;
28
- getDepartmentList(deptId?: number): Promise<unknown[]>;
29
- sendTextMessage(userId: string, content: string): Promise<boolean>;
30
- }
31
- /** WeCom API surface exposed to plugin events without Endpoint lifecycle methods. */
32
- export declare class WecomClient implements WecomClientApi {
33
- readonly api: WecomClientApi;
34
- constructor(api: WecomClientApi);
35
- getUserInfo: (userId: string) => Promise<unknown>;
36
- getDepartmentUsers: (deptId: number) => Promise<unknown[]>;
37
- getDepartmentList: (deptId?: number) => Promise<unknown[]>;
38
- sendTextMessage: (userId: string, content: string) => Promise<boolean>;
39
- }
40
- /**
41
- * 企业微信服务端 API 无 bot 群列表/群成员列表接口(客户群接口属「客户联系」
42
- * 独立授权域,非 bot 社交面),好友/频道概念亦不存在;
43
- * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
44
- */
45
- export declare class WecomEndpoint extends Endpoint<WecomClient> {
1
+ import { endpointSpec, type Endpoint } from 'zhin.js/adapter';
2
+ import { WecomClient, type WecomEndpointOptions } from './client.js';
3
+ export type { WecomEndpointOptions } from './client.js';
4
+ export type { WecomFetch } from './client.js';
5
+ /** Actual protocol Client; framework lifecycle and admission stay in their symbol namespace. */
6
+ export declare class WecomEndpoint extends WecomClient implements Endpoint {
46
7
  #private;
47
- readonly client: WecomClient;
48
- readonly control: EndpointControl;
8
+ readonly [endpointSpec]: import("@zhin.js/adapter").EndpointSpecification;
49
9
  constructor(options: WecomEndpointOptions);
50
- /** Used by webhook handler. */
51
- get isOpen(): boolean;
52
- get config(): ResolvedWecomConfig;
53
- start(): Promise<void>;
54
- open(): void;
55
- close(): void;
56
- stop(): Promise<void>;
57
- send({ conversation, payload, locale }: EndpointSendRequest): Promise<string>;
58
- recallMessage(messageId: string): Promise<void>;
59
- /** Test / internal: admit a parsed message when open (non-webhook path). */
60
- admit(msg: WecomMessage): void;
10
+ protected receiveInboundCallback<T>(receive: () => T | Promise<T>): Promise<T>;
11
+ protected publishEvent(name: string, payload: unknown): Promise<unknown>;
12
+ protected publishAccepted(name: string, payload: unknown): Promise<void>;
13
+ protected publishPlatform(name: string, event: unknown): Promise<unknown>;
61
14
  }