weixin-mcp 1.0.2 → 1.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/dist/api.d.ts +25 -8
- package/dist/api.js +91 -45
- package/dist/index.js +43 -40
- package/package.json +1 -1
- package/src/api.ts +121 -59
- package/src/index.ts +68 -48
- package/test-poll.mjs +23 -8
package/dist/api.d.ts
CHANGED
|
@@ -6,13 +6,30 @@ export declare class WeixinNetworkError extends Error {
|
|
|
6
6
|
constructor(message: string);
|
|
7
7
|
}
|
|
8
8
|
export declare function generateClientId(): string;
|
|
9
|
+
export declare function loadCursor(accountId: string): string;
|
|
10
|
+
export declare function saveCursor(accountId: string, cursor: string): void;
|
|
9
11
|
export declare function weixinRequest(endpoint: string, body: unknown, token: string, baseUrl?: string, retries?: number): Promise<unknown>;
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Send a text message.
|
|
14
|
+
* Pass contextToken from the received message to link the reply to the conversation.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sendTextMessage(to: string, text: string, token: string, baseUrl: string, contextToken?: string): Promise<unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Long-poll for new messages.
|
|
19
|
+
* Pass the cursor from the previous response to avoid re-receiving old messages.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getUpdates(token: string, baseUrl: string, cursor?: string): Promise<{
|
|
22
|
+
msgs?: unknown[];
|
|
23
|
+
get_updates_buf?: string;
|
|
24
|
+
ret?: number;
|
|
25
|
+
errcode?: number;
|
|
18
26
|
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Get bot config for a user (includes typing_ticket and context_token).
|
|
29
|
+
*/
|
|
30
|
+
export declare function getConfig(ilinkUserId: string, token: string, baseUrl: string, contextToken?: string): Promise<unknown>;
|
|
31
|
+
/**
|
|
32
|
+
* Send typing indicator.
|
|
33
|
+
* status: 1 = typing, 2 = cancel
|
|
34
|
+
*/
|
|
35
|
+
export declare function sendTyping(ilinkUserId: string, typingTicket: string, status: 1 | 2, token: string, baseUrl: string): Promise<unknown>;
|
package/dist/api.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
2
5
|
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
3
|
-
const
|
|
6
|
+
const CHANNEL_VERSION = "1.0.2";
|
|
4
7
|
export class WeixinAuthError extends Error {
|
|
5
|
-
constructor(message = "Authentication failed. Run npm run login
|
|
8
|
+
constructor(message = "Authentication failed. Run: npm run login") {
|
|
6
9
|
super(message);
|
|
7
10
|
this.name = "WeixinAuthError";
|
|
8
11
|
}
|
|
@@ -13,21 +16,56 @@ export class WeixinNetworkError extends Error {
|
|
|
13
16
|
this.name = "WeixinNetworkError";
|
|
14
17
|
}
|
|
15
18
|
}
|
|
19
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
16
20
|
export function generateClientId() {
|
|
17
|
-
return `
|
|
21
|
+
return `weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
18
22
|
}
|
|
23
|
+
/** X-WECHAT-UIN: base64-encoded random uint32, required by backend */
|
|
24
|
+
function randomWechatUin() {
|
|
25
|
+
const buf = crypto.randomBytes(4);
|
|
26
|
+
return buf.toString("base64");
|
|
27
|
+
}
|
|
28
|
+
function buildHeaders(token, bodyStr) {
|
|
29
|
+
return {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"Content-Length": String(Buffer.byteLength(bodyStr, "utf-8")),
|
|
32
|
+
"AuthorizationType": "ilink_bot_token",
|
|
33
|
+
"Authorization": `Bearer ${token}`,
|
|
34
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// ── Cursor persistence ─────────────────────────────────────────────────────
|
|
38
|
+
const STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
39
|
+
path.join(os.homedir(), ".openclaw");
|
|
40
|
+
function cursorPath(accountId) {
|
|
41
|
+
return path.join(STATE_DIR, "openclaw-weixin", "accounts", `${accountId}.cursor.json`);
|
|
42
|
+
}
|
|
43
|
+
export function loadCursor(accountId) {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(fs.readFileSync(cursorPath(accountId), "utf-8"));
|
|
46
|
+
return data.cursor ?? "";
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function saveCursor(accountId, cursor) {
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(cursorPath(accountId), JSON.stringify({ cursor }));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// non-fatal
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ── Core request ───────────────────────────────────────────────────────────
|
|
19
61
|
async function parseErrorResponse(res) {
|
|
20
62
|
try {
|
|
21
|
-
|
|
22
|
-
return text.trim() || `HTTP ${res.status}`;
|
|
63
|
+
return (await res.text()).trim() || `HTTP ${res.status}`;
|
|
23
64
|
}
|
|
24
65
|
catch {
|
|
25
66
|
return `HTTP ${res.status}`;
|
|
26
67
|
}
|
|
27
68
|
}
|
|
28
|
-
function isTransientNetworkError(error) {
|
|
29
|
-
return error instanceof TypeError || error instanceof WeixinNetworkError;
|
|
30
|
-
}
|
|
31
69
|
export async function weixinRequest(endpoint, body, token, baseUrl = DEFAULT_BASE_URL, retries = 1) {
|
|
32
70
|
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
33
71
|
const url = new URL(endpoint, base).toString();
|
|
@@ -36,40 +74,35 @@ export async function weixinRequest(endpoint, body, token, baseUrl = DEFAULT_BAS
|
|
|
36
74
|
try {
|
|
37
75
|
const res = await fetch(url, {
|
|
38
76
|
method: "POST",
|
|
39
|
-
headers:
|
|
40
|
-
"Content-Type": "application/json",
|
|
41
|
-
"Content-Length": String(Buffer.byteLength(bodyStr, "utf-8")),
|
|
42
|
-
"AuthorizationType": "ilink_bot_token",
|
|
43
|
-
"Authorization": `Bearer ${token}`,
|
|
44
|
-
},
|
|
77
|
+
headers: buildHeaders(token, bodyStr),
|
|
45
78
|
body: bodyStr,
|
|
46
79
|
});
|
|
47
|
-
if (
|
|
80
|
+
if (res.status === 401 || res.status === 403)
|
|
48
81
|
throw new WeixinAuthError();
|
|
49
|
-
}
|
|
50
82
|
if (!res.ok) {
|
|
51
|
-
const
|
|
52
|
-
throw new Error(`Weixin API error ${res.status}: ${
|
|
83
|
+
const msg = await parseErrorResponse(res);
|
|
84
|
+
throw new Error(`Weixin API error ${res.status}: ${msg}`);
|
|
53
85
|
}
|
|
54
86
|
return res.json();
|
|
55
87
|
}
|
|
56
|
-
catch (
|
|
57
|
-
if (
|
|
58
|
-
throw
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
throw new WeixinNetworkError(error instanceof Error ? error.message : "Network request failed");
|
|
65
|
-
}
|
|
66
|
-
throw error;
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err instanceof WeixinAuthError)
|
|
90
|
+
throw err;
|
|
91
|
+
if (err instanceof TypeError && attempt < retries)
|
|
92
|
+
continue; // network retry
|
|
93
|
+
if (err instanceof TypeError)
|
|
94
|
+
throw new WeixinNetworkError(String(err));
|
|
95
|
+
throw err;
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
|
-
throw new WeixinNetworkError("Network request failed");
|
|
98
|
+
throw new WeixinNetworkError("Network request failed after retries");
|
|
70
99
|
}
|
|
71
|
-
|
|
72
|
-
|
|
100
|
+
// ── API functions ──────────────────────────────────────────────────────────
|
|
101
|
+
/**
|
|
102
|
+
* Send a text message.
|
|
103
|
+
* Pass contextToken from the received message to link the reply to the conversation.
|
|
104
|
+
*/
|
|
105
|
+
export async function sendTextMessage(to, text, token, baseUrl, contextToken) {
|
|
73
106
|
return weixinRequest("ilink/bot/sendmessage", {
|
|
74
107
|
msg: {
|
|
75
108
|
from_user_id: "",
|
|
@@ -78,27 +111,40 @@ export async function sendTextMessage(to, text, token, baseUrl) {
|
|
|
78
111
|
message_type: 2, // BOT
|
|
79
112
|
message_state: 2, // FINISH
|
|
80
113
|
item_list: [{ type: 1, text_item: { text } }],
|
|
114
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
81
115
|
},
|
|
82
|
-
base_info: { channel_version:
|
|
116
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
83
117
|
}, token, baseUrl);
|
|
84
118
|
}
|
|
85
|
-
/**
|
|
86
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Long-poll for new messages.
|
|
121
|
+
* Pass the cursor from the previous response to avoid re-receiving old messages.
|
|
122
|
+
*/
|
|
123
|
+
export async function getUpdates(token, baseUrl, cursor = "") {
|
|
87
124
|
return weixinRequest("ilink/bot/getupdates", {
|
|
88
|
-
|
|
89
|
-
base_info: { channel_version:
|
|
125
|
+
get_updates_buf: cursor,
|
|
126
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
90
127
|
}, token, baseUrl);
|
|
91
128
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Get bot config for a user (includes typing_ticket and context_token).
|
|
131
|
+
*/
|
|
132
|
+
export async function getConfig(ilinkUserId, token, baseUrl, contextToken) {
|
|
94
133
|
return weixinRequest("ilink/bot/getconfig", {
|
|
95
134
|
ilink_user_id: ilinkUserId,
|
|
96
|
-
|
|
135
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
136
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
97
137
|
}, token, baseUrl);
|
|
98
138
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Send typing indicator.
|
|
141
|
+
* status: 1 = typing, 2 = cancel
|
|
142
|
+
*/
|
|
143
|
+
export async function sendTyping(ilinkUserId, typingTicket, status, token, baseUrl) {
|
|
144
|
+
return weixinRequest("ilink/bot/sendtyping", {
|
|
145
|
+
ilink_user_id: ilinkUserId,
|
|
146
|
+
typing_ticket: typingTicket,
|
|
147
|
+
status,
|
|
148
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
149
|
+
}, token, baseUrl);
|
|
104
150
|
}
|
package/dist/index.js
CHANGED
|
@@ -9,25 +9,26 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import os from "node:os";
|
|
12
|
-
import { DEFAULT_BASE_URL,
|
|
12
|
+
import { DEFAULT_BASE_URL, getUpdates, getConfig, sendTextMessage, loadCursor, saveCursor, WeixinAuthError, WeixinNetworkError, } from "./api.js";
|
|
13
13
|
// ── Auth / config ──────────────────────────────────────────────────────────
|
|
14
14
|
const STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
15
15
|
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
16
16
|
path.join(os.homedir(), ".openclaw");
|
|
17
17
|
const WEIXIN_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
|
|
18
18
|
function loadAccount() {
|
|
19
|
-
const files = fs
|
|
19
|
+
const files = fs
|
|
20
|
+
.readdirSync(WEIXIN_DIR)
|
|
21
|
+
.filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
|
20
22
|
if (files.length === 0)
|
|
21
|
-
throw new Error("No WeChat account found. Run:
|
|
22
|
-
// Pick the first (or explicitly set) account
|
|
23
|
+
throw new Error("No WeChat account found. Run: npm run login");
|
|
23
24
|
const accountId = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
|
|
24
25
|
const filePath = path.join(WEIXIN_DIR, `${accountId}.json`);
|
|
25
26
|
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
26
27
|
if (!data.token)
|
|
27
|
-
throw new Error(`No token for account ${accountId}. Re-run
|
|
28
|
+
throw new Error(`No token for account ${accountId}. Re-run: npm run login`);
|
|
28
29
|
return { ...data, accountId };
|
|
29
30
|
}
|
|
30
|
-
// ──
|
|
31
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
31
32
|
function assertNonEmptyString(value, field) {
|
|
32
33
|
if (typeof value !== "string" || value.trim() === "") {
|
|
33
34
|
throw new Error(`Invalid argument "${field}": must be a non-empty string`);
|
|
@@ -35,84 +36,86 @@ function assertNonEmptyString(value, field) {
|
|
|
35
36
|
return value.trim();
|
|
36
37
|
}
|
|
37
38
|
function formatToolError(error) {
|
|
38
|
-
if (error instanceof WeixinAuthError)
|
|
39
|
+
if (error instanceof WeixinAuthError)
|
|
39
40
|
return error.message;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
if (error instanceof Error) {
|
|
41
|
+
if (error instanceof WeixinNetworkError)
|
|
42
|
+
return `Network error: ${error.message}`;
|
|
43
|
+
if (error instanceof Error)
|
|
45
44
|
return error.message;
|
|
46
|
-
}
|
|
47
45
|
return String(error);
|
|
48
46
|
}
|
|
49
47
|
// ── MCP Server ─────────────────────────────────────────────────────────────
|
|
50
|
-
const server = new Server({ name: "weixin-mcp", version: "1.0.
|
|
48
|
+
const server = new Server({ name: "weixin-mcp", version: "1.0.2" }, { capabilities: { tools: {} } });
|
|
51
49
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
52
50
|
tools: [
|
|
53
51
|
{
|
|
54
52
|
name: "weixin_send",
|
|
55
|
-
description: "Send a WeChat message to a user
|
|
53
|
+
description: "Send a WeChat text message to a user. Pass context_token from a received message to link the reply to the conversation thread.",
|
|
56
54
|
inputSchema: {
|
|
57
55
|
type: "object",
|
|
58
56
|
properties: {
|
|
59
57
|
to: { type: "string", description: "Recipient user ID / OpenId" },
|
|
60
58
|
text: { type: "string", description: "Message text to send" },
|
|
59
|
+
context_token: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Optional: context_token from a received message, links the reply to the conversation",
|
|
62
|
+
},
|
|
61
63
|
},
|
|
62
64
|
required: ["to", "text"],
|
|
63
65
|
},
|
|
64
66
|
},
|
|
65
67
|
{
|
|
66
|
-
name: "
|
|
67
|
-
description: "
|
|
68
|
-
inputSchema: { type: "object", properties: {} },
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
name: "weixin_poll_messages",
|
|
72
|
-
description: "Poll for new WeChat messages since a timestamp",
|
|
68
|
+
name: "weixin_poll",
|
|
69
|
+
description: "Poll for new WeChat messages. Uses a persistent cursor to avoid re-delivering old messages. Returns new messages since last poll.",
|
|
73
70
|
inputSchema: {
|
|
74
71
|
type: "object",
|
|
75
72
|
properties: {
|
|
76
|
-
|
|
73
|
+
reset_cursor: {
|
|
74
|
+
type: "boolean",
|
|
75
|
+
description: "If true, reset cursor and re-fetch from the beginning (useful for debugging)",
|
|
76
|
+
},
|
|
77
77
|
},
|
|
78
78
|
},
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
|
-
name: "
|
|
82
|
-
description: "Get
|
|
81
|
+
name: "weixin_get_config",
|
|
82
|
+
description: "Get bot config for a user — includes typing_ticket needed for sendTyping. Call before sending typing indicators.",
|
|
83
83
|
inputSchema: {
|
|
84
84
|
type: "object",
|
|
85
85
|
properties: {
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
user_id: { type: "string", description: "Target user ID / OpenId" },
|
|
87
|
+
context_token: { type: "string", description: "Optional context token" },
|
|
88
88
|
},
|
|
89
|
-
required: ["
|
|
89
|
+
required: ["user_id"],
|
|
90
90
|
},
|
|
91
91
|
},
|
|
92
92
|
],
|
|
93
93
|
}));
|
|
94
94
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
95
95
|
const account = loadAccount();
|
|
96
|
-
const { token, baseUrl = DEFAULT_BASE_URL } = account;
|
|
96
|
+
const { token, baseUrl = DEFAULT_BASE_URL, accountId } = account;
|
|
97
97
|
const { name, arguments: args } = req.params;
|
|
98
98
|
try {
|
|
99
99
|
let result;
|
|
100
100
|
if (name === "weixin_send") {
|
|
101
|
-
const { to, text } = (args ?? {});
|
|
101
|
+
const { to, text, context_token } = (args ?? {});
|
|
102
102
|
const validatedTo = assertNonEmptyString(to, "to");
|
|
103
103
|
const validatedText = assertNonEmptyString(text, "text");
|
|
104
|
-
result = await sendTextMessage(validatedTo, validatedText, token, baseUrl);
|
|
105
|
-
}
|
|
106
|
-
else if (name === "weixin_get_contacts") {
|
|
107
|
-
result = await getContacts(token, baseUrl);
|
|
104
|
+
result = await sendTextMessage(validatedTo, validatedText, token, baseUrl, context_token);
|
|
108
105
|
}
|
|
109
|
-
else if (name === "
|
|
110
|
-
const {
|
|
111
|
-
|
|
106
|
+
else if (name === "weixin_poll") {
|
|
107
|
+
const { reset_cursor } = (args ?? {});
|
|
108
|
+
const cursor = reset_cursor ? "" : loadCursor(accountId);
|
|
109
|
+
const resp = await getUpdates(token, baseUrl, cursor);
|
|
110
|
+
// Persist new cursor for next poll
|
|
111
|
+
if (resp.get_updates_buf)
|
|
112
|
+
saveCursor(accountId, resp.get_updates_buf);
|
|
113
|
+
result = resp;
|
|
112
114
|
}
|
|
113
|
-
else if (name === "
|
|
114
|
-
const {
|
|
115
|
-
|
|
115
|
+
else if (name === "weixin_get_config") {
|
|
116
|
+
const { user_id, context_token } = (args ?? {});
|
|
117
|
+
const validatedUserId = assertNonEmptyString(user_id, "user_id");
|
|
118
|
+
result = await getConfig(validatedUserId, token, baseUrl, context_token);
|
|
116
119
|
}
|
|
117
120
|
else {
|
|
118
121
|
throw new Error(`Unknown tool: ${name}`);
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
2
5
|
|
|
3
6
|
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
4
|
-
|
|
5
|
-
const AUTH_ERROR_STATUSES = new Set([401, 403]);
|
|
7
|
+
const CHANNEL_VERSION = "1.0.2";
|
|
6
8
|
|
|
7
9
|
export class WeixinAuthError extends Error {
|
|
8
|
-
constructor(message = "Authentication failed. Run npm run login
|
|
10
|
+
constructor(message = "Authentication failed. Run: npm run login") {
|
|
9
11
|
super(message);
|
|
10
12
|
this.name = "WeixinAuthError";
|
|
11
13
|
}
|
|
@@ -18,21 +20,63 @@ export class WeixinNetworkError extends Error {
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
21
25
|
export function generateClientId(): string {
|
|
22
|
-
return `
|
|
26
|
+
return `weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
/** X-WECHAT-UIN: base64-encoded random uint32, required by backend */
|
|
30
|
+
function randomWechatUin(): string {
|
|
31
|
+
const buf = crypto.randomBytes(4);
|
|
32
|
+
return buf.toString("base64");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildHeaders(token: string, bodyStr: string): Record<string, string> {
|
|
36
|
+
return {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"Content-Length": String(Buffer.byteLength(bodyStr, "utf-8")),
|
|
39
|
+
"AuthorizationType": "ilink_bot_token",
|
|
40
|
+
"Authorization": `Bearer ${token}`,
|
|
41
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Cursor persistence ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const STATE_DIR =
|
|
48
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
49
|
+
path.join(os.homedir(), ".openclaw");
|
|
50
|
+
|
|
51
|
+
function cursorPath(accountId: string): string {
|
|
52
|
+
return path.join(STATE_DIR, "openclaw-weixin", "accounts", `${accountId}.cursor.json`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadCursor(accountId: string): string {
|
|
26
56
|
try {
|
|
27
|
-
const
|
|
28
|
-
return
|
|
57
|
+
const data = JSON.parse(fs.readFileSync(cursorPath(accountId), "utf-8")) as { cursor?: string };
|
|
58
|
+
return data.cursor ?? "";
|
|
29
59
|
} catch {
|
|
30
|
-
return
|
|
60
|
+
return "";
|
|
31
61
|
}
|
|
32
62
|
}
|
|
33
63
|
|
|
34
|
-
function
|
|
35
|
-
|
|
64
|
+
export function saveCursor(accountId: string, cursor: string): void {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(cursorPath(accountId), JSON.stringify({ cursor }));
|
|
67
|
+
} catch {
|
|
68
|
+
// non-fatal
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Core request ───────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async function parseErrorResponse(res: Response): Promise<string> {
|
|
75
|
+
try {
|
|
76
|
+
return (await res.text()).trim() || `HTTP ${res.status}`;
|
|
77
|
+
} catch {
|
|
78
|
+
return `HTTP ${res.status}`;
|
|
79
|
+
}
|
|
36
80
|
}
|
|
37
81
|
|
|
38
82
|
export async function weixinRequest(
|
|
@@ -50,49 +94,39 @@ export async function weixinRequest(
|
|
|
50
94
|
try {
|
|
51
95
|
const res = await fetch(url, {
|
|
52
96
|
method: "POST",
|
|
53
|
-
headers:
|
|
54
|
-
"Content-Type": "application/json",
|
|
55
|
-
"Content-Length": String(Buffer.byteLength(bodyStr, "utf-8")),
|
|
56
|
-
"AuthorizationType": "ilink_bot_token",
|
|
57
|
-
"Authorization": `Bearer ${token}`,
|
|
58
|
-
},
|
|
97
|
+
headers: buildHeaders(token, bodyStr),
|
|
59
98
|
body: bodyStr,
|
|
60
99
|
});
|
|
61
100
|
|
|
62
|
-
if (
|
|
63
|
-
throw new WeixinAuthError();
|
|
64
|
-
}
|
|
65
|
-
|
|
101
|
+
if (res.status === 401 || res.status === 403) throw new WeixinAuthError();
|
|
66
102
|
if (!res.ok) {
|
|
67
|
-
const
|
|
68
|
-
throw new Error(`Weixin API error ${res.status}: ${
|
|
103
|
+
const msg = await parseErrorResponse(res);
|
|
104
|
+
throw new Error(`Weixin API error ${res.status}: ${msg}`);
|
|
69
105
|
}
|
|
70
|
-
|
|
71
106
|
return res.json();
|
|
72
|
-
} catch (
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (isTransientNetworkError(error) && attempt < retries) {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (isTransientNetworkError(error)) {
|
|
82
|
-
throw new WeixinNetworkError(
|
|
83
|
-
error instanceof Error ? error.message : "Network request failed",
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
throw error;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err instanceof WeixinAuthError) throw err;
|
|
109
|
+
if (err instanceof TypeError && attempt < retries) continue; // network retry
|
|
110
|
+
if (err instanceof TypeError) throw new WeixinNetworkError(String(err));
|
|
111
|
+
throw err;
|
|
88
112
|
}
|
|
89
113
|
}
|
|
90
|
-
|
|
91
|
-
throw new WeixinNetworkError("Network request failed");
|
|
114
|
+
throw new WeixinNetworkError("Network request failed after retries");
|
|
92
115
|
}
|
|
93
116
|
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
// ── API functions ──────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send a text message.
|
|
121
|
+
* Pass contextToken from the received message to link the reply to the conversation.
|
|
122
|
+
*/
|
|
123
|
+
export async function sendTextMessage(
|
|
124
|
+
to: string,
|
|
125
|
+
text: string,
|
|
126
|
+
token: string,
|
|
127
|
+
baseUrl: string,
|
|
128
|
+
contextToken?: string,
|
|
129
|
+
) {
|
|
96
130
|
return weixinRequest(
|
|
97
131
|
"ilink/bot/sendmessage",
|
|
98
132
|
{
|
|
@@ -100,46 +134,74 @@ export async function sendTextMessage(to: string, text: string, token: string, b
|
|
|
100
134
|
from_user_id: "",
|
|
101
135
|
to_user_id: to,
|
|
102
136
|
client_id: generateClientId(),
|
|
103
|
-
message_type: 2,
|
|
104
|
-
message_state: 2,
|
|
137
|
+
message_type: 2, // BOT
|
|
138
|
+
message_state: 2, // FINISH
|
|
105
139
|
item_list: [{ type: 1, text_item: { text } }],
|
|
140
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
106
141
|
},
|
|
107
|
-
base_info: { channel_version:
|
|
142
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
108
143
|
},
|
|
109
144
|
token,
|
|
110
145
|
baseUrl,
|
|
111
146
|
);
|
|
112
147
|
}
|
|
113
148
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Long-poll for new messages.
|
|
151
|
+
* Pass the cursor from the previous response to avoid re-receiving old messages.
|
|
152
|
+
*/
|
|
153
|
+
export async function getUpdates(
|
|
154
|
+
token: string,
|
|
155
|
+
baseUrl: string,
|
|
156
|
+
cursor = "",
|
|
157
|
+
): Promise<{ msgs?: unknown[]; get_updates_buf?: string; ret?: number; errcode?: number }> {
|
|
116
158
|
return weixinRequest(
|
|
117
159
|
"ilink/bot/getupdates",
|
|
118
160
|
{
|
|
119
|
-
|
|
120
|
-
base_info: { channel_version:
|
|
161
|
+
get_updates_buf: cursor,
|
|
162
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
121
163
|
},
|
|
122
164
|
token,
|
|
123
165
|
baseUrl,
|
|
124
|
-
);
|
|
166
|
+
) as Promise<{ msgs?: unknown[]; get_updates_buf?: string; ret?: number; errcode?: number }>;
|
|
125
167
|
}
|
|
126
168
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Get bot config for a user (includes typing_ticket and context_token).
|
|
171
|
+
*/
|
|
172
|
+
export async function getConfig(ilinkUserId: string, token: string, baseUrl: string, contextToken?: string) {
|
|
129
173
|
return weixinRequest(
|
|
130
174
|
"ilink/bot/getconfig",
|
|
131
175
|
{
|
|
132
176
|
ilink_user_id: ilinkUserId,
|
|
133
|
-
|
|
177
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
178
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
134
179
|
},
|
|
135
180
|
token,
|
|
136
181
|
baseUrl,
|
|
137
182
|
);
|
|
138
183
|
}
|
|
139
184
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Send typing indicator.
|
|
187
|
+
* status: 1 = typing, 2 = cancel
|
|
188
|
+
*/
|
|
189
|
+
export async function sendTyping(
|
|
190
|
+
ilinkUserId: string,
|
|
191
|
+
typingTicket: string,
|
|
192
|
+
status: 1 | 2,
|
|
193
|
+
token: string,
|
|
194
|
+
baseUrl: string,
|
|
195
|
+
) {
|
|
196
|
+
return weixinRequest(
|
|
197
|
+
"ilink/bot/sendtyping",
|
|
198
|
+
{
|
|
199
|
+
ilink_user_id: ilinkUserId,
|
|
200
|
+
typing_ticket: typingTicket,
|
|
201
|
+
status,
|
|
202
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
203
|
+
},
|
|
204
|
+
token,
|
|
205
|
+
baseUrl,
|
|
206
|
+
);
|
|
145
207
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,10 +15,11 @@ import path from "node:path";
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import {
|
|
17
17
|
DEFAULT_BASE_URL,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
pollMessages,
|
|
18
|
+
getUpdates,
|
|
19
|
+
getConfig,
|
|
21
20
|
sendTextMessage,
|
|
21
|
+
loadCursor,
|
|
22
|
+
saveCursor,
|
|
22
23
|
WeixinAuthError,
|
|
23
24
|
WeixinNetworkError,
|
|
24
25
|
} from "./api.js";
|
|
@@ -40,18 +41,22 @@ interface AccountData {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
function loadAccount(): AccountData & { accountId: string } {
|
|
43
|
-
const files = fs
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const files = fs
|
|
45
|
+
.readdirSync(WEIXIN_DIR)
|
|
46
|
+
.filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
|
47
|
+
if (files.length === 0)
|
|
48
|
+
throw new Error("No WeChat account found. Run: npm run login");
|
|
49
|
+
|
|
50
|
+
const accountId =
|
|
51
|
+
process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
|
|
48
52
|
const filePath = path.join(WEIXIN_DIR, `${accountId}.json`);
|
|
49
53
|
const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as AccountData;
|
|
50
|
-
if (!data.token)
|
|
54
|
+
if (!data.token)
|
|
55
|
+
throw new Error(`No token for account ${accountId}. Re-run: npm run login`);
|
|
51
56
|
return { ...data, accountId };
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
// ──
|
|
59
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
55
60
|
|
|
56
61
|
function assertNonEmptyString(value: unknown, field: string): string {
|
|
57
62
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -61,25 +66,17 @@ function assertNonEmptyString(value: unknown, field: string): string {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
function formatToolError(error: unknown): string {
|
|
64
|
-
if (error instanceof WeixinAuthError)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (error instanceof WeixinNetworkError) {
|
|
69
|
-
return `Network error while calling Weixin API: ${error.message}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (error instanceof Error) {
|
|
73
|
-
return error.message;
|
|
74
|
-
}
|
|
75
|
-
|
|
69
|
+
if (error instanceof WeixinAuthError) return error.message;
|
|
70
|
+
if (error instanceof WeixinNetworkError)
|
|
71
|
+
return `Network error: ${error.message}`;
|
|
72
|
+
if (error instanceof Error) return error.message;
|
|
76
73
|
return String(error);
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
// ── MCP Server ─────────────────────────────────────────────────────────────
|
|
80
77
|
|
|
81
78
|
const server = new Server(
|
|
82
|
-
{ name: "weixin-mcp", version: "1.0.
|
|
79
|
+
{ name: "weixin-mcp", version: "1.0.2" },
|
|
83
80
|
{ capabilities: { tools: {} } },
|
|
84
81
|
);
|
|
85
82
|
|
|
@@ -87,41 +84,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
87
84
|
tools: [
|
|
88
85
|
{
|
|
89
86
|
name: "weixin_send",
|
|
90
|
-
description:
|
|
87
|
+
description:
|
|
88
|
+
"Send a WeChat text message to a user. Pass context_token from a received message to link the reply to the conversation thread.",
|
|
91
89
|
inputSchema: {
|
|
92
90
|
type: "object",
|
|
93
91
|
properties: {
|
|
94
92
|
to: { type: "string", description: "Recipient user ID / OpenId" },
|
|
95
93
|
text: { type: "string", description: "Message text to send" },
|
|
94
|
+
context_token: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description:
|
|
97
|
+
"Optional: context_token from a received message, links the reply to the conversation",
|
|
98
|
+
},
|
|
96
99
|
},
|
|
97
100
|
required: ["to", "text"],
|
|
98
101
|
},
|
|
99
102
|
},
|
|
100
103
|
{
|
|
101
|
-
name: "
|
|
102
|
-
description:
|
|
103
|
-
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
name: "weixin_poll_messages",
|
|
107
|
-
description: "Poll for new WeChat messages since a timestamp",
|
|
104
|
+
name: "weixin_poll",
|
|
105
|
+
description:
|
|
106
|
+
"Poll for new WeChat messages. Uses a persistent cursor to avoid re-delivering old messages. Returns new messages since last poll.",
|
|
108
107
|
inputSchema: {
|
|
109
108
|
type: "object",
|
|
110
109
|
properties: {
|
|
111
|
-
|
|
110
|
+
reset_cursor: {
|
|
111
|
+
type: "boolean",
|
|
112
|
+
description:
|
|
113
|
+
"If true, reset cursor and re-fetch from the beginning (useful for debugging)",
|
|
114
|
+
},
|
|
112
115
|
},
|
|
113
116
|
},
|
|
114
117
|
},
|
|
115
118
|
{
|
|
116
|
-
name: "
|
|
117
|
-
description:
|
|
119
|
+
name: "weixin_get_config",
|
|
120
|
+
description:
|
|
121
|
+
"Get bot config for a user — includes typing_ticket needed for sendTyping. Call before sending typing indicators.",
|
|
118
122
|
inputSchema: {
|
|
119
123
|
type: "object",
|
|
120
124
|
properties: {
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
user_id: { type: "string", description: "Target user ID / OpenId" },
|
|
126
|
+
context_token: { type: "string", description: "Optional context token" },
|
|
123
127
|
},
|
|
124
|
-
required: ["
|
|
128
|
+
required: ["user_id"],
|
|
125
129
|
},
|
|
126
130
|
},
|
|
127
131
|
],
|
|
@@ -129,7 +133,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
129
133
|
|
|
130
134
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
131
135
|
const account = loadAccount();
|
|
132
|
-
const { token, baseUrl = DEFAULT_BASE_URL } = account;
|
|
136
|
+
const { token, baseUrl = DEFAULT_BASE_URL, accountId } = account;
|
|
133
137
|
|
|
134
138
|
const { name, arguments: args } = req.params;
|
|
135
139
|
|
|
@@ -137,18 +141,34 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
137
141
|
let result: unknown;
|
|
138
142
|
|
|
139
143
|
if (name === "weixin_send") {
|
|
140
|
-
const { to, text } = (args ?? {}) as {
|
|
144
|
+
const { to, text, context_token } = (args ?? {}) as {
|
|
145
|
+
to?: string;
|
|
146
|
+
text?: string;
|
|
147
|
+
context_token?: string;
|
|
148
|
+
};
|
|
141
149
|
const validatedTo = assertNonEmptyString(to, "to");
|
|
142
150
|
const validatedText = assertNonEmptyString(text, "text");
|
|
143
|
-
result = await sendTextMessage(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
result = await sendTextMessage(
|
|
152
|
+
validatedTo,
|
|
153
|
+
validatedText,
|
|
154
|
+
token!,
|
|
155
|
+
baseUrl,
|
|
156
|
+
context_token,
|
|
157
|
+
);
|
|
158
|
+
} else if (name === "weixin_poll") {
|
|
159
|
+
const { reset_cursor } = (args ?? {}) as { reset_cursor?: boolean };
|
|
160
|
+
const cursor = reset_cursor ? "" : loadCursor(accountId);
|
|
161
|
+
const resp = await getUpdates(token!, baseUrl, cursor);
|
|
162
|
+
// Persist new cursor for next poll
|
|
163
|
+
if (resp.get_updates_buf) saveCursor(accountId, resp.get_updates_buf);
|
|
164
|
+
result = resp;
|
|
165
|
+
} else if (name === "weixin_get_config") {
|
|
166
|
+
const { user_id, context_token } = (args ?? {}) as {
|
|
167
|
+
user_id?: string;
|
|
168
|
+
context_token?: string;
|
|
169
|
+
};
|
|
170
|
+
const validatedUserId = assertNonEmptyString(user_id, "user_id");
|
|
171
|
+
result = await getConfig(validatedUserId, token!, baseUrl, context_token);
|
|
152
172
|
} else {
|
|
153
173
|
throw new Error(`Unknown tool: ${name}`);
|
|
154
174
|
}
|
package/test-poll.mjs
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getUpdates, loadCursor, saveCursor } from './dist/api.js'
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import os from 'node:os'
|
|
5
5
|
|
|
6
6
|
const dir = path.join(os.homedir(), '.openclaw', 'openclaw-weixin', 'accounts')
|
|
7
|
-
const files = fs.readdirSync(dir).filter(f => !f.endsWith('.sync.json'))
|
|
7
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json') && !f.endsWith('.sync.json') && !f.endsWith('.cursor.json'))
|
|
8
|
+
const accountId = files[0].replace('.json', '')
|
|
8
9
|
const data = JSON.parse(fs.readFileSync(path.join(dir, files[0]), 'utf8'))
|
|
9
10
|
|
|
10
|
-
console.log('
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
console.log('Account:', accountId)
|
|
12
|
+
const cursor = loadCursor(accountId)
|
|
13
|
+
console.log('Cursor:', cursor ? cursor.slice(0, 20) + '...' : '(empty — first poll)')
|
|
14
|
+
|
|
15
|
+
const resp = await getUpdates(data.token, data.baseUrl, cursor)
|
|
16
|
+
console.log('ret:', resp.ret, '| msgs count:', resp.msgs?.length ?? 0)
|
|
17
|
+
|
|
18
|
+
if (resp.get_updates_buf) {
|
|
19
|
+
saveCursor(accountId, resp.get_updates_buf)
|
|
20
|
+
console.log('✅ Cursor saved')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (resp.msgs?.length > 0) {
|
|
24
|
+
const m = resp.msgs[0]
|
|
25
|
+
console.log('\nFirst message:')
|
|
26
|
+
console.log(' from:', m.from_user_id)
|
|
27
|
+
console.log(' type:', m.message_type, '| state:', m.message_state)
|
|
28
|
+
const text = m.item_list?.find(i => i.type === 1)?.text_item?.text
|
|
29
|
+
if (text) console.log(' text:', text.slice(0, 100))
|
|
30
|
+
console.log(' context_token:', m.context_token ? m.context_token.slice(0, 30) + '...' : '(none)')
|
|
16
31
|
}
|