@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 +7 -1
- package/index.js +22 -1
- package/package.json +4 -3
- package/wecom/callback-media.js +1 -1
- package/wecom/channel-plugin.js +57 -55
- package/wecom/onboarding.js +1 -1
- package/wecom/openclaw-compat.js +32 -28
- package/wecom/outbound-sender-protocol.js +142 -0
- package/wecom/state.js +10 -0
- package/wecom/target.js +88 -1
- package/wecom/welcome-messages-file.js +1 -1
- package/wecom/workspace-template.js +0 -1
- package/wecom/ws-monitor.js +50 -8
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 {
|
|
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": "
|
|
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
|
}
|
package/wecom/callback-media.js
CHANGED
|
@@ -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()
|
|
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
|
}
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -3,8 +3,8 @@ import { basename } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
buildBaseAccountStatusSnapshot,
|
|
5
5
|
buildBaseChannelStatusSummary,
|
|
6
|
-
|
|
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:
|
|
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 (
|
|
168
|
-
await sendWebhookText({ url, content:
|
|
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 (
|
|
195
|
-
for (const chunk of splitTextByByteLimit(
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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 = {};
|
package/wecom/onboarding.js
CHANGED
package/wecom/openclaw-compat.js
CHANGED
|
@@ -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()
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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()
|
|
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
|
}
|
package/wecom/ws-monitor.js
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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,
|