@sunnoy/wecom 1.9.0 → 2.0.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 +391 -845
- package/image-processor.js +30 -27
- package/index.js +10 -43
- package/package.json +5 -5
- package/think-parser.js +51 -11
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +543 -750
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -541
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -395
- package/wecom/inbound-processor.js +0 -562
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
package/wecom/media.js
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { WecomCrypto } from "../crypto.js";
|
|
4
|
-
import { logger } from "../logger.js";
|
|
5
|
-
import { MEDIA_CACHE_DIR } from "./constants.js";
|
|
6
|
-
import { wecomFetch } from "./http.js";
|
|
7
|
-
|
|
8
|
-
// ── Magic-byte signatures for common file formats ───────────────────────────
|
|
9
|
-
const MAGIC_SIGNATURES = [
|
|
10
|
-
{ magic: [0xff, 0xd8, 0xff], ext: "jpg" }, // JPEG
|
|
11
|
-
{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a], ext: "png" }, // PNG
|
|
12
|
-
{ magic: [0x47, 0x49, 0x46, 0x38], ext: "gif" }, // GIF
|
|
13
|
-
{ magic: [0x25, 0x50, 0x44, 0x46], ext: "pdf" }, // %PDF
|
|
14
|
-
{ magic: [0x50, 0x4b, 0x03, 0x04], ext: "zip" }, // PK (ZIP / DOCX / XLSX / PPTX)
|
|
15
|
-
{ magic: [0x50, 0x4b, 0x05, 0x06], ext: "zip" }, // PK (empty ZIP)
|
|
16
|
-
{ magic: [0xd0, 0xcf, 0x11, 0xe0], ext: "doc" }, // OLE2 (DOC / XLS / PPT)
|
|
17
|
-
{ magic: [0x52, 0x61, 0x72, 0x21], ext: "rar" }, // Rar!
|
|
18
|
-
{ magic: [0x1f, 0x8b], ext: "gz" }, // gzip
|
|
19
|
-
{ magic: [0x42, 0x4d], ext: "bmp" }, // BMP
|
|
20
|
-
{ magic: [0x49, 0x44, 0x33], ext: "mp3" }, // ID3 (MP3)
|
|
21
|
-
{ magic: [0x00, 0x00, 0x00], ext: "mp4" }, // ftyp box (loose)
|
|
22
|
-
{ magic: [0x52, 0x49, 0x46, 0x46], ext: "wav" }, // RIFF (WAV / AVI)
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Check whether a buffer looks like a recognisable (plain) file by inspecting
|
|
27
|
-
* its leading magic bytes. Returns the matched extension or `null`.
|
|
28
|
-
*/
|
|
29
|
-
function detectMagic(buf) {
|
|
30
|
-
if (!buf || buf.length < 4) return null;
|
|
31
|
-
for (const sig of MAGIC_SIGNATURES) {
|
|
32
|
-
if (buf.length >= sig.magic.length && sig.magic.every((b, i) => buf[i] === b)) {
|
|
33
|
-
return sig.ext;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Conditionally decrypt a buffer.
|
|
41
|
-
*
|
|
42
|
-
* WeCom encrypts media with AES-256-CBC in Agent mode, but in some Bot (AI
|
|
43
|
-
* 机器人) configurations the file URL returns an **unencrypted** payload.
|
|
44
|
-
* Blindly decrypting an already-plain file corrupts it (#44).
|
|
45
|
-
*
|
|
46
|
-
* Strategy:
|
|
47
|
-
* 1. If raw bytes already match a known file signature → skip decrypt.
|
|
48
|
-
* 2. Otherwise attempt AES-256-CBC decryption.
|
|
49
|
-
* 3. If decryption throws or the result has no recognisable signature,
|
|
50
|
-
* fall back to the original bytes (best-effort).
|
|
51
|
-
*/
|
|
52
|
-
function smartDecrypt(rawBuffer, encodingAesKey, token) {
|
|
53
|
-
const plainExt = detectMagic(rawBuffer);
|
|
54
|
-
if (plainExt) {
|
|
55
|
-
logger.info("Media is already plain (skip decrypt)", { magic: plainExt, size: rawBuffer.length });
|
|
56
|
-
return { buffer: rawBuffer, decrypted: false };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const crypto = new WecomCrypto(token, encodingAesKey);
|
|
61
|
-
const dec = crypto.decryptMedia(rawBuffer);
|
|
62
|
-
logger.info("Media decrypted", { inputSize: rawBuffer.length, outputSize: dec.length });
|
|
63
|
-
return { buffer: dec, decrypted: true };
|
|
64
|
-
} catch (e) {
|
|
65
|
-
logger.warn("Decrypt failed, using raw bytes", { error: e.message, size: rawBuffer.length });
|
|
66
|
-
return { buffer: rawBuffer, decrypted: false };
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ── Image helpers ───────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
function detectImageExt(buf) {
|
|
73
|
-
if (buf[0] === 0x89 && buf[1] === 0x50) return "png";
|
|
74
|
-
if (buf[0] === 0x47 && buf[1] === 0x49) return "gif";
|
|
75
|
-
return "jpg";
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Download and (conditionally) decrypt a WeCom image.
|
|
80
|
-
* @param {string} imageUrl - Image URL from WeCom callback
|
|
81
|
-
* @param {string} encodingAesKey - AES key
|
|
82
|
-
* @param {string} token - Token
|
|
83
|
-
* @returns {Promise<{ localPath: string, mimeType: string }>}
|
|
84
|
-
*/
|
|
85
|
-
export async function downloadAndDecryptImage(imageUrl, encodingAesKey, token) {
|
|
86
|
-
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
87
|
-
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
logger.info("Downloading image", { url: imageUrl.substring(0, 80) });
|
|
91
|
-
const response = await wecomFetch(imageUrl);
|
|
92
|
-
if (!response.ok) {
|
|
93
|
-
throw new Error(`Failed to download image: ${response.status}`);
|
|
94
|
-
}
|
|
95
|
-
const rawBuffer = Buffer.from(await response.arrayBuffer());
|
|
96
|
-
logger.debug("Downloaded image bytes", { size: rawBuffer.length });
|
|
97
|
-
|
|
98
|
-
const { buffer: finalBuffer } = smartDecrypt(rawBuffer, encodingAesKey, token);
|
|
99
|
-
|
|
100
|
-
const ext = detectImageExt(finalBuffer);
|
|
101
|
-
const filename = `wecom_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`;
|
|
102
|
-
const localPath = join(MEDIA_CACHE_DIR, filename);
|
|
103
|
-
writeFileSync(localPath, finalBuffer);
|
|
104
|
-
|
|
105
|
-
const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
106
|
-
logger.info("Image saved", { path: localPath, size: finalBuffer.length, mimeType });
|
|
107
|
-
return { localPath, mimeType };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── File helpers ────────────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Download and (conditionally) decrypt a file from WeCom.
|
|
114
|
-
*
|
|
115
|
-
* In Agent mode WeCom encrypts all media with AES-256-CBC; in Bot mode the
|
|
116
|
-
* file URL may return plain bytes. This function auto-detects the case and
|
|
117
|
-
* only decrypts when necessary, preventing file corruption (#44).
|
|
118
|
-
*
|
|
119
|
-
* @param {string} fileUrl - File download URL
|
|
120
|
-
* @param {string} fileName - Original file name
|
|
121
|
-
* @param {string} encodingAesKey - AES key for decryption
|
|
122
|
-
* @param {string} token - Token for decryption
|
|
123
|
-
* @returns {Promise<{ localPath: string, effectiveFileName: string }>}
|
|
124
|
-
*/
|
|
125
|
-
export async function downloadWecomFile(fileUrl, fileName, encodingAesKey, token) {
|
|
126
|
-
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
127
|
-
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
logger.info("Downloading file", { url: fileUrl.substring(0, 80), name: fileName });
|
|
131
|
-
const response = await wecomFetch(fileUrl);
|
|
132
|
-
if (!response.ok) {
|
|
133
|
-
throw new Error(`Failed to download file: ${response.status}`);
|
|
134
|
-
}
|
|
135
|
-
const rawBuffer = Buffer.from(await response.arrayBuffer());
|
|
136
|
-
|
|
137
|
-
// Try to extract filename from Content-Disposition header if not provided
|
|
138
|
-
let effectiveFileName = fileName;
|
|
139
|
-
if (!effectiveFileName) {
|
|
140
|
-
const contentDisposition = response.headers.get("content-disposition");
|
|
141
|
-
if (contentDisposition) {
|
|
142
|
-
// Match: filename="xxx.pdf" or filename*=UTF-8''xxx.pdf
|
|
143
|
-
const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)["']?/i);
|
|
144
|
-
if (filenameMatch && filenameMatch[1]) {
|
|
145
|
-
effectiveFileName = decodeURIComponent(filenameMatch[1]);
|
|
146
|
-
logger.info("Extracted filename from Content-Disposition", { name: effectiveFileName });
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Smart decrypt: only decrypt if the raw bytes are not already a valid file.
|
|
152
|
-
const { buffer: finalBuffer, decrypted } = smartDecrypt(rawBuffer, encodingAesKey, token);
|
|
153
|
-
logger.info("File processed", {
|
|
154
|
-
name: effectiveFileName,
|
|
155
|
-
rawSize: rawBuffer.length,
|
|
156
|
-
finalSize: finalBuffer.length,
|
|
157
|
-
wasDecrypted: decrypted,
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const safeName = (effectiveFileName || `file_${Date.now()}`).replace(/[/\\:*?"<>|]/g, "_");
|
|
161
|
-
const localPath = join(MEDIA_CACHE_DIR, `${Date.now()}_${safeName}`);
|
|
162
|
-
writeFileSync(localPath, finalBuffer);
|
|
163
|
-
|
|
164
|
-
logger.info("File saved", { path: localPath, size: finalBuffer.length });
|
|
165
|
-
return { localPath, effectiveFileName: effectiveFileName || fileName };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Guess MIME type from file extension.
|
|
170
|
-
*/
|
|
171
|
-
export function guessMimeType(fileName) {
|
|
172
|
-
const ext = (fileName || "").split(".").pop()?.toLowerCase() || "";
|
|
173
|
-
const mimeMap = {
|
|
174
|
-
pdf: "application/pdf",
|
|
175
|
-
doc: "application/msword",
|
|
176
|
-
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
177
|
-
xls: "application/vnd.ms-excel",
|
|
178
|
-
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
179
|
-
ppt: "application/vnd.ms-powerpoint",
|
|
180
|
-
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
181
|
-
txt: "text/plain",
|
|
182
|
-
csv: "text/csv",
|
|
183
|
-
zip: "application/zip",
|
|
184
|
-
png: "image/png",
|
|
185
|
-
jpg: "image/jpeg",
|
|
186
|
-
jpeg: "image/jpeg",
|
|
187
|
-
gif: "image/gif",
|
|
188
|
-
mp4: "video/mp4",
|
|
189
|
-
mp3: "audio/mpeg",
|
|
190
|
-
};
|
|
191
|
-
return mimeMap[ext] || "application/octet-stream";
|
|
192
|
-
}
|
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { basename, isAbsolute, relative, resolve, sep } 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
|
-
import { wecomFetch } from "./http.js";
|
|
12
|
-
|
|
13
|
-
// WeCom upload API rejects files smaller than 5 bytes (error 40006).
|
|
14
|
-
const WECOM_MIN_FILE_SIZE = 5;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Resolve sandbox /workspace/… paths to the host-side equivalent.
|
|
18
|
-
* Inside the sandbox container, /workspace is mounted from
|
|
19
|
-
* ~/.openclaw/workspace-{agentId} on the host. Any path starting with
|
|
20
|
-
* /workspace/ is transparently rewritten when an agentId is available.
|
|
21
|
-
*/
|
|
22
|
-
export function resolveWorkspaceHostPathSafe({ workspaceDir, workspacePath }) {
|
|
23
|
-
const relativePath = String(workspacePath || "").replace(/^\/workspace\/?/, "");
|
|
24
|
-
if (!relativePath) {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const hostPath = resolve(workspaceDir, relativePath);
|
|
29
|
-
const rel = relative(workspaceDir, hostPath);
|
|
30
|
-
const escapesWorkspace = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
31
|
-
if (escapesWorkspace) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
return hostPath;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveHostPath(filePath, effectiveAgentId) {
|
|
38
|
-
if (effectiveAgentId && filePath.startsWith("/workspace/")) {
|
|
39
|
-
const workspaceDir = resolveAgentWorkspaceDirLocal(effectiveAgentId);
|
|
40
|
-
const hostPath = resolveWorkspaceHostPathSafe({
|
|
41
|
-
workspaceDir,
|
|
42
|
-
workspacePath: filePath,
|
|
43
|
-
});
|
|
44
|
-
if (!hostPath) {
|
|
45
|
-
logger.warn("Rejected unsafe /workspace/ path outside workspace", {
|
|
46
|
-
sandbox: filePath,
|
|
47
|
-
workspaceDir,
|
|
48
|
-
agentId: effectiveAgentId,
|
|
49
|
-
});
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
logger.debug("Resolved sandbox path to host path", { sandbox: filePath, host: hostPath });
|
|
53
|
-
return hostPath;
|
|
54
|
-
}
|
|
55
|
-
return filePath;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Upload a local file and send via Agent DM.
|
|
60
|
-
* If the file is smaller than WECOM_MIN_FILE_SIZE (WeCom rejects tiny files),
|
|
61
|
-
* read the content and send as a text message instead.
|
|
62
|
-
* Returns a user-facing hint string.
|
|
63
|
-
*/
|
|
64
|
-
async function uploadAndSendFile({ hostPath, filename, agent, senderId, streamId }) {
|
|
65
|
-
const fileBuf = await readFile(hostPath);
|
|
66
|
-
if (fileBuf.length < WECOM_MIN_FILE_SIZE) {
|
|
67
|
-
// File too small for WeCom upload — send content inline as text.
|
|
68
|
-
const content = fileBuf.toString("utf-8");
|
|
69
|
-
await agentSendText({
|
|
70
|
-
agent,
|
|
71
|
-
toUser: senderId,
|
|
72
|
-
text: `📄 ${filename}:\n${content}`,
|
|
73
|
-
});
|
|
74
|
-
logger.info("Sent tiny file as text via Agent DM", {
|
|
75
|
-
streamId,
|
|
76
|
-
filename,
|
|
77
|
-
size: fileBuf.length,
|
|
78
|
-
});
|
|
79
|
-
return `📎 文件「${filename}」内容已通过私信发送给您`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const uploadedId = await agentUploadMedia({
|
|
83
|
-
agent,
|
|
84
|
-
type: "file",
|
|
85
|
-
buffer: fileBuf,
|
|
86
|
-
filename,
|
|
87
|
-
});
|
|
88
|
-
await agentSendMedia({
|
|
89
|
-
agent,
|
|
90
|
-
toUser: senderId,
|
|
91
|
-
mediaId: uploadedId,
|
|
92
|
-
mediaType: "file",
|
|
93
|
-
});
|
|
94
|
-
logger.info("Sent file via Agent DM", { streamId, filename, size: fileBuf.length });
|
|
95
|
-
return `📎 文件「${filename}」已通过私信发送给您`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function deliverWecomReply({ payload, senderId, streamId, agentId }) {
|
|
99
|
-
const text = payload.text || "";
|
|
100
|
-
// Resolve effective agentId from parameter or async context.
|
|
101
|
-
const effectiveAgentId = agentId || streamContext.getStore()?.agentId;
|
|
102
|
-
|
|
103
|
-
logger.debug("deliverWecomReply called", {
|
|
104
|
-
hasText: !!text.trim(),
|
|
105
|
-
textPreview: text.substring(0, 50),
|
|
106
|
-
streamId,
|
|
107
|
-
senderId,
|
|
108
|
-
agentId: effectiveAgentId,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// Handle absolute-path MEDIA lines manually; OpenClaw rejects these paths upstream.
|
|
112
|
-
// Match only line-start MEDIA directives to align with upstream OpenClaw.
|
|
113
|
-
const mediaRegex = /^MEDIA:\s*(.+?)$/gm;
|
|
114
|
-
const mediaMatches = [];
|
|
115
|
-
let match;
|
|
116
|
-
while ((match = mediaRegex.exec(text)) !== null) {
|
|
117
|
-
const mediaPath = match[1].trim();
|
|
118
|
-
// Only intercept absolute filesystem paths.
|
|
119
|
-
if (mediaPath.startsWith("/")) {
|
|
120
|
-
mediaMatches.push({
|
|
121
|
-
fullMatch: match[0],
|
|
122
|
-
path: mediaPath,
|
|
123
|
-
});
|
|
124
|
-
logger.debug("Detected absolute path MEDIA line", {
|
|
125
|
-
streamId,
|
|
126
|
-
mediaPath,
|
|
127
|
-
line: match[0],
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Queue absolute-path images; send non-image files via Agent DM.
|
|
133
|
-
const mediaImageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
|
|
134
|
-
let processedText = text;
|
|
135
|
-
if (mediaMatches.length > 0 && streamId) {
|
|
136
|
-
for (const media of mediaMatches) {
|
|
137
|
-
// Resolve /workspace/ sandbox paths to host-side paths.
|
|
138
|
-
const resolvedMediaPath = resolveHostPath(media.path, effectiveAgentId);
|
|
139
|
-
if (!resolvedMediaPath) {
|
|
140
|
-
processedText = processedText
|
|
141
|
-
.replace(media.fullMatch, "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送")
|
|
142
|
-
.trim();
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
const mediaExt = resolvedMediaPath.split(".").pop()?.toLowerCase() || "";
|
|
146
|
-
if (mediaImageExts.has(mediaExt)) {
|
|
147
|
-
// Image: queue for delivery when stream finishes.
|
|
148
|
-
const queued = streamManager.queueImage(streamId, resolvedMediaPath);
|
|
149
|
-
if (queued) {
|
|
150
|
-
processedText = processedText.replace(media.fullMatch, "").trim();
|
|
151
|
-
logger.info("Queued absolute path image for stream", {
|
|
152
|
-
streamId,
|
|
153
|
-
imagePath: resolvedMediaPath,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
} else {
|
|
157
|
-
// Non-image file: WeCom Bot stream API does not support files.
|
|
158
|
-
// Send via Agent DM and replace the MEDIA line with a hint.
|
|
159
|
-
const mediaFilename = basename(resolvedMediaPath);
|
|
160
|
-
const agentCfgMedia = resolveAgentConfig();
|
|
161
|
-
if (agentCfgMedia && senderId) {
|
|
162
|
-
try {
|
|
163
|
-
const hint = await uploadAndSendFile({
|
|
164
|
-
hostPath: resolvedMediaPath,
|
|
165
|
-
filename: mediaFilename,
|
|
166
|
-
agent: agentCfgMedia,
|
|
167
|
-
senderId,
|
|
168
|
-
streamId,
|
|
169
|
-
});
|
|
170
|
-
processedText = processedText
|
|
171
|
-
.replace(media.fullMatch, hint)
|
|
172
|
-
.trim();
|
|
173
|
-
logger.info("Sent non-image file via Agent DM (MEDIA line)", {
|
|
174
|
-
streamId,
|
|
175
|
-
filename: mediaFilename,
|
|
176
|
-
senderId,
|
|
177
|
-
});
|
|
178
|
-
} catch (mediaErr) {
|
|
179
|
-
processedText = processedText
|
|
180
|
-
.replace(media.fullMatch, `⚠️ 文件发送失败(${mediaFilename}):${mediaErr.message}`)
|
|
181
|
-
.trim();
|
|
182
|
-
logger.error("Failed to send non-image file via Agent DM (MEDIA line)", {
|
|
183
|
-
streamId,
|
|
184
|
-
filename: mediaFilename,
|
|
185
|
-
error: mediaErr.message,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
// No agent configured or no sender — just strip the MEDIA line.
|
|
190
|
-
processedText = processedText
|
|
191
|
-
.replace(media.fullMatch, `⚠️ 无法发送文件 ${mediaFilename}(未配置 Agent API)`)
|
|
192
|
-
.trim();
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Handle payload.mediaUrl / payload.mediaUrls from OpenClaw core dispatcher.
|
|
199
|
-
// These are local file paths or remote URLs that the LLM wants to deliver as media.
|
|
200
|
-
const payloadMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
201
|
-
if (payloadMediaUrls.length > 0) {
|
|
202
|
-
const payloadImageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
|
|
203
|
-
for (const mediaPath of payloadMediaUrls) {
|
|
204
|
-
// Normalize sandbox: prefix
|
|
205
|
-
let absPath = mediaPath;
|
|
206
|
-
if (absPath.startsWith("sandbox:")) {
|
|
207
|
-
absPath = absPath.replace(/^sandbox:\/{0,2}/, "");
|
|
208
|
-
if (!absPath.startsWith("/")) absPath = "/" + absPath;
|
|
209
|
-
}
|
|
210
|
-
// Resolve /workspace/ sandbox paths to host-side paths.
|
|
211
|
-
absPath = resolveHostPath(absPath, effectiveAgentId);
|
|
212
|
-
if (!absPath) {
|
|
213
|
-
const unsafeHint = "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送";
|
|
214
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
215
|
-
streamManager.appendStream(streamId, `\n\n${unsafeHint}`);
|
|
216
|
-
} else {
|
|
217
|
-
processedText = processedText ? `${processedText}\n\n${unsafeHint}` : unsafeHint;
|
|
218
|
-
}
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const isLocal = absPath.startsWith("/");
|
|
223
|
-
const mediaFilename = isLocal ? basename(absPath) : (basename(new URL(mediaPath).pathname) || "file");
|
|
224
|
-
const ext = mediaFilename.split(".").pop()?.toLowerCase() || "";
|
|
225
|
-
|
|
226
|
-
if (isLocal && payloadImageExts.has(ext) && streamId) {
|
|
227
|
-
// Image: queue for delivery via stream msg_item when stream finishes.
|
|
228
|
-
const queued = streamManager.queueImage(streamId, absPath);
|
|
229
|
-
if (queued) {
|
|
230
|
-
logger.info("Queued payload image for stream", { streamId, imagePath: absPath });
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
// Non-image file (or image without active stream): send via Agent DM.
|
|
234
|
-
const agentCfgPayload = resolveAgentConfig();
|
|
235
|
-
if (agentCfgPayload && senderId) {
|
|
236
|
-
try {
|
|
237
|
-
let fileBuf;
|
|
238
|
-
if (isLocal) {
|
|
239
|
-
fileBuf = await readFile(absPath);
|
|
240
|
-
} else {
|
|
241
|
-
const res = await wecomFetch(mediaPath);
|
|
242
|
-
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
243
|
-
fileBuf = Buffer.from(await res.arrayBuffer());
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Determine upload type based on content type
|
|
247
|
-
let uploadType = "file";
|
|
248
|
-
if (payloadImageExts.has(ext)) uploadType = "image";
|
|
249
|
-
|
|
250
|
-
// Check minimum file size for WeCom upload.
|
|
251
|
-
if (fileBuf.length < WECOM_MIN_FILE_SIZE) {
|
|
252
|
-
const content = fileBuf.toString("utf-8");
|
|
253
|
-
await agentSendText({
|
|
254
|
-
agent: agentCfgPayload,
|
|
255
|
-
toUser: senderId,
|
|
256
|
-
text: `📄 ${mediaFilename}:\n${content}`,
|
|
257
|
-
});
|
|
258
|
-
logger.info("Sent tiny payload media as text via Agent DM", {
|
|
259
|
-
streamId,
|
|
260
|
-
filename: mediaFilename,
|
|
261
|
-
size: fileBuf.length,
|
|
262
|
-
});
|
|
263
|
-
} else {
|
|
264
|
-
const uploadedId = await agentUploadMedia({
|
|
265
|
-
agent: agentCfgPayload,
|
|
266
|
-
type: uploadType,
|
|
267
|
-
buffer: fileBuf,
|
|
268
|
-
filename: mediaFilename,
|
|
269
|
-
});
|
|
270
|
-
await agentSendMedia({
|
|
271
|
-
agent: agentCfgPayload,
|
|
272
|
-
toUser: senderId,
|
|
273
|
-
mediaId: uploadedId,
|
|
274
|
-
mediaType: uploadType,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Add hint in stream text
|
|
279
|
-
const hint = `📎 文件已通过私信发送给您:${mediaFilename}`;
|
|
280
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
281
|
-
streamManager.appendStream(streamId, `\n\n${hint}`);
|
|
282
|
-
} else {
|
|
283
|
-
processedText = processedText ? `${processedText}\n\n${hint}` : hint;
|
|
284
|
-
}
|
|
285
|
-
logger.info("Sent payload media via Agent DM", {
|
|
286
|
-
streamId,
|
|
287
|
-
filename: mediaFilename,
|
|
288
|
-
senderId,
|
|
289
|
-
});
|
|
290
|
-
} catch (payloadMediaErr) {
|
|
291
|
-
logger.error("Failed to send payload media via Agent DM", {
|
|
292
|
-
streamId,
|
|
293
|
-
mediaPath: mediaPath.substring(0, 80),
|
|
294
|
-
error: payloadMediaErr.message,
|
|
295
|
-
});
|
|
296
|
-
const errHint = `⚠️ 文件发送失败(${mediaFilename}):${payloadMediaErr.message}`;
|
|
297
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
298
|
-
streamManager.appendStream(streamId, `\n\n${errHint}`);
|
|
299
|
-
} else {
|
|
300
|
-
processedText = processedText ? `${processedText}\n\n${errHint}` : errHint;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
} else {
|
|
304
|
-
const noAgentHint = `⚠️ 无法发送文件 ${mediaFilename}(未配置 Agent API)`;
|
|
305
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
306
|
-
streamManager.appendStream(streamId, `\n\n${noAgentHint}`);
|
|
307
|
-
} else {
|
|
308
|
-
processedText = processedText ? `${processedText}\n\n${noAgentHint}` : noAgentHint;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// All outbound content is sent via stream updates.
|
|
316
|
-
if (!processedText.trim()) {
|
|
317
|
-
logger.debug("WeCom: empty block after processing, skipping stream update");
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Helper: append content with duplicate suppression and placeholder awareness.
|
|
322
|
-
const appendToStream = (targetStreamId, content) => {
|
|
323
|
-
const stream = streamManager.getStream(targetStreamId);
|
|
324
|
-
if (!stream) {
|
|
325
|
-
return false;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// If stream still has the placeholder, replace it entirely.
|
|
329
|
-
if (stream.content.trim() === THINKING_PLACEHOLDER.trim()) {
|
|
330
|
-
streamManager.replaceIfPlaceholder(targetStreamId, content, THINKING_PLACEHOLDER);
|
|
331
|
-
return true;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Skip duplicate chunks (for example, block + final overlap).
|
|
335
|
-
if (stream.content.includes(content.trim())) {
|
|
336
|
-
logger.debug("WeCom: duplicate content, skipping", {
|
|
337
|
-
streamId: targetStreamId,
|
|
338
|
-
contentPreview: content.substring(0, 30),
|
|
339
|
-
});
|
|
340
|
-
return true;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const separator = stream.content.length > 0 ? "\n\n" : "";
|
|
344
|
-
streamManager.appendStream(targetStreamId, separator + content);
|
|
345
|
-
return true;
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
if (!streamId) {
|
|
349
|
-
// Try async context first, then fallback to active stream map.
|
|
350
|
-
const ctx = streamContext.getStore();
|
|
351
|
-
const contextStreamId = ctx?.streamId;
|
|
352
|
-
const activeStreamId = contextStreamId ?? resolveActiveStream(senderId);
|
|
353
|
-
|
|
354
|
-
if (activeStreamId && streamManager.hasStream(activeStreamId)) {
|
|
355
|
-
appendToStream(activeStreamId, processedText);
|
|
356
|
-
logger.debug("WeCom stream appended (via context/activeStreams)", {
|
|
357
|
-
streamId: activeStreamId,
|
|
358
|
-
source: contextStreamId ? "asyncContext" : "activeStreams",
|
|
359
|
-
contentLength: processedText.length,
|
|
360
|
-
});
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
logger.warn("WeCom: no active stream for this message", { senderId });
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (!streamManager.hasStream(streamId)) {
|
|
368
|
-
logger.warn("WeCom: stream not found, attempting response_url fallback", { streamId, senderId });
|
|
369
|
-
|
|
370
|
-
// Layer 2: Fallback via response_url (stream closed, but response_url may still be valid)
|
|
371
|
-
const saved = responseUrls.get(senderId);
|
|
372
|
-
if (saved && !saved.used && Date.now() < saved.expiresAt) {
|
|
373
|
-
try {
|
|
374
|
-
const response = await wecomFetch(saved.url, {
|
|
375
|
-
method: "POST",
|
|
376
|
-
headers: { "Content-Type": "application/json" },
|
|
377
|
-
body: JSON.stringify({ msgtype: "markdown", markdown: { content: processedText } }),
|
|
378
|
-
});
|
|
379
|
-
const responseBody = await response.text().catch(() => "");
|
|
380
|
-
const result = parseResponseUrlResult(response, responseBody);
|
|
381
|
-
if (!result.accepted) {
|
|
382
|
-
logger.error("WeCom: response_url fallback rejected (deliverWecomReply)", {
|
|
383
|
-
senderId,
|
|
384
|
-
status: response.status,
|
|
385
|
-
statusText: response.statusText,
|
|
386
|
-
errcode: result.errcode,
|
|
387
|
-
errmsg: result.errmsg,
|
|
388
|
-
bodyPreview: result.bodyPreview,
|
|
389
|
-
});
|
|
390
|
-
} else {
|
|
391
|
-
saved.used = true;
|
|
392
|
-
logger.info("WeCom: sent via response_url fallback (deliverWecomReply)", {
|
|
393
|
-
senderId,
|
|
394
|
-
status: response.status,
|
|
395
|
-
errcode: result.errcode,
|
|
396
|
-
contentPreview: processedText.substring(0, 50),
|
|
397
|
-
});
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
} catch (err) {
|
|
401
|
-
logger.error("WeCom: response_url fallback failed", {
|
|
402
|
-
senderId,
|
|
403
|
-
error: err.message,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Layer 3: Agent API fallback (stream closed + response_url unavailable)
|
|
409
|
-
const agentConfig = resolveAgentConfig();
|
|
410
|
-
if (agentConfig) {
|
|
411
|
-
try {
|
|
412
|
-
await agentSendText({ agent: agentConfig, toUser: senderId, text: processedText });
|
|
413
|
-
logger.info("WeCom: sent via Agent API fallback (deliverWecomReply)", {
|
|
414
|
-
senderId,
|
|
415
|
-
contentPreview: processedText.substring(0, 50),
|
|
416
|
-
});
|
|
417
|
-
return;
|
|
418
|
-
} catch (err) {
|
|
419
|
-
logger.error("WeCom: Agent API fallback failed", { senderId, error: err.message });
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
logger.warn("WeCom: unable to deliver message (all layers exhausted)", {
|
|
423
|
-
senderId,
|
|
424
|
-
contentPreview: processedText.substring(0, 50),
|
|
425
|
-
});
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
appendToStream(streamId, processedText);
|
|
430
|
-
logger.debug("WeCom stream appended", {
|
|
431
|
-
streamId,
|
|
432
|
-
contentLength: processedText.length,
|
|
433
|
-
to: senderId,
|
|
434
|
-
});
|
|
435
|
-
}
|
package/wecom/response-url.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|