@yanhaidao/wecom 2.3.190 → 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.
@@ -0,0 +1,135 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { prepareInboundSession } from "./session-manager.js";
3
+
4
+ const {
5
+ resolveRuntimeRoute,
6
+ getPeerContextToken,
7
+ registerWecomSourceSnapshot,
8
+ } = vi.hoisted(() => ({
9
+ resolveRuntimeRoute: vi.fn(),
10
+ getPeerContextToken: vi.fn(),
11
+ registerWecomSourceSnapshot: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("./routing-bridge.js", () => ({
15
+ resolveRuntimeRoute,
16
+ }));
17
+
18
+ vi.mock("../context-store.js", () => ({
19
+ getPeerContextToken,
20
+ }));
21
+
22
+ vi.mock("./source-registry.js", () => ({
23
+ registerWecomSourceSnapshot,
24
+ }));
25
+
26
+ function createCore() {
27
+ const finalizeInboundContext = vi.fn((ctx) => ctx);
28
+ const recordInboundSession = vi.fn(async () => {});
29
+
30
+ return {
31
+ core: {
32
+ channel: {
33
+ session: {
34
+ resolveStorePath: vi.fn(() => "/tmp/wecom-session-store"),
35
+ readSessionUpdatedAt: vi.fn(() => 1234567890),
36
+ recordInboundSession,
37
+ },
38
+ reply: {
39
+ resolveEnvelopeFormatOptions: vi.fn(() => ({ envelope: "default" })),
40
+ formatAgentEnvelope: vi.fn(() => "formatted-body"),
41
+ finalizeInboundContext,
42
+ },
43
+ },
44
+ } as any,
45
+ finalizeInboundContext,
46
+ recordInboundSession,
47
+ };
48
+ }
49
+
50
+ function createMediaService() {
51
+ return {
52
+ normalizeFirstAttachment: vi.fn(async () => undefined),
53
+ saveInboundAttachment: vi.fn(async () => undefined),
54
+ } as any;
55
+ }
56
+
57
+ describe("prepareInboundSession", () => {
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ resolveRuntimeRoute.mockReturnValue({
61
+ sessionKey: "agent:ops_bot:wecom:direct:hidaomax",
62
+ agentId: "ops_bot",
63
+ accountId: "default",
64
+ });
65
+ });
66
+
67
+ it("marks bot-ws turns as the current wecom surface", async () => {
68
+ getPeerContextToken.mockReturnValue("ctx-bot");
69
+ const { core, finalizeInboundContext, recordInboundSession } = createCore();
70
+
71
+ const result = await prepareInboundSession({
72
+ core,
73
+ cfg: {} as any,
74
+ event: {
75
+ accountId: "default",
76
+ transport: "bot-ws",
77
+ messageId: "msg-bot-1",
78
+ conversation: {
79
+ peerKind: "direct",
80
+ peerId: "HiDaoMax",
81
+ senderId: "HiDaoMax",
82
+ },
83
+ senderName: "HiDaoMax",
84
+ text: "hello",
85
+ } as any,
86
+ mediaService: createMediaService(),
87
+ });
88
+
89
+ expect(finalizeInboundContext).toHaveBeenCalledWith(
90
+ expect.objectContaining({
91
+ Provider: "wecom",
92
+ Surface: "wecom",
93
+ OriginatingChannel: "wecom",
94
+ OriginatingTo: "wecom:context:ctx-bot",
95
+ To: "wecom:user:HiDaoMax",
96
+ }),
97
+ );
98
+ expect(recordInboundSession).toHaveBeenCalledTimes(1);
99
+ expect(result.ctx.Provider).toBe("wecom");
100
+ expect(result.ctx.Surface).toBe("wecom");
101
+ });
102
+
103
+ it("keeps agent-callback turns on provider-only context", async () => {
104
+ getPeerContextToken.mockReturnValue(undefined);
105
+ const { core, finalizeInboundContext } = createCore();
106
+
107
+ const result = await prepareInboundSession({
108
+ core,
109
+ cfg: {} as any,
110
+ event: {
111
+ accountId: "default",
112
+ transport: "agent-callback",
113
+ messageId: "msg-agent-1",
114
+ conversation: {
115
+ peerKind: "direct",
116
+ peerId: "HiDaoMax",
117
+ senderId: "HiDaoMax",
118
+ },
119
+ senderName: "HiDaoMax",
120
+ text: "hello",
121
+ } as any,
122
+ mediaService: createMediaService(),
123
+ });
124
+
125
+ expect(finalizeInboundContext).toHaveBeenCalledWith(
126
+ expect.objectContaining({
127
+ Provider: "wecom",
128
+ OriginatingChannel: "wecom",
129
+ OriginatingTo: "wecom:user:HiDaoMax",
130
+ }),
131
+ );
132
+ expect(result.ctx.Provider).toBe("wecom");
133
+ expect(result.ctx).not.toHaveProperty("Surface");
134
+ });
135
+ });
@@ -1,6 +1,8 @@
1
1
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
2
  import type { WecomMediaService } from "../shared/media-service.js";
3
3
  import type { UnifiedInboundEvent } from "../types/index.js";
4
+ import { getPeerContextToken } from "../context-store.js";
5
+ import { buildWecomContextTarget } from "../target.js";
4
6
  import { resolveRuntimeRoute } from "./routing-bridge.js";
5
7
  import { registerWecomSourceSnapshot } from "./source-registry.js";
6
8
 
@@ -18,12 +20,20 @@ export async function prepareInboundSession(params: {
18
20
  }): Promise<PreparedSession> {
19
21
  const { core, cfg, event, mediaService } = params;
20
22
  const route = resolveRuntimeRoute({ core, cfg, event });
21
- if (event.transport === "bot-ws") {
23
+ const source =
24
+ event.transport === "bot-ws"
25
+ ? "bot-ws"
26
+ : event.transport === "agent-callback"
27
+ ? "agent-callback"
28
+ : undefined;
29
+ if (source) {
22
30
  registerWecomSourceSnapshot({
23
31
  accountId: event.accountId,
24
- source: "bot-ws",
32
+ source,
25
33
  messageId: event.messageId,
26
34
  sessionKey: route.sessionKey,
35
+ peerKind: event.conversation.peerKind,
36
+ peerId: event.conversation.peerId,
27
37
  });
28
38
  }
29
39
  const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
@@ -46,6 +56,30 @@ export async function prepareInboundSession(params: {
46
56
  const mediaPath = firstAttachment
47
57
  ? await mediaService.saveInboundAttachment(event, firstAttachment)
48
58
  : undefined;
59
+ const defaultOriginatingTo =
60
+ event.conversation.peerKind === "group"
61
+ ? `wecom:group:${event.conversation.peerId}`
62
+ : `wecom:user:${event.conversation.peerId}`;
63
+ const contextToken =
64
+ event.transport === "bot-ws"
65
+ ? getPeerContextToken(event.accountId, event.conversation.peerId)
66
+ : undefined;
67
+ const originatingTo = contextToken
68
+ ? buildWecomContextTarget(contextToken)
69
+ : defaultOriginatingTo;
70
+ const providerContext =
71
+ event.transport === "bot-ws"
72
+ ? {
73
+ // Bot WS inbound turns already have a live reply handle bound to the
74
+ // current req_id. Mark the current surface as WeCom so core final text
75
+ // stays on that handle and replaces the placeholder instead of being
76
+ // re-routed as a second active-push message.
77
+ Provider: "wecom" as const,
78
+ Surface: "wecom" as const,
79
+ }
80
+ : {
81
+ Provider: "wecom" as const,
82
+ };
49
83
 
50
84
  const ctx = core.channel.reply.finalizeInboundContext({
51
85
  Body: body,
@@ -65,13 +99,11 @@ export async function prepareInboundSession(params: {
65
99
  ConversationLabel: `${event.conversation.peerKind}:${event.conversation.peerId}`,
66
100
  SenderName: event.senderName ?? event.conversation.senderId,
67
101
  SenderId: event.conversation.senderId,
68
- Provider: "wecom",
69
- Surface: "wecom",
102
+ // Keep Originating* populated so explicit route-to-origin flows and message
103
+ // tools can still resolve the active peer context when needed.
104
+ ...providerContext,
70
105
  OriginatingChannel: "wecom",
71
- OriginatingTo:
72
- event.conversation.peerKind === "group"
73
- ? `wecom:group:${event.conversation.peerId}`
74
- : `wecom:user:${event.conversation.peerId}`,
106
+ OriginatingTo: originatingTo,
75
107
  MessageSid: event.messageId,
76
108
  CommandAuthorized: true,
77
109
  MediaPath: mediaPath,
@@ -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 (默认为用户)
@@ -1,10 +1,10 @@
1
1
  import type { WSClient } from "@wecom/aibot-node-sdk";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/media-runtime";
3
4
 
4
5
  import { uploadAndSendBotWsMedia } from "./media.js";
5
- import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
6
6
 
7
- vi.mock("openclaw/plugin-sdk", () => ({
7
+ vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
8
8
  detectMime: vi.fn(),
9
9
  loadOutboundMediaFromUrl: vi.fn(),
10
10
  }));
@@ -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;
@@ -2,7 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import type { WSClient } from "@wecom/aibot-node-sdk";
4
4
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
5
- import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
5
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
6
6
  import { uploadAndSendBotWsMedia } from "./media.js";
7
7
  import { createBotWsReplyHandle } from "./reply.js";
8
8
 
@@ -5,10 +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
9
  import { resolveWecomMediaMaxBytes, resolveWecomMergedMediaLocalRoots } from "../../config/index.js";
10
10
  import { getWecomRuntime } from "../../runtime.js";
11
11
  import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
12
+ import { toWeComMarkdownV2 } from "../../wecom_msg_adapter/markdown_adapter.js";
12
13
  import { uploadAndSendBotWsMedia } from "./media.js";
13
14
 
14
15
  const PLACEHOLDER_KEEPALIVE_MS = 3000;
@@ -307,13 +308,13 @@ export function createBotWsReplyHandle(params: {
307
308
  // Send push message for other events
308
309
  await params.client.sendMessage(peerId, {
309
310
  msgtype: "markdown",
310
- markdown: { content: finalText },
311
+ markdown: { content: toWeComMarkdownV2(finalText) },
311
312
  });
312
313
  } else {
313
314
  await params.client.replyStream(
314
315
  params.frame,
315
316
  resolveStreamId(),
316
- finalText,
317
+ toWeComMarkdownV2(finalText),
317
318
  info.kind === "final",
318
319
  );
319
320
  }
@@ -356,5 +357,9 @@ export function createBotWsReplyHandle(params: {
356
357
  }
357
358
  params.onFail?.(error);
358
359
  },
360
+ markExternalActivity: () => {
361
+ notifyPeerActive();
362
+ stopPlaceholderKeepalive();
363
+ },
359
364
  };
360
365
  }
@@ -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";
@@ -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 = {