@sunnoy/wecom 1.9.0 → 2.0.0

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