@yanhaidao/wecom 2.2.7 → 2.3.2
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/.github/workflows/release.yml +56 -0
- package/CLAUDE.md +1 -1
- package/GOVERNANCE.md +26 -0
- package/LICENSE +7 -0
- package/README.md +275 -91
- package/assets/01.bot-add.png +0 -0
- package/assets/01.bot-setp2.png +0 -0
- package/assets/02.agent.add.png +0 -0
- package/assets/02.agent.api-set.png +0 -0
- package/assets/register.png +0 -0
- package/changelog/v2.2.28.md +70 -0
- package/changelog/v2.3.2.md +70 -0
- package/compat-single-account.md +118 -0
- package/package.json +10 -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 +147 -145
- package/src/channel.config.test.ts +147 -0
- package/src/channel.lifecycle.test.ts +234 -0
- package/src/channel.ts +90 -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 +35 -4
- package/src/config-schema.ts +5 -41
- package/src/dynamic-agent.account-scope.test.ts +17 -0
- package/src/dynamic-agent.ts +13 -13
- package/src/gateway-monitor.ts +200 -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 +13 -7
- package/src/monitor.inbound-filter.test.ts +63 -0
- package/src/monitor.ts +948 -128
- package/src/monitor.webhook.test.ts +288 -3
- 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 +37 -2
- package/src/types/index.ts +3 -0
- package/src/types.ts +29 -147
- package/GEMINI.md +0 -76
- package//345/212/250/346/200/201Agent/350/267/257/347/224/261.md +0 -360
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# 🚀 OpenClaw 企业微信 (WeCom) 插件 v2.3.2 - Bot/Agent 交付收口与文件发送兼容性增强
|
|
2
|
+
|
|
3
|
+
本次 v2.3.2 版本聚焦修复企业微信 Bot 模式在复杂交付场景下的稳定性问题,重点解决:
|
|
4
|
+
- Bot 已收到文件但界面仍停留在“正在搜索相关内容”不结束。
|
|
5
|
+
- 本地文件下发时,`txt`、`docx` 以及更多文件类型发送不稳定的问题。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### 🌟 版本亮点 (Release Highlights)
|
|
10
|
+
|
|
11
|
+
* ✅ **修复“正在搜索相关内容”不收口**:Agent 链路执行完成后,Bot 会主动推送最终流帧,确保企微思考态正确结束。
|
|
12
|
+
* 🔒 **修复消息工具绕过链路问题**:在 WeCom Bot 会话中正确禁用顶层 `message` 工具,避免模型绕开 Bot 流式交付导致链路错位。
|
|
13
|
+
* 📁 **本地路径下发能力增强**:支持识别 `/root`、`/home` 等 Linux 常见路径,减少文件请求误分流。
|
|
14
|
+
* 📄 **文件类型兼容大幅提升**:补齐 `txt`、`docx`、`xlsx`、`pptx`、`csv`、`zip` 等常见类型 MIME,并增强入站媒体类型推断准确率。
|
|
15
|
+
* 🛟 **未知类型自动兜底**:上传失败时自动回退到 `application/octet-stream` 重试,提高“能发出去”的成功率。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
### 📝 详细更新日志 (Changelog)
|
|
20
|
+
|
|
21
|
+
#### 【交付稳定性】🔄 Bot/Agent 链路收口修复
|
|
22
|
+
- 增加统一“最终流帧”主动推送逻辑,确保 `response_url` 可用时会主动收口。
|
|
23
|
+
- 当回复内容为空时增加最终可见兜底文案,避免企微前端悬空态。
|
|
24
|
+
- 非图片文件、媒体处理失败、超时切换等场景统一走“Bot 提示 + Agent 私信兜底”闭环。
|
|
25
|
+
|
|
26
|
+
#### 【链路一致性】🧭 工具策略修复
|
|
27
|
+
- 在 WeCom Bot 会话中将 `message` 工具写入 `tools.deny`(并同步 sandbox deny),修复错误禁用位置造成的链路绕过。
|
|
28
|
+
- 避免“文件已发出但 Bot 思考流未结束”的链路错位。
|
|
29
|
+
|
|
30
|
+
#### 【文件兼容性】📎 MIME 与上传兜底增强
|
|
31
|
+
- 扩展本地文件 MIME 推断覆盖:`txt/csv/tsv/md/json/xml/yaml/yml/pdf/doc/docx/xls/xlsx/ppt/pptx/zip/rar/7z/tar/gz/tgz/rtf/odt`。
|
|
32
|
+
- 上传 multipart 时增加文件名规范化,降低特殊文件名导致的兼容性问题。
|
|
33
|
+
- 新增“首选 MIME 失败 -> octet-stream 自动重试”机制,提升未知/边缘类型上传成功率。
|
|
34
|
+
|
|
35
|
+
#### 【类型识别优化】🧠 入站文件名与后缀判定更准确
|
|
36
|
+
- 下载并解密媒体时保留源信息(`content-type`、`content-disposition`、最终 URL),用于后续精确判断。
|
|
37
|
+
- 新增三层文件类型判定策略(按优先级):
|
|
38
|
+
- 二进制内容特征(Magic Number / 文件头)
|
|
39
|
+
- 非泛型响应头 `content-type`
|
|
40
|
+
- 文件名后缀映射
|
|
41
|
+
- 文件名确定策略(按优先级):
|
|
42
|
+
- 回调体显式文件名字段
|
|
43
|
+
- 下载响应 `content-disposition` 文件名
|
|
44
|
+
- URL 路径 basename(仅在看起来是有效文件名时采用)
|
|
45
|
+
- 兜底默认名(自动补扩展名)
|
|
46
|
+
- 对 OOXML(`docx/xlsx/pptx`)增加 ZIP 内容探测,降低仅靠 URL 无后缀时误判为 `zip/bin` 的概率。
|
|
47
|
+
|
|
48
|
+
#### 【质量保障】✅ 回归测试补充
|
|
49
|
+
- 新增上传链路单测,覆盖:
|
|
50
|
+
- `.txt` 使用 `text/plain`
|
|
51
|
+
- `.docx` 使用官方 MIME
|
|
52
|
+
- 首次 MIME 失败时自动回退重试
|
|
53
|
+
- 同步通过 WeCom monitor/outbound 相关回归测试,确保无行为回退。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### 💾 安装与升级 (Install & Update)
|
|
58
|
+
|
|
59
|
+
使用 **OpenClaw** CLI 一键升级 **插件**:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
openclaw plugins upgrade wecom
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
或手动更新版本至 `v2.3.2`。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### 📮 联系我们
|
|
70
|
+
如果您在 **企业微信 / 微信** 接入过程中遇到任何问题,欢迎提交 Issue 反馈日志与复现场景。
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# 附录 A:WeCom 单账号兼容模式配置指南
|
|
2
|
+
|
|
3
|
+
> 本文档用于历史部署或小规模场景。
|
|
4
|
+
> 新项目默认推荐多账号矩阵配置(`accounts + bindings.match.accountId`)。
|
|
5
|
+
|
|
6
|
+
## A.1 适用场景
|
|
7
|
+
|
|
8
|
+
- 已在线运行单账号配置,短期内不希望迁移。
|
|
9
|
+
- 只有一个 Bot / Agent,不需要账号隔离。
|
|
10
|
+
- 本地 PoC 或临时验证链路。
|
|
11
|
+
|
|
12
|
+
## A.2 快速配置
|
|
13
|
+
|
|
14
|
+
### A.2.1 仅 Bot(单智能体)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
openclaw config set channels.wecom.enabled true
|
|
18
|
+
openclaw config set channels.wecom.bot.token "YOUR_BOT_TOKEN"
|
|
19
|
+
openclaw config set channels.wecom.bot.encodingAESKey "YOUR_BOT_AES_KEY"
|
|
20
|
+
openclaw config set channels.wecom.bot.receiveId ""
|
|
21
|
+
openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
|
|
22
|
+
openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
|
|
23
|
+
|
|
24
|
+
# DM 门禁(推荐显式设置 policy)
|
|
25
|
+
openclaw config set channels.wecom.bot.dm.policy "open"
|
|
26
|
+
openclaw config set channels.wecom.bot.dm.allowFrom '["*"]'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### A.2.2 增加 Agent 兜底(可选)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
openclaw config set channels.wecom.agent.corpId "YOUR_CORP_ID"
|
|
33
|
+
openclaw config set channels.wecom.agent.corpSecret "YOUR_CORP_SECRET"
|
|
34
|
+
openclaw config set channels.wecom.agent.agentId 1000001
|
|
35
|
+
openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
|
|
36
|
+
openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
|
|
37
|
+
openclaw config set channels.wecom.agent.welcomeText "欢迎使用智能助手"
|
|
38
|
+
openclaw config set channels.wecom.agent.dm.policy "open"
|
|
39
|
+
openclaw config set channels.wecom.agent.dm.allowFrom '["*"]'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### A.2.3 验证
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
openclaw gateway restart
|
|
46
|
+
openclaw channels status
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## A.3 完整单账号配置结构
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
{
|
|
53
|
+
"channels": {
|
|
54
|
+
"wecom": {
|
|
55
|
+
"enabled": true,
|
|
56
|
+
|
|
57
|
+
"bot": {
|
|
58
|
+
"aibotid": "BOT_ID_OPTIONAL",
|
|
59
|
+
"token": "YOUR_BOT_TOKEN",
|
|
60
|
+
"encodingAESKey": "YOUR_BOT_AES_KEY",
|
|
61
|
+
"botIds": ["BOT_ID_OPTIONAL"],
|
|
62
|
+
"receiveId": "",
|
|
63
|
+
"streamPlaceholderContent": "正在思考...",
|
|
64
|
+
"welcomeText": "你好!我是 AI 助手",
|
|
65
|
+
"dm": {
|
|
66
|
+
"policy": "open",
|
|
67
|
+
"allowFrom": ["*"]
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
"agent": {
|
|
72
|
+
"corpId": "YOUR_CORP_ID",
|
|
73
|
+
"corpSecret": "YOUR_CORP_SECRET",
|
|
74
|
+
"agentId": 1000001,
|
|
75
|
+
"token": "YOUR_CALLBACK_TOKEN",
|
|
76
|
+
"encodingAESKey": "YOUR_CALLBACK_AES_KEY",
|
|
77
|
+
"welcomeText": "欢迎使用智能助手",
|
|
78
|
+
"dm": {
|
|
79
|
+
"policy": "open",
|
|
80
|
+
"allowFrom": ["*"]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
"media": {
|
|
85
|
+
"tempDir": "/tmp/openclaw-wecom-media",
|
|
86
|
+
"retentionHours": 24,
|
|
87
|
+
"cleanupOnStart": true,
|
|
88
|
+
"maxBytes": 26214400
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
"network": {
|
|
92
|
+
"timeoutMs": 15000,
|
|
93
|
+
"retries": 2,
|
|
94
|
+
"retryDelayMs": 500,
|
|
95
|
+
"egressProxyUrl": "http://proxy.company.local:3128"
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
"dynamicAgents": {
|
|
99
|
+
"enabled": false,
|
|
100
|
+
"dmCreateAgent": false,
|
|
101
|
+
"groupEnabled": false,
|
|
102
|
+
"adminUsers": []
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## A.4 Webhook 路径
|
|
110
|
+
|
|
111
|
+
- Bot: `/wecom/bot`
|
|
112
|
+
- Agent: `/wecom/agent`
|
|
113
|
+
|
|
114
|
+
## A.5 迁移建议
|
|
115
|
+
|
|
116
|
+
如果后续需要多 Bot / 多 Agent 隔离,建议迁移到多账号矩阵模式:
|
|
117
|
+
- 在 `channels.wecom.accounts.<accountId>` 下拆分配置
|
|
118
|
+
- 通过 `bindings[].match.accountId` 映射到对应 OpenClaw agent
|
package/package.json
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.2
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/YanHaidao/wecom.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"license": "ISC",
|
|
6
14
|
"author": "YanHaidao (VX: YanHaidao)",
|
|
7
15
|
"openclaw": {
|
|
8
16
|
"extensions": [
|
|
@@ -37,7 +45,7 @@
|
|
|
37
45
|
"zod": "^4.3.6"
|
|
38
46
|
},
|
|
39
47
|
"peerDependencies": {
|
|
40
|
-
"openclaw": ">=2026.
|
|
48
|
+
"openclaw": ">=2026.2.24"
|
|
41
49
|
},
|
|
42
50
|
"devDependencies": {
|
|
43
51
|
"@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
|
+
});
|