@mocrane/wecom 2026.2.5
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 +21 -0
- package/README.md +0 -0
- package/clawdbot.plugin.json +10 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +81 -0
- package/src/accounts.ts +72 -0
- package/src/agent/api-client.ts +336 -0
- package/src/agent/handler.ts +566 -0
- package/src/agent/index.ts +12 -0
- package/src/channel.ts +259 -0
- package/src/config/accounts.ts +99 -0
- package/src/config/index.ts +12 -0
- package/src/config/media.ts +14 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +104 -0
- package/src/config-schema.ts +41 -0
- package/src/crypto/aes.ts +108 -0
- package/src/crypto/index.ts +24 -0
- package/src/crypto/signature.ts +43 -0
- package/src/crypto/xml.ts +49 -0
- package/src/crypto.test.ts +32 -0
- package/src/crypto.ts +176 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +55 -0
- package/src/media.ts +55 -0
- package/src/monitor/state.queue.test.ts +185 -0
- package/src/monitor/state.ts +514 -0
- package/src/monitor/types.ts +136 -0
- package/src/monitor.active.test.ts +239 -0
- package/src/monitor.integration.test.ts +207 -0
- package/src/monitor.ts +1802 -0
- package/src/monitor.webhook.test.ts +311 -0
- package/src/onboarding.ts +472 -0
- package/src/outbound.test.ts +143 -0
- package/src/outbound.ts +200 -0
- package/src/runtime.ts +14 -0
- package/src/shared/command-auth.ts +101 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/xml-parser.test.ts +30 -0
- package/src/shared/xml-parser.ts +183 -0
- package/src/target.ts +80 -0
- package/src/types/account.ts +76 -0
- package/src/types/config.ts +88 -0
- package/src/types/constants.ts +42 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/index.ts +38 -0
- package/src/types/message.ts +185 -0
- package/src/types.ts +159 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xl370869-art
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
File without changes
|
package/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
import { handleWecomWebhookRequest } from "./src/monitor.js";
|
|
5
|
+
import { setWecomRuntime } from "./src/runtime.js";
|
|
6
|
+
import { wecomPlugin } from "./src/channel.js";
|
|
7
|
+
|
|
8
|
+
const plugin = {
|
|
9
|
+
id: "wecom",
|
|
10
|
+
name: "WeCom",
|
|
11
|
+
description: "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
12
|
+
configSchema: emptyPluginConfigSchema(),
|
|
13
|
+
/**
|
|
14
|
+
* **register (注册插件)**
|
|
15
|
+
*
|
|
16
|
+
* OpenClaw 插件入口点。
|
|
17
|
+
* 1. 注入 Runtime 环境 (api.runtime)。
|
|
18
|
+
* 2. 注册 WeCom 渠道插件 (ChannelPlugin)。
|
|
19
|
+
* 3. 注册 Webhook HTTP 处理器 (handleWecomWebhookRequest)。
|
|
20
|
+
*/
|
|
21
|
+
register(api: OpenClawPluginApi) {
|
|
22
|
+
setWecomRuntime(api.runtime);
|
|
23
|
+
api.registerChannel({ plugin: wecomPlugin });
|
|
24
|
+
api.registerHttpHandler(handleWecomWebhookRequest);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mocrane/wecom",
|
|
3
|
+
"version": "2026.2.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"src/",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"clawdbot.plugin.json",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"openclaw": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./index.ts"
|
|
18
|
+
],
|
|
19
|
+
"channel": {
|
|
20
|
+
"id": "wecom",
|
|
21
|
+
"label": "WeCom",
|
|
22
|
+
"selectionLabel": "WeCom (plugin)",
|
|
23
|
+
"detailLabel": "WeCom Bot",
|
|
24
|
+
"docsPath": "/channels/wecom",
|
|
25
|
+
"docsLabel": "wecom",
|
|
26
|
+
"blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
|
|
27
|
+
"aliases": [
|
|
28
|
+
"wechatwork",
|
|
29
|
+
"wework",
|
|
30
|
+
"qywx",
|
|
31
|
+
"企微",
|
|
32
|
+
"企业微信"
|
|
33
|
+
],
|
|
34
|
+
"order": 85
|
|
35
|
+
},
|
|
36
|
+
"install": {
|
|
37
|
+
"npmSpec": "@mocrane/wecom",
|
|
38
|
+
"localPath": "extensions/wecom",
|
|
39
|
+
"defaultChoice": "npm"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"fast-xml-parser": "5.3.4",
|
|
44
|
+
"undici": "^7.20.0",
|
|
45
|
+
"zod": "^4.3.6"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"openclaw": ">=2026.1.26"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^25.2.0",
|
|
52
|
+
"typescript": "^5.9.3"
|
|
53
|
+
},
|
|
54
|
+
"clawdbot": {
|
|
55
|
+
"extensions": [
|
|
56
|
+
"./index.ts"
|
|
57
|
+
],
|
|
58
|
+
"channel": {
|
|
59
|
+
"id": "wecom",
|
|
60
|
+
"label": "WeCom",
|
|
61
|
+
"selectionLabel": "WeCom (plugin)",
|
|
62
|
+
"detailLabel": "WeCom Bot",
|
|
63
|
+
"docsPath": "/channels/wecom",
|
|
64
|
+
"docsLabel": "wecom",
|
|
65
|
+
"blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
|
|
66
|
+
"aliases": [
|
|
67
|
+
"wechatwork",
|
|
68
|
+
"wework",
|
|
69
|
+
"qywx",
|
|
70
|
+
"企微",
|
|
71
|
+
"企业微信"
|
|
72
|
+
],
|
|
73
|
+
"order": 85
|
|
74
|
+
},
|
|
75
|
+
"install": {
|
|
76
|
+
"npmSpec": "@mocrane/wecom",
|
|
77
|
+
"localPath": "extensions/wecom",
|
|
78
|
+
"defaultChoice": "npm"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
|
|
12
|
+
export function listWecomAccountIds(cfg: OpenClawConfig): string[] {
|
|
13
|
+
const ids = listConfiguredAccountIds(cfg);
|
|
14
|
+
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
|
15
|
+
return ids.sort((a, b) => a.localeCompare(b));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveDefaultWecomAccountId(cfg: OpenClawConfig): string {
|
|
19
|
+
const wecomConfig = cfg.channels?.wecom as WecomConfig | undefined;
|
|
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 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveWecomAccount(params: {
|
|
43
|
+
cfg: OpenClawConfig;
|
|
44
|
+
accountId?: string | null;
|
|
45
|
+
}): ResolvedWecomAccount {
|
|
46
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
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
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listEnabledWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccount[] {
|
|
69
|
+
return listWecomAccountIds(cfg)
|
|
70
|
+
.map((accountId) => resolveWecomAccount({ cfg, accountId }))
|
|
71
|
+
.filter((account) => account.enabled);
|
|
72
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom Agent API 客户端
|
|
3
|
+
* 管理 AccessToken 缓存和 API 调用
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { API_ENDPOINTS, LIMITS } from "../types/constants.js";
|
|
8
|
+
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
9
|
+
import { readResponseBodyAsBuffer, wecomFetch } from "../http.js";
|
|
10
|
+
import { resolveWecomEgressProxyUrlFromNetwork } from "../config/index.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* **TokenCache (AccessToken 缓存结构)**
|
|
14
|
+
*
|
|
15
|
+
* 用于缓存企业微信 API 调用所需的 AccessToken。
|
|
16
|
+
* @property token 缓存的 Token 字符串
|
|
17
|
+
* @property expiresAt 过期时间戳 (ms)
|
|
18
|
+
* @property refreshPromise 当前正在进行的刷新 Promise (防止并发刷新)
|
|
19
|
+
*/
|
|
20
|
+
type TokenCache = {
|
|
21
|
+
token: string;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
refreshPromise: Promise<string> | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const tokenCaches = new Map<string, TokenCache>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* **getAccessToken (获取 AccessToken)**
|
|
30
|
+
*
|
|
31
|
+
* 获取企业微信 API 调用所需的 AccessToken。
|
|
32
|
+
* 具备自动缓存和过期刷新机制。
|
|
33
|
+
*
|
|
34
|
+
* @param agent Agent 账号信息
|
|
35
|
+
* @returns 有效的 AccessToken
|
|
36
|
+
*/
|
|
37
|
+
export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
|
|
38
|
+
const cacheKey = `${agent.corpId}:${agent.agentId}`;
|
|
39
|
+
let cache = tokenCaches.get(cacheKey);
|
|
40
|
+
|
|
41
|
+
if (!cache) {
|
|
42
|
+
cache = { token: "", expiresAt: 0, refreshPromise: null };
|
|
43
|
+
tokenCaches.set(cacheKey, cache);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (cache.token && cache.expiresAt > now + LIMITS.TOKEN_REFRESH_BUFFER_MS) {
|
|
48
|
+
return cache.token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 防止并发刷新
|
|
52
|
+
if (cache.refreshPromise) {
|
|
53
|
+
return cache.refreshPromise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cache.refreshPromise = (async () => {
|
|
57
|
+
try {
|
|
58
|
+
const url = `${API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
|
|
59
|
+
const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
60
|
+
const json = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
|
|
61
|
+
|
|
62
|
+
if (!json?.access_token) {
|
|
63
|
+
throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
cache!.token = json.access_token;
|
|
67
|
+
cache!.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
|
|
68
|
+
return cache!.token;
|
|
69
|
+
} finally {
|
|
70
|
+
cache!.refreshPromise = null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
return cache.refreshPromise;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* **sendText (发送文本消息)**
|
|
79
|
+
*
|
|
80
|
+
* 调用 `message/send` (Agent) 或 `appchat/send` (群聊) 发送文本。
|
|
81
|
+
*
|
|
82
|
+
* @param params.agent 发送方 Agent
|
|
83
|
+
* @param params.toUser 接收用户 ID (单聊可选,可与 toParty/toTag 同时使用)
|
|
84
|
+
* @param params.toParty 接收部门 ID (单聊可选)
|
|
85
|
+
* @param params.toTag 接收标签 ID (单聊可选)
|
|
86
|
+
* @param params.chatId 接收群 ID (群聊模式必填,互斥)
|
|
87
|
+
* @param params.text 消息内容
|
|
88
|
+
*/
|
|
89
|
+
export async function sendText(params: {
|
|
90
|
+
agent: ResolvedAgentAccount;
|
|
91
|
+
toUser?: string;
|
|
92
|
+
toParty?: string;
|
|
93
|
+
toTag?: string;
|
|
94
|
+
chatId?: string;
|
|
95
|
+
text: string;
|
|
96
|
+
}): Promise<void> {
|
|
97
|
+
const { agent, toUser, toParty, toTag, chatId, text } = params;
|
|
98
|
+
const token = await getAccessToken(agent);
|
|
99
|
+
|
|
100
|
+
const useChat = Boolean(chatId);
|
|
101
|
+
const url = useChat
|
|
102
|
+
? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
|
|
103
|
+
: `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
104
|
+
|
|
105
|
+
const body = useChat
|
|
106
|
+
? { chatid: chatId, msgtype: "text", text: { content: text } }
|
|
107
|
+
: {
|
|
108
|
+
touser: toUser,
|
|
109
|
+
toparty: toParty,
|
|
110
|
+
totag: toTag,
|
|
111
|
+
msgtype: "text",
|
|
112
|
+
agentid: agent.agentId,
|
|
113
|
+
text: { content: text }
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const res = await wecomFetch(url, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
121
|
+
const json = await res.json() as {
|
|
122
|
+
errcode?: number;
|
|
123
|
+
errmsg?: string;
|
|
124
|
+
invaliduser?: string;
|
|
125
|
+
invalidparty?: string;
|
|
126
|
+
invalidtag?: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (json?.errcode !== 0) {
|
|
130
|
+
throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
134
|
+
const details = [
|
|
135
|
+
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
|
|
136
|
+
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
|
|
137
|
+
json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
|
|
138
|
+
].filter(Boolean).join(", ");
|
|
139
|
+
throw new Error(`send partial failure: ${details}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* **uploadMedia (上传媒体文件)**
|
|
145
|
+
*
|
|
146
|
+
* 上传临时素材到企业微信。
|
|
147
|
+
* 素材有效期为 3 天。
|
|
148
|
+
*
|
|
149
|
+
* @param params.type 媒体类型 (image, voice, video, file)
|
|
150
|
+
* @param params.buffer 文件二进制数据
|
|
151
|
+
* @param params.filename 文件名 (需包含正确扩展名)
|
|
152
|
+
* @returns 媒体 ID (media_id)
|
|
153
|
+
*/
|
|
154
|
+
export async function uploadMedia(params: {
|
|
155
|
+
agent: ResolvedAgentAccount;
|
|
156
|
+
type: "image" | "voice" | "video" | "file";
|
|
157
|
+
buffer: Buffer;
|
|
158
|
+
filename: string;
|
|
159
|
+
}): Promise<string> {
|
|
160
|
+
const { agent, type, buffer, filename } = params;
|
|
161
|
+
const token = await getAccessToken(agent);
|
|
162
|
+
// 添加 debug=1 参数获取更多错误信息
|
|
163
|
+
const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
|
|
164
|
+
|
|
165
|
+
// DEBUG: 输出上传信息
|
|
166
|
+
console.log(`[wecom-upload] Uploading media: type=${type}, filename=${filename}, size=${buffer.length} bytes`);
|
|
167
|
+
|
|
168
|
+
// 手动构造 multipart/form-data 请求体
|
|
169
|
+
// 企业微信要求包含 filename 和 filelength
|
|
170
|
+
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
|
|
171
|
+
|
|
172
|
+
// 根据文件类型设置 Content-Type
|
|
173
|
+
const contentTypeMap: Record<string, string> = {
|
|
174
|
+
jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
175
|
+
bmp: "image/bmp", amr: "voice/amr", mp4: "video/mp4",
|
|
176
|
+
};
|
|
177
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
178
|
+
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
|
|
179
|
+
|
|
180
|
+
// 构造 multipart body
|
|
181
|
+
const header = Buffer.from(
|
|
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]);
|
|
188
|
+
|
|
189
|
+
console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
|
|
190
|
+
|
|
191
|
+
const res = await wecomFetch(url, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
195
|
+
"Content-Length": String(body.length),
|
|
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));
|
|
203
|
+
|
|
204
|
+
if (!json?.media_id) {
|
|
205
|
+
throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`);
|
|
206
|
+
}
|
|
207
|
+
return json.media_id;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* **sendMedia (发送媒体消息)**
|
|
212
|
+
*
|
|
213
|
+
* 发送图片、音频、视频或文件。需先通过 `uploadMedia` 获取 media_id。
|
|
214
|
+
*
|
|
215
|
+
* @param params.agent 发送方 Agent
|
|
216
|
+
* @param params.toUser 接收用户 ID (单聊可选)
|
|
217
|
+
* @param params.toParty 接收部门 ID (单聊可选)
|
|
218
|
+
* @param params.toTag 接收标签 ID (单聊可选)
|
|
219
|
+
* @param params.chatId 接收群 ID (群聊模式必填)
|
|
220
|
+
* @param params.mediaId 媒体 ID
|
|
221
|
+
* @param params.mediaType 媒体类型
|
|
222
|
+
* @param params.title 视频标题 (可选)
|
|
223
|
+
* @param params.description 视频描述 (可选)
|
|
224
|
+
*/
|
|
225
|
+
export async function sendMedia(params: {
|
|
226
|
+
agent: ResolvedAgentAccount;
|
|
227
|
+
toUser?: string;
|
|
228
|
+
toParty?: string;
|
|
229
|
+
toTag?: string;
|
|
230
|
+
chatId?: string;
|
|
231
|
+
mediaId: string;
|
|
232
|
+
mediaType: "image" | "voice" | "video" | "file";
|
|
233
|
+
title?: string;
|
|
234
|
+
description?: string;
|
|
235
|
+
}): Promise<void> {
|
|
236
|
+
const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType, title, description } = params;
|
|
237
|
+
const token = await getAccessToken(agent);
|
|
238
|
+
|
|
239
|
+
const useChat = Boolean(chatId);
|
|
240
|
+
const url = useChat
|
|
241
|
+
? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
|
|
242
|
+
: `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
243
|
+
|
|
244
|
+
const mediaPayload = mediaType === "video"
|
|
245
|
+
? { media_id: mediaId, title: title ?? "Video", description: description ?? "" }
|
|
246
|
+
: { media_id: mediaId };
|
|
247
|
+
|
|
248
|
+
const body = useChat
|
|
249
|
+
? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
|
|
250
|
+
: {
|
|
251
|
+
touser: toUser,
|
|
252
|
+
toparty: toParty,
|
|
253
|
+
totag: toTag,
|
|
254
|
+
msgtype: mediaType,
|
|
255
|
+
agentid: agent.agentId,
|
|
256
|
+
[mediaType]: mediaPayload
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const res = await wecomFetch(url, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "Content-Type": "application/json" },
|
|
262
|
+
body: JSON.stringify(body),
|
|
263
|
+
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
264
|
+
const json = await res.json() as {
|
|
265
|
+
errcode?: number;
|
|
266
|
+
errmsg?: string;
|
|
267
|
+
invaliduser?: string;
|
|
268
|
+
invalidparty?: string;
|
|
269
|
+
invalidtag?: string;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (json?.errcode !== 0) {
|
|
273
|
+
throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
277
|
+
const details = [
|
|
278
|
+
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
|
|
279
|
+
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
|
|
280
|
+
json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
|
|
281
|
+
].filter(Boolean).join(", ");
|
|
282
|
+
throw new Error(`send ${mediaType} partial failure: ${details}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* **downloadMedia (下载媒体文件)**
|
|
288
|
+
*
|
|
289
|
+
* 通过 media_id 从企业微信服务器下载临时素材。
|
|
290
|
+
*
|
|
291
|
+
* @returns { buffer, contentType }
|
|
292
|
+
*/
|
|
293
|
+
export async function downloadMedia(params: {
|
|
294
|
+
agent: ResolvedAgentAccount;
|
|
295
|
+
mediaId: string;
|
|
296
|
+
maxBytes?: number;
|
|
297
|
+
}): Promise<{ buffer: Buffer; contentType: string; filename?: string }> {
|
|
298
|
+
const { agent, mediaId } = params;
|
|
299
|
+
const token = await getAccessToken(agent);
|
|
300
|
+
const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
301
|
+
|
|
302
|
+
const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
303
|
+
|
|
304
|
+
if (!res.ok) {
|
|
305
|
+
throw new Error(`download failed: ${res.status}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
309
|
+
const disposition = res.headers.get("content-disposition") || "";
|
|
310
|
+
const filename = (() => {
|
|
311
|
+
// 兼容:filename="a.md" / filename=a.md / filename*=UTF-8''a%2Eb.md
|
|
312
|
+
const mStar = disposition.match(/filename\*\s*=\s*([^;]+)/i);
|
|
313
|
+
if (mStar) {
|
|
314
|
+
const raw = mStar[1]!.trim().replace(/^"(.*)"$/, "$1");
|
|
315
|
+
const parts = raw.split("''");
|
|
316
|
+
const encoded = parts.length === 2 ? parts[1]! : raw;
|
|
317
|
+
try {
|
|
318
|
+
return decodeURIComponent(encoded);
|
|
319
|
+
} catch {
|
|
320
|
+
return encoded;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const m = disposition.match(/filename\s*=\s*([^;]+)/i);
|
|
324
|
+
if (!m) return undefined;
|
|
325
|
+
return m[1]!.trim().replace(/^"(.*)"$/, "$1") || undefined;
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
// 检查是否返回了错误 JSON
|
|
329
|
+
if (contentType.includes("application/json")) {
|
|
330
|
+
const json = await res.json() as { errcode?: number; errmsg?: string };
|
|
331
|
+
throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const buffer = await readResponseBodyAsBuffer(res, params.maxBytes);
|
|
335
|
+
return { buffer, contentType, filename };
|
|
336
|
+
}
|