@sunnoy/wecom 2.4.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@
69
69
 
70
70
  ## 前置要求
71
71
 
72
- - 已安装 [OpenClaw](https://github.com/openclaw/openclaw) `2026.3.2+`
72
+ - 已安装 [OpenClaw](https://github.com/openclaw/openclaw) `2026.3.23-2+`
73
73
  - 企业微信管理后台权限,可创建 AI 机器人或自建应用
74
74
  - **机器人已切换到长连接模式**(参考[官方文档](https://open.work.weixin.qq.com/help2/pc/cat?doc_id=21657))
75
75
  - 运行 OpenClaw 的机器可以出站访问:
@@ -84,6 +84,8 @@ Bot 主链路不需要企业微信反向访问你的 HTTP 回调地址。
84
84
  openclaw plugins install @sunnoy/wecom
85
85
  ```
86
86
 
87
+ > **3.0 兼容性说明:** 从 `3.0.0` 开始,本插件仅支持 OpenClaw `2026.3.23-2+`。旧版 OpenClaw 请继续使用 `2.x`。
88
+
87
89
  > **从官方插件迁移:** 如果之前使用 `openclaw plugins install @wecom/wecom-openclaw-plugin`,请先卸载官方插件再安装本插件。`channels.wecom` 配置字段兼容,无需修改。
88
90
 
89
91
  ## 从 HTTP 回调迁移
@@ -525,6 +527,10 @@ Webhook 只负责群通知。
525
527
 
526
528
  2.0 完全采用 WebSocket 长连接,不再使用 HTTP 回调。需要在企业微信后台将机器人切换到[长连接模式](https://open.work.weixin.qq.com/help2/pc/cat?doc_id=21657)。
527
529
 
530
+ ### Q: 3.0 为什么不能再装在旧 OpenClaw 上?
531
+
532
+ 3.0 开始直接适配 OpenClaw `2026.3.23-2+` 的新版 plugin SDK 导出和媒体/runtime 约定,旧版 core 缺少这些接口。旧环境请固定使用 `2.x`。
533
+
528
534
  ### Q: 之前用的官方插件 `@wecom/wecom-openclaw-plugin`,怎么迁移?
529
535
 
530
536
  ```bash
package/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { logger } from "./logger.js";
2
- import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
2
+ import {
3
+ wecomChannelPlugin,
4
+ handleSubagentDeliveryTarget,
5
+ handleSubagentSpawned,
6
+ handleSubagentEnded,
7
+ } from "./wecom/channel-plugin.js";
3
8
  import { createWeComMcpTool } from "./wecom/mcp-tool.js";
4
9
  import { createImageStudioTool } from "./wecom/image-studio-tool.js";
5
10
  import { resolveQwenImageToolsConfig, wecomPluginConfigSchema } from "./wecom/plugin-config.js";
@@ -7,6 +12,7 @@ import { setOpenclawConfig, setRuntime } from "./wecom/state.js";
7
12
  import { buildReplyMediaGuidance } from "./wecom/ws-monitor.js";
8
13
  import { listAccountIds, resolveAccount } from "./wecom/accounts.js";
9
14
  import { createCallbackHandler } from "./wecom/callback-inbound.js";
15
+ import { prepareWecomMessageToolParams } from "./wecom/outbound-sender-protocol.js";
10
16
 
11
17
  const plugin = {
12
18
  id: "wecom",
@@ -49,6 +55,21 @@ const plugin = {
49
55
  const guidance = buildReplyMediaGuidance(api.config, ctx.agentId);
50
56
  return { appendSystemContext: guidance };
51
57
  });
58
+
59
+ api.on("subagent_delivery_target", handleSubagentDeliveryTarget);
60
+ api.on("subagent_spawned", handleSubagentSpawned);
61
+ api.on("subagent_ended", handleSubagentEnded);
62
+
63
+ api.on("before_tool_call", (event, ctx) => {
64
+ if (event.toolName !== "message") {
65
+ return;
66
+ }
67
+ const params = prepareWecomMessageToolParams(event.params, ctx.agentId);
68
+ if (params === event.params) {
69
+ return;
70
+ }
71
+ return { params };
72
+ });
52
73
  },
53
74
  };
54
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "2.4.1",
3
+ "version": "3.0.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -18,7 +18,7 @@
18
18
  "openclaw.plugin.json"
19
19
  ],
20
20
  "peerDependencies": {
21
- "openclaw": "*"
21
+ "openclaw": "^2026.3.23-2"
22
22
  },
23
23
  "optionalDependencies": {
24
24
  "undici": "^7.0.0"
@@ -60,6 +60,7 @@
60
60
  "license": "ISC",
61
61
  "dependencies": {
62
62
  "@wecom/aibot-node-sdk": "^1.0.3",
63
- "file-type": "^21.3.0"
63
+ "file-type": "^21.3.0",
64
+ "pinyin-pro": "^3.28.0"
64
65
  }
65
66
  }
@@ -13,7 +13,7 @@ import { wecomFetch } from "./http.js";
13
13
  import { AGENT_API_ENDPOINTS, CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS } from "./constants.js";
14
14
 
15
15
  function resolveManagedCallbackMediaDir() {
16
- const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
16
+ const override = process.env.OPENCLAW_STATE_DIR?.trim();
17
17
  const stateDir = override || path.join(process.env.HOME || "/tmp", ".openclaw");
18
18
  return path.join(stateDir, "media", "wecom");
19
19
  }
@@ -3,8 +3,8 @@ import { basename } from "node:path";
3
3
  import {
4
4
  buildBaseAccountStatusSnapshot,
5
5
  buildBaseChannelStatusSummary,
6
- formatPairingApproveHint,
7
- } from "openclaw/plugin-sdk";
6
+ } from "openclaw/plugin-sdk/status-helpers";
7
+ import { formatPairingApproveHint } from "openclaw/plugin-sdk/core";
8
8
  import { logger } from "../logger.js";
