@mocrane/wecom 2026.2.27 → 2026.3.8-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/LICENSE +4 -18
- package/README.md +572 -0
- package/assets/01.bot-add.png +0 -0
- package/assets/01.bot-setp2.png +0 -0
- package/assets/02.agent.add.png +0 -0
- package/assets/02.agent.api-set.png +0 -0
- package/assets/register.png +0 -0
- package/changelog/v2.2.28.md +70 -0
- package/changelog/v2.3.2.md +28 -0
- package/changelog/v2.3.4.md +20 -0
- package/index.ts +11 -3
- package/package.json +4 -2
- package/src/accounts.ts +17 -55
- package/src/agent/api-client.ts +84 -37
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.event-filter.test.ts +50 -0
- package/src/agent/handler.ts +147 -145
- package/src/channel.config.test.ts +147 -0
- package/src/channel.lifecycle.test.ts +252 -0
- package/src/channel.ts +132 -141
- package/src/config/accounts.resolve.test.ts +38 -0
- package/src/config/accounts.ts +267 -25
- package/src/config/index.ts +6 -0
- package/src/config/network.ts +9 -5
- package/src/config/routing.test.ts +88 -0
- package/src/config/routing.ts +26 -0
- package/src/config/schema.ts +41 -6
- package/src/config-schema.ts +5 -41
- package/src/dynamic-agent.account-scope.test.ts +17 -0
- package/src/dynamic-agent.ts +13 -13
- package/src/gateway-monitor.ts +260 -0
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor/state.queue.test.ts +1 -1
- package/src/monitor/state.ts +1 -1
- package/src/monitor/types.ts +5 -1
- package/src/monitor.active.test.ts +15 -9
- package/src/monitor.inbound-filter.test.ts +63 -0
- package/src/monitor.integration.test.ts +4 -2
- package/src/monitor.ts +982 -134
- package/src/monitor.webhook.test.ts +381 -3
- package/src/onboarding.ts +379 -54
- package/src/outbound.test.ts +130 -0
- package/src/outbound.ts +82 -9
- package/src/shared/command-auth.ts +4 -2
- package/src/shared/xml-parser.test.ts +21 -1
- package/src/shared/xml-parser.ts +18 -0
- package/src/types/account.ts +54 -16
- package/src/types/config.ts +50 -6
- package/src/types/constants.ts +7 -3
- package/src/types/index.ts +3 -0
- package/src/types.ts +29 -147
- package/src/ws-adapter.ts +481 -0
package/src/types.ts
CHANGED
|
@@ -1,143 +1,34 @@
|
|
|
1
|
-
export type WecomDmConfig = {
|
|
2
|
-
policy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
3
|
-
allowFrom?: Array<string | number>;
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
export type WecomAccountConfig = {
|
|
7
|
-
name?: string;
|
|
8
|
-
enabled?: boolean;
|
|
9
|
-
|
|
10
|
-
webhookPath?: string;
|
|
11
|
-
token?: string;
|
|
12
|
-
encodingAESKey?: string;
|
|
13
|
-
receiveId?: string;
|
|
14
|
-
|
|
15
|
-
streamPlaceholderContent?: string;
|
|
16
|
-
|
|
17
|
-
dm?: WecomDmConfig;
|
|
18
|
-
welcomeText?: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type WecomConfig = WecomAccountConfig & {
|
|
22
|
-
accounts?: Record<string, WecomAccountConfig>;
|
|
23
|
-
defaultAccount?: string;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type ResolvedWecomAccount = {
|
|
27
|
-
accountId: string;
|
|
28
|
-
name?: string;
|
|
29
|
-
enabled: boolean;
|
|
30
|
-
configured: boolean;
|
|
31
|
-
token?: string;
|
|
32
|
-
encodingAESKey?: string;
|
|
33
|
-
receiveId: string;
|
|
34
|
-
config: WecomAccountConfig;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type WecomInboundBase = {
|
|
38
|
-
msgid?: string;
|
|
39
|
-
aibotid?: string;
|
|
40
|
-
chattype?: "single" | "group";
|
|
41
|
-
chatid?: string;
|
|
42
|
-
response_url?: string;
|
|
43
|
-
from?: { userid?: string; corpid?: string };
|
|
44
|
-
msgtype?: string;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export type WecomInboundText = WecomInboundBase & {
|
|
48
|
-
msgtype: "text";
|
|
49
|
-
text?: { content?: string };
|
|
50
|
-
quote?: unknown;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type WecomInboundVoice = WecomInboundBase & {
|
|
54
|
-
msgtype: "voice";
|
|
55
|
-
voice?: { content?: string };
|
|
56
|
-
quote?: unknown;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export type WecomInboundStreamRefresh = WecomInboundBase & {
|
|
60
|
-
msgtype: "stream";
|
|
61
|
-
stream?: { id?: string };
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export type WecomInboundEvent = WecomInboundBase & {
|
|
65
|
-
msgtype: "event";
|
|
66
|
-
create_time?: number;
|
|
67
|
-
event?: {
|
|
68
|
-
eventtype?: string;
|
|
69
|
-
[key: string]: unknown;
|
|
70
|
-
};
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export type WecomInboundQuote = {
|
|
74
|
-
msgtype?: "text" | "image" | "mixed" | "voice" | "file";
|
|
75
|
-
text?: { content?: string };
|
|
76
|
-
image?: { url?: string };
|
|
77
|
-
mixed?: {
|
|
78
|
-
msg_item?: Array<{
|
|
79
|
-
msgtype: "text" | "image";
|
|
80
|
-
text?: { content?: string };
|
|
81
|
-
image?: { url?: string };
|
|
82
|
-
}>;
|
|
83
|
-
};
|
|
84
|
-
voice?: { content?: string };
|
|
85
|
-
file?: { url?: string };
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
export type WecomInboundMessage =
|
|
89
|
-
| (WecomInboundText & { quote?: WecomInboundQuote })
|
|
90
|
-
| WecomInboundVoice
|
|
91
|
-
| WecomInboundStreamRefresh
|
|
92
|
-
| WecomInboundEvent
|
|
93
|
-
| (WecomInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
|
|
94
|
-
|
|
95
|
-
export type WecomTemplateCard = {
|
|
96
|
-
card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
|
|
97
|
-
source?: { icon_url?: string; desc?: string; desc_color?: number };
|
|
98
|
-
main_title?: { title?: string; desc?: string };
|
|
99
|
-
task_id?: string;
|
|
100
|
-
button_list?: Array<{ text: string; style?: number; key: string }>;
|
|
101
|
-
sub_title_text?: string;
|
|
102
|
-
horizontal_content_list?: Array<{ keyname: string; value?: string; type?: number; url?: string; userid?: string }>;
|
|
103
|
-
card_action?: { type: number; url?: string; appid?: string; pagepath?: string };
|
|
104
|
-
action_menu?: { desc: string; action_list: Array<{ text: string; key: string }> };
|
|
105
|
-
select_list?: Array<{
|
|
106
|
-
question_key: string;
|
|
107
|
-
title?: string;
|
|
108
|
-
selected_id?: string;
|
|
109
|
-
option_list: Array<{ id: string; text: string }>;
|
|
110
|
-
}>;
|
|
111
|
-
submit_button?: { text: string; key: string };
|
|
112
|
-
checkbox?: {
|
|
113
|
-
question_key: string;
|
|
114
|
-
option_list: Array<{ id: string; text: string; is_checked?: boolean }>;
|
|
115
|
-
mode?: number;
|
|
116
|
-
};
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
export type WecomInboundTemplateCardEvent = WecomInboundBase & {
|
|
120
|
-
msgtype: "event";
|
|
121
|
-
event: {
|
|
122
|
-
eventtype: "template_card_event";
|
|
123
|
-
template_card_event: {
|
|
124
|
-
card_type: string;
|
|
125
|
-
event_key: string;
|
|
126
|
-
task_id: string;
|
|
127
|
-
selected_items?: {
|
|
128
|
-
selected_item: Array<{
|
|
129
|
-
question_key: string;
|
|
130
|
-
option_ids: { option_id: string[] };
|
|
131
|
-
}>;
|
|
132
|
-
};
|
|
133
|
-
};
|
|
134
|
-
};
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
|
|
138
1
|
/**
|
|
139
|
-
*
|
|
2
|
+
* Backward-compatible type bridge.
|
|
3
|
+
* Canonical definitions live in `src/types/*`.
|
|
140
4
|
*/
|
|
5
|
+
export type {
|
|
6
|
+
WecomDmConfig,
|
|
7
|
+
WecomAccountConfig,
|
|
8
|
+
WecomConfig,
|
|
9
|
+
ResolvedWecomAccount,
|
|
10
|
+
WecomInboundQuote,
|
|
11
|
+
WecomTemplateCard,
|
|
12
|
+
WecomOutboundMessage,
|
|
13
|
+
} from "./types/index.js";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
WecomBotInboundBase,
|
|
17
|
+
WecomBotInboundText,
|
|
18
|
+
WecomBotInboundVoice,
|
|
19
|
+
WecomBotInboundStreamRefresh,
|
|
20
|
+
WecomBotInboundEvent,
|
|
21
|
+
WecomBotInboundMessage,
|
|
22
|
+
} from "./types/index.js";
|
|
23
|
+
|
|
24
|
+
export type WecomInboundBase = WecomBotInboundBase;
|
|
25
|
+
export type WecomInboundText = WecomBotInboundText;
|
|
26
|
+
export type WecomInboundVoice = WecomBotInboundVoice;
|
|
27
|
+
export type WecomInboundStreamRefresh = WecomBotInboundStreamRefresh;
|
|
28
|
+
export type WecomInboundEvent = WecomBotInboundEvent;
|
|
29
|
+
export type WecomInboundMessage = WecomBotInboundMessage;
|
|
30
|
+
|
|
31
|
+
export type WecomInboundTemplateCardEvent = WecomBotInboundEvent;
|
|
141
32
|
export type WecomTemplateCardEventPayload = {
|
|
142
33
|
card_type: string;
|
|
143
34
|
event_key: string;
|
|
@@ -148,12 +39,3 @@ export type WecomTemplateCardEventPayload = {
|
|
|
148
39
|
option_ids?: string[];
|
|
149
40
|
};
|
|
150
41
|
};
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Outbound message types that can be sent via response_url
|
|
154
|
-
*/
|
|
155
|
-
export type WecomOutboundMessage =
|
|
156
|
-
| { msgtype: "text"; text: { content: string } }
|
|
157
|
-
| { msgtype: "markdown"; markdown: { content: string } }
|
|
158
|
-
| { msgtype: "template_card"; template_card: WecomTemplateCard };
|
|
159
|
-
|
|
@@ -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
|
+
}
|