feat(grupos): o filtro de grupos do WAHA passa a ser decidido pela funcionalidade de grupos

CHAVES_DO_FILTRO_FIXAS (status/broadcast/channels, sem groups) passa a ser o
que compatibleSession, convergirConfigDaSessao e startExistingSession
impõem. groups fica de fora de propósito: quem decide agora é a nova
definirRecebimentoDeGrupos, a partir de channel_session_groups (Task 4).
createSession continua criando com CONVERSAS_IGNORADAS (groups:true) —
sessão nova nasce ignorando grupos.

Novos métodos em WahaClient:
- listarGrupos(session): parseia GET /api/{session}/groups. A Task 0 (sonda
  do WAHA real 2026.7.2/NOWEB) mediu que o endpoint devolve OBJETO chaveado
  por id, não array — o brief original assumia array. group.id é sempre
  string simples (nunca {_serialized}), em formato moderno ou legado
  (<telefone>-<timestamp>@g.us); ambos terminam em @g.us.
- definirRecebimentoDeGrupos(name, receber): PUT ignore.groups + confirma
  relendo. Só devolve true quando o GET seguinte confirma
  ignore.groups === !receber — o WAHA pode responder 200 sem aplicar
  (issue melgarafael/DeskcommCRM#1428). Não chama startExistingSession: a
  Task 0 mediu que o próprio WAHA reinicia a sessão sozinho após o PUT
  (STARTING -> WORKING em <=5s); em vez disso poll bounded (~10s) até sair
  de STARTING antes de confirmar.

Ajuste em convergirConfigDaSessao além do previsto no brief: o gate inicial
trocou de compatibleSession(parsed.data) inteiro para só checar engine. O
brief mantinha o gate original, mas isso reprovava o caso novo "a
convergência ainda corrige as outras chaves e preserva groups" (ignore com
status:false deveria ser corrigido) — compatibleSession exige TODAS as
chaves do filtro já corretas, o que contradiz o próprio propósito de
convergir um filtro que drift. groups continua fora do que este método
escreve (preserva o valor atual).

client.test.ts: removido o caso "filtro explícito incompatível"
({ignore:{groups:false}}) do it.each de conflito de create — groups:false
sozinho agora é COMPATÍVEL (é essa a mudança de propósito desta task).
Substituído por "outra chave do filtro incompatível" (status:false) para
manter a cobertura do caminho de recusa por chave fixa incompatível.

