@gloablehive/ipad-wechat-plugin 1.0.26 → 2.0.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 +135 -0
- package/dist/index.js +199 -59
- package/dist/src/client.js +5 -5
- package/index.ts +207 -52
- package/openclaw.plugin.json +1 -1
- package/package.json +17 -2
- 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,135 @@
|
|
|
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.0.0
|
|
118
|
+
|
|
119
|
+
- Full end-to-end inbound + outbound message flow
|
|
120
|
+
- Real JuHeBot callback format support (`desc` field parsing)
|
|
121
|
+
- Fixed all API params to snake_case (`to_username`, `notify_url`)
|
|
122
|
+
- Message deduplication by `msg_id`
|
|
123
|
+
- Chatroom field mapping (`is_chatroom_msg` + `chatroom`)
|
|
124
|
+
- Gateway WS polling strategy for agent reply detection
|
|
125
|
+
- Short-lived WS connections per RPC call
|
|
126
|
+
|
|
127
|
+
### 1.x
|
|
128
|
+
|
|
129
|
+
- Initial implementation with webhook receiver
|
|
130
|
+
- Cache integration
|
|
131
|
+
- Multi-account scaffolding
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { randomUUID } from "crypto";
|
|
|
21
21
|
import WebSocket from "ws";
|
|
22
22
|
import { defineChannelPluginEntry, buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/core";
|
|
23
23
|
import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady } 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,188 @@ 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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 url = `ws://127.0.0.1:${GATEWAY_PORT}`;
|
|
88
|
+
const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
|
|
89
|
+
await new Promise((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
|
|
90
|
+
try {
|
|
91
|
+
// Handshake
|
|
92
|
+
const connectRes = await rpcCall(ws, "connect", {
|
|
93
|
+
minProtocol: 3, maxProtocol: 3,
|
|
94
|
+
client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
|
|
95
|
+
scopes: ["operator.admin", "operator.read", "operator.write"],
|
|
96
|
+
...(token ? { auth: { token } } : {}),
|
|
62
97
|
});
|
|
63
|
-
|
|
98
|
+
if (!connectRes)
|
|
99
|
+
throw new Error("Gateway connect failed");
|
|
100
|
+
// Execute the method
|
|
101
|
+
return await rpcCall(ws, method, reqParams);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
ws.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Send a single WS RPC request and wait for the response (with 30s timeout).
|
|
109
|
+
*/
|
|
110
|
+
function rpcCall(ws, method, reqParams) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const id = `ipad-wechat-${++_gwReqId}`;
|
|
113
|
+
const timer = setTimeout(() => { ws.off("message", handler); reject(new Error(`${method} timed out`)); }, 30000);
|
|
114
|
+
const handler = (data) => {
|
|
64
115
|
try {
|
|
65
116
|
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
|
-
}
|
|
117
|
+
if (frame.type === "res" && frame.id === id) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
ws.off("message", handler);
|
|
120
|
+
if (frame.ok)
|
|
121
|
+
resolve(frame.payload ?? frame);
|
|
122
|
+
else
|
|
123
|
+
reject(new Error(`${method} failed: ${frame.error?.message || "unknown"}`));
|
|
97
124
|
}
|
|
98
125
|
}
|
|
99
|
-
catch { /* ignore
|
|
100
|
-
}
|
|
101
|
-
ws.on("
|
|
102
|
-
ws.
|
|
103
|
-
reject(new Error("Gateway closed before connect")); });
|
|
126
|
+
catch { /* ignore non-matching frames */ }
|
|
127
|
+
};
|
|
128
|
+
ws.on("message", handler);
|
|
129
|
+
ws.send(JSON.stringify({ type: "req", id, method, params: reqParams }));
|
|
104
130
|
});
|
|
105
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Dispatch an inbound message to the agent via the Gateway WebSocket protocol.
|
|
134
|
+
*
|
|
135
|
+
* Strategy:
|
|
136
|
+
* 1. Fire chat.send via a short-lived WS connection (triggers agent)
|
|
137
|
+
* 2. Poll chat.history via separate WS connections to detect the reply
|
|
138
|
+
* 3. Extract reply text, strip NO_REPLY token
|
|
139
|
+
* 4. If there is content, call JuHeBot API to send to WeChat
|
|
140
|
+
*/
|
|
141
|
+
async function dispatchViaGateway(params) {
|
|
142
|
+
const cfg = loadOpenClawConfig();
|
|
143
|
+
const token = cfg?.gateway?.controlUi?.auth?.token || cfg?.gateway?.auth?.token || "";
|
|
144
|
+
// Step 1: Get baseline message count
|
|
145
|
+
const baselineHistory = await gwRpc(token, "chat.history", {
|
|
146
|
+
sessionKey: params.sessionKey, limit: 50,
|
|
147
|
+
});
|
|
148
|
+
const baselineCount = Array.isArray(baselineHistory?.messages)
|
|
149
|
+
? baselineHistory.messages.length : 0;
|
|
150
|
+
console.log(`[iPad WeChat] Baseline history: ${baselineCount} messages`);
|
|
151
|
+
// Step 2: Send user message (triggers agent processing)
|
|
152
|
+
const sendResult = await gwRpc(token, "chat.send", {
|
|
153
|
+
idempotencyKey: randomUUID(),
|
|
154
|
+
sessionKey: params.sessionKey,
|
|
155
|
+
message: params.message,
|
|
156
|
+
deliver: true,
|
|
157
|
+
originatingChannel: "ipad-wechat",
|
|
158
|
+
originatingTo: params.from,
|
|
159
|
+
originatingAccountId: params.accountId || "default",
|
|
160
|
+
});
|
|
161
|
+
console.log(`[iPad WeChat] chat.send accepted, runId=${sendResult?.runId}, polling for reply…`);
|
|
162
|
+
// Step 3: Poll chat.history via fresh connections
|
|
163
|
+
const POLL_INTERVAL = 3000;
|
|
164
|
+
const MAX_POLLS = 40; // 3s × 40 = 120s max
|
|
165
|
+
let replyText = "";
|
|
166
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
167
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
168
|
+
try {
|
|
169
|
+
const history = await gwRpc(token, "chat.history", {
|
|
170
|
+
sessionKey: params.sessionKey, limit: 50,
|
|
171
|
+
});
|
|
172
|
+
const messages = Array.isArray(history?.messages) ? history.messages : [];
|
|
173
|
+
if (messages.length <= baselineCount)
|
|
174
|
+
continue;
|
|
175
|
+
// Look for new assistant messages after the baseline
|
|
176
|
+
// chat.history returns messages with role/content at top level
|
|
177
|
+
const newMessages = messages.slice(baselineCount);
|
|
178
|
+
const assistantMsg = newMessages.find((m) => m?.role === "assistant" || m?.message?.role === "assistant");
|
|
179
|
+
if (assistantMsg) {
|
|
180
|
+
const rawText = extractReplyText(assistantMsg);
|
|
181
|
+
replyText = rawText.replace(/\s*NO_REPLY\s*/gi, "").trim();
|
|
182
|
+
console.log(`[iPad WeChat] Agent reply found on poll ${i + 1}: "${replyText.slice(0, 200)}"`);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (pollErr) {
|
|
187
|
+
console.error(`[iPad WeChat] Poll ${i + 1} error:`, pollErr);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Step 4: Send outbound via JuHeBot API
|
|
191
|
+
if (replyText) {
|
|
192
|
+
try {
|
|
193
|
+
await sendToWeChat(cfg, params.from, replyText, params.accountId);
|
|
194
|
+
console.log(`[iPad WeChat] ✅ Outbound sent to ${params.from}`);
|
|
195
|
+
}
|
|
196
|
+
catch (sendErr) {
|
|
197
|
+
console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
console.log("[iPad WeChat] No non-silent agent reply found after polling, skipping outbound");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
106
204
|
// JuHeBot notify_type constants (from doc-6966894.md)
|
|
107
205
|
const NOTIFY_NEW_MSG = 1010;
|
|
108
206
|
const NOTIFY_BATCH_NEW_MSG = 1011;
|
|
207
|
+
/**
|
|
208
|
+
* Extract message content from JuHeBot `desc` field.
|
|
209
|
+
* Real callback format: "NickName : actualContent"
|
|
210
|
+
* For chatroom: "NickName:\nactualContent"
|
|
211
|
+
*/
|
|
212
|
+
function extractContentFromDesc(desc) {
|
|
213
|
+
if (!desc)
|
|
214
|
+
return "";
|
|
215
|
+
// Private chat: "NickName : content"
|
|
216
|
+
const colonIdx = desc.indexOf(" : ");
|
|
217
|
+
if (colonIdx >= 0)
|
|
218
|
+
return desc.slice(colonIdx + 3);
|
|
219
|
+
// Chatroom: "NickName:\ncontent"
|
|
220
|
+
const nlIdx = desc.indexOf(":\n");
|
|
221
|
+
if (nlIdx >= 0)
|
|
222
|
+
return desc.slice(nlIdx + 2);
|
|
223
|
+
return desc;
|
|
224
|
+
}
|
|
109
225
|
/**
|
|
110
226
|
* Transform JuHeBot callback payload → WebhookPayload
|
|
111
227
|
* Handles both JuHeBot native format and our own test format.
|
|
@@ -130,11 +246,11 @@ function transformPayload(raw) {
|
|
|
130
246
|
messageId: String(m.msg_id ?? m.msgId ?? m.new_msg_id ?? m.newMsgId ?? `msg_${Date.now()}`),
|
|
131
247
|
fromUser: m.from_username ?? m.fromUsername ?? m.from_user ?? m.fromUser ?? "",
|
|
132
248
|
toUser: m.to_username ?? m.toUsername ?? m.to_user ?? m.toUser ?? "",
|
|
133
|
-
content: m.content ?? m.msg_content ?? m.msgContent ?? "",
|
|
249
|
+
content: m.content ?? m.msg_content ?? m.msgContent ?? extractContentFromDesc(m.desc) ?? "",
|
|
134
250
|
type: m.msg_type ?? m.msgType ?? m.type ?? 1,
|
|
135
251
|
timestamp: m.timestamp ?? m.create_time ?? m.createTime ?? Date.now(),
|
|
136
252
|
isSelf: m.is_self === 1 || m.is_self === true || m.isSelf === true,
|
|
137
|
-
roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? undefined,
|
|
253
|
+
roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? (m.is_chatroom_msg === 1 && m.chatroom ? m.chatroom : undefined),
|
|
138
254
|
},
|
|
139
255
|
};
|
|
140
256
|
}
|
|
@@ -144,6 +260,22 @@ function transformPayload(raw) {
|
|
|
144
260
|
}
|
|
145
261
|
const WEBHOOK_PORT = parseInt(process.env.IPAD_WECHAT_WEBHOOK_PORT || "18790", 10);
|
|
146
262
|
let _webhookServerStarted = false;
|
|
263
|
+
// Deduplication: track recently processed msg_ids (TTL 60s)
|
|
264
|
+
const _processedMsgIds = new Map();
|
|
265
|
+
function isDuplicate(msgId) {
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
// Prune old entries every check
|
|
268
|
+
if (_processedMsgIds.size > 500) {
|
|
269
|
+
for (const [k, t] of _processedMsgIds) {
|
|
270
|
+
if (now - t > 60000)
|
|
271
|
+
_processedMsgIds.delete(k);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (_processedMsgIds.has(msgId))
|
|
275
|
+
return true;
|
|
276
|
+
_processedMsgIds.set(msgId, now);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
147
279
|
export default defineChannelPluginEntry({
|
|
148
280
|
id: "ipad-wechat",
|
|
149
281
|
name: "iPad WeChat",
|
|
@@ -202,8 +334,16 @@ export default defineChannelPluginEntry({
|
|
|
202
334
|
console.log("[iPad WeChat] Webhook received:", JSON.stringify(raw).slice(0, 500));
|
|
203
335
|
const payload = transformPayload(raw);
|
|
204
336
|
if (payload && payload.message) {
|
|
337
|
+
// Deduplicate by msg_id
|
|
338
|
+
if (isDuplicate(payload.message.messageId)) {
|
|
339
|
+
console.log(`[iPad WeChat] Duplicate msg_id=${payload.message.messageId}, skipping`);
|
|
340
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
341
|
+
res.end("ok");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
205
344
|
const cfg = loadOpenClawConfig();
|
|
206
345
|
const msg = payload.message;
|
|
346
|
+
console.log(`[iPad WeChat] Parsed: from=${msg.fromUser}, content="${msg.content?.slice(0, 100)}", type=${msg.type}`);
|
|
207
347
|
const isChatroom = !!msg.roomId;
|
|
208
348
|
const peerId = isChatroom ? msg.roomId : (msg.fromUser || msg.toUser || "");
|
|
209
349
|
const peerKind = isChatroom ? "group" : "direct";
|
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
|
@@ -22,6 +22,7 @@ import { randomUUID } from "crypto";
|
|
|
22
22
|
import WebSocket from "ws";
|
|
23
23
|
import { defineChannelPluginEntry, buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/core";
|
|
24
24
|
import { ipadWeChatPlugin, handleInboundMessage, getCacheManagerReady } 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,107 @@ 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 url = `ws://127.0.0.1:${GATEWAY_PORT}`;
|
|
94
|
+
const ws = new WebSocket(url, { headers: { Origin: `http://127.0.0.1:${GATEWAY_PORT}` } });
|
|
95
|
+
await new Promise<void>((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Handshake
|
|
99
|
+
const connectRes = await rpcCall(ws, "connect", {
|
|
100
|
+
minProtocol: 3, maxProtocol: 3,
|
|
101
|
+
client: { id: "openclaw-control-ui", version: "1.0.0", mode: "webchat", platform: "node" },
|
|
102
|
+
scopes: ["operator.admin", "operator.read", "operator.write"],
|
|
103
|
+
...(token ? { auth: { token } } : {}),
|
|
104
|
+
});
|
|
105
|
+
if (!connectRes) throw new Error("Gateway connect failed");
|
|
106
|
+
|
|
107
|
+
// Execute the method
|
|
108
|
+
return await rpcCall(ws, method, reqParams);
|
|
109
|
+
} finally {
|
|
110
|
+
ws.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Send a single WS RPC request and wait for the response (with 30s timeout).
|
|
116
|
+
*/
|
|
117
|
+
function rpcCall(ws: WebSocket, method: string, reqParams: any): Promise<any> {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const id = `ipad-wechat-${++_gwReqId}`;
|
|
120
|
+
const timer = setTimeout(() => { ws.off("message", handler); reject(new Error(`${method} timed out`)); }, 30000);
|
|
121
|
+
const handler = (data: Buffer) => {
|
|
122
|
+
try {
|
|
123
|
+
const frame = JSON.parse(data.toString());
|
|
124
|
+
if (frame.type === "res" && frame.id === id) {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
ws.off("message", handler);
|
|
127
|
+
if (frame.ok) resolve(frame.payload ?? frame);
|
|
128
|
+
else reject(new Error(`${method} failed: ${frame.error?.message || "unknown"}`));
|
|
129
|
+
}
|
|
130
|
+
} catch { /* ignore non-matching frames */ }
|
|
131
|
+
};
|
|
132
|
+
ws.on("message", handler);
|
|
133
|
+
ws.send(JSON.stringify({ type: "req", id, method, params: reqParams }));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Dispatch an inbound message to the agent via the Gateway WebSocket protocol.
|
|
139
|
+
*
|
|
140
|
+
* Strategy:
|
|
141
|
+
* 1. Fire chat.send via a short-lived WS connection (triggers agent)
|
|
142
|
+
* 2. Poll chat.history via separate WS connections to detect the reply
|
|
143
|
+
* 3. Extract reply text, strip NO_REPLY token
|
|
144
|
+
* 4. If there is content, call JuHeBot API to send to WeChat
|
|
145
|
+
*/
|
|
146
|
+
async function dispatchViaGateway(params: {
|
|
46
147
|
sessionKey: string;
|
|
47
148
|
message: string;
|
|
48
149
|
from: string;
|
|
@@ -52,63 +153,93 @@ function dispatchViaGateway(params: {
|
|
|
52
153
|
const cfg = loadOpenClawConfig();
|
|
53
154
|
const token = cfg?.gateway?.controlUi?.auth?.token || cfg?.gateway?.auth?.token || "";
|
|
54
155
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
156
|
+
// Step 1: Get baseline message count
|
|
157
|
+
const baselineHistory = await gwRpc(token, "chat.history", {
|
|
158
|
+
sessionKey: params.sessionKey, limit: 50,
|
|
159
|
+
});
|
|
160
|
+
const baselineCount = Array.isArray(baselineHistory?.messages)
|
|
161
|
+
? baselineHistory.messages.length : 0;
|
|
162
|
+
console.log(`[iPad WeChat] Baseline history: ${baselineCount} messages`);
|
|
163
|
+
|
|
164
|
+
// Step 2: Send user message (triggers agent processing)
|
|
165
|
+
const sendResult = await gwRpc(token, "chat.send", {
|
|
166
|
+
idempotencyKey: randomUUID(),
|
|
167
|
+
sessionKey: params.sessionKey,
|
|
168
|
+
message: params.message,
|
|
169
|
+
deliver: true,
|
|
170
|
+
originatingChannel: "ipad-wechat",
|
|
171
|
+
originatingTo: params.from,
|
|
172
|
+
originatingAccountId: params.accountId || "default",
|
|
173
|
+
});
|
|
174
|
+
console.log(`[iPad WeChat] chat.send accepted, runId=${sendResult?.runId}, polling for reply…`);
|
|
75
175
|
|
|
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
|
-
});
|
|
176
|
+
// Step 3: Poll chat.history via fresh connections
|
|
177
|
+
const POLL_INTERVAL = 3000;
|
|
178
|
+
const MAX_POLLS = 40; // 3s × 40 = 120s max
|
|
179
|
+
let replyText = "";
|
|
102
180
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
181
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
182
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const history = await gwRpc(token, "chat.history", {
|
|
186
|
+
sessionKey: params.sessionKey, limit: 50,
|
|
187
|
+
});
|
|
188
|
+
const messages = Array.isArray(history?.messages) ? history.messages : [];
|
|
189
|
+
|
|
190
|
+
if (messages.length <= baselineCount) continue;
|
|
191
|
+
|
|
192
|
+
// Look for new assistant messages after the baseline
|
|
193
|
+
// chat.history returns messages with role/content at top level
|
|
194
|
+
const newMessages = messages.slice(baselineCount);
|
|
195
|
+
const assistantMsg = newMessages.find(
|
|
196
|
+
(m: any) => m?.role === "assistant" || m?.message?.role === "assistant"
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (assistantMsg) {
|
|
200
|
+
const rawText = extractReplyText(assistantMsg);
|
|
201
|
+
replyText = rawText.replace(/\s*NO_REPLY\s*/gi, "").trim();
|
|
202
|
+
console.log(`[iPad WeChat] Agent reply found on poll ${i + 1}: "${replyText.slice(0, 200)}"`);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
} catch (pollErr) {
|
|
206
|
+
console.error(`[iPad WeChat] Poll ${i + 1} error:`, pollErr);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Step 4: Send outbound via JuHeBot API
|
|
211
|
+
if (replyText) {
|
|
212
|
+
try {
|
|
213
|
+
await sendToWeChat(cfg, params.from, replyText, params.accountId);
|
|
214
|
+
console.log(`[iPad WeChat] ✅ Outbound sent to ${params.from}`);
|
|
215
|
+
} catch (sendErr) {
|
|
216
|
+
console.error(`[iPad WeChat] ❌ Outbound send failed:`, sendErr);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
console.log("[iPad WeChat] No non-silent agent reply found after polling, skipping outbound");
|
|
220
|
+
}
|
|
106
221
|
}
|
|
107
222
|
|
|
108
223
|
// JuHeBot notify_type constants (from doc-6966894.md)
|
|
109
224
|
const NOTIFY_NEW_MSG = 1010;
|
|
110
225
|
const NOTIFY_BATCH_NEW_MSG = 1011;
|
|
111
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Extract message content from JuHeBot `desc` field.
|
|
229
|
+
* Real callback format: "NickName : actualContent"
|
|
230
|
+
* For chatroom: "NickName:\nactualContent"
|
|
231
|
+
*/
|
|
232
|
+
function extractContentFromDesc(desc: string | undefined): string {
|
|
233
|
+
if (!desc) return "";
|
|
234
|
+
// Private chat: "NickName : content"
|
|
235
|
+
const colonIdx = desc.indexOf(" : ");
|
|
236
|
+
if (colonIdx >= 0) return desc.slice(colonIdx + 3);
|
|
237
|
+
// Chatroom: "NickName:\ncontent"
|
|
238
|
+
const nlIdx = desc.indexOf(":\n");
|
|
239
|
+
if (nlIdx >= 0) return desc.slice(nlIdx + 2);
|
|
240
|
+
return desc;
|
|
241
|
+
}
|
|
242
|
+
|
|
112
243
|
/**
|
|
113
244
|
* Transform JuHeBot callback payload → WebhookPayload
|
|
114
245
|
* Handles both JuHeBot native format and our own test format.
|
|
@@ -136,11 +267,11 @@ function transformPayload(raw: any): WebhookPayload | null {
|
|
|
136
267
|
messageId: String(m.msg_id ?? m.msgId ?? m.new_msg_id ?? m.newMsgId ?? `msg_${Date.now()}`),
|
|
137
268
|
fromUser: m.from_username ?? m.fromUsername ?? m.from_user ?? m.fromUser ?? "",
|
|
138
269
|
toUser: m.to_username ?? m.toUsername ?? m.to_user ?? m.toUser ?? "",
|
|
139
|
-
content: m.content ?? m.msg_content ?? m.msgContent ?? "",
|
|
270
|
+
content: m.content ?? m.msg_content ?? m.msgContent ?? extractContentFromDesc(m.desc) ?? "",
|
|
140
271
|
type: m.msg_type ?? m.msgType ?? m.type ?? 1,
|
|
141
272
|
timestamp: m.timestamp ?? m.create_time ?? m.createTime ?? Date.now(),
|
|
142
273
|
isSelf: m.is_self === 1 || m.is_self === true || m.isSelf === true,
|
|
143
|
-
roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? undefined,
|
|
274
|
+
roomId: m.room_username ?? m.roomUsername ?? m.roomId ?? (m.is_chatroom_msg === 1 && m.chatroom ? m.chatroom : undefined),
|
|
144
275
|
},
|
|
145
276
|
};
|
|
146
277
|
}
|
|
@@ -153,6 +284,21 @@ function transformPayload(raw: any): WebhookPayload | null {
|
|
|
153
284
|
const WEBHOOK_PORT = parseInt(process.env.IPAD_WECHAT_WEBHOOK_PORT || "18790", 10);
|
|
154
285
|
let _webhookServerStarted = false;
|
|
155
286
|
|
|
287
|
+
// Deduplication: track recently processed msg_ids (TTL 60s)
|
|
288
|
+
const _processedMsgIds = new Map<string, number>();
|
|
289
|
+
function isDuplicate(msgId: string): boolean {
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
// Prune old entries every check
|
|
292
|
+
if (_processedMsgIds.size > 500) {
|
|
293
|
+
for (const [k, t] of _processedMsgIds) {
|
|
294
|
+
if (now - t > 60000) _processedMsgIds.delete(k);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (_processedMsgIds.has(msgId)) return true;
|
|
298
|
+
_processedMsgIds.set(msgId, now);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
156
302
|
export default defineChannelPluginEntry({
|
|
157
303
|
id: "ipad-wechat",
|
|
158
304
|
name: "iPad WeChat",
|
|
@@ -212,8 +358,17 @@ export default defineChannelPluginEntry({
|
|
|
212
358
|
|
|
213
359
|
const payload = transformPayload(raw);
|
|
214
360
|
if (payload && payload.message) {
|
|
361
|
+
// Deduplicate by msg_id
|
|
362
|
+
if (isDuplicate(payload.message.messageId)) {
|
|
363
|
+
console.log(`[iPad WeChat] Duplicate msg_id=${payload.message.messageId}, skipping`);
|
|
364
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
365
|
+
res.end("ok");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
215
369
|
const cfg = loadOpenClawConfig();
|
|
216
370
|
const msg = payload.message;
|
|
371
|
+
console.log(`[iPad WeChat] Parsed: from=${msg.fromUser}, content="${msg.content?.slice(0, 100)}", type=${msg.type}`);
|
|
217
372
|
const isChatroom = !!(msg as any).roomId;
|
|
218
373
|
const peerId = isChatroom ? (msg as any).roomId : (msg.fromUser || msg.toUser || "");
|
|
219
374
|
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": "
|
|
5
|
+
"version": "2.0.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": "
|
|
3
|
+
"version": "2.0.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/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
|
-
}
|