Initial commit: feishu + wecom extensions

This commit is contained in:
2026-02-15 00:37:39 +08:00
commit 194a506da0
105 changed files with 36880 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CopilotChatHistory">
<option name="conversations">
<list>
<Conversation>
<option name="createTime" value="1770283758228" />
<option name="id" value="019c2d2272947bbabae7bf41010028de" />
<option name="updateTime" value="1770283758228" />
</Conversation>
</list>
</option>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CopilotChatTabs">
<option name="completionTestTabDelete" value="false" />
<option name="crTabDelete" value="false" />
<option name="selectedChatTabId" value="019c2d2272867dc397297729eaa3fa34" />
<option name="tabConversationMap">
<map>
<entry key="019c2d2272867dc397297729eaa3fa34" value="019c2d2272947bbabae7bf41010028de" />
</map>
</option>
</component>
</project>
+44
View File
@@ -0,0 +1,44 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="56" name="Python" />
</Languages>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="18">
<item index="0" class="java.lang.String" itemvalue="python-cl5" />
<item index="1" class="java.lang.String" itemvalue="pytcmw" />
<item index="2" class="java.lang.String" itemvalue="scipy" />
<item index="3" class="java.lang.String" itemvalue="numpy" />
<item index="4" class="java.lang.String" itemvalue="pandas" />
<item index="5" class="java.lang.String" itemvalue="tqdm" />
<item index="6" class="java.lang.String" itemvalue="fastapi" />
<item index="7" class="java.lang.String" itemvalue="pytest" />
<item index="8" class="java.lang.String" itemvalue="cachetools" />
<item index="9" class="java.lang.String" itemvalue="aiohttp" />
<item index="10" class="java.lang.String" itemvalue="setuptools" />
<item index="11" class="java.lang.String" itemvalue="uvicorn" />
<item index="12" class="java.lang.String" itemvalue="PyMySQL" />
<item index="13" class="java.lang.String" itemvalue="pydantic" />
<item index="14" class="java.lang.String" itemvalue="tabulate" />
<item index="15" class="java.lang.String" itemvalue="python-dotenv" />
<item index="16" class="java.lang.String" itemvalue="requests" />
<item index="17" class="java.lang.String" itemvalue="starlette" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="peewee.*" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.9" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/wecom.iml" filepath="$PROJECT_DIR$/.idea/wecom.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 xl370869-art
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+360
View File
@@ -0,0 +1,360 @@
# OpenClaw 企业微信(WeCom)Channel 插件
<p align="center">
<strong>🚀 企业级双模式 AI 助手接入方案</strong>
</p>
<p align="center">
<a href="#功能亮点">功能亮点</a> •
<a href="#模式对比">模式对比</a> •
<a href="#快速开始">快速开始</a> •
<a href="#配置说明">配置说明</a> •
<a href="#联系我">联系我</a>
</p>
---
## 💡 核心价值:为什么选择本插件?
### 🏗 独创架构:Bot + Agent 双模融合
传统的企微插件通常只能在 "只能聊天的机器人 (Bot)" 和 "只能推送的自建应用 (Agent)" 之间二选一。
本插件采用 **双模并行架构**,同时压榨两种模式的极限能力:
* **Bot 通道 (智能体)**:负责 **实时对话**。提供毫秒级流式响应(打字机效果),零延迟交互。
* **Agent 通道 (自建应用)**:负责 **能力兜底**。当需要发送图片/文件、进行全员广播、或 Bot 对话超时(>6分钟)时,无缝切换到 Agent 通道接管。
### 🧩 功能特性全景
#### 1. 🗣 **沉浸式交互 (Immersive Interaction)**
* **原生流式 (Stream)**:基于 HTTP 分块传输,拒绝 "转圈等待",体验如 ChatGPT 网页版般丝滑。
* **交互式卡片 (Card)**:支持 Button/Menu 交互回传,可构建审批、查询等复杂业务流 (Agent模式)。
#### 2. 📎 **全模态支持 (Multi-Modal)**
* **发什么都能看**:支持接收图片、文件 (PDF/Doc/Zip)、语音 (自动转文字)、视频。
* **要什么都能给**:AI 生成的图表、代码文件、语音回复,均可自动上传并推送到企微。
#### 3. 📢 **企业级触达 (Enterprise Reach)**
* **精准广播**:支持向 **部门 (Party)**、**标签 (Tag)** 或 **外部群** 批量推送消息。
* **Cronjob 集成**:通过简单的 JSON 配置实现早报推送、日报提醒、服务器报警。
#### 4. 🛡 **生产级稳定 (Production Ready)**
* **容灾切换**:Bot 模式 6 分钟超时自动熔断,切换 Agent 私信送达,防止长任务回答丢失。
* **Token 自动运维**:内置 AccessToken 守护进程,自动缓存、提前刷新、过期重试。
---
## 📊 模式能力对比
| 能力维度 | 🤖 Bot 模式 | 🧩 Agent 模式 | ✨ **本插件 (双模)** |
|:---|:---|:---|:---|
| **接收消息 (单聊)** | ✅ 文本/图片/语音/文件 | ✅ 文本/图片/语音/视频/位置/链接 | **✅ 全能互补** (覆盖所有类型) |
| **接收消息 (群聊)** | ✅ 文本/引用 | ❌ 不支持 (无回调) | **✅ 文本/引用** |
| **发送消息** | ❌ 仅支持文本/图片/Markdown | ✅ **全格式支持** (文本/图片/视频/文件等) | **✅ 智能路由** (自动切换) |
| **流式响应** | ✅ **支持** (打字机效果) | ❌ 不支持 | **✅ 完美支持** |
| **主动推送** | ❌ 仅被动回复 | ✅ **支持** (指定用户/部门/标签) | **✅ 完整 API** |
---
## 快速开始
### 1. 安装插件
```bash
openclaw plugins install @openclaw/wecom
openclaw plugins enable wecom
```
也可以通过命令行向导快速配置:
```bash
openclaw config --section channels
```
### 2. 配置 Bot 模式(智能体)
```bash
openclaw config set channels.wecom.enabled true
openclaw config set channels.wecom.bot.token "YOUR_BOT_TOKEN"
openclaw config set channels.wecom.bot.encodingAESKey "YOUR_BOT_AES_KEY"
openclaw config set channels.wecom.bot.receiveId ""
openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
# DM 门禁(推荐显式设置 policy)
# - open: 默认放开(所有人可用)
# - disabled: 全部禁用
# - allowlist: 仅 allowFrom 允许的人可用
openclaw config set channels.wecom.bot.dm.policy "open"
# policy=allowlist 时生效(例如只允许某些 userid;"*" 表示允许所有人)
openclaw config set channels.wecom.bot.dm.allowFrom '["*"]'
```
### 3. 配置 Agent 模式(自建应用,可选)
```bash
openclaw config set channels.wecom.enabled true
openclaw config set channels.wecom.agent.corpId "YOUR_CORP_ID"
openclaw config set channels.wecom.agent.corpSecret "YOUR_CORP_SECRET"
openclaw config set channels.wecom.agent.agentId 1000001
openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
openclaw config set channels.wecom.agent.welcomeText "欢迎使用智能助手"
openclaw config set channels.wecom.agent.dm.policy "open"
openclaw config set channels.wecom.agent.dm.allowFrom '["*"]'
```
### 4. 高级网络配置 (公网出口代理)
如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,企业微信 API 会因 IP 变动报错 `60020 not allow to access from your ip`。
此时需配置一个**固定 IP 的正向代理** (如 Squid),让插件通过该代理访问企微 API。
```bash
openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
```
### 5. 验证
```bash
openclaw gateway restart
openclaw channels status
```
---
## 配置说明
### 完整配置结构
```jsonc
{
"channels": {
"wecom": {
"enabled": true,
// Bot 模式配置(智能体)
"bot": {
"token": "YOUR_BOT_TOKEN",
"encodingAESKey": "YOUR_BOT_AES_KEY",
"receiveId": "", // 可选,用于解密校验
"streamPlaceholderContent": "正在思考...",
"welcomeText": "你好!我是 AI 助手",
"dm": { "allowFrom": [] } // 私聊限制
},
// Agent 模式配置(自建应用)
"agent": {
"corpId": "YOUR_CORP_ID",
"corpSecret": "YOUR_CORP_SECRET",
"agentId": 1000001,
"token": "YOUR_CALLBACK_TOKEN", // 企微后台「设置API接收」
"encodingAESKey": "YOUR_CALLBACK_AES_KEY",
"welcomeText": "欢迎使用智能助手",
"dm": { "allowFrom": [] }
},
// 网络配置(可选)
"network": {
"egressProxyUrl": "http://proxy.company.local:3128"
}
}
}
}
```
### Webhook 路径(固定)
| 模式 | 路径 | 说明 |
|:---|:---|:---|
| Bot | `/wecom/bot` | 智能体回调 |
| Agent | `/wecom/agent` | 自建应用回调 |
### DM 策略
- **不配置 `dm.allowFrom`** → 所有人可用(默认)
- **配置 `dm.allowFrom: ["user1", "user2"]`** → 白名单模式,仅列表内用户可私聊
### 常用指令
| 指令 | 说明 | 示例 |
|:---|:---|:---|
| `/new` | 🆕 开启新会话 (重置上下文) | `/new` 或 `/new GPT-4` |
| `/reset` | 🔄 重置会话 (同 /new) | `/reset` |
---
## 企业微信接入指南
### Bot 模式(智能机器人)
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#/manageTools)
2. 进入「安全与管理」→「管理工具」→「智能机器人」
3. 创建机器人,选择 **API 模式**
4. 填写回调 URL:`https://your-domain.com/wecom/bot`
5. 记录 Token 和 EncodingAESKey
### Agent 模式(自建应用)
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#/apps)
2. 进入「应用管理」→「自建」→ 创建应用
3. 获取 AgentId、CorpId、Secret
4. **重要:** 进入「企业可信IP」→「配置」→ 添加你服务器的 IP 地址
- 如果你使用内网穿透/动态 IP,建议配置 `channels.wecom.network.egressProxyUrl` 走固定出口代理,否则可能出现:`60020 not allow to access from your ip`
5. 在应用详情中设置「接收消息 - 设置API接收」
6. 填写回调 URL:`https://your-domain.com/wecom/agent`
7. 记录回调 Token 和 EncodingAESKey
---
## 高级功能
### A2UI 交互卡片
Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
- ✅ 单聊场景:发送真实交互卡片
- ✅ 按钮点击:触发 `template_card_event` 回调
- ✅ 自动去重:基于 `msgid` 避免重复处理
- ⚠️ 群聊降级:自动转为文本描述
### ⏰ Cronjob 企业级定时推送
本插件深度集成了 OpenClaw 的 Cronjob 调度能力,配合 Agent 强大的广播 API,轻松实现企业级通知服务。
> **核心场景**:早报推送、服务器报警、日报提醒、节日祝福。
#### 1. 目标配置 (Target)
无需遍历用户列表,直接利用 Agent 强大的组织架构触达能力:
| 目标类型 | 格式示例 | 推送范围 | 典型场景 |
|:---|:---|:---|:---|
| **部门 (Party)** | `party:1` (或 `1`) | 📢 **全员广播** | 全员通知、技术部周报 |
| **标签 (Tag)** | `tag:Ops` | 🎯 **精准分组** | 运维报警、管理层汇报 |
| **外部群 (Group)** | `group:wr...` | 💬 **群聊推送** | 项目组群日报 (需由Agent建群) |
| **用户 (User)** | `user:zhangsan` | 👤 **即时私信** | 个人待办提醒 |
#### 2. 配置示例 (`schedule.json`)
只需在工作区根目录创建 `schedule.json` 即可生效:
```json
{
"tasks": [
{
"cron": "0 9 * * 1-5", // 每周一至周五 早上9:00
"action": "reply.send",
"params": {
"channel": "wecom",
"to": "party:1", // 一键发送给根部门所有人!
"text": "🌞 早安!请查收[今日行业简报](https://example.com/daily)。"
}
},
{
"cron": "0 18 * * 5",
"action": "reply.send",
"params": {
"channel": "wecom",
"to": "tag:Ops", // 仅发送给运维组
"text": "🔒 周五封网提醒:请检查服务器状态。"
}
}
]
}
```
---
## 📖 详细行为说明 (Behavior Detail)
### 1. 企业微信群聊交付规则
* **默认 (Bot 回复)**:群聊里 @Bot,默认由 Bot 在群内直接回复(优先文本/图片/Markdown)。
* **例外 (文件兜底)**:如果回复内容包含**非图片文件**(如 PDF/Word/表格/压缩包等),由于企微 Bot 接口不支持,插件会自动:
1. Bot 在群里提示:"由于格式限制,文件将通过私信发送给您"。
2. 无缝切换到 **自建应用 (Agent)** 通道,将文件私信发送给触发者。
* **提示**:若未配置 Agent,Bot 会明确提示“需要管理员配置自建应用通道”。
### 2. 长任务可靠性保障
* **超时熔断**:企业微信限制 Bot 流式回复窗口约为 6 分钟。
* **自动接力**:当对话时长接近此阈值时,Monitor 会自动截断 Bot 流,提示 "剩余内容将私信发送",并立即启动 Agent 通道私信发送完整结果。这彻底解决了长思考任务(如深度推理、代码生成)因超时导致用户收不到结果的问题。
### 3. 主动发送安全机制
* **群发保护**:Agent 主动发送接口不再尝试向普通群 `chatid` (wr/wc...) 发消息(该路径常因权限与归属产生的隐蔽错误)。
* **引导提示**:系统会明确拦截并通过日志提示中文错误:"请使用 Bot 群内交付或改为私信目标(userid/部门/标签)",帮助管理员快速排查配置。
### 4. 管理员友好
* 所有兜底逻辑(Fallback)触发时,如果因配置缺失导致失败,Bot 都会给出清晰的**中文提示**,而不是沉默或报代码错误,极大降低了排查难度。
---
## 🙋 社区问答 (FAQ)
针对社区反馈的高频问题,我们已在 v2.2.4 版本中全部解决:
**Q1: 同时使用 Bot 和 Agent 会导致消息重复吗?**
> **A:** 不会。本插件采用“Bot 优先”策略。用户在哪个通道发消息,就从哪个通道回。只有在 Bot 无法处理(如发文件)时才会智能切换到 Agent 通道作为补充。
**Q2: 使用内网穿透时,企业微信报错 60020 (IP 不白名单) 怎么办?**
> **A:** 新增了 `config.network.egressProxyUrl` 配置。您可以配置一个拥有固定公网 IP 的代理服务器(如 Squid),让插件通过该代理与企微 API 通信,从而绕过动态 IP 限制。
**Q3: 原生 Bot 模式支持图片,为什么 Agent 模式不行?**
> **A:** Agent 模式之前确实存在此短板。但在 v2.2.4 中,我们完整实现了 Agent 端的 XML 媒体解析与 `media_id` 下载逻辑,现在 Agent 模式也能完美看图、听语音了。
**Q4: 群里 @机器人 发送文件失败?**
> **A:** 因为企业微信 Bot 接口本身不支持发送非图片文件。我们的解决方案是:自动检测到文件发送需求后,改为通过 Agent 私信该用户发送文件,并在群里给出 "文件已私信发给您" 的提示。
**Q5: 为什么在 Agent 模式下发送文件(如 PDF、Word)给机器人没有反应?**
> **A:** 这是由于企业微信官方接口限制。自建应用(Agent)的消息回调接口仅支持:文本、图片、语音、视频、位置和链接信息。**不支持**通用文件(File)类型的回调,因此插件无法感知您发送的文件。
**Q6: Cronjob 定时任务怎么发给群?**
> **A:** Cronjob 必须走 Agent 通道(Bot 无法主动发消息)。您只需在配置中指定 `to: "party:1"` (部门) 或 `to: "group:wr123..."` (外部群),即可实现定时推送到群。
**Q7: 为什么发视频给 Bot 没反应?**
> **A:** 官方 Bot 接口**不支持接收视频**。如果您需要处理视频内容,请配置并使用 Agent 模式(Agent 支持接收视频)。
---
---
## 更新日志
### 2026.2.5
- 🛠 **体验优化**:WeCom 媒体(图片/语音/视频/文件)处理的默认大小上限提升到 25MB,减少大文件因超限导致的“下载/保存失败”。
- 📌 **可配置提示**:若仍遇到 Media exceeds ... limit,日志/回复会提示通过 channels.wecom.media.maxBytes 调整上限,并给出可直接执行的 openclaw config set 示例命令。
### 2026.2.4
- 🚀 **架构升级**:实施 "Bot 优先 + Agent 兜底" 策略,兼顾流式体验与长任务稳定性(6分钟切换)。
- ✨ **全模态支持**:Agent 模式完整支持接收图片/语音/视频(文件仅支持发送)。
- ✨ **Cronjob 增强**:支持向部门 (`party:ID`) 和标签 (`tag:ID`) 广播消息。
- 🛠 **Monitor 重构**:统一的消息防抖与流状态管理,提升并发稳定性。
- 🛠 **体验优化**:修复企微重试导致的重复回复(Bot/Agent 均做 `msgid` 去重);优化 Bot 连续多条消息的排队/合并回执,避免“重复同一答案”或“消息失败提示”。
- 🐞 **修复**:Outbound ID 解析逻辑及 API 客户端参数缺失问题。
### 2026.2.3
- 🎉 **重大更新**:新增 Agent 模式(自建应用)支持
- ✨ 双模式并行:Bot + Agent 可同时运行
- ✨ **多模态支持**:Agent 模式支持图片/语音/文件/视频的接收与自动下载
- ✨ AccessToken 自动管理:缓存 + 智能刷新
- ✨ Agent 主动推送:脱离回调限制
- ✨ XML 加解密:完整 Agent 回调支持
- 📁 代码重构:模块化解耦设计
### 2026.1.31
- 文档:补充入模与测试截图说明
- 新增文件支持
- 新增卡片支持
### 2026.1.30
- 项目更名:Clawdbot → OpenClaw
+10
View File
@@ -0,0 +1,10 @@
{
"id": "wecom",
"channels": ["wecom"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { handleWecomWebhookRequest } from "./src/monitor.js";
import { setWecomRuntime } from "./src/runtime.js";
import { wecomPlugin } from "./src/channel.js";
const plugin = {
id: "wecom",
name: "WeCom",
description: "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
configSchema: emptyPluginConfigSchema(),
/**
* **register (注册插件)**
*
* OpenClaw 插件入口点。
* 1. 注入 Runtime 环境 (api.runtime)。
* 2. 注册 WeCom 渠道插件 (ChannelPlugin)。
* 3. 注册 Webhook HTTP 处理器 (handleWecomWebhookRequest)。
*/
register(api: OpenClawPluginApi) {
setWecomRuntime(api.runtime);
api.registerChannel({ plugin: wecomPlugin });
api.registerHttpHandler(handleWecomWebhookRequest);
},
};
export default plugin;
+10
View File
@@ -0,0 +1,10 @@
{
"id": "wecom",
"channels": ["wecom"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+10469
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "wecom",
"version": "2026.2.5",
"type": "module",
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "wecom",
"label": "WeCom",
"selectionLabel": "WeCom (plugin)",
"detailLabel": "WeCom Bot",
"docsPath": "/channels/wecom",
"docsLabel": "wecom",
"blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
"aliases": [
"wechatwork",
"wework",
"qywx",
"企微",
"企业微信"
],
"order": 85
},
"install": {
"npmSpec": "@openclaw/wecom",
"localPath": "extensions/wecom",
"defaultChoice": "npm"
}
},
"dependencies": {
"fast-xml-parser": "5.3.4",
"undici": "^7.20.0",
"zod": "^4.3.6"
},
"peerDependencies": {
"openclaw": ">=2026.1.26"
},
"devDependencies": {
"@types/node": "^25.2.0",
"typescript": "^5.9.3"
},
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "wecom",
"label": "WeCom",
"selectionLabel": "WeCom (plugin)",
"detailLabel": "WeCom Bot",
"docsPath": "/channels/wecom",
"docsLabel": "wecom",
"blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
"aliases": [
"wechatwork",
"wework",
"qywx",
"企微",
"企业微信"
],
"order": 85
},
"install": {
"npmSpec": "@openclaw/wecom",
"localPath": "extensions/wecom",
"defaultChoice": "npm"
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import { wecomFetch } from "../src/http.js";
const args = process.argv.slice(2);
let proxyUrl = "";
if (args.includes("--host")) {
const hostIndex = args.indexOf("--host");
const portIndex = args.indexOf("--port");
const userIndex = args.indexOf("--user");
const passIndex = args.indexOf("--pass");
if (hostIndex === -1 || portIndex === -1) {
console.error("Error: --host and --port are required when using specific params.");
process.exit(1);
}
const host = args[hostIndex + 1];
const port = args[portIndex + 1];
const user = userIndex !== -1 ? args[userIndex + 1] : "";
const pass = passIndex !== -1 ? args[passIndex + 1] : "";
if (user && pass) {
// Safe encoding
proxyUrl = `http://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}`;
} else {
proxyUrl = `http://${host}:${port}`;
}
} else {
proxyUrl = args[0] || process.env.PROXY_URL || "";
}
if (!proxyUrl) {
console.error("Usage: npx tsx extensions/wecom/scripts/test-proxy.ts <proxy_url>");
console.error(" OR: npx tsx extensions/wecom/scripts/test-proxy.ts --host <ip> --port <port> --user <u?> --pass <p?>");
process.exit(1);
}
console.log(`Testing proxy: ${proxyUrl.replace(/:([^:@]+)@/, ":***@")}`); // Mask password in log
async function run() {
try {
// 1. Test IP echo to verify traffic goes through proxy
console.log("1. Checking IP via httpbin.org...");
const ipRes = await wecomFetch("https://httpbin.org/ip", {}, { proxyUrl, timeoutMs: 10000 });
if (!ipRes.ok) {
throw new Error(`IP check failed: ${ipRes.status} ${ipRes.statusText}`);
}
const ipJson = await ipRes.json();
console.log(" Result:", ipJson);
// 2. Test WeCom API connectivity
console.log("2. Checking WeCom connectivity...");
const wecomRes = await wecomFetch("https://qyapi.weixin.qq.com/cgi-bin/gettoken", {}, { proxyUrl, timeoutMs: 10000 });
const wecomJson = await wecomRes.json();
console.log(" Result:", wecomJson);
console.log("✅ Proxy works!");
} catch (err) {
// Extract cause for better debugging
const cause = (err as any).cause;
if (cause) {
console.error("❌ Proxy test failed (Cause):", cause);
} else {
console.error("❌ Proxy test failed:", err);
}
process.exit(1);
}
}
run();
+72
View File
@@ -0,0 +1,72 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { ResolvedWecomAccount, WecomAccountConfig, WecomConfig } from "./types.js";
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listWecomAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultWecomAccountId(cfg: OpenClawConfig): string {
const wecomConfig = cfg.channels?.wecom as WecomConfig | undefined;
if (wecomConfig?.defaultAccount?.trim()) return wecomConfig.defaultAccount.trim();
const ids = listWecomAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): WecomAccountConfig | undefined {
const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as WecomAccountConfig | undefined;
}
function mergeWecomAccountConfig(cfg: OpenClawConfig, accountId: string): WecomAccountConfig {
const raw = (cfg.channels?.wecom ?? {}) as WecomConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
export function resolveWecomAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedWecomAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.wecom as WecomConfig | undefined)?.enabled !== false;
const merged = mergeWecomAccountConfig(params.cfg, accountId);
const enabled = baseEnabled && merged.enabled !== false;
const token = merged.token?.trim() || undefined;
const encodingAESKey = merged.encodingAESKey?.trim() || undefined;
const receiveId = merged.receiveId?.trim() ?? "";
const configured = Boolean(token && encodingAESKey);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
configured,
token,
encodingAESKey,
receiveId,
config: merged,
};
}
export function listEnabledWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccount[] {
return listWecomAccountIds(cfg)
.map((accountId) => resolveWecomAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
+336
View File
@@ -0,0 +1,336 @@
/**
* WeCom Agent API 客户端
* 管理 AccessToken 缓存和 API 调用
*/
import crypto from "node:crypto";
import { API_ENDPOINTS, LIMITS } from "../types/constants.js";
import type { ResolvedAgentAccount } from "../types/index.js";
import { readResponseBodyAsBuffer, wecomFetch } from "../http.js";
import { resolveWecomEgressProxyUrlFromNetwork } from "../config/index.js";
/**
* **TokenCache (AccessToken 缓存结构)**
*
* 用于缓存企业微信 API 调用所需的 AccessToken。
* @property token 缓存的 Token 字符串
* @property expiresAt 过期时间戳 (ms)
* @property refreshPromise 当前正在进行的刷新 Promise (防止并发刷新)
*/
type TokenCache = {
token: string;
expiresAt: number;
refreshPromise: Promise<string> | null;
};
const tokenCaches = new Map<string, TokenCache>();
/**
* **getAccessToken (获取 AccessToken)**
*
* 获取企业微信 API 调用所需的 AccessToken。
* 具备自动缓存和过期刷新机制。
*
* @param agent Agent 账号信息
* @returns 有效的 AccessToken
*/
export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
const cacheKey = `${agent.corpId}:${agent.agentId}`;
let cache = tokenCaches.get(cacheKey);
if (!cache) {
cache = { token: "", expiresAt: 0, refreshPromise: null };
tokenCaches.set(cacheKey, cache);
}
const now = Date.now();
if (cache.token && cache.expiresAt > now + LIMITS.TOKEN_REFRESH_BUFFER_MS) {
return cache.token;
}
// 防止并发刷新
if (cache.refreshPromise) {
return cache.refreshPromise;
}
cache.refreshPromise = (async () => {
try {
const url = `${API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
const json = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
if (!json?.access_token) {
throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`);
}
cache!.token = json.access_token;
cache!.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
return cache!.token;
} finally {
cache!.refreshPromise = null;
}
})();
return cache.refreshPromise;
}
/**
* **sendText (发送文本消息)**
*
* 调用 `message/send` (Agent) 或 `appchat/send` (群聊) 发送文本。
*
* @param params.agent 发送方 Agent
* @param params.toUser 接收用户 ID (单聊可选,可与 toParty/toTag 同时使用)
* @param params.toParty 接收部门 ID (单聊可选)
* @param params.toTag 接收标签 ID (单聊可选)
* @param params.chatId 接收群 ID (群聊模式必填,互斥)
* @param params.text 消息内容
*/
export async function sendText(params: {
agent: ResolvedAgentAccount;
toUser?: string;
toParty?: string;
toTag?: string;
chatId?: string;
text: string;
}): Promise<void> {
const { agent, toUser, toParty, toTag, chatId, text } = params;
const token = await getAccessToken(agent);
const useChat = Boolean(chatId);
const url = useChat
? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
: `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
const body = useChat
? { chatid: chatId, msgtype: "text", text: { content: text } }
: {
touser: toUser,
toparty: toParty,
totag: toTag,
msgtype: "text",
agentid: agent.agentId,
text: { content: text }
};
const res = await wecomFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
const json = await res.json() as {
errcode?: number;
errmsg?: string;
invaliduser?: string;
invalidparty?: string;
invalidtag?: string;
};
if (json?.errcode !== 0) {
throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`);
}
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
const details = [
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
].filter(Boolean).join(", ");
throw new Error(`send partial failure: ${details}`);
}
}
/**
* **uploadMedia (上传媒体文件)**
*
* 上传临时素材到企业微信。
* 素材有效期为 3 天。
*
* @param params.type 媒体类型 (image, voice, video, file)
* @param params.buffer 文件二进制数据
* @param params.filename 文件名 (需包含正确扩展名)
* @returns 媒体 ID (media_id)
*/
export async function uploadMedia(params: {
agent: ResolvedAgentAccount;
type: "image" | "voice" | "video" | "file";
buffer: Buffer;
filename: string;
}): Promise<string> {
const { agent, type, buffer, filename } = params;
const token = await getAccessToken(agent);
// 添加 debug=1 参数获取更多错误信息
const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
// DEBUG: 输出上传信息
console.log(`[wecom-upload] Uploading media: type=${type}, filename=${filename}, size=${buffer.length} bytes`);
// 手动构造 multipart/form-data 请求体
// 企业微信要求包含 filename 和 filelength
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
// 根据文件类型设置 Content-Type
const contentTypeMap: Record<string, string> = {
jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
bmp: "image/bmp", amr: "voice/amr", mp4: "video/mp4",
};
const ext = filename.split(".").pop()?.toLowerCase() || "";
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
// 构造 multipart body
const header = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
`Content-Type: ${fileContentType}\r\n\r\n`
);
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([header, buffer, footer]);
console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
const res = await wecomFetch(url, {
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": String(body.length),
},
body: body,
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
// DEBUG: 输出完整响应
console.log(`[wecom-upload] Response:`, JSON.stringify(json));
if (!json?.media_id) {
throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`);
}
return json.media_id;
}
/**
* **sendMedia (发送媒体消息)**
*
* 发送图片、音频、视频或文件。需先通过 `uploadMedia` 获取 media_id。
*
* @param params.agent 发送方 Agent
* @param params.toUser 接收用户 ID (单聊可选)
* @param params.toParty 接收部门 ID (单聊可选)
* @param params.toTag 接收标签 ID (单聊可选)
* @param params.chatId 接收群 ID (群聊模式必填)
* @param params.mediaId 媒体 ID
* @param params.mediaType 媒体类型
* @param params.title 视频标题 (可选)
* @param params.description 视频描述 (可选)
*/
export async function sendMedia(params: {
agent: ResolvedAgentAccount;
toUser?: string;
toParty?: string;
toTag?: string;
chatId?: string;
mediaId: string;
mediaType: "image" | "voice" | "video" | "file";
title?: string;
description?: string;
}): Promise<void> {
const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType, title, description } = params;
const token = await getAccessToken(agent);
const useChat = Boolean(chatId);
const url = useChat
? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
: `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
const mediaPayload = mediaType === "video"
? { media_id: mediaId, title: title ?? "Video", description: description ?? "" }
: { media_id: mediaId };
const body = useChat
? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
: {
touser: toUser,
toparty: toParty,
totag: toTag,
msgtype: mediaType,
agentid: agent.agentId,
[mediaType]: mediaPayload
};
const res = await wecomFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
const json = await res.json() as {
errcode?: number;
errmsg?: string;
invaliduser?: string;
invalidparty?: string;
invalidtag?: string;
};
if (json?.errcode !== 0) {
throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
}
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
const details = [
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
].filter(Boolean).join(", ");
throw new Error(`send ${mediaType} partial failure: ${details}`);
}
}
/**
* **downloadMedia (下载媒体文件)**
*
* 通过 media_id 从企业微信服务器下载临时素材。
*
* @returns { buffer, contentType }
*/
export async function downloadMedia(params: {
agent: ResolvedAgentAccount;
mediaId: string;
maxBytes?: number;
}): Promise<{ buffer: Buffer; contentType: string; filename?: string }> {
const { agent, mediaId } = params;
const token = await getAccessToken(agent);
const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
if (!res.ok) {
throw new Error(`download failed: ${res.status}`);
}
const contentType = res.headers.get("content-type") || "application/octet-stream";
const disposition = res.headers.get("content-disposition") || "";
const filename = (() => {
// 兼容:filename="a.md" / filename=a.md / filename*=UTF-8''a%2Eb.md
const mStar = disposition.match(/filename\*\s*=\s*([^;]+)/i);
if (mStar) {
const raw = mStar[1]!.trim().replace(/^"(.*)"$/, "$1");
const parts = raw.split("''");
const encoded = parts.length === 2 ? parts[1]! : raw;
try {
return decodeURIComponent(encoded);
} catch {
return encoded;
}
}
const m = disposition.match(/filename\s*=\s*([^;]+)/i);
if (!m) return undefined;
return m[1]!.trim().replace(/^"(.*)"$/, "$1") || undefined;
})();
// 检查是否返回了错误 JSON
if (contentType.includes("application/json")) {
const json = await res.json() as { errcode?: number; errmsg?: string };
throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
}
const buffer = await readResponseBodyAsBuffer(res, params.maxBytes);
return { buffer, contentType, filename };
}
+566
View File
@@ -0,0 +1,566 @@
/**
* WeCom Agent Webhook 处理器
* 处理 XML 格式回调
*/
import { pathToFileURL } from "node:url";
import path from "node:path";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import type { ResolvedAgentAccount } from "../types/index.js";
import { LIMITS } from "../types/constants.js";
import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId, extractMsgId, extractFileName } from "../shared/xml-parser.js";
import { sendText, downloadMedia } from "./api-client.js";
import { getWecomRuntime } from "../runtime.js";
import type { WecomAgentInboundMessage } from "../types/index.js";
import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
import { resolveWecomMediaMaxBytes } from "../config/index.js";
/** 错误提示信息 */
const ERROR_HELP = "";
// Agent webhook 幂等去重池(防止企微回调重试导致重复回复)
// 注意:这是进程内内存去重,重启会清空;但足以覆盖企微的短周期重试。
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
const recentAgentMsgIds = new Map<string, number>();
function rememberAgentMsgId(msgId: string): boolean {
const now = Date.now();
const existing = recentAgentMsgIds.get(msgId);
if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
recentAgentMsgIds.set(msgId, now);
// 简单清理:只在写入时做一次线性 prune,避免无界增长
for (const [k, ts] of recentAgentMsgIds) {
if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
}
return true;
}
function looksLikeTextFile(buffer: Buffer): boolean {
const sampleSize = Math.min(buffer.length, 4096);
if (sampleSize === 0) return true;
let bad = 0;
for (let i = 0; i < sampleSize; i++) {
const b = buffer[i]!;
const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d; // \t \n \r
const isPrintable = b >= 0x20 && b !== 0x7f;
if (!isWhitespace && !isPrintable) bad++;
}
// 非可打印字符占比太高,基本可判断为二进制
return bad / sampleSize <= 0.02;
}
function analyzeTextHeuristic(buffer: Buffer): { sampleSize: number; badCount: number; badRatio: number } {
const sampleSize = Math.min(buffer.length, 4096);
if (sampleSize === 0) return { sampleSize: 0, badCount: 0, badRatio: 0 };
let badCount = 0;
for (let i = 0; i < sampleSize; i++) {
const b = buffer[i]!;
const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d;
const isPrintable = b >= 0x20 && b !== 0x7f;
if (!isWhitespace && !isPrintable) badCount++;
}
return { sampleSize, badCount, badRatio: badCount / sampleSize };
}
function previewHex(buffer: Buffer, maxBytes = 32): string {
const n = Math.min(buffer.length, maxBytes);
if (n <= 0) return "";
return buffer
.subarray(0, n)
.toString("hex")
.replace(/(..)/g, "$1 ")
.trim();
}
function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefined {
if (!looksLikeTextFile(buffer)) return undefined;
const text = buffer.toString("utf8");
if (!text.trim()) return undefined;
const truncated = text.length > maxChars ? `${text.slice(0, maxChars)}\n…(已截断)` : text;
return truncated;
}
/**
* **AgentWebhookParams (Webhook 处理器参数)**
*
* 传递给 Agent Webhook 处理函数的上下文参数集合。
* @property req Node.js 原始请求对象
* @property res Node.js 原始响应对象
* @property agent 解析后的 Agent 账号信息
* @property config 全局插件配置
* @property core OpenClaw 插件运行时
* @property log 可选日志输出函数
* @property error 可选错误输出函数
*/
export type AgentWebhookParams = {
req: IncomingMessage;
res: ServerResponse;
agent: ResolvedAgentAccount;
config: OpenClawConfig;
core: PluginRuntime;
log?: (msg: string) => void;
error?: (msg: string) => void;
};
/**
* **resolveQueryParams (解析查询参数)**
*
* 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
*/
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
const url = new URL(req.url ?? "/", "http://localhost");
return url.searchParams;
}
/**
* **readRawBody (读取原始请求体)**
*
* 异步读取 HTTP POST 请求的原始 BODY 数据(XML 字符串)。
* 包含最大体积限制检查,防止内存溢出攻击。
*/
async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_REQUEST_BODY_SIZE): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > maxSize) {
reject(new Error("Request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
/**
* **handleUrlVerification (处理 URL 验证)**
*
* 处理企业微信 Agent 配置时的 GET 请求验证。
* 流程:
* 1. 验证 msg_signature 签名。
* 2. 解密 echostr 参数。
* 3. 返回解密后的明文 echostr。
*/
async function handleUrlVerification(
req: IncomingMessage,
res: ServerResponse,
agent: ResolvedAgentAccount,
): Promise<boolean> {
const query = resolveQueryParams(req);
const timestamp = query.get("timestamp") ?? "";
const nonce = query.get("nonce") ?? "";
const echostr = query.get("echostr") ?? "";
const signature = query.get("msg_signature") ?? "";
const remote = req.socket?.remoteAddress ?? "unknown";
// 不输出敏感参数内容,仅输出存在性
// 用于排查:是否有请求打到 /wecom/agent
// 以及是否带齐 timestamp/nonce/msg_signature/echostr
// eslint-disable-next-line no-unused-vars
const _debug = { remote, hasTimestamp: Boolean(timestamp), hasNonce: Boolean(nonce), hasSig: Boolean(signature), hasEchostr: Boolean(echostr) };
const valid = verifyWecomSignature({
token: agent.token,
timestamp,
nonce,
encrypt: echostr,
signature,
});
if (!valid) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(`unauthorized - 签名验证失败,请检查 Token 配置${ERROR_HELP}`);
return true;
}
try {
const plain = decryptWecomEncrypted({
encodingAESKey: agent.encodingAESKey,
receiveId: agent.corpId,
encrypt: echostr,
});
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(plain);
return true;
} catch {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey 配置${ERROR_HELP}`);
return true;
}
}
/**
* 处理消息回调 (POST)
*/
async function handleMessageCallback(params: AgentWebhookParams): Promise<boolean> {
const { req, res, agent, config, core, log, error } = params;
try {
log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
const rawXml = await readRawBody(req);
log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
const encrypted = extractEncryptFromXml(rawXml);
log?.(`[wecom-agent] inbound: hasEncrypt=${Boolean(encrypted)} encryptLen=${encrypted ? String(encrypted).length : 0}`);
const query = resolveQueryParams(req);
const timestamp = query.get("timestamp") ?? "";
const nonce = query.get("nonce") ?? "";
const signature = query.get("msg_signature") ?? "";
log?.(
`[wecom-agent] inbound: query timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"}`,
);
// 验证签名
const valid = verifyWecomSignature({
token: agent.token,
timestamp,
nonce,
encrypt: encrypted,
signature,
});
if (!valid) {
error?.(`[wecom-agent] inbound: signature invalid`);
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
return true;
}
// 解密
const decrypted = decryptWecomEncrypted({
encodingAESKey: agent.encodingAESKey,
receiveId: agent.corpId,
encrypt: encrypted,
});
log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);
// 解析 XML
const msg = parseXml(decrypted);
const msgType = extractMsgType(msg);
const fromUser = extractFromUser(msg);
const chatId = extractChatId(msg);
const msgId = extractMsgId(msg);
if (msgId) {
const ok = rememberAgentMsgId(msgId);
if (!ok) {
log?.(`[wecom-agent] duplicate msgId=${msgId} from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}; skipped`);
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("success");
return true;
}
}
const content = String(extractContent(msg) ?? "");
const preview = content.length > 100 ? `${content.slice(0, 100)}…` : content;
log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} msgId=${msgId ?? "N/A"} content=${preview}`);
// 先返回 success (Agent 模式使用 API 发送回复,不用被动回复)
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("success");
// 异步处理消息
processAgentMessage({
agent,
config,
core,
fromUser,
chatId,
msgType,
content,
msg,
log,
error,
}).catch((err) => {
error?.(`[wecom-agent] process failed: ${String(err)}`);
});
return true;
} catch (err) {
error?.(`[wecom-agent] callback failed: ${String(err)}`);
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(`error - 回调处理失败${ERROR_HELP}`);
return true;
}
}
/**
* **processAgentMessage (处理 Agent 消息)**
*
* 异步处理解密后的消息内容,并触发 OpenClaw Agent。
* 流程:
* 1. 路由解析:根据 userid或群ID 确定 Agent 路由。
* 2. 媒体处理:如果是图片/文件等,下载资源。
* 3. 上下文构建:创建 Inbound Context。
* 4. 会话记录:更新 Session 状态。
* 5. 调度回复:将 Agent 的响应通过 `api-client` 发送回企业微信。
*/
async function processAgentMessage(params: {
agent: ResolvedAgentAccount;
config: OpenClawConfig;
core: PluginRuntime;
fromUser: string;
chatId?: string;
msgType: string;
content: string;
msg: WecomAgentInboundMessage;
log?: (msg: string) => void;
error?: (msg: string) => void;
}): Promise<void> {
const { agent, config, core, fromUser, chatId, content, msg, msgType, log, error } = params;
const isGroup = Boolean(chatId);
const peerId = isGroup ? chatId! : fromUser;
const mediaMaxBytes = resolveWecomMediaMaxBytes(config);
// 处理媒体文件
const attachments: any[] = []; // TODO: define specific type
let finalContent = content;
let mediaPath: string | undefined;
let mediaType: string | undefined;
if (["image", "voice", "video", "file"].includes(msgType)) {
const mediaId = extractMediaId(msg);
if (mediaId) {
try {
log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
const { buffer, contentType, filename: headerFileName } = await downloadMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
const xmlFileName = extractFileName(msg);
const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
const heuristic = analyzeTextHeuristic(buffer);
// 推断文件名后缀
const extMap: Record<string, string> = {
"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
"audio/amr": "amr", "audio/speex": "speex", "video/mp4": "mp4",
};
const textPreview = msgType === "file" ? buildTextFilePreview(buffer, 12_000) : undefined;
const looksText = Boolean(textPreview);
const originalExt = path.extname(originalFileName).toLowerCase();
const normalizedContentType =
looksText && originalExt === ".md" ? "text/markdown" :
looksText && (!contentType || contentType === "application/octet-stream")
? "text/plain; charset=utf-8"
: contentType;
const ext = extMap[normalizedContentType] || (looksText ? "txt" : "bin");
const filename = `${mediaId}.${ext}`;
log?.(
`[wecom-agent] file meta: msgType=${msgType} mediaId=${mediaId} size=${buffer.length} maxBytes=${mediaMaxBytes} ` +
`contentType=${contentType} normalizedContentType=${normalizedContentType} originalFileName=${originalFileName} ` +
`xmlFileName=${xmlFileName ?? "N/A"} headerFileName=${headerFileName ?? "N/A"} ` +
`textHeuristic(sample=${heuristic.sampleSize}, bad=${heuristic.badCount}, ratio=${heuristic.badRatio.toFixed(4)}) ` +
`headHex="${previewHex(buffer)}"`,
);
// 使用 Core SDK 保存媒体文件
const saved = await core.channel.media.saveMediaBuffer(
buffer,
normalizedContentType,
"inbound", // context/scope
mediaMaxBytes, // limit
originalFileName
);
log?.(`[wecom-agent] media saved to: ${saved.path}`);
mediaPath = saved.path;
mediaType = normalizedContentType;
// 构建附件
attachments.push({
name: originalFileName,
mimeType: normalizedContentType,
url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
});
// 更新文本提示
if (textPreview) {
finalContent = [
content,
"",
"文件内容预览:",
"```",
textPreview,
"```",
`(已下载 ${buffer.length} 字节)`,
].join("\n");
} else {
if (msgType === "file") {
finalContent = [
content,
"",
`已收到文件:${originalFileName}`,
`文件类型:${normalizedContentType || contentType || "未知"}`,
"提示:当前仅对文本/Markdown/JSON/CSV/HTML/PDF(可选)做内容抽取;其他二进制格式请转为 PDF 或复制文本内容。",
`(已下载 ${buffer.length} 字节)`,
].join("\n");
} else {
finalContent = `${content} (已下载 ${buffer.length} 字节)`;
}
}
log?.(`[wecom-agent] file preview: enabled=${looksText} finalContentLen=${finalContent.length} attachments=${attachments.length}`);
} catch (err) {
error?.(`[wecom-agent] media processing failed: ${String(err)}`);
finalContent = [
content,
"",
`媒体处理失败:${String(err)}`,
`提示:可在 OpenClaw 配置中提高 channels.wecom.media.maxBytes(当前=${mediaMaxBytes})`,
`例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
].join("\n");
}
} else {
const keys = Object.keys((msg as unknown as Record<string, unknown>) ?? {}).slice(0, 50).join(",");
error?.(`[wecom-agent] mediaId not found for ${msgType}; keys=${keys}`);
}
}
// 解析路由
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "wecom",
accountId: agent.accountId,
peer: { kind: isGroup ? "group" : "dm", id: peerId },
});
// 构建上下文
const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "WeCom",
from: fromLabel,
previousTimestamp,
envelope: envelopeOptions,
body: finalContent,
});
const authz = await resolveWecomCommandAuthorization({
core,
cfg: config,
// Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
accountConfig: agent.config as any,
rawBody: finalContent,
senderUserId: fromUser,
});
log?.(`[wecom-agent] authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${fromUser.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(authz.commandAuthorized)}`);
// 命令门禁:未授权时必须明确回复(Agent 侧用私信提示)
if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: fromUser, dmPolicy: authz.dmPolicy, scope: "agent" });
try {
await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
log?.(`[wecom-agent] unauthorized command: replied via DM to ${fromUser}`);
} catch (err: unknown) {
error?.(`[wecom-agent] unauthorized command reply failed: ${String(err)}`);
}
return;
}
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: finalContent,
CommandBody: finalContent,
Attachments: attachments.length > 0 ? attachments : undefined,
From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
To: `wecom:${peerId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: fromUser,
SenderId: fromUser,
Provider: "wecom",
Surface: "wecom",
OriginatingChannel: "wecom",
// 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
// - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
// - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
OriginatingTo: `wecom-agent:${fromUser}`,
CommandAuthorized: authz.commandAuthorized ?? true,
MediaPath: mediaPath,
MediaType: mediaType,
MediaUrl: mediaPath,
});
// 记录会话
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err: unknown) => {
error?.(`[wecom-agent] session record failed: ${String(err)}`);
},
});
// 调度回复
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload: { text?: string }, info: { kind: string }) => {
const text = payload.text ?? "";
if (!text) return;
try {
// 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
await sendText({ agent, toUser: fromUser, chatId: undefined, text });
log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
} catch (err: unknown) {
error?.(`[wecom-agent] reply failed: ${String(err)}`);
}
},
onError: (err: unknown, info: { kind: string }) => {
error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
},
},
replyOptions: {
disableBlockStreaming: true,
},
});
}
/**
* **handleAgentWebhook (Agent Webhook 入口)**
*
* 统一处理 Agent 模式的 Webhook 请求。
* 根据 HTTP 方法分发到 URL 验证 (GET) 或 消息处理 (POST)。
*/
export async function handleAgentWebhook(params: AgentWebhookParams): Promise<boolean> {
const { req } = params;
if (req.method === "GET") {
return handleUrlVerification(req, params.res, params.agent);
}
if (req.method === "POST") {
return handleMessageCallback(params);
}
return false;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* WeCom Agent 模块导出
*/
export { handleAgentWebhook, type AgentWebhookParams } from "./handler.js";
export {
getAccessToken,
sendText,
uploadMedia,
sendMedia,
downloadMedia,
} from "./api-client.js";
+259
View File
@@ -0,0 +1,259 @@
import type {
ChannelAccountSnapshot,
ChannelPlugin,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";
import { resolveWecomAccounts } from "./config/index.js";
import { WecomConfigSchema } from "./config/index.js";
import type { ResolvedAgentAccount, ResolvedBotAccount } from "./types/index.js";
import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
import { wecomOnboardingAdapter } from "./onboarding.js";
import { wecomOutbound } from "./outbound.js";
const meta = {
id: "wecom",
label: "WeCom",
selectionLabel: "WeCom (plugin)",
docsPath: "/channels/wecom",
docsLabel: "wecom",
blurb: "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
aliases: ["wechatwork", "wework", "qywx", "企微", "企业微信"],
order: 85,
quickstartAllowFrom: true,
};
function normalizeWecomMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
}
type ResolvedWecomAccount = {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
bot?: ResolvedBotAccount;
agent?: ResolvedAgentAccount;
};
/**
* **resolveWecomAccount (解析账号配置)**
*
* 从全局配置中解析出 WeCom 渠道的配置状态。
* 兼容 Bot 和 Agent 两种模式的配置检查。
*/
function resolveWecomAccount(cfg: OpenClawConfig): ResolvedWecomAccount {
const enabled = (cfg.channels?.wecom as { enabled?: boolean } | undefined)?.enabled !== false;
const accounts = resolveWecomAccounts(cfg);
const bot = accounts.bot;
const agent = accounts.agent;
const configured = Boolean(bot?.configured || agent?.configured);
return {
accountId: DEFAULT_ACCOUNT_ID,
enabled,
configured,
bot,
agent,
};
}
export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
id: "wecom",
meta,
onboarding: wecomOnboardingAdapter,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.wecom"] },
configSchema: buildChannelConfigSchema(WecomConfigSchema),
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => resolveWecomAccount(cfg as OpenClawConfig),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as OpenClawConfig,
sectionKey: "wecom",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg }) => {
const next = { ...(cfg as OpenClawConfig) };
if (next.channels?.wecom) {
const channels = { ...(next.channels ?? {}) } as Record<string, unknown>;
delete (channels as Record<string, unknown>).wecom;
return { ...next, channels } as OpenClawConfig;
}
return next;
},
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveWecomAccount(cfg as OpenClawConfig);
// 与其他渠道保持一致:直接返回 allowFrom,空则允许所有人
const allowFrom = account.agent?.config.dm?.allowFrom ?? account.bot?.config.dm?.allowFrom ?? [];
return allowFrom.map((entry) => String(entry));
},
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
// security 配置在 WeCom 中不需要,框架会通过 resolveAllowFrom 自动判断
groups: {
// WeCom bots are usually mention-gated by the platform in groups already.
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: normalizeWecomMessagingTarget,
targetResolver: {
looksLikeId: (raw) => Boolean(raw.trim()),
hint: "<userid|chatid>",
},
},
outbound: {
...wecomOutbound,
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
webhookPath: snapshot.webhookPath ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
lastInboundAt: snapshot.lastInboundAt ?? null,
lastOutboundAt: snapshot.lastOutboundAt ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async () => ({ ok: true }),
buildAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.bot?.config.dm?.policy ?? "pairing",
}),
},
gateway: {
/**
* **startAccount (启动账号)**
*
* 插件生命周期:启动
* 职责:
* 1. 检查配置是否有效。
* 2. 注册 Bot Webhook (`/wecom`, `/wecom/bot`)。
* 3. 注册 Agent Webhook (`/wecom/agent`)。
* 4. 更新运行时状态 (Running)。
* 5. 返回停止回调 (Cleanup)。
*/
startAccount: async (ctx) => {
const account = ctx.account;
const bot = account.bot;
const agent = account.agent;
const botConfigured = Boolean(bot?.configured);
const agentConfigured = Boolean(agent?.configured);
if (!botConfigured && !agentConfigured) {
ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`);
ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
return { stop: () => { } };
}
const unregisters: Array<() => void> = [];
if (bot && botConfigured) {
for (const path of ["/wecom", "/wecom/bot"]) {
unregisters.push(
registerWecomWebhookTarget({
account: bot,
config: ctx.cfg as OpenClawConfig,
runtime: ctx.runtime,
// The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
// The stored target only needs to be decrypt/verify-capable.
core: ({} as unknown) as any,
path,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
}),
);
}
ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at /wecom and /wecom/bot`);
}
if (agent && agentConfigured) {
unregisters.push(
registerAgentWebhookTarget({
agent,
config: ctx.cfg as OpenClawConfig,
runtime: ctx.runtime,
}),
);
ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at /wecom/agent`);
}
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
webhookPath: botConfigured ? "/wecom/bot" : "/wecom/agent",
lastStartAt: Date.now(),
});
return {
stop: () => {
for (const unregister of unregisters) {
unregister();
}
ctx.setStatus({
accountId: account.accountId,
running: false,
lastStopAt: Date.now(),
});
},
};
},
stopAccount: async (ctx) => {
ctx.setStatus({
accountId: ctx.account.accountId,
running: false,
lastStopAt: Date.now(),
});
},
},
};
+41
View File
@@ -0,0 +1,41 @@
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const dmSchema = z
.object({
enabled: z.boolean().optional(),
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
})
.optional();
export const WecomConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
webhookPath: z.string().optional(),
token: z.string().optional(),
encodingAESKey: z.string().optional(),
receiveId: z.string().optional(),
streamPlaceholderContent: z.string().optional(),
debounceMs: z.number().optional(),
welcomeText: z.string().optional(),
dm: dmSchema,
defaultAccount: z.string().optional(),
accounts: z.object({}).catchall(z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
webhookPath: z.string().optional(),
token: z.string().optional(),
encodingAESKey: z.string().optional(),
receiveId: z.string().optional(),
streamPlaceholderContent: z.string().optional(),
debounceMs: z.number().optional(),
welcomeText: z.string().optional(),
dm: dmSchema,
})).optional(),
});
+99
View File
@@ -0,0 +1,99 @@
/**
* WeCom 账号解析与模式检测
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type {
WecomConfig,
WecomBotConfig,
WecomAgentConfig,
WecomNetworkConfig,
ResolvedBotAccount,
ResolvedAgentAccount,
ResolvedMode,
ResolvedWecomAccounts,
} from "../types/index.js";
const DEFAULT_ACCOUNT_ID = "default";
/**
* 检测配置中启用的模式
*/
export function detectMode(config: WecomConfig | undefined): ResolvedMode {
if (!config) return { bot: false, agent: false };
const botConfigured = Boolean(
config.bot?.token && config.bot?.encodingAESKey
);
const agentConfigured = Boolean(
config.agent?.corpId && config.agent?.corpSecret && config.agent?.agentId &&
config.agent?.token && config.agent?.encodingAESKey
);
return { bot: botConfigured, agent: agentConfigured };
}
/**
* 解析 Bot 模式账号
*/
function resolveBotAccount(config: WecomBotConfig): ResolvedBotAccount {
return {
accountId: DEFAULT_ACCOUNT_ID,
enabled: true,
configured: Boolean(config.token && config.encodingAESKey),
token: config.token,
encodingAESKey: config.encodingAESKey,
receiveId: config.receiveId?.trim() ?? "",
config,
};
}
/**
* 解析 Agent 模式账号
*/
function resolveAgentAccount(config: WecomAgentConfig, network?: WecomNetworkConfig): ResolvedAgentAccount {
const agentIdRaw = config.agentId;
const agentId = typeof agentIdRaw === "number" ? agentIdRaw : Number(agentIdRaw);
return {
accountId: DEFAULT_ACCOUNT_ID,
enabled: true,
configured: Boolean(
config.corpId && config.corpSecret && agentId &&
config.token && config.encodingAESKey
),
corpId: config.corpId,
corpSecret: config.corpSecret,
agentId,
token: config.token,
encodingAESKey: config.encodingAESKey,
config,
network,
};
}
/**
* 解析 WeCom 账号 (双模式)
*/
export function resolveWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccounts {
const wecom = cfg.channels?.wecom as WecomConfig | undefined;
if (!wecom || wecom.enabled === false) {
return {};
}
const mode = detectMode(wecom);
return {
bot: mode.bot && wecom.bot ? { ...resolveBotAccount(wecom.bot), network: wecom.network } : undefined,
agent: mode.agent && wecom.agent ? resolveAgentAccount(wecom.agent, wecom.network) : undefined,
};
}
/**
* 检查是否有任何模式启用
*/
export function isWecomEnabled(cfg: OpenClawConfig): boolean {
const accounts = resolveWecomAccounts(cfg);
return Boolean(accounts.bot?.configured || accounts.agent?.configured);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* WeCom 配置模块导出
*/
export { WecomConfigSchema, type WecomConfigInput } from "./schema.js";
export {
detectMode,
resolveWecomAccounts,
isWecomEnabled,
} from "./accounts.js";
export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
export { DEFAULT_WECOM_MEDIA_MAX_BYTES, resolveWecomMediaMaxBytes } from "./media.js";
+14
View File
@@ -0,0 +1,14 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
// 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
// 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
export const DEFAULT_WECOM_MEDIA_MAX_BYTES = 80 * 1024 * 1024;
export function resolveWecomMediaMaxBytes(cfg: OpenClawConfig): number {
const raw = (cfg.channels?.wecom as any)?.media?.maxBytes;
const n = typeof raw === "number" ? raw : Number(raw);
if (Number.isFinite(n) && n > 0) {
return Math.floor(n);
}
return DEFAULT_WECOM_MEDIA_MAX_BYTES;
}
+16
View File
@@ -0,0 +1,16 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type { WecomConfig, WecomNetworkConfig } from "../types/index.js";
export function resolveWecomEgressProxyUrlFromNetwork(network?: WecomNetworkConfig): string | undefined {
const env = (process.env.OPENCLAW_WECOM_EGRESS_PROXY_URL ?? process.env.WECOM_EGRESS_PROXY_URL ?? "").trim();
if (env) return env;
const fromCfg = network?.egressProxyUrl?.trim() ?? "";
return fromCfg || undefined;
}
export function resolveWecomEgressProxyUrl(cfg: OpenClawConfig): string | undefined {
const wecom = cfg.channels?.wecom as WecomConfig | undefined;
return resolveWecomEgressProxyUrlFromNetwork(wecom?.network);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* WeCom 配置 Schema (Zod)
*/
import { z } from "zod";
/**
* **dmSchema (单聊配置)**
*
* 控制单聊行为(如允许名单、策略)。
* @property enabled - 是否启用单聊 [默认: true]
* @property policy - 访问策略: "pairing" (需配对, 默认), "allowlist" (仅在名单), "open" (所有人), "disabled" (禁用)
* @property allowFrom - 允许的用户ID或群ID列表 (仅当 policy="allowlist" 时生效)
*/
const dmSchema = z.object({
enabled: z.boolean().optional(),
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
}).optional();
/**
* **mediaSchema (媒体处理配置)**
*
* 控制媒体文件的下载和缓存行为。
* @property tempDir - 临时文件下载目录
* @property retentionHours - 临时文件保留时间(小时)
* @property cleanupOnStart - 启动时是否自动清理旧文件
* @property maxBytes - 允许下载的最大字节数
*/
const mediaSchema = z.object({
tempDir: z.string().optional(),
retentionHours: z.number().optional(),
cleanupOnStart: z.boolean().optional(),
maxBytes: z.number().optional(),
}).optional();
/**
* **networkSchema (网络配置)**
*
* 控制 HTTP 请求行为,特别是出站代理。
* @property timeoutMs - 请求超时时间 (毫秒)
* @property retries - 重试次数
* @property retryDelayMs - 重试间隔 (毫秒)
* @property egressProxyUrl - 出站 HTTP 代理 (如 "http://127.0.0.1:7890")
*/
const networkSchema = z.object({
timeoutMs: z.number().optional(),
retries: z.number().optional(),
retryDelayMs: z.number().optional(),
egressProxyUrl: z.string().optional(),
}).optional();
/**
* **botSchema (Bot 模式配置)**
*
* 用于配置企业微信内部机器人 (Webhook 模式)。
* @property token - 企业微信后台设置的 Token
* @property encodingAESKey - 企业微信后台设置的 EncodingAESKey
* @property receiveId - (可选) 接收者ID,通常不用填
* @property streamPlaceholderContent - (可选) 流式响应中的占位符,默认为 "Thinking..."或空
* @property welcomeText - (可选) 用户首次对话时的欢迎语
* @property dm - 单聊策略覆盖配置
*/
const botSchema = z.object({
token: z.string(),
encodingAESKey: z.string(),
receiveId: z.string().optional(),
streamPlaceholderContent: z.string().optional(),
welcomeText: z.string().optional(),
dm: dmSchema,
}).optional();
/**
* **agentSchema (Agent 模式配置)**
*
* 用于配置企业微信自建应用 (Agent)。
* @property corpId - 企业 ID (CorpID)
* @property corpSecret - 应用 Secret
* @property agentId - 应用 AgentId (数字)
* @property token - 回调配置 Token
* @property encodingAESKey - 回调配置 EncodingAESKey
* @property welcomeText - (可选) 欢迎语
* @property dm - 单聊策略覆盖配置
*/
const agentSchema = z.object({
corpId: z.string(),
corpSecret: z.string(),
agentId: z.union([z.string(), z.number()]),
token: z.string(),
encodingAESKey: z.string(),
welcomeText: z.string().optional(),
dm: dmSchema,
}).optional();
/** 顶层 WeCom 配置 Schema */
export const WecomConfigSchema = z.object({
enabled: z.boolean().optional(),
bot: botSchema,
agent: agentSchema,
media: mediaSchema,
network: networkSchema,
});
export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js";
describe("wecom crypto", () => {
it("round-trips plaintext", () => {
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars base64 (plus '=' padding)
const plaintext = JSON.stringify({ hello: "world" });
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext });
const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt });
expect(decrypted).toBe(plaintext);
});
it("pads correctly when raw length is a multiple of 32", () => {
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
// raw length = 20 + plaintext.length + receiveId.length; choose plaintext length % 32 === 12
const plaintext = "x".repeat(12);
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext });
const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt });
expect(decrypted).toBe(plaintext);
});
it("computes sha1 msg signature", () => {
const sig = computeWecomMsgSignature({
token: "token",
timestamp: "123",
nonce: "456",
encrypt: "ENCRYPT",
});
expect(sig).toMatch(/^[a-f0-9]{40}$/);
});
});
+176
View File
@@ -0,0 +1,176 @@
import crypto from "node:crypto";
/**
* **decodeEncodingAESKey (解码 AES Key)**
*
* 将企业微信配置的 Base64 编码的 AES Key 解码为 Buffer。
* 包含补全 Padding 和长度校验 (必须32字节)。
*/
export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
const trimmed = encodingAESKey.trim();
if (!trimmed) throw new Error("encodingAESKey missing");
const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
const key = Buffer.from(withPadding, "base64");
if (key.length !== 32) {
throw new Error(`invalid encodingAESKey (expected 32 bytes after base64 decode, got ${key.length})`);
}
return key;
}
// WeCom uses PKCS#7 padding with a block size of 32 bytes (not AES's 16-byte block).
// This is compatible with AES-CBC as 32 is a multiple of 16, but it requires manual padding/unpadding.
export const WECOM_PKCS7_BLOCK_SIZE = 32;
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
const mod = buf.length % blockSize;
const pad = mod === 0 ? blockSize : blockSize - mod;
const padByte = Buffer.from([pad]);
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
}
/**
* **pkcs7Unpad (去除 PKCS#7 填充)**
*
* 移除 AES 解密后的 PKCS#7 填充字节。
* 包含填充合法性校验。
*/
export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
if (buf.length === 0) throw new Error("invalid pkcs7 payload");
const pad = buf[buf.length - 1]!;
if (pad < 1 || pad > blockSize) {
throw new Error("invalid pkcs7 padding");
}
if (pad > buf.length) {
throw new Error("invalid pkcs7 payload");
}
// Best-effort validation (all padding bytes equal).
for (let i = 0; i < pad; i += 1) {
if (buf[buf.length - 1 - i] !== pad) {
throw new Error("invalid pkcs7 padding");
}
}
return buf.subarray(0, buf.length - pad);
}
function sha1Hex(input: string): string {
return crypto.createHash("sha1").update(input).digest("hex");
}
/**
* **computeWecomMsgSignature (计算消息签名)**
*
* 算法:sha1(sort(token, timestamp, nonce, encrypt_msg))
*/
export function computeWecomMsgSignature(params: {
token: string;
timestamp: string;
nonce: string;
encrypt: string;
}): string {
const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
.map((v) => String(v ?? ""))
.sort();
return sha1Hex(parts.join(""));
}
/**
* **verifyWecomSignature (验证消息签名)**
*
* 比较计算出的签名与企业微信传入的签名是否一致。
*/
export function verifyWecomSignature(params: {
token: string;
timestamp: string;
nonce: string;
encrypt: string;
signature: string;
}): boolean {
const expected = computeWecomMsgSignature({
token: params.token,
timestamp: params.timestamp,
nonce: params.nonce,
encrypt: params.encrypt,
});
return expected === params.signature;
}
/**
* **decryptWecomEncrypted (解密企业微信消息)**
*
* 将企业微信的 AES 加密包解密为明文。
* 流程:
* 1. Base64 解码 AESKey 并获取 IV (前16字节)。
* 2. AES-CBC 解密。
* 3. 去除 PKCS#7 填充。
* 4. 拆解协议包结构: [16字节随机串][4字节长度][消息体][接收者ID]。
* 5. 校验接收者ID (ReceiveId)。
*/
export function decryptWecomEncrypted(params: {
encodingAESKey: string;
receiveId?: string;
encrypt: string;
}): string {
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
const iv = aesKey.subarray(0, 16);
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
decipher.setAutoPadding(false);
const decryptedPadded = Buffer.concat([
decipher.update(Buffer.from(params.encrypt, "base64")),
decipher.final(),
]);
const decrypted = pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
if (decrypted.length < 20) {
throw new Error(`invalid decrypted payload (expected at least 20 bytes, got ${decrypted.length})`);
}
// 16 bytes random + 4 bytes network-order length + msg + receiveId (optional)
const msgLen = decrypted.readUInt32BE(16);
const msgStart = 20;
const msgEnd = msgStart + msgLen;
if (msgEnd > decrypted.length) {
throw new Error(`invalid decrypted msg length (msgEnd=${msgEnd}, payloadLength=${decrypted.length})`);
}
const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
const receiveId = params.receiveId ?? "";
if (receiveId) {
const trailing = decrypted.subarray(msgEnd).toString("utf8");
if (trailing !== receiveId) {
throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
}
}
return msg;
}
/**
* **encryptWecomPlaintext (加密回复消息)**
*
* 将明文消息打包为企业微信的加密格式。
* 流程:
* 1. 构造协议包: [16字节随机串][4字节长度][消息体][接收者ID]。
* 2. PKCS#7 填充。
* 3. AES-CBC 加密。
* 4. 转 Base64。
*/
export function encryptWecomPlaintext(params: {
encodingAESKey: string;
receiveId?: string;
plaintext: string;
}): string {
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
const iv = aesKey.subarray(0, 16);
const random16 = crypto.randomBytes(16);
const msg = Buffer.from(params.plaintext ?? "", "utf8");
const msgLen = Buffer.alloc(4);
msgLen.writeUInt32BE(msg.length, 0);
const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
const padded = pkcs7Pad(raw, WECOM_PKCS7_BLOCK_SIZE);
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
cipher.setAutoPadding(false);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
return encrypted.toString("base64");
}
+108
View File
@@ -0,0 +1,108 @@
/**
* WeCom AES-256-CBC 加解密核心
* Bot 和 Agent 模式共用
*/
import crypto from "node:crypto";
import { CRYPTO } from "../types/constants.js";
export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
const trimmed = encodingAESKey.trim();
if (!trimmed) throw new Error("encodingAESKey missing");
const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
const key = Buffer.from(withPadding, "base64");
if (key.length !== CRYPTO.AES_KEY_LENGTH) {
throw new Error(`invalid encodingAESKey (expected ${CRYPTO.AES_KEY_LENGTH} bytes, got ${key.length})`);
}
return key;
}
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
const mod = buf.length % blockSize;
const pad = mod === 0 ? blockSize : blockSize - mod;
const padByte = Buffer.from([pad]);
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
}
export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
if (buf.length === 0) throw new Error("invalid pkcs7 payload");
const pad = buf[buf.length - 1]!;
if (pad < 1 || pad > blockSize) {
throw new Error("invalid pkcs7 padding");
}
if (pad > buf.length) {
throw new Error("invalid pkcs7 payload");
}
for (let i = 0; i < pad; i += 1) {
if (buf[buf.length - 1 - i] !== pad) {
throw new Error("invalid pkcs7 padding");
}
}
return buf.subarray(0, buf.length - pad);
}
/**
* 解密 WeCom 加密消息
*/
export function decryptWecomEncrypted(params: {
encodingAESKey: string;
receiveId?: string;
encrypt: string;
}): string {
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
const iv = aesKey.subarray(0, 16);
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
decipher.setAutoPadding(false);
const decryptedPadded = Buffer.concat([
decipher.update(Buffer.from(params.encrypt, "base64")),
decipher.final(),
]);
const decrypted = pkcs7Unpad(decryptedPadded, CRYPTO.PKCS7_BLOCK_SIZE);
if (decrypted.length < 20) {
throw new Error(`invalid payload (expected >=20 bytes, got ${decrypted.length})`);
}
// 16 bytes random + 4 bytes length + msg + receiveId
const msgLen = decrypted.readUInt32BE(16);
const msgStart = 20;
const msgEnd = msgStart + msgLen;
if (msgEnd > decrypted.length) {
throw new Error(`invalid msg length (msgEnd=${msgEnd}, total=${decrypted.length})`);
}
const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
const receiveId = params.receiveId ?? "";
if (receiveId) {
const trailing = decrypted.subarray(msgEnd).toString("utf8");
if (trailing !== receiveId) {
throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
}
}
return msg;
}
/**
* 加密明文为 WeCom 格式
*/
export function encryptWecomPlaintext(params: {
encodingAESKey: string;
receiveId?: string;
plaintext: string;
}): string {
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
const iv = aesKey.subarray(0, 16);
const random16 = crypto.randomBytes(16);
const msg = Buffer.from(params.plaintext ?? "", "utf8");
const msgLen = Buffer.alloc(4);
msgLen.writeUInt32BE(msg.length, 0);
const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
const padded = pkcs7Pad(raw, CRYPTO.PKCS7_BLOCK_SIZE);
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
cipher.setAutoPadding(false);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
return encrypted.toString("base64");
}
+24
View File
@@ -0,0 +1,24 @@
/**
* WeCom 加解密模块导出
*/
// AES 加解密
export {
decodeEncodingAESKey,
pkcs7Unpad,
decryptWecomEncrypted,
encryptWecomPlaintext,
} from "./aes.js";
// 签名验证
export {
computeWecomMsgSignature,
verifyWecomSignature,
} from "./signature.js";
// XML 辅助
export {
extractEncryptFromXml,
extractToUserNameFromXml,
buildEncryptedXmlResponse,
} from "./xml.js";
+43
View File
@@ -0,0 +1,43 @@
/**
* WeCom 签名计算与验证
*/
import crypto from "node:crypto";
function sha1Hex(input: string): string {
return crypto.createHash("sha1").update(input).digest("hex");
}
/**
* 计算 WeCom 消息签名
*/
export function computeWecomMsgSignature(params: {
token: string;
timestamp: string;
nonce: string;
encrypt: string;
}): string {
const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
.map((v) => String(v ?? ""))
.sort();
return sha1Hex(parts.join(""));
}
/**
* 验证 WeCom 消息签名
*/
export function verifyWecomSignature(params: {
token: string;
timestamp: string;
nonce: string;
encrypt: string;
signature: string;
}): boolean {
const expected = computeWecomMsgSignature({
token: params.token,
timestamp: params.timestamp,
nonce: params.nonce,
encrypt: params.encrypt,
});
return expected === params.signature;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* WeCom XML 加解密辅助函数
* 用于 Agent 模式处理 XML 格式回调
*/
/**
* 从 XML 密文中提取 Encrypt 字段
*/
export function extractEncryptFromXml(xml: string): string {
const match = /<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/s.exec(xml);
if (!match?.[1]) {
// 尝试不带 CDATA 的格式
const altMatch = /<Encrypt>(.*?)<\/Encrypt>/s.exec(xml);
if (!altMatch?.[1]) {
throw new Error("Invalid XML: missing Encrypt field");
}
return altMatch[1];
}
return match[1];
}
/**
* 从 XML 中提取 ToUserName (CorpID)
*/
export function extractToUserNameFromXml(xml: string): string {
const match = /<ToUserName><!\[CDATA\[(.*?)\]\]><\/ToUserName>/s.exec(xml);
if (!match?.[1]) {
const altMatch = /<ToUserName>(.*?)<\/ToUserName>/s.exec(xml);
return altMatch?.[1] ?? "";
}
return match[1];
}
/**
* 构建加密 XML 响应
*/
export function buildEncryptedXmlResponse(params: {
encrypt: string;
signature: string;
timestamp: string;
nonce: string;
}): string {
return `<xml>
<Encrypt><![CDATA[${params.encrypt}]]></Encrypt>
<MsgSignature><![CDATA[${params.signature}]]></MsgSignature>
<TimeStamp>${params.timestamp}</TimeStamp>
<Nonce><![CDATA[${params.nonce}]]></Nonce>
</xml>`;
}
+102
View File
@@ -0,0 +1,102 @@
import type { Dispatcher } from "undici";
import { ProxyAgent, fetch as undiciFetch } from "undici";
type ProxyDispatcher = Dispatcher;
const proxyDispatchers = new Map<string, ProxyDispatcher>();
/**
* **getProxyDispatcher (获取代理 Dispatcher)**
*
* 缓存并复用 ProxyAgent,避免重复创建连接池。
*/
function getProxyDispatcher(proxyUrl: string): ProxyDispatcher {
const existing = proxyDispatchers.get(proxyUrl);
if (existing) return existing;
const created = new ProxyAgent(proxyUrl);
proxyDispatchers.set(proxyUrl, created);
return created;
}
function mergeAbortSignal(params: {
signal?: AbortSignal;
timeoutMs?: number;
}): AbortSignal | undefined {
const signals: AbortSignal[] = [];
if (params.signal) signals.push(params.signal);
if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) {
signals.push(AbortSignal.timeout(params.timeoutMs));
}
if (!signals.length) return undefined;
if (signals.length === 1) return signals[0];
return AbortSignal.any(signals);
}
/**
* **WecomHttpOptions (HTTP 选项)**
*
* @property proxyUrl 代理服务器地址
* @property timeoutMs 请求超时时间 (毫秒)
* @property signal AbortSignal 信号
*/
export type WecomHttpOptions = {
proxyUrl?: string;
timeoutMs?: number;
signal?: AbortSignal;
};
/**
* **wecomFetch (统一 HTTP 请求)**
*
* 基于 `undici` 的 fetch 封装,自动处理 ProxyAgent 和 Timeout。
* 所有对企业微信 API 的调用都应经过此函数。
*/
export async function wecomFetch(input: string | URL, init?: RequestInit, opts?: WecomHttpOptions): Promise<Response> {
const proxyUrl = opts?.proxyUrl?.trim() ?? "";
const dispatcher = proxyUrl ? getProxyDispatcher(proxyUrl) : undefined;
const initSignal = init?.signal ?? undefined;
const signal = mergeAbortSignal({ signal: opts?.signal ?? initSignal, timeoutMs: opts?.timeoutMs });
const nextInit: RequestInit & { dispatcher?: Dispatcher } = {
...(init ?? {}),
...(signal ? { signal } : {}),
...(dispatcher ? { dispatcher } : {}),
};
return undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
}
/**
* **readResponseBodyAsBuffer (读取响应 Body)**
*
* 将 Response Body 读取为 Buffer,支持最大字节限制以防止内存溢出。
* 适用于下载媒体文件等场景。
*/
export async function readResponseBodyAsBuffer(res: Response, maxBytes?: number): Promise<Buffer> {
if (!res.body) return Buffer.alloc(0);
const limit = maxBytes && Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : undefined;
const chunks: Uint8Array[] = [];
let total = 0;
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (limit && total > limit) {
try {
await reader.cancel("body too large");
} catch {
// ignore
}
throw new Error(`response body too large (>${limit} bytes)`);
}
chunks.push(value);
}
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { decryptWecomMedia } from "./media.js";
import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
import crypto from "node:crypto";
const { undiciFetch } = vi.hoisted(() => {
const undiciFetch = vi.fn();
return { undiciFetch };
});
vi.mock("undici", () => ({
fetch: undiciFetch,
ProxyAgent: class ProxyAgent { },
}));
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
const mod = buf.length % blockSize;
const pad = mod === 0 ? blockSize : blockSize - mod;
const padByte = Buffer.from([pad]);
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
}
describe("decryptWecomMedia", () => {
it("should download and decrypt media successfully", async () => {
// 1. Setup Key and Data
const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes when decoded + padding
const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
const iv = aesKey.subarray(0, 16);
const originalData = Buffer.from("Hello WeCom Image Data", "utf8");
// 2. Encrypt manually (AES-256-CBC + PKCS7)
const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
cipher.setAutoPadding(false);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
// 3. Mock HTTP fetch
undiciFetch.mockResolvedValue(new Response(encrypted));
// 4. Test
const decrypted = await decryptWecomMedia("http://mock.url/image", aesKeyBase64);
// 5. Assert
expect(decrypted.toString("utf8")).toBe("Hello WeCom Image Data");
expect(undiciFetch).toHaveBeenCalledWith(
"http://mock.url/image",
expect.objectContaining({ signal: expect.anything() }),
);
});
it("should fail if key is invalid", async () => {
await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
});
});
+55
View File
@@ -0,0 +1,55 @@
import crypto from "node:crypto";
import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
/**
* **decryptWecomMedia (解密企业微信媒体文件)**
*
* 简易封装:直接传入 URL 和 AES Key 下载并解密。
* 企业微信媒体文件使用与消息体相同的 AES-256-CBC 加密,IV 为 AES Key 前16字节。
* 解密后需移除 PKCS#7 填充。
*/
export async function decryptWecomMedia(url: string, encodingAESKey: string, maxBytes?: number): Promise<Buffer> {
return decryptWecomMediaWithHttp(url, encodingAESKey, { maxBytes });
}
/**
* **decryptWecomMediaWithHttp (解密企业微信媒体 - 高级)**
*
* 支持传递 HTTP 选项(如 Proxy、Timeout)。
* 流程:
* 1. 下载加密内容。
* 2. 准备 AES Key 和 IV。
* 3. AES-CBC 解密。
* 4. PKCS#7 去除填充。
*/
export async function decryptWecomMediaWithHttp(
url: string,
encodingAESKey: string,
params?: { maxBytes?: number; http?: WecomHttpOptions },
): Promise<Buffer> {
// 1. Download encrypted content
const res = await wecomFetch(url, undefined, { ...params?.http, timeoutMs: params?.http?.timeoutMs ?? 15_000 });
if (!res.ok) {
throw new Error(`failed to download media: ${res.status}`);
}
const encryptedData = await readResponseBodyAsBuffer(res, params?.maxBytes);
// 2. Prepare Key and IV
const aesKey = decodeEncodingAESKey(encodingAESKey);
const iv = aesKey.subarray(0, 16);
// 3. Decrypt
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
decipher.setAutoPadding(false); // We handle padding manually
const decryptedPadded = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
// 4. Unpad
// Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
// The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
// Our pkcs7Unpad function does exactly this + validation.
return pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
}
+239
View File
@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sendActiveMessage, handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
import * as cryptoHelpers from "./crypto.js";
import * as runtime from "./runtime.js";
import * as agentApi from "./agent/api-client.js";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import * as crypto from "node:crypto";
const { undiciFetch } = vi.hoisted(() => {
const undiciFetch = vi.fn();
return { undiciFetch };
});
vi.mock("undici", () => ({
fetch: undiciFetch,
ProxyAgent: class ProxyAgent { },
}));
vi.mock("./agent/api-client.js", () => ({
sendText: vi.fn(),
sendMedia: vi.fn(),
uploadMedia: vi.fn(),
}));
// Helpers
function createMockRequest(bodyObj: any): IncomingMessage {
const socket = new Socket();
const req = new IncomingMessage(socket);
req.method = "POST";
req.url = "/wecom?timestamp=123&nonce=456&signature=789";
req.push(JSON.stringify(bodyObj));
req.push(null);
return req;
}
function createMockResponse(): ServerResponse {
const req = new IncomingMessage(new Socket());
const res = new ServerResponse(req);
res.end = vi.fn() as any;
res.setHeader = vi.fn();
(res as any).statusCode = 200;
return res;
}
describe("Monitor Active Features", () => {
let capturedDeliver: ((payload: { text: string }) => Promise<void>) | undefined;
let mockCore: any;
let msgSeq = 0;
let senderUserId = "";
let senderChatId = "";
// Valid 32-byte AES Key (Base64 encoded)
const validKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa=";
beforeEach(() => {
vi.useFakeTimers();
capturedDeliver = undefined;
vi.restoreAllMocks();
undiciFetch.mockClear();
msgSeq += 1;
senderUserId = `zhangsan-${msgSeq}`;
senderChatId = `wr123-${msgSeq}`;
// Spy on crypto.randomBytes (default export in monitor.ts usage)
vi.spyOn(crypto.default, "randomBytes").mockImplementation((size) => {
return Buffer.alloc(size, 0x11);
});
// Mock Crypto Helpers
// Wespy on verifyWecomSignature to always pass
vi.spyOn(cryptoHelpers, "verifyWecomSignature").mockReturnValue(true);
// We spy on decryptWecomEncrypted to return our mock plaintext
// Note: For this to work despite direct import in monitor.ts, we rely on Vitest's
// module mocking capabilities or the fact that * exports might be live bindings.
// If this fails, we will know.
vi.spyOn(cryptoHelpers, "decryptWecomEncrypted").mockImplementation((opts) => {
return JSON.stringify({
msgid: `test-msg-id-${msgSeq}`,
aibotid: "bot-1",
chattype: "group",
chatid: senderChatId,
from: { userid: senderUserId },
msgtype: "text",
text: { content: "hello" },
response_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key"
});
});
mockCore = {
channel: {
text: {
resolveMarkdownTableMode: () => "off",
convertMarkdownTables: (t: string) => t.replace(/\|/g, "-")
},
commands: {
shouldComputeCommandAuthorized: () => false,
resolveCommandAuthorizedFromAuthorizers: () => true,
},
pairing: {
readAllowFromStore: async () => [],
},
reply: {
finalizeInboundContext: (c: any) => c,
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: () => "",
dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => {
capturedDeliver = opts.dispatcherOptions.deliver;
return;
}
},
routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "1" }) },
session: {
resolveStorePath: () => "",
readSessionUpdatedAt: () => 0,
recordInboundSession: vi.fn()
}
},
logging: { shouldLogVerbose: () => false }
};
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore);
registerWecomWebhookTarget({
account: { accountId: "1", enabled: true, configured: true, token: "T", encodingAESKey: validKey, receiveId: "R", config: {} as any },
config: {
channels: {
wecom: {
enabled: true,
agent: {
corpId: "corp",
corpSecret: "secret",
agentId: 1000002,
token: "token",
encodingAESKey: "aes",
},
},
},
} as any,
runtime: { log: () => { } },
core: mockCore,
path: "/wecom"
});
});
afterEach(() => {
vi.useRealTimers();
});
it("should protect <think> tags from table conversion", async () => {
const req = createMockRequest({ encrypt: "mock-encrypt" });
const res = createMockResponse();
await handleWecomWebhookRequest(req, res);
// The WeCom monitor debounces inbound messages before starting the agent.
// `flushPending` triggers async agent start without awaiting it, so give the
// microtask queue a chance to run after the timer fires.
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
await Promise.resolve();
expect(capturedDeliver).toBeDefined();
const payload = { text: "Out | side\n<think>Inside | Think</think>" };
const convertSpy = vi.spyOn(mockCore.channel.text, "convertMarkdownTables");
await capturedDeliver!(payload);
const calledArg = convertSpy.mock.calls[0][0];
expect(calledArg).toContain("__THINK_PLACEHOLDER_0__");
expect(calledArg).not.toContain("<think>");
});
it("should store response_url and allow active message sending", async () => {
const req = createMockRequest({ encrypt: "mock-encrypt" });
const res = createMockResponse();
// We use a real key but mocked randomBytes.
// However, `handleWecomWebhookRequest` calls `buildEncryptedJsonReply` -> `encryptWecomPlaintext`.
// `encryptWecomPlaintext` uses the key. Since it's valid, it should work fine.
// We don't verify the OUTPUT of handleWecomWebhookRequest, just that it runs and sets up state.
await handleWecomWebhookRequest(req, res);
const streamId = Buffer.alloc(16, 0x11).toString("hex");
undiciFetch.mockResolvedValue(new Response("ok", { status: 200 }));
await sendActiveMessage(streamId, "Active Hello");
expect(undiciFetch).toHaveBeenCalledWith(
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ "Content-Type": "application/json" }),
body: JSON.stringify({ msgtype: "text", text: { content: "Active Hello" } }),
}),
);
});
it("should fallback non-image media to agent DM (and push a Chinese prompt)", async () => {
const { uploadMedia, sendMedia } = agentApi as any;
uploadMedia.mockResolvedValue("media-id-1");
sendMedia.mockResolvedValue(undefined);
const req = createMockRequest({ encrypt: "mock-encrypt" });
const res = createMockResponse();
await handleWecomWebhookRequest(req, res);
await vi.advanceTimersByTimeAsync(600);
await Promise.resolve();
await Promise.resolve();
expect(capturedDeliver).toBeDefined();
// Create a local PDF to force non-image content-type inference.
const fs = await import("node:fs/promises");
const os = await import("node:os");
const path = await import("node:path");
const tmp = path.join(os.tmpdir(), `wecom-test-${Date.now()}.pdf`);
await fs.writeFile(tmp, Buffer.from("pdf"));
undiciFetch.mockResolvedValue(new Response("ok", { status: 200 }));
await capturedDeliver!({ text: "here", mediaUrls: [tmp] } as any);
expect(uploadMedia).toHaveBeenCalled();
expect(sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
toUser: senderUserId,
mediaType: "file",
}),
);
// Ensure we attempted to push a prompt to response_url (uses undici fetch).
expect(undiciFetch).toHaveBeenCalled();
});
// 注:本机路径(/Users/... 或 /tmp/...)短路发图逻辑属于运行态特性,
// 单测在 fake timers + module singleton 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
import { encryptWecomPlaintext, computeWecomMsgSignature, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
import * as runtime from "./runtime.js";
import crypto from "node:crypto";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
const { undiciFetch } = vi.hoisted(() => {
const undiciFetch = vi.fn();
return { undiciFetch };
});
vi.mock("undici", () => ({
fetch: undiciFetch,
ProxyAgent: class ProxyAgent { },
}));
// Helpers to simulate HTTP request
function createMockRequest(bodyObj: any, query: URLSearchParams): IncomingMessage {
const socket = new Socket();
const req = new IncomingMessage(socket);
req.method = "POST";
req.url = `/wecom?${query.toString()}`;
req.push(JSON.stringify(bodyObj));
req.push(null);
return req;
}
function createMockResponse(): ServerResponse & { _getData: () => string, _getStatusCode: () => number } {
const req = new IncomingMessage(new Socket());
const res = new ServerResponse(req);
let data = "";
res.write = (chunk: any) => { data += chunk; return true; };
res.end = (chunk: any) => { if (chunk) data += chunk; return res; };
(res as any)._getData = () => data;
(res as any)._getStatusCode = () => res.statusCode;
return res as any;
}
// PKCS7 Pad Helper for manual encryption
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
const mod = buf.length % blockSize;
const pad = mod === 0 ? blockSize : blockSize - mod;
const padByte = Buffer.from([pad]);
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
}
describe("Monitor Integration: Inbound Image", () => {
const token = "MY_TOKEN";
const encodingAESKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes key
const receiveId = "MY_CORPID";
let unregisterTarget: (() => void) | null = null;
// Mock Core Runtime
const mockDeliver = vi.fn();
const mockCore = {
channel: {
routing: { resolveAgentRoute: () => ({ agentId: "agent-1", sessionKey: "sess-1", accountId: "acc-1" }) },
commands: {
shouldComputeCommandAuthorized: () => false,
resolveCommandAuthorizedFromAuthorizers: () => true,
},
pairing: {
readAllowFromStore: async () => [],
},
session: {
resolveStorePath: () => "store/path",
readSessionUpdatedAt: () => 0,
recordInboundSession: vi.fn(),
},
reply: {
formatAgentEnvelope: () => "formatted-body",
finalizeInboundContext: (ctx: any) => ctx,
resolveEnvelopeFormatOptions: () => ({}),
dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => {
// Simulate Agent processing by calling deliver immediately or later
// For this test, verifying the Inbound Body is enough.
// The delivery payload is what the AGENT sees.
// But wait, dispatchReply... is for OUTBOUND streaming replies.
// startAgentForStream calls it.
// We really want to spy on what `rawBody` was passed to startAgentForStream context.
// Actually `recordInboundSession` receives `ctx` which contains `RawBody`.
return;
},
},
text: { resolveMarkdownTableMode: () => "off", convertMarkdownTables: (t: string) => t },
},
logging: { shouldLogVerbose: () => true },
};
beforeEach(() => {
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
unregisterTarget?.();
unregisterTarget = registerWecomWebhookTarget({
account: {
accountId: "test-acc",
name: "Test",
enabled: true,
configured: true,
token,
encodingAESKey,
receiveId,
config: {} as any
},
config: {} as any,
runtime: { log: console.log, error: console.error },
core: mockCore as any,
path: "/wecom"
});
});
afterEach(() => {
unregisterTarget?.();
unregisterTarget = null;
vi.restoreAllMocks();
});
// Mock media saving
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ path: "/tmp/saved-image.jpg", contentType: "image/jpeg" });
(mockCore.channel as any).media = { saveMediaBuffer: mockSaveMediaBuffer };
it("should decrypt inbound image, save it, and inject into context", async () => {
// 1. Prepare Encrypted Media (The "File" on WeCom Server)
const fileContent = Buffer.from("fake-image-data");
const aesKey = Buffer.from(encodingAESKey + "=", "base64");
const iv = aesKey.subarray(0, 16);
// Encrypt content (WeCom does this)
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
cipher.setAutoPadding(false);
const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
// Mock HTTP fetch to return this encrypted media
undiciFetch.mockResolvedValue(new Response(encryptedMedia));
// 2. Prepare Inbound Message (The Webhook JSON)
const imageUrl = "http://wecom.server/media/123";
const inboundMsg = {
msgtype: "image",
image: { url: imageUrl },
from: { userid: "testuser" }
};
// 3. Encrypt the *Inbound Message* Payload (The Envelope)
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = "123456";
const encrypt = encryptWecomPlaintext({
encodingAESKey,
receiveId,
plaintext: JSON.stringify(inboundMsg)
});
const msgSignature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
const query = new URLSearchParams({
msg_signature: msgSignature,
timestamp,
nonce
});
const bodyObj = {
touser: receiveId,
agentid: "10001",
encrypt, // Standard WeCom POST body structure
};
// 4. Send Request
const req = createMockRequest(bodyObj, query);
const res = createMockResponse();
await handleWecomWebhookRequest(req, res);
// Wait for debounce timer to trigger agent (DEFAULT_DEBOUNCE_MS = 500ms)
await new Promise(resolve => setTimeout(resolve, 600));
// 5. Verify
// Check recordInboundSession was called with correct RawBody and Media Context
expect(mockCore.channel.session.recordInboundSession).toHaveBeenCalled();
const recordCall = (mockCore.channel.session.recordInboundSession as any).mock.calls[0][0];
const ctx = recordCall.ctx;
// Expect: [image]
expect(ctx.RawBody).toBe("[image]");
// Expect media to be saved
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer), // The decrypted buffer
"image/jpeg",
"inbound",
expect.any(Number), // maxBytes
"image.jpg"
);
const savedBuffer = mockSaveMediaBuffer.mock.calls[0][0];
expect(savedBuffer.toString()).toBe("fake-image-data");
// Expect Context Injection
expect(ctx.MediaPath).toBe("/tmp/saved-image.jpg");
expect(ctx.MediaType).toBe("image/jpeg");
expect(undiciFetch).toHaveBeenCalledWith(
imageUrl,
expect.objectContaining({ signal: expect.anything() }),
);
});
});
+1802
View File
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type { ResolvedWecomAccount } from "./types.js";
import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js";
import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
function createMockRequest(params: {
method: "GET" | "POST";
url: string;
body?: unknown;
}): IncomingMessage {
const socket = new Socket();
const req = new IncomingMessage(socket);
req.method = params.method;
req.url = params.url;
if (params.method === "POST") {
req.push(JSON.stringify(params.body ?? {}));
}
req.push(null);
return req;
}
function createMockResponse(): ServerResponse & {
_getData: () => string;
_getStatusCode: () => number;
} {
const req = new IncomingMessage(new Socket());
const res = new ServerResponse(req);
let data = "";
res.write = (chunk: any) => {
data += String(chunk);
return true;
};
res.end = (chunk: any) => {
if (chunk) data += String(chunk);
return res;
};
(res as any)._getData = () => data;
(res as any)._getStatusCode = () => res.statusCode;
return res as any;
}
describe("handleWecomWebhookRequest", () => {
const token = "test-token";
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
it("handles GET url verification", async () => {
const account: ResolvedWecomAccount = {
accountId: "default",
name: "Test",
enabled: true,
configured: true,
token,
encodingAESKey,
receiveId: "",
config: { webhookPath: "/hook", token, encodingAESKey },
};
const unregister = registerWecomWebhookTarget({
account,
config: {} as OpenClawConfig,
runtime: {},
core: {} as any,
path: "/hook",
});
try {
const timestamp = "13500001234";
const nonce = "123412323";
const echostr = encryptWecomPlaintext({
encodingAESKey,
receiveId: "",
plaintext: "ping",
});
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr });
const req = createMockRequest({
method: "GET",
url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}&timestamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`,
});
const res = createMockResponse();
const handled = await handleWecomWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res._getStatusCode()).toBe(200);
expect(res._getData()).toBe("ping");
} finally {
unregister();
}
});
it("handles POST callback and returns encrypted stream placeholder", async () => {
const account: ResolvedWecomAccount = {
accountId: "default",
name: "Test",
enabled: true,
configured: true,
token,
encodingAESKey,
receiveId: "",
config: { webhookPath: "/hook", token, encodingAESKey },
};
const unregister = registerWecomWebhookTarget({
account,
config: {} as OpenClawConfig,
runtime: {},
core: {} as any,
path: "/hook",
});
try {
const timestamp = "1700000000";
const nonce = "nonce";
const plain = JSON.stringify({
msgid: "MSGID",
aibotid: "AIBOTID",
chattype: "single",
from: { userid: "USERID" },
response_url: "RESPONSEURL",
msgtype: "text",
text: { content: "hello" },
});
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
const req = createMockRequest({
method: "POST",
url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}&timestamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
body: { encrypt },
});
const res = createMockResponse();
const handled = await handleWecomWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res._getStatusCode()).toBe(200);
const json = JSON.parse(res._getData()) as any;
expect(typeof json.encrypt).toBe("string");
expect(typeof json.msgsignature).toBe("string");
expect(typeof json.timestamp).toBe("string");
expect(typeof json.nonce).toBe("string");
const replyPlain = decryptWecomEncrypted({
encodingAESKey,
receiveId: "",
encrypt: json.encrypt,
});
const reply = JSON.parse(replyPlain) as any;
expect(reply.msgtype).toBe("stream");
expect(reply.stream?.content).toBe("1");
expect(reply.stream?.finish).toBe(false);
expect(typeof reply.stream?.id).toBe("string");
expect(reply.stream?.id.length).toBeGreaterThan(0);
const expectedSig = computeWecomMsgSignature({
token,
timestamp: String(json.timestamp),
nonce: String(json.nonce),
encrypt: String(json.encrypt),
});
expect(json.msgsignature).toBe(expectedSig);
} finally {
unregister();
}
});
it("supports custom streamPlaceholderContent", async () => {
const account: ResolvedWecomAccount = {
accountId: "default",
name: "Test",
enabled: true,
configured: true,
token,
encodingAESKey,
receiveId: "",
config: { webhookPath: "/hook", token, encodingAESKey, streamPlaceholderContent: "正在思考..." },
};
const unregister = registerWecomWebhookTarget({
account,
config: {} as OpenClawConfig,
runtime: {},
core: {} as any,
path: "/hook",
});
try {
const timestamp = "1700000001";
const nonce = "nonce2";
const plain = JSON.stringify({
msgid: "MSGID2",
aibotid: "AIBOTID",
chattype: "single",
from: { userid: "USERID2" },
response_url: "RESPONSEURL",
msgtype: "text",
text: { content: "hello" },
});
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
const req = createMockRequest({
method: "POST",
url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}&timestamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
body: { encrypt },
});
const res = createMockResponse();
const handled = await handleWecomWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res._getStatusCode()).toBe(200);
const json = JSON.parse(res._getData()) as any;
const replyPlain = decryptWecomEncrypted({
encodingAESKey,
receiveId: "",
encrypt: json.encrypt,
});
const reply = JSON.parse(replyPlain) as any;
expect(reply.msgtype).toBe("stream");
expect(reply.stream?.content).toBe("正在思考...");
expect(reply.stream?.finish).toBe(false);
} finally {
unregister();
}
});
it("returns a queued stream for 2, and an ack stream for merged follow-ups", async () => {
const token = "TOKEN";
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
const account: any = {
accountId: "default",
enabled: true,
configured: true,
token,
encodingAESKey,
receiveId: "",
config: {
streamPlaceholderContent: "正在思考...",
debounceMs: 10_000,
},
};
const unregister = registerWecomWebhookTarget({
account,
config: {} as OpenClawConfig,
runtime: {},
core: {} as any,
path: "/hook-merge",
});
try {
const timestamp = "1700000002";
const nonce = "nonce-merge";
const makeReq = (msgid: string) => {
const plain = JSON.stringify({
msgid,
aibotid: "AIBOTID",
chattype: "single",
from: { userid: "USERID_QUEUE" },
response_url: "RESPONSEURL",
msgtype: "text",
text: { content: "hello" },
});
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
return createMockRequest({
method: "POST",
url: `/hook-merge?msg_signature=${encodeURIComponent(msg_signature)}&timestamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
body: { encrypt },
});
};
const res1 = createMockResponse();
await handleWecomWebhookRequest(makeReq("MSGID-M1"), res1);
const json1 = JSON.parse(res1._getData()) as any;
const replyPlain1 = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json1.encrypt });
const reply1 = JSON.parse(replyPlain1) as any;
expect(reply1.msgtype).toBe("stream");
expect(reply1.stream?.finish).toBe(false);
const res2 = createMockResponse();
await handleWecomWebhookRequest(makeReq("MSGID-M2"), res2);
const json2 = JSON.parse(res2._getData()) as any;
const replyPlain2 = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json2.encrypt });
const reply2 = JSON.parse(replyPlain2) as any;
expect(reply2.msgtype).toBe("stream");
expect(reply2.stream?.finish).toBe(false);
expect(reply2.stream?.id).not.toBe(reply1.stream?.id);
expect(reply2.stream?.content).toContain("排队");
const res3 = createMockResponse();
await handleWecomWebhookRequest(makeReq("MSGID-M3"), res3);
const json3 = JSON.parse(res3._getData()) as any;
const replyPlain3 = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt: json3.encrypt });
const reply3 = JSON.parse(replyPlain3) as any;
expect(reply3.msgtype).toBe("stream");
// merged follow-up should get its own ack stream (not finished yet);
// it will be updated to a final hint after the merged batch completes.
expect(reply3.stream?.finish).toBe(false);
expect(reply3.stream?.id).not.toBe(reply1.stream?.id);
expect(reply3.stream?.id).not.toBe(reply2.stream?.id);
expect(reply3.stream?.content).toContain("合并");
} finally {
unregister();
}
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, test, vi } from "vitest";
import type { WecomInboundMessage } from "../types.js";
import type { WecomWebhookTarget } from "./types.js";
import { StreamStore } from "./state.js";
describe("wecom StreamStore queue", () => {
test("does not merge into active batch; flushes queued batch after active finishes", async () => {
vi.useFakeTimers();
try {
const store = new StreamStore();
const flushed: string[] = [];
store.setFlushHandler((pending) => flushed.push(pending.streamId));
const target = {
account: {} as any,
config: {} as any,
runtime: {},
core: {} as any,
path: "/wecom",
} satisfies WecomWebhookTarget;
const conversationKey = "wecom:default:U:C";
const msg1 = { msgid: "M1" } satisfies WecomInboundMessage;
const msg2 = { msgid: "M2" } satisfies WecomInboundMessage;
const r1 = store.addPendingMessage({
conversationKey,
target,
msg: msg1,
msgContent: "1",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
const r2 = store.addPendingMessage({
conversationKey,
target,
msg: msg2,
msgContent: "2",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
expect(r1.status).toBe("active_new");
// 初始批次不接收合并:第二条进入 queued
expect(r2.status).toBe("queued_new");
expect(r2.streamId).not.toBe(r1.streamId);
// Follow-ups within queued should merge into queued (status queued_merged).
const r3 = store.addPendingMessage({
conversationKey,
target,
msg: { msgid: "M3" } as any,
msgContent: "3",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
expect(r3.status).toBe("queued_merged");
expect(r3.streamId).toBe(r2.streamId);
// Active batch flushes at debounce time.
await vi.advanceTimersByTimeAsync(11);
expect(flushed).toEqual([r1.streamId]);
// Queued batch timer also fires, but cannot flush until active finishes.
await vi.advanceTimersByTimeAsync(11);
expect(flushed).toEqual([r1.streamId]);
// Once the active stream finishes, queued batch is promoted and flushes immediately.
store.onStreamFinished(r1.streamId);
expect(flushed).toEqual([r1.streamId, r2.streamId]);
} finally {
vi.useRealTimers();
}
});
test("merges into active batch when it has not started yet (even after promotion)", async () => {
vi.useFakeTimers();
try {
const store = new StreamStore();
const flushed: string[] = [];
store.setFlushHandler((pending) => flushed.push(pending.streamId));
const target = {
account: {} as any,
config: {} as any,
runtime: {},
core: {} as any,
path: "/wecom",
} satisfies WecomWebhookTarget;
const conversationKey = "wecom:default:U:C2";
// 1 becomes active and flushes; mark as started to simulate "processing started".
const r1 = store.addPendingMessage({
conversationKey,
target,
msg: { msgid: "M1" } as any,
msgContent: "1",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
store.markStarted(r1.streamId);
await vi.advanceTimersByTimeAsync(11);
expect(flushed).toEqual([r1.streamId]);
// 2 enters queued with a longer debounce; it should NOT become readyToFlush yet.
const r2 = store.addPendingMessage({
conversationKey,
target,
msg: { msgid: "M2" } as any,
msgContent: "2",
nonce: "n",
timestamp: "t",
debounceMs: 100,
});
expect(flushed).toEqual([r1.streamId]);
// Finish 1, promote 2 to active (but do NOT flush immediately since it's not readyToFlush).
store.onStreamFinished(r1.streamId);
expect(flushed).toEqual([r1.streamId]);
// Now 2 is active, but (in real monitor) it may still be in debounce before markStarted.
// We simulate that by NOT calling markStarted. Follow-up should merge into active (same streamId).
const r3 = store.addPendingMessage({
conversationKey,
target,
msg: { msgid: "M3" } as any,
msgContent: "3",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
expect(r3.streamId).toBe(r2.streamId);
expect(r3.status).toBe("active_merged");
} finally {
vi.useRealTimers();
}
});
test("clears conversation state when idle so next message becomes active", async () => {
const store = new StreamStore();
store.setFlushHandler(() => { });
const target = {
account: {} as any,
config: {} as any,
runtime: {},
core: {} as any,
path: "/wecom",
} satisfies WecomWebhookTarget;
const conversationKey = "wecom:default:U:idle";
const r1 = store.addPendingMessage({
conversationKey,
target,
msg: { msgid: "M1" } as any,
msgContent: "1",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
store.markStarted(r1.streamId);
store.markFinished(r1.streamId);
store.onStreamFinished(r1.streamId);
const r2 = store.addPendingMessage({
conversationKey,
target,
msg: { msgid: "M2" } as any,
msgContent: "2",
nonce: "n",
timestamp: "t",
debounceMs: 10,
});
expect(r2.status).toBe("active_new");
expect(r2.streamId).not.toBe(r1.streamId);
});
});
+514
View File
@@ -0,0 +1,514 @@
import crypto from "node:crypto";
import type { StreamState, PendingInbound, ActiveReplyState, WecomWebhookTarget } from "./types.js";
import type { WecomInboundMessage } from "../types.js";
// Constants
export const LIMITS = {
STREAM_TTL_MS: 10 * 60 * 1000,
ACTIVE_REPLY_TTL_MS: 60 * 60 * 1000,
DEFAULT_DEBOUNCE_MS: 500,
STREAM_MAX_BYTES: 20_480,
REQUEST_TIMEOUT_MS: 15_000
};
/**
* **StreamStore (流状态会话存储)**
*
* 管理企业微信回调的流式会话状态、消息去重和防抖聚合逻辑。
* 负责维护 msgid 到 streamId 的映射,以及临时缓存待处理的 Pending 消息。
*/
export class StreamStore {
private streams = new Map<string, StreamState>();
private msgidToStreamId = new Map<string, string>();
private pendingInbounds = new Map<string, PendingInbound>();
private conversationState = new Map<string, { activeBatchKey: string; queue: string[]; nextSeq: number }>();
private streamIdToBatchKey = new Map<string, string>();
private batchStreamIdToAckStreamIds = new Map<string, string[]>();
private onFlush?: (pending: PendingInbound) => void;
/**
* **setFlushHandler (设置防抖刷新回调)**
*
* 当防抖计时器结束时调用的处理函数。通常用于触发 Agent 进行消息处理。
* @param handler 回调函数,接收聚合后的 PendingInbound 对象
*/
public setFlushHandler(handler: (pending: PendingInbound) => void) {
this.onFlush = handler;
}
/**
* **createStream (创建流会话)**
*
* 初始化一个新的流式会话状态。
* @param params.msgid (可选) 企业微信消息 ID,用于后续去重映射
* @returns 生成的 streamId (Hex 字符串)
*/
createStream(params: { msgid?: string; conversationKey?: string; batchKey?: string }): string {
const streamId = crypto.randomBytes(16).toString("hex");
if (params.msgid) {
this.msgidToStreamId.set(String(params.msgid), streamId);
}
this.streams.set(streamId, {
streamId,
msgid: params.msgid,
conversationKey: params.conversationKey,
batchKey: params.batchKey,
createdAt: Date.now(),
updatedAt: Date.now(),
started: false,
finished: false,
content: ""
});
if (params.batchKey) {
this.streamIdToBatchKey.set(streamId, params.batchKey);
}
return streamId;
}
/**
* **getStream (获取流状态)**
*
* 根据 streamId 获取当前的会话状态。
* @param streamId 流会话 ID
*/
getStream(streamId: string): StreamState | undefined {
return this.streams.get(streamId);
}
/**
* **getStreamByMsgId (通过 msgid 查找流 ID)**
*
* 用于消息去重:检查该 msgid 是否已经关联由正在进行或已完成的流会话。
* @param msgid 企业微信消息 ID
*/
getStreamByMsgId(msgid: string): string | undefined {
return this.msgidToStreamId.get(String(msgid));
}
setStreamIdForMsgId(msgid: string, streamId: string): void {
const key = String(msgid).trim();
const value = String(streamId).trim();
if (!key || !value) return;
this.msgidToStreamId.set(key, value);
}
/**
* 将“回执流”(ack stream) 关联到某个“批次流”(batch stream)。
* 用于:当用户连发多条消息被合并排队时,让后续消息的 stream 最终也能更新为可理解的提示,而不是永久停留在“已合并排队…”。
*/
addAckStreamForBatch(params: { batchStreamId: string; ackStreamId: string }): void {
const batchStreamId = params.batchStreamId.trim();
const ackStreamId = params.ackStreamId.trim();
if (!batchStreamId || !ackStreamId) return;
const list = this.batchStreamIdToAckStreamIds.get(batchStreamId) ?? [];
list.push(ackStreamId);
this.batchStreamIdToAckStreamIds.set(batchStreamId, list);
}
/**
* 取出并清空某个批次流关联的所有回执流。
*/
drainAckStreamsForBatch(batchStreamId: string): string[] {
const key = batchStreamId.trim();
if (!key) return [];
const list = this.batchStreamIdToAckStreamIds.get(key) ?? [];
this.batchStreamIdToAckStreamIds.delete(key);
return list;
}
/**
* **updateStream (更新流状态)**
*
* 原子更新流状态,并自动刷新 updatedAt 时间戳。
* @param streamId 流会话 ID
* @param mutator 状态修改函数
*/
updateStream(streamId: string, mutator: (state: StreamState) => void): void {
const state = this.streams.get(streamId);
if (state) {
mutator(state);
state.updatedAt = Date.now();
}
}
/**
* **markStarted (标记流开始)**
*
* 标记该流会话已经开始处理(通常在 Agent 启动后调用)。
*/
markStarted(streamId: string): void {
this.updateStream(streamId, (s) => { s.started = true; });
}
/**
* **markFinished (标记流结束)**
*
* 标记该流会话已完成,不再接收内容更新。
*/
markFinished(streamId: string): void {
this.updateStream(streamId, (s) => { s.finished = true; });
}
/**
* **addPendingMessage (添加待处理消息 / 防抖聚合)**
*
* 将收到的消息加入待处理队列。如果相同 pendingKey 已存在,则是防抖聚合;否则创建新条目。
* 会自动设置或重置防抖定时器。
*
* @param params 消息参数
* @returns { streamId, isNew } isNew=true 表示这是新的一组消息,需初始化 ActiveReply
*/
addPendingMessage(params: {
conversationKey: string;
target: WecomWebhookTarget;
msg: WecomInboundMessage;
msgContent: string;
nonce: string;
timestamp: string;
debounceMs?: number;
}): { streamId: string; status: "active_new" | "active_merged" | "queued_new" | "queued_merged" } {
const { conversationKey, target, msg, msgContent, nonce, timestamp, debounceMs } = params;
const effectiveDebounceMs = debounceMs ?? LIMITS.DEFAULT_DEBOUNCE_MS;
const state = this.conversationState.get(conversationKey);
if (!state) {
// 第一批次(active)
const batchKey = conversationKey;
const streamId = this.createStream({ msgid: msg.msgid, conversationKey, batchKey });
const pending: PendingInbound = {
streamId,
conversationKey,
batchKey,
target,
msg,
contents: [msgContent],
msgids: msg.msgid ? [msg.msgid] : [],
nonce,
timestamp,
createdAt: Date.now(),
timeout: setTimeout(() => {
this.requestFlush(batchKey);
}, effectiveDebounceMs)
};
this.pendingInbounds.set(batchKey, pending);
this.conversationState.set(conversationKey, { activeBatchKey: batchKey, queue: [], nextSeq: 1 });
return { streamId, status: "active_new" };
}
// 合并规则(排队语义):
// - 初始批次(batchKey===conversationKey)不接收合并:避免 1/2 都刷出同一份最终答案。
// - 如果 active 批次是“排队批次”(batchKey!=conversationKey)且还没开始处理(started=false),
// 则允许把后续消息合并进该 active 批次(典型:1 很快结束,2 变 active 但还没开始跑,3 合并到 2)。
const activeBatchKey = state.activeBatchKey;
const activeIsInitial = activeBatchKey === conversationKey;
const activePending = this.pendingInbounds.get(activeBatchKey);
if (activePending && !activeIsInitial) {
const activeStream = this.streams.get(activePending.streamId);
const activeStarted = Boolean(activeStream?.started);
if (!activeStarted) {
activePending.contents.push(msgContent);
if (msg.msgid) {
activePending.msgids.push(msg.msgid);
// 注意:不把该 msgid 映射到 active streamId(避免该消息最终也刷出同一份完整答案)
}
if (activePending.timeout) clearTimeout(activePending.timeout);
activePending.timeout = setTimeout(() => {
this.requestFlush(activeBatchKey);
}, effectiveDebounceMs);
return { streamId: activePending.streamId, status: "active_merged" };
}
}
// active 批次已经开始处理;后续消息进入队列批次(queued),并允许在队列批次内做防抖聚合。
const queuedBatchKey = state.queue[0];
if (queuedBatchKey) {
const existingQueued = this.pendingInbounds.get(queuedBatchKey);
if (existingQueued) {
existingQueued.contents.push(msgContent);
if (msg.msgid) {
existingQueued.msgids.push(msg.msgid);
// 注意:不把该 msgid 映射到 queued streamId(避免该消息最终也刷出同一份完整答案)
}
if (existingQueued.timeout) clearTimeout(existingQueued.timeout);
existingQueued.timeout = setTimeout(() => {
this.requestFlush(queuedBatchKey);
}, effectiveDebounceMs);
return { streamId: existingQueued.streamId, status: "queued_merged" };
}
}
// 创建新的 queued 批次(会话只保留 1 个“下一批次”,后续消息继续合并到该批次)
const seq = state.nextSeq++;
const batchKey = `${conversationKey}#q${seq}`;
state.queue = [batchKey];
const streamId = this.createStream({ msgid: msg.msgid, conversationKey, batchKey });
const pending: PendingInbound = {
streamId,
conversationKey,
batchKey,
target,
msg,
contents: [msgContent],
msgids: msg.msgid ? [msg.msgid] : [],
nonce,
timestamp,
createdAt: Date.now(),
timeout: setTimeout(() => {
this.requestFlush(batchKey);
}, effectiveDebounceMs)
};
this.pendingInbounds.set(batchKey, pending);
this.conversationState.set(conversationKey, state);
return { streamId, status: "queued_new" };
}
/**
* 请求刷新:如果该批次当前为 active,则立即 flush;否则标记 ready,等待前序批次完成后再 flush。
*/
private requestFlush(batchKey: string): void {
const pending = this.pendingInbounds.get(batchKey);
if (!pending) return;
const state = this.conversationState.get(pending.conversationKey);
const isActive = state?.activeBatchKey === batchKey;
if (!isActive) {
if (pending.timeout) {
clearTimeout(pending.timeout);
pending.timeout = null;
}
pending.readyToFlush = true;
return;
}
this.flushPending(batchKey);
}
/**
* **flushPending (触发消息处理)**
*
* 内部方法:防抖时间结束后,将聚合的消息一次性推送给 flushHandler。
*/
private flushPending(pendingKey: string): void {
const pending = this.pendingInbounds.get(pendingKey);
if (!pending) return;
this.pendingInbounds.delete(pendingKey);
if (pending.timeout) {
clearTimeout(pending.timeout);
pending.timeout = null;
}
pending.readyToFlush = false;
if (this.onFlush) {
this.onFlush(pending);
}
}
/**
* 在一个 stream 完成后推进会话队列:将 queued 批次提升为 active,并在需要时触发 flush。
*/
onStreamFinished(streamId: string): void {
const batchKey = this.streamIdToBatchKey.get(streamId);
const state = batchKey ? this.streams.get(streamId) : undefined;
const conversationKey = state?.conversationKey;
if (!batchKey || !conversationKey) return;
const conv = this.conversationState.get(conversationKey);
if (!conv) return;
if (conv.activeBatchKey !== batchKey) return;
const next = conv.queue.shift();
if (!next) {
// 队列为空:会话已空闲。删除状态,避免后续消息被误判为“排队但永远不触发”。
this.conversationState.delete(conversationKey);
return;
}
conv.activeBatchKey = next;
this.conversationState.set(conversationKey, conv);
const pending = this.pendingInbounds.get(next);
if (!pending) return;
if (pending.readyToFlush) {
this.flushPending(next);
}
// 否则等待该批次自己的 debounce timer 到期后 requestFlush(next) 执行
}
/**
* **prune (清理过期状态)**
*
* 清理过期的流会话、msgid 映射以及残留的 Pending 消息。
* @param now 当前时间戳 (毫秒)
*/
prune(now: number = Date.now()): void {
const streamCutoff = now - LIMITS.STREAM_TTL_MS;
// 清理过期的流会话
for (const [id, state] of this.streams.entries()) {
if (state.updatedAt < streamCutoff) {
this.streams.delete(id);
if (state.msgid) {
// 如果 msgid 映射仍指向该 stream,则一并移除
if (this.msgidToStreamId.get(state.msgid) === id) {
this.msgidToStreamId.delete(state.msgid);
}
}
}
}
// 清理悬空的 msgid 映射 (Double check)
for (const [msgid, id] of this.msgidToStreamId.entries()) {
if (!this.streams.has(id)) {
this.msgidToStreamId.delete(msgid);
}
}
// 清理超时的 Pending 消息 (通常由 timeout 清理,此处作为兜底)
for (const [key, pending] of this.pendingInbounds.entries()) {
if (now - pending.createdAt > LIMITS.STREAM_TTL_MS) {
if (pending.timeout) clearTimeout(pending.timeout);
this.pendingInbounds.delete(key);
}
}
// 清理 conversationState:active 已不存在且队列为空的会话
for (const [convKey, conv] of this.conversationState.entries()) {
const activeExists = this.pendingInbounds.has(conv.activeBatchKey) || Array.from(this.streamIdToBatchKey.values()).includes(conv.activeBatchKey);
const hasQueue = conv.queue.length > 0;
if (!activeExists && !hasQueue) {
this.conversationState.delete(convKey);
}
}
}
}
/**
* **ActiveReplyStore (主动回复地址存储)**
*
* 管理企业微信回调中的 `response_url` (用于被动回复转主动推送) 和 `proxyUrl`。
* 支持 'once' (一次性) 或 'multi' (多次) 使用策略。
*/
export class ActiveReplyStore {
private activeReplies = new Map<string, ActiveReplyState>();
/**
* @param policy 使用策略: "once" (默认,销毁式) 或 "multi"
*/
constructor(private policy: "once" | "multi" = "once") { }
/**
* **store (存储回复地址)**
*
* 关联 streamId 与 response_url。
*/
store(streamId: string, responseUrl?: string, proxyUrl?: string): void {
const url = responseUrl?.trim();
if (!url) return;
this.activeReplies.set(streamId, { response_url: url, proxyUrl, createdAt: Date.now() });
}
/**
* **getUrl (获取回复地址)**
*
* 获取指定 streamId 关联的 response_url。
*/
getUrl(streamId: string): string | undefined {
return this.activeReplies.get(streamId)?.response_url;
}
/**
* **use (消耗回复地址)**
*
* 使用存储的 response_url 执行操作。
* - 如果策略是 "once",第二次调用会抛错。
* - 自动更新使用时间 (usedAt)。
*
* @param streamId 流会话 ID
* @param fn 执行函数,接收 { responseUrl, proxyUrl }
*/
async use(streamId: string, fn: (params: { responseUrl: string; proxyUrl?: string }) => Promise<void>): Promise<void> {
const state = this.activeReplies.get(streamId);
if (!state?.response_url) {
return; // 无 URL 可用,安全跳过
}
if (this.policy === "once" && state.usedAt) {
throw new Error(`response_url already used for stream ${streamId} (Policy: once)`);
}
try {
await fn({ responseUrl: state.response_url, proxyUrl: state.proxyUrl });
state.usedAt = Date.now();
} catch (err: unknown) {
state.lastError = err instanceof Error ? err.message : String(err);
throw err;
}
}
/**
* **prune (清理过期地址)**
*
* 清理超过 TTL 的 active reply 记录。
*/
prune(now: number = Date.now()): void {
const cutoff = now - LIMITS.ACTIVE_REPLY_TTL_MS;
for (const [id, state] of this.activeReplies.entries()) {
if (state.createdAt < cutoff) {
this.activeReplies.delete(id);
}
}
}
}
/**
* **MonitorState (全局监控状态容器)**
*
* 模块单例,统一管理 StreamStore 和 ActiveReplyStore 实例。
* 提供生命周期方法 (startPruning / stopPruning) 以自动清理过期数据。
*/
class MonitorState {
/** 主要的流状态存储 */
public readonly streamStore = new StreamStore();
/** 主动回复地址存储 */
public readonly activeReplyStore = new ActiveReplyStore("multi");
private pruneInterval?: NodeJS.Timeout;
/**
* **startPruning (启动自动清理)**
*
* 启动定时器,定期清理过期的流和回复地址。应在插件有活跃 Target 时调用。
* @param intervalMs 清理间隔 (默认 60s)
*/
public startPruning(intervalMs: number = 60_000): void {
if (this.pruneInterval) return;
this.pruneInterval = setInterval(() => {
const now = Date.now();
this.streamStore.prune(now);
this.activeReplyStore.prune(now);
}, intervalMs);
}
/**
* **stopPruning (停止自动清理)**
*
* 停止定时器。应在插件无活跃 Target 时调用以释放资源。
*/
public stopPruning(): void {
if (this.pruneInterval) {
clearInterval(this.pruneInterval);
this.pruneInterval = undefined;
}
}
}
/**
* **monitorState (全局单例)**
*
* 导出全局唯一的 MonitorState 实例,供整个应用共享状态。
*/
export const monitorState = new MonitorState();
+136
View File
@@ -0,0 +1,136 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import type { ResolvedBotAccount } from "../types/index.js";
import type { WecomInboundMessage } from "../types.js";
/**
* **WecomRuntimeEnv (运行时环境)**
*
* 包含基础的日志和错误报告接口,用于解耦对 PluginRuntime 的直接依赖。
*/
export type WecomRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
/**
* **WecomWebhookTarget (Webhook 目标上下文)**
*
* 描述一个注册的 Bot 接收端点。包含处理该端点所需的所有上下文信息。
*
* @property account 解析后的 Bot 账号信息 (Token, AESKey 等)
* @property config 插件全局配置
* @property runtime 运行时环境 (日志)
* @property core OpenClaw 插件核心运行时
* @property path 该 Target 注册的 Webhook 路径
* @property statusSink 用于上报最后收发消息时间的回调
*/
export type WecomWebhookTarget = {
account: ResolvedBotAccount;
config: OpenClawConfig;
runtime: WecomRuntimeEnv;
core: PluginRuntime;
path: string;
/** 反馈最后接收/发送时间 */
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
/**
* **StreamState (流式会话状态)**
*
* 记录一个流式请求的生命周期状态。
*
* @property streamId 唯一会话 ID
* @property msgid 关联的企业微信消息 ID (用于去重)
* @property createdAt 创建时间
* @property updatedAt 最后更新时间 (用于 Prune)
* @property started 是否已开始处理 (Agent 已介入)
* @property finished 是否已完成 (Agent 输出完毕或出错)
* @property error 错误信息 (如有)
* @property content 已积累的响应内容 (用于长轮询返回)
* @property images 过程中生成的图片 (Base64 + MD5)
*/
export type StreamState = {
streamId: string;
msgid?: string;
/** 会话键(同一人同一会话,用于队列/批次) */
conversationKey?: string;
/** 批次键(conversationKey + 批次序号) */
batchKey?: string;
/** 触发者 userid(用于 Agent 私信兜底) */
userId?: string;
/** 会话类型(用于群聊兜底逻辑) */
chatType?: "group" | "direct";
/** 群聊 chatid(用于日志/提示,不用于 Agent 发群) */
chatId?: string;
/** 智能机器人 aibotid(用于 taskKey 生成与日志) */
aibotid?: string;
/** Bot 回调幂等键(用于最终交付幂等) */
taskKey?: string;
createdAt: number;
updatedAt: number;
started: boolean;
finished: boolean;
error?: string;
content: string;
images?: { base64: string; md5: string }[];
/** 兜底模式(仅作为内部状态,不暴露给企微) */
fallbackMode?: "media" | "timeout" | "error";
/** 群内兜底提示是否已发送(用于防重复刷屏) */
fallbackPromptSentAt?: number;
/** Agent 私信最终交付是否已完成(用于防重复发送) */
finalDeliveredAt?: number;
/** 用于私信兜底的完整内容(不受 STREAM_MAX_BYTES 限制,但仍需上限保护) */
dmContent?: string;
/** 已通过 Agent 私信发送过的媒体标识(防重复发送附件) */
agentMediaKeys?: string[];
};
/**
* **PendingInbound (待处理/防抖消息)**
*
* 暂存在队列中的消息,等待防抖计时器结束进行聚合。
*
* @property streamId 预分配的流 ID
* @property target 目标 Webhook 上下文
* @property msg 原始消息对象 (如果聚合,通常指第一条)
* @property contents 聚合的消息内容列表
* @property media 附带的媒体文件 (如果有)
* @property msgids 聚合的所有消息 ID (用于去重)
* @property timeout 防抖定时器句柄
*/
export type PendingInbound = {
streamId: string;
conversationKey: string;
batchKey: string;
target: WecomWebhookTarget;
msg: WecomInboundMessage;
contents: string[];
media?: { buffer: Buffer; contentType: string; filename: string };
msgids: string[];
nonce: string;
timestamp: string;
timeout: ReturnType<typeof setTimeout> | null;
/** 已到达防抖截止时间,但因前序批次仍在处理中而暂存 */
readyToFlush?: boolean;
createdAt: number;
};
/**
* **ActiveReplyState (主动回复地址状态)**
*
* 存储企业微信回调中提供的 `response_url`,用于后续将流式响应转为主动推送(template_card)等。
*
* @property response_url 企业微信提供的回调回复 URL
* @property proxyUrl 如果配置了代理,存储代理地址
* @property createdAt 创建时间
* @property usedAt 使用时间 (仅当 policy="once" 时有意义)
* @property lastError 最后一次发送失败的错误信息
*/
export type ActiveReplyState = {
response_url: string;
proxyUrl?: string;
createdAt: number;
usedAt?: number;
lastError?: string;
};
+472
View File
@@ -0,0 +1,472 @@
/**
* WeCom 配置向导 (Onboarding)
* 支持 Bot、Agent 和双模式同时启动的交互式配置流程
*/
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
OpenClawConfig,
WizardPrompter,
} from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
import type { WecomConfig, WecomBotConfig, WecomAgentConfig, WecomDmConfig } from "./types/index.js";
const channel = "wecom" as const;
type WecomMode = "bot" | "agent" | "both";
// ============================================================
// 辅助函数
// ============================================================
function getWecomConfig(cfg: OpenClawConfig): WecomConfig | undefined {
return cfg.channels?.wecom as WecomConfig | undefined;
}
function setWecomEnabled(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig {
return {
...cfg,
channels: {
...cfg.channels,
wecom: {
...(cfg.channels?.wecom ?? {}),
enabled,
},
},
} as OpenClawConfig;
}
function setWecomBotConfig(cfg: OpenClawConfig, bot: WecomBotConfig): OpenClawConfig {
return {
...cfg,
channels: {
...cfg.channels,
wecom: {
...(cfg.channels?.wecom ?? {}),
enabled: true,
bot,
},
},
} as OpenClawConfig;
}
function setWecomAgentConfig(cfg: OpenClawConfig, agent: WecomAgentConfig): OpenClawConfig {
return {
...cfg,
channels: {
...cfg.channels,
wecom: {
...(cfg.channels?.wecom ?? {}),
enabled: true,
agent,
},
},
} as OpenClawConfig;
}
function setGatewayBindLan(cfg: OpenClawConfig): OpenClawConfig {
return {
...cfg,
gateway: {
...(cfg.gateway ?? {}),
bind: "lan",
},
} as OpenClawConfig;
}
function setWecomDmPolicy(
cfg: OpenClawConfig,
mode: "bot" | "agent",
dm: WecomDmConfig,
): OpenClawConfig {
const wecom = getWecomConfig(cfg) ?? {};
if (mode === "bot") {
return {
...cfg,
channels: {
...cfg.channels,
wecom: {
...wecom,
bot: {
...wecom.bot,
dm,
},
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
wecom: {
...wecom,
agent: {
...wecom.agent,
dm,
},
},
},
} as OpenClawConfig;
}
// ============================================================
// 欢迎与引导
// ============================================================
async function showWelcome(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"🚀 欢迎使用企业微信(WeCom)接入向导",
"本插件支持「智能体 Bot」与「自建应用 Agent」双模式并行。",
].join("\n"),
"WeCom 配置向导",
);
}
// ============================================================
// 模式选择
// ============================================================
async function promptMode(prompter: WizardPrompter): Promise<WecomMode> {
const choice = await prompter.select({
message: "请选择您要配置的接入模式:",
options: [
{
value: "bot",
label: "Bot 模式 (智能机器人)",
hint: "回调速度快,支持流式占位符,适合日常对话",
},
{
value: "agent",
label: "Agent 模式 (自建应用)",
hint: "功能最全,支持 API 主动推送、发送文件/视频、交互卡片",
},
{
value: "both",
label: "双模式 (Bot + Agent 同时启用)",
hint: "推荐:Bot 用于快速对话,Agent 用于主动推送和媒体发送",
},
],
initialValue: "both",
});
return choice as WecomMode;
}
// ============================================================
// Bot 模式配置
// ============================================================
async function configureBotMode(
cfg: OpenClawConfig,
prompter: WizardPrompter,
): Promise<OpenClawConfig> {
await prompter.note(
[
"正在配置 Bot 模式...",
"",
"💡 操作指南: 请在企微后台【管理工具 -> 智能机器人】开启 API 模式。",
"🔗 回调 URL: https://您的域名/wecom/bot",
"",
"请先在后台填入回调 URL,然后获取以下信息。",
].join("\n"),
"Bot 模式配置",
);
const token = String(
await prompter.text({
message: "请输入 Token:",
validate: (value: string | undefined) => (value?.trim() ? undefined : "Token 不能为空"),
}),
).trim();
const encodingAESKey = String(
await prompter.text({
message: "请输入 EncodingAESKey:",
validate: (value: string | undefined) => {
const v = value?.trim() ?? "";
if (!v) return "EncodingAESKey 不能为空";
if (v.length !== 43) return "EncodingAESKey 应为 43 个字符";
return undefined;
},
}),
).trim();
const streamPlaceholder = await prompter.text({
message: "流式占位符 (可选):",
placeholder: "正在思考...",
initialValue: "正在思考...",
});
const welcomeText = await prompter.text({
message: "欢迎语 (可选):",
placeholder: "你好!我是 AI 助手",
initialValue: "你好!我是 AI 助手",
});
const botConfig: WecomBotConfig = {
token,
encodingAESKey,
streamPlaceholderContent: streamPlaceholder?.trim() || undefined,
welcomeText: welcomeText?.trim() || undefined,
};
return setWecomBotConfig(cfg, botConfig);
}
// ============================================================
// Agent 模式配置
// ============================================================
async function configureAgentMode(
cfg: OpenClawConfig,
prompter: WizardPrompter,
): Promise<OpenClawConfig> {
await prompter.note(
[
"正在配置 Agent 模式...",
"",
"💡 操作指南: 请在企微后台【应用管理 -> 自建应用】创建应用。",
].join("\n"),
"Agent 模式配置",
);
const corpId = String(
await prompter.text({
message: "请输入 CorpID (企业ID):",
validate: (value: string | undefined) => (value?.trim() ? undefined : "CorpID 不能为空"),
}),
).trim();
const agentIdStr = String(
await prompter.text({
message: "请输入 AgentID (应用ID):",
validate: (value: string | undefined) => {
const v = value?.trim() ?? "";
if (!v) return "AgentID 不能为空";
if (!/^\d+$/.test(v)) return "AgentID 应为数字";
return undefined;
},
}),
).trim();
const agentId = Number(agentIdStr);
const corpSecret = String(
await prompter.text({
message: "请输入 Secret (应用密钥):",
validate: (value: string | undefined) => (value?.trim() ? undefined : "Secret 不能为空"),
}),
).trim();
await prompter.note(
[
"💡 操作指南: 请在自建应用详情页进入【接收消息 -> 设置API接收】。",
"🔗 回调 URL: https://您的域名/wecom/agent",
"",
"请先在后台填入回调 URL,然后获取以下信息。",
].join("\n"),
"回调配置",
);
const token = String(
await prompter.text({
message: "请输入 Token (回调令牌):",
validate: (value: string | undefined) => (value?.trim() ? undefined : "Token 不能为空"),
}),
).trim();
const encodingAESKey = String(
await prompter.text({
message: "请输入 EncodingAESKey (回调加密密钥):",
validate: (value: string | undefined) => {
const v = value?.trim() ?? "";
if (!v) return "EncodingAESKey 不能为空";
if (v.length !== 43) return "EncodingAESKey 应为 43 个字符";
return undefined;
},
}),
).trim();
const welcomeText = await prompter.text({
message: "欢迎语 (可选):",
placeholder: "欢迎使用智能助手",
initialValue: "欢迎使用智能助手",
});
const agentConfig: WecomAgentConfig = {
corpId,
corpSecret,
agentId,
token,
encodingAESKey,
welcomeText: welcomeText?.trim() || undefined,
};
return setWecomAgentConfig(cfg, agentConfig);
}
// ============================================================
// DM 策略配置
// ============================================================
async function promptDmPolicy(
cfg: OpenClawConfig,
prompter: WizardPrompter,
modes: ("bot" | "agent")[],
): Promise<OpenClawConfig> {
const policyChoice = await prompter.select({
message: "请选择私聊 (DM) 访问策略:",
options: [
{ value: "pairing", label: "配对模式", hint: "推荐:安全,未知用户需授权" },
{ value: "allowlist", label: "白名单模式", hint: "仅允许特定 UserID" },
{ value: "open", label: "开放模式", hint: "任何人可发起" },
{ value: "disabled", label: "禁用私聊", hint: "不接受私聊消息" },
],
initialValue: "pairing",
});
const policy = policyChoice as "pairing" | "allowlist" | "open" | "disabled";
let allowFrom: string[] | undefined;
if (policy === "allowlist") {
const allowFromStr = String(
await prompter.text({
message: "请输入白名单 UserID (多个用逗号分隔):",
placeholder: "user1,user2",
validate: (value: string | undefined) => (value?.trim() ? undefined : "请输入至少一个 UserID"),
}),
).trim();
allowFrom = allowFromStr.split(",").map((s) => s.trim()).filter(Boolean);
}
const dm: WecomDmConfig = { policy, allowFrom };
let result = cfg;
for (const mode of modes) {
result = setWecomDmPolicy(result, mode, dm);
}
return result;
}
// ============================================================
// 配置汇总
// ============================================================
async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter): Promise<void> {
const wecom = getWecomConfig(cfg);
const lines: string[] = ["✅ 配置已保存!", ""];
if (wecom?.bot?.token) {
lines.push("📱 Bot 模式: 已配置");
lines.push(` 回调 URL: https://您的域名/wecom/bot`);
}
if (wecom?.agent?.corpId) {
lines.push("🏢 Agent 模式: 已配置");
lines.push(` 回调 URL: https://您的域名/wecom/agent`);
}
lines.push("");
lines.push("⚠️ 请确保您已在企微后台填写了正确的回调 URL,");
lines.push(" 并点击了后台的『保存』按钮完成验证。");
await prompter.note(lines.join("\n"), "配置完成");
}
// ============================================================
// DM Policy Adapter
// ============================================================
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "WeCom",
channel,
policyKey: "channels.wecom.bot.dm.policy",
allowFromKey: "channels.wecom.bot.dm.allowFrom",
getCurrent: (cfg: OpenClawConfig) => {
const wecom = getWecomConfig(cfg);
return (wecom?.bot?.dm?.policy ?? "pairing") as "pairing";
},
setPolicy: (cfg: OpenClawConfig, policy: "pairing" | "allowlist" | "open" | "disabled") => {
return setWecomDmPolicy(cfg, "bot", { policy });
},
promptAllowFrom: async ({ cfg, prompter }: { cfg: OpenClawConfig; prompter: WizardPrompter }) => {
const allowFromStr = String(
await prompter.text({
message: "请输入白名单 UserID:",
validate: (value: string | undefined) => (value?.trim() ? undefined : "请输入 UserID"),
}),
).trim();
const allowFrom = allowFromStr.split(",").map((s) => s.trim()).filter(Boolean);
return setWecomDmPolicy(cfg, "bot", { policy: "allowlist", allowFrom });
},
};
// ============================================================
// Onboarding Adapter
// ============================================================
export const wecomOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }: { cfg: OpenClawConfig }) => {
const wecom = getWecomConfig(cfg);
const botConfigured = Boolean(wecom?.bot?.token && wecom?.bot?.encodingAESKey);
const agentConfigured = Boolean(
wecom?.agent?.corpId && wecom?.agent?.corpSecret && wecom?.agent?.agentId,
);
const configured = botConfigured || agentConfigured;
const statusParts: string[] = [];
if (botConfigured) statusParts.push("Bot ✓");
if (agentConfigured) statusParts.push("Agent ✓");
return {
channel,
configured,
statusLines: [
`WeCom: ${configured ? statusParts.join(" + ") : "需要配置"}`,
],
selectionHint: configured
? `configured · ${statusParts.join(" + ")}`
: "enterprise-ready · dual-mode",
quickstartScore: configured ? 1 : 8,
};
},
configure: async ({ cfg, prompter }: { cfg: OpenClawConfig; prompter: WizardPrompter }) => {
// 1. 欢迎
await showWelcome(prompter);
// 2. 模式选择
const mode = await promptMode(prompter);
let next = cfg;
const configuredModes: ("bot" | "agent")[] = [];
// 3. 配置 Bot
if (mode === "bot" || mode === "both") {
next = await configureBotMode(next, prompter);
configuredModes.push("bot");
}
// 4. 配置 Agent
if (mode === "agent" || mode === "both") {
next = await configureAgentMode(next, prompter);
configuredModes.push("agent");
}
// 5. DM 策略
next = await promptDmPolicy(next, prompter, configuredModes);
// 6. 启用通道
next = setWecomEnabled(next, true);
// 7. 设置 gateway.bind 为 lan(允许外部访问回调)
next = setGatewayBindLan(next);
// 8. 汇总
await showSummary(next, prompter);
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
};
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./agent/api-client.js", () => ({
sendText: vi.fn(),
sendMedia: vi.fn(),
uploadMedia: vi.fn(),
}));
describe("wecomOutbound", () => {
it("does not crash when called with core outbound params", async () => {
const { wecomOutbound } = await import("./outbound.js");
await expect(
wecomOutbound.sendMedia({
cfg: {},
to: "wr-test-chat",
text: "caption",
mediaUrl: "https://example.com/media.png",
} as any),
).rejects.toThrow(/Agent mode/i);
});
it("routes sendText to agent chatId/userid", async () => {
const { wecomOutbound } = await import("./outbound.js");
const api = await import("./agent/api-client.js");
const now = vi.spyOn(Date, "now").mockReturnValue(123);
(api.sendText as any).mockResolvedValue(undefined);
const cfg = {
channels: {
wecom: {
enabled: true,
agent: {
corpId: "corp",
corpSecret: "secret",
agentId: 1000002,
token: "token",
encodingAESKey: "aes",
},
},
},
};
// Chat ID (wr/wc) is intentionally NOT supported for Agent outbound.
await expect(wecomOutbound.sendText({ cfg, to: "wr123", text: "hello" } as any)).rejects.toThrow(
/不支持向群 chatId 发送/,
);
expect(api.sendText).not.toHaveBeenCalled();
// Test: User ID (Default)
const userResult = await wecomOutbound.sendText({
cfg,
to: "userid123",
text: "hi",
} as any);
expect(api.sendText).toHaveBeenCalledWith(
expect.objectContaining({
chatId: undefined,
toUser: "userid123",
toParty: undefined,
toTag: undefined,
text: "hi",
}),
);
expect(userResult.messageId).toBe("agent-123");
(api.sendText as any).mockClear();
// Test: User ID explicit
await wecomOutbound.sendText({ cfg, to: "user:zhangsan", text: "hi" } as any);
expect(api.sendText).toHaveBeenCalledWith(
expect.objectContaining({ toUser: "zhangsan", toParty: undefined }),
);
(api.sendText as any).mockClear();
// Test: Party ID (Numeric)
await wecomOutbound.sendText({ cfg, to: "1001", text: "hi party" } as any);
expect(api.sendText).toHaveBeenCalledWith(
expect.objectContaining({ toUser: undefined, toParty: "1001" }),
);
(api.sendText as any).mockClear();
// Test: Party ID Explicit
await wecomOutbound.sendText({ cfg, to: "party:2002", text: "hi party 2" } as any);
expect(api.sendText).toHaveBeenCalledWith(
expect.objectContaining({ toUser: undefined, toParty: "2002" }),
);
(api.sendText as any).mockClear();
// Test: Tag ID Explicit
await wecomOutbound.sendText({ cfg, to: "tag:1", text: "hi tag" } as any);
expect(api.sendText).toHaveBeenCalledWith(
expect.objectContaining({ toUser: undefined, toTag: "1" }),
);
now.mockRestore();
});
it("suppresses /new ack for bot sessions but not agent sessions", async () => {
const { wecomOutbound } = await import("./outbound.js");
const api = await import("./agent/api-client.js");
const now = vi.spyOn(Date, "now").mockReturnValue(456);
(api.sendText as any).mockResolvedValue(undefined);
(api.sendText as any).mockClear();
const cfg = {
channels: {
wecom: {
enabled: true,
agent: {
corpId: "corp",
corpSecret: "secret",
agentId: 1000002,
token: "token",
encodingAESKey: "aes",
},
},
},
};
const ack = "✅ New session started · model: openai-codex/gpt-5.2";
// Bot 会话(wecom:...)应抑制,避免私信回执
const r1 = await wecomOutbound.sendText({ cfg, to: "wecom:userid123", text: ack } as any);
expect(api.sendText).not.toHaveBeenCalled();
expect(r1.messageId).toBe("suppressed-456");
(api.sendText as any).mockClear();
// Agent 会话(wecom-agent:...)允许发送回执
await wecomOutbound.sendText({ cfg, to: "wecom-agent:userid123", text: ack } as any);
expect(api.sendText).toHaveBeenCalledWith(
expect.objectContaining({
toUser: "userid123",
text: "✅ 已开启新会话(模型:openai-codex/gpt-5.2)",
}),
);
now.mockRestore();
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/plugin-sdk";
import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
import { resolveWecomAccounts } from "./config/index.js";
import { getWecomRuntime } from "./runtime.js";
import { resolveWecomTarget } from "./target.js";
function resolveAgentConfigOrThrow(cfg: ChannelOutboundContext["cfg"]) {
const account = resolveWecomAccounts(cfg).agent;
if (!account?.configured) {
throw new Error(
"WeCom outbound requires Agent mode. Configure channels.wecom.agent (corpId/corpSecret/agentId/token/encodingAESKey).",
);
}
// 注意:不要在日志里输出 corpSecret 等敏感信息
console.log(`[wecom-outbound] Using agent config: corpId=${account.corpId}, agentId=${account.agentId}`);
return account;
}
export const wecomOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunkerMode: "text",
textChunkLimit: 20480,
chunker: (text, limit) => {
try {
return getWecomRuntime().channel.text.chunkText(text, limit);
} catch {
return [text];
}
},
sendText: async ({ cfg, to, text }: ChannelOutboundContext) => {
// signal removed - not supported in current SDK
const agent = resolveAgentConfigOrThrow(cfg);
const target = resolveWecomTarget(to);
if (!target) {
throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
}
// 体验优化:/new /reset 的“New session started”回执在 OpenClaw 核心里是英文固定文案,
// 且通过 routeReply 走 wecom outbound(Agent 主动发送)。
// 在 WeCom“双模式”场景下,这会造成:
// - 用户在 Bot 会话发 /new,但却收到一条 Agent 私信回执(双重回复/错会话)。
// 因此:
// - Bot 会话目标:抑制该回执(Bot 会话里由 wecom 插件补中文回执)。
// - Agent 会话目标(wecom-agent:):允许发送,但改写成中文。
let outgoingText = text;
const trimmed = String(outgoingText ?? "").trim();
const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
const isAgentSessionTarget = rawTo.startsWith("wecom-agent:");
const looksLikeNewSessionAck =
/new session started/i.test(trimmed) && /model:/i.test(trimmed);
if (looksLikeNewSessionAck) {
if (!isAgentSessionTarget) {
console.log(`[wecom-outbound] Suppressed command ack to avoid Bot/Agent double-reply (len=${trimmed.length})`);
return { channel: "wecom", messageId: `suppressed-${Date.now()}`, timestamp: Date.now() };
}
const modelLabel = (() => {
const m = trimmed.match(/model:\s*([^\n()]+)\s*/i);
return m?.[1]?.trim();
})();
const rewritten = modelLabel ? `✅ 已开启新会话(模型:${modelLabel})` : "✅ 已开启新会话。";
console.log(`[wecom-outbound] Rewrote command ack for agent session (len=${rewritten.length})`);
outgoingText = rewritten;
}
const { touser, toparty, totag, chatid } = target;
if (chatid) {
throw new Error(
`企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${chatid})。` +
`该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
`请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
);
}
console.log(`[wecom-outbound] Sending text to target=${JSON.stringify(target)} (len=${outgoingText.length})`);
try {
await sendAgentText({
agent,
toUser: touser,
toParty: toparty,
toTag: totag,
chatId: chatid,
text: outgoingText,
});
console.log(`[wecom-outbound] Successfully sent text to ${JSON.stringify(target)}`);
} catch (err) {
console.error(`[wecom-outbound] Failed to send text to ${JSON.stringify(target)}:`, err);
throw err;
}
return {
channel: "wecom",
messageId: `agent-${Date.now()}`,
timestamp: Date.now(),
};
},
sendMedia: async ({ cfg, to, text, mediaUrl }: ChannelOutboundContext) => {
// signal removed - not supported in current SDK
const agent = resolveAgentConfigOrThrow(cfg);
const target = resolveWecomTarget(to);
if (!target) {
throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
}
if (target.chatid) {
throw new Error(
`企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${target.chatid})。` +
`该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
`请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
);
}
if (!mediaUrl) {
throw new Error("WeCom outbound requires mediaUrl.");
}
let buffer: Buffer;
let contentType: string;
let filename: string;
// 判断是 URL 还是本地文件路径
const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
if (isRemoteUrl) {
const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30000) });
if (!res.ok) {
throw new Error(`Failed to download media: ${res.status}`);
}
buffer = Buffer.from(await res.arrayBuffer());
contentType = res.headers.get("content-type") || "application/octet-stream";
const urlPath = new URL(mediaUrl).pathname;
filename = urlPath.split("/").pop() || "media";
} else {
// 本地文件路径
const fs = await import("node:fs/promises");
const path = await import("node:path");
buffer = await fs.readFile(mediaUrl);
filename = path.basename(mediaUrl);
// 根据扩展名推断 content-type
const ext = path.extname(mediaUrl).slice(1).toLowerCase();
const mimeTypes: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
contentType = mimeTypes[ext] || "application/octet-stream";
console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
}
let mediaType: "image" | "voice" | "video" | "file" = "file";
if (contentType.startsWith("image/")) mediaType = "image";
else if (contentType.startsWith("audio/")) mediaType = "voice";
else if (contentType.startsWith("video/")) mediaType = "video";
const mediaId = await uploadMedia({
agent,
type: mediaType,
buffer,
filename,
});
const { touser, toparty, totag, chatid } = target;
console.log(`[wecom-outbound] Sending media (${mediaType}) to ${JSON.stringify(target)} (mediaId=${mediaId})`);
try {
await sendAgentMedia({
agent,
toUser: touser,
toParty: toparty,
toTag: totag,
chatId: chatid,
mediaId,
mediaType,
...(mediaType === "video" && text?.trim()
? {
title: text.trim().slice(0, 64),
description: text.trim().slice(0, 512),
}
: {}),
});
console.log(`[wecom-outbound] Successfully sent media to ${JSON.stringify(target)}`);
} catch (err) {
console.error(`[wecom-outbound] Failed to send media to ${JSON.stringify(target)}:`, err);
throw err;
}
return {
channel: "wecom",
messageId: `agent-media-${Date.now()}`,
timestamp: Date.now(),
};
},
};
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setWecomRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getWecomRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("WeCom runtime not initialized");
}
return runtime;
}
+101
View File
@@ -0,0 +1,101 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import type { WecomAccountConfig } from "../types.js";
function normalizeWecomAllowFromEntry(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/^wecom:/, "")
.replace(/^user:/, "")
.replace(/^userid:/, "");
}
function isWecomSenderAllowed(senderUserId: string, allowFrom: string[]): boolean {
const list = allowFrom.map((entry) => normalizeWecomAllowFromEntry(entry)).filter(Boolean);
if (list.includes("*")) return true;
const normalizedSender = normalizeWecomAllowFromEntry(senderUserId);
if (!normalizedSender) return false;
return list.includes(normalizedSender);
}
export async function resolveWecomCommandAuthorization(params: {
core: PluginRuntime;
cfg: OpenClawConfig;
accountConfig: WecomAccountConfig;
rawBody: string;
senderUserId: string;
}): Promise<{
shouldComputeAuth: boolean;
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
senderAllowed: boolean;
authorizerConfigured: boolean;
commandAuthorized: boolean | undefined;
effectiveAllowFrom: string[];
}> {
const { core, cfg, accountConfig, rawBody, senderUserId } = params;
const dmPolicy = (accountConfig.dm?.policy ?? "pairing") as "pairing" | "allowlist" | "open" | "disabled";
const configAllowFrom = (accountConfig.dm?.allowFrom ?? []).map((v) => String(v));
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, cfg);
// WeCom channel currently does NOT support the `openclaw pairing` CLI workflow
// ("Channel wecom does not support pairing"). So we must not rely on pairing
// store approvals for command authorization here.
//
// Policy semantics:
// - open: commands are allowed for everyone by default (unless higher-level access-groups deny).
// - allowlist: commands require allowFrom entries.
// - pairing: treated the same as allowlist for WeCom (since pairing CLI is unsupported).
const effectiveAllowFrom = dmPolicy === "open" ? ["*"] : configAllowFrom;
const senderAllowed = isWecomSenderAllowed(senderUserId, effectiveAllowFrom);
const allowAllConfigured = effectiveAllowFrom.some((entry) => normalizeWecomAllowFromEntry(entry) === "*");
const authorizerConfigured = allowAllConfigured || effectiveAllowFrom.length > 0;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: authorizerConfigured, allowed: senderAllowed }],
})
: undefined;
return {
shouldComputeAuth,
dmPolicy,
senderAllowed,
authorizerConfigured,
commandAuthorized,
effectiveAllowFrom,
};
}
export function buildWecomUnauthorizedCommandPrompt(params: {
senderUserId: string;
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
scope: "bot" | "agent";
}): string {
const user = params.senderUserId || "unknown";
const policy = params.dmPolicy;
const scopeLabel = params.scope === "bot" ? "Bot(智能机器人)" : "Agent(自建应用)";
const dmPrefix = params.scope === "bot" ? "channels.wecom.bot.dm" : "channels.wecom.agent.dm";
const allowCmd = (value: string) => `openclaw config set ${dmPrefix}.allowFrom '${value}'`;
const policyCmd = (value: string) => `openclaw config set ${dmPrefix}.policy "${value}"`;
if (policy === "disabled") {
return [
`无权限执行命令(${scopeLabel} 已禁用:dm.policy=disabled)`,
`触发者:${user}`,
`管理员:${policyCmd("open")}(全放开)或 ${policyCmd("allowlist")}(白名单)`,
].join("\n");
}
// WeCom 不支持 pairing CLI,因此这里统一给出“open / allowlist”两种明确的配置指令
return [
`无权限执行命令(入口:${scopeLabel},userid:${user})`,
`管理员全放开:${policyCmd("open")}`,
`管理员放行该用户:${policyCmd("allowlist")}`,
`然后设置白名单:${allowCmd(JSON.stringify([user]))}`,
`如果仍被拦截:检查 commands.useAccessGroups/访问组`,
].join("\n");
}
+5
View File
@@ -0,0 +1,5 @@
/**
* WeCom Shared 模块导出
*/
export { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractToUser } from "./xml-parser.js";
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { extractContent, extractMediaId, extractMsgId } from "./xml-parser.js";
describe("wecom xml-parser", () => {
test("extractContent is robust to non-string Content", () => {
const msg: any = { MsgType: "text", Content: { "#text": "hello", "@_foo": "bar" } };
expect(extractContent(msg)).toBe("hello");
});
test("extractContent handles array content", () => {
const msg: any = { MsgType: "text", Content: ["a", "b"] };
expect(extractContent(msg)).toBe("a\nb");
});
test("extractContent handles file messages", () => {
const msg: any = { MsgType: "file", MediaId: "MEDIA123" };
expect(extractContent(msg)).toBe("[文件消息]");
});
test("extractMediaId handles object MediaId", () => {
const msg: any = { MediaId: { "#text": "MEDIA123", "@_foo": "bar" } };
expect(extractMediaId(msg)).toBe("MEDIA123");
});
test("extractMsgId handles number MsgId", () => {
const msg: any = { MsgId: 123456789 };
expect(extractMsgId(msg)).toBe("123456789");
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* WeCom XML 解析器
* 用于 Agent 模式解析 XML 格式消息
*/
import { XMLParser } from "fast-xml-parser";
import type { WecomAgentInboundMessage } from "../types/index.js";
const xmlParser = new XMLParser({
ignoreAttributes: false,
trimValues: true,
processEntities: false,
});
/**
* 解析 XML 字符串为消息对象
*/
export function parseXml(xml: string): WecomAgentInboundMessage {
const obj = xmlParser.parse(xml);
const root = obj?.xml ?? obj;
return root ?? {};
}
/**
* 从 XML 中提取消息类型
*/
export function extractMsgType(msg: WecomAgentInboundMessage): string {
return String(msg.MsgType ?? "").toLowerCase();
}
/**
* 从 XML 中提取发送者 ID
*/
export function extractFromUser(msg: WecomAgentInboundMessage): string {
return String(msg.FromUserName ?? "");
}
/**
* 从 XML 中提取文件名(主要用于 file 消息)
*/
export function extractFileName(msg: WecomAgentInboundMessage): string | undefined {
const raw = (msg as any).FileName ?? (msg as any).Filename ?? (msg as any).fileName ?? (msg as any).filename;
if (raw == null) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") return String(raw);
if (Array.isArray(raw)) {
const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
return merged || undefined;
}
if (typeof raw === "object") {
const obj = raw as Record<string, unknown>;
const text = (typeof obj["#text"] === "string" ? obj["#text"] :
typeof obj["_text"] === "string" ? obj["_text"] :
typeof obj["text"] === "string" ? obj["text"] : undefined);
if (text && text.trim()) return text.trim();
}
const s = String(raw);
return s.trim() || undefined;
}
/**
* 从 XML 中提取接收者 ID (CorpID)
*/
export function extractToUser(msg: WecomAgentInboundMessage): string {
return String(msg.ToUserName ?? "");
}
/**
* 从 XML 中提取群聊 ID
*/
export function extractChatId(msg: WecomAgentInboundMessage): string | undefined {
return msg.ChatId ? String(msg.ChatId) : undefined;
}
/**
* 从 XML 中提取消息内容
*/
export function extractContent(msg: WecomAgentInboundMessage): string {
const msgType = extractMsgType(msg);
const asText = (value: unknown): string => {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
if (Array.isArray(value)) return value.map(asText).filter(Boolean).join("\n");
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
// fast-xml-parser 在某些情况下(例如带属性)会把文本放在 "#text"
if (typeof obj["#text"] === "string") return obj["#text"];
if (typeof obj["_text"] === "string") return obj["_text"];
if (typeof obj["text"] === "string") return obj["text"];
try {
return JSON.stringify(obj);
} catch {
return String(value);
}
}
return String(value);
};
switch (msgType) {
case "text":
return asText(msg.Content);
case "voice":
// 语音识别结果
return asText(msg.Recognition) || "[语音消息]";
case "image":
return `[图片] ${asText(msg.PicUrl)}`;
case "file":
return "[文件消息]";
case "video":
return "[视频消息]";
case "location":
return `[位置] ${asText(msg.Label)} (${asText(msg.Location_X)}, ${asText(msg.Location_Y)})`;
case "link":
return `[链接] ${asText(msg.Title)}\n${asText(msg.Description)}\n${asText(msg.Url)}`;
case "event":
return `[事件] ${asText(msg.Event)} - ${asText(msg.EventKey)}`;
default:
return `[${msgType || "未知消息类型"}]`;
}
}
/**
* 从 XML 中提取媒体 ID (Image, Voice, Video)
* 根据官方文档,MediaId 在 Agent 回调中直接位于根节点
*/
export function extractMediaId(msg: WecomAgentInboundMessage): string | undefined {
const raw = (msg as any).MediaId ?? (msg as any).MediaID ?? (msg as any).mediaid ?? (msg as any).mediaId;
if (raw == null) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") return String(raw);
if (Array.isArray(raw)) {
const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
return merged || undefined;
}
if (typeof raw === "object") {
const obj = raw as Record<string, unknown>;
const text = (typeof obj["#text"] === "string" ? obj["#text"] :
typeof obj["_text"] === "string" ? obj["_text"] :
typeof obj["text"] === "string" ? obj["text"] : undefined);
if (text && text.trim()) return text.trim();
try {
const s = JSON.stringify(obj);
return s.trim() || undefined;
} catch {
const s = String(raw);
return s.trim() || undefined;
}
}
const s = String(raw);
return s.trim() || undefined;
}
/**
* 从 XML 中提取 MsgId(用于去重)
*/
export function extractMsgId(msg: WecomAgentInboundMessage): string | undefined {
const raw = (msg as any).MsgId ?? (msg as any).MsgID ?? (msg as any).msgid ?? (msg as any).msgId;
if (raw == null) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") return String(raw);
if (Array.isArray(raw)) {
const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
return merged || undefined;
}
if (typeof raw === "object") {
const obj = raw as Record<string, unknown>;
const text = (typeof obj["#text"] === "string" ? obj["#text"] :
typeof obj["_text"] === "string" ? obj["_text"] :
typeof obj["text"] === "string" ? obj["text"] : undefined);
if (text && text.trim()) return text.trim();
try {
const s = JSON.stringify(obj);
return s.trim() || undefined;
} catch {
const s = String(raw);
return s.trim() || undefined;
}
}
const s = String(raw);
return s.trim() || undefined;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* WeCom Target Resolver (企业微信目标解析器)
*
* 解析 OpenClaw 的 `to` 字段(原始目标字符串),将其转换为企业微信支持的具体接收对象。
* 支持显式前缀 (party:, tag: 等) 和基于规则的启发式推断。
*
* **关于“目标发送”与“消息记录”的对应关系 (Target vs Inbound):**
* - **发送 (Outbound)**: 支持一对多广播 (Party/Tag)。
* 例如发送给 `party:1`,消息会触达该部门下所有成员。
* - **接收 (Inbound)**: 总是来自具体的 **用户 (User)** 或 **群聊 (Chat)**。
* 当成员回复部门广播消息时,可以视为一个新的单聊会话或在该成员的现有单聊中回复。
* 因此,Outbound Target (如 Party) 与 Inbound Source (User) 不需要也不可能 1:1 强匹配。
* 广播是“发后即忘” (Fire-and-Forget) 的通知模式,而回复是具体的会话模式。
*/
export interface WecomTarget {
touser?: string;
toparty?: string;
totag?: string;
chatid?: string;
}
/**
* Parses a raw target string into a WeComTarget object.
* 解析原始目标字符串为 WeComTarget 对象。
*
* 逻辑:
* 1. 移除标准命名空间前缀 (wecom:, qywx: 等)。
* 2. 检查显式类型前缀 (party:, tag:, group:, user:)。
* 3. 启发式回退 (无前缀时):
* - 以 "wr" 或 "wc" 开头 -> Chat ID (群聊)
* - 纯数字 -> Party ID (部门)
* - 其他 -> User ID (用户)
*
* @param raw - The raw target string (e.g. "party:1", "zhangsan", "wecom:wr123")
*/
export function resolveWecomTarget(raw: string | undefined): WecomTarget | undefined {
if (!raw?.trim()) return undefined;
// 1. Remove standard namespace prefixes (移除标准命名空间前缀)
let clean = raw.trim().replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "");
// 2. Explicit Type Prefixes (显式类型前缀)
if (/^party:/i.test(clean)) {
return { toparty: clean.replace(/^party:/i, "").trim() };
}
if (/^dept:/i.test(clean)) {
return { toparty: clean.replace(/^dept:/i, "").trim() };
}
if (/^tag:/i.test(clean)) {
return { totag: clean.replace(/^tag:/i, "").trim() };
}
if (/^group:/i.test(clean)) {
return { chatid: clean.replace(/^group:/i, "").trim() };
}
if (/^chat:/i.test(clean)) {
return { chatid: clean.replace(/^chat:/i, "").trim() };
}
if (/^user:/i.test(clean)) {
return { touser: clean.replace(/^user:/i, "").trim() };
}
// 3. Heuristics (启发式规则)
// Chat ID typically starts with 'wr' or 'wc'
// 群聊 ID 通常以 'wr' (外部群) 或 'wc' 开头
if (/^(wr|wc)/i.test(clean)) {
return { chatid: clean };
}
// Pure digits are likely Department IDs (Parties)
// 纯数字优先被视为部门 ID (Parties),方便运维配置 (如 "1" 代表根部门)
// 如果必须要发送给纯数字 ID 的用户,请使用显式前缀 "user:1001"
if (/^\d+$/.test(clean)) {
return { toparty: clean };
}
// Default to User (默认为用户)
return { touser: clean };
}
+159
View File
@@ -0,0 +1,159 @@
export type WecomDmConfig = {
policy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
};
export type WecomAccountConfig = {
name?: string;
enabled?: boolean;
webhookPath?: string;
token?: string;
encodingAESKey?: string;
receiveId?: string;
streamPlaceholderContent?: string;
dm?: WecomDmConfig;
welcomeText?: string;
};
export type WecomConfig = WecomAccountConfig & {
accounts?: Record<string, WecomAccountConfig>;
defaultAccount?: string;
};
export type ResolvedWecomAccount = {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
token?: string;
encodingAESKey?: string;
receiveId: string;
config: WecomAccountConfig;
};
export type WecomInboundBase = {
msgid?: string;
aibotid?: string;
chattype?: "single" | "group";
chatid?: string;
response_url?: string;
from?: { userid?: string; corpid?: string };
msgtype?: string;
};
export type WecomInboundText = WecomInboundBase & {
msgtype: "text";
text?: { content?: string };
quote?: unknown;
};
export type WecomInboundVoice = WecomInboundBase & {
msgtype: "voice";
voice?: { content?: string };
quote?: unknown;
};
export type WecomInboundStreamRefresh = WecomInboundBase & {
msgtype: "stream";
stream?: { id?: string };
};
export type WecomInboundEvent = WecomInboundBase & {
msgtype: "event";
create_time?: number;
event?: {
eventtype?: string;
[key: string]: unknown;
};
};
export type WecomInboundQuote = {
msgtype?: "text" | "image" | "mixed" | "voice" | "file";
text?: { content?: string };
image?: { url?: string };
mixed?: {
msg_item?: Array<{
msgtype: "text" | "image";
text?: { content?: string };
image?: { url?: string };
}>;
};
voice?: { content?: string };
file?: { url?: string };
};
export type WecomInboundMessage =
| (WecomInboundText & { quote?: WecomInboundQuote })
| WecomInboundVoice
| WecomInboundStreamRefresh
| WecomInboundEvent
| (WecomInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
export type WecomTemplateCard = {
card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
source?: { icon_url?: string; desc?: string; desc_color?: number };
main_title?: { title?: string; desc?: string };
task_id?: string;
button_list?: Array<{ text: string; style?: number; key: string }>;
sub_title_text?: string;
horizontal_content_list?: Array<{ keyname: string; value?: string; type?: number; url?: string; userid?: string }>;
card_action?: { type: number; url?: string; appid?: string; pagepath?: string };
action_menu?: { desc: string; action_list: Array<{ text: string; key: string }> };
select_list?: Array<{
question_key: string;
title?: string;
selected_id?: string;
option_list: Array<{ id: string; text: string }>;
}>;
submit_button?: { text: string; key: string };
checkbox?: {
question_key: string;
option_list: Array<{ id: string; text: string; is_checked?: boolean }>;
mode?: number;
};
};
export type WecomInboundTemplateCardEvent = WecomInboundBase & {
msgtype: "event";
event: {
eventtype: "template_card_event";
template_card_event: {
card_type: string;
event_key: string;
task_id: string;
selected_items?: {
selected_item: Array<{
question_key: string;
option_ids: { option_id: string[] };
}>;
};
};
};
};
/**
* Template card event payload (button click, checkbox, select)
*/
export type WecomTemplateCardEventPayload = {
card_type: string;
event_key: string;
task_id: string;
response_code?: string;
selected_items?: {
question_key?: string;
option_ids?: string[];
};
};
/**
* Outbound message types that can be sent via response_url
*/
export type WecomOutboundMessage =
| { msgtype: "text"; text: { content: string } }
| { msgtype: "markdown"; markdown: { content: string } }
| { msgtype: "template_card"; template_card: WecomTemplateCard };
+76
View File
@@ -0,0 +1,76 @@
/**
* WeCom 账号类型定义
*/
import type { WecomBotConfig, WecomAgentConfig, WecomDmConfig, WecomNetworkConfig } from "./config.js";
/**
* 解析后的 Bot 账号
*/
export type ResolvedBotAccount = {
/** 账号 ID */
accountId: string;
/** 是否启用 */
enabled: boolean;
/** 是否配置完整 */
configured: boolean;
/** 回调 Token */
token: string;
/** 回调加密密钥 */
encodingAESKey: string;
/** 接收者 ID */
receiveId: string;
/** 原始配置 */
config: WecomBotConfig;
/** 网络配置(来自 channels.wecom.network) */
network?: WecomNetworkConfig;
};
/**
* 解析后的 Agent 账号
*/
export type ResolvedAgentAccount = {
/** 账号 ID */
accountId: string;
/** 是否启用 */
enabled: boolean;
/** 是否配置完整 */
configured: boolean;
/** 企业 ID */
corpId: string;
/** 应用 Secret */
corpSecret: string;
/** 应用 ID (数字) */
agentId: number;
/** 回调 Token */
token: string;
/** 回调加密密钥 */
encodingAESKey: string;
/** 原始配置 */
config: WecomAgentConfig;
/** 网络配置(来自 channels.wecom.network) */
network?: WecomNetworkConfig;
};
/**
* 已解析的模式状态
*/
export type ResolvedMode = {
/** Bot 模式是否已配置 */
bot: boolean;
/** Agent 模式是否已配置 */
agent: boolean;
};
/**
* 解析后的 WeCom 账号集合
*/
export type ResolvedWecomAccounts = {
/** Bot 模式账号 */
bot?: ResolvedBotAccount;
/** Agent 模式账号 */
agent?: ResolvedAgentAccount;
};
// Re-export 用于向后兼容
export type { WecomDmConfig } from "./config.js";
+88
View File
@@ -0,0 +1,88 @@
/**
* WeCom 双模式配置类型定义
*/
/** DM 策略配置 - 与其他渠道保持一致,仅用 allowFrom */
export type WecomDmConfig = {
/** DM 策略: 'open' 允许所有人, 'pairing' 需要配对, 'allowlist' 仅允许列表, 'disabled' 禁用 */
policy?: 'open' | 'pairing' | 'allowlist' | 'disabled';
/** 允许的用户列表,为空表示允许所有人 */
allowFrom?: Array<string | number>;
};
/** 媒体处理配置 */
export type WecomMediaConfig = {
tempDir?: string;
retentionHours?: number;
cleanupOnStart?: boolean;
maxBytes?: number;
};
/** 网络配置 */
export type WecomNetworkConfig = {
timeoutMs?: number;
retries?: number;
retryDelayMs?: number;
/**
* 出口代理(用于企业可信 IP 固定出口场景)。
* 示例: "http://proxy.company.local:3128"
*/
egressProxyUrl?: string;
};
/**
* Bot 模式配置 (智能体)
* 用于接收 JSON 格式回调 + 流式回复
*/
export type WecomBotConfig = {
/** 回调 Token (企微后台生成) */
token: string;
/** 回调加密密钥 (企微后台生成) */
encodingAESKey: string;
/** 接收者 ID (可选,用于解密校验) */
receiveId?: string;
/** 流式消息占位符 */
streamPlaceholderContent?: string;
/** 欢迎语 */
welcomeText?: string;
/** DM 策略 */
dm?: WecomDmConfig;
};
/**
* Agent 模式配置 (自建应用)
* 用于接收 XML 格式回调 + API 主动发送
*/
export type WecomAgentConfig = {
/** 企业 ID */
corpId: string;
/** 应用 Secret */
corpSecret: string;
/** 应用 ID */
agentId: number | string;
/** 回调 Token (企微后台「设置API接收」) */
token: string;
/** 回调加密密钥 (企微后台「设置API接收」) */
encodingAESKey: string;
/** 欢迎语 */
welcomeText?: string;
/** DM 策略 */
dm?: WecomDmConfig;
};
/**
* 顶层 WeCom 配置
* 通过 bot / agent 字段隐式指定模式
*/
export type WecomConfig = {
/** 是否启用 */
enabled?: boolean;
/** Bot 模式配置 (智能体) */
bot?: WecomBotConfig;
/** Agent 模式配置 (自建应用) */
agent?: WecomAgentConfig;
/** 媒体处理配置 */
media?: WecomMediaConfig;
/** 网络配置 */
network?: WecomNetworkConfig;
};
+42
View File
@@ -0,0 +1,42 @@
/**
* WeCom 双模式常量定义
*/
/** 固定 Webhook 路径 */
export const WEBHOOK_PATHS = {
/** Bot 模式 (智能体) - 兼容原有路径 */
BOT: "/wecom",
/** Bot 模式备用路径 */
BOT_ALT: "/wecom/bot",
/** Agent 模式 (自建应用) */
AGENT: "/wecom/agent",
} as const;
/** 企业微信 API 端点 */
export const API_ENDPOINTS = {
GET_TOKEN: "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
SEND_MESSAGE: "https://qyapi.weixin.qq.com/cgi-bin/message/send",
SEND_APPCHAT: "https://qyapi.weixin.qq.com/cgi-bin/appchat/send",
UPLOAD_MEDIA: "https://qyapi.weixin.qq.com/cgi-bin/media/upload",
DOWNLOAD_MEDIA: "https://qyapi.weixin.qq.com/cgi-bin/media/get",
} as const;
/** 各类限制常量 */
export const LIMITS = {
/** 文本消息最大字节数 */
TEXT_MAX_BYTES: 2048,
/** Token 刷新缓冲时间 (提前刷新) */
TOKEN_REFRESH_BUFFER_MS: 60_000,
/** HTTP 请求超时 */
REQUEST_TIMEOUT_MS: 15_000,
/** 最大请求体大小 */
MAX_REQUEST_BODY_SIZE: 1024 * 1024,
} as const;
/** AES 加密常量 */
export const CRYPTO = {
/** PKCS#7 块大小 */
PKCS7_BLOCK_SIZE: 32,
/** AES Key 长度 */
AES_KEY_LENGTH: 32,
} as const;
+9
View File
@@ -0,0 +1,9 @@
declare global {
var Buffer: any;
namespace NodeJS {
interface Timeout { }
}
}
export { };
+38
View File
@@ -0,0 +1,38 @@
/**
* WeCom 类型统一导出
*/
// 常量
export * from "./constants.js";
// 配置类型
export type {
WecomDmConfig,
WecomMediaConfig,
WecomNetworkConfig,
WecomBotConfig,
WecomAgentConfig,
WecomConfig,
} from "./config.js";
// 账号类型
export type {
ResolvedBotAccount,
ResolvedAgentAccount,
ResolvedMode,
ResolvedWecomAccounts,
} from "./account.js";
// 消息类型
export type {
WecomBotInboundBase,
WecomBotInboundText,
WecomBotInboundVoice,
WecomBotInboundStreamRefresh,
WecomBotInboundEvent,
WecomBotInboundMessage,
WecomAgentInboundMessage,
WecomInboundQuote,
WecomTemplateCard,
WecomOutboundMessage,
} from "./message.js";
+185
View File
@@ -0,0 +1,185 @@
/**
* WeCom 消息类型定义
* Bot 和 Agent 模式共用
*/
/**
* Bot 模式入站消息基础结构 (JSON)
*/
/**
* **WecomBotInboundBase (Bot 入站消息基类)**
*
* Bot 模式下 JSON 格式回调的基础字段。
* @property msgid 消息 ID
* @property aibotid 机器人 ID
* @property chattype 会话类型: "single" | "group"
* @property chatid 群聊 ID (仅群组时存在)
* @property response_url 下行回复 URL (用于被动响应转主动推送)
* @property from 发送者信息
*/
export type WecomBotInboundBase = {
msgid?: string;
aibotid?: string;
chattype?: "single" | "group";
chatid?: string;
response_url?: string;
from?: { userid?: string; corpid?: string };
msgtype?: string;
/** 附件数量 (部分消息存在) */
attachment_count?: number;
};
export type WecomBotInboundText = WecomBotInboundBase & {
msgtype: "text";
text?: { content?: string };
quote?: WecomInboundQuote;
};
export type WecomBotInboundVoice = WecomBotInboundBase & {
msgtype: "voice";
voice?: { content?: string };
quote?: WecomInboundQuote;
};
export type WecomBotInboundStreamRefresh = WecomBotInboundBase & {
msgtype: "stream";
stream?: { id?: string };
};
export type WecomBotInboundEvent = WecomBotInboundBase & {
msgtype: "event";
create_time?: number;
event?: {
eventtype?: string;
[key: string]: unknown;
};
};
/**
* **WecomInboundQuote (引用消息)**
*
* 消息中引用的原始内容(如回复某条消息)。
* 支持引用文本、图片、混合类型、语音、文件等。
*/
export type WecomInboundQuote = {
msgtype?: "text" | "image" | "mixed" | "voice" | "file";
/** 引用文本内容 */
text?: { content?: string };
/** 引用图片 URL */
image?: { url?: string };
/** 引用混合消息 (图文) */
mixed?: {
msg_item?: Array<{
msgtype: "text" | "image";
text?: { content?: string };
image?: { url?: string };
}>;
};
/** 引用语音 */
voice?: { content?: string };
/** 引用文件 */
file?: { url?: string };
};
export type WecomBotInboundMessage =
| WecomBotInboundText
| WecomBotInboundVoice
| WecomBotInboundStreamRefresh
| WecomBotInboundEvent
| (WecomBotInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
/**
* Agent 模式入站消息结构 (解析自 XML)
*/
/**
* **WecomAgentInboundMessage (Agent 入站消息)**
*
* Agent 模式下解析自 XML 的扁平化消息结构。
* 键名保持 PascalCase (如 `ToUserName`)。
*/
export type WecomAgentInboundMessage = {
ToUserName?: string;
FromUserName?: string;
CreateTime?: number;
MsgType?: string;
MsgId?: string;
AgentID?: number;
// 文本消息
Content?: string;
// 图片消息
PicUrl?: string;
MediaId?: string;
// 文件消息
FileName?: string;
// 语音消息
Format?: string;
Recognition?: string;
// 视频消息
ThumbMediaId?: string;
// 位置消息
Location_X?: number;
Location_Y?: number;
Scale?: number;
Label?: string;
// 链接消息
Title?: string;
Description?: string;
Url?: string;
// 事件消息
Event?: string;
EventKey?: string;
// 群聊
ChatId?: string;
};
/**
* 模板卡片类型
*/
/**
* **WecomTemplateCard (模板卡片)**
*
* 复杂的交互式卡片结构。
* @property card_type 卡片类型: "text_notice" | "news_notice" | "button_interaction" ...
* @property source 来源信息
* @property main_title 主标题
* @property sub_title_text 副标题
* @property horizontal_content_list 水平排列的键值列表
* @property button_list 按钮列表
*/
export type WecomTemplateCard = {
card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
source?: { icon_url?: string; desc?: string; desc_color?: number };
main_title?: { title?: string; desc?: string };
task_id?: string;
button_list?: Array<{ text: string; style?: number; key: string }>;
sub_title_text?: string;
horizontal_content_list?: Array<{
keyname: string;
value?: string;
type?: number;
url?: string;
userid?: string;
}>;
card_action?: { type: number; url?: string; appid?: string; pagepath?: string };
action_menu?: { desc: string; action_list: Array<{ text: string; key: string }> };
select_list?: Array<{
question_key: string;
title?: string;
selected_id?: string;
option_list: Array<{ id: string; text: string }>;
}>;
submit_button?: { text: string; key: string };
checkbox?: {
question_key: string;
option_list: Array<{ id: string; text: string; is_checked?: boolean }>;
mode?: number;
};
};
/**
* 出站消息类型
*/
export type WecomOutboundMessage =
| { msgtype: "text"; text: { content: string } }
| { msgtype: "markdown"; markdown: { content: string } }
| { msgtype: "template_card"; template_card: WecomTemplateCard };
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": ".",
"declaration": false,
"sourceMap": false,
"strict": true,
"skipLibCheck": true
},
"include": [
"index.ts",
"src/**/*.ts"
],
"exclude": [
"dist",
"node_modules",
"**/*.test.ts"
]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
import baseConfig from "../../vitest.config.ts";
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
const exclude = baseTest.exclude ?? [];
export default defineConfig({
...baseConfig,
test: {
...baseTest,
include: ["extensions/wecom/src/**/*.test.ts"],
exclude,
},
});