@yanhaidao/wecom 2.0.2 → 2.2.4
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/GEMINI.md +76 -0
- package/README.md +314 -71
- package/assets/02.image.jpg +0 -0
- package/assets/03.agent.page.png +0 -0
- package/assets/03.bot.page.png +0 -0
- package/index.ts +8 -0
- package/package.json +7 -2
- package/src/agent/api-client.ts +287 -0
- package/src/agent/handler.ts +401 -0
- package/src/agent/index.ts +12 -0
- package/src/channel.ts +111 -64
- package/src/config/accounts.ts +99 -0
- package/src/config/index.ts +11 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +104 -0
- package/src/config-schema.ts +2 -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.ts +43 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +15 -9
- package/src/media.ts +28 -12
- package/src/monitor/state.ts +354 -0
- package/src/monitor/types.ts +128 -0
- package/src/monitor.active.test.ts +109 -7
- package/src/monitor.integration.test.ts +22 -5
- package/src/monitor.ts +964 -147
- package/src/onboarding.ts +463 -0
- package/src/outbound.test.ts +100 -0
- package/src/outbound.ts +171 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/xml-parser.ts +85 -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 +183 -0
package/src/channel.ts
CHANGED
|
@@ -6,15 +6,15 @@ import type {
|
|
|
6
6
|
import {
|
|
7
7
|
buildChannelConfigSchema,
|
|
8
8
|
DEFAULT_ACCOUNT_ID,
|
|
9
|
-
deleteAccountFromConfigSection,
|
|
10
|
-
formatPairingApproveHint,
|
|
11
9
|
setAccountEnabledInConfigSection,
|
|
12
10
|
} from "openclaw/plugin-sdk";
|
|
13
11
|
|
|
14
|
-
import {
|
|
15
|
-
import { WecomConfigSchema } from "./config
|
|
16
|
-
import type {
|
|
17
|
-
import { registerWecomWebhookTarget } from "./monitor.js";
|
|
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
18
|
|
|
19
19
|
const meta = {
|
|
20
20
|
id: "wecom",
|
|
@@ -34,12 +34,43 @@ function normalizeWecomMessagingTarget(raw: string): string | undefined {
|
|
|
34
34
|
return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
|
|
35
35
|
}
|
|
36
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
|
+
|
|
37
67
|
export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
38
68
|
id: "wecom",
|
|
39
69
|
meta,
|
|
70
|
+
onboarding: wecomOnboardingAdapter,
|
|
40
71
|
capabilities: {
|
|
41
72
|
chatTypes: ["direct", "group"],
|
|
42
|
-
media:
|
|
73
|
+
media: true,
|
|
43
74
|
reactions: false,
|
|
44
75
|
threads: false,
|
|
45
76
|
polls: false,
|
|
@@ -49,9 +80,9 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
49
80
|
reload: { configPrefixes: ["channels.wecom"] },
|
|
50
81
|
configSchema: buildChannelConfigSchema(WecomConfigSchema),
|
|
51
82
|
config: {
|
|
52
|
-
listAccountIds: (
|
|
53
|
-
resolveAccount: (cfg
|
|
54
|
-
defaultAccountId: (
|
|
83
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
84
|
+
resolveAccount: (cfg) => resolveWecomAccount(cfg as OpenClawConfig),
|
|
85
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
55
86
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
56
87
|
setAccountEnabledInConfigSection({
|
|
57
88
|
cfg: cfg as OpenClawConfig,
|
|
@@ -60,24 +91,28 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
60
91
|
enabled,
|
|
61
92
|
allowTopLevel: true,
|
|
62
93
|
}),
|
|
63
|
-
deleteAccount: ({ cfg
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
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
|
+
},
|
|
70
103
|
isConfigured: (account) => account.configured,
|
|
71
104
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
72
105
|
accountId: account.accountId,
|
|
73
106
|
name: account.name,
|
|
74
107
|
enabled: account.enabled,
|
|
75
108
|
configured: account.configured,
|
|
76
|
-
webhookPath: account.config.
|
|
109
|
+
webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
|
|
77
110
|
}),
|
|
78
111
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
79
|
-
const account = resolveWecomAccount(
|
|
80
|
-
|
|
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));
|
|
81
116
|
},
|
|
82
117
|
formatAllowFrom: ({ allowFrom }) =>
|
|
83
118
|
allowFrom
|
|
@@ -85,21 +120,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
85
120
|
.filter(Boolean)
|
|
86
121
|
.map((entry) => entry.toLowerCase()),
|
|
87
122
|
},
|
|
88
|
-
security
|
|
89
|
-
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
90
|
-
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
91
|
-
const useAccountPath = Boolean((cfg as OpenClawConfig).channels?.wecom?.accounts?.[resolvedAccountId]);
|
|
92
|
-
const basePath = useAccountPath ? `channels.wecom.accounts.${resolvedAccountId}.` : "channels.wecom.";
|
|
93
|
-
return {
|
|
94
|
-
policy: account.config.dm?.policy ?? "pairing",
|
|
95
|
-
allowFrom: (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)),
|
|
96
|
-
policyPath: `${basePath}dm.policy`,
|
|
97
|
-
allowFromPath: `${basePath}dm.allowFrom`,
|
|
98
|
-
approveHint: formatPairingApproveHint("wecom"),
|
|
99
|
-
normalizeEntry: (raw) => raw.trim().toLowerCase(),
|
|
100
|
-
};
|
|
101
|
-
},
|
|
102
|
-
},
|
|
123
|
+
// security 配置在 WeCom 中不需要,框架会通过 resolveAllowFrom 自动判断
|
|
103
124
|
groups: {
|
|
104
125
|
// WeCom bots are usually mention-gated by the platform in groups already.
|
|
105
126
|
resolveRequireMention: () => true,
|
|
@@ -115,17 +136,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
115
136
|
},
|
|
116
137
|
},
|
|
117
138
|
outbound: {
|
|
118
|
-
|
|
119
|
-
chunkerMode: "text",
|
|
120
|
-
textChunkLimit: 20480,
|
|
121
|
-
sendText: async () => {
|
|
122
|
-
return {
|
|
123
|
-
channel: "wecom",
|
|
124
|
-
ok: false,
|
|
125
|
-
messageId: "",
|
|
126
|
-
error: new Error("WeCom intelligent bot only supports replying within callbacks (no standalone sendText)."),
|
|
127
|
-
};
|
|
128
|
-
},
|
|
139
|
+
...wecomOutbound,
|
|
129
140
|
},
|
|
130
141
|
status: {
|
|
131
142
|
defaultRuntime: {
|
|
@@ -153,46 +164,82 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
153
164
|
name: account.name,
|
|
154
165
|
enabled: account.enabled,
|
|
155
166
|
configured: account.configured,
|
|
156
|
-
webhookPath: account.config.
|
|
167
|
+
webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
|
|
157
168
|
running: runtime?.running ?? false,
|
|
158
169
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
159
170
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
160
171
|
lastError: runtime?.lastError ?? null,
|
|
161
172
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
162
173
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
163
|
-
dmPolicy: account.config.dm?.policy ?? "pairing",
|
|
174
|
+
dmPolicy: account.bot?.config.dm?.policy ?? "pairing",
|
|
164
175
|
}),
|
|
165
176
|
},
|
|
166
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
|
+
*/
|
|
167
189
|
startAccount: async (ctx) => {
|
|
168
190
|
const account = ctx.account;
|
|
169
|
-
|
|
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) {
|
|
170
197
|
ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`);
|
|
171
198
|
ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
|
|
172
|
-
return { stop: () => {} };
|
|
199
|
+
return { stop: () => { } };
|
|
173
200
|
}
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
|
|
186
231
|
ctx.setStatus({
|
|
187
232
|
accountId: account.accountId,
|
|
188
233
|
running: true,
|
|
189
234
|
configured: true,
|
|
190
|
-
webhookPath:
|
|
235
|
+
webhookPath: botConfigured ? "/wecom/bot" : "/wecom/agent",
|
|
191
236
|
lastStartAt: Date.now(),
|
|
192
237
|
});
|
|
193
238
|
return {
|
|
194
239
|
stop: () => {
|
|
195
|
-
unregister
|
|
240
|
+
for (const unregister of unregisters) {
|
|
241
|
+
unregister();
|
|
242
|
+
}
|
|
196
243
|
ctx.setStatus({
|
|
197
244
|
accountId: account.accountId,
|
|
198
245
|
running: false,
|
|
@@ -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,11 @@
|
|
|
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";
|
|
@@ -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>;
|
package/src/config-schema.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const WecomConfigSchema = z.object({
|
|
|
20
20
|
receiveId: z.string().optional(),
|
|
21
21
|
|
|
22
22
|
streamPlaceholderContent: z.string().optional(),
|
|
23
|
+
debounceMs: z.number().optional(),
|
|
23
24
|
|
|
24
25
|
welcomeText: z.string().optional(),
|
|
25
26
|
dm: dmSchema,
|
|
@@ -33,6 +34,7 @@ export const WecomConfigSchema = z.object({
|
|
|
33
34
|
encodingAESKey: z.string().optional(),
|
|
34
35
|
receiveId: z.string().optional(),
|
|
35
36
|
streamPlaceholderContent: z.string().optional(),
|
|
37
|
+
debounceMs: z.number().optional(),
|
|
36
38
|
welcomeText: z.string().optional(),
|
|
37
39
|
dm: dmSchema,
|
|
38
40
|
})).optional(),
|
|
@@ -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";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom 签名计算与验证
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
function sha1Hex(input: string): string {
|
|
8
|
+
return crypto.createHash("sha1").update(input).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 计算 WeCom 消息签名
|
|
13
|
+
*/
|
|
14
|
+
export function computeWecomMsgSignature(params: {
|
|
15
|
+
token: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
encrypt: string;
|
|
19
|
+
}): string {
|
|
20
|
+
const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
|
|
21
|
+
.map((v) => String(v ?? ""))
|
|
22
|
+
.sort();
|
|
23
|
+
return sha1Hex(parts.join(""));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 验证 WeCom 消息签名
|
|
28
|
+
*/
|
|
29
|
+
export function verifyWecomSignature(params: {
|
|
30
|
+
token: string;
|
|
31
|
+
timestamp: string;
|
|
32
|
+
nonce: string;
|
|
33
|
+
encrypt: string;
|
|
34
|
+
signature: string;
|
|
35
|
+
}): boolean {
|
|
36
|
+
const expected = computeWecomMsgSignature({
|
|
37
|
+
token: params.token,
|
|
38
|
+
timestamp: params.timestamp,
|
|
39
|
+
nonce: params.nonce,
|
|
40
|
+
encrypt: params.encrypt,
|
|
41
|
+
});
|
|
42
|
+
return expected === params.signature;
|
|
43
|
+
}
|