@mocrane/wecom 2026.2.5
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/LICENSE +21 -0
- package/README.md +0 -0
- package/clawdbot.plugin.json +10 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +81 -0
- package/src/accounts.ts +72 -0
- package/src/agent/api-client.ts +336 -0
- package/src/agent/handler.ts +566 -0
- package/src/agent/index.ts +12 -0
- package/src/channel.ts +259 -0
- package/src/config/accounts.ts +99 -0
- package/src/config/index.ts +12 -0
- package/src/config/media.ts +14 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +104 -0
- package/src/config-schema.ts +41 -0
- package/src/crypto/aes.ts +108 -0
- package/src/crypto/index.ts +24 -0
- package/src/crypto/signature.ts +43 -0
- package/src/crypto/xml.ts +49 -0
- package/src/crypto.test.ts +32 -0
- package/src/crypto.ts +176 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +55 -0
- package/src/media.ts +55 -0
- package/src/monitor/state.queue.test.ts +185 -0
- package/src/monitor/state.ts +514 -0
- package/src/monitor/types.ts +136 -0
- package/src/monitor.active.test.ts +239 -0
- package/src/monitor.integration.test.ts +207 -0
- package/src/monitor.ts +1802 -0
- package/src/monitor.webhook.test.ts +311 -0
- package/src/onboarding.ts +472 -0
- package/src/outbound.test.ts +143 -0
- package/src/outbound.ts +200 -0
- package/src/runtime.ts +14 -0
- package/src/shared/command-auth.ts +101 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/xml-parser.test.ts +30 -0
- package/src/shared/xml-parser.ts +183 -0
- package/src/target.ts +80 -0
- package/src/types/account.ts +76 -0
- package/src/types/config.ts +88 -0
- package/src/types/constants.ts +42 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/index.ts +38 -0
- package/src/types/message.ts +185 -0
- package/src/types.ts +159 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAccountSnapshot,
|
|
3
|
+
ChannelPlugin,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import {
|
|
7
|
+
buildChannelConfigSchema,
|
|
8
|
+
DEFAULT_ACCOUNT_ID,
|
|
9
|
+
setAccountEnabledInConfigSection,
|
|
10
|
+
} from "openclaw/plugin-sdk";
|
|
11
|
+
|
|
12
|
+
import { resolveWecomAccounts } from "./config/index.js";
|
|
13
|
+
import { WecomConfigSchema } from "./config/index.js";
|
|
14
|
+
import type { ResolvedAgentAccount, ResolvedBotAccount } from "./types/index.js";
|
|
15
|
+
import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
|
|
16
|
+
import { wecomOnboardingAdapter } from "./onboarding.js";
|
|
17
|
+
import { wecomOutbound } from "./outbound.js";
|
|
18
|
+
|
|
19
|
+
const meta = {
|
|
20
|
+
id: "wecom",
|
|
21
|
+
label: "WeCom",
|
|
22
|
+
selectionLabel: "WeCom (plugin)",
|
|
23
|
+
docsPath: "/channels/wecom",
|
|
24
|
+
docsLabel: "wecom",
|
|
25
|
+
blurb: "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
|
|
26
|
+
aliases: ["wechatwork", "wework", "qywx", "企微", "企业微信"],
|
|
27
|
+
order: 85,
|
|
28
|
+
quickstartAllowFrom: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function normalizeWecomMessagingTarget(raw: string): string | undefined {
|
|
32
|
+
const trimmed = raw.trim();
|
|
33
|
+
if (!trimmed) return undefined;
|
|
34
|
+
return trimmed.replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ResolvedWecomAccount = {
|
|
38
|
+
accountId: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
configured: boolean;
|
|
42
|
+
bot?: ResolvedBotAccount;
|
|
43
|
+
agent?: ResolvedAgentAccount;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* **resolveWecomAccount (解析账号配置)**
|
|
48
|
+
*
|
|
49
|
+
* 从全局配置中解析出 WeCom 渠道的配置状态。
|
|
50
|
+
* 兼容 Bot 和 Agent 两种模式的配置检查。
|
|
51
|
+
*/
|
|
52
|
+
function resolveWecomAccount(cfg: OpenClawConfig): ResolvedWecomAccount {
|
|
53
|
+
const enabled = (cfg.channels?.wecom as { enabled?: boolean } | undefined)?.enabled !== false;
|
|
54
|
+
const accounts = resolveWecomAccounts(cfg);
|
|
55
|
+
const bot = accounts.bot;
|
|
56
|
+
const agent = accounts.agent;
|
|
57
|
+
const configured = Boolean(bot?.configured || agent?.configured);
|
|
58
|
+
return {
|
|
59
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
60
|
+
enabled,
|
|
61
|
+
configured,
|
|
62
|
+
bot,
|
|
63
|
+
agent,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
68
|
+
id: "wecom",
|
|
69
|
+
meta,
|
|
70
|
+
onboarding: wecomOnboardingAdapter,
|
|
71
|
+
capabilities: {
|
|
72
|
+
chatTypes: ["direct", "group"],
|
|
73
|
+
media: true,
|
|
74
|
+
reactions: false,
|
|
75
|
+
threads: false,
|
|
76
|
+
polls: false,
|
|
77
|
+
nativeCommands: false,
|
|
78
|
+
blockStreaming: true,
|
|
79
|
+
},
|
|
80
|
+
reload: { configPrefixes: ["channels.wecom"] },
|
|
81
|
+
configSchema: buildChannelConfigSchema(WecomConfigSchema),
|
|
82
|
+
config: {
|
|
83
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
84
|
+
resolveAccount: (cfg) => resolveWecomAccount(cfg as OpenClawConfig),
|
|
85
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
86
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
87
|
+
setAccountEnabledInConfigSection({
|
|
88
|
+
cfg: cfg as OpenClawConfig,
|
|
89
|
+
sectionKey: "wecom",
|
|
90
|
+
accountId,
|
|
91
|
+
enabled,
|
|
92
|
+
allowTopLevel: true,
|
|
93
|
+
}),
|
|
94
|
+
deleteAccount: ({ cfg }) => {
|
|
95
|
+
const next = { ...(cfg as OpenClawConfig) };
|
|
96
|
+
if (next.channels?.wecom) {
|
|
97
|
+
const channels = { ...(next.channels ?? {}) } as Record<string, unknown>;
|
|
98
|
+
delete (channels as Record<string, unknown>).wecom;
|
|
99
|
+
return { ...next, channels } as OpenClawConfig;
|
|
100
|
+
}
|
|
101
|
+
return next;
|
|
102
|
+
},
|
|
103
|
+
isConfigured: (account) => account.configured,
|
|
104
|
+
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
105
|
+
accountId: account.accountId,
|
|
106
|
+
name: account.name,
|
|
107
|
+
enabled: account.enabled,
|
|
108
|
+
configured: account.configured,
|
|
109
|
+
webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
|
|
110
|
+
}),
|
|
111
|
+
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
112
|
+
const account = resolveWecomAccount(cfg as OpenClawConfig);
|
|
113
|
+
// 与其他渠道保持一致:直接返回 allowFrom,空则允许所有人
|
|
114
|
+
const allowFrom = account.agent?.config.dm?.allowFrom ?? account.bot?.config.dm?.allowFrom ?? [];
|
|
115
|
+
return allowFrom.map((entry) => String(entry));
|
|
116
|
+
},
|
|
117
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
118
|
+
allowFrom
|
|
119
|
+
.map((entry) => String(entry).trim())
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map((entry) => entry.toLowerCase()),
|
|
122
|
+
},
|
|
123
|
+
// security 配置在 WeCom 中不需要,框架会通过 resolveAllowFrom 自动判断
|
|
124
|
+
groups: {
|
|
125
|
+
// WeCom bots are usually mention-gated by the platform in groups already.
|
|
126
|
+
resolveRequireMention: () => true,
|
|
127
|
+
},
|
|
128
|
+
threading: {
|
|
129
|
+
resolveReplyToMode: () => "off",
|
|
130
|
+
},
|
|
131
|
+
messaging: {
|
|
132
|
+
normalizeTarget: normalizeWecomMessagingTarget,
|
|
133
|
+
targetResolver: {
|
|
134
|
+
looksLikeId: (raw) => Boolean(raw.trim()),
|
|
135
|
+
hint: "<userid|chatid>",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
outbound: {
|
|
139
|
+
...wecomOutbound,
|
|
140
|
+
},
|
|
141
|
+
status: {
|
|
142
|
+
defaultRuntime: {
|
|
143
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
144
|
+
running: false,
|
|
145
|
+
lastStartAt: null,
|
|
146
|
+
lastStopAt: null,
|
|
147
|
+
lastError: null,
|
|
148
|
+
},
|
|
149
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
150
|
+
configured: snapshot.configured ?? false,
|
|
151
|
+
running: snapshot.running ?? false,
|
|
152
|
+
webhookPath: snapshot.webhookPath ?? null,
|
|
153
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
154
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
155
|
+
lastError: snapshot.lastError ?? null,
|
|
156
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
157
|
+
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
|
158
|
+
probe: snapshot.probe,
|
|
159
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
160
|
+
}),
|
|
161
|
+
probeAccount: async () => ({ ok: true }),
|
|
162
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
163
|
+
accountId: account.accountId,
|
|
164
|
+
name: account.name,
|
|
165
|
+
enabled: account.enabled,
|
|
166
|
+
configured: account.configured,
|
|
167
|
+
webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
|
|
168
|
+
running: runtime?.running ?? false,
|
|
169
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
170
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
171
|
+
lastError: runtime?.lastError ?? null,
|
|
172
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
173
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
174
|
+
dmPolicy: account.bot?.config.dm?.policy ?? "pairing",
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
gateway: {
|
|
178
|
+
/**
|
|
179
|
+
* **startAccount (启动账号)**
|
|
180
|
+
*
|
|
181
|
+
* 插件生命周期:启动
|
|
182
|
+
* 职责:
|
|
183
|
+
* 1. 检查配置是否有效。
|
|
184
|
+
* 2. 注册 Bot Webhook (`/wecom`, `/wecom/bot`)。
|
|
185
|
+
* 3. 注册 Agent Webhook (`/wecom/agent`)。
|
|
186
|
+
* 4. 更新运行时状态 (Running)。
|
|
187
|
+
* 5. 返回停止回调 (Cleanup)。
|
|
188
|
+
*/
|
|
189
|
+
startAccount: async (ctx) => {
|
|
190
|
+
const account = ctx.account;
|
|
191
|
+
const bot = account.bot;
|
|
192
|
+
const agent = account.agent;
|
|
193
|
+
const botConfigured = Boolean(bot?.configured);
|
|
194
|
+
const agentConfigured = Boolean(agent?.configured);
|
|
195
|
+
|
|
196
|
+
if (!botConfigured && !agentConfigured) {
|
|
197
|
+
ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`);
|
|
198
|
+
ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
|
|
199
|
+
return { stop: () => { } };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const unregisters: Array<() => void> = [];
|
|
203
|
+
if (bot && botConfigured) {
|
|
204
|
+
for (const path of ["/wecom", "/wecom/bot"]) {
|
|
205
|
+
unregisters.push(
|
|
206
|
+
registerWecomWebhookTarget({
|
|
207
|
+
account: bot,
|
|
208
|
+
config: ctx.cfg as OpenClawConfig,
|
|
209
|
+
runtime: ctx.runtime,
|
|
210
|
+
// The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
|
|
211
|
+
// The stored target only needs to be decrypt/verify-capable.
|
|
212
|
+
core: ({} as unknown) as any,
|
|
213
|
+
path,
|
|
214
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at /wecom and /wecom/bot`);
|
|
219
|
+
}
|
|
220
|
+
if (agent && agentConfigured) {
|
|
221
|
+
unregisters.push(
|
|
222
|
+
registerAgentWebhookTarget({
|
|
223
|
+
agent,
|
|
224
|
+
config: ctx.cfg as OpenClawConfig,
|
|
225
|
+
runtime: ctx.runtime,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at /wecom/agent`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
ctx.setStatus({
|
|
232
|
+
accountId: account.accountId,
|
|
233
|
+
running: true,
|
|
234
|
+
configured: true,
|
|
235
|
+
webhookPath: botConfigured ? "/wecom/bot" : "/wecom/agent",
|
|
236
|
+
lastStartAt: Date.now(),
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
stop: () => {
|
|
240
|
+
for (const unregister of unregisters) {
|
|
241
|
+
unregister();
|
|
242
|
+
}
|
|
243
|
+
ctx.setStatus({
|
|
244
|
+
accountId: account.accountId,
|
|
245
|
+
running: false,
|
|
246
|
+
lastStopAt: Date.now(),
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
stopAccount: async (ctx) => {
|
|
252
|
+
ctx.setStatus({
|
|
253
|
+
accountId: ctx.account.accountId,
|
|
254
|
+
running: false,
|
|
255
|
+
lastStopAt: Date.now(),
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom 账号解析与模式检测
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
6
|
+
import type {
|
|
7
|
+
WecomConfig,
|
|
8
|
+
WecomBotConfig,
|
|
9
|
+
WecomAgentConfig,
|
|
10
|
+
WecomNetworkConfig,
|
|
11
|
+
ResolvedBotAccount,
|
|
12
|
+
ResolvedAgentAccount,
|
|
13
|
+
ResolvedMode,
|
|
14
|
+
ResolvedWecomAccounts,
|
|
15
|
+
} from "../types/index.js";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 检测配置中启用的模式
|
|
21
|
+
*/
|
|
22
|
+
export function detectMode(config: WecomConfig | undefined): ResolvedMode {
|
|
23
|
+
if (!config) return { bot: false, agent: false };
|
|
24
|
+
|
|
25
|
+
const botConfigured = Boolean(
|
|
26
|
+
config.bot?.token && config.bot?.encodingAESKey
|
|
27
|
+
);
|
|
28
|
+
const agentConfigured = Boolean(
|
|
29
|
+
config.agent?.corpId && config.agent?.corpSecret && config.agent?.agentId &&
|
|
30
|
+
config.agent?.token && config.agent?.encodingAESKey
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return { bot: botConfigured, agent: agentConfigured };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析 Bot 模式账号
|
|
38
|
+
*/
|
|
39
|
+
function resolveBotAccount(config: WecomBotConfig): ResolvedBotAccount {
|
|
40
|
+
return {
|
|
41
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
42
|
+
enabled: true,
|
|
43
|
+
configured: Boolean(config.token && config.encodingAESKey),
|
|
44
|
+
token: config.token,
|
|
45
|
+
encodingAESKey: config.encodingAESKey,
|
|
46
|
+
receiveId: config.receiveId?.trim() ?? "",
|
|
47
|
+
config,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 解析 Agent 模式账号
|
|
53
|
+
*/
|
|
54
|
+
function resolveAgentAccount(config: WecomAgentConfig, network?: WecomNetworkConfig): ResolvedAgentAccount {
|
|
55
|
+
const agentIdRaw = config.agentId;
|
|
56
|
+
const agentId = typeof agentIdRaw === "number" ? agentIdRaw : Number(agentIdRaw);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
60
|
+
enabled: true,
|
|
61
|
+
configured: Boolean(
|
|
62
|
+
config.corpId && config.corpSecret && agentId &&
|
|
63
|
+
config.token && config.encodingAESKey
|
|
64
|
+
),
|
|
65
|
+
corpId: config.corpId,
|
|
66
|
+
corpSecret: config.corpSecret,
|
|
67
|
+
agentId,
|
|
68
|
+
token: config.token,
|
|
69
|
+
encodingAESKey: config.encodingAESKey,
|
|
70
|
+
config,
|
|
71
|
+
network,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 解析 WeCom 账号 (双模式)
|
|
77
|
+
*/
|
|
78
|
+
export function resolveWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccounts {
|
|
79
|
+
const wecom = cfg.channels?.wecom as WecomConfig | undefined;
|
|
80
|
+
|
|
81
|
+
if (!wecom || wecom.enabled === false) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mode = detectMode(wecom);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
bot: mode.bot && wecom.bot ? { ...resolveBotAccount(wecom.bot), network: wecom.network } : undefined,
|
|
89
|
+
agent: mode.agent && wecom.agent ? resolveAgentAccount(wecom.agent, wecom.network) : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 检查是否有任何模式启用
|
|
95
|
+
*/
|
|
96
|
+
export function isWecomEnabled(cfg: OpenClawConfig): boolean {
|
|
97
|
+
const accounts = resolveWecomAccounts(cfg);
|
|
98
|
+
return Boolean(accounts.bot?.configured || accounts.agent?.configured);
|
|
99
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom 配置模块导出
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { WecomConfigSchema, type WecomConfigInput } from "./schema.js";
|
|
6
|
+
export {
|
|
7
|
+
detectMode,
|
|
8
|
+
resolveWecomAccounts,
|
|
9
|
+
isWecomEnabled,
|
|
10
|
+
} from "./accounts.js";
|
|
11
|
+
export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
|
|
12
|
+
export { DEFAULT_WECOM_MEDIA_MAX_BYTES, resolveWecomMediaMaxBytes } from "./media.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
|
|
4
|
+
// 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
|
|
5
|
+
export const DEFAULT_WECOM_MEDIA_MAX_BYTES = 80 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export function resolveWecomMediaMaxBytes(cfg: OpenClawConfig): number {
|
|
8
|
+
const raw = (cfg.channels?.wecom as any)?.media?.maxBytes;
|
|
9
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
10
|
+
if (Number.isFinite(n) && n > 0) {
|
|
11
|
+
return Math.floor(n);
|
|
12
|
+
}
|
|
13
|
+
return DEFAULT_WECOM_MEDIA_MAX_BYTES;
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
import type { WecomConfig, WecomNetworkConfig } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
export function resolveWecomEgressProxyUrlFromNetwork(network?: WecomNetworkConfig): string | undefined {
|
|
6
|
+
const env = (process.env.OPENCLAW_WECOM_EGRESS_PROXY_URL ?? process.env.WECOM_EGRESS_PROXY_URL ?? "").trim();
|
|
7
|
+
if (env) return env;
|
|
8
|
+
|
|
9
|
+
const fromCfg = network?.egressProxyUrl?.trim() ?? "";
|
|
10
|
+
return fromCfg || undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveWecomEgressProxyUrl(cfg: OpenClawConfig): string | undefined {
|
|
14
|
+
const wecom = cfg.channels?.wecom as WecomConfig | undefined;
|
|
15
|
+
return resolveWecomEgressProxyUrlFromNetwork(wecom?.network);
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom 配置 Schema (Zod)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **dmSchema (单聊配置)**
|
|
9
|
+
*
|
|
10
|
+
* 控制单聊行为(如允许名单、策略)。
|
|
11
|
+
* @property enabled - 是否启用单聊 [默认: true]
|
|
12
|
+
* @property policy - 访问策略: "pairing" (需配对, 默认), "allowlist" (仅在名单), "open" (所有人), "disabled" (禁用)
|
|
13
|
+
* @property allowFrom - 允许的用户ID或群ID列表 (仅当 policy="allowlist" 时生效)
|
|
14
|
+
*/
|
|
15
|
+
const dmSchema = z.object({
|
|
16
|
+
enabled: z.boolean().optional(),
|
|
17
|
+
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
18
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
19
|
+
}).optional();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* **mediaSchema (媒体处理配置)**
|
|
23
|
+
*
|
|
24
|
+
* 控制媒体文件的下载和缓存行为。
|
|
25
|
+
* @property tempDir - 临时文件下载目录
|
|
26
|
+
* @property retentionHours - 临时文件保留时间(小时)
|
|
27
|
+
* @property cleanupOnStart - 启动时是否自动清理旧文件
|
|
28
|
+
* @property maxBytes - 允许下载的最大字节数
|
|
29
|
+
*/
|
|
30
|
+
const mediaSchema = z.object({
|
|
31
|
+
tempDir: z.string().optional(),
|
|
32
|
+
retentionHours: z.number().optional(),
|
|
33
|
+
cleanupOnStart: z.boolean().optional(),
|
|
34
|
+
maxBytes: z.number().optional(),
|
|
35
|
+
}).optional();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* **networkSchema (网络配置)**
|
|
39
|
+
*
|
|
40
|
+
* 控制 HTTP 请求行为,特别是出站代理。
|
|
41
|
+
* @property timeoutMs - 请求超时时间 (毫秒)
|
|
42
|
+
* @property retries - 重试次数
|
|
43
|
+
* @property retryDelayMs - 重试间隔 (毫秒)
|
|
44
|
+
* @property egressProxyUrl - 出站 HTTP 代理 (如 "http://127.0.0.1:7890")
|
|
45
|
+
*/
|
|
46
|
+
const networkSchema = z.object({
|
|
47
|
+
timeoutMs: z.number().optional(),
|
|
48
|
+
retries: z.number().optional(),
|
|
49
|
+
retryDelayMs: z.number().optional(),
|
|
50
|
+
egressProxyUrl: z.string().optional(),
|
|
51
|
+
}).optional();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* **botSchema (Bot 模式配置)**
|
|
55
|
+
*
|
|
56
|
+
* 用于配置企业微信内部机器人 (Webhook 模式)。
|
|
57
|
+
* @property token - 企业微信后台设置的 Token
|
|
58
|
+
* @property encodingAESKey - 企业微信后台设置的 EncodingAESKey
|
|
59
|
+
* @property receiveId - (可选) 接收者ID,通常不用填
|
|
60
|
+
* @property streamPlaceholderContent - (可选) 流式响应中的占位符,默认为 "Thinking..."或空
|
|
61
|
+
* @property welcomeText - (可选) 用户首次对话时的欢迎语
|
|
62
|
+
* @property dm - 单聊策略覆盖配置
|
|
63
|
+
*/
|
|
64
|
+
const botSchema = z.object({
|
|
65
|
+
token: z.string(),
|
|
66
|
+
encodingAESKey: z.string(),
|
|
67
|
+
receiveId: z.string().optional(),
|
|
68
|
+
streamPlaceholderContent: z.string().optional(),
|
|
69
|
+
welcomeText: z.string().optional(),
|
|
70
|
+
dm: dmSchema,
|
|
71
|
+
}).optional();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* **agentSchema (Agent 模式配置)**
|
|
75
|
+
*
|
|
76
|
+
* 用于配置企业微信自建应用 (Agent)。
|
|
77
|
+
* @property corpId - 企业 ID (CorpID)
|
|
78
|
+
* @property corpSecret - 应用 Secret
|
|
79
|
+
* @property agentId - 应用 AgentId (数字)
|
|
80
|
+
* @property token - 回调配置 Token
|
|
81
|
+
* @property encodingAESKey - 回调配置 EncodingAESKey
|
|
82
|
+
* @property welcomeText - (可选) 欢迎语
|
|
83
|
+
* @property dm - 单聊策略覆盖配置
|
|
84
|
+
*/
|
|
85
|
+
const agentSchema = z.object({
|
|
86
|
+
corpId: z.string(),
|
|
87
|
+
corpSecret: z.string(),
|
|
88
|
+
agentId: z.union([z.string(), z.number()]),
|
|
89
|
+
token: z.string(),
|
|
90
|
+
encodingAESKey: z.string(),
|
|
91
|
+
welcomeText: z.string().optional(),
|
|
92
|
+
dm: dmSchema,
|
|
93
|
+
}).optional();
|
|
94
|
+
|
|
95
|
+
/** 顶层 WeCom 配置 Schema */
|
|
96
|
+
export const WecomConfigSchema = z.object({
|
|
97
|
+
enabled: z.boolean().optional(),
|
|
98
|
+
bot: botSchema,
|
|
99
|
+
agent: agentSchema,
|
|
100
|
+
media: mediaSchema,
|
|
101
|
+
network: networkSchema,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
4
|
+
|
|
5
|
+
const dmSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
enabled: z.boolean().optional(),
|
|
8
|
+
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
9
|
+
allowFrom: z.array(allowFromEntry).optional(),
|
|
10
|
+
})
|
|
11
|
+
.optional();
|
|
12
|
+
|
|
13
|
+
export const WecomConfigSchema = z.object({
|
|
14
|
+
name: z.string().optional(),
|
|
15
|
+
enabled: z.boolean().optional(),
|
|
16
|
+
|
|
17
|
+
webhookPath: z.string().optional(),
|
|
18
|
+
token: z.string().optional(),
|
|
19
|
+
encodingAESKey: z.string().optional(),
|
|
20
|
+
receiveId: z.string().optional(),
|
|
21
|
+
|
|
22
|
+
streamPlaceholderContent: z.string().optional(),
|
|
23
|
+
debounceMs: z.number().optional(),
|
|
24
|
+
|
|
25
|
+
welcomeText: z.string().optional(),
|
|
26
|
+
dm: dmSchema,
|
|
27
|
+
|
|
28
|
+
defaultAccount: z.string().optional(),
|
|
29
|
+
accounts: z.object({}).catchall(z.object({
|
|
30
|
+
name: z.string().optional(),
|
|
31
|
+
enabled: z.boolean().optional(),
|
|
32
|
+
webhookPath: z.string().optional(),
|
|
33
|
+
token: z.string().optional(),
|
|
34
|
+
encodingAESKey: z.string().optional(),
|
|
35
|
+
receiveId: z.string().optional(),
|
|
36
|
+
streamPlaceholderContent: z.string().optional(),
|
|
37
|
+
debounceMs: z.number().optional(),
|
|
38
|
+
welcomeText: z.string().optional(),
|
|
39
|
+
dm: dmSchema,
|
|
40
|
+
})).optional(),
|
|
41
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom AES-256-CBC 加解密核心
|
|
3
|
+
* Bot 和 Agent 模式共用
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { CRYPTO } from "../types/constants.js";
|
|
8
|
+
|
|
9
|
+
export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
|
|
10
|
+
const trimmed = encodingAESKey.trim();
|
|
11
|
+
if (!trimmed) throw new Error("encodingAESKey missing");
|
|
12
|
+
const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
|
|
13
|
+
const key = Buffer.from(withPadding, "base64");
|
|
14
|
+
if (key.length !== CRYPTO.AES_KEY_LENGTH) {
|
|
15
|
+
throw new Error(`invalid encodingAESKey (expected ${CRYPTO.AES_KEY_LENGTH} bytes, got ${key.length})`);
|
|
16
|
+
}
|
|
17
|
+
return key;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
21
|
+
const mod = buf.length % blockSize;
|
|
22
|
+
const pad = mod === 0 ? blockSize : blockSize - mod;
|
|
23
|
+
const padByte = Buffer.from([pad]);
|
|
24
|
+
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
|
|
28
|
+
if (buf.length === 0) throw new Error("invalid pkcs7 payload");
|
|
29
|
+
const pad = buf[buf.length - 1]!;
|
|
30
|
+
if (pad < 1 || pad > blockSize) {
|
|
31
|
+
throw new Error("invalid pkcs7 padding");
|
|
32
|
+
}
|
|
33
|
+
if (pad > buf.length) {
|
|
34
|
+
throw new Error("invalid pkcs7 payload");
|
|
35
|
+
}
|
|
36
|
+
for (let i = 0; i < pad; i += 1) {
|
|
37
|
+
if (buf[buf.length - 1 - i] !== pad) {
|
|
38
|
+
throw new Error("invalid pkcs7 padding");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return buf.subarray(0, buf.length - pad);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 解密 WeCom 加密消息
|
|
46
|
+
*/
|
|
47
|
+
export function decryptWecomEncrypted(params: {
|
|
48
|
+
encodingAESKey: string;
|
|
49
|
+
receiveId?: string;
|
|
50
|
+
encrypt: string;
|
|
51
|
+
}): string {
|
|
52
|
+
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
|
|
53
|
+
const iv = aesKey.subarray(0, 16);
|
|
54
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
|
|
55
|
+
decipher.setAutoPadding(false);
|
|
56
|
+
const decryptedPadded = Buffer.concat([
|
|
57
|
+
decipher.update(Buffer.from(params.encrypt, "base64")),
|
|
58
|
+
decipher.final(),
|
|
59
|
+
]);
|
|
60
|
+
const decrypted = pkcs7Unpad(decryptedPadded, CRYPTO.PKCS7_BLOCK_SIZE);
|
|
61
|
+
|
|
62
|
+
if (decrypted.length < 20) {
|
|
63
|
+
throw new Error(`invalid payload (expected >=20 bytes, got ${decrypted.length})`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 16 bytes random + 4 bytes length + msg + receiveId
|
|
67
|
+
const msgLen = decrypted.readUInt32BE(16);
|
|
68
|
+
const msgStart = 20;
|
|
69
|
+
const msgEnd = msgStart + msgLen;
|
|
70
|
+
if (msgEnd > decrypted.length) {
|
|
71
|
+
throw new Error(`invalid msg length (msgEnd=${msgEnd}, total=${decrypted.length})`);
|
|
72
|
+
}
|
|
73
|
+
const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
|
|
74
|
+
|
|
75
|
+
const receiveId = params.receiveId ?? "";
|
|
76
|
+
if (receiveId) {
|
|
77
|
+
const trailing = decrypted.subarray(msgEnd).toString("utf8");
|
|
78
|
+
if (trailing !== receiveId) {
|
|
79
|
+
throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return msg;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 加密明文为 WeCom 格式
|
|
88
|
+
*/
|
|
89
|
+
export function encryptWecomPlaintext(params: {
|
|
90
|
+
encodingAESKey: string;
|
|
91
|
+
receiveId?: string;
|
|
92
|
+
plaintext: string;
|
|
93
|
+
}): string {
|
|
94
|
+
const aesKey = decodeEncodingAESKey(params.encodingAESKey);
|
|
95
|
+
const iv = aesKey.subarray(0, 16);
|
|
96
|
+
const random16 = crypto.randomBytes(16);
|
|
97
|
+
const msg = Buffer.from(params.plaintext ?? "", "utf8");
|
|
98
|
+
const msgLen = Buffer.alloc(4);
|
|
99
|
+
msgLen.writeUInt32BE(msg.length, 0);
|
|
100
|
+
const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
|
|
101
|
+
|
|
102
|
+
const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
|
|
103
|
+
const padded = pkcs7Pad(raw, CRYPTO.PKCS7_BLOCK_SIZE);
|
|
104
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
105
|
+
cipher.setAutoPadding(false);
|
|
106
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
107
|
+
return encrypted.toString("base64");
|
|
108
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom 加解密模块导出
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// AES 加解密
|
|
6
|
+
export {
|
|
7
|
+
decodeEncodingAESKey,
|
|
8
|
+
pkcs7Unpad,
|
|
9
|
+
decryptWecomEncrypted,
|
|
10
|
+
encryptWecomPlaintext,
|
|
11
|
+
} from "./aes.js";
|
|
12
|
+
|
|
13
|
+
// 签名验证
|
|
14
|
+
export {
|
|
15
|
+
computeWecomMsgSignature,
|
|
16
|
+
verifyWecomSignature,
|
|
17
|
+
} from "./signature.js";
|
|
18
|
+
|
|
19
|
+
// XML 辅助
|
|
20
|
+
export {
|
|
21
|
+
extractEncryptFromXml,
|
|
22
|
+
extractToUserNameFromXml,
|
|
23
|
+
buildEncryptedXmlResponse,
|
|
24
|
+
} from "./xml.js";
|