jswhatsappbot
Version:
A modern telegraf-style framework for WhatsApp bots.
325 lines (298 loc) • 8.9 kB
JavaScript
import axios from 'axios'
import express from 'express'
import Context from './context.js'
import Markup from './markup.js'
import { Scene, SceneManager } from './scenes.js'
import { session } from './session.js'
import { sessionStore as defaultSessionStore } from './sessionStore.js'
/**
* Main class for WhatsApp bot framework.
* Handles middleware, command/event registration, and message sending.
* @class
*/
class WhatsAppBot {
/**
* Registers a global error handler.
* @param {function} fn Error handler function
*/
catch(fn) {
this.errorHandler = fn
}
/**
* Creates a WhatsAppBot instance.
* @param {object} options
* @param {string} options.accessToken WhatsApp Cloud API access token
* @param {string} options.phoneNumberId WhatsApp phone number ID
* @param {string} options.verifyToken Webhook verification token
* @param {object} [options.sessionStore] Custom session store
* @param {function} [options.errorHandler] Error handler
* @param {string} [options.apiVersion] WhatsApp API version
*/
constructor({
accessToken,
phoneNumberId,
verifyToken,
sessionStore,
errorHandler = null,
apiVersion = 'v23.0',
}) {
if (!accessToken || !phoneNumberId || !verifyToken) {
throw new Error(
'WhatsAppBot requires accessToken, phoneNumberId, and verifyToken'
)
}
this.accessToken = accessToken
this.phoneNumberId = phoneNumberId
this.verifyToken = verifyToken
this.sessionStore = sessionStore || defaultSessionStore
this.errorHandler = errorHandler
this.apiVersion = apiVersion
this.handlers = { message: [], postback: [] }
this.middlewares = []
this.actions = {}
this.app = express()
this.app.use(express.json())
}
/**
* Registers a middleware function.
* @param {function} fn Middleware function
*/
use(fn) {
this.middlewares.push(fn)
}
/**
* Registers an error handler function.
* @param {function} fn Error handler function
*/
useErrorHandler(fn) {
this.errorHandler = fn
}
/**
* Registers an event handler.
* @param {string} event Event name
* @param {function} fn Handler function
*/
on(event, fn) {
if (!this.handlers[event]) this.handlers[event] = []
this.handlers[event].push(fn)
}
/**
* Registers a command handler for text messages.
* @param {string|Array<string>} cmd Command(s) to match
* @param {function} fn Handler function
*/
command(cmd, fn) {
this.on('message', (ctx) => {
if (!ctx.text || ctx._handled) return
if (Array.isArray(cmd)) {
for (const c of cmd) {
if (ctx.text === c) {
ctx._handled = true
fn(ctx)
break
}
}
} else {
if (ctx.text === cmd) {
ctx._handled = true
fn(ctx)
}
}
})
}
// WhatsApp does not support postback/callback actions. Use hears() for button actions.
/**
* No-op for WhatsApp. Included for API compatibility only.
* Use hears() for button actions.
* @param {*} payload
* @param {function} fn
*/
action(payload, fn) {
// No-op for WhatsApp. Included for API compatibility only.
// Use hears() for button actions.
}
/**
* Registers a handler for matching text or regex patterns.
* @param {string|RegExp|Array<string|RegExp>} pattern Pattern(s) to match
* @param {function} fn Handler function
*/
hears(pattern, fn) {
this.on('message', (ctx) => {
if (!ctx.text || ctx._handled) return
if (Array.isArray(pattern)) {
for (const p of pattern) {
if (typeof p === 'string' && ctx.text === p) {
ctx._handled = true
fn(ctx)
break
} else if (p instanceof RegExp && p.test(ctx.text)) {
ctx._handled = true
fn(ctx)
break
}
}
} else if (typeof pattern === 'string') {
if (ctx.text === pattern) {
ctx._handled = true
fn(ctx)
}
} else if (pattern instanceof RegExp) {
if (pattern.test(ctx.text)) {
ctx._handled = true
fn(ctx)
}
}
})
}
/**
* Runs all registered middlewares for a context.
* @param {Context} ctx
*/
async runMiddlewares(ctx) {
let index = -1
const runner = async (i) => {
if (i <= index) return
index = i
const fn = this.middlewares[i]
if (fn) {
try {
await fn(ctx, () => runner(i + 1))
} catch (err) {
if (this.errorHandler) await this.errorHandler(err, ctx)
else throw err
}
}
}
await runner(0)
}
/**
* Handles an incoming WhatsApp event.
* @param {object} event WhatsApp event object
*/
async handleEvent(event) {
const senderId = event.from
const ctx = new Context(this, event, senderId)
try {
// Load session
if (this.sessionStore) {
ctx.session = (await this.sessionStore.get(ctx.chat.id)) || {}
}
await this.runMiddlewares(ctx)
// Map 'text' and 'interactive' event types to 'message' for handler matching
let eventType =
event.type === 'text' || event.type === 'interactive'
? 'message'
: event.type
if (this.handlers[eventType]) {
for (const fn of this.handlers[eventType]) {
if (ctx._handled) break
await fn(ctx)
}
}
// WhatsApp does not support postback/callback actions. No action simulation.
// Save session
if (this.sessionStore) {
await this.sessionStore.set(ctx.chat.id, ctx.session)
}
} catch (err) {
if (this.errorHandler) await this.errorHandler(err, ctx)
else throw err
}
}
/**
* Sends a message to a WhatsApp user.
* @param {string} to Recipient phone number
* @param {string|object} textOrPayload Text or message payload
* @returns {Promise<object|Error>} API response or error
*/
async sendMessage(to, textOrPayload) {
const url = `https://graph.facebook.com/${this.apiVersion}/${this.phoneNumberId}/messages`
let data
if (!to) {
console.error('[sendMessage] Error: recipient (to) is missing')
return
}
if (typeof textOrPayload === 'string') {
data = {
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: textOrPayload },
}
} else if (typeof textOrPayload === 'object' && textOrPayload !== null) {
data = {
messaging_product: 'whatsapp',
to,
...textOrPayload,
}
} else {
return
}
try {
const response = await axios.post(url, data, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
})
return response
} catch (err) {
return err
}
}
/**
* Starts the Express server and webhook endpoints.
* @param {number} [port=3000] Port to listen on
*/
start(port = 3000) {
// Verification endpoint
this.app.get('/webhook', (req, res) => {
const mode = req.query['hub.mode']
const token = req.query['hub.verify_token']
const challenge = req.query['hub.challenge']
if (mode === 'subscribe' && token === this.verifyToken) {
res.status(200).send(challenge)
} else {
res.sendStatus(403)
}
})
// Incoming webhook events
this.app.post('/webhook', async (req, res) => {
const body = req.body
if (body.object === 'whatsapp_business_account') {
for (const entry of body.entry) {
for (const change of entry.changes) {
const messages = change.value.messages
if (messages) {
// Handle multiple messages concurrently
await Promise.all(messages.map((msg) => this.handleEvent(msg)))
}
}
}
res.sendStatus(200)
} else {
res.sendStatus(404)
}
})
this.app.listen(port, (cb) => {
console.log(`🚀 WhatsappJs running on port ${port}`)
})
}
}
/**
* Markup utility for WhatsApp reply buttons and keyboards.
* @see Markup
*/
// ...existing code...
/**
* Session middleware for WhatsApp bots.
* @see session
*/
// ...existing code...
/**
* Scene system for multi-step conversational flows.
* @see Scene, SceneManager
*/
// ...existing code...
export default WhatsAppBot
export { Markup, Scene, SceneManager, session }