Sabotagem (não a do brief, que sabotava compatibleSession — sem efeito após
o gate acima ser removido de convergirConfigDaSessao): reverter o filter()
que exclui "groups" de CHAVES_DO_FILTRO_FIXAS. Previsto e confirmado: red
exatamente em "sessão com groups=false continua compatível e a convergência
NÃO a reverte" (68/69 continuam verdes). Restaurado.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Josicleuton da Silva Braga
2026-09-25 01:03:06 -05:00
co-authored by Claude Opus 5.5
parent 1fcd98c7b5
commit 991390b3d0
3 changed files with 257 additions and 12 deletions
+123
View File
@@ -0,0 +1,123 @@
/**
* Grupos no cliente do WAHA — a Task 0 mediu o WAHA real e o formato NÃO bate
* com o que o brief original assumia:
*
* - `GET /api/{session}/groups` devolve um OBJETO chaveado por id de grupo
* (`{ "<id>@g.us": GroupObject, ... }`), não um array. A fixture guarda os
* grupos como array (`fixture.groups`) só porque é mais fácil de escrever
* à mão; o servidor falso abaixo os re-chaveia por `id` antes de responder,
* para exercitar a MESMA forma que o WAHA real devolve.
* - `group.id` é string simples (nunca `{ _serialized }`), formato moderno
* `<18 dígitos>@g.us` OU legado `<telefone>-<timestamp>@g.us` — os dois
* terminam em `@g.us` e são o que `listarGrupos` precisa aceitar.
*
* Ver .superpowers/sdd/2026-09-23-grupos-na-inbox/task-0-report.md.
*/
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { readFileSync } from "node:fs";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { WahaClient } from "./client";
const fixture = JSON.parse(
readFileSync("lib/waha/__fixtures__/grupos-noweb-2026.7.2.json", "utf8"),
) as { groups: Array<{ id: string; subject: string }> };
/** O WAHA real devolve objeto chaveado por id (Task 0) — não o array cru da fixture. */
const gruposComoOWahaDevolve = Object.fromEntries(fixture.groups.map((g) => [g.id, g]));
let server: Server;
let base = "";
let sessao: {
name: string;
status: string;
engine: { engine: string };
config: { ignore: Record<string, boolean>; webhooks: unknown[] };
};
let putsRecebidos: unknown[] = [];
/** Controla o caso "PUT responde 200 mas não aplica" sem depender de timing (regra do controlador). */
let ignorarProximoPut = false;
beforeAll(async () => {
server = createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
res.setHeader("content-type", "application/json");
if (req.url?.endsWith("/groups")) return res.end(JSON.stringify(gruposComoOWahaDevolve));
if (req.url === "/api/sessions/s1" && req.method === "GET") return res.end(JSON.stringify(sessao));
if (req.url === "/api/sessions/s1" && req.method === "PUT") {
const j = JSON.parse(body);
putsRecebidos.push(j);
if (!ignorarProximoPut) sessao = { ...sessao, config: j.config };
ignorarProximoPut = false;
return res.end(JSON.stringify(sessao));
}
if (req.url === "/api/server/version") return res.end(JSON.stringify({ engine: "NOWEB" }));
res.statusCode = 404;
res.end("{}");
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise<void>((r) => server.close(() => r())));
beforeEach(() => {
putsRecebidos = [];
ignorarProximoPut = false;
sessao = {
name: "s1",
status: "WORKING",
engine: { engine: "NOWEB" },
config: { ignore: { status: true, broadcast: true, channels: true, groups: true }, webhooks: [] },
};
});
const cliente = () => new WahaClient(base, "chave-teste");
describe("grupos no cliente do WAHA", () => {
it("lista grupos como { chatId, subject } a partir do formato real medido", async () => {
const grupos = await cliente().listarGrupos("s1");
expect(grupos.length).toBeGreaterThan(0);
expect(grupos.length).toBe(fixture.groups.length);
for (const g of grupos) {
expect(g.chatId).toMatch(/@g\.us$/);
expect(typeof g.subject === "string" || g.subject === null).toBe(true);
}
});
it("ligar grupos grava ignore.groups=false preservando o resto do config, e confirma relendo", async () => {
await expect(cliente().definirRecebimentoDeGrupos("s1", true)).resolves.toBe(true);
expect(putsRecebidos).toHaveLength(1);
expect(
(putsRecebidos[0] as { config: { ignore: Record<string, boolean>; webhooks: unknown } }).config,
).toMatchObject({
ignore: { status: true, broadcast: true, channels: true, groups: false },
webhooks: [],
});
});
it("devolve false quando o WAHA responde 200 mas o GET não reflete a troca", async () => {
ignorarProximoPut = true;
await expect(cliente().definirRecebimentoDeGrupos("s1", true)).resolves.toBe(false);
// o PUT foi de fato tentado — o caso mede "200 sem efeito", não "PUT nunca aconteceu"
expect(putsRecebidos).toHaveLength(1);
});
it("sessão com groups=false continua compatível e a convergência NÃO a reverte", async () => {
sessao.config.ignore.groups = false;
await cliente().convergirConfigDaSessao("s1");
expect(putsRecebidos).toHaveLength(0);
});
it("a convergência ainda corrige as outras chaves e preserva groups", async () => {
sessao.config.ignore = { status: false, broadcast: true, channels: true, groups: false };
await cliente().convergirConfigDaSessao("s1");
expect((putsRecebidos[0] as { config: { ignore: Record<string, boolean> } }).config.ignore).toEqual({
status: true,
broadcast: true,
channels: true,
groups: false,
});
});
});
+5 -1
View File
@@ -343,7 +343,11 @@ describe("sessões: conflito conhecido só converge com identidade e pós-condi
["outra identidade", { name: "outra" }],
["outro engine", { engine: { engine: "WEBJS" } }],
["config inválida", { config: null }],
["filtro explícito incompatível", { config: { ignore: { groups: false } } }],
// "filtro explícito incompatível" (`{ ignore: { groups: false } }`) saiu daqui de
// propósito: a funcionalidade de grupos na inbox torna `groups:false` COMPATÍVEL —
// é `CHAVES_DO_FILTRO_FIXAS` (sem `groups`) que decide compatibilidade agora. Ver
// "sessão com groups=false continua compatível" em client-grupos.test.ts.
["outra chave do filtro incompatível", { config: { ignore: { status: false } } }],
])("conflito de create com %s falha sem tomar a sessão", async (_label, extra) => {
await receive([create(422, duplicate), read(session("STOPPED", extra))], async (c) => {
await expect(c.startSession(name)).rejects.toThrow("waha_create_422");
+129 -11
View File
@@ -54,6 +54,15 @@ export const CONVERSAS_IGNORADAS = {
groups: true,
} as const;
/**
* As chaves do filtro que o CRM IMPÕE. `groups` fica de fora de propósito: desde a
* funcionalidade de grupos na inbox, quem decide `groups` é `definirRecebimentoDeGrupos`,
* a partir de `channel_session_groups`. Compatibilidade e convergência não a tocam.
*/
export const CHAVES_DO_FILTRO_FIXAS = Object.fromEntries(
Object.entries(CONVERSAS_IGNORADAS).filter(([k]) => k !== "groups"),
) as Omit<typeof CONVERSAS_IGNORADAS, "groups">;
/**
* Teto de relógio das chamadas ao WAHA.
*
@@ -235,7 +244,7 @@ export class WahaClient {
const ignore = session.config.ignore;
if (ignore === undefined) return true; // sessão legada; convergência preserva webhooks
if (!ignore || typeof ignore !== "object" || Array.isArray(ignore)) return false;
return Object.entries(CONVERSAS_IGNORADAS).every(([key, value]) =>
return Object.entries(CHAVES_DO_FILTRO_FIXAS).every(([key, value]) =>
!(key in ignore) || (ignore as Record<string, unknown>)[key] === value);
}
@@ -260,7 +269,7 @@ export class WahaClient {
async startSession(name: string): Promise<{ qr?: string; status: string }> {
const creation = await this.createSession(name);
const ignore = creation.session.config?.ignore;
const filtersCurrent = ignore && typeof ignore === "object" && Object.entries(CONVERSAS_IGNORADAS)
const filtersCurrent = ignore && typeof ignore === "object" && Object.entries(CHAVES_DO_FILTRO_FIXAS)
.every(([key, value]) => (ignore as Record<string, unknown>)[key] === value);
if (!creation.created && !filtersCurrent) await this.convergirConfigDaSessao(name);
return this.startExistingSession(name);
@@ -351,14 +360,34 @@ export class WahaClient {
return;
}
const parsed = sessionSnapshotSchema.safeParse(await atual.json().catch(() => null));
if (!parsed.success || parsed.data.name !== name || !(await this.compatibleSession(parsed.data))) {
logger.warn("[waha] a sessão respondeu sem identidade/config compatíveis; não vou reescrevê-la", {});
if (!parsed.success || parsed.data.name !== name) {
logger.warn("[waha] a sessão respondeu sem identidade correta; não vou reescrevê-la", {});
return;
}
const sessao = parsed.data;
if (!sessao.config) return;
const config = { ...sessao.config, ignore: CONVERSAS_IGNORADAS };
// Checa só o ENGINE aqui, não `compatibleSession()` inteiro: aquele também
// exige que o filtro JÁ esteja correto, e o trabalho deste método é
// justamente corrigir um filtro que drift ou — o caso comum — entrou
// como `{}` numa sessão legada. Recusar converger porque o filtro está
// errado seria recusar o próprio propósito da função.
const engine = typeof sessao.engine === "string" ? sessao.engine : sessao.engine?.engine;
const actualEngine = engine ?? (await this.getServerVersion()).engine;
if (actualEngine !== "NOWEB") {
logger.warn("[waha] engine incompatível; não vou reescrever o filtro da sessão", {});
return;
}
// `groups` NÃO entra no que este método impõe: desde a funcionalidade de
// grupos na inbox, quem decide `groups` é `definirRecebimentoDeGrupos`.
// Preserva o valor atual (ou o default de criação, se a sessão nunca
// teve a chave) em vez de reescrevê-lo às cegas.
const ignoreAtual = (typeof sessao.config.ignore === "object" && sessao.config.ignore !== null
? sessao.config.ignore
: {}) as Record<string, unknown>;
const groupsAtual = typeof ignoreAtual.groups === "boolean" ? ignoreAtual.groups : CONVERSAS_IGNORADAS.groups;
const config = { ...sessao.config, ignore: { ...CHAVES_DO_FILTRO_FIXAS, groups: groupsAtual } };
// Já está como queremos: não reiniciar a sessão à toa. Este caminho roda
// em TODA reconexão, e um restart desnecessário por rodada seria pior que
// o gasto que ele evita.
@@ -366,12 +395,7 @@ export class WahaClient {
// à ORDEM das chaves, então o dia em que o WAHA devolver o mesmo objeto
// com as chaves noutra sequência, esta guarda passa a dizer "mudou" e a
// sessão reinicia a cada reconexão — sem que nada tenha mudado.
const jaConvergida =
typeof sessao.config.ignore === "object" &&
sessao.config.ignore !== null &&
Object.entries(CONVERSAS_IGNORADAS).every(
([k, v]) => (sessao.config!.ignore as Record<string, unknown>)[k] === v,
);
const jaConvergida = Object.entries(CHAVES_DO_FILTRO_FIXAS).every(([k, v]) => ignoreAtual[k] === v);
if (jaConvergida) return;
const res = await this.fetchComTeto(url, {
@@ -394,6 +418,100 @@ export class WahaClient {
}
}
/**
* Grupos em que o número está. Medido no WAHA real (Task 0,
* `.superpowers/sdd/2026-09-23-grupos-na-inbox/task-0-report.md`):
* `GET /api/{session}/groups` devolve um OBJETO chaveado por id de grupo,
* não um array — mas a tolerância a array também fica, para não quebrar
* contra uma versão futura do WAHA que volte a ele.
*
* `group.id` é sempre string simples (nunca `{ _serialized }`), em dois
* formatos: moderno (`<18 dígitos>@g.us`) e legado
* (`<telefone>-<timestamp>@g.us`, 34/98 grupos reais medidos). Os dois
* terminam em `@g.us`, e é essa a única checagem — não `18 dígitos`.
*/
async listarGrupos(session: string): Promise<Array<{ chatId: string; subject: string | null }>> {
const res = await this.fetchComTeto(`${this.baseUrl}/api/${encodeURIComponent(session)}/groups`, {
headers: { "X-Api-Key": this.apiKey },
});
if (!res.ok) throw new Error(`waha_groups_${res.status}`);
const bruto = (await res.json().catch(() => null)) as unknown;
const lista = Array.isArray(bruto) ? bruto : bruto && typeof bruto === "object" ? Object.values(bruto) : [];
const grupos: Array<{ chatId: string; subject: string | null }> = [];
for (const item of lista) {
if (!item || typeof item !== "object") continue;
const o = item as Record<string, unknown>;
const id = o.id;
const chatId =
typeof id === "string" ? id
: id && typeof id === "object" && typeof (id as Record<string, unknown>)._serialized === "string"
? ((id as Record<string, unknown>)._serialized as string)
: typeof o.JID === "string" ? (o.JID as string) : null;
if (!chatId || !chatId.endsWith("@g.us")) continue;
const subject = typeof o.subject === "string" ? o.subject : typeof o.name === "string" ? o.name : null;
grupos.push({ chatId, subject });
}
return grupos;
}
/**
* Liga ou desliga o recebimento de grupos NESTA sessão. Só devolve `true` quando o GET
* seguinte confirma a troca: o WAHA já respondeu 200 para operação que não aconteceu
* (medido na issue melgarafael/DeskcommCRM#1428).
*
* ─── Por que não chama `startExistingSession` ──────────────────────────────
*
* A Task 0 mediu o WAHA real (2026.7.2, NOWEB): o `PUT` já muda o status para
* `STARTING` sozinho, como efeito colateral, e a sessão volta a `WORKING` por
* conta própria em poucos segundos (≤5s, 6/6 polls) — sem pedido de restart
* manual. Chamar `startExistingSession` aqui seria um segundo restart em cima
* do que o próprio WAHA já dispara.
*
* O que este método faz em vez disso: espera, com teto, a sessão sair de
* `STARTING`, e só então confirma `ignore.groups`. Devolver `true` com a
* sessão ainda reiniciando seria aceitável SE o valor já estivesse
* confirmado — mas medir enquanto ainda está `STARTING` arriscaria ler um
* `config` transitório, então a espera vem antes da leitura que decide.
*/
async definirRecebimentoDeGrupos(name: string, receber: boolean): Promise<boolean> {
const url = `${this.baseUrl}/api/sessions/${encodeURIComponent(name)}`;
const ler = async () => {
const r = await this.fetchComTeto(url, { headers: { "X-Api-Key": this.apiKey } });
if (!r.ok) return null;
const p = sessionSnapshotSchema.safeParse(await r.json().catch(() => null));
return p.success && p.data.name === name ? p.data : null;
};
const atual = await ler();
if (!atual?.config) return false;
const ignore = {
...((typeof atual.config.ignore === "object" && atual.config.ignore) || {}),
...CHAVES_DO_FILTRO_FIXAS,
groups: !receber,
};
const put = await this.fetchComTeto(url, {
method: "PUT",
headers: { "X-Api-Key": this.apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ name, config: { ...atual.config, ignore } }),
});
if (!put.ok) return false;
// Teto de ~10s no total, em passos de 2s: bounded, e curto o bastante para
// não prender a rota que chama isto. Só espera quando a sessão realmente
// está reiniciando (STARTING); no caso comum (fica WORKING o tempo todo,
// como a Task 0 mediu em produção) o laço nem entra.
const TETO_DE_ESPERA_MS = 10_000;
const PASSO_MS = 2_000;
const inicio = Date.now();
let depois = await ler();
while (depois?.status === "STARTING" && Date.now() - inicio < TETO_DE_ESPERA_MS) {
await new Promise((r) => setTimeout(r, PASSO_MS));
depois = await ler();
}
const g = depois?.config && typeof depois.config.ignore === "object" && depois.config.ignore
? (depois.config.ignore as Record<string, unknown>).groups
: undefined;
return g === !receber;
}
/** Remoção só converge depois de GET da identidade exata confirmar ausência. */
async deleteSession(name: string): Promise<void> {
return this.finishSession(name, "delete");