@yanhaidao/wecom 2.3.180 → 2.3.260
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/.github/workflows/release.yml +23 -4
- package/README.md +87 -2
- package/SKILLS_DOC.md +272 -120
- package/changelog/v2.3.19.md +73 -0
- package/changelog/v2.3.26.md +21 -0
- package/package.json +2 -2
- package/src/agent/handler.ts +5 -3
- package/src/app/account-runtime.ts +5 -1
- package/src/app/index.ts +117 -0
- package/src/capability/bot/stream-orchestrator.ts +1 -1
- package/src/capability/doc/client.ts +228 -9
- package/src/capability/doc/tool.ts +14 -7
- package/src/channel.ts +1 -1
- package/src/config/index.ts +7 -1
- package/src/config/media.test.ts +113 -0
- package/src/config/media.ts +130 -5
- package/src/config/schema.ts +3 -0
- package/src/context-store.ts +264 -0
- package/src/onboarding.ts +1 -1
- package/src/outbound.test.ts +565 -5
- package/src/outbound.ts +94 -7
- package/src/runtime/dispatcher.ts +24 -5
- package/src/runtime/routing-bridge.test.ts +115 -0
- package/src/runtime/routing-bridge.ts +26 -1
- package/src/runtime/session-manager.test.ts +135 -0
- package/src/runtime/session-manager.ts +40 -8
- package/src/runtime/source-registry.ts +79 -0
- package/src/runtime.ts +3 -0
- package/src/target.ts +20 -8
- package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
- package/src/transport/bot-ws/media.test.ts +44 -0
- package/src/transport/bot-ws/media.ts +7 -4
- package/src/transport/bot-ws/reply.test.ts +131 -1
- package/src/transport/bot-ws/reply.ts +15 -3
- package/src/transport/bot-ws/sdk-adapter.ts +2 -1
- package/src/transport/http/registry.ts +1 -1
- package/src/types/config.ts +3 -0
- package/src/types/runtime.ts +1 -0
- package/src/wecom_msg_adapter/markdown_adapter.ts +331 -0
|
@@ -7,14 +7,19 @@ export type WecomSourceSnapshot = {
|
|
|
7
7
|
messageId?: string;
|
|
8
8
|
sessionKey?: string;
|
|
9
9
|
sessionId?: string;
|
|
10
|
+
peerKind?: "direct" | "group";
|
|
11
|
+
peerId?: string;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
const MAX_MESSAGE_FACTS = 2048;
|
|
13
15
|
const MAX_SESSION_SNAPSHOTS = 1024;
|
|
16
|
+
const MAX_CONVERSATION_SNAPSHOTS = 1024;
|
|
14
17
|
|
|
15
18
|
const messageFacts = new Map<string, WecomSourceSnapshot>();
|
|
16
19
|
const sessionSnapshotsByAccountKey = new Map<string, WecomSourceSnapshot>();
|
|
17
20
|
const sessionSnapshotsByLooseKey = new Map<string, WecomSourceSnapshot>();
|
|
21
|
+
const conversationSnapshotsByAccountKey = new Map<string, WecomSourceSnapshot>();
|
|
22
|
+
const conversationSnapshotsByLooseKey = new Map<string, WecomSourceSnapshot>();
|
|
18
23
|
|
|
19
24
|
function normalizeOptional(value: string | null | undefined): string | undefined {
|
|
20
25
|
const trimmed = String(value ?? "").trim();
|
|
@@ -33,6 +38,24 @@ function accountScopedSessionKey(
|
|
|
33
38
|
return `${accountId}::${kind}::${value}`;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
function normalizePeerId(value: string | null | undefined): string | undefined {
|
|
42
|
+
const trimmed = String(value ?? "").trim();
|
|
43
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizePeerKind(value: string | null | undefined): "direct" | "group" | undefined {
|
|
47
|
+
const trimmed = String(value ?? "").trim().toLowerCase();
|
|
48
|
+
return trimmed === "direct" || trimmed === "group" ? trimmed : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function accountScopedConversationKey(
|
|
52
|
+
accountId: string,
|
|
53
|
+
peerKind: "direct" | "group",
|
|
54
|
+
peerId: string,
|
|
55
|
+
): string {
|
|
56
|
+
return `${accountId}::peer::${peerKind}::${peerId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
function pruneOldest<T>(map: Map<string, T>, maxSize: number): void {
|
|
37
60
|
while (map.size > maxSize) {
|
|
38
61
|
const oldestKey = map.keys().next().value;
|
|
@@ -62,12 +85,37 @@ function writeSessionSnapshot(snapshot: WecomSourceSnapshot): void {
|
|
|
62
85
|
pruneOldest(sessionSnapshotsByLooseKey, MAX_SESSION_SNAPSHOTS * 2);
|
|
63
86
|
}
|
|
64
87
|
|
|
88
|
+
function writeConversationSnapshot(snapshot: WecomSourceSnapshot): void {
|
|
89
|
+
const peerKind = normalizePeerKind(snapshot.peerKind);
|
|
90
|
+
const peerId = normalizePeerId(snapshot.peerId);
|
|
91
|
+
if (!peerKind || !peerId) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
conversationSnapshotsByAccountKey.set(
|
|
95
|
+
accountScopedConversationKey(snapshot.accountId, peerKind, peerId),
|
|
96
|
+
{
|
|
97
|
+
...snapshot,
|
|
98
|
+
peerKind,
|
|
99
|
+
peerId,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
conversationSnapshotsByLooseKey.set(`peer::${peerKind}::${peerId}`, {
|
|
103
|
+
...snapshot,
|
|
104
|
+
peerKind,
|
|
105
|
+
peerId,
|
|
106
|
+
});
|
|
107
|
+
pruneOldest(conversationSnapshotsByAccountKey, MAX_CONVERSATION_SNAPSHOTS);
|
|
108
|
+
pruneOldest(conversationSnapshotsByLooseKey, MAX_CONVERSATION_SNAPSHOTS);
|
|
109
|
+
}
|
|
110
|
+
|
|
65
111
|
export function registerWecomSourceSnapshot(params: {
|
|
66
112
|
accountId: string;
|
|
67
113
|
source: WecomSourcePlane;
|
|
68
114
|
messageId?: string | null;
|
|
69
115
|
sessionKey?: string | null;
|
|
70
116
|
sessionId?: string | null;
|
|
117
|
+
peerKind?: "direct" | "group" | null;
|
|
118
|
+
peerId?: string | null;
|
|
71
119
|
}): void {
|
|
72
120
|
const accountId = normalizeOptional(params.accountId);
|
|
73
121
|
if (!accountId) return;
|
|
@@ -85,6 +133,8 @@ export function registerWecomSourceSnapshot(params: {
|
|
|
85
133
|
...(normalizeOptional(params.sessionId)
|
|
86
134
|
? { sessionId: normalizeOptional(params.sessionId) }
|
|
87
135
|
: {}),
|
|
136
|
+
...(normalizePeerKind(params.peerKind) ? { peerKind: normalizePeerKind(params.peerKind) } : {}),
|
|
137
|
+
...(normalizePeerId(params.peerId) ? { peerId: normalizePeerId(params.peerId) } : {}),
|
|
88
138
|
};
|
|
89
139
|
|
|
90
140
|
if (snapshot.messageId) {
|
|
@@ -93,16 +143,21 @@ export function registerWecomSourceSnapshot(params: {
|
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
writeSessionSnapshot(snapshot);
|
|
146
|
+
writeConversationSnapshot(snapshot);
|
|
96
147
|
}
|
|
97
148
|
|
|
98
149
|
export function resolveWecomSourceSnapshot(params: {
|
|
99
150
|
accountId?: string | null;
|
|
100
151
|
sessionKey?: string | null;
|
|
101
152
|
sessionId?: string | null;
|
|
153
|
+
peerKind?: "direct" | "group" | null;
|
|
154
|
+
peerId?: string | null;
|
|
102
155
|
}): WecomSourceSnapshot | undefined {
|
|
103
156
|
const accountId = normalizeOptional(params.accountId);
|
|
104
157
|
const sessionKey = normalizeOptional(params.sessionKey);
|
|
105
158
|
const sessionId = normalizeOptional(params.sessionId);
|
|
159
|
+
const peerKind = normalizePeerKind(params.peerKind);
|
|
160
|
+
const peerId = normalizePeerId(params.peerId);
|
|
106
161
|
|
|
107
162
|
if (accountId && sessionKey) {
|
|
108
163
|
const scoped = sessionSnapshotsByAccountKey.get(
|
|
@@ -124,6 +179,16 @@ export function resolveWecomSourceSnapshot(params: {
|
|
|
124
179
|
const loose = sessionSnapshotsByLooseKey.get(`sessionId::${sessionId}`);
|
|
125
180
|
if (loose) return loose;
|
|
126
181
|
}
|
|
182
|
+
if (accountId && peerKind && peerId) {
|
|
183
|
+
const scoped = conversationSnapshotsByAccountKey.get(
|
|
184
|
+
accountScopedConversationKey(accountId, peerKind, peerId),
|
|
185
|
+
);
|
|
186
|
+
if (scoped) return scoped;
|
|
187
|
+
}
|
|
188
|
+
if (peerKind && peerId) {
|
|
189
|
+
const loose = conversationSnapshotsByLooseKey.get(`peer::${peerKind}::${peerId}`);
|
|
190
|
+
if (loose) return loose;
|
|
191
|
+
}
|
|
127
192
|
return undefined;
|
|
128
193
|
}
|
|
129
194
|
|
|
@@ -146,12 +211,24 @@ export function clearWecomSourceAccount(accountId: string): void {
|
|
|
146
211
|
sessionSnapshotsByLooseKey.delete(key);
|
|
147
212
|
}
|
|
148
213
|
}
|
|
214
|
+
for (const [key, value] of conversationSnapshotsByAccountKey) {
|
|
215
|
+
if (value.accountId === normalized || key.startsWith(`${normalized}::`)) {
|
|
216
|
+
conversationSnapshotsByAccountKey.delete(key);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [key, value] of conversationSnapshotsByLooseKey) {
|
|
220
|
+
if (value.accountId === normalized) {
|
|
221
|
+
conversationSnapshotsByLooseKey.delete(key);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
149
224
|
}
|
|
150
225
|
|
|
151
226
|
export function isWecomBotWsSource(params: {
|
|
152
227
|
accountId?: string | null;
|
|
153
228
|
sessionKey?: string | null;
|
|
154
229
|
sessionId?: string | null;
|
|
230
|
+
peerKind?: "direct" | "group" | null;
|
|
231
|
+
peerId?: string | null;
|
|
155
232
|
}): boolean {
|
|
156
233
|
return resolveWecomSourceSnapshot(params)?.source === "bot-ws";
|
|
157
234
|
}
|
|
@@ -160,6 +237,8 @@ export function isWecomAgentSource(params: {
|
|
|
160
237
|
accountId?: string | null;
|
|
161
238
|
sessionKey?: string | null;
|
|
162
239
|
sessionId?: string | null;
|
|
240
|
+
peerKind?: "direct" | "group" | null;
|
|
241
|
+
peerId?: string | null;
|
|
163
242
|
}): boolean {
|
|
164
243
|
return resolveWecomSourceSnapshot(params)?.source === "agent-callback";
|
|
165
244
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export {
|
|
2
|
+
getActiveBotWsReplyHandle,
|
|
2
3
|
getAccountRuntime,
|
|
3
4
|
getAccountRuntimeSnapshot,
|
|
4
5
|
getBotWsPushHandle,
|
|
5
6
|
getWecomRuntime,
|
|
7
|
+
registerActiveBotWsReplyHandle,
|
|
6
8
|
registerAccountRuntime,
|
|
7
9
|
registerBotWsPushHandle,
|
|
8
10
|
setWecomRuntime,
|
|
11
|
+
unregisterActiveBotWsReplyHandle,
|
|
9
12
|
unregisterBotWsPushHandle,
|
|
10
13
|
unregisterAccountRuntime,
|
|
11
14
|
} from "./app/index.js";
|
package/src/target.ts
CHANGED
|
@@ -26,6 +26,18 @@ export interface ScopedWecomTarget {
|
|
|
26
26
|
rawTarget: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export function buildWecomContextTarget(contextToken: string): string {
|
|
30
|
+
return `wecom:context:${contextToken}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveWecomContextTarget(raw: string | undefined): { contextToken: string } | undefined {
|
|
34
|
+
const trimmed = raw?.trim();
|
|
35
|
+
if (!trimmed) return undefined;
|
|
36
|
+
const match = trimmed.match(/^(?:wecom|wechatwork|wework|qywx):context:(.+)$/i);
|
|
37
|
+
const contextToken = match?.[1]?.trim();
|
|
38
|
+
return contextToken ? { contextToken } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Parses a raw target string into a WeComTarget object.
|
|
31
43
|
* 解析原始目标字符串为 WeComTarget 对象。
|
|
@@ -86,16 +98,16 @@ export function resolveWecomTarget(raw: string | undefined, options?: { preferUs
|
|
|
86
98
|
return { chatid: clean };
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
// Pure digits: Default to
|
|
90
|
-
// 原因:1)
|
|
91
|
-
// 2)
|
|
92
|
-
// 3)
|
|
93
|
-
//
|
|
101
|
+
// Pure digits: Default to User (纯数字默认为用户)
|
|
102
|
+
// 原因:1) Bot WS 主动推送只接受 touser/chatid,不接受 toparty/totag
|
|
103
|
+
// 2) 用户 ID 在企业微信中常为纯数字
|
|
104
|
+
// 3) 部门推送应使用显式前缀 "party:xxx" 或通过 Agent 模式
|
|
105
|
+
// 如果确实需要发送到部门,请使用 party: 前缀或 Agent 路径
|
|
94
106
|
if (/^\d+$/.test(clean)) {
|
|
95
|
-
if (options?.preferUserForDigits) {
|
|
96
|
-
return {
|
|
107
|
+
if (options?.preferUserForDigits === false) {
|
|
108
|
+
return { toparty: clean };
|
|
97
109
|
}
|
|
98
|
-
return {
|
|
110
|
+
return { touser: clean };
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
// Default to User (默认为用户)
|
|
@@ -257,7 +257,7 @@ export async function processBotInboundMessage(params: {
|
|
|
257
257
|
const { target, msg, recordOperationalIssue } = params;
|
|
258
258
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
259
259
|
const aesKey = target.account.encodingAESKey;
|
|
260
|
-
const maxBytes = resolveWecomMediaMaxBytes(target.config);
|
|
260
|
+
const maxBytes = resolveWecomMediaMaxBytes(target.config, target.account.accountId);
|
|
261
261
|
const proxyUrl = resolveWecomEgressProxyUrl(target.config);
|
|
262
262
|
|
|
263
263
|
if (msgtype === "image") {
|
|
@@ -275,7 +275,7 @@ export async function processBotInboundMessage(params: {
|
|
|
275
275
|
});
|
|
276
276
|
return { body: "[image]", media: { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename } };
|
|
277
277
|
} catch (err) {
|
|
278
|
-
target.runtime.error?.(`图片解密失败: ${String(err)}; 可调大 channels.wecom.
|
|
278
|
+
target.runtime.error?.(`图片解密失败: ${String(err)}; 可调大 channels.wecom.mediaMaxMb(当前=${Math.round(maxBytes / (1024 * 1024))}MB)例如:openclaw config set channels.wecom.mediaMaxMb 50`);
|
|
279
279
|
recordOperationalIssue({
|
|
280
280
|
category: "media-decrypt-failed",
|
|
281
281
|
messageId: msg.msgid ? String(msg.msgid) : undefined,
|
|
@@ -304,7 +304,7 @@ export async function processBotInboundMessage(params: {
|
|
|
304
304
|
});
|
|
305
305
|
return { body: "[file]", media: { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename } };
|
|
306
306
|
} catch (err) {
|
|
307
|
-
target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.
|
|
307
|
+
target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.mediaMaxMb(当前=${Math.round(maxBytes / (1024 * 1024))}MB)例如:openclaw config set channels.wecom.mediaMaxMb 50`);
|
|
308
308
|
recordOperationalIssue({
|
|
309
309
|
category: "media-decrypt-failed",
|
|
310
310
|
messageId: msg.msgid ? String(msg.msgid) : undefined,
|
|
@@ -347,7 +347,7 @@ export async function processBotInboundMessage(params: {
|
|
|
347
347
|
bodyParts.push(`[${t}]`);
|
|
348
348
|
continue;
|
|
349
349
|
} catch (err) {
|
|
350
|
-
target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.
|
|
350
|
+
target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.mediaMaxMb(当前=${Math.round(maxBytes / (1024 * 1024))}MB)例如:openclaw config set channels.wecom.mediaMaxMb 50`);
|
|
351
351
|
recordOperationalIssue({
|
|
352
352
|
category: "media-decrypt-failed",
|
|
353
353
|
messageId: msg.msgid ? String(msg.msgid) : undefined,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WSClient } from "@wecom/aibot-node-sdk";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/media-runtime";
|
|
4
|
+
|
|
5
|
+
import { uploadAndSendBotWsMedia } from "./media.js";
|
|
6
|
+
|
|
7
|
+
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
|
8
|
+
detectMime: vi.fn(),
|
|
9
|
+
loadOutboundMediaFromUrl: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("uploadAndSendBotWsMedia", () => {
|
|
13
|
+
const loadOutboundMediaFromUrlMock = vi.mocked(loadOutboundMediaFromUrl);
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
loadOutboundMediaFromUrlMock.mockReset();
|
|
17
|
+
loadOutboundMediaFromUrlMock.mockResolvedValue({
|
|
18
|
+
buffer: Buffer.from("png"),
|
|
19
|
+
contentType: "image/png",
|
|
20
|
+
fileName: "sample.png",
|
|
21
|
+
} as never);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("passes the configured maxBytes to outbound media loading", async () => {
|
|
25
|
+
const wsClient = {
|
|
26
|
+
uploadMedia: vi.fn().mockResolvedValue({ media_id: "media-1" }),
|
|
27
|
+
sendMediaMessage: vi.fn().mockResolvedValue({ headers: { req_id: "req-1" } }),
|
|
28
|
+
} as unknown as WSClient;
|
|
29
|
+
|
|
30
|
+
await uploadAndSendBotWsMedia({
|
|
31
|
+
wsClient,
|
|
32
|
+
chatId: "hidao",
|
|
33
|
+
mediaUrl: "https://example.com/sample.png",
|
|
34
|
+
maxBytes: 42 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith(
|
|
38
|
+
"https://example.com/sample.png",
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
maxBytes: 42 * 1024 * 1024,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { WeComMediaType, WsFrameHeaders, WSClient } from "@wecom/aibot-node-sdk";
|
|
2
|
-
import { detectMime, loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
|
|
2
|
+
import { detectMime, loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/media-runtime";
|
|
3
3
|
|
|
4
4
|
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
5
5
|
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -158,9 +158,10 @@ function applyFileSizeLimits(
|
|
|
158
158
|
async function resolveMediaFile(
|
|
159
159
|
mediaUrl: string,
|
|
160
160
|
mediaLocalRoots?: readonly string[],
|
|
161
|
+
maxBytes?: number,
|
|
161
162
|
): Promise<ResolvedMediaFile> {
|
|
162
163
|
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
163
|
-
maxBytes: FILE_MAX_BYTES,
|
|
164
|
+
maxBytes: maxBytes ?? FILE_MAX_BYTES,
|
|
164
165
|
mediaLocalRoots,
|
|
165
166
|
});
|
|
166
167
|
let contentType = result.contentType || "application/octet-stream";
|
|
@@ -185,9 +186,10 @@ export async function uploadAndSendBotWsMedia(params: {
|
|
|
185
186
|
mediaUrl: string;
|
|
186
187
|
chatId: string;
|
|
187
188
|
mediaLocalRoots?: readonly string[];
|
|
189
|
+
maxBytes?: number;
|
|
188
190
|
}): Promise<BotWsMediaSendResult> {
|
|
189
191
|
try {
|
|
190
|
-
const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots);
|
|
192
|
+
const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots, params.maxBytes);
|
|
191
193
|
const detectedType = detectWeComMediaType(media.contentType);
|
|
192
194
|
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
193
195
|
if (sizeCheck.shouldReject) {
|
|
@@ -229,9 +231,10 @@ export async function uploadAndReplyBotWsMedia(params: {
|
|
|
229
231
|
frame: WsFrameHeaders;
|
|
230
232
|
mediaUrl: string;
|
|
231
233
|
mediaLocalRoots?: readonly string[];
|
|
234
|
+
maxBytes?: number;
|
|
232
235
|
}): Promise<BotWsMediaSendResult> {
|
|
233
236
|
try {
|
|
234
|
-
const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots);
|
|
237
|
+
const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots, params.maxBytes);
|
|
235
238
|
const detectedType = detectWeComMediaType(media.contentType);
|
|
236
239
|
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
237
240
|
if (sizeCheck.shouldReject) {
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import type { WSClient } from "@wecom/aibot-node-sdk";
|
|
2
4
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
|
|
6
|
+
import { uploadAndSendBotWsMedia } from "./media.js";
|
|
3
7
|
import { createBotWsReplyHandle } from "./reply.js";
|
|
4
8
|
|
|
9
|
+
vi.mock("./media.js", () => ({
|
|
10
|
+
uploadAndSendBotWsMedia: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
5
13
|
type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
|
|
6
14
|
|
|
7
15
|
describe("createBotWsReplyHandle", () => {
|
|
8
16
|
let mockClient: import("vitest").Mocked<WSClient>;
|
|
17
|
+
const uploadAndSendBotWsMediaMock = vi.mocked(uploadAndSendBotWsMedia);
|
|
9
18
|
|
|
10
|
-
beforeEach(() => {
|
|
19
|
+
beforeEach(async () => {
|
|
11
20
|
vi.useFakeTimers();
|
|
21
|
+
vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/wecom-reply-state");
|
|
12
22
|
mockClient = {
|
|
13
23
|
replyStream: vi.fn(),
|
|
14
24
|
sendMessage: vi.fn(),
|
|
@@ -17,12 +27,25 @@ describe("createBotWsReplyHandle", () => {
|
|
|
17
27
|
mockClient.replyStream.mockResolvedValue({} as any);
|
|
18
28
|
mockClient.sendMessage.mockResolvedValue({} as any);
|
|
19
29
|
mockClient.replyWelcome.mockResolvedValue({} as any);
|
|
30
|
+
uploadAndSendBotWsMediaMock.mockReset();
|
|
31
|
+
uploadAndSendBotWsMediaMock.mockResolvedValue({ ok: true, messageId: "media-1" } as any);
|
|
32
|
+
const runtime = await import("../../runtime.js");
|
|
33
|
+
runtime.setWecomRuntime({
|
|
34
|
+
config: {
|
|
35
|
+
loadConfig: () => ({
|
|
36
|
+
channels: {
|
|
37
|
+
wecom: {},
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
} as any);
|
|
20
42
|
});
|
|
21
43
|
|
|
22
44
|
afterEach(() => {
|
|
23
45
|
vi.clearAllTimers();
|
|
24
46
|
vi.useRealTimers();
|
|
25
47
|
vi.restoreAllMocks();
|
|
48
|
+
vi.unstubAllEnvs();
|
|
26
49
|
});
|
|
27
50
|
|
|
28
51
|
it("uses configured placeholder content for immediate ws ack", async () => {
|
|
@@ -176,6 +199,113 @@ describe("createBotWsReplyHandle", () => {
|
|
|
176
199
|
);
|
|
177
200
|
});
|
|
178
201
|
|
|
202
|
+
it("includes default global media local roots for final media sends", async () => {
|
|
203
|
+
const runtime = await import("../../runtime.js");
|
|
204
|
+
runtime.setWecomRuntime({
|
|
205
|
+
config: {
|
|
206
|
+
loadConfig: () => ({}),
|
|
207
|
+
},
|
|
208
|
+
} as any);
|
|
209
|
+
|
|
210
|
+
const handle = createBotWsReplyHandle({
|
|
211
|
+
client: mockClient,
|
|
212
|
+
frame: {
|
|
213
|
+
headers: { req_id: "req-final-media-roots" },
|
|
214
|
+
body: {
|
|
215
|
+
from: { userid: "hidao" },
|
|
216
|
+
chattype: "single",
|
|
217
|
+
},
|
|
218
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
219
|
+
accountId: "default",
|
|
220
|
+
inboundKind: "text",
|
|
221
|
+
autoSendPlaceholder: false,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await handle.deliver(
|
|
225
|
+
{
|
|
226
|
+
mediaUrls: ["/Users/YanHaidao/Downloads/01.png"],
|
|
227
|
+
isReasoning: false,
|
|
228
|
+
},
|
|
229
|
+
{ kind: "final" },
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(uploadAndSendBotWsMediaMock).toHaveBeenCalledWith(
|
|
233
|
+
expect.objectContaining({
|
|
234
|
+
chatId: "hidao",
|
|
235
|
+
maxBytes: 80 * 1024 * 1024,
|
|
236
|
+
mediaUrl: "/Users/YanHaidao/Downloads/01.png",
|
|
237
|
+
mediaLocalRoots: expect.arrayContaining([
|
|
238
|
+
path.resolve(resolvePreferredOpenClawTmpDir()),
|
|
239
|
+
"/tmp/wecom-reply-state",
|
|
240
|
+
"/tmp/wecom-reply-state/media",
|
|
241
|
+
path.resolve(os.homedir(), "Desktop"),
|
|
242
|
+
path.resolve(os.homedir(), "Documents"),
|
|
243
|
+
path.resolve(os.homedir(), "Downloads"),
|
|
244
|
+
]),
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
expect(mockClient.replyStream).toHaveBeenCalledWith(
|
|
248
|
+
expect.objectContaining({ headers: { req_id: "req-final-media-roots" } }),
|
|
249
|
+
expect.any(String),
|
|
250
|
+
"文件已发送。",
|
|
251
|
+
true,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("passes configured mediaMaxMb to final media sends", async () => {
|
|
256
|
+
const runtime = await import("../../runtime.js");
|
|
257
|
+
runtime.setWecomRuntime({
|
|
258
|
+
config: {
|
|
259
|
+
loadConfig: () => ({
|
|
260
|
+
agents: {
|
|
261
|
+
defaults: {
|
|
262
|
+
mediaMaxMb: 12,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
channels: {
|
|
266
|
+
wecom: {
|
|
267
|
+
mediaMaxMb: 24,
|
|
268
|
+
accounts: {
|
|
269
|
+
default: {
|
|
270
|
+
mediaMaxMb: 40,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
},
|
|
277
|
+
} as any);
|
|
278
|
+
|
|
279
|
+
const handle = createBotWsReplyHandle({
|
|
280
|
+
client: mockClient,
|
|
281
|
+
frame: {
|
|
282
|
+
headers: { req_id: "req-final-media-max-bytes" },
|
|
283
|
+
body: {
|
|
284
|
+
from: { userid: "hidao" },
|
|
285
|
+
chattype: "single",
|
|
286
|
+
},
|
|
287
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
288
|
+
accountId: "default",
|
|
289
|
+
inboundKind: "text",
|
|
290
|
+
autoSendPlaceholder: false,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await handle.deliver(
|
|
294
|
+
{
|
|
295
|
+
mediaUrls: ["/Users/YanHaidao/Downloads/01.png"],
|
|
296
|
+
isReasoning: false,
|
|
297
|
+
},
|
|
298
|
+
{ kind: "final" },
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
expect(uploadAndSendBotWsMediaMock).toHaveBeenCalledWith(
|
|
302
|
+
expect.objectContaining({
|
|
303
|
+
chatId: "hidao",
|
|
304
|
+
maxBytes: 40 * 1024 * 1024,
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
179
309
|
it("stops placeholder keepalive when the first block contains media", async () => {
|
|
180
310
|
const handle = createBotWsReplyHandle({
|
|
181
311
|
client: mockClient,
|
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
type EventMessage,
|
|
6
6
|
type WSClient,
|
|
7
7
|
} from "@wecom/aibot-node-sdk";
|
|
8
|
-
import { formatErrorMessage } from "openclaw/plugin-sdk";
|
|
8
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
|
|
9
|
+
import { resolveWecomMediaMaxBytes, resolveWecomMergedMediaLocalRoots } from "../../config/index.js";
|
|
10
|
+
import { getWecomRuntime } from "../../runtime.js";
|
|
9
11
|
import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
|
|
12
|
+
import { toWeComMarkdownV2 } from "../../wecom_msg_adapter/markdown_adapter.js";
|
|
10
13
|
import { uploadAndSendBotWsMedia } from "./media.js";
|
|
11
14
|
|
|
12
15
|
const PLACEHOLDER_KEEPALIVE_MS = 3000;
|
|
@@ -246,6 +249,9 @@ export function createBotWsReplyHandle(params: {
|
|
|
246
249
|
|
|
247
250
|
let finalText = outboundText;
|
|
248
251
|
if (info.kind === "final" && mediaUrls.length > 0) {
|
|
252
|
+
const cfg = getWecomRuntime().config.loadConfig();
|
|
253
|
+
const mediaLocalRoots = resolveWecomMergedMediaLocalRoots({ cfg });
|
|
254
|
+
const mediaMaxBytes = resolveWecomMediaMaxBytes(cfg, params.accountId);
|
|
249
255
|
const mediaFailures: string[] = [];
|
|
250
256
|
const mediaNotes: string[] = [];
|
|
251
257
|
let mediaSent = 0;
|
|
@@ -254,6 +260,8 @@ export function createBotWsReplyHandle(params: {
|
|
|
254
260
|
wsClient: params.client,
|
|
255
261
|
chatId: peerId,
|
|
256
262
|
mediaUrl,
|
|
263
|
+
mediaLocalRoots,
|
|
264
|
+
maxBytes: mediaMaxBytes,
|
|
257
265
|
});
|
|
258
266
|
if (result.ok) {
|
|
259
267
|
mediaSent += 1;
|
|
@@ -300,13 +308,13 @@ export function createBotWsReplyHandle(params: {
|
|
|
300
308
|
// Send push message for other events
|
|
301
309
|
await params.client.sendMessage(peerId, {
|
|
302
310
|
msgtype: "markdown",
|
|
303
|
-
markdown: { content: finalText },
|
|
311
|
+
markdown: { content: toWeComMarkdownV2(finalText) },
|
|
304
312
|
});
|
|
305
313
|
} else {
|
|
306
314
|
await params.client.replyStream(
|
|
307
315
|
params.frame,
|
|
308
316
|
resolveStreamId(),
|
|
309
|
-
finalText,
|
|
317
|
+
toWeComMarkdownV2(finalText),
|
|
310
318
|
info.kind === "final",
|
|
311
319
|
);
|
|
312
320
|
}
|
|
@@ -349,5 +357,9 @@ export function createBotWsReplyHandle(params: {
|
|
|
349
357
|
}
|
|
350
358
|
params.onFail?.(error);
|
|
351
359
|
},
|
|
360
|
+
markExternalActivity: () => {
|
|
361
|
+
notifyPeerActive();
|
|
362
|
+
stopPlaceholderKeepalive();
|
|
363
|
+
},
|
|
352
364
|
};
|
|
353
365
|
}
|
|
@@ -80,12 +80,13 @@ export class BotWsSdkAdapter {
|
|
|
80
80
|
lastError: undefined,
|
|
81
81
|
});
|
|
82
82
|
},
|
|
83
|
-
sendMedia: async ({ chatId, mediaUrl, text, mediaLocalRoots }) => {
|
|
83
|
+
sendMedia: async ({ chatId, mediaUrl, text, mediaLocalRoots, maxBytes }) => {
|
|
84
84
|
const result = await uploadAndSendBotWsMedia({
|
|
85
85
|
wsClient: client,
|
|
86
86
|
chatId,
|
|
87
87
|
mediaUrl,
|
|
88
88
|
mediaLocalRoots,
|
|
89
|
+
maxBytes,
|
|
89
90
|
});
|
|
90
91
|
if (result.ok && text?.trim()) {
|
|
91
92
|
await client.sendMessage(chatId, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
|
3
3
|
|
|
4
4
|
import type { ResolvedAgentAccount, TransportSessionPatch } from "../../types/index.js";
|
|
5
5
|
import { monitorState } from "../../monitor/state.js";
|
package/src/types/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type WecomMediaConfig = {
|
|
|
11
11
|
retentionHours?: number;
|
|
12
12
|
cleanupOnStart?: boolean;
|
|
13
13
|
maxBytes?: number;
|
|
14
|
+
localRoots?: string[];
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export type WecomNetworkConfig = {
|
|
@@ -72,12 +73,14 @@ export type WecomDynamicAgentsConfig = {
|
|
|
72
73
|
export type WecomAccountConfig = {
|
|
73
74
|
enabled?: boolean;
|
|
74
75
|
name?: string;
|
|
76
|
+
mediaMaxMb?: number;
|
|
75
77
|
bot?: WecomBotConfig;
|
|
76
78
|
agent?: WecomAgentConfig;
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
export type WecomConfig = {
|
|
80
82
|
enabled?: boolean;
|
|
83
|
+
mediaMaxMb?: number;
|
|
81
84
|
bot?: WecomBotConfig;
|
|
82
85
|
agent?: WecomAgentConfig;
|
|
83
86
|
accounts?: Record<string, WecomAccountConfig>;
|
package/src/types/runtime.ts
CHANGED
|
@@ -95,6 +95,7 @@ export type ReplyHandle = {
|
|
|
95
95
|
context: ReplyContext;
|
|
96
96
|
deliver: (payload: ReplyPayload, info: ReplyDeliveryInfo) => Promise<void>;
|
|
97
97
|
fail?: (error: unknown) => Promise<void>;
|
|
98
|
+
markExternalActivity?: () => void;
|
|
98
99
|
};
|
|
99
100
|
|
|
100
101
|
export type TransportSessionSnapshot = {
|