openclaw-home-pc/openclaw/extensions/wecom-openclaw-plugin/dist/index.esm.js
2026-03-21 15:31:06 +08:00

2794 lines
105 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readJsonFileWithFallback, withFileLock, writeJsonFileAtomically, DEFAULT_ACCOUNT_ID, addWildcardAllowFrom, formatPairingApproveHint, emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
import * as os$1 from 'os';
import os__default$1 from 'os';
import * as path$1 from 'path';
import path__default from 'path';
import { generateReqId, WSClient } from '@wecom/aibot-node-sdk';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import path__default$1 from 'node:path';
import * as os from 'node:os';
import os__default from 'node:os';
import { fileURLToPath } from 'node:url';
import { fileURLToPath as fileURLToPath$1 } from 'url';
import { fileTypeFromBuffer } from 'file-type';
let runtime = null;
function setWeComRuntime(r) {
runtime = r;
}
function getWeComRuntime() {
if (!runtime) {
throw new Error("WeCom runtime not initialized - plugin not registered");
}
return runtime;
}
/**
* openclaw plugin-sdk 高版本方法兼容层
*
* 部分方法(如 loadOutboundMediaFromUrl、detectMime、getDefaultMediaLocalRoots
* 仅在较新版本的 openclaw plugin-sdk 中才导出。
*
* 本模块在加载时一次性探测 SDK 导出,存在则直接 re-export SDK 版本,
* 不存在则导出 fallback 实现。其他模块统一从本文件导入,无需关心底层兼容细节。
*/
const _sdkReady = import('openclaw/plugin-sdk')
.then((sdk) => {
const exports$1 = {};
if (typeof sdk.loadOutboundMediaFromUrl === "function") {
exports$1.loadOutboundMediaFromUrl = sdk.loadOutboundMediaFromUrl;
}
if (typeof sdk.detectMime === "function") {
exports$1.detectMime = sdk.detectMime;
}
if (typeof sdk.getDefaultMediaLocalRoots === "function") {
exports$1.getDefaultMediaLocalRoots = sdk.getDefaultMediaLocalRoots;
}
return exports$1;
})
.catch(() => {
// openclaw/plugin-sdk 不可用或版本过低,全部使用 fallback
return {};
});
// ============================================================================
// detectMime —— 检测 MIME 类型
// ============================================================================
const MIME_BY_EXT = {
".heic": "image/heic",
".heif": "image/heif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".ogg": "audio/ogg",
".mp3": "audio/mpeg",
".m4a": "audio/x-m4a",
".mp4": "video/mp4",
".mov": "video/quicktime",
".pdf": "application/pdf",
".json": "application/json",
".zip": "application/zip",
".gz": "application/gzip",
".tar": "application/x-tar",
".7z": "application/x-7z-compressed",
".rar": "application/vnd.rar",
".doc": "application/msword",
".xls": "application/vnd.ms-excel",
".ppt": "application/vnd.ms-powerpoint",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".csv": "text/csv",
".txt": "text/plain",
".md": "text/markdown",
".amr": "audio/amr",
".aac": "audio/aac",
".wav": "audio/wav",
".webm": "video/webm",
".avi": "video/x-msvideo",
".bmp": "image/bmp",
".svg": "image/svg+xml",
};
/** 通过 buffer 魔术字节嗅探 MIME 类型(动态导入 file-type不强依赖 */
async function sniffMimeFromBuffer(buffer) {
try {
const { fileTypeFromBuffer } = await import('file-type');
const type = await fileTypeFromBuffer(buffer);
return type?.mime ?? undefined;
}
catch {
return undefined;
}
}
/** fallback 版 detectMime参考 weclaw/src/media/mime.ts */
async function detectMimeFallback(opts) {
const ext = opts.filePath ? path.extname(opts.filePath).toLowerCase() : undefined;
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
const sniffed = opts.buffer ? await sniffMimeFromBuffer(opts.buffer) : undefined;
const isGeneric = (m) => !m || m === "application/octet-stream" || m === "application/zip";
if (sniffed && (!isGeneric(sniffed) || !extMime)) {
return sniffed;
}
if (extMime) {
return extMime;
}
const headerMime = opts.headerMime?.split(";")?.[0]?.trim().toLowerCase();
if (headerMime && !isGeneric(headerMime)) {
return headerMime;
}
if (sniffed) {
return sniffed;
}
if (headerMime) {
return headerMime;
}
return undefined;
}
/**
* 检测 MIME 类型(兼容入口)
*
* 支持两种调用签名以兼容不同使用场景:
* - detectMime(buffer) → 旧式调用
* - detectMime({ buffer, headerMime, filePath }) → 完整参数
*
* 优先使用 SDK 版本,不可用时使用 fallback。
*/
async function detectMime(bufferOrOpts) {
const sdk = await _sdkReady;
const opts = Buffer.isBuffer(bufferOrOpts)
? { buffer: bufferOrOpts }
: bufferOrOpts;
if (sdk.detectMime) {
try {
return await sdk.detectMime(opts);
}
catch {
// SDK detectMime 异常,降级到 fallback
}
}
return detectMimeFallback(opts);
}
// ============================================================================
// loadOutboundMediaFromUrl —— 从 URL/路径加载媒体文件
// ============================================================================
/** 安全的本地文件路径校验,参考 weclaw/src/web/media.ts */
async function assertLocalMediaAllowed(mediaPath, localRoots) {
if (!localRoots || localRoots.length === 0) {
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
}
let resolved;
try {
resolved = await fs.realpath(mediaPath);
}
catch {
resolved = path.resolve(mediaPath);
}
for (const root of localRoots) {
let resolvedRoot;
try {
resolvedRoot = await fs.realpath(root);
}
catch {
resolvedRoot = path.resolve(root);
}
if (resolvedRoot === path.parse(resolvedRoot).root) {
continue;
}
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
return;
}
}
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
}
/** 从远程 URL 获取媒体 */
async function fetchRemoteMedia(url, maxBytes) {
const res = await fetch(url, { redirect: "follow" });
if (!res.ok) {
throw new Error(`Failed to fetch media from ${url}: HTTP ${res.status} ${res.statusText}`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (maxBytes && buffer.length > maxBytes) {
throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
}
const headerMime = res.headers.get("content-type")?.split(";")?.[0]?.trim();
let fileName;
const disposition = res.headers.get("content-disposition");
if (disposition) {
const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
if (match?.[1]) {
try {
fileName = path.basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
}
catch {
fileName = path.basename(match[1].replace(/["']/g, "").trim());
}
}
}
if (!fileName) {
try {
const parsed = new URL(url);
const base = path.basename(parsed.pathname);
if (base && base.includes("."))
fileName = base;
}
catch { /* ignore */ }
}
const contentType = await detectMimeFallback({ buffer, headerMime, filePath: fileName ?? url });
return { buffer, contentType, fileName };
}
/** 展开 ~ 为用户主目录 */
function resolveUserPath(p) {
if (p.startsWith("~")) {
return path.join(os.homedir(), p.slice(1));
}
return p;
}
/** fallback 版 loadOutboundMediaFromUrl参考 weclaw/src/web/media.ts */
async function loadOutboundMediaFromUrlFallback(mediaUrl, options = {}) {
const { maxBytes, mediaLocalRoots } = options;
// 去除 MEDIA: 前缀
mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
// 处理 file:// URL
if (mediaUrl.startsWith("file://")) {
try {
mediaUrl = fileURLToPath(mediaUrl);
}
catch {
throw new Error(`Invalid file:// URL: ${mediaUrl}`);
}
}
// 远程 URL
if (/^https?:\/\//i.test(mediaUrl)) {
const fetched = await fetchRemoteMedia(mediaUrl, maxBytes);
return {
buffer: fetched.buffer,
contentType: fetched.contentType,
fileName: fetched.fileName,
};
}
// 展开 ~ 路径
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
// 本地文件:安全校验
await assertLocalMediaAllowed(mediaUrl, mediaLocalRoots);
// 读取本地文件
let data;
try {
const stat = await fs.stat(mediaUrl);
if (!stat.isFile()) {
throw new Error(`Local media path is not a file: ${mediaUrl}`);
}
data = await fs.readFile(mediaUrl);
}
catch (err) {
if (err?.code === "ENOENT") {
throw new Error(`Local media file not found: ${mediaUrl}`);
}
throw err;
}
if (maxBytes && data.length > maxBytes) {
throw new Error(`Local media exceeds max size (${data.length} > ${maxBytes})`);
}
const mime = await detectMimeFallback({ buffer: data, filePath: mediaUrl });
const fileName = path.basename(mediaUrl) || undefined;
return {
buffer: data,
contentType: mime,
fileName,
};
}
/**
* 从 URL 或本地路径加载媒体文件(兼容入口)
*
* 优先使用 SDK 版本,不可用时使用 fallback。
* SDK 版本抛出的业务异常(如 LocalMediaAccessError会直接透传。
*/
async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
const sdk = await _sdkReady;
if (sdk.loadOutboundMediaFromUrl) {
return sdk.loadOutboundMediaFromUrl(mediaUrl, options);
}
return loadOutboundMediaFromUrlFallback(mediaUrl, options);
}
// ============================================================================
// getDefaultMediaLocalRoots —— 获取默认媒体本地路径白名单
// ============================================================================
/** 解析 openclaw 状态目录 */
function resolveStateDir$1() {
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
if (stateOverride)
return stateOverride;
return path.join(os.homedir(), ".openclaw");
}
/**
* 获取默认媒体本地路径白名单(兼容入口)
*
* 优先使用 SDK 版本,不可用时手动构建白名单(与 weclaw/src/media/local-roots.ts 逻辑一致)。
*/
async function getDefaultMediaLocalRoots() {
const sdk = await _sdkReady;
if (sdk.getDefaultMediaLocalRoots) {
try {
return sdk.getDefaultMediaLocalRoots();
}
catch {
// SDK 版本异常,降级到 fallback
}
}
// fallback: 手动构建默认白名单
const stateDir = path.resolve(resolveStateDir$1());
return [
path.join(stateDir, "media"),
path.join(stateDir, "agents"),
path.join(stateDir, "workspace"),
path.join(stateDir, "sandboxes"),
];
}
/**
* 企业微信渠道常量定义
*/
/**
* 企业微信渠道 ID
*/
const CHANNEL_ID = "wecom";
/**
* 企业微信 WebSocket 命令枚举
*/
var WeComCommand;
(function (WeComCommand) {
/** 认证订阅 */
WeComCommand["SUBSCRIBE"] = "aibot_subscribe";
/** 心跳 */
WeComCommand["PING"] = "ping";
/** 企业微信推送消息 */
WeComCommand["AIBOT_CALLBACK"] = "aibot_callback";
/** clawdbot 响应消息 */
WeComCommand["AIBOT_RESPONSE"] = "aibot_response";
})(WeComCommand || (WeComCommand = {}));
// ============================================================================
// 超时和重试配置
// ============================================================================
/** 图片下载超时时间(毫秒) */
const IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
/** 文件下载超时时间(毫秒) */
const FILE_DOWNLOAD_TIMEOUT_MS = 60000;
/** 消息发送超时时间(毫秒) */
const REPLY_SEND_TIMEOUT_MS = 15000;
/** 消息处理总超时时间(毫秒) */
const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
/** WebSocket 心跳间隔(毫秒) */
const WS_HEARTBEAT_INTERVAL_MS = 30000;
/** WebSocket 最大重连次数 */
const WS_MAX_RECONNECT_ATTEMPTS = 100;
// ============================================================================
// 消息状态管理配置
// ============================================================================
/** messageStates Map 条目的最大 TTL毫秒防止内存泄漏 */
const MESSAGE_STATE_TTL_MS = 10 * 60 * 1000;
/** messageStates Map 清理间隔(毫秒) */
const MESSAGE_STATE_CLEANUP_INTERVAL_MS = 60000;
/** messageStates Map 最大条目数 */
const MESSAGE_STATE_MAX_SIZE = 500;
// ============================================================================
// 消息模板
// ============================================================================
/** "思考中"流式消息占位内容 */
const THINKING_MESSAGE = "<think></think>";
/** 仅包含图片时的消息占位符 */
const MEDIA_IMAGE_PLACEHOLDER = "<media:image>";
/** 仅包含文件时的消息占位符 */
const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
// ============================================================================
// 默认值
// ============================================================================
// ============================================================================
// MCP 配置
// ============================================================================
/** 获取 MCP 配置的 WebSocket 命令 */
const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
/** MCP 配置拉取超时时间(毫秒) */
const MCP_CONFIG_FETCH_TIMEOUT_MS = 15000;
const __filename$1 = fileURLToPath$1(import.meta.url);
const __dirname$1 = path__default.dirname(__filename$1);
const PLUGIN_JSON_FILENAME = "openclaw.plugin.json";
path__default.resolve(path__default.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
// ============================================================================
// 默认值
// ============================================================================
/** 默认媒体大小上限MB */
const DEFAULT_MEDIA_MAX_MB = 5;
/** 文本分块大小上限 */
const TEXT_CHUNK_LIMIT = 4000;
// ============================================================================
// 媒体上传相关常量
// ============================================================================
/** 图片大小上限字节10MB */
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
/** 视频大小上限字节10MB */
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
/** 语音大小上限字节2MB */
const VOICE_MAX_BYTES = 2 * 1024 * 1024;
/** 文件大小上限字节20MB */
const FILE_MAX_BYTES = 20 * 1024 * 1024;
/** 文件绝对上限(字节):超过此值无法发送,等于 FILE_MAX_BYTES */
const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
/**
* 企业微信消息内容解析模块
*
* 负责从 WsFrame 中提取文本、图片、引用等内容
*/
// ============================================================================
// 解析函数
// ============================================================================
/**
* 解析消息内容(支持单条消息、图文混排和引用消息)
* @returns 提取的文本数组、图片URL数组和引用消息内容
*/
function parseMessageContent(body) {
const textParts = [];
const imageUrls = [];
const imageAesKeys = new Map();
const fileUrls = [];
const fileAesKeys = new Map();
let quoteContent;
// 处理图文混排消息
if (body.msgtype === "mixed" && body.mixed?.msg_item) {
for (const item of body.mixed.msg_item) {
if (item.msgtype === "text" && item.text?.content) {
textParts.push(item.text.content);
}
else if (item.msgtype === "image" && item.image?.url) {
imageUrls.push(item.image.url);
if (item.image.aeskey) {
imageAesKeys.set(item.image.url, item.image.aeskey);
}
}
}
}
else {
// 处理单条消息
if (body.text?.content) {
textParts.push(body.text.content);
}
// 处理语音消息(语音转文字后的文本内容)
if (body.msgtype === "voice" && body.voice?.content) {
textParts.push(body.voice.content);
}
if (body.image?.url) {
imageUrls.push(body.image.url);
if (body.image.aeskey) {
imageAesKeys.set(body.image.url, body.image.aeskey);
}
}
// 处理文件消息
if (body.msgtype === "file" && body.file?.url) {
fileUrls.push(body.file.url);
if (body.file.aeskey) {
fileAesKeys.set(body.file.url, body.file.aeskey);
}
}
}
// 处理引用消息
if (body.quote) {
if (body.quote.msgtype === "text" && body.quote.text?.content) {
quoteContent = body.quote.text.content;
}
else if (body.quote.msgtype === "voice" && body.quote.voice?.content) {
quoteContent = body.quote.voice.content;
}
else if (body.quote.msgtype === "image" && body.quote.image?.url) {
// 引用的图片消息:将图片 URL 加入下载列表
imageUrls.push(body.quote.image.url);
if (body.quote.image.aeskey) {
imageAesKeys.set(body.quote.image.url, body.quote.image.aeskey);
}
}
else if (body.quote.msgtype === "file" && body.quote.file?.url) {
// 引用的文件消息:将文件 URL 加入下载列表
fileUrls.push(body.quote.file.url);
if (body.quote.file.aeskey) {
fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
}
}
}
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
}
/**
* 超时控制工具模块
*
* 为异步操作提供统一的超时保护机制
*/
/**
* 为 Promise 添加超时保护
*
* @param promise - 原始 Promise
* @param timeoutMs - 超时时间(毫秒)
* @param message - 超时错误消息
* @returns 带超时保护的 Promise
*/
function withTimeout(promise, timeoutMs, message) {
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
return promise;
}
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new TimeoutError(message ?? `Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}
/**
* 超时错误类型
*/
class TimeoutError extends Error {
constructor(message) {
super(message);
this.name = "TimeoutError";
}
}
/**
* 企业微信消息发送模块
*
* 负责通过 WSClient 发送回复消息,包含超时保护
*/
// ============================================================================
// 消息发送
// ============================================================================
/**
* 发送企业微信回复消息
* 供 monitor 内部和 channel outbound 使用
*
* @returns messageId (streamId)
*/
async function sendWeComReply(params) {
const { wsClient, frame, text, runtime, finish = true, streamId: existingStreamId } = params;
if (!text) {
return "";
}
const streamId = existingStreamId || generateReqId("stream");
if (!wsClient.isConnected) {
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
throw new Error("WSClient not connected");
}
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}, text=${text}`);
return streamId;
}
/**
* 企业微信媒体(图片)下载和保存模块
*
* 负责下载、检测格式、保存图片到本地,包含超时保护
*/
// ============================================================================
// 图片格式检测辅助函数(基于 file-type 包)
// ============================================================================
/**
* 检查 Buffer 是否为有效的图片格式
*/
async function isImageBuffer(data) {
const type = await fileTypeFromBuffer(data);
return type?.mime.startsWith("image/") ?? false;
}
/**
* 检测 Buffer 的图片内容类型
*/
async function detectImageContentType(data) {
const type = await fileTypeFromBuffer(data);
if (type?.mime.startsWith("image/")) {
return type.mime;
}
return "application/octet-stream";
}
// ============================================================================
// 图片下载和保存
// ============================================================================
/**
* 下载并保存所有图片到本地,每张图片的下载带超时保护
*/
async function downloadAndSaveImages(params) {
const { imageUrls, config, runtime, wsClient } = params;
const core = getWeComRuntime();
const mediaList = [];
for (const imageUrl of imageUrls) {
try {
runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const maxBytes = mediaMaxMb * 1024 * 1024;
let imageBuffer;
let imageContentType;
let originalFilename;
const imageAesKey = params.imageAesKeys?.get(imageUrl);
try {
// 优先使用 SDK 的 downloadFile 方法下载(带超时保护)
const result = await withTimeout(wsClient.downloadFile(imageUrl, imageAesKey), IMAGE_DOWNLOAD_TIMEOUT_MS, `Image download timed out: ${imageUrl}`);
imageBuffer = result.buffer;
originalFilename = result.filename;
imageContentType = await detectImageContentType(imageBuffer);
runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
}
catch (sdkError) {
// 如果 SDK 方法失败,回退到原有方式(带超时保护)
runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
imageBuffer = fetched.buffer;
imageContentType = fetched.contentType ?? "application/octet-stream";
const isValidImage = await isImageBuffer(fetched.buffer);
if (!isValidImage) {
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
}
}
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
mediaList.push({ path: saved.path, contentType: saved.contentType });
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
}
catch (err) {
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
}
}
return mediaList;
}
/**
* 下载并保存所有文件到本地,每个文件的下载带超时保护
*/
async function downloadAndSaveFiles(params) {
const { fileUrls, config, runtime, wsClient } = params;
const core = getWeComRuntime();
const mediaList = [];
for (const fileUrl of fileUrls) {
try {
runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const maxBytes = mediaMaxMb * 1024 * 1024;
let fileBuffer;
let fileContentType;
let originalFilename;
const fileAesKey = params.fileAesKeys?.get(fileUrl);
try {
// 使用 SDK 的 downloadFile 方法下载(带超时保护)
const result = await withTimeout(wsClient.downloadFile(fileUrl, fileAesKey), FILE_DOWNLOAD_TIMEOUT_MS, `File download timed out: ${fileUrl}`);
fileBuffer = result.buffer;
originalFilename = result.filename;
// 检测文件类型
const type = await fileTypeFromBuffer(fileBuffer);
fileContentType = type?.mime ?? "application/octet-stream";
runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
}
catch (sdkError) {
// 如果 SDK 方法失败,回退到 fetchRemoteMedia带超时保护
runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
fileBuffer = fetched.buffer;
fileContentType = fetched.contentType ?? "application/octet-stream";
}
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
mediaList.push({ path: saved.path, contentType: saved.contentType });
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
}
catch (err) {
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
}
}
return mediaList;
}
/**
* 企业微信出站媒体上传工具模块
*
* 负责:
* - 从 mediaUrl 加载文件 buffer远程 URL 或本地路径均支持)
* - 检测 MIME 类型并映射为企微媒体类型
* - 文件大小检查与降级策略
*/
// ============================================================================
// MIME → 企微媒体类型映射
// ============================================================================
/**
* 根据 MIME 类型检测企微媒体类型
*
* @param mimeType - MIME 类型字符串
* @returns 企微媒体类型
*/
function detectWeComMediaType(mimeType) {
const mime = mimeType.toLowerCase();
// 图片类型
if (mime.startsWith("image/")) {
return "image";
}
// 视频类型
if (mime.startsWith("video/")) {
return "video";
}
// 语音类型
if (mime.startsWith("audio/") ||
mime === "application/ogg" // OGG 音频容器
) {
return "voice";
}
// 其他类型默认为文件
return "file";
}
// ============================================================================
// 媒体文件加载
// ============================================================================
/**
* 从 mediaUrl 加载媒体文件
*
* 支持远程 URLhttp/https和本地路径file:// 或绝对路径),
* 利用 openclaw plugin-sdk 的 loadOutboundMediaFromUrl 统一处理。
*
* @param mediaUrl - 媒体文件的 URL 或本地路径
* @param mediaLocalRoots - 允许读取本地文件的安全白名单目录
* @returns 解析后的媒体文件信息
*/
async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
// 使用兼容层加载媒体文件(优先 SDK不可用时 fallback
// 传入足够大的 maxBytes由我们自己在后续步骤做大小检查
const result = await loadOutboundMediaFromUrl(mediaUrl, {
maxBytes: ABSOLUTE_MAX_BYTES,
mediaLocalRoots,
});
if (!result.buffer || result.buffer.length === 0) {
throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
}
// 检测真实 MIME 类型
let contentType = result.contentType || "application/octet-stream";
// 如果没有返回准确的 contentType尝试通过 buffer 魔术字节检测
if (contentType === "application/octet-stream" ||
contentType === "text/plain") {
const detected = await detectMime(result.buffer);
if (detected) {
contentType = detected;
}
}
// 提取文件名
const fileName = extractFileName(mediaUrl, result.fileName, contentType);
return {
buffer: result.buffer,
contentType,
fileName,
};
}
// ============================================================================
// 文件大小检查与降级
// ============================================================================
/** 企微语音消息仅支持 AMR 格式 */
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
/**
* 检查文件大小并执行降级策略
*
* 降级规则:
* - voice 非 AMR 格式 → 降级为 file企微后台仅支持 AMR
* - image 超过 10MB → 降级为 file
* - video 超过 10MB → 降级为 file
* - voice 超过 2MB → 降级为 file
* - file 超过 20MB → 拒绝发送
*
* @param fileSize - 文件大小(字节)
* @param detectedType - 检测到的企微媒体类型
* @param contentType - 文件的 MIME 类型(用于语音格式校验)
* @returns 大小检查结果
*/
function applyFileSizeLimits(fileSize, detectedType, contentType) {
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
// 先检查绝对上限20MB
if (fileSize > ABSOLUTE_MAX_BYTES) {
return {
finalType: detectedType,
shouldReject: true,
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB无法发送。请尝试压缩文件或减小文件大小。`,
downgraded: false,
};
}
// 按类型检查大小限制
switch (detectedType) {
case "image":
if (fileSize > IMAGE_MAX_BYTES) {
return {
finalType: "file",
shouldReject: false,
downgraded: true,
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
};
}
break;
case "video":
if (fileSize > VIDEO_MAX_BYTES) {
return {
finalType: "file",
shouldReject: false,
downgraded: true,
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
};
}
break;
case "voice":
// 企微语音消息仅支持 AMR 格式,非 AMR 一律降级为文件
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
return {
finalType: "file",
shouldReject: false,
downgraded: true,
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
};
}
if (fileSize > VOICE_MAX_BYTES) {
return {
finalType: "file",
shouldReject: false,
downgraded: true,
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
};
}
break;
}
// 无需降级
return {
finalType: detectedType,
shouldReject: false,
downgraded: false,
};
}
// ============================================================================
// 辅助函数
// ============================================================================
/**
* 从 URL/路径中提取文件名
*/
function extractFileName(mediaUrl, providedFileName, contentType) {
// 优先使用提供的文件名
if (providedFileName) {
return providedFileName;
}
// 尝试从 URL 中提取
try {
const urlObj = new URL(mediaUrl, "file://");
const pathParts = urlObj.pathname.split("/");
const lastPart = pathParts[pathParts.length - 1];
if (lastPart && lastPart.includes(".")) {
return decodeURIComponent(lastPart);
}
}
catch {
// 尝试作为普通路径处理
const parts = mediaUrl.split("/");
const lastPart = parts[parts.length - 1];
if (lastPart && lastPart.includes(".")) {
return lastPart;
}
}
// 使用 MIME 类型生成默认文件名
const ext = mimeToExtension(contentType || "application/octet-stream");
return `media_${Date.now()}${ext}`;
}
/**
* MIME 类型转文件扩展名
*/
function mimeToExtension(mime) {
const map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"video/x-msvideo": ".avi",
"video/webm": ".webm",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/amr": ".amr",
"audio/aac": ".aac",
"application/pdf": ".pdf",
"application/zip": ".zip",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"text/plain": ".txt",
};
return map[mime] || ".bin";
}
/**
* 公共媒体上传+发送流程
*
* 统一处理resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
* 媒体消息统一走 aibot_send_msg 主动发送,避免多文件场景下 reqId 只能用一次的问题。
* channel.ts 的 sendMedia 和 monitor.ts 的 deliver 回调都使用此函数。
*/
async function uploadAndSendMedia(options) {
const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
try {
// 1. 加载媒体文件
log?.(`[wecom] Uploading media: url=${mediaUrl}`);
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
// 2. 检测企微媒体类型
const detectedType = detectWeComMediaType(media.contentType);
// 3. 文件大小检查与降级策略
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
if (sizeCheck.shouldReject) {
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
return {
ok: false,
rejected: true,
rejectReason: sizeCheck.rejectReason,
finalType: sizeCheck.finalType,
};
}
const finalType = sizeCheck.finalType;
// 4. 分片上传获取 media_id
const uploadResult = await wsClient.uploadMedia(media.buffer, {
type: finalType,
filename: media.fileName,
});
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
// 5. 统一通过 aibot_send_msg 主动发送媒体消息
const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
return {
ok: true,
messageId,
finalType,
downgraded: sizeCheck.downgraded,
downgradeNote: sizeCheck.downgradeNote,
};
}
catch (err) {
const errMsg = String(err);
errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
return {
ok: false,
error: errMsg,
};
}
}
/**
* 企业微信群组访问控制模块
*
* 负责群组策略检查groupPolicy、群组白名单、群内发送者白名单
*/
// ============================================================================
// 内部辅助函数
// ============================================================================
/**
* 解析企业微信群组配置
*/
function resolveWeComGroupConfig(params) {
const groups = params.cfg?.groups ?? {};
const wildcard = groups["*"];
const groupId = params.groupId?.trim();
if (!groupId) {
return undefined;
}
const direct = groups[groupId];
if (direct) {
return direct;
}
const lowered = groupId.toLowerCase();
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
if (matchKey) {
return groups[matchKey];
}
return wildcard;
}
/**
* 检查群组是否在允许列表中
*/
function isWeComGroupAllowed(params) {
const { groupPolicy } = params;
if (groupPolicy === "disabled") {
return false;
}
if (groupPolicy === "open") {
return true;
}
// allowlist 模式:检查群组是否在允许列表中
const normalizedAllowFrom = params.allowFrom.map((entry) => String(entry).replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim());
if (normalizedAllowFrom.includes("*")) {
return true;
}
const normalizedGroupId = params.groupId.trim();
return normalizedAllowFrom.some((entry) => entry === normalizedGroupId || entry.toLowerCase() === normalizedGroupId.toLowerCase());
}
/**
* 检查群组内发送者是否在允许列表中
*/
function isGroupSenderAllowed(params) {
const { senderId, groupId, wecomConfig } = params;
const groupConfig = resolveWeComGroupConfig({
cfg: wecomConfig,
groupId,
});
const perGroupSenderAllowFrom = (groupConfig?.allowFrom ?? []).map((v) => String(v));
if (perGroupSenderAllowFrom.length === 0) {
return true;
}
if (perGroupSenderAllowFrom.includes("*")) {
return true;
}
return perGroupSenderAllowFrom.some((entry) => {
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
return normalized === senderId || normalized === `user:${senderId}`;
});
}
// ============================================================================
// 公开 API
// ============================================================================
/**
* 检查群组策略访问控制
* @returns 检查结果,包含是否允许继续处理
*/
function checkGroupPolicy(params) {
const { chatId, senderId, account, config, runtime } = params;
const wecomConfig = (config.channels?.[CHANNEL_ID] ?? {});
const defaultGroupPolicy = config.channels?.[CHANNEL_ID]?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
// const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
// providerConfigPresent: config.channels?.[CHANNEL_ID] !== undefined,
// groupPolicy: wecomConfig.groupPolicy,
// defaultGroupPolicy,
// });
// warnMissingProviderGroupPolicyFallbackOnce({
// providerMissingFallbackApplied,
// providerKey: CHANNEL_ID,
// accountId: account.accountId,
// log: (msg) => runtime.log?.(msg),
// });
const groupAllowFrom = wecomConfig.groupAllowFrom ?? [];
const groupAllowed = isWeComGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
groupId: chatId,
});
if (!groupAllowed) {
runtime.log?.(`[WeCom] Group ${chatId} not allowed (groupPolicy=${groupPolicy})`);
return { allowed: false };
}
const senderAllowed = isGroupSenderAllowed({
senderId,
groupId: chatId,
wecomConfig,
});
if (!senderAllowed) {
runtime.log?.(`[WeCom] Sender ${senderId} not in group ${chatId} sender allowlist`);
return { allowed: false };
}
return { allowed: true };
}
/**
* 检查发送者是否在允许列表中(通用)
*/
function isSenderAllowed(senderId, allowFrom) {
if (allowFrom.includes("*")) {
return true;
}
return allowFrom.some((entry) => {
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
return normalized === senderId || normalized === `user:${senderId}`;
});
}
/**
* 企业微信 DM私聊访问控制模块
*
* 负责私聊策略检查、配对流程
*/
// ============================================================================
// 公开 API
// ============================================================================
/**
* 检查 DM Policy 访问控制
* @returns 检查结果,包含是否允许继续处理
*/
async function checkDmPolicy(params) {
const { senderId, isGroup, account, wsClient, frame, runtime } = params;
const core = getWeComRuntime();
// 群聊消息不检查 DM Policy
if (isGroup) {
return { allowed: true };
}
const dmPolicy = account.config.dmPolicy ?? "open";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
// 如果 dmPolicy 是 disabled直接拒绝
if (dmPolicy === "disabled") {
runtime.log?.(`[WeCom] Blocked DM from ${senderId} (dmPolicy=disabled)`);
return { allowed: false };
}
// 如果是 open 模式,允许所有人
if (dmPolicy === "open") {
return { allowed: true };
}
// OpenClaw <= 2026.2.19 signature: readAllowFromStore(channel, env?, accountId?)
const oldStoreAllowFrom = await core.channel.pairing.readAllowFromStore('wecom', undefined, account.accountId).catch(() => []);
// Compatibility fallback for newer OpenClaw implementations.
const newStoreAllowFrom = await core.channel.pairing
.readAllowFromStore({ channel: CHANNEL_ID, accountId: account.accountId })
.catch(() => []);
// 检查发送者是否在允许列表中
const storeAllowFrom = [...oldStoreAllowFrom, ...newStoreAllowFrom];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const senderAllowedResult = isSenderAllowed(senderId, effectiveAllowFrom);
if (senderAllowedResult) {
return { allowed: true };
}
// 处理未授权用户
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderId,
accountId: account.accountId,
meta: { name: senderId },
});
if (created) {
runtime.log?.(`[WeCom] Pairing request created for sender=${senderId}`);
try {
await sendWeComReply({
wsClient,
frame,
text: core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `您的企业微信用户ID: ${senderId}`,
code,
}),
runtime,
finish: true,
});
}
catch (err) {
runtime.error?.(`[WeCom] Failed to send pairing reply to ${senderId}: ${String(err)}`);
}
}
else {
runtime.log?.(`[WeCom] Pairing request already exists for sender=${senderId}`);
}
return { allowed: false, pairingSent: created };
}
// allowlist 模式:直接拒绝未授权用户
runtime.log?.(`[WeCom] Blocked unauthorized sender ${senderId} (dmPolicy=${dmPolicy})`);
return { allowed: false };
}
// ============================================================================
// 常量
// ============================================================================
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天
const DEFAULT_MEMORY_MAX_SIZE = 200;
const DEFAULT_FILE_MAX_ENTRIES = 500;
const DEFAULT_FLUSH_DEBOUNCE_MS = 1000;
const DEFAULT_LOCK_OPTIONS = {
stale: 60000,
retries: {
retries: 6,
factor: 1.35,
minTimeout: 8,
maxTimeout: 180,
randomize: true,
},
};
// ============================================================================
// 状态目录解析
// ============================================================================
function resolveStateDirFromEnv(env = process.env) {
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (stateOverride) {
return stateOverride;
}
if (env.VITEST || env.NODE_ENV === "test") {
return path__default$1.join(os__default.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
}
return path__default$1.join(os__default.homedir(), ".openclaw");
}
function resolveReqIdFilePath(accountId) {
const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
return path__default$1.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
}
// ============================================================================
// 核心实现
// ============================================================================
function createPersistentReqIdStore(accountId, options) {
const ttlMs = DEFAULT_TTL_MS;
const memoryMaxSize = DEFAULT_MEMORY_MAX_SIZE;
const fileMaxEntries = DEFAULT_FILE_MAX_ENTRIES;
const flushDebounceMs = DEFAULT_FLUSH_DEBOUNCE_MS;
const filePath = resolveReqIdFilePath(accountId);
// 内存层chatId → ReqIdEntry
const memory = new Map();
// 防抖写入相关
let dirty = false;
let flushTimer = null;
// ========== 内部辅助函数 ==========
/** 检查条目是否过期 */
function isExpired(entry, now) {
return now - entry.ts >= ttlMs;
}
/** 验证磁盘条目的合法性 */
function isValidEntry(entry) {
return (typeof entry === "object" &&
entry !== null &&
typeof entry.reqId === "string" &&
typeof entry.ts === "number" &&
Number.isFinite(entry.ts));
}
/** 清理磁盘数据中的无效值,返回干净的 Record */
function sanitizeData(value) {
if (!value || typeof value !== "object") {
return {};
}
const out = {};
for (const [key, entry] of Object.entries(value)) {
if (isValidEntry(entry)) {
out[key] = entry;
}
}
return out;
}
/**
* 内存容量控制:淘汰最旧的条目。
* 利用 Map 的插入顺序 + touch(先 delete 再 set) 实现类 LRU 效果。
*/
function pruneMemory() {
if (memory.size <= memoryMaxSize)
return;
const sorted = [...memory.entries()].sort((a, b) => a[1].ts - b[1].ts);
const toRemove = sorted.slice(0, memory.size - memoryMaxSize);
for (const [key] of toRemove) {
memory.delete(key);
}
}
/** 磁盘数据容量控制:先清过期,再按时间淘汰超量 */
function pruneFileData(data, now) {
{
for (const [key, entry] of Object.entries(data)) {
if (now - entry.ts >= ttlMs) {
delete data[key];
}
}
}
const keys = Object.keys(data);
if (keys.length <= fileMaxEntries)
return;
keys
.sort((a, b) => data[a].ts - data[b].ts)
.slice(0, keys.length - fileMaxEntries)
.forEach((key) => delete data[key]);
}
/** 防抖写入磁盘 */
function scheduleDiskFlush() {
dirty = true;
if (flushTimer)
return;
flushTimer = setTimeout(async () => {
flushTimer = null;
if (!dirty)
return;
await flushToDisk();
}, flushDebounceMs);
}
/** 立即写入磁盘(带文件锁,参考 createPersistentDedupe 的 checkAndRecordInner */
async function flushToDisk() {
dirty = false;
const now = Date.now();
try {
await withFileLock(filePath, DEFAULT_LOCK_OPTIONS, async () => {
// 读取现有磁盘数据并合并
const { value } = await readJsonFileWithFallback(filePath, {});
const data = sanitizeData(value);
// 将内存中未过期的数据合并到磁盘数据(内存优先)
for (const [chatId, entry] of memory) {
if (!isExpired(entry, now)) {
data[chatId] = entry;
}
}
// 清理过期和超量
pruneFileData(data, now);
// 原子写入
await writeJsonFileAtomically(filePath, data);
});
}
catch (error) {
// 磁盘写入失败不影响内存使用,降级到纯内存模式
// console.error(`[WeCom] reqid-store: flush to disk failed: ${String(error)}`);
}
}
// ========== 公开 API ==========
function set(chatId, reqId) {
const entry = { reqId, ts: Date.now() };
// touch先删再设保持 Map 插入顺序(类 LRU
memory.delete(chatId);
memory.set(chatId, entry);
pruneMemory();
scheduleDiskFlush();
}
async function get(chatId) {
const now = Date.now();
// 1. 先查内存
const memEntry = memory.get(chatId);
if (memEntry && !isExpired(memEntry, now)) {
return memEntry.reqId;
}
if (memEntry) {
memory.delete(chatId); // 过期则删除
}
// 2. 内存 miss回查磁盘并回填内存
try {
const { value } = await readJsonFileWithFallback(filePath, {});
const data = sanitizeData(value);
const diskEntry = data[chatId];
if (diskEntry && !isExpired(diskEntry, now)) {
// 回填内存
memory.set(chatId, diskEntry);
return diskEntry.reqId;
}
}
catch {
// 磁盘读取失败,降级返回 undefined
}
return undefined;
}
function getSync(chatId) {
const now = Date.now();
const entry = memory.get(chatId);
if (entry && !isExpired(entry, now)) {
return entry.reqId;
}
if (entry) {
memory.delete(chatId);
}
return undefined;
}
function del(chatId) {
memory.delete(chatId);
scheduleDiskFlush();
}
async function warmup(onError) {
const now = Date.now();
try {
const { value } = await readJsonFileWithFallback(filePath, {});
const data = sanitizeData(value);
let loaded = 0;
for (const [chatId, entry] of Object.entries(data)) {
if (!isExpired(entry, now)) {
memory.set(chatId, entry);
loaded++;
}
}
pruneMemory();
return loaded;
}
catch (error) {
onError?.(error);
return 0;
}
}
async function flush() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
await flushToDisk();
}
function clearMemory() {
memory.clear();
}
function memorySize() {
return memory.size;
}
return {
set,
get,
getSync,
delete: del,
warmup,
flush,
clearMemory,
memorySize,
};
}
/**
* 企业微信全局状态管理模块
*
* 负责管理 WSClient 实例、消息状态(带 TTL 清理、ReqId 存储
* 解决全局 Map 的内存泄漏问题
*/
// ============================================================================
// WSClient 实例管理
// ============================================================================
/** WSClient 实例管理 */
const wsClientInstances = new Map();
/**
* 获取指定账户的 WSClient 实例
*/
function getWeComWebSocket(accountId) {
return wsClientInstances.get(accountId) ?? null;
}
/**
* 设置指定账户的 WSClient 实例
*/
function setWeComWebSocket(accountId, client) {
wsClientInstances.set(accountId, client);
}
/** 消息状态管理 */
const messageStates = new Map();
/** 定期清理定时器 */
let cleanupTimer = null;
/**
* 启动消息状态定期清理(自动 TTL 清理 + 容量限制)
*/
function startMessageStateCleanup() {
if (cleanupTimer)
return;
cleanupTimer = setInterval(() => {
pruneMessageStates();
}, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
// 允许进程退出时不阻塞
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
}
/**
* 停止消息状态定期清理
*/
function stopMessageStateCleanup() {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}
/**
* 清理过期和超量的消息状态条目
*/
function pruneMessageStates() {
const now = Date.now();
// 1. 清理过期条目
for (const [key, entry] of messageStates) {
if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
messageStates.delete(key);
}
}
// 2. 容量限制:如果仍超过最大条目数,按时间淘汰最旧的
if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
for (const [key] of toRemove) {
messageStates.delete(key);
}
}
}
/**
* 设置消息状态
*/
function setMessageState(messageId, state) {
messageStates.set(messageId, {
state,
createdAt: Date.now(),
});
}
/**
* 删除消息状态
*/
function deleteMessageState(messageId) {
messageStates.delete(messageId);
}
// ============================================================================
// ReqId 持久化存储管理(按 accountId 隔离)
// ============================================================================
/**
* ReqId 持久化存储管理
* 参考 createPersistentDedupe 模式:内存 + 磁盘双层、文件锁、原子写入、TTL 过期、防抖写入
* 重启后可从磁盘恢复,确保主动推送消息时能获取到 reqId
*/
const reqIdStores = new Map();
function getOrCreateReqIdStore(accountId) {
let store = reqIdStores.get(accountId);
if (!store) {
store = createPersistentReqIdStore(accountId);
reqIdStores.set(accountId, store);
}
return store;
}
// ============================================================================
// ReqId 操作函数
// ============================================================================
/**
* 设置 chatId 对应的 reqId写入内存 + 防抖写磁盘)
*/
function setReqIdForChat(chatId, reqId, accountId = "default") {
getOrCreateReqIdStore(accountId).set(chatId, reqId);
}
/**
* 启动时预热 reqId 缓存(从磁盘加载到内存)
*/
async function warmupReqIdStore(accountId = "default", log) {
const store = getOrCreateReqIdStore(accountId);
return store.warmup((error) => {
log?.(`[WeCom] reqid-store warmup error: ${String(error)}`);
});
}
// ============================================================================
// 全局 cleanup断开连接时释放所有资源
// ============================================================================
/**
* 清理指定账户的所有资源
*/
async function cleanupAccount(accountId) {
// 1. 断开 WSClient
const wsClient = wsClientInstances.get(accountId);
if (wsClient) {
try {
wsClient.disconnect();
}
catch {
// 忽略断开连接时的错误
}
wsClientInstances.delete(accountId);
}
// 2. flush reqId 存储到磁盘
const store = reqIdStores.get(accountId);
if (store) {
try {
await store.flush();
}
catch {
// 忽略 flush 错误
}
// 注意:不删除 store因为重连后可能还需要
}
}
/**
* MCP 配置拉取与持久化模块
*
* 负责:
* - 通过 WSClient 发送 aibot_get_mcp_config 请求
* - 解析服务端响应,提取 MCP 配置(url、type、is_authed)
* - 将配置写入 openclaw.plugin.json 的 wecomMcp 字段
*/
// ============================================================================
// MCP 配置拉取
// ============================================================================
/**
* 通过 WSClient 发送 aibot_get_mcp_config 命令,获取 MCP 配置
*
* @param wsClient - 已认证的 WSClient 实例
* @returns MCP 配置(url、type、is_authed)
* @throws 响应错误码非 0 或缺少 url 字段时抛出错误
*/
async function fetchMcpConfig(wsClient) {
const biz_type = "doc";
const reqId = generateReqId("mcp_config");
// 通过 reply 方法发送自定义命令
const response = await withTimeout(wsClient.reply({ headers: { req_id: reqId } }, { biz_type }, MCP_GET_CONFIG_CMD), MCP_CONFIG_FETCH_TIMEOUT_MS, `MCP config fetch timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`);
// 校验响应错误码
if (response.errcode && response.errcode !== 0) {
throw new Error(`MCP config request failed: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`);
}
// 提取并校验 body
const body = response.body;
if (!body?.url) {
throw new Error("MCP config response missing required 'url' field");
}
return {
biz_type,
url: body.url,
type: body.type,
is_authed: body.is_authed,
};
}
// ============================================================================
// 配置持久化
// ============================================================================
/**
* 将 MCP 配置写入 openclaw.plugin.json 的 mcpConfig 字段
*
* 使用 OpenClaw SDK 提供的文件锁和原子写入,保证并发安全。
* 配置格式: { mcpConfig: { [type]: { type, url } } }
* 示例: { "mcpConfig": { "doc": { "type": "streamable-http", "url": "..." } } }
*
* @param config - MCP 配置
* @param runtime - 运行时环境
*/
async function saveMcpConfigToPluginJson(config, runtime) {
// const pluginJsonPath = PLUGIN_JSON_PATH;
// 获取绝对路径并写入到配置文件
// const absolutePath = path.resolve(pluginJsonPath);
const wecomConfigDir = path__default.join(os__default$1.homedir(), ".openclaw", "wecomConfig");
const wecomConfigPath = path__default.join(wecomConfigDir, "config.json");
// // 写入 wecom 配置文件
// try {
// const lockOptions = {
// stale: 60_000,
// retries: {
// retries: 3,
// factor: 1.35,
// minTimeout: 8,
// maxTimeout: 200,
// randomize: true,
// },
// };
// await withFileLock(wecomConfigPath, lockOptions, async () => {
// const { value: wecomConfig } = await readJsonFileWithFallback<Record<string, unknown>>(
// wecomConfigPath,
// {},
// );
// wecomConfig.pluginConfigPath = absolutePath;
// await writeJsonFileAtomically(wecomConfigPath, wecomConfig);
// runtime.log?.(`[WeCom] Plugin config path saved to ${wecomConfigPath}`);
// });
// } catch (err) {
// runtime.error?.(`[WeCom] Failed to save plugin config path: ${String(err)}`);
// }
const lockOptions = {
stale: 60000, // 60秒锁过期
retries: {
retries: 6,
factor: 1.35,
minTimeout: 8,
maxTimeout: 1200,
randomize: true,
},
};
await withFileLock(wecomConfigPath, lockOptions, async () => {
// 读取现有配置(不存在时使用空对象)
const { value: pluginJson } = await readJsonFileWithFallback(wecomConfigPath, {});
// 确保 mcpConfig 字段存在且为对象
if (!pluginJson.mcpConfig || typeof pluginJson.mcpConfig !== 'object') {
pluginJson.mcpConfig = {};
}
// 使用 type 作为键存储配置
const typeKey = config.biz_type || 'default';
pluginJson.mcpConfig[typeKey] = {
type: config.type,
url: config.url,
};
// 原子写入
await writeJsonFileAtomically(wecomConfigPath, pluginJson);
runtime.log?.(`[WeCom] MCP config saved to ${wecomConfigPath}`);
});
}
// ============================================================================
// 组合入口
// ============================================================================
/**
* 拉取 MCP 配置并持久化到 openclaw.plugin.json
*
* 认证成功后调用。失败仅记录日志,不影响 WebSocket 消息正常收发。
*
* @param wsClient - 已认证的 WSClient 实例
* @param accountId - 账户 ID(用于日志)
* @param runtime - 运行时环境(用于日志)
*/
async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
try {
runtime.log?.(`[${accountId}] Fetching MCP config...`);
const config = await fetchMcpConfig(wsClient);
runtime.log?.(`[${accountId}] MCP config fetched: url=${config.url}, type=${config.type ?? "N/A"}, is_authed=${config.is_authed ?? "N/A"}`);
await saveMcpConfigToPluginJson(config, runtime);
}
catch (err) {
runtime.error?.(`[${accountId}] Failed to fetch/save MCP config: ${String(err)}`);
}
}
/**
* 企业微信 WebSocket 监控器主模块
*
* 负责:
* - 建立和管理 WebSocket 连接
* - 协调消息处理流程(解析→策略检查→下载图片→路由回复)
* - 资源生命周期管理
*
* 子模块:
* - message-parser.ts : 消息内容解析
* - message-sender.ts : 消息发送(带超时保护)
* - media-handler.ts : 图片下载和保存(带超时保护)
* - group-policy.ts : 群组访问控制
* - dm-policy.ts : 私聊访问控制
* - state-manager.ts : 全局状态管理(带 TTL 清理)
* - timeout.ts : 超时工具
*/
/**
* 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
* 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
*/
function stripThinkTags(text) {
return text;
// return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
// ============================================================================
// 媒体本地路径白名单扩展
// ============================================================================
/**
* 解析 openclaw 状态目录(与 plugin-sdk 内部逻辑保持一致)
*/
function resolveStateDir() {
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
if (stateOverride)
return stateOverride;
return path$1.join(os$1.homedir(), ".openclaw");
}
/**
* 在 getDefaultMediaLocalRoots() 基础上,将 stateDir 本身也加入白名单,
* 并合并用户在 WeComConfig 中配置的自定义 mediaLocalRoots。
*
* getDefaultMediaLocalRoots() 仅包含 stateDir 下的子目录media/agents/workspace/sandboxes
* 但 agent 生成的文件可能直接放在 stateDir 根目录下(如 ~/.openclaw-dev/1.png
* 因此需要将 stateDir 本身也加入白名单以避免 LocalMediaAccessError。
*
* 用户可在 openclaw.json 中配置:
* {
* "channels": {
* "wecom": {
* "mediaLocalRoots": ["~/Downloads", "~/Documents"]
* }
* }
* }
*/
async function getExtendedMediaLocalRoots(config) {
// 从兼容层获取默认白名单(内部已处理低版本 SDK 的 fallback
const defaults = await getDefaultMediaLocalRoots();
const roots = [...defaults];
const stateDir = path$1.resolve(resolveStateDir());
if (!roots.includes(stateDir)) {
roots.push(stateDir);
}
// 合并用户在 WeComConfig 中配置的自定义路径
if (config?.mediaLocalRoots) {
for (const r of config.mediaLocalRoots) {
const resolved = path$1.resolve(r.replace(/^~(?=\/|$)/, os$1.homedir()));
if (!roots.includes(resolved)) {
roots.push(resolved);
}
}
}
return roots;
}
// ============================================================================
// 媒体发送错误提示
// ============================================================================
/**
* 根据媒体发送结果生成纯文本错误摘要(用于替换 thinking 流式消息展示给用户)。
*
* 使用纯文本而非 markdown 格式,因为 replyStream 只支持纯文本。
*/
function buildMediaErrorSummary(mediaUrl, result) {
if (result.error?.includes("LocalMediaAccessError")) {
return `⚠️ 文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
}
if (result.rejectReason) {
return `⚠️ 文件发送失败:${result.rejectReason}`;
}
return `⚠️ 文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
}
// ============================================================================
// 消息上下文构建
// ============================================================================
/**
* 构建消息上下文
*/
function buildMessageContext(frame, account, config, text, mediaList, quoteContent) {
const core = getWeComRuntime();
const body = frame.body;
const chatId = body.chatid || body.from.userid;
const chatType = body.chattype === "group" ? "group" : "direct";
// 解析路由信息
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: chatType,
id: chatId,
},
});
// 构建会话标签
const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${body.from.userid}`;
// 当只有媒体没有文本时,使用占位符标识媒体类型
const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
const messageBody = text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
// 构建多媒体数组
const mediaPaths = mediaList.length > 0 ? mediaList.map((m) => m.path) : undefined;
const mediaTypes = mediaList.length > 0
? mediaList.map((m) => m.contentType).filter(Boolean)
: undefined;
// 构建标准消息上下文
return core.channel.reply.finalizeInboundContext({
Body: messageBody,
RawBody: messageBody,
CommandBody: messageBody,
MessageSid: body.msgid,
From: chatType === "group" ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${body.from.userid}`,
To: `${CHANNEL_ID}:${chatId}`,
SenderId: body.from.userid,
SessionKey: route.sessionKey,
AccountId: account.accountId,
ChatType: chatType,
ConversationLabel: fromLabel,
Timestamp: Date.now(),
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `${CHANNEL_ID}:${chatId}`,
CommandAuthorized: true,
ResponseUrl: body.response_url,
ReqId: frame.headers.req_id,
WeComFrame: frame,
MediaPath: mediaList[0]?.path,
MediaType: mediaList[0]?.contentType,
MediaPaths: mediaPaths,
MediaTypes: mediaTypes,
MediaUrls: mediaPaths,
ReplyToBody: quoteContent,
});
}
/**
* 发送"思考中"消息
*/
async function sendThinkingReply(params) {
const { wsClient, frame, streamId, runtime } = params;
try {
await sendWeComReply({
wsClient,
frame,
text: THINKING_MESSAGE,
runtime,
finish: false,
streamId,
});
}
catch (err) {
runtime.error?.(`[wecom] Failed to send thinking message: ${String(err)}`);
}
}
/**
* 累积文本并判断是否有可见内容(去除 <think> 标签后)
*/
function accumulateText(state, text) {
state.accumulatedText += text;
if (!state.hasText && stripThinkTags(state.accumulatedText)) {
state.hasText = true;
}
}
/**
* 上传并发送一批媒体文件(统一走主动发送通道)
*
* replyMedia被动回复无法覆盖 replyStream 发出的 thinking 流式消息,
* 因此所有媒体统一走 aibot_send_msg 主动发送。
*/
async function sendMediaBatch(ctx, mediaUrls) {
const { wsClient, frame, state, account, runtime } = ctx;
const body = frame.body;
const chatId = body.chatid || body.from.userid;
const mediaLocalRoots = await getExtendedMediaLocalRoots(account.config);
runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}, hasText=${!!state.hasText}`);
for (const mediaUrl of mediaUrls) {
const result = await uploadAndSendMedia({
wsClient,
mediaUrl,
chatId,
mediaLocalRoots,
log: (...args) => runtime.log?.(...args),
errorLog: (...args) => runtime.error?.(...args),
});
if (result.ok) {
state.hasMedia = true;
}
else {
state.hasMediaFailed = true;
runtime.error?.(`[wecom] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
// 收集错误摘要,后续在 finishThinkingStream 中直接替换 thinking 流展示给用户
const summary = buildMediaErrorSummary(mediaUrl, result);
state.mediaErrorSummary = state.mediaErrorSummary
? `${state.mediaErrorSummary}\n\n${summary}`
: summary;
}
}
}
/**
* 关闭 thinking 流(发送 finish=true 的流式消息)
*
* thinking 是通过 replyStream 用 streamId 发的流式消息,
* 只有同一 streamId 的 replyStream(finish=true) 才能关闭它。
*
* ⚠️ 注意:企微会忽略空格等不可见内容,必须用有可见字符的文案才能真正
* 替换掉 thinking 动画,否则 thinking 会一直残留。
*
* 关闭策略(按优先级):
* 1. 有可见文本 → 用完整文本关闭
* 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
* 3. 媒体发送失败 → 直接用错误摘要替换 thinking
* 4. 其他 → 用通用"处理完成"提示
* agent 可能已通过内置 message 工具直接发送了文件,
* 该路径走 outbound.sendMedia 完全绕过 deliver 回调,
* 所以 state 中无记录,但文件已实际送达)
*/
async function finishThinkingStream(ctx) {
const { wsClient, frame, state, runtime } = ctx;
const visibleText = stripThinkTags(state.accumulatedText);
let finishText;
if (visibleText) {
// 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
finishText = state.accumulatedText;
}
else if (state.hasMedia) {
// 媒体成功发送:用友好提示告知用户
finishText = "📎 文件已发送,请查收。";
}
else if (state.hasMediaFailed && state.mediaErrorSummary) {
// 媒体发送失败:直接用错误摘要替换 thinking 流(不再额外发 sendMessage
finishText = state.mediaErrorSummary;
}
else {
// 核心无可见文本且 deliver 中未处理过媒体。
//
// 不使用错误提示,因为 agent 可能已通过内置 message 工具直接调用
// outbound.sendMedia 成功发送了文件——该路径完全绕过 monitor 的 deliver
// 回调,所以 state.deliverCalled / state.hasMedia 均为 false但文件
// 实际已送达用户。此时显示 "未生成回复" 会误导用户。
finishText = "✅ 处理完成。";
}
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
}
/**
* 路由消息到核心处理流程并处理回复
*/
async function routeAndDispatchMessage(params) {
const { ctxPayload, config, account, wsClient, frame, state, runtime, onCleanup } = params;
const core = getWeComRuntime();
const ctx = { wsClient, frame, state, account, runtime };
// 防止 onCleanup 被多次调用onError 回调与 catch 块可能重复触发)
let cleanedUp = false;
const safeCleanup = () => {
if (!cleanedUp) {
cleanedUp = true;
onCleanup();
}
};
try {
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload, info) => {
state.deliverCalled = true;
runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
// 累积文本
if (payload.text) {
accumulateText(state, payload.text);
}
// 发送媒体(统一走主动发送)
const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
if (mediaUrls.length > 0) {
try {
await sendMediaBatch(ctx, mediaUrls);
}
catch (mediaErr) {
// sendMediaBatch 内部异常(如 getDefaultMediaLocalRoots 不可用等)
// 必须标记 state否则 finishThinkingStream 会显示"处理完成"误导用户
state.hasMediaFailed = true;
const errMsg = String(mediaErr);
const summary = `⚠️ 文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
state.mediaErrorSummary = state.mediaErrorSummary
? `${state.mediaErrorSummary}\n\n${summary}`
: summary;
runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
}
}
// 中间帧:有可见文本时流式更新
if (info.kind !== "final" && state.hasText && state.accumulatedText) {
await sendWeComReply({ wsClient, frame, text: state.accumulatedText, runtime, finish: false, streamId: state.streamId });
}
},
onError: (err, info) => {
runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
},
},
});
// 关闭 thinking 流
await finishThinkingStream(ctx);
safeCleanup();
}
catch (err) {
runtime.error?.(`[wecom][plugin] Failed to process message: ${String(err)}`);
// 即使 dispatch 抛异常,也需要关闭 thinking 流,
// 避免 deliver 已成功发送媒体但后续出错时 thinking 消息残留或被错误文案覆盖
try {
await finishThinkingStream(ctx);
}
catch (finishErr) {
runtime.error?.(`[wecom] Failed to finish thinking stream after dispatch error: ${String(finishErr)}`);
}
safeCleanup();
}
}
/**
* 处理企业微信消息(主函数)
*
* 处理流程:
* 1. 解析消息内容(文本、图片、引用)
* 2. 群组策略检查(仅群聊)
* 3. DM Policy 访问控制检查(仅私聊)
* 4. 下载并保存图片
* 5. 初始化消息状态
* 6. 发送"思考中"消息
* 7. 路由消息到核心处理流程
*
* 整体带超时保护,防止单条消息处理阻塞过久
*/
async function processWeComMessage(params) {
const { frame, account, config, runtime, wsClient } = params;
const body = frame.body;
const chatId = body.chatid || body.from.userid;
const chatType = body.chattype === "group" ? "group" : "direct";
const messageId = body.msgid;
const reqId = frame.headers.req_id;
// Step 1: 解析消息内容
const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
let text = textParts.join("\n").trim();
// // 群聊中移除 @机器人 的提及标记
// if (body.chattype === "group") {
// text = text.replace(/@\S+/g, "").trim();
// }
// 如果文本为空但存在引用消息,使用引用消息内容
if (!text && quoteContent) {
text = quoteContent;
runtime.log?.("[wecom][plugin] Using quote content as message body (user only mentioned bot)");
}
// 如果既没有文本也没有图片也没有文件也没有引用内容,则跳过
if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
runtime.log?.("[wecom][plugin] Skipping empty message (no text, image, file or quote)");
return;
}
// Step 2: 群组策略检查(仅群聊)
if (chatType === "group") {
const groupPolicyResult = checkGroupPolicy({
chatId,
senderId: body.from.userid,
account,
config,
runtime,
});
if (!groupPolicyResult.allowed) {
return;
}
}
// Step 3: DM Policy 访问控制检查(仅私聊)
const dmPolicyResult = await checkDmPolicy({
senderId: body.from.userid,
isGroup: chatType === "group",
account,
wsClient,
frame,
runtime,
});
if (!dmPolicyResult.allowed) {
return;
}
// Step 4: 下载并保存图片和文件
const [imageMediaList, fileMediaList] = await Promise.all([
downloadAndSaveImages({
imageUrls,
imageAesKeys,
account,
config,
runtime,
wsClient,
}),
downloadAndSaveFiles({
fileUrls,
fileAesKeys,
account,
config,
runtime,
wsClient,
}),
]);
const mediaList = [...imageMediaList, ...fileMediaList];
// Step 5: 初始化消息状态
setReqIdForChat(chatId, reqId, account.accountId);
const streamId = generateReqId("stream");
const state = { accumulatedText: "", streamId };
setMessageState(messageId, state);
const cleanupState = () => {
deleteMessageState(messageId);
};
// Step 6: 发送"思考中"消息
const shouldSendThinking = account.sendThinkingMessage ?? true;
if (shouldSendThinking) {
await sendThinkingReply({ wsClient, frame, streamId, runtime });
}
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
try {
await withTimeout(routeAndDispatchMessage({
ctxPayload,
config,
account,
wsClient,
frame,
state,
runtime,
onCleanup: cleanupState,
}), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${messageId})`);
}
catch (err) {
runtime.error?.(`[wecom][plugin] Message processing failed or timed out: ${String(err)}`);
// 确保 thinking 流被关闭,防止异常/超时时 thinking 消息一直残留
try {
if (shouldSendThinking) {
await sendWeComReply({
wsClient,
frame,
text: "处理消息时出现异常,请稍后重试。",
runtime,
finish: true,
streamId: state.streamId,
});
}
}
catch (finishErr) {
runtime.error?.(`[wecom] Failed to finish thinking stream on error: ${String(finishErr)}`);
}
cleanupState();
}
}
// ============================================================================
// 创建 SDK Logger 适配器
// ============================================================================
/**
* 创建适配 RuntimeEnv 的 Logger
*/
function createSdkLogger(runtime, accountId) {
return {
debug: (message, ...args) => {
runtime.log?.(`[${accountId}] ${message}`, ...args);
},
info: (message, ...args) => {
runtime.log?.(`[${accountId}] ${message}`, ...args);
},
warn: (message, ...args) => {
runtime.log?.(`[${accountId}] WARN: ${message}`, ...args);
},
error: (message, ...args) => {
runtime.error?.(`[${accountId}] ${message}`, ...args);
},
};
}
// ============================================================================
// 主函数
// ============================================================================
/**
* 监听企业微信 WebSocket 连接
* 使用 aibot-node-sdk 简化连接管理
*/
async function monitorWeComProvider(options) {
const { account, config, runtime, abortSignal } = options;
runtime.log?.(`[${account.accountId}] Initializing WSClient with SDK...`);
// 启动消息状态定期清理
startMessageStateCleanup();
return new Promise((resolve, reject) => {
const logger = createSdkLogger(runtime, account.accountId);
const wsClient = new WSClient({
botId: account.botId,
secret: account.secret,
wsUrl: account.websocketUrl,
logger,
heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
});
// 清理函数:确保所有资源被释放
const cleanup = async () => {
stopMessageStateCleanup();
await cleanupAccount(account.accountId);
};
// 处理中止信号
if (abortSignal) {
abortSignal.addEventListener("abort", async () => {
runtime.log?.(`[${account.accountId}] Connection aborted`);
await cleanup();
resolve();
});
}
// 监听连接事件
wsClient.on("connected", () => {
runtime.log?.(`[${account.accountId}] WebSocket connected`);
});
// 监听认证成功事件
wsClient.on("authenticated", () => {
runtime.log?.(`[${account.accountId}] Authentication successful`);
setWeComWebSocket(account.accountId, wsClient);
// 认证成功后自动拉取 MCP 配置(异步,失败不影响主流程)
fetchAndSaveMcpConfig(wsClient, account.accountId, runtime);
});
// 监听断开事件
wsClient.on("disconnected", (reason) => {
runtime.log?.(`[${account.accountId}] WebSocket disconnected: ${reason}`);
});
// 监听重连事件
wsClient.on("reconnecting", (attempt) => {
runtime.log?.(`[${account.accountId}] Reconnecting attempt ${attempt}...`);
});
// 监听错误事件
wsClient.on("error", (error) => {
runtime.error?.(`[${account.accountId}] WebSocket error: ${error.message}`);
// 认证失败时拒绝 Promise
if (error.message.includes("Authentication failed")) {
cleanup().finally(() => reject(error));
}
});
// 监听所有消息
wsClient.on("message", async (frame) => {
try {
await processWeComMessage({
frame,
account,
config,
runtime,
wsClient,
});
}
catch (err) {
runtime.error?.(`[${account.accountId}] Failed to process message: ${String(err)}`);
}
});
// 启动前预热 reqId 缓存,确保完成后再建立连接,避免 getSync 在预热完成前返回 undefined
warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
.then((count) => {
runtime.log?.(`[${account.accountId}] Warmed up ${count} reqId entries from disk`);
})
.catch((err) => {
runtime.error?.(`[${account.accountId}] Failed to warmup reqId store: ${String(err)}`);
})
.finally(() => {
// 无论预热成功或失败,都建立连接
wsClient.connect();
});
});
}
/**
* 企业微信公共工具函数
*/
const DefaultWsUrl = "wss://openws.work.weixin.qq.com";
/**
* 解析企业微信账户配置
*/
function resolveWeComAccount(cfg) {
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
return {
accountId: DEFAULT_ACCOUNT_ID,
name: wecomConfig.name ?? "企业微信",
enabled: wecomConfig.enabled ?? false,
websocketUrl: wecomConfig.websocketUrl || DefaultWsUrl,
botId: wecomConfig.botId ?? "",
secret: wecomConfig.secret ?? "",
sendThinkingMessage: wecomConfig.sendThinkingMessage ?? true,
config: wecomConfig,
};
}
/**
* 设置企业微信账户配置
*/
function setWeComAccount(cfg, account) {
const existing = (cfg.channels?.[CHANNEL_ID] ?? {});
const merged = {
enabled: account.enabled ?? existing?.enabled ?? true,
botId: account.botId ?? existing?.botId ?? "",
secret: account.secret ?? existing?.secret ?? "",
allowFrom: account.allowFrom ?? existing?.allowFrom,
dmPolicy: account.dmPolicy ?? existing?.dmPolicy,
// 以下字段仅在已有配置值或显式传入时才写入onboarding 时不主动生成
...(account.websocketUrl || existing?.websocketUrl
? { websocketUrl: account.websocketUrl ?? existing?.websocketUrl }
: {}),
...(account.name || existing?.name
? { name: account.name ?? existing?.name }
: {}),
...(account.sendThinkingMessage !== undefined || existing?.sendThinkingMessage !== undefined
? { sendThinkingMessage: account.sendThinkingMessage ?? existing?.sendThinkingMessage }
: {}),
};
return {
...cfg,
channels: {
...cfg.channels,
[CHANNEL_ID]: merged,
},
};
}
/**
* 企业微信 onboarding adapter for CLI setup wizard.
*/
const channel = CHANNEL_ID;
/**
* 企业微信设置帮助说明
*/
async function noteWeComSetupHelp(prompter) {
await prompter.note([
"企业微信机器人需要以下配置信息:",
"1. Bot ID: 企业微信机器人id",
"2. Secret: 企业微信机器人密钥",
].join("\n"), "企业微信设置");
}
/**
* 提示输入 Bot ID
*/
async function promptBotId(prompter, account) {
return String(await prompter.text({
message: "企业微信机器人 Bot ID",
initialValue: account?.botId ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
/**
* 提示输入 Secret
*/
async function promptSecret(prompter, account) {
return String(await prompter.text({
message: "企业微信机器人 Secret",
initialValue: account?.secret ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
/**
* 设置企业微信 dmPolicy
*/
function setWeComDmPolicy(cfg, dmPolicy) {
const account = resolveWeComAccount(cfg);
const existingAllowFrom = account.config.allowFrom ?? [];
const allowFrom = dmPolicy === "open"
? addWildcardAllowFrom(existingAllowFrom.map((x) => String(x)))
: existingAllowFrom.map((x) => String(x));
return setWeComAccount(cfg, {
dmPolicy,
allowFrom,
});
}
const dmPolicy = {
label: "企业微信",
channel,
policyKey: `channels.${CHANNEL_ID}.dmPolicy`,
allowFromKey: `channels.${CHANNEL_ID}.allowFrom`,
getCurrent: (cfg) => {
const account = resolveWeComAccount(cfg);
return account.config.dmPolicy ?? "open";
},
setPolicy: (cfg, policy) => {
return setWeComDmPolicy(cfg, policy);
},
promptAllowFrom: async ({ cfg, prompter }) => {
const account = resolveWeComAccount(cfg);
const existingAllowFrom = account.config.allowFrom ?? [];
const entry = await prompter.text({
message: "企业微信允许来源用户ID或群组ID每行一个推荐用于安全控制",
placeholder: "user123 或 group456",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
});
const allowFrom = String(entry ?? "")
.split(/[\n,;]+/g)
.map((s) => s.trim())
.filter(Boolean);
return setWeComAccount(cfg, { allowFrom });
},
};
const wecomOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = resolveWeComAccount(cfg);
const configured = Boolean(account.botId?.trim() &&
account.secret?.trim());
return {
channel,
configured,
statusLines: [`企业微信: ${configured ? "已配置" : "需要 Bot ID 和 Secret"}`],
selectionHint: configured ? "已配置" : "需要设置",
};
},
configure: async ({ cfg, prompter, forceAllowFrom }) => {
const account = resolveWeComAccount(cfg);
if (!account.botId?.trim() || !account.secret?.trim()) {
await noteWeComSetupHelp(prompter);
}
// 提示输入必要的配置信息Bot ID 和 Secret
const botId = await promptBotId(prompter, account);
const secret = await promptSecret(prompter, account);
// 使用默认值配置其他选项
const cfgWithAccount = setWeComAccount(cfg, {
botId,
secret,
enabled: true,
dmPolicy: account.config.dmPolicy ?? "open",
allowFrom: account.config.allowFrom ?? [],
});
return { cfg: cfgWithAccount };
},
dmPolicy,
disable: (cfg) => {
return setWeComAccount(cfg, { enabled: false });
},
};
/**
* 使用 SDK 的 sendMessage 主动发送企业微信消息
* 无需依赖 reqId直接向指定会话推送消息
*/
async function sendWeComMessage({ to, content, accountId, }) {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
// 从 to 中提取 chatId格式是 "${CHANNEL_ID}:chatId" 或直接是 chatId
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
const chatId = to.replace(channelPrefix, "");
// 获取 WSClient 实例
const wsClient = getWeComWebSocket(resolvedAccountId);
if (!wsClient) {
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
}
// 使用 SDK 的 sendMessage 主动发送 markdown 消息
const result = await wsClient.sendMessage(chatId, {
msgtype: 'markdown',
markdown: { content },
});
const messageId = result?.headers?.req_id ?? `wecom-${Date.now()}`;
return {
channel: CHANNEL_ID,
messageId,
chatId,
};
}
// 企业微信频道元数据
const meta = {
id: CHANNEL_ID,
label: "企业微信",
selectionLabel: "企业微信 (WeCom)",
detailLabel: "企业微信智能机器人",
docsPath: `/channels/${CHANNEL_ID}`,
docsLabel: CHANNEL_ID,
blurb: "企业微信智能机器人接入插件",
systemImage: "message.fill",
};
const wecomPlugin = {
id: CHANNEL_ID,
meta: {
...meta,
quickstartAllowFrom: true,
},
pairing: {
idLabel: "wecomUserId",
normalizeAllowEntry: (entry) => entry.replace(new RegExp(`^(${CHANNEL_ID}|user):`, "i"), "").trim(),
notifyApproval: async ({ cfg, id }) => {
// sendWeComMessage({
// to: id,
// content: " pairing approved",
// accountId: cfg.accountId,
// });
// Pairing approved for user
},
},
onboarding: wecomOnboardingAdapter,
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
config: {
// 列出所有账户 ID最小实现只支持默认账户
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
// 解析账户配置
resolveAccount: (cfg) => resolveWeComAccount(cfg),
// 获取默认账户 ID
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
// 设置账户启用状态
setAccountEnabled: ({ cfg, enabled }) => {
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
return {
...cfg,
channels: {
...cfg.channels,
[CHANNEL_ID]: {
...wecomConfig,
enabled,
},
},
};
},
// 删除账户
deleteAccount: ({ cfg }) => {
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
const { botId, secret, ...rest } = wecomConfig;
return {
...cfg,
channels: {
...cfg.channels,
[CHANNEL_ID]: rest,
},
};
},
// 检查是否已配置
isConfigured: (account) => Boolean(account.botId?.trim() && account.secret?.trim()),
// 描述账户信息
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botId?.trim() && account.secret?.trim()),
botId: account.botId,
websocketUrl: account.websocketUrl,
}),
// 解析允许来源列表
resolveAllowFrom: ({ cfg }) => {
const account = resolveWeComAccount(cfg);
return (account.config.allowFrom ?? []).map((entry) => String(entry));
},
// 格式化允许来源列表
formatAllowFrom: ({ allowFrom }) => allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean),
},
security: {
resolveDmPolicy: ({ account }) => {
const basePath = `channels.${CHANNEL_ID}.`;
return {
policy: account.config.dmPolicy ?? "open",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint(CHANNEL_ID),
normalizeEntry: (raw) => raw.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim(),
};
},
collectWarnings: ({ account, cfg }) => {
const warnings = [];
// DM 策略警告
const dmPolicy = account.config.dmPolicy ?? "open";
if (dmPolicy === "open") {
const hasWildcard = (account.config.allowFrom ?? []).some((entry) => String(entry).trim() === "*");
if (!hasWildcard) {
warnings.push(`- 企业微信私信dmPolicy="open" 但 allowFrom 未包含 "*"。任何人都可以发消息,但允许列表为空可能导致意外行为。建议设置 channels.${CHANNEL_ID}.allowFrom=["*"] 或使用 dmPolicy="pairing"。`);
}
}
// 群组策略警告
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
// const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
// providerConfigPresent: true,
// groupPolicy: account.config.groupPolicy,
// defaultGroupPolicy,
// });
if (groupPolicy === "open") {
warnings.push(`- 企业微信群组groupPolicy="open" 允许所有群组中的成员触发。设置 channels.${CHANNEL_ID}.groupPolicy="allowlist" + channels.${CHANNEL_ID}.groupAllowFrom 来限制群组。`);
}
return warnings;
},
},
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed)
return undefined;
return trimmed;
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
return Boolean(trimmed);
},
hint: "<userId|groupId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
outbound: {
deliveryMode: "gateway",
chunker: (text, limit) => getWeComRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: TEXT_CHUNK_LIMIT,
sendText: async ({ to, text, accountId }) => {
return sendWeComMessage({ to, content: text, accountId: accountId ?? undefined });
},
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
const chatId = to.replace(channelPrefix, "");
// 获取 WSClient 实例
const wsClient = getWeComWebSocket(resolvedAccountId);
if (!wsClient) {
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
}
// 如果没有 mediaUrlfallback 为纯文本
if (!mediaUrl) {
return sendWeComMessage({ to, content: text || "", accountId: resolvedAccountId });
}
const result = await uploadAndSendMedia({
wsClient,
mediaUrl,
chatId,
mediaLocalRoots,
});
if (result.rejected) {
return sendWeComMessage({ to, content: `⚠️ ${result.rejectReason}`, accountId: resolvedAccountId });
}
if (!result.ok) {
// 上传/发送失败fallback 为文本 + URL
const fallbackContent = text
? `${text}\n📎 ${mediaUrl}`
: `📎 ${mediaUrl}`;
return sendWeComMessage({ to, content: fallbackContent, accountId: resolvedAccountId });
}
// 如有伴随文本,额外发送一条 markdown
if (text) {
await sendWeComMessage({ to, content: text, accountId: resolvedAccountId });
}
// 如果有降级说明,额外发送提示
if (result.downgradeNote) {
await sendWeComMessage({ to, content: ` ${result.downgradeNote}`, accountId: resolvedAccountId });
}
return {
channel: CHANNEL_ID,
messageId: result.messageId,
chatId,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) => accounts.flatMap((entry) => {
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
const enabled = entry.enabled !== false;
const configured = entry.configured === true;
if (!enabled) {
return [];
}
const issues = [];
if (!configured) {
issues.push({
channel: CHANNEL_ID,
accountId,
kind: "config",
message: "企业微信机器人 ID 或 Secret 未配置",
fix: "Run: openclaw channels add wecom --bot-id <id> --secret <secret>",
});
}
return issues;
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
}),
probeAccount: async () => {
return { ok: true, status: 200 };
},
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.botId?.trim() &&
account.secret?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
// 启动 WebSocket 监听
return monitorWeComProvider({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
logoutAccount: async ({ cfg }) => {
const nextCfg = { ...cfg };
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
const nextWecom = { ...wecomConfig };
let cleared = false;
let changed = false;
if (nextWecom.botId || nextWecom.secret) {
delete nextWecom.botId;
delete nextWecom.secret;
cleared = true;
changed = true;
}
if (changed) {
if (Object.keys(nextWecom).length > 0) {
nextCfg.channels = { ...nextCfg.channels, [CHANNEL_ID]: nextWecom };
}
else {
const nextChannels = { ...nextCfg.channels };
delete nextChannels[CHANNEL_ID];
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
}
else {
delete nextCfg.channels;
}
}
await getWeComRuntime().config.writeConfigFile(nextCfg);
}
const resolved = resolveWeComAccount(changed ? nextCfg : cfg);
const loggedOut = !resolved.botId && !resolved.secret;
return { cleared, envToken: false, loggedOut };
},
},
};
const plugin = {
id: "wecom-openclaw-plugin",
name: "企业微信",
description: "企业微信 OpenClaw 插件",
configSchema: emptyPluginConfigSchema(),
register(api) {
setWeComRuntime(api.runtime);
api.registerChannel({ plugin: wecomPlugin });
// 注入媒体发送指令和文件大小限制提示词
api.on("before_prompt_build", () => {
return {
appendSystemContext: [
"【发送文件/图片/视频/语音】",
"当你需要向用户发送文件、图片、视频或语音时,必须在回复中单独一行使用 MEDIA: 指令,后面跟文件的本地路径。",
"格式MEDIA: /文件的绝对路径",
"文件优先存放到 ~/.openclaw 目录下,确保路径可访问。",
"示例:",
" MEDIA: ~/.openclaw/output.png",
" MEDIA: ~/.openclaw/report.pdf",
"系统会自动识别文件类型并发送给用户。",
"",
"注意事项:",
"- MEDIA: 必须在行首,后面紧跟文件路径(不是 URL",
"- 如果路径中包含空格可以用反引号包裹MEDIA: `/path/to/my file.png`",
"- 每个文件单独一行 MEDIA: 指令",
"- 可以在 MEDIA: 指令前后附带文字说明",
"",
"【文件大小限制】",
"- 图片不超过 10MB视频不超过 10MB语音不超过 2MB仅支持 AMR 格式),文件不超过 20MB",
"- 语音消息仅支持 AMR 格式(.amr如需发送语音请确保文件为 AMR 格式",
"- 超过大小限制的图片/视频/语音会被自动转为文件格式发送",
"- 如果文件超过 20MB将无法发送请提前告知用户并尝试缩减文件大小",
].join("\n"),
};
});
},
};
export { plugin as default };
//# sourceMappingURL=index.esm.js.map