Initial commit: feishu + wecom extensions
This commit is contained in:
Generated
+14
@@ -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>
|
||||
Generated
+13
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+7
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "wecom",
|
||||
"channels": ["wecom"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "wecom",
|
||||
"channels": ["wecom"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+10469
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* WeCom Agent 模块导出
|
||||
*/
|
||||
|
||||
export { handleAgentWebhook, type AgentWebhookParams } from "./handler.js";
|
||||
export {
|
||||
getAccessToken,
|
||||
sendText,
|
||||
uploadMedia,
|
||||
sendMedia,
|
||||
downloadMedia,
|
||||
} from "./api-client.js";
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。
|
||||
});
|
||||
@@ -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() }),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)}×tamp=${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)}×tamp=${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)}×tamp=${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)}×tamp=${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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* WeCom Shared 模块导出
|
||||
*/
|
||||
|
||||
export { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractToUser } from "./xml-parser.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
|
||||
declare global {
|
||||
var Buffer: any;
|
||||
namespace NodeJS {
|
||||
interface Timeout { }
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user