@mocrane/wecom 2026.2.5 → 2026.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +4 -18
- package/README.md +572 -0
- package/assets/01.bot-add.png +0 -0
- package/assets/01.bot-setp2.png +0 -0
- package/assets/01.image.jpg +0 -0
- package/assets/02.agent.add.png +0 -0
- package/assets/02.agent.api-set.png +0 -0
- package/assets/02.image.jpg +0 -0
- package/assets/03.agent.page.png +0 -0
- package/assets/03.bot.page.png +0 -0
- package/assets/link-me.jpg +0 -0
- package/assets/register.png +0 -0
- package/changelog/v2.2.28.md +70 -0
- package/changelog/v2.3.2.md +28 -0
- package/changelog/v2.3.4.md +20 -0
- package/index.ts +11 -3
- package/package.json +4 -2
- package/src/accounts.ts +17 -55
- package/src/agent/api-client.ts +84 -37
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.event-filter.test.ts +50 -0
- package/src/agent/handler.ts +166 -143
- package/src/channel.config.test.ts +147 -0
- package/src/channel.lifecycle.test.ts +252 -0
- package/src/channel.ts +95 -140
- package/src/config/accounts.resolve.test.ts +38 -0
- package/src/config/accounts.ts +257 -22
- package/src/config/index.ts +6 -0
- package/src/config/network.ts +9 -5
- package/src/config/routing.test.ts +88 -0
- package/src/config/routing.ts +26 -0
- package/src/config/schema.ts +52 -4
- package/src/config-schema.ts +5 -41
- package/src/dynamic-agent.account-scope.test.ts +17 -0
- package/src/dynamic-agent.ts +178 -0
- package/src/gateway-monitor.ts +238 -0
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor/state.queue.test.ts +1 -1
- package/src/monitor/state.ts +1 -1
- package/src/monitor/types.ts +1 -1
- package/src/monitor.active.test.ts +15 -9
- package/src/monitor.inbound-filter.test.ts +63 -0
- package/src/monitor.integration.test.ts +4 -2
- package/src/monitor.ts +988 -125
- package/src/monitor.webhook.test.ts +381 -3
- package/src/onboarding.ts +229 -53
- package/src/outbound.test.ts +130 -0
- package/src/outbound.ts +44 -9
- package/src/shared/command-auth.ts +4 -2
- package/src/shared/xml-parser.test.ts +21 -1
- package/src/shared/xml-parser.ts +18 -0
- package/src/types/account.ts +43 -14
- package/src/types/config.ts +51 -2
- package/src/types/constants.ts +7 -3
- package/src/types/index.ts +3 -0
- package/src/types.ts +29 -147
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# 🚀 OpenClaw 企业微信 (WeCom) 插件 v2.2.28 - 多账号隔离与稳定性增强
|
|
2
|
+
|
|
3
|
+
本次 v2.2.28 版本是 **OpenClaw 企业微信 (WeCom) 插件** 的一次重大里程碑更新。我们深度优化了 **微信 / 企业微信** 办公场景下的多智能体隔离逻辑,并修复了生命周期、XML 数据保真等多个生产环境的核心痛点。
|
|
4
|
+
|
|
5
|
+
本次更新让 **OpenClaw** 在处理企业级复杂 **插件** 配置时更加得心应手,完美解决大模型接入 **WeCom** 的所有阻碍。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### 🌟 版本亮点 (Release Highlights)
|
|
10
|
+
|
|
11
|
+
* 🎯 **多账号矩阵支持**:支持按 `accountId` 进行组内会话隔离。不同部门、不同业务的 **企业微信** 机器人可并行运行,互不干扰,彻底解决跨账号串会话问题。
|
|
12
|
+
* 🔐 **数据保真解析**:针对 **WeCom** 的 XML 消息解析进行了重构。关闭了自动数值化,保留 `FromUserName` 前导 `0`,并完美解决 64 位 `MsgId` 精度风险。
|
|
13
|
+
* 🔁 **Gateway 生命周期适配**:完美兼容最新版 **OpenClaw** Gateway 的生命周期管理,修复了在高频心跳监测下的重启循环问题,运行更稳健。
|
|
14
|
+
* 🧹 **入站消息过滤**:优化了 **微信** 与 **企业微信** 的事件过滤逻辑,避免系统事件、缺失发送者等无效消息进入 AI 会话,防止“误回复”。
|
|
15
|
+
* 🧱 **配置安全护栏**:新增 **企业微信** 账号冲突检测。自动拦截重复的 `bot.token` 或 `agentId` 配置,并提供友好的中文错误提示。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
### 📝 详细更新日志 (Changelog)
|
|
20
|
+
|
|
21
|
+
#### 【重磅更新】🎯 多账号/多智能体可用性增强
|
|
22
|
+
- 支持按 `accountId` 做组内隔离(Bot + Agent + 路由绑定同组生效)。
|
|
23
|
+
- 动态 Agent 与会话键增加 `accountId` 维度,避免跨账号串会话。
|
|
24
|
+
|
|
25
|
+
#### 【稳定性】🔁 生命周期兼容修复
|
|
26
|
+
- 适配新版 **OpenClaw** Gateway 生命周期,`startAccount` 改为长生命周期运行。
|
|
27
|
+
- 修复了“几秒一次重启 + health-monitor 二次重启”的循环问题。
|
|
28
|
+
|
|
29
|
+
#### 【准确性】🔐 XML 字段保真修复
|
|
30
|
+
- **WeCom** Agent XML 解析关闭自动数值化,保留发送者原始 ID。
|
|
31
|
+
- 避免 `MsgId` (64bit) 精度损失,确保回复目标不被误改。
|
|
32
|
+
|
|
33
|
+
#### 【准确性】🧹 误回复修复
|
|
34
|
+
- Bot/Agent 入站均增加事件过滤,避免处理 `event`、`sys` 及缺失发送者的消息。
|
|
35
|
+
- 修复群聊缺失 `chatid` 时仍进入 AI 会话的问题,避免“一个消息触发多人误回复”。
|
|
36
|
+
|
|
37
|
+
#### 【可控性】🧱 配置安全护栏
|
|
38
|
+
- 新增多账号冲突检测,自动拦截重复 Token 或 Agent ID 的配置。
|
|
39
|
+
- **账号管理修复**:`deleteAccount` 现在仅删除目标账号,不再误删整个 **插件** 的 `channels.wecom` 配置。
|
|
40
|
+
|
|
41
|
+
#### 【质量保障】✅ 自动化回归
|
|
42
|
+
- 新增账号解析、冲突检测、动态路由隔离、生命周期与入站过滤的多项自动化测试。
|
|
43
|
+
- **文档优化**:README 快速开始文档更新,优先展示“多账号 + 多 Agent”矩阵配置。
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
### 💾 安装与升级 (Install & Update)
|
|
48
|
+
|
|
49
|
+
使用 **OpenClaw** CLI 即可一键升级 **插件**:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
openclaw plugins upgrade wecom
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
或手动更新配置:
|
|
56
|
+
```bash
|
|
57
|
+
openclaw config set channels.wecom.enabled true
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### 🔍 SEO 关键词 (Keywords)
|
|
63
|
+
**openclaw** | **企业微信** | **微信** | **wecom** | **插件** | **AI 机器人** | **大模型网关** | **流式响应** | **多账号隔离** | **WeCom Plugin**
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
### 📮 联系我们
|
|
68
|
+
如果您在 **企业微信 / 微信** 接入过程中遇到任何问题,欢迎提交 Issue 或加入我们的交流群。
|
|
69
|
+
|
|
70
|
+
> **提示**:建议 **OpenClaw** 主程序版本保持在 **2026.2.24+** 以获得最佳体验。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.2 变更简报
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **OpenClaw 3.1+ 升级必读**:升级到 OpenClaw `3.1` 及以上版本的用户务必同步升级本插件,并将企业微信回调 URL 更新为 OpenClaw 推荐路径:`/plugins/wecom/bot/{accountId}` 与 `/plugins/wecom/agent/{accountId}`(旧 `/wecom/*` 仍兼容但不再维护)。
|
|
5
|
+
|
|
6
|
+
## 2026-03-03(今日)
|
|
7
|
+
- 【路由兼容】🧩 修复 OpenClaw 3.1 下 Control UI fallback 可能抢占 `/wecom/*` webhook 的路由冲突问题。
|
|
8
|
+
- 【引导收敛】🧭 将 WeCom onboarding 统一为账号化配置写入 `channels.wecom.accounts.<accountId>`,不再引导单账号旧结构。
|
|
9
|
+
- 【回调路径】🔁 将 WeCom 回调路径的推荐方案统一为 `/plugins/wecom/bot/{accountId}` 与 `/plugins/wecom/agent/{accountId}`。
|
|
10
|
+
- 【兼容策略】🔁 保留 `/wecom/*` 历史回调路径兼容能力,但不再维护旧路径分支。
|
|
11
|
+
- 【分流稳定】🧭 将 monitor 分流升级为按插件命名空间账号路径识别,确保 Bot/Agent 稳定命中。
|
|
12
|
+
- 【链路一致】🔒 将 Bot 上下文 `Surface` 对齐为 `wecom`,避免核心误判后错误走到 Agent outbound。
|
|
13
|
+
- 【账号必填】🧱 在 matrix 模式下对无 accountId 的基础路径返回 `wecom_matrix_path_required`,强制使用账号化回调路径。
|
|
14
|
+
- 【文档同步】📘 将回调地址文档与 onboarding 提示统一为 `/plugins/wecom/*/{accountId}` 唯一推荐路径。
|
|
15
|
+
|
|
16
|
+
## 2026-03-02(v2.3.2 主体)
|
|
17
|
+
- 【交付收口】🔄 修复 Bot 结果回写后“正在搜索相关内容”不收口的问题,并在可用时推送最终流帧结束思考态。
|
|
18
|
+
- 【媒体兜底】📎 统一非图片文件、媒体失败和超时场景为“Bot 提示 + Agent 私信兜底”闭环,保证结果可达。
|
|
19
|
+
- 【工具治理】🛡 修复 WeCom Bot 会话中 `message` 工具禁用位置,避免模型绕过 Bot 交付链路直接主动发送。
|
|
20
|
+
- 【类型兼容】🧠 扩展本地与远端文件 MIME 识别覆盖 `txt/docx/xlsx/pptx/csv/zip` 等常见类型,并保留 `octet-stream` 重试兜底。
|
|
21
|
+
- 【判定增强】🔍 将入站文件类型推断升级为“文件头特征 + 响应头 + 文件名后缀”多层判定,提升无后缀和异常 URL 的识别准确率。
|
|
22
|
+
|
|
23
|
+
## 验证结果
|
|
24
|
+
- WeCom 插件测试通过 `10` 个测试文件共 `41` 条用例,覆盖 webhook 生命周期、路径分流、媒体兜底与回归场景。
|
|
25
|
+
|
|
26
|
+
## 升级提示
|
|
27
|
+
- 推荐在企业微信后台使用 `https://<your-domain>/plugins/wecom/bot/{accountId}` 与 `https://<your-domain>/plugins/wecom/agent/{accountId}` 作为回调地址。
|
|
28
|
+
- 旧地址 `/wecom/bot/{accountId}` 与 `/wecom/agent/{accountId}` 仍兼容但不再维护,建议尽快迁移到 `/plugins/wecom/*/{accountId}`。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.4 变更简报
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **可用性热修复版本**:`v2.3.4` 重点修复 OpenClaw 3.1+ 下 WeCom webhook 路由兼容与可达性问题,保障历史配置可继续使用。
|
|
5
|
+
|
|
6
|
+
## 2026-03-03(v2.3.4 热修复)
|
|
7
|
+
- 【SDK适配】♻️ 插件入口从 `registerHttpHandler` 迁移为 `registerHttpRoute`(`/plugins/wecom` + `match=prefix` + `auth=plugin`),兼容 OpenClaw 新版插件 HTTP 注册模型。
|
|
8
|
+
- 【兼容修复】🔁 补回旧路径入口注册:新增 `/wecom` 前缀路由注册,确保历史 Bot/Agent webhook 继续可达。
|
|
9
|
+
- 【模式兼容】🧭 保持 legacy 单账号配置可运行,并明确 matrix 模式必须使用带 `accountId` 的回调路径。
|
|
10
|
+
|
|
11
|
+
## 影响说明
|
|
12
|
+
- WeCom Bot/Agent 业务链路保持兼容;核心变化为插件 HTTP 注册 API 的升级适配与旧路径可达性恢复。
|
|
13
|
+
|
|
14
|
+
## 兼容矩阵(单账号/多账号)
|
|
15
|
+
| 场景 | 配置形态 | 回调地址 | 兼容状态 | 说明 |
|
|
16
|
+
|---|---|---|---|---|
|
|
17
|
+
| 历史单账号(legacy) | `channels.wecom.bot/agent` | Bot: `/wecom`(默认)或 `/wecom/bot`;Agent: `/wecom/agent` | ✅ 兼容保留 | 适用于存量部署,不作为新引导方案。 |
|
|
18
|
+
| 多账号(matrix)错误用法 | `channels.wecom.accounts.*` | `/wecom/bot`、`/wecom/agent`(无 accountId) | ❌ 不可用 | 会返回 `wecom_matrix_path_required`。 |
|
|
19
|
+
| 多账号(matrix)兼容路径 | `channels.wecom.accounts.*` | `/wecom/bot/{accountId}`、`/wecom/agent/{accountId}` | ✅ 兼容保留 | 历史路径可用,但不再维护。 |
|
|
20
|
+
| 多账号(matrix)推荐路径 | `channels.wecom.accounts.*` | `/plugins/wecom/bot/{accountId}`、`/plugins/wecom/agent/{accountId}` | ✅ 推荐 | 当前主维护路径。 |
|
package/index.ts
CHANGED
|
@@ -12,16 +12,24 @@ const plugin = {
|
|
|
12
12
|
configSchema: emptyPluginConfigSchema(),
|
|
13
13
|
/**
|
|
14
14
|
* **register (注册插件)**
|
|
15
|
-
*
|
|
15
|
+
*
|
|
16
16
|
* OpenClaw 插件入口点。
|
|
17
17
|
* 1. 注入 Runtime 环境 (api.runtime)。
|
|
18
18
|
* 2. 注册 WeCom 渠道插件 (ChannelPlugin)。
|
|
19
|
-
* 3. 注册 Webhook HTTP
|
|
19
|
+
* 3. 注册 Webhook HTTP 路由(推荐 /plugins/wecom/*,兼容 /wecom*)。
|
|
20
20
|
*/
|
|
21
21
|
register(api: OpenClawPluginApi) {
|
|
22
22
|
setWecomRuntime(api.runtime);
|
|
23
23
|
api.registerChannel({ plugin: wecomPlugin });
|
|
24
|
-
|
|
24
|
+
const routes = ["/plugins/wecom", "/wecom"];
|
|
25
|
+
for (const path of routes) {
|
|
26
|
+
api.registerHttpRoute({
|
|
27
|
+
path,
|
|
28
|
+
handler: handleWecomWebhookRequest,
|
|
29
|
+
auth: "plugin",
|
|
30
|
+
match: "prefix",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
25
33
|
},
|
|
26
34
|
};
|
|
27
35
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mocrane/wecom",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"index.ts",
|
|
9
9
|
"src/",
|
|
10
|
+
"assets/",
|
|
11
|
+
"changelog/",
|
|
10
12
|
"openclaw.plugin.json",
|
|
11
13
|
"clawdbot.plugin.json",
|
|
12
14
|
"README.md",
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"zod": "^4.3.6"
|
|
46
48
|
},
|
|
47
49
|
"peerDependencies": {
|
|
48
|
-
"openclaw": ">=2026.
|
|
50
|
+
"openclaw": ">=2026.2.24"
|
|
49
51
|
},
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"@types/node": "^25.2.0",
|
package/src/accounts.ts
CHANGED
|
@@ -1,72 +1,34 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
|
3
|
-
|
|
4
|
-
import type { ResolvedWecomAccount, WecomAccountConfig, WecomConfig } from "./types.js";
|
|
5
|
-
|
|
6
|
-
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
|
7
|
-
const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
|
|
8
|
-
if (!accounts || typeof accounts !== "object") return [];
|
|
9
|
-
return Object.keys(accounts).filter(Boolean);
|
|
10
|
-
}
|
|
11
2
|
|
|
3
|
+
import type { ResolvedWecomAccount } from "./types/index.js";
|
|
4
|
+
import {
|
|
5
|
+
listWecomAccountIds as listWecomAccountIdsFromConfig,
|
|
6
|
+
resolveDefaultWecomAccountId as resolveDefaultWecomAccountIdFromConfig,
|
|
7
|
+
resolveWecomAccount as resolveWecomAccountFromConfig,
|
|
8
|
+
} from "./config/accounts.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Backward-compatible re-export layer.
|
|
12
|
+
* Keep this file as a thin wrapper so older imports continue to work,
|
|
13
|
+
* while all account logic stays single-sourced in `src/config/accounts.ts`.
|
|
14
|
+
*/
|
|
12
15
|
export function listWecomAccountIds(cfg: OpenClawConfig): string[] {
|
|
13
|
-
|
|
14
|
-
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
|
15
|
-
return ids.sort((a, b) => a.localeCompare(b));
|
|
16
|
+
return listWecomAccountIdsFromConfig(cfg);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function resolveDefaultWecomAccountId(cfg: OpenClawConfig): string {
|
|
19
|
-
|
|
20
|
-
if (wecomConfig?.defaultAccount?.trim()) return wecomConfig.defaultAccount.trim();
|
|
21
|
-
const ids = listWecomAccountIds(cfg);
|
|
22
|
-
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
|
23
|
-
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function resolveAccountConfig(
|
|
27
|
-
cfg: OpenClawConfig,
|
|
28
|
-
accountId: string,
|
|
29
|
-
): WecomAccountConfig | undefined {
|
|
30
|
-
const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
|
|
31
|
-
if (!accounts || typeof accounts !== "object") return undefined;
|
|
32
|
-
return accounts[accountId] as WecomAccountConfig | undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function mergeWecomAccountConfig(cfg: OpenClawConfig, accountId: string): WecomAccountConfig {
|
|
36
|
-
const raw = (cfg.channels?.wecom ?? {}) as WecomConfig;
|
|
37
|
-
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
38
|
-
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
|
39
|
-
return { ...base, ...account };
|
|
20
|
+
return resolveDefaultWecomAccountIdFromConfig(cfg);
|
|
40
21
|
}
|
|
41
22
|
|
|
42
23
|
export function resolveWecomAccount(params: {
|
|
43
24
|
cfg: OpenClawConfig;
|
|
44
25
|
accountId?: string | null;
|
|
45
26
|
}): ResolvedWecomAccount {
|
|
46
|
-
|
|
47
|
-
const baseEnabled = (params.cfg.channels?.wecom as WecomConfig | undefined)?.enabled !== false;
|
|
48
|
-
const merged = mergeWecomAccountConfig(params.cfg, accountId);
|
|
49
|
-
const enabled = baseEnabled && merged.enabled !== false;
|
|
50
|
-
|
|
51
|
-
const token = merged.token?.trim() || undefined;
|
|
52
|
-
const encodingAESKey = merged.encodingAESKey?.trim() || undefined;
|
|
53
|
-
const receiveId = merged.receiveId?.trim() ?? "";
|
|
54
|
-
const configured = Boolean(token && encodingAESKey);
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
accountId,
|
|
58
|
-
name: merged.name?.trim() || undefined,
|
|
59
|
-
enabled,
|
|
60
|
-
configured,
|
|
61
|
-
token,
|
|
62
|
-
encodingAESKey,
|
|
63
|
-
receiveId,
|
|
64
|
-
config: merged,
|
|
65
|
-
};
|
|
27
|
+
return resolveWecomAccountFromConfig(params);
|
|
66
28
|
}
|
|
67
29
|
|
|
68
30
|
export function listEnabledWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccount[] {
|
|
69
|
-
return
|
|
70
|
-
.map((accountId) =>
|
|
31
|
+
return listWecomAccountIdsFromConfig(cfg)
|
|
32
|
+
.map((accountId) => resolveWecomAccountFromConfig({ cfg, accountId }))
|
|
71
33
|
.filter((account) => account.enabled);
|
|
72
34
|
}
|
package/src/agent/api-client.ts
CHANGED
|
@@ -25,6 +25,48 @@ type TokenCache = {
|
|
|
25
25
|
|
|
26
26
|
const tokenCaches = new Map<string, TokenCache>();
|
|
27
27
|
|
|
28
|
+
function normalizeUploadFilename(filename: string): string {
|
|
29
|
+
const trimmed = filename.trim();
|
|
30
|
+
if (!trimmed) return "file.bin";
|
|
31
|
+
const ext = trimmed.includes(".") ? `.${trimmed.split(".").pop()!.toLowerCase()}` : "";
|
|
32
|
+
const base = ext ? trimmed.slice(0, -ext.length) : trimmed;
|
|
33
|
+
const sanitizedBase = base
|
|
34
|
+
.replace(/[^\x20-\x7e]/g, "_")
|
|
35
|
+
.replace(/["\\\/;=]/g, "_")
|
|
36
|
+
.replace(/\s+/g, "_")
|
|
37
|
+
.replace(/_+/g, "_")
|
|
38
|
+
.replace(/^_+|_+$/g, "");
|
|
39
|
+
const safeBase = sanitizedBase || "file";
|
|
40
|
+
const safeExt = ext.replace(/[^a-z0-9.]/g, "");
|
|
41
|
+
return `${safeBase}${safeExt || ".bin"}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function guessUploadContentType(filename: string): string {
|
|
45
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
46
|
+
const contentTypeMap: Record<string, string> = {
|
|
47
|
+
// image
|
|
48
|
+
jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp",
|
|
49
|
+
// audio / video
|
|
50
|
+
amr: "voice/amr", mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", ogg: "audio/ogg", mp4: "video/mp4", mov: "video/quicktime",
|
|
51
|
+
// documents
|
|
52
|
+
txt: "text/plain", md: "text/markdown", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json",
|
|
53
|
+
xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
|
|
54
|
+
pdf: "application/pdf", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
55
|
+
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
56
|
+
ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
57
|
+
rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
|
|
58
|
+
// archives
|
|
59
|
+
zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
|
|
60
|
+
gz: "application/gzip", tgz: "application/gzip", tar: "application/x-tar",
|
|
61
|
+
};
|
|
62
|
+
return contentTypeMap[ext] || "application/octet-stream";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireAgentId(agent: ResolvedAgentAccount): number {
|
|
66
|
+
if (typeof agent.agentId === "number" && Number.isFinite(agent.agentId)) return agent.agentId;
|
|
67
|
+
throw new Error(`wecom agent account=${agent.accountId} missing agentId; sending via cgi-bin/message/send requires agentId`);
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
/**
|
|
29
71
|
* **getAccessToken (获取 AccessToken)**
|
|
30
72
|
*
|
|
@@ -35,7 +77,7 @@ const tokenCaches = new Map<string, TokenCache>();
|
|
|
35
77
|
* @returns 有效的 AccessToken
|
|
36
78
|
*/
|
|
37
79
|
export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
|
|
38
|
-
const cacheKey = `${agent.corpId}:${agent.agentId}`;
|
|
80
|
+
const cacheKey = `${agent.corpId}:${String(agent.agentId ?? "na")}`;
|
|
39
81
|
let cache = tokenCaches.get(cacheKey);
|
|
40
82
|
|
|
41
83
|
if (!cache) {
|
|
@@ -109,7 +151,7 @@ export async function sendText(params: {
|
|
|
109
151
|
toparty: toParty,
|
|
110
152
|
totag: toTag,
|
|
111
153
|
msgtype: "text",
|
|
112
|
-
agentid: agent
|
|
154
|
+
agentid: requireAgentId(agent),
|
|
113
155
|
text: { content: text }
|
|
114
156
|
};
|
|
115
157
|
|
|
@@ -158,48 +200,53 @@ export async function uploadMedia(params: {
|
|
|
158
200
|
filename: string;
|
|
159
201
|
}): Promise<string> {
|
|
160
202
|
const { agent, type, buffer, filename } = params;
|
|
203
|
+
const safeFilename = normalizeUploadFilename(filename);
|
|
161
204
|
const token = await getAccessToken(agent);
|
|
205
|
+
const proxyUrl = resolveWecomEgressProxyUrlFromNetwork(agent.network);
|
|
162
206
|
// 添加 debug=1 参数获取更多错误信息
|
|
163
207
|
const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
|
|
164
208
|
|
|
165
209
|
// DEBUG: 输出上传信息
|
|
166
|
-
console.log(`[wecom-upload] Uploading media: type=${type}, filename=${
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
210
|
+
console.log(`[wecom-upload] Uploading media: type=${type}, filename=${safeFilename}, size=${buffer.length} bytes`);
|
|
211
|
+
|
|
212
|
+
const uploadOnce = async (fileContentType: string) => {
|
|
213
|
+
// 手动构造 multipart/form-data 请求体
|
|
214
|
+
// 企业微信要求包含 filename 和 filelength
|
|
215
|
+
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
|
|
216
|
+
|
|
217
|
+
const header = Buffer.from(
|
|
218
|
+
`--${boundary}\r\n` +
|
|
219
|
+
`Content-Disposition: form-data; name="media"; filename="${safeFilename}"; filelength=${buffer.length}\r\n` +
|
|
220
|
+
`Content-Type: ${fileContentType}\r\n\r\n`
|
|
221
|
+
);
|
|
222
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
223
|
+
const body = Buffer.concat([header, buffer, footer]);
|
|
224
|
+
|
|
225
|
+
console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
|
|
226
|
+
|
|
227
|
+
const res = await wecomFetch(url, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
231
|
+
"Content-Length": String(body.length),
|
|
232
|
+
},
|
|
233
|
+
body: body,
|
|
234
|
+
}, { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
235
|
+
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
|
|
236
|
+
console.log(`[wecom-upload] Response:`, JSON.stringify(json));
|
|
237
|
+
return json;
|
|
176
238
|
};
|
|
177
|
-
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
178
|
-
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
|
|
179
239
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`--${boundary}\r\n` +
|
|
183
|
-
`Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
|
|
184
|
-
`Content-Type: ${fileContentType}\r\n\r\n`
|
|
185
|
-
);
|
|
186
|
-
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
187
|
-
const body = Buffer.concat([header, buffer, footer]);
|
|
240
|
+
const preferredContentType = guessUploadContentType(safeFilename);
|
|
241
|
+
let json = await uploadOnce(preferredContentType);
|
|
188
242
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
},
|
|
197
|
-
body: body,
|
|
198
|
-
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
199
|
-
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
|
|
200
|
-
|
|
201
|
-
// DEBUG: 输出完整响应
|
|
202
|
-
console.log(`[wecom-upload] Response:`, JSON.stringify(json));
|
|
243
|
+
// 某些文件类型在严格网关/企业微信校验下可能失败,回退到通用类型再试一次。
|
|
244
|
+
if (!json?.media_id && preferredContentType !== "application/octet-stream") {
|
|
245
|
+
console.warn(
|
|
246
|
+
`[wecom-upload] Upload failed with ${preferredContentType}, retrying as application/octet-stream: ${json?.errcode} ${json?.errmsg}`,
|
|
247
|
+
);
|
|
248
|
+
json = await uploadOnce("application/octet-stream");
|
|
249
|
+
}
|
|
203
250
|
|
|
204
251
|
if (!json?.media_id) {
|
|
205
252
|
throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`);
|
|
@@ -252,7 +299,7 @@ export async function sendMedia(params: {
|
|
|
252
299
|
toparty: toParty,
|
|
253
300
|
totag: toTag,
|
|
254
301
|
msgtype: mediaType,
|
|
255
|
-
agentid: agent
|
|
302
|
+
agentid: requireAgentId(agent),
|
|
256
303
|
[mediaType]: mediaPayload
|
|
257
304
|
};
|
|
258
305
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
const { wecomFetchMock, resolveProxyMock } = vi.hoisted(() => ({
|
|
5
|
+
wecomFetchMock: vi.fn(),
|
|
6
|
+
resolveProxyMock: vi.fn(() => undefined),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../http.js", () => ({
|
|
10
|
+
wecomFetch: wecomFetchMock,
|
|
11
|
+
readResponseBodyAsBuffer: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../config/index.js", () => ({
|
|
15
|
+
resolveWecomEgressProxyUrlFromNetwork: resolveProxyMock,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { uploadMedia } from "./api-client.js";
|
|
19
|
+
|
|
20
|
+
function createAgent(agentId: number): ResolvedAgentAccount {
|
|
21
|
+
return {
|
|
22
|
+
accountId: `acct-${agentId}`,
|
|
23
|
+
enabled: true,
|
|
24
|
+
configured: true,
|
|
25
|
+
corpId: "corp",
|
|
26
|
+
corpSecret: "secret",
|
|
27
|
+
agentId,
|
|
28
|
+
token: "token",
|
|
29
|
+
encodingAESKey: "aes",
|
|
30
|
+
config: {} as any,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function jsonResponse(body: unknown): Response {
|
|
35
|
+
return new Response(JSON.stringify(body), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("wecom agent uploadMedia", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
wecomFetchMock.mockReset();
|
|
44
|
+
resolveProxyMock.mockReset();
|
|
45
|
+
resolveProxyMock.mockReturnValue(undefined);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses text/plain for .txt uploads", async () => {
|
|
49
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ access_token: "token-1", expires_in: 7200 }));
|
|
50
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 0, errmsg: "ok", media_id: "m-1" }));
|
|
51
|
+
|
|
52
|
+
const mediaId = await uploadMedia({
|
|
53
|
+
agent: createAgent(10001),
|
|
54
|
+
type: "file",
|
|
55
|
+
buffer: Buffer.from("hello txt"),
|
|
56
|
+
filename: "note.txt",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(mediaId).toBe("m-1");
|
|
60
|
+
const [, init] = wecomFetchMock.mock.calls[1] as [string, RequestInit];
|
|
61
|
+
const body = init.body as Buffer;
|
|
62
|
+
const bodyText = body.toString("utf8");
|
|
63
|
+
expect(bodyText).toContain('filename="note.txt"');
|
|
64
|
+
expect(bodyText).toContain("Content-Type: text/plain");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("uses docx mime and normalizes non-ascii filename", async () => {
|
|
68
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ access_token: "token-2", expires_in: 7200 }));
|
|
69
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 0, errmsg: "ok", media_id: "m-2" }));
|
|
70
|
+
|
|
71
|
+
const mediaId = await uploadMedia({
|
|
72
|
+
agent: createAgent(10002),
|
|
73
|
+
type: "file",
|
|
74
|
+
buffer: Buffer.from("docx bytes"),
|
|
75
|
+
filename: "需求文档.docx",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(mediaId).toBe("m-2");
|
|
79
|
+
const [, init] = wecomFetchMock.mock.calls[1] as [string, RequestInit];
|
|
80
|
+
const body = init.body as Buffer;
|
|
81
|
+
const bodyText = body.toString("utf8");
|
|
82
|
+
expect(bodyText).toContain('filename="file.docx"');
|
|
83
|
+
expect(bodyText).toContain(
|
|
84
|
+
"Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("retries with octet-stream when preferred mime upload fails", async () => {
|
|
89
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ access_token: "token-3", expires_in: 7200 }));
|
|
90
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 40005, errmsg: "invalid media type" }));
|
|
91
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 0, errmsg: "ok", media_id: "m-3" }));
|
|
92
|
+
|
|
93
|
+
const mediaId = await uploadMedia({
|
|
94
|
+
agent: createAgent(10003),
|
|
95
|
+
type: "file",
|
|
96
|
+
buffer: Buffer.from("yaml bytes"),
|
|
97
|
+
filename: "config.yaml",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(mediaId).toBe("m-3");
|
|
101
|
+
expect(wecomFetchMock).toHaveBeenCalledTimes(3);
|
|
102
|
+
|
|
103
|
+
const [, firstUploadInit] = wecomFetchMock.mock.calls[1] as [string, RequestInit];
|
|
104
|
+
const [, retryUploadInit] = wecomFetchMock.mock.calls[2] as [string, RequestInit];
|
|
105
|
+
const firstUploadBody = (firstUploadInit.body as Buffer).toString("utf8");
|
|
106
|
+
const retryUploadBody = (retryUploadInit.body as Buffer).toString("utf8");
|
|
107
|
+
expect(firstUploadBody).toContain("Content-Type: application/yaml");
|
|
108
|
+
expect(retryUploadBody).toContain("Content-Type: application/octet-stream");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { shouldProcessAgentInboundMessage } from "./handler.js";
|
|
4
|
+
|
|
5
|
+
describe("shouldProcessAgentInboundMessage", () => {
|
|
6
|
+
it("skips event callbacks so they do not create sessions", () => {
|
|
7
|
+
const enterAgent = shouldProcessAgentInboundMessage({
|
|
8
|
+
msgType: "event",
|
|
9
|
+
eventType: "enter_agent",
|
|
10
|
+
fromUser: "zhangsan",
|
|
11
|
+
});
|
|
12
|
+
expect(enterAgent.shouldProcess).toBe(false);
|
|
13
|
+
expect(enterAgent.reason).toBe("event:enter_agent");
|
|
14
|
+
|
|
15
|
+
const subscribe = shouldProcessAgentInboundMessage({
|
|
16
|
+
msgType: "event",
|
|
17
|
+
eventType: "subscribe",
|
|
18
|
+
fromUser: "lisi",
|
|
19
|
+
});
|
|
20
|
+
expect(subscribe.shouldProcess).toBe(false);
|
|
21
|
+
expect(subscribe.reason).toBe("event:subscribe");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("skips system sender callbacks", () => {
|
|
25
|
+
const systemSender = shouldProcessAgentInboundMessage({
|
|
26
|
+
msgType: "text",
|
|
27
|
+
fromUser: "sys",
|
|
28
|
+
});
|
|
29
|
+
expect(systemSender.shouldProcess).toBe(false);
|
|
30
|
+
expect(systemSender.reason).toBe("system_sender");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips messages with missing sender id", () => {
|
|
34
|
+
const missingSender = shouldProcessAgentInboundMessage({
|
|
35
|
+
msgType: "text",
|
|
36
|
+
fromUser: " ",
|
|
37
|
+
});
|
|
38
|
+
expect(missingSender.shouldProcess).toBe(false);
|
|
39
|
+
expect(missingSender.reason).toBe("missing_sender");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("allows normal user text message processing", () => {
|
|
43
|
+
const normalMessage = shouldProcessAgentInboundMessage({
|
|
44
|
+
msgType: "text",
|
|
45
|
+
fromUser: "wangwu",
|
|
46
|
+
});
|
|
47
|
+
expect(normalMessage.shouldProcess).toBe(true);
|
|
48
|
+
expect(normalMessage.reason).toBe("user_message");
|
|
49
|
+
});
|
|
50
|
+
});
|