9
9
  import { splitTextByByteLimit } from "../utils.js";
10
10
  import {
@@ -22,7 +22,7 @@ import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js"
22
22
  import { setConfigProxyUrl, wecomFetch } from "./http.js";
23
23
  import { wecomOnboardingAdapter } from "./onboarding.js";
24
24
  import { getAccountTelemetry, recordOutboundActivity } from "./runtime-telemetry.js";
25
- import { getOpenclawConfig, getRuntime, setOpenclawConfig } from "./state.js";
25
+ import { getOpenclawConfig, getRuntime, setChannelRuntime, setOpenclawConfig } from "./state.js";
26
26
  import { resolveWecomTarget } from "./target.js";
27
27
  import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookSendText, webhookUploadFile } from "./webhook-bot.js";
28
28
  import { loadOutboundMediaFromUrl as loadOutboundMediaFromUrlCompat } from "./openclaw-compat.js";
@@ -36,6 +36,7 @@ import {
36
36
  } from "./constants.js";
37
37
  import { uploadAndSendMedia } from "./media-uploader.js";
38
38
  import { getExtendedMediaLocalRoots } from "./openclaw-compat.js";
39
+ import { applyOutboundSenderProtocol } from "./outbound-sender-protocol.js";
39
40
  import { extractParentAgentId } from "./parent-resolver.js";
40
41
  import { sendWsMessage, startWsMonitor } from "./ws-monitor.js";
41
42
  import { getWsClient } from "./ws-state.js";
