commit 995f21128813aa5caa5dbeb749c151bf4159299a Author: Ecio Date: Sat Jun 20 16:49:24 2026 -0300 feat: projeto whatsapp-api-oficial — wrapper Meta Cloud API API Node.js/Express que encapsula a Meta Cloud API (Graph API v21.0): - POST /account/configure/:accountId — salva credenciais WABA no SQLite - GET /account/status/:accountId — verifica credenciais via Meta API - POST /message/send/:accountId — envia texto, template ou mídia - GET /templates/:accountId — lista templates aprovados da WABA - GET /webhooks/meta — verificação de token (Meta handshake) - POST /webhooks/meta — recebe eventos Meta e repassa ao sys - Dockerfile + docker-compose.yml com Traefik (apimsgoficial.neuralsys.com.br) - Swagger em /api-docs com autenticação por x-api-key Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cbde846 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +## Aplicação +PORT=3001 +API_KEY=sua_api_key_secreta_aqui + +## Webhook: URL do whatsapp-sys que receberá os eventos da Meta +BASE_WEBHOOK_URL=https://comunicacao.neuralsys.com.br/webhooks/whatsapp-oficial + +## Token de verificação que a Meta usa para confirmar seu webhook +## (configure o mesmo valor no Meta for Developers > Webhooks > Verify Token) +META_WEBHOOK_VERIFY_TOKEN=neuralsys_meta_verify_token + +## Swagger +ENABLE_SWAGGER_ENDPOINT=TRUE + +## Banco de dados local (credenciais WABA por account) +DB_PATH=./data/accounts.db + +## Rate limiting +RATE_LIMIT_MAX=1000 +RATE_LIMIT_WINDOW_MS=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da53f1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +data/ +swagger.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c8c26f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine + +WORKDIR /usr/src/app + +ENV NODE_ENV=production + +RUN apk add --no-cache tini ca-certificates + +COPY package*.json ./ + +# better-sqlite3 precisa de build tools (python/make/g++) para compilar no Alpine. +RUN apk add --no-cache --virtual .build-deps python3 make g++ && \ + npm install --omit=dev --no-audit --no-fund && \ + apk del .build-deps + +COPY . . + +EXPOSE 3001 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..82e174a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' + +services: + api_zdg_oficial: + image: chrishubert/whatsapp-oficial-api:latest + build: + context: . + dockerfile: Dockerfile + networks: + - comunidadezdg + volumes: + - api_zdg_oficial_data:/usr/src/app/data + environment: + - PORT=3001 + - API_KEY=${API_KEY} + - BASE_WEBHOOK_URL=${BASE_WEBHOOK_URL} + - META_WEBHOOK_VERIFY_TOKEN=${META_WEBHOOK_VERIFY_TOKEN} + - ENABLE_SWAGGER_ENDPOINT=TRUE + - DB_PATH=/usr/src/app/data/accounts.db + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.role == manager + resources: + limits: + cpus: "0.5" + memory: 256M + labels: + - traefik.enable=true + - traefik.http.routers.api_zdg_oficial.rule=Host(`apimsgoficial.neuralsys.com.br`) + - traefik.http.routers.api_zdg_oficial.entrypoints=websecure + - traefik.http.routers.api_zdg_oficial.tls.certresolver=letsencryptresolver + - traefik.http.routers.api_zdg_oficial.priority=1 + - traefik.http.routers.api_zdg_oficial.service=api_zdg_oficial + - traefik.http.services.api_zdg_oficial.loadbalancer.server.port=3001 + - traefik.http.services.api_zdg_oficial.loadbalancer.passHostHeader=true + +volumes: + api_zdg_oficial_data: + external: true + name: api_zdg_oficial_data + +networks: + comunidadezdg: + name: comunidadezdg + external: true diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d482b3 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "whatsapp-oficial-api", + "version": "1.0.0", + "description": "REST API wrapper para WhatsApp Cloud API (Meta) — CoreGuard", + "main": "server.js", + "scripts": { + "start": "node server.js", + "swagger": "node swagger.js" + }, + "dependencies": { + "axios": "^1.14.0", + "better-sqlite3": "^11.3.0", + "dotenv": "^16.3.1", + "express": "^4.21.2", + "express-rate-limit": "^6.10.0", + "swagger-ui-express": "^4.6.3" + }, + "devDependencies": { + "swagger-autogen": "^2.23.1" + }, + "keywords": ["whatsapp", "cloud-api", "meta", "api", "rest", "express"], + "author": "CoreGuard / Neuralsys", + "license": "MIT", + "private": true, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..80760cb --- /dev/null +++ b/server.js @@ -0,0 +1,8 @@ +require('dotenv').config({ override: true }) +const app = require('./src/app') + +const port = process.env.PORT || 3001 + +app.listen(port, () => { + console.log(`WhatsApp Oficial API rodando na porta ${port}`) +}) diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..cdd4396 --- /dev/null +++ b/src/app.js @@ -0,0 +1,12 @@ +const express = require('express') +const bodyParser = require('body-parser') +const { routes } = require('./routes') + +const app = express() + +app.disable('x-powered-by') +app.use(bodyParser.json({ limit: '20mb' })) +app.use(bodyParser.urlencoded({ limit: '20mb', extended: true })) +app.use('/', routes) + +module.exports = app diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a37a30b --- /dev/null +++ b/src/config.js @@ -0,0 +1,10 @@ +module.exports = { + apiKey: process.env.API_KEY || '', + port: parseInt(process.env.PORT || '3001', 10), + baseWebhookUrl: process.env.BASE_WEBHOOK_URL || '', + metaWebhookVerifyToken: process.env.META_WEBHOOK_VERIFY_TOKEN || 'neuralsys_meta_verify_token', + enableSwagger: process.env.ENABLE_SWAGGER_ENDPOINT !== 'FALSE', + rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '1000', 10), + rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '1000', 10), + dbPath: process.env.DB_PATH || './data/accounts.db', +} diff --git a/src/controllers/accountController.js b/src/controllers/accountController.js new file mode 100644 index 0000000..02cc3db --- /dev/null +++ b/src/controllers/accountController.js @@ -0,0 +1,73 @@ +const { upsertAccount, deleteAccount } = require('../storage') +const meta = require('../services/metaApiService') + +exports.configure = async (req, res) => { + /* #swagger.tags = ['Account'] + #swagger.summary = 'Configura credenciais WABA de uma conta' + #swagger.parameters['accountId'] = { in: 'path', required: true, schema: { type: 'string' } } + #swagger.requestBody = { + required: true, + content: { 'application/json': { schema: { + type: 'object', + required: ['phone_number_id', 'access_token'], + properties: { + phone_number_id: { type: 'string', example: '123456789012345' }, + access_token: { type: 'string', example: 'EAAxxxxx...' }, + waba_id: { type: 'string', example: '987654321098765' } + } + }}} + } + */ + const { accountId } = req.params + const { phone_number_id, access_token, waba_id } = req.body + + if (!phone_number_id || !access_token) { + return res.status(400).json({ + success: false, + message: 'Os campos phone_number_id e access_token são obrigatórios.', + }) + } + + try { + upsertAccount(accountId, { phone_number_id, access_token, waba_id }) + return res.json({ success: true, message: `Conta "${accountId}" configurada com sucesso.` }) + } catch (err) { + return res.status(500).json({ success: false, message: err.message }) + } +} + +exports.status = async (req, res) => { + /* #swagger.tags = ['Account'] + #swagger.summary = 'Verifica se as credenciais da conta são válidas (chama Meta API)' + #swagger.parameters['accountId'] = { in: 'path', required: true, schema: { type: 'string' } } + */ + const account = req.account + + try { + const data = await meta.verifyCredentials(account.phone_number_id, account.access_token) + return res.json({ + success: true, + state: 'CONNECTED', + display_phone_number: data.display_phone_number, + verified_name: data.verified_name, + quality_rating: data.quality_rating, + }) + } catch (err) { + const status = err.response?.status + const detail = err.response?.data?.error?.message || err.message + if (status === 401 || status === 403) { + return res.json({ success: false, state: 'INVALID_TOKEN', message: detail }) + } + return res.json({ success: false, state: 'ERROR', message: detail }) + } +} + +exports.remove = (req, res) => { + /* #swagger.tags = ['Account'] + #swagger.summary = 'Remove as credenciais de uma conta' + #swagger.parameters['accountId'] = { in: 'path', required: true, schema: { type: 'string' } } + */ + const { accountId } = req.params + deleteAccount(accountId) + return res.json({ success: true, message: `Conta "${accountId}" removida.` }) +} diff --git a/src/controllers/healthController.js b/src/controllers/healthController.js new file mode 100644 index 0000000..eda2952 --- /dev/null +++ b/src/controllers/healthController.js @@ -0,0 +1,7 @@ +exports.ping = (req, res) => { + res.json({ success: true, message: 'pong' }) +} + +exports.health = (req, res) => { + res.json({ success: true, status: 'online', service: 'whatsapp-oficial-api' }) +} diff --git a/src/controllers/messageController.js b/src/controllers/messageController.js new file mode 100644 index 0000000..b064a72 --- /dev/null +++ b/src/controllers/messageController.js @@ -0,0 +1,83 @@ +const meta = require('../services/metaApiService') + +exports.sendMessage = async (req, res) => { + /* #swagger.tags = ['Message'] + #swagger.summary = 'Envia uma mensagem (texto livre, template ou mídia)' + #swagger.parameters['accountId'] = { in: 'path', required: true, schema: { type: 'string' } } + #swagger.requestBody = { + required: true, + content: { 'application/json': { schema: { + type: 'object', + required: ['to'], + properties: { + to: { type: 'string', description: 'Número do destinatário (com DDD e código do país)', example: '5511999998888' }, + type: { type: 'string', enum: ['text', 'template', 'media'], default: 'text' }, + text: { type: 'string', description: 'Conteúdo da mensagem (type=text)', example: 'Olá, {{nome}}!' }, + template: { type: 'object', description: 'Dados do template (type=template)', properties: { + name: { type: 'string', example: 'meu_template' }, + language: { type: 'string', example: 'pt_BR' }, + components: { type: 'array', items: { type: 'object' } } + }}, + media_url: { type: 'string', description: 'URL pública da mídia (type=media)' }, + media_type: { type: 'string', description: 'Tipo da mídia (image|document|video|audio)', example: 'image' }, + caption: { type: 'string', description: 'Legenda da mídia (opcional)' } + } + }}} + } + */ + const account = req.account + const { to, type, text, template, media_url, media_type, caption } = req.body + + if (!to) { + return res.status(400).json({ success: false, message: 'O campo "to" é obrigatório.' }) + } + + try { + let result + + if (type === 'template') { + if (!template?.name) { + return res.status(400).json({ + success: false, + message: 'template.name é obrigatório quando type=template.', + }) + } + result = await meta.sendTemplateMessage( + account.phone_number_id, + account.access_token, + to, + template.name, + template.language || 'pt_BR', + template.components || [] + ) + } else if (type === 'media') { + if (!media_url || !media_type) { + return res.status(400).json({ + success: false, + message: 'media_url e media_type são obrigatórios quando type=media.', + }) + } + result = await meta.sendMediaMessage( + account.phone_number_id, + account.access_token, + to, + media_type, + media_url, + caption + ) + } else { + const body = text || req.body.content + if (!body) { + return res.status(400).json({ success: false, message: 'O campo "text" é obrigatório.' }) + } + result = await meta.sendTextMessage(account.phone_number_id, account.access_token, to, body) + } + + const messageId = result?.messages?.[0]?.id || null + return res.json({ success: true, message: { id: messageId } }) + } catch (err) { + const detail = err.response?.data?.error?.message || err.message + const code = err.response?.data?.error?.code + return res.status(500).json({ success: false, message: detail, error_code: code }) + } +} diff --git a/src/controllers/templateController.js b/src/controllers/templateController.js new file mode 100644 index 0000000..8cef2de --- /dev/null +++ b/src/controllers/templateController.js @@ -0,0 +1,24 @@ +const meta = require('../services/metaApiService') + +exports.list = async (req, res) => { + /* #swagger.tags = ['Templates'] + #swagger.summary = 'Lista os templates aprovados da conta WABA' + #swagger.parameters['accountId'] = { in: 'path', required: true, schema: { type: 'string' } } + */ + const account = req.account + + if (!account.waba_id) { + return res.status(400).json({ + success: false, + message: 'waba_id não configurado para esta conta. Inclua o waba_id ao configurar via POST /account/configure.', + }) + } + + try { + const data = await meta.getTemplates(account.waba_id, account.access_token) + return res.json({ success: true, templates: data.data || [], paging: data.paging }) + } catch (err) { + const detail = err.response?.data?.error?.message || err.message + return res.status(500).json({ success: false, message: detail }) + } +} diff --git a/src/controllers/webhookController.js b/src/controllers/webhookController.js new file mode 100644 index 0000000..3d06452 --- /dev/null +++ b/src/controllers/webhookController.js @@ -0,0 +1,33 @@ +const axios = require('axios') +const { metaWebhookVerifyToken, baseWebhookUrl } = require('../config') + +exports.verify = (req, res) => { + /* #swagger.ignore = true */ + const mode = req.query['hub.mode'] + const token = req.query['hub.verify_token'] + const challenge = req.query['hub.challenge'] + + if (mode === 'subscribe' && token === metaWebhookVerifyToken) { + console.log('Webhook Meta verificado com sucesso.') + return res.status(200).send(challenge) + } + console.warn('Verificação de webhook Meta falhou — token inválido.') + return res.status(403).json({ success: false, message: 'Token de verificação inválido.' }) +} + +exports.receive = async (req, res) => { + /* #swagger.ignore = true */ + // Responde imediatamente — Meta considera timeout em 20s + res.status(200).json({ success: true }) + + if (!baseWebhookUrl) return + + try { + await axios.post(baseWebhookUrl, req.body, { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000, + }) + } catch (err) { + console.error('Erro ao repassar webhook Meta para o whatsapp-sys:', err.message) + } +} diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 0000000..5eacd87 --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,36 @@ +const rateLimit = require('express-rate-limit') +const { apiKey, rateLimitMax, rateLimitWindowMs } = require('./config') +const { getAccount } = require('./storage') + +const rateLimiter = rateLimit({ + windowMs: rateLimitWindowMs, + max: rateLimitMax, + handler: (req, res) => { + res.status(429).json({ success: false, message: 'Muitas requisições — tente novamente em instantes.' }) + }, +}) + +function apikey (req, res, next) { + if (!apiKey) return next() + const provided = req.headers['x-api-key'] + if (provided && provided === apiKey) return next() + return res.status(403).json({ success: false, message: 'API Key inválida.' }) +} + +function accountExists (req, res, next) { + const { accountId } = req.params + if (!accountId) { + return res.status(400).json({ success: false, message: 'accountId não fornecido.' }) + } + const account = getAccount(accountId) + if (!account) { + return res.status(404).json({ + success: false, + message: `Conta "${accountId}" não encontrada. Configure as credenciais primeiro via POST /account/configure/${accountId}.`, + }) + } + req.account = account + return next() +} + +module.exports = { rateLimiter, apikey, accountExists } diff --git a/src/routes.js b/src/routes.js new file mode 100644 index 0000000..4864198 --- /dev/null +++ b/src/routes.js @@ -0,0 +1,139 @@ +const express = require('express') +const routes = express.Router() +const middleware = require('./middleware') +const healthController = require('./controllers/healthController') +const accountController = require('./controllers/accountController') +const messageController = require('./controllers/messageController') +const templateController = require('./controllers/templateController') +const webhookController = require('./controllers/webhookController') +const { enableSwagger } = require('./config') + +// Health (sem auth) +routes.get('/ping', healthController.ping) +routes.get('/health', healthController.health) + +// Webhook Meta — sem autenticação (Meta não envia x-api-key) +routes.get('/webhooks/meta', webhookController.verify) +routes.post('/webhooks/meta', webhookController.receive) + +// Valida API key para o Swagger (retorna 200 se ok) +routes.get('/validateApiKey', middleware.apikey, (req, res) => { + /* #swagger.ignore = true */ + res.json({ success: true }) +}) + +// Account management +const accountRouter = express.Router() +accountRouter.use(middleware.rateLimiter) +accountRouter.use(middleware.apikey) +routes.use('/account', accountRouter) + +accountRouter.post('/configure/:accountId', accountController.configure) +accountRouter.get('/status/:accountId', [middleware.accountExists], accountController.status) +accountRouter.delete('/remove/:accountId', accountController.remove) + +// Messages +const messageRouter = express.Router() +messageRouter.use(middleware.rateLimiter) +messageRouter.use(middleware.apikey) +routes.use('/message', messageRouter) + +messageRouter.post('/send/:accountId', [middleware.accountExists], messageController.sendMessage) + +// Templates +const templateRouter = express.Router() +templateRouter.use(middleware.rateLimiter) +templateRouter.use(middleware.apikey) +routes.use('/templates', templateRouter) + +templateRouter.get('/:accountId', [middleware.accountExists], templateController.list) + +// Swagger +if (enableSwagger) { + const swaggerUi = require('swagger-ui-express') + const path = require('path') + + let swaggerDocument + try { + swaggerDocument = require('../swagger.json') + } catch { + swaggerDocument = { + openapi: '3.0.0', + info: { title: 'WhatsApp Oficial API', version: '1.0.0', description: 'Execute swagger.js para gerar a documentação completa.' }, + paths: {}, + } + } + + const customCss = ` + .swagger-ui .topbar { background: #1a1a2e; } + .swagger-ui .topbar .topbar-wrapper .link { display: grid; grid-template-columns: 1fr; align-items: center; } + .swagger-ui .topbar .topbar-wrapper .link img { display: none; } + .swagger-ui .topbar .topbar-wrapper .link span { display: none; } + .swagger-ui .topbar .topbar-wrapper .link::before { + content: "CoreGuard • WhatsAPI Oficial"; + color: #fff; font-family: sans-serif; font-weight: 700; font-size: 20px; padding: 8px 0; + } + .swagger-ui section.models, .swagger-ui .models { display: none !important; } + body:not(.neuralsys-authorized) .swagger-ui .opblock-tag { pointer-events: none; opacity: 0.55; } + body:not(.neuralsys-authorized) .swagger-ui .opblock-tag-section .no-margin { display: none !important; } + .neuralsys-footer { + background: #000; color: #fff; padding: 16px 32px; font-size: 13px; + display: flex; justify-content: space-between; font-family: sans-serif; margin-top: 40px; + } + ` + + const customJsStr = ` + (function () { + var authorized = false, lastKey = null, validating = false; + function currentKey() { + try { + var auth = window.ui && window.ui.authSelectors.authorized(); + if (!auth) return null; + var js = auth.toJS ? auth.toJS() : auth; + if (js && js.apiKeyAuth && js.apiKeyAuth.value) return js.apiKeyAuth.value; + } catch(e) {} + return null; + } + function setAuthorized(ok) { + authorized = ok; + document.body.classList.toggle('neuralsys-authorized', ok); + if (!ok) { + document.querySelectorAll('.opblock-tag-section.is-open').forEach(function(s) { + var h = s.querySelector('.opblock-tag'); if (h) h.click(); + }); + } + } + function validateKey(key) { + if (validating) return; validating = true; + fetch('/validateApiKey', { headers: { 'x-api-key': key } }) + .then(function(r) { setAuthorized(r.status === 200); }) + .catch(function() { setAuthorized(false); }) + .then(function() { validating = false; }); + } + function pollAuth() { + var key = currentKey(); + if (key !== lastKey) { lastKey = key; if (key) validateKey(key); else setAuthorized(false); } + } + function installFooter() { + if (document.querySelector('.neuralsys-footer')) return; + var f = document.createElement('footer'); + f.className = 'neuralsys-footer'; + f.innerHTML = 'Copyright © ' + new Date().getFullYear() + ' — CoreGuardcontato@neuralsys.com.br'; + document.body.appendChild(f); + } + function boot() { setAuthorized(false); setInterval(pollAuth, 500); installFooter(); } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function() { setTimeout(boot, 400); }); + else setTimeout(boot, 400); + })(); + ` + + routes.use('/api-docs', swaggerUi.serve) + routes.get('/api-docs', swaggerUi.setup(swaggerDocument, { + customCss, + customJsStr, + customSiteTitle: 'WhatsApp Oficial API — Documentação', + swaggerOptions: { docExpansion: 'none', persistAuthorization: true }, + })) +} + +module.exports = { routes } diff --git a/src/services/metaApiService.js b/src/services/metaApiService.js new file mode 100644 index 0000000..04a55d0 --- /dev/null +++ b/src/services/metaApiService.js @@ -0,0 +1,91 @@ +const axios = require('axios') + +const META_VERSION = 'v21.0' +const BASE = `https://graph.facebook.com/${META_VERSION}` + +function buildClient (accessToken) { + return axios.create({ + baseURL: BASE, + timeout: 30000, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) +} + +function sanitizePhone (phone) { + let digits = String(phone).replace(/\D+/g, '') + if (!digits.startsWith('55') && digits.length <= 11) { + digits = '55' + digits + } + return digits +} + +async function sendTextMessage (phoneNumberId, accessToken, to, text) { + const client = buildClient(accessToken) + const { data } = await client.post(`/${phoneNumberId}/messages`, { + messaging_product: 'whatsapp', + to: sanitizePhone(to), + type: 'text', + text: { body: text, preview_url: false }, + }) + return data +} + +async function sendTemplateMessage (phoneNumberId, accessToken, to, templateName, languageCode, components) { + const client = buildClient(accessToken) + const { data } = await client.post(`/${phoneNumberId}/messages`, { + messaging_product: 'whatsapp', + to: sanitizePhone(to), + type: 'template', + template: { + name: templateName, + language: { code: languageCode || 'pt_BR' }, + components: components || [], + }, + }) + return data +} + +async function sendMediaMessage (phoneNumberId, accessToken, to, mediaType, mediaUrl, caption) { + const client = buildClient(accessToken) + const mediaObj = { link: mediaUrl } + if (caption) mediaObj.caption = caption + + const { data } = await client.post(`/${phoneNumberId}/messages`, { + messaging_product: 'whatsapp', + to: sanitizePhone(to), + type: mediaType, + [mediaType]: mediaObj, + }) + return data +} + +async function getTemplates (wabaId, accessToken) { + const client = buildClient(accessToken) + const { data } = await client.get(`/${wabaId}/message_templates`, { + params: { + limit: 100, + fields: 'name,status,language,category,components', + }, + }) + return data +} + +async function verifyCredentials (phoneNumberId, accessToken) { + const client = buildClient(accessToken) + const { data } = await client.get(`/${phoneNumberId}`, { + params: { fields: 'display_phone_number,verified_name,quality_rating' }, + timeout: 10000, + }) + return data +} + +module.exports = { + sendTextMessage, + sendTemplateMessage, + sendMediaMessage, + getTemplates, + verifyCredentials, +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..152be02 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,42 @@ +const Database = require('better-sqlite3') +const path = require('path') +const fs = require('fs') +const { dbPath } = require('./config') + +const dir = path.dirname(path.resolve(dbPath)) +if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + +const db = new Database(path.resolve(dbPath)) + +db.exec(` + CREATE TABLE IF NOT EXISTS accounts ( + account_id TEXT PRIMARY KEY, + phone_number_id TEXT NOT NULL, + access_token TEXT NOT NULL, + waba_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`) + +function upsertAccount (accountId, data) { + db.prepare(` + INSERT INTO accounts (account_id, phone_number_id, access_token, waba_id, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(account_id) DO UPDATE SET + phone_number_id = excluded.phone_number_id, + access_token = excluded.access_token, + waba_id = excluded.waba_id, + updated_at = excluded.updated_at + `).run(accountId, data.phone_number_id, data.access_token, data.waba_id || null) +} + +function getAccount (accountId) { + return db.prepare('SELECT * FROM accounts WHERE account_id = ?').get(accountId) || null +} + +function deleteAccount (accountId) { + return db.prepare('DELETE FROM accounts WHERE account_id = ?').run(accountId) +} + +module.exports = { upsertAccount, getAccount, deleteAccount } diff --git a/swagger.js b/swagger.js new file mode 100644 index 0000000..cd8c661 --- /dev/null +++ b/swagger.js @@ -0,0 +1,57 @@ +const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0', autoBody: false }) + +const outputFile = './swagger.json' +const endpointsFiles = ['./src/routes.js'] + +const doc = { + info: { + title: 'WhatsApp Oficial API', + description: 'API REST para envio de mensagens via WhatsApp Cloud API (Meta). Suporta texto livre (janela 24h), templates aprovados e mídia.', + version: '1.0.0', + }, + host: '', + securityDefinitions: { + apiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'x-api-key', + }, + }, + produces: ['application/json'], + tags: [ + { + name: 'Account', + description: 'Configuração e verificação de credenciais WABA (phone_number_id, access_token)', + }, + { + name: 'Message', + description: 'Envio de mensagens — texto livre (janela 24h), template aprovado ou mídia', + }, + { + name: 'Templates', + description: 'Listagem de templates aprovados na conta WABA', + }, + ], + definitions: { + ConfigureAccountRequest: { + phone_number_id: '123456789012345', + access_token: 'EAAxxxxx...', + waba_id: '987654321098765', + }, + SendMessageRequest: { + to: '5511999998888', + type: 'text', + text: 'Olá, mundo!', + }, + SuccessResponse: { + success: true, + message: 'Operação realizada com sucesso.', + }, + ErrorResponse: { + success: false, + message: 'Descrição do erro.', + }, + }, +} + +swaggerAutogen(outputFile, endpointsFiles, doc)