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