// server.js
import { createServer } from 'node:http'
import { timingSafeEqual } from 'node:crypto'
import { Bot, OutsideSessionWindowError, defineBot, flow, isMainModule } from '@botmesh/core'
import { CloudAPIProvider } from '@botmesh/meta'
// 1. El bot: una fábrica, así `npx botmesh dev server.js` lo ejecuta sin Meta
const createBot = defineBot((provider) => {
const bot = new Bot({ provider, lang: 'es' })
bot.when(['hola', 'buenas'], (ctx) => ctx.reply('👋 ¡Hola! Soy el mostrador de pedidos. Envía "pedido" para seguir un pedido.'))
bot.use(
flow('pedido')
.ask('¿Cuál es tu número de pedido?', 'order', {
validate: (body) => (/^\d{4,8}$/.test(body.trim()) ? true : '❌ Los números de pedido tienen de 4 a 8 dígitos.'),
})
.say((_ctx, c) => `📦 Estamos preparando el pedido ${c.state.order.trim()}. Te escribo cuando salga.`),
)
bot.onMessage((ctx) => ctx.reply('Envía "pedido" para seguir un pedido.'))
return bot
})
export default createBot
// 2. Los bytes crudos del body, con límite de tamaño: Meta firma exactamente estos bytes
export function readRawBody(req, limit = 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
req.on('data', (chunk) => {
size += chunk.length
if (size > limit) reject(Object.assign(new Error('body too large'), { status: 413 }))
else chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}
const sameSecret = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b))
// 3. Texto libre dentro de la ventana de 24 h; fuera de ella, una plantilla aprobada
export async function notifyShipped(bot, chatId, order) {
try {
await bot.send(chatId, `🚚 ¡Tu pedido ${order} ya salió!`)
return 'text'
} catch (err) {
if (!(err instanceof OutsideSessionWindowError)) throw err
await bot.send(chatId, {
kind: 'template',
name: 'order_shipped',
language: 'es',
components: [{ type: 'body', parameters: [{ type: 'text', text: order }] }],
})
return 'template'
}
}
// 4. El servidor HTTP: el handshake de Meta, webhooks firmados, una ruta interna de aviso y un health check
export function createWebhookHandler({ provider, bot, adminToken }) {
return async (req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost')
const reply = (status, body = '') => {
if (status === 413) res.setHeader('connection', 'close')
res.writeHead(status, { 'content-type': 'text/plain' }).end(body)
}
try {
if (url.pathname === '/healthz') return reply(200, 'ok')
if (url.pathname === '/webhook' && req.method === 'GET') {
// Handshake de suscripción: devuelve hub.challenge si hub.verify_token coincide
const challenge = provider.verifyChallenge(Object.fromEntries(url.searchParams))
return challenge === null ? reply(403) : reply(200, challenge)
}
if (url.pathname === '/webhook' && req.method === 'POST') {
// Bytes crudos + cabeceras: ingest() comprueba X-Hub-Signature-256 y devuelve el status a responder
const raw = await readRawBody(req)
return reply(provider.ingest(raw, req.headers))
}
if (url.pathname === '/notify/shipped' && req.method === 'POST') {
// La llama tu backend, nunca Meta: protegida con un token compartido
if (!adminToken || !sameSecret(req.headers.authorization ?? '', `Bearer ${adminToken}`)) return reply(401)
const { chatId, order } = JSON.parse((await readRawBody(req, 4096)).toString('utf8'))
return reply(200, await notifyShipped(bot, chatId, order))
}
reply(404)
} catch (err) {
reply(err.status ?? (err instanceof SyntaxError ? 400 : 500))
}
}
}
// 5. Entrada de producción: `node server.js` detrás de HTTPS (un proxy inverso o un túnel)
if (isMainModule(import.meta)) {
const env = (name) => {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing environment variable ${name}`)
return value
}
const provider = new CloudAPIProvider({
accessToken: env('META_TOKEN'),
phoneNumberId: env('META_PHONE_ID'),
verifyToken: env('META_VERIFY_TOKEN'),
appSecret: env('META_APP_SECRET'),
})
const bot = await createBot(provider)
const server = createServer(createWebhookHandler({ provider, bot, adminToken: env('ADMIN_TOKEN') }))
await bot.start()
server.listen(Number(process.env.PORT ?? 3000), () => bot.logger.info('webhook server listening'))
process.once('SIGTERM', async () => {
server.close() // deja de aceptar webhooks; Meta reintenta lo que no pudo entregar
await bot.stop({ drain: true, timeout: '10s' })
})
}