@gloablehive/ipad-wechat-plugin 2.0.0 → 2.2.0

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/README.md CHANGED
@@ -114,6 +114,12 @@ All JuHeBot API parameters use **snake_case**:
114
114
 
115
115
  ## Version History
116
116
 
117
+ ### 2.1.0
118
+
119
+ - **Outbound message caching**: Agent replies are written to `@gloablehive/wechat-cache` after successful JuHeBot send (direction: `outbound`, isSelf: `true`)
120
+ - **WS connection pool**: Reuse gateway WebSocket connections instead of opening a new one per RPC call. Pool of up to 3 connections with 30s idle timeout. Reduces connection overhead during `chat.history` polling from ~42 to ~3 connections per message.
121
+ - **Cloudflare named tunnel script**: `scripts/setup-cloudflare-tunnel.sh` for permanent tunnel URL that survives restarts. Auto-updates JuHeBot `notifyUrl` on start.
122
+
117
123
  ### 2.0.0
118
124
 
119
125
  - Full end-to-end inbound + outbound message flow
package/dist/index.js CHANGED
@@ -20,16 +20,19 @@ import { homedir } from "os";
20
20
  import { randomUUID } from "crypto";
21
21
  import WebSocket from "ws";
22
22
  import { defineChannelPluginEntry, buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/core";
23
- import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady } from "./src/channel.js";
23
+ import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady, getCacheManager } from "./src/channel.js";
24
24
  import { getIPadClient } from "./src/client-pool.js";
25
25
  /** Read OpenClaw main config from disk so standalone server has full cfg */
26
26
  let _cfgCache = null;
27
+ let _cfgCacheTs = 0;
28
+ const CFG_CACHE_TTL_MS = 30_000; // re-read config every 30s to pick up binding changes
27
29
  function loadOpenClawConfig() {
28
- if (_cfgCache)
30
+ if (_cfgCache && (Date.now() - _cfgCacheTs) < CFG_CACHE_TTL_MS)
29
31
  return _cfgCache;
30
32
  try {
31
33
  const p = join(homedir(), ".openclaw", "openclaw.json");
32
34
  _cfgCache = JSON.parse(readFileSync(p, "utf-8"));
35
+ _cfgCacheTs = Date.now();
33
36
  return _cfgCache;
34
37
  }
35
38
  catch (err) {
@@ -37,6 +40,24 @@ function loadOpenClawConfig() {
37
40
  return null;
38
41
  }
39
42
  }
43
+ /**
44
+ * Resolve agentId from config bindings for a given channel/accountId.
45
+ * Falls back to "main" if no binding matches.
46
+ */
47
+ function resolveAgentFromBindings(cfg, channel, accountId) {
48
+ const bindings = cfg?.bindings ?? [];
49
+ // Try specific match first (channel + accountId)
50
+ if (accountId) {
51
+ const specific = bindings.find((b) => b.match?.channel === channel && b.match?.accountId === accountId);
52
+ if (specific?.agentId)
53
+ return specific.agentId;
54
+ }
55
+ // Then try channel-only match
56
+ const channelOnly = bindings.find((b) => b.match?.channel === channel && !b.match?.accountId);
57
+ if (channelOnly?.agentId)
58
+ return channelOnly.agentId;
59
+ return "main";
60
+ }
40
61
  // ── Gateway WebSocket dispatch ──
41
62
  const GATEWAY_PORT = parseInt(process.env.OPENCLAW_GATEWAY_PORT || "18789", 10);
42
63
  let _gwReqId = 0;
@@ -84,25 +105,104 @@ function extractReplyText(message) {
84
105
  * Open a short-lived WS to the gateway, run a single RPC call, and return the result.
85
106
  */
86
107
  async function gwRpc(token, method, reqParams) {
108
+ const conn = await gwPoolAcquire(token);
109
+ try {
110
+ const result = await rpcCall(conn, method, reqParams);
111
+ gwPoolRelease(conn);
112
+ return result;
113
+ }
114
+ catch (err) {
115
+ // On error, discard this connection from the pool
116
+ gwPoolDiscard(conn);
117
+ throw err;
118
+ }
119
+ }
120
+ // ── Gateway WS Connection Pool ──
121
+ const GW_POOL_MAX = 3;
122
+ const GW_POOL_IDLE_MS = 30_000; // close idle connections after 30s
123
+ const _gwPool = [];
124
+ let _gwPoolTimer = null;
125
+ function _gwPoolStartSweep() {
126
+ if (_gwPoolTimer)
127
+ return;
128
+ _gwPoolTimer = setInterval(() => {
129
+ const now = Date.now();
130
+ for (let i = _gwPool.length - 1; i >= 0; i--) {
131
+ const entry = _gwPool[i];
132
+ if (!entry.busy && now - entry.lastUsed > GW_POOL_IDLE_MS) {
133
+ _gwPool.splice(i, 1);
134
+ try {
135
+ entry.ws.close();
136
+ }
137
+ catch { }
138
+ }
139
+ }
140
+ if (_gwPool.length === 0 && _gwPoolTimer) {
141
+ clearInterval(_gwPoolTimer);
142
+ _gwPoolTimer = null;
143
+ }
144
+ }, 10_000);
145
+ }
146
+ async function gwPoolAcquire(token) {
147
+ // Try to reuse an idle connection with the same token
148
+ for (const entry of _gwPool) {
149
+ if (!entry.busy && entry.token === token && entry.ws.readyState === WebSocket.OPEN) {
150
+ entry.busy = true;
151
+ entry.lastUsed = Date.now();
152
+ return entry.ws;
153
+ }
154
+ }
155
+ // Create a new connection
87
156
  const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
88
157
  const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
89
158
  await new Promise((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
90
- try {
91
- // Handshake
92
- const connectRes = await rpcCall(ws, "connect", {
93
- minProtocol: 3, maxProtocol: 3,
94
- client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
95
- scopes: ["operator.admin", "operator.read", "operator.write"],
96
- ...(token ? { auth: { token } } : {}),
97
- });
98
- if (!connectRes)
99
- throw new Error("Gateway connect failed");
100
- // Execute the method
101
- return await rpcCall(ws, method, reqParams);
159
+ // Handshake
160
+ const connectRes = await rpcCall(ws, "connect", {
161
+ minProtocol: 3, maxProtocol: 3,
162
+ client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
163
+ scopes: ["operator.admin", "operator.read", "operator.write"],
164
+ ...(token ? { auth: { token } } : {}),
165
+ });
166
+ if (!connectRes) {
167
+ ws.close();
168
+ throw new Error("Gateway connect failed");
169
+ }
170
+ // Evict oldest idle if pool is full
171
+ if (_gwPool.length >= GW_POOL_MAX) {
172
+ const idleIdx = _gwPool.findIndex(e => !e.busy);
173
+ if (idleIdx >= 0) {
174
+ const evicted = _gwPool.splice(idleIdx, 1)[0];
175
+ try {
176
+ evicted.ws.close();
177
+ }
178
+ catch { }
179
+ }
180
+ }
181
+ const entry = { ws, token, busy: true, lastUsed: Date.now() };
182
+ _gwPool.push(entry);
183
+ // Auto-remove on close/error
184
+ ws.on("close", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0)
185
+ _gwPool.splice(idx, 1); });
186
+ ws.on("error", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0)
187
+ _gwPool.splice(idx, 1); });
188
+ _gwPoolStartSweep();
189
+ return ws;
190
+ }
191
+ function gwPoolRelease(ws) {
192
+ const entry = _gwPool.find(e => e.ws === ws);
193
+ if (entry) {
194
+ entry.busy = false;
195
+ entry.lastUsed = Date.now();
102
196
  }
103
- finally {
197
+ }
198
+ function gwPoolDiscard(ws) {
199
+ const idx = _gwPool.findIndex(e => e.ws === ws);
200
+ if (idx >= 0)
201
+ _gwPool.splice(idx, 1);
202
+ try {
104
203
  ws.close();
105
204
  }
205
+ catch { }
106
206
  }
107
207
  /**
108
208
  * Send a single WS RPC request and wait for the response (with 30s timeout).
@@ -187,11 +287,31 @@ async function dispatchViaGateway(params) {
187
287
  console.error(`[iPad WeChat] Poll ${i + 1} error:`, pollErr);
188
288
  }
189
289
  }
190
- // Step 4: Send outbound via JuHeBot API
290
+ // Step 4: Send outbound via JuHeBot API + cache
191
291
  if (replyText) {
192
292
  try {
193
293
  await sendToWeChat(cfg, params.from, replyText, params.accountId);
194
294
  console.log(`[iPad WeChat] ✅ Outbound sent to ${params.from}`);
295
+ // Write outbound message to cache
296
+ try {
297
+ const cache = getCacheManager(cfg);
298
+ const isChatroom = params.from.includes("@chatroom");
299
+ await cache.onMessage({
300
+ messageId: `out_${Date.now()}`,
301
+ accountId: params.accountId || "default",
302
+ conversationType: isChatroom ? "chatroom" : "friend",
303
+ conversationId: params.from,
304
+ senderId: params.to || "self",
305
+ content: replyText,
306
+ messageType: 1,
307
+ timestamp: Date.now(),
308
+ isSelf: true,
309
+ direction: "outbound",
310
+ });
311
+ }
312
+ catch (cacheErr) {
313
+ console.error("[iPad WeChat] Outbound cache write failed:", cacheErr);
314
+ }
195
315
  }
196
316
  catch (sendErr) {
197
317
  console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
@@ -347,10 +467,12 @@ export default defineChannelPluginEntry({
347
467
  const isChatroom = !!msg.roomId;
348
468
  const peerId = isChatroom ? msg.roomId : (msg.fromUser || msg.toUser || "");
349
469
  const peerKind = isChatroom ? "group" : "direct";
350
- let sessionKey = `agent:main:ipad-wechat:${peerKind}:${peerId}`;
470
+ const resolvedAgent = resolveAgentFromBindings(cfg, "ipad-wechat");
471
+ console.log(`[iPad WeChat] Resolved agent: ${resolvedAgent} (from bindings)`);
472
+ let sessionKey = `agent:${resolvedAgent}:ipad-wechat:${peerKind}:${peerId}`;
351
473
  try {
352
474
  const route = buildChannelOutboundSessionRoute({
353
- cfg, agentId: "main", channel: "ipad-wechat",
475
+ cfg, agentId: resolvedAgent, channel: "ipad-wechat",
354
476
  peer: { kind: peerKind, id: peerId },
355
477
  chatType: peerKind,
356
478
  from: msg.toUser || "", to: msg.fromUser || "",
@@ -58,7 +58,7 @@ function initializeRegistry(cfg) {
58
58
  /**
59
59
  * Get or create cache manager
60
60
  */
61
- function getCacheManager(cfg) {
61
+ export function getCacheManager(cfg) {
62
62
  if (cacheManager)
63
63
  return cacheManager;
64
64
  const section = getConfigSection(cfg);
package/index.ts CHANGED
@@ -21,17 +21,20 @@ import { homedir } from "os";
21
21
  import { randomUUID } from "crypto";
22
22
  import WebSocket from "ws";
23
23
  import { defineChannelPluginEntry, buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/core";
24
- import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady } from "./src/channel.js";
24
+ import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady, getCacheManager } from "./src/channel.js";
25
25
  import { getIPadClient } from "./src/client-pool.js";
26
26
  import type { WebhookPayload } from "./src/client.js";
27
27
 
28
28
  /** Read OpenClaw main config from disk so standalone server has full cfg */
29
29
  let _cfgCache: any = null;
30
+ let _cfgCacheTs = 0;
31
+ const CFG_CACHE_TTL_MS = 30_000; // re-read config every 30s to pick up binding changes
30
32
  function loadOpenClawConfig(): any {
31
- if (_cfgCache) return _cfgCache;
33
+ if (_cfgCache && (Date.now() - _cfgCacheTs) < CFG_CACHE_TTL_MS) return _cfgCache;
32
34
  try {
33
35
  const p = join(homedir(), ".openclaw", "openclaw.json");
34
36
  _cfgCache = JSON.parse(readFileSync(p, "utf-8"));
37
+ _cfgCacheTs = Date.now();
35
38
  return _cfgCache;
36
39
  } catch (err) {
37
40
  console.error("[iPad WeChat] Failed to load openclaw.json:", err);
@@ -39,6 +42,27 @@ function loadOpenClawConfig(): any {
39
42
  }
40
43
  }
41
44
 
45
+ /**
46
+ * Resolve agentId from config bindings for a given channel/accountId.
47
+ * Falls back to "main" if no binding matches.
48
+ */
49
+ function resolveAgentFromBindings(cfg: any, channel: string, accountId?: string): string {
50
+ const bindings: any[] = cfg?.bindings ?? [];
51
+ // Try specific match first (channel + accountId)
52
+ if (accountId) {
53
+ const specific = bindings.find(
54
+ (b: any) => b.match?.channel === channel && b.match?.accountId === accountId
55
+ );
56
+ if (specific?.agentId) return specific.agentId;
57
+ }
58
+ // Then try channel-only match
59
+ const channelOnly = bindings.find(
60
+ (b: any) => b.match?.channel === channel && !b.match?.accountId
61
+ );
62
+ if (channelOnly?.agentId) return channelOnly.agentId;
63
+ return "main";
64
+ }
65
+
42
66
  // ── Gateway WebSocket dispatch ──
43
67
  const GATEWAY_PORT = parseInt(process.env.OPENCLAW_GATEWAY_PORT || "18789", 10);
44
68
  let _gwReqId = 0;
@@ -90,25 +114,95 @@ function extractReplyText(message: any): string {
90
114
  * Open a short-lived WS to the gateway, run a single RPC call, and return the result.
91
115
  */
92
116
  async function gwRpc(token: string, method: string, reqParams: any): Promise<any> {
117
+ const conn = await gwPoolAcquire(token);
118
+ try {
119
+ const result = await rpcCall(conn, method, reqParams);
120
+ gwPoolRelease(conn);
121
+ return result;
122
+ } catch (err) {
123
+ // On error, discard this connection from the pool
124
+ gwPoolDiscard(conn);
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ // ── Gateway WS Connection Pool ──
130
+ const GW_POOL_MAX = 3;
131
+ const GW_POOL_IDLE_MS = 30_000; // close idle connections after 30s
132
+ const _gwPool: { ws: WebSocket; token: string; busy: boolean; lastUsed: number }[] = [];
133
+ let _gwPoolTimer: ReturnType<typeof setInterval> | null = null;
134
+
135
+ function _gwPoolStartSweep() {
136
+ if (_gwPoolTimer) return;
137
+ _gwPoolTimer = setInterval(() => {
138
+ const now = Date.now();
139
+ for (let i = _gwPool.length - 1; i >= 0; i--) {
140
+ const entry = _gwPool[i];
141
+ if (!entry.busy && now - entry.lastUsed > GW_POOL_IDLE_MS) {
142
+ _gwPool.splice(i, 1);
143
+ try { entry.ws.close(); } catch {}
144
+ }
145
+ }
146
+ if (_gwPool.length === 0 && _gwPoolTimer) {
147
+ clearInterval(_gwPoolTimer);
148
+ _gwPoolTimer = null;
149
+ }
150
+ }, 10_000);
151
+ }
152
+
153
+ async function gwPoolAcquire(token: string): Promise<WebSocket> {
154
+ // Try to reuse an idle connection with the same token
155
+ for (const entry of _gwPool) {
156
+ if (!entry.busy && entry.token === token && entry.ws.readyState === WebSocket.OPEN) {
157
+ entry.busy = true;
158
+ entry.lastUsed = Date.now();
159
+ return entry.ws;
160
+ }
161
+ }
162
+
163
+ // Create a new connection
93
164
  const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
94
165
  const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
95
166
  await new Promise<void>((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
96
167
 
97
- try {
98
- // Handshake
99
- const connectRes = await rpcCall(ws, "connect", {
100
- minProtocol: 3, maxProtocol: 3,
101
- client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
102
- scopes: ["operator.admin", "operator.read", "operator.write"],
103
- ...(token ? { auth: { token } } : {}),
104
- });
105
- if (!connectRes) throw new Error("Gateway connect failed");
106
-
107
- // Execute the method
108
- return await rpcCall(ws, method, reqParams);
109
- } finally {
110
- ws.close();
168
+ // Handshake
169
+ const connectRes = await rpcCall(ws, "connect", {
170
+ minProtocol: 3, maxProtocol: 3,
171
+ client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
172
+ scopes: ["operator.admin", "operator.read", "operator.write"],
173
+ ...(token ? { auth: { token } } : {}),
174
+ });
175
+ if (!connectRes) { ws.close(); throw new Error("Gateway connect failed"); }
176
+
177
+ // Evict oldest idle if pool is full
178
+ if (_gwPool.length >= GW_POOL_MAX) {
179
+ const idleIdx = _gwPool.findIndex(e => !e.busy);
180
+ if (idleIdx >= 0) {
181
+ const evicted = _gwPool.splice(idleIdx, 1)[0];
182
+ try { evicted.ws.close(); } catch {}
183
+ }
111
184
  }
185
+
186
+ const entry = { ws, token, busy: true, lastUsed: Date.now() };
187
+ _gwPool.push(entry);
188
+
189
+ // Auto-remove on close/error
190
+ ws.on("close", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0) _gwPool.splice(idx, 1); });
191
+ ws.on("error", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0) _gwPool.splice(idx, 1); });
192
+
193
+ _gwPoolStartSweep();
194
+ return ws;
195
+ }
196
+
197
+ function gwPoolRelease(ws: WebSocket) {
198
+ const entry = _gwPool.find(e => e.ws === ws);
199
+ if (entry) { entry.busy = false; entry.lastUsed = Date.now(); }
200
+ }
201
+
202
+ function gwPoolDiscard(ws: WebSocket) {
203
+ const idx = _gwPool.findIndex(e => e.ws === ws);
204
+ if (idx >= 0) _gwPool.splice(idx, 1);
205
+ try { ws.close(); } catch {}
112
206
  }
113
207
 
114
208
  /**
@@ -207,11 +301,31 @@ async function dispatchViaGateway(params: {
207
301
  }
208
302
  }
209
303
 
210
- // Step 4: Send outbound via JuHeBot API
304
+ // Step 4: Send outbound via JuHeBot API + cache
211
305
  if (replyText) {
212
306
  try {
213
307
  await sendToWeChat(cfg, params.from, replyText, params.accountId);
214
308
  console.log(`[iPad WeChat] ✅ Outbound sent to ${params.from}`);
309
+
310
+ // Write outbound message to cache
311
+ try {
312
+ const cache = getCacheManager(cfg);
313
+ const isChatroom = params.from.includes("@chatroom");
314
+ await cache.onMessage({
315
+ messageId: `out_${Date.now()}`,
316
+ accountId: params.accountId || "default",
317
+ conversationType: isChatroom ? "chatroom" : "friend",
318
+ conversationId: params.from,
319
+ senderId: params.to || "self",
320
+ content: replyText,
321
+ messageType: 1,
322
+ timestamp: Date.now(),
323
+ isSelf: true,
324
+ direction: "outbound",
325
+ });
326
+ } catch (cacheErr) {
327
+ console.error("[iPad WeChat] Outbound cache write failed:", cacheErr);
328
+ }
215
329
  } catch (sendErr) {
216
330
  console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
217
331
  }
@@ -373,10 +487,13 @@ export default defineChannelPluginEntry({
373
487
  const peerId = isChatroom ? (msg as any).roomId : (msg.fromUser || msg.toUser || "");
374
488
  const peerKind = isChatroom ? "group" : "direct";
375
489
 
376
- let sessionKey = `agent:main:ipad-wechat:${peerKind}:${peerId}`;
490
+ const resolvedAgent = resolveAgentFromBindings(cfg, "ipad-wechat");
491
+ console.log(`[iPad WeChat] Resolved agent: ${resolvedAgent} (from bindings)`);
492
+
493
+ let sessionKey = `agent:${resolvedAgent}:ipad-wechat:${peerKind}:${peerId}`;
377
494
  try {
378
495
  const route = buildChannelOutboundSessionRoute({
379
- cfg, agentId: "main", channel: "ipad-wechat",
496
+ cfg, agentId: resolvedAgent, channel: "ipad-wechat",
380
497
  peer: { kind: peerKind as any, id: peerId },
381
498
  chatType: peerKind as any,
382
499
  from: msg.toUser || "", to: msg.fromUser || "",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://openclaw.ai/schema/plugin.json",
3
3
  "id": "ipad-wechat",
4
4
  "name": "iPad WeChat",
5
- "version": "2.0.0",
5
+ "version": "2.1.0",
6
6
  "description": "Connect OpenClaw to iPad WeChat protocol for sending and receiving WeChat messages",
7
7
  "author": {
8
8
  "name": "gloablehive",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gloablehive/ipad-wechat-plugin",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for iPad WeChat protocol - enables sending/receiving WeChat messages through JuHeBot API",
6
6
  "main": "index.ts",
package/src/channel.ts CHANGED
@@ -83,7 +83,7 @@ function initializeRegistry(cfg: OpenClawConfig): void {
83
83
  /**
84
84
  * Get or create cache manager
85
85
  */
86
- function getCacheManager(cfg: OpenClawConfig): CacheManager {
86
+ export function getCacheManager(cfg: OpenClawConfig): CacheManager {
87
87
  if (cacheManager) return cacheManager;
88
88
 
89
89
  const section = getConfigSection(cfg);