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 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 weixinRequest(requestPath: string, body: unknown, token: string, baseUrl?: string, retries?: number): Promise<unknown>;
10
- export declare function sendTextMessage(to: string, text: string, token: string, baseUrl: string): Promise<unknown>;
11
- export declare function getContacts(token: string, baseUrl: string): Promise<unknown>;
12
- export declare function pollMessages(token: string, baseUrl: string, sinceTs?: number): Promise<unknown>;
13
- export declare function getChatHistory(to: string, limit: number, token: string, baseUrl: string): Promise<unknown>;
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 AUTH_ERROR_STATUSES = new Set([401, 403]);
6
+ const CHANNEL_VERSION = "1.0.2";
4
7
  export class WeixinAuthError extends Error {
5
- constructor(message = "Authentication failed. Run npm run login to re-authenticate.") {
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 `openclaw-weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
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
- const text = await res.text();
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
- export async function weixinRequest(requestPath, body, token, baseUrl = DEFAULT_BASE_URL, retries = 1) {
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
- "Content-Type": "application/json",
39
- Authorization: `Bearer ${token}`,
40
- },
41
- body: JSON.stringify(body),
77
+ headers: buildHeaders(token, bodyStr),
78
+ body: bodyStr,
42
79
  });
43
- if (AUTH_ERROR_STATUSES.has(res.status)) {
80
+ if (res.status === 401 || res.status === 403)
44
81
  throw new WeixinAuthError();
45
- }
46
82
  if (!res.ok) {
47
- const message = await parseErrorResponse(res);
48
- throw new Error(`Weixin API error ${res.status}: ${message}`);
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 (error) {
53
- if (error instanceof WeixinAuthError) {
54
- throw error;
55
- }
56
- if (isTransientNetworkError(error) && attempt < retries) {
57
- continue;
58
- }
59
- if (isTransientNetworkError(error)) {
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
- export async function sendTextMessage(to, text, token, baseUrl) {
68
- return weixinRequest("/v1/message/send", {
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
- export async function getContacts(token, baseUrl) {
80
- return weixinRequest("/v1/contacts/list", { page_size: 50 }, token, baseUrl);
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
- export async function pollMessages(token, baseUrl, sinceTs) {
83
- return weixinRequest("/v1/updates/get", {
84
- timeout_ms: 5000,
85
- ...(sinceTs ? { since_ts: sinceTs } : {}),
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
- export async function getChatHistory(to, limit, token, baseUrl) {
89
- return weixinRequest("/v1/message/history", { to_user_id: to, limit }, token, baseUrl);
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, getChatHistory, getContacts, pollMessages, sendTextMessage, WeixinAuthError, WeixinNetworkError, } from "./api.js";
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.readdirSync(WEIXIN_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json"));
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: npx @tencent-weixin/openclaw-weixin-cli install");
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 QR login.`);
28
+ throw new Error(`No token for account ${accountId}. Re-run: npm run login`);
28
29
  return { ...data, accountId };
29
30
  }
30
- // ── Weixin API ─────────────────────────────────────────────────────────────
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
- if (error instanceof WeixinNetworkError) {
42
- return `Network error while calling Weixin API: ${error.message}`;
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.0" }, { capabilities: { tools: {} } });
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 (by user ID or OpenId)",
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: "weixin_get_contacts",
67
- description: "List WeChat contacts",
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
- since_ts: { type: "number", description: "Unix timestamp in ms (optional)" },
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: "weixin_get_history",
82
- description: "Get chat history with a WeChat contact",
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
- to: { type: "string", description: "Contact user ID / OpenId" },
87
- limit: { type: "number", description: "Number of messages (default 20)" },
86
+ user_id: { type: "string", description: "Target user ID / OpenId" },
87
+ context_token: { type: "string", description: "Optional context token" },
88
88
  },
89
- required: ["to"],
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 === "weixin_poll_messages") {
110
- const { since_ts } = (args ?? {});
111
- result = await pollMessages(token, baseUrl, since_ts);
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 === "weixin_get_history") {
114
- const { to, limit = 20 } = (args ?? {});
115
- const validatedTo = assertNonEmptyString(to, "to");
116
- result = await getChatHistory(validatedTo, limit, token, baseUrl);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weixin-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for WeChat (Weixin) — send messages via OpenClaw weixin plugin auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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 to re-authenticate.") {
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 `openclaw-weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
26
+ return `weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
23
27
  }
