// 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. The bot: a factory, so `npx botmesh dev server.js` runs it without Meta
const createBot = defineBot((provider) => {
const bot = new Bot({ provider })
bot.when(['hi', 'hello'], (ctx) => ctx.reply('👋 Hi! I am the order desk. Send "track" to follow an order.'))
bot.use(
flow('track')
.ask('What is your order number?', 'order', {
validate: (body) => (/^\d{4,8}$/.test(body.trim()) ? true : '❌ Order numbers have 4 to 8 digits.'),
})
.say((_ctx, c) => `📦 Order ${c.state.order.trim()} is being prepared. I will write when it ships.`),
)
bot.onMessage((ctx) => ctx.reply('Send "track" to follow an order.'))
return bot
})
export default createBot
// 2. The raw body bytes, with a size limit: Meta signs these exact 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. Free-form text inside the 24-hour window, an approved template outside it
export async function notifyShipped(bot, chatId, order) {
try {
await bot.send(chatId, `🚚 Order ${order} has shipped!`)
return 'text'
} catch (err) {
if (!(err instanceof OutsideSessionWindowError)) throw err
await bot.send(chatId, {
kind: 'template',
name: 'order_shipped',
language: 'en',
components: [{ type: 'body', parameters: [{ type: 'text', text: order }] }],
})
return 'template'
}
}
// 4. The HTTP server: Meta's handshake, signed webhooks, an internal notify route and a 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') {
// Subscription handshake: echo hub.challenge when hub.verify_token matches
const challenge = provider.verifyChallenge(Object.fromEntries(url.searchParams))
return challenge === null ? reply(403) : reply(200, challenge)
}
if (url.pathname === '/webhook' && req.method === 'POST') {
// Raw bytes + headers: ingest() checks X-Hub-Signature-256 and returns the status to answer
const raw = await readRawBody(req)
return reply(provider.ingest(raw, req.headers))
}
if (url.pathname === '/notify/shipped' && req.method === 'POST') {
// Called by your backend, never by Meta: protected by a shared token
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. Production entry point: `node server.js` behind HTTPS (a reverse proxy or a tunnel)
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() // stop accepting webhooks; Meta retries what it could not deliver
await bot.stop({ drain: true, timeout: '10s' })
})
}