@mocrane/wecom 2026.3.4 → 2026.3.9
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/package.json +2 -1
- package/src/channel.ts +38 -2
- package/src/config/accounts.ts +10 -3
- package/src/config/schema.ts +6 -2
- package/src/gateway-monitor.ts +33 -11
- package/src/monitor/types.ts +4 -0
- package/src/monitor.ts +13 -7
- package/src/onboarding.ts +160 -11
- package/src/outbound.ts +38 -0
- package/src/types/account.ts +11 -2
- package/src/types/config.ts +14 -5
- package/src/ws-adapter.ts +481 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mocrane/wecom",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@wecom/aibot-node-sdk": "^1.0.0",
|
|
45
46
|
"fast-xml-parser": "5.3.4",
|
|
46
47
|
"undici": "^7.20.0",
|
|
47
48
|
"zod": "^4.3.6"
|
package/src/channel.ts
CHANGED
|
@@ -15,9 +15,9 @@ import {
|
|
|
15
15
|
resolveWecomAccount,
|
|
16
16
|
resolveWecomAccountConflict,
|
|
17
17
|
} from "./config/index.js";
|
|
18
|
-
import type { ResolvedWecomAccount } from "./types/index.js";
|
|
18
|
+
import type { ResolvedWecomAccount, WecomBotConfig } from "./types/index.js";
|
|
19
19
|
import { monitorWecomProvider } from "./gateway-monitor.js";
|
|
20
|
-
import { wecomOnboardingAdapter } from "./onboarding.js";
|
|
20
|
+
import { setWecomBotConfig, wecomOnboardingAdapter } from "./onboarding.js";
|
|
21
21
|
import { wecomOutbound } from "./outbound.js";
|
|
22
22
|
import { WEBHOOK_PATHS } from "./types/constants.js";
|
|
23
23
|
|
|
@@ -43,6 +43,42 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
43
43
|
id: "wecom",
|
|
44
44
|
meta,
|
|
45
45
|
onboarding: wecomOnboardingAdapter,
|
|
46
|
+
setup: {
|
|
47
|
+
resolveAccountId: ({ cfg, accountId }) => {
|
|
48
|
+
return accountId?.trim() || resolveDefaultWecomAccountId(cfg as OpenClawConfig) || DEFAULT_ACCOUNT_ID;
|
|
49
|
+
},
|
|
50
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
51
|
+
const isWsMode = input.url === "ws" || input.url === "websocket";
|
|
52
|
+
|
|
53
|
+
if (isWsMode) {
|
|
54
|
+
// websocket 模式: --bot-token → botId, --token → secret
|
|
55
|
+
const botConfig: WecomBotConfig = {
|
|
56
|
+
connectionMode: "websocket",
|
|
57
|
+
botId: input.botToken?.trim() || undefined,
|
|
58
|
+
secret: input.token?.trim() || undefined,
|
|
59
|
+
};
|
|
60
|
+
return setWecomBotConfig(cfg as OpenClawConfig, botConfig, accountId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// webhook 模式: --token → token, --access-token → encodingAESKey
|
|
64
|
+
const botConfig: WecomBotConfig = {
|
|
65
|
+
connectionMode: "webhook",
|
|
66
|
+
token: input.token?.trim() ?? "",
|
|
67
|
+
encodingAESKey: input.accessToken?.trim() ?? "",
|
|
68
|
+
};
|
|
69
|
+
return setWecomBotConfig(cfg as OpenClawConfig, botConfig, accountId);
|
|
70
|
+
},
|
|
71
|
+
validateInput: ({ input }) => {
|
|
72
|
+
const isWsMode = input.url === "ws" || input.url === "websocket";
|
|
73
|
+
if (isWsMode) {
|
|
74
|
+
if (!input.botToken?.trim()) return "websocket 模式需要 --bot-token <BotID>";
|
|
75
|
+
if (!input.token?.trim()) return "websocket 模式需要 --token <Secret>";
|
|
76
|
+
} else {
|
|
77
|
+
if (!input.token?.trim()) return "webhook 模式需要 --token <Token>";
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
46
82
|
capabilities: {
|
|
47
83
|
chatTypes: ["direct", "group"],
|
|
48
84
|
media: true,
|
package/src/config/accounts.ts
CHANGED
|
@@ -46,15 +46,22 @@ export function detectMode(config: WecomConfig | undefined): ResolvedMode {
|
|
|
46
46
|
* 解析 Bot 模式账号
|
|
47
47
|
*/
|
|
48
48
|
function resolveBotAccount(accountId: string, config: WecomBotConfig, network?: WecomNetworkConfig): ResolvedBotAccount {
|
|
49
|
+
const connectionMode = config.connectionMode ?? 'webhook';
|
|
50
|
+
const configured = connectionMode === 'websocket'
|
|
51
|
+
? Boolean(config.botId && config.secret)
|
|
52
|
+
: Boolean(config.token && config.encodingAESKey);
|
|
49
53
|
return {
|
|
50
54
|
accountId,
|
|
51
55
|
enabled: true,
|
|
52
|
-
configured
|
|
53
|
-
token: config.token,
|
|
54
|
-
encodingAESKey: config.encodingAESKey,
|
|
56
|
+
configured,
|
|
57
|
+
token: config.token ?? "",
|
|
58
|
+
encodingAESKey: config.encodingAESKey ?? "",
|
|
55
59
|
receiveId: config.receiveId?.trim() ?? "",
|
|
56
60
|
config,
|
|
57
61
|
network,
|
|
62
|
+
connectionMode,
|
|
63
|
+
botId: config.botId,
|
|
64
|
+
secret: config.secret,
|
|
58
65
|
};
|
|
59
66
|
}
|
|
60
67
|
|
package/src/config/schema.ts
CHANGED
|
@@ -81,13 +81,17 @@ const routingSchema = z.object({
|
|
|
81
81
|
*/
|
|
82
82
|
const botSchema = z.object({
|
|
83
83
|
aibotid: z.string().optional(),
|
|
84
|
-
token: z.string(),
|
|
85
|
-
encodingAESKey: z.string(),
|
|
84
|
+
token: z.string().optional(),
|
|
85
|
+
encodingAESKey: z.string().optional(),
|
|
86
86
|
botIds: z.array(z.string()).optional(),
|
|
87
87
|
receiveId: z.string().optional(),
|
|
88
88
|
streamPlaceholderContent: z.string().optional(),
|
|
89
89
|
welcomeText: z.string().optional(),
|
|
90
90
|
dm: dmSchema,
|
|
91
|
+
// 长链接模式 (WebSocket)
|
|
92
|
+
connectionMode: z.enum(['webhook', 'websocket']).optional(),
|
|
93
|
+
botId: z.string().optional(),
|
|
94
|
+
secret: z.string().optional(),
|
|
91
95
|
}).optional();
|
|
92
96
|
|
|
93
97
|
/**
|
package/src/gateway-monitor.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveWecomAccountConflict,
|
|
12
12
|
} from "./config/index.js";
|
|
13
13
|
import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
|
|
14
|
+
import { startWsClient } from "./ws-adapter.js";
|
|
14
15
|
import type { ResolvedWecomAccount, WecomConfig } from "./types/index.js";
|
|
15
16
|
import { WEBHOOK_PATHS } from "./types/constants.js";
|
|
16
17
|
|
|
@@ -164,26 +165,47 @@ export async function monitorWecomProvider(
|
|
|
164
165
|
const agentPaths: string[] = [];
|
|
165
166
|
try {
|
|
166
167
|
if (bot && botConfigured) {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
for (const path of paths) {
|
|
168
|
+
const connectionMode = bot.connectionMode ?? 'webhook';
|
|
169
|
+
|
|
170
|
+
if (connectionMode === 'websocket') {
|
|
171
|
+
// 长链接模式:启动 WSClient
|
|
172
172
|
unregisters.push(
|
|
173
|
-
|
|
173
|
+
startWsClient({
|
|
174
|
+
accountId: account.accountId,
|
|
175
|
+
botId: bot.botId!,
|
|
176
|
+
secret: bot.secret!,
|
|
174
177
|
account: bot,
|
|
175
178
|
config: cfg,
|
|
176
179
|
runtime: ctx.runtime,
|
|
177
|
-
// The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
|
|
178
|
-
// The stored target only needs to be decrypt/verify-capable.
|
|
179
180
|
core: {} as PluginRuntime,
|
|
180
|
-
path,
|
|
181
181
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
182
|
+
welcomeText: bot.config.welcomeText,
|
|
183
|
+
network: bot.network,
|
|
182
184
|
}),
|
|
183
185
|
);
|
|
186
|
+
botPaths.push(`ws://${account.accountId}`);
|
|
187
|
+
ctx.log?.info(`[${account.accountId}] wecom bot websocket client started (botId=${bot.botId})`);
|
|
188
|
+
} else {
|
|
189
|
+
// Webhook 模式:注册 HTTP 路径(现有逻辑不变)
|
|
190
|
+
const paths = resolveBotRegistrationPaths({
|
|
191
|
+
accountId: account.accountId,
|
|
192
|
+
matrixMode,
|
|
193
|
+
});
|
|
194
|
+
for (const path of paths) {
|
|
195
|
+
unregisters.push(
|
|
196
|
+
registerWecomWebhookTarget({
|
|
197
|
+
account: bot,
|
|
198
|
+
config: cfg,
|
|
199
|
+
runtime: ctx.runtime,
|
|
200
|
+
core: {} as PluginRuntime,
|
|
201
|
+
path,
|
|
202
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
botPaths.push(...paths);
|
|
207
|
+
ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at ${paths.join(", ")}`);
|
|
184
208
|
}
|
|
185
|
-
botPaths.push(...paths);
|
|
186
|
-
ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at ${paths.join(", ")}`);
|
|
187
209
|
}
|
|
188
210
|
|
|
189
211
|
if (agent && agentConfigured) {
|
package/src/monitor/types.ts
CHANGED
|
@@ -84,6 +84,8 @@ export type StreamState = {
|
|
|
84
84
|
dmContent?: string;
|
|
85
85
|
/** 已通过 Agent 私信发送过的媒体标识(防重复发送附件) */
|
|
86
86
|
agentMediaKeys?: string[];
|
|
87
|
+
/** 是否来自 WebSocket 长链接模式(用于跳过 6 分钟超时等 webhook 特有逻辑) */
|
|
88
|
+
wsMode?: boolean;
|
|
87
89
|
};
|
|
88
90
|
|
|
89
91
|
/**
|
|
@@ -114,6 +116,8 @@ export type PendingInbound = {
|
|
|
114
116
|
/** 已到达防抖截止时间,但因前序批次仍在处理中而暂存 */
|
|
115
117
|
readyToFlush?: boolean;
|
|
116
118
|
createdAt: number;
|
|
119
|
+
/** 是否来自 WebSocket 长链接模式 */
|
|
120
|
+
wsMode?: boolean;
|
|
117
121
|
};
|
|
118
122
|
|
|
119
123
|
/**
|
package/src/monitor.ts
CHANGED
|
@@ -931,7 +931,7 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
|
|
|
931
931
|
return parsed as WecomInboundMessage;
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
-
type InboundResult = {
|
|
934
|
+
export type InboundResult = {
|
|
935
935
|
body: string;
|
|
936
936
|
media?: {
|
|
937
937
|
buffer: Buffer;
|
|
@@ -952,15 +952,16 @@ type InboundResult = {
|
|
|
952
952
|
* @param target Webhook 目标配置
|
|
953
953
|
* @param msg 企业微信原始消息对象
|
|
954
954
|
*/
|
|
955
|
-
async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
|
|
955
|
+
export async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
|
|
956
956
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
957
|
-
const
|
|
957
|
+
const globalAesKey = target.account.encodingAESKey;
|
|
958
958
|
const maxBytes = resolveWecomMediaMaxBytes(target.config);
|
|
959
959
|
const proxyUrl = resolveWecomEgressProxyUrl(target.config);
|
|
960
960
|
|
|
961
961
|
// 图片消息处理:如果存在 url 且配置了 aesKey,则尝试解密下载
|
|
962
962
|
if (msgtype === "image") {
|
|
963
963
|
const url = String((msg as any).image?.url ?? "").trim();
|
|
964
|
+
const aesKey = globalAesKey || (msg as any).image?.aeskey || "";
|
|
964
965
|
if (url && aesKey) {
|
|
965
966
|
try {
|
|
966
967
|
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
@@ -995,6 +996,7 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
|
|
|
995
996
|
|
|
996
997
|
if (msgtype === "file") {
|
|
997
998
|
const url = String((msg as any).file?.url ?? "").trim();
|
|
999
|
+
const aesKey = globalAesKey || (msg as any).file?.aeskey || "";
|
|
998
1000
|
if (url && aesKey) {
|
|
999
1001
|
try {
|
|
1000
1002
|
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
@@ -1038,12 +1040,16 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
|
|
|
1038
1040
|
if (t === "text") {
|
|
1039
1041
|
const content = String(item.text?.content ?? "").trim();
|
|
1040
1042
|
if (content) bodyParts.push(content);
|
|
1041
|
-
} else if ((t === "image" || t === "file") && !foundMedia
|
|
1043
|
+
} else if ((t === "image" || t === "file") && !foundMedia) {
|
|
1042
1044
|
// Found first media, try to download
|
|
1045
|
+
const itemAesKey = globalAesKey || item[t]?.aeskey || "";
|
|
1043
1046
|
const url = String(item[t]?.url ?? "").trim();
|
|
1044
|
-
if (
|
|
1047
|
+
if (!itemAesKey) {
|
|
1048
|
+
bodyParts.push(`[${t}]`);
|
|
1049
|
+
} else
|
|
1050
|
+
if (url) {
|
|
1045
1051
|
try {
|
|
1046
|
-
const decrypted = await decryptWecomMediaWithMeta(url,
|
|
1052
|
+
const decrypted = await decryptWecomMediaWithMeta(url, itemAesKey, { maxBytes, http: { proxyUrl } });
|
|
1047
1053
|
const inferred = inferInboundMediaMeta({
|
|
1048
1054
|
kind: t,
|
|
1049
1055
|
buffer: decrypted.buffer,
|
|
@@ -2049,7 +2055,7 @@ function formatQuote(quote: WecomInboundQuote): string {
|
|
|
2049
2055
|
return "";
|
|
2050
2056
|
}
|
|
2051
2057
|
|
|
2052
|
-
function buildInboundBody(msg: WecomInboundMessage): string {
|
|
2058
|
+
export function buildInboundBody(msg: WecomInboundMessage): string {
|
|
2053
2059
|
let body = "";
|
|
2054
2060
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
2055
2061
|
|
package/src/onboarding.ts
CHANGED
|
@@ -38,6 +38,39 @@ function setWecomEnabled(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig
|
|
|
38
38
|
} as OpenClawConfig;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 确保 cfg.bindings 中存在一条 wecom 账号到默认 agent 的路由。
|
|
43
|
+
*
|
|
44
|
+
* `openclaw channels add` 流程会在插件 configure() 返回后单独提示用户绑定 agent,
|
|
45
|
+
* 但 `openclaw onboard` 的 quickstart 路径会跳过这一步,导致消息路由缺失。
|
|
46
|
+
* 在插件层面主动补全 binding 可以让两种流程都能正常工作。
|
|
47
|
+
*
|
|
48
|
+
* 如果 bindings 中已存在匹配 channel+accountId 的条目,则不会重复添加。
|
|
49
|
+
*/
|
|
50
|
+
function ensureWecomBinding(cfg: OpenClawConfig, accountId: string): OpenClawConfig {
|
|
51
|
+
const existing = cfg.bindings ?? [];
|
|
52
|
+
const alreadyBound = existing.some(
|
|
53
|
+
(b) => b.match.channel === channel && (b.match.accountId === accountId || (!b.match.accountId && accountId === DEFAULT_ACCOUNT_ID)),
|
|
54
|
+
);
|
|
55
|
+
if (alreadyBound) return cfg;
|
|
56
|
+
|
|
57
|
+
// 默认路由到 main agent(OpenClaw 约定 defaultAgentId 为 "main")
|
|
58
|
+
const defaultAgentId = "main";
|
|
59
|
+
return {
|
|
60
|
+
...cfg,
|
|
61
|
+
bindings: [
|
|
62
|
+
...existing,
|
|
63
|
+
{
|
|
64
|
+
agentId: defaultAgentId,
|
|
65
|
+
match: {
|
|
66
|
+
channel,
|
|
67
|
+
accountId,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
41
74
|
function setGatewayBindLan(cfg: OpenClawConfig): OpenClawConfig {
|
|
42
75
|
return {
|
|
43
76
|
...cfg,
|
|
@@ -48,6 +81,20 @@ function setGatewayBindLan(cfg: OpenClawConfig): OpenClawConfig {
|
|
|
48
81
|
} as OpenClawConfig;
|
|
49
82
|
}
|
|
50
83
|
|
|
84
|
+
function setWecomDefaultAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig {
|
|
85
|
+
const wecom = getWecomConfig(cfg) ?? {};
|
|
86
|
+
return {
|
|
87
|
+
...cfg,
|
|
88
|
+
channels: {
|
|
89
|
+
...cfg.channels,
|
|
90
|
+
wecom: {
|
|
91
|
+
...wecom,
|
|
92
|
+
defaultAccount: accountId,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
} as OpenClawConfig;
|
|
96
|
+
}
|
|
97
|
+
|
|
51
98
|
function shouldUseAccountScopedConfig(wecom: WecomConfig | undefined, accountId: string): boolean {
|
|
52
99
|
void wecom;
|
|
53
100
|
void accountId;
|
|
@@ -85,7 +132,7 @@ function accountWebhookPath(kind: "bot" | "agent", accountId: string): string {
|
|
|
85
132
|
return `${recommendedBase}/${accountId}`;
|
|
86
133
|
}
|
|
87
134
|
|
|
88
|
-
function setWecomBotConfig(cfg: OpenClawConfig, bot: WecomBotConfig, accountId: string): OpenClawConfig {
|
|
135
|
+
export function setWecomBotConfig(cfg: OpenClawConfig, bot: WecomBotConfig, accountId: string): OpenClawConfig {
|
|
89
136
|
const wecom = getWecomConfig(cfg) ?? {};
|
|
90
137
|
if (!shouldUseAccountScopedConfig(wecom, accountId)) {
|
|
91
138
|
return {
|
|
@@ -190,7 +237,7 @@ function setWecomDmPolicy(
|
|
|
190
237
|
agent: {
|
|
191
238
|
...existingAccount.agent,
|
|
192
239
|
dm,
|
|
193
|
-
},
|
|
240
|
+
} as WecomAgentConfig,
|
|
194
241
|
};
|
|
195
242
|
return {
|
|
196
243
|
...cfg,
|
|
@@ -315,18 +362,47 @@ async function configureBotMode(
|
|
|
315
362
|
cfg: OpenClawConfig,
|
|
316
363
|
prompter: WizardPrompter,
|
|
317
364
|
accountId: string,
|
|
365
|
+
): Promise<OpenClawConfig> {
|
|
366
|
+
// 选择接入方式
|
|
367
|
+
const connectionMode = (await prompter.select({
|
|
368
|
+
message: "请选择 Bot 接入方式:",
|
|
369
|
+
options: [
|
|
370
|
+
{
|
|
371
|
+
value: "websocket",
|
|
372
|
+
label: "WebSocket 长链接模式",
|
|
373
|
+
hint: "无需公网 IP,SDK 主动连接企微服务器,适合内网环境",
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
value: "webhook",
|
|
377
|
+
label: "Webhook 回调模式",
|
|
378
|
+
hint: "需要公网 IP + 回调 URL,适合有公网服务器的环境",
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
initialValue: "websocket",
|
|
382
|
+
})) as "webhook" | "websocket";
|
|
383
|
+
|
|
384
|
+
if (connectionMode === "websocket") {
|
|
385
|
+
return configureBotWebsocket(cfg, prompter, accountId);
|
|
386
|
+
}
|
|
387
|
+
return configureBotWebhook(cfg, prompter, accountId);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function configureBotWebhook(
|
|
391
|
+
cfg: OpenClawConfig,
|
|
392
|
+
prompter: WizardPrompter,
|
|
393
|
+
accountId: string,
|
|
318
394
|
): Promise<OpenClawConfig> {
|
|
319
395
|
const recommendedPath = accountWebhookPath("bot", accountId);
|
|
320
396
|
await prompter.note(
|
|
321
397
|
[
|
|
322
|
-
"正在配置 Bot
|
|
398
|
+
"正在配置 Bot 模式(Webhook 回调)...",
|
|
323
399
|
"",
|
|
324
400
|
"💡 操作指南: 请在企微后台【管理工具 -> 智能机器人】开启 API 模式。",
|
|
325
401
|
`🔗 回调 URL (推荐): https://您的域名${recommendedPath}`,
|
|
326
402
|
"",
|
|
327
403
|
"请先在后台填入回调 URL,然后获取以下信息。",
|
|
328
404
|
].join("\n"),
|
|
329
|
-
"Bot 模式配置",
|
|
405
|
+
"Bot 模式配置 — Webhook",
|
|
330
406
|
);
|
|
331
407
|
|
|
332
408
|
const token = String(
|
|
@@ -361,6 +437,7 @@ async function configureBotMode(
|
|
|
361
437
|
});
|
|
362
438
|
|
|
363
439
|
const botConfig: WecomBotConfig = {
|
|
440
|
+
connectionMode: "webhook",
|
|
364
441
|
token,
|
|
365
442
|
encodingAESKey,
|
|
366
443
|
streamPlaceholderContent: streamPlaceholder?.trim() || undefined,
|
|
@@ -370,6 +447,59 @@ async function configureBotMode(
|
|
|
370
447
|
return setWecomBotConfig(cfg, botConfig, accountId);
|
|
371
448
|
}
|
|
372
449
|
|
|
450
|
+
async function configureBotWebsocket(
|
|
451
|
+
cfg: OpenClawConfig,
|
|
452
|
+
prompter: WizardPrompter,
|
|
453
|
+
accountId: string,
|
|
454
|
+
): Promise<OpenClawConfig> {
|
|
455
|
+
await prompter.note(
|
|
456
|
+
[
|
|
457
|
+
"正在配置 Bot 模式(WebSocket 长链接)...",
|
|
458
|
+
"",
|
|
459
|
+
"💡 操作指南: 请在企微后台【管理工具 -> 智能机器人】获取 BotID 和 Secret。",
|
|
460
|
+
"",
|
|
461
|
+
"长链接模式无需公网 IP 和回调 URL,适合内网环境。",
|
|
462
|
+
].join("\n"),
|
|
463
|
+
"Bot 模式配置 — WebSocket",
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const botId = String(
|
|
467
|
+
await prompter.text({
|
|
468
|
+
message: "请输入 BotID (机器人ID):",
|
|
469
|
+
validate: (value: string | undefined) => (value?.trim() ? undefined : "BotID 不能为空"),
|
|
470
|
+
}),
|
|
471
|
+
).trim();
|
|
472
|
+
|
|
473
|
+
const secret = String(
|
|
474
|
+
await prompter.text({
|
|
475
|
+
message: "请输入 Secret (机器人密钥):",
|
|
476
|
+
validate: (value: string | undefined) => (value?.trim() ? undefined : "Secret 不能为空"),
|
|
477
|
+
}),
|
|
478
|
+
).trim();
|
|
479
|
+
|
|
480
|
+
const streamPlaceholder = await prompter.text({
|
|
481
|
+
message: "流式占位符 (可选):",
|
|
482
|
+
placeholder: "正在思考...",
|
|
483
|
+
initialValue: "正在思考...",
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const welcomeText = await prompter.text({
|
|
487
|
+
message: "欢迎语 (可选):",
|
|
488
|
+
placeholder: "你好!我是 AI 助手",
|
|
489
|
+
initialValue: "你好!我是 AI 助手",
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const botConfig: WecomBotConfig = {
|
|
493
|
+
connectionMode: "websocket",
|
|
494
|
+
botId,
|
|
495
|
+
secret,
|
|
496
|
+
streamPlaceholderContent: streamPlaceholder?.trim() || undefined,
|
|
497
|
+
welcomeText: welcomeText?.trim() || undefined,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
return setWecomBotConfig(cfg, botConfig, accountId);
|
|
501
|
+
}
|
|
502
|
+
|
|
373
503
|
// ============================================================
|
|
374
504
|
// Agent 模式配置
|
|
375
505
|
// ============================================================
|
|
@@ -516,8 +646,13 @@ async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter, accoun
|
|
|
516
646
|
const lines: string[] = ["✅ 配置已保存!", ""];
|
|
517
647
|
|
|
518
648
|
if (account.bot?.configured) {
|
|
519
|
-
|
|
520
|
-
|
|
649
|
+
if (account.bot.connectionMode === "websocket") {
|
|
650
|
+
lines.push("📱 Bot 模式: 已配置 (WebSocket 长链接)");
|
|
651
|
+
lines.push(" 无需配置回调 URL,SDK 将主动连接企微服务器");
|
|
652
|
+
} else {
|
|
653
|
+
lines.push("📱 Bot 模式: 已配置 (Webhook 回调)");
|
|
654
|
+
lines.push(` 回调 URL: https://您的域名${accountWebhookPath("bot", accountId)}`);
|
|
655
|
+
}
|
|
521
656
|
}
|
|
522
657
|
|
|
523
658
|
if (account.agent?.configured) {
|
|
@@ -527,9 +662,17 @@ async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter, accoun
|
|
|
527
662
|
|
|
528
663
|
lines.push(` 账号 ID: ${accountId}`);
|
|
529
664
|
|
|
665
|
+
const hasWebhook =
|
|
666
|
+
(account.bot?.configured && account.bot.connectionMode !== "websocket") ||
|
|
667
|
+
account.agent?.configured;
|
|
668
|
+
|
|
530
669
|
lines.push("");
|
|
531
|
-
|
|
532
|
-
|
|
670
|
+
if (hasWebhook) {
|
|
671
|
+
lines.push("⚠️ 请确保您已在企微后台填写了正确的回调 URL,");
|
|
672
|
+
lines.push(" 并点击了后台的『保存』按钮完成验证。");
|
|
673
|
+
} else {
|
|
674
|
+
lines.push("💡 WebSocket 模式将在服务启动时自动连接企微服务器。");
|
|
675
|
+
}
|
|
533
676
|
|
|
534
677
|
await prompter.note(lines.join("\n"), "配置完成");
|
|
535
678
|
}
|
|
@@ -634,13 +777,19 @@ export const wecomOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
634
777
|
// 6. DM 策略
|
|
635
778
|
next = await promptDmPolicy(next, prompter, configuredModes, accountId);
|
|
636
779
|
|
|
637
|
-
// 7.
|
|
780
|
+
// 7. 设置 defaultAccount
|
|
781
|
+
next = setWecomDefaultAccount(next, accountId);
|
|
782
|
+
|
|
783
|
+
// 8. 启用通道
|
|
638
784
|
next = setWecomEnabled(next, true);
|
|
639
785
|
|
|
640
|
-
//
|
|
786
|
+
// 9. 设置 gateway.bind 为 lan(允许外部访问回调)
|
|
641
787
|
next = setGatewayBindLan(next);
|
|
642
788
|
|
|
643
|
-
//
|
|
789
|
+
// 10. 确保 bindings 中有默认路由(onboard quickstart 不会提示绑定)
|
|
790
|
+
next = ensureWecomBinding(next, accountId);
|
|
791
|
+
|
|
792
|
+
// 11. 汇总
|
|
644
793
|
await showSummary(next, prompter, accountId);
|
|
645
794
|
|
|
646
795
|
return { cfg: next, accountId };
|
package/src/outbound.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/pl
|
|
|
3
3
|
import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
|
|
4
4
|
import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
|
|
5
5
|
import { getWecomRuntime } from "./runtime.js";
|
|
6
|
+
import { getWsClient, waitForWsConnection } from "./ws-adapter.js";
|
|
6
7
|
|
|
7
8
|
import { resolveWecomTarget } from "./target.js";
|
|
8
9
|
|
|
@@ -61,6 +62,43 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
61
62
|
sendText: async ({ cfg, to, text, accountId }: ChannelOutboundContext) => {
|
|
62
63
|
// signal removed - not supported in current SDK
|
|
63
64
|
|
|
65
|
+
// ── Bot WebSocket outbound 独立路径 ──
|
|
66
|
+
// WS Bot 完全独立收发,不与 Agent 组成双模,失败时直接抛错而非 fallthrough
|
|
67
|
+
const resolvedAccount = resolveWecomAccount({ cfg, accountId });
|
|
68
|
+
const botAccount = resolvedAccount.bot;
|
|
69
|
+
if (botAccount?.connectionMode === 'websocket' && botAccount.configured) {
|
|
70
|
+
const wsClient = getWsClient(botAccount.accountId);
|
|
71
|
+
const wsTarget = resolveWecomTarget(to);
|
|
72
|
+
const chatid = wsTarget?.touser || wsTarget?.chatid;
|
|
73
|
+
|
|
74
|
+
// 如果目标是 Agent 会话(wecom-agent:),跳过 WS Bot,走 Agent outbound
|
|
75
|
+
const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
|
|
76
|
+
if (!rawTo.startsWith("wecom-agent:")) {
|
|
77
|
+
if (!wsClient?.isConnected) {
|
|
78
|
+
console.log(`[wecom-outbound] Bot WS 未连接,等待重连... (accountId=${botAccount.accountId})`);
|
|
79
|
+
const reconnected = await waitForWsConnection(botAccount.accountId, 10_000);
|
|
80
|
+
if (!reconnected) {
|
|
81
|
+
throw new Error(`[wecom-outbound] Bot WS 等待重连超时,无法发送消息 (accountId=${botAccount.accountId})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!chatid) {
|
|
85
|
+
throw new Error(`[wecom-outbound] Bot WS 无法解析目标 chatid (to=${String(to)})`);
|
|
86
|
+
}
|
|
87
|
+
// 重连后重新获取 client(可能是新实例)
|
|
88
|
+
const activeClient = getWsClient(botAccount.accountId);
|
|
89
|
+
if (!activeClient?.isConnected) {
|
|
90
|
+
throw new Error(`[wecom-outbound] Bot WS 重连后仍不可用 (accountId=${botAccount.accountId})`);
|
|
91
|
+
}
|
|
92
|
+
await activeClient.sendMessage(chatid, {
|
|
93
|
+
msgtype: 'markdown',
|
|
94
|
+
markdown: { content: text },
|
|
95
|
+
});
|
|
96
|
+
console.log(`[wecom-outbound] Sent text via Bot WS to chatid=${chatid} (len=${text.length})`);
|
|
97
|
+
return { channel: "wecom", messageId: `ws-bot-${Date.now()}`, timestamp: Date.now() };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Agent outbound(Webhook Bot 双模 / Agent 独立)──
|
|
64
102
|
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
65
103
|
const target = resolveWecomTarget(to);
|
|
66
104
|
if (!target) {
|
package/src/types/account.ts
CHANGED
|
@@ -20,9 +20,9 @@ export type ResolvedBotAccount = {
|
|
|
20
20
|
enabled: boolean;
|
|
21
21
|
/** 是否配置完整 */
|
|
22
22
|
configured: boolean;
|
|
23
|
-
/** 回调 Token */
|
|
23
|
+
/** 回调 Token (webhook 模式) */
|
|
24
24
|
token: string;
|
|
25
|
-
/** 回调加密密钥 */
|
|
25
|
+
/** 回调加密密钥 (webhook 模式) */
|
|
26
26
|
encodingAESKey: string;
|
|
27
27
|
/** 接收者 ID */
|
|
28
28
|
receiveId: string;
|
|
@@ -30,6 +30,15 @@ export type ResolvedBotAccount = {
|
|
|
30
30
|
config: WecomBotConfig;
|
|
31
31
|
/** 网络配置(来自 channels.wecom.network) */
|
|
32
32
|
network?: WecomNetworkConfig;
|
|
33
|
+
|
|
34
|
+
// --- 长链接模式 (WebSocket) ---
|
|
35
|
+
|
|
36
|
+
/** 连接模式 */
|
|
37
|
+
connectionMode: 'webhook' | 'websocket';
|
|
38
|
+
/** 机器人 BotID(websocket 模式下有值) */
|
|
39
|
+
botId?: string;
|
|
40
|
+
/** 机器人 Secret(websocket 模式下有值) */
|
|
41
|
+
secret?: string;
|
|
33
42
|
};
|
|
34
43
|
|
|
35
44
|
/**
|
package/src/types/config.ts
CHANGED
|
@@ -45,12 +45,12 @@ export type WecomRoutingConfig = {
|
|
|
45
45
|
* 用于接收 JSON 格式回调 + 流式回复
|
|
46
46
|
*/
|
|
47
47
|
export type WecomBotConfig = {
|
|
48
|
-
/** 智能机器人 ID(用于 Matrix
|
|
48
|
+
/** 智能机器人 ID(用于 Matrix 模式二次身份确认,webhook 模式) */
|
|
49
49
|
aibotid?: string;
|
|
50
|
-
/** 回调 Token (
|
|
51
|
-
token
|
|
52
|
-
/** 回调加密密钥 (
|
|
53
|
-
encodingAESKey
|
|
50
|
+
/** 回调 Token (企微后台生成,webhook 模式必填) */
|
|
51
|
+
token?: string;
|
|
52
|
+
/** 回调加密密钥 (企微后台生成,webhook 模式必填) */
|
|
53
|
+
encodingAESKey?: string;
|
|
54
54
|
/**
|
|
55
55
|
* BotId 列表(可选,用于审计与告警)。
|
|
56
56
|
* - 回调路由优先由 URL + 签名决定;botIds 不参与强制拦截。
|
|
@@ -65,6 +65,15 @@ export type WecomBotConfig = {
|
|
|
65
65
|
welcomeText?: string;
|
|
66
66
|
/** DM 策略 */
|
|
67
67
|
dm?: WecomDmConfig;
|
|
68
|
+
|
|
69
|
+
// --- 长链接模式 (WebSocket) ---
|
|
70
|
+
|
|
71
|
+
/** 连接模式:webhook(默认)或 websocket */
|
|
72
|
+
connectionMode?: 'webhook' | 'websocket';
|
|
73
|
+
/** 机器人 BotID(websocket 模式必填,企微后台获取) */
|
|
74
|
+
botId?: string;
|
|
75
|
+
/** 机器人 Secret(websocket 模式必填,企微后台获取) */
|
|
76
|
+
secret?: string;
|
|
68
77
|
};
|
|
69
78
|
|
|
70
79
|
/**
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom WebSocket 长链接模式适配器
|
|
3
|
+
*
|
|
4
|
+
* 职责:管理 WSClient 生命周期,将 SDK 事件桥接到现有 monitor.ts 消息管线。
|
|
5
|
+
*
|
|
6
|
+
* SDK WsFrame 事件
|
|
7
|
+
* ↓
|
|
8
|
+
* ws-adapter 转换为 WecomBotInboundMessage 格式
|
|
9
|
+
* ↓
|
|
10
|
+
* 复用 monitor.ts 中的 shouldProcessBotInboundMessage → buildInboundBody
|
|
11
|
+
* → streamStore.addPendingMessage → flushPending 管线
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
15
|
+
import { WSClient } from "@wecom/aibot-node-sdk";
|
|
16
|
+
import type {
|
|
17
|
+
WsFrame,
|
|
18
|
+
BaseMessage,
|
|
19
|
+
TextMessage,
|
|
20
|
+
ImageMessage,
|
|
21
|
+
MixedMessage,
|
|
22
|
+
VoiceMessage,
|
|
23
|
+
FileMessage,
|
|
24
|
+
EventMessage,
|
|
25
|
+
EventMessageWith,
|
|
26
|
+
ReplyMsgItem,
|
|
27
|
+
} from "@wecom/aibot-node-sdk";
|
|
28
|
+
import type { EnterChatEvent, TemplateCardEventData } from "@wecom/aibot-node-sdk";
|
|
29
|
+
|
|
30
|
+
import type { ResolvedBotAccount, WecomNetworkConfig, WecomBotInboundMessage } from "./types/index.js";
|
|
31
|
+
import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState } from "./monitor/types.js";
|
|
32
|
+
import { shouldProcessBotInboundMessage, buildInboundBody } from "./monitor.js";
|
|
33
|
+
import { monitorState } from "./monitor/state.js";
|
|
34
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
35
|
+
|
|
36
|
+
// ─── WSClient Instance Registry ────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const wsClients = new Map<string, WSClient>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 获取指定账号的 WSClient 实例
|
|
42
|
+
*/
|
|
43
|
+
export function getWsClient(accountId: string): WSClient | undefined {
|
|
44
|
+
return wsClients.get(accountId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 等待 WSClient 连接就绪,最多等待 timeoutMs 毫秒(默认 30 秒)。
|
|
49
|
+
* 如果已连接则立即返回;如果 client 尚未创建,会轮询等待创建后再监听连接事件。
|
|
50
|
+
*/
|
|
51
|
+
export async function waitForWsConnection(accountId: string, timeoutMs = 30_000): Promise<boolean> {
|
|
52
|
+
const deadline = Date.now() + timeoutMs;
|
|
53
|
+
|
|
54
|
+
// 等待 client 实例出现(gateway 重启时 client 可能还没注册)
|
|
55
|
+
while (!wsClients.has(accountId)) {
|
|
56
|
+
if (Date.now() >= deadline) return false;
|
|
57
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const client = wsClients.get(accountId)!;
|
|
61
|
+
if (client.isConnected) return true;
|
|
62
|
+
|
|
63
|
+
const remaining = deadline - Date.now();
|
|
64
|
+
if (remaining <= 0) return false;
|
|
65
|
+
|
|
66
|
+
return new Promise<boolean>((resolve) => {
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
cleanup();
|
|
69
|
+
resolve(false);
|
|
70
|
+
}, remaining);
|
|
71
|
+
|
|
72
|
+
const onConnected = () => {
|
|
73
|
+
cleanup();
|
|
74
|
+
resolve(true);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
client.off("connected", onConnected);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
client.on("connected", onConnected);
|
|
83
|
+
// 再检查一次,防止在注册监听器之前已连上
|
|
84
|
+
if (client.isConnected) {
|
|
85
|
+
cleanup();
|
|
86
|
+
resolve(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Stream Reply Watcher ──────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 流式回复监听器:轮询 StreamState 变化并通过 WSClient 推送回复
|
|
95
|
+
*/
|
|
96
|
+
function watchStreamReply(params: {
|
|
97
|
+
wsClient: WSClient;
|
|
98
|
+
frame: WsFrame;
|
|
99
|
+
streamId: string;
|
|
100
|
+
log?: (msg: string) => void;
|
|
101
|
+
error?: (msg: string) => void;
|
|
102
|
+
}): void {
|
|
103
|
+
const { wsClient, frame, streamId, log, error } = params;
|
|
104
|
+
const streamStore = monitorState.streamStore;
|
|
105
|
+
let lastSentContent = "";
|
|
106
|
+
let finished = false;
|
|
107
|
+
const POLL_INTERVAL_MS = 200;
|
|
108
|
+
|
|
109
|
+
const tick = async () => {
|
|
110
|
+
if (finished) return;
|
|
111
|
+
|
|
112
|
+
const state = streamStore.getStream(streamId);
|
|
113
|
+
if (!state) {
|
|
114
|
+
finished = true;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const content = state.content ?? "";
|
|
119
|
+
const isFinished = state.finished ?? false;
|
|
120
|
+
|
|
121
|
+
// 有新内容或流结束时发送
|
|
122
|
+
if (content !== lastSentContent || isFinished) {
|
|
123
|
+
try {
|
|
124
|
+
// 构建图片附件(仅在结束时)
|
|
125
|
+
let msgItems: ReplyMsgItem[] | undefined;
|
|
126
|
+
if (isFinished && state.images?.length) {
|
|
127
|
+
msgItems = state.images.map((img) => ({
|
|
128
|
+
msgtype: "image" as const,
|
|
129
|
+
image: { base64: img.base64, md5: img.md5 },
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await wsClient.replyStream(
|
|
134
|
+
frame,
|
|
135
|
+
streamId,
|
|
136
|
+
content,
|
|
137
|
+
isFinished,
|
|
138
|
+
msgItems,
|
|
139
|
+
);
|
|
140
|
+
lastSentContent = content;
|
|
141
|
+
log?.(`ws-reply: streamId=${streamId} len=${content.length} finish=${isFinished}`);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
error?.(`ws-reply: replyStream failed streamId=${streamId}: ${String(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isFinished) {
|
|
148
|
+
finished = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setTimeout(tick, POLL_INTERVAL_MS);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// 初次延迟启动,等待 agent 开始生产内容
|
|
156
|
+
setTimeout(tick, POLL_INTERVAL_MS);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── SDK Message → WecomBotInboundMessage Conversion ───────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 将 SDK 的 WsFrame<BaseMessage> 转换为现有的 WecomBotInboundMessage 格式
|
|
163
|
+
*/
|
|
164
|
+
function convertSdkMessageToInbound(body: BaseMessage): WecomBotInboundMessage {
|
|
165
|
+
const base: WecomBotInboundMessage = {
|
|
166
|
+
msgid: body.msgid,
|
|
167
|
+
aibotid: body.aibotid,
|
|
168
|
+
chattype: body.chattype,
|
|
169
|
+
chatid: body.chatid,
|
|
170
|
+
response_url: body.response_url,
|
|
171
|
+
from: body.from ? { userid: body.from.userid } : undefined,
|
|
172
|
+
msgtype: body.msgtype as string,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const msgtype = String(body.msgtype ?? "").toLowerCase();
|
|
176
|
+
|
|
177
|
+
if (msgtype === "text") {
|
|
178
|
+
const textBody = body as TextMessage;
|
|
179
|
+
return { ...base, msgtype: "text", text: textBody.text, quote: textBody.quote as any };
|
|
180
|
+
}
|
|
181
|
+
if (msgtype === "voice") {
|
|
182
|
+
const voiceBody = body as VoiceMessage;
|
|
183
|
+
return { ...base, msgtype: "voice", voice: voiceBody.voice, quote: voiceBody.quote as any };
|
|
184
|
+
}
|
|
185
|
+
if (msgtype === "image") {
|
|
186
|
+
const imageBody = body as ImageMessage;
|
|
187
|
+
return { ...base, msgtype: "image" as any, image: imageBody.image, quote: imageBody.quote as any } as any;
|
|
188
|
+
}
|
|
189
|
+
if (msgtype === "file") {
|
|
190
|
+
const fileBody = body as FileMessage;
|
|
191
|
+
return { ...base, msgtype: "file" as any, file: fileBody.file, quote: fileBody.quote as any } as any;
|
|
192
|
+
}
|
|
193
|
+
if (msgtype === "mixed") {
|
|
194
|
+
const mixedBody = body as MixedMessage;
|
|
195
|
+
return { ...base, msgtype: "mixed" as any, mixed: mixedBody.mixed, quote: mixedBody.quote as any } as any;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Fallback: pass through as-is
|
|
199
|
+
return { ...base, ...body };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── WS Event Handlers ────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function setupMessageHandler(params: {
|
|
205
|
+
wsClient: WSClient;
|
|
206
|
+
accountId: string;
|
|
207
|
+
target: WecomWebhookTarget;
|
|
208
|
+
}) {
|
|
209
|
+
const { wsClient, accountId, target } = params;
|
|
210
|
+
const streamStore = monitorState.streamStore;
|
|
211
|
+
|
|
212
|
+
// 监听所有消息类型
|
|
213
|
+
wsClient.on("message", (frame: WsFrame<BaseMessage>) => {
|
|
214
|
+
const body = frame.body;
|
|
215
|
+
if (!body) return;
|
|
216
|
+
|
|
217
|
+
const msgtype = String(body.msgtype ?? "").toLowerCase();
|
|
218
|
+
|
|
219
|
+
// event 类型由专门的 event handler 处理
|
|
220
|
+
if (msgtype === "event") return;
|
|
221
|
+
|
|
222
|
+
const msg = convertSdkMessageToInbound(body);
|
|
223
|
+
const decision = shouldProcessBotInboundMessage(msg);
|
|
224
|
+
if (!decision.shouldProcess) {
|
|
225
|
+
target.runtime.log?.(
|
|
226
|
+
`[${accountId}] ws-inbound: skipped msgtype=${msgtype} reason=${decision.reason}`,
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const userid = decision.senderUserId!;
|
|
232
|
+
const chatId = decision.chatId ?? userid;
|
|
233
|
+
const conversationKey = `wecom:${accountId}:${userid}:${chatId}`;
|
|
234
|
+
const msgContent = buildInboundBody(msg);
|
|
235
|
+
|
|
236
|
+
target.runtime.log?.(
|
|
237
|
+
`[${accountId}] ws-inbound: msgtype=${msgtype} chattype=${String(msg.chattype ?? "")} ` +
|
|
238
|
+
`from=${userid} msgid=${String(msg.msgid ?? "")}`,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// 消息去重
|
|
242
|
+
if (msg.msgid) {
|
|
243
|
+
const existingStreamId = streamStore.getStreamByMsgId(String(msg.msgid));
|
|
244
|
+
if (existingStreamId) {
|
|
245
|
+
target.runtime.log?.(
|
|
246
|
+
`[${accountId}] ws-inbound: duplicate msgid=${msg.msgid}, skipping`,
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 加入 Pending 队列(复用现有防抖/聚合逻辑)
|
|
253
|
+
const { streamId } = streamStore.addPendingMessage({
|
|
254
|
+
conversationKey,
|
|
255
|
+
target,
|
|
256
|
+
msg,
|
|
257
|
+
msgContent,
|
|
258
|
+
nonce: "",
|
|
259
|
+
timestamp: String(Date.now()),
|
|
260
|
+
debounceMs: (target.account.config as any).debounceMs,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// 标记 wsMode
|
|
264
|
+
streamStore.updateStream(streamId, (s: StreamState) => {
|
|
265
|
+
s.wsMode = true;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// 注册流式回复监听器
|
|
269
|
+
watchStreamReply({
|
|
270
|
+
wsClient,
|
|
271
|
+
frame,
|
|
272
|
+
streamId,
|
|
273
|
+
log: (msg) => target.runtime.log?.(`[${accountId}] ${msg}`),
|
|
274
|
+
error: (msg) => target.runtime.error?.(`[${accountId}] ${msg}`),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function setupEventHandler(params: {
|
|
282
|
+
wsClient: WSClient;
|
|
283
|
+
accountId: string;
|
|
284
|
+
target: WecomWebhookTarget;
|
|
285
|
+
welcomeText?: string;
|
|
286
|
+
}) {
|
|
287
|
+
const { wsClient, accountId, target, welcomeText } = params;
|
|
288
|
+
const streamStore = monitorState.streamStore;
|
|
289
|
+
|
|
290
|
+
// 进入会话事件 → 欢迎语
|
|
291
|
+
wsClient.on("event.enter_chat", async (frame: WsFrame<EventMessageWith<EnterChatEvent>>) => {
|
|
292
|
+
const text = welcomeText?.trim();
|
|
293
|
+
if (!text) return;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
await wsClient.replyWelcome(frame, {
|
|
297
|
+
msgtype: "text",
|
|
298
|
+
text: { content: text },
|
|
299
|
+
});
|
|
300
|
+
target.runtime.log?.(`[${accountId}] ws-event: sent welcome text`);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
target.runtime.error?.(`[${accountId}] ws-event: replyWelcome failed: ${String(err)}`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// 模板卡片交互事件 → 转换为文本消息注入管线
|
|
307
|
+
wsClient.on("event.template_card_event", (frame: WsFrame<EventMessageWith<TemplateCardEventData>>) => {
|
|
308
|
+
const body = frame.body;
|
|
309
|
+
if (!body) return;
|
|
310
|
+
|
|
311
|
+
const eventData = body.event;
|
|
312
|
+
let interactionDesc = `[卡片交互] 按钮: ${eventData?.event_key || "unknown"}`;
|
|
313
|
+
if (eventData?.task_id) interactionDesc += ` (任务ID: ${eventData.task_id})`;
|
|
314
|
+
|
|
315
|
+
const msgid = body.msgid ? String(body.msgid) : undefined;
|
|
316
|
+
|
|
317
|
+
// 去重
|
|
318
|
+
if (msgid && streamStore.getStreamByMsgId(msgid)) {
|
|
319
|
+
target.runtime.log?.(`[${accountId}] ws-event: template_card_event already processed msgid=${msgid}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const streamId = streamStore.createStream({ msgid });
|
|
324
|
+
streamStore.markStarted(streamId);
|
|
325
|
+
streamStore.updateStream(streamId, (s: StreamState) => {
|
|
326
|
+
s.wsMode = true;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const syntheticMsg: WecomBotInboundMessage = {
|
|
330
|
+
msgid,
|
|
331
|
+
aibotid: body.aibotid,
|
|
332
|
+
chattype: body.chattype,
|
|
333
|
+
chatid: body.chatid,
|
|
334
|
+
from: body.from ? { userid: body.from.userid } : undefined,
|
|
335
|
+
msgtype: "text",
|
|
336
|
+
text: { content: interactionDesc },
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
let core: PluginRuntime;
|
|
340
|
+
try {
|
|
341
|
+
core = getWecomRuntime();
|
|
342
|
+
} catch {
|
|
343
|
+
target.runtime.error?.(`[${accountId}] ws-event: runtime not ready for template_card_event`);
|
|
344
|
+
streamStore.markFinished(streamId);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 由于卡片事件没有经过防抖队列,直接触发 flushPending 的等效操作
|
|
349
|
+
// 需要通过 addPendingMessage 注入,让现有管线处理
|
|
350
|
+
const userid = body.from?.userid ?? "unknown";
|
|
351
|
+
const chatId = body.chatid ?? userid;
|
|
352
|
+
const conversationKey = `wecom:${accountId}:${userid}:${chatId}`;
|
|
353
|
+
|
|
354
|
+
// 先清除之前创建的 stream(addPendingMessage 会创建新的)
|
|
355
|
+
// 直接用 addPendingMessage 复用完整管线
|
|
356
|
+
const enrichedTarget: WecomWebhookTarget = { ...target, core };
|
|
357
|
+
const { streamId: actualStreamId } = streamStore.addPendingMessage({
|
|
358
|
+
conversationKey,
|
|
359
|
+
target: enrichedTarget,
|
|
360
|
+
msg: syntheticMsg,
|
|
361
|
+
msgContent: interactionDesc,
|
|
362
|
+
nonce: "",
|
|
363
|
+
timestamp: String(Date.now()),
|
|
364
|
+
debounceMs: 0, // 卡片事件不防抖
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
streamStore.updateStream(actualStreamId, (s: StreamState) => {
|
|
368
|
+
s.wsMode = true;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
watchStreamReply({
|
|
372
|
+
wsClient,
|
|
373
|
+
frame,
|
|
374
|
+
streamId: actualStreamId,
|
|
375
|
+
log: (msg) => target.runtime.log?.(`[${accountId}] ${msg}`),
|
|
376
|
+
error: (msg) => target.runtime.error?.(`[${accountId}] ${msg}`),
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// 反馈事件 → 仅记录日志
|
|
381
|
+
wsClient.on("event.feedback_event", (frame) => {
|
|
382
|
+
target.runtime.log?.(
|
|
383
|
+
`[${accountId}] ws-event: feedback_event received (logged only)`,
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── WSClient Lifecycle ────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
export type StartWsClientParams = {
|
|
391
|
+
accountId: string;
|
|
392
|
+
botId: string;
|
|
393
|
+
secret: string;
|
|
394
|
+
account: ResolvedBotAccount;
|
|
395
|
+
config: OpenClawConfig;
|
|
396
|
+
runtime: WecomRuntimeEnv;
|
|
397
|
+
core: PluginRuntime;
|
|
398
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
399
|
+
welcomeText?: string;
|
|
400
|
+
network?: WecomNetworkConfig;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 启动 WebSocket 长链接客户端
|
|
405
|
+
* @returns cleanup 函数(用于注销)
|
|
406
|
+
*/
|
|
407
|
+
export function startWsClient(params: StartWsClientParams): () => void {
|
|
408
|
+
const {
|
|
409
|
+
accountId, botId, secret,
|
|
410
|
+
account, config, runtime, core,
|
|
411
|
+
statusSink, welcomeText,
|
|
412
|
+
} = params;
|
|
413
|
+
|
|
414
|
+
// 如果已有实例,先停止
|
|
415
|
+
stopWsClient(accountId);
|
|
416
|
+
|
|
417
|
+
const wsClient = new WSClient({
|
|
418
|
+
botId,
|
|
419
|
+
secret,
|
|
420
|
+
maxReconnectAttempts: -1, // 无限重连
|
|
421
|
+
logger: {
|
|
422
|
+
debug: (msg: string) => runtime.log?.(`[${accountId}][ws-sdk] ${msg}`),
|
|
423
|
+
info: (msg: string) => runtime.log?.(`[${accountId}][ws-sdk] ${msg}`),
|
|
424
|
+
warn: (msg: string) => runtime.log?.(`[${accountId}][ws-sdk] WARN: ${msg}`),
|
|
425
|
+
error: (msg: string) => runtime.error?.(`[${accountId}][ws-sdk] ERROR: ${msg}`),
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
wsClients.set(accountId, wsClient);
|
|
430
|
+
|
|
431
|
+
// 构建 WecomWebhookTarget 以复用 monitor 管线
|
|
432
|
+
const target: WecomWebhookTarget = {
|
|
433
|
+
account,
|
|
434
|
+
config,
|
|
435
|
+
runtime,
|
|
436
|
+
core,
|
|
437
|
+
path: `ws://${accountId}`,
|
|
438
|
+
statusSink,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// 设置消息和事件处理
|
|
442
|
+
setupMessageHandler({ wsClient, accountId, target });
|
|
443
|
+
setupEventHandler({ wsClient, accountId, target, welcomeText });
|
|
444
|
+
|
|
445
|
+
// 连接状态日志
|
|
446
|
+
wsClient.on("connected", () => {
|
|
447
|
+
runtime.log?.(`[${accountId}] ws: connected`);
|
|
448
|
+
});
|
|
449
|
+
wsClient.on("authenticated", () => {
|
|
450
|
+
runtime.log?.(`[${accountId}] ws: authenticated successfully`);
|
|
451
|
+
});
|
|
452
|
+
wsClient.on("disconnected", (reason: string) => {
|
|
453
|
+
runtime.log?.(`[${accountId}] ws: disconnected - ${reason}`);
|
|
454
|
+
});
|
|
455
|
+
wsClient.on("reconnecting", (attempt: number) => {
|
|
456
|
+
runtime.log?.(`[${accountId}] ws: reconnecting attempt=${attempt}`);
|
|
457
|
+
});
|
|
458
|
+
wsClient.on("error", (err: Error) => {
|
|
459
|
+
runtime.error?.(`[${accountId}] ws: error - ${err.message}`);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// 建立连接
|
|
463
|
+
wsClient.connect();
|
|
464
|
+
runtime.log?.(`[${accountId}] ws: starting connection (botId=${botId})`);
|
|
465
|
+
|
|
466
|
+
// 返回清理函数
|
|
467
|
+
return () => {
|
|
468
|
+
stopWsClient(accountId);
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 停止指定账号的 WSClient
|
|
474
|
+
*/
|
|
475
|
+
export function stopWsClient(accountId: string): void {
|
|
476
|
+
const existing = wsClients.get(accountId);
|
|
477
|
+
if (existing) {
|
|
478
|
+
existing.disconnect();
|
|
479
|
+
wsClients.delete(accountId);
|
|
480
|
+
}
|
|
481
|
+
}
|