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

wa-bot-framework

v0.1.1

Published

A modern, Express-flavored framework for building command-based WhatsApp bots on top of Baileys.

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

Node 18+ is required. Storage adapters other than Memory/JSON have their own optional peer dependencies — see Storage.

Table of contents

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 function

The 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 now

ctx.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 user

Command 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 implementation

Design philosophy

  • Baileys is never hidden. ctx.sock/ctx.msg/ctx.raw are 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