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