@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 +6 -0
- package/dist/index.js +114 -15
- package/dist/src/channel.js +1 -1
- package/index.ts +106 -16
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.ts +1 -1
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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);
|
package/dist/src/channel.js
CHANGED
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
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);
|