@sunnoy/wecom 1.2.0 → 1.4.0
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/README.md +831 -147
- package/dynamic-agent.js +18 -4
- package/index.js +16 -1602
- package/package.json +8 -2
- package/wecom/accounts.js +258 -0
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +441 -0
- package/wecom/allow-from.js +45 -0
- package/wecom/channel-plugin.js +732 -0
- package/wecom/commands.js +90 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +531 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +484 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +84 -0
- package/wecom/stream-utils.js +124 -0
- package/wecom/target.js +57 -0
- package/wecom/webhook-bot.js +155 -0
- package/wecom/webhook-targets.js +28 -0
- package/wecom/workspace-template.js +165 -0
- package/wecom/xml-parser.js +126 -0
- package/README_ZH.md +0 -303
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sunnoy/wecom",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
8
8
|
"index.js",
|
|
9
|
+
"wecom",
|
|
9
10
|
"crypto.js",
|
|
10
11
|
"dynamic-agent.js",
|
|
11
12
|
"image-processor.js",
|
|
12
13
|
"logger.js",
|
|
13
14
|
"README.md",
|
|
14
|
-
"README_ZH.md",
|
|
15
15
|
"LICENSE",
|
|
16
16
|
"CONTRIBUTING.md",
|
|
17
17
|
"stream-manager.js",
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"openclaw": "*"
|
|
24
24
|
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "npm run test:unit",
|
|
27
|
+
"test:unit": "node --test tests/*.test.js",
|
|
28
|
+
"test:e2e": "node --test tests/e2e/*.e2e.test.js",
|
|
29
|
+
"test:e2e:ali-ai": "bash tests/e2e/run-ali-ai.sh"
|
|
30
|
+
},
|
|
25
31
|
"openclaw": {
|
|
26
32
|
"extensions": [
|
|
27
33
|
"./index.js"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-account resolution layer.
|
|
3
|
+
*
|
|
4
|
+
* Design: dictionary-based — each key under `channels.wecom` is an account ID,
|
|
5
|
+
* and its value contains the full per-account config (token, encodingAesKey,
|
|
6
|
+
* agent, webhooks, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Legacy single-account configs (where `token` exists directly under `wecom`)
|
|
9
|
+
* are auto-detected and treated as accountId = "default".
|
|
10
|
+
*
|
|
11
|
+
* ── Multi-account config ───────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* channels:
|
|
14
|
+
* wecom:
|
|
15
|
+
* bot1:
|
|
16
|
+
* token: "bot-token-a"
|
|
17
|
+
* encodingAesKey: "..."
|
|
18
|
+
* agent:
|
|
19
|
+
* corpId: "ww1234"
|
|
20
|
+
* corpSecret: "secret-a"
|
|
21
|
+
* agentId: 1000001
|
|
22
|
+
* webhooks:
|
|
23
|
+
* ops-group: "key-xxx"
|
|
24
|
+
* bot2:
|
|
25
|
+
* token: "bot-token-b"
|
|
26
|
+
* encodingAesKey: "..."
|
|
27
|
+
* agent:
|
|
28
|
+
* corpId: "ww5678"
|
|
29
|
+
* corpSecret: "secret-b"
|
|
30
|
+
* agentId: 1000002
|
|
31
|
+
*
|
|
32
|
+
* ── Legacy single-account config (auto-detected, fully compatible) ─
|
|
33
|
+
*
|
|
34
|
+
* channels:
|
|
35
|
+
* wecom:
|
|
36
|
+
* token: "bot-token-a"
|
|
37
|
+
* encodingAesKey: "..."
|
|
38
|
+
* agent:
|
|
39
|
+
* corpId: "ww1234"
|
|
40
|
+
* corpSecret: "secret-a"
|
|
41
|
+
* agentId: 1000001
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { logger } from "../logger.js";
|
|
45
|
+
import { DEFAULT_ACCOUNT_ID } from "./constants.js";
|
|
46
|
+
|
|
47
|
+
// Keys that belong to the top-level wecom config and are NOT account IDs.
|
|
48
|
+
const RESERVED_KEYS = new Set([
|
|
49
|
+
"enabled",
|
|
50
|
+
"token",
|
|
51
|
+
"encodingAesKey",
|
|
52
|
+
"agent",
|
|
53
|
+
"webhooks",
|
|
54
|
+
"webhookPath",
|
|
55
|
+
"name",
|
|
56
|
+
"allowFrom",
|
|
57
|
+
"commandAllowlist",
|
|
58
|
+
"commandBlockMessage",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect whether the wecom config block is legacy (single-account) format.
|
|
65
|
+
* Heuristic: if `token` exists directly under `channels.wecom`, it's legacy.
|
|
66
|
+
*/
|
|
67
|
+
function isLegacyConfig(wecom) {
|
|
68
|
+
return typeof wecom?.token === "string";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a resolved account object from a per-account config block.
|
|
73
|
+
*/
|
|
74
|
+
function buildAccount(accountId, accountCfg) {
|
|
75
|
+
const agent = accountCfg?.agent;
|
|
76
|
+
const webhooks = accountCfg?.webhooks;
|
|
77
|
+
const agentConfigured = Boolean(agent?.corpId && agent?.corpSecret && agent?.agentId);
|
|
78
|
+
const agentInboundConfigured = Boolean(
|
|
79
|
+
agent?.corpId && agent?.corpSecret && agent?.agentId && agent?.token && agent?.encodingAesKey,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
accountId,
|
|
84
|
+
name: accountCfg?.name || accountId,
|
|
85
|
+
enabled: accountCfg?.enabled !== false,
|
|
86
|
+
configured: Boolean(accountCfg?.token && accountCfg?.encodingAesKey) || agentConfigured,
|
|
87
|
+
token: accountCfg?.token || "",
|
|
88
|
+
encodingAesKey: accountCfg?.encodingAesKey || "",
|
|
89
|
+
webhookPath:
|
|
90
|
+
accountCfg?.webhookPath ||
|
|
91
|
+
(accountId === DEFAULT_ACCOUNT_ID ? "/webhooks/wecom" : `/webhooks/wecom/${accountId}`),
|
|
92
|
+
config: accountCfg || {},
|
|
93
|
+
agentConfigured,
|
|
94
|
+
agentInboundConfigured,
|
|
95
|
+
webhooksConfigured: Boolean(webhooks && Object.keys(webhooks).length > 0),
|
|
96
|
+
agentCredentials: agentConfigured
|
|
97
|
+
? { corpId: agent.corpId, corpSecret: agent.corpSecret, agentId: agent.agentId }
|
|
98
|
+
: null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalize a raw account key → canonical ID (lowercase, safe chars only).
|
|
104
|
+
*/
|
|
105
|
+
function normalizeAccountKey(key) {
|
|
106
|
+
return String(key).trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List all configured account IDs.
|
|
113
|
+
* Returns `["default"]` for legacy single-account configs.
|
|
114
|
+
*/
|
|
115
|
+
export function listAccountIds(cfg) {
|
|
116
|
+
const wecom = cfg?.channels?.wecom;
|
|
117
|
+
if (!wecom || wecom.enabled === false) return [];
|
|
118
|
+
|
|
119
|
+
// Legacy single-account → just "default".
|
|
120
|
+
if (isLegacyConfig(wecom)) return [DEFAULT_ACCOUNT_ID];
|
|
121
|
+
|
|
122
|
+
// Dictionary mode — each non-reserved key is an account.
|
|
123
|
+
const ids = [];
|
|
124
|
+
for (const key of Object.keys(wecom)) {
|
|
125
|
+
if (RESERVED_KEYS.has(key)) continue;
|
|
126
|
+
const val = wecom[key];
|
|
127
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
128
|
+
const id = normalizeAccountKey(key);
|
|
129
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ids.length === 0) {
|
|
134
|
+
logger.warn("[accounts] wecom config has no account entries and no legacy token — returning empty");
|
|
135
|
+
}
|
|
136
|
+
return ids;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a single account by its ID.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveAccount(cfg, accountId) {
|
|
143
|
+
const wecom = cfg?.channels?.wecom;
|
|
144
|
+
if (!wecom) return null;
|
|
145
|
+
|
|
146
|
+
const resolvedId = accountId || DEFAULT_ACCOUNT_ID;
|
|
147
|
+
|
|
148
|
+
// Legacy single-account: the entire wecom block IS the account config.
|
|
149
|
+
if (isLegacyConfig(wecom)) {
|
|
150
|
+
if (resolvedId !== DEFAULT_ACCOUNT_ID) {
|
|
151
|
+
logger.warn(`[accounts] legacy config does not have account "${resolvedId}"`);
|
|
152
|
+
return buildAccount(resolvedId, { enabled: false });
|
|
153
|
+
}
|
|
154
|
+
return buildAccount(DEFAULT_ACCOUNT_ID, wecom);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Dictionary mode: look up key (case-insensitive).
|
|
158
|
+
const normalizedId = normalizeAccountKey(resolvedId);
|
|
159
|
+
for (const key of Object.keys(wecom)) {
|
|
160
|
+
if (RESERVED_KEYS.has(key)) continue;
|
|
161
|
+
if (normalizeAccountKey(key) === normalizedId) {
|
|
162
|
+
const val = wecom[key];
|
|
163
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
164
|
+
return buildAccount(normalizedId, val);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Not found.
|
|
170
|
+
return buildAccount(resolvedId, { enabled: false });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve all accounts as a Map<accountId, account>.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveAllAccounts(cfg) {
|
|
177
|
+
const ids = listAccountIds(cfg);
|
|
178
|
+
const accounts = new Map();
|
|
179
|
+
for (const id of ids) {
|
|
180
|
+
accounts.set(id, resolveAccount(cfg, id));
|
|
181
|
+
}
|
|
182
|
+
return accounts;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract Agent API credentials for a given accountId.
|
|
187
|
+
* Returns `{ corpId, corpSecret, agentId }` or null.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveAgentConfigForAccount(cfg, accountId) {
|
|
190
|
+
const account = resolveAccount(cfg, accountId);
|
|
191
|
+
return account?.agentCredentials ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Detect duplicate tokens / agentIds across accounts.
|
|
196
|
+
* Returns an array of conflict descriptions (empty = no conflicts).
|
|
197
|
+
*/
|
|
198
|
+
export function detectAccountConflicts(cfg) {
|
|
199
|
+
const accounts = resolveAllAccounts(cfg);
|
|
200
|
+
const conflicts = [];
|
|
201
|
+
|
|
202
|
+
const tokenOwners = new Map();
|
|
203
|
+
const agentIdOwners = new Map();
|
|
204
|
+
|
|
205
|
+
for (const [id, account] of accounts) {
|
|
206
|
+
if (!account.enabled) continue;
|
|
207
|
+
|
|
208
|
+
// Check bot token uniqueness.
|
|
209
|
+
const token = account.token?.trim();
|
|
210
|
+
if (token) {
|
|
211
|
+
const key = token.toLowerCase();
|
|
212
|
+
if (tokenOwners.has(key)) {
|
|
213
|
+
const owner = tokenOwners.get(key);
|
|
214
|
+
conflicts.push({
|
|
215
|
+
type: "duplicate_token",
|
|
216
|
+
accounts: [owner, id],
|
|
217
|
+
message: `账号 "${id}" 与 "${owner}" 使用了相同的 Bot Token,会导致消息错乱。`,
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
tokenOwners.set(key, id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check agent corpId+agentId uniqueness.
|
|
225
|
+
const creds = account.agentCredentials;
|
|
226
|
+
if (creds) {
|
|
227
|
+
const key = `${creds.corpId}:${creds.agentId}`;
|
|
228
|
+
if (agentIdOwners.has(key)) {
|
|
229
|
+
const owner = agentIdOwners.get(key);
|
|
230
|
+
conflicts.push({
|
|
231
|
+
type: "duplicate_agent",
|
|
232
|
+
accounts: [owner, id],
|
|
233
|
+
message: `账号 "${id}" 与 "${owner}" 使用了相同的 Agent 配置 (${creds.corpId}/${creds.agentId})。`,
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
agentIdOwners.set(key, id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return conflicts;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Find which accountId owns a given bot token.
|
|
246
|
+
* Useful for inbound routing when the request carries a token.
|
|
247
|
+
*/
|
|
248
|
+
export function findAccountByToken(cfg, token) {
|
|
249
|
+
if (!token) return null;
|
|
250
|
+
const key = token.trim().toLowerCase();
|
|
251
|
+
const accounts = resolveAllAccounts(cfg);
|
|
252
|
+
for (const [id, account] of accounts) {
|
|
253
|
+
if (account.enabled && account.token?.trim().toLowerCase() === key) {
|
|
254
|
+
return id;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom Agent API Client
|
|
3
|
+
* Manages AccessToken caching and API calls for self-built applications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { logger } from "../logger.js";
|
|
8
|
+
import {
|
|
9
|
+
AGENT_API_ENDPOINTS,
|
|
10
|
+
AGENT_API_REQUEST_TIMEOUT_MS,
|
|
11
|
+
TOKEN_REFRESH_BUFFER_MS,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Token cache: Map<corpId:agentId, { token, expiresAt, refreshPromise }>
|
|
16
|
+
*/
|
|
17
|
+
const tokenCaches = new Map();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get a valid AccessToken, with caching and concurrent-refresh protection.
|
|
21
|
+
* @param {object} agent - { corpId, corpSecret, agentId }
|
|
22
|
+
* @returns {Promise<string>}
|
|
23
|
+
*/
|
|
24
|
+
export async function getAccessToken(agent) {
|
|
25
|
+
const cacheKey = `${agent.corpId}:${agent.agentId}`;
|
|
26
|
+
let cache = tokenCaches.get(cacheKey);
|
|
27
|
+
|
|
28
|
+
if (!cache) {
|
|
29
|
+
cache = { token: "", expiresAt: 0, refreshPromise: null };
|
|
30
|
+
tokenCaches.set(cacheKey, cache);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (cache.token && cache.expiresAt > now + TOKEN_REFRESH_BUFFER_MS) {
|
|
35
|
+
return cache.token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Reuse in-flight refresh to prevent concurrent token requests.
|
|
39
|
+
if (cache.refreshPromise) {
|
|
40
|
+
return cache.refreshPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cache.refreshPromise = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
const url = `${AGENT_API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
|
|
46
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
|
|
47
|
+
const json = await res.json();
|
|
48
|
+
|
|
49
|
+
if (!json?.access_token) {
|
|
50
|
+
throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cache.token = json.access_token;
|
|
54
|
+
cache.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
|
|
55
|
+
return cache.token;
|
|
56
|
+
} finally {
|
|
57
|
+
cache.refreshPromise = null;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
return cache.refreshPromise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a text message via Agent API.
|
|
66
|
+
*
|
|
67
|
+
* Uses `message/send` for user/party/tag targets, `appchat/send` for group chats.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} params
|
|
70
|
+
* @param {object} params.agent - { corpId, corpSecret, agentId }
|
|
71
|
+
* @param {string} [params.toUser]
|
|
72
|
+
* @param {string} [params.toParty]
|
|
73
|
+
* @param {string} [params.toTag]
|
|
74
|
+
* @param {string} [params.chatId]
|
|
75
|
+
* @param {string} params.text
|
|
76
|
+
*/
|
|
77
|
+
export async function agentSendText(params) {
|
|
78
|
+
const { agent, toUser, toParty, toTag, chatId, text } = params;
|
|
79
|
+
const token = await getAccessToken(agent);
|
|
80
|
+
|
|
81
|
+
const useChat = Boolean(chatId);
|
|
82
|
+
const url = useChat
|
|
83
|
+
? `${AGENT_API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
|
|
84
|
+
: `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
85
|
+
|
|
86
|
+
const body = useChat
|
|
87
|
+
? { chatid: chatId, msgtype: "text", text: { content: text } }
|
|
88
|
+
: {
|
|
89
|
+
touser: toUser,
|
|
90
|
+
toparty: toParty,
|
|
91
|
+
totag: toTag,
|
|
92
|
+
msgtype: "text",
|
|
93
|
+
agentid: agent.agentId,
|
|
94
|
+
text: { content: text },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
|
|
102
|
+
});
|
|
103
|
+
const json = await res.json();
|
|
104
|
+
|
|
105
|
+
if (json?.errcode !== 0) {
|
|
106
|
+
throw new Error(`agent send text failed: ${json?.errcode} ${json?.errmsg}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
110
|
+
const details = [
|
|
111
|
+
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
|
|
112
|
+
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
|
|
113
|
+
json.invalidtag ? `invalidtag=${json.invalidtag}` : "",
|
|
114
|
+
]
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(", ");
|
|
117
|
+
throw new Error(`agent send text partial failure: ${details}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Upload a temporary media file to WeCom.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} params
|
|
125
|
+
* @param {object} params.agent - { corpId, corpSecret, agentId }
|
|
126
|
+
* @param {"image"|"voice"|"video"|"file"} params.type
|
|
127
|
+
* @param {Buffer} params.buffer
|
|
128
|
+
* @param {string} params.filename
|
|
129
|
+
* @returns {Promise<string>} media_id
|
|
130
|
+
*/
|
|
131
|
+
export async function agentUploadMedia(params) {
|
|
132
|
+
const { agent, type, buffer, filename } = params;
|
|
133
|
+
const token = await getAccessToken(agent);
|
|
134
|
+
const url = `${AGENT_API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}`;
|
|
135
|
+
|
|
136
|
+
// Manually construct multipart/form-data (no extra dependencies).
|
|
137
|
+
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
|
|
138
|
+
|
|
139
|
+
const contentTypeMap = {
|
|
140
|
+
jpg: "image/jpeg",
|
|
141
|
+
jpeg: "image/jpeg",
|
|
142
|
+
png: "image/png",
|
|
143
|
+
gif: "image/gif",
|
|
144
|
+
bmp: "image/bmp",
|
|
145
|
+
amr: "voice/amr",
|
|
146
|
+
mp4: "video/mp4",
|
|
147
|
+
};
|
|
148
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
149
|
+
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
|
|
150
|
+
|
|
151
|
+
const header = Buffer.from(
|
|
152
|
+
`--${boundary}\r\n` +
|
|
153
|
+
`Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
|
|
154
|
+
`Content-Type: ${fileContentType}\r\n\r\n`,
|
|
155
|
+
);
|
|
156
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
157
|
+
const body = Buffer.concat([header, buffer, footer]);
|
|
158
|
+
|
|
159
|
+
const res = await fetch(url, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: {
|
|
162
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
163
|
+
"Content-Length": String(body.length),
|
|
164
|
+
},
|
|
165
|
+
body,
|
|
166
|
+
signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
|
|
167
|
+
});
|
|
168
|
+
const json = await res.json();
|
|
169
|
+
|
|
170
|
+
if (!json?.media_id) {
|
|
171
|
+
throw new Error(`agent upload media failed: ${json?.errcode} ${json?.errmsg}`);
|
|
172
|
+
}
|
|
173
|
+
return json.media_id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Send a media message (image/voice/video/file) via Agent API.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} params
|
|
180
|
+
* @param {object} params.agent
|
|
181
|
+
* @param {string} [params.toUser]
|
|
182
|
+
* @param {string} [params.toParty]
|
|
183
|
+
* @param {string} [params.toTag]
|
|
184
|
+
* @param {string} [params.chatId]
|
|
185
|
+
* @param {string} params.mediaId
|
|
186
|
+
* @param {"image"|"voice"|"video"|"file"} params.mediaType
|
|
187
|
+
*/
|
|
188
|
+
export async function agentSendMedia(params) {
|
|
189
|
+
const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType } = params;
|
|
190
|
+
const token = await getAccessToken(agent);
|
|
191
|
+
|
|
192
|
+
const useChat = Boolean(chatId);
|
|
193
|
+
const url = useChat
|
|
194
|
+
? `${AGENT_API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
|
|
195
|
+
: `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
196
|
+
|
|
197
|
+
const mediaPayload = { media_id: mediaId };
|
|
198
|
+
const body = useChat
|
|
199
|
+
? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
|
|
200
|
+
: {
|
|
201
|
+
touser: toUser,
|
|
202
|
+
toparty: toParty,
|
|
203
|
+
totag: toTag,
|
|
204
|
+
msgtype: mediaType,
|
|
205
|
+
agentid: agent.agentId,
|
|
206
|
+
[mediaType]: mediaPayload,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const res = await fetch(url, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
body: JSON.stringify(body),
|
|
213
|
+
signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
|
|
214
|
+
});
|
|
215
|
+
const json = await res.json();
|
|
216
|
+
|
|
217
|
+
if (json?.errcode !== 0) {
|
|
218
|
+
throw new Error(`agent send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Download a temporary media file from WeCom by media_id.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} params
|
|
226
|
+
* @param {object} params.agent
|
|
227
|
+
* @param {string} params.mediaId
|
|
228
|
+
* @returns {Promise<{ buffer: Buffer, contentType: string }>}
|
|
229
|
+
*/
|
|
230
|
+
export async function agentDownloadMedia(params) {
|
|
231
|
+
const { agent, mediaId } = params;
|
|
232
|
+
const token = await getAccessToken(agent);
|
|
233
|
+
const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
234
|
+
|
|
235
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
|
|
236
|
+
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
throw new Error(`agent download media failed: ${res.status}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
242
|
+
|
|
243
|
+
// WeCom may return an error JSON body instead of binary media.
|
|
244
|
+
if (contentType.includes("application/json")) {
|
|
245
|
+
const json = await res.json();
|
|
246
|
+
throw new Error(`agent download media failed: ${json?.errcode} ${json?.errmsg}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const arrayBuf = await res.arrayBuffer();
|
|
250
|
+
return { buffer: Buffer.from(arrayBuf), contentType };
|
|
251
|
+
}
|