npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

weixin-ilink

v0.1.0

Published

Lightweight TypeScript SDK for WeChat iLink Bot protocol — 5 HTTP endpoints, QR login, zero dependencies.

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-ilink

Requirements: 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 Error if QR expires too many times or timeout is reached
  • baseUrl defaults to https://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 chat

client.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/json

QR 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: waitscanedconfirmed


Testing

The SDK includes a comprehensive test suite using Vitest.

# Run all tests
npm test

# Run tests in watch mode
npm run test:watch

Test 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 crypto and native fetch
  • ESM only — published as ES modules ("type": "module")
  • Strict TypeScript — full type safety with strict mode enabled

License

MIT