Full type definitions live in /types.js: a single root-level file of JSDoc @typedefs (no .d.ts, no build step, no type-checking pipeline). This document is a human-readable tour of the same surface. Every core/plugin/event file references it via @typedef {import('#types').X} / @param {import('#types').X}, so your editor gives you hover info and autocomplete from the same source.
Module resolution uses Node's native package.json#imports field, no jsconfig.json, no bundler:
"imports": {
"#types": "./types.js",
"#config": "./config/config.js",
"#core/*.js": "./src/core/*.js",
"#events/*.js": "./src/events/*.js",
"#plugins/*.js": "./src/plugins/*.js",
"#utils/*.js": "./src/utils/*.js"
}import { Bot } from '#core/Bot.js';
import { parseMessage } from '#utils/parseMessage.js';
import config from '#config';These resolve identically regardless of which file imports them: no more counting ../../ segments.
core/Bot.js, the framework entry point.
import { Bot } from '#core/Bot.js';
import config from '#config';
const bot = new Bot(config);
bot.use(async (ctx, next) => {
/* middleware */ await next();
});
await bot.start(); // autoloads events, autoloads plugins, connects to WhatsApp
await bot.stop(); // graceful disconnect (keeps credentials)| Member | Signature | Notes |
|---|---|---|
new Bot(config) |
(config: BotConfig) => Bot |
Constructs every core manager and the zapo client wrapper. Does not connect yet. |
bot.use(middleware) |
(mw: Middleware) => void |
Shorthand for bot.middleware.use(mw). |
bot.buildContext() |
() => BotContext |
Returns the memoized shared context (same object every call). |
bot.start() |
() => Promise<void> |
Autoloads src/events/*.js, autoloads src/plugins/**/*.js, connects. |
bot.stop() |
() => Promise<void> |
Disconnects without clearing stored credentials. |
bot.config / bot.logger / bot.events / bot.commands / bot.plugins / bot.wa |
n/a | Direct access to each core manager, same instances exposed on ctx. |
BotContext (see plugin-development.md for the member table) is the object passed to every plugin's init, every event module's register, and every middleware. It is built once by Bot.buildContext() and reused: mutating something reachable from ctx (e.g. ctx.config.set(...)) is visible everywhere immediately.
Built by utils/parseMessage.js for every inbound message, before prefix detection: this is the piece that makes pulling text, isMedia, isType, isGroup, quoted, mentions, etc. off a message a one-liner instead of re-inspecting the raw protobuf:
{
raw, // the original zapo WaIncomingMessageEvent
ctx, // the shared BotContext
key, // WaMessageKey
id, // stanza id
chatJid, // conversation JID
senderJid, // participant in groups, remoteJid in 1:1
isGroup, // boolean
fromMe, // boolean
pushName, // string | undefined
text, // extracted text, trimmed
type, // 'text' | 'image' | 'video' | 'gif' | 'audio' | 'ptt' | 'document' | 'sticker' | 'poll' | 'location' | 'contact' | 'unknown'
isMedia, // boolean
isType(kind), // (kind) => boolean (shorthand for `type === kind`)
mimetype, // string | undefined
seconds, // number | undefined (video/audio duration)
quoted, // { message, type } | undefined
mentions, // string[]
send(content, options?), // send into the originating chat
reply(content, options?) // reply (quoting this message) into the originating chat
}CommandContext is the same object with prefix, command, args added once a message matches a registered command.
Parsing notes (per zapo's docs, read before relying on these fields):
chatJid/senderJidpreferevent.key.remoteJidAlt/participantAltoverremoteJid/participant. zapo surfaces both PN (phone-number) and LID addressing on a message; using the alt form keeps the same contact resolving to the same JID string regardless of which addressing mode WhatsApp used for that particular stanza. If you persist JIDs yourself, normalize them the same way before comparing.textonly readsconversation,extendedTextMessage.text, and the current content type'scaption(utils/media.js#extractText): polls, locations, contacts, and reactions have no plain-text body and yield''. Always checktype/isType()before trustingtexton non-text messages.event.messagecan beundefined(e.g. protocol/unavailable messages):parseMessagehandles that defensively (type: 'unknown',text: ''), but a plugin readingraw.message.*directly must guard for it too.quoted/mentionsare read from the message'scontextInfo, which only exists on content-bearing kinds (text, media, poll, event...): expectundefined/[]on reactions, receipts, and protocol messages.
Built by events/media.js for any message carrying an attachment:
{
(raw,
ctx,
type, // 'image' | 'video' | 'gif' | 'audio' | 'sticker' | 'document'
mimetype, // string | undefined
seconds, // number | undefined
caption, // string | undefined
downloadToFile(filePath), // stream to disk
downloadBytes()); // buffer in memory (small media only)
}See plugin-development.md. The contract (import('#types').BotPlugin):
{
name: string,
type: 'command' | 'event',
commands: string[] | undefined, // required when type === 'command'
description: string | undefined,
enabled: boolean | undefined, // defaults to true
init(ctx) { /* ... */ },
async execute(command) { /* ... */ } // required when type === 'command'
}ctx.utils groups the pure-function helper modules, none of them touch the network or hold state.
| Function | Signature |
|---|---|
parseMessage(event, ctx) |
(WaIncomingMessageEvent, BotContext) => MessageContext, also exposed directly as ctx.utils.parseMessage. |
| Function | Signature |
|---|---|
sleep(ms) |
(number) => Promise<void> |
formatBytes(bytes, decimals?) |
(number, number?) => string |
formatDuration(ms) |
(number) => string |
parseCommand(text, prefix) |
(string, string) => { command: string, args: string[] } |
matchPrefix(text, prefixes) |
(string, string[]) => string | undefined |
safeJsonParse(input, fallback?) |
(string, unknown?) => unknown |
truncate(text, maxLength) |
(string, number) => string |
| Function | Signature |
|---|---|
extractText(message) |
(Proto.IMessage?) => string |
getMessageKind(message) |
(Proto.IMessage?) => MessageKind |
hasMedia(message) |
(Proto.IMessage?) => boolean |
getMediaDurationSeconds(message) |
(Proto.IMessage?) => number | undefined |
getMediaMimetype(message) |
(Proto.IMessage?) => string | undefined |
isAnimatedSticker(message) |
(Proto.IMessage?) => boolean |
getContextInfo(message) |
(Proto.IMessage?) => Proto.IContextInfo | undefined |
getQuotedMessage(message) |
(Proto.IMessage?) => Proto.IMessage | undefined |
getMentionedJids(message) |
(Proto.IMessage?) => string[] |
| Function | Signature |
|---|---|
createSticker(buffer, options?) |
(Buffer, StickerOptions?) => Promise<Buffer>, WebP-encoded sticker bytes, via wa-sticker-formatter. |
The shared engine behind both autoloaders (core/PluginManager.js and events/index.js's loadEvents):
| Function | Signature |
|---|---|
findJsFiles(directory, options?) |
(string, { exclude?: string[] }?) => Promise<string[]>, recursively collects .js file paths, sorted. |
loadDefaultExports(directory, options?) |
(string, { exclude?: string[] }?) => Promise<{ filePath, module }[]>, dynamically imports each file found and returns its default export. |
Reach for these directly only when ctx's convenience surface doesn't cover what you need, normal plugins should not need to import core/* files.
| Class | File | Responsibility |
|---|---|---|
ConfigManager |
core/ConfigManager.js |
Dot-path config get/set + "change" events. |
Logger |
core/Logger.js |
Implements zapo's Logger interface; also usable standalone. |
EventManager |
core/EventManager.js |
The internal event bus (extends EventEmitter). |
MiddlewareManager |
core/MiddlewareManager.js |
Koa-style onion middleware stack. |
CommandManager |
core/CommandManager.js |
Command name → plugin Map, dispatch. |
PluginManager |
core/PluginManager.js |
Recursive filesystem plugin autoloader (category folders, no metadata file). |
ClientWrapper |
core/ClientWrapper.js |
Owns the zapo WaClient, store, pairing, reconnect. |
Bot |
core/Bot.js |
Wires everything above together; the only file src/index.js imports. |