@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,484 @@
|
|
|
1
|
+
import { readFile, access, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
4
|
+
import { streamManager } from "../stream-manager.js";
|
|
5
|
+
import { agentSendText, agentUploadMedia, agentSendMedia } from "./agent-api.js";
|
|
6
|
+
import { parseResponseUrlResult } from "./response-url.js";
|
|
7
|
+
import { resolveAgentConfig, responseUrls, streamContext } from "./state.js";
|
|
8
|
+
import { resolveActiveStream } from "./stream-utils.js";
|
|
9
|
+
import { resolveAgentWorkspaceDirLocal } from "./workspace-template.js";
|
|
10
|
+
import { THINKING_PLACEHOLDER } from "./constants.js";
|
|
11
|
+
|
|
12
|
+
// WeCom upload API rejects files smaller than 5 bytes (error 40006).
|
|
13
|
+
const WECOM_MIN_FILE_SIZE = 5;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve sandbox /workspace/… paths to the host-side equivalent.
|
|
17
|
+
* Inside the sandbox container, /workspace is mounted from
|
|
18
|
+
* ~/.openclaw/workspace-{agentId} on the host. Any path starting with
|
|
19
|
+
* /workspace/ is transparently rewritten when an agentId is available.
|
|
20
|
+
*/
|
|
21
|
+
function resolveHostPath(filePath, effectiveAgentId) {
|
|
22
|
+
if (effectiveAgentId && filePath.startsWith("/workspace/")) {
|
|
23
|
+
const relative = filePath.slice("/workspace/".length);
|
|
24
|
+
const hostPath = join(resolveAgentWorkspaceDirLocal(effectiveAgentId), relative);
|
|
25
|
+
logger.debug("Resolved sandbox path to host path", { sandbox: filePath, host: hostPath });
|
|
26
|
+
return hostPath;
|
|
27
|
+
}
|
|
28
|
+
return filePath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upload a local file and send via Agent DM.
|
|
33
|
+
* If the file is smaller than WECOM_MIN_FILE_SIZE (WeCom rejects tiny files),
|
|
34
|
+
* read the content and send as a text message instead.
|
|
35
|
+
* Returns a user-facing hint string.
|
|
36
|
+
*/
|
|
37
|
+
async function uploadAndSendFile({ hostPath, filename, agent, senderId, streamId }) {
|
|
38
|
+
const fileBuf = await readFile(hostPath);
|
|
39
|
+
if (fileBuf.length < WECOM_MIN_FILE_SIZE) {
|
|
40
|
+
// File too small for WeCom upload — send content inline as text.
|
|
41
|
+
const content = fileBuf.toString("utf-8");
|
|
42
|
+
await agentSendText({
|
|
43
|
+
agent,
|
|
44
|
+
toUser: senderId,
|
|
45
|
+
text: `📄 ${filename}:\n${content}`,
|
|
46
|
+
});
|
|
47
|
+
logger.info("Sent tiny file as text via Agent DM", {
|
|
48
|
+
streamId,
|
|
49
|
+
filename,
|
|
50
|
+
size: fileBuf.length,
|
|
51
|
+
});
|
|
52
|
+
return `📎 文件「${filename}」内容已通过私信发送给您`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const uploadedId = await agentUploadMedia({
|
|
56
|
+
agent,
|
|
57
|
+
type: "file",
|
|
58
|
+
buffer: fileBuf,
|
|
59
|
+
filename,
|
|
60
|
+
});
|
|
61
|
+
await agentSendMedia({
|
|
62
|
+
agent,
|
|
63
|
+
toUser: senderId,
|
|
64
|
+
mediaId: uploadedId,
|
|
65
|
+
mediaType: "file",
|
|
66
|
+
});
|
|
67
|
+
logger.info("Sent file via Agent DM", { streamId, filename, size: fileBuf.length });
|
|
68
|
+
return `📎 文件「${filename}」已通过私信发送给您`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function deliverWecomReply({ payload, senderId, streamId, agentId }) {
|
|
72
|
+
const text = payload.text || "";
|
|
73
|
+
// Resolve effective agentId from parameter or async context.
|
|
74
|
+
const effectiveAgentId = agentId || streamContext.getStore()?.agentId;
|
|
75
|
+
|
|
76
|
+
logger.debug("deliverWecomReply called", {
|
|
77
|
+
hasText: !!text.trim(),
|
|
78
|
+
textPreview: text.substring(0, 50),
|
|
79
|
+
streamId,
|
|
80
|
+
senderId,
|
|
81
|
+
agentId: effectiveAgentId,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Handle absolute-path MEDIA lines manually; OpenClaw rejects these paths upstream.
|
|
85
|
+
// Match both line-start (^MEDIA:) and inline (…MEDIA:) patterns.
|
|
86
|
+
const mediaRegex = /(?:^|(?<=\s))MEDIA:\s*(.+?)$/gm;
|
|
87
|
+
const mediaMatches = [];
|
|
88
|
+
let match;
|
|
89
|
+
while ((match = mediaRegex.exec(text)) !== null) {
|
|
90
|
+
const mediaPath = match[1].trim();
|
|
91
|
+
// Only intercept absolute filesystem paths.
|
|
92
|
+
if (mediaPath.startsWith("/")) {
|
|
93
|
+
mediaMatches.push({
|
|
94
|
+
fullMatch: match[0],
|
|
95
|
+
path: mediaPath,
|
|
96
|
+
});
|
|
97
|
+
logger.debug("Detected absolute path MEDIA line", {
|
|
98
|
+
streamId,
|
|
99
|
+
mediaPath,
|
|
100
|
+
line: match[0],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Queue absolute-path images; send non-image files via Agent DM.
|
|
106
|
+
const mediaImageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
|
|
107
|
+
let processedText = text;
|
|
108
|
+
if (mediaMatches.length > 0 && streamId) {
|
|
109
|
+
for (const media of mediaMatches) {
|
|
110
|
+
// Resolve /workspace/ sandbox paths to host-side paths.
|
|
111
|
+
const resolvedMediaPath = resolveHostPath(media.path, effectiveAgentId);
|
|
112
|
+
const mediaExt = resolvedMediaPath.split(".").pop()?.toLowerCase() || "";
|
|
113
|
+
if (mediaImageExts.has(mediaExt)) {
|
|
114
|
+
// Image: queue for delivery when stream finishes.
|
|
115
|
+
const queued = streamManager.queueImage(streamId, resolvedMediaPath);
|
|
116
|
+
if (queued) {
|
|
117
|
+
processedText = processedText.replace(media.fullMatch, "").trim();
|
|
118
|
+
logger.info("Queued absolute path image for stream", {
|
|
119
|
+
streamId,
|
|
120
|
+
imagePath: resolvedMediaPath,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
// Non-image file: WeCom Bot stream API does not support files.
|
|
125
|
+
// Send via Agent DM and replace the MEDIA line with a hint.
|
|
126
|
+
const mediaFilename = basename(resolvedMediaPath);
|
|
127
|
+
const agentCfgMedia = resolveAgentConfig();
|
|
128
|
+
if (agentCfgMedia && senderId) {
|
|
129
|
+
try {
|
|
130
|
+
const hint = await uploadAndSendFile({
|
|
131
|
+
hostPath: resolvedMediaPath,
|
|
132
|
+
filename: mediaFilename,
|
|
133
|
+
agent: agentCfgMedia,
|
|
134
|
+
senderId,
|
|
135
|
+
streamId,
|
|
136
|
+
});
|
|
137
|
+
processedText = processedText
|
|
138
|
+
.replace(media.fullMatch, hint)
|
|
139
|
+
.trim();
|
|
140
|
+
logger.info("Sent non-image file via Agent DM (MEDIA line)", {
|
|
141
|
+
streamId,
|
|
142
|
+
filename: mediaFilename,
|
|
143
|
+
senderId,
|
|
144
|
+
});
|
|
145
|
+
} catch (mediaErr) {
|
|
146
|
+
processedText = processedText
|
|
147
|
+
.replace(media.fullMatch, `⚠️ 文件发送失败(${mediaFilename}):${mediaErr.message}`)
|
|
148
|
+
.trim();
|
|
149
|
+
logger.error("Failed to send non-image file via Agent DM (MEDIA line)", {
|
|
150
|
+
streamId,
|
|
151
|
+
filename: mediaFilename,
|
|
152
|
+
error: mediaErr.message,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// No agent configured or no sender — just strip the MEDIA line.
|
|
157
|
+
processedText = processedText
|
|
158
|
+
.replace(media.fullMatch, `⚠️ 无法发送文件 ${mediaFilename}(未配置 Agent API)`)
|
|
159
|
+
.trim();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle payload.mediaUrl / payload.mediaUrls from OpenClaw core dispatcher.
|
|
166
|
+
// These are local file paths or remote URLs that the LLM wants to deliver as media.
|
|
167
|
+
const payloadMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
168
|
+
if (payloadMediaUrls.length > 0) {
|
|
169
|
+
const payloadImageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
|
|
170
|
+
for (const mediaPath of payloadMediaUrls) {
|
|
171
|
+
// Normalize sandbox: prefix
|
|
172
|
+
let absPath = mediaPath;
|
|
173
|
+
if (absPath.startsWith("sandbox:")) {
|
|
174
|
+
absPath = absPath.replace(/^sandbox:\/{0,2}/, "");
|
|
175
|
+
if (!absPath.startsWith("/")) absPath = "/" + absPath;
|
|
176
|
+
}
|
|
177
|
+
// Resolve /workspace/ sandbox paths to host-side paths.
|
|
178
|
+
absPath = resolveHostPath(absPath, effectiveAgentId);
|
|
179
|
+
|
|
180
|
+
const isLocal = absPath.startsWith("/");
|
|
181
|
+
const mediaFilename = isLocal ? basename(absPath) : (basename(new URL(mediaPath).pathname) || "file");
|
|
182
|
+
const ext = mediaFilename.split(".").pop()?.toLowerCase() || "";
|
|
183
|
+
|
|
184
|
+
if (isLocal && payloadImageExts.has(ext) && streamId) {
|
|
185
|
+
// Image: queue for delivery via stream msg_item when stream finishes.
|
|
186
|
+
const queued = streamManager.queueImage(streamId, absPath);
|
|
187
|
+
if (queued) {
|
|
188
|
+
logger.info("Queued payload image for stream", { streamId, imagePath: absPath });
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Non-image file (or image without active stream): send via Agent DM.
|
|
192
|
+
const agentCfgPayload = resolveAgentConfig();
|
|
193
|
+
if (agentCfgPayload && senderId) {
|
|
194
|
+
try {
|
|
195
|
+
let fileBuf;
|
|
196
|
+
if (isLocal) {
|
|
197
|
+
fileBuf = await readFile(absPath);
|
|
198
|
+
} else {
|
|
199
|
+
const res = await fetch(mediaPath, { signal: AbortSignal.timeout(30_000) });
|
|
200
|
+
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
201
|
+
fileBuf = Buffer.from(await res.arrayBuffer());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Determine upload type based on content type
|
|
205
|
+
let uploadType = "file";
|
|
206
|
+
if (payloadImageExts.has(ext)) uploadType = "image";
|
|
207
|
+
|
|
208
|
+
// Check minimum file size for WeCom upload.
|
|
209
|
+
if (fileBuf.length < WECOM_MIN_FILE_SIZE) {
|
|
210
|
+
const content = fileBuf.toString("utf-8");
|
|
211
|
+
await agentSendText({
|
|
212
|
+
agent: agentCfgPayload,
|
|
213
|
+
toUser: senderId,
|
|
214
|
+
text: `📄 ${mediaFilename}:\n${content}`,
|
|
215
|
+
});
|
|
216
|
+
logger.info("Sent tiny payload media as text via Agent DM", {
|
|
217
|
+
streamId,
|
|
218
|
+
filename: mediaFilename,
|
|
219
|
+
size: fileBuf.length,
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
const uploadedId = await agentUploadMedia({
|
|
223
|
+
agent: agentCfgPayload,
|
|
224
|
+
type: uploadType,
|
|
225
|
+
buffer: fileBuf,
|
|
226
|
+
filename: mediaFilename,
|
|
227
|
+
});
|
|
228
|
+
await agentSendMedia({
|
|
229
|
+
agent: agentCfgPayload,
|
|
230
|
+
toUser: senderId,
|
|
231
|
+
mediaId: uploadedId,
|
|
232
|
+
mediaType: uploadType,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Add hint in stream text
|
|
237
|
+
const hint = `📎 文件已通过私信发送给您:${mediaFilename}`;
|
|
238
|
+
if (streamId && streamManager.hasStream(streamId)) {
|
|
239
|
+
streamManager.appendStream(streamId, `\n\n${hint}`);
|
|
240
|
+
} else {
|
|
241
|
+
processedText = processedText ? `${processedText}\n\n${hint}` : hint;
|
|
242
|
+
}
|
|
243
|
+
logger.info("Sent payload media via Agent DM", {
|
|
244
|
+
streamId,
|
|
245
|
+
filename: mediaFilename,
|
|
246
|
+
senderId,
|
|
247
|
+
});
|
|
248
|
+
} catch (payloadMediaErr) {
|
|
249
|
+
logger.error("Failed to send payload media via Agent DM", {
|
|
250
|
+
streamId,
|
|
251
|
+
mediaPath: mediaPath.substring(0, 80),
|
|
252
|
+
error: payloadMediaErr.message,
|
|
253
|
+
});
|
|
254
|
+
const errHint = `⚠️ 文件发送失败(${mediaFilename}):${payloadMediaErr.message}`;
|
|
255
|
+
if (streamId && streamManager.hasStream(streamId)) {
|
|
256
|
+
streamManager.appendStream(streamId, `\n\n${errHint}`);
|
|
257
|
+
} else {
|
|
258
|
+
processedText = processedText ? `${processedText}\n\n${errHint}` : errHint;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
const noAgentHint = `⚠️ 无法发送文件 ${mediaFilename}(未配置 Agent API)`;
|
|
263
|
+
if (streamId && streamManager.hasStream(streamId)) {
|
|
264
|
+
streamManager.appendStream(streamId, `\n\n${noAgentHint}`);
|
|
265
|
+
} else {
|
|
266
|
+
processedText = processedText ? `${processedText}\n\n${noAgentHint}` : noAgentHint;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
274
|
+
// Auto-detect /workspace/… file paths in LLM reply text.
|
|
275
|
+
// The sandbox container mounts /workspace → host ~/.openclaw/workspace-{agentId}.
|
|
276
|
+
// When the LLM mentions a file path like "/workspace/report.pdf", we resolve
|
|
277
|
+
// the host-side path, verify the file exists, and send it via Agent DM.
|
|
278
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
279
|
+
if (effectiveAgentId && processedText) {
|
|
280
|
+
// Match /workspace/ paths (non-greedy: stop at whitespace, quotes, backticks,
|
|
281
|
+
// angle brackets, parentheses, or end of string).
|
|
282
|
+
const workspacePathRegex = /\/workspace\/[^\s"'`<>()]+/g;
|
|
283
|
+
const detectedPaths = [];
|
|
284
|
+
let wpMatch;
|
|
285
|
+
while ((wpMatch = workspacePathRegex.exec(processedText)) !== null) {
|
|
286
|
+
const rawPath = wpMatch[0]
|
|
287
|
+
// Strip trailing punctuation that is likely not part of the filename.
|
|
288
|
+
.replace(/[.,;:!?。,;:!?)》」』\]]+$/, "");
|
|
289
|
+
if (rawPath.length > "/workspace/".length) {
|
|
290
|
+
detectedPaths.push(rawPath);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (detectedPaths.length > 0) {
|
|
295
|
+
const workspaceDir = resolveAgentWorkspaceDirLocal(effectiveAgentId);
|
|
296
|
+
const agentCfgAuto = resolveAgentConfig();
|
|
297
|
+
const imageExtsAuto = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
|
|
298
|
+
|
|
299
|
+
for (const wsPath of detectedPaths) {
|
|
300
|
+
// /workspace/foo.pdf → hostDir/foo.pdf
|
|
301
|
+
const relativePath = wsPath.replace(/^\/workspace\/?/, "");
|
|
302
|
+
if (!relativePath) continue;
|
|
303
|
+
const hostPath = join(workspaceDir, relativePath);
|
|
304
|
+
const filename = basename(hostPath);
|
|
305
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
306
|
+
|
|
307
|
+
// Skip image files — they are handled by the stream msg_item mechanism.
|
|
308
|
+
if (imageExtsAuto.has(ext)) continue;
|
|
309
|
+
|
|
310
|
+
// Check file existence on host.
|
|
311
|
+
try {
|
|
312
|
+
await access(hostPath);
|
|
313
|
+
} catch {
|
|
314
|
+
logger.debug("Auto-detect: workspace file not found on host, skipping", {
|
|
315
|
+
wsPath,
|
|
316
|
+
hostPath,
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// File exists on host — send via Agent DM.
|
|
322
|
+
if (agentCfgAuto && senderId) {
|
|
323
|
+
try {
|
|
324
|
+
const hint = await uploadAndSendFile({
|
|
325
|
+
hostPath,
|
|
326
|
+
filename,
|
|
327
|
+
agent: agentCfgAuto,
|
|
328
|
+
senderId,
|
|
329
|
+
streamId,
|
|
330
|
+
});
|
|
331
|
+
// Replace the path mention in text with a delivery hint.
|
|
332
|
+
// Also strip any preceding "MEDIA:" prefix if the LLM wrote "MEDIA:/workspace/…".
|
|
333
|
+
const escapedPath = wsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
334
|
+
const withMediaPrefix = new RegExp(`MEDIA:\\s*${escapedPath}`, "g");
|
|
335
|
+
if (withMediaPrefix.test(processedText)) {
|
|
336
|
+
processedText = processedText.replace(withMediaPrefix, hint);
|
|
337
|
+
} else {
|
|
338
|
+
processedText = processedText.replace(wsPath, hint);
|
|
339
|
+
}
|
|
340
|
+
logger.info("Auto-detect: sent workspace file via Agent DM", {
|
|
341
|
+
streamId,
|
|
342
|
+
wsPath,
|
|
343
|
+
hostPath,
|
|
344
|
+
filename,
|
|
345
|
+
senderId,
|
|
346
|
+
});
|
|
347
|
+
} catch (autoErr) {
|
|
348
|
+
processedText = processedText.replace(
|
|
349
|
+
wsPath,
|
|
350
|
+
`⚠️ 文件「${filename}」发送失败:${autoErr.message}`,
|
|
351
|
+
);
|
|
352
|
+
logger.error("Auto-detect: failed to send workspace file via Agent DM", {
|
|
353
|
+
streamId,
|
|
354
|
+
wsPath,
|
|
355
|
+
hostPath,
|
|
356
|
+
error: autoErr.message,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// All outbound content is sent via stream updates.
|
|
365
|
+
if (!processedText.trim()) {
|
|
366
|
+
logger.debug("WeCom: empty block after processing, skipping stream update");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Helper: append content with duplicate suppression and placeholder awareness.
|
|
371
|
+
const appendToStream = (targetStreamId, content) => {
|
|
372
|
+
const stream = streamManager.getStream(targetStreamId);
|
|
373
|
+
if (!stream) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// If stream still has the placeholder, replace it entirely.
|
|
378
|
+
if (stream.content.trim() === THINKING_PLACEHOLDER.trim()) {
|
|
379
|
+
streamManager.replaceIfPlaceholder(targetStreamId, content, THINKING_PLACEHOLDER);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Skip duplicate chunks (for example, block + final overlap).
|
|
384
|
+
if (stream.content.includes(content.trim())) {
|
|
385
|
+
logger.debug("WeCom: duplicate content, skipping", {
|
|
386
|
+
streamId: targetStreamId,
|
|
387
|
+
contentPreview: content.substring(0, 30),
|
|
388
|
+
});
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const separator = stream.content.length > 0 ? "\n\n" : "";
|
|
393
|
+
streamManager.appendStream(targetStreamId, separator + content);
|
|
394
|
+
return true;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
if (!streamId) {
|
|
398
|
+
// Try async context first, then fallback to active stream map.
|
|
399
|
+
const ctx = streamContext.getStore();
|
|
400
|
+
const contextStreamId = ctx?.streamId;
|
|
401
|
+
const activeStreamId = contextStreamId ?? resolveActiveStream(senderId);
|
|
402
|
+
|
|
403
|
+
if (activeStreamId && streamManager.hasStream(activeStreamId)) {
|
|
404
|
+
appendToStream(activeStreamId, processedText);
|
|
405
|
+
logger.debug("WeCom stream appended (via context/activeStreams)", {
|
|
406
|
+
streamId: activeStreamId,
|
|
407
|
+
source: contextStreamId ? "asyncContext" : "activeStreams",
|
|
408
|
+
contentLength: processedText.length,
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
logger.warn("WeCom: no active stream for this message", { senderId });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!streamManager.hasStream(streamId)) {
|
|
417
|
+
logger.warn("WeCom: stream not found, attempting response_url fallback", { streamId, senderId });
|
|
418
|
+
|
|
419
|
+
// Layer 2: Fallback via response_url (stream closed, but response_url may still be valid)
|
|
420
|
+
const saved = responseUrls.get(senderId);
|
|
421
|
+
if (saved && !saved.used && Date.now() < saved.expiresAt) {
|
|
422
|
+
try {
|
|
423
|
+
const response = await fetch(saved.url, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "Content-Type": "application/json" },
|
|
426
|
+
body: JSON.stringify({ msgtype: "text", text: { content: processedText } }),
|
|
427
|
+
});
|
|
428
|
+
const responseBody = await response.text().catch(() => "");
|
|
429
|
+
const result = parseResponseUrlResult(response, responseBody);
|
|
430
|
+
if (!result.accepted) {
|
|
431
|
+
logger.error("WeCom: response_url fallback rejected (deliverWecomReply)", {
|
|
432
|
+
senderId,
|
|
433
|
+
status: response.status,
|
|
434
|
+
statusText: response.statusText,
|
|
435
|
+
errcode: result.errcode,
|
|
436
|
+
errmsg: result.errmsg,
|
|
437
|
+
bodyPreview: result.bodyPreview,
|
|
438
|
+
});
|
|
439
|
+
} else {
|
|
440
|
+
saved.used = true;
|
|
441
|
+
logger.info("WeCom: sent via response_url fallback (deliverWecomReply)", {
|
|
442
|
+
senderId,
|
|
443
|
+
status: response.status,
|
|
444
|
+
errcode: result.errcode,
|
|
445
|
+
contentPreview: processedText.substring(0, 50),
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
} catch (err) {
|
|
450
|
+
logger.error("WeCom: response_url fallback failed", {
|
|
451
|
+
senderId,
|
|
452
|
+
error: err.message,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Layer 3: Agent API fallback (stream closed + response_url unavailable)
|
|
458
|
+
const agentConfig = resolveAgentConfig();
|
|
459
|
+
if (agentConfig) {
|
|
460
|
+
try {
|
|
461
|
+
await agentSendText({ agent: agentConfig, toUser: senderId, text: processedText });
|
|
462
|
+
logger.info("WeCom: sent via Agent API fallback (deliverWecomReply)", {
|
|
463
|
+
senderId,
|
|
464
|
+
contentPreview: processedText.substring(0, 50),
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
} catch (err) {
|
|
468
|
+
logger.error("WeCom: Agent API fallback failed", { senderId, error: err.message });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
logger.warn("WeCom: unable to deliver message (all layers exhausted)", {
|
|
472
|
+
senderId,
|
|
473
|
+
contentPreview: processedText.substring(0, 50),
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
appendToStream(streamId, processedText);
|
|
479
|
+
logger.debug("WeCom stream appended", {
|
|
480
|
+
streamId,
|
|
481
|
+
contentLength: processedText.length,
|
|
482
|
+
to: senderId,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { RESPONSE_URL_ERROR_BODY_PREVIEW_MAX } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export function normalizeWecomErrcode(value) {
|
|
4
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
|
|
8
|
+
return Number.parseInt(value.trim(), 10);
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseResponseUrlResult(response, responseBody) {
|
|
14
|
+
const bodyText = typeof responseBody === "string" ? responseBody.trim() : "";
|
|
15
|
+
let parsed = null;
|
|
16
|
+
if (bodyText) {
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(bodyText);
|
|
19
|
+
} catch {
|
|
20
|
+
parsed = null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const errcode = normalizeWecomErrcode(parsed?.errcode);
|
|
24
|
+
const errmsg = typeof parsed?.errmsg === "string" ? parsed.errmsg : "";
|
|
25
|
+
// WeCom response_url should return JSON with errcode=0 when accepted.
|
|
26
|
+
const accepted = response.ok && errcode === 0;
|
|
27
|
+
return {
|
|
28
|
+
accepted,
|
|
29
|
+
errcode,
|
|
30
|
+
errmsg,
|
|
31
|
+
bodyPreview: bodyText.substring(0, RESPONSE_URL_ERROR_BODY_PREVIEW_MAX),
|
|
32
|
+
};
|
|
33
|
+
}
|
package/wecom/state.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { WEBHOOK_BOT_SEND_URL } from "./constants.js";
|
|
3
|
+
import { resolveAgentConfigForAccount, resolveAccount } from "./accounts.js";
|
|
4
|
+
|
|
5
|
+
const runtimeState = {
|
|
6
|
+
runtime: null,
|
|
7
|
+
openclawConfig: null,
|
|
8
|
+
ensuredDynamicAgentIds: new Set(),
|
|
9
|
+
ensureDynamicAgentWriteQueue: Promise.resolve(),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const dispatchLocks = new Map();
|
|
13
|
+
export const messageBuffers = new Map();
|
|
14
|
+
export const webhookTargets = new Map();
|
|
15
|
+
export const activeStreams = new Map();
|
|
16
|
+
export const activeStreamHistory = new Map();
|
|
17
|
+
export const streamMeta = new Map();
|
|
18
|
+
export const responseUrls = new Map();
|
|
19
|
+
export const streamContext = new AsyncLocalStorage();
|
|
20
|
+
|
|
21
|
+
export function setRuntime(runtime) {
|
|
22
|
+
runtimeState.runtime = runtime;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getRuntime() {
|
|
26
|
+
if (!runtimeState.runtime) {
|
|
27
|
+
throw new Error("[wecom] Runtime not initialized");
|
|
28
|
+
}
|
|
29
|
+
return runtimeState.runtime;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setOpenclawConfig(config) {
|
|
33
|
+
runtimeState.openclawConfig = config;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getOpenclawConfig() {
|
|
37
|
+
return runtimeState.openclawConfig;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getEnsuredDynamicAgentIds() {
|
|
41
|
+
return runtimeState.ensuredDynamicAgentIds;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getEnsureDynamicAgentWriteQueue() {
|
|
45
|
+
return runtimeState.ensureDynamicAgentWriteQueue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setEnsureDynamicAgentWriteQueue(queuePromise) {
|
|
49
|
+
runtimeState.ensureDynamicAgentWriteQueue = queuePromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract Agent API config from the runtime openclaw config.
|
|
54
|
+
* Returns null when Agent mode is not configured.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} [accountId] - Optional account ID. When omitted, first tries
|
|
57
|
+
* the streamContext async store, then falls back to the default account.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveAgentConfig(accountId) {
|
|
60
|
+
const config = getOpenclawConfig();
|
|
61
|
+
// Determine effective accountId: explicit param > async context > default.
|
|
62
|
+
const effectiveId = accountId || streamContext.getStore()?.accountId || undefined;
|
|
63
|
+
return resolveAgentConfigForAccount(config, effectiveId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a webhook name to a full webhook URL.
|
|
68
|
+
* Supports both full URLs and bare keys in config.
|
|
69
|
+
* Returns null when the webhook name is not configured.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} name - Webhook name from the `to` field (e.g. "ops-group")
|
|
72
|
+
* @param {string} [accountId] - Optional account ID for multi-account lookup.
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function resolveWebhookUrl(name, accountId) {
|
|
76
|
+
const config = getOpenclawConfig();
|
|
77
|
+
const effectiveId = accountId || streamContext.getStore()?.accountId || undefined;
|
|
78
|
+
const account = resolveAccount(config, effectiveId);
|
|
79
|
+
const webhooks = account?.config?.webhooks;
|
|
80
|
+
if (!webhooks || !webhooks[name]) return null;
|
|
81
|
+
const value = webhooks[name];
|
|
82
|
+
if (value.startsWith("http")) return value;
|
|
83
|
+
return `${WEBHOOK_BOT_SEND_URL}?key=${value}`;
|
|
84
|
+
}
|