@gloablehive/ipad-wechat-plugin 1.0.26 → 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 ADDED
@@ -0,0 +1,141 @@
1
+ # @gloablehive/ipad-wechat-plugin
2
+
3
+ OpenClaw channel plugin for iPad WeChat protocol — enables sending and receiving WeChat messages through the JuHeBot API.
4
+
5
+ ## Features
6
+
7
+ - **Inbound**: Receive WeChat messages via JuHeBot webhook callbacks
8
+ - **Outbound**: Auto-reply via agent → JuHeBot `send_text` API
9
+ - **Multi-account**: Support multiple WeChat accounts via config
10
+ - **Deduplication**: Prevents duplicate message processing (msg_id TTL)
11
+ - **Chatroom support**: Direct messages and group chats
12
+ - **Message caching**: Integrated with `@gloablehive/wechat-cache`
13
+
14
+ ## Architecture
15
+
16
+ ```
17
+ WeChat User
18
+ ↓ (message)
19
+ JuHeBot Cloud (notify_type=1010)
20
+ ↓ (HTTP POST callback)
21
+ Cloudflare Tunnel / Public IP
22
+
23
+ Standalone HTTP Server (:18790)
24
+ ↓ transformPayload() + dedup
25
+ Gateway WebSocket (chat.send → chat.history polling)
26
+
27
+ OpenClaw Agent (processes message)
28
+ ↓ (agent reply detected via polling)
29
+ JuHeBot API (/msg/send_text, to_username)
30
+
31
+ WeChat User (receives reply)
32
+ ```
33
+
34
+ ## Setup
35
+
36
+ ### 1. Install
37
+
38
+ ```bash
39
+ npm install @gloablehive/ipad-wechat-plugin
40
+ ```
41
+
42
+ ### 2. Configure OpenClaw
43
+
44
+ Add to your `openclaw.json` under `plugins.entries`:
45
+
46
+ ```json
47
+ {
48
+ "ipad-wechat": {
49
+ "config": {
50
+ "appKey": "<your-juhebot-app-key>",
51
+ "appSecret": "<your-juhebot-app-secret>",
52
+ "guid": "<your-instance-guid>"
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### 3. Set JuHeBot Notify URL
59
+
60
+ After the plugin starts (webhook server on port 18790), set the callback URL:
61
+
62
+ ```bash
63
+ curl -X POST "https://chat-api.juhebot.com/open/GuidRequest" \
64
+ -H "Content-Type: application/json" \
65
+ -d '{
66
+ "app_key": "<appKey>",
67
+ "app_secret": "<appSecret>",
68
+ "path": "/client/set_notify_url",
69
+ "data": {
70
+ "guid": "<guid>",
71
+ "notify_url": "https://<your-public-url>/"
72
+ }
73
+ }'
74
+ ```
75
+
76
+ > Use a Cloudflare tunnel (`cloudflared tunnel --url http://127.0.0.1:18790`) if you don't have a public IP.
77
+
78
+ ### 4. Environment Variables
79
+
80
+ | Variable | Default | Description |
81
+ |---|---|---|
82
+ | `IPAD_WECHAT_WEBHOOK_PORT` | `18790` | Standalone webhook server port |
83
+ | `OPENCLAW_GATEWAY_PORT` | `18789` | OpenClaw gateway WS port |
84
+
85
+ ## JuHeBot API Notes
86
+
87
+ All JuHeBot API parameters use **snake_case**:
88
+
89
+ | Method | Path | Key Params |
90
+ |---|---|---|
91
+ | Send text | `/msg/send_text` | `to_username`, `content` |
92
+ | Send room @ | `/msg/send_room_at` | `to_username`, `content`, `at_list` |
93
+ | Set notify URL | `/client/set_notify_url` | `notify_url` |
94
+
95
+ ### Callback Format (notify_type=1010)
96
+
97
+ ```json
98
+ {
99
+ "guid": "...",
100
+ "notify_type": 1010,
101
+ "data": {
102
+ "from_username": "wxid_xxx",
103
+ "to_username": "wxid_yyy",
104
+ "desc": "NickName : message content",
105
+ "msg_id": "123456",
106
+ "msg_type": 1,
107
+ "is_chatroom_msg": 0,
108
+ "chatroom": ""
109
+ }
110
+ }
111
+ ```
112
+
113
+ > Note: There is **no `content` field** — text is extracted from `desc` by stripping the `"NickName : "` prefix.
114
+
115
+ ## Version History
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
+
123
+ ### 2.0.0
124
+
125
+ - Full end-to-end inbound + outbound message flow
126
+ - Real JuHeBot callback format support (`desc` field parsing)
127
+ - Fixed all API params to snake_case (`to_username`, `notify_url`)
128
+ - Message deduplication by `msg_id`
129
+ - Chatroom field mapping (`is_chatroom_msg` + `chatroom`)
130
+ - Gateway WS polling strategy for agent reply detection
131
+ - Short-lived WS connections per RPC call
132
+
133
+ ### 1.x
134
+
135
+ - Initial implementation with webhook receiver
136
+ - Cache integration
137
+ - Multi-account scaffolding
138
+
139
+ ## License
140
+
141
+ MIT
package/dist/index.js CHANGED
@@ -20,7 +20,8 @@ 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
+ import { getIPadClient } from "./src/client-pool.js";
24
25
  /** Read OpenClaw main config from disk so standalone server has full cfg */
25
26
  let _cfgCache = null;
