weixin-mcp 1.0.1 → 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 +27 -5
- package/dist/api.js +103 -43
- package/dist/index.js +43 -41
- package/package.json +1 -1
- package/src/api.ts +131 -55
- package/src/index.ts +68 -49
- package/test-contacts.mjs +19 -0
- package/test-poll.mjs +31 -0
package/dist/api.d.ts
CHANGED
|
@@ -6,8 +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
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
export declare function loadCursor(accountId: string): string;
|
|
10
|
+
export declare function saveCursor(accountId: string, cursor: string): void;
|
|
11
|
+
export declare function weixinRequest(endpoint: string, body: unknown, token: string, baseUrl?: string, retries?: number): Promise<unknown>;
|
|
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;
|
|
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,78 +16,135 @@ 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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const url = `${baseUrl}${requestPath}`;
|
|
69
|
+
export async function weixinRequest(endpoint, body, token, baseUrl = DEFAULT_BASE_URL, retries = 1) {
|
|
70
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
71
|
+
const url = new URL(endpoint, base).toString();
|
|
72
|
+
const bodyStr = JSON.stringify(body);
|
|
33
73
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
34
74
|
try {
|
|
35
75
|
const res = await fetch(url, {
|
|
36
76
|
method: "POST",
|
|
37
|
-
headers:
|
|
38
|
-
|
|
39
|
-
Authorization: `Bearer ${token}`,
|
|
40
|
-
},
|
|
41
|
-
body: JSON.stringify(body),
|
|
77
|
+
headers: buildHeaders(token, bodyStr),
|
|
78
|
+
body: bodyStr,
|
|
42
79
|
});
|
|
43
|
-
if (
|
|
80
|
+
if (res.status === 401 || res.status === 403)
|
|
44
81
|
throw new WeixinAuthError();
|
|
45
|
-
}
|
|
46
82
|
if (!res.ok) {
|
|
47
|
-
const
|
|
48
|
-
throw new Error(`Weixin API error ${res.status}: ${
|
|
83
|
+
const msg = await parseErrorResponse(res);
|
|
84
|
+
throw new Error(`Weixin API error ${res.status}: ${msg}`);
|
|
49
85
|
}
|
|
50
86
|
return res.json();
|
|
51
87
|
}
|
|
52
|
-
catch (
|
|
53
|
-
if (
|
|
54
|
-
throw
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
throw new WeixinNetworkError(error instanceof Error ? error.message : "Network request failed");
|
|
61
|
-
}
|
|
62
|
-
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;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
65
|
-
throw new WeixinNetworkError("Network request failed");
|
|
98
|
+
throw new WeixinNetworkError("Network request failed after retries");
|
|
66
99
|
}
|
|
67
|
-
|
|
68
|
-
|
|
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) {
|
|
106
|
+
return weixinRequest("ilink/bot/sendmessage", {
|
|
69
107
|
msg: {
|
|
70
108
|
from_user_id: "",
|
|
71
109
|
to_user_id: to,
|
|
72
110
|
client_id: generateClientId(),
|
|
73
|
-
message_type: 2,
|
|
74
|
-
message_state: 2,
|
|
111
|
+
message_type: 2, // BOT
|
|
112
|
+
message_state: 2, // FINISH
|
|
75
113
|
item_list: [{ type: 1, text_item: { text } }],
|
|
114
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
76
115
|
},
|
|
116
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
77
117
|
}, token, baseUrl);
|
|
78
118
|
}
|
|
79
|
-
|
|
80
|
-
|
|
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 = "") {
|
|
124
|
+
return weixinRequest("ilink/bot/getupdates", {
|
|
125
|
+
get_updates_buf: cursor,
|
|
126
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
127
|
+
}, token, baseUrl);
|
|
81
128
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Get bot config for a user (includes typing_ticket and context_token).
|
|
131
|
+
*/
|
|
132
|
+
export async function getConfig(ilinkUserId, token, baseUrl, contextToken) {
|
|
133
|
+
return weixinRequest("ilink/bot/getconfig", {
|
|
134
|
+
ilink_user_id: ilinkUserId,
|
|
135
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
136
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
86
137
|
}, token, baseUrl);
|
|
87
138
|
}
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
90
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,85 +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
|
-
const
|
|
116
|
-
result = await
|
|
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);
|
|
117
119
|
}
|
|
118
120
|
else {
|
|
119
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,113 +20,187 @@ 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 "";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function saveCursor(accountId: string, cursor: string): void {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(cursorPath(accountId), JSON.stringify({ cursor }));
|
|
67
|
+
} catch {
|
|
68
|
+
// non-fatal
|
|
31
69
|
}
|
|
32
70
|
}
|
|
33
71
|
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
39
|
-
|
|
83
|
+
endpoint: string,
|
|
40
84
|
body: unknown,
|
|
41
85
|
token: string,
|
|
42
86
|
baseUrl = DEFAULT_BASE_URL,
|
|
43
87
|
retries = 1,
|
|
44
88
|
): Promise<unknown> {
|
|
45
|
-
const
|
|
89
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
90
|
+
const url = new URL(endpoint, base).toString();
|
|
91
|
+
const bodyStr = JSON.stringify(body);
|
|
46
92
|
|
|
47
93
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
48
94
|
try {
|
|
49
95
|
const res = await fetch(url, {
|
|
50
96
|
method: "POST",
|
|
51
|
-
headers:
|
|
52
|
-
|
|
53
|
-
Authorization: `Bearer ${token}`,
|
|
54
|
-
},
|
|
55
|
-
body: JSON.stringify(body),
|
|
97
|
+
headers: buildHeaders(token, bodyStr),
|
|
98
|
+
body: bodyStr,
|
|
56
99
|
});
|
|
57
100
|
|
|
58
|
-
if (
|
|
59
|
-
throw new WeixinAuthError();
|
|
60
|
-
}
|
|
61
|
-
|
|
101
|
+
if (res.status === 401 || res.status === 403) throw new WeixinAuthError();
|
|
62
102
|
if (!res.ok) {
|
|
63
|
-
const
|
|
64
|
-
throw new Error(`Weixin API error ${res.status}: ${
|
|
103
|
+
const msg = await parseErrorResponse(res);
|
|
104
|
+
throw new Error(`Weixin API error ${res.status}: ${msg}`);
|
|
65
105
|
}
|
|
66
|
-
|
|
67
106
|
return res.json();
|
|
68
|
-
} catch (
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (isTransientNetworkError(error) && attempt < retries) {
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (isTransientNetworkError(error)) {
|
|
78
|
-
throw new WeixinNetworkError(
|
|
79
|
-
error instanceof Error ? error.message : "Network request failed",
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
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;
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
|
-
|
|
87
|
-
throw new WeixinNetworkError("Network request failed");
|
|
114
|
+
throw new WeixinNetworkError("Network request failed after retries");
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
|
|
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
|
+
) {
|
|
91
130
|
return weixinRequest(
|
|
92
|
-
"/
|
|
131
|
+
"ilink/bot/sendmessage",
|
|
93
132
|
{
|
|
94
133
|
msg: {
|
|
95
134
|
from_user_id: "",
|
|
96
135
|
to_user_id: to,
|
|
97
136
|
client_id: generateClientId(),
|
|
98
|
-
message_type: 2,
|
|
99
|
-
message_state: 2,
|
|
137
|
+
message_type: 2, // BOT
|
|
138
|
+
message_state: 2, // FINISH
|
|
100
139
|
item_list: [{ type: 1, text_item: { text } }],
|
|
140
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
101
141
|
},
|
|
142
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
102
143
|
},
|
|
103
144
|
token,
|
|
104
145
|
baseUrl,
|
|
105
146
|
);
|
|
106
147
|
}
|
|
107
148
|
|
|
108
|
-
|
|
109
|
-
|
|
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 }> {
|
|
158
|
+
return weixinRequest(
|
|
159
|
+
"ilink/bot/getupdates",
|
|
160
|
+
{
|
|
161
|
+
get_updates_buf: cursor,
|
|
162
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
163
|
+
},
|
|
164
|
+
token,
|
|
165
|
+
baseUrl,
|
|
166
|
+
) as Promise<{ msgs?: unknown[]; get_updates_buf?: string; ret?: number; errcode?: number }>;
|
|
110
167
|
}
|
|
111
168
|
|
|
112
|
-
|
|
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) {
|
|
113
173
|
return weixinRequest(
|
|
114
|
-
"/
|
|
174
|
+
"ilink/bot/getconfig",
|
|
115
175
|
{
|
|
116
|
-
|
|
117
|
-
...(
|
|
176
|
+
ilink_user_id: ilinkUserId,
|
|
177
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
178
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
118
179
|
},
|
|
119
180
|
token,
|
|
120
181
|
baseUrl,
|
|
121
182
|
);
|
|
122
183
|
}
|
|
123
184
|
|
|
124
|
-
|
|
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
|
+
) {
|
|
125
196
|
return weixinRequest(
|
|
126
|
-
"/
|
|
127
|
-
{
|
|
197
|
+
"ilink/bot/sendtyping",
|
|
198
|
+
{
|
|
199
|
+
ilink_user_id: ilinkUserId,
|
|
200
|
+
typing_ticket: typingTicket,
|
|
201
|
+
status,
|
|
202
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
203
|
+
},
|
|
128
204
|
token,
|
|
129
205
|
baseUrl,
|
|
130
206
|
);
|
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,19 +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
|
-
const
|
|
152
|
-
|
|
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);
|
|
153
172
|
} else {
|
|
154
173
|
throw new Error(`Unknown tool: ${name}`);
|
|
155
174
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getContacts } from './dist/api.js'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
|
|
6
|
+
const dir = path.join(os.homedir(), '.openclaw', 'openclaw-weixin', 'accounts')
|
|
7
|
+
const files = fs.readdirSync(dir).filter(f => !f.endsWith('.sync.json'))
|
|
8
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, files[0]), 'utf8'))
|
|
9
|
+
|
|
10
|
+
console.log('Testing with account:', files[0])
|
|
11
|
+
console.log('Token prefix:', data.token?.slice(0, 15) + '...')
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const result = await getContacts(data.token, data.baseUrl)
|
|
15
|
+
console.log('✅ getContacts response:')
|
|
16
|
+
console.log(JSON.stringify(result, null, 2).slice(0, 1000))
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.error('❌ Error:', e.message)
|
|
19
|
+
}
|
package/test-poll.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getUpdates, loadCursor, saveCursor } from './dist/api.js'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
|
|
6
|
+
const dir = path.join(os.homedir(), '.openclaw', 'openclaw-weixin', 'accounts')
|
|
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', '')
|
|
9
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, files[0]), 'utf8'))
|
|
10
|
+
|
|
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)')
|
|
31
|
+
}
|