@yanhaidao/wecom 2.3.10 → 2.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -23
- package/changelog/v2.3.11.md +19 -0
- package/changelog/v2.3.12.md +25 -0
- package/changelog/v2.3.13.md +19 -0
- package/package.json +2 -2
- package/src/agent/handler.ts +25 -26
- package/src/app/account-runtime.ts +5 -4
- package/src/app/index.ts +19 -0
- package/src/dynamic-agent.ts +2 -1
- package/src/onboarding.test.ts +50 -0
- package/src/onboarding.ts +4 -1
- package/src/outbound.test.ts +153 -1
- package/src/outbound.ts +105 -10
- package/src/runtime.ts +3 -0
- package/src/shared/media-service.ts +20 -0
- package/src/target.ts +8 -3
- package/src/transport/bot-ws/inbound.test.ts +50 -0
- package/src/transport/bot-ws/inbound.ts +10 -20
- package/src/transport/bot-ws/reply.test.ts +184 -0
- package/src/transport/bot-ws/reply.ts +112 -18
- package/src/transport/bot-ws/sdk-adapter.test.ts +124 -0
- package/src/transport/bot-ws/sdk-adapter.ts +58 -2
- package/CLAUDE.md +0 -238
|
@@ -1,29 +1,93 @@
|
|
|
1
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk";
|
|
1
2
|
import { generateReqId, type WsFrame, type BaseMessage, type EventMessage, type WSClient } from "@wecom/aibot-node-sdk";
|
|
2
3
|
|
|
3
4
|
import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
|
|
4
5
|
|
|
6
|
+
const PLACEHOLDER_KEEPALIVE_MS = 3000;
|
|
7
|
+
|
|
8
|
+
function isInvalidReqIdError(error: unknown): boolean {
|
|
9
|
+
if (!error || typeof error !== "object") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const errcode = "errcode" in error ? Number(error.errcode) : undefined;
|
|
13
|
+
const errmsg = "errmsg" in error ? String(error.errmsg ?? "") : "";
|
|
14
|
+
return errcode === 846605 || errmsg.includes("invalid req_id");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isExpiredStreamUpdateError(error: unknown): boolean {
|
|
18
|
+
if (!error || typeof error !== "object") {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const errcode = "errcode" in error ? Number(error.errcode) : undefined;
|
|
22
|
+
const errmsg = "errmsg" in error ? String(error.errmsg ?? "").toLowerCase() : "";
|
|
23
|
+
return errcode === 846608 || errmsg.includes("stream message update expired");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SDK rejects with a plain Error whose message contains "ack timeout" when
|
|
27
|
+
* the WeCom server does not acknowledge a reply within 5 s. Once timed out
|
|
28
|
+
* the reqId slot is released; further replies on the same reqId will fail. */
|
|
29
|
+
function isAckTimeoutError(error: unknown): boolean {
|
|
30
|
+
return error instanceof Error && error.message.includes("ack timeout");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isTerminalReplyError(error: unknown): boolean {
|
|
34
|
+
return isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error);
|
|
35
|
+
}
|
|
36
|
+
|
|
5
37
|
export function createBotWsReplyHandle(params: {
|
|
6
38
|
client: WSClient;
|
|
7
39
|
frame: WsFrame<BaseMessage | EventMessage>;
|
|
8
40
|
accountId: string;
|
|
41
|
+
placeholderContent?: string;
|
|
42
|
+
autoSendPlaceholder?: boolean;
|
|
9
43
|
onDeliver?: () => void;
|
|
10
44
|
onFail?: (error: unknown) => void;
|
|
11
45
|
}): ReplyHandle {
|
|
12
46
|
let streamId: string | undefined;
|
|
47
|
+
let accumulatedText = "";
|
|
13
48
|
const resolveStreamId = () => {
|
|
14
49
|
streamId ||= generateReqId("stream");
|
|
15
50
|
return streamId;
|
|
16
51
|
};
|
|
17
52
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
params.client.replyStream(params.frame, resolveStreamId(), "⏳ 正在思考中...\n\n", false)
|
|
23
|
-
.catch(() => { /* ignore */ });
|
|
24
|
-
}, 4000);
|
|
53
|
+
const placeholderText = params.placeholderContent?.trim() || "⏳ 正在思考中...\n\n";
|
|
54
|
+
let streamSettled = false;
|
|
55
|
+
let placeholderInFlight = false;
|
|
56
|
+
let placeholderKeepalive: ReturnType<typeof setInterval> | undefined;
|
|
25
57
|
|
|
26
|
-
const
|
|
58
|
+
const stopPlaceholderKeepalive = () => {
|
|
59
|
+
if (!placeholderKeepalive) return;
|
|
60
|
+
clearInterval(placeholderKeepalive);
|
|
61
|
+
placeholderKeepalive = undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const settleStream = () => {
|
|
65
|
+
streamSettled = true;
|
|
66
|
+
stopPlaceholderKeepalive();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const sendPlaceholder = () => {
|
|
70
|
+
if (streamSettled || placeholderInFlight) return;
|
|
71
|
+
placeholderInFlight = true;
|
|
72
|
+
params.client.replyStream(params.frame, resolveStreamId(), placeholderText, false)
|
|
73
|
+
.catch((error) => {
|
|
74
|
+
if (!isTerminalReplyError(error)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
settleStream();
|
|
78
|
+
params.onFail?.(error);
|
|
79
|
+
})
|
|
80
|
+
.finally(() => {
|
|
81
|
+
placeholderInFlight = false;
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (params.autoSendPlaceholder !== false) {
|
|
86
|
+
sendPlaceholder();
|
|
87
|
+
placeholderKeepalive = setInterval(() => {
|
|
88
|
+
sendPlaceholder();
|
|
89
|
+
}, PLACEHOLDER_KEEPALIVE_MS);
|
|
90
|
+
}
|
|
27
91
|
|
|
28
92
|
return {
|
|
29
93
|
context: {
|
|
@@ -40,23 +104,53 @@ export function createBotWsReplyHandle(params: {
|
|
|
40
104
|
},
|
|
41
105
|
deliver: async (payload: ReplyPayload, info) => {
|
|
42
106
|
if (payload.isReasoning) return;
|
|
43
|
-
|
|
107
|
+
|
|
44
108
|
const text = payload.text?.trim();
|
|
45
109
|
if (!text) return;
|
|
46
110
|
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
111
|
+
if (info.kind === "block") {
|
|
112
|
+
accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const outboundText =
|
|
116
|
+
info.kind === "final"
|
|
117
|
+
? accumulatedText
|
|
118
|
+
? text
|
|
119
|
+
? `${accumulatedText}\n${text}`
|
|
120
|
+
: accumulatedText
|
|
121
|
+
: text
|
|
122
|
+
: accumulatedText || text;
|
|
123
|
+
|
|
124
|
+
settleStream();
|
|
125
|
+
try {
|
|
126
|
+
await params.client.replyStream(
|
|
127
|
+
params.frame,
|
|
128
|
+
resolveStreamId(),
|
|
129
|
+
outboundText,
|
|
130
|
+
info.kind === "final",
|
|
131
|
+
);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (isTerminalReplyError(error)) {
|
|
134
|
+
params.onFail?.(error);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
50
138
|
}
|
|
51
|
-
|
|
52
|
-
await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
|
|
53
139
|
params.onDeliver?.();
|
|
54
140
|
},
|
|
55
141
|
fail: async (error: unknown) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
142
|
+
settleStream();
|
|
143
|
+
if (isTerminalReplyError(error)) {
|
|
144
|
+
params.onFail?.(error);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const message = formatErrorMessage(error);
|
|
148
|
+
try {
|
|
149
|
+
await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
|
|
150
|
+
} catch (sendError) {
|
|
151
|
+
params.onFail?.(sendError);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
60
154
|
params.onFail?.(error);
|
|
61
155
|
},
|
|
62
156
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const sdkMockState = vi.hoisted(() => {
|
|
4
|
+
class MockWSClient {
|
|
5
|
+
readonly handlers = new Map<string, Array<(payload: any) => void>>();
|
|
6
|
+
readonly isConnected = true;
|
|
7
|
+
readonly replyStream = vi.fn().mockResolvedValue(undefined);
|
|
8
|
+
|
|
9
|
+
constructor(_options: unknown) {
|
|
10
|
+
sdkMockState.client = this;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
on(event: string, handler: (payload: any) => void): void {
|
|
14
|
+
const current = this.handlers.get(event) ?? [];
|
|
15
|
+
current.push(handler);
|
|
16
|
+
this.handlers.set(event, current);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
emit(event: string, payload: any): void {
|
|
20
|
+
for (const handler of this.handlers.get(event) ?? []) {
|
|
21
|
+
handler(payload);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connect(): void {}
|
|
26
|
+
|
|
27
|
+
disconnect(): void {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
client: null as InstanceType<typeof MockWSClient> | null,
|
|
32
|
+
MockWSClient,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
vi.mock("@wecom/aibot-node-sdk", () => ({
|
|
37
|
+
default: {
|
|
38
|
+
WSClient: sdkMockState.MockWSClient,
|
|
39
|
+
},
|
|
40
|
+
WSClient: sdkMockState.MockWSClient,
|
|
41
|
+
generateReqId: (prefix: string) => `${prefix}-1`,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
import { BotWsSdkAdapter } from "./sdk-adapter.js";
|
|
45
|
+
|
|
46
|
+
const waitForAsyncCallbacks = async () => {
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("BotWsSdkAdapter", () => {
|
|
52
|
+
const unhandledRejections: unknown[] = [];
|
|
53
|
+
const onUnhandledRejection = (reason: unknown) => {
|
|
54
|
+
unhandledRejections.push(reason);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
59
|
+
unhandledRejections.length = 0;
|
|
60
|
+
sdkMockState.client = null;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("contains frame handler rejections instead of leaking unhandled rejections", async () => {
|
|
64
|
+
process.on("unhandledRejection", onUnhandledRejection);
|
|
65
|
+
|
|
66
|
+
const runtime = {
|
|
67
|
+
account: {
|
|
68
|
+
accountId: "acc-1",
|
|
69
|
+
bot: {
|
|
70
|
+
wsConfigured: true,
|
|
71
|
+
ws: {
|
|
72
|
+
botId: "bot-1",
|
|
73
|
+
secret: "secret-1",
|
|
74
|
+
},
|
|
75
|
+
config: {},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
handleEvent: vi.fn().mockRejectedValue(new Error("frame exploded")),
|
|
79
|
+
updateTransportSession: vi.fn(),
|
|
80
|
+
touchTransportSession: vi.fn(),
|
|
81
|
+
recordOperationalIssue: vi.fn(),
|
|
82
|
+
};
|
|
83
|
+
const log = {
|
|
84
|
+
info: vi.fn(),
|
|
85
|
+
warn: vi.fn(),
|
|
86
|
+
error: vi.fn(),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
new BotWsSdkAdapter(runtime as any, log as any).start();
|
|
90
|
+
|
|
91
|
+
sdkMockState.client?.emit("message", {
|
|
92
|
+
cmd: "aibot_msg_callback",
|
|
93
|
+
headers: { req_id: "req-1" },
|
|
94
|
+
body: {
|
|
95
|
+
msgid: "msg-1",
|
|
96
|
+
msgtype: "text",
|
|
97
|
+
from: { userid: "user-1" },
|
|
98
|
+
text: { content: "hello" },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await waitForAsyncCallbacks();
|
|
103
|
+
|
|
104
|
+
expect(runtime.handleEvent).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(runtime.recordOperationalIssue).toHaveBeenCalledWith(
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
transport: "bot-ws",
|
|
108
|
+
category: "runtime-error",
|
|
109
|
+
messageId: "msg-1",
|
|
110
|
+
error: "frame exploded",
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
expect(runtime.touchTransportSession).toHaveBeenCalledWith(
|
|
114
|
+
"bot-ws",
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
lastError: "frame exploded",
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
expect(log.error).toHaveBeenCalledWith(
|
|
120
|
+
expect.stringContaining("frame handler failed account=acc-1 reqId=req-1 message=frame exploded"),
|
|
121
|
+
);
|
|
122
|
+
expect(unhandledRejections).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import AiBot, { type BaseMessage, type EventMessage, type WsFrame } from "@wecom/aibot-node-sdk";
|
|
4
4
|
|
|
5
5
|
import type { RuntimeLogSink } from "../../types/index.js";
|
|
6
|
+
import { registerBotWsPushHandle, unregisterBotWsPushHandle } from "../../app/index.js";
|
|
6
7
|
import { mapBotWsFrameToInboundEvent } from "./inbound.js";
|
|
7
8
|
import { createBotWsReplyHandle } from "./reply.js";
|
|
8
9
|
import { createBotWsSessionSnapshot } from "./session.js";
|
|
@@ -38,6 +39,23 @@ export class BotWsSdkAdapter {
|
|
|
38
39
|
},
|
|
39
40
|
});
|
|
40
41
|
this.client = client;
|
|
42
|
+
registerBotWsPushHandle(this.runtime.account.accountId, {
|
|
43
|
+
isConnected: () => client.isConnected,
|
|
44
|
+
sendMarkdown: async (chatId, content) => {
|
|
45
|
+
await client.sendMessage(chatId, {
|
|
46
|
+
msgtype: "markdown",
|
|
47
|
+
markdown: { content },
|
|
48
|
+
});
|
|
49
|
+
this.runtime.touchTransportSession("bot-ws", {
|
|
50
|
+
ownerId: this.ownerId,
|
|
51
|
+
running: true,
|
|
52
|
+
connected: client.isConnected,
|
|
53
|
+
authenticated: client.isConnected,
|
|
54
|
+
lastOutboundAt: Date.now(),
|
|
55
|
+
lastError: undefined,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
41
59
|
|
|
42
60
|
client.on("connected", () => {
|
|
43
61
|
this.log.info?.(`[wecom-ws] connected account=${this.runtime.account.accountId}`);
|
|
@@ -131,6 +149,13 @@ export class BotWsSdkAdapter {
|
|
|
131
149
|
client,
|
|
132
150
|
frame,
|
|
133
151
|
accountId: this.runtime.account.accountId,
|
|
152
|
+
placeholderContent: botAccount.config.streamPlaceholderContent,
|
|
153
|
+
autoSendPlaceholder:
|
|
154
|
+
event.inboundKind === "text" ||
|
|
155
|
+
event.inboundKind === "image" ||
|
|
156
|
+
event.inboundKind === "file" ||
|
|
157
|
+
event.inboundKind === "voice" ||
|
|
158
|
+
event.inboundKind === "mixed",
|
|
134
159
|
onDeliver: () => {
|
|
135
160
|
this.runtime.touchTransportSession("bot-ws", {
|
|
136
161
|
ownerId: this.ownerId,
|
|
@@ -153,11 +178,41 @@ export class BotWsSdkAdapter {
|
|
|
153
178
|
await this.runtime.handleEvent(event, replyHandle);
|
|
154
179
|
};
|
|
155
180
|
|
|
181
|
+
const runHandleFrame = (frame: WsFrame<BaseMessage | EventMessage>) => {
|
|
182
|
+
void handleFrame(frame).catch((error) => {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
+
this.log.error?.(
|
|
185
|
+
`[wecom-ws] frame handler failed account=${this.runtime.account.accountId} reqId=${frame.headers?.req_id ?? "n/a"} message=${message}`,
|
|
186
|
+
);
|
|
187
|
+
this.runtime.recordOperationalIssue({
|
|
188
|
+
transport: "bot-ws",
|
|
189
|
+
category: "runtime-error",
|
|
190
|
+
messageId: frame.body?.msgid,
|
|
191
|
+
raw: {
|
|
192
|
+
transport: "bot-ws",
|
|
193
|
+
command: frame.cmd,
|
|
194
|
+
headers: frame.headers,
|
|
195
|
+
body: frame.body,
|
|
196
|
+
envelopeType: "ws",
|
|
197
|
+
},
|
|
198
|
+
summary: `bot-ws frame handler crashed reqId=${frame.headers?.req_id ?? "n/a"}`,
|
|
199
|
+
error: message,
|
|
200
|
+
});
|
|
201
|
+
this.runtime.touchTransportSession("bot-ws", {
|
|
202
|
+
ownerId: this.ownerId,
|
|
203
|
+
running: client.isConnected,
|
|
204
|
+
connected: client.isConnected,
|
|
205
|
+
authenticated: client.isConnected,
|
|
206
|
+
lastError: message,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
156
211
|
client.on("message", (frame) => {
|
|
157
|
-
|
|
212
|
+
runHandleFrame(frame);
|
|
158
213
|
});
|
|
159
214
|
client.on("event", (frame) => {
|
|
160
|
-
|
|
215
|
+
runHandleFrame(frame);
|
|
161
216
|
});
|
|
162
217
|
|
|
163
218
|
client.connect();
|
|
@@ -165,6 +220,7 @@ export class BotWsSdkAdapter {
|
|
|
165
220
|
|
|
166
221
|
stop(): void {
|
|
167
222
|
this.log.info?.(`[wecom-ws] stop account=${this.runtime.account.accountId}`);
|
|
223
|
+
unregisterBotWsPushHandle(this.runtime.account.accountId);
|
|
168
224
|
this.runtime.updateTransportSession(
|
|
169
225
|
createBotWsSessionSnapshot({
|
|
170
226
|
accountId: this.runtime.account.accountId,
|
package/CLAUDE.md
DELETED
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Project Overview
|
|
6
|
-
|
|
7
|
-
This is an **OpenClaw Channel Plugin** for WeCom (企业微信 / WeChat Work). It enables AI bot integration with enterprise WeChat through a dual-mode architecture.
|
|
8
|
-
|
|
9
|
-
- **Package**: `@yanhaidao/wecom`
|
|
10
|
-
- **Type**: ES Module (NodeNext)
|
|
11
|
-
- **Entry**: `index.ts`
|
|
12
|
-
|
|
13
|
-
## Architecture
|
|
14
|
-
|
|
15
|
-
### Dual-Mode Design (Bot + Agent)
|
|
16
|
-
|
|
17
|
-
The plugin implements a unique dual-mode architecture:
|
|
18
|
-
|
|
19
|
-
| Mode | Purpose | Webhook Path | Capabilities |
|
|
20
|
-
|------|---------|--------------|--------------|
|
|
21
|
-
| **Bot** (智能体) | Real-time streaming chat | `/wecom`, `/wecom/bot` | Streaming responses, low latency, text/image only |
|
|
22
|
-
| **Agent** (自建应用) | Fallback & broadcast | `/wecom/agent` | File sending, broadcasts, long tasks (>6min) |
|
|
23
|
-
|
|
24
|
-
**Key Design Principle**: Bot is preferred for conversations; Agent is used as fallback when Bot cannot deliver (files, timeouts) or for proactive broadcasts.
|
|
25
|
-
|
|
26
|
-
### Core Components
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
index.ts # Plugin entry - registers channel and HTTP handlers
|
|
30
|
-
src/
|
|
31
|
-
channel.ts # ChannelPlugin implementation, lifecycle management
|
|
32
|
-
monitor.ts # Core webhook handler, message flow, stream state
|
|
33
|
-
runtime.ts # Runtime state singleton
|
|
34
|
-
http.ts # HTTP client with undici + proxy support
|
|
35
|
-
crypto.ts # AES-CBC encryption/decryption for webhooks
|
|
36
|
-
media.ts # Media file download/decryption
|
|
37
|
-
outbound.ts # Outbound message adapter
|
|
38
|
-
target.ts # Target resolution (user/party/tag/chat)
|
|
39
|
-
dynamic-agent.ts # Dynamic agent routing (per-user/per-group isolation)
|
|
40
|
-
agent/
|
|
41
|
-
api-client.ts # WeCom API client with AccessToken caching
|
|
42
|
-
handler.ts # XML webhook handler for Agent mode
|
|
43
|
-
config/
|
|
44
|
-
schema.ts # Zod schemas for configuration
|
|
45
|
-
monitor/
|
|
46
|
-
state.ts # StreamStore and ActiveReplyStore with TTL pruning
|
|
47
|
-
types/constants.ts # API endpoints and limits
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### Stream State Management
|
|
51
|
-
|
|
52
|
-
The plugin uses a sophisticated stream state system (`src/monitor/state.ts`):
|
|
53
|
-
|
|
54
|
-
- **StreamStore**: Manages message streams with 6-minute timeout window
|
|
55
|
-
- **ActiveReplyStore**: Tracks `response_url` for proactive pushes
|
|
56
|
-
- **Pending Queue**: Debounces rapid messages (500ms default)
|
|
57
|
-
- **Message Deduplication**: Uses `msgid` to prevent duplicate processing
|
|
58
|
-
|
|
59
|
-
### Token Management
|
|
60
|
-
|
|
61
|
-
Agent mode uses automatic AccessToken caching (`src/agent/api-client.ts`):
|
|
62
|
-
- Token cached with 60-second refresh buffer
|
|
63
|
-
- Automatic retry on expiration
|
|
64
|
-
- Thread-safe refresh deduplication
|
|
65
|
-
|
|
66
|
-
## Development Commands
|
|
67
|
-
|
|
68
|
-
### Testing
|
|
69
|
-
|
|
70
|
-
This project uses **Vitest**. Tests extend from a base config at `../../vitest.config.ts`:
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
# Run all tests
|
|
74
|
-
npx vitest --config vitest.config.ts
|
|
75
|
-
|
|
76
|
-
# Run specific test file
|
|
77
|
-
npx vitest --config vitest.config.ts src/crypto.test.ts
|
|
78
|
-
|
|
79
|
-
# Run tests matching pattern
|
|
80
|
-
npx vitest --config vitest.config.ts --testNamePattern="should encrypt"
|
|
81
|
-
|
|
82
|
-
# Watch mode
|
|
83
|
-
npx vitest --config vitest.config.ts --watch
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Test files are located alongside source files with `.test.ts` suffix:
|
|
87
|
-
- `src/crypto.test.ts`
|
|
88
|
-
- `src/monitor.integration.test.ts`
|
|
89
|
-
- `src/monitor/state.queue.test.ts`
|
|
90
|
-
- etc.
|
|
91
|
-
|
|
92
|
-
### Type Checking
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
npx tsc --noEmit
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### Build
|
|
99
|
-
|
|
100
|
-
The plugin is loaded directly as TypeScript by OpenClaw. No build step is required for development, but type checking is recommended.
|
|
101
|
-
|
|
102
|
-
## Configuration Schema
|
|
103
|
-
|
|
104
|
-
Configuration is validated via Zod (`src/config/schema.ts`):
|
|
105
|
-
|
|
106
|
-
```typescript
|
|
107
|
-
{
|
|
108
|
-
enabled: boolean,
|
|
109
|
-
bot: {
|
|
110
|
-
token: string, // Bot webhook token
|
|
111
|
-
encodingAESKey: string, // AES encryption key
|
|
112
|
-
receiveId: string?, // Optional receive ID
|
|
113
|
-
streamPlaceholderContent: string?, // "Thinking..."
|
|
114
|
-
welcomeText: string?,
|
|
115
|
-
dm: { policy, allowFrom }
|
|
116
|
-
},
|
|
117
|
-
agent: {
|
|
118
|
-
corpId: string,
|
|
119
|
-
corpSecret: string,
|
|
120
|
-
agentId: number,
|
|
121
|
-
token: string, // Callback token
|
|
122
|
-
encodingAESKey: string, // Callback AES key
|
|
123
|
-
welcomeText: string?,
|
|
124
|
-
dm: { policy, allowFrom }
|
|
125
|
-
},
|
|
126
|
-
network: {
|
|
127
|
-
egressProxyUrl: string? // For dynamic IP scenarios
|
|
128
|
-
},
|
|
129
|
-
media: {
|
|
130
|
-
maxBytes: number? // Default 25MB
|
|
131
|
-
},
|
|
132
|
-
dynamicAgents: {
|
|
133
|
-
enabled: boolean? // Enable per-user/per-group agents
|
|
134
|
-
dmCreateAgent: boolean? // Create agent per DM user
|
|
135
|
-
groupEnabled: boolean? // Enable for group chats
|
|
136
|
-
adminUsers: string[]? // Admin users (bypass dynamic routing)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
### Dynamic Agent Routing
|
|
142
|
-
|
|
143
|
-
When `dynamicAgents.enabled` is `true`, the plugin automatically creates isolated Agent instances for each user/group:
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
# Enable dynamic agents
|
|
147
|
-
openclaw config set channels.wecom.dynamicAgents.enabled true
|
|
148
|
-
|
|
149
|
-
# Configure admin users (use main agent)
|
|
150
|
-
openclaw config set channels.wecom.dynamicAgents.adminUsers '["admin1","admin2"]'
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
**Generated Agent ID format**: `wecom-{type}-{peerId}`
|
|
154
|
-
- DM: `wecom-dm-zhangsan`
|
|
155
|
-
- Group: `wecom-group-wr123456`
|
|
156
|
-
|
|
157
|
-
Dynamic agents are automatically added to `agents.list` in the config file.
|
|
158
|
-
|
|
159
|
-
## Key Technical Details
|
|
160
|
-
|
|
161
|
-
### Webhook Security
|
|
162
|
-
|
|
163
|
-
- **Signature Verification**: HMAC-SHA256 with token
|
|
164
|
-
- **Encryption**: AES-CBC with PKCS#7 padding (32-byte blocks)
|
|
165
|
-
- **Paths**: `/wecom` (legacy), `/wecom/bot`, `/wecom/agent`
|
|
166
|
-
|
|
167
|
-
### Timeout Handling
|
|
168
|
-
|
|
169
|
-
Bot mode has a 6-minute window (360s) for streaming responses. The plugin:
|
|
170
|
-
- Tracks deadline: `createdAt + 6 * 60 * 1000`
|
|
171
|
-
- Switches to Agent fallback at `deadline - 30s` margin
|
|
172
|
-
- Sends DM via Agent for remaining content
|
|
173
|
-
|
|
174
|
-
### Media Handling
|
|
175
|
-
|
|
176
|
-
- **Inbound**: Decrypts WeCom encrypted media URLs
|
|
177
|
-
- **Outbound Images**: Base64 encoded via `msg_item` in stream
|
|
178
|
-
- **Outbound Files**: Requires Agent mode, sent via `media/upload` + `message/send`
|
|
179
|
-
- **Max Size**: 25MB default (configurable via `channels.wecom.media.maxBytes`)
|
|
180
|
-
|
|
181
|
-
### Proxy Support
|
|
182
|
-
|
|
183
|
-
For servers with dynamic IPs (common error: `60020 not allow to access from your ip`):
|
|
184
|
-
|
|
185
|
-
```bash
|
|
186
|
-
openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
## Testing Notes
|
|
190
|
-
|
|
191
|
-
- Tests use Vitest with `../../vitest.config.ts` as base
|
|
192
|
-
- Integration tests mock WeCom API responses
|
|
193
|
-
- Crypto tests verify AES encryption round-trips
|
|
194
|
-
- Monitor tests cover stream state transitions and queue behavior
|
|
195
|
-
|
|
196
|
-
## Common Patterns
|
|
197
|
-
|
|
198
|
-
### Adding a New Message Type Handler
|
|
199
|
-
|
|
200
|
-
1. Update `buildInboundBody()` in `src/monitor.ts` to parse the message
|
|
201
|
-
2. Add type definitions in `src/types/message.ts`
|
|
202
|
-
3. Update `processInboundMessage()` if media handling is needed
|
|
203
|
-
|
|
204
|
-
### Agent API Calls
|
|
205
|
-
|
|
206
|
-
Always use `api-client.ts` methods which handle token management:
|
|
207
|
-
|
|
208
|
-
```typescript
|
|
209
|
-
import { sendText, uploadMedia } from "./agent/api-client.js";
|
|
210
|
-
|
|
211
|
-
// Token is automatically cached and refreshed
|
|
212
|
-
await sendText({ agent, toUser: "userid", text: "Hello" });
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
### Stream Content Updates
|
|
216
|
-
|
|
217
|
-
Use `streamStore.updateStream()` for thread-safe updates:
|
|
218
|
-
|
|
219
|
-
```typescript
|
|
220
|
-
streamStore.updateStream(streamId, (state) => {
|
|
221
|
-
state.content = "new content";
|
|
222
|
-
state.finished = true;
|
|
223
|
-
});
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
## Dependencies
|
|
227
|
-
|
|
228
|
-
- `undici`: HTTP client with proxy support
|
|
229
|
-
- `fast-xml-parser`: XML parsing for Agent callbacks
|
|
230
|
-
- `zod`: Configuration validation
|
|
231
|
-
- `openclaw`: Peer dependency (>=2026.2.24)
|
|
232
|
-
|
|
233
|
-
## WeCom API Endpoints Used
|
|
234
|
-
|
|
235
|
-
- `GET_TOKEN`: `https://qyapi.weixin.qq.com/cgi-bin/gettoken`
|
|
236
|
-
- `SEND_MESSAGE`: `https://qyapi.weixin.qq.com/cgi-bin/message/send`
|
|
237
|
-
- `UPLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/upload`
|
|
238
|
-
- `DOWNLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/get`
|