The Node.js SDK is the most feature-rich implementation. 77 tests, zero runtime dependencies. Requires Node.js β₯ 22.
Install
npm install @wechatbot/wechatbot
Quick start
import { WeChatBot } from '@wechatbot/wechatbot' const bot = new WeChatBot() await bot.login() bot.onMessage(async (msg) => { await bot.sendTyping(msg.userId) await bot.reply(msg, `Echo: ${msg.text}`) }) await bot.start()
Configuration
const bot = new WeChatBot({ storage: 'file', // 'file' | 'memory' | custom Storage storageDir: '~/.wechatbot', logLevel: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'silent' botAgent: 'MyApp/1.2 (prod)', // base_info.bot_agent identifier sent on every request loginCallbacks: { onQrUrl: (url) => renderQrCode(url), onScanned: () => console.log('Scanned!'), onExpired: () => console.log('Expired...'), }, })
Sending β reply() and send()
Two methods handle all outgoing messages. reply() replies to an incoming message (auto context_token, auto cancel typing). send() sends to a user by ID.
Both accept the same SendContent type:
// Text (string shorthand) await bot.reply(msg, 'Hello!') // Text (object) await bot.reply(msg, { text: 'Hello!' }) // Image with optional caption await bot.reply(msg, { image: pngBuffer, caption: 'Screenshot' }) // Video with optional caption await bot.reply(msg, { video: mp4Buffer, caption: 'Check this out' }) // File β auto-routes by extension: // .png/.jpg/.gif/.webp β sent as image // .mp4/.mov/.webm β sent as video // everything else β sent as file attachment await bot.reply(msg, { file: data, fileName: 'report.pdf' }) await bot.reply(msg, { file: data, fileName: 'photo.png' }) // β image! // From URL β auto-download + auto-detect type from Content-Type / extension await bot.reply(msg, { url: 'https://example.com/photo.jpg' }) await bot.reply(msg, { url: 'https://picsum.photos/400/300', caption: 'Random!' }) // send() works the same way, just takes userId instead of message await bot.send(userId, 'Hello!') await bot.send(userId, { image: buffer, caption: 'Hi!' })
Downloading media
One method downloads any media type from an incoming message:
bot.onMessage(async (msg) => { const media = await bot.download(msg) if (!media) return // no media in this message console.log(media.type) // 'image' | 'file' | 'video' | 'voice' console.log(media.data) // Buffer console.log(media.fileName) // 'report.pdf' (for files) console.log(media.format) // 'wav' | 'silk' (for voice) // Save to disk await writeFile(`/tmp/${media.fileName ?? 'download'}`, media.data) // Echo back if (media.type === 'image') { await bot.reply(msg, { image: media.data, caption: 'Got your image!' }) } })
Priority: image > file > video > voice. Voice is auto-transcoded from SILK to WAV (if silk-wasm is installed).
Middleware
Express/Koa-style composable middleware pipeline. Runs before message handlers.
import { WeChatBot, loggingMiddleware, rateLimitMiddleware, typeFilterMiddleware, filterMiddleware, } from '@wechatbot/wechatbot' const bot = new WeChatBot() bot.use(loggingMiddleware(bot.logger)) bot.use(rateLimitMiddleware({ maxMessages: 10, windowMs: 60_000 })) bot.use(typeFilterMiddleware('text', 'image')) bot.use(filterMiddleware(/^\/\w+/)) // Custom middleware bot.use(async (ctx, next) => { const start = Date.now() await next() console.log(`Processed in ${Date.now() - start}ms`) })
Middleware that doesn't call next() stops the chain β perfect for auth, filtering, routing.
Pluggable storage
The SDK persists credentials, the message cursor, and other state through the Storage interface β five methods (get / set / delete / has / clear), JSON values. File storage (~/.wechatbot) is the default; swap in any backend:
// File storage (default) β survives restarts const bot1 = new WeChatBot({ storage: 'file' }) // Memory storage β fast, ephemeral const bot2 = new WeChatBot({ storage: 'memory' }) // Custom β implement 5 methods const bot3 = new WeChatBot({ storage: new RedisStorage() })
Custom storage example β PostgreSQL (one jsonb table keyed by (tenant_id, key)):
import type { Storage } from '@wechatbot/wechatbot' import type { Pool } from 'pg' export class PostgresStorage implements Storage { constructor(private pool: Pool, private tenantId: string) {} async get<T>(key: string): Promise<T | undefined> { const { rows } = await this.pool.query( 'SELECT value FROM bot_storage WHERE tenant_id = $1 AND key = $2', [this.tenantId, key], ) return rows[0]?.value as T | undefined } async set<T>(key: string, value: T): Promise<void> { await this.pool.query( `INSERT INTO bot_storage (tenant_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (tenant_id, key) DO UPDATE SET value = $3, updated_at = now()`, [this.tenantId, key, JSON.stringify(value)], ) } async delete(key: string): Promise<void> { await this.pool.query( 'DELETE FROM bot_storage WHERE tenant_id = $1 AND key = $2', [this.tenantId, key], ) } async has(key: string): Promise<boolean> { return (await this.get(key)) !== undefined } async clear(): Promise<void> { await this.pool.query( 'DELETE FROM bot_storage WHERE tenant_id = $1', [this.tenantId], ) } }
The credentials key grants full control of the WeChat account β encrypt it at the application layer (AES-GCM + KMS) before it hits the database.
Multi-tenant / SaaS
Every WeChatBot instance is fully isolated β its own HTTP client, event emitter, and message poller, with no global state and no local ports. One backend process can run many accounts side by side. The QR code arrives via the onQrUrl callback, so you can push it to a web frontend and let each user scan from their own page:
// One instance per tenant, credentials in the database (see PostgresStorage above) async function startTenantBot(tenantId: string) { const bot = new WeChatBot({ storage: new PostgresStorage(pool, tenantId), }) await bot.login({ callbacks: { onQrUrl: (url) => pushQrToWebUI(tenantId, url), // render on that user's page onScanned: () => notify(tenantId, 'Scanned β waiting for confirmation'), onExpired: () => refreshQr(tenantId), }, }) bot.onMessage(async (msg) => { await bot.reply(msg, await agentReply(tenantId, msg)) }) await bot.start() return bot }
Things to watch:
- Storage isolation β each account needs its own
storageDiror storage namespace (thetenantIdabove); sharing one overwrites credentials. - One live instance per account β only one poller per account across your whole fleet, or message cursors overwrite each other and messages get lost or duplicated. In multi-machine deployments, use a Postgres advisory lock or a lease table to elect a single owner per account.
- Restart recovery β credentials are persisted, so after a restart
login()restores the session without a new QR scan.
Events
bot.on('login', (creds) => console.log(`Logged in: ${creds.accountId}`)) bot.on('session:expired', () => console.log('Session expired')) bot.on('session:restored', (creds) => console.log('Restored')) bot.on('error', (err) => console.error(err)) bot.on('poll:start', () => { }) bot.on('poll:stop', () => { }) bot.on('close', () => { })
Advanced: MessageBuilder
For complex multi-item messages, use the builder directly:
const payload = bot.createMessage(userId) .text("Here's your report:") .file({ media: cdnRef, fileName: 'report.pdf', size: 542188 }) .build() await bot.sendRaw(payload)
Other advanced methods: bot.upload(opts) for CDN upload without sending, bot.downloadRaw(media, aeskey?) for raw CDN references.
API reference
| Method | Description | |
|---|---|---|
| new WeChatBot(opts?) | Create instance | |
| bot.login(opts?) | QR login (skips if credentials exist) | |
| bot.start() | Start long-poll loop | |
| bot.run(opts?) | login() + start() in one call | |
| bot.stop() | Stop gracefully | |
| bot.onMessage(handler) | Register message handler | |
| bot.reply(msg, content) | Reply β text, image, video, file, or URL | |
| bot.send(userId, content) | Send to user β same content types | |
| bot.download(msg) | Download any media from message | |
| bot.sendTyping(userId) | Show "typing..." indicator | |
| bot.stopTyping(userId) | Cancel typing indicator | |
| bot.use(middleware) | Add middleware to pipeline | |
| bot.sendRaw(payload) | Send pre-built MessageBuilder payload | |
| bot.upload(opts) | Upload to CDN without sending | |
| bot.downloadRaw(media) | Download from raw CDN reference | |
| bot.createMessage(userId) | Create MessageBuilder for user |