wa-bot-framework
v0.1.1
Published
A modern, Express-flavored framework for building command-based WhatsApp bots on top of Baileys.
Maintainers
Readme
wa-bot-framework
A modern, Express-flavored TypeScript framework for building command-based WhatsApp bots on top of Baileys.
Baileys handles the WhatsApp protocol. This framework handles everything around it — commands, prefixes, permissions, cooldowns, middleware, plugins, and pluggable storage — while keeping the raw Baileys socket and message always within reach.
import { Bot } from 'wa-bot-framework';
const bot = new Bot({
prefix: '!',
auth: { authDir: './auth' },
});
bot.command({ name: 'ping', description: 'Replies with Pong!' }, async (ctx) => {
await ctx.reply('Pong!');
});
bot.on('qr', (qr) => console.log(qr));
await bot.start();Install
npm install wa-bot-frameworkNode 18+ is required. Storage adapters other than Memory/JSON have their own optional peer dependencies — see Storage.
Table of contents
- Install
- Quick start
- Authentication
- Commands
- Prefixes
- The Context object
- Permissions
- Trusted users
- Chat-trust security gate
- Built-in help command
- Middleware
- Plugins
- Storage
- Events
- Raw Baileys access
- Project structure
- Design philosophy
Quick start
import path from 'path';
import { Bot, JsonStorage } from 'wa-bot-framework';
const bot = new Bot({
prefix: '!',
auth: { authDir: path.join(__dirname, '.auth') },
storage: new JsonStorage({ filePath: path.join(__dirname, 'data/store.json') }),
});
bot.command(
{ name: 'ping', aliases: ['p'], cooldown: 5, description: 'Replies with Pong!' },
async (ctx) => ctx.reply('Pong!'),
);
bot.on('qr', (qr) => console.log('Scan this QR code:\n', qr));
bot.on('ready', () => console.log('Bot is up.'));
await bot.start();See examples/basic-bot for a fuller example wiring up storage, plugins, middleware, and commands loaded from a directory.
Authentication
Authentication is handled entirely internally — credentials are persisted to auth.authDir and are never routed through your storage adapter.
const bot = new Bot({
prefix: '!',
auth: {
authDir: './auth',
method: 'qr', // default
},
});
bot.on('qr', (qr) => {
// render this however you like — terminal, a `qrcode` package, a web UI, etc.
console.log(qr);
});Pairing-code login:
const bot = new Bot({
prefix: '!',
auth: {
authDir: './auth',
method: 'pairing-code',
phoneNumber: '15551234567', // country code + number, no leading +
},
});
bot.on('pairingCode', (code) => console.log('Enter this code on your phone:', code));The framework auto-reconnects on recoverable disconnects (autoReconnect: true by default) and emits logout when the session is invalidated server-side (you'll need to clear authDir and re-link).
Commands
bot.command(
{
name: 'ping',
aliases: ['p'],
permission: 0, // Permission.EVERYONE
description: 'Replies with Pong!',
usage: '!ping',
cooldown: 5, // seconds, per (command, sender)
hidden: false,
enabled: true,
scope: 'both', // 'group' | 'dm' | 'both'
},
async (ctx) => {
await ctx.reply('Pong!');
},
);Load commands from a directory instead of registering them inline. Each file exports { options, handler }:
// commands/ping.ts
export default {
options: { name: 'ping', description: 'Replies with Pong!' },
handler: async (ctx) => ctx.reply('Pong!'),
};await bot.loadCommands(path.join(__dirname, 'commands'));Duplicate command names or aliases throw at registration time — the framework never silently drops a command.
Prefixes
Three flavors, all accepted by prefix in BotOptions:
prefix: '!' // static
prefix: ['!', '.'] // multiple static prefixes
prefix: (ctx) => '!' // sync function
prefix: async (ctx) => database.getPrefix(ctx.chat) // async functionThe resolver receives { chat, sender, isGroup, raw } and is re-evaluated for every incoming message, so per-chat or per-user prefixes (backed by your storage adapter) work out of the box. See examples/middleware-example for a worked example.
The Context object
Every command handler and every middleware function receives a Context:
ctx.sock // raw Baileys WASocket
ctx.raw / ctx.msg // raw Baileys WAMessage
ctx.text // normalized plain-text body, regardless of message type
ctx.args // arguments after the command name
ctx.command // matched command name
ctx.prefix // matched prefix
ctx.sender // sender JID
ctx.chat // chat JID
ctx.isGroup
ctx.isAdmin
ctx.isOwner
ctx.isTrusted
ctx.isBotAdmin
ctx.permissions // resolved Permission enum value
ctx.mentions // mentioned JIDs
ctx.quoted // quoted/replied-to message, if any
await ctx.reply('text') // replies, quoting the triggering message
await ctx.react('👍') // reacts to the triggering message
await ctx.send(content, chat?) // send anything sendMessage() accepts, anywhere
await ctx.download() // download attached (or quoted) media as a Buffer
ctx.storage // same adapter as bot.storage
ctx.trustedUsers // same instance as bot.trusted - ctx.trustedUsers.add(jid)
ctx.trustedGroups // same instance as bot.trustedGroups - ctx.trustedGroups.add(ctx.chat)
ctx.eventTrustedGroups // same instance as bot.eventTrustedGroups
ctx.help // generated help text scoped to what this sender can run right nowctx.text is normalized across conversation, extendedTextMessage, edited messages, image/video/document captions, and interactive reply types — you never need to branch on the raw Baileys message type yourself.
Permissions
A flat integer scale, checked with a simple >=:
| Level | Constant | Meaning |
|-------|----------------------------|----------------------------------|
| 3 | Permission.TRUSTED | Explicitly trusted (see below) |
| 2 | Permission.GROUP_OWNER | Group creator/superadmin |
| 1 | Permission.GROUP_ADMIN | Group admin |
| 0 | Permission.EVERYONE | Anyone |
bot.command({ name: 'kick', permission: Permission.GROUP_ADMIN }, async (ctx) => { ... });If ctx.permissions is below a command's required permission, the command doesn't run and the bot emits permissionDenied instead — nothing crashes, nothing silently no-ops without a hook to observe it.
Trusted users
Persisted through your configured storage adapter:
await bot.trusted.add(jid);
await bot.trusted.remove(jid);
await bot.trusted.has(jid);
await bot.trusted.list();To manually add a number, call bot.trusted.add() with a full JID ("<number>@s.whatsapp.net", no + or spaces) anywhere you have a Bot instance - a one-off script, or right after construction:
const bot = new Bot({ /* ... */ });
await bot.trusted.add('[email protected]');Or from inside a running command (see below) - ctx.trustedUsers is the exact same list:
bot.command({ name: 'trust', permission: Permission.TRUSTED }, async (ctx) => {
await ctx.trustedUsers.add(ctx.mentions[0]);
await ctx.reply('Trusted.');
});Chat-trust security gate
On top of per-command permission, the framework applies a chat-level trust gate, on by default:
A command only runs if its chat is a trusted group, or the sender is a trusted user.
Both trusted-group lists start empty, so out of the box the secure default is: nothing runs anywhere except for trusted users, until you explicitly trust a group or a user. DMs are chats too - since a 1:1 chat can't be a "trusted group", a DM only runs commands if the sender themself is trusted.
await bot.trustedGroups.add(groupJid); // that group can now run commands, for anyone in it
await bot.trustedGroups.remove(groupJid);
await bot.trustedGroups.has(groupJid);
await bot.trustedGroups.list();Framework events (message, join, leave, groupUpdate) are gated the same way, but through a separate list - bot.eventTrustedGroups - so you can, for example, let a group run commands without also having every message it sends flow through your message/join/leave listeners, or vice versa:
await bot.eventTrustedGroups.add(groupJid);For join/leave, the check also passes if any participant in the update is a trusted user; groupUpdate has no sender to check and relies on eventTrustedGroups alone.
Both lists are editable from any command, via ctx.trustedGroups and ctx.eventTrustedGroups (the same instances as bot.trustedGroups/bot.eventTrustedGroups):
bot.command({ name: 'trust-group', permission: Permission.TRUSTED }, async (ctx) => {
await ctx.trustedGroups.add(ctx.chat);
await ctx.reply('This group can now run commands.');
});When a command is blocked purely by this gate (not by its own permission), the bot emits chatNotTrusted instead of running it - listen for it if you want to tell people why nothing happened:
bot.on('chatNotTrusted', (ctx) => ctx.reply("This chat isn't trusted yet."));Turn either half off in BotOptions:
new Bot({
prefix: '!',
auth: { authDir: './auth' },
security: {
enabled: false, // commands run everywhere, gated only by their own `permission`
gateEvents: false, // events fire everywhere
},
});Built-in help command
A help command (aliased menu) is registered automatically unless you register your own help command first, or pass builtinHelpCommand: false. It replies with ctx.help - text listing every visible, enabled command the sender is currently eligible to run (respecting permission level and group/DM scope).
ctx.help is populated on every context, not just for the help command itself, so you can bind it to whatever command name you actually want:
new Bot({ prefix: '!', auth: { authDir: './auth' }, builtinHelpCommand: false });
bot.command({ name: 'menu', description: 'Show commands' }, async (ctx) => {
await ctx.reply(ctx.help); // reuse the generated listing under your own command name
});Middleware
Express-style, with the same "call next() to continue" contract:
bot.use(async (ctx, next) => {
console.log(ctx.sender, '->', ctx.text);
await next();
});Middleware runs in registration order, before any command handler. Not calling next() stops the chain — useful for rate limiting, banned-word filtering, auth gates, etc. See examples/middleware-example.
Plugins
Plugins bundle commands, middleware, and event listeners into one installable unit:
class WelcomePlugin implements Plugin {
name = 'welcome';
install(bot: BotLike) {
bot.on('join', (payload) => { /* ... */ });
}
}
bot.use(new WelcomePlugin());bot.use() distinguishes plugins from plain middleware functions automatically (anything with name + install is treated as a plugin). Installing the same plugin name twice throws. See examples/plugin-example for a moderation plugin (commands + middleware) and a welcome plugin (events).
Storage
Storage is strictly for application data — auth credentials never touch it. Every adapter implements the same five-method interface:
interface StorageAdapter {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
keys(): Promise<string[]>;
init?(): Promise<void>;
close?(): Promise<void>;
}Built-in adapters:
| Adapter | Extra dependency | Notes |
|---------------------|-----------------------------|----------------------------------------------|
| MemoryStorage | none | Default. Data lost on restart. |
| JsonStorage | none | Debounced writes to a single JSON file. |
| SqliteStorage | better-sqlite3 | Lazily required — install it yourself. |
| RedisStorage | ioredis | Lazily required — install it yourself. |
| SupabaseStorage | @supabase/supabase-js | Lazily required — install it yourself. |
new Bot({ prefix: '!', auth: { authDir: './auth' }, storage: new JsonStorage({ filePath: './data/store.json' }) });Writing your own adapter is just implementing the interface above — see examples/custom-storage-adapter for a TTL-aware in-memory example.
Events
bot.on('qr', (qr) => {});
bot.on('pairingCode', (code) => {});
bot.on('connected', () => {});
bot.on('disconnected', (reason) => {});
bot.on('logout', () => {});
bot.on('ready', () => {});
bot.on('message', (ctx) => {}); // every inbound message, command or not
bot.on('join', (payload) => {});
bot.on('leave', (payload) => {});
bot.on('groupUpdate', (payload) => {});
bot.on('error', (err, ctx) => {}); // command handler threw
bot.on('commandNotFound', (ctx) => {});
bot.on('cooldown', (ctx, remainingMs) => {});
bot.on('permissionDenied', (ctx) => {}); // sender's role is below the command's required permission
bot.on('chatNotTrusted', (ctx) => {}); // chat isn't a trusted group and sender isn't a trusted userCommand handler errors are always caught and routed through error — a bug in one command never crashes the bot.
Raw Baileys access
Nothing is hidden. ctx.sock, ctx.msg, and ctx.raw are always the real Baileys objects, so anything Baileys can do, your commands can do:
bot.command({ name: 'metadata' }, async (ctx) => {
const meta = await ctx.sock.groupMetadata(ctx.chat);
await ctx.reply(meta.subject);
});Project structure
src/
Bot.ts # main entry point, wires everything together
Context.ts # per-message context object
CommandManager.ts # registration, lookup, cooldowns
MiddlewareManager.ts # Express-style middleware chaining
PrefixEngine.ts # static/array/function prefix resolution
PermissionManager.ts # permission level resolution
TrustedManager.ts # trusted-user list, storage-backed
MessageParser.ts # raw Baileys message -> normalized text
types.ts # all public types
auth/
AuthManager.ts # Baileys connection lifecycle
storage/
StorageAdapter.ts # interface
MemoryStorage.ts
JsonStorage.ts
SqliteStorage.ts
RedisStorage.ts
SupabaseStorage.ts
examples/
basic-bot/ # full example: storage, plugins, middleware, commands dir
plugin-example/ # ModerationPlugin, WelcomePlugin
middleware-example/ # logging + rate limiting + async prefix
custom-storage-adapter/ # TTL-aware StorageAdapter implementationDesign philosophy
- Baileys is never hidden.
ctx.sock/ctx.msg/ctx.raware always the real thing. - No magic. Middleware is a plain function
(ctx, next) => .... Plugins are a plain object{ name, install(bot) }. Storage is five methods. - Fail loud, not silent. Duplicate command names, duplicate plugin installs, and calling
next()twice all throw immediately instead of behaving unpredictably. - Fully async, throughout — prefixes, storage, middleware, commands.
- Full IntelliSense. The whole framework is TypeScript with strict mode on; every public method, option, and event is typed.
License
MIT
