@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/src/endpoint.ts
CHANGED
|
@@ -1,636 +1,388 @@
|
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
import {
|
|
6
|
+
createRecallEndpointControl,
|
|
7
|
+
type EndpointControl,
|
|
8
|
+
type EndpointSendRequest,
|
|
9
|
+
} from 'zhin.js/adapter';
|
|
10
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
11
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
12
|
+
import type { CapabilityId } from 'zhin.js';
|
|
13
|
+
import {
|
|
14
|
+
buildMediaUploadForm,
|
|
15
|
+
readOutboundImageMedia,
|
|
16
|
+
resolveMediaBinary,
|
|
17
|
+
type WecomMediaUploadResult,
|
|
18
|
+
} from './media-upload.js';
|
|
19
|
+
import {
|
|
20
|
+
buildSendRequestBody,
|
|
21
|
+
formatInboundContent,
|
|
22
|
+
formatOutboundBody,
|
|
23
|
+
resolveChatType,
|
|
24
|
+
wecomInboundConversation,
|
|
25
|
+
type AccessToken,
|
|
26
|
+
type ResolvedWecomConfig,
|
|
27
|
+
type WecomApiResponse,
|
|
28
|
+
type WecomMessage,
|
|
29
|
+
} from './protocol.js';
|
|
30
|
+
import { registerWecomWebhookRoutes } from './webhook.js';
|
|
31
|
+
import { receiveWecomSideEvent } from './side-event-dispatch.js';
|
|
32
|
+
|
|
33
|
+
export type WecomFetch = (
|
|
34
|
+
url: string,
|
|
35
|
+
init?: {
|
|
36
|
+
readonly method?: string;
|
|
37
|
+
readonly headers?: Record<string, string>;
|
|
38
|
+
readonly body?: string | FormData;
|
|
39
|
+
},
|
|
40
|
+
) => Promise<{
|
|
41
|
+
readonly ok: boolean;
|
|
42
|
+
readonly status: number;
|
|
43
|
+
text(): Promise<string>;
|
|
44
|
+
json(): Promise<unknown>;
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
export interface WecomEndpointOptions {
|
|
48
|
+
readonly id: CapabilityId;
|
|
49
|
+
readonly http: HttpHost;
|
|
50
|
+
readonly config: ResolvedWecomConfig;
|
|
51
|
+
readonly fetch?: WecomFetch;
|
|
52
|
+
}
|
|
15
53
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
private corpId: string;
|
|
23
|
-
#refreshPromise: Promise<string> | null = null;
|
|
54
|
+
export interface WecomClientApi {
|
|
55
|
+
getUserInfo(userId: string): Promise<unknown>;
|
|
56
|
+
getDepartmentUsers(deptId: number): Promise<unknown[]>;
|
|
57
|
+
getDepartmentList(deptId?: number): Promise<unknown[]>;
|
|
58
|
+
sendTextMessage(userId: string, content: string): Promise<boolean>;
|
|
59
|
+
}
|
|
24
60
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
61
|
+
/** WeCom API surface exposed to plugin events without Endpoint lifecycle methods. */
|
|
62
|
+
export class WecomClient implements WecomClientApi {
|
|
63
|
+
constructor(readonly api: WecomClientApi) {}
|
|
64
|
+
getUserInfo = (userId: string) => this.api.getUserInfo(userId);
|
|
65
|
+
getDepartmentUsers = (deptId: number) => this.api.getDepartmentUsers(deptId);
|
|
66
|
+
getDepartmentList = (deptId?: number) => this.api.getDepartmentList(deptId);
|
|
67
|
+
sendTextMessage = (userId: string, content: string) => this.api.sendTextMessage(userId, content);
|
|
68
|
+
}
|
|
28
69
|
|
|
29
|
-
|
|
30
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 企业微信服务端 API 无 bot 群列表/群成员列表接口(客户群接口属「客户联系」
|
|
72
|
+
* 独立授权域,非 bot 社交面),好友/频道概念亦不存在;
|
|
73
|
+
* 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
|
|
74
|
+
*/
|
|
75
|
+
export class WecomEndpoint extends Endpoint<WecomClient> {
|
|
76
|
+
readonly client = new WecomClient({
|
|
77
|
+
getUserInfo: (userId) => this.#getUserInfo(userId),
|
|
78
|
+
getDepartmentUsers: (deptId) => this.#getDepartmentUsers(deptId),
|
|
79
|
+
getDepartmentList: (deptId) => this.#getDepartmentList(deptId),
|
|
80
|
+
sendTextMessage: (userId, content) => this.#sendTextMessage(userId, content),
|
|
81
|
+
});
|
|
82
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
83
|
+
|
|
84
|
+
readonly #options: WecomEndpointOptions;
|
|
85
|
+
readonly control: EndpointControl = createRecallEndpointControl((id) => this.recallMessage(id));
|
|
86
|
+
readonly #fetch: WecomFetch;
|
|
87
|
+
#routeReleases: HttpRouteRegistration[] = [];
|
|
88
|
+
#accessToken: AccessToken = { access_token: '', expires_in: 0, timestamp: 0 };
|
|
89
|
+
#refreshPromise: Promise<string> | null = null;
|
|
90
|
+
#open = false;
|
|
91
|
+
#started = false;
|
|
92
|
+
|
|
93
|
+
constructor(options: WecomEndpointOptions) {
|
|
94
|
+
super();
|
|
95
|
+
this.#logger = getAdapterLogger('wecom', options.config.id);
|
|
96
|
+
this.#options = options;
|
|
97
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
31
98
|
}
|
|
32
99
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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();
|
|
100
|
+
/** Used by webhook handler. */
|
|
101
|
+
get isOpen(): boolean {
|
|
102
|
+
return this.#open;
|
|
48
103
|
}
|
|
49
104
|
|
|
50
|
-
|
|
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();
|
|
105
|
+
get config(): ResolvedWecomConfig {
|
|
106
|
+
return this.#options.config;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 {
|
|
109
|
+
async start(): Promise<void> {
|
|
110
|
+
if (this.#started) return;
|
|
111
|
+
this.#started = true;
|
|
97
112
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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;
|
|
113
|
+
await this.#refreshAccessToken();
|
|
114
|
+
this.#routeReleases.push(...registerWecomWebhookRoutes(this.#options.http, this));
|
|
115
|
+
this.#logger.debug(formatCompact({
|
|
116
|
+
endpoint: this.#options.config.id,
|
|
117
|
+
op: 'webhook',
|
|
118
|
+
path: this.#options.config.webhookPath,
|
|
119
|
+
}));
|
|
116
120
|
} catch (error) {
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
await this.stop();
|
|
122
|
+
this.#logger.error('Failed to connect WeCom endpoint:', error);
|
|
123
|
+
throw error;
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
|
|
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
|
-
}
|
|
127
|
+
open(): void {
|
|
128
|
+
this.#open = true;
|
|
169
129
|
}
|
|
170
130
|
|
|
171
|
-
|
|
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
|
-
}
|
|
131
|
+
close(): void {
|
|
132
|
+
this.#open = false;
|
|
183
133
|
}
|
|
184
134
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
|
135
|
+
async stop(): Promise<void> {
|
|
136
|
+
this.#open = false;
|
|
137
|
+
for (const release of this.#routeReleases.splice(0)) release();
|
|
138
|
+
this.#started = false;
|
|
139
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
204
140
|
}
|
|
205
141
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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;
|
|
142
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
143
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
144
|
+
const content = formatOutboundBody(materialized);
|
|
145
|
+
const body = buildSendRequestBody(
|
|
146
|
+
conversation.id,
|
|
147
|
+
content,
|
|
148
|
+
// Legacy field: historically written into agentid (preserved for cutover).
|
|
149
|
+
this.#options.config.agentSecret,
|
|
150
|
+
);
|
|
151
|
+
const data = await this.#request('/cgi-bin/message/send', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
body,
|
|
154
|
+
});
|
|
155
|
+
if (data.errcode !== 0) {
|
|
156
|
+
throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
|
|
266
157
|
}
|
|
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,
|
|
158
|
+
this.#logger.debug(formatCompact({ op: 'send', to: `${conversation.kind}:${conversation.id}`,
|
|
280
159
|
}));
|
|
160
|
+
return (data.msgid as string) || `${Date.now()}`;
|
|
281
161
|
}
|
|
282
162
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
},
|
|
163
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
164
|
+
if (!messageId) return;
|
|
165
|
+
await this.#request('/cgi-bin/message/recall', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: { msgid: messageId },
|
|
363
168
|
});
|
|
364
169
|
}
|
|
365
170
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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;
|
|
171
|
+
/**
|
|
172
|
+
* message/send 的 image 段只接受 media_id:canonical MediaRef 按 kind 投递——
|
|
173
|
+
* file(平台不透明引用)直用为 media_id;base64/本地路径/URL 先经
|
|
174
|
+
* /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
|
|
175
|
+
* 无 canonical MediaRef 的媒体段 warn + 丢弃。
|
|
176
|
+
*/
|
|
177
|
+
async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
|
|
178
|
+
if (!Array.isArray(payload)) return payload;
|
|
179
|
+
const items = await Promise.all(payload.map(async (item) => {
|
|
180
|
+
if (typeof item === 'string' || !item || typeof item !== 'object') return item;
|
|
181
|
+
const seg = item as { type?: unknown; data?: Record<string, unknown> };
|
|
182
|
+
if (seg.type !== 'image') return item;
|
|
183
|
+
const data = seg.data ?? {};
|
|
184
|
+
const media = readOutboundImageMedia(data);
|
|
185
|
+
if (!media) {
|
|
186
|
+
this.#logger.warn(formatCompact({
|
|
187
|
+
op: 'wecom_outbound_media_dropped',
|
|
188
|
+
endpoint: this.#options.config.id,
|
|
189
|
+
type: 'image',
|
|
190
|
+
reason: 'missing_media_ref',
|
|
191
|
+
}));
|
|
192
|
+
return null;
|
|
450
193
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
method: 'POST',
|
|
454
|
-
body,
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
if (data.errcode !== 0) {
|
|
458
|
-
throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
|
|
194
|
+
if (media.kind === 'file') {
|
|
195
|
+
return { type: 'image', data: { media_id: media.value } };
|
|
459
196
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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',
|
|
197
|
+
try {
|
|
198
|
+
const mediaId = await this.#uploadMedia('image', media);
|
|
199
|
+
return { type: 'image', data: { media_id: mediaId } };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.#logger.warn(formatCompact({
|
|
202
|
+
op: 'wecom_media_upload_failed',
|
|
203
|
+
endpoint: this.#options.config.id,
|
|
204
|
+
error: error instanceof Error ? error.message : String(error),
|
|
544
205
|
}));
|
|
206
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
|
|
207
|
+
return { type: 'text', data: { text: alt } };
|
|
545
208
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
return { msgtype: mediaType, data: mediaData };
|
|
549
|
-
}
|
|
550
|
-
return { msgtype: 'text', data: { content: textParts.join('') } };
|
|
551
|
-
}
|
|
552
|
-
return { msgtype: 'text', data: { content: String(content) } };
|
|
209
|
+
}));
|
|
210
|
+
return items.filter((item) => item !== null);
|
|
553
211
|
}
|
|
554
212
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
throw error;
|
|
566
|
-
}
|
|
213
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
214
|
+
async #uploadMedia(type: 'image', media: Parameters<typeof resolveMediaBinary>[0]): Promise<string> {
|
|
215
|
+
await this.#ensureAccessToken();
|
|
216
|
+
const binary = await resolveMediaBinary(media);
|
|
217
|
+
const form = buildMediaUploadForm(binary);
|
|
218
|
+
const url = `${this.#options.config.apiBaseUrl}/cgi-bin/media/upload?access_token=${this.#accessToken.access_token}&type=${type}`;
|
|
219
|
+
const response = await this.#fetch(url, { method: 'POST', body: form });
|
|
220
|
+
const data = await response.json() as WecomMediaUploadResult;
|
|
221
|
+
if (data.errcode === 0 && data.media_id) return data.media_id;
|
|
222
|
+
throw new Error(`WeCom media upload failed: ${data.errmsg ?? 'unknown'} (${data.errcode ?? '?'})`);
|
|
567
223
|
}
|
|
568
224
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
225
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
226
|
+
admit(msg: WecomMessage): void {
|
|
227
|
+
if (!this.#open) return;
|
|
228
|
+
void this.emitPlatform(msg.Event || msg.MsgType || 'event', msg).catch((error) => {
|
|
229
|
+
this.#logger.warn(formatCompact({
|
|
230
|
+
op: 'wecom_platform_event_failed',
|
|
231
|
+
event: msg.Event || msg.MsgType,
|
|
232
|
+
error: error instanceof Error ? error.message : String(error),
|
|
233
|
+
}));
|
|
234
|
+
});
|
|
235
|
+
if (receiveWecomSideEvent(
|
|
236
|
+
(name, payload) => this.emit(name, payload),
|
|
237
|
+
String(this.#options.id),
|
|
238
|
+
this.#options.config.id,
|
|
239
|
+
msg,
|
|
240
|
+
this.#logger,
|
|
241
|
+
)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const chatType = resolveChatType(msg.FromUserName);
|
|
245
|
+
const conversation = wecomInboundConversation(String(this.#options.id), msg);
|
|
246
|
+
void this.emit('message.receive', {
|
|
247
|
+
conversation,
|
|
248
|
+
message: { conversation, id: msg.MsgId || `${msg.CreateTime}` },
|
|
249
|
+
content: formatInboundContent(msg),
|
|
250
|
+
sender: { id: msg.FromUserName },
|
|
251
|
+
endpointId: this.#options.config.id,
|
|
252
|
+
metadata: Object.freeze({
|
|
253
|
+
msgType: msg.MsgType,
|
|
254
|
+
event: msg.Event,
|
|
255
|
+
chatType,
|
|
256
|
+
toUserName: msg.ToUserName,
|
|
257
|
+
agentId: msg.AgentID,
|
|
258
|
+
}),
|
|
259
|
+
}).catch((err) => {
|
|
260
|
+
this.#logger.warn(formatCompact({
|
|
261
|
+
op: 'wecom_gateway_receive_failed',
|
|
262
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
263
|
+
error: err instanceof Error ? err.message : String(err),
|
|
264
|
+
}));
|
|
265
|
+
});
|
|
572
266
|
}
|
|
573
267
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
async getUserInfo(userId: string): Promise<WecomApiResponse | null> {
|
|
268
|
+
async #getUserInfo(userId: string): Promise<WecomApiResponse | null> {
|
|
577
269
|
try {
|
|
578
|
-
const data = await this
|
|
270
|
+
const data = await this.#request('/cgi-bin/user/get', {
|
|
579
271
|
params: { userid: userId },
|
|
580
272
|
});
|
|
581
273
|
if (data.errcode === 0) return data;
|
|
582
274
|
throw new Error(`Failed to get user info: ${data.errmsg}`);
|
|
583
275
|
} catch (error) {
|
|
584
|
-
this
|
|
276
|
+
this.#logger.error('Failed to get user info:', error);
|
|
585
277
|
return null;
|
|
586
278
|
}
|
|
587
279
|
}
|
|
588
280
|
|
|
589
|
-
async getDepartmentUsers(deptId: number): Promise<unknown[]> {
|
|
281
|
+
async #getDepartmentUsers(deptId: number): Promise<unknown[]> {
|
|
590
282
|
try {
|
|
591
|
-
const data = await this
|
|
283
|
+
const data = await this.#request('/cgi-bin/user/simplelist', {
|
|
592
284
|
params: { department_id: deptId },
|
|
593
285
|
});
|
|
594
286
|
if (data.errcode === 0) return (data.userlist as unknown[]) || [];
|
|
595
287
|
throw new Error(`Failed to get department users: ${data.errmsg}`);
|
|
596
288
|
} catch (error) {
|
|
597
|
-
this
|
|
289
|
+
this.#logger.error('Failed to get department users:', error);
|
|
598
290
|
return [];
|
|
599
291
|
}
|
|
600
292
|
}
|
|
601
293
|
|
|
602
|
-
async getDepartmentList(deptId: number = 1): Promise<unknown[]> {
|
|
294
|
+
async #getDepartmentList(deptId: number = 1): Promise<unknown[]> {
|
|
603
295
|
try {
|
|
604
|
-
const data = await this
|
|
296
|
+
const data = await this.#request('/cgi-bin/department/list', {
|
|
605
297
|
params: { id: deptId },
|
|
606
298
|
});
|
|
607
299
|
if (data.errcode === 0) return (data.department as unknown[]) || [];
|
|
608
300
|
throw new Error(`Failed to get department list: ${data.errmsg}`);
|
|
609
301
|
} catch (error) {
|
|
610
|
-
this
|
|
302
|
+
this.#logger.error('Failed to get department list:', error);
|
|
611
303
|
return [];
|
|
612
304
|
}
|
|
613
305
|
}
|
|
614
306
|
|
|
615
|
-
async sendTextMessage(userId: string, content: string): Promise<boolean> {
|
|
307
|
+
async #sendTextMessage(userId: string, content: string): Promise<boolean> {
|
|
616
308
|
try {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
text: { content },
|
|
309
|
+
const endpointKey = String(this.#options.id);
|
|
310
|
+
await this.send({
|
|
311
|
+
conversation: {
|
|
312
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
313
|
+
kind: resolveChatType(userId),
|
|
314
|
+
id: userId,
|
|
624
315
|
},
|
|
316
|
+
payload: content,
|
|
625
317
|
});
|
|
626
|
-
|
|
627
|
-
this.logger.debug('Text message sent successfully');
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
throw new Error(`Failed to send text message: ${data.errmsg}`);
|
|
318
|
+
return true;
|
|
631
319
|
} catch (error) {
|
|
632
|
-
this
|
|
320
|
+
this.#logger.error('Failed to send text message:', error);
|
|
633
321
|
return false;
|
|
634
322
|
}
|
|
635
323
|
}
|
|
324
|
+
|
|
325
|
+
async #request(
|
|
326
|
+
path: string,
|
|
327
|
+
options: {
|
|
328
|
+
method?: 'GET' | 'POST';
|
|
329
|
+
params?: Record<string, string | number>;
|
|
330
|
+
body?: Record<string, unknown>;
|
|
331
|
+
} = {},
|
|
332
|
+
): Promise<WecomApiResponse> {
|
|
333
|
+
await this.#ensureAccessToken();
|
|
334
|
+
const { method = 'GET', params = {}, body } = options;
|
|
335
|
+
const urlParams = new URLSearchParams({
|
|
336
|
+
...Object.fromEntries(
|
|
337
|
+
Object.entries(params).map(([key, value]) => [key, String(value)]),
|
|
338
|
+
),
|
|
339
|
+
access_token: this.#accessToken.access_token,
|
|
340
|
+
});
|
|
341
|
+
const url = `${this.#options.config.apiBaseUrl}${path}?${urlParams.toString()}`;
|
|
342
|
+
const response = await this.#fetch(url, {
|
|
343
|
+
method,
|
|
344
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
345
|
+
body: body && method === 'POST' ? JSON.stringify(body) : undefined,
|
|
346
|
+
});
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
const text = await response.text().catch(() => '');
|
|
349
|
+
throw new Error(`WeCom API error ${response.status}: ${text}`);
|
|
350
|
+
}
|
|
351
|
+
return await response.json() as WecomApiResponse;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async #ensureAccessToken(): Promise<void> {
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
if (
|
|
357
|
+
this.#accessToken.access_token
|
|
358
|
+
&& now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000
|
|
359
|
+
) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (this.#refreshPromise) {
|
|
363
|
+
await this.#refreshPromise;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.#refreshPromise = this.#refreshAccessToken()
|
|
367
|
+
.then(() => this.#accessToken.access_token)
|
|
368
|
+
.finally(() => { this.#refreshPromise = null; });
|
|
369
|
+
await this.#refreshPromise;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async #refreshAccessToken(): Promise<void> {
|
|
373
|
+
const { corpId, agentSecret, apiBaseUrl } = this.#options.config;
|
|
374
|
+
const url = `${apiBaseUrl}/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${agentSecret}`;
|
|
375
|
+
const response = await this.#fetch(url);
|
|
376
|
+
const data = await response.json() as WecomApiResponse;
|
|
377
|
+
if (data.errcode === 0 && data.access_token) {
|
|
378
|
+
this.#accessToken = {
|
|
379
|
+
access_token: data.access_token,
|
|
380
|
+
expires_in: data.expires_in ?? 7200,
|
|
381
|
+
timestamp: Date.now(),
|
|
382
|
+
};
|
|
383
|
+
this.#logger.debug('Access token refreshed successfully');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
|
|
387
|
+
}
|
|
636
388
|
}
|