mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
Merge PR #317 — duas cópias do repo derrubavam o WhatsApp, e o 401 ficou 3 dias invisível
fix(whatsapp): duas cópias do repo derrubavam as conexões, e o 401 ficou 3 dias invisível
This commit is contained in:
@@ -25,6 +25,7 @@ import { ok, fail } from "@/lib/api/wrappers";
|
||||
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
|
||||
import { requireRole } from "@/lib/auth/require-role";
|
||||
import { CHANNEL_PROVIDER_WAHA } from "@/lib/channels/capabilities";
|
||||
import { numeroObservadoDaSessao } from "@/lib/channels/numero-observado";
|
||||
import { isChannelStatus } from "@/lib/schemas/channels";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
@@ -166,9 +167,15 @@ export async function GET(
|
||||
me?: { id?: string; pushName?: string };
|
||||
};
|
||||
if (remote.status) liveStatus = remote.status;
|
||||
// WAHA expõe o número (JID `<phone>@c.us`) quando a sessão está WORKING.
|
||||
const jid = remote.me?.id;
|
||||
if (jid && !phoneNumber) phoneNumber = jid.replace(/@.*/, "");
|
||||
// O número vem do JID (`<phone>@c.us`), e a regra de quando ele VALE mora
|
||||
// em `numeroObservadoDaSessao` — inclusive por que não basta gravar sempre.
|
||||
// O que havia aqui só preenchia a coluna VAZIA, então um re-pareamento com
|
||||
// outro aparelho deixava o banco mentindo para sempre.
|
||||
phoneNumber = numeroObservadoDaSessao({
|
||||
jid: remote.me?.id,
|
||||
statusAoVivo: liveStatus,
|
||||
gravado: phoneNumber,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
// 404 no WAHA = sessão não iniciada lá → considera STOPPED.
|
||||
|
||||
@@ -12,7 +12,7 @@ Ao mudar um invariante aqui, atualize os dois na mesma sessão.
|
||||
|
||||
| Se você quer… | Vá para |
|
||||
|---|---|
|
||||
| saber se sua mudança precisa virar imagem publicada | §Os 7 invariantes, nº 1 |
|
||||
| saber se sua mudança precisa virar imagem publicada | §Os 8 invariantes, nº 1 |
|
||||
| escolher a tag que uma instalação de cliente consome | §Política de canais |
|
||||
| lançar uma versão | §Checklist de release |
|
||||
| entender por que o namespace é `melgarafael` e não uma org | o ADR |
|
||||
@@ -58,7 +58,7 @@ porque a exceção é o que apaga a regra.
|
||||
|
||||
---
|
||||
|
||||
## Os 7 invariantes (verificáveis)
|
||||
## Os 8 invariantes (verificáveis)
|
||||
|
||||
### 1. Nenhum serviço de produção constrói na máquina do cliente
|
||||
|
||||
@@ -245,6 +245,40 @@ default que preserva o comportamento anterior**; se ela precisa existir, quem a
|
||||
Medido no app real: com `APP_VERSION=9.9.9-teste` o endpoint responde `9.9.9-teste`; sem ela,
|
||||
`desconhecido` — nunca um número plausível.
|
||||
|
||||
### 8. Uma instalação, um dono — o projeto Docker não se compartilha
|
||||
|
||||
Só a árvore que criou os contêineres pode atualizá-los. Uma segunda cópia do repo na
|
||||
mesma VPS **recusa** mexer, e diz por quê.
|
||||
|
||||
- **Por quê:** `docker compose` deriva o nome do projeto do *basename* do diretório.
|
||||
`/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` viram ambos `deskcommcrm` — um
|
||||
conjunto só de contêineres, dois `.env` diferentes. Cada `up -d` recria o parque com as
|
||||
credenciais da sua árvore, e a outra passa a falar com serviços que não a reconhecem.
|
||||
- **Anti-exemplo real (medido, 2026-08):** o cron rodava o `agent.sh` das duas árvores a
|
||||
cada 5 minutos. Em 21/08 13:30 a cópia de teste recriou o contêiner do WAHA com a chave
|
||||
dela; às 14:47 o app foi recriado da árvore de produção, com outra. Resultado: **três
|
||||
dias** com `waha_create_401` em toda chamada — nenhum número de WhatsApp conectava — e as
|
||||
sessões caindo a cada recriação. O mesmo aconteceu com o `srh`, que ficou com o token da
|
||||
árvore errada e derrubou o rate limit (`GET /api/v1/health` → `redis: down, http_401`).
|
||||
- **Por que o `flock` não bastava:** ele tranca por **diretório** (`$PROJECT_DIR/.update.lock`),
|
||||
e as duas árvores pegam locks diferentes enquanto disputam os mesmos contêineres. A trava
|
||||
tem de ser pelo que elas de fato compartilham — o projeto Docker.
|
||||
- **Como se detecta:** o label `com.docker.compose.project.working_dir`, que todo contêiner
|
||||
do compose carrega, nomeia a árvore que o criou.
|
||||
|
||||
```bash
|
||||
docker ps -a --filter "label=com.docker.compose.project=$(basename "$PWD" | tr 'A-Z' 'a-z')" \
|
||||
--format '{{.Names}} => {{.Label "com.docker.compose.project.working_dir"}}'
|
||||
```
|
||||
|
||||
- **Escape:** `DESKCOMM_ASSUMIR_PROJETO=1` assume o parque de propósito. Existe para a
|
||||
instalação que **mudou de pasta** de verdade; é explícito porque assumir por engano é o
|
||||
defeito que o guarda existe para impedir. Uma árvore alheia que já **não está no disco**
|
||||
não conta como rival — senão o guarda nasceria vermelho em quem só moveu a instalação.
|
||||
- **Verificação:** `tests/shell/dono-do-projeto.test.sh` (no `pnpm test:shell`) — cobre
|
||||
parque limpo, parque próprio, parque alheio, parque **misto** (o caso medido), pasta
|
||||
movida, o escape, e os dois call sites (`agent.sh` e `update.sh`).
|
||||
|
||||
---
|
||||
|
||||
## Política de canais
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ Detalham schema SQL e payloads exatos. **Consulte antes de modelar qualquer cois
|
||||
| [`doctrine/sistema-vivo/`](doctrine/sistema-vivo/README.md) | **Manual do Sistema Vivo** — 8 capítulos plugáveis (princípio universal + aplicação de referência). O *porquê* de cada invariante, e como adotar a doutrina em outro sistema |
|
||||
| [`doctrine/restricao-de-canal.md`](doctrine/restricao-de-canal.md) | Auto-restrição × hetero-restrição de canais externos; contrato de parâmetros derivado |
|
||||
| [`doctrine/separacao-fala-e-operacao.md`](doctrine/separacao-fala-e-operacao.md) | Vocabulário interno nunca vaza para o cliente |
|
||||
| [`doctrine/packaging.md`](doctrine/packaging.md) | **Doutrina de Packaging — a LEI.** 7 invariantes + política de canais + checklist de release (item 15 do DoD) |
|
||||
| [`doctrine/packaging.md`](doctrine/packaging.md) | **Doutrina de Packaging — a LEI.** 8 invariantes + política de canais + checklist de release (item 15 do DoD) |
|
||||
| [`adr/0001-packaging-e-distribuicao.md`](adr/0001-packaging-e-distribuicao.md) | ADR do packaging: namespace, os 3 packages, e o que foi recusado |
|
||||
| [`architecture/agent-turn.html`](architecture/agent-turn.html) | Diagrama do turno do agente (inbound → guardrails → outbound) |
|
||||
| [`architecture/teto-de-orcamento.architecture.json`](architecture/teto-de-orcamento.architecture.json) | **Mapa vivo do teto de gasto com IA** — quem alimenta o gate, o que a parada NÃO desfaz sozinha, e o laço de retorno (invariante 7) |
|
||||
|
||||
@@ -52,6 +52,84 @@ nome_do_projeto_atual() {
|
||||
printf '%s' "${COMPOSE_PROJECT_NAME:-$(nome_do_projeto_compose "${PROJECT_DIR:-$PWD}")}"
|
||||
}
|
||||
|
||||
# ── Quem é o DONO deste projeto Docker ───────────────────────────────────────
|
||||
#
|
||||
# Duas cópias do repo na mesma VPS — o clone de produção e um de teste ao lado —
|
||||
# recebem o MESMO nome de projeto compose: o docker o deriva do basename do
|
||||
# diretório, e `/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` dão os dois
|
||||
# `deskcommcrm`. Os contêineres são UM conjunto só; os `.env` são dois. Cada
|
||||
# `up -d` recria o parque com as credenciais da SUA árvore, e a outra fica
|
||||
# falando com um transporte que não a reconhece mais.
|
||||
#
|
||||
# Não é hipótese. Numa VPS real o clone de teste recriou o contêiner do WhatsApp
|
||||
# com a chave dele às 13:30; o app foi recriado da árvore de produção às 14:47,
|
||||
# com outra chave; e por TRÊS DIAS toda chamada ao WAHA respondeu 401 — nenhum
|
||||
# número conectava, nenhuma mensagem entrava, e o painel só dizia "não foi
|
||||
# possível verificar a conexão".
|
||||
#
|
||||
# O `flock` do agent.sh não protege disso: ele é por DIRETÓRIO, então as duas
|
||||
# árvores pegam locks diferentes enquanto disputam os mesmos contêineres. A
|
||||
# trava tem de ser pelo que elas de fato compartilham — o projeto Docker.
|
||||
#
|
||||
# O sinal é o próprio Docker: todo contêiner criado pelo compose carrega o label
|
||||
# `com.docker.compose.project.working_dir` com a árvore que o criou.
|
||||
donos_do_projeto_em_execucao() { # → um diretório por linha, sem repetir
|
||||
docker ps -a \
|
||||
--filter "label=com.docker.compose.project=$(nome_do_projeto_atual)" \
|
||||
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null \
|
||||
| grep -v '^$' | sort -u
|
||||
}
|
||||
|
||||
# Imprime as árvores ALHEIAS que ainda são instalações VIVAS; sai 0 quando existe
|
||||
# ao menos uma. Sem contêiner no ar não há dono, e uma instalação nova assume
|
||||
# legitimamente — por isso o silêncio aqui é "pode seguir", não "não sei".
|
||||
#
|
||||
# "Viva" é o filtro que impede este guarda de nascer vermelho em quem não fez
|
||||
# nada de errado: quem MOVEU a instalação de pasta deixa contêineres apontando
|
||||
# para um caminho que não existe mais. Esse não é um rival disputando o parque —
|
||||
# é o endereço antigo desta mesma instalação, e recusar ali travaria as
|
||||
# atualizações para sempre, num log que ninguém lê. Só conta como rival a árvore
|
||||
# que ainda está no disco COM um compose: aquela de onde um segundo cron
|
||||
# realmente consegue rodar `up -d`.
|
||||
projeto_pertence_a_outra_arvore() {
|
||||
local dir vivas=""
|
||||
while IFS= read -r dir; do
|
||||
[ -n "$dir" ] || continue
|
||||
[ "$dir" != "${PROJECT_DIR:-$PWD}" ] || continue
|
||||
[ -f "$dir/$COMPOSE" ] || continue
|
||||
vivas="${vivas}${vivas:+$'\n'}${dir}"
|
||||
done <<EOF
|
||||
$(donos_do_projeto_em_execucao)
|
||||
EOF
|
||||
[ -n "$vivas" ] || return 1
|
||||
printf '%s' "$vivas"
|
||||
}
|
||||
|
||||
# O guarda que o agent.sh e o update.sh chamam antes de tocar em contêiner.
|
||||
#
|
||||
# Falha FECHADA na ação (não mexe em parque alheio) e ABERTA na informação: diz
|
||||
# qual árvore é a dona e como assumir de propósito. Parar calado deixaria o dono
|
||||
# da VPS achando que o agente atualiza, quando ele desiste a cada 5 minutos.
|
||||
#
|
||||
# `DESKCOMM_ASSUMIR_PROJETO=1` é a saída para o caso legítimo — a instalação
|
||||
# mudou de pasta e os contêineres ainda apontam para a antiga. É explícita de
|
||||
# propósito: assumir por engano é justamente o defeito que esta função existe
|
||||
# para impedir.
|
||||
recusar_projeto_de_outra_arvore() { # recusar_projeto_de_outra_arvore <como reportar>
|
||||
local alheias reportar="${1:-}"
|
||||
alheias="$(projeto_pertence_a_outra_arvore)" || return 0
|
||||
[ "${DESKCOMM_ASSUMIR_PROJETO:-}" != "1" ] || return 0
|
||||
|
||||
local recado
|
||||
recado="os contêineres do projeto '$(nome_do_projeto_atual)' foram criados por outra cópia do repo ($(printf '%s' "$alheias" | tr '\n' ' ')) — esta aqui é $(printf '%s' "${PROJECT_DIR:-$PWD}"). Duas cópias com o mesmo nome de projeto disputam os MESMOS contêineres e cada uma os recria com o .env dela, o que derruba as conexões de WhatsApp e quebra as credenciais. Deixe apenas UMA no cron (crontab -e) ou, se esta é mesmo a instalação boa, rode com DESKCOMM_ASSUMIR_PROJETO=1"
|
||||
if [ -n "$reportar" ] && command -v "$reportar" >/dev/null 2>&1; then
|
||||
"$reportar" "$recado"
|
||||
else
|
||||
printf '%s\n' "$recado" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# A bridge que ESTE projeto reserva para o proxy externo. Um `basename` cru
|
||||
# diverge numa pasta com maiúscula, ponto ou underscore inicial — e aí o kit
|
||||
# cria uma rede e o compose procura outra.
|
||||
|
||||
@@ -37,6 +37,13 @@ log_err() { # log_err <mensagem> — grava com timestamp, corta pra ~200 linhas
|
||||
{ tail -n 200 "$ERRLOG" > "${ERRLOG}.tmp" && mv "${ERRLOG}.tmp" "$ERRLOG"; } 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Antes de qualquer outra coisa: esta cópia do repo manda neste projeto Docker?
|
||||
#
|
||||
# Cedo de propósito — antes até de ANUNCIAR a versão. Uma cópia que não é a dona
|
||||
# anunciaria a versão da árvore dela, e o app ofereceria "Atualizar agora" com
|
||||
# base num número que não descreve o que está no ar.
|
||||
recusar_projeto_de_outra_arvore log_err || exit 0
|
||||
|
||||
post() { # post <json> → corpo da resposta em 2xx; VAZIO em qualquer falha
|
||||
# (quem chama, ex. o laço de retry do run_result, usa "saiu vazio" como sinal
|
||||
# de falha — por isso o corpo só é impresso no ramo de sucesso).
|
||||
|
||||
@@ -26,6 +26,12 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
done
|
||||
|
||||
# ── 0-. Esta cópia do repo é a dona dos contêineres? ─────────────────────────
|
||||
# Antes do cron e antes do git: uma segunda cópia que atualiza por cima recria o
|
||||
# parque com o .env DELA. Foi o que deixou o WhatsApp de uma VPS real três dias
|
||||
# em 401. Ver `recusar_projeto_de_outra_arvore` em _common.sh.
|
||||
recusar_projeto_de_outra_arvore || die "Atualização interrompida para não quebrar a instalação que está no ar."
|
||||
|
||||
# ── 0. Liga o agente da tela ANTES de qualquer decisão de versão ─────────────
|
||||
# Instalar o cron aqui, e não no fim, é o que faz o bootstrap ter fim: os
|
||||
# caminhos "já está na versão mais recente" e "essa versão é anterior à sua"
|
||||
|
||||
@@ -13,8 +13,28 @@ import { resolveWhatsappIdForContactCard } from "@/lib/waha/resolve-contact-what
|
||||
import { bareWaMessageId, parseWahaMessageId } from "@/lib/waha/message-id";
|
||||
import { resolveWahaChatId } from "@/lib/waha/send";
|
||||
import type { FetchedMedia } from "@/lib/messaging/media/types";
|
||||
import { DETALHE_CREDENCIAL_RECUSADA } from "../health";
|
||||
import type { ChannelAdapter, ChannelHealth, OutboundEnvelope, RecipientInput } from "../types";
|
||||
|
||||
/**
|
||||
* O HTTP que o WAHA devolveu, lido do PREFIXO da mensagem de erro.
|
||||
*
|
||||
* `lib/waha/client.ts` lança `waha_<status>` ou `waha_<operação>_<status>`, às
|
||||
* vezes seguido do corpo da resposta. Procurar `"404"` com `includes` — como
|
||||
* este arquivo fazia — varreria o CORPO junto: um `waha_stop_500: {"detail":
|
||||
* "upstream 404"}` viraria "sessão parada", dando um transporte quebrado por
|
||||
* explicado.
|
||||
*
|
||||
* Medido, e sem inflar: pelo caminho do `checkHealth` isso NÃO era alcançável
|
||||
* hoje — quem ele chama é `getSessionQr`, e essa lança `waha_<status>` seco,
|
||||
* sem corpo. A troca é robustez, não o conserto de um defeito observado; o que
|
||||
* conserta o defeito observado é o ramo 401/403 abaixo.
|
||||
*/
|
||||
export function statusHttpDoErroWaha(msg: string): number | null {
|
||||
const m = /^waha_(?:[a-z]+_)?(\d{3})\b/.exec(msg);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export const wahaAdapter: ChannelAdapter = {
|
||||
provider: "waha",
|
||||
|
||||
@@ -104,7 +124,27 @@ export const wahaAdapter: ChannelAdapter = {
|
||||
return { reachable: true, status: r.status ?? null, detail: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "erro_desconhecido";
|
||||
if (msg.includes("404")) return { reachable: true, status: "STOPPED", detail: null };
|
||||
const http = statusHttpDoErroWaha(msg);
|
||||
|
||||
// Sessão não existe no transporte → parada. É o único desfecho em que
|
||||
// dá para AFIRMAR o estado da sessão a partir de um erro.
|
||||
if (http === 404) return { reachable: true, status: "STOPPED", detail: null };
|
||||
|
||||
// A chave foi recusada. Não é o estado da sessão que está em jogo — é o
|
||||
// acesso ao transporte inteiro, e enquanto durar NENHUMA conexão
|
||||
// funciona. Continua `reachable: false` porque de fato não se sabe o
|
||||
// estado da sessão; o que muda é o `detail`, que a Central lê para dizer
|
||||
// ao operador que escanear o QR não vai resolver.
|
||||
//
|
||||
// Sem isto, um 401 caía no ramo genérico e virava "Não foi possível
|
||||
// verificar a conexão" — um aviso `warn` que descreve oscilação de rede.
|
||||
// Numa VPS real isso durou TRÊS DIAS: a chave do WAHA tinha sido trocada
|
||||
// por uma segunda cópia do repo, nada funcionava, e a única pista visível
|
||||
// sugeria um soluço passageiro.
|
||||
if (http === 401 || http === 403) {
|
||||
return { reachable: false, status: null, detail: DETALHE_CREDENCIAL_RECUSADA };
|
||||
}
|
||||
|
||||
return { reachable: false, status: null, detail: msg.slice(0, 200) };
|
||||
}
|
||||
},
|
||||
|
||||
+35
-4
@@ -52,6 +52,16 @@ export const STATUS_QUE_AVISAM = ["SCAN_QR_CODE", "FAILED", "STOPPED"] as const;
|
||||
/** Marca os avisos desta origem, para resolver o do canal CERTO depois. */
|
||||
export const REF_KIND_SESSAO = "channel_session";
|
||||
|
||||
/**
|
||||
* O transporte respondeu, e a resposta foi "sua credencial não vale".
|
||||
*
|
||||
* Vive aqui, e não no adapter, porque quem lê este código é a regra de aviso
|
||||
* logo abaixo: é um contrato entre os dois, e um literal repetido nas duas
|
||||
* pontas divergiria no primeiro renomeio — com o gate verde, porque nenhum
|
||||
* `includes` reclama de string que ninguém mais escreve.
|
||||
*/
|
||||
export const DETALHE_CREDENCIAL_RECUSADA = "credencial_recusada_pelo_transporte";
|
||||
|
||||
/**
|
||||
* Marca do episódio aberto por um EMPURRÃO do provedor.
|
||||
*
|
||||
@@ -84,11 +94,32 @@ export interface SaudeObservada {
|
||||
* pergunta de quem lê o aviso é exatamente essa.
|
||||
*/
|
||||
export function avisoDaConexao(saude: SaudeObservada, apelido: string): AvisoDeConexao | null {
|
||||
// Não deu para perguntar. NÃO é o mesmo que estar caído, e o aviso não pode
|
||||
// afirmar o que não sabe: a ação aqui é olhar o serviço, não escanear um QR.
|
||||
// `warn` e não `critical` porque uma oscilação de rede cabe neste ramo, e
|
||||
// gritar por ela ensinaria o operador a ignorar a cor.
|
||||
if (!saude.reachable) {
|
||||
// "Não deu para perguntar" tem DOIS motivos que pedem ações opostas, e
|
||||
// tratá-los igual foi o defeito medido: numa VPS real a chave do WAHA foi
|
||||
// trocada por uma segunda cópia do repo, TUDO parou, e por três dias a
|
||||
// Central mostrou apenas um `warn` dizendo "não foi possível verificar" —
|
||||
// a frase que se usa para descrever um soluço de rede. O dono só descobriu
|
||||
// ao tentar conectar um número e ver o 401 na cara.
|
||||
//
|
||||
// Quando o transporte RESPONDE recusando a credencial, não há incerteza
|
||||
// sobre a causa nem sobre o que fazer — e escanear o QR, que é o reflexo de
|
||||
// quem lê "conexão caída", não resolve nada.
|
||||
if (saude.detail === DETALHE_CREDENCIAL_RECUSADA) {
|
||||
return {
|
||||
kind: "channel_number_alert",
|
||||
severity: "critical",
|
||||
title: `Conexão "${apelido}": o servidor de WhatsApp recusou a chave de acesso`,
|
||||
body:
|
||||
"Escanear o QR não resolve: a chave que o CRM usa para falar com o servidor de WhatsApp não confere com a que o servidor espera. Enquanto isso durar, nenhuma mensagem entra nem sai por NENHUMA conexão. Quem cuida do servidor precisa conferir a WAHA_API_KEY do .env e recriar o contêiner do WhatsApp.",
|
||||
episodio: "CREDENCIAL_RECUSADA",
|
||||
};
|
||||
}
|
||||
|
||||
// Não deu para perguntar mesmo. NÃO é o mesmo que estar caído, e o aviso
|
||||
// não pode afirmar o que não sabe: a ação aqui é olhar o serviço, não
|
||||
// escanear um QR. `warn` e não `critical` porque uma oscilação de rede cabe
|
||||
// neste ramo, e gritar por ela ensinaria o operador a ignorar a cor.
|
||||
return {
|
||||
kind: "channel_number_alert",
|
||||
severity: "warn",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Qual número o transporte está mesmo atendendo nesta conexão.
|
||||
*
|
||||
* ─── O defeito, medido em produção ──────────────────────────────────────────
|
||||
*
|
||||
* A rota de saúde da conexão gravava o número assim:
|
||||
*
|
||||
* if (jid && !phoneNumber) phoneNumber = jid.replace(/@.*\/, "");
|
||||
*
|
||||
* — só quando a coluna ainda estava VAZIA. O primeiro pareamento gravava, e
|
||||
* dali em diante o valor era imutável. Re-parear a conexão com OUTRO aparelho é
|
||||
* exatamente o que o dono faz quando o WhatsApp cai, e o banco seguia dizendo o
|
||||
* número antigo para sempre.
|
||||
*
|
||||
* Medido numa instalação real: a conexão de produção atendia `551148633324`, e
|
||||
* o banco dizia `553198966398` — o número de um pareamento anterior, de outra
|
||||
* organização. Os 23 avisos abertos na Central nomeavam o número errado.
|
||||
*
|
||||
* Isso não é cosmético. `health.ts` escolhe o apelido do aviso justamente para
|
||||
* responder "QUAL conexão caiu?" — a primeira pergunta de quem lê. Com o dado
|
||||
* errado, o aviso manda o operador pegar o celular errado, e a impressão que
|
||||
* fica é "esse negócio vive caindo e eu nunca consigo reconectar".
|
||||
*
|
||||
* ─── Por que não basta gravar sempre ────────────────────────────────────────
|
||||
*
|
||||
* Porque o `me` do WAHA é o do ÚLTIMO pareamento que vingou, e ele continua
|
||||
* sendo servido enquanto a sessão está fora do ar. Medido no mesmo dia: com
|
||||
* duas sessões em `FAILED`, a API devolvia o MESMO `me` para as duas — o de uma
|
||||
* delas. Gravar isso trocaria um número errado por outro, e ainda por cima
|
||||
* poderia colidir com a trava de número único do banco.
|
||||
*
|
||||
* Só `WORKING` é observação: é o estado em que o transporte fala do aparelho
|
||||
* que ele está de fato atendendo agora.
|
||||
*/
|
||||
|
||||
/** O estado em que o `me` do transporte descreve o aparelho de verdade. */
|
||||
const STATUS_EM_QUE_O_NUMERO_VALE = "WORKING";
|
||||
|
||||
export function numeroObservadoDaSessao(input: {
|
||||
/** JID como o transporte devolve: `5511999998888@c.us`. */
|
||||
jid: string | null | undefined;
|
||||
/** Status lido AGORA, não o que estava no banco. */
|
||||
statusAoVivo: string | null | undefined;
|
||||
/** O que já está gravado — devolvido de volta quando não há observação boa. */
|
||||
gravado: string | null;
|
||||
}): string | null {
|
||||
if (!input.jid) return input.gravado;
|
||||
if ((input.statusAoVivo ?? "").toUpperCase() !== STATUS_EM_QUE_O_NUMERO_VALE) {
|
||||
return input.gravado;
|
||||
}
|
||||
const numero = input.jid.replace(/@.*/, "").trim();
|
||||
// JID sem parte local (`@c.us`) não descreve aparelho nenhum: manter o que
|
||||
// está gravado é melhor que apagar o único dado que a tela tinha.
|
||||
return numero || input.gravado;
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"test:journeys": "playwright test -c tests/journeys/playwright.config.ts",
|
||||
"test:unit": "vitest run",
|
||||
"test:db": "bash scripts/test-db.sh",
|
||||
"test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash hostgator-setup-kit/test-validators.sh",
|
||||
"test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/dono-do-projeto.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash hostgator-setup-kit/test-validators.sh",
|
||||
"test:invariants": "bash scripts/test-db.sh",
|
||||
"lint:channels": "tsx scripts/lint-channels.ts",
|
||||
"gov:verify": "pnpm typecheck && pnpm lint && pnpm lint:channels && pnpm test:unit",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prova do guarda que impede DUAS cópias do repo de disputarem os mesmos
|
||||
# contêineres — `recusar_projeto_de_outra_arvore` em `_common.sh`, e os dois
|
||||
# call sites que o usam (`agent.sh` e `update.sh`).
|
||||
#
|
||||
# bash tests/shell/dono-do-projeto.test.sh
|
||||
#
|
||||
# ── O defeito que ele guarda, medido numa VPS de verdade ────────────────────
|
||||
#
|
||||
# `/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` — o clone de produção e um
|
||||
# de teste ao lado — têm o mesmo basename, logo o mesmo nome de projeto compose
|
||||
# (`deskcommcrm`). O cron rodava o agent.sh das DUAS a cada 5 minutos. Em
|
||||
# 21/08 13:30 o clone de teste recriou o contêiner do WhatsApp com a chave do
|
||||
# .env dele; às 14:47 o app foi recriado da árvore de produção, com outra chave.
|
||||
# Resultado: `waha_create_401` em toda chamada, por três dias, nenhum número
|
||||
# conectando — e a única pista na tela era "não foi possível verificar".
|
||||
#
|
||||
# O `flock` do agent.sh não pega isso: ele tranca por DIRETÓRIO, e as duas
|
||||
# árvores pegam locks diferentes enquanto disputam o mesmo parque.
|
||||
#
|
||||
# Nada aqui toca a máquina de quem roda: `docker` é um dublê que devolve os
|
||||
# labels que o teste manda.
|
||||
set -uo pipefail
|
||||
|
||||
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../hostgator-setup-kit" && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
FAILS=0
|
||||
check() { # check <descrição> <comando...>
|
||||
if "${@:2}"; then printf ' ✓ %s\n' "$1"; else printf ' ✗ %s\n' "$1"; FAILS=$((FAILS + 1)); fi
|
||||
}
|
||||
|
||||
# ── Dublê de docker ──────────────────────────────────────────────────────────
|
||||
# Devolve, uma por linha, as árvores em $DONOS — que é o que
|
||||
# `docker ps --format '{{.Label "...working_dir"}}'` imprime de verdade.
|
||||
mkdir -p "$WORK/bin"
|
||||
cat > "$WORK/bin/docker" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" ps "*) printf '%s\n' ${DONOS:-} ;;
|
||||
*) : ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$WORK/bin/docker"
|
||||
PATH="$WORK/bin:$PATH"
|
||||
|
||||
# ── Duas árvores de verdade no disco ─────────────────────────────────────────
|
||||
# Precisam existir COM um compose: o guarda só conta como rival a instalação que
|
||||
# ainda está no disco — quem apenas moveu a pasta deixa contêineres apontando
|
||||
# para um caminho morto, e travar esse caso seria um gate nascendo vermelho em
|
||||
# quem não fez nada de errado.
|
||||
PROD="$WORK/root/DeskcommCRM"
|
||||
TESTE="$WORK/root/apagar6/DeskcommCRM"
|
||||
MUDOU_DE_PASTA="$WORK/root/endereco-antigo" # de propósito: NÃO é criado
|
||||
mkdir -p "$PROD" "$TESTE"
|
||||
touch "$PROD/docker-compose.prod.yml" "$TESTE/docker-compose.prod.yml"
|
||||
|
||||
# `recusar_...` roda numa subshell para que o `set -e` do _common.sh e um
|
||||
# eventual `exit` não derrubem este arquivo de teste.
|
||||
guarda() { # guarda <PROJECT_DIR> [DONOS...] → exit code; mensagem no stdout
|
||||
local dir="$1"; shift
|
||||
(
|
||||
DONOS="$*" PROJECT_DIR="$dir" \
|
||||
bash -c '. "$0"/_common.sh 2>/dev/null || true
|
||||
PROJECT_DIR="$1"
|
||||
recusar_projeto_de_outra_arvore' "$KIT_DIR" "$dir" 2>&1
|
||||
)
|
||||
}
|
||||
|
||||
printf '\n▶ o guarda em si\n'
|
||||
|
||||
saida="$(guarda "$PROD")"; rc=$?
|
||||
check "sem contêiner no ar, a instalação nova assume (rc=0)" test "$rc" -eq 0
|
||||
|
||||
saida="$(guarda "$PROD" "$PROD")"; rc=$?
|
||||
check "parque criado pela MESMA árvore segue (rc=0)" test "$rc" -eq 0
|
||||
|
||||
saida="$(guarda "$PROD" "$TESTE")"; rc=$?
|
||||
check "parque criado por OUTRA árvore é recusado (rc≠0)" test "$rc" -ne 0
|
||||
check "a recusa nomeia a árvore intrusa" grep -q "apagar6" <<<"$saida"
|
||||
check "a recusa nomeia a árvore corrente" grep -qF "$PROD" <<<"$saida"
|
||||
check "a recusa ensina a saída (crontab)" grep -q "crontab" <<<"$saida"
|
||||
|
||||
# O caso REAL da VPS: app/waha de uma árvore, redis/srh da outra. Uma checagem
|
||||
# que olhasse só o PRIMEIRO contêiner daria o parque por são.
|
||||
saida="$(guarda "$PROD" "$PROD" "$TESTE" "$PROD")"; rc=$?
|
||||
check "parque MISTO (o caso medido) é recusado" test "$rc" -ne 0
|
||||
|
||||
saida="$(DESKCOMM_ASSUMIR_PROJETO=1 guarda "$PROD" "$TESTE")"; rc=$?
|
||||
check "DESKCOMM_ASSUMIR_PROJETO=1 é a saída explícita (rc=0)" test "$rc" -eq 0
|
||||
|
||||
# O gate não pode nascer vermelho em quem só mudou a instalação de pasta: os
|
||||
# contêineres seguem apontando para o endereço antigo, que já não existe. Aquilo
|
||||
# não é um rival — é esta mesma instalação, no endereço de ontem.
|
||||
saida="$(guarda "$PROD" "$MUDOU_DE_PASTA")"; rc=$?
|
||||
check "instalação MOVIDA de pasta não é rival (rc=0)" test "$rc" -eq 0
|
||||
|
||||
# Mas se a árvore antiga ainda está lá com um compose, ela PODE rodar um segundo
|
||||
# cron — e aí é rival de novo.
|
||||
saida="$(guarda "$PROD" "$TESTE" "$MUDOU_DE_PASTA")"; rc=$?
|
||||
check "árvore morta + árvore VIVA: a viva ainda faz recusar" test "$rc" -ne 0
|
||||
check "e a recusa não cita o endereço morto" bash -c '! grep -qF "$1" <<<"$2"' _ "$MUDOU_DE_PASTA" "$saida"
|
||||
|
||||
printf '\n▶ os call sites (guardar a função não basta se ninguém a chama)\n'
|
||||
|
||||
check "agent.sh chama o guarda e desiste" \
|
||||
grep -qE '^recusar_projeto_de_outra_arvore .*\|\| exit 0' "$KIT_DIR/agent.sh"
|
||||
check "update.sh chama o guarda e morre" \
|
||||
grep -qE '^recusar_projeto_de_outra_arvore .*\|\| die ' "$KIT_DIR/update.sh"
|
||||
|
||||
# O guarda do agent.sh precisa vir ANTES do POST que anuncia a versão: uma cópia
|
||||
# que não é dona anunciaria a versão da árvore DELA, e o app ofereceria
|
||||
# "Atualizar agora" com base num número que não descreve o que está no ar.
|
||||
linha_guarda="$(grep -n '^recusar_projeto_de_outra_arvore' "$KIT_DIR/agent.sh" | cut -d: -f1)"
|
||||
linha_post="$(grep -n '^RESP="\$(post ' "$KIT_DIR/agent.sh" | cut -d: -f1)"
|
||||
check "no agent.sh o guarda vem ANTES de anunciar a versão" \
|
||||
test -n "$linha_guarda" -a -n "$linha_post" -a "${linha_guarda:-9999}" -lt "${linha_post:-0}"
|
||||
|
||||
printf '\n'
|
||||
if [ "$FAILS" -gt 0 ]; then printf '✗ %d falha(s)\n' "$FAILS"; exit 1; fi
|
||||
printf '✓ guarda do dono do projeto: tudo verde\n'
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getAdapter } from '@/lib/channels';
|
||||
import { DETALHE_CREDENCIAL_RECUSADA } from '@/lib/channels/health';
|
||||
import { statusHttpDoErroWaha } from '@/lib/channels/adapters/waha';
|
||||
|
||||
/** A organização atravessa o seam desde a issue #236. */
|
||||
const ORG = "00000000-0000-4000-8000-000000000236";
|
||||
@@ -145,4 +147,71 @@ describe('adapter WAHA', () => {
|
||||
convert: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ─── O que estes casos custaram ────────────────────────────────────────────
|
||||
*
|
||||
* Numa VPS de produção, uma segunda cópia do repo recriou o contêiner do WAHA
|
||||
* com a chave do `.env` DELA. A partir dali toda chamada respondia 401. O
|
||||
* `checkHealth` jogava isso no ramo genérico, a Central dizia "não foi
|
||||
* possível verificar a conexão" — a frase de um soluço de rede — e ficou
|
||||
* assim por TRÊS DIAS, com o WhatsApp inteiro parado.
|
||||
*/
|
||||
describe('checkHealth traduz o erro do transporte', () => {
|
||||
/** Sobe o WAHA e faz `fetch` responder um HTTP cru (o client lança daí). */
|
||||
function stubWahaHttp(status: number, body = '{}') {
|
||||
vi.stubEnv('WAHA_API_BASE_URL', WAHA_BASE);
|
||||
vi.stubEnv('WAHA_API_KEY', 'hash123');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status })));
|
||||
}
|
||||
|
||||
it('401 é credencial recusada, não "não deu para perguntar"', async () => {
|
||||
stubWahaHttp(401, '{"message":"Unauthorized","statusCode":401}');
|
||||
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
|
||||
expect(h.detail).toBe(DETALHE_CREDENCIAL_RECUSADA);
|
||||
// Segue sem afirmar o estado da SESSÃO: o que se sabe é que o acesso ao
|
||||
// transporte foi negado, não que este número caiu.
|
||||
expect(h.status).toBeNull();
|
||||
});
|
||||
|
||||
it('403 idem — chave existe e não vale', async () => {
|
||||
stubWahaHttp(403);
|
||||
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
|
||||
expect(h.detail).toBe(DETALHE_CREDENCIAL_RECUSADA);
|
||||
});
|
||||
|
||||
it('404 é sessão parada — o único estado que dá para afirmar por erro', async () => {
|
||||
stubWahaHttp(404);
|
||||
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
|
||||
expect(h).toEqual({ reachable: true, status: 'STOPPED', detail: null });
|
||||
});
|
||||
|
||||
it('500 fica em "não sei" — inventar estado é o que ensina a ignorar o aviso', async () => {
|
||||
stubWahaHttp(500);
|
||||
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
|
||||
expect(h.reachable).toBe(false);
|
||||
expect(h.status).toBeNull();
|
||||
expect(h.detail).not.toBe(DETALHE_CREDENCIAL_RECUSADA);
|
||||
});
|
||||
|
||||
it('lê o status do PREFIXO, nas duas formas que o client lança', () => {
|
||||
// Duas formas porque `lib/waha/client.ts` tem duas: `getSessionQr` lança
|
||||
// `waha_<status>` seco, e create/start/stop/logout/delete anexam o CORPO
|
||||
// da resposta. Só a primeira chega ao checkHealth hoje — mas a função é
|
||||
// compartilhada, e é o corpo que torna a leitura por `includes` ambígua.
|
||||
expect(statusHttpDoErroWaha('waha_401')).toBe(401);
|
||||
expect(statusHttpDoErroWaha('waha_create_401: {"message":"Unauthorized"}')).toBe(401);
|
||||
expect(statusHttpDoErroWaha('waha_404')).toBe(404);
|
||||
|
||||
// O caso que `includes("404")` erra: o 404 está no CORPO de um 500. Ler o
|
||||
// prefixo devolve o status de verdade; varrer a string inteira daria 404
|
||||
// e transformaria um transporte quebrado em "essa sessão está parada".
|
||||
expect(statusHttpDoErroWaha('waha_stop_500: {"detail":"upstream 404 not found"}')).toBe(500);
|
||||
|
||||
// Nada que não venha do client vira status — inclusive texto que CONTÉM
|
||||
// um número de três dígitos.
|
||||
expect(statusHttpDoErroWaha('fetch failed 401')).toBeNull();
|
||||
expect(statusHttpDoErroWaha('erro_desconhecido')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
* filtrar por organização parece certo até haver dois números ligados.
|
||||
*/
|
||||
import {
|
||||
DETALHE_CREDENCIAL_RECUSADA,
|
||||
STATUS_QUE_AVISAM,
|
||||
avisoDaConexao,
|
||||
sincronizarSaudeDaConexao,
|
||||
@@ -74,6 +75,31 @@ describe("quando avisar", () => {
|
||||
expect(a?.episodio).toBe("UNREACHABLE");
|
||||
});
|
||||
|
||||
it("credencial RECUSADA é crítica, e diz que o QR não resolve", () => {
|
||||
// O irmão do caso acima, e o que ele custou: os dois entram por
|
||||
// `reachable: false`, mas pedem ações opostas. Numa VPS real a chave do
|
||||
// WAHA foi trocada por uma segunda cópia do repo; tudo parou; e por TRÊS
|
||||
// DIAS a Central mostrou só o `warn` de "não foi possível verificar" — a
|
||||
// frase de uma oscilação passageira. Quem lê "conexão caída" corre atrás do
|
||||
// QR, e o QR não conserta chave errada.
|
||||
const a = avisoDaConexao(
|
||||
{ reachable: false, status: null, detail: DETALHE_CREDENCIAL_RECUSADA },
|
||||
"Vendas",
|
||||
);
|
||||
expect(a?.severity).toBe("critical");
|
||||
expect(a?.episodio).toBe("CREDENCIAL_RECUSADA");
|
||||
expect(a?.title).toContain("Vendas");
|
||||
expect(a?.body).toMatch(/QR não resolve/i);
|
||||
// Episódio PRÓPRIO: se dividisse "UNREACHABLE" com o caso acima, a troca de
|
||||
// um pelo outro não abriria aviso nenhum — o dedup por episódio veria o
|
||||
// mesmo valor e ficaria calado justamente na piora.
|
||||
const oscilacao = avisoDaConexao(
|
||||
{ reachable: false, status: null, detail: "ECONNREFUSED" },
|
||||
"Vendas",
|
||||
);
|
||||
expect(a?.episodio).not.toBe(oscilacao?.episodio);
|
||||
});
|
||||
|
||||
it("estado desconhecido não vira aviso — o vocabulário é do transporte", () => {
|
||||
expect(avisoDaConexao({ reachable: true, status: "ALGO_NOVO", detail: null }, "V")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { numeroObservadoDaSessao } from "@/lib/channels/numero-observado";
|
||||
|
||||
/**
|
||||
* ─── O defeito, medido numa instalação real ────────────────────────────────
|
||||
*
|
||||
* A rota de saúde gravava o número só quando a coluna estava VAZIA
|
||||
* (`if (jid && !phoneNumber)`). O primeiro pareamento gravava; dali em diante o
|
||||
* valor era imutável. Re-parear com outro aparelho — o que o dono faz toda vez
|
||||
* que o WhatsApp cai — deixava o banco mentindo para sempre.
|
||||
*
|
||||
* O que se mediu: a conexão de produção atendia `551148633324`, o banco dizia
|
||||
* `553198966398` (um pareamento anterior, de OUTRA organização), e os 23 avisos
|
||||
* abertos na Central nomeavam o número errado. O aviso existe para responder
|
||||
* "QUAL conexão caiu?"; com o dado errado ele manda pegar o celular errado.
|
||||
*/
|
||||
const LIA = "551148633324";
|
||||
const ANTIGO = "553198966398";
|
||||
|
||||
describe("qual número o transporte está mesmo atendendo", () => {
|
||||
it("corrige o número quando o aparelho muda — o defeito medido", () => {
|
||||
expect(
|
||||
numeroObservadoDaSessao({
|
||||
jid: `${LIA}@c.us`,
|
||||
statusAoVivo: "WORKING",
|
||||
gravado: ANTIGO,
|
||||
}),
|
||||
).toBe(LIA);
|
||||
});
|
||||
|
||||
it("preenche quando ainda não havia nada — o caso que já funcionava", () => {
|
||||
expect(
|
||||
numeroObservadoDaSessao({ jid: `${LIA}@c.us`, statusAoVivo: "WORKING", gravado: null }),
|
||||
).toBe(LIA);
|
||||
});
|
||||
|
||||
it("FORA de WORKING não grava: o `me` é o do último pareamento que vingou", () => {
|
||||
// Medido no mesmo dia: com DUAS sessões em FAILED, a API do WAHA devolvia o
|
||||
// MESMO `me` para as duas — o de uma delas. Gravar ali trocaria um número
|
||||
// errado por outro, e ainda poderia bater na trava de número único do banco.
|
||||
for (const status of ["FAILED", "STOPPED", "STARTING", "SCAN_QR_CODE"]) {
|
||||
expect(
|
||||
numeroObservadoDaSessao({ jid: `${ANTIGO}@c.us`, statusAoVivo: status, gravado: LIA }),
|
||||
).toBe(LIA);
|
||||
}
|
||||
});
|
||||
|
||||
it("status ausente também não grava — sem observação não há o que afirmar", () => {
|
||||
expect(
|
||||
numeroObservadoDaSessao({ jid: `${ANTIGO}@c.us`, statusAoVivo: null, gravado: LIA }),
|
||||
).toBe(LIA);
|
||||
});
|
||||
|
||||
it("sem jid mantém o que está gravado", () => {
|
||||
expect(
|
||||
numeroObservadoDaSessao({ jid: null, statusAoVivo: "WORKING", gravado: LIA }),
|
||||
).toBe(LIA);
|
||||
});
|
||||
|
||||
it("jid sem parte local não APAGA o número que a tela já mostrava", () => {
|
||||
expect(
|
||||
numeroObservadoDaSessao({ jid: "@c.us", statusAoVivo: "WORKING", gravado: LIA }),
|
||||
).toBe(LIA);
|
||||
});
|
||||
|
||||
it("aceita o status em minúsculas — o vocabulário vem do transporte", () => {
|
||||
expect(
|
||||
numeroObservadoDaSessao({ jid: `${LIA}@c.us`, statusAoVivo: "working", gravado: ANTIGO }),
|
||||
).toBe(LIA);
|
||||
});
|
||||
});
|
||||
|
||||
describe("o call site — guardar a função não basta se a rota não a usa", () => {
|
||||
it("a rota de saúde da conexão chama a função, e não grava o jid na mão", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const fonte = readFileSync("app/api/v1/channel-sessions/[id]/route.ts", "utf-8");
|
||||
expect(fonte).toContain("numeroObservadoDaSessao({");
|
||||
// A forma antiga, que é o defeito: preencher só quando está vazio.
|
||||
expect(fonte).not.toMatch(/if\s*\(jid\s*&&\s*!phoneNumber\)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user