@sunnoy/wecom 1.9.0 → 2.0.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 +391 -845
- package/image-processor.js +30 -27
- package/index.js +10 -43
- package/package.json +5 -5
- package/think-parser.js +51 -11
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +543 -750
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -541
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -395
- package/wecom/inbound-processor.js +0 -562
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
package/wecom/channel-plugin.js
CHANGED
|
@@ -1,280 +1,370 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
1
|
import crypto from "node:crypto";
|
|
3
2
|
import { basename } from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
buildBaseAccountStatusSnapshot,
|
|
6
|
+
buildBaseChannelStatusSummary,
|
|
7
|
+
formatPairingApproveHint,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
4
9
|
import { logger } from "../logger.js";
|
|
5
|
-
import {
|
|
10
|
+
import { splitTextByByteLimit } from "../utils.js";
|
|
11
|
+
import {
|
|
12
|
+
deleteAccountConfig,
|
|
13
|
+
describeAccount,
|
|
14
|
+
detectAccountConflicts,
|
|
15
|
+
listAccountIds,
|
|
16
|
+
logAccountConflicts,
|
|
17
|
+
resolveAccount,
|
|
18
|
+
resolveAllowFromForAccount,
|
|
19
|
+
resolveDefaultAccountId,
|
|
20
|
+
updateAccountConfig,
|
|
21
|
+
} from "./accounts.js";
|
|
6
22
|
import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { resolveRecoverableStream, unregisterActiveStream } from "./stream-utils.js";
|
|
23
|
+
import { setConfigProxyUrl, wecomFetch } from "./http.js";
|
|
24
|
+
import { wecomOnboardingAdapter } from "./onboarding.js";
|
|
25
|
+
import { getAccountTelemetry, recordOutboundActivity } from "./runtime-telemetry.js";
|
|
26
|
+
import { getRuntime, setOpenclawConfig } from "./state.js";
|
|
12
27
|
import { resolveWecomTarget } from "./target.js";
|
|
13
|
-
import { webhookSendImage,
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookUploadFile } from "./webhook-bot.js";
|
|
29
|
+
import {
|
|
30
|
+
CHANNEL_ID,
|
|
31
|
+
DEFAULT_ACCOUNT_ID,
|
|
32
|
+
DEFAULT_WS_URL,
|
|
33
|
+
TEXT_CHUNK_LIMIT,
|
|
34
|
+
getWebhookBotSendUrl,
|
|
35
|
+
setApiBaseUrl,
|
|
36
|
+
} from "./constants.js";
|
|
37
|
+
import { sendWsMessage, startWsMonitor } from "./ws-monitor.js";
|
|
38
|
+
|
|
39
|
+
function normalizePairingEntry(entry) {
|
|
40
|
+
return String(entry ?? "")
|
|
41
|
+
.trim()
|
|
42
|
+
.replace(/^(wecom|wework):/i, "")
|
|
43
|
+
.replace(/^user:/i, "");
|
|
44
|
+
}
|
|
18
45
|
|
|
46
|
+
function normalizeAllowFromEntries(allowFrom) {
|
|
47
|
+
return allowFrom
|
|
48
|
+
.map((entry) => normalizePairingEntry(entry))
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
19
51
|
|
|
20
|
-
|
|
52
|
+
function buildConfigPath(account, field) {
|
|
53
|
+
return field ? `${account.configPath}.${field}` : account.configPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveRuntimeTextChunker(text, limit) {
|
|
57
|
+
let runtime = null;
|
|
58
|
+
try {
|
|
59
|
+
runtime = getRuntime();
|
|
60
|
+
} catch {}
|
|
61
|
+
const chunker = runtime?.channel?.text?.chunkMarkdownText;
|
|
62
|
+
if (typeof chunker === "function") {
|
|
63
|
+
return chunker(text, limit);
|
|
64
|
+
}
|
|
65
|
+
return splitTextByByteLimit(text, limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeMediaPath(mediaUrl) {
|
|
69
|
+
let value = String(mediaUrl ?? "").trim();
|
|
70
|
+
if (!value) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
if (value.startsWith("sandbox:")) {
|
|
74
|
+
value = value.replace(/^sandbox:\/{0,2}/, "");
|
|
75
|
+
if (!value.startsWith("/")) {
|
|
76
|
+
value = `/${value}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadMediaPayload(mediaUrl, { mediaLocalRoots } = {}) {
|
|
83
|
+
const normalized = normalizeMediaPath(mediaUrl);
|
|
84
|
+
if (normalized.startsWith("/")) {
|
|
85
|
+
// Prefer core's loadWebMedia with sandbox enforcement when available.
|
|
86
|
+
const runtime = getRuntime();
|
|
87
|
+
if (typeof runtime?.media?.loadWebMedia === "function" && Array.isArray(mediaLocalRoots) && mediaLocalRoots.length > 0) {
|
|
88
|
+
const loaded = await runtime.media.loadWebMedia(normalized, { localRoots: mediaLocalRoots });
|
|
89
|
+
return {
|
|
90
|
+
buffer: loaded.buffer,
|
|
91
|
+
filename: loaded.fileName || basename(normalized) || "file",
|
|
92
|
+
contentType: loaded.contentType || "",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const buffer = await readFile(normalized);
|
|
96
|
+
return {
|
|
97
|
+
buffer,
|
|
98
|
+
filename: basename(normalized) || "file",
|
|
99
|
+
contentType: "",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const response = await wecomFetch(normalized);
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error(`failed to download media: ${response.status}`);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
buffer: Buffer.from(await response.arrayBuffer()),
|
|
109
|
+
filename: basename(new URL(normalized).pathname) || "file",
|
|
110
|
+
contentType: response.headers.get("content-type") || "",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function loadResolvedMedia(mediaUrl, { mediaLocalRoots } = {}) {
|
|
115
|
+
const media = await loadMediaPayload(mediaUrl, { mediaLocalRoots });
|
|
116
|
+
return {
|
|
117
|
+
...media,
|
|
118
|
+
mediaType: resolveAgentMediaType(media.filename, media.contentType),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveAgentMediaType(filename, contentType) {
|
|
123
|
+
if (String(contentType).toLowerCase().startsWith("image/")) {
|
|
124
|
+
return "image";
|
|
125
|
+
}
|
|
126
|
+
const ext = String(filename ?? "")
|
|
127
|
+
.split(".")
|
|
128
|
+
.pop()
|
|
129
|
+
?.toLowerCase();
|
|
130
|
+
return new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]).has(ext) ? "image" : "file";
|
|
131
|
+
}
|
|
21
132
|
|
|
22
133
|
export function resolveAgentMediaTypeFromFilename(filename) {
|
|
23
|
-
|
|
24
|
-
|
|
134
|
+
return resolveAgentMediaType(filename, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveWsNoticeTarget(target, rawTo) {
|
|
138
|
+
if (target?.webhook || target?.toParty || target?.toTag) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const fallback = String(rawTo ?? "").trim();
|
|
142
|
+
return target?.chatId || target?.toUser || fallback || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildUnsupportedMediaNotice({ text, mediaType, deliveredViaAgent }) {
|
|
146
|
+
let notice;
|
|
147
|
+
if (mediaType === "file") {
|
|
148
|
+
notice = deliveredViaAgent
|
|
149
|
+
? "由于当前企业微信bot不支持给用户发送文件,文件通过自建应用发送。"
|
|
150
|
+
: "由于当前企业微信bot不支持给用户发送文件,且当前未配置自建应用发送渠道。";
|
|
151
|
+
} else if (mediaType === "image") {
|
|
152
|
+
notice = deliveredViaAgent
|
|
153
|
+
? "由于当前企业微信bot不支持直接发送图片,图片通过自建应用发送。"
|
|
154
|
+
: "由于当前企业微信bot不支持直接发送图片,且当前未配置自建应用发送渠道。";
|
|
155
|
+
} else {
|
|
156
|
+
notice = deliveredViaAgent
|
|
157
|
+
? "由于当前企业微信bot不支持直接发送媒体,媒体通过自建应用发送。"
|
|
158
|
+
: "由于当前企业微信bot不支持直接发送媒体,且当前未配置自建应用发送渠道。";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [text, notice].filter(Boolean).join("\n\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function sendUnsupportedMediaNoticeViaWs({ to, text, mediaType, accountId }) {
|
|
165
|
+
return sendWsMessage({
|
|
166
|
+
to,
|
|
167
|
+
content: buildUnsupportedMediaNotice({
|
|
168
|
+
text,
|
|
169
|
+
mediaType,
|
|
170
|
+
deliveredViaAgent: true,
|
|
171
|
+
}),
|
|
172
|
+
accountId,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveOutboundAccountId(cfg, accountId) {
|
|
177
|
+
return accountId || resolveDefaultAccountId(cfg);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function applyNetworkConfig(cfg, accountId) {
|
|
181
|
+
const account = resolveAccount(cfg, accountId);
|
|
182
|
+
const network = account?.config?.network ?? {};
|
|
183
|
+
setConfigProxyUrl(network.egressProxyUrl ?? "");
|
|
184
|
+
setApiBaseUrl(network.apiBaseUrl ?? "");
|
|
185
|
+
return account;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, preparedMedia }) {
|
|
189
|
+
const account = resolveAccount(cfg, accountId);
|
|
190
|
+
const raw = account?.config?.webhooks?.[webhookName];
|
|
191
|
+
const url = raw ? (String(raw).startsWith("http") ? String(raw) : `${getWebhookBotSendUrl()}?key=${raw}`) : null;
|
|
192
|
+
if (!url) {
|
|
193
|
+
throw new Error(`unknown webhook target: ${webhookName}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!mediaUrl) {
|
|
197
|
+
await webhookSendMarkdown({ url, content: text });
|
|
198
|
+
recordOutboundActivity({ accountId });
|
|
199
|
+
return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { buffer, filename, mediaType } = preparedMedia ?? (await loadResolvedMedia(mediaUrl));
|
|
203
|
+
|
|
204
|
+
if (text) {
|
|
205
|
+
await webhookSendMarkdown({ url, content: text });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (mediaType === "image") {
|
|
209
|
+
await webhookSendImage({
|
|
210
|
+
url,
|
|
211
|
+
base64: buffer.toString("base64"),
|
|
212
|
+
md5: crypto.createHash("md5").update(buffer).digest("hex"),
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
const mediaId = await webhookUploadFile({ url, buffer, filename });
|
|
216
|
+
await webhookSendFile({ url, mediaId });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
recordOutboundActivity({ accountId });
|
|
220
|
+
return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMedia }) {
|
|
224
|
+
const agent = resolveAccount(cfg, accountId)?.agentCredentials;
|
|
225
|
+
if (!agent) {
|
|
226
|
+
throw new Error("Agent API is not configured for this account");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (text) {
|
|
230
|
+
for (const chunk of splitTextByByteLimit(text)) {
|
|
231
|
+
await agentSendText({ agent, ...target, text: chunk });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!mediaUrl) {
|
|
236
|
+
recordOutboundActivity({ accountId });
|
|
237
|
+
return { channel: CHANNEL_ID, messageId: `wecom-agent-${Date.now()}` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { buffer, filename, mediaType } = preparedMedia ?? (await loadResolvedMedia(mediaUrl));
|
|
241
|
+
const mediaId = await agentUploadMedia({
|
|
242
|
+
agent,
|
|
243
|
+
type: mediaType,
|
|
244
|
+
buffer,
|
|
245
|
+
filename,
|
|
246
|
+
});
|
|
247
|
+
await agentSendMedia({
|
|
248
|
+
agent,
|
|
249
|
+
...target,
|
|
250
|
+
mediaId,
|
|
251
|
+
mediaType,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
recordOutboundActivity({ accountId });
|
|
255
|
+
return { channel: CHANNEL_ID, messageId: `wecom-agent-${Date.now()}` };
|
|
25
256
|
}
|
|
26
257
|
|
|
27
258
|
export const wecomChannelPlugin = {
|
|
28
|
-
id:
|
|
259
|
+
id: CHANNEL_ID,
|
|
29
260
|
meta: {
|
|
30
|
-
id:
|
|
261
|
+
id: CHANNEL_ID,
|
|
31
262
|
label: "Enterprise WeChat",
|
|
32
263
|
selectionLabel: "Enterprise WeChat (AI Bot)",
|
|
33
|
-
docsPath:
|
|
34
|
-
|
|
264
|
+
docsPath: `/channels/${CHANNEL_ID}`,
|
|
265
|
+
docsLabel: CHANNEL_ID,
|
|
266
|
+
blurb: "Enterprise WeChat AI Bot over WebSocket.",
|
|
35
267
|
aliases: ["wecom", "wework"],
|
|
268
|
+
quickstartAllowFrom: true,
|
|
269
|
+
},
|
|
270
|
+
pairing: {
|
|
271
|
+
idLabel: "wecomUserId",
|
|
272
|
+
normalizeAllowEntry: normalizePairingEntry,
|
|
273
|
+
notifyApproval: async ({ cfg, id, accountId }) => {
|
|
274
|
+
try {
|
|
275
|
+
await sendWsMessage({
|
|
276
|
+
to: id,
|
|
277
|
+
content: "配对已通过,可以开始发送消息。",
|
|
278
|
+
accountId: resolveOutboundAccountId(cfg, accountId),
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
logger.warn(`[wecom] failed to notify pairing approval: ${error.message}`);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
36
284
|
},
|
|
285
|
+
onboarding: wecomOnboardingAdapter,
|
|
37
286
|
capabilities: {
|
|
38
287
|
chatTypes: ["direct", "group"],
|
|
39
288
|
reactions: false,
|
|
40
289
|
threads: false,
|
|
41
290
|
media: true,
|
|
42
291
|
nativeCommands: false,
|
|
43
|
-
blockStreaming: true,
|
|
292
|
+
blockStreaming: true,
|
|
44
293
|
},
|
|
45
|
-
reload: { configPrefixes: [
|
|
294
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
46
295
|
configSchema: {
|
|
47
296
|
schema: {
|
|
48
|
-
$schema: "http://json-schema.org/draft-07/schema#",
|
|
49
297
|
type: "object",
|
|
50
298
|
additionalProperties: true,
|
|
51
299
|
properties: {
|
|
52
|
-
enabled: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
description: "WeCom message encryption key (43 characters)",
|
|
64
|
-
minLength: 43,
|
|
65
|
-
maxLength: 43,
|
|
66
|
-
},
|
|
67
|
-
commands: {
|
|
68
|
-
type: "object",
|
|
69
|
-
description: "Command whitelist configuration",
|
|
70
|
-
additionalProperties: false,
|
|
71
|
-
properties: {
|
|
72
|
-
enabled: {
|
|
73
|
-
type: "boolean",
|
|
74
|
-
description: "Enable command whitelist filtering",
|
|
75
|
-
default: true,
|
|
76
|
-
},
|
|
77
|
-
allowlist: {
|
|
78
|
-
type: "array",
|
|
79
|
-
description: "Allowed commands (e.g., /new, /status, /help)",
|
|
80
|
-
items: {
|
|
81
|
-
type: "string",
|
|
82
|
-
},
|
|
83
|
-
default: ["/new", "/status", "/help", "/compact"],
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
dynamicAgents: {
|
|
88
|
-
type: "object",
|
|
89
|
-
description: "Dynamic agent routing configuration",
|
|
90
|
-
additionalProperties: false,
|
|
91
|
-
properties: {
|
|
92
|
-
enabled: {
|
|
93
|
-
type: "boolean",
|
|
94
|
-
description: "Enable per-user/per-group agent isolation",
|
|
95
|
-
default: true,
|
|
96
|
-
},
|
|
97
|
-
adminBypass: {
|
|
98
|
-
type: "boolean",
|
|
99
|
-
description: "When true, adminUsers bypass dynamic agent routing and use the default route",
|
|
100
|
-
default: false,
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
dm: {
|
|
105
|
-
type: "object",
|
|
106
|
-
description: "Direct message (private chat) configuration",
|
|
107
|
-
additionalProperties: false,
|
|
108
|
-
properties: {
|
|
109
|
-
createAgentOnFirstMessage: {
|
|
110
|
-
type: "boolean",
|
|
111
|
-
description: "Create separate agent for each user",
|
|
112
|
-
default: true,
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
groupChat: {
|
|
117
|
-
type: "object",
|
|
118
|
-
description: "Group chat configuration",
|
|
119
|
-
additionalProperties: false,
|
|
120
|
-
properties: {
|
|
121
|
-
enabled: {
|
|
122
|
-
type: "boolean",
|
|
123
|
-
description: "Enable group chat support",
|
|
124
|
-
default: true,
|
|
125
|
-
},
|
|
126
|
-
requireMention: {
|
|
127
|
-
type: "boolean",
|
|
128
|
-
description: "Only respond when @mentioned in groups",
|
|
129
|
-
default: true,
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
adminUsers: {
|
|
134
|
-
type: "array",
|
|
135
|
-
description: "Admin users who bypass command allowlist (routing unchanged)",
|
|
136
|
-
items: { type: "string" },
|
|
137
|
-
default: [],
|
|
138
|
-
},
|
|
139
|
-
workspaceTemplate: {
|
|
140
|
-
type: "string",
|
|
141
|
-
description: "Directory with custom bootstrap templates (AGENTS.md, BOOTSTRAP.md, etc.)",
|
|
142
|
-
},
|
|
143
|
-
agent: {
|
|
144
|
-
type: "object",
|
|
145
|
-
description: "Agent mode (self-built application) configuration for outbound messaging and inbound callbacks",
|
|
146
|
-
additionalProperties: false,
|
|
147
|
-
properties: {
|
|
148
|
-
corpId: { type: "string", description: "Enterprise Corp ID" },
|
|
149
|
-
corpSecret: { type: "string", description: "Application Secret" },
|
|
150
|
-
agentId: { type: "number", description: "Application Agent ID" },
|
|
151
|
-
token: { type: "string", description: "Callback Token for Agent inbound" },
|
|
152
|
-
encodingAesKey: {
|
|
153
|
-
type: "string",
|
|
154
|
-
description: "Callback Encoding AES Key for Agent inbound (43 characters)",
|
|
155
|
-
minLength: 43,
|
|
156
|
-
maxLength: 43,
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
},
|
|
160
|
-
network: {
|
|
161
|
-
type: "object",
|
|
162
|
-
description: "Network configuration (proxy, timeouts)",
|
|
163
|
-
additionalProperties: false,
|
|
164
|
-
properties: {
|
|
165
|
-
egressProxyUrl: {
|
|
166
|
-
type: "string",
|
|
167
|
-
description: "HTTP(S) proxy URL for outbound WeCom API requests (e.g. http://proxy:8080). Env var WECOM_EGRESS_PROXY_URL takes precedence.",
|
|
168
|
-
},
|
|
169
|
-
apiBaseUrl: {
|
|
170
|
-
type: "string",
|
|
171
|
-
description: "Custom WeCom API base URL (default: https://qyapi.weixin.qq.com). Use when routing through a reverse-proxy or API gateway. Env var WECOM_API_BASE_URL takes precedence.",
|
|
172
|
-
},
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
webhooks: {
|
|
176
|
-
type: "object",
|
|
177
|
-
description: "Webhook bot URLs for group notifications (key: name, value: webhook URL or key)",
|
|
178
|
-
additionalProperties: { type: "string" },
|
|
179
|
-
},
|
|
180
|
-
instances: {
|
|
181
|
-
type: "array",
|
|
182
|
-
description: "Additional bot / agent accounts. Each entry inherits top-level fields it does not override.",
|
|
183
|
-
items: {
|
|
184
|
-
type: "object",
|
|
185
|
-
additionalProperties: false,
|
|
186
|
-
required: ["name"],
|
|
187
|
-
properties: {
|
|
188
|
-
name: {
|
|
189
|
-
type: "string",
|
|
190
|
-
description: "Unique account slug (lowercase, a-z0-9_- only). Used as accountId and in webhook paths.",
|
|
191
|
-
pattern: "^[a-z0-9_-]+$",
|
|
192
|
-
},
|
|
193
|
-
enabled: { type: "boolean", default: true },
|
|
194
|
-
token: { type: "string", description: "Bot Token (overrides top-level)" },
|
|
195
|
-
encodingAesKey: {
|
|
196
|
-
type: "string",
|
|
197
|
-
description: "Encoding AES Key (overrides top-level)",
|
|
198
|
-
minLength: 43,
|
|
199
|
-
maxLength: 43,
|
|
200
|
-
},
|
|
201
|
-
agent: {
|
|
202
|
-
type: "object",
|
|
203
|
-
description: "Agent configuration for this instance (full replacement, not merged with top-level)",
|
|
204
|
-
properties: {
|
|
205
|
-
corpId: { type: "string" },
|
|
206
|
-
corpSecret: { type: "string" },
|
|
207
|
-
agentId: { type: "number" },
|
|
208
|
-
token: { type: "string" },
|
|
209
|
-
encodingAesKey: { type: "string", minLength: 43, maxLength: 43 },
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
webhooks: {
|
|
213
|
-
type: "object",
|
|
214
|
-
description: "Webhook bot URLs for this instance",
|
|
215
|
-
additionalProperties: { type: "string" },
|
|
216
|
-
},
|
|
217
|
-
webhookPath: {
|
|
218
|
-
type: "string",
|
|
219
|
-
description: "Custom webhook path (default: /webhooks/wecom/{name})",
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
},
|
|
300
|
+
enabled: { type: "boolean" },
|
|
301
|
+
defaultAccount: { type: "string" },
|
|
302
|
+
botId: { type: "string" },
|
|
303
|
+
secret: { type: "string" },
|
|
304
|
+
websocketUrl: { type: "string" },
|
|
305
|
+
sendThinkingMessage: { type: "boolean" },
|
|
306
|
+
welcomeMessage: { type: "string" },
|
|
307
|
+
dmPolicy: { enum: ["pairing", "allowlist", "open", "disabled"] },
|
|
308
|
+
allowFrom: { type: "array", items: { type: "string" } },
|
|
309
|
+
groupPolicy: { enum: ["open", "allowlist", "disabled"] },
|
|
310
|
+
groupAllowFrom: { type: "array", items: { type: "string" } },
|
|
224
311
|
},
|
|
225
312
|
},
|
|
226
313
|
uiHints: {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
},
|
|
231
|
-
|
|
232
|
-
sensitive: true,
|
|
233
|
-
label: "Encoding AES Key",
|
|
234
|
-
help: "43-character encryption key from WeCom admin console",
|
|
235
|
-
},
|
|
236
|
-
"agent.corpSecret": {
|
|
237
|
-
sensitive: true,
|
|
238
|
-
label: "Application Secret",
|
|
239
|
-
},
|
|
240
|
-
"agent.token": {
|
|
241
|
-
sensitive: true,
|
|
242
|
-
label: "Agent Callback Token",
|
|
243
|
-
},
|
|
244
|
-
"agent.encodingAesKey": {
|
|
245
|
-
sensitive: true,
|
|
246
|
-
label: "Agent Callback Encoding AES Key",
|
|
247
|
-
help: "43-character encryption key for Agent inbound callbacks",
|
|
248
|
-
},
|
|
314
|
+
botId: { label: "Bot ID" },
|
|
315
|
+
secret: { label: "Secret", sensitive: true },
|
|
316
|
+
websocketUrl: { label: "WebSocket URL", placeholder: DEFAULT_WS_URL },
|
|
317
|
+
welcomeMessage: { label: "Welcome Message" },
|
|
318
|
+
"agent.corpSecret": { sensitive: true, label: "Application Secret" },
|
|
249
319
|
},
|
|
250
320
|
},
|
|
251
321
|
config: {
|
|
252
|
-
listAccountIds
|
|
253
|
-
resolveAccount
|
|
254
|
-
defaultAccountId: (cfg) =>
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
322
|
+
listAccountIds,
|
|
323
|
+
resolveAccount,
|
|
324
|
+
defaultAccountId: (cfg) => resolveDefaultAccountId(cfg),
|
|
325
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => updateAccountConfig(cfg, accountId, { enabled }),
|
|
326
|
+
deleteAccount: ({ cfg, accountId }) => deleteAccountConfig(cfg, accountId),
|
|
327
|
+
isConfigured: (account) => Boolean(account.botId && account.secret),
|
|
328
|
+
describeAccount,
|
|
329
|
+
resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFromForAccount(cfg, accountId),
|
|
330
|
+
formatAllowFrom: ({ allowFrom }) => normalizeAllowFromEntries(allowFrom.map((entry) => String(entry))),
|
|
331
|
+
},
|
|
332
|
+
security: {
|
|
333
|
+
resolveDmPolicy: ({ account }) => ({
|
|
334
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
335
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
336
|
+
policyPath: buildConfigPath(account, "dmPolicy"),
|
|
337
|
+
allowFromPath: buildConfigPath(account, "allowFrom"),
|
|
338
|
+
approveHint: formatPairingApproveHint(CHANNEL_ID),
|
|
339
|
+
normalizeEntry: normalizePairingEntry,
|
|
340
|
+
}),
|
|
341
|
+
collectWarnings: ({ account }) => {
|
|
342
|
+
const warnings = [];
|
|
343
|
+
const allowFrom = Array.isArray(account.config.allowFrom) ? account.config.allowFrom.map((entry) => String(entry)) : [];
|
|
344
|
+
|
|
345
|
+
if ((account.config.dmPolicy ?? "pairing") === "open" && !allowFrom.includes("*")) {
|
|
346
|
+
warnings.push(
|
|
347
|
+
`- ${account.accountId}: dmPolicy="open" 但 allowFrom 未包含 "*"; 建议同时显式配置 ${buildConfigPath(account, "allowFrom")}=["*"]。`,
|
|
348
|
+
);
|
|
268
349
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
} else if (cfg.channels?.wecom) {
|
|
275
|
-
delete cfg.channels.wecom[accountId];
|
|
350
|
+
|
|
351
|
+
if ((account.config.groupPolicy ?? "open") === "open") {
|
|
352
|
+
warnings.push(
|
|
353
|
+
`- ${account.accountId}: groupPolicy="open" 会允许所有群聊触发;如需收敛,请配置 ${buildConfigPath(account, "groupPolicy")}="allowlist"。`,
|
|
354
|
+
);
|
|
276
355
|
}
|
|
277
|
-
|
|
356
|
+
|
|
357
|
+
return warnings;
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
messaging: {
|
|
361
|
+
normalizeTarget: (target) => {
|
|
362
|
+
const trimmed = String(target ?? "").trim();
|
|
363
|
+
return trimmed || undefined;
|
|
364
|
+
},
|
|
365
|
+
targetResolver: {
|
|
366
|
+
looksLikeId: (value) => Boolean(String(value ?? "").trim()),
|
|
367
|
+
hint: "<userId|groupId>",
|
|
278
368
|
},
|
|
279
369
|
},
|
|
280
370
|
directory: {
|
|
@@ -282,559 +372,262 @@ export const wecomChannelPlugin = {
|
|
|
282
372
|
listPeers: async () => [],
|
|
283
373
|
listGroups: async () => [],
|
|
284
374
|
},
|
|
285
|
-
// Outbound adapter: all replies are streamed for WeCom AI Bot compatibility.
|
|
286
375
|
outbound: {
|
|
287
376
|
deliveryMode: "direct",
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const finished = streamObj?.finished ?? true;
|
|
303
|
-
|
|
304
|
-
// Layer 1: Active stream (normal path)
|
|
305
|
-
if (streamId && hasStream && !finished) {
|
|
306
|
-
logger.debug("Appending outbound text to stream", {
|
|
307
|
-
userId,
|
|
308
|
-
streamId,
|
|
309
|
-
source: ctx ? "asyncContext" : "activeStreams",
|
|
310
|
-
text: text.substring(0, 30),
|
|
377
|
+
chunker: (text, limit) => resolveRuntimeTextChunker(text, limit),
|
|
378
|
+
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
379
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
380
|
+
const resolvedAccountId = resolveOutboundAccountId(cfg, accountId);
|
|
381
|
+
setOpenclawConfig(cfg);
|
|
382
|
+
applyNetworkConfig(cfg, resolvedAccountId);
|
|
383
|
+
const target = resolveWecomTarget(to) ?? {};
|
|
384
|
+
|
|
385
|
+
if (target.webhook) {
|
|
386
|
+
return sendViaWebhook({
|
|
387
|
+
cfg,
|
|
388
|
+
accountId: resolvedAccountId,
|
|
389
|
+
webhookName: target.webhook,
|
|
390
|
+
text,
|
|
311
391
|
});
|
|
312
|
-
// Replace placeholder or append content.
|
|
313
|
-
streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
channel: "wecom",
|
|
317
|
-
messageId: `msg_stream_${Date.now()}`,
|
|
318
|
-
};
|
|
319
392
|
}
|
|
320
393
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
ctxStreamId,
|
|
329
|
-
canUseCtxStream,
|
|
330
|
-
ctxStreamKey: ctx?.streamKey ?? null,
|
|
331
|
-
textPreview: text.substring(0, 50),
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// Layer 2: Fallback via response_url
|
|
335
|
-
// response_url is valid for 1 hour and can be used only once.
|
|
336
|
-
// responseUrls is keyed by streamKey (fromUser for DM, chatId for group).
|
|
337
|
-
const saved = responseUrls.get(ctx?.streamKey ?? userId);
|
|
338
|
-
if (saved && !saved.used && Date.now() < saved.expiresAt) {
|
|
339
|
-
try {
|
|
340
|
-
const response = await wecomFetch(saved.url, {
|
|
341
|
-
method: "POST",
|
|
342
|
-
headers: { "Content-Type": "application/json" },
|
|
343
|
-
body: JSON.stringify({ msgtype: "markdown", markdown: { content: text } }),
|
|
394
|
+
try {
|
|
395
|
+
if (!target.toParty && !target.toTag) {
|
|
396
|
+
const wsTarget = target.chatId || target.toUser || to;
|
|
397
|
+
return await sendWsMessage({
|
|
398
|
+
to: wsTarget,
|
|
399
|
+
content: text,
|
|
400
|
+
accountId: resolvedAccountId,
|
|
344
401
|
});
|
|
345
|
-
const responseBody = await response.text().catch(() => "");
|
|
346
|
-
const result = parseResponseUrlResult(response, responseBody);
|
|
347
|
-
if (!result.accepted) {
|
|
348
|
-
logger.error("WeCom: response_url fallback rejected", {
|
|
349
|
-
userId,
|
|
350
|
-
status: response.status,
|
|
351
|
-
statusText: response.statusText,
|
|
352
|
-
errcode: result.errcode,
|
|
353
|
-
errmsg: result.errmsg,
|
|
354
|
-
bodyPreview: result.bodyPreview,
|
|
355
|
-
});
|
|
356
|
-
} else {
|
|
357
|
-
saved.used = true;
|
|
358
|
-
logger.info("WeCom: sent via response_url fallback", {
|
|
359
|
-
userId,
|
|
360
|
-
status: response.status,
|
|
361
|
-
errcode: result.errcode,
|
|
362
|
-
});
|
|
363
|
-
return {
|
|
364
|
-
channel: "wecom",
|
|
365
|
-
messageId: `msg_response_url_${Date.now()}`,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
} catch (err) {
|
|
369
|
-
logger.error("WeCom: response_url fallback failed", { userId, error: err.message });
|
|
370
402
|
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
logger.warn(`[wecom] WS sendText failed, falling back to Agent API: ${error.message}`);
|
|
371
405
|
}
|
|
372
406
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
407
|
+
return sendViaAgent({
|
|
408
|
+
cfg,
|
|
409
|
+
accountId: resolvedAccountId,
|
|
410
|
+
target: target.toParty || target.toTag ? target : target.chatId ? { chatId: target.chatId } : { toUser: target.toUser || String(to).replace(/^wecom:/i, "") },
|
|
411
|
+
text,
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId }) => {
|
|
415
|
+
const resolvedAccountId = resolveOutboundAccountId(cfg, accountId);
|
|
416
|
+
setOpenclawConfig(cfg);
|
|
417
|
+
const account = applyNetworkConfig(cfg, resolvedAccountId);
|
|
418
|
+
const target = resolveWecomTarget(to) ?? {};
|
|
419
|
+
const wsNoticeTarget = resolveWsNoticeTarget(target, to);
|
|
420
|
+
|
|
421
|
+
if (target.webhook) {
|
|
422
|
+
const preparedMedia = mediaUrl ? await loadResolvedMedia(mediaUrl, { mediaLocalRoots }) : undefined;
|
|
423
|
+
return sendViaWebhook({
|
|
424
|
+
cfg,
|
|
425
|
+
accountId: resolvedAccountId,
|
|
426
|
+
webhookName: target.webhook,
|
|
427
|
+
text,
|
|
428
|
+
mediaUrl,
|
|
429
|
+
preparedMedia,
|
|
430
|
+
});
|
|
397
431
|
}
|
|
398
432
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
to,
|
|
411
|
-
chunks: chunks.length,
|
|
412
|
-
contentPreview: text.substring(0, 50),
|
|
413
|
-
});
|
|
414
|
-
return {
|
|
415
|
-
channel: "wecom",
|
|
416
|
-
messageId: `msg_agent_${Date.now()}`,
|
|
417
|
-
};
|
|
418
|
-
} catch (err) {
|
|
419
|
-
logger.error("WeCom: Agent API fallback failed (sendText)", { userId, error: err.message });
|
|
433
|
+
const agentTarget =
|
|
434
|
+
target.toParty || target.toTag
|
|
435
|
+
? target
|
|
436
|
+
: target.chatId
|
|
437
|
+
? { chatId: target.chatId }
|
|
438
|
+
: { toUser: target.toUser || String(to).replace(/^wecom:/i, "") };
|
|
439
|
+
const preparedMedia = await loadResolvedMedia(mediaUrl, { mediaLocalRoots });
|
|
440
|
+
|
|
441
|
+
if (target.toParty || target.toTag) {
|
|
442
|
+
if (!account?.agentCredentials) {
|
|
443
|
+
throw new Error("Agent API is required for party/tag media delivery");
|
|
420
444
|
}
|
|
445
|
+
return sendViaAgent({
|
|
446
|
+
cfg,
|
|
447
|
+
accountId: resolvedAccountId,
|
|
448
|
+
target: agentTarget,
|
|
449
|
+
text,
|
|
450
|
+
mediaUrl,
|
|
451
|
+
preparedMedia,
|
|
452
|
+
});
|
|
421
453
|
}
|
|
422
454
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
sendMedia: async ({ cfg: _cfg, to, text, mediaUrl, accountId: _accountId }) => {
|
|
433
|
-
const userId = to.replace(/^wecom:/, "");
|
|
434
|
-
|
|
435
|
-
// Prefer async-context stream only when it is still writable.
|
|
436
|
-
const ctx = streamContext.getStore();
|
|
437
|
-
const ctxStreamId = ctx?.streamId ?? null;
|
|
438
|
-
const ctxStream = ctxStreamId ? streamManager.getStream(ctxStreamId) : null;
|
|
439
|
-
const canUseCtxStream = !!(ctxStreamId && ctxStream && !ctxStream.finished);
|
|
440
|
-
const streamId = canUseCtxStream ? ctxStreamId : resolveRecoverableStream(userId);
|
|
441
|
-
|
|
442
|
-
if (streamId && streamManager.hasStream(streamId) && !streamManager.getStream(streamId)?.finished) {
|
|
443
|
-
// Check if mediaUrl is a local path (sandbox: prefix or absolute path)
|
|
444
|
-
const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
|
|
445
|
-
|
|
446
|
-
if (isLocalPath) {
|
|
447
|
-
// Convert sandbox: URLs to absolute paths.
|
|
448
|
-
// sandbox:///tmp/a -> /tmp/a, sandbox://tmp/a -> /tmp/a, sandbox:/tmp/a -> /tmp/a
|
|
449
|
-
let absolutePath = mediaUrl;
|
|
450
|
-
if (absolutePath.startsWith("sandbox:")) {
|
|
451
|
-
absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
|
|
452
|
-
// Ensure the result is an absolute path.
|
|
453
|
-
if (!absolutePath.startsWith("/")) {
|
|
454
|
-
absolutePath = "/" + absolutePath;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
455
|
+
if (account?.agentCredentials) {
|
|
456
|
+
const agentResult = await sendViaAgent({
|
|
457
|
+
cfg,
|
|
458
|
+
accountId: resolvedAccountId,
|
|
459
|
+
target: agentTarget,
|
|
460
|
+
text: wsNoticeTarget ? undefined : text,
|
|
461
|
+
mediaUrl,
|
|
462
|
+
preparedMedia,
|
|
463
|
+
});
|
|
457
464
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
logger.debug("Non-image file in active stream, routing via Agent DM", {
|
|
466
|
-
userId,
|
|
467
|
-
streamId,
|
|
468
|
-
absolutePath,
|
|
469
|
-
fileExt,
|
|
465
|
+
if (wsNoticeTarget) {
|
|
466
|
+
try {
|
|
467
|
+
await sendUnsupportedMediaNoticeViaWs({
|
|
468
|
+
to: wsNoticeTarget,
|
|
469
|
+
text,
|
|
470
|
+
mediaType: preparedMedia.mediaType,
|
|
471
|
+
accountId: resolvedAccountId,
|
|
470
472
|
});
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
try {
|
|
474
|
-
const fileBuf = await readFile(absolutePath);
|
|
475
|
-
const fileMediaId = await agentUploadMedia({
|
|
476
|
-
agent: agentCfgForFile,
|
|
477
|
-
type: "file",
|
|
478
|
-
buffer: fileBuf,
|
|
479
|
-
filename: fileFilename,
|
|
480
|
-
});
|
|
481
|
-
await agentSendMedia({
|
|
482
|
-
agent: agentCfgForFile,
|
|
483
|
-
toUser: userId,
|
|
484
|
-
mediaId: fileMediaId,
|
|
485
|
-
mediaType: "file",
|
|
486
|
-
});
|
|
487
|
-
const fileHint = text
|
|
488
|
-
? `${text}\n\n📎 文件已通过私信发送给您:${fileFilename}`
|
|
489
|
-
: `📎 文件已通过私信发送给您:${fileFilename}`;
|
|
490
|
-
streamManager.replaceIfPlaceholder(streamId, fileHint, THINKING_PLACEHOLDER);
|
|
491
|
-
logger.info("WeCom: sent non-image file via Agent DM (active stream)", {
|
|
492
|
-
userId,
|
|
493
|
-
filename: fileFilename,
|
|
494
|
-
});
|
|
495
|
-
} catch (fileErr) {
|
|
496
|
-
logger.error("WeCom: Agent DM file send failed (active stream)", {
|
|
497
|
-
userId,
|
|
498
|
-
filename: fileFilename,
|
|
499
|
-
error: fileErr.message,
|
|
500
|
-
});
|
|
501
|
-
const errHint = text
|
|
502
|
-
? `${text}\n\n⚠️ 文件发送失败(${fileFilename}):${fileErr.message}`
|
|
503
|
-
: `⚠️ 文件发送失败(${fileFilename}):${fileErr.message}`;
|
|
504
|
-
streamManager.replaceIfPlaceholder(streamId, errHint, THINKING_PLACEHOLDER);
|
|
505
|
-
}
|
|
506
|
-
} else {
|
|
507
|
-
// No Agent API configured — post a notice in stream.
|
|
508
|
-
const noAgentHint = text
|
|
509
|
-
? `${text}\n\n⚠️ 无法发送文件 ${fileFilename}(未配置 Agent API)`
|
|
510
|
-
: `⚠️ 无法发送文件 ${fileFilename}(未配置 Agent API)`;
|
|
511
|
-
streamManager.replaceIfPlaceholder(streamId, noAgentHint, THINKING_PLACEHOLDER);
|
|
512
|
-
}
|
|
513
|
-
return {
|
|
514
|
-
channel: "wecom",
|
|
515
|
-
messageId: `msg_stream_file_${Date.now()}`,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
logger.debug("Queueing local image for stream", {
|
|
520
|
-
userId,
|
|
521
|
-
streamId,
|
|
522
|
-
mediaUrl,
|
|
523
|
-
absolutePath,
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
// Queue the image for processing when stream finishes
|
|
527
|
-
const queued = streamManager.queueImage(streamId, absolutePath);
|
|
528
|
-
|
|
529
|
-
if (queued) {
|
|
530
|
-
// Append text content to stream (without markdown image)
|
|
473
|
+
} catch (error) {
|
|
474
|
+
logger.warn(`[wecom] WS media notice failed, falling back to Agent text delivery: ${error.message}`);
|
|
531
475
|
if (text) {
|
|
532
|
-
|
|
476
|
+
await sendViaAgent({
|
|
477
|
+
cfg,
|
|
478
|
+
accountId: resolvedAccountId,
|
|
479
|
+
target: agentTarget,
|
|
480
|
+
text,
|
|
481
|
+
});
|
|
533
482
|
}
|
|
534
|
-
|
|
535
|
-
// Append placeholder indicating image will follow
|
|
536
|
-
const imagePlaceholder = "\n\n[图片]";
|
|
537
|
-
streamManager.appendStream(streamId, imagePlaceholder);
|
|
538
|
-
|
|
539
|
-
return {
|
|
540
|
-
channel: "wecom",
|
|
541
|
-
messageId: `msg_stream_img_${Date.now()}`,
|
|
542
|
-
};
|
|
543
|
-
} else {
|
|
544
|
-
logger.warn("Failed to queue image, falling back to markdown", {
|
|
545
|
-
userId,
|
|
546
|
-
streamId,
|
|
547
|
-
mediaUrl,
|
|
548
|
-
});
|
|
549
|
-
// Fallback to old behavior
|
|
550
483
|
}
|
|
551
484
|
}
|
|
552
485
|
|
|
553
|
-
|
|
554
|
-
const content = text ? `${text}\n\n` : ``;
|
|
555
|
-
logger.debug("Appending outbound media to stream (markdown)", {
|
|
556
|
-
userId,
|
|
557
|
-
streamId,
|
|
558
|
-
mediaUrl,
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// Replace placeholder or append media markdown to the current stream content.
|
|
562
|
-
streamManager.replaceIfPlaceholder(streamId, content, THINKING_PLACEHOLDER);
|
|
563
|
-
|
|
564
|
-
return {
|
|
565
|
-
channel: "wecom",
|
|
566
|
-
messageId: `msg_stream_${Date.now()}`,
|
|
567
|
-
};
|
|
486
|
+
return agentResult;
|
|
568
487
|
}
|
|
569
488
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (absolutePath.startsWith("sandbox:")) {
|
|
583
|
-
absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
|
|
584
|
-
if (!absolutePath.startsWith("/")) absolutePath = "/" + absolutePath;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
if (absolutePath.startsWith("/")) {
|
|
588
|
-
buffer = await readFile(absolutePath);
|
|
589
|
-
filename = basename(absolutePath);
|
|
590
|
-
} else {
|
|
591
|
-
const res = await wecomFetch(mediaUrl);
|
|
592
|
-
buffer = Buffer.from(await res.arrayBuffer());
|
|
593
|
-
filename = basename(new URL(mediaUrl).pathname) || "image.png";
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Try image (base64) for common image types, otherwise upload as file
|
|
597
|
-
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
598
|
-
const imageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
|
|
599
|
-
|
|
600
|
-
if (imageExts.has(ext)) {
|
|
601
|
-
const base64 = buffer.toString("base64");
|
|
602
|
-
const md5 = crypto.createHash("md5").update(buffer).digest("hex");
|
|
603
|
-
await webhookSendImage({ url: webhookUrl, base64, md5 });
|
|
604
|
-
} else {
|
|
605
|
-
const mediaId = await webhookUploadFile({ url: webhookUrl, buffer, filename });
|
|
606
|
-
await webhookSendFile({ url: webhookUrl, mediaId });
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Send accompanying text if present
|
|
610
|
-
if (text) {
|
|
611
|
-
await webhookSendText({ url: webhookUrl, content: text });
|
|
612
|
-
}
|
|
489
|
+
if (wsNoticeTarget) {
|
|
490
|
+
logger.warn("[wecom] Agent API is not configured for unsupported WS media; sending notice only");
|
|
491
|
+
return sendWsMessage({
|
|
492
|
+
to: wsNoticeTarget,
|
|
493
|
+
content: buildUnsupportedMediaNotice({
|
|
494
|
+
text,
|
|
495
|
+
mediaType: preparedMedia.mediaType,
|
|
496
|
+
deliveredViaAgent: false,
|
|
497
|
+
}),
|
|
498
|
+
accountId: resolvedAccountId,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
613
501
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
502
|
+
throw new Error("Agent API is not configured for unsupported WeCom media delivery");
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
status: {
|
|
506
|
+
defaultRuntime: {
|
|
507
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
508
|
+
running: false,
|
|
509
|
+
lastStartAt: null,
|
|
510
|
+
lastStopAt: null,
|
|
511
|
+
lastError: null,
|
|
512
|
+
lastInboundAt: null,
|
|
513
|
+
lastOutboundAt: null,
|
|
514
|
+
},
|
|
515
|
+
collectStatusIssues: (accounts, ctx = {}) =>
|
|
516
|
+
accounts.flatMap((entry) => {
|
|
517
|
+
if (entry.enabled === false) {
|
|
518
|
+
return [];
|
|
630
519
|
}
|
|
631
|
-
}
|
|
632
520
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
|
|
644
|
-
if (!absolutePath.startsWith("/")) absolutePath = "/" + absolutePath;
|
|
645
|
-
}
|
|
521
|
+
const issues = [];
|
|
522
|
+
if (!entry.configured) {
|
|
523
|
+
issues.push({
|
|
524
|
+
channel: CHANNEL_ID,
|
|
525
|
+
accountId: entry.accountId,
|
|
526
|
+
kind: "config",
|
|
527
|
+
message: "企业微信 botId 或 secret 未配置",
|
|
528
|
+
fix: "Run: openclaw channels add wecom --bot-id <id> --secret <secret>",
|
|
529
|
+
});
|
|
530
|
+
}
|
|
646
531
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
agent: agentConfig,
|
|
655
|
-
type: uploadType,
|
|
656
|
-
buffer,
|
|
657
|
-
filename,
|
|
658
|
-
});
|
|
659
|
-
await agentSendMedia({
|
|
660
|
-
agent: agentConfig,
|
|
661
|
-
...agentTarget,
|
|
662
|
-
mediaId,
|
|
663
|
-
mediaType: uploadType,
|
|
532
|
+
for (const conflict of detectAccountConflicts(ctx.cfg ?? {})) {
|
|
533
|
+
if (conflict.accounts.includes(entry.accountId)) {
|
|
534
|
+
issues.push({
|
|
535
|
+
channel: CHANNEL_ID,
|
|
536
|
+
accountId: entry.accountId,
|
|
537
|
+
kind: "config",
|
|
538
|
+
message: conflict.message,
|
|
664
539
|
});
|
|
665
|
-
} else {
|
|
666
|
-
// For external URLs, download first then upload.
|
|
667
|
-
const res = await wecomFetch(mediaUrl);
|
|
668
|
-
if (!res.ok) {
|
|
669
|
-
throw new Error(`download media failed: ${res.status}`);
|
|
670
|
-
}
|
|
671
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
672
|
-
const filename = basename(new URL(mediaUrl).pathname) || "file";
|
|
673
|
-
deliveredFilename = filename;
|
|
674
|
-
let uploadType = resolveAgentMediaTypeFromFilename(filename);
|
|
675
|
-
const contentType = res.headers.get("content-type") || "";
|
|
676
|
-
if (uploadType === "file" && contentType.toLowerCase().startsWith("image/")) {
|
|
677
|
-
uploadType = "image";
|
|
678
|
-
}
|
|
679
|
-
const mediaId = await agentUploadMedia({
|
|
680
|
-
agent: agentConfig,
|
|
681
|
-
type: uploadType,
|
|
682
|
-
buffer,
|
|
683
|
-
filename,
|
|
684
|
-
});
|
|
685
|
-
await agentSendMedia({
|
|
686
|
-
agent: agentConfig,
|
|
687
|
-
...agentTarget,
|
|
688
|
-
mediaId,
|
|
689
|
-
mediaType: uploadType,
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Also send accompanying text if present.
|
|
694
|
-
if (text) {
|
|
695
|
-
await agentSendText({ agent: agentConfig, ...agentTarget, text });
|
|
696
540
|
}
|
|
541
|
+
}
|
|
697
542
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
recoverStreamId,
|
|
710
|
-
deliveryHint,
|
|
711
|
-
THINKING_PLACEHOLDER,
|
|
712
|
-
);
|
|
713
|
-
await streamManager.finishStream(recoverStreamId);
|
|
714
|
-
unregisterActiveStream(userId, recoverStreamId);
|
|
715
|
-
logger.info("WeCom: recovered and finished stream after media fallback", {
|
|
716
|
-
userId,
|
|
717
|
-
streamId: recoverStreamId,
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
}
|
|
543
|
+
const telemetry = entry.wecomStatus ?? {};
|
|
544
|
+
const displacedAt = telemetry.connection?.lastDisplacedAt;
|
|
545
|
+
if (telemetry.connection?.displaced) {
|
|
546
|
+
issues.push({
|
|
547
|
+
channel: CHANNEL_ID,
|
|
548
|
+
accountId: entry.accountId,
|
|
549
|
+
kind: "runtime",
|
|
550
|
+
message: `企业微信长连接已被其他实例接管${displacedAt ? `(${new Date(displacedAt).toISOString()})` : ""}。`,
|
|
551
|
+
fix: "检查是否有多个实例同时使用相同 botId;保留一个活跃连接即可。",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
721
554
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
555
|
+
const quotas = telemetry.quotas ?? {};
|
|
556
|
+
if ((quotas.exhaustedReplyChats ?? 0) > 0 || (quotas.exhaustedActiveChats ?? 0) > 0) {
|
|
557
|
+
issues.push({
|
|
558
|
+
channel: CHANNEL_ID,
|
|
559
|
+
accountId: entry.accountId,
|
|
560
|
+
kind: "runtime",
|
|
561
|
+
message: `企业微信配额已触顶:24h 回复窗口触顶 ${quotas.exhaustedReplyChats ?? 0} 个会话,主动发送日配额触顶 ${quotas.exhaustedActiveChats ?? 0} 个会话。`,
|
|
562
|
+
});
|
|
563
|
+
} else if ((quotas.nearLimitReplyChats ?? 0) > 0 || (quotas.nearLimitActiveChats ?? 0) > 0) {
|
|
564
|
+
issues.push({
|
|
565
|
+
channel: CHANNEL_ID,
|
|
566
|
+
accountId: entry.accountId,
|
|
567
|
+
kind: "runtime",
|
|
568
|
+
message: `企业微信配额接近上限:24h 回复窗口接近上限 ${quotas.nearLimitReplyChats ?? 0} 个会话,主动发送日配额接近上限 ${quotas.nearLimitActiveChats ?? 0} 个会话。`,
|
|
726
569
|
});
|
|
727
|
-
return {
|
|
728
|
-
channel: "wecom",
|
|
729
|
-
messageId: `msg_agent_media_${Date.now()}`,
|
|
730
|
-
};
|
|
731
|
-
} catch (err) {
|
|
732
|
-
logger.error("WeCom: Agent API media fallback failed", { userId, error: err.message });
|
|
733
570
|
}
|
|
734
|
-
}
|
|
735
571
|
|
|
572
|
+
return issues;
|
|
573
|
+
}),
|
|
574
|
+
buildChannelSummary: ({ snapshot }) => buildBaseChannelStatusSummary(snapshot),
|
|
575
|
+
probeAccount: async () => ({ ok: true, status: 200 }),
|
|
576
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
|
577
|
+
const telemetry = getAccountTelemetry(account.accountId);
|
|
736
578
|
return {
|
|
737
|
-
|
|
738
|
-
|
|
579
|
+
...buildBaseAccountStatusSnapshot({
|
|
580
|
+
account,
|
|
581
|
+
runtime: {
|
|
582
|
+
...runtime,
|
|
583
|
+
lastInboundAt: telemetry.lastInboundAt ?? runtime?.lastInboundAt ?? null,
|
|
584
|
+
lastOutboundAt: telemetry.lastOutboundAt ?? runtime?.lastOutboundAt ?? null,
|
|
585
|
+
},
|
|
586
|
+
probe,
|
|
587
|
+
}),
|
|
588
|
+
wecomStatus: telemetry,
|
|
739
589
|
};
|
|
740
590
|
},
|
|
741
591
|
},
|
|
742
592
|
gateway: {
|
|
743
593
|
startAccount: async (ctx) => {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
594
|
+
setOpenclawConfig(ctx.cfg);
|
|
595
|
+
logAccountConflicts(ctx.cfg);
|
|
596
|
+
|
|
597
|
+
const network = ctx.account.config.network ?? {};
|
|
598
|
+
setConfigProxyUrl(network.egressProxyUrl ?? "");
|
|
599
|
+
setApiBaseUrl(network.apiBaseUrl ?? "");
|
|
600
|
+
|
|
601
|
+
return startWsMonitor({
|
|
602
|
+
account: ctx.account,
|
|
603
|
+
config: ctx.cfg,
|
|
604
|
+
runtime: ctx.runtime,
|
|
605
|
+
abortSignal: ctx.abortSignal,
|
|
748
606
|
});
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
let unregister;
|
|
766
|
-
const botPath = account.webhookPath;
|
|
767
|
-
if (botPath) {
|
|
768
|
-
unregister = registerWebhookTarget({
|
|
769
|
-
path: botPath,
|
|
770
|
-
account,
|
|
771
|
-
config: ctx.cfg,
|
|
772
|
-
});
|
|
773
|
-
logger.info("WeCom Bot webhook path active", { path: botPath });
|
|
774
|
-
} else {
|
|
775
|
-
logger.debug("No Bot webhook path for this account, skipping", { accountId: account.accountId });
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Register Agent inbound webhook if agent inbound is fully configured.
|
|
779
|
-
let unregisterAgent;
|
|
780
|
-
// Per-account agent path: /webhooks/app for default, /webhooks/app/{accountId} for others.
|
|
781
|
-
const agentInboundPath = account.accountId === DEFAULT_ACCOUNT_ID
|
|
782
|
-
? "/webhooks/app"
|
|
783
|
-
: `/webhooks/app/${account.accountId}`;
|
|
784
|
-
if (account.agentInboundConfigured) {
|
|
785
|
-
if (botPath === agentInboundPath) {
|
|
786
|
-
logger.error("WeCom: Agent inbound path conflicts with Bot webhook path, skipping Agent registration", {
|
|
787
|
-
path: agentInboundPath,
|
|
788
|
-
});
|
|
789
|
-
} else {
|
|
790
|
-
const agentCfg = account.config.agent;
|
|
791
|
-
unregisterAgent = registerWebhookTarget({
|
|
792
|
-
path: agentInboundPath,
|
|
793
|
-
account: {
|
|
794
|
-
...account,
|
|
795
|
-
// Agent inbound uses its own token/encodingAesKey for callback verification.
|
|
796
|
-
agentInbound: {
|
|
797
|
-
accountId: account.accountId,
|
|
798
|
-
token: agentCfg.token,
|
|
799
|
-
encodingAesKey: agentCfg.encodingAesKey,
|
|
800
|
-
corpId: agentCfg.corpId,
|
|
801
|
-
corpSecret: agentCfg.corpSecret,
|
|
802
|
-
agentId: agentCfg.agentId,
|
|
803
|
-
},
|
|
804
|
-
},
|
|
805
|
-
config: ctx.cfg,
|
|
806
|
-
});
|
|
807
|
-
logger.info("WeCom Agent inbound webhook registered", { path: agentInboundPath });
|
|
808
|
-
}
|
|
607
|
+
},
|
|
608
|
+
logoutAccount: async ({ cfg, accountId }) => {
|
|
609
|
+
const current = resolveAccount(cfg, accountId);
|
|
610
|
+
const cleared = Boolean(current.botId || current.secret);
|
|
611
|
+
const nextCfg = cleared
|
|
612
|
+
? updateAccountConfig(cfg, accountId, {
|
|
613
|
+
botId: undefined,
|
|
614
|
+
secret: undefined,
|
|
615
|
+
})
|
|
616
|
+
: cfg;
|
|
617
|
+
const runtime = getRuntime();
|
|
618
|
+
if (cleared && runtime?.config?.writeConfigFile) {
|
|
619
|
+
await runtime.config.writeConfigFile(nextCfg);
|
|
809
620
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
clearTimeout(buf.timer);
|
|
816
|
-
}
|
|
817
|
-
messageBuffers.clear();
|
|
818
|
-
if (unregister) unregister();
|
|
819
|
-
if (unregisterAgent) unregisterAgent();
|
|
621
|
+
const resolved = resolveAccount(nextCfg, accountId);
|
|
622
|
+
return {
|
|
623
|
+
cleared,
|
|
624
|
+
envToken: false,
|
|
625
|
+
loggedOut: !resolved.botId && !resolved.secret,
|
|
820
626
|
};
|
|
821
|
-
|
|
822
|
-
// Backward compatibility: older runtime may not pass abortSignal.
|
|
823
|
-
// In that case, keep legacy behavior and expose explicit shutdown.
|
|
824
|
-
if (!ctx.abortSignal) {
|
|
825
|
-
return { shutdown };
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
if (ctx.abortSignal.aborted) {
|
|
829
|
-
await shutdown();
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
await new Promise((resolve) => {
|
|
834
|
-
ctx.abortSignal.addEventListener("abort", resolve, { once: true });
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
await shutdown();
|
|
838
627
|
},
|
|
839
628
|
},
|
|
840
629
|
};
|
|
630
|
+
|
|
631
|
+
export const wecomChannelPluginTesting = {
|
|
632
|
+
buildUnsupportedMediaNotice,
|
|
633
|
+
};
|