--- name: Sindicância multi-tenant WhatsApp + fixes 2026-04-20 description: Isolamento por tipo de sessão (principal/financeiro), DB schema, sync cron e guardrails contra cross-contamination entre sessões WhatsApp type: project originSessionId: 7f9fdcad-8768-42c1-b296-fe5a18c5bd2c --- # Sindicância multi-tenant WhatsApp — 2026-04-20 Auditoria completa após usuário relatar mensagens de uma sessão executando em outra (GotechBr Principal ↔ Financeiro ↔ Agnaldo). ## DB: schema novo em `whatsapp_sessions` Colunas adicionadas: - `tipo` ENUM('principal','financeiro','atendimento','outro') NOT NULL - `nome_sessao` VARCHAR(100) - `sistema` VARCHAR(50) DEFAULT 'intranet' - Índice único em `(tipo, sistema, deleted_at)` Rows cadastradas: - id=1 `be3c97d7-…5046113` tipo='principal' GotechBr 553135003516 - id=2 `QLh063KG7x…thuPN` tipo='financeiro' GotechBr Financeiro 553184932799 - id=3 `137371875097838` tipo='outro' Agnaldo Santos 553173559779 (auto-criada pelo cron sync) **ATENÇÃO**: Número do Agnaldo mudou — Baileys reporta `553173559779` mas MEMORY.md antigo tinha `553189049626`. Alguém re-escaneou aquela instância com número diferente. ## API nova para envio seguro - `WhatsappSessionModel::getByTipo(string $tipo, string $sistema = 'intranet'): ?array` - `WhatsappSessionModel::getInstanceKeyByTipo(string $tipo, string $sistema = 'intranet'): ?string` - `WhatsappSessionModel::resolveKey(string $tipo, ?string $envVar = null, string $sistema = 'intranet'): ?string` — tenta .env primeiro, fallback DB - `WhatsApp::enviarPorTipo(string $tipo, string $telefone, string $mensagem)` — único método recomendado, valida tipo e status - `WhatsApp::enviarArquivoPorTipo(string $tipo, string $telefone, string $caminho, ...)` idem para PDF ## Guardrails em `WhatsApp::enviarMensagemTexto/enviarArquivo` - `$sessionName` agora OBRIGATÓRIO. Se null → log critical + return false (antes: pegava primeira sessão connected, causa raiz da contaminação). - Quando `$sessionName` vem, faz lookup DB por instance_key e grava mensagem no `session_id` correto. ## Webhook handler `WhatsApp::webhook()`: instance_key desconhecido NÃO é mais descartado silenciosamente. Cria row com `tipo='outro'` e log critical. Mensagens não desaparecem mais. ## Cron `sync-instances` (a cada 5min) - Rota: `GET /cron/sync-instances` → `CronController::syncInstancesBaileys` - Usa cURL (não file_get_contents — PHP do servidor sem HTTP wrapper) - Consulta `GET /rest/instance/list` no Baileys - Reconcilia: marca disconnected se instância sumiu, alerta critical se telefone divergiu (re-scan com outro número), cria row auto tipo='outro' para instâncias novas - Adicionada ao crontab do usuário `gotechbr` ## Hardcodes removidos (15 arquivos) - `CronCobrancaAutomatica.php` — 6 ocorrências (property `$whatsappFinanceiroKey` removida) - `Sicoob.php`, `Boleto.php` (3x), `CronNfseAutomatica.php`, `CronBoletos.php` — todos → `resolveKey('financeiro', 'COBRANCA_WHATSAPP_SESSION')` - `CronSocialMedia.php` (3x), `SocialMedia.php` (2x) — → `resolveKey('principal', 'WHATSAPP_INSTANCE_KEY')` - `ApiEntregas.php`, `OrdemServico.php` (2x) — passavam sem sessionName (fallback perigoso) → agora resolvem via tipo='principal' - `MensagensAgendadas.php` (2x), `CronMensagensAgendadas.php` — fallback para string literal 'GotechBr' removido ## Backup Produção: `/home/gotechbr/backup_sind_20260420/` (cópia pré-deploy de todos os 15 arquivos). DB: `/tmp/whatsapp_sessions_backup_20260420_100403.sql` (whatsapp_sessions antes da migration). ## FIX CRÍTICO: bloqueio de ligação per-instance **Causa raiz do "respondido pelo Agnaldo" encontrada:** `/root/api-teste2/dist/services/Instance.js` linha ~376 tinha mensagem HARDCODED: > "Olá! O número (31) 3500-3516 é exclusivo..." Rodava em TODAS as instâncias. Ou seja: cliente ligava para Financeiro ou Agnaldo → instância rejeitava e mandava mensagem da GotechBR principal. Parecia contaminação. **Patch aplicado** (pode ser perdido se `npm install` — mesmo caso do LID patch): - Bloco de call rejection agora lê `./instances_data//call_reject_config.json` em tempo real - JSON: `{"enabled": bool, "message": "..."}` - Se `enabled=false` ou arquivo ausente → NÃO rejeita (fica silente) **Configs criadas:** - Principal: enabled=true, msg original com (31) 3500-3516 - Financeiro: enabled=true, msg "Este número é exclusivo do Departamento Financeiro..." - Agnaldo (tipo=outro): **enabled=false** — não responde com msg GotechBr **Restart:** `pm2 restart api-teste2 --update-env` aplicado 2026-04-20 10:24. **Backup original:** `/root/api-teste2/dist/services/Instance.js.bak_call_20260420_102217` **Script patch:** `D:/tmp/patch_call_reject.py` (idempotente — detecta `var _callKey = this.key;` para evitar re-patch). **TODO futuro:** endpoint no micro-server :8001 `POST /call-reject-config/:key` para atualizar configs via UI (hoje precisa editar JSON manualmente via SSH). ## Pendências - **NULL phone_number**: 63% das mensagens em abril (472/745) têm phone_number NULL. Rastrear `handleIncomingWebhookMessage()`. Não relacionado a cross-contamination mas degrada chat. - **Agnaldo re-scan**: confirmar com usuário se 553173559779 é esperado ou se alguém usou a instância do Agnaldo por engano (MEMORY.md tinha 553189049626). - **UI p/ editar call_reject_config**: hoje precisa SSH + editar JSON. Criar endpoint + tela na intranet.