@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.
@@ -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 { streamManager } from "../stream-manager.js";
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 { listAccountIds, resolveAccount, detectAccountConflicts } from "./accounts.js";
8
- import { DEFAULT_ACCOUNT_ID, THINKING_PLACEHOLDER } from "./constants.js";
9
- import { parseResponseUrlResult } from "./response-url.js";
10
- import { messageBuffers, resolveAgentConfig, resolveWebhookUrl, responseUrls, streamContext } from "./state.js";
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, webhookSendText, webhookUploadFile, webhookSendFile } from "./webhook-bot.js";
14
- import { normalizeWebhookPath, registerWebhookTarget } from "./webhook-targets.js";
15
- import { wecomFetch, setConfigProxyUrl } from "./http.js";
16
- import { setApiBaseUrl } from "./constants.js";
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
- const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
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
- const ext = filename.split(".").pop()?.toLowerCase() || "";
23
- return AGENT_IMAGE_EXTS.has(ext) ? "image" : "file";
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: "wecom",
259
+ id: CHANNEL_ID,
28
260
  meta: {
29
- id: "wecom",
261
+ id: CHANNEL_ID,
30
262
  label: "Enterprise WeChat",
31
263
  selectionLabel: "Enterprise WeChat (AI Bot)",
32
- docsPath: "/channels/wecom",
33
- blurb: "Enterprise WeChat AI Bot channel plugin.",
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, // WeCom AI Bot requires stream-style responses.
292
+ blockStreaming: true,
43
293
  },
