@sunnoy/wecom 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +465 -144
- package/crypto.js +110 -83
- package/dynamic-agent.js +70 -87
- package/image-processor.js +86 -93
- package/index.js +16 -1068
- package/logger.js +48 -49
- package/package.json +5 -6
- package/stream-manager.js +316 -265
- package/utils.js +76 -238
- package/webhook.js +434 -287
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +433 -0
- package/wecom/allow-from.js +58 -0
- package/wecom/channel-plugin.js +638 -0
- package/wecom/commands.js +85 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +519 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +175 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +82 -0
- package/wecom/stream-utils.js +124 -0
- package/wecom/target.js +57 -0
- package/wecom/webhook-bot.js +155 -0
- package/wecom/webhook-targets.js +28 -0
- package/wecom/workspace-template.js +165 -0
- package/wecom/xml-parser.js +126 -0
- package/README_ZH.md +0 -289
- package/client.js +0 -127
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
4
|
+
import { BOOTSTRAP_FILENAMES } from "./constants.js";
|
|
5
|
+
import {
|
|
6
|
+
getEnsureDynamicAgentWriteQueue,
|
|
7
|
+
getEnsuredDynamicAgentIds,
|
|
8
|
+
getOpenclawConfig,
|
|
9
|
+
getRuntime,
|
|
10
|
+
setEnsureDynamicAgentWriteQueue,
|
|
11
|
+
setOpenclawConfig,
|
|
12
|
+
} from "./state.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the agent workspace directory for a given agentId.
|
|
16
|
+
* Mirrors openclaw core's resolveAgentWorkspaceDir logic for non-default agents:
|
|
17
|
+
* stateDir/workspace-{agentId}
|
|
18
|
+
*/
|
|
19
|
+
export function resolveAgentWorkspaceDirLocal(agentId) {
|
|
20
|
+
const stateDir =
|
|
21
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
22
|
+
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
23
|
+
join(process.env.HOME || "/root", ".openclaw");
|
|
24
|
+
return join(stateDir, `workspace-${agentId}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the workspace template dir from plugin config.
|
|
29
|
+
* Config key: channels.wecom.workspaceTemplate
|
|
30
|
+
*/
|
|
31
|
+
export function getWorkspaceTemplateDir(config) {
|
|
32
|
+
return config?.channels?.wecom?.workspaceTemplate?.trim() || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Copy template files into a newly created agent's workspace directory.
|
|
37
|
+
* Only copies files that don't already exist (writeFileIfMissing semantics).
|
|
38
|
+
* Silently skips if workspaceTemplate is not configured or directory is missing.
|
|
39
|
+
*/
|
|
40
|
+
export function seedAgentWorkspace(agentId, config) {
|
|
41
|
+
const templateDir = getWorkspaceTemplateDir(config);
|
|
42
|
+
if (!templateDir) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(templateDir)) {
|
|
47
|
+
logger.warn("WeCom: workspace template dir not found, skipping seed", { templateDir });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const workspaceDir = resolveAgentWorkspaceDirLocal(agentId);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
const files = readdirSync(templateDir);
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (!BOOTSTRAP_FILENAMES.has(file)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const dest = join(workspaceDir, file);
|
|
62
|
+
if (existsSync(dest)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
copyFileSync(join(templateDir, file), dest);
|
|
66
|
+
logger.info("WeCom: seeded workspace file", { agentId, file });
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logger.warn("WeCom: failed to seed agent workspace", {
|
|
70
|
+
agentId,
|
|
71
|
+
error: err?.message || String(err),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
77
|
+
const normalizedId = String(agentId || "")
|
|
78
|
+
.trim()
|
|
79
|
+
.toLowerCase();
|
|
80
|
+
if (!normalizedId) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!cfg.agents || typeof cfg.agents !== "object") {
|
|
85
|
+
cfg.agents = {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const currentList = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
|
|
89
|
+
const existingIds = new Set(
|
|
90
|
+
currentList
|
|
91
|
+
.map((entry) => (entry && typeof entry.id === "string" ? entry.id.trim().toLowerCase() : ""))
|
|
92
|
+
.filter(Boolean),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
let changed = false;
|
|
96
|
+
const nextList = [...currentList];
|
|
97
|
+
|
|
98
|
+
// Keep "main" as the explicit default when creating agents.list for the first time.
|
|
99
|
+
if (nextList.length === 0) {
|
|
100
|
+
nextList.push({ id: "main" });
|
|
101
|
+
existingIds.add("main");
|
|
102
|
+
changed = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!existingIds.has(normalizedId)) {
|
|
106
|
+
nextList.push({ id: normalizedId });
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (changed) {
|
|
111
|
+
cfg.agents.list = nextList;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return changed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function ensureDynamicAgentListed(agentId) {
|
|
118
|
+
const normalizedId = String(agentId || "")
|
|
119
|
+
.trim()
|
|
120
|
+
.toLowerCase();
|
|
121
|
+
if (!normalizedId) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const runtime = getRuntime();
|
|
126
|
+
const configRuntime = runtime?.config;
|
|
127
|
+
if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const queue = getEnsureDynamicAgentWriteQueue()
|
|
132
|
+
.then(async () => {
|
|
133
|
+
const latestConfig = configRuntime.loadConfig();
|
|
134
|
+
if (!latestConfig || typeof latestConfig !== "object") {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
|
|
139
|
+
if (changed) {
|
|
140
|
+
await configRuntime.writeConfigFile(latestConfig);
|
|
141
|
+
logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
|
|
142
|
+
}
|
|
143
|
+
// Always attempt seeding so recreated/cleaned dynamic agents can recover
|
|
144
|
+
// template files even when the id already exists in agents.list.
|
|
145
|
+
seedAgentWorkspace(normalizedId, latestConfig);
|
|
146
|
+
|
|
147
|
+
// Keep runtime in-memory config aligned to avoid stale reads in this process.
|
|
148
|
+
const openclawConfig = getOpenclawConfig();
|
|
149
|
+
if (openclawConfig && typeof openclawConfig === "object") {
|
|
150
|
+
upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
|
|
151
|
+
setOpenclawConfig(openclawConfig);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getEnsuredDynamicAgentIds().add(normalizedId);
|
|
155
|
+
})
|
|
156
|
+
.catch((err) => {
|
|
157
|
+
logger.warn("WeCom: failed to sync dynamic agent into agents.list", {
|
|
158
|
+
agentId: normalizedId,
|
|
159
|
+
error: err?.message || String(err),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
setEnsureDynamicAgentWriteQueue(queue);
|
|
164
|
+
await queue;
|
|
165
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom XML Parser
|
|
3
|
+
*
|
|
4
|
+
* Simple regex-based parser for Agent mode XML callbacks.
|
|
5
|
+
* No external dependencies — WeCom XML has a flat, predictable structure.
|
|
6
|
+
*
|
|
7
|
+
* Typical decrypted XML:
|
|
8
|
+
* <xml>
|
|
9
|
+
* <ToUserName><![CDATA[corpId]]></ToUserName>
|
|
10
|
+
* <FromUserName><![CDATA[zhangsan]]></FromUserName>
|
|
11
|
+
* <CreateTime>1348831860</CreateTime>
|
|
12
|
+
* <MsgType><![CDATA[text]]></MsgType>
|
|
13
|
+
* <Content><![CDATA[hello]]></Content>
|
|
14
|
+
* <MsgId>1234567890123456</MsgId>
|
|
15
|
+
* <AgentID>1000002</AgentID>
|
|
16
|
+
* </xml>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the <Encrypt> field from the outer XML envelope.
|
|
21
|
+
* Supports both CDATA and plain text formats.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} xml - Raw XML string from WeCom POST body
|
|
24
|
+
* @returns {string} The encrypted payload
|
|
25
|
+
*/
|
|
26
|
+
export function extractEncryptFromXml(xml) {
|
|
27
|
+
const cdataMatch = /<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/s.exec(xml);
|
|
28
|
+
if (cdataMatch?.[1]) return cdataMatch[1];
|
|
29
|
+
|
|
30
|
+
const plainMatch = /<Encrypt>(.*?)<\/Encrypt>/s.exec(xml);
|
|
31
|
+
if (plainMatch?.[1]) return plainMatch[1];
|
|
32
|
+
|
|
33
|
+
throw new Error("Invalid XML: missing Encrypt field");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a decrypted WeCom XML message into a flat key-value object.
|
|
38
|
+
* Handles both CDATA-wrapped and plain text values.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} xml - Decrypted XML string
|
|
41
|
+
* @returns {Record<string, string>}
|
|
42
|
+
*/
|
|
43
|
+
export function parseXml(xml) {
|
|
44
|
+
const result = {};
|
|
45
|
+
|
|
46
|
+
// Match <TagName><![CDATA[value]]></TagName> (CDATA)
|
|
47
|
+
const cdataRegex = /<(\w+)><!\[CDATA\[([\s\S]*?)\]\]><\/\1>/g;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = cdataRegex.exec(xml)) !== null) {
|
|
50
|
+
result[match[1]] = match[2];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Match <TagName>value</TagName> (plain text, skip already-captured CDATA fields)
|
|
54
|
+
const plainRegex = /<(\w+)>([^<]+)<\/\1>/g;
|
|
55
|
+
while ((match = plainRegex.exec(xml)) !== null) {
|
|
56
|
+
if (!(match[1] in result)) {
|
|
57
|
+
result[match[1]] = match[2].trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Extract message type (lowercase). */
|
|
65
|
+
export function extractMsgType(msg) {
|
|
66
|
+
return String(msg.MsgType ?? "").toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract sender user ID. */
|
|
70
|
+
export function extractFromUser(msg) {
|
|
71
|
+
return String(msg.FromUserName ?? "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Extract group chat ID (undefined for DMs). */
|
|
75
|
+
export function extractChatId(msg) {
|
|
76
|
+
return msg.ChatId ? String(msg.ChatId) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Extract message ID for deduplication. */
|
|
80
|
+
export function extractMsgId(msg) {
|
|
81
|
+
const raw = msg.MsgId ?? msg.MsgID ?? msg.msgid ?? msg.msgId;
|
|
82
|
+
return raw != null ? String(raw) : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Extract file name (for file messages). */
|
|
86
|
+
export function extractFileName(msg) {
|
|
87
|
+
const raw = msg.FileName ?? msg.Filename ?? msg.fileName ?? msg.filename;
|
|
88
|
+
return raw != null ? String(raw).trim() || undefined : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Extract media ID (for image/voice/video/file messages). */
|
|
92
|
+
export function extractMediaId(msg) {
|
|
93
|
+
const raw = msg.MediaId ?? msg.MediaID ?? msg.mediaid ?? msg.mediaId;
|
|
94
|
+
return raw != null ? String(raw).trim() || undefined : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract human-readable content from a parsed message.
|
|
99
|
+
*
|
|
100
|
+
* @param {Record<string, string>} msg - Parsed XML message
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function extractContent(msg) {
|
|
104
|
+
const msgType = extractMsgType(msg);
|
|
105
|
+
|
|
106
|
+
switch (msgType) {
|
|
107
|
+
case "text":
|
|
108
|
+
return String(msg.Content ?? "");
|
|
109
|
+
case "voice":
|
|
110
|
+
return String(msg.Recognition ?? "") || "[语音消息]";
|
|
111
|
+
case "image":
|
|
112
|
+
return `[图片] ${msg.PicUrl ?? ""}`;
|
|
113
|
+
case "file":
|
|
114
|
+
return "[文件消息]";
|
|
115
|
+
case "video":
|
|
116
|
+
return "[视频消息]";
|
|
117
|
+
case "location":
|
|
118
|
+
return `[位置] ${msg.Label ?? ""} (${msg.Location_X ?? ""}, ${msg.Location_Y ?? ""})`;
|
|
119
|
+
case "link":
|
|
120
|
+
return `[链接] ${msg.Title ?? ""}\n${msg.Description ?? ""}\n${msg.Url ?? ""}`;
|
|
121
|
+
case "event":
|
|
122
|
+
return `[事件] ${msg.Event ?? ""} - ${msg.EventKey ?? ""}`;
|
|
123
|
+
default:
|
|
124
|
+
return `[${msgType || "未知消息类型"}]`;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/README_ZH.md
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
# OpenClaw 企业微信 (WeCom) AI 机器人插件
|
|
2
|
-
|
|
3
|
-
[简体中文](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README_ZH.md) | [English](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README.md)
|
|
4
|
-
|
|
5
|
-
`openclaw-plugin-wecom` 是一个专为 [OpenClaw](https://github.com/openclaw/openclaw) 框架开发的企业微信(WeCom)集成插件。它允许你将强大的 AI 能力无缝接入企业微信,并支持多项高级功能。
|
|
6
|
-
|
|
7
|
-
## ✨ 核心特性
|
|
8
|
-
|
|
9
|
-
- 🌊 **流式输出 (Streaming)**: 基于企业微信最新的 AI 机器人流式分片机制,实现流畅的打字机式回复体验。
|
|
10
|
-
- 🤖 **动态 Agent 管理**: 默认按"每个私聊用户 / 每个群聊"自动创建独立 Agent。每个 Agent 拥有独立的工作区与对话上下文,实现更强的数据隔离。
|
|
11
|
-
- 👥 **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
|
|
12
|
-
- 🎤 **语音消息支持**: 自动处理企业微信转录后的语音消息,转换为文本进行 AI 交互(仅限私聊)。
|
|
13
|
-
- 🖼️ **图片支持**: 自动将本地图片(截图、生成的图像)进行 base64 编码并发送,无需额外配置。
|
|
14
|
-
- 🛠️ **指令增强**: 内置常用指令支持(如 `/new` 开启新会话、`/status` 查看状态等),并提供指令白名单配置功能。
|
|
15
|
-
- 🔒 **安全与认证**: 完整支持企业微信消息加解密、URL 验证及发送者身份校验。
|
|
16
|
-
- ⚡ **高性能异步处理**: 采用异步消息处理架构,确保即使在长耗时 AI 推理过程中,企业微信网关也能保持高响应性。
|
|
17
|
-
|
|
18
|
-
## 📋 前置要求
|
|
19
|
-
|
|
20
|
-
- 已安装 [OpenClaw](https://github.com/openclaw/openclaw) (版本 2026.1.30+)
|
|
21
|
-
- 企业微信管理后台权限,可创建智能机器人应用
|
|
22
|
-
- 可从企业微信访问的服务器地址(HTTP/HTTPS)
|
|
23
|
-
|
|
24
|
-
## 🚀 安装
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
openclaw plugins install @sunnoy/wecom
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
此命令会自动:
|
|
31
|
-
- 从 npm 下载插件
|
|
32
|
-
- 安装到 `~/.openclaw/extensions/` 目录
|
|
33
|
-
- 更新 OpenClaw 配置
|
|
34
|
-
- 注册插件
|
|
35
|
-
|
|
36
|
-
## ⚙️ 配置
|
|
37
|
-
|
|
38
|
-
在 OpenClaw 配置文件(`~/.openclaw/openclaw.json`)中添加:
|
|
39
|
-
|
|
40
|
-
```json
|
|
41
|
-
{
|
|
42
|
-
"plugins": {
|
|
43
|
-
"entries": {
|
|
44
|
-
"wecom": {
|
|
45
|
-
"enabled": true
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
"channels": {
|
|
50
|
-
"wecom": {
|
|
51
|
-
"enabled": true,
|
|
52
|
-
"token": "你的 Token",
|
|
53
|
-
"encodingAesKey": "你的 EncodingAESKey",
|
|
54
|
-
"commands": {
|
|
55
|
-
"enabled": true,
|
|
56
|
-
"allowlist": ["/new", "/status", "/help", "/compact"]
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 配置说明
|
|
64
|
-
|
|
65
|
-
| 配置项 | 类型 | 必填 | 说明 |
|
|
66
|
-
|--------|------|------|------|
|
|
67
|
-
| `plugins.entries.wecom.enabled` | boolean | 是 | 启用插件 |
|
|
68
|
-
| `channels.wecom.token` | string | 是 | 企业微信机器人 Token |
|
|
69
|
-
| `channels.wecom.encodingAesKey` | string | 是 | 企业微信消息加密密钥(43 位) |
|
|
70
|
-
| `channels.wecom.commands.allowlist` | array | 否 | 允许的指令白名单 |
|
|
71
|
-
|
|
72
|
-
## 🔌 企业微信后台配置
|
|
73
|
-
|
|
74
|
-
1. 登录[企业微信管理后台](https://work.weixin.qq.com/)
|
|
75
|
-
2. 进入"应用管理" → "应用" → "创建应用" → 选择"智能机器人"
|
|
76
|
-
3. 在"接收消息配置"中设置:
|
|
77
|
-
- **URL**: `https://your-domain.com/webhooks/wecom`
|
|
78
|
-
- **Token**: 与 `channels.wecom.token` 一致
|
|
79
|
-
- **EncodingAESKey**: 与 `channels.wecom.encodingAesKey` 一致
|
|
80
|
-
4. 保存配置并启用消息接收
|
|
81
|
-
|
|
82
|
-
## 🤖 动态 Agent 路由
|
|
83
|
-
|
|
84
|
-
本插件实现"按人/按群隔离"的 Agent 管理:
|
|
85
|
-
|
|
86
|
-
### 工作原理
|
|
87
|
-
|
|
88
|
-
1. 企业微信消息到达后,插件生成确定性的 `agentId`:
|
|
89
|
-
- **私聊**: `wecom-dm-<userId>`
|
|
90
|
-
- **群聊**: `wecom-group-<chatId>`
|
|
91
|
-
2. OpenClaw 自动创建/复用对应的 Agent 工作区
|
|
92
|
-
3. 每个用户/群聊拥有独立的对话历史和上下文
|
|
93
|
-
|
|
94
|
-
### 高级配置
|
|
95
|
-
|
|
96
|
-
配置在 `channels.wecom` 下:
|
|
97
|
-
|
|
98
|
-
```json
|
|
99
|
-
{
|
|
100
|
-
"channels": {
|
|
101
|
-
"wecom": {
|
|
102
|
-
"dynamicAgents": {
|
|
103
|
-
"enabled": true
|
|
104
|
-
},
|
|
105
|
-
"dm": {
|
|
106
|
-
"createAgentOnFirstMessage": true
|
|
107
|
-
},
|
|
108
|
-
"groupChat": {
|
|
109
|
-
"enabled": true,
|
|
110
|
-
"requireMention": true
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
118
|
-
|--------|------|--------|------|
|
|
119
|
-
| `dynamicAgents.enabled` | boolean | `true` | 是否启用动态 Agent |
|
|
120
|
-
| `dm.createAgentOnFirstMessage` | boolean | `true` | 私聊使用动态 Agent |
|
|
121
|
-
| `groupChat.enabled` | boolean | `true` | 启用群聊处理 |
|
|
122
|
-
| `groupChat.requireMention` | boolean | `true` | 群聊必须 @ 提及才响应 |
|
|
123
|
-
|
|
124
|
-
### 禁用动态 Agent
|
|
125
|
-
|
|
126
|
-
如果需要所有消息进入默认 Agent:
|
|
127
|
-
|
|
128
|
-
```json
|
|
129
|
-
{
|
|
130
|
-
"channels": {
|
|
131
|
-
"wecom": {
|
|
132
|
-
"dynamicAgents": { "enabled": false }
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## 🛠️ 指令白名单
|
|
139
|
-
|
|
140
|
-
为防止普通用户通过企业微信消息执行敏感的 Gateway 管理指令,本插件支持**指令白名单**机制。
|
|
141
|
-
|
|
142
|
-
```json
|
|
143
|
-
{
|
|
144
|
-
"channels": {
|
|
145
|
-
"wecom": {
|
|
146
|
-
"commands": {
|
|
147
|
-
"enabled": true,
|
|
148
|
-
"allowlist": ["/new", "/status", "/help", "/compact"]
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### 推荐白名单指令
|
|
156
|
-
|
|
157
|
-
| 指令 | 说明 | 安全级别 |
|
|
158
|
-
|------|------|----------|
|
|
159
|
-
| `/new` | 重置当前对话,开启全新会话 | ✅ 用户级 |
|
|
160
|
-
| `/compact` | 压缩当前会话上下文 | ✅ 用户级 |
|
|
161
|
-
| `/help` | 查看帮助信息 | ✅ 用户级 |
|
|
162
|
-
| `/status` | 查看当前 Agent 状态 | ✅ 用户级 |
|
|
163
|
-
|
|
164
|
-
> ⚠️ **安全提示**:不要将 `/gateway`、`/plugins` 等管理指令添加到白名单,避免普通用户获得 Gateway 实例的管理权限。
|
|
165
|
-
|
|
166
|
-
## ❓ 常见问题 (FAQ)
|
|
167
|
-
|
|
168
|
-
### Q: 配置文件中的插件 ID 应该使用什么?
|
|
169
|
-
|
|
170
|
-
**A:** 在 `plugins.entries` 中,应该使用**完整的插件 ID**:
|
|
171
|
-
|
|
172
|
-
```json
|
|
173
|
-
{
|
|
174
|
-
"plugins": {
|
|
175
|
-
"entries": {
|
|
176
|
-
"wecom": { "enabled": true } // ✅ 正确
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
**不要**使用 channel id:
|
|
183
|
-
```json
|
|
184
|
-
{
|
|
185
|
-
"plugins": {
|
|
186
|
-
"entries": {
|
|
187
|
-
"wecom": { "enabled": true } // ❌ 错误
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
### Q: 为什么 `openclaw doctor` 报告警告?
|
|
194
|
-
|
|
195
|
-
**A:** 如果看到配置警告,运行:
|
|
196
|
-
|
|
197
|
-
```bash
|
|
198
|
-
openclaw doctor --fix
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
这会自动修复常见的配置问题。
|
|
202
|
-
|
|
203
|
-
### Q: 图片发送是如何工作的?
|
|
204
|
-
|
|
205
|
-
**A:** 插件会自动处理 OpenClaw 生成的图片(如浏览器截图):
|
|
206
|
-
|
|
207
|
-
- **本地图片**(来自 `~/.openclaw/media/`)会自动进行 base64 编码,通过企业微信 `msg_item` API 发送
|
|
208
|
-
- **图片限制**:单张图片最大 2MB,支持 JPG 和 PNG 格式,每条消息最多 10 张图片
|
|
209
|
-
- **无需配置**:开箱即用,配合浏览器截图等工具自动生效
|
|
210
|
-
- 图片会在 AI 完成回复后显示(流式输出不支持增量发送图片)
|
|
211
|
-
|
|
212
|
-
**示例:**
|
|
213
|
-
```
|
|
214
|
-
用户:"帮我截个 GitHub 首页的图"
|
|
215
|
-
AI:[执行截图] → 图片在企业微信中正常显示 ✅
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
如果图片处理失败(超出大小限制、格式不支持等),文本回复仍会正常发送,错误信息会记录在日志中。
|
|
219
|
-
|
|
220
|
-
### Q: 机器人支持语音消息吗?
|
|
221
|
-
|
|
222
|
-
**A:** 支持!私聊中的语音消息会被企业微信自动转录为文字并作为文本处理,无需额外配置。
|
|
223
|
-
|
|
224
|
-
### Q: OpenClaw 开放公网需要 auth token,企业微信回调如何配置?
|
|
225
|
-
|
|
226
|
-
**A:** 企业微信机器人**不需要**配置 OpenClaw 的 Gateway Auth Token。
|
|
227
|
-
|
|
228
|
-
- **Gateway Auth Token** (`gateway.auth.token`) 主要用于:
|
|
229
|
-
- WebUI 访问认证
|
|
230
|
-
- WebSocket 连接认证
|
|
231
|
-
- CLI 远程连接认证
|
|
232
|
-
|
|
233
|
-
- **企业微信 Webhook** (`/webhooks/wecom`) 的认证机制:
|
|
234
|
-
- 使用企业微信自己的签名验证(Token + EncodingAESKey)
|
|
235
|
-
- 不需要 Gateway Auth Token
|
|
236
|
-
- OpenClaw 插件系统会自动处理 webhook 路由
|
|
237
|
-
|
|
238
|
-
**部署建议:**
|
|
239
|
-
1. 如果使用反向代理(如 Nginx),可以为 `/webhooks/wecom` 路径配置豁免认证
|
|
240
|
-
2. 或者将 webhook 端点暴露在独立端口,不经过 Gateway Auth
|
|
241
|
-
|
|
242
|
-
### Q: EncodingAESKey 长度验证失败怎么办?
|
|
243
|
-
|
|
244
|
-
**A:** 常见原因和解决方法:
|
|
245
|
-
|
|
246
|
-
1. **检查配置键名**:确保使用正确的键名 `encodingAesKey`(注意大小写)
|
|
247
|
-
```json
|
|
248
|
-
{
|
|
249
|
-
"channels": {
|
|
250
|
-
"wecom": {
|
|
251
|
-
"encodingAesKey": "..." // ✅ 正确
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
2. **检查密钥长度**:EncodingAESKey 必须是 43 位字符
|
|
258
|
-
```bash
|
|
259
|
-
# 检查长度
|
|
260
|
-
echo -n "你的密钥" | wc -c
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
3. **检查是否有多余空格/换行**:确保密钥字符串前后没有空格或换行符
|
|
264
|
-
|
|
265
|
-
## 📂 项目结构
|
|
266
|
-
|
|
267
|
-
```
|
|
268
|
-
openclaw-plugin-wecom/
|
|
269
|
-
├── index.js # 插件入口
|
|
270
|
-
├── webhook.js # 企业微信 HTTP 通信处理
|
|
271
|
-
├── dynamic-agent.js # 动态 Agent 分配逻辑
|
|
272
|
-
├── stream-manager.js # 流式回复管理
|
|
273
|
-
├── crypto.js # 企业微信加密算法
|
|
274
|
-
├── client.js # 客户端逻辑
|
|
275
|
-
├── logger.js # 日志模块
|
|
276
|
-
├── utils.js # 工具函数
|
|
277
|
-
├── package.json # npm 包配置
|
|
278
|
-
└── openclaw.plugin.json # OpenClaw 插件清单
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
## 🤝 贡献规范
|
|
282
|
-
|
|
283
|
-
我们非常欢迎开发者参与贡献!如果你发现了 Bug 或有更好的功能建议,请提交 Issue 或 Pull Request。
|
|
284
|
-
|
|
285
|
-
详见 [CONTRIBUTING.md](./CONTRIBUTING.md)
|
|
286
|
-
|
|
287
|
-
## 📄 开源协议
|
|
288
|
-
|
|
289
|
-
本项目采用 [ISC License](./LICENSE) 协议。
|
package/client.js
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WeCom AI Bot Client
|
|
3
|
-
* 智能机器人专用 - 只使用 response_url 回复,不需要 access_token
|
|
4
|
-
* https://developer.work.weixin.qq.com/document/path/101039
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { logger } from "./logger.js";
|
|
8
|
-
import { withRetry, parseWecomError, CONSTANTS } from "./utils.js";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 通过 response_url 主动回复消息
|
|
12
|
-
* https://developer.work.weixin.qq.com/document/path/101138
|
|
13
|
-
*/
|
|
14
|
-
export async function sendReplyMessage(responseUrl, message) {
|
|
15
|
-
if (!responseUrl) {
|
|
16
|
-
throw new Error("response_url is required");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
logger.debug("Sending reply via response_url", { msgtype: message.msgtype });
|
|
20
|
-
|
|
21
|
-
return await withRetry(async () => {
|
|
22
|
-
const res = await fetch(responseUrl, {
|
|
23
|
-
method: "POST",
|
|
24
|
-
headers: { "Content-Type": "application/json" },
|
|
25
|
-
body: JSON.stringify(message),
|
|
26
|
-
signal: AbortSignal.timeout(CONSTANTS.WEBHOOK_RESPONSE_TIMEOUT_MS),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
if (!res.ok) {
|
|
30
|
-
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const data = await res.json();
|
|
34
|
-
if (data.errcode !== 0) {
|
|
35
|
-
const errorInfo = parseWecomError(data.errcode, data.errmsg);
|
|
36
|
-
throw new Error(`Response failed: [${data.errcode}] ${errorInfo.message}`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
logger.info("Reply sent successfully via response_url");
|
|
40
|
-
return data;
|
|
41
|
-
}, {
|
|
42
|
-
retries: 2,
|
|
43
|
-
minTimeout: 500,
|
|
44
|
-
maxTimeout: 2000,
|
|
45
|
-
onRetry: (error, attempt) => {
|
|
46
|
-
logger.warn(`Reply retry ${attempt}/2`, { error: error.message });
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* 发送 Markdown 消息
|
|
53
|
-
*/
|
|
54
|
-
export async function sendMarkdownReply(responseUrl, content) {
|
|
55
|
-
return sendReplyMessage(responseUrl, {
|
|
56
|
-
msgtype: "markdown",
|
|
57
|
-
markdown: { content },
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 发送文本消息
|
|
63
|
-
*/
|
|
64
|
-
export async function sendTextReply(responseUrl, content) {
|
|
65
|
-
return sendReplyMessage(responseUrl, {
|
|
66
|
-
msgtype: "text",
|
|
67
|
-
text: { content },
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* 发送流式响应片段
|
|
73
|
-
* https://developer.work.weixin.qq.com/document/path/101031#流式消息回复
|
|
74
|
-
*
|
|
75
|
-
* @param responseUrl - 回调中返回的 response_url
|
|
76
|
-
* @param streamId - 流ID,同一轮对话保持一致
|
|
77
|
-
* @param content - 本次消息内容 (markdown 格式)
|
|
78
|
-
* @param isFinished - 是否结束流式响应
|
|
79
|
-
*/
|
|
80
|
-
export async function sendStreamChunk(responseUrl, streamId, content, isFinished = false) {
|
|
81
|
-
if (!responseUrl) {
|
|
82
|
-
throw new Error("response_url is required for streaming");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const message = {
|
|
86
|
-
msgtype: "stream",
|
|
87
|
-
stream: {
|
|
88
|
-
id: streamId,
|
|
89
|
-
finish: isFinished,
|
|
90
|
-
content: content,
|
|
91
|
-
// msg_item: [], // 可选:图片等
|
|
92
|
-
// feedback: { id: "feedid" } // 可选:反馈ID
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
logger.debug("Sending stream chunk", { streamId, isFinished, length: content.length });
|
|
97
|
-
|
|
98
|
-
const res = await fetch(responseUrl, {
|
|
99
|
-
method: "POST",
|
|
100
|
-
headers: { "Content-Type": "application/json" },
|
|
101
|
-
body: JSON.stringify(message),
|
|
102
|
-
signal: AbortSignal.timeout(CONSTANTS.WEBHOOK_RESPONSE_TIMEOUT_MS),
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
if (!res.ok) {
|
|
106
|
-
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const data = await res.json();
|
|
110
|
-
if (data.errcode !== 0) {
|
|
111
|
-
const errorInfo = parseWecomError(data.errcode, data.errmsg);
|
|
112
|
-
throw new Error(`Stream response failed: [${data.errcode}] ${errorInfo.message}`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return data;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* 发送模板卡片消息
|
|
120
|
-
* https://developer.work.weixin.qq.com/document/path/101061
|
|
121
|
-
*/
|
|
122
|
-
export async function sendTemplateCardReply(responseUrl, card) {
|
|
123
|
-
return sendReplyMessage(responseUrl, {
|
|
124
|
-
msgtype: "template_card",
|
|
125
|
-
template_card: card,
|
|
126
|
-
});
|
|
127
|
-
}
|