@@ -144,6 +145,7 @@ function applyNetworkConfig(cfg, accountId) {
144
145
 
145
146
  async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, preparedMedia, replyFormat }) {
146
147
  const account = resolveAccount(cfg, accountId);
148
+ const outboundText = text ? applyOutboundSenderProtocol(text).content : text;
147
149
  const raw = account?.config?.webhooks?.[webhookName];
148
150
  const url = raw ? (String(raw).startsWith("http") ? String(raw) : `${getWebhookBotSendUrl()}?key=${raw}`) : null;
149
151
  if (!url) {
@@ -156,7 +158,7 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
156
158
  : (opts) => webhookSendMarkdown(opts);
157
159
 
158
160
  if (!mediaUrl) {
159
- await sendWebhookText({ url, content: text });
161
+ await sendWebhookText({ url, content: outboundText });
160
162
  recordOutboundActivity({ accountId });
161
163
  return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
162
164
  }
@@ -164,8 +166,8 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
164
166
  const { buffer, filename, mediaType } =
165
167
  preparedMedia ?? (await loadResolvedMedia(mediaUrl, { accountConfig: account?.config }));
166
168
 
167
- if (text) {
168
- await sendWebhookText({ url, content: text });
169
+ if (outboundText) {
170
+ await sendWebhookText({ url, content: outboundText });
169
171
  }
170
172
 
171
173
  if (mediaType === "image") {
@@ -186,13 +188,14 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
186
188
  async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMedia, replyFormat }) {
187
189
  const account = resolveAccount(cfg, accountId);
188
190
  const agent = account?.agentCredentials;
191
+ const outboundText = text ? applyOutboundSenderProtocol(text).content : text;
189
192
  if (!agent) {
190
193
  throw new Error("Agent API is not configured for this account");
191
194
  }
192
195
 
193
196
  const effectiveFormat = replyFormat || account?.agentReplyFormat || "markdown";
194
- if (text) {
195
- for (const chunk of splitTextByByteLimit(text)) {
197
+ if (outboundText) {
198
+ for (const chunk of splitTextByByteLimit(outboundText)) {
196
199
  await agentSendText({ agent, ...target, text: chunk, format: effectiveFormat });
197
200
  }
198
201
  }
@@ -619,6 +622,9 @@ export const wecomChannelPlugin = {
619
622
  gateway: {
620
623
  startAccount: async (ctx) => {
621
624
  setOpenclawConfig(ctx.cfg);
625
+ if (ctx.channelRuntime) {
626
+ setChannelRuntime(ctx.channelRuntime);
627
+ }
622
628
  logAccountConflicts(ctx.cfg);
623
629
 
624
630
  const network = ctx.account.config.network ?? {};
@@ -664,55 +670,51 @@ export const wecomChannelPlugin = {
664
670
  };
665
671
  },
666
672
  },
667
- hooks: {
668
- /**
669
- * Ensure announce delivery uses a valid WeCom channel accountId.
670
- *
671
- * When a dynamic agent (e.g. wecom-yoyo-dm-xxx) spawns a sub-agent,
672
- * the announce delivery may reference the dynamic agent ID as accountId.
673
- * This hook resolves it to the actual WeCom account (e.g. yoyo) so the
674
- * outbound sendText can find valid WS/Agent API credentials.
675
- */
676
- subagent_delivery_target: async (event, ctx) => {
677
- const origin = event.requesterOrigin;
678
- if (!origin?.channel || origin.channel !== CHANNEL_ID) return;
679
-
680
- const cfg = ctx?.cfg ?? getOpenclawConfig();
681
-
682
- // Check whether current accountId already resolves to a valid account
683
- const currentAccount = resolveAccount(cfg, origin.accountId);
684
- if (currentAccount?.enabled) return;
685
-
686
- // Try to extract the base account from a dynamic agent ID
687
- const baseId = extractParentAgentId(origin.accountId);
688
- if (baseId && baseId !== origin.accountId) {
689
- const baseAccount = resolveAccount(cfg, baseId);
690
- if (baseAccount?.enabled) {
691
- logger.info(`[wecom] subagent_delivery_target: ${origin.accountId} → ${baseId}`);
692
- return { origin: { ...origin, accountId: baseId } };
693
- }
694
- }
673
+ };
695
674
 
696
- // Fallback to default account
697
- const defaultId = resolveDefaultAccountId(cfg);
698
- if (defaultId && defaultId !== origin.accountId) {
699
- logger.info(`[wecom] subagent_delivery_target: fallback → ${defaultId}`);
700
- return { origin: { ...origin, accountId: defaultId } };
701
- }
702
- },
675
+ /**
676
+ * Ensure announce delivery uses a valid WeCom channel accountId.
677
+ *
678
+ * When a dynamic agent (e.g. wecom-yoyo-dm-xxx) spawns a sub-agent,
679
+ * the announce delivery may reference the dynamic agent ID as accountId.
680
+ * This hook resolves it to the actual WeCom account (e.g. yoyo) so the
681
+ * outbound sendText can find valid WS/Agent API credentials.
682
+ */
683
+ export async function handleSubagentDeliveryTarget(event, ctx) {
684
+ const origin = event.requesterOrigin;
685
+ if (!origin?.channel || origin.channel !== CHANNEL_ID) return;
686
+
687
+ const cfg = ctx?.cfg ?? getOpenclawConfig();
688
+
689
+ const currentAccount = resolveAccount(cfg, origin.accountId);
690
+ if (currentAccount?.enabled) return;
691
+
692
+ const baseId = extractParentAgentId(origin.accountId);
693
+ if (baseId && baseId !== origin.accountId) {
694
+ const baseAccount = resolveAccount(cfg, baseId);
695
+ if (baseAccount?.enabled) {
696
+ logger.info(`[wecom] subagent_delivery_target: ${origin.accountId} → ${baseId}`);
697
+ return { origin: { ...origin, accountId: baseId } };
698
+ }
699
+ }
703
700
 
704
- subagent_spawned: async (event) => {
705
- logger.info(
706
- `[wecom] subagent spawned: child=${event.childSessionKey} requester=${event.requesterSessionKey}`,
707
- );
708
- },
701
+ const defaultId = resolveDefaultAccountId(cfg);
702
+ if (defaultId && defaultId !== origin.accountId) {
703
+ logger.info(`[wecom] subagent_delivery_target: fallback → ${defaultId}`);
704
+ return { origin: { ...origin, accountId: defaultId } };
705
+ }
706
+ }
709
707
 
710
- subagent_ended: async (event) => {
711
- logger.info(
712
- `[wecom] subagent ended: target=${event.targetSessionKey} reason=${event.reason} outcome=${event.outcome}`,
713
- );
714
- },
715
- },
716
- };
708
+ export async function handleSubagentSpawned(event) {
709
+ logger.info(
710
+ `[wecom] subagent spawned: child=${event.childSessionKey} requester=${event.requesterSessionKey}`,
711
+ );
712
+ }
713
+
714
+ export async function handleSubagentEnded(event) {
715
+ logger.info(
716
+ `[wecom] subagent ended: target=${event.targetSessionKey} reason=${event.reason} outcome=${event.outcome}`,
717
+ );
718
+ }
717
719
 
718
720
  export const wecomChannelPluginTesting = {};
@@ -1,4 +1,4 @@
1
- import { addWildcardAllowFrom } from "openclaw/plugin-sdk";
1
+ import { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup";
2
2
  import { DEFAULT_WS_URL, DEFAULT_ACCOUNT_ID } from "./constants.js";
3
3
  import { resolveAccount, updateAccountConfig } from "./accounts.js";
4
4
 
@@ -3,10 +3,8 @@ import { homedir, tmpdir } from "node:os";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { readFile, realpath, stat } from "node:fs/promises";
5
5
 
6
- const sdkReady = import("openclaw/plugin-sdk")
6
+ const sdkReady = import("openclaw/plugin-sdk/media-runtime")
7
7
  .then((sdk) => ({
8
- loadOutboundMediaFromUrl:
9
- typeof sdk.loadOutboundMediaFromUrl === "function" ? sdk.loadOutboundMediaFromUrl.bind(sdk) : undefined,
10
8
  detectMime: typeof sdk.detectMime === "function" ? sdk.detectMime.bind(sdk) : undefined,
11
9
  getDefaultMediaLocalRoots:
12
10
  typeof sdk.getDefaultMediaLocalRoots === "function" ? sdk.getDefaultMediaLocalRoots.bind(sdk) : undefined,
@@ -82,7 +80,7 @@ function normalizeMediaReference(mediaUrl) {
82
80
  }
83
81
 
84
82
  function resolveStateDir() {
85
- const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
83
+ const override = process.env.OPENCLAW_STATE_DIR?.trim();
86
84
  if (override) {
87
85
  return resolve(resolveUserPath(override));
88
86
  }
@@ -137,6 +135,30 @@ function shouldFallbackFromLocalAccessError(error, options) {
137
135
  return isLocalMediaAccessError(error) && !hasExplicitMediaRoots(options);
138
136
  }
139
137
 
138
+ function isPathInsideRoot(filePath, rootPath) {
139
+ const normalizedRoot = rootPath.endsWith("/") ? rootPath : `${rootPath}/`;
140
+ return filePath === rootPath || filePath.startsWith(normalizedRoot);
141
+ }
142
+
143
+ async function assertLocalPathAllowed(filePath, roots) {
144
+ const canonicalFilePath = await realpath(filePath);
145
+
146
+ for (const root of roots) {
147
+ try {
148
+ const canonicalRoot = await realpath(root);
149
+ if (isPathInsideRoot(canonicalFilePath, canonicalRoot)) {
150
+ return canonicalFilePath;
151
+ }
152
+ } catch {
153
+ if (isPathInsideRoot(canonicalFilePath, root)) {
154
+ return canonicalFilePath;
155
+ }
156
+ }
157
+ }
158
+
159
+ throw new Error(`LocalMediaAccessError: path is not under an allowed directory: ${filePath}`);
160
+ }
161
+
140
162
  async function readLocalMediaFile(filePath, { maxBytes } = {}) {
141
163
  const info = await stat(filePath);
142
164
  if (!info.isFile()) {
@@ -251,16 +273,18 @@ export async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
251
273
  const normalized = normalizeMediaReference(mediaUrl);
252
274
  const filePath = asLocalPath(normalized);
253
275
  const localRoots = await getExtendedMediaLocalRoots(options);
254
- const sdk = await sdkReady;
276
+ const enforceLocalRoots = options.includeDefaultMediaLocalRoots === false || hasExplicitMediaRoots(options);
255
277
 
256
278
  if (filePath) {
279
+ const allowedFilePath = enforceLocalRoots ? await assertLocalPathAllowed(filePath, localRoots) : filePath;
280
+
257
281
  if (typeof options.runtimeLoadMedia === "function" && localRoots.length > 0) {
258
282
  try {
259
- const loaded = await options.runtimeLoadMedia(filePath, { localRoots });
283
+ const loaded = await options.runtimeLoadMedia(allowedFilePath, { localRoots });
260
284
  return {
261
285
  buffer: loaded.buffer,
262
286
  contentType: loaded.contentType || "",
263
- fileName: loaded.fileName || basename(filePath) || "file",
287
+ fileName: loaded.fileName || basename(allowedFilePath) || "file",
264
288
  };
265
289
  } catch (error) {
266
290
  if (!shouldFallbackFromLocalAccessError(error, options)) {
@@ -269,27 +293,7 @@ export async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
269
293
  }
270
294
  }
271
295
 
272
- if (sdk.loadOutboundMediaFromUrl) {
273
- try {
274
- return await sdk.loadOutboundMediaFromUrl(filePath, {
275
- maxBytes: options.maxBytes,
276
- mediaLocalRoots: localRoots,
277
- });
278
- } catch (error) {
279
- if (!shouldFallbackFromLocalAccessError(error, options)) {
280
- throw error;
281
- }
282
- }
283
- }
284
-
285
- return readLocalMediaFile(filePath, options);
286
- }
287
-
288
- if (sdk.loadOutboundMediaFromUrl && !options.fetchImpl) {
289
- return sdk.loadOutboundMediaFromUrl(normalized, {
290
- maxBytes: options.maxBytes,
291
- mediaLocalRoots: localRoots,
292
- });
296
+ return readLocalMediaFile(allowedFilePath, options);
293
297
  }
294
298
 
295
299
  return fetchRemoteMedia(normalized, options);
@@ -0,0 +1,142 @@
1
+ import { resolveWecomTarget } from "./target.js";
2
+
3
+ const OUTBOUND_SENDER_PROTOCOL_PATTERN = /^\s*\[\[\s*sender\s*:\s*([^\]\r\n]+?)\s*\]\]\s*(?:\r?\n)?/i;
4
+
5
+ function sanitizeSenderLabel(value) {
6
+ return String(value ?? "")
7
+ .replace(/[\[\]\r\n]+/g, " ")
8
+ .replace(/\s+/g, " ")
9
+ .trim();
10
+ }
11
+
12
+ function buildVisibleContent(sender, body) {
13
+ const normalizedSender = sanitizeSenderLabel(sender);
14
+ if (!normalizedSender) {
15
+ return String(body ?? "");
16
+ }
17
+
18
+ const normalizedBody = String(body ?? "").replace(/^\r?\n/, "");
19
+ if (!normalizedBody.trim()) {
20
+ return `【sender:${normalizedSender}】`;
21
+ }
22
+ if (normalizedBody.includes("\n")) {
23
+ return `【sender:${normalizedSender}】\n${normalizedBody}`;
24
+ }
25
+ return `【sender:${normalizedSender}】${normalizedBody.trimStart()}`;
26
+ }
27
+
28
+ function resolveDynamicSender(agentId) {
29
+ const normalized = String(agentId ?? "").trim().toLowerCase();
30
+ if (!normalized) {
31
+ return null;
32
+ }
33
+
34
+ const dmMatch = normalized.match(/^wecom-(?:(.+?)-)?dm-(.+)$/);
35
+ if (dmMatch?.[2]) {
36
+ return {
37
+ kind: "user",
38
+ id: dmMatch[2],
39
+ label: dmMatch[2],
40
+ };
41
+ }
42
+
43
+ const groupMatch = normalized.match(/^wecom-(?:(.+?)-)?group-(.+)$/);
44
+ if (groupMatch?.[2]) {
45
+ return {
46
+ kind: "group",
47
+ id: groupMatch[2],
48
+ label: `group:${groupMatch[2]}`,
49
+ };
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ export function resolveOutboundSenderLabel(agentId) {
56
+ const sender = resolveDynamicSender(agentId);
57
+ if (sender?.label) {
58
+ return sender.label;
59
+ }
60
+
61
+ const normalized = String(agentId ?? "").trim().toLowerCase();
62
+ return normalized || "main";
63
+ }
64
+
65
+ export function ensureOutboundSenderProtocol(content, sender) {
66
+ const raw = String(content ?? "");
67
+ const normalizedSender = sanitizeSenderLabel(sender);
68
+ if (!normalizedSender || OUTBOUND_SENDER_PROTOCOL_PATTERN.test(raw)) {
69
+ return raw;
70
+ }
71
+ return raw ? `[[sender:${normalizedSender}]]\n${raw}` : `[[sender:${normalizedSender}]]`;
72
+ }
73
+
74
+ export function prepareWecomMessageToolParams(params, agentId) {
75
+ const action = String(params?.action ?? "").trim().toLowerCase();
76
+ if (action !== "send" && action !== "sendattachment") {
77
+ return params;
78
+ }
79
+
80
+ const channel = String(params?.channel ?? "").trim().toLowerCase();
81
+ const sender = resolveDynamicSender(agentId);
82
+ if ((!channel && !sender) || (channel && channel !== "wecom")) {
83
+ return params;
84
+ }
85
+
86
+ const target = typeof params?.target === "string" ? params.target.trim() : "";
87
+ const message = typeof params?.message === "string" ? params.message : "";
88
+ if (!sender || !target || !message) {
89
+ return params;
90
+ }
91
+
92
+ const resolvedTarget = resolveWecomTarget(target);
93
+ const normalizedSenderId = sender.id.toLowerCase();
94
+ const isSameDirectTarget =
95
+ sender.kind === "user" &&
96
+ typeof resolvedTarget?.toUser === "string" &&
97
+ resolvedTarget.toUser.trim().toLowerCase() === normalizedSenderId;
98
+ const isSameGroupTarget =
99
+ sender.kind === "group" &&
100
+ typeof resolvedTarget?.chatId === "string" &&
101
+ resolvedTarget.chatId.trim().toLowerCase() === normalizedSenderId;
102
+ if (isSameDirectTarget || isSameGroupTarget) {
103
+ return params;
104
+ }
105
+
106
+ const nextMessage = ensureOutboundSenderProtocol(message, sender.label);
107
+ if (nextMessage === message) {
108
+ return params;
109
+ }
110
+
111
+ return {
112
+ ...params,
113
+ message: nextMessage,
114
+ };
115
+ }
116
+
117
+ export function applyOutboundSenderProtocol(content) {
118
+ const raw = String(content ?? "");
119
+ const match = raw.match(OUTBOUND_SENDER_PROTOCOL_PATTERN);
120
+ if (!match) {
121
+ return {
122
+ sender: "",
123
+ content: raw,
124
+ usedProtocol: false,
125
+ };
126
+ }
127
+
128
+ const sender = sanitizeSenderLabel(match[1]);
129
+ if (!sender) {
130
+ return {
131
+ sender: "",
132
+ content: raw,
133
+ usedProtocol: false,
134
+ };
135
+ }
136
+
137
+ return {
138
+ sender,
139
+ content: buildVisibleContent(sender, raw.slice(match[0].length)),
140
+ usedProtocol: true,
141
+ };
142
+ }
package/wecom/state.js CHANGED
@@ -5,6 +5,7 @@ import { resolveAgentConfigForAccount, resolveDefaultAccountId, resolveAccount }
5
5
  const runtimeState = {
6
6
  runtime: null,
7
7
  openclawConfig: null,
8
+ channelRuntime: null,
8
9
  ensuredDynamicAgentIds: new Set(),
9
10
  ensureDynamicAgentWriteQueue: Promise.resolve(),
10
11
  };
@@ -23,6 +24,14 @@ export function getRuntime() {
23
24
  return runtimeState.runtime;
24
25
  }
25
26
 
27
+ export function setChannelRuntime(channelRuntime) {
28
+ runtimeState.channelRuntime = channelRuntime;
29
+ }
30
+
31
+ export function getChannelRuntime() {
32
+ return runtimeState.channelRuntime;
33
+ }
34
+
26
35
  export function setOpenclawConfig(config) {
27
36
  runtimeState.openclawConfig = config;
28
37
  }
@@ -77,6 +86,7 @@ export function resolveWebhookUrl(name, accountId) {
77
86
  export function resetStateForTesting() {
78
87
  runtimeState.runtime = null;
79
88
  runtimeState.openclawConfig = null;
89
+ runtimeState.channelRuntime = null;
80
90
  runtimeState.ensuredDynamicAgentIds = new Set();
81
91
  runtimeState.ensureDynamicAgentWriteQueue = Promise.resolve();
82
92
  dispatchLocks.clear();
package/wecom/target.js CHANGED
@@ -7,6 +7,88 @@
7
7
  * Supports explicit prefixes (party:, tag:, etc.) and heuristic fallback.
8
8
  */
9
9
 
10
+ import { readdirSync } from "node:fs";
11
+ import { pinyin } from "pinyin-pro";
12
+ import { resolveStateDir } from "./openclaw-compat.js";
13
+
14
+ let knownUserIdsCache = {
15
+ loadedAt: 0,
16
+ stateDir: "",
17
+ userIds: [],
18
+ };
19
+
20
+ function getKnownWecomUserIds() {
21
+ const now = Date.now();
22
+ const stateDir = resolveStateDir();
23
+ if (knownUserIdsCache.stateDir === stateDir && now - knownUserIdsCache.loadedAt < 5_000) {
24
+ return knownUserIdsCache.userIds;
25
+ }
26
+
27
+ try {
28
+ const agentDirs = readdirSync(`${stateDir}/agents`, { withFileTypes: true });
29
+ const userIds = [];
30
+ for (const entry of agentDirs) {
31
+ if (!entry.isDirectory()) {
32
+ continue;
33
+ }
34
+ const match = entry.name.match(/^wecom-(?:(.+?)-)?dm-(.+)$/);
35
+ if (match?.[2]) {
36
+ userIds.push(match[2].toLowerCase());
37
+ }
38
+ }
39
+ knownUserIdsCache = {
40
+ loadedAt: now,
41
+ stateDir,
42
+ userIds: [...new Set(userIds)],
43
+ };
44
+ } catch {
45
+ knownUserIdsCache = {
46
+ loadedAt: now,
47
+ stateDir,
48
+ userIds: [],
49
+ };
50
+ }
51
+
52
+ return knownUserIdsCache.userIds;
53
+ }
54
+
55
+ function transliterateChineseNameToUserId(value) {
56
+ const collapsed = String(value ?? "").replace(/[\s·•・]/g, "");
57
+ if (!collapsed || !/^\p{Script=Han}+$/u.test(collapsed)) {
58
+ return "";
59
+ }
60
+
61
+ return pinyin(collapsed, { toneType: "none", type: "array" }).join("").toLowerCase();
62
+ }
63
+
64
+ function resolveKnownWecomUserId(value) {
65
+ const needle = String(value ?? "").trim().toLowerCase();
66
+ if (!needle) {
67
+ return "";
68
+ }
69
+
70
+ const candidates = getKnownWecomUserIds();
71
+ if (candidates.length === 0) {
72
+ return "";
73
+ }
74
+
75
+ if (candidates.includes(needle)) {
76
+ return needle;
77
+ }
78
+
79
+ const suffixMatches = candidates.filter((candidate) => candidate.endsWith(needle));
80
+ if (suffixMatches.length === 1) {
81
+ return suffixMatches[0];
82
+ }
83
+
84
+ const containsMatches = candidates.filter((candidate) => candidate.includes(needle));
85
+ if (containsMatches.length === 1) {
86
+ return containsMatches[0];
87
+ }
88
+
89
+ return "";
90
+ }
91
+
10
92
  /**
11
93
  * @param {string|undefined} raw
12
94
  * @returns {{ webhook?: string, toUser?: string, toParty?: string, toTag?: string, chatId?: string } | undefined}
@@ -53,6 +135,11 @@ export function resolveWecomTarget(raw) {
53
135
  return { toParty: clean };
54
136
  }
55
137
 
138
+ const pinyinUserId = transliterateChineseNameToUserId(clean);
139
+ if (pinyinUserId) {
140
+ return { toUser: resolveKnownWecomUserId(pinyinUserId) || pinyinUserId };
141
+ }
142
+
56
143
  // Default: treat as user ID.
57
- return { toUser: clean };
144
+ return { toUser: resolveKnownWecomUserId(clean) || clean };
58
145
  }
@@ -22,7 +22,7 @@ function resolveUserPath(value) {
22
22
  }
23
23
 
24
24
  function resolveOpenclawStateDir() {
25
- const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
25
+ const override = process.env.OPENCLAW_STATE_DIR?.trim();
26
26
  if (override) {
27
27
  return resolveUserPath(override);
28
28
  }
@@ -65,7 +65,6 @@ export function clearTemplateMtimeCache({ agentSeedCache = true } = {}) {
65
65
  export function resolveAgentWorkspaceDirLocal(agentId) {
66
66
  const stateDir =
67
67
  process.env.OPENCLAW_STATE_DIR?.trim() ||
68
- process.env.CLAWDBOT_STATE_DIR?.trim() ||
69
68
  join(process.env.HOME || "/root", ".openclaw");
70
69
  return join(stateDir, `workspace-${agentId}`);
71
70
  }
@@ -7,6 +7,7 @@ import { WSClient, generateReqId } from "@wecom/aibot-node-sdk";
7
7
  import { uploadAndSendMedia, buildMediaErrorSummary } from "./media-uploader.js";
8
8
  import { createPersistentReqIdStore } from "./reqid-store.js";
9
9
  import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js";
10
+ import { applyOutboundSenderProtocol, resolveOutboundSenderLabel } from "./outbound-sender-protocol.js";
10
11
  import { logger } from "../logger.js";
11
12
  import { normalizeThinkingTags } from "../think-parser.js";
12
13
  import { MessageDeduplicator } from "../utils.js";
@@ -309,7 +310,7 @@ function resolveUserPath(value) {
309
310
  }
310
311
 
311
312
  function resolveStateDir() {
312
- const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
313
+ const override = process.env.OPENCLAW_STATE_DIR?.trim();
313
314
  if (override) {
314
315
  return resolveUserPath(override);
315
316
  }
@@ -418,6 +419,7 @@ function buildReplyMediaGuidance(config, agentId) {
418
419
  const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
419
420
  const configuredRoots = resolveConfiguredReplyMediaLocalRoots(config);
420
421
  const qwenImageToolsConfig = config?.plugins?.entries?.wecom?.config?.qwenImageTools;
422
+ const senderLabel = resolveOutboundSenderLabel(agentId);
421
423
  const guidance = [
422
424
  WECOM_REPLY_MEDIA_GUIDANCE_HEADER,
423
425
  `Local reply files are allowed only under the current workspace: ${workspaceDir}`,
@@ -432,6 +434,10 @@ function buildReplyMediaGuidance(config, agentId) {
432
434
  "CRITICAL: If a tool already returned a path prefixed with FILE: (e.g. FILE:/abs/path.pdf), keep the FILE: prefix exactly as-is. Do NOT change it to MEDIA:.",
433
435
  "Each directive MUST be on its own line with no other text on that line.",
434
436
  "The plugin will automatically send the media to the user.",
437
+ "[WeCom cross-chat send rule]",
438
+ `If you proactively send a WeCom message to a different user/group via message.send, prepend this exact hidden header on the first line: [[sender:${senderLabel}]]`,
439
+ `Example cross-chat content: [[sender:${senderLabel}]]\\n你好`,
440
+ "Use the sender header only for proactive cross-chat sends. Do NOT add it when replying in the current WeCom chat.",
435
441
  ];
436
442
 
437
443
  if (configuredRoots.length > 0) {
@@ -455,6 +461,13 @@ function buildReplyMediaGuidance(config, agentId) {
455
461
  }
456
462
 
457
463
  guidance.push("Never reference any other host path.");
464
+
465
+ guidance.push(
466
+ "[WeCom visible text rule]",
467
+ "ALL text you want the user to see MUST be wrapped in <final> tags — this includes short status messages before tool calls (e.g. <final>正在查询,稍等~</final>).",
468
+ "Text outside <final> tags is silently discarded and never delivered to the WeCom chat.",
469
+ );
470
+
458
471
  return guidance.join("\n");
459
472
  }
460
473
 
@@ -498,9 +511,38 @@ function normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId) {
498
511
  }
499
512
 
500
513
  function buildBodyForAgent(body, config, agentId) {
501
- // Guidance is now injected via before_prompt_build hook into system prompt.
502
- // Keep buildBodyForAgent as a plain passthrough for the user message body.
503
- return typeof body === "string" && body.length > 0 ? body : "";
514
+ if (typeof body !== "string" || body.length === 0) {
515
+ return "";
516
+ }
517
+
518
+ const senderLabel = resolveOutboundSenderLabel(agentId);
519
+ const inlineRules = [
520
+ "[WeCom agent rules]",
521
+ "If proactively sending to a different WeCom user/group via message.send, prepend this exact hidden header on the first line:",
522
+ `[[sender:${senderLabel}]]`,
523
+ `Example: [[sender:${senderLabel}]]\\n你好`,
524
+ "Do NOT add that header when replying in the current WeCom chat.",
525
+ "To send files back to the current WeCom chat, do NOT use message.send or message.sendAttachment; emit MEDIA:/... or FILE:/... directives on their own lines instead.",
526
+ ].join("\n");
527
+
528
+ return `${inlineRules}\n\n${body}`;
529
+ }
530
+
531
+ function shouldUseMarkdownForActiveSend(content) {
532
+ const text = String(content ?? "").trim();
533
+ if (!text) {
534
+ return true;
535
+ }
536
+
537
+ return true;
538
+ }
539
+
540
+ function buildWsActiveSendBody(content) {
541
+ const text = String(content ?? "");
542
+ return {
543
+ msgtype: "markdown",
544
+ markdown: { content: text },
545
+ };
504
546
  }
505
547
 
506
548
  function splitReplyMediaFromText(text) {
@@ -868,6 +910,7 @@ function resolveOutboundChatId(to) {
868
910
  export async function sendWsMessage({ to, content, accountId = "default" }) {
869
911
  const chatId = resolveOutboundChatId(to);
870
912
  const wsClient = getWsClient(accountId);
913
+ const outbound = applyOutboundSenderProtocol(content);
871
914
 
872
915
  if (!chatId) {
873
916
  throw new Error("Missing chat target for WeCom WS send");
@@ -888,10 +931,7 @@ export async function sendWsMessage({ to, content, accountId = "default" }) {
888
931
  });
889
932
  }
890
933
 
891
- const result = await wsClient.sendMessage(chatId, {
892
- msgtype: "markdown",
893
- markdown: { content },
894
- });
934
+ const result = await wsClient.sendMessage(chatId, buildWsActiveSendBody(outbound.content));
895
935
 
896
936
  recordActiveSend({ accountId, chatId });
897
937
 
@@ -2062,6 +2102,8 @@ export const wsMonitorTesting = {
2062
2102
  parseMessageContent,
2063
2103
  splitReplyMediaFromText,
2064
2104
  buildBodyForAgent,
2105
+ buildWsActiveSendBody,
2106
+ resolveOutboundSenderLabel,
2065
2107
  normalizeReplyMediaUrlForLoad,
2066
2108
  flushPendingRepliesViaAgentApi,
2067
2109
  stripThinkTags,