24
28
 
25
- async function parseErrorResponse(res: Response): Promise<string> {
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 text = await res.text();
28
- return text.trim() || `HTTP ${res.status}`;
57
+ const data = JSON.parse(fs.readFileSync(cursorPath(accountId), "utf-8")) as { cursor?: string };
58
+ return data.cursor ?? "";
29
59
  } catch {
30
- return `HTTP ${res.status}`;
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
- function isTransientNetworkError(error: unknown): boolean {
35
- return error instanceof TypeError || error instanceof WeixinNetworkError;
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
- requestPath: string,
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 url = `${baseUrl}${requestPath}`;
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
- "Content-Type": "application/json",
53
- Authorization: `Bearer ${token}`,
54
- },
55
- body: JSON.stringify(body),
97
+ headers: buildHeaders(token, bodyStr),
98
+ body: bodyStr,
56
99
  });
57
100
 
58
- if (AUTH_ERROR_STATUSES.has(res.status)) {
59
- throw new WeixinAuthError();
60
- }
61
-
101
+ if (res.status === 401 || res.status === 403) throw new WeixinAuthError();
62
102
  if (!res.ok) {
63
- const message = await parseErrorResponse(res);
64
- throw new Error(`Weixin API error ${res.status}: ${message}`);
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 (error) {
69
- if (error instanceof WeixinAuthError) {
70
- throw error;
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
- export async function sendTextMessage(to: string, text: string, token: string, baseUrl: string) {
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
- "/v1/message/send",
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
- export async function getContacts(token: string, baseUrl: string) {
109
- return weixinRequest("/v1/contacts/list", { page_size: 50 }, token, baseUrl);
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
- export async function pollMessages(token: string, baseUrl: string, sinceTs?: number) {
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
- "/v1/updates/get",
174
+ "ilink/bot/getconfig",
115
175
  {
116
- timeout_ms: 5000,
117
- ...(sinceTs ? { since_ts: sinceTs } : {}),
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
- export async function getChatHistory(to: string, limit: number, token: string, baseUrl: string) {
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
- "/v1/message/history",
127
- { to_user_id: to, limit },
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
- getChatHistory,
19
- getContacts,
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.readdirSync(WEIXIN_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json"));
44
- if (files.length === 0) throw new Error("No WeChat account found. Run: npx @tencent-weixin/openclaw-weixin-cli install");
45
-
46
- // Pick the first (or explicitly set) account
47
- const accountId = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
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) throw new Error(`No token for account ${accountId}. Re-run QR login.`);
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
- // ── Weixin API ─────────────────────────────────────────────────────────────
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
- return error.message;
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.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: "Send a WeChat message to a user (by user ID or OpenId)",
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: "weixin_get_contacts",
102
- description: "List WeChat contacts",
103
- inputSchema: { type: "object", properties: {} },
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
- since_ts: { type: "number", description: "Unix timestamp in ms (optional)" },
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: "weixin_get_history",
117
- description: "Get chat history with a WeChat contact",
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
- to: { type: "string", description: "Contact user ID / OpenId" },
122
- limit: { type: "number", description: "Number of messages (default 20)" },
125
+ user_id: { type: "string", description: "Target user ID / OpenId" },
126
+ context_token: { type: "string", description: "Optional context token" },
123
127
  },
124
- required: ["to"],
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 { to?: string; text?: string };
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(validatedTo, validatedText, token!, baseUrl);
144
- } else if (name === "weixin_get_contacts") {
145
- result = await getContacts(token!, baseUrl);
146
- } else if (name === "weixin_poll_messages") {
147
- const { since_ts } = (args ?? {}) as { since_ts?: number };
148
- result = await pollMessages(token!, baseUrl, since_ts);
149
- } else if (name === "weixin_get_history") {
150
- const { to, limit = 20 } = (args ?? {}) as { to?: string; limit?: number };
151
- const validatedTo = assertNonEmptyString(to, "to");
152
- result = await getChatHistory(validatedTo, limit, token!, baseUrl);
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
+ }