@sunnoy/wecom 1.1.2 → 1.3.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.
@@ -0,0 +1,58 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "./constants.js";
2
+
3
+ export function normalizeWecomAllowFromEntry(raw) {
4
+ const trimmed = String(raw ?? "").trim();
5
+ if (!trimmed) {
6
+ return null;
7
+ }
8
+ if (trimmed === "*") {
9
+ return "*";
10
+ }
11
+ return trimmed
12
+ .replace(/^(wecom|wework):/i, "")
13
+ .replace(/^user:/i, "")
14
+ .toLowerCase();
15
+ }
16
+
17
+ export function resolveWecomAllowFrom(cfg, accountId) {
18
+ const wecom = cfg?.channels?.wecom;
19
+ if (!wecom) {
20
+ return [];
21
+ }
22
+
23
+ const normalizedAccountId = String(accountId || DEFAULT_ACCOUNT_ID)
24
+ .trim()
25
+ .toLowerCase();
26
+ const accounts = wecom.accounts;
27
+ const account =
28
+ accounts && typeof accounts === "object"
29
+ ? (accounts[accountId] ??
30
+ accounts[
31
+ Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId) ?? ""
32
+ ])
33
+ : undefined;
34
+
35
+ const allowFromRaw =
36
+ account?.dm?.allowFrom ?? account?.allowFrom ?? wecom.dm?.allowFrom ?? wecom.allowFrom ?? [];
37
+
38
+ if (!Array.isArray(allowFromRaw)) {
39
+ return [];
40
+ }
41
+
42
+ return allowFromRaw.map(normalizeWecomAllowFromEntry).filter((entry) => Boolean(entry));
43
+ }
44
+
45
+ export function resolveWecomCommandAuthorized({ cfg, accountId, senderId }) {
46
+ const sender = String(senderId ?? "")
47
+ .trim()
48
+ .toLowerCase();
49
+ if (!sender) {
50
+ return false;
51
+ }
52
+
53
+ const allowFrom = resolveWecomAllowFrom(cfg, accountId);
54
+ if (allowFrom.includes("*") || allowFrom.length === 0) {
55
+ return true;
56
+ }
57
+ return allowFrom.includes(sender);
58
+ }
@@ -0,0 +1,638 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import crypto from "node:crypto";
3
+ import { basename } from "node:path";
4
+ import { logger } from "../logger.js";
5
+ import { streamManager } from "../stream-manager.js";
6
+ import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js";
7
+ import { DEFAULT_ACCOUNT_ID, THINKING_PLACEHOLDER } from "./constants.js";
8
+ import { parseResponseUrlResult } from "./response-url.js";
9
+ import { messageBuffers, resolveAgentConfig, resolveWebhookUrl, responseUrls, streamContext } from "./state.js";
10
+ import { resolveActiveStream } from "./stream-utils.js";
11
+ import { resolveWecomTarget } from "./target.js";
12
+ import { webhookSendImage, webhookSendText, webhookUploadFile, webhookSendFile } from "./webhook-bot.js";
13
+ import { registerWebhookTarget } from "./webhook-targets.js";
14
+
15
+ export const wecomChannelPlugin = {
16
+ id: "wecom",
17
+ meta: {
18
+ id: "wecom",
19
+ label: "Enterprise WeChat",
20
+ selectionLabel: "Enterprise WeChat (AI Bot)",
21
+ docsPath: "/channels/wecom",
22
+ blurb: "Enterprise WeChat AI Bot channel plugin.",
23
+ aliases: ["wecom", "wework"],
24
+ },
25
+ capabilities: {
26
+ chatTypes: ["direct", "group"],
27
+ reactions: false,
28
+ threads: false,
29
+ media: true,
30
+ nativeCommands: false,
31
+ blockStreaming: true, // WeCom AI Bot requires stream-style responses.
32
+ },
33
+ reload: { configPrefixes: ["channels.wecom"] },
34
+ configSchema: {
35
+ schema: {
36
+ $schema: "http://json-schema.org/draft-07/schema#",
37
+ type: "object",
38
+ additionalProperties: false,
39
+ properties: {
40
+ enabled: {
41
+ type: "boolean",
42
+ description: "Enable WeCom channel",
43
+ default: true,
44
+ },
45
+ token: {
46
+ type: "string",
47
+ description: "WeCom bot token from admin console",
48
+ },
49
+ encodingAesKey: {
50
+ type: "string",
51
+ description: "WeCom message encryption key (43 characters)",
52
+ minLength: 43,
53
+ maxLength: 43,
54
+ },
55
+ commands: {
56
+ type: "object",
57
+ description: "Command whitelist configuration",
58
+ additionalProperties: false,
59
+ properties: {
60
+ enabled: {
61
+ type: "boolean",
62
+ description: "Enable command whitelist filtering",
63
+ default: true,
64
+ },
65
+ allowlist: {
66
+ type: "array",
67
+ description: "Allowed commands (e.g., /new, /status, /help)",
68
+ items: {
69
+ type: "string",
70
+ },
71
+ default: ["/new", "/status", "/help", "/compact"],
72
+ },
73
+ },
74
+ },
75
+ dynamicAgents: {
76
+ type: "object",
77
+ description: "Dynamic agent routing configuration",
78
+ additionalProperties: false,
79
+ properties: {
80
+ enabled: {
81
+ type: "boolean",
82
+ description: "Enable per-user/per-group agent isolation",
83
+ default: true,
84
+ },
85
+ },
86
+ },
87
+ dm: {
88
+ type: "object",
89
+ description: "Direct message (private chat) configuration",
90
+ additionalProperties: false,
91
+ properties: {
92
+ createAgentOnFirstMessage: {
93
+ type: "boolean",
94
+ description: "Create separate agent for each user",
95
+ default: true,
96
+ },
97
+ },
98
+ },
99
+ groupChat: {
100
+ type: "object",
101
+ description: "Group chat configuration",
102
+ additionalProperties: false,
103
+ properties: {
104
+ enabled: {
105
+ type: "boolean",
106
+ description: "Enable group chat support",
107
+ default: true,
108
+ },
109
+ requireMention: {
110
+ type: "boolean",
111
+ description: "Only respond when @mentioned in groups",
112
+ default: true,
113
+ },
114
+ },
115
+ },
116
+ adminUsers: {
117
+ type: "array",
118
+ description: "Admin users who bypass command allowlist (routing unchanged)",
119
+ items: { type: "string" },
120
+ default: [],
121
+ },
122
+ workspaceTemplate: {
123
+ type: "string",
124
+ description: "Directory with custom bootstrap templates (AGENTS.md, BOOTSTRAP.md, etc.)",
125
+ },
126
+ agent: {
127
+ type: "object",
128
+ description: "Agent mode (self-built application) configuration for outbound messaging and inbound callbacks",
129
+ additionalProperties: false,
130
+ properties: {
131
+ corpId: { type: "string", description: "Enterprise Corp ID" },
132
+ corpSecret: { type: "string", description: "Application Secret" },
133
+ agentId: { type: "number", description: "Application Agent ID" },
134
+ token: { type: "string", description: "Callback Token for Agent inbound" },
135
+ encodingAesKey: {
136
+ type: "string",
137
+ description: "Callback Encoding AES Key for Agent inbound (43 characters)",
138
+ minLength: 43,
139
+ maxLength: 43,
140
+ },
141
+ },
142
+ },
143
+ webhooks: {
144
+ type: "object",
145
+ description: "Webhook bot URLs for group notifications (key: name, value: webhook URL or key)",
146
+ additionalProperties: { type: "string" },
147
+ },
148
+ },
149
+ },
150
+ uiHints: {
151
+ token: {
152
+ sensitive: true,
153
+ label: "Bot Token",
154
+ },
155
+ encodingAesKey: {
156
+ sensitive: true,
157
+ label: "Encoding AES Key",
158
+ help: "43-character encryption key from WeCom admin console",
159
+ },
160
+ "agent.corpSecret": {
161
+ sensitive: true,
162
+ label: "Application Secret",
163
+ },
164
+ "agent.token": {
165
+ sensitive: true,
166
+ label: "Agent Callback Token",
167
+ },
168
+ "agent.encodingAesKey": {
169
+ sensitive: true,
170
+ label: "Agent Callback Encoding AES Key",
171
+ help: "43-character encryption key for Agent inbound callbacks",
172
+ },
173
+ },
174
+ },
175
+ config: {
176
+ listAccountIds: (cfg) => {
177
+ const wecom = cfg?.channels?.wecom;
178
+ if (!wecom || !wecom.enabled) {
179
+ return [];
180
+ }
181
+ return [DEFAULT_ACCOUNT_ID];
182
+ },
183
+ resolveAccount: (cfg, accountId) => {
184
+ const wecom = cfg?.channels?.wecom;
185
+ if (!wecom) {
186
+ return null;
187
+ }
188
+ const agent = wecom.agent;
189
+ const webhooks = wecom.webhooks;
190
+ return {
191
+ id: accountId || DEFAULT_ACCOUNT_ID,
192
+ accountId: accountId || DEFAULT_ACCOUNT_ID,
193
+ enabled: wecom.enabled !== false,
194
+ token: wecom.token || "",
195
+ encodingAesKey: wecom.encodingAesKey || "",
196
+ webhookPath: wecom.webhookPath || "/webhooks/wecom",
197
+ config: wecom,
198
+ agentConfigured: Boolean(agent?.corpId && agent?.corpSecret && agent?.agentId),
199
+ agentInboundConfigured: Boolean(
200
+ agent?.corpId && agent?.corpSecret && agent?.agentId && agent?.token && agent?.encodingAesKey,
201
+ ),
202
+ webhooksConfigured: Boolean(webhooks && Object.keys(webhooks).length > 0),
203
+ };
204
+ },
205
+ defaultAccountId: (cfg) => {
206
+ const wecom = cfg?.channels?.wecom;
207
+ if (!wecom || !wecom.enabled) {
208
+ return null;
209
+ }
210
+ return DEFAULT_ACCOUNT_ID;
211
+ },
212
+ setAccountEnabled: ({ cfg, accountId: _accountId, enabled }) => {
213
+ if (!cfg.channels) {
214
+ cfg.channels = {};
215
+ }
216
+ if (!cfg.channels.wecom) {
217
+ cfg.channels.wecom = {};
218
+ }
219
+ cfg.channels.wecom.enabled = enabled;
220
+ return cfg;
221
+ },
222
+ deleteAccount: ({ cfg, accountId: _accountId }) => {
223
+ if (cfg.channels?.wecom) {
224
+ delete cfg.channels.wecom;
225
+ }
226
+ return cfg;
227
+ },
228
+ },
229
+ directory: {
230
+ self: async () => null,
231
+ listPeers: async () => [],
232
+ listGroups: async () => [],
233
+ },
234
+ // Outbound adapter: all replies are streamed for WeCom AI Bot compatibility.
235
+ outbound: {
236
+ sendText: async ({ cfg: _cfg, to, text, accountId: _accountId }) => {
237
+ // `to` format: "wecom:userid" or "userid".
238
+ const userId = to.replace(/^wecom:/, "");
239
+
240
+ // Prefer stream from async context (correct for concurrent processing).
241
+ const ctx = streamContext.getStore();
242
+ const streamId = ctx?.streamId ?? resolveActiveStream(userId);
243
+
244
+ // Layer 1: Active stream (normal path)
245
+ if (streamId && streamManager.hasStream(streamId) && !streamManager.getStream(streamId)?.finished) {
246
+ logger.debug("Appending outbound text to stream", {
247
+ userId,
248
+ streamId,
249
+ source: ctx ? "asyncContext" : "activeStreams",
250
+ text: text.substring(0, 30),
251
+ });
252
+ // Replace placeholder or append content.
253
+ streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
254
+
255
+ return {
256
+ channel: "wecom",
257
+ messageId: `msg_stream_${Date.now()}`,
258
+ };
259
+ }
260
+
261
+ // Layer 2: Fallback via response_url
262
+ // response_url is valid for 1 hour and can be used only once.
263
+ // responseUrls is keyed by streamKey (fromUser for DM, chatId for group).
264
+ const saved = responseUrls.get(ctx?.streamKey ?? userId);
265
+ if (saved && !saved.used && Date.now() < saved.expiresAt) {
266
+ try {
267
+ const response = await fetch(saved.url, {
268
+ method: "POST",
269
+ headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify({ msgtype: "text", text: { content: text } }),
271
+ });
272
+ const responseBody = await response.text().catch(() => "");
273
+ const result = parseResponseUrlResult(response, responseBody);
274
+ if (!result.accepted) {
275
+ logger.error("WeCom: response_url fallback rejected", {
276
+ userId,
277
+ status: response.status,
278
+ statusText: response.statusText,
279
+ errcode: result.errcode,
280
+ errmsg: result.errmsg,
281
+ bodyPreview: result.bodyPreview,
282
+ });
283
+ } else {
284
+ saved.used = true;
285
+ logger.info("WeCom: sent via response_url fallback", {
286
+ userId,
287
+ status: response.status,
288
+ errcode: result.errcode,
289
+ });
290
+ return {
291
+ channel: "wecom",
292
+ messageId: `msg_response_url_${Date.now()}`,
293
+ };
294
+ }
295
+ } catch (err) {
296
+ logger.error("WeCom: response_url fallback failed", { userId, error: err.message });
297
+ }
298
+ }
299
+
300
+ // Layer 3a: Webhook Bot (group notifications via webhook:name target)
301
+ const target = resolveWecomTarget(to);
302
+ if (target?.webhook) {
303
+ const webhookUrl = resolveWebhookUrl(target.webhook);
304
+ if (webhookUrl) {
305
+ try {
306
+ await webhookSendText({ url: webhookUrl, content: text });
307
+ logger.info("WeCom: sent via Webhook Bot (sendText)", {
308
+ webhookName: target.webhook,
309
+ contentPreview: text.substring(0, 50),
310
+ });
311
+ return {
312
+ channel: "wecom",
313
+ messageId: `msg_webhook_${Date.now()}`,
314
+ };
315
+ } catch (err) {
316
+ logger.error("WeCom: Webhook Bot sendText failed", {
317
+ webhookName: target.webhook,
318
+ error: err.message,
319
+ });
320
+ }
321
+ } else {
322
+ logger.warn("WeCom: webhook name not found in config", { webhookName: target.webhook });
323
+ }
324
+ }
325
+
326
+ // Layer 3b: Agent API fallback (stream closed + response_url unavailable)
327
+ const agentConfig = resolveAgentConfig();
328
+ if (agentConfig) {
329
+ try {
330
+ const agentTarget = (target && !target.webhook) ? target : { toUser: userId };
331
+ await agentSendText({ agent: agentConfig, ...agentTarget, text });
332
+ logger.info("WeCom: sent via Agent API fallback (sendText)", {
333
+ userId,
334
+ to,
335
+ contentPreview: text.substring(0, 50),
336
+ });
337
+ return {
338
+ channel: "wecom",
339
+ messageId: `msg_agent_${Date.now()}`,
340
+ };
341
+ } catch (err) {
342
+ logger.error("WeCom: Agent API fallback failed (sendText)", { userId, error: err.message });
343
+ }
344
+ }
345
+
346
+ logger.warn("WeCom outbound: no delivery channel available (all layers exhausted)", {
347
+ userId,
348
+ });
349
+
350
+ return {
351
+ channel: "wecom",
352
+ messageId: `fake_${Date.now()}`,
353
+ };
354
+ },
355
+ sendMedia: async ({ cfg: _cfg, to, text, mediaUrl, accountId: _accountId }) => {
356
+ const userId = to.replace(/^wecom:/, "");
357
+
358
+ // Prefer stream from async context (correct for concurrent processing).
359
+ const ctx = streamContext.getStore();
360
+ const streamId = ctx?.streamId ?? resolveActiveStream(userId);
361
+
362
+ if (streamId && streamManager.hasStream(streamId)) {
363
+ // Check if mediaUrl is a local path (sandbox: prefix or absolute path)
364
+ const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
365
+
366
+ if (isLocalPath) {
367
+ // Convert sandbox: URLs to absolute paths.
368
+ // sandbox:///tmp/a -> /tmp/a, sandbox://tmp/a -> /tmp/a, sandbox:/tmp/a -> /tmp/a
369
+ let absolutePath = mediaUrl;
370
+ if (absolutePath.startsWith("sandbox:")) {
371
+ absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
372
+ // Ensure the result is an absolute path.
373
+ if (!absolutePath.startsWith("/")) {
374
+ absolutePath = "/" + absolutePath;
375
+ }
376
+ }
377
+
378
+ logger.debug("Queueing local image for stream", {
379
+ userId,
380
+ streamId,
381
+ mediaUrl,
382
+ absolutePath,
383
+ });
384
+
385
+ // Queue the image for processing when stream finishes
386
+ const queued = streamManager.queueImage(streamId, absolutePath);
387
+
388
+ if (queued) {
389
+ // Append text content to stream (without markdown image)
390
+ if (text) {
391
+ streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
392
+ }
393
+
394
+ // Append placeholder indicating image will follow
395
+ const imagePlaceholder = "\n\n[图片]";
396
+ streamManager.appendStream(streamId, imagePlaceholder);
397
+
398
+ return {
399
+ channel: "wecom",
400
+ messageId: `msg_stream_img_${Date.now()}`,
401
+ };
402
+ } else {
403
+ logger.warn("Failed to queue image, falling back to markdown", {
404
+ userId,
405
+ streamId,
406
+ mediaUrl,
407
+ });
408
+ // Fallback to old behavior
409
+ }
410
+ }
411
+
412
+ // OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
413
+ const content = text ? `${text}\n\n![image](${mediaUrl})` : `![image](${mediaUrl})`;
414
+ logger.debug("Appending outbound media to stream (markdown)", {
415
+ userId,
416
+ streamId,
417
+ mediaUrl,
418
+ });
419
+
420
+ // Replace placeholder or append media markdown to the current stream content.
421
+ streamManager.replaceIfPlaceholder(streamId, content, THINKING_PLACEHOLDER);
422
+
423
+ return {
424
+ channel: "wecom",
425
+ messageId: `msg_stream_${Date.now()}`,
426
+ };
427
+ }
428
+
429
+ logger.warn("WeCom outbound sendMedia: no active stream, trying fallbacks", { userId });
430
+
431
+ // Layer 2a: Webhook Bot fallback for media (group notifications)
432
+ const target = resolveWecomTarget(to);
433
+ if (target?.webhook) {
434
+ const webhookUrl = resolveWebhookUrl(target.webhook);
435
+ if (webhookUrl) {
436
+ try {
437
+ // Resolve file to buffer
438
+ let buffer;
439
+ let filename;
440
+ let absolutePath = mediaUrl;
441
+ if (absolutePath.startsWith("sandbox:")) {
442
+ absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
443
+ if (!absolutePath.startsWith("/")) absolutePath = "/" + absolutePath;
444
+ }
445
+
446
+ if (absolutePath.startsWith("/")) {
447
+ buffer = await readFile(absolutePath);
448
+ filename = basename(absolutePath);
449
+ } else {
450
+ const res = await fetch(mediaUrl);
451
+ buffer = Buffer.from(await res.arrayBuffer());
452
+ filename = basename(new URL(mediaUrl).pathname) || "image.png";
453
+ }
454
+
455
+ // Try image (base64) for common image types, otherwise upload as file
456
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
457
+ const imageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
458
+
459
+ if (imageExts.has(ext)) {
460
+ const base64 = buffer.toString("base64");
461
+ const md5 = crypto.createHash("md5").update(buffer).digest("hex");
462
+ await webhookSendImage({ url: webhookUrl, base64, md5 });
463
+ } else {
464
+ const mediaId = await webhookUploadFile({ url: webhookUrl, buffer, filename });
465
+ await webhookSendFile({ url: webhookUrl, mediaId });
466
+ }
467
+
468
+ // Send accompanying text if present
469
+ if (text) {
470
+ await webhookSendText({ url: webhookUrl, content: text });
471
+ }
472
+
473
+ logger.info("WeCom: sent media via Webhook Bot (sendMedia)", {
474
+ webhookName: target.webhook,
475
+ mediaUrl: mediaUrl.substring(0, 80),
476
+ });
477
+ return {
478
+ channel: "wecom",
479
+ messageId: `msg_webhook_media_${Date.now()}`,
480
+ };
481
+ } catch (err) {
482
+ logger.error("WeCom: Webhook Bot sendMedia failed", {
483
+ webhookName: target.webhook,
484
+ error: err.message,
485
+ });
486
+ }
487
+ } else {
488
+ logger.warn("WeCom: webhook name not found in config (sendMedia)", { webhookName: target.webhook });
489
+ }
490
+ }
491
+
492
+ // Layer 2b: Agent API fallback for media
493
+ const agentConfig = resolveAgentConfig();
494
+ if (agentConfig) {
495
+ try {
496
+ const agentTarget = (target && !target.webhook) ? target : resolveWecomTarget(to) || { toUser: userId };
497
+
498
+ // Determine if mediaUrl is a local file path.
499
+ let absolutePath = mediaUrl;
500
+ if (absolutePath.startsWith("sandbox:")) {
501
+ absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
502
+ if (!absolutePath.startsWith("/")) absolutePath = "/" + absolutePath;
503
+ }
504
+
505
+ if (absolutePath.startsWith("/")) {
506
+ // Upload local file then send via Agent API.
507
+ const buffer = await readFile(absolutePath);
508
+ const filename = basename(absolutePath);
509
+ const mediaId = await agentUploadMedia({
510
+ agent: agentConfig,
511
+ type: "image",
512
+ buffer,
513
+ filename,
514
+ });
515
+ await agentSendMedia({
516
+ agent: agentConfig,
517
+ ...agentTarget,
518
+ mediaId,
519
+ mediaType: "image",
520
+ });
521
+ } else {
522
+ // For external URLs, download first then upload.
523
+ const res = await fetch(mediaUrl);
524
+ const buffer = Buffer.from(await res.arrayBuffer());
525
+ const filename = basename(new URL(mediaUrl).pathname) || "image.png";
526
+ const mediaId = await agentUploadMedia({
527
+ agent: agentConfig,
528
+ type: "image",
529
+ buffer,
530
+ filename,
531
+ });
532
+ await agentSendMedia({
533
+ agent: agentConfig,
534
+ ...agentTarget,
535
+ mediaId,
536
+ mediaType: "image",
537
+ });
538
+ }
539
+
540
+ // Also send accompanying text if present.
541
+ if (text) {
542
+ await agentSendText({ agent: agentConfig, ...agentTarget, text });
543
+ }
544
+
545
+ logger.info("WeCom: sent media via Agent API fallback (sendMedia)", {
546
+ userId,
547
+ to,
548
+ mediaUrl: mediaUrl.substring(0, 80),
549
+ });
550
+ return {
551
+ channel: "wecom",
552
+ messageId: `msg_agent_media_${Date.now()}`,
553
+ };
554
+ } catch (err) {
555
+ logger.error("WeCom: Agent API media fallback failed", { userId, error: err.message });
556
+ }
557
+ }
558
+
559
+ return {
560
+ channel: "wecom",
561
+ messageId: `fake_${Date.now()}`,
562
+ };
563
+ },
564
+ },
565
+ gateway: {
566
+ startAccount: async (ctx) => {
567
+ const account = ctx.account;
568
+ logger.info("WeCom gateway starting", {
569
+ accountId: account.accountId,
570
+ webhookPath: account.webhookPath,
571
+ });
572
+
573
+ const unregister = registerWebhookTarget({
574
+ path: account.webhookPath || "/webhooks/wecom",
575
+ account,
576
+ config: ctx.cfg,
577
+ });
578
+
579
+ // Register Agent inbound webhook if agent inbound is fully configured.
580
+ let unregisterAgent;
581
+ const agentInboundPath = "/webhooks/app";
582
+ const botPath = account.webhookPath || "/webhooks/wecom";
583
+ if (account.agentInboundConfigured) {
584
+ if (botPath === agentInboundPath) {
585
+ logger.error("WeCom: Agent inbound path conflicts with Bot webhook path, skipping Agent registration", {
586
+ path: agentInboundPath,
587
+ });
588
+ } else {
589
+ const agentCfg = account.config.agent;
590
+ unregisterAgent = registerWebhookTarget({
591
+ path: agentInboundPath,
592
+ account: {
593
+ ...account,
594
+ // Agent inbound uses its own token/encodingAesKey for callback verification.
595
+ agentInbound: {
596
+ token: agentCfg.token,
597
+ encodingAesKey: agentCfg.encodingAesKey,
598
+ corpId: agentCfg.corpId,
599
+ corpSecret: agentCfg.corpSecret,
600
+ agentId: agentCfg.agentId,
601
+ },
602
+ },
603
+ config: ctx.cfg,
604
+ });
605
+ logger.info("WeCom Agent inbound webhook registered", { path: agentInboundPath });
606
+ }
607
+ }
608
+
609
+ const shutdown = async () => {
610
+ logger.info("WeCom gateway shutting down");
611
+ // Clear pending debounce timers to prevent post-shutdown dispatches.
612
+ for (const [, buf] of messageBuffers) {
613
+ clearTimeout(buf.timer);
614
+ }
615
+ messageBuffers.clear();
616
+ unregister();
617
+ if (unregisterAgent) unregisterAgent();
618
+ };
619
+
620
+ // Backward compatibility: older runtime may not pass abortSignal.
621
+ // In that case, keep legacy behavior and expose explicit shutdown.
622
+ if (!ctx.abortSignal) {
623
+ return { shutdown };
624
+ }
625
+
626
+ if (ctx.abortSignal.aborted) {
627
+ await shutdown();
628
+ return;
629
+ }
630
+
631
+ await new Promise((resolve) => {
632
+ ctx.abortSignal.addEventListener("abort", resolve, { once: true });
633
+ });
634
+
635
+ await shutdown();
636
+ },
637
+ },
638
+ };