// 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. Un solo archivo SQLite guarda mensajes, chats, checkpoints de flujos y los journals durables
mkdirSync(dirname(DB_FILE), { recursive: true })
const store = new SQLiteStore({ file: DB_FILE, maxMessagesPerChat: 1000 })
const bot = new Bot({
provider,
store,
lang: 'es',
// 2. Durabilidad: un mensaje cortado por una caída se reprocesa al reiniciar, sin respuestas dobles
durability: { backend: store.durability(), instanceId: process.env.INSTANCE_ID ?? 'bot-1' },
// 3. Logs JSON; los ids de chat y de remitente salen hasheados en los logs del bot (logIds: 'hash')
logger: pino({ level: process.env.LOG_LEVEL ?? 'info' }),
})
bot.plug(slashCommandsPlugin)
bot.when(['hola', 'buenas'], (ctx) => ctx.reply('👋 ¡Hola! Envía "ticket" para abrir un ticket de soporte.'))
// 4. Un flujo persistente: quien lo deja a medias lo retoma tras un reinicio o un despliegue
bot.use(
flow('ticket')
.ask('¿Cuál es el problema?', 'issue')
.ask('¿Cómo te contactamos (email o teléfono)?', 'contact')
.say((_ctx, c) => `🎫 Ticket creado. Te contactaremos en ${c.state.contact}.`)
.persistent()
.timeout('1h'),
)
bot.onMessage((ctx) => ctx.reply('Envía "ticket" para abrir un ticket de soporte, o /help.'))
// 5. Registra qué pasó, nunca el texto de los mensajes ni los teléfonos
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 y métricas Prometheus en un puerto privado
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. Apagado ordenado: termina las respuestas en curso y luego cierra el servidor de salud
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` (o el contenedor) ejecuta esto; `npx botmesh dev src/bot.js` solo importa la fábrica
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()
}