feat: inicializar repositorio oficial do clube67.com
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
PORT=8787
|
||||
API_KEY=ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ
|
||||
AUTH_FOLDER=auth
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
auth/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,115 @@
|
||||
# rscara
|
||||
|
||||
API em **TypeScript** para múltiplas contas WhatsApp usando [InfiniteAPI](https://github.com/rsalcara/InfiniteAPI) (fork do Baileys), com geração de QR code, gerenciamento de conexões e disparo de componentes especiais (botões, listas, carrossel, enquete). Inclui interface web para conectar números e enviar mensagens.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js >= 20
|
||||
- npm ou yarn
|
||||
|
||||
## Instalação
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configuração
|
||||
|
||||
Copie o arquivo de exemplo e ajuste:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Variáveis em `.env`:
|
||||
|
||||
| Variável | Descrição | Padrão |
|
||||
|-------------|------------------------|--------|
|
||||
| `PORT` | Porta do servidor | 8787 |
|
||||
| `API_KEY` | Chave para header `x-api-key` (deixe vazio para desativar) | - |
|
||||
| `AUTH_FOLDER` | Pasta onde salvar credenciais por instância | auth |
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build e produção
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Interface web
|
||||
|
||||
Com o servidor rodando, acesse **http://localhost:8787**. A página inicial e os arquivos estáticos não exigem API key; apenas as rotas `/v1/*` usam o header `x-api-key` quando `API_KEY` está definida.
|
||||
|
||||
- **Conexões**: listar conexões salvas (clique em Conectar), conectar por nome, ver QR (atualizado automaticamente), listar instâncias ativas com ações (Desconectar, Novo QR, Deletar). Status e QR são atualizados a cada 2 segundos enquanto a aba estiver aberta.
|
||||
- **Disparos**: escolher instância, colar **lista de mailing** (um número por linha, com DDI), definir **intervalo mínimo e máximo** (em segundos) entre cada envio, escolher tipo de mensagem e preencher os campos (formulários dinâmicos com adicionar/remover). O envio é feito em lote com espera aleatória entre os números.
|
||||
|
||||
## Endpoints
|
||||
|
||||
O header **`x-api-key`** é obrigatório apenas nas rotas `/v1/*` quando `API_KEY` está definida. A rota `/health` e a interface em `/` não exigem key.
|
||||
|
||||
### Instâncias
|
||||
|
||||
| Método | Rota | Descrição |
|
||||
|--------|------|-----------|
|
||||
| POST | `/v1/instances` | Cria/conecta instância e retorna QR (body: `{ "instance": "main" }`) |
|
||||
| GET | `/v1/instances` | Lista instâncias ativas e nomes das conexões salvas (`saved`) |
|
||||
| GET | `/v1/instances/saved` | Lista apenas nomes das conexões salvas (pastas em `auth/`) |
|
||||
| GET | `/v1/instances/:name` | Status de uma instância |
|
||||
| GET | `/v1/instances/:name/qr` | QR em base64 (quando status = qr) |
|
||||
| POST | `/v1/instances/:name/disconnect` | Desconecta e remove da memória (credenciais ficam em disco) |
|
||||
| POST | `/v1/instances/:name/logout` | Logout e apaga sessão em disco (próxima conexão gera novo QR) |
|
||||
| DELETE | `/v1/instances/:name` | Remove instância da memória (fecha socket) |
|
||||
|
||||
### Mensagens (componentes especiais)
|
||||
|
||||
| Método | Rota | Descrição |
|
||||
|--------|------|-----------|
|
||||
| POST | `/v1/messages/send_menu` | Menu texto (opções numeradas) |
|
||||
| POST | `/v1/messages/send_buttons_helpers` | Botões quick reply (até 3) |
|
||||
| POST | `/v1/messages/send_interactive_helpers` | Botões CTA (URL, Copiar, Ligar) |
|
||||
| POST | `/v1/messages/send_list_helpers` | Lista dropdown (nativeList) |
|
||||
| POST | `/v1/messages/send_poll` | Enquete |
|
||||
| POST | `/v1/messages/send_carousel_helpers` | Carrossel com cards (imagem + botões) |
|
||||
|
||||
Em todos os endpoints de mensagens o body deve incluir **`instance`** (nome da instância) e **`to`** (número no formato `5511999999999`).
|
||||
|
||||
## Exemplo rápido
|
||||
|
||||
1. Subir a API e abrir a interface em http://localhost:8787 (ou criar instância via API):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/v1/instances \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SUA_API_KEY" \
|
||||
-d '{"instance": "main"}'
|
||||
```
|
||||
|
||||
2. A resposta pode trazer `qr` em base64. Exiba a imagem ou use `GET /v1/instances/main/qr` até conectar. Na interface, o QR e o status são atualizados automaticamente.
|
||||
|
||||
3. Enviar botões:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/v1/messages/send_buttons_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SUA_API_KEY" \
|
||||
-d '{
|
||||
"instance": "main",
|
||||
"to": "553598828503",
|
||||
"text": "Como posso ajudar?",
|
||||
"footer": "Atendimento 24h",
|
||||
"buttons": [
|
||||
{"id": "vendas", "text": "Fazer Pedido"},
|
||||
{"id": "suporte", "text": "Suporte"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Licença
|
||||
|
||||
MIT.
|
||||
+2922
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "rsalcara",
|
||||
"version": "1.0.0",
|
||||
"description": "API Node.js para múltiplas contas WhatsApp com InfiniteAPI (Baileys) e disparo de componentes especiais",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"baileys": "github:rsalcara/InfiniteAPI"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"tsx": "^4.19.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/qrcode": "^1.5.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
(function () {
|
||||
const API = '';
|
||||
function headers() {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
const key = document.getElementById('apiKey').value.trim() || localStorage.getItem('rscara_api_key');
|
||||
if (key) {
|
||||
h['x-api-key'] = key;
|
||||
localStorage.setItem('rscara_api_key', key);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function show(el, visible) {
|
||||
el.classList.toggle('hidden', !visible);
|
||||
}
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(tab.getAttribute('data-tab')).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Tipo de disparo
|
||||
const dispatchForms = {
|
||||
menu: document.getElementById('formMenu'),
|
||||
buttons: document.getElementById('formButtons'),
|
||||
interactive: document.getElementById('formInteractive'),
|
||||
list: document.getElementById('formList'),
|
||||
poll: document.getElementById('formPoll'),
|
||||
carousel: document.getElementById('formCarousel'),
|
||||
};
|
||||
document.getElementById('dispatchType').addEventListener('change', () => {
|
||||
const type = document.getElementById('dispatchType').value;
|
||||
Object.values(dispatchForms).forEach((f) => f && f.classList.add('hidden'));
|
||||
if (dispatchForms[type]) dispatchForms[type].classList.remove('hidden');
|
||||
if (type === 'list' && !document.getElementById('listSectionsList').querySelector('.block-section')) addListSection();
|
||||
if (type === 'carousel' && !document.getElementById('carouselCardsList').querySelector('.block-section')) addCarouselCard();
|
||||
});
|
||||
dispatchForms.menu.classList.remove('hidden');
|
||||
|
||||
// Instância que estamos conectando (para atualizar QR e status em tempo real)
|
||||
let connectingInstanceName = null;
|
||||
|
||||
// --- Conexões: listar salvas e conectar ao clicar ---
|
||||
function renderSavedList(saved) {
|
||||
const ul = document.getElementById('savedList');
|
||||
if (!saved || saved.length === 0) {
|
||||
ul.innerHTML = '<li class="text-muted">Nenhuma conexão salva. Conecte uma vez por nome e ela aparecerá aqui.</li>';
|
||||
return;
|
||||
}
|
||||
ul.innerHTML = saved
|
||||
.map(
|
||||
(name) =>
|
||||
`<li class="saved-item-row">
|
||||
<span class="instance-name">${name}</span>
|
||||
<div class="saved-item-actions">
|
||||
<button type="button" class="btn btn-primary btn-connect-saved" data-connect-name="${name}">Conectar</button>
|
||||
<button type="button" class="btn btn-small btn-danger" data-delete-saved-name="${name}" title="Excluir sessão salva (será necessário novo QR para conectar)">Deletar</button>
|
||||
</div>
|
||||
</li>`
|
||||
)
|
||||
.join('');
|
||||
ul.querySelectorAll('[data-connect-name]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const name = btn.getAttribute('data-connect-name');
|
||||
document.getElementById('connectInstanceSelect').value = name;
|
||||
document.getElementById('instanceName').value = name;
|
||||
connectNewNameRow.style.display = 'none';
|
||||
doConnect(name);
|
||||
});
|
||||
});
|
||||
ul.querySelectorAll('[data-delete-saved-name]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = btn.getAttribute('data-delete-saved-name');
|
||||
if (!name || !confirm(`Excluir a conexão salva "${name}"? Será necessário escanear o QR de novo para conectar.`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/v1/instances/${encodeURIComponent(name)}/logout`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) refreshInstanceList();
|
||||
} catch (_) {
|
||||
refreshInstanceList();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function doConnect(name) {
|
||||
connectingInstanceName = name;
|
||||
const statusEl = document.getElementById('connectStatus');
|
||||
const qrContainer = document.getElementById('qrContainer');
|
||||
const qrImage = document.getElementById('qrImage');
|
||||
show(statusEl, false);
|
||||
try {
|
||||
const res = await fetch(`${API}/v1/instances`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ instance: name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
statusEl.textContent = data.error || 'Erro ao conectar';
|
||||
statusEl.className = 'status error';
|
||||
show(statusEl, true);
|
||||
connectingInstanceName = null;
|
||||
return;
|
||||
}
|
||||
if (data.qr) {
|
||||
qrImage.src = data.qr;
|
||||
show(qrContainer, true);
|
||||
statusEl.textContent = 'Escaneie o QR no WhatsApp.';
|
||||
statusEl.className = 'status success';
|
||||
} else if (data.status === 'connected') {
|
||||
show(qrContainer, false);
|
||||
statusEl.textContent = 'Conectado.';
|
||||
statusEl.className = 'status success';
|
||||
connectingInstanceName = null;
|
||||
} else {
|
||||
statusEl.textContent = 'Aguardando QR...';
|
||||
statusEl.className = 'status';
|
||||
show(qrContainer, false);
|
||||
}
|
||||
show(statusEl, true);
|
||||
refreshInstanceList();
|
||||
} catch (e) {
|
||||
statusEl.textContent = e.message || 'Erro de rede';
|
||||
statusEl.className = 'status error';
|
||||
show(statusEl, true);
|
||||
connectingInstanceName = null;
|
||||
}
|
||||
}
|
||||
|
||||
const connectInstanceSelect = document.getElementById('connectInstanceSelect');
|
||||
const connectNewNameRow = document.getElementById('connectNewNameRow');
|
||||
|
||||
connectInstanceSelect.addEventListener('change', () => {
|
||||
const isNew = connectInstanceSelect.value === '';
|
||||
connectNewNameRow.style.display = isNew ? '' : 'none';
|
||||
});
|
||||
|
||||
document.getElementById('btnConnect').addEventListener('click', () => {
|
||||
const selected = connectInstanceSelect.value;
|
||||
const name = selected ? selected : (document.getElementById('instanceName').value.trim() || 'main');
|
||||
doConnect(name);
|
||||
});
|
||||
|
||||
async function fetchQrAndShow(name, qrImage, qrContainer) {
|
||||
try {
|
||||
const res = await fetch(`${API}/v1/instances/${encodeURIComponent(name)}/qr`, { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (data.qr) {
|
||||
qrImage.src = data.qr;
|
||||
show(qrContainer, true);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderInstanceList(list) {
|
||||
const ul = document.getElementById('instanceList');
|
||||
if (!list.length) {
|
||||
ul.innerHTML = '<li>Nenhuma instância ativa.</li>';
|
||||
return;
|
||||
}
|
||||
ul.innerHTML = list
|
||||
.map(
|
||||
(i) =>
|
||||
`<li class="instance-row">
|
||||
<span class="instance-name">${i.instance}</span>
|
||||
<span class="badge ${i.status}">${i.status}</span>
|
||||
<div class="instance-actions">
|
||||
${i.status === 'qr' ? `<button type="button" class="btn btn-small btn-ghost" data-action="qr" data-name="${i.instance}">Ver QR</button>` : ''}
|
||||
${i.status === 'connected' ? `<button type="button" class="btn btn-small btn-ghost" data-action="disconnect" data-name="${i.instance}">Desconectar</button>` : ''}
|
||||
<button type="button" class="btn btn-small btn-ghost" data-action="logout" data-name="${i.instance}" title="Novo QR na próxima conexão">Novo QR</button>
|
||||
<button type="button" class="btn btn-small btn-danger" data-action="delete" data-name="${i.instance}">Deletar</button>
|
||||
</div>
|
||||
</li>`
|
||||
)
|
||||
.join('');
|
||||
ul.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.getAttribute('data-action');
|
||||
const name = btn.getAttribute('data-name');
|
||||
if (!name) return;
|
||||
const base = `${API}/v1/instances/${encodeURIComponent(name)}`;
|
||||
try {
|
||||
if (action === 'qr') {
|
||||
const res = await fetch(`${base}/qr`, { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (data.qr) {
|
||||
document.getElementById('qrImage').src = data.qr;
|
||||
document.getElementById('instanceName').value = name;
|
||||
show(document.getElementById('qrContainer'), true);
|
||||
show(document.getElementById('connectStatus'), false);
|
||||
}
|
||||
} else if (action === 'disconnect') {
|
||||
await fetch(`${base}/disconnect`, { method: 'POST', headers: headers() });
|
||||
refreshInstanceList();
|
||||
} else if (action === 'logout') {
|
||||
await fetch(`${base}/logout`, { method: 'POST', headers: headers() });
|
||||
refreshInstanceList();
|
||||
} else if (action === 'delete') {
|
||||
await fetch(base, { method: 'DELETE', headers: headers() });
|
||||
refreshInstanceList();
|
||||
}
|
||||
} catch (_) {}
|
||||
refreshInstanceList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnectSelect(saved) {
|
||||
const sel = document.getElementById('connectInstanceSelect');
|
||||
const current = sel.value;
|
||||
const options = ['— Nova conexão —', ...(saved || [])];
|
||||
sel.innerHTML = '<option value="">— Nova conexão —</option>' +
|
||||
(saved || []).map((n) => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
|
||||
connectNewNameRow.style.display = sel.value === '' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function refreshInstanceList() {
|
||||
const statusEl = document.getElementById('connectStatus');
|
||||
const qrContainer = document.getElementById('qrContainer');
|
||||
const qrImage = document.getElementById('qrImage');
|
||||
try {
|
||||
const res = await fetch(`${API}/v1/instances`, { headers: headers() });
|
||||
const data = await res.json();
|
||||
if (data.saved) {
|
||||
renderSavedList(data.saved);
|
||||
updateConnectSelect(data.saved);
|
||||
} else {
|
||||
renderSavedList([]);
|
||||
updateConnectSelect([]);
|
||||
}
|
||||
if (data.instances) {
|
||||
renderInstanceList(data.instances);
|
||||
const sel = document.getElementById('dispatchInstance');
|
||||
const current = sel.value;
|
||||
const names = [...new Set([...data.instances.map((i) => i.instance), ...(data.saved || [])])];
|
||||
sel.innerHTML = names.map((n) => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
|
||||
if (!names.includes(current)) sel.selectedIndex = 0;
|
||||
|
||||
// Atualização ativa: se estamos conectando uma instância, atualizar QR e status
|
||||
if (connectingInstanceName) {
|
||||
const inst = data.instances.find((i) => i.instance === connectingInstanceName);
|
||||
if (inst) {
|
||||
if (inst.status === 'qr') {
|
||||
try {
|
||||
const qrRes = await fetch(`${API}/v1/instances/${encodeURIComponent(connectingInstanceName)}/qr`, { headers: headers() });
|
||||
const qrData = await qrRes.json();
|
||||
if (qrData.qr) {
|
||||
qrImage.src = qrData.qr;
|
||||
show(qrContainer, true);
|
||||
statusEl.textContent = 'Escaneie o QR no WhatsApp.';
|
||||
statusEl.className = 'status success';
|
||||
show(statusEl, true);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (inst.status === 'connected') {
|
||||
show(qrContainer, false);
|
||||
statusEl.textContent = 'Conectado.';
|
||||
statusEl.className = 'status success';
|
||||
show(statusEl, true);
|
||||
connectingInstanceName = null;
|
||||
} else if (inst.status === 'disconnected') {
|
||||
statusEl.textContent = 'Desconectado. Clique em Conectar novamente.';
|
||||
statusEl.className = 'status error';
|
||||
show(statusEl, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
renderSavedList([]);
|
||||
renderInstanceList([]);
|
||||
updateConnectSelect([]);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btnRefreshList').addEventListener('click', refreshInstanceList);
|
||||
refreshInstanceList();
|
||||
|
||||
// Polling ativo: atualizar lista, QR e status a cada 2s quando a aba Conexões estiver visível
|
||||
setInterval(() => {
|
||||
if (document.getElementById('conexoes').classList.contains('active')) {
|
||||
refreshInstanceList();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// --- Formulários dinâmicos (add/remove e montagem do payload) ---
|
||||
function addRow(containerId, html, removeClass) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = removeClass || 'item-row';
|
||||
div.innerHTML = html + (removeClass ? '' : ' <button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>');
|
||||
const removeBtn = div.querySelector('.btn-remove');
|
||||
if (removeBtn) removeBtn.addEventListener('click', () => div.remove());
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function addMenuOption() {
|
||||
addRow('menuOptionsList', '<input type="text" placeholder="Texto da opção" data-field="opt">');
|
||||
}
|
||||
function addButtonRow() {
|
||||
addRow('buttonsList', '<input type="text" placeholder="ID do botão" data-field="id"><input type="text" placeholder="Texto do botão" data-field="text">');
|
||||
}
|
||||
function addInteractiveRow() {
|
||||
addRow(
|
||||
'interactiveList',
|
||||
`<select data-field="type"><option value="url">URL</option><option value="copy">Copiar</option><option value="call">Ligar</option></select>
|
||||
<input type="text" placeholder="Texto do botão" data-field="text">
|
||||
<input type="text" placeholder="URL / Código / Telefone" data-field="extra">`
|
||||
);
|
||||
}
|
||||
function addPollOption() {
|
||||
addRow('pollOptionsList', '<input type="text" placeholder="Opção" data-field="opt">');
|
||||
}
|
||||
|
||||
function addListSection() {
|
||||
const container = document.getElementById('listSectionsList');
|
||||
const block = document.createElement('div');
|
||||
block.className = 'block-section';
|
||||
block.innerHTML = `
|
||||
<div class="block-title">Seção</div>
|
||||
<input type="text" class="section-title" placeholder="Título da seção">
|
||||
<div class="sub-list section-rows"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-row-in-section">+ Adicionar item</button>
|
||||
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover seção</button>
|
||||
`;
|
||||
block.querySelector('.add-row-in-section').addEventListener('click', () => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'item-row';
|
||||
row.innerHTML = `
|
||||
<input type="text" placeholder="ID" data-field="id">
|
||||
<input type="text" placeholder="Título" data-field="title">
|
||||
<input type="text" placeholder="Descrição" data-field="desc">
|
||||
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
|
||||
`;
|
||||
row.querySelector('.btn-remove').onclick = () => row.remove();
|
||||
block.querySelector('.section-rows').appendChild(row);
|
||||
});
|
||||
block.querySelector('.btn-remove-block').onclick = () => block.remove();
|
||||
container.appendChild(block);
|
||||
}
|
||||
|
||||
function addCarouselCard() {
|
||||
const container = document.getElementById('carouselCardsList');
|
||||
const block = document.createElement('div');
|
||||
block.className = 'block-section';
|
||||
block.innerHTML = `
|
||||
<div class="block-title">Card</div>
|
||||
<div class="form-row"><input type="text" placeholder="Título" data-field="title"></div>
|
||||
<div class="form-row"><input type="text" placeholder="Corpo/descrição" data-field="body"></div>
|
||||
<div class="form-row"><input type="text" placeholder="Rodapé" data-field="footer"></div>
|
||||
<div class="form-row"><input type="text" placeholder="URL da imagem" data-field="imageUrl"></div>
|
||||
<div class="sub-list card-buttons"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-card-btn">+ Botão no card</button>
|
||||
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover card</button>
|
||||
`;
|
||||
block.querySelector('.add-card-btn').addEventListener('click', () => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'item-row';
|
||||
row.innerHTML = `
|
||||
<input type="text" placeholder="ID" data-field="id">
|
||||
<input type="text" placeholder="Texto" data-field="text">
|
||||
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
|
||||
`;
|
||||
row.querySelector('.btn-remove').onclick = () => row.remove();
|
||||
block.querySelector('.card-buttons').appendChild(row);
|
||||
});
|
||||
block.querySelector('.btn-remove-block').onclick = () => block.remove();
|
||||
container.appendChild(block);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.add-item').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const forId = btn.getAttribute('data-for');
|
||||
if (forId === 'menuOptions') addMenuOption();
|
||||
else if (forId === 'buttons') addButtonRow();
|
||||
else if (forId === 'interactive') addInteractiveRow();
|
||||
else if (forId === 'listSections') addListSection();
|
||||
else if (forId === 'pollOptions') addPollOption();
|
||||
else if (forId === 'carouselCards') addCarouselCard();
|
||||
});
|
||||
});
|
||||
|
||||
// Inicializar um item vazio por tipo
|
||||
addMenuOption();
|
||||
addButtonRow();
|
||||
addInteractiveRow();
|
||||
addPollOption();
|
||||
|
||||
// Coletar dados dos formulários e montar payload
|
||||
function getMenuPayload() {
|
||||
const options = [];
|
||||
document.querySelectorAll('#menuOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
|
||||
const v = inp.value.trim();
|
||||
if (v) options.push(v);
|
||||
});
|
||||
return {
|
||||
url: '/v1/messages/send_menu',
|
||||
body: {
|
||||
instance: document.getElementById('dispatchInstance').value,
|
||||
to: document.getElementById('dispatchTo').value.trim(),
|
||||
title: document.getElementById('menuTitle').value.trim() || 'Menu',
|
||||
text: document.getElementById('menuText').value.trim() || 'Escolha uma opção:',
|
||||
options: options.length ? options : ['Opção 1'],
|
||||
footer: document.getElementById('menuFooter').value.trim() || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
function getButtonsPayload() {
|
||||
const buttons = [];
|
||||
document.querySelectorAll('#buttonsList .item-row').forEach((row) => {
|
||||
const id = row.querySelector('[data-field="id"]')?.value?.trim();
|
||||
const text = row.querySelector('[data-field="text"]')?.value?.trim();
|
||||
if (id && text) buttons.push({ id, text });
|
||||
});
|
||||
return {
|
||||
url: '/v1/messages/send_buttons_helpers',
|
||||
body: {
|
||||
instance: document.getElementById('dispatchInstance').value,
|
||||
to: document.getElementById('dispatchTo').value.trim(),
|
||||
text: document.getElementById('buttonsText').value.trim() || 'Escolha:',
|
||||
footer: document.getElementById('buttonsFooter').value.trim() || undefined,
|
||||
buttons: buttons.length ? buttons.slice(0, 3) : [{ id: 'btn1', text: 'Opção 1' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
function getInteractivePayload() {
|
||||
const buttons = [];
|
||||
document.querySelectorAll('#interactiveList .item-row').forEach((row) => {
|
||||
const type = row.querySelector('[data-field="type"]')?.value || 'url';
|
||||
const text = row.querySelector('[data-field="text"]')?.value?.trim();
|
||||
const extra = row.querySelector('[data-field="extra"]')?.value?.trim();
|
||||
if (!text || !extra) return;
|
||||
const btn = { type, text };
|
||||
if (type === 'url') btn.url = extra;
|
||||
else if (type === 'copy') btn.copyCode = extra;
|
||||
else if (type === 'call') btn.phoneNumber = extra;
|
||||
buttons.push(btn);
|
||||
});
|
||||
return {
|
||||
url: '/v1/messages/send_interactive_helpers',
|
||||
body: {
|
||||
instance: document.getElementById('dispatchInstance').value,
|
||||
to: document.getElementById('dispatchTo').value.trim(),
|
||||
text: document.getElementById('interactiveText').value.trim() || 'Confira:',
|
||||
footer: document.getElementById('interactiveFooter').value.trim() || undefined,
|
||||
buttons,
|
||||
},
|
||||
};
|
||||
}
|
||||
function getListPayload() {
|
||||
const sections = [];
|
||||
document.querySelectorAll('#listSectionsList .block-section').forEach((block) => {
|
||||
const title = block.querySelector('.section-title')?.value?.trim() || 'Seção';
|
||||
const rows = [];
|
||||
block.querySelectorAll('.section-rows .item-row').forEach((row) => {
|
||||
const id = row.querySelector('[data-field="id"]')?.value?.trim();
|
||||
const titleR = row.querySelector('[data-field="title"]')?.value?.trim();
|
||||
const desc = row.querySelector('[data-field="desc"]')?.value?.trim();
|
||||
if (id && titleR) rows.push({ id, title: titleR, description: desc || '' });
|
||||
});
|
||||
if (rows.length) sections.push({ title, rows });
|
||||
});
|
||||
return {
|
||||
url: '/v1/messages/send_list_helpers',
|
||||
body: {
|
||||
instance: document.getElementById('dispatchInstance').value,
|
||||
to: document.getElementById('dispatchTo').value.trim(),
|
||||
text: document.getElementById('listText').value.trim() || 'Escolha:',
|
||||
buttonText: document.getElementById('listButtonText').value.trim() || 'Ver opções',
|
||||
footer: document.getElementById('listFooter').value.trim() || undefined,
|
||||
sections: sections.length ? sections : [{ title: 'Opções', rows: [{ id: 'opt1', title: 'Opção 1', description: '' }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
function getPollPayload() {
|
||||
const options = [];
|
||||
document.querySelectorAll('#pollOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
|
||||
const v = inp.value.trim();
|
||||
if (v) options.push(v);
|
||||
});
|
||||
return {
|
||||
url: '/v1/messages/send_poll',
|
||||
body: {
|
||||
instance: document.getElementById('dispatchInstance').value,
|
||||
to: document.getElementById('dispatchTo').value.trim(),
|
||||
name: document.getElementById('pollName').value.trim() || 'Enquete',
|
||||
options: options.length >= 2 ? options : ['Sim', 'Não'],
|
||||
selectableCount: parseInt(document.getElementById('pollSelectable').value, 10) || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
function getCarouselPayload() {
|
||||
const cards = [];
|
||||
document.querySelectorAll('#carouselCardsList .block-section').forEach((block) => {
|
||||
const title = block.querySelector('[data-field="title"]')?.value?.trim();
|
||||
const body = block.querySelector('[data-field="body"]')?.value?.trim();
|
||||
const footer = block.querySelector('[data-field="footer"]')?.value?.trim();
|
||||
const imageUrl = block.querySelector('[data-field="imageUrl"]')?.value?.trim();
|
||||
const buttons = [];
|
||||
block.querySelectorAll('.card-buttons .item-row').forEach((row) => {
|
||||
const id = row.querySelector('[data-field="id"]')?.value?.trim();
|
||||
const text = row.querySelector('[data-field="text"]')?.value?.trim();
|
||||
if (id && text) buttons.push({ id, text });
|
||||
});
|
||||
cards.push({
|
||||
title: title || '',
|
||||
body: body || '',
|
||||
footer: footer || undefined,
|
||||
imageUrl: imageUrl || undefined,
|
||||
buttons: buttons.length ? buttons : [{ id: 'btn1', text: 'Ver' }],
|
||||
});
|
||||
});
|
||||
return {
|
||||
url: '/v1/messages/send_carousel_helpers',
|
||||
body: {
|
||||
instance: document.getElementById('dispatchInstance').value,
|
||||
to: document.getElementById('dispatchTo').value.trim(),
|
||||
text: document.getElementById('carouselText').value.trim() || undefined,
|
||||
footer: document.getElementById('carouselFooter').value.trim() || undefined,
|
||||
cards: cards.length ? cards : [{ title: 'Card', body: '', buttons: [{ id: 'b1', text: 'Botão' }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê destinatários do campo e normaliza: aceita +55, espaços, traços, vírgulas etc.
|
||||
* Ex: "+55 35 9882-8503," vira "553598828503".
|
||||
*/
|
||||
function getRecipients() {
|
||||
const raw = document.getElementById('dispatchTo').value.trim();
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(/[\r\n,;]+/)
|
||||
.map((s) => s.replace(/\D/g, ''))
|
||||
.filter((n) => n.length >= 10);
|
||||
}
|
||||
|
||||
function delayMs(minSec, maxSec) {
|
||||
const min = Math.max(0, Number(minSec) || 0);
|
||||
const max = Math.max(min, Number(maxSec) || min);
|
||||
const sec = min + Math.random() * (max - min);
|
||||
return Math.round(sec * 1000);
|
||||
}
|
||||
|
||||
document.getElementById('btnSend').addEventListener('click', async () => {
|
||||
const recipients = getRecipients();
|
||||
const resultEl = document.getElementById('sendResult');
|
||||
const btnSend = document.getElementById('btnSend');
|
||||
if (!recipients.length) {
|
||||
resultEl.textContent = 'Informe ao menos um número (um por linha, com DDI).';
|
||||
resultEl.className = 'result error';
|
||||
show(resultEl, true);
|
||||
return;
|
||||
}
|
||||
const type = document.getElementById('dispatchType').value;
|
||||
let payload;
|
||||
switch (type) {
|
||||
case 'menu': payload = getMenuPayload(); break;
|
||||
case 'buttons': payload = getButtonsPayload(); break;
|
||||
case 'interactive': payload = getInteractivePayload(); break;
|
||||
case 'list': payload = getListPayload(); break;
|
||||
case 'poll': payload = getPollPayload(); break;
|
||||
case 'carousel': payload = getCarouselPayload(); break;
|
||||
default:
|
||||
resultEl.textContent = 'Tipo não implementado.';
|
||||
resultEl.className = 'result error';
|
||||
show(resultEl, true);
|
||||
return;
|
||||
}
|
||||
if (type === 'interactive' && (!payload.body.buttons || payload.body.buttons.length === 0)) {
|
||||
resultEl.textContent = 'Adicione ao menos um botão CTA.';
|
||||
resultEl.className = 'result error';
|
||||
show(resultEl, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMin = document.getElementById('dispatchDelayMin').value;
|
||||
const delayMax = document.getElementById('dispatchDelayMax').value;
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
btnSend.disabled = true;
|
||||
show(resultEl, true);
|
||||
resultEl.className = 'result';
|
||||
|
||||
for (let i = 0; i < recipients.length; i++) {
|
||||
const to = recipients[i];
|
||||
payload.body.to = to;
|
||||
resultEl.textContent = `Enviando ${i + 1}/${recipients.length}... (${to})`;
|
||||
try {
|
||||
const res = await fetch(`${API}${payload.url}`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload.body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
sent++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (_) {
|
||||
failed++;
|
||||
}
|
||||
if (i < recipients.length - 1) {
|
||||
const wait = delayMs(delayMin, delayMax);
|
||||
resultEl.textContent = `Aguardando ${wait / 1000}s antes do próximo... (${i + 1}/${recipients.length})`;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
|
||||
resultEl.textContent = `Concluído: ${sent} enviados${failed ? `, ${failed} falhas` : ''}.`;
|
||||
resultEl.className = failed === 0 ? 'result success' : failed === recipients.length ? 'result error' : 'result';
|
||||
btnSend.disabled = false;
|
||||
});
|
||||
|
||||
const savedKey = localStorage.getItem('rscara_api_key');
|
||||
if (savedKey) document.getElementById('apiKey').placeholder = '••••••••';
|
||||
})();
|
||||
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>rsalcara — WhatsApp API</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<h1 class="logo">rsalcara</h1>
|
||||
<p class="tagline">WhatsApp · Múltiplas contas e disparos</p>
|
||||
<div class="api-key-wrap">
|
||||
<label for="apiKey">API Key (opcional)</label>
|
||||
<input type="password" id="apiKey" name="apiKey" placeholder="x-api-key" autocomplete="off">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" class="tab active" data-tab="conexoes">Conexões</button>
|
||||
<button type="button" class="tab" data-tab="disparos">Disparos</button>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<section id="conexoes" class="panel active">
|
||||
<div class="card">
|
||||
<h2>Conexões salvas</h2>
|
||||
<p class="card-hint">Clique em Conectar para abrir a sessão (QR se necessário).</p>
|
||||
<ul id="savedList" class="saved-list"></ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Conectar por nome</h2>
|
||||
<div class="form-row">
|
||||
<label for="connectInstanceSelect">Escolha uma conexão</label>
|
||||
<select id="connectInstanceSelect" name="connectInstanceSelect">
|
||||
<option value="">— Nova conexão —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="connectNewNameRow">
|
||||
<label for="instanceName">Nome da nova instância</label>
|
||||
<input type="text" id="instanceName" name="instanceName" value="main" placeholder="ex: main, vendas">
|
||||
</div>
|
||||
<button type="button" id="btnConnect" class="btn btn-primary">Criar / Conectar</button>
|
||||
<div id="qrContainer" class="qr-container hidden">
|
||||
<p>Escaneie o QR no WhatsApp:</p>
|
||||
<img id="qrImage" alt="QR Code" class="qr-image">
|
||||
<p class="qr-hint">Ou abra WhatsApp → Aparelhos conectados → Conectar aparelho</p>
|
||||
</div>
|
||||
<div id="connectStatus" class="status hidden"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Instâncias ativas</h2>
|
||||
<ul id="instanceList" class="instance-list"></ul>
|
||||
<button type="button" id="btnRefreshList" class="btn btn-ghost">Atualizar lista</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="disparos" class="panel">
|
||||
<div class="card form-card">
|
||||
<div class="form-row">
|
||||
<label for="dispatchInstance">Instância</label>
|
||||
<select id="dispatchInstance" name="dispatchInstance"><option value="main">main</option></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="dispatchTo">Destinatários</label>
|
||||
<textarea id="dispatchTo" name="dispatchTo" rows="4" placeholder="Um número por linha (com DDI) Ex: 553598828503 5511999999999"></textarea>
|
||||
<span class="field-hint">Use um número por linha. Para um único número, digite uma linha.</span>
|
||||
</div>
|
||||
<div class="form-row mailing-delay">
|
||||
<label for="dispatchDelayMin">Intervalo entre envios (segundos)</label>
|
||||
<div class="delay-inputs">
|
||||
<input type="number" id="dispatchDelayMin" name="dispatchDelayMin" value="2" min="0" max="300" placeholder="Mín"> <span>a</span>
|
||||
<input type="number" id="dispatchDelayMax" name="dispatchDelayMax" value="5" min="0" max="300" placeholder="Máx">
|
||||
</div>
|
||||
<span class="field-hint">Tempo aleatório entre cada envio (evita bloqueio).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="dispatchType">Tipo de mensagem</label>
|
||||
<select id="dispatchType" name="dispatchType">
|
||||
<option value="menu">Menu (texto numerado)</option>
|
||||
<option value="buttons">Botões (quick reply)</option>
|
||||
<option value="interactive">Botões CTA (URL / Copiar / Ligar)</option>
|
||||
<option value="list">Lista dropdown</option>
|
||||
<option value="poll">Enquete</option>
|
||||
<option value="carousel">Carrossel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div id="formMenu" class="dispatch-form">
|
||||
<div class="form-row"><label>Título</label><input type="text" id="menuTitle" name="menuTitle" placeholder="Menu de Opções"></div>
|
||||
<div class="form-row"><label>Texto</label><textarea id="menuText" name="menuText" rows="2" placeholder="Escolha uma opção:"></textarea></div>
|
||||
<div class="form-row">
|
||||
<label>Opções</label>
|
||||
<div id="menuOptionsList" class="dynamic-list"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-item" data-for="menuOptions">+ Adicionar opção</button>
|
||||
</div>
|
||||
<div class="form-row"><label>Rodapé</label><input type="text" id="menuFooter" name="menuFooter" placeholder="Responda com número"></div>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div id="formButtons" class="dispatch-form hidden">
|
||||
<div class="form-row"><label>Texto</label><textarea id="buttonsText" name="buttonsText" rows="2" placeholder="Como posso ajudar?"></textarea></div>
|
||||
<div class="form-row"><label>Rodapé</label><input type="text" id="buttonsFooter" name="buttonsFooter" placeholder="Atendimento 24h"></div>
|
||||
<div class="form-row">
|
||||
<label>Botões (máx 3)</label>
|
||||
<div id="buttonsList" class="dynamic-list"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-item" data-for="buttons">+ Adicionar botão</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interactive CTA -->
|
||||
<div id="formInteractive" class="dispatch-form hidden">
|
||||
<div class="form-row"><label>Texto</label><textarea id="interactiveText" name="interactiveText" rows="3" placeholder="Mensagem com CTAs"></textarea></div>
|
||||
<div class="form-row"><label>Rodapé</label><input type="text" id="interactiveFooter" name="interactiveFooter"></div>
|
||||
<div class="form-row">
|
||||
<label>Botões CTA</label>
|
||||
<div id="interactiveList" class="dynamic-list"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-item" data-for="interactive">+ Adicionar botão</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div id="formList" class="dispatch-form hidden">
|
||||
<div class="form-row"><label>Texto</label><textarea id="listText" name="listText" rows="2" placeholder="Escolha uma categoria:"></textarea></div>
|
||||
<div class="form-row"><label>Texto do botão</label><input type="text" id="listButtonText" name="listButtonText" value="Ver Cardápio" placeholder="Ver opções"></div>
|
||||
<div class="form-row"><label>Rodapé</label><input type="text" id="listFooter" name="listFooter" placeholder="Delivery grátis acima de R$ 50"></div>
|
||||
<div class="form-row">
|
||||
<label>Seções</label>
|
||||
<div id="listSectionsList" class="dynamic-list"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-item" data-for="listSections">+ Adicionar seção</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enquete -->
|
||||
<div id="formPoll" class="dispatch-form hidden">
|
||||
<div class="form-row"><label>Pergunta</label><input type="text" id="pollName" name="pollName" placeholder="Qual sua linguagem favorita?"></div>
|
||||
<div class="form-row"><label>Quantas pode escolher</label><input type="number" id="pollSelectable" name="pollSelectable" value="1" min="1"></div>
|
||||
<div class="form-row">
|
||||
<label>Opções</label>
|
||||
<div id="pollOptionsList" class="dynamic-list"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-item" data-for="pollOptions">+ Adicionar opção</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carrossel -->
|
||||
<div id="formCarousel" class="dispatch-form hidden">
|
||||
<div class="form-row"><label>Texto (acima dos cards)</label><input type="text" id="carouselText" name="carouselText" placeholder="Ofertas especiais"></div>
|
||||
<div class="form-row"><label>Rodapé</label><input type="text" id="carouselFooter" name="carouselFooter"></div>
|
||||
<div class="form-row">
|
||||
<label>Cards</label>
|
||||
<div id="carouselCardsList" class="dynamic-list"></div>
|
||||
<button type="button" class="btn btn-small btn-ghost add-item" data-for="carouselCards">+ Adicionar card</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="btnSend" class="btn btn-primary btn-send">Enviar</button>
|
||||
<div id="sendResult" class="result hidden"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,499 @@
|
||||
:root {
|
||||
--bg: #0f0f12;
|
||||
--bg-card: #18181c;
|
||||
--bg-input: #222228;
|
||||
--border: #2d2d35;
|
||||
--text: #e4e4e7;
|
||||
--text-muted: #71717a;
|
||||
--accent: #22c55e;
|
||||
--accent-hover: #16a34a;
|
||||
--danger: #ef4444;
|
||||
--radius: 12px;
|
||||
--font: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.api-key-wrap {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.api-key-wrap label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#apiKey {
|
||||
width: 220px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#apiKey:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #0a0a0c;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.saved-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.saved-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.saved-list li:last-child { border-bottom: none; }
|
||||
|
||||
.saved-list .saved-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.saved-list .saved-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.saved-list .btn-connect-saved {
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dynamic-list {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-list .item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dynamic-list .item-row input,
|
||||
.dynamic-list .item-row select {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dynamic-list .item-row .btn-remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.block-section .block-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.block-section .sub-list {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.block-section .sub-list .item-row { margin-bottom: 0.35rem; }
|
||||
.block-section .btn-remove-block { margin-top: 0.5rem; }
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.form-row input,
|
||||
.form-row select,
|
||||
.form-row textarea {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-row textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row input:focus,
|
||||
.form-row select:focus,
|
||||
.form-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.delay-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.delay-inputs input {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.delay-inputs span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-row input[type="number"] {
|
||||
max-width: 6rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #0a0a0c;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #fca5a5;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-container p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
max-width: 280px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
margin-top: 0.75rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.instance-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.instance-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.instance-list .instance-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instance-list .instance-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.instance-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instance-list .badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.instance-list .badge.connected {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.instance-list .badge.qr {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.instance-list .badge.disconnected {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
#btnRefreshList {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dispatch-form {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dispatch-form.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-card .form-row:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.result.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.result.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* CTA row: tipo + texto + extra */
|
||||
#formInteractive .form-row select,
|
||||
#formInteractive .form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#formInteractive .form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#formInteractive .form-row label {
|
||||
margin-bottom: 0;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
#formInteractive .form-row input:nth-of-type(2) {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
#formInteractive .form-row input:nth-of-type(3) {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT ?? '8787', 10),
|
||||
apiKey: process.env.API_KEY ?? 'ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ',
|
||||
authFolder: process.env.AUTH_FOLDER ?? 'auth',
|
||||
limits: {
|
||||
maxButtons: 3,
|
||||
maxCarouselCards: 10,
|
||||
maxListSections: 10,
|
||||
maxListRowsPerSection: 10,
|
||||
maxPollOptions: 12,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Config = typeof config;
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { WASocketLike } from '../types/whatsapp.js';
|
||||
|
||||
type MenuKey = 'main' | 'users' | 'admins' | 'system' | 'session' | 'logs' | 'database';
|
||||
|
||||
type MenuOption = {
|
||||
label: string;
|
||||
next?: MenuKey;
|
||||
action?: () => Promise<string>;
|
||||
exit?: boolean;
|
||||
back?: boolean;
|
||||
};
|
||||
|
||||
type MenuDefinition = {
|
||||
title: string;
|
||||
options: MenuOption[];
|
||||
};
|
||||
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000;
|
||||
const sessions = new Map<string, { menu: MenuKey; updatedAt: number }>();
|
||||
|
||||
function sanitizePhone(input: string): string {
|
||||
return input.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function getSenderJid(msg: any): string | null {
|
||||
return msg?.key?.remoteJid ?? null;
|
||||
}
|
||||
|
||||
function getSenderNumber(jid: string): string {
|
||||
const raw = jid.includes('@') ? jid.split('@')[0] : jid;
|
||||
return sanitizePhone(raw);
|
||||
}
|
||||
|
||||
function isSuperAdmin(jid: string): boolean {
|
||||
const admin = sanitizePhone(process.env.SUPER_ADMIN || '');
|
||||
if (!admin) return false;
|
||||
const sender = getSenderNumber(jid);
|
||||
return sender === admin;
|
||||
}
|
||||
|
||||
function extractText(msg: any): string | null {
|
||||
const message = msg?.message;
|
||||
if (!message) return null;
|
||||
if (message.conversation) return message.conversation;
|
||||
if (message.extendedTextMessage?.text) return message.extendedTextMessage.text;
|
||||
if (message.imageMessage?.caption) return message.imageMessage.caption;
|
||||
if (message.videoMessage?.caption) return message.videoMessage.caption;
|
||||
if (message.buttonsResponseMessage?.selectedButtonId) return message.buttonsResponseMessage.selectedButtonId;
|
||||
if (message.listResponseMessage?.singleSelectReply?.selectedRowId) return message.listResponseMessage.singleSelectReply.selectedRowId;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMenuText(menu: MenuDefinition): string {
|
||||
let text = `*${menu.title}*\n\n`;
|
||||
menu.options.forEach((opt, idx) => {
|
||||
text += `*${idx + 1}.* ${opt.label}\n`;
|
||||
});
|
||||
text += '\nResponda com o número desejado.';
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function isExpired(ts: number): boolean {
|
||||
return Date.now() - ts > SESSION_TTL_MS;
|
||||
}
|
||||
|
||||
function setSession(jid: string, menu: MenuKey) {
|
||||
sessions.set(jid, { menu, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
function clearSession(jid: string) {
|
||||
sessions.delete(jid);
|
||||
}
|
||||
|
||||
function getSession(jid: string): { menu: MenuKey; updatedAt: number } | null {
|
||||
const session = sessions.get(jid);
|
||||
if (!session) return null;
|
||||
if (isExpired(session.updatedAt)) {
|
||||
sessions.delete(jid);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, maxLines: number): string {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return 'Arquivo de log nao encontrado.';
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = data.split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join('\n') || 'Sem logs recentes.';
|
||||
} catch {
|
||||
return 'Erro ao ler logs.';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function getPm2LogPath(kind: 'out' | 'error'): string {
|
||||
const pm2Name = process.env.WHATSAPP_PM2_NAME || 'whatsapp-v1';
|
||||
return path.join('/root/.pm2/logs', `${pm2Name}-${kind}.log`);
|
||||
}
|
||||
|
||||
export function attachSuperAdminMenu(sock: WASocketLike, actions: {
|
||||
getSessionStatus: () => string;
|
||||
resetSession: () => Promise<string>;
|
||||
logoutSession: () => Promise<string>;
|
||||
}): void {
|
||||
if ((sock as any).__superAdminMenuAttached) return;
|
||||
(sock as any).__superAdminMenuAttached = true;
|
||||
|
||||
const menus: Record<MenuKey, MenuDefinition> = {
|
||||
main: {
|
||||
title: '🧠 PAINEL SUPER ADMIN',
|
||||
options: [
|
||||
{ label: 'Usuarios', next: 'users' },
|
||||
{ label: 'Admins', next: 'admins' },
|
||||
{ label: 'Sistema', next: 'system' },
|
||||
{ label: 'Sessao WhatsApp', next: 'session' },
|
||||
{ label: 'Logs', next: 'logs' },
|
||||
{ label: 'Banco de Dados', next: 'database' },
|
||||
{ label: 'Sair', exit: true },
|
||||
],
|
||||
},
|
||||
users: {
|
||||
title: '1️⃣ Usuarios',
|
||||
options: [
|
||||
{ label: 'Listar usuarios', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Buscar usuario', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Remover usuario', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Voltar', back: true },
|
||||
],
|
||||
},
|
||||
admins: {
|
||||
title: '2️⃣ Admins',
|
||||
options: [
|
||||
{ label: 'Listar admins', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Adicionar admin', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Remover admin', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Voltar', back: true },
|
||||
],
|
||||
},
|
||||
system: {
|
||||
title: '3️⃣ Sistema',
|
||||
options: [
|
||||
{ label: 'Status do servidor', action: async () => {
|
||||
const uptime = Math.floor(os.uptime() / 60);
|
||||
return `Servidor online. Uptime: ${uptime} min. Node: ${process.version}.`;
|
||||
}},
|
||||
{ label: 'Uso de memoria', action: async () => {
|
||||
const mem = process.memoryUsage();
|
||||
return `Memoria RSS: ${formatBytes(mem.rss)} | Heap: ${formatBytes(mem.heapUsed)} / ${formatBytes(mem.heapTotal)}`;
|
||||
}},
|
||||
{ label: 'Reiniciar bot', action: async () => {
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
return 'Reiniciando bot...';
|
||||
}},
|
||||
{ label: 'Voltar', back: true },
|
||||
],
|
||||
},
|
||||
session: {
|
||||
title: '4️⃣ Sessao WhatsApp',
|
||||
options: [
|
||||
{ label: 'Status conexao', action: async () => `Status: ${actions.getSessionStatus()}` },
|
||||
{ label: 'Resetar sessao', action: actions.resetSession },
|
||||
{ label: 'Deslogar WhatsApp', action: actions.logoutSession },
|
||||
{ label: 'Voltar', back: true },
|
||||
],
|
||||
},
|
||||
logs: {
|
||||
title: '5️⃣ Logs',
|
||||
options: [
|
||||
{ label: 'Ultimos 50 logs', action: async () => tailFile(getPm2LogPath('out'), 50) },
|
||||
{ label: 'Erros recentes', action: async () => tailFile(getPm2LogPath('error'), 50) },
|
||||
{ label: 'Exportar log', action: async () => tailFile(getPm2LogPath('out'), 50) },
|
||||
{ label: 'Voltar', back: true },
|
||||
],
|
||||
},
|
||||
database: {
|
||||
title: '6️⃣ Banco de Dados',
|
||||
options: [
|
||||
{ label: 'Exportar users.json', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Backup completo', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Limpar registros inativos', action: async () => 'Funcionalidade em desenvolvimento.' },
|
||||
{ label: 'Voltar', back: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
sock.ev.on('messages.upsert', async (payload: any) => {
|
||||
const list = payload?.messages || [];
|
||||
for (const msg of list) {
|
||||
if (!msg?.message) continue;
|
||||
if (msg?.key?.fromMe) continue;
|
||||
|
||||
const jid = getSenderJid(msg);
|
||||
if (!jid || jid.endsWith('@g.us')) continue;
|
||||
|
||||
const text = extractText(msg);
|
||||
if (!text) continue;
|
||||
const body = text.trim();
|
||||
if (!body) continue;
|
||||
|
||||
const lower = body.toLowerCase();
|
||||
if (lower === '.menu' || lower === '.panel') {
|
||||
if (!isSuperAdmin(jid)) {
|
||||
await sock.sendMessage(jid, { text: 'Acesso restrito.' });
|
||||
continue;
|
||||
}
|
||||
setSession(jid, 'main');
|
||||
await sock.sendMessage(jid, { text: buildMenuText(menus.main) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin(jid)) continue;
|
||||
|
||||
const session = getSession(jid);
|
||||
if (!session) continue;
|
||||
|
||||
if (isExpired(session.updatedAt)) {
|
||||
clearSession(jid);
|
||||
await sock.sendMessage(jid, { text: 'Sessao expirada. Envie .menu novamente.' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const menu = menus[session.menu];
|
||||
const choice = parseInt(body, 10);
|
||||
if (Number.isNaN(choice) || choice < 1 || choice > menu.options.length) {
|
||||
await sock.sendMessage(jid, { text: 'Opcao invalida. Responda com um numero da lista.' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = menu.options[choice - 1];
|
||||
if (option.exit) {
|
||||
clearSession(jid);
|
||||
await sock.sendMessage(jid, { text: 'Sessao encerrada.' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (option.back) {
|
||||
setSession(jid, 'main');
|
||||
await sock.sendMessage(jid, { text: buildMenuText(menus.main) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (option.next) {
|
||||
setSession(jid, option.next);
|
||||
await sock.sendMessage(jid, { text: buildMenuText(menus[option.next]) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (option.action) {
|
||||
setSession(jid, session.menu);
|
||||
const reply = await option.action();
|
||||
await sock.sendMessage(jid, { text: reply });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config } from './config.js';
|
||||
import instancesRouter from './routes/instances.js';
|
||||
import messagesRouter from './routes/messages.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
function apiKeyMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
const key = req.headers['x-api-key'];
|
||||
if (!config.apiKey || config.apiKey === '') {
|
||||
return next();
|
||||
}
|
||||
if (key !== config.apiKey) {
|
||||
return res.status(401).json({ ok: false, error: 'invalid_api_key' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'rsalcara' });
|
||||
});
|
||||
|
||||
// API key só nas rotas /v1 (a interface em / carrega sem key)
|
||||
app.use('/v1/instances', apiKeyMiddleware, instancesRouter);
|
||||
app.use('/v1/messages', apiKeyMiddleware, messagesRouter);
|
||||
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
app.use(express.static(publicDir));
|
||||
app.get('/', (_req, res) => res.sendFile(path.join(publicDir, 'index.html')));
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`[rsalcara] API rodando em http://localhost:${config.port}`);
|
||||
console.log(`[rsalcara] Interface: http://localhost:${config.port}`);
|
||||
if (config.apiKey) {
|
||||
console.log('[rsalcara] API Key ativa. Use header: x-api-key');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
createInstance,
|
||||
getInstance,
|
||||
getAllInstances,
|
||||
removeInstance,
|
||||
disconnectInstance,
|
||||
logoutInstance,
|
||||
} from '../services/whatsapp.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /v1/instances
|
||||
* Cria uma nova instância e retorna o QR code em base64 (ou status se já conectada).
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main' } = req.body as { instance?: string };
|
||||
const name = String(instance).trim() || 'main';
|
||||
|
||||
const result = await createInstance(name, config.authFolder);
|
||||
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
let qrBase64: string | undefined;
|
||||
if (result.qr) {
|
||||
qrBase64 = await QRCode.toDataURL(result.qr, { width: 400, margin: 2 });
|
||||
}
|
||||
|
||||
const ctx = getInstance(name);
|
||||
return res.json({
|
||||
ok: true,
|
||||
instance: name,
|
||||
status: ctx?.status ?? 'connecting',
|
||||
qr: qrBase64 ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /v1/instances
|
||||
* Lista instâncias ativas e salvas (pastas em auth/).
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const list = getAllInstances().map((ctx) => ({
|
||||
instance: ctx.name,
|
||||
status: ctx.status,
|
||||
hasQr: Boolean(ctx.qr),
|
||||
createdAt: ctx.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
let saved: string[] = [];
|
||||
const authDir = path.resolve(process.cwd(), config.authFolder);
|
||||
try {
|
||||
if (fs.existsSync(authDir)) {
|
||||
saved = fs.readdirSync(authDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
}
|
||||
} catch {
|
||||
saved = [];
|
||||
}
|
||||
|
||||
return res.json({ ok: true, instances: list, saved });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /v1/instances/saved
|
||||
* Lista apenas nomes das conexões salvas (pastas em auth/).
|
||||
*/
|
||||
router.get('/saved', (_req: Request, res: Response) => {
|
||||
const authDir = path.resolve(process.cwd(), config.authFolder);
|
||||
let saved: string[] = [];
|
||||
try {
|
||||
if (fs.existsSync(authDir)) {
|
||||
saved = fs.readdirSync(authDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
}
|
||||
} catch {
|
||||
saved = [];
|
||||
}
|
||||
return res.json({ ok: true, saved });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /v1/instances/:name/qr
|
||||
* Retorna o QR code da instância em base64 (se estiver em estado qr).
|
||||
*/
|
||||
router.get('/:name/qr', async (req: Request, res: Response) => {
|
||||
const { name } = req.params;
|
||||
const ctx = getInstance(name);
|
||||
if (!ctx) {
|
||||
return res.status(404).json({ ok: false, error: 'instance_not_found' });
|
||||
}
|
||||
if (ctx.status !== 'qr' || !ctx.qr) {
|
||||
return res.status(400).json({ ok: false, error: 'no_qr_available', status: ctx.status });
|
||||
}
|
||||
const qrBase64 = await QRCode.toDataURL(ctx.qr, { width: 400, margin: 2 });
|
||||
return res.json({ ok: true, instance: name, qr: qrBase64 });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /v1/instances/:name
|
||||
* Status de uma instância.
|
||||
*/
|
||||
router.get('/:name', (req: Request, res: Response) => {
|
||||
const { name } = req.params;
|
||||
const ctx = getInstance(name);
|
||||
if (!ctx) {
|
||||
return res.status(404).json({ ok: false, error: 'instance_not_found' });
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
instance: ctx.name,
|
||||
status: ctx.status,
|
||||
hasQr: Boolean(ctx.qr),
|
||||
createdAt: ctx.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /v1/instances/:name/disconnect
|
||||
* Desconecta e remove a instância da memória (credenciais ficam em disco; reconectar pode usar sessão salva).
|
||||
*/
|
||||
router.post('/:name/disconnect', (req: Request, res: Response) => {
|
||||
const { name } = req.params;
|
||||
const removed = disconnectInstance(name);
|
||||
return res.json({ ok: removed, instance: name });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /v1/instances/:name/logout
|
||||
* Logout + apaga pasta de auth. Próxima conexão gera novo QR.
|
||||
*/
|
||||
router.post('/:name/logout', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const result = await logoutInstance(name, config.authFolder);
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ ok: false, instance: name, error: result.error });
|
||||
}
|
||||
return res.json({ ok: true, instance: name });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /v1/instances/:name
|
||||
* Remove a instância (fecha socket, não apaga credenciais em disco).
|
||||
*/
|
||||
router.delete('/:name', (req: Request, res: Response) => {
|
||||
const { name } = req.params;
|
||||
const removed = removeInstance(name);
|
||||
return res.json({ ok: removed, instance: name });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getInstance } from '../services/whatsapp.js';
|
||||
import { toJid, isConnected } from '../utils/helpers.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function validateInstance(instanceName: string, res: Response): ReturnType<typeof getInstance> {
|
||||
const ctx = getInstance(instanceName);
|
||||
if (!ctx) {
|
||||
res.status(404).json({ ok: false, error: 'instance_not_found' });
|
||||
return undefined;
|
||||
}
|
||||
if (!isConnected(ctx)) {
|
||||
res.status(400).json({ ok: false, error: 'instance_not_connected', status: ctx.status });
|
||||
return undefined;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// --- 1. MENU TEXTO (opções numeradas) ---
|
||||
router.post('/send_menu', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main', to, title, text, options, footer } = req.body as {
|
||||
instance?: string;
|
||||
to: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
options: string[];
|
||||
footer?: string;
|
||||
};
|
||||
|
||||
if (!to || !Array.isArray(options) || options.length === 0) {
|
||||
return res.status(400).json({ ok: false, error: 'missing to/options' });
|
||||
}
|
||||
|
||||
const ctx = validateInstance(instance, res);
|
||||
if (!ctx) return;
|
||||
|
||||
const jid = toJid(to);
|
||||
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
|
||||
|
||||
let menuText = '';
|
||||
if (title) menuText += `*${title}*\n\n`;
|
||||
if (text) menuText += `${text}\n\n`;
|
||||
options.forEach((opt, idx) => {
|
||||
const label = typeof opt === 'string' ? opt : (opt as { text?: string }).text ?? `Opção ${idx + 1}`;
|
||||
menuText += `*${idx + 1}.* ${label}\n`;
|
||||
});
|
||||
if (footer) menuText += `\n_${footer}_`;
|
||||
|
||||
await ctx.sock.sendMessage(jid, { text: menuText.trim() });
|
||||
return res.json({ ok: true, hint: 'User should reply with the option number (1, 2, 3...)' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 2. BOTÕES QUICK REPLY (nativeButtons) ---
|
||||
router.post('/send_buttons_helpers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main', to, text, buttons, footer } = req.body as {
|
||||
instance?: string;
|
||||
to: string;
|
||||
text: string;
|
||||
buttons: Array<{ id: string; text: string }>;
|
||||
footer?: string;
|
||||
};
|
||||
|
||||
if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) {
|
||||
return res.status(400).json({ ok: false, error: 'missing to/text/buttons' });
|
||||
}
|
||||
const limited = buttons.slice(0, config.limits.maxButtons);
|
||||
|
||||
const ctx = validateInstance(instance, res);
|
||||
if (!ctx) return;
|
||||
|
||||
const jid = toJid(to);
|
||||
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
|
||||
|
||||
const nativeButtons = limited.map((btn, idx) => ({
|
||||
type: 'reply' as const,
|
||||
id: btn.id ?? `btn_${idx}`,
|
||||
text: btn.text ?? `Botão ${idx + 1}`,
|
||||
}));
|
||||
|
||||
const result = await ctx.sock.sendMessage(jid, {
|
||||
nativeButtons,
|
||||
text: String(text),
|
||||
footer: footer ? String(footer) : undefined,
|
||||
});
|
||||
|
||||
return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 3. BOTÕES CTA (URL, COPY, CALL) ---
|
||||
router.post('/send_interactive_helpers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main', to, text, buttons, footer } = req.body as {
|
||||
instance?: string;
|
||||
to: string;
|
||||
text: string;
|
||||
buttons: Array<{
|
||||
type: 'url' | 'copy' | 'call';
|
||||
text: string;
|
||||
url?: string;
|
||||
copyCode?: string;
|
||||
copyText?: string;
|
||||
phoneNumber?: string;
|
||||
}>;
|
||||
footer?: string;
|
||||
};
|
||||
|
||||
if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) {
|
||||
return res.status(400).json({ ok: false, error: 'missing to/text/buttons' });
|
||||
}
|
||||
|
||||
const ctx = validateInstance(instance, res);
|
||||
if (!ctx) return;
|
||||
|
||||
const jid = toJid(to);
|
||||
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
|
||||
|
||||
const nativeButtons = buttons.slice(0, config.limits.maxButtons).map((btn, idx) => {
|
||||
const type = (btn.type ?? 'reply').toLowerCase();
|
||||
if (type === 'url' || btn.url) {
|
||||
return { type: 'url' as const, text: btn.text ?? 'Abrir', url: btn.url! };
|
||||
}
|
||||
if (type === 'copy' || btn.copyCode || btn.copyText) {
|
||||
return { type: 'copy' as const, text: btn.text ?? 'Copiar', copyText: btn.copyCode ?? btn.copyText ?? '' };
|
||||
}
|
||||
if (type === 'call' || btn.phoneNumber) {
|
||||
return { type: 'call' as const, text: btn.text ?? 'Ligar', phoneNumber: btn.phoneNumber! };
|
||||
}
|
||||
return { type: 'reply' as const, id: `btn_${idx}`, text: btn.text ?? 'Botão' };
|
||||
});
|
||||
|
||||
const result = await ctx.sock.sendMessage(jid, {
|
||||
nativeButtons,
|
||||
text: String(text),
|
||||
footer: footer ? String(footer) : undefined,
|
||||
});
|
||||
|
||||
return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 4. LISTA DROPDOWN (nativeList) ---
|
||||
router.post('/send_list_helpers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main', to, text, footer, buttonText, sections } = req.body as {
|
||||
instance?: string;
|
||||
to: string;
|
||||
text: string;
|
||||
footer?: string;
|
||||
buttonText: string;
|
||||
sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>;
|
||||
};
|
||||
|
||||
if (!to || !text || !buttonText || !Array.isArray(sections) || sections.length === 0) {
|
||||
return res.status(400).json({ ok: false, error: 'missing to/text/buttonText/sections' });
|
||||
}
|
||||
|
||||
const ctx = validateInstance(instance, res);
|
||||
if (!ctx) return;
|
||||
|
||||
const jid = toJid(to);
|
||||
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
|
||||
|
||||
const result = await ctx.sock.sendMessage(jid, {
|
||||
nativeList: {
|
||||
buttonText: String(buttonText),
|
||||
sections: sections.map((s) => ({
|
||||
title: s.title ?? 'Opções',
|
||||
rows: (s.rows ?? []).map((row, idx) => ({
|
||||
id: row.id ?? `row_${idx}`,
|
||||
title: row.title ?? 'Item',
|
||||
description: row.description ?? '',
|
||||
})),
|
||||
})),
|
||||
},
|
||||
text: String(text),
|
||||
footer: footer ? String(footer) : undefined,
|
||||
});
|
||||
|
||||
return res.json({ ok: true, format: 'nativeList', messageId: result?.key?.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 5. ENQUETE / POLL ---
|
||||
router.post('/send_poll', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main', to, name, options, selectableCount } = req.body as {
|
||||
instance?: string;
|
||||
to: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
selectableCount?: number;
|
||||
};
|
||||
|
||||
if (!to || !name || !Array.isArray(options) || options.length < 2) {
|
||||
return res.status(400).json({ ok: false, error: 'missing to/name/options (min 2)' });
|
||||
}
|
||||
const opts = options.slice(0, config.limits.maxPollOptions).map((o) => (typeof o === 'string' ? o : String(o)));
|
||||
|
||||
const ctx = validateInstance(instance, res);
|
||||
if (!ctx) return;
|
||||
|
||||
const jid = toJid(to);
|
||||
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
|
||||
|
||||
// Formato que funciona no InfiniteAPI/Baileys: poll com name, values e selectableCount
|
||||
const result = await ctx.sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: String(name),
|
||||
values: opts,
|
||||
selectableCount: Math.min(Math.max(1, selectableCount ?? 1), opts.length),
|
||||
},
|
||||
});
|
||||
return res.json({ ok: true, messageId: result?.key?.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 6. CARROSSEL (nativeCarousel) ---
|
||||
router.post('/send_carousel_helpers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance = 'main', to, text, footer, cards } = req.body as {
|
||||
instance?: string;
|
||||
to: string;
|
||||
text?: string;
|
||||
footer?: string;
|
||||
cards: Array<{
|
||||
title?: string;
|
||||
body?: string;
|
||||
footer?: string;
|
||||
imageUrl?: string;
|
||||
buttons?: Array<{ id: string; text: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!to || !Array.isArray(cards) || cards.length === 0) {
|
||||
return res.status(400).json({ ok: false, error: 'missing to/cards' });
|
||||
}
|
||||
const limited = cards.slice(0, config.limits.maxCarouselCards);
|
||||
|
||||
const ctx = validateInstance(instance, res);
|
||||
if (!ctx) return;
|
||||
|
||||
const jid = toJid(to);
|
||||
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
|
||||
|
||||
const formattedCards = limited.map((card, idx) => ({
|
||||
title: card.title ?? `Card ${idx + 1}`,
|
||||
body: card.body ?? '',
|
||||
footer: card.footer,
|
||||
image: card.imageUrl ? { url: card.imageUrl } : undefined,
|
||||
buttons: (card.buttons ?? []).map((btn, bIdx) => ({
|
||||
type: 'reply' as const,
|
||||
id: btn.id ?? `card${idx}_btn${bIdx}`,
|
||||
text: btn.text ?? 'Botão',
|
||||
})),
|
||||
}));
|
||||
|
||||
const result = await ctx.sock.sendMessage(jid, {
|
||||
nativeCarousel: { cards: formattedCards },
|
||||
text: text ? String(text) : undefined,
|
||||
footer: footer ? String(footer) : undefined,
|
||||
});
|
||||
|
||||
return res.json({ ok: true, format: 'nativeCarousel', messageId: result?.key?.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,198 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { InstanceContext } from '../types/whatsapp.js';
|
||||
import { attachSuperAdminMenu } from '../handlers/superAdminMenu.js';
|
||||
|
||||
const instances = new Map<string, InstanceContext>();
|
||||
|
||||
function closeSocket(sock: InstanceContext['sock']): void {
|
||||
try {
|
||||
(sock as InstanceContext['sock']).ws?.close?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o contexto da instância pelo nome, ou undefined se não existir.
|
||||
*/
|
||||
export function getInstance(name: string): InstanceContext | undefined {
|
||||
return instances.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna todas as instâncias.
|
||||
*/
|
||||
export function getAllInstances(): InstanceContext[] {
|
||||
return Array.from(instances.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria e inicia uma nova instância WhatsApp (InfiniteAPI/Baileys).
|
||||
* Gera QR code até o usuário escanear e conectar.
|
||||
* Em 515 (restartRequired) recria o socket automaticamente após 2s.
|
||||
*/
|
||||
export async function createInstance(
|
||||
name: string,
|
||||
authFolder: string
|
||||
): Promise<{ ok: boolean; instance: string; qr?: string; error?: string }> {
|
||||
if (instances.has(name)) {
|
||||
const ctx = instances.get(name)!;
|
||||
if (ctx.status === 'connected') {
|
||||
return { ok: true, instance: name };
|
||||
}
|
||||
if (ctx.status === 'qr' && ctx.qr) {
|
||||
return { ok: true, instance: name, qr: ctx.qr };
|
||||
}
|
||||
// disconnected ou connecting: remove e recria para nova tentativa
|
||||
closeSocket(ctx.sock);
|
||||
instances.delete(name);
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
default: makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
fetchLatestWaWebVersion,
|
||||
Browsers,
|
||||
} = await import('baileys');
|
||||
const authPath = path.resolve(process.cwd(), authFolder, name);
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authPath);
|
||||
|
||||
let version: [number, number, number];
|
||||
try {
|
||||
const wa = await fetchLatestWaWebVersion({});
|
||||
const v = wa.version;
|
||||
version = Array.isArray(v) && v.length >= 3 ? [v[0], v[1], v[2]] : [2, 3000, 1032884366];
|
||||
} catch {
|
||||
version = [2, 3000, 1032884366];
|
||||
}
|
||||
|
||||
const sock = makeWASocket({
|
||||
auth: state,
|
||||
printQRInTerminal: false,
|
||||
version,
|
||||
browser: Browsers.windows('Chrome'),
|
||||
}) as InstanceContext['sock'];
|
||||
|
||||
const ctx: InstanceContext = {
|
||||
name,
|
||||
sock,
|
||||
status: 'connecting',
|
||||
qr: null,
|
||||
createdAt: new Date(),
|
||||
authFolder,
|
||||
};
|
||||
instances.set(name, ctx);
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', ((update: unknown) => {
|
||||
const { connection, qr, lastDisconnect } = (update ?? {}) as {
|
||||
connection?: string;
|
||||
qr?: string;
|
||||
lastDisconnect?: { error?: { output?: { statusCode?: number } } };
|
||||
};
|
||||
|
||||
if (qr) {
|
||||
ctx.status = 'qr';
|
||||
ctx.qr = qr;
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
ctx.status = 'connected';
|
||||
ctx.qr = null;
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode;
|
||||
ctx.status = 'disconnected';
|
||||
ctx.qr = null;
|
||||
|
||||
if (code === DisconnectReason.loggedOut || code === DisconnectReason.connectionReplaced) {
|
||||
closeSocket(ctx.sock);
|
||||
instances.delete(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 515 = restartRequired: pairing concluído, WA pede reinício. Recriar socket com o mesmo auth.
|
||||
if (code === DisconnectReason.restartRequired) {
|
||||
const folder = ctx.authFolder;
|
||||
closeSocket(ctx.sock);
|
||||
instances.delete(name);
|
||||
setTimeout(() => {
|
||||
createInstance(name, folder).catch(() => {});
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
attachSuperAdminMenu(sock, {
|
||||
getSessionStatus: () => ctx.status,
|
||||
resetSession: async () => {
|
||||
await logoutInstance(name, authFolder);
|
||||
const created = await createInstance(name, authFolder);
|
||||
if (!created.ok) return `Erro ao resetar: ${created.error || 'desconhecido'}`;
|
||||
return created.qr ? 'Sessao resetada. Novo QR gerado.' : 'Sessao resetada. Aguarde novo QR.';
|
||||
},
|
||||
logoutSession: async () => {
|
||||
const result = await logoutInstance(name, authFolder);
|
||||
if (!result.ok) return `Erro ao deslogar: ${result.error || 'desconhecido'}`;
|
||||
return 'WhatsApp deslogado. Novo QR sera necessario.';
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, instance: name, qr: ctx.qr ?? undefined };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, instance: name, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desconecta e remove a instância da memória (credenciais permanecem em disco).
|
||||
*/
|
||||
export function disconnectInstance(name: string): boolean {
|
||||
const ctx = instances.get(name);
|
||||
if (!ctx) return false;
|
||||
closeSocket(ctx.sock);
|
||||
instances.delete(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout + apaga pasta de auth e remove instância. Próxima conexão gerará novo QR.
|
||||
*/
|
||||
export async function logoutInstance(name: string, authFolder: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const ctx = instances.get(name);
|
||||
if (ctx) {
|
||||
try {
|
||||
if (typeof ctx.sock.logout === 'function') {
|
||||
await ctx.sock.logout();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
closeSocket(ctx.sock);
|
||||
instances.delete(name);
|
||||
}
|
||||
const authPath = path.resolve(process.cwd(), authFolder, name);
|
||||
try {
|
||||
if (fs.existsSync(authPath)) {
|
||||
fs.rmSync(authPath, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a instância (fecha socket e remove do mapa). Não apaga credenciais.
|
||||
*/
|
||||
export function removeInstance(name: string): boolean {
|
||||
return disconnectInstance(name);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Tipos para o socket InfiniteAPI/Baileys.
|
||||
* O socket expõe sendMessage com nativeButtons, nativeList, nativeCarousel, etc.
|
||||
*/
|
||||
export interface InstanceContext {
|
||||
name: string;
|
||||
sock: WASocketLike;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'qr';
|
||||
qr: string | null;
|
||||
createdAt: Date;
|
||||
authFolder: string;
|
||||
}
|
||||
|
||||
export interface WASocketLike {
|
||||
sendMessage: (jid: string, content: MessageContent) => Promise<{ key?: { id?: string }; messageTimestamp?: number }>;
|
||||
relayMessage?: (jid: string, content: unknown, opts?: unknown) => Promise<unknown>;
|
||||
ev: { on: (event: string, handler: (...args: unknown[]) => void) => void };
|
||||
logout?: () => Promise<void>;
|
||||
ws?: { close: () => void };
|
||||
}
|
||||
|
||||
export interface NativeButtonReply {
|
||||
type: 'reply';
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NativeButtonUrl {
|
||||
type: 'url';
|
||||
text: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface NativeButtonCopy {
|
||||
type: 'copy';
|
||||
text: string;
|
||||
copyText: string;
|
||||
}
|
||||
|
||||
export interface NativeButtonCall {
|
||||
type: 'call';
|
||||
text: string;
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export type NativeButton = NativeButtonReply | NativeButtonUrl | NativeButtonCopy | NativeButtonCall;
|
||||
|
||||
export interface NativeListRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface NativeListSection {
|
||||
title: string;
|
||||
rows: NativeListRow[];
|
||||
}
|
||||
|
||||
export interface NativeCarouselCard {
|
||||
title?: string;
|
||||
body?: string;
|
||||
footer?: string;
|
||||
image?: { url: string };
|
||||
imageUrl?: string;
|
||||
buttons?: Array<{ type?: string; id: string; text: string }>;
|
||||
}
|
||||
|
||||
export interface MessageContent {
|
||||
text?: string;
|
||||
footer?: string;
|
||||
nativeButtons?: NativeButton[];
|
||||
nativeList?: {
|
||||
buttonText: string;
|
||||
sections: NativeListSection[];
|
||||
};
|
||||
nativeCarousel?: {
|
||||
cards: NativeCarouselCard[];
|
||||
};
|
||||
poll?: {
|
||||
name: string;
|
||||
values: string[];
|
||||
selectableCount?: number;
|
||||
};
|
||||
pollCreationMessage?: {
|
||||
name: string;
|
||||
options: Array<{ optionName: string }>;
|
||||
selectableOptionsCount?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Formata número para JID do WhatsApp (5511999999999@s.whatsapp.net)
|
||||
*/
|
||||
export function toJid(phone: string | null | undefined): string | null {
|
||||
if (!phone || typeof phone !== 'string') return null;
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
if (cleaned.length < 10) return null;
|
||||
if (phone.includes('@')) return phone;
|
||||
return `${cleaned}@s.whatsapp.net`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se a instância está conectada
|
||||
*/
|
||||
export function isConnected(ctx: { sock?: unknown; status?: string } | null): boolean {
|
||||
return Boolean(ctx?.sock && ctx.status === 'connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se string é URL
|
||||
*/
|
||||
export function isUrl(str: unknown): str is string {
|
||||
if (typeof str !== 'string') return false;
|
||||
return /^https?:\/\//i.test(str.trim());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user