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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
swagger.json
|
||||
+21
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
+12
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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.` })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
+139
@@ -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 = '<span>Copyright © ' + new Date().getFullYear() + ' — CoreGuard</span><span>contato@neuralsys.com.br</span>';
|
||||
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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
+57
@@ -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)
|
||||
Reference in New Issue
Block a user