26
27
  function loadOpenClawConfig() {
@@ -39,73 +40,287 @@ function loadOpenClawConfig() {
39
40
  // ── Gateway WebSocket dispatch ──
40
41
  const GATEWAY_PORT = parseInt(process.env.OPENCLAW_GATEWAY_PORT || "18789", 10);
41
42
  let _gwReqId = 0;
42
- function dispatchViaGateway(params) {
43
- const cfg = loadOpenClawConfig();
44
- const token = cfg?.gateway?.controlUi?.auth?.token || cfg?.gateway?.auth?.token || "";
43
+ /**
44
+ * Resolve JuHeBot credentials from OpenClaw config and send a text message
45
+ * to a WeChat user/room via the JuHeBot API.
46
+ */
47
+ async function sendToWeChat(cfg, toUser, text, accountId) {
48
+ const entry = cfg?.plugins?.entries?.["ipad-wechat"] || {};
49
+ const section = entry.config || cfg?.channels?.["ipad-wechat"] || {};
50
+ const accounts = (section.accounts || []);
51
+ const account = accounts.find((a) => a.accountId === (accountId || "default")) || accounts[0] || {};
52
+ const appKey = account.appKey || section.appKey || "";
53
+ const appSecret = account.appSecret || section.appSecret || "";
54
+ const guid = account.guid || section.guid || "";
55
+ if (!appKey || !appSecret || !guid) {
56
+ throw new Error("Missing JuHeBot credentials (appKey/appSecret/guid)");
57
+ }
58
+ const client = getIPadClient(account.accountId || "default", { appKey, appSecret, guid });
59
+ const isChatroom = toUser.includes("@chatroom");
60
+ if (isChatroom) {
61
+ await client.sendRoomMessage({ roomId: toUser, content: text });
62
+ }
63
+ else {
64
+ await client.sendFriendMessage({ friendWechatId: toUser, content: text });
65
+ }
66
+ }
67
+ /**
68
+ * Extract text content from a gateway chat event message object.
69
+ */
70
+ function extractReplyText(message) {
71
+ if (!message?.content)
72
+ return "";
73
+ if (typeof message.content === "string")
74
+ return message.content;
75
+ if (Array.isArray(message.content)) {
76
+ return message.content
77
+ .filter((c) => c.type === "text")
78
+ .map((c) => c.text || "")
79
+ .join("\n");
80
+ }
81
+ return "";
82
+ }
83
+ /**
84
+ * Open a short-lived WS to the gateway, run a single RPC call, and return the result.
85
+ */
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
135
+ const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
136
+ const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
137
+ await new Promise((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
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
+ }
159
+ }
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 {
182
+ ws.close();
183
+ }
184
+ catch { }
185
+ }
186
+ /**
187
+ * Send a single WS RPC request and wait for the response (with 30s timeout).
188
+ */
189
+ function rpcCall(ws, method, reqParams) {
45
190
  return new Promise((resolve, reject) => {
46
- const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
47
- const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
48
- const timeout = setTimeout(() => { ws.close(); reject(new Error("Gateway dispatch timeout")); }, 30000);
49
- let connected = false;
50
- let sendId = null;
51
- ws.on("open", () => {
52
- const connectId = `ipad-wechat-${++_gwReqId}`;
53
- ws.send(JSON.stringify({
54
- type: "req", id: connectId, method: "connect",
55
- params: {
56
- minProtocol: 3, maxProtocol: 3,
57
- client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
58
- scopes: ["operator.admin", "operator.read", "operator.write"],
59
- ...(token ? { auth: { token } } : {}),
60
- },
61
- }));
62
- });
63
- ws.on("message", (data) => {
191
+ const id = `ipad-wechat-${++_gwReqId}`;
192
+ const timer = setTimeout(() => { ws.off("message", handler); reject(new Error(`${method} timed out`)); }, 30000);
193
+ const handler = (data) => {
64
194
  try {
65
195
  const frame = JSON.parse(data.toString());
66
- if (frame.type === "res" && !connected) {
67
- connected = true;
68
- if (!frame.ok) {
69
- clearTimeout(timeout);
70
- ws.close();
71
- reject(new Error("Gateway connect failed: " + (frame.error?.message || "unknown")));
72
- return;
73
- }
74
- sendId = `ipad-wechat-${++_gwReqId}`;
75
- ws.send(JSON.stringify({
76
- type: "req", id: sendId, method: "chat.send",
77
- params: {
78
- idempotencyKey: randomUUID(),
79
- sessionKey: params.sessionKey,
80
- message: params.message,
81
- deliver: true,
82
- originatingChannel: "ipad-wechat",
83
- originatingTo: params.from,
84
- originatingAccountId: params.accountId || "default",
85
- },
86
- }));
87
- }
88
- else if (frame.type === "res" && frame.id === sendId) {
89
- clearTimeout(timeout);
90
- ws.close();
91
- if (frame.ok) {
92
- resolve();
93
- }
94
- else {
95
- reject(new Error("chat.send failed: " + (frame.error?.message || "unknown")));
96
- }
196
+ if (frame.type === "res" && frame.id === id) {
197
+ clearTimeout(timer);
198
+ ws.off("message", handler);
199
+ if (frame.ok)
200
+ resolve(frame.payload ?? frame);
201
+ else
202
+ reject(new Error(`${method} failed: ${frame.error?.message || "unknown"}`));
97
203
  }
98
204
  }
99
- catch { /* ignore parse errors */ }
100
- });
101
- ws.on("error", (err) => { clearTimeout(timeout); reject(err); });
102
- ws.on("close", () => { clearTimeout(timeout); if (!connected)
103
- reject(new Error("Gateway closed before connect")); });
205
+ catch { /* ignore non-matching frames */ }
206
+ };
207
+ ws.on("message", handler);
208
+ ws.send(JSON.stringify({ type: "req", id, method, params: reqParams }));
104
209
  });
105
210
  }
211
+ /**
212
+ * Dispatch an inbound message to the agent via the Gateway WebSocket protocol.
213
+ *
214
+ * Strategy:
215
+ * 1. Fire chat.send via a short-lived WS connection (triggers agent)
216
+ * 2. Poll chat.history via separate WS connections to detect the reply
217
+ * 3. Extract reply text, strip NO_REPLY token
218
+ * 4. If there is content, call JuHeBot API to send to WeChat
219
+ */
220
+ async function dispatchViaGateway(params) {
221
+ const cfg = loadOpenClawConfig();
222
+ const token = cfg?.gateway?.controlUi?.auth?.token || cfg?.gateway?.auth?.token || "";
223
+ // Step 1: Get baseline message count
224
+ const baselineHistory = await gwRpc(token, "chat.history", {
225
+ sessionKey: params.sessionKey, limit: 50,
226
+ });
227
+ const baselineCount = Array.isArray(baselineHistory?.messages)
228
+ ? baselineHistory.messages.length : 0;
229
+ console.log(`[iPad WeChat] Baseline history: ${baselineCount} messages`);
230
+ // Step 2: Send user message (triggers agent processing)
231
+ const sendResult = await gwRpc(token, "chat.send", {
232
+ idempotencyKey: randomUUID(),
233
+ sessionKey: params.sessionKey,
234
+ message: params.message,
235
+ deliver: true,
236
+ originatingChannel: "ipad-wechat",
237
+ originatingTo: params.from,
238
+ originatingAccountId: params.accountId || "default",
239
+ });
240
+ console.log(`[iPad WeChat] chat.send accepted, runId=${sendResult?.runId}, polling for reply…`);
241
+ // Step 3: Poll chat.history via fresh connections
242
+ const POLL_INTERVAL = 3000;
243
+ const MAX_POLLS = 40; // 3s × 40 = 120s max
244
+ let replyText = "";
245
+ for (let i = 0; i < MAX_POLLS; i++) {
246
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
247
+ try {
248
+ const history = await gwRpc(token, "chat.history", {
249
+ sessionKey: params.sessionKey, limit: 50,
250
+ });
251
+ const messages = Array.isArray(history?.messages) ? history.messages : [];
252
+ if (messages.length <= baselineCount)
253
+ continue;
254
+ // Look for new assistant messages after the baseline
255
+ // chat.history returns messages with role/content at top level
256
+ const newMessages = messages.slice(baselineCount);
257
+ const assistantMsg = newMessages.find((m) => m?.role === "assistant" || m?.message?.role === "assistant");
258
+ if (assistantMsg) {
259
+ const rawText = extractReplyText(assistantMsg);
260
+ replyText = rawText.replace(/\s*NO_REPLY\s*/gi, "").trim();
261
+ console.log(`[iPad WeChat] Agent reply found on poll ${i + 1}: "${replyText.slice(0, 200)}"`);
262
+ break;
263
+ }
264
+ }
265
+ catch (pollErr) {
266
+ console.error(`[iPad WeChat] Poll ${i + 1} error:`, pollErr);
267
+ }
268
+ }
269
+ // Step 4: Send outbound via JuHeBot API + cache
270
+ if (replyText) {
271
+ try {
272
+ await sendToWeChat(cfg, params.from, replyText, params.accountId);
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
+ }
294
+ }
295
+ catch (sendErr) {
296
+ console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
297
+ }
298
+ }
299
+ else {
300
+ console.log("[iPad WeChat] No non-silent agent reply found after polling, skipping outbound");
301
+ }
302
+ }
106
303
  // JuHeBot notify_type constants (from doc-6966894.md)
107
304
  const NOTIFY_NEW_MSG = 1010;
108
305
  const NOTIFY_BATCH_NEW_MSG = 1011;
306
+ /**
307
+ * Extract message content from JuHeBot `desc` field.
308
+ * Real callback format: "NickName : actualContent"
309
+ * For chatroom: "NickName:\nactualContent"
310
+ */
311
+ function extractContentFromDesc(desc) {
312
+ if (!desc)
313
+ return "";
314
+ // Private chat: "NickName : content"
315
+ const colonIdx = desc.indexOf(" : ");
316
+ if (colonIdx >= 0)
317
+ return desc.slice(colonIdx + 3);
318
+ // Chatroom: "NickName:\ncontent"
319
+ const nlIdx = desc.indexOf(":\n");
320
+ if (nlIdx >= 0)
321
+ return desc.slice(nlIdx + 2);
322
+ return desc;
323
+ }
109
324
  /**
110
325
  * Transform JuHeBot callback payload → WebhookPayload
111
326
  * Handles both JuHeBot native format and our own test format.
@@ -130,11 +345,11 @@ function transformPayload(raw) {
130
345
  messageId: String(m.msg_id ?? m.msgId ?? m.new_msg_id ?? m.newMsgId ?? `msg_${Date.now()}`),
131
346
  fromUser: m.from_username ?? m.fromUsername ?? m.from_user ?? m.fromUser ?? "",
132
347
  toUser: m.to_username ?? m.toUsername ?? m.to_user ?? m.toUser ?? "",
133
- content: m.content ?? m.msg_content ?? m.msgContent ?? "",
348
+ content: m.content ?? m.msg_content ?? m.msgContent ?? extractContentFromDesc(m.desc) ?? "",
134
349
  type: m.msg_type ?? m.msgType ?? m.type ?? 1,
135
350
  timestamp: m.timestamp ?? m.create_time ?? m.createTime ?? Date.now(),
136
351
  isSelf: m.is_self === 1 || m.is_self === true || m.isSelf === true,
137
- roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? undefined,
352
+ roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? (m.is_chatroom_msg === 1 && m.chatroom ? m.chatroom : undefined),
138
353
  },
139
354
  };
140
355
  }
@@ -144,6 +359,22 @@ function transformPayload(raw) {
144
359
  }
145
360
  const WEBHOOK_PORT = parseInt(process.env.IPAD_WECHAT_WEBHOOK_PORT || "18790", 10);
146
361
  let _webhookServerStarted = false;
362
+ // Deduplication: track recently processed msg_ids (TTL 60s)
363
+ const _processedMsgIds = new Map();
364
+ function isDuplicate(msgId) {
365
+ const now = Date.now();
366
+ // Prune old entries every check
367
+ if (_processedMsgIds.size > 500) {
368
+ for (const [k, t] of _processedMsgIds) {
369
+ if (now - t > 60000)
370
+ _processedMsgIds.delete(k);
371
+ }
372
+ }
373
+ if (_processedMsgIds.has(msgId))
374
+ return true;
375
+ _processedMsgIds.set(msgId, now);
376
+ return false;
377
+ }
147
378
  export default defineChannelPluginEntry({
148
379
  id: "ipad-wechat",
149
380
  name: "iPad WeChat",
@@ -202,8 +433,16 @@ export default defineChannelPluginEntry({
202
433
  console.log("[iPad WeChat] Webhook received:", JSON.stringify(raw).slice(0, 500));
203
434
  const payload = transformPayload(raw);
204
435
  if (payload && payload.message) {
436
+ // Deduplicate by msg_id
437
+ if (isDuplicate(payload.message.messageId)) {
438
+ console.log(`[iPad WeChat] Duplicate msg_id=${payload.message.messageId}, skipping`);
439
+ res.writeHead(200, { "Content-Type": "text/plain" });
440
+ res.end("ok");
441
+ return;
442
+ }
205
443
  const cfg = loadOpenClawConfig();
206
444
  const msg = payload.message;
445
+ console.log(`[iPad WeChat] Parsed: from=${msg.fromUser}, content="${msg.content?.slice(0, 100)}", type=${msg.type}`);
207
446
  const isChatroom = !!msg.roomId;
208
447
  const peerId = isChatroom ? msg.roomId : (msg.fromUser || msg.toUser || "");
209
448
  const peerKind = isChatroom ? "group" : "direct";
@@ -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);
@@ -64,10 +64,10 @@ export function createIPadClient(config) {
64
64
  },
65
65
  /**
66
66
  * 设置实例通知地址 (回调地址)
67
- * Path: /instance/set_notify_url
67
+ * Path: /client/set_notify_url
68
68
  */
69
69
  async setNotifyUrl(notifyUrl) {
70
- await callApi(config, "/instance/set_notify_url", { notifyUrl });
70
+ await callApi(config, "/client/set_notify_url", { notify_url: notifyUrl });
71
71
  },
72
72
  /**
73
73
  * 设置实例桥接ID
@@ -180,7 +180,7 @@ export function createIPadClient(config) {
180
180
  */
181
181
  async sendFriendMessage(params) {
182
182
  const res = await callApi(config, "/msg/send_text", {
183
- toUser: params.friendWechatId,
183
+ to_username: params.friendWechatId,
184
184
  content: params.content,
185
185
  });
186
186
  return {
@@ -194,7 +194,7 @@ export function createIPadClient(config) {
194
194
  */
195
195
  async sendRoomMessage(params) {
196
196
  const res = await callApi(config, "/msg/send_room_at", {
197
- roomId: params.roomId,
197
+ to_username: params.roomId,
198
198
  content: params.content,
199
199
  });
200
200
  return {
@@ -543,7 +543,7 @@ export const API_PATHS = {
543
543
  instanceGetStatus: "/instance/get_status",
544
544
  instanceRestore: "/instance/restore",
545
545
  instanceStop: "/instance/stop",
546
- instanceSetNotifyUrl: "/instance/set_notify_url",
546
+ instanceSetNotifyUrl: "/client/set_notify_url",
547
547
  instanceSetBridgeId: "/instance/set_bridge_id",
548
548
  // 用户
549
549
  userGetProfile: "/user/get_profile",
package/index.ts CHANGED
@@ -21,7 +21,8 @@ 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
+ import { getIPadClient } from "./src/client-pool.js";
25
26
  import type { WebhookPayload } from "./src/client.js";
26
27
 
27
28
  /** Read OpenClaw main config from disk so standalone server has full cfg */
@@ -42,7 +43,177 @@ function loadOpenClawConfig(): any {
42
43
  const GATEWAY_PORT = parseInt(process.env.OPENCLAW_GATEWAY_PORT || "18789", 10);
43
44
  let _gwReqId = 0;
44
45
 
45
- function dispatchViaGateway(params: {
46
+ /**
47
+ * Resolve JuHeBot credentials from OpenClaw config and send a text message
48
+ * to a WeChat user/room via the JuHeBot API.
49
+ */
50
+ async function sendToWeChat(cfg: any, toUser: string, text: string, accountId?: string): Promise<void> {
51
+ const entry = cfg?.plugins?.entries?.["ipad-wechat"] || {};
52
+ const section = entry.config || cfg?.channels?.["ipad-wechat"] || {};
53
+ const accounts = (section.accounts || []) as any[];
54
+ const account = accounts.find((a: any) => a.accountId === (accountId || "default")) || accounts[0] || {};
55
+
56
+ const appKey = account.appKey || section.appKey || "";
57
+ const appSecret = account.appSecret || section.appSecret || "";
58
+ const guid = account.guid || section.guid || "";
59
+
60
+ if (!appKey || !appSecret || !guid) {
61
+ throw new Error("Missing JuHeBot credentials (appKey/appSecret/guid)");
62
+ }
63
+
64
+ const client = getIPadClient(account.accountId || "default", { appKey, appSecret, guid });
65
+ const isChatroom = toUser.includes("@chatroom");
66
+
67
+ if (isChatroom) {
68
+ await client.sendRoomMessage({ roomId: toUser, content: text });
69
+ } else {
70
+ await client.sendFriendMessage({ friendWechatId: toUser, content: text });
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Extract text content from a gateway chat event message object.
76
+ */
77
+ function extractReplyText(message: any): string {
78
+ if (!message?.content) return "";
79
+ if (typeof message.content === "string") return message.content;
80
+ if (Array.isArray(message.content)) {
81
+ return message.content
82
+ .filter((c: any) => c.type === "text")
83
+ .map((c: any) => c.text || "")
84
+ .join("\n");
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /**
90
+ * Open a short-lived WS to the gateway, run a single RPC call, and return the result.
91
+ */
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
140
+ const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
141
+ const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
142
+ await new Promise<void>((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
143
+
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
+ }
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 {}
182
+ }
183
+
184
+ /**
185
+ * Send a single WS RPC request and wait for the response (with 30s timeout).
186
+ */
187
+ function rpcCall(ws: WebSocket, method: string, reqParams: any): Promise<any> {
188
+ return new Promise((resolve, reject) => {
189
+ const id = `ipad-wechat-${++_gwReqId}`;
190
+ const timer = setTimeout(() => { ws.off("message", handler); reject(new Error(`${method} timed out`)); }, 30000);
191
+ const handler = (data: Buffer) => {
192
+ try {
193
+ const frame = JSON.parse(data.toString());
194
+ if (frame.type === "res" && frame.id === id) {
195
+ clearTimeout(timer);
196
+ ws.off("message", handler);
197
+ if (frame.ok) resolve(frame.payload ?? frame);
198
+ else reject(new Error(`${method} failed: ${frame.error?.message || "unknown"}`));
199
+ }
200
+ } catch { /* ignore non-matching frames */ }
201
+ };
202
+ ws.on("message", handler);
203
+ ws.send(JSON.stringify({ type: "req", id, method, params: reqParams }));
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Dispatch an inbound message to the agent via the Gateway WebSocket protocol.
209
+ *
210
+ * Strategy:
211
+ * 1. Fire chat.send via a short-lived WS connection (triggers agent)
212
+ * 2. Poll chat.history via separate WS connections to detect the reply
213
+ * 3. Extract reply text, strip NO_REPLY token
214
+ * 4. If there is content, call JuHeBot API to send to WeChat
215
+ */
216
+ async function dispatchViaGateway(params: {
46
217
  sessionKey: string;
47
218
  message: string;
48
219
  from: string;
@@ -52,63 +223,113 @@ function dispatchViaGateway(params: {
52
223
  const cfg = loadOpenClawConfig();
53
224
  const token = cfg?.gateway?.controlUi?.auth?.token || cfg?.gateway?.auth?.token || "";
54
225
 
55
- return new Promise((resolve, reject) => {
56
- const url = `ws://127.0.0.1:${GATEWAY_PORT}`;
57
- const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
58
- const timeout = setTimeout(() => { ws.close(); reject(new Error("Gateway dispatch timeout")); }, 30000);
59
-
60
- let connected = false;
61
- let sendId: string | null = null;
62
-
63
- ws.on("open", () => {
64
- const connectId = `ipad-wechat-${++_gwReqId}`;
65
- ws.send(JSON.stringify({
66
- type: "req", id: connectId, method: "connect",
67
- params: {
68
- minProtocol: 3, maxProtocol: 3,
69
- client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
70
- scopes: ["operator.admin", "operator.read", "operator.write"],
71
- ...(token ? { auth: { token } } : {}),
72
- },
73
- }));
74
- });
226
+ // Step 1: Get baseline message count
227
+ const baselineHistory = await gwRpc(token, "chat.history", {
228
+ sessionKey: params.sessionKey, limit: 50,
229
+ });
230
+ const baselineCount = Array.isArray(baselineHistory?.messages)
231
+ ? baselineHistory.messages.length : 0;
232
+ console.log(`[iPad WeChat] Baseline history: ${baselineCount} messages`);
233
+
234
+ // Step 2: Send user message (triggers agent processing)
235
+ const sendResult = await gwRpc(token, "chat.send", {
236
+ idempotencyKey: randomUUID(),
237
+ sessionKey: params.sessionKey,
238
+ message: params.message,
239
+ deliver: true,
240
+ originatingChannel: "ipad-wechat",
241
+ originatingTo: params.from,
242
+ originatingAccountId: params.accountId || "default",
243
+ });
244
+ console.log(`[iPad WeChat] chat.send accepted, runId=${sendResult?.runId}, polling for reply…`);
75
245
 
76
- ws.on("message", (data: Buffer) => {
77
- try {
78
- const frame = JSON.parse(data.toString());
79
- if (frame.type === "res" && !connected) {
80
- connected = true;
81
- if (!frame.ok) { clearTimeout(timeout); ws.close(); reject(new Error("Gateway connect failed: " + (frame.error?.message || "unknown"))); return; }
82
- sendId = `ipad-wechat-${++_gwReqId}`;
83
- ws.send(JSON.stringify({
84
- type: "req", id: sendId, method: "chat.send",
85
- params: {
86
- idempotencyKey: randomUUID(),
87
- sessionKey: params.sessionKey,
88
- message: params.message,
89
- deliver: true,
90
- originatingChannel: "ipad-wechat",
91
- originatingTo: params.from,
92
- originatingAccountId: params.accountId || "default",
93
- },
94
- }));
95
- } else if (frame.type === "res" && frame.id === sendId) {
96
- clearTimeout(timeout);
97
- ws.close();
98
- if (frame.ok) { resolve(); } else { reject(new Error("chat.send failed: " + (frame.error?.message || "unknown"))); }
99
- }
100
- } catch { /* ignore parse errors */ }
101
- });
246
+ // Step 3: Poll chat.history via fresh connections
247
+ const POLL_INTERVAL = 3000;
248
+ const MAX_POLLS = 40; // 3s × 40 = 120s max
249
+ let replyText = "";
102
250
 
103
- ws.on("error", (err) => { clearTimeout(timeout); reject(err); });
104
- ws.on("close", () => { clearTimeout(timeout); if (!connected) reject(new Error("Gateway closed before connect")); });
105
- });
251
+ for (let i = 0; i < MAX_POLLS; i++) {
252
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
253
+
254
+ try {
255
+ const history = await gwRpc(token, "chat.history", {
256
+ sessionKey: params.sessionKey, limit: 50,
257
+ });
258
+ const messages = Array.isArray(history?.messages) ? history.messages : [];
259
+
260
+ if (messages.length <= baselineCount) continue;
261
+
262
+ // Look for new assistant messages after the baseline
263
+ // chat.history returns messages with role/content at top level
264
+ const newMessages = messages.slice(baselineCount);
265
+ const assistantMsg = newMessages.find(
266
+ (m: any) => m?.role === "assistant" || m?.message?.role === "assistant"
267
+ );
268
+
269
+ if (assistantMsg) {
270
+ const rawText = extractReplyText(assistantMsg);
271
+ replyText = rawText.replace(/\s*NO_REPLY\s*/gi, "").trim();
272
+ console.log(`[iPad WeChat] Agent reply found on poll ${i + 1}: "${replyText.slice(0, 200)}"`);
273
+ break;
274
+ }
275
+ } catch (pollErr) {
276
+ console.error(`[iPad WeChat] Poll ${i + 1} error:`, pollErr);
277
+ }
278
+ }
279
+
280
+ // Step 4: Send outbound via JuHeBot API + cache
281
+ if (replyText) {
282
+ try {
283
+ await sendToWeChat(cfg, params.from, replyText, params.accountId);
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
+ }
305
+ } catch (sendErr) {
306
+ console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
307
+ }
308
+ } else {
309
+ console.log("[iPad WeChat] No non-silent agent reply found after polling, skipping outbound");
310
+ }
106
311
  }
107
312
 
108
313
  // JuHeBot notify_type constants (from doc-6966894.md)
109
314
  const NOTIFY_NEW_MSG = 1010;
110
315
  const NOTIFY_BATCH_NEW_MSG = 1011;
111
316
 
317
+ /**
318
+ * Extract message content from JuHeBot `desc` field.
319
+ * Real callback format: "NickName : actualContent"
320
+ * For chatroom: "NickName:\nactualContent"
321
+ */
322
+ function extractContentFromDesc(desc: string | undefined): string {
323
+ if (!desc) return "";
324
+ // Private chat: "NickName : content"
325
+ const colonIdx = desc.indexOf(" : ");
326
+ if (colonIdx >= 0) return desc.slice(colonIdx + 3);
327
+ // Chatroom: "NickName:\ncontent"
328
+ const nlIdx = desc.indexOf(":\n");
329
+ if (nlIdx >= 0) return desc.slice(nlIdx + 2);
330
+ return desc;
331
+ }
332
+
112
333
  /**
113
334
  * Transform JuHeBot callback payload → WebhookPayload
114
335
  * Handles both JuHeBot native format and our own test format.
@@ -136,11 +357,11 @@ function transformPayload(raw: any): WebhookPayload | null {
136
357
  messageId: String(m.msg_id ?? m.msgId ?? m.new_msg_id ?? m.newMsgId ?? `msg_${Date.now()}`),
137
358
  fromUser: m.from_username ?? m.fromUsername ?? m.from_user ?? m.fromUser ?? "",
138
359
  toUser: m.to_username ?? m.toUsername ?? m.to_user ?? m.toUser ?? "",
139
- content: m.content ?? m.msg_content ?? m.msgContent ?? "",
360
+ content: m.content ?? m.msg_content ?? m.msgContent ?? extractContentFromDesc(m.desc) ?? "",
140
361
  type: m.msg_type ?? m.msgType ?? m.type ?? 1,
141
362
  timestamp: m.timestamp ?? m.create_time ?? m.createTime ?? Date.now(),
142
363
  isSelf: m.is_self === 1 || m.is_self === true || m.isSelf === true,
143
- roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? undefined,
364
+ roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? (m.is_chatroom_msg === 1 && m.chatroom ? m.chatroom : undefined),
144
365
  },
145
366
  };
146
367
  }
@@ -153,6 +374,21 @@ function transformPayload(raw: any): WebhookPayload | null {
153
374
  const WEBHOOK_PORT = parseInt(process.env.IPAD_WECHAT_WEBHOOK_PORT || "18790", 10);
154
375
  let _webhookServerStarted = false;
155
376
 
377
+ // Deduplication: track recently processed msg_ids (TTL 60s)
378
+ const _processedMsgIds = new Map<string, number>();
379
+ function isDuplicate(msgId: string): boolean {
380
+ const now = Date.now();
381
+ // Prune old entries every check
382
+ if (_processedMsgIds.size > 500) {
383
+ for (const [k, t] of _processedMsgIds) {
384
+ if (now - t > 60000) _processedMsgIds.delete(k);
385
+ }
386
+ }
387
+ if (_processedMsgIds.has(msgId)) return true;
388
+ _processedMsgIds.set(msgId, now);
389
+ return false;
390
+ }
391
+
156
392
  export default defineChannelPluginEntry({
157
393
  id: "ipad-wechat",
158
394
  name: "iPad WeChat",
@@ -212,8 +448,17 @@ export default defineChannelPluginEntry({
212
448
 
213
449
  const payload = transformPayload(raw);
214
450
  if (payload && payload.message) {
451
+ // Deduplicate by msg_id
452
+ if (isDuplicate(payload.message.messageId)) {
453
+ console.log(`[iPad WeChat] Duplicate msg_id=${payload.message.messageId}, skipping`);
454
+ res.writeHead(200, { "Content-Type": "text/plain" });
455
+ res.end("ok");
456
+ return;
457
+ }
458
+
215
459
  const cfg = loadOpenClawConfig();
216
460
  const msg = payload.message;
461
+ console.log(`[iPad WeChat] Parsed: from=${msg.fromUser}, content="${msg.content?.slice(0, 100)}", type=${msg.type}`);
217
462
  const isChatroom = !!(msg as any).roomId;
218
463
  const peerId = isChatroom ? (msg as any).roomId : (msg.fromUser || msg.toUser || "");
219
464
  const peerKind = isChatroom ? "group" : "direct";
@@ -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": "1.0.14",
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,11 +1,19 @@
1
1
  {
2
2
  "name": "@gloablehive/ipad-wechat-plugin",
3
- "version": "1.0.26",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
- "description": "OpenClaw channel plugin for iPad WeChat protocol - enables sending/receiving WeChat messages through iPad protocol",
5
+ "description": "OpenClaw channel plugin for iPad WeChat protocol - enables sending/receiving WeChat messages through JuHeBot API",
6
6
  "main": "index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "src/",
10
+ "dist/",
11
+ "openclaw.plugin.json",
12
+ "README.md"
13
+ ],
7
14
  "scripts": {
8
15
  "build": "tsc",
16
+ "prepublishOnly": "npm run build",
9
17
  "test": "npx tsx test-ipad.ts",
10
18
  "dev": "tsx watch index.ts"
11
19
  },
@@ -19,6 +27,13 @@
19
27
  "blurb": "Connect OpenClaw to iPad WeChat protocol for sending and receiving WeChat messages"
20
28
  }
21
29
  },
30
+ "keywords": ["openclaw", "wechat", "ipad", "channel", "plugin", "juhebot", "messaging"],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/tbuijibing/gloablehive",
35
+ "directory": "channels/ipad-wechat-plugin"
36
+ },
22
37
  "dependencies": {
23
38
  "@gloablehive/wechat-cache": "^1.2.0",
24
39
  "openclaw": ">=1.0.0"
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);
package/src/client.ts CHANGED
@@ -140,10 +140,10 @@ export function createIPadClient(config: JuHeBotConfig) {
140
140
 
141
141
  /**
142
142
  * 设置实例通知地址 (回调地址)
143
- * Path: /instance/set_notify_url
143
+ * Path: /client/set_notify_url
144
144
  */
145
145
  async setNotifyUrl(notifyUrl: string): Promise<void> {
146
- await callApi(config, "/instance/set_notify_url", { notifyUrl });
146
+ await callApi(config, "/client/set_notify_url", { notify_url: notifyUrl });
147
147
  },
148
148
 
149
149
  /**
@@ -280,7 +280,7 @@ export function createIPadClient(config: JuHeBotConfig) {
280
280
  content: string;
281
281
  }): Promise<SendTextResponse> {
282
282
  const res = await callApi(config, "/msg/send_text", {
283
- toUser: params.friendWechatId,
283
+ to_username: params.friendWechatId,
284
284
  content: params.content,
285
285
  });
286
286
 
@@ -299,7 +299,7 @@ export function createIPadClient(config: JuHeBotConfig) {
299
299
  content: string;
300
300
  }): Promise<SendTextResponse> {
301
301
  const res = await callApi(config, "/msg/send_room_at", {
302
- roomId: params.roomId,
302
+ to_username: params.roomId,
303
303
  content: params.content,
304
304
  });
305
305
 
@@ -704,7 +704,7 @@ export const API_PATHS = {
704
704
  instanceGetStatus: "/instance/get_status",
705
705
  instanceRestore: "/instance/restore",
706
706
  instanceStop: "/instance/stop",
707
- instanceSetNotifyUrl: "/instance/set_notify_url",
707
+ instanceSetNotifyUrl: "/client/set_notify_url",
708
708
  instanceSetBridgeId: "/instance/set_bridge_id",
709
709
 
710
710
  // 用户
package/test-ipad-real.ts DELETED
@@ -1,77 +0,0 @@
1
- /**
2
- * Test Script for iPad WeChat Plugin - Real API Test
3
- * Run: npx tsx test-ipad-real.ts
4
- */
5
-
6
- import { createIPadClient } from './src/client.js';
7
-
8
- const CONFIG = {
9
- appKey: 'app84l4hKvqmUphNX1H',
10
- appSecret: 'CztidspJSiunhw7BVWnKiTgwVFV55nbaPVcBMRa34IT7hRvyxtJLsgMM0C3XMfbD',
11
- guid: '0e7d1810-2b76-3c2b-8cab-74d94a951af9',
12
- };
13
-
14
- async function test1_GetLoginAccountInfo() {
15
- console.log('\n📋 Test 1: Get Login Account Info');
16
-
17
- const client = createIPadClient(CONFIG);
18
-
19
- try {
20
- const info = await client.getLoginAccountInfo();
21
- console.log('✅ Account Info:');
22
- console.log(' WeChat ID:', (info as any).userName?.string);
23
- console.log(' NickName:', (info as any).nickName?.string);
24
- console.log(' Mobile:', (info as any).bindMobile?.string);
25
- console.log(' Signature:', (info as any).signature?.string);
26
- return info;
27
- } catch (error: any) {
28
- console.log('❌ Error:', error.message);
29
- return null;
30
- }
31
- }
32
-
33
- async function test2_SyncContacts() {
34
- console.log('\n📋 Test 2: Sync Contacts');
35
-
36
- const client = createIPadClient(CONFIG);
37
-
38
- try {
39
- const contacts = await client.syncContacts();
40
- console.log('✅ Contacts count:', contacts.length);
41
- if (contacts.length > 0) {
42
- console.log('Sample contact:', contacts[0]);
43
- }
44
- return contacts;
45
- } catch (error: any) {
46
- console.log('❌ Error:', error.message);
47
- return [];
48
- }
49
- }
50
-
51
- async function test3_GetRoomList() {
52
- console.log('\n📋 Test 3: Get Room Info (sample room ID needed)');
53
- console.log('⚠️ Skipping - requires a valid room ID');
54
- return null;
55
- }
56
-
57
- async function test4_SendTextMessage() {
58
- console.log('\n📋 Test 4: Send Text Message');
59
- console.log('⚠️ Skipping - requires a valid friend wechat ID');
60
- return null;
61
- }
62
-
63
- async function main() {
64
- console.log('🚀 iPad WeChat Plugin - Real API Test');
65
- console.log('=======================================');
66
- console.log('Config:', { appKey: CONFIG.appKey, guid: CONFIG.guid });
67
-
68
- await test1_GetLoginAccountInfo();
69
- await test2_SyncContacts();
70
- await test3_GetRoomList();
71
- await test4_SendTextMessage();
72
-
73
- console.log('\n=======================================');
74
- console.log('✅ Tests completed!');
75
- }
76
-
77
- main();
package/test-ipad.ts DELETED
@@ -1,150 +0,0 @@
1
- /**
2
- * Test Script for iPad WeChat Plugin
3
- * Run: npx tsx test-ipad.ts
4
- */
5
-
6
- import * as fs from 'fs/promises';
7
- import * as path from 'path';
8
- import {
9
- createCacheManager,
10
- CacheManager,
11
- WeChatAccount,
12
- WeChatMessage,
13
- } from "@gloablehive/wechat-cache";
14
- import { createIPadClient, type WebhookPayload } from './src/client.js';
15
-
16
- const TEST_CACHE_PATH = '/tmp/wechat-cache-ipad-test';
17
-
18
- async function cleanup() {
19
- try {
20
- await fs.rm(TEST_CACHE_PATH, { recursive: true, force: true });
21
- } catch {}
22
- }
23
-
24
- async function test1_CacheSystem() {
25
- console.log('\n📋 Test 1: iPad WeChat Cache System');
26
-
27
- const accounts: WeChatAccount[] = [
28
- {
29
- accountId: 'ipad-account-001',
30
- wechatAccountId: 'ipad-wechat-001',
31
- wechatId: 'wxid_ipad001',
32
- nickName: 'iPad客服',
33
- enabled: true,
34
- },
35
- ];
36
-
37
- const manager = createCacheManager({
38
- basePath: TEST_CACHE_PATH,
39
- accounts,
40
- });
41
-
42
- await manager.init();
43
- console.log('✅ Cache manager initialized');
44
-
45
- // Test message
46
- const message: WeChatMessage = {
47
- messageId: 'ipad-msg-001',
48
- accountId: 'ipad-account-001',
49
- conversationType: 'friend',
50
- conversationId: 'wxid_friend001',
51
- senderId: 'wxid_friend001',
52
- content: '你好,这是iPad协议测试消息',
53
- messageType: 1,
54
- timestamp: Date.now(),
55
- isSelf: false,
56
- direction: 'inbound',
57
- };
58
-
59
- await manager.onMessage(message);
60
- console.log('✅ Message cached');
61
-
62
- // Verify file exists
63
- const friendPath = path.join(TEST_CACHE_PATH, 'accounts', 'ipad-account-001', 'friends', 'wxid_friend001');
64
- const files = await fs.readdir(friendPath);
65
- console.log('✅ Cached files:', files);
66
-
67
- return manager;
68
- }
69
-
70
- async function test2_WebhookPayload() {
71
- console.log('\n📋 Test 2: Webhook Payload Parsing');
72
-
73
- // Simulate iPad webhook payload
74
- const payload: WebhookPayload = {
75
- event: 'message',
76
- message: {
77
- messageId: 'ipad-msg-002',
78
- fromUser: 'wxid_testfriend',
79
- toUser: 'wxid_ipad001',
80
- content: '收到一条测试消息',
81
- type: 1,
82
- timestamp: Date.now(),
83
- isSelf: false,
84
- },
85
- };
86
-
87
- console.log('✅ Payload:', JSON.stringify(payload, null, 2));
88
- console.log('✅ Webhook payload structure valid');
89
- }
90
-
91
- async function test3_ClientCreation() {
92
- console.log('\n📋 Test 3: iPad Client Creation');
93
-
94
- const client = createIPadClient({
95
- baseUrl: 'https://api.example.com',
96
- apiKey: 'test-api-key',
97
- });
98
-
99
- console.log('✅ Client created with methods:', Object.keys(client));
100
-
101
- // Check all methods exist
102
- const expectedMethods = [
103
- 'sendFriendMessage',
104
- 'sendRoomMessage',
105
- 'sendFriendMedia',
106
- 'sendRoomMedia',
107
- 'syncContacts',
108
- 'getContactDetail',
109
- 'updateFriendRemark',
110
- 'getRoomInfo',
111
- 'getRoomMembers',
112
- 'createRoom',
113
- 'addRoomMember',
114
- 'removeRoomMember',
115
- 'getLoginAccountInfo',
116
- 'getFriendMoments',
117
- 'publishMoment',
118
- ];
119
-
120
- for (const method of expectedMethods) {
121
- if (typeof (client as any)[method] === 'function') {
122
- console.log(` ✅ ${method}`);
123
- } else {
124
- console.log(` ❌ ${method} - missing`);
125
- }
126
- }
127
- }
128
-
129
- async function main() {
130
- console.log('🚀 iPad WeChat Plugin Tests');
131
- console.log('============================');
132
-
133
- await cleanup();
134
-
135
- try {
136
- await test1_CacheSystem();
137
- await test2_WebhookPayload();
138
- await test3_ClientCreation();
139
-
140
- console.log('\n============================');
141
- console.log('✅ All tests passed!');
142
- } catch (error) {
143
- console.error('\n❌ Test failed:', error);
144
- process.exit(1);
145
- } finally {
146
- await cleanup();
147
- }
148
- }
149
-
150
- main();
package/tsconfig.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "lib": ["ES2022"],
7
- "outDir": "./dist",
8
- "rootDir": ".",
9
- "declaration": false,
10
- "strict": false,
11
- "noImplicitAny": false,
12
- "esModuleInterop": true,
13
- "skipLibCheck": true,
14
- "forceConsistentCasingInFileNames": true,
15
- "resolveJsonModule": true,
16
- "allowSyntheticDefaultImports": true,
17
- "noEmit": false,
18
- "noEmitOnError": false
19
- },
20
- "include": ["*.ts", "src/**/*.ts"],
21
- "exclude": ["node_modules", "dist", "test*.ts"]
22
- }