// src/bot.js
import { createServer } from 'node:http'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import pino from 'pino'
import { Bot, PROMETHEUS_CONTENT_TYPE, defineBot, flow, isMainModule, slashCommandsPlugin, toPrometheus } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { SQLiteStore } from '@botmesh/sqlite-store'
const DB_FILE = process.env.DB_FILE ?? './data/bot.db'
const createBot = defineBot((provider) => {
// 1. One SQLite file keeps messages, chats, flow checkpoints and the durable journals
mkdirSync(dirname(DB_FILE), { recursive: true })
const store = new SQLiteStore({ file: DB_FILE, maxMessagesPerChat: 1000 })
const bot = new Bot({
provider,
store,
// 2. Durability: a message cut short by a crash is replayed on restart, without double replies
durability: { backend: store.durability(), instanceId: process.env.INSTANCE_ID ?? 'bot-1' },
// 3. JSON logs; chat and sender ids are hashed in the bot's log lines (logIds: 'hash')
logger: pino({ level: process.env.LOG_LEVEL ?? 'info' }),
})
bot.plug(slashCommandsPlugin)
bot.when(['hi', 'hello'], (ctx) => ctx.reply('👋 Hi! Send "ticket" to open a support ticket.'))
// 4. A persistent flow: a user halfway through it resumes after a restart or a deploy
bot.use(
flow('ticket')
.ask('What is the problem?', 'issue')
.ask('How can we reach you (email or phone)?', 'contact')
.say((_ctx, c) => `🎫 Ticket created. We will contact you at ${c.state.contact}.`)
.persistent()
.timeout('1h'),
)
bot.onMessage((ctx) => ctx.reply('Send "ticket" to open a support ticket, or /help.'))
// 5. Log what happened, never message bodies or phone numbers
bot.on('conversation:ended', (e) => bot.logger.info({ flow: e.name, outcome: e.outcome, ms: e.durationMs }, 'conversation ended'))
bot.on('error', ({ error }) => bot.logger.error({ err: error }, 'handler failed'))
return bot
})
export default createBot
// 6. Liveness, readiness and Prometheus metrics on a private port
export function startHealthServer(bot, port = 9464) {
let stopping = false
bot.on('stopping', () => { stopping = true })
const server = createServer((req, res) => {
if (req.url === '/healthz') return void res.writeHead(200).end('ok')
if (req.url === '/readyz') {
const ready = !stopping && bot.getConnectionState() === 'ready'
return void res.writeHead(ready ? 200 : 503).end(ready ? 'ready' : 'not ready')
}
if (req.url === '/metrics') {
return void res.writeHead(200, { 'content-type': PROMETHEUS_CONTENT_TYPE }).end(toPrometheus(bot.metrics()))
}
res.writeHead(404).end()
})
return new Promise((resolve) => server.listen(port, () => resolve(server)))
}
// 7. Graceful shutdown: finish the replies in flight, then close the health server
export function gracefulShutdown(bot, server) {
let closing
return (signal) => (closing ??= (async () => {
bot.logger.info({ signal }, 'shutting down')
await bot.stop({ drain: true, timeout: '15s' })
await new Promise((resolve) => server.close(resolve))
})())
}
// 8. `node src/bot.js` (or the container) runs this; `npx botmesh dev src/bot.js` only imports the factory
if (isMainModule(import.meta)) {
const bot = await createBot(new BaileysProvider({ session: process.env.WA_SESSION ?? 'support-bot' }))
const server = await startHealthServer(bot, Number(process.env.HEALTH_PORT ?? 9464))
const shutdown = gracefulShutdown(bot, server)
process.once('SIGTERM', () => void shutdown('SIGTERM'))
process.once('SIGINT', () => void shutdown('SIGINT'))
await bot.start()
}