@yanhaidao/wecom 2.3.190 → 2.3.270

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.
@@ -1,10 +1,8 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import {
4
- resolveChannelMediaMaxBytes,
5
- resolvePreferredOpenClawTmpDir,
6
- type OpenClawConfig,
7
- } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
5
+ import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
8
6
 
9
7
  // 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
10
8
  // 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Context store for WeCom Bot WS proactive push.
3
+ *
4
+ * Similar to Weixin's contextToken mechanism, we need to track:
5
+ * - Which accountId has active sessions with which peerId
6
+ * - The contextToken for routing outbound messages
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ // Simple logger
14
+ const logger = {
15
+ info: (...args: unknown[]) => console.log('[wecom-context]', ...args),
16
+ warn: (...args: unknown[]) => console.warn('[wecom-context]', ...args),
17
+ debug: (...args: unknown[]) => process.env.DEBUG && console.log('[wecom-context]', ...args),
18
+ };
19
+
20
+ type PeerKind = "direct" | "group";
21
+
22
+ type StoredPeerContext = {
23
+ contextToken: string;
24
+ peerKind: PeerKind;
25
+ lastSeen: number;
26
+ };
27
+
28
+ type ResolvedPeerContext = StoredPeerContext & {
29
+ accountId: string;
30
+ peerId: string;
31
+ };
32
+
33
+ // In-memory store: accountId -> peerId -> context info
34
+ const peerContextStore = new Map<string, Map<string, StoredPeerContext>>();
35
+
36
+ // Reverse lookup: peerId -> accountId (for routing outbound)
37
+ const peerToAccountMap = new Map<string, string>();
38
+ const contextTokenToPeerMap = new Map<string, ResolvedPeerContext>();
39
+
40
+ function resolveStateDir(): string {
41
+ return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME || "/tmp", ".openclaw");
42
+ }
43
+
44
+ function resolveContextFilePath(accountId: string): string {
45
+ return path.join(
46
+ resolveStateDir(),
47
+ "wecom",
48
+ "context",
49
+ `${accountId}.json`
50
+ );
51
+ }
52
+
53
+ /** Persist peer contexts for an account to disk */
54
+ function persistContexts(accountId: string): void {
55
+ const peerMap = peerContextStore.get(accountId);
56
+ if (!peerMap) return;
57
+
58
+ const data: Record<string, StoredPeerContext> = {};
59
+ for (const [peerId, info] of peerMap) {
60
+ data[peerId] = info;
61
+ }
62
+
63
+ const filePath = resolveContextFilePath(accountId);
64
+ try {
65
+ const dir = path.dirname(filePath);
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 0), "utf-8");
68
+ } catch (err) {
69
+ logger.warn?.(`persistContexts: failed to write ${filePath}: ${String(err)}`);
70
+ }
71
+ }
72
+
73
+ function normalizeContextToken(value: unknown): string | undefined {
74
+ const token = typeof value === "string" ? value.trim() : "";
75
+ return token || undefined;
76
+ }
77
+
78
+ function normalizePeerKind(value: unknown): PeerKind {
79
+ return value === "group" ? "group" : "direct";
80
+ }
81
+
82
+ function registerPeerContext(accountId: string, peerId: string, info: StoredPeerContext): void {
83
+ let peerMap = peerContextStore.get(accountId);
84
+ if (!peerMap) {
85
+ peerMap = new Map();
86
+ peerContextStore.set(accountId, peerMap);
87
+ }
88
+
89
+ const previous = peerMap.get(peerId);
90
+ if (previous?.contextToken && previous.contextToken !== info.contextToken) {
91
+ contextTokenToPeerMap.delete(previous.contextToken);
92
+ }
93
+
94
+ peerMap.set(peerId, info);
95
+ peerToAccountMap.set(peerId, accountId);
96
+ contextTokenToPeerMap.set(info.contextToken, {
97
+ accountId,
98
+ peerId,
99
+ ...info,
100
+ });
101
+ }
102
+
103
+ function resolveStoredPeerContext(
104
+ accountId: string,
105
+ peerId: string,
106
+ params: {
107
+ contextToken?: string;
108
+ peerKind?: PeerKind;
109
+ lastSeen?: number;
110
+ },
111
+ ): StoredPeerContext {
112
+ const existing = peerContextStore.get(accountId)?.get(peerId);
113
+ return {
114
+ contextToken:
115
+ normalizeContextToken(params.contextToken) ??
116
+ existing?.contextToken ??
117
+ randomUUID(),
118
+ peerKind: params.peerKind ?? existing?.peerKind ?? "direct",
119
+ lastSeen: params.lastSeen ?? Date.now(),
120
+ };
121
+ }
122
+
123
+ /** Restore persisted peer contexts for an account */
124
+ export function restorePeerContexts(accountId: string): void {
125
+ const filePath = resolveContextFilePath(accountId);
126
+ try {
127
+ if (!fs.existsSync(filePath)) return;
128
+ const raw = fs.readFileSync(filePath, "utf-8");
129
+ const data = JSON.parse(raw) as Record<
130
+ string,
131
+ {
132
+ contextToken?: string;
133
+ peerKind?: string;
134
+ lastSeen?: number;
135
+ }
136
+ >;
137
+
138
+ const peerMap = new Map<string, StoredPeerContext>();
139
+ let count = 0;
140
+ let mutated = false;
141
+ for (const [peerId, info] of Object.entries(data)) {
142
+ const normalized: StoredPeerContext = {
143
+ contextToken: normalizeContextToken(info?.contextToken) ?? randomUUID(),
144
+ peerKind: normalizePeerKind(info?.peerKind),
145
+ lastSeen:
146
+ typeof info?.lastSeen === "number" && Number.isFinite(info.lastSeen)
147
+ ? info.lastSeen
148
+ : Date.now(),
149
+ };
150
+ peerMap.set(peerId, normalized);
151
+ peerToAccountMap.set(peerId, accountId);
152
+ contextTokenToPeerMap.set(normalized.contextToken, {
153
+ accountId,
154
+ peerId,
155
+ ...normalized,
156
+ });
157
+ if (
158
+ normalized.contextToken !== info?.contextToken ||
159
+ normalized.peerKind !== info?.peerKind ||
160
+ normalized.lastSeen !== info?.lastSeen
161
+ ) {
162
+ mutated = true;
163
+ }
164
+ count++;
165
+ }
166
+ peerContextStore.set(accountId, peerMap);
167
+ if (mutated) {
168
+ persistContexts(accountId);
169
+ }
170
+ logger.info?.(`restorePeerContexts: restored ${count} peers for account=${accountId}`);
171
+ } catch (err) {
172
+ logger.warn?.(`restorePeerContexts: failed to read ${filePath}: ${String(err)}`);
173
+ }
174
+ }
175
+
176
+ /** Store context for a peer (called on inbound message) */
177
+ export function setPeerContext(
178
+ accountId: string,
179
+ peerId: string,
180
+ options?: {
181
+ contextToken?: string;
182
+ peerKind?: PeerKind;
183
+ lastSeen?: number;
184
+ },
185
+ ): string {
186
+ const resolved = resolveStoredPeerContext(accountId, peerId, options ?? {});
187
+ registerPeerContext(accountId, peerId, resolved);
188
+
189
+ // Persist to disk (debounced would be better, but simple for now)
190
+ persistContexts(accountId);
191
+
192
+ logger.debug?.(
193
+ `setPeerContext: accountId=${accountId} peerId=${peerId} token=${resolved.contextToken} kind=${resolved.peerKind}`,
194
+ );
195
+ return resolved.contextToken;
196
+ }
197
+
198
+ /** Get the accountId that has an active session with a peer */
199
+ export function getAccountIdByPeer(peerId: string): string | undefined {
200
+ return peerToAccountMap.get(peerId);
201
+ }
202
+
203
+ /** Get the most recent peerId for an account (for proactive push) */
204
+ export function getRecentPeerForAccount(accountId: string, maxAgeMs = 30 * 60 * 1000): string | undefined {
205
+ const peerMap = peerContextStore.get(accountId);
206
+ if (!peerMap) return undefined;
207
+
208
+ let mostRecent: { peerId: string; lastSeen: number } | undefined;
209
+
210
+ for (const [peerId, info] of peerMap) {
211
+ if (Date.now() - info.lastSeen > maxAgeMs) continue;
212
+ if (!mostRecent || info.lastSeen > mostRecent.lastSeen) {
213
+ mostRecent = { peerId, lastSeen: info.lastSeen };
214
+ }
215
+ }
216
+
217
+ return mostRecent?.peerId;
218
+ }
219
+
220
+ /** Get context token for a peer */
221
+ export function getPeerContextToken(accountId: string, peerId: string): string | undefined {
222
+ const peerMap = peerContextStore.get(accountId);
223
+ return peerMap?.get(peerId)?.contextToken;
224
+ }
225
+
226
+ /** Resolve a peer context from a context token. */
227
+ export function getPeerContextByToken(contextToken: string): ResolvedPeerContext | undefined {
228
+ return contextTokenToPeerMap.get(contextToken);
229
+ }
230
+
231
+ /** Resolve accountId from a context token. */
232
+ export function getAccountIdByContextToken(contextToken: string): string | undefined {
233
+ return contextTokenToPeerMap.get(contextToken)?.accountId;
234
+ }
235
+
236
+ /** Check if we have an active session for routing */
237
+ export function hasActiveSession(accountId: string, peerId: string, maxAgeMs = 30 * 60 * 1000): boolean {
238
+ const peerMap = peerContextStore.get(accountId);
239
+ if (!peerMap) return false;
240
+
241
+ const info = peerMap.get(peerId);
242
+ if (!info) return false;
243
+
244
+ return Date.now() - info.lastSeen < maxAgeMs;
245
+ }
246
+
247
+ /** Clear all contexts for an account */
248
+ export function clearPeerContexts(accountId: string): void {
249
+ const peerMap = peerContextStore.get(accountId);
250
+ if (peerMap) {
251
+ for (const [peerId, info] of peerMap) {
252
+ peerToAccountMap.delete(peerId);
253
+ contextTokenToPeerMap.delete(info.contextToken);
254
+ }
255
+ }
256
+ peerContextStore.delete(accountId);
257
+
258
+ const filePath = resolveContextFilePath(accountId);
259
+ try {
260
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
261
+ } catch (err) {
262
+ logger.warn?.(`clearPeerContexts: failed to remove ${filePath}`);
263
+ }
264
+ }
@@ -1,7 +1,13 @@
1
1
  import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
2
2
  import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
3
  import { describe, expect, it, vi } from "vitest";
4
- import { wecomOnboardingAdapter } from "./onboarding.js";
4
+ import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
5
+ import { wecomPlugin } from "./channel.js";
6
+
7
+ const wecomSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
8
+ plugin: wecomPlugin,
9
+ wizard: wecomPlugin.setupWizard!,
10
+ });
5
11
 
6
12
  function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
7
13
  return {
@@ -54,7 +60,7 @@ describe("wecom onboarding", () => {
54
60
  }) as WizardPrompter["text"],
55
61
  });
56
62
 
57
- const result = await wecomOnboardingAdapter.configure({
63
+ const result = await wecomSetupAdapter.configure({
58
64
  cfg: {} as OpenClawConfig,
59
65
  runtime: createRuntime(),
60
66
  prompter,
@@ -76,8 +82,12 @@ describe("wecom onboarding", () => {
76
82
  const noteText = (prompter.note as ReturnType<typeof vi.fn>).mock.calls
77
83
  .map(([message]) => String(message))
78
84
  .join("\n");
79
- expect(noteText).toContain("YanHaidao/wecom 是企业微信官方推荐三方插件,功能强大,适合直接落生产环境。");
80
- expect(noteText).toContain("默认就是 Bot WebSocket 模式,配置简单,无需域名,普通用户也能快速接入。");
85
+ expect(noteText).toContain(
86
+ "YanHaidao/wecom 是企业微信官方推荐三方插件,功能强大,适合直接落生产环境。",
87
+ );
88
+ expect(noteText).toContain(
89
+ "默认就是 Bot WebSocket 模式,配置简单,无需域名,普通用户也能快速接入。",
90
+ );
81
91
  expect(noteText).toContain("支持主动发消息,定时任务、异常提醒、工作流通知都可直接落地。");
82
92
  });
83
93
 
@@ -131,7 +141,7 @@ describe("wecom onboarding", () => {
131
141
  },
132
142
  };
133
143
 
134
- const result = await wecomOnboardingAdapter.configure({
144
+ const result = await wecomSetupAdapter.configure({
135
145
  cfg: initialCfg,
136
146
  runtime: createRuntime(),
137
147
  prompter,
@@ -155,7 +165,7 @@ describe("wecom onboarding", () => {
155
165
  });
156
166
 
157
167
  it("reports chinese status copy for channel selection", async () => {
158
- const status = await wecomOnboardingAdapter.getStatus({
168
+ const status = await wecomSetupAdapter.getStatus({
159
169
  cfg: {} as OpenClawConfig,
160
170
  options: {},
161
171
  accountOverrides: {},
@@ -199,7 +209,7 @@ describe("wecom onboarding", () => {
199
209
  }) as WizardPrompter["text"],
200
210
  });
201
211
 
202
- const result = await wecomOnboardingAdapter.configure({
212
+ const result = await wecomSetupAdapter.configure({
203
213
  cfg: {} as OpenClawConfig,
204
214
  runtime: createRuntime(),
205
215
  prompter,
@@ -214,25 +224,33 @@ describe("wecom onboarding", () => {
214
224
  .map(([message]) => String(message))
215
225
  .join("\n");
216
226
  expect(noteText).toContain("接入标识已规范化为:haidao");
217
- expect(wecomOnboardingAdapter.dmPolicy).toBeUndefined();
227
+ expect(wecomSetupAdapter.dmPolicy).toBeUndefined();
218
228
  });
219
229
 
220
230
  it("offers default account selection when config has no accounts", async () => {
221
231
  const prompter = createPrompter({
222
- select: vi.fn(async ({ message, options }: { message: string; options: Array<{ value: string; label: string }> }) => {
223
- if (message === "请选择企业微信接入标识(英文):") {
224
- expect(options.map((option) => option.value)).toEqual(["default", "__new__"]);
225
- expect(options[0]?.label).toBe("default(默认标识)");
226
- return "default";
227
- }
228
- if (message === "请选择您要配置的接入模式:") {
229
- return "bot";
230
- }
231
- if (message === "请选择私聊 (DM) 访问策略:") {
232
- return "pairing";
233
- }
234
- throw new Error(`Unexpected select prompt: ${message}`);
235
- }) as WizardPrompter["select"],
232
+ select: vi.fn(
233
+ async ({
234
+ message,
235
+ options,
236
+ }: {
237
+ message: string;
238
+ options: Array<{ value: string; label: string }>;
239
+ }) => {
240
+ if (message === "请选择企业微信接入标识(英文):") {
241
+ expect(options.map((option) => option.value)).toEqual(["default", "__new__"]);
242
+ expect(options[0]?.label).toBe("default(默认标识)");
243
+ return "default";
244
+ }
245
+ if (message === "请选择您要配置的接入模式:") {
246
+ return "bot";
247
+ }
248
+ if (message === "请选择私聊 (DM) 访问策略:") {
249
+ return "pairing";
250
+ }
251
+ throw new Error(`Unexpected select prompt: ${message}`);
252
+ },
253
+ ) as WizardPrompter["select"],
236
254
  text: vi.fn(async ({ message }: { message: string }) => {
237
255
  if (message === "请输入 BotId(机器人 ID):") {
238
256
  return "bot-id-default";
@@ -250,7 +268,7 @@ describe("wecom onboarding", () => {
250
268
  }) as WizardPrompter["text"],
251
269
  });
252
270
 
253
- const result = await wecomOnboardingAdapter.configure({
271
+ const result = await wecomSetupAdapter.configure({
254
272
  cfg: {} as OpenClawConfig,
255
273
  runtime: createRuntime(),
256
274
  prompter,
@@ -301,7 +319,7 @@ describe("wecom onboarding", () => {
301
319
  }) as WizardPrompter["text"],
302
320
  });
303
321
 
304
- const result = await wecomOnboardingAdapter.configure({
322
+ const result = await wecomSetupAdapter.configure({
305
323
  cfg: {} as OpenClawConfig,
306
324
  runtime: createRuntime(),
307
325
  prompter,