44
- reload: { configPrefixes: ["channels.wecom"] },
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: false,
298
+ additionalProperties: true,
50
299
  properties: {
51
- enabled: {
52
- type: "boolean",
53
- description: "Enable WeCom channel",
54
- default: true,
55
- },
56
- token: {
57
- type: "string",
58
- description: "WeCom bot token from admin console",
59
- },
60
- encodingAesKey: {
61
- type: "string",
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
- token: {
227
- sensitive: true,
228
- label: "Bot Token",
229
- },
230
- encodingAesKey: {
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: (cfg) => listAccountIds(cfg),
252
- resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
253
- defaultAccountId: (cfg) => {
254
- const ids = listAccountIds(cfg);
255
- return ids.length > 0 ? ids[0] : null;
256
- },
257
- setAccountEnabled: ({ cfg, accountId, enabled }) => {
258
- if (!cfg.channels) cfg.channels = {};
259
- if (!cfg.channels.wecom) cfg.channels.wecom = {};
260
- const wecom = cfg.channels.wecom;
261
- if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
262
- // Legacy single-account: toggle top-level enabled.
263
- wecom.enabled = enabled;
264
- } else if (wecom[accountId] && typeof wecom[accountId] === "object") {
265
- // Dictionary mode: toggle per-account enabled.
266
- wecom[accountId].enabled = enabled;
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
- return cfg;
269
- },
270
- deleteAccount: ({ cfg, accountId }) => {
271
- if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
272
- if (cfg.channels?.wecom) delete cfg.channels.wecom;
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
- return cfg;
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
- sendText: async ({ cfg: _cfg, to, text, accountId: _accountId }) => {
287
- // `to` format: "wecom:userid" or "userid".
288
- const userId = to.replace(/^wecom:/, "");
289
-
290
- // Prefer async-context stream only when it is still writable.
291
- // If the context stream already finished (common with concurrent messages),
292
- // fall back to the latest recoverable active stream for this user/group.
293
- const ctx = streamContext.getStore();
294
- const ctxStreamId = ctx?.streamId ?? null;
295
- const ctxStream = ctxStreamId ? streamManager.getStream(ctxStreamId) : null;
296
- const canUseCtxStream = !!(ctxStreamId && ctxStream && !ctxStream.finished);
297
- const streamId = canUseCtxStream ? ctxStreamId : resolveRecoverableStream(userId);
298
- const streamObj = streamId ? streamManager.getStream(streamId) : null;
299
- const hasStream = streamId ? streamManager.hasStream(streamId) : false;
300
- const finished = streamObj?.finished ?? true;
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
- // Log stream miss details for debugging concurrent-message issues.
320
- logger.warn("WeCom sendText: Layer 1 stream miss", {
321
- userId,
322
- streamId: streamId ?? null,
323
- hasStream,
324
- finished,
325
- hasAsyncContext: !!ctx,
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
- // Layer 3a: Webhook Bot (group notifications via webhook:name target)
372
- const target = resolveWecomTarget(to);
373
- if (target?.webhook) {
374
- const webhookUrl = resolveWebhookUrl(target.webhook);
375
- if (webhookUrl) {
376
- try {
377
- await webhookSendText({ url: webhookUrl, content: text });
378
- logger.info("WeCom: sent via Webhook Bot (sendText)", {
379
- webhookName: target.webhook,
380
- contentPreview: text.substring(0, 50),
381
- });
382
- return {
383
- channel: "wecom",
384
- messageId: `msg_webhook_${Date.now()}`,
385
- };
386
- } catch (err) {
387
- logger.error("WeCom: Webhook Bot sendText failed", {
388
- webhookName: target.webhook,
389
- error: err.message,
390
- });
391
- }
392
- } else {
393
- logger.warn("WeCom: webhook name not found in config", { webhookName: target.webhook });
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
- // Layer 3b: Agent API fallback (stream closed + response_url unavailable)
398
- const agentConfig = resolveAgentConfig();
399
- if (agentConfig) {
400
- try {
401
- const agentTarget = (target && !target.webhook) ? target : { toUser: userId };
402
- await agentSendText({ agent: agentConfig, ...agentTarget, text });
403
- logger.info("WeCom: sent via Agent API fallback (sendText)", {
404
- userId,
405
- to,
406
- contentPreview: text.substring(0, 50),
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
- logger.warn("WeCom outbound: no delivery channel available (all layers exhausted)", {
418
- userId,
419
- });
420
-
421
- return {
422
- channel: "wecom",
423
- messageId: `fake_${Date.now()}`,
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
- const fileFilename = basename(absolutePath);
453
- const fileExt = fileFilename.split(".").pop()?.toLowerCase() || "";
454
- const streamImageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
455
-
456
- if (!streamImageExts.has(fileExt)) {
457
- // Non-image file: WeCom Bot stream API does not support files.
458
- // Send via Agent DM and post a hint in the group stream.
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
- const agentCfgForFile = resolveAgentConfig();
466
- if (agentCfgForFile) {
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
- streamManager.replaceIfPlaceholder(streamId, text, THINKING_PLACEHOLDER);
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
- // OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
548
- const content = text ? `${text}\n\n![image](${mediaUrl})` : `![image](${mediaUrl})`;
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
- logger.warn("WeCom outbound sendMedia: no active stream, trying fallbacks", { userId });
565
-
566
- // Layer 2a: Webhook Bot fallback for media (group notifications)
567
- const target = resolveWecomTarget(to);
568
- if (target?.webhook) {
569
- const webhookUrl = resolveWebhookUrl(target.webhook);
570
- if (webhookUrl) {
571
- try {
572
- // Resolve file to buffer
573
- let buffer;
574
- let filename;
575
- let absolutePath = mediaUrl;
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
- logger.info("WeCom: sent media via Webhook Bot (sendMedia)", {
609
- webhookName: target.webhook,
610
- mediaUrl: mediaUrl.substring(0, 80),
611
- });
612
- return {
613
- channel: "wecom",
614
- messageId: `msg_webhook_media_${Date.now()}`,
615
- };
616
- } catch (err) {
617
- logger.error("WeCom: Webhook Bot sendMedia failed", {
618
- webhookName: target.webhook,
619
- error: err.message,
620
- });
621
- }
622
- } else {
623
- logger.warn("WeCom: webhook name not found in config (sendMedia)", { webhookName: target.webhook });
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
- // Layer 2b: Agent API fallback for media
628
- const agentConfig = resolveAgentConfig();
629
- if (agentConfig) {
630
- try {
631
- const agentTarget = (target && !target.webhook) ? target : resolveWecomTarget(to) || { toUser: userId };
632
- let deliveredFilename = "file";
633
-
634
- // Determine if mediaUrl is a local file path.
635
- let absolutePath = mediaUrl;
636
- if (absolutePath.startsWith("sandbox:")) {
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
- if (absolutePath.startsWith("/")) {
642
- // Upload local file then send via Agent API.
643
- const buffer = await readFile(absolutePath);
644
- const filename = basename(absolutePath);
645
- deliveredFilename = filename;
646
- const uploadType = resolveAgentMediaTypeFromFilename(filename);
647
- const mediaId = await agentUploadMedia({
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
- // Best-effort stream recovery: when async context is missing and the
693
- // active stream mapping was already cleaned, still clear "thinking..."
694
- // in the most recent stream for this user.
695
- const recoverStreamId = resolveRecoverableStream(userId);
696
- if (recoverStreamId && streamManager.hasStream(recoverStreamId)) {
697
- const recoverStream = streamManager.getStream(recoverStreamId);
698
- if (recoverStream && !recoverStream.finished) {
699
- const deliveryHint = text
700
- ? `${text}\n\n📎 文件已通过私信发送给您:${deliveredFilename}`
701
- : `📎 文件已通过私信发送给您:${deliveredFilename}`;
702
- streamManager.replaceIfPlaceholder(
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
- logger.info("WeCom: sent media via Agent API fallback (sendMedia)", {
717
- userId,
718
- to,
719
- mediaUrl: mediaUrl.substring(0, 80),
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
- channel: "wecom",
732
- messageId: `fake_${Date.now()}`,
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
- const account = ctx.account;
739
- logger.info("WeCom gateway starting", {
740
- accountId: account.accountId,
741
- webhookPath: account.webhookPath,
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
- // Wire proxy URL from config (env var takes precedence inside http.js).
745
- const wecomCfg = ctx.cfg?.channels?.wecom ?? {};
746
- setConfigProxyUrl(wecomCfg.network?.egressProxyUrl ?? "");
747
- // Wire API base URL override (env var WECOM_API_BASE_URL takes precedence).
748
- setApiBaseUrl(wecomCfg.network?.apiBaseUrl ?? "");
749
-
750
- // Conflict detection: warn about duplicate tokens / agent IDs.
751
- const conflicts = detectAccountConflicts(ctx.cfg);
752
- for (const conflict of conflicts) {
753
- logger.error(`WeCom config conflict: ${conflict.message}`, {
754
- type: conflict.type,
755
- accounts: conflict.accounts,
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
- const shutdown = async () => {
806
- logger.info("WeCom gateway shutting down");
807
- // Clear pending debounce timers to prevent post-shutdown dispatches.
808
- for (const [, buf] of messageBuffers) {
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
+ };