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 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
- /** Send a text message. Uses real endpoint: ilink/bot/sendmessage */
11
- export declare function sendTextMessage(to: string, text: string, token: string, baseUrl: string): Promise<unknown>;
12
- /** Long-poll for new messages. Uses real endpoint: ilink/bot/getupdates */
13
- export declare function pollMessages(token: string, baseUrl: string, timeoutMs?: number): Promise<unknown>;
14
- /** Get bot config for a user (includes context_token for replies). */
15
- export declare function getConfig(ilinkUserId: string, token: string, baseUrl: string): Promise<unknown>;
16
- export declare function getContacts(_token: string, _baseUrl: string): Promise<{
17
- note: string;
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 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,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 `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
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 (AUTH_ERROR_STATUSES.has(res.status)) {
80
+ if (res.status === 401 || res.status === 403)
48
81
  throw new WeixinAuthError();
49
- }
50
82
  if (!res.ok) {
51
- const message = await parseErrorResponse(res);
52
- 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}`);
53
85
  }
54
86
  return res.json();
55
87
  }
56
- catch (error) {
57
- if (error instanceof WeixinAuthError) {
58
- throw error;
59
- }
60
- if (isTransientNetworkError(error) && attempt < retries) {
61
- continue;
62
- }
63
- if (isTransientNetworkError(error)) {
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
- /** Send a text message. Uses real endpoint: ilink/bot/sendmessage */
72
- export async function sendTextMessage(to, text, token, baseUrl) {
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: "1.0.1" },
116
+ base_info: { channel_version: CHANNEL_VERSION },
83
117
  }, token, baseUrl);
84
118
  }
85
- /** Long-poll for new messages. Uses real endpoint: ilink/bot/getupdates */
86
- export async function pollMessages(token, baseUrl, timeoutMs = 5000) {
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
- timeout_ms: timeoutMs,
89
- base_info: { channel_version: "1.0.1" },
125
+ get_updates_buf: cursor,
126
+ base_info: { channel_version: CHANNEL_VERSION },
90
127
  }, token, baseUrl);
91
128
  }
92
- /** Get bot config for a user (includes context_token for replies). */
93
- export async function getConfig(ilinkUserId, token, baseUrl) {
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
- base_info: { channel_version: "1.0.1" },
135
+ ...(contextToken ? { context_token: contextToken } : {}),
136
+ base_info: { channel_version: CHANNEL_VERSION },
97
137
  }, token, baseUrl);
98
138
  }
99
- // Note: WeChat bot API does not have a standalone contacts/history endpoint.
100
- // Contacts are tracked from incoming messages (getupdates).
101
- // Keeping a stub for MCP compatibility:
102
- export async function getContacts(_token, _baseUrl) {
103
- return { note: "WeChat bot API does not support listing contacts. Use weixin_poll_messages to receive incoming messages and track senders." };
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, 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,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
- 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
- result = { note: "WeChat bot API does not support fetching chat history." };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weixin-mcp",
3
- "version": "1.0.2",
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,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 `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 "";
31
61
  }
32
62
  }
33
63
 
34
- function isTransientNetworkError(error: unknown): boolean {
35
- return error instanceof TypeError || error instanceof WeixinNetworkError;
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 (AUTH_ERROR_STATUSES.has(res.status)) {
63
- throw new WeixinAuthError();
64
- }
65
-
101
+ if (res.status === 401 || res.status === 403) throw new WeixinAuthError();
66
102
  if (!res.ok) {
67
- const message = await parseErrorResponse(res);
68
- 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}`);
69
105
  }
70
-
71
106
  return res.json();
72
- } catch (error) {
73
- if (error instanceof WeixinAuthError) {
74
- throw error;
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
- /** Send a text message. Uses real endpoint: ilink/bot/sendmessage */
95
- 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
+ ) {
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, // BOT
104
- message_state: 2, // FINISH
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: "1.0.1" },
142
+ base_info: { channel_version: CHANNEL_VERSION },
108
143
  },
109
144
  token,
110
145
  baseUrl,
111
146
  );
112
147
  }
113
148
 
114
- /** Long-poll for new messages. Uses real endpoint: ilink/bot/getupdates */
115
- export async function pollMessages(token: string, baseUrl: string, timeoutMs = 5000) {
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
- timeout_ms: timeoutMs,
120
- base_info: { channel_version: "1.0.1" },
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
- /** Get bot config for a user (includes context_token for replies). */
128
- export async function getConfig(ilinkUserId: string, token: string, baseUrl: string) {
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
- base_info: { channel_version: "1.0.1" },
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
- // Note: WeChat bot API does not have a standalone contacts/history endpoint.
141
- // Contacts are tracked from incoming messages (getupdates).
142
- // Keeping a stub for MCP compatibility:
143
- export async function getContacts(_token: string, _baseUrl: string) {
144
- return { note: "WeChat bot API does not support listing contacts. Use weixin_poll_messages to receive incoming messages and track senders." };
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
- 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,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 { 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
- result = { note: "WeChat bot API does not support fetching chat history." };
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 { pollMessages } from './dist/api.js'
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('Testing pollMessages...')
11
- try {
12
- const result = await pollMessages(data.token, data.baseUrl, 3000)
13
- console.log('✅ Response:', JSON.stringify(result, null, 2).slice(0, 500))
14
- } catch (e) {
15
- console.error(' Error:', e.message)
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
  }