@gloablehive/ipad-wechat-plugin 1.0.9 → 1.0.11

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/dist/index.js CHANGED
@@ -3,26 +3,69 @@
3
3
  *
4
4
  * iPad WeChat Plugin - enables OpenClaw to send/receive WeChat messages
5
5
  * through the iPad protocol (different from WorkPhone/phone protocol)
6
+ *
7
+ * Multi-account support:
8
+ * - cfg is passed to handleInboundMessage for cache isolation
9
+ * - Each account's guid can have a dedicated webhook path
6
10
  */
7
11
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
8
- import { ipadWeChatPlugin, handleInboundMessage } from "./src/channel.js";
12
+ import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady } from "./src/channel.js";
9
13
  export default defineChannelPluginEntry({
10
14
  id: "ipad-wechat",
11
15
  name: "iPad WeChat",
12
16
  description: "Connect OpenClaw to iPad WeChat protocol for sending and receiving WeChat messages",
13
17
  plugin: ipadWeChatPlugin,
14
18
  registerFull(api) {
15
- // Register webhook endpoint for receiving messages
19
+ // Register main webhook endpoint for receiving messages
16
20
  api.registerHttpRoute({
17
21
  path: "/ipad-wechat/webhook",
18
22
  auth: "plugin",
19
23
  handler: async (req, res) => {
20
24
  try {
25
+ // Get config from request
26
+ const cfg = req.cfg;
27
+ // Wait for cache manager to be ready
28
+ const ready = getCacheManagerReady();
29
+ if (ready) {
30
+ await ready;
31
+ }
21
32
  const payload = req.body;
22
- // Handle inbound message
23
33
  console.log("[iPad WeChat] Received webhook:", payload);
24
34
  if (payload) {
25
- await handleInboundMessage(api, payload);
35
+ await handleInboundMessage(api, payload, cfg);
36
+ }
37
+ res.statusCode = 200;
38
+ res.end("ok");
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ console.error("[iPad WeChat] Webhook error:", error);
43
+ res.statusCode = 500;
44
+ res.end("Internal error");
45
+ return true;
46
+ }
47
+ },
48
+ });
49
+ // Register per-guid webhook endpoints for multi-account routing
50
+ // These paths allow distinguishing which account received the message
51
+ // Path format: /ipad-wechat/webhook/guid-12345
52
+ api.registerHttpRoute({
53
+ path: "/ipad-wechat/webhook/:guid",
54
+ auth: "plugin",
55
+ handler: async (req, res) => {
56
+ try {
57
+ const cfg = req.cfg;
58
+ const guid = req.params?.guid;
59
+ // Wait for cache manager to be ready
60
+ const ready = getCacheManagerReady();
61
+ if (ready) {
62
+ await ready;
63
+ }
64
+ const payload = req.body;
65
+ console.log(`[iPad WeChat] Received webhook for guid ${guid}:`, payload);
66
+ if (payload) {
67
+ // Pass the guid as overrideAccountId so handleInboundMessage knows which account
68
+ await handleInboundMessage(api, payload, cfg, guid);
26
69
  }
27
70
  res.statusCode = 200;
28
71
  res.end("ok");
@@ -4,21 +4,21 @@
4
4
  * Uses iPad protocol (different from phone/WorkPhone protocol)
5
5
  * API documentation: wechat/api.md
6
6
  *
7
- * Includes Local Cache (shared with celphone-wechat-plugin):
8
- * - Per-account, per-user/conversation MD files
9
- * - YAML frontmatter (aligned with Claude Code)
10
- * - MEMORY.md indexing
11
- * - 4-layer compression
12
- * - AI summary extraction
13
- * - SAAS connectivity + offline fallback
14
- * - Cloud sync
7
+ * Multi-account support:
8
+ * - Config supports accounts array with per-account credentials
9
+ * - Each account can be bound to a different agent
10
+ * - Security policies are per-account isolated
15
11
  */
16
12
  import { createChatChannelPlugin, createChannelPluginBase, } from "openclaw/plugin-sdk/core";
17
- import { createIPadClient } from "./client.js";
18
13
  // Import cache modules from shared package
19
- import { createCacheManager, } from "@gloablehive/wechat-cache";
14
+ import { createCacheManager, createAccountRegistry, } from "@gloablehive/wechat-cache";
15
+ // Import client pool
16
+ import { getIPadClient } from "./client-pool.js";
20
17
  // Cache manager instance (lazy initialized)
21
18
  let cacheManager = null;
19
+ let cacheManagerReady = null;
20
+ // Account registry for routing
21
+ let accountRegistry = null;
22
22
  /**
23
23
  * Get config section from either channels or plugins.entries
24
24
  */
@@ -26,6 +26,35 @@ function getConfigSection(cfg) {
26
26
  return cfg?.channels?.["ipad-wechat"]
27
27
  || cfg?.plugins?.entries?.["ipad-wechat"]?.config;
28
28
  }
29
+ /**
30
+ * Get or create account registry
31
+ */
32
+ function getAccountRegistry() {
33
+ if (!accountRegistry) {
34
+ accountRegistry = createAccountRegistry();
35
+ }
36
+ return accountRegistry;
37
+ }
38
+ /**
39
+ * Initialize account registry from config
40
+ */
41
+ function initializeRegistry(cfg) {
42
+ const registry = getAccountRegistry();
43
+ registry.clear();
44
+ const section = getConfigSection(cfg);
45
+ const accounts = (section?.accounts || []);
46
+ // Register each account
47
+ for (const account of accounts) {
48
+ if (account.enabled !== false) {
49
+ const registeredAccount = {
50
+ ...account,
51
+ channelType: "ipad-wechat",
52
+ registeredAt: Date.now(),
53
+ };
54
+ registry.register(registeredAccount);
55
+ }
56
+ }
57
+ }
29
58
  /**
30
59
  * Get or create cache manager
31
60
  */
@@ -35,24 +64,82 @@ function getCacheManager(cfg) {
35
64
  const section = getConfigSection(cfg);
36
65
  const accounts = (section?.accounts || []);
37
66
  // If no accounts configured, create default from main config
38
- if (accounts.length === 0 && section?.wechatAccountId) {
67
+ if (accounts.length === 0 && section?.guid) {
39
68
  accounts.push({
40
69
  accountId: section.accountId || "default",
41
- wechatAccountId: section.wechatAccountId,
70
+ wechatAccountId: section.wechatAccountId || "",
42
71
  wechatId: section.wechatId || "",
43
72
  nickName: section.nickName || "WeChat User",
44
73
  enabled: true,
74
+ appKey: section.appKey,
75
+ appSecret: section.appSecret,
76
+ guid: section.guid,
45
77
  });
46
78
  }
79
+ // Initialize registry with accounts
80
+ initializeRegistry(cfg);
47
81
  const basePath = section?.cachePath || "./cache/wechat-ipad";
48
82
  cacheManager = createCacheManager({
49
83
  basePath,
50
84
  accounts,
51
85
  });
86
+ // Initialize cache manager - await for ready state
87
+ cacheManagerReady = cacheManager.init().catch(err => {
88
+ console.error("[iPad WeChat] Cache manager init failed:", err);
89
+ throw err;
90
+ });
52
91
  return cacheManager;
53
92
  }
93
+ /**
94
+ * Get cache manager ready promise
95
+ */
96
+ export function getCacheManagerReady() {
97
+ return cacheManagerReady;
98
+ }
99
+ /**
100
+ * List all account IDs from config
101
+ */
102
+ function listAccountIds(cfg) {
103
+ const section = getConfigSection(cfg);
104
+ const accounts = (section?.accounts || []);
105
+ if (accounts.length > 0) {
106
+ return accounts.map(a => a.accountId).filter(Boolean);
107
+ }
108
+ // Backward compatibility: single account
109
+ if (section?.guid) {
110
+ return [section.accountId || "default"];
111
+ }
112
+ return [];
113
+ }
114
+ /**
115
+ * Resolve account from OpenClaw config
116
+ *
117
+ * Supports both multi-account (accounts array) and legacy (single) formats.
118
+ */
54
119
  function resolveAccount(cfg, accountId) {
55
120
  const section = getConfigSection(cfg);
121
+ const accounts = (section?.accounts || []);
122
+ // Multi-account mode: look up from accounts array
123
+ if (accounts.length > 0) {
124
+ const targetAccountId = accountId || accounts[0]?.accountId;
125
+ const account = accounts.find(a => a.accountId === targetAccountId);
126
+ if (!account) {
127
+ throw new Error(`ipad-wechat: account not found - ${targetAccountId}`);
128
+ }
129
+ return {
130
+ accountId: account.accountId,
131
+ agentId: account.agentId || null,
132
+ appKey: account.appKey || section?.appKey || "",
133
+ appSecret: account.appSecret || section?.appSecret || "",
134
+ guid: account.guid || section?.guid || "",
135
+ wechatAccountId: account.wechatAccountId || "",
136
+ wechatId: account.wechatId || "",
137
+ nickName: account.nickName || "WeChat User",
138
+ allowFrom: account.allowFrom ?? [],
139
+ dmPolicy: account.dmPolicy,
140
+ };
141
+ }
142
+ // Legacy single-account mode
56
143
  const appKey = section?.appKey;
57
144
  const appSecret = section?.appSecret;
58
145
  const guid = section?.guid;
@@ -61,6 +148,7 @@ function resolveAccount(cfg, accountId) {
61
148
  }
62
149
  return {
63
150
  accountId: accountId ?? null,
151
+ agentId: null,
64
152
  appKey,
65
153
  appSecret,
66
154
  guid,
@@ -85,25 +173,17 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
85
173
  supportsEditing: false,
86
174
  supportsDeleting: false,
87
175
  },
88
- // Config adapter - for runtime account management
89
176
  config: {
90
- listAccountIds: (cfg) => {
91
- const section = getConfigSection(cfg);
92
- if (section?.wechatAccountId) {
93
- return [section.wechatAccountId];
94
- }
95
- return ["default"];
96
- },
97
- resolveAccount: (cfg, accountId) => {
98
- return resolveAccount(cfg, accountId);
99
- },
177
+ listAccountIds: (cfg) => listAccountIds(cfg),
178
+ resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
100
179
  defaultAccountId: (cfg) => {
101
180
  const section = getConfigSection(cfg);
102
- return section?.wechatAccountId || "default";
181
+ const accounts = (section?.accounts || []);
182
+ return accounts[0]?.accountId || section?.accountId || "default";
103
183
  },
104
184
  inspectAccount: (cfg, accountId) => {
105
- const section = getConfigSection(cfg);
106
- const hasCredentials = Boolean(section?.appKey && section?.appSecret && section?.guid);
185
+ const account = resolveAccount(cfg, accountId);
186
+ const hasCredentials = Boolean(account.appKey && account.appSecret && account.guid);
107
187
  return {
108
188
  enabled: hasCredentials,
109
189
  configured: hasCredentials,
@@ -111,15 +191,11 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
111
191
  };
112
192
  },
113
193
  },
114
- // Setup adapter - for initial configuration
115
194
  setup: {
116
195
  resolveAccountId: (params) => {
117
196
  return resolveAccount(params.cfg, params.accountId)?.accountId || "";
118
197
  },
119
- applyAccountConfig: (params) => {
120
- const { cfg, accountId, input } = params;
121
- return cfg;
122
- },
198
+ applyAccountConfig: (params) => params.cfg,
123
199
  },
124
200
  }),
125
201
  security: {
@@ -136,7 +212,6 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
136
212
  outbound: {
137
213
  deliveryMode: "gateway",
138
214
  channel: "ipad-wechat",
139
- // Resolve target - for WeChat, just pass through the ID
140
215
  resolveTarget: (params) => {
141
216
  const target = params.to?.trim();
142
217
  if (!target) {
@@ -146,15 +221,16 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
146
221
  },
147
222
  attachedResults: {
148
223
  sendText: async (params) => {
149
- // Get config from params.cfg
150
224
  const cfg = params.cfg;
151
- const section = getConfigSection(cfg);
152
- const client = createIPadClient({
153
- appKey: section?.appKey,
154
- appSecret: section?.appSecret,
155
- guid: section?.guid,
225
+ const accountId = params.accountId;
226
+ // Resolve account to get credentials
227
+ const account = resolveAccount(cfg, accountId);
228
+ // Use client pool for connection reuse
229
+ const client = getIPadClient(account.accountId || "default", {
230
+ appKey: account.appKey,
231
+ appSecret: account.appSecret,
232
+ guid: account.guid,
156
233
  });
157
- // Check if DM or group
158
234
  const isChatroom = params.to?.includes("@chatroom");
159
235
  if (isChatroom) {
160
236
  const result = await client.sendRoomMessage({
@@ -173,34 +249,17 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
173
249
  },
174
250
  sendMedia: async (params) => {
175
251
  const cfg = params.cfg;
176
- const section = getConfigSection(cfg);
177
- const client = createIPadClient({
178
- appKey: section?.appKey,
179
- appSecret: section?.appSecret,
180
- guid: section?.guid,
252
+ const accountId = params.accountId;
253
+ // Resolve account to get credentials
254
+ const account = resolveAccount(cfg, accountId);
255
+ // Use client pool
256
+ const client = getIPadClient(account.accountId || "default", {
257
+ appKey: account.appKey,
258
+ appSecret: account.appSecret,
259
+ guid: account.guid,
181
260
  });
182
261
  const isChatroom = params.to?.includes("@chatroom");
183
- // Get file path from mediaUrl or mediaReadFile
184
- let filePath = "";
185
- if (params.mediaUrl) {
186
- filePath = params.mediaUrl;
187
- }
188
- else if (params.mediaReadFile) {
189
- // Read file from local roots
190
- const roots = params.mediaLocalRoots || ["./"];
191
- for (const root of roots) {
192
- try {
193
- const fullPath = `${root}/${filePath}`;
194
- const buffer = await params.mediaReadFile(fullPath);
195
- // In a real implementation, we'd upload the file first
196
- filePath = fullPath;
197
- break;
198
- }
199
- catch {
200
- // Try next root
201
- }
202
- }
203
- }
262
+ let filePath = params.mediaUrl || "";
204
263
  if (isChatroom) {
205
264
  const result = await client.sendRoomMedia({
206
265
  roomId: params.to,
@@ -218,6 +277,60 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
218
277
  },
219
278
  },
220
279
  },
280
+ directory: {
281
+ self: async (params) => {
282
+ const account = resolveAccount(params.cfg, params.accountId);
283
+ return {
284
+ id: account.wechatId || "",
285
+ name: account.nickName || "WeChat User",
286
+ accountId: params.accountId || "default",
287
+ };
288
+ },
289
+ listPeers: async (params) => {
290
+ const account = resolveAccount(params.cfg, params.accountId);
291
+ const client = getIPadClient(account.accountId || "default", {
292
+ appKey: account.appKey,
293
+ appSecret: account.appSecret,
294
+ guid: account.guid,
295
+ });
296
+ try {
297
+ const contacts = await client.syncContacts();
298
+ return contacts.map((contact) => ({
299
+ id: contact.wechatId || contact.wxid,
300
+ name: contact.nickName || contact.remark || contact.wechatId,
301
+ accountId: params.accountId || "default",
302
+ }));
303
+ }
304
+ catch (error) {
305
+ console.error("[iPad WeChat] Failed to list peers:", error);
306
+ return [];
307
+ }
308
+ },
309
+ listGroups: async (params) => {
310
+ console.log("[iPad WeChat] listGroups: API not available, returning empty");
311
+ return [];
312
+ },
313
+ listGroupMembers: async (params) => {
314
+ const account = resolveAccount(params.cfg, params.accountId);
315
+ const client = getIPadClient(account.accountId || "default", {
316
+ appKey: account.appKey,
317
+ appSecret: account.appSecret,
318
+ guid: account.guid,
319
+ });
320
+ try {
321
+ const members = await client.getRoomMembers(params.groupId);
322
+ return members.map((member) => ({
323
+ id: member.userId || member.wxid,
324
+ name: member.nickName || member.displayName || member.userId,
325
+ accountId: params.accountId || "default",
326
+ }));
327
+ }
328
+ catch (error) {
329
+ console.error("[iPad WeChat] Failed to list group members:", error);
330
+ return [];
331
+ }
332
+ },
333
+ },
221
334
  capabilities: {
222
335
  supportedMessageTypes: ["text", "image", "video", "file", "link", "location", "contact"],
223
336
  maxAttachmentSize: 25 * 1024 * 1024,
@@ -230,13 +343,32 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
230
343
  supportsDeleting: false,
231
344
  },
232
345
  });
233
- export async function handleInboundMessage(api, payload, cfg) {
346
+ /**
347
+ * Handle inbound webhook message
348
+ * Supports multi-account routing via AccountRegistry
349
+ */
350
+ export async function handleInboundMessage(api, payload, cfg, overrideAccountId) {
234
351
  const { event, message } = payload;
235
352
  if (event === "message" && message) {
236
353
  const isChatroom = !!message.roomId;
237
354
  const conversationId = isChatroom
238
355
  ? message.roomId
239
356
  : message.fromUser || message.toUser || "";
357
+ // Determine account ID - use override from webhook path or resolve from registry
358
+ let resolvedAccountId = overrideAccountId;
359
+ let agentId = null;
360
+ if (cfg && !resolvedAccountId) {
361
+ const registry = getAccountRegistry();
362
+ // Try by guid if available in message
363
+ const messageGuid = message.guid;
364
+ if (messageGuid) {
365
+ agentId = registry.getAgentIdByGuid(messageGuid);
366
+ const regAccount = registry.getByGuid(messageGuid);
367
+ resolvedAccountId = regAccount?.accountId || null;
368
+ }
369
+ }
370
+ // Default account ID if not resolved
371
+ resolvedAccountId = resolvedAccountId || "default";
240
372
  const openclawMessage = {
241
373
  id: message.messageId,
242
374
  conversation: {
@@ -252,15 +384,16 @@ export async function handleInboundMessage(api, payload, cfg) {
252
384
  },
253
385
  timestamp: new Date(message.timestamp || Date.now()),
254
386
  isSelf: message.isSelf || false,
387
+ agentId,
388
+ accountId: resolvedAccountId,
255
389
  };
256
390
  // Write to cache
257
391
  if (cfg) {
258
392
  try {
259
393
  const cache = getCacheManager(cfg);
260
- const section = getConfigSection(cfg);
261
394
  const wechatMessage = {
262
395
  messageId: message.messageId,
263
- accountId: section?.wechatAccountId || "default",
396
+ accountId: resolvedAccountId,
264
397
  conversationType: isChatroom ? "chatroom" : "friend",
265
398
  conversationId,
266
399
  senderId: message.fromUser || "",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Client Pool - Per-account client management for iPad WeChat
3
+ *
4
+ * Manages iPad (JuHeBot) API clients per account to avoid creating
5
+ * new clients for each message and ensure credential isolation.
6
+ */
7
+ import { createIPadClient } from "./client.js";
8
+ /**
9
+ * Client pool for iPad WeChat clients
10
+ */
11
+ class IPadClientPool {
12
+ clients = new Map();
13
+ configs = new Map();
14
+ /**
15
+ * Get or create a client for the given account
16
+ */
17
+ getClient(accountId, config) {
18
+ // Check if we already have a client and configs match
19
+ const existingConfig = this.configs.get(accountId);
20
+ if (this.clients.has(accountId) && existingConfig) {
21
+ // Verify configs are the same
22
+ if (existingConfig.appKey === config.appKey &&
23
+ existingConfig.appSecret === config.appSecret &&
24
+ existingConfig.guid === config.guid) {
25
+ return this.clients.get(accountId);
26
+ }
27
+ }
28
+ // Create new client
29
+ const client = createIPadClient(config);
30
+ this.clients.set(accountId, client);
31
+ this.configs.set(accountId, config);
32
+ return client;
33
+ }
34
+ /**
35
+ * Check if client exists
36
+ */
37
+ hasClient(accountId) {
38
+ return this.clients.has(accountId);
39
+ }
40
+ /**
41
+ * Remove a client by accountId
42
+ */
43
+ removeClient(accountId) {
44
+ this.configs.delete(accountId);
45
+ return this.clients.delete(accountId);
46
+ }
47
+ /**
48
+ * Clear all clients
49
+ */
50
+ clear() {
51
+ this.clients.clear();
52
+ this.configs.clear();
53
+ }
54
+ /**
55
+ * Get all account IDs with active clients
56
+ */
57
+ getActiveAccountIds() {
58
+ return Array.from(this.clients.keys());
59
+ }
60
+ /**
61
+ * Get pool size
62
+ */
63
+ get size() {
64
+ return this.clients.size;
65
+ }
66
+ }
67
+ // Singleton instance
68
+ let clientPool = null;
69
+ /**
70
+ * Get or create the client pool singleton
71
+ */
72
+ function getClientPool() {
73
+ if (!clientPool) {
74
+ clientPool = new IPadClientPool();
75
+ }
76
+ return clientPool;
77
+ }
78
+ /**
79
+ * Get a client for a specific account
80
+ */
81
+ export function getIPadClient(accountId, config) {
82
+ return getClientPool().getClient(accountId, config);
83
+ }
package/index.ts CHANGED
@@ -3,10 +3,14 @@
3
3
  *
4
4
  * iPad WeChat Plugin - enables OpenClaw to send/receive WeChat messages
5
5
  * through the iPad protocol (different from WorkPhone/phone protocol)
6
+ *
7
+ * Multi-account support:
8
+ * - cfg is passed to handleInboundMessage for cache isolation
9
+ * - Each account's guid can have a dedicated webhook path
6
10
  */
7
11
 
8
12
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
9
- import { ipadWeChatPlugin, handleInboundMessage } from "./src/channel.js";
13
+ import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady } from "./src/channel.js";
10
14
 
11
15
  export default defineChannelPluginEntry({
12
16
  id: "ipad-wechat",
@@ -15,18 +19,63 @@ export default defineChannelPluginEntry({
15
19
  plugin: ipadWeChatPlugin,
16
20
 
17
21
  registerFull(api) {
18
- // Register webhook endpoint for receiving messages
22
+ // Register main webhook endpoint for receiving messages
19
23
  api.registerHttpRoute({
20
24
  path: "/ipad-wechat/webhook",
21
25
  auth: "plugin",
22
26
  handler: async (req, res) => {
23
27
  try {
28
+ // Get config from request
29
+ const cfg = (req as any).cfg;
30
+
31
+ // Wait for cache manager to be ready
32
+ const ready = getCacheManagerReady();
33
+ if (ready) {
34
+ await ready;
35
+ }
36
+
24
37
  const payload = (req as any).body;
25
- // Handle inbound message
26
38
  console.log("[iPad WeChat] Received webhook:", payload);
27
39
 
28
40
  if (payload) {
29
- await handleInboundMessage(api, payload);
41
+ await handleInboundMessage(api, payload, cfg);
42
+ }
43
+
44
+ res.statusCode = 200;
45
+ res.end("ok");
46
+ return true;
47
+ } catch (error) {
48
+ console.error("[iPad WeChat] Webhook error:", error);
49
+ res.statusCode = 500;
50
+ res.end("Internal error");
51
+ return true;
52
+ }
53
+ },
54
+ });
55
+
56
+ // Register per-guid webhook endpoints for multi-account routing
57
+ // These paths allow distinguishing which account received the message
58
+ // Path format: /ipad-wechat/webhook/guid-12345
59
+ api.registerHttpRoute({
60
+ path: "/ipad-wechat/webhook/:guid",
61
+ auth: "plugin",
62
+ handler: async (req, res) => {
63
+ try {
64
+ const cfg = (req as any).cfg;
65
+ const guid = (req as any).params?.guid;
66
+
67
+ // Wait for cache manager to be ready
68
+ const ready = getCacheManagerReady();
69
+ if (ready) {
70
+ await ready;
71
+ }
72
+
73
+ const payload = (req as any).body;
74
+ console.log(`[iPad WeChat] Received webhook for guid ${guid}:`, payload);
75
+
76
+ if (payload) {
77
+ // Pass the guid as overrideAccountId so handleInboundMessage knows which account
78
+ await handleInboundMessage(api, payload, cfg, guid);
30
79
  }
31
80
 
32
81
  res.statusCode = 200;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gloablehive/ipad-wechat-plugin",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for iPad WeChat protocol - enables sending/receiving WeChat messages through iPad protocol",
6
6
  "main": "index.ts",
@@ -20,7 +20,7 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@gloablehive/wechat-cache": "^1.0.1",
23
+ "@gloablehive/wechat-cache": "^1.0.2",
24
24
  "openclaw": ">=1.0.0"
25
25
  },
26
26
  "devDependencies": {
package/src/channel.ts CHANGED
@@ -4,14 +4,10 @@
4
4
  * Uses iPad protocol (different from phone/WorkPhone protocol)
5
5
  * API documentation: wechat/api.md
6
6
  *
7
- * Includes Local Cache (shared with celphone-wechat-plugin):
8
- * - Per-account, per-user/conversation MD files
9
- * - YAML frontmatter (aligned with Claude Code)
10
- * - MEMORY.md indexing
11
- * - 4-layer compression
12
- * - AI summary extraction
13
- * - SAAS connectivity + offline fallback
14
- * - Cloud sync
7
+ * Multi-account support:
8
+ * - Config supports accounts array with per-account credentials
9
+ * - Each account can be bound to a different agent
10
+ * - Security policies are per-account isolated
15
11
  */
16
12
 
17
13
  import {
@@ -19,7 +15,7 @@ import {
19
15
  createChannelPluginBase,
20
16
  } from "openclaw/plugin-sdk/core";
21
17
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
22
- import { createIPadClient, type WebhookPayload } from "./client.js";
18
+ import type { WebhookPayload } from "./client.js";
23
19
 
24
20
  // Import cache modules from shared package
25
21
  import {
@@ -27,10 +23,20 @@ import {
27
23
  CacheManager,
28
24
  WeChatAccount,
29
25
  WeChatMessage,
26
+ AccountRegistry,
27
+ createAccountRegistry,
28
+ RegisteredAccount,
30
29
  } from "@gloablehive/wechat-cache";
31
30
 
31
+ // Import client pool
32
+ import { getIPadClient } from "./client-pool.js";
33
+
32
34
  // Cache manager instance (lazy initialized)
33
35
  let cacheManager: CacheManager | null = null;
36
+ let cacheManagerReady: Promise<void> | null = null;
37
+
38
+ // Account registry for routing
39
+ let accountRegistry: AccountRegistry | null = null;
34
40
 
35
41
  /**
36
42
  * Get config section from either channels or plugins.entries
@@ -40,6 +46,39 @@ function getConfigSection(cfg: any): any {
40
46
  || cfg?.plugins?.entries?.["ipad-wechat"]?.config;
41
47
  }
42
48
 
49
+ /**
50
+ * Get or create account registry
51
+ */
52
+ function getAccountRegistry(): AccountRegistry {
53
+ if (!accountRegistry) {
54
+ accountRegistry = createAccountRegistry();
55
+ }
56
+ return accountRegistry;
57
+ }
58
+
59
+ /**
60
+ * Initialize account registry from config
61
+ */
62
+ function initializeRegistry(cfg: OpenClawConfig): void {
63
+ const registry = getAccountRegistry();
64
+ registry.clear();
65
+
66
+ const section = getConfigSection(cfg);
67
+ const accounts = (section?.accounts || []) as WeChatAccount[];
68
+
69
+ // Register each account
70
+ for (const account of accounts) {
71
+ if (account.enabled !== false) {
72
+ const registeredAccount: RegisteredAccount = {
73
+ ...account,
74
+ channelType: "ipad-wechat",
75
+ registeredAt: Date.now(),
76
+ };
77
+ registry.register(registeredAccount);
78
+ }
79
+ }
80
+ }
81
+
43
82
  /**
44
83
  * Get or create cache manager
45
84
  */
@@ -50,16 +89,22 @@ function getCacheManager(cfg: OpenClawConfig): CacheManager {
50
89
  const accounts = (section?.accounts || []) as WeChatAccount[];
51
90
 
52
91
  // If no accounts configured, create default from main config
53
- if (accounts.length === 0 && section?.wechatAccountId) {
92
+ if (accounts.length === 0 && section?.guid) {
54
93
  accounts.push({
55
94
  accountId: section.accountId || "default",
56
- wechatAccountId: section.wechatAccountId,
95
+ wechatAccountId: section.wechatAccountId || "",
57
96
  wechatId: section.wechatId || "",
58
97
  nickName: section.nickName || "WeChat User",
59
98
  enabled: true,
99
+ appKey: section.appKey,
100
+ appSecret: section.appSecret,
101
+ guid: section.guid,
60
102
  });
61
103
  }
62
104
 
105
+ // Initialize registry with accounts
106
+ initializeRegistry(cfg);
107
+
63
108
  const basePath = section?.cachePath || "./cache/wechat-ipad";
64
109
 
65
110
  cacheManager = createCacheManager({
@@ -67,11 +112,25 @@ function getCacheManager(cfg: OpenClawConfig): CacheManager {
67
112
  accounts,
68
113
  });
69
114
 
115
+ // Initialize cache manager - await for ready state
116
+ cacheManagerReady = cacheManager.init().catch(err => {
117
+ console.error("[iPad WeChat] Cache manager init failed:", err);
118
+ throw err;
119
+ });
120
+
70
121
  return cacheManager;
71
122
  }
72
123
 
124
+ /**
125
+ * Get cache manager ready promise
126
+ */
127
+ export function getCacheManagerReady(): Promise<void> | null {
128
+ return cacheManagerReady;
129
+ }
130
+
73
131
  export interface IPadWeChatResolvedAccount {
74
132
  accountId: string | null;
133
+ agentId: string | null;
75
134
  appKey: string;
76
135
  appSecret: string;
77
136
  guid: string;
@@ -82,12 +141,61 @@ export interface IPadWeChatResolvedAccount {
82
141
  dmPolicy: string | undefined;
83
142
  }
84
143
 
144
+ /**
145
+ * List all account IDs from config
146
+ */
147
+ function listAccountIds(cfg: OpenClawConfig): string[] {
148
+ const section = getConfigSection(cfg);
149
+ const accounts = (section?.accounts || []) as WeChatAccount[];
150
+
151
+ if (accounts.length > 0) {
152
+ return accounts.map(a => a.accountId).filter(Boolean);
153
+ }
154
+
155
+ // Backward compatibility: single account
156
+ if (section?.guid) {
157
+ return [section.accountId || "default"];
158
+ }
159
+
160
+ return [];
161
+ }
162
+
163
+ /**
164
+ * Resolve account from OpenClaw config
165
+ *
166
+ * Supports both multi-account (accounts array) and legacy (single) formats.
167
+ */
85
168
  function resolveAccount(
86
169
  cfg: OpenClawConfig,
87
170
  accountId?: string | null
88
171
  ): IPadWeChatResolvedAccount {
89
172
  const section = getConfigSection(cfg);
173
+ const accounts = (section?.accounts || []) as WeChatAccount[];
174
+
175
+ // Multi-account mode: look up from accounts array
176
+ if (accounts.length > 0) {
177
+ const targetAccountId = accountId || accounts[0]?.accountId;
178
+ const account = accounts.find(a => a.accountId === targetAccountId);
179
+
180
+ if (!account) {
181
+ throw new Error(`ipad-wechat: account not found - ${targetAccountId}`);
182
+ }
183
+
184
+ return {
185
+ accountId: account.accountId,
186
+ agentId: account.agentId || null,
187
+ appKey: account.appKey || section?.appKey || "",
188
+ appSecret: account.appSecret || section?.appSecret || "",
189
+ guid: account.guid || section?.guid || "",
190
+ wechatAccountId: account.wechatAccountId || "",
191
+ wechatId: account.wechatId || "",
192
+ nickName: account.nickName || "WeChat User",
193
+ allowFrom: account.allowFrom ?? [],
194
+ dmPolicy: account.dmPolicy,
195
+ };
196
+ }
90
197
 
198
+ // Legacy single-account mode
91
199
  const appKey = section?.appKey;
92
200
  const appSecret = section?.appSecret;
93
201
  const guid = section?.guid;
@@ -98,6 +206,7 @@ function resolveAccount(
98
206
 
99
207
  return {
100
208
  accountId: accountId ?? null,
209
+ agentId: null,
101
210
  appKey,
102
211
  appSecret,
103
212
  guid,
@@ -123,25 +232,17 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
123
232
  supportsEditing: false,
124
233
  supportsDeleting: false,
125
234
  } as any,
126
- // Config adapter - for runtime account management
127
235
  config: {
128
- listAccountIds: (cfg: any) => {
129
- const section = getConfigSection(cfg);
130
- if (section?.wechatAccountId) {
131
- return [section.wechatAccountId];
132
- }
133
- return ["default"];
134
- },
135
- resolveAccount: (cfg: any, accountId?: string | null) => {
136
- return resolveAccount(cfg, accountId);
137
- },
236
+ listAccountIds: (cfg: any) => listAccountIds(cfg),
237
+ resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
138
238
  defaultAccountId: (cfg: any) => {
139
239
  const section = getConfigSection(cfg);
140
- return section?.wechatAccountId || "default";
240
+ const accounts = (section?.accounts || []) as WeChatAccount[];
241
+ return accounts[0]?.accountId || section?.accountId || "default";
141
242
  },
142
243
  inspectAccount: (cfg: any, accountId?: string | null) => {
143
- const section = getConfigSection(cfg);
144
- const hasCredentials = Boolean(section?.appKey && section?.appSecret && section?.guid);
244
+ const account = resolveAccount(cfg, accountId);
245
+ const hasCredentials = Boolean(account.appKey && account.appSecret && account.guid);
145
246
  return {
146
247
  enabled: hasCredentials,
147
248
  configured: hasCredentials,
@@ -149,15 +250,11 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
149
250
  };
150
251
  },
151
252
  } as any,
152
- // Setup adapter - for initial configuration
153
253
  setup: {
154
254
  resolveAccountId: (params: any) => {
155
255
  return resolveAccount(params.cfg, params.accountId)?.accountId || "";
156
256
  },
157
- applyAccountConfig: (params: any) => {
158
- const { cfg, accountId, input } = params;
159
- return cfg;
160
- },
257
+ applyAccountConfig: (params: any) => params.cfg,
161
258
  },
162
259
  }) as any,
163
260
 
@@ -177,7 +274,6 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
177
274
  outbound: {
178
275
  deliveryMode: "gateway",
179
276
  channel: "ipad-wechat",
180
- // Resolve target - for WeChat, just pass through the ID
181
277
  resolveTarget: (params: any) => {
182
278
  const target = params.to?.trim();
183
279
  if (!target) {
@@ -187,17 +283,19 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
187
283
  },
188
284
  attachedResults: {
189
285
  sendText: async (params: any) => {
190
- // Get config from params.cfg
191
286
  const cfg = params.cfg;
192
- const section = getConfigSection(cfg);
287
+ const accountId = params.accountId;
288
+
289
+ // Resolve account to get credentials
290
+ const account = resolveAccount(cfg, accountId);
193
291
 
194
- const client = createIPadClient({
195
- appKey: section?.appKey,
196
- appSecret: section?.appSecret,
197
- guid: section?.guid,
292
+ // Use client pool for connection reuse
293
+ const client = getIPadClient(account.accountId || "default", {
294
+ appKey: account.appKey,
295
+ appSecret: account.appSecret,
296
+ guid: account.guid,
198
297
  });
199
298
 
200
- // Check if DM or group
201
299
  const isChatroom = params.to?.includes("@chatroom");
202
300
 
203
301
  if (isChatroom) {
@@ -217,35 +315,21 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
217
315
 
218
316
  sendMedia: async (params: any) => {
219
317
  const cfg = params.cfg;
220
- const section = getConfigSection(cfg);
318
+ const accountId = params.accountId;
319
+
320
+ // Resolve account to get credentials
321
+ const account = resolveAccount(cfg, accountId);
221
322
 
222
- const client = createIPadClient({
223
- appKey: section?.appKey,
224
- appSecret: section?.appSecret,
225
- guid: section?.guid,
323
+ // Use client pool
324
+ const client = getIPadClient(account.accountId || "default", {
325
+ appKey: account.appKey,
326
+ appSecret: account.appSecret,
327
+ guid: account.guid,
226
328
  });
227
329
 
228
330
  const isChatroom = params.to?.includes("@chatroom");
229
331
 
230
- // Get file path from mediaUrl or mediaReadFile
231
- let filePath = "";
232
- if (params.mediaUrl) {
233
- filePath = params.mediaUrl;
234
- } else if (params.mediaReadFile) {
235
- // Read file from local roots
236
- const roots = params.mediaLocalRoots || ["./"];
237
- for (const root of roots) {
238
- try {
239
- const fullPath = `${root}/${filePath}`;
240
- const buffer = await params.mediaReadFile(fullPath);
241
- // In a real implementation, we'd upload the file first
242
- filePath = fullPath;
243
- break;
244
- } catch {
245
- // Try next root
246
- }
247
- }
248
- }
332
+ let filePath = params.mediaUrl || "";
249
333
 
250
334
  if (isChatroom) {
251
335
  const result = await client.sendRoomMedia({
@@ -264,6 +348,59 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
264
348
  },
265
349
  },
266
350
 
351
+ directory: {
352
+ self: async (params: any) => {
353
+ const account = resolveAccount(params.cfg, params.accountId);
354
+ return {
355
+ id: account.wechatId || "",
356
+ name: account.nickName || "WeChat User",
357
+ accountId: params.accountId || "default",
358
+ };
359
+ },
360
+ listPeers: async (params: any) => {
361
+ const account = resolveAccount(params.cfg, params.accountId);
362
+ const client = getIPadClient(account.accountId || "default", {
363
+ appKey: account.appKey,
364
+ appSecret: account.appSecret,
365
+ guid: account.guid,
366
+ });
367
+ try {
368
+ const contacts = await client.syncContacts();
369
+ return contacts.map((contact: any) => ({
370
+ id: contact.wechatId || contact.wxid,
371
+ name: contact.nickName || contact.remark || contact.wechatId,
372
+ accountId: params.accountId || "default",
373
+ }));
374
+ } catch (error) {
375
+ console.error("[iPad WeChat] Failed to list peers:", error);
376
+ return [];
377
+ }
378
+ },
379
+ listGroups: async (params: any) => {
380
+ console.log("[iPad WeChat] listGroups: API not available, returning empty");
381
+ return [];
382
+ },
383
+ listGroupMembers: async (params: any) => {
384
+ const account = resolveAccount(params.cfg, params.accountId);
385
+ const client = getIPadClient(account.accountId || "default", {
386
+ appKey: account.appKey,
387
+ appSecret: account.appSecret,
388
+ guid: account.guid,
389
+ });
390
+ try {
391
+ const members = await client.getRoomMembers(params.groupId);
392
+ return members.map((member: any) => ({
393
+ id: member.userId || member.wxid,
394
+ name: member.nickName || member.displayName || member.userId,
395
+ accountId: params.accountId || "default",
396
+ }));
397
+ } catch (error) {
398
+ console.error("[iPad WeChat] Failed to list group members:", error);
399
+ return [];
400
+ }
401
+ },
402
+ },
403
+
267
404
  capabilities: {
268
405
  supportedMessageTypes: ["text", "image", "video", "file", "link", "location", "contact"],
269
406
  maxAttachmentSize: 25 * 1024 * 1024,
@@ -277,10 +414,15 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
277
414
  },
278
415
  } as any);
279
416
 
417
+ /**
418
+ * Handle inbound webhook message
419
+ * Supports multi-account routing via AccountRegistry
420
+ */
280
421
  export async function handleInboundMessage(
281
422
  api: any,
282
423
  payload: WebhookPayload,
283
- cfg?: OpenClawConfig
424
+ cfg?: OpenClawConfig,
425
+ overrideAccountId?: string
284
426
  ): Promise<void> {
285
427
  const { event, message } = payload;
286
428
 
@@ -290,6 +432,24 @@ export async function handleInboundMessage(
290
432
  ? (message as any).roomId
291
433
  : message.fromUser || message.toUser || "";
292
434
 
435
+ // Determine account ID - use override from webhook path or resolve from registry
436
+ let resolvedAccountId = overrideAccountId;
437
+ let agentId: string | null = null;
438
+
439
+ if (cfg && !resolvedAccountId) {
440
+ const registry = getAccountRegistry();
441
+ // Try by guid if available in message
442
+ const messageGuid = (message as any).guid;
443
+ if (messageGuid) {
444
+ agentId = registry.getAgentIdByGuid(messageGuid);
445
+ const regAccount = registry.getByGuid(messageGuid);
446
+ resolvedAccountId = regAccount?.accountId || null;
447
+ }
448
+ }
449
+
450
+ // Default account ID if not resolved
451
+ resolvedAccountId = resolvedAccountId || "default";
452
+
293
453
  const openclawMessage = {
294
454
  id: message.messageId,
295
455
  conversation: {
@@ -305,16 +465,17 @@ export async function handleInboundMessage(
305
465
  },
306
466
  timestamp: new Date(message.timestamp || Date.now()),
307
467
  isSelf: message.isSelf || false,
468
+ agentId,
469
+ accountId: resolvedAccountId,
308
470
  };
309
471
 
310
472
  // Write to cache
311
473
  if (cfg) {
312
474
  try {
313
475
  const cache = getCacheManager(cfg);
314
- const section = getConfigSection(cfg);
315
476
  const wechatMessage: WeChatMessage = {
316
477
  messageId: message.messageId,
317
- accountId: section?.wechatAccountId || "default",
478
+ accountId: resolvedAccountId,
318
479
  conversationType: isChatroom ? "chatroom" : "friend",
319
480
  conversationId,
320
481
  senderId: message.fromUser || "",
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Client Pool - Per-account client management for iPad WeChat
3
+ *
4
+ * Manages iPad (JuHeBot) API clients per account to avoid creating
5
+ * new clients for each message and ensure credential isolation.
6
+ */
7
+
8
+ import { createIPadClient, type JuHeBotConfig } from "./client.js";
9
+
10
+ /**
11
+ * Client pool for iPad WeChat clients
12
+ */
13
+ class IPadClientPool {
14
+ private clients: Map<string, ReturnType<typeof createIPadClient>> = new Map();
15
+ private configs: Map<string, JuHeBotConfig> = new Map();
16
+
17
+ /**
18
+ * Get or create a client for the given account
19
+ */
20
+ getClient(accountId: string, config: JuHeBotConfig): ReturnType<typeof createIPadClient> {
21
+ // Check if we already have a client and configs match
22
+ const existingConfig = this.configs.get(accountId);
23
+ if (this.clients.has(accountId) && existingConfig) {
24
+ // Verify configs are the same
25
+ if (existingConfig.appKey === config.appKey &&
26
+ existingConfig.appSecret === config.appSecret &&
27
+ existingConfig.guid === config.guid) {
28
+ return this.clients.get(accountId)!;
29
+ }
30
+ }
31
+
32
+ // Create new client
33
+ const client = createIPadClient(config);
34
+ this.clients.set(accountId, client);
35
+ this.configs.set(accountId, config);
36
+ return client;
37
+ }
38
+
39
+ /**
40
+ * Check if client exists
41
+ */
42
+ hasClient(accountId: string): boolean {
43
+ return this.clients.has(accountId);
44
+ }
45
+
46
+ /**
47
+ * Remove a client by accountId
48
+ */
49
+ removeClient(accountId: string): boolean {
50
+ this.configs.delete(accountId);
51
+ return this.clients.delete(accountId);
52
+ }
53
+
54
+ /**
55
+ * Clear all clients
56
+ */
57
+ clear(): void {
58
+ this.clients.clear();
59
+ this.configs.clear();
60
+ }
61
+
62
+ /**
63
+ * Get all account IDs with active clients
64
+ */
65
+ getActiveAccountIds(): string[] {
66
+ return Array.from(this.clients.keys());
67
+ }
68
+
69
+ /**
70
+ * Get pool size
71
+ */
72
+ get size(): number {
73
+ return this.clients.size;
74
+ }
75
+ }
76
+
77
+ // Singleton instance
78
+ let clientPool: IPadClientPool | null = null;
79
+
80
+ /**
81
+ * Get or create the client pool singleton
82
+ */
83
+ function getClientPool(): IPadClientPool {
84
+ if (!clientPool) {
85
+ clientPool = new IPadClientPool();
86
+ }
87
+ return clientPool;
88
+ }
89
+
90
+ /**
91
+ * Get a client for a specific account
92
+ */
93
+ export function getIPadClient(
94
+ accountId: string,
95
+ config: JuHeBotConfig
96
+ ): ReturnType<typeof createIPadClient> {
97
+ return getClientPool().getClient(accountId, config);
98
+ }