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