@gloablehive/ipad-wechat-plugin 2.0.0 → 2.1.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,7 +20,7 @@ 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;
@@ -84,25 +84,104 @@ function extractReplyText(message) {
84
84
  * Open a short-lived WS to the gateway, run a single RPC call, and return the result.
85
85
  */
86
86
  async function gwRpc(token, method, reqParams) {
87
+ const conn = await gwPoolAcquire(token);
88
+ try {
89
+ const result = await rpcCall(conn, method, reqParams);
90
+ gwPoolRelease(conn);
91
+ return result;
92
+ }
93
+ catch (err) {
94
+ // On error, discard this connection from the pool
95
+ gwPoolDiscard(conn);
96
+ throw err;
97
+ }
98
+ }
99
+ // ── Gateway WS Connection Pool ──
100
+ const GW_POOL_MAX = 3;
101
+ const GW_POOL_IDLE_MS = 30_000; // close idle connections after 30s
102
+ const _gwPool = [];
103
+ let _gwPoolTimer = null;
104
+ function _gwPoolStartSweep() {
105
+ if (_gwPoolTimer)
106
+ return;
107
+ _gwPoolTimer = setInterval(() => {
108
+ const now = Date.now();
109
+ for (let i = _gwPool.length - 1; i >= 0; i--) {
110
+ const entry = _gwPool[i];
111
+ if (!entry.busy && now - entry.lastUsed > GW_POOL_IDLE_MS) {
112
+ _gwPool.splice(i, 1);
113
+ try {
114
+ entry.ws.close();
115
+ }
116
+ catch { }
117
+ }
118
+ }
119
+ if (_gwPool.length === 0 && _gwPoolTimer) {
120
+ clearInterval(_gwPoolTimer);
121
+ _gwPoolTimer = null;
122
+ }
123
+ }, 10_000);
124
+ }
125
+ async function gwPoolAcquire(token) {
126
+ // Try to reuse an idle connection with the same token
127
+ for (const entry of _gwPool) {
128
+ if (!entry.busy && entry.token === token && entry.ws.readyState === WebSocket.OPEN) {
129
+ entry.busy = true;
130
+ entry.lastUsed = Date.now();
131
+ return entry.ws;
132
+ }
133
+ }
134
+ // Create a new connection
87
135
  const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
88
136
  const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
89
137
  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);
138
+ // Handshake
139
+ const connectRes = await rpcCall(ws, "connect", {
140
+ minProtocol: 3, maxProtocol: 3,
141
+ client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
142
+ scopes: ["operator.admin", "operator.read", "operator.write"],
143
+ ...(token ? { auth: { token } } : {}),
144
+ });
145
+ if (!connectRes) {
146
+ ws.close();
147
+ throw new Error("Gateway connect failed");
148
+ }
149
+ // Evict oldest idle if pool is full
150
+ if (_gwPool.length >= GW_POOL_MAX) {
151
+ const idleIdx = _gwPool.findIndex(e => !e.busy);
152
+ if (idleIdx >= 0) {
153
+ const evicted = _gwPool.splice(idleIdx, 1)[0];
154
+ try {
155
+ evicted.ws.close();
156
+ }
157
+ catch { }
158
+ }
102
159
  }
103
- finally {
160
+ const entry = { ws, token, busy: true, lastUsed: Date.now() };
161
+ _gwPool.push(entry);
162
+ // Auto-remove on close/error
163
+ ws.on("close", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0)
164
+ _gwPool.splice(idx, 1); });
165
+ ws.on("error", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0)
166
+ _gwPool.splice(idx, 1); });
167
+ _gwPoolStartSweep();
168
+ return ws;
169
+ }
170
+ function gwPoolRelease(ws) {
171
+ const entry = _gwPool.find(e => e.ws === ws);
172
+ if (entry) {
173
+ entry.busy = false;
174
+ entry.lastUsed = Date.now();
175
+ }
176
+ }
177
+ function gwPoolDiscard(ws) {
178
+ const idx = _gwPool.findIndex(e => e.ws === ws);
179
+ if (idx >= 0)
180
+ _gwPool.splice(idx, 1);
181
+ try {
104
182
  ws.close();
105
183
  }
184
+ catch { }
106
185
  }
107
186
  /**
108
187
  * Send a single WS RPC request and wait for the response (with 30s timeout).
@@ -187,11 +266,31 @@ async function dispatchViaGateway(params) {
187
266
  console.error(`[iPad WeChat] Poll ${i + 1} error:`, pollErr);
188
267
  }
189
268
  }
190
- // Step 4: Send outbound via JuHeBot API
269
+ // Step 4: Send outbound via JuHeBot API + cache
191
270
  if (replyText) {
192
271
  try {
193
272
  await sendToWeChat(cfg, params.from, replyText, params.accountId);
194
273
  console.log(`[iPad WeChat] ✅ Outbound sent to ${params.from}`);
274
+ // Write outbound message to cache
275
+ try {
276
+ const cache = getCacheManager(cfg);
277
+ const isChatroom = params.from.includes("@chatroom");
278
+ await cache.onMessage({
279
+ messageId: `out_${Date.now()}`,
280
+ accountId: params.accountId || "default",
281
+ conversationType: isChatroom ? "chatroom" : "friend",
282
+ conversationId: params.from,
283
+ senderId: params.to || "self",
284
+ content: replyText,
285
+ messageType: 1,
286
+ timestamp: Date.now(),
287
+ isSelf: true,
288
+ direction: "outbound",
289
+ });
290
+ }
291
+ catch (cacheErr) {
292
+ console.error("[iPad WeChat] Outbound cache write failed:", cacheErr);
293
+ }
195
294
  }
196
295
  catch (sendErr) {
197
296
  console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
@@ -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,7 +21,7 @@ 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
 
@@ -90,25 +90,95 @@ function extractReplyText(message: any): string {
90
90
  * Open a short-lived WS to the gateway, run a single RPC call, and return the result.
91
91
  */
92
92
  async function gwRpc(token: string, method: string, reqParams: any): Promise<any> {
93
+ const conn = await gwPoolAcquire(token);
94
+ try {
95
+ const result = await rpcCall(conn, method, reqParams);
96
+ gwPoolRelease(conn);
97
+ return result;
98
+ } catch (err) {
99
+ // On error, discard this connection from the pool
100
+ gwPoolDiscard(conn);
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ // ── Gateway WS Connection Pool ──
106
+ const GW_POOL_MAX = 3;
107
+ const GW_POOL_IDLE_MS = 30_000; // close idle connections after 30s
108
+ const _gwPool: { ws: WebSocket; token: string; busy: boolean; lastUsed: number }[] = [];
109
+ let _gwPoolTimer: ReturnType<typeof setInterval> | null = null;
110
+
111
+ function _gwPoolStartSweep() {
112
+ if (_gwPoolTimer) return;
113
+ _gwPoolTimer = setInterval(() => {
114
+ const now = Date.now();
115
+ for (let i = _gwPool.length - 1; i >= 0; i--) {
116
+ const entry = _gwPool[i];
117
+ if (!entry.busy && now - entry.lastUsed > GW_POOL_IDLE_MS) {
118
+ _gwPool.splice(i, 1);
119
+ try { entry.ws.close(); } catch {}
120
+ }
121
+ }
122
+ if (_gwPool.length === 0 && _gwPoolTimer) {
123
+ clearInterval(_gwPoolTimer);
124
+ _gwPoolTimer = null;
125
+ }
126
+ }, 10_000);
127
+ }
128
+
129
+ async function gwPoolAcquire(token: string): Promise<WebSocket> {
130
+ // Try to reuse an idle connection with the same token
131
+ for (const entry of _gwPool) {
132
+ if (!entry.busy && entry.token === token && entry.ws.readyState === WebSocket.OPEN) {
133
+ entry.busy = true;
134
+ entry.lastUsed = Date.now();
135
+ return entry.ws;
136
+ }
137
+ }
138
+
139
+ // Create a new connection
93
140
  const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
94
141
  const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
95
142
  await new Promise<void>((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
96
143
 
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();
144
+ // Handshake
145
+ const connectRes = await rpcCall(ws, "connect", {
146
+ minProtocol: 3, maxProtocol: 3,
147
+ client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
148
+ scopes: ["operator.admin", "operator.read", "operator.write"],
149
+ ...(token ? { auth: { token } } : {}),
150
+ });
151
+ if (!connectRes) { ws.close(); throw new Error("Gateway connect failed"); }
152
+
153
+ // Evict oldest idle if pool is full
154
+ if (_gwPool.length >= GW_POOL_MAX) {
155
+ const idleIdx = _gwPool.findIndex(e => !e.busy);
156
+ if (idleIdx >= 0) {
157
+ const evicted = _gwPool.splice(idleIdx, 1)[0];
158
+ try { evicted.ws.close(); } catch {}
159
+ }
111
160
  }
161
+
162
+ const entry = { ws, token, busy: true, lastUsed: Date.now() };
163
+ _gwPool.push(entry);
164
+
165
+ // Auto-remove on close/error
166
+ ws.on("close", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0) _gwPool.splice(idx, 1); });
167
+ ws.on("error", () => { const idx = _gwPool.indexOf(entry); if (idx >= 0) _gwPool.splice(idx, 1); });
168
+
169
+ _gwPoolStartSweep();
170
+ return ws;
171
+ }
172
+
173
+ function gwPoolRelease(ws: WebSocket) {
174
+ const entry = _gwPool.find(e => e.ws === ws);
175
+ if (entry) { entry.busy = false; entry.lastUsed = Date.now(); }
176
+ }
177
+
178
+ function gwPoolDiscard(ws: WebSocket) {
179
+ const idx = _gwPool.findIndex(e => e.ws === ws);
180
+ if (idx >= 0) _gwPool.splice(idx, 1);
181
+ try { ws.close(); } catch {}
112
182
  }
113
183
 
114
184
  /**
@@ -207,11 +277,31 @@ async function dispatchViaGateway(params: {
207
277
  }
208
278
  }
209
279
 
210
- // Step 4: Send outbound via JuHeBot API
280
+ // Step 4: Send outbound via JuHeBot API + cache
211
281
  if (replyText) {
212
282
  try {
213
283
  await sendToWeChat(cfg, params.from, replyText, params.accountId);
214
284
  console.log(`[iPad WeChat] ✅ Outbound sent to ${params.from}`);
285
+
286
+ // Write outbound message to cache
287
+ try {
288
+ const cache = getCacheManager(cfg);
289
+ const isChatroom = params.from.includes("@chatroom");
290
+ await cache.onMessage({
291
+ messageId: `out_${Date.now()}`,
292
+ accountId: params.accountId || "default",
293
+ conversationType: isChatroom ? "chatroom" : "friend",
294
+ conversationId: params.from,
295
+ senderId: params.to || "self",
296
+ content: replyText,
297
+ messageType: 1,
298
+ timestamp: Date.now(),
299
+ isSelf: true,
300
+ direction: "outbound",
301
+ });
302
+ } catch (cacheErr) {
303
+ console.error("[iPad WeChat] Outbound cache write failed:", cacheErr);
304
+ }
215
305
  } catch (sendErr) {
216
306
  console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
217
307
  }
@@ -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.1.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);