weixin-ilink
v0.1.0
Published
Lightweight TypeScript SDK for WeChat iLink Bot protocol — 5 HTTP endpoints, QR login, zero dependencies.
Maintainers
Readme
weixin-ilink
Lightweight TypeScript SDK for WeChat's iLink Bot protocol — 5 HTTP endpoints, QR login, zero runtime dependencies.
Derived from reverse-engineering
@tencent-weixin/openclaw-weixin. See weixin-claude-bot for a full working example.
Install
npm install weixin-ilinkRequirements: Node.js >= 18.0.0 (uses native fetch and AbortController)
Quick Start
import { ILinkClient, loginWithQR } from "weixin-ilink";
// 1. Login via QR code
const creds = await loginWithQR({
onQRCode: (url) => console.log(`Scan this QR: ${url}`),
onStatusChange: (s) => console.log(`Status: ${s}`),
});
// 2. Create client
const client = new ILinkClient({
baseUrl: creds.baseUrl,
token: creds.botToken,
});
// 3. Poll for messages & reply
while (true) {
const updates = await client.poll();
for (const msg of updates.msgs ?? []) {
const text = msg.item_list?.[0]?.text_item?.text;
if (text && msg.from_user_id && msg.context_token) {
await client.sendText(msg.from_user_id, `Echo: ${text}`, msg.context_token);
}
}
}API Reference
loginWithQR(callbacks, baseUrl?)
QR code login with callback-based UI — no terminal dependency.
import { loginWithQR } from "weixin-ilink";
const creds = await loginWithQR({
onQRCode: (url) => { /* display QR code URL */ },
onStatusChange: (status) => { /* track login progress */ },
});
// creds: { botToken, accountId, baseUrl, userId }| Callback | When |
|----------|------|
| onQRCode(url) | QR code URL ready for display (may be called multiple times on refresh) |
| onStatusChange(status) | "waiting" → "scanned" → "expired" → "refreshing" |
Behavior:
- Auto-refreshes QR code on expiration (up to 3 times)
- Total login timeout: 8 minutes
- Throws
Errorif QR expires too many times or timeout is reached baseUrldefaults tohttps://ilinkai.weixin.qq.com
Returns: LoginResult
interface LoginResult {
botToken: string; // Bearer token for API auth
accountId: string; // Bot account ID (ilink_bot_id)
baseUrl: string; // API base URL (may differ from default)
userId?: string; // Bot's user ID
}ILinkClient
High-level client with automatic cursor tracking and message chunking.
Constructor
import { ILinkClient } from "weixin-ilink";
const client = new ILinkClient({
baseUrl: "https://ilinkai.weixin.qq.com",
token: "your-bot-token",
// Optional:
channelVersion: "my-bot/1.0", // default: "weixin-ilink/0.1.0"
longPollTimeoutMs: 35000, // default: 35000
apiTimeoutMs: 15000, // default: 15000
});| Option | Type | Default | Description |
|--------|------|---------|-------------|
| baseUrl | string | (required) | iLink API base URL |
| token | string | (required) | Bot auth token from login |
| channelVersion | string | "weixin-ilink/0.1.0" | Version string sent with every request |
| longPollTimeoutMs | number | 35000 | Timeout for long-poll requests |
| apiTimeoutMs | number | 15000 | Timeout for regular API calls |
client.cursor
Get/set the sync cursor for message persistence across restarts.
// Save cursor before shutdown
const savedCursor = client.cursor;
fs.writeFileSync("cursor.txt", savedCursor);
// Restore cursor on startup
client.cursor = fs.readFileSync("cursor.txt", "utf-8");client.poll()
Long-poll for new messages. Automatically updates the internal sync cursor.
const updates = await client.poll();
// updates: { ret, msgs, get_updates_buf, longpolling_timeout_ms }
for (const msg of updates.msgs ?? []) {
console.log(msg.from_user_id, msg.item_list);
}- Holds the connection for ~35s (configurable via
longPollTimeoutMs) - Returns
{ msgs: [] }on timeout (no error thrown) - Designed to be called in a loop
client.sendText(toUserId, text, contextToken)
Send a text reply. Automatically sets from_user_id: "", message_type: BOT, message_state: FINISH, and generates a unique client_id.
await client.sendText(msg.from_user_id, "Hello!", msg.context_token);client.sendTextChunked(toUserId, text, contextToken, maxLength?)
Auto-split long text into multiple messages. Returns the number of messages sent.
const chunkCount = await client.sendTextChunked(
msg.from_user_id,
veryLongText,
msg.context_token,
4000, // default max chars per message
);
console.log(`Sent in ${chunkCount} messages`);client.sendMedia(toUserId, item, contextToken)
Send a media message (image, file, video, voice).
import { MessageItemType } from "weixin-ilink";
// Send image
await client.sendMedia(userId, {
type: MessageItemType.IMAGE,
image_item: { url: "https://example.com/photo.jpg", width: 800, height: 600 },
}, contextToken);
// Send file
await client.sendMedia(userId, {
type: MessageItemType.FILE,
file_item: { file_name: "report.pdf", file_size: 102400, cdn_url: "https://cdn..." },
}, contextToken);
// Send video
await client.sendMedia(userId, {
type: MessageItemType.VIDEO,
video_item: { url: "https://...", width: 1920, height: 1080, duration: 60 },
}, contextToken);client.sendTyping(userId, contextToken?)
Show "typing..." indicator. Automatically fetches typing_ticket from getConfig.
await client.sendTyping(msg.from_user_id, msg.context_token);
// User sees "正在输入..." in their chatclient.getConfig(userId, contextToken?)
Fetch bot config for a user. Returns typing_ticket and other config.
const config = await client.getConfig(userId, contextToken);
console.log(config.typing_ticket);client.getUploadUrl(params)
Get a pre-signed URL for uploading media files.
const upload = await client.getUploadUrl({
file_name: "photo.jpg",
file_type: "image",
file_size: 102400,
});
// Upload the file to the pre-signed URL
await fetch(upload.upload_url!, { method: "PUT", body: fileBuffer });
// Then send the media message using cdn_url
await client.sendMedia(userId, {
type: MessageItemType.IMAGE,
image_item: { cdn_url: upload.cdn_url },
}, contextToken);Low-level API Functions
For full control, use the raw API functions directly. Each function takes a ClientOptions object as the first argument.
import { getUpdates, sendMessage, sendTyping, getConfig, getUploadUrl } from "weixin-ilink";
const opts = { baseUrl: "https://ilinkai.weixin.qq.com", token: "your-token" };getUpdates(opts, params)
Long-poll for incoming messages.
const resp = await getUpdates(opts, { get_updates_buf: cursor });
// resp: { ret, msgs, get_updates_buf, longpolling_timeout_ms, errcode, errmsg }sendMessage(opts, body)
Send a message. You must construct the full WeixinMessage structure.
await sendMessage(opts, {
msg: {
from_user_id: "", // MUST be empty
to_user_id: "target-user",
client_id: "unique-id", // MUST be unique per message
message_type: 2, // MessageType.BOT
message_state: 2, // MessageState.FINISH
context_token: "ctx-token", // MUST echo from incoming message
item_list: [{ type: 1, text_item: { text: "hello" } }],
},
});sendTyping(opts, body)
Send typing indicator.
await sendTyping(opts, {
ilink_user_id: userId,
typing_ticket: ticket, // from getConfig
status: 1, // TypingStatus.TYPING
});getConfig(opts, ilinkUserId, contextToken?)
Get bot config including typing_ticket.
const config = await getConfig(opts, userId, contextToken);getUploadUrl(opts, params)
Get pre-signed URL for file upload.
const resp = await getUploadUrl(opts, {
file_name: "doc.pdf",
file_type: "file",
file_size: 51200,
});
// resp: { upload_url, download_url, cdn_url }Types & Constants
All protocol types and constants are exported:
import {
// Constants
MessageType, // { NONE: 0, USER: 1, BOT: 2 }
MessageItemType, // { NONE: 0, TEXT: 1, IMAGE: 2, VOICE: 3, FILE: 4, VIDEO: 5 }
MessageState, // { NEW: 0, GENERATING: 1, FINISH: 2 }
TypingStatus, // { TYPING: 1, CANCEL: 2 }
// Types
type WeixinMessage,
type MessageItem,
type TextItem,
type ImageItem,
type VoiceItem,
type FileItem,
type VideoItem,
type ClientOptions,
type GetUpdatesReq,
type GetUpdatesResp,
type SendMessageReq,
type SendTypingReq,
type GetConfigResp,
type GetUploadUrlReq,
type GetUploadUrlResp,
type LoginResult,
type LoginCallbacks,
} from "weixin-ilink";WeixinMessage Structure
interface WeixinMessage {
seq?: number; // Sequence number
message_id?: number; // Server-assigned message ID
from_user_id?: string; // Sender (empty for outgoing)
to_user_id?: string; // Recipient
client_id?: string; // Deduplication ID (unique per message)
session_id?: string; // Session ID
group_id?: string; // Group ID
message_type?: number; // MessageType (USER=1, BOT=2)
message_state?: number; // MessageState (NEW=0, GENERATING=1, FINISH=2)
item_list?: MessageItem[]; // Message content items
context_token?: string; // Must echo back when replying
create_time_ms?: number; // Timestamp in milliseconds
}MessageItem Structure
interface MessageItem {
type?: number; // MessageItemType
text_item?: TextItem; // { text }
voice_item?: VoiceItem; // { text (ASR), encode_type, playtime }
image_item?: ImageItem; // { url, cdn_url, width, height }
file_item?: FileItem; // { url, cdn_url, file_name, file_size }
video_item?: VideoItem; // { url, cdn_url, thumb_url, width, height, duration }
ref_msg?: { // Referenced/quoted message
title?: string;
message_item?: MessageItem;
};
}Usage Examples
Echo Bot
import { ILinkClient, loginWithQR, MessageItemType } from "weixin-ilink";
const creds = await loginWithQR({
onQRCode: (url) => console.log(`Scan: ${url}`),
onStatusChange: (s) => console.log(`Login: ${s}`),
});
const client = new ILinkClient({ baseUrl: creds.baseUrl, token: creds.botToken });
while (true) {
const { msgs } = await client.poll();
for (const msg of msgs ?? []) {
if (!msg.from_user_id || !msg.context_token) continue;
const item = msg.item_list?.[0];
if (item?.type === MessageItemType.TEXT && item.text_item?.text) {
await client.sendText(msg.from_user_id, `Echo: ${item.text_item.text}`, msg.context_token);
}
}
}Handle Multiple Message Types
for (const msg of msgs ?? []) {
if (!msg.from_user_id || !msg.context_token) continue;
for (const item of msg.item_list ?? []) {
switch (item.type) {
case MessageItemType.TEXT:
console.log("Text:", item.text_item?.text);
break;
case MessageItemType.IMAGE:
console.log("Image:", item.image_item?.cdn_url, `${item.image_item?.width}x${item.image_item?.height}`);
break;
case MessageItemType.VOICE:
console.log("Voice ASR:", item.voice_item?.text);
break;
case MessageItemType.FILE:
console.log("File:", item.file_item?.file_name, item.file_item?.file_size);
break;
case MessageItemType.VIDEO:
console.log("Video:", item.video_item?.cdn_url, `${item.video_item?.duration}s`);
break;
}
}
}Typing Indicator + Response
for (const msg of msgs ?? []) {
if (!msg.from_user_id || !msg.context_token) continue;
// Show typing indicator while processing
await client.sendTyping(msg.from_user_id, msg.context_token);
// Process the message (e.g., call an AI API)
const reply = await generateResponse(msg);
// Send reply (auto-chunk if too long)
await client.sendTextChunked(msg.from_user_id, reply, msg.context_token);
}Upload and Send Media
import fs from "node:fs";
// 1. Get pre-signed upload URL
const upload = await client.getUploadUrl({
file_name: "photo.jpg",
file_type: "image",
file_size: fs.statSync("photo.jpg").size,
});
// 2. Upload the file
await fetch(upload.upload_url!, {
method: "PUT",
body: fs.readFileSync("photo.jpg"),
headers: { "Content-Type": "image/jpeg" },
});
// 3. Send the image message
await client.sendMedia(userId, {
type: MessageItemType.IMAGE,
image_item: { cdn_url: upload.cdn_url },
}, contextToken);Persist Cursor Across Restarts
import fs from "node:fs";
const client = new ILinkClient({ baseUrl, token });
// Restore cursor from file
if (fs.existsSync("cursor.dat")) {
client.cursor = fs.readFileSync("cursor.dat", "utf-8");
}
while (true) {
const updates = await client.poll();
// Save cursor after each poll
fs.writeFileSync("cursor.dat", client.cursor);
for (const msg of updates.msgs ?? []) {
// process messages...
}
}Handling Quoted/Reply Messages
for (const msg of msgs ?? []) {
const item = msg.item_list?.[0];
if (item?.ref_msg) {
console.log("Reply to:", item.ref_msg.title);
console.log("Original:", item.ref_msg.message_item?.text_item?.text);
}
}iLink Protocol
5 HTTP endpoints, all POST (except auth which is GET):
| Endpoint | Purpose |
|----------|---------|
| ilink/bot/getupdates | Long-poll for incoming messages |
| ilink/bot/sendmessage | Send text/media replies |
| ilink/bot/sendtyping | Show "typing..." indicator |
| ilink/bot/getconfig | Get typing_ticket and bot config |
| ilink/bot/getuploadurl | Get pre-signed URL for media uploads |
Protocol Rules
| Rule | Details |
|------|---------|
| context_token | Every incoming message carries one; must be echoed back when replying |
| from_user_id: "" | Must be empty in outgoing messages; server fills it from your token |
| client_id | Must be unique per message; duplicates are silently dropped |
| Long-poll | getupdates holds the connection for ~35s; timeout = no new messages |
| base_info | Every request includes { base_info: { channel_version: "..." } } |
Authentication Headers
All API requests include these headers:
Authorization: Bearer {bot_token}
AuthorizationType: ilink_bot_token
X-WECHAT-UIN: {random_base64_value}
Content-Type: application/jsonQR Login Flow
GET ilink/bot/get_bot_qrcode?bot_type=3
→ { qrcode, qrcode_img_content }
GET ilink/bot/get_qrcode_status?qrcode={qrcode} (long-poll)
→ { status: "wait" | "scaned" | "confirmed" | "expired", bot_token?, ... }Status flow: wait → scaned → confirmed
Testing
The SDK includes a comprehensive test suite using Vitest.
# Run all tests
npm test
# Run tests in watch mode
npm run test:watchTest Coverage
| Module | Tests | Description |
|--------|-------|-------------|
| types | Constants validation | Verifies all enum values |
| api | HTTP layer tests | Auth headers, endpoint URLs, request bodies, error handling |
| client | Business logic tests | Cursor mgmt, message building, chunking, typing flow |
| auth | Login flow tests | QR fetch, status polling, refresh, error cases |
Writing Your Own Tests
import { describe, it, expect, vi } from "vitest";
import { ILinkClient } from "weixin-ilink";
import * as api from "weixin-ilink/dist/api.js";
// Mock the API layer
vi.mock("weixin-ilink/dist/api.js", () => ({
getUpdates: vi.fn(),
sendMessage: vi.fn(),
sendTyping: vi.fn(),
getConfig: vi.fn(),
getUploadUrl: vi.fn(),
}));
it("my bot handles text messages", async () => {
vi.mocked(api.getUpdates).mockResolvedValue({
ret: 0,
msgs: [{
from_user_id: "user1",
context_token: "ctx",
item_list: [{ type: 1, text_item: { text: "hello" } }],
}],
});
const client = new ILinkClient({ baseUrl: "https://test", token: "tk" });
const updates = await client.poll();
expect(updates.msgs![0].item_list![0].text_item!.text).toBe("hello");
});Architecture
src/
├── types.ts Protocol types, constants, interfaces
├── api.ts 5 raw HTTP endpoint functions (stateless)
├── auth.ts QR login flow (callback-driven)
├── client.ts High-level stateful client (wraps api.ts)
└── index.ts Re-exports all public API- Zero runtime dependencies — uses only Node.js built-in
cryptoand nativefetch - ESM only — published as ES modules (
"type": "module") - Strict TypeScript — full type safety with strict mode enabled
License
MIT
