@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.
Files changed (39) hide show
  1. package/.github/workflows/release.yml +23 -4
  2. package/README.md +87 -2
  3. package/SKILLS_DOC.md +272 -120
  4. package/changelog/v2.3.19.md +73 -0
  5. package/changelog/v2.3.26.md +21 -0
  6. package/package.json +2 -2
  7. package/src/agent/handler.ts +5 -3
  8. package/src/app/account-runtime.ts +5 -1
  9. package/src/app/index.ts +117 -0
  10. package/src/capability/bot/stream-orchestrator.ts +1 -1
  11. package/src/capability/doc/client.ts +228 -9
  12. package/src/capability/doc/tool.ts +14 -7
  13. package/src/channel.ts +1 -1
  14. package/src/config/index.ts +7 -1
  15. package/src/config/media.test.ts +113 -0
  16. package/src/config/media.ts +130 -5
  17. package/src/config/schema.ts +3 -0
  18. package/src/context-store.ts +264 -0
  19. package/src/onboarding.ts +1 -1
  20. package/src/outbound.test.ts +565 -5
  21. package/src/outbound.ts +94 -7
  22. package/src/runtime/dispatcher.ts +24 -5
  23. package/src/runtime/routing-bridge.test.ts +115 -0
  24. package/src/runtime/routing-bridge.ts +26 -1
  25. package/src/runtime/session-manager.test.ts +135 -0
  26. package/src/runtime/session-manager.ts +40 -8
  27. package/src/runtime/source-registry.ts +79 -0
  28. package/src/runtime.ts +3 -0
  29. package/src/target.ts +20 -8
  30. package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
  31. package/src/transport/bot-ws/media.test.ts +44 -0
  32. package/src/transport/bot-ws/media.ts +7 -4
  33. package/src/transport/bot-ws/reply.test.ts +131 -1
  34. package/src/transport/bot-ws/reply.ts +15 -3
  35. package/src/transport/bot-ws/sdk-adapter.ts +2 -1
  36. package/src/transport/http/registry.ts +1 -1
  37. package/src/types/config.ts +3 -0
  38. package/src/types/runtime.ts +1 -0
  39. 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 Party (纯数字默认为部门)
90
- // 原因:1) 定时任务可能直接配置 to: "1" 发送给根部门
91
- // 2) 企业微信官方文档示例使用纯数字表示部门
92
- // 3) 用户 ID 应该使用显式前缀 "user:xxx"
93
- // 但如果 preferUserForDigits true 则视为 User ID(用于 agent scoped 场景)
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 { touser: clean };
107
+ if (options?.preferUserForDigits === false) {
108
+ return { toparty: clean };
97
109
  }
98
- return { toparty: clean };
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.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
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.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
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.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
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";
@@ -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>;
@@ -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 = {