@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 +141 -0
- package/dist/index.js +299 -60
- package/dist/src/channel.js +1 -1
- package/dist/src/client.js +5 -5
- package/index.ts +298 -53
- package/openclaw.plugin.json +1 -1
- package/package.json +17 -2
- package/src/channel.ts +1 -1
- package/src/client.ts +5 -5
- package/test-ipad-real.ts +0 -77
- package/test-ipad.ts +0 -150
- package/tsconfig.json +0 -22
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
const
|
|
48
|
-
const
|
|
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" &&
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
100
|
-
}
|
|
101
|
-
ws.on("
|
|
102
|
-
ws.
|
|
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";
|
package/dist/src/channel.js
CHANGED
package/dist/src/client.js
CHANGED
|
@@ -64,10 +64,10 @@ export function createIPadClient(config) {
|
|
|
64
64
|
},
|
|
65
65
|
/**
|
|
66
66
|
* 设置实例通知地址 (回调地址)
|
|
67
|
-
* Path: /
|
|
67
|
+
* Path: /client/set_notify_url
|
|
68
68
|
*/
|
|
69
69
|
async setNotifyUrl(notifyUrl) {
|
|
70
|
-
await callApi(config, "/
|
|
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
|
-
|
|
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
|
-
|
|
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: "/
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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";
|
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": "1.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,11 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gloablehive/ipad-wechat-plugin",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenClaw channel plugin for iPad WeChat protocol - enables sending/receiving WeChat messages through
|
|
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: /
|
|
143
|
+
* Path: /client/set_notify_url
|
|
144
144
|
*/
|
|
145
145
|
async setNotifyUrl(notifyUrl: string): Promise<void> {
|
|
146
|
-
await callApi(config, "/
|
|
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
|
-
|
|
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
|
-
|
|
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: "/
|
|
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
|
-
}
|