mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5.5
parent
1fcd98c7b5
commit
991390b3d0
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user