@gloablehive/ipad-wechat-plugin 1.0.10 → 1.0.12

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: {
@@ -130,13 +206,33 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
130
206
  defaultPolicy: "allowlist",
131
207
  },
132
208
  },
209
+ // Messaging adapter - for target resolution
210
+ messaging: {
211
+ targetResolver: {
212
+ looksLikeId: (raw) => {
213
+ // WeChat IDs typically start with "wxid_" or are phone numbers
214
+ return raw.startsWith("wxid_") || /^\d{5,15}$/.test(raw) || raw.includes("@chatroom");
215
+ },
216
+ resolveTarget: async (params) => {
217
+ const target = params.input?.trim() || params.normalized?.trim();
218
+ if (!target)
219
+ return null;
220
+ // For WeChat, just pass through the ID - it's already valid
221
+ return {
222
+ to: target,
223
+ kind: target.includes("@chatroom") ? "group" : "user",
224
+ display: target,
225
+ source: "normalized"
226
+ };
227
+ },
228
+ },
229
+ },
133
230
  threading: {
134
231
  topLevelReplyToMode: "reply",
135
232
  },
136
233
  outbound: {
137
234
  deliveryMode: "gateway",
138
235
  channel: "ipad-wechat",
139
- // Resolve target - for WeChat, just pass through the ID
140
236
  resolveTarget: (params) => {
141
237
  const target = params.to?.trim();
142
238
  if (!target) {
@@ -146,15 +242,16 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
146
242
  },
147
243
  attachedResults: {
148
244
  sendText: async (params) => {
149
- // Get config from params.cfg
150
245
  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,
246
+ const accountId = params.accountId;
247
+ // Resolve account to get credentials
248
+ const account = resolveAccount(cfg, accountId);
249
+ // Use client pool for connection reuse
250
+ const client = getIPadClient(account.accountId || "default", {
251
+ appKey: account.appKey,
252
+ appSecret: account.appSecret,
253
+ guid: account.guid,
156
254
  });
157
- // Check if DM or group
158
255
  const isChatroom = params.to?.includes("@chatroom");
159
256
  if (isChatroom) {
160
257
  const result = await client.sendRoomMessage({
@@ -173,34 +270,17 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
173
270
  },
174
271
  sendMedia: async (params) => {
175
272
  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,
273
+ const accountId = params.accountId;
274
+ // Resolve account to get credentials
275
+ const account = resolveAccount(cfg, accountId);
276
+ // Use client pool
277
+ const client = getIPadClient(account.accountId || "default", {
278
+ appKey: account.appKey,
279
+ appSecret: account.appSecret,
280
+ guid: account.guid,
181
281
  });
182
282
  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
- }
283
+ let filePath = params.mediaUrl || "";
204
284
  if (isChatroom) {
205
285
  const result = await client.sendRoomMedia({
206
286
  roomId: params.to,
@@ -218,22 +298,21 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
218
298
  },
219
299
  },
220
300
  },
221
- // Directory adapter - for contact resolution
222
301
  directory: {
223
302
  self: async (params) => {
224
- const section = getConfigSection(params.cfg);
303
+ const account = resolveAccount(params.cfg, params.accountId);
225
304
  return {
226
- id: section?.wechatId || "",
227
- name: section?.nickName || "WeChat User",
305
+ id: account.wechatId || "",
306
+ name: account.nickName || "WeChat User",
228
307
  accountId: params.accountId || "default",
229
308
  };
230
309
  },
231
310
  listPeers: async (params) => {
232
- const section = getConfigSection(params.cfg);
233
- const client = createIPadClient({
234
- appKey: section?.appKey,
235
- appSecret: section?.appSecret,
236
- guid: section?.guid,
311
+ const account = resolveAccount(params.cfg, params.accountId);
312
+ const client = getIPadClient(account.accountId || "default", {
313
+ appKey: account.appKey,
314
+ appSecret: account.appSecret,
315
+ guid: account.guid,
237
316
  });
238
317
  try {
239
318
  const contacts = await client.syncContacts();
@@ -249,17 +328,15 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
249
328
  }
250
329
  },
251
330
  listGroups: async (params) => {
252
- // Note: JuHeBot API doesn't have a direct "list all chatrooms" endpoint
253
- // For now, return empty array - users can still send to known chatroom IDs
254
331
  console.log("[iPad WeChat] listGroups: API not available, returning empty");
255
332
  return [];
256
333
  },
257
334
  listGroupMembers: async (params) => {
258
- const section = getConfigSection(params.cfg);
259
- const client = createIPadClient({
260
- appKey: section?.appKey,
261
- appSecret: section?.appSecret,
262
- guid: section?.guid,
335
+ const account = resolveAccount(params.cfg, params.accountId);
336
+ const client = getIPadClient(account.accountId || "default", {
337
+ appKey: account.appKey,
338
+ appSecret: account.appSecret,
339
+ guid: account.guid,
263
340
  });
264
341
  try {
265
342
  const members = await client.getRoomMembers(params.groupId);
@@ -287,13 +364,32 @@ export const ipadWeChatPlugin = createChatChannelPlugin({
287
364
  supportsDeleting: false,
288
365
  },
289
366
  });
290
- export async function handleInboundMessage(api, payload, cfg) {
367
+ /**
368
+ * Handle inbound webhook message
369
+ * Supports multi-account routing via AccountRegistry
370
+ */
371
+ export async function handleInboundMessage(api, payload, cfg, overrideAccountId) {
291
372
  const { event, message } = payload;
292
373
  if (event === "message" && message) {
293
374
  const isChatroom = !!message.roomId;
294
375
  const conversationId = isChatroom
295
376
  ? message.roomId
296
377
  : message.fromUser || message.toUser || "";
378
+ // Determine account ID - use override from webhook path or resolve from registry
379
+ let resolvedAccountId = overrideAccountId;
380
+ let agentId = null;
381
+ if (cfg && !resolvedAccountId) {
382
+ const registry = getAccountRegistry();
383
+ // Try by guid if available in message
384
+ const messageGuid = message.guid;
385
+ if (messageGuid) {
386
+ agentId = registry.getAgentIdByGuid(messageGuid);
387
+ const regAccount = registry.getByGuid(messageGuid);
388
+ resolvedAccountId = regAccount?.accountId || null;
389
+ }
390
+ }
391
+ // Default account ID if not resolved
392
+ resolvedAccountId = resolvedAccountId || "default";
297
393
  const openclawMessage = {
298
394
  id: message.messageId,
299
395
  conversation: {
@@ -309,15 +405,16 @@ export async function handleInboundMessage(api, payload, cfg) {
309
405
  },
310
406
  timestamp: new Date(message.timestamp || Date.now()),
311
407
  isSelf: message.isSelf || false,
408
+ agentId,
409
+ accountId: resolvedAccountId,
312
410
  };
313
411
  // Write to cache
314
412
  if (cfg) {
315
413
  try {
316
414
  const cache = getCacheManager(cfg);
317
- const section = getConfigSection(cfg);
318
415
  const wechatMessage = {
319
416
  messageId: message.messageId,
320
- accountId: section?.wechatAccountId || "default",
417
+ accountId: resolvedAccountId,
321
418
  conversationType: isChatroom ? "chatroom" : "friend",
322
419
  conversationId,
323
420
  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.10",
3
+ "version": "1.0.12",
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
 
@@ -170,6 +267,28 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
170
267
  },
171
268
  },
172
269
 
270
+ // Messaging adapter - for target resolution
271
+ messaging: {
272
+ targetResolver: {
273
+ looksLikeId: (raw: string) => {
274
+ // WeChat IDs typically start with "wxid_" or are phone numbers
275
+ return raw.startsWith("wxid_") || /^\d{5,15}$/.test(raw) || raw.includes("@chatroom");
276
+ },
277
+ resolveTarget: async (params: any) => {
278
+ const target = params.input?.trim() || params.normalized?.trim();
279
+ if (!target) return null;
280
+
281
+ // For WeChat, just pass through the ID - it's already valid
282
+ return {
283
+ to: target,
284
+ kind: target.includes("@chatroom") ? "group" : "user",
285
+ display: target,
286
+ source: "normalized"
287
+ };
288
+ },
289
+ },
290
+ },
291
+
173
292
  threading: {
174
293
  topLevelReplyToMode: "reply",
175
294
  },
@@ -177,7 +296,6 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
177
296
  outbound: {
178
297
  deliveryMode: "gateway",
179
298
  channel: "ipad-wechat",
180
- // Resolve target - for WeChat, just pass through the ID
181
299
  resolveTarget: (params: any) => {
182
300
  const target = params.to?.trim();
183
301
  if (!target) {
@@ -187,17 +305,19 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
187
305
  },
188
306
  attachedResults: {
189
307
  sendText: async (params: any) => {
190
- // Get config from params.cfg
191
308
  const cfg = params.cfg;
192
- const section = getConfigSection(cfg);
309
+ const accountId = params.accountId;
193
310
 
194
- const client = createIPadClient({
195
- appKey: section?.appKey,
196
- appSecret: section?.appSecret,
197
- guid: section?.guid,
311
+ // Resolve account to get credentials
312
+ const account = resolveAccount(cfg, accountId);
313
+
314
+ // Use client pool for connection reuse
315
+ const client = getIPadClient(account.accountId || "default", {
316
+ appKey: account.appKey,
317
+ appSecret: account.appSecret,
318
+ guid: account.guid,
198
319
  });
199
320
 
200
- // Check if DM or group
201
321
  const isChatroom = params.to?.includes("@chatroom");
202
322
 
203
323
  if (isChatroom) {
@@ -217,35 +337,21 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
217
337
 
218
338
  sendMedia: async (params: any) => {
219
339
  const cfg = params.cfg;
220
- const section = getConfigSection(cfg);
340
+ const accountId = params.accountId;
221
341
 
222
- const client = createIPadClient({
223
- appKey: section?.appKey,
224
- appSecret: section?.appSecret,
225
- guid: section?.guid,
342
+ // Resolve account to get credentials
343
+ const account = resolveAccount(cfg, accountId);
344
+
345
+ // Use client pool
346
+ const client = getIPadClient(account.accountId || "default", {
347
+ appKey: account.appKey,
348
+ appSecret: account.appSecret,
349
+ guid: account.guid,
226
350
  });
227
351
 
228
352
  const isChatroom = params.to?.includes("@chatroom");
229
353
 
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
- }
354
+ let filePath = params.mediaUrl || "";
249
355
 
250
356
  if (isChatroom) {
251
357
  const result = await client.sendRoomMedia({
@@ -264,22 +370,21 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
264
370
  },
265
371
  },
266
372
 
267
- // Directory adapter - for contact resolution
268
373
  directory: {
269
374
  self: async (params: any) => {
270
- const section = getConfigSection(params.cfg);
375
+ const account = resolveAccount(params.cfg, params.accountId);
271
376
  return {
272
- id: section?.wechatId || "",
273
- name: section?.nickName || "WeChat User",
377
+ id: account.wechatId || "",
378
+ name: account.nickName || "WeChat User",
274
379
  accountId: params.accountId || "default",
275
380
  };
276
381
  },
277
382
  listPeers: async (params: any) => {
278
- const section = getConfigSection(params.cfg);
279
- const client = createIPadClient({
280
- appKey: section?.appKey,
281
- appSecret: section?.appSecret,
282
- guid: section?.guid,
383
+ const account = resolveAccount(params.cfg, params.accountId);
384
+ const client = getIPadClient(account.accountId || "default", {
385
+ appKey: account.appKey,
386
+ appSecret: account.appSecret,
387
+ guid: account.guid,
283
388
  });
284
389
  try {
285
390
  const contacts = await client.syncContacts();
@@ -294,17 +399,15 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
294
399
  }
295
400
  },
296
401
  listGroups: async (params: any) => {
297
- // Note: JuHeBot API doesn't have a direct "list all chatrooms" endpoint
298
- // For now, return empty array - users can still send to known chatroom IDs
299
402
  console.log("[iPad WeChat] listGroups: API not available, returning empty");
300
403
  return [];
301
404
  },
302
405
  listGroupMembers: async (params: any) => {
303
- const section = getConfigSection(params.cfg);
304
- const client = createIPadClient({
305
- appKey: section?.appKey,
306
- appSecret: section?.appSecret,
307
- guid: section?.guid,
406
+ const account = resolveAccount(params.cfg, params.accountId);
407
+ const client = getIPadClient(account.accountId || "default", {
408
+ appKey: account.appKey,
409
+ appSecret: account.appSecret,
410
+ guid: account.guid,
308
411
  });
309
412
  try {
310
413
  const members = await client.getRoomMembers(params.groupId);
@@ -333,10 +436,15 @@ export const ipadWeChatPlugin = createChatChannelPlugin<IPadWeChatResolvedAccoun
333
436
  },
334
437
  } as any);
335
438
 
439
+ /**
440
+ * Handle inbound webhook message
441
+ * Supports multi-account routing via AccountRegistry
442
+ */
336
443
  export async function handleInboundMessage(
337
444
  api: any,
338
445
  payload: WebhookPayload,
339
- cfg?: OpenClawConfig
446
+ cfg?: OpenClawConfig,
447
+ overrideAccountId?: string
340
448
  ): Promise<void> {
341
449
  const { event, message } = payload;
342
450
 
@@ -346,6 +454,24 @@ export async function handleInboundMessage(
346
454
  ? (message as any).roomId
347
455
  : message.fromUser || message.toUser || "";
348
456
 
457
+ // Determine account ID - use override from webhook path or resolve from registry
458
+ let resolvedAccountId = overrideAccountId;
459
+ let agentId: string | null = null;
460
+
461
+ if (cfg && !resolvedAccountId) {
462
+ const registry = getAccountRegistry();
463
+ // Try by guid if available in message
464
+ const messageGuid = (message as any).guid;
465
+ if (messageGuid) {
466
+ agentId = registry.getAgentIdByGuid(messageGuid);
467
+ const regAccount = registry.getByGuid(messageGuid);
468
+ resolvedAccountId = regAccount?.accountId || null;
469
+ }
470
+ }
471
+
472
+ // Default account ID if not resolved
473
+ resolvedAccountId = resolvedAccountId || "default";
474
+
349
475
  const openclawMessage = {
350
476
  id: message.messageId,
351
477
  conversation: {
@@ -361,16 +487,17 @@ export async function handleInboundMessage(
361
487
  },
362
488
  timestamp: new Date(message.timestamp || Date.now()),
363
489
  isSelf: message.isSelf || false,
490
+ agentId,
491
+ accountId: resolvedAccountId,
364
492
  };
365
493
 
366
494
  // Write to cache
367
495
  if (cfg) {
368
496
  try {
369
497
  const cache = getCacheManager(cfg);
370
- const section = getConfigSection(cfg);
371
498
  const wechatMessage: WeChatMessage = {
372
499
  messageId: message.messageId,
373
- accountId: section?.wechatAccountId || "default",
500
+ accountId: resolvedAccountId,
374
501
  conversationType: isChatroom ? "chatroom" : "friend",
375
502
  conversationId,
376
503
  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
+ }