openclaw-plugin-wecom 0.2.1

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.
@@ -0,0 +1,25 @@
1
+ # 贡献指南
2
+
3
+ 感谢你对 OpenClaw 企业微信插件项目的关注!我们欢迎任何形式的贡献,包括修复 bug、改进文档、增加新功能或提出建议。
4
+
5
+ ## 如何贡献
6
+
7
+ 1. **Fork** 本仓库。
8
+ 2. **创建特性分支**: `git checkout -b feature/amazing-feature`
9
+ 3. **提交你的更改**: `git commit -m 'Add some amazing feature'`
10
+ 4. **推送到分支**: `git push origin feature/amazing-feature`
11
+ 5. **提交 Pull Request**。
12
+
13
+ ## 提交规范
14
+
15
+ - 请确保代码风格与项目现有风格保持一致。
16
+ - 提交信息请尽可能简明扼要,说明更改的原因。
17
+ - 如果你的更改涉及功能变更,请同步更新 `README.md`。
18
+
19
+ ## 问题反馈
20
+
21
+ 如果你在使用过程中遇到任何问题,请通过项目的 **Issues** 页面进行反馈,并提供尽可能详细的复现步骤。
22
+
23
+ ## 行为准则
24
+
25
+ 在参与本项目开发的过程中,请保持友善和相互尊重。
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ ISC License (ISC)
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # OpenClaw 企业微信 (WeCom) AI 机器人插件
2
+
3
+ `openclaw-plugin-wecom` 是一个专为 [OpenClaw](https://github.com/sunnoy/openclaw-plugin-wecom) 框架开发的企业微信(WeCom)集成插件。它允许你将强大的 AI 能力无缝接入企业微信,并支持多项高级功能。
4
+
5
+ ## ✨ 核心特性
6
+
7
+ - 🌊 **流式输出 (Streaming)**: 基于企业微信最新的 AI 机器人流式分片机制,实现流畅的打字机式回复体验。
8
+ - 🤖 **动态 Agent 管理**: 自动为每位私聊用户和每个群聊创建独立的 Agent 实例。每个实例拥有独立的文件工作区、配置环境和对话上下文,确保数据隔离与安全。
9
+ - 👥 **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
10
+ - 🛠️ **指令增强**: 内置常用指令支持(如 `/new` 开启新会话、`/status` 查看状态等),并提供指令白名单配置功能。
11
+ - 🔒 **安全与认证**: 完整支持企业微信消息加解密、URL 验证及发送者身份校验。
12
+ - ⚡ **高性能异步处理**: 采用异步消息处理架构,确保即使在长耗时 AI 推理过程中,企业微信网关也能保持高响应性。
13
+
14
+ ## 🚀 快速开始
15
+
16
+ ### 1. 安装插件
17
+
18
+ 在你的 OpenClaw 项目目录中运行:
19
+
20
+ ```bash
21
+ npm install openclaw-plugin-wecom
22
+ ```
23
+
24
+ ### 2. 配置插件
25
+
26
+ 在 OpenClaw 的配置文件(如 `config.json`)中添加插件配置:
27
+
28
+ ```json
29
+ {
30
+ "channels": {
31
+ "wxwork": {
32
+ "enabled": true,
33
+ "token": "你的 Token",
34
+ "encodingAesKey": "你的 EncodingAESKey",
35
+ "webhookPath": "/webhooks/wxwork",
36
+ "accounts": {
37
+ "default": {
38
+ "allowFrom": ["*"]
39
+ }
40
+ },
41
+ "commands": {
42
+ "enabled": true,
43
+ "allowlist": ["/new", "/status", "/help", "/compact"]
44
+ },
45
+ "dynamicAgent": {
46
+ "enabled": true,
47
+ "prefix": "wxwork-"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### 3. 企业微信后台设置
55
+
56
+ 1. 在企业微信管理后台创建一个“智能机器人”。
57
+ 2. 将机器人的“接收消息配置”中的 URL 设置为你的服务地址(例如:`https://your-domain.com/webhooks/wxwork`)。
58
+ 3. 填入对应的 Token 和 EncodingAESKey。
59
+
60
+ ## 🛠️ 指令支持
61
+
62
+ 插件内置了对以下指令的处理:
63
+
64
+ - `/new`: 重置当前对话,开启全新会话。
65
+ - `/compact`: 压缩当前会话上下文,保留关键摘要以节省 Token。
66
+ - `/help`: 查看帮助信息。
67
+ - `/status`: 查看当前 Agent 及插件状态。
68
+
69
+ ## 📂 项目结构
70
+
71
+ - `index.js`: 插件入口,处理所有核心路由与生命周期管理。
72
+ - `webhook.js`: 处理企业微信 HTTP 通信、加解密及消息解析。
73
+ - `dynamic-agent.js`: 动态 Agent 分配逻辑。
74
+ - `stream-manager.js`: 管理流式回复的状态与数据分片。
75
+ - `crypto.js`: 企业微信加密算法实现。
76
+
77
+ ## 🤝 贡献规范
78
+
79
+ 我们非常欢迎开发者参与贡献!如果你发现了 Bug 或有更好的功能建议,请提交 Issue 或 Pull Request。
80
+
81
+ ## 📄 开源协议
82
+
83
+ 本项目采用 [ISC License](./LICENSE) 协议。
package/client.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * WxWork 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, parseWxWorkError, 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 = parseWxWorkError(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 = parseWxWorkError(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
+ }
128
+
129
+ // 兼容旧代码 - 保留 WxWorkClient 类名但简化实现
130
+ export class WxWorkClient {
131
+ constructor(config) {
132
+ logger.warn("WxWorkClient is deprecated for AI Bot, use sendReplyMessage directly");
133
+ }
134
+
135
+ // 保留兼容方法
136
+ async sendStreamResponse(responseUrl, message) {
137
+ return sendReplyMessage(responseUrl, message);
138
+ }
139
+ }
package/config.js ADDED
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ export const WxWorkConfigSchema = z.object({
3
+ enabled: z.boolean().default(false),
4
+ corpId: z.string().describe("Enterprise ID (CorpID)"),
5
+ agentId: z.string().describe("Agent ID (Application ID)"),
6
+ secret: z.string().describe("Application Secret"),
7
+ token: z.string().describe("Webhook Token"),
8
+ encodingAesKey: z.string().describe("Webhook EncodingAESKey"),
9
+ webhookPath: z.string().default("/webhooks/wxwork").describe("Webhook URL path"),
10
+ });
11
+ //# sourceMappingURL=config.js.map
package/crypto.js ADDED
@@ -0,0 +1,108 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
2
+ import { XMLParser, XMLBuilder } from "fast-xml-parser";
3
+ import { CONSTANTS } from "./utils.js";
4
+ import { logger } from "./logger.js";
5
+
6
+ /**
7
+ * Enterprise WeChat Intelligent Robot Crypto Implementation
8
+ * Simplified for AI Bot mode (no corpId validation)
9
+ */
10
+ export class WxWorkCrypto {
11
+ token;
12
+ encodingAesKey;
13
+ aesKey;
14
+ iv;
15
+
16
+ constructor(token, encodingAesKey) {
17
+ if (!encodingAesKey || encodingAesKey.length !== CONSTANTS.AES_KEY_LENGTH) {
18
+ throw new Error(`EncodingAESKey invalid: length must be ${CONSTANTS.AES_KEY_LENGTH}`);
19
+ }
20
+ if (!token) {
21
+ throw new Error("Token is required");
22
+ }
23
+ this.token = token;
24
+ this.encodingAesKey = encodingAesKey;
25
+ this.aesKey = Buffer.from(encodingAesKey + "=", "base64");
26
+ this.iv = this.aesKey.subarray(0, 16);
27
+ logger.debug("WxWorkCrypto initialized (AI Bot mode)");
28
+ }
29
+
30
+ getSignature(timestamp, nonce, encrypt) {
31
+ const shasum = createHash("sha1");
32
+ const arr = [this.token, timestamp, nonce, encrypt].sort();
33
+ shasum.update(arr.join(""));
34
+ return shasum.digest("hex");
35
+ }
36
+
37
+ decrypt(text) {
38
+ let decipher;
39
+ try {
40
+ decipher = createDecipheriv("aes-256-cbc", this.aesKey, this.iv);
41
+ decipher.setAutoPadding(false);
42
+ } catch (e) {
43
+ throw new Error(`Decrypt init failed: ${String(e)}`);
44
+ }
45
+
46
+ let deciphered = Buffer.concat([
47
+ decipher.update(text, "base64"),
48
+ decipher.final(),
49
+ ]);
50
+
51
+ deciphered = this.decodePkcs7(deciphered);
52
+
53
+ // Format: 16 random bytes | 4 bytes msg_len | msg_content | appid
54
+ const content = deciphered.subarray(16);
55
+ const lenList = content.subarray(0, 4);
56
+ const xmlLen = lenList.readUInt32BE(0);
57
+ const xmlContent = content.subarray(4, 4 + xmlLen).toString("utf-8");
58
+ // For AI Bot mode, corpId/appid is empty, skip validation
59
+
60
+ return { message: xmlContent };
61
+ }
62
+
63
+ encrypt(text) {
64
+ // For AI Bot mode, corpId is empty
65
+ const random16 = randomBytes(16);
66
+ const msgBuffer = Buffer.from(text);
67
+ const lenBuffer = Buffer.alloc(4);
68
+ lenBuffer.writeUInt32BE(msgBuffer.length, 0);
69
+
70
+ const rawMsg = Buffer.concat([random16, lenBuffer, msgBuffer]);
71
+ const encoded = this.encodePkcs7(rawMsg);
72
+
73
+ const cipher = createCipheriv("aes-256-cbc", this.aesKey, this.iv);
74
+ cipher.setAutoPadding(false);
75
+ const ciphered = Buffer.concat([cipher.update(encoded), cipher.final()]);
76
+ return ciphered.toString("base64");
77
+ }
78
+
79
+ encodePkcs7(buff) {
80
+ const blockSize = CONSTANTS.AES_BLOCK_SIZE;
81
+ const amountToPad = blockSize - (buff.length % blockSize);
82
+ const pad = Buffer.alloc(amountToPad, amountToPad);
83
+ return Buffer.concat([buff, pad]);
84
+ }
85
+
86
+ decodePkcs7(buff) {
87
+ const pad = buff[buff.length - 1];
88
+ if (pad < 1 || pad > CONSTANTS.AES_BLOCK_SIZE) {
89
+ throw new Error(`Invalid PKCS7 padding: ${pad}`);
90
+ }
91
+ for (let i = buff.length - pad; i < buff.length; i++) {
92
+ if (buff[i] !== pad) {
93
+ throw new Error("Invalid PKCS7 padding: inconsistent padding bytes");
94
+ }
95
+ }
96
+ return buff.subarray(0, buff.length - pad);
97
+ }
98
+ }
99
+
100
+ export const xmlParser = new XMLParser({
101
+ ignoreAttributes: true,
102
+ parseTagValue: false
103
+ });
104
+
105
+ export const xmlBuilder = new XMLBuilder({
106
+ format: false,
107
+ ignoreAttributes: true
108
+ });
@@ -0,0 +1,153 @@
1
+ import { logger } from "./logger.js";
2
+
3
+ /**
4
+ * Dynamic Agent Manager (Minimal Version)
5
+ *
6
+ * 极简版:插件只负责生成 AgentId
7
+ * 所有 Workspace 创建和 Bootstrap 文件由 OpenClaw 主程序自动处理
8
+ *
9
+ * 流程:
10
+ * 1. 插件收到消息 → generateAgentId() 生成 agentId
11
+ * 2. 插件构造 SessionKey → `agent:{agentId}:{peerKind}:{peerId}`
12
+ * 3. OpenClaw 解析 SessionKey → 提取 agentId
13
+ * 4. OpenClaw 调用 resolveAgentWorkspaceDir() → fallback 到 ~/.openclaw/workspace-{agentId}
14
+ * 5. OpenClaw 调用 ensureAgentWorkspace() → 自动创建目录和 Bootstrap 文件
15
+ */
16
+
17
+ /**
18
+ * 生成 AgentId
19
+ * 规范:wxwork-dm-{userId} 或 wxwork-group-{groupId}
20
+ *
21
+ * @param {string} chatType - "dm" 或 "group"
22
+ * @param {string} peerId - userId 或 groupId
23
+ * @returns {string} agentId
24
+ */
25
+ export function generateAgentId(chatType, peerId) {
26
+ const sanitizedId = String(peerId).toLowerCase().replace(/[^a-z0-9_-]/g, "_");
27
+ if (chatType === "group") {
28
+ return `wxwork-group-${sanitizedId}`;
29
+ }
30
+ return `wxwork-dm-${sanitizedId}`;
31
+ }
32
+
33
+ /**
34
+ * 获取动态 Agent 配置
35
+ */
36
+ export function getDynamicAgentConfig(config) {
37
+ const wxwork = config?.channels?.wxwork || {};
38
+ return {
39
+ enabled: wxwork.dynamicAgents?.enabled !== false,
40
+
41
+ // 私聊配置
42
+ dmCreateAgent: wxwork.dm?.createAgentOnFirstMessage !== false,
43
+
44
+ // 群聊配置
45
+ groupEnabled: wxwork.groupChat?.enabled !== false,
46
+ groupRequireMention: wxwork.groupChat?.requireMention !== false,
47
+ groupMentionPatterns: wxwork.groupChat?.mentionPatterns || ["@"],
48
+ groupCreateAgent: wxwork.groupChat?.createAgentOnFirstMessage !== false,
49
+ groupHistoryLimit: wxwork.groupChat?.historyLimit || 10,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * 检查是否应该为此消息创建/使用动态 Agent
55
+ *
56
+ * @param {Object} options
57
+ * @param {string} options.chatType - "dm" 或 "group"
58
+ * @param {Object} options.config - openclaw 配置
59
+ * @returns {boolean}
60
+ */
61
+ export function shouldUseDynamicAgent({ chatType, config }) {
62
+ const dynamicConfig = getDynamicAgentConfig(config);
63
+
64
+ if (!dynamicConfig.enabled) {
65
+ return false;
66
+ }
67
+
68
+ if (chatType === "dm") {
69
+ return dynamicConfig.dmCreateAgent;
70
+ }
71
+
72
+ if (chatType === "group") {
73
+ return dynamicConfig.groupCreateAgent;
74
+ }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * 检查群聊消息是否满足触发条件(@提及)
81
+ */
82
+ export function shouldTriggerGroupResponse(content, config) {
83
+ const dynamicConfig = getDynamicAgentConfig(config);
84
+
85
+ if (!dynamicConfig.groupEnabled) {
86
+ return false;
87
+ }
88
+
89
+ if (!dynamicConfig.groupRequireMention) {
90
+ return true; // 不需要 @,所有消息都触发
91
+ }
92
+
93
+ // 检查是否包含 @提及
94
+ const patterns = dynamicConfig.groupMentionPatterns;
95
+ for (const pattern of patterns) {
96
+ if (content.includes(pattern)) {
97
+ return true;
98
+ }
99
+ }
100
+
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * 从群聊消息中提取实际内容(移除 @提及)
106
+ */
107
+ export function extractGroupMessageContent(content, config) {
108
+ const dynamicConfig = getDynamicAgentConfig(config);
109
+ let cleanContent = content;
110
+
111
+ // 移除 @提及 pattern
112
+ const patterns = dynamicConfig.groupMentionPatterns;
113
+ for (const pattern of patterns) {
114
+ // 移除 @xxx 格式的提及(包括后面可能的空格)
115
+ const regex = new RegExp(`${pattern}\\S*\\s*`, "g");
116
+ cleanContent = cleanContent.replace(regex, "");
117
+ }
118
+
119
+ return cleanContent.trim();
120
+ }
121
+
122
+ // ============ 兼容性导出 ============
123
+
124
+ /**
125
+ * @deprecated 不再需要,OpenClaw 会自动创建
126
+ */
127
+ export async function createDynamicAgent({ chatType, peerId, config }) {
128
+ const agentId = generateAgentId(chatType, peerId);
129
+ logger.debug("createDynamicAgent called (no-op, OpenClaw handles creation)", { agentId });
130
+ return { success: true, agentId, error: null };
131
+ }
132
+
133
+ /**
134
+ * @deprecated 不再需要
135
+ */
136
+ export function ensureDynamicAgent({ chatType, peerId, config }) {
137
+ const agentId = generateAgentId(chatType, peerId);
138
+ return { success: true, agentId, isNew: false };
139
+ }
140
+
141
+ /**
142
+ * @deprecated 不再需要,OpenClaw 自动处理
143
+ */
144
+ export function agentExists(config, agentId) {
145
+ return true; // 总是返回 true,让 OpenClaw 处理
146
+ }
147
+
148
+ /**
149
+ * @deprecated 不再需要
150
+ */
151
+ export function clearAgentCache(agentId) {
152
+ // no-op
153
+ }