Merge remote-tracking branch 'refs/remotes/pr/305' into triagem/305

This commit is contained in:
Rafael Melgaço
2026-08-24 09:59:27 -03:00
20 changed files with 768 additions and 16 deletions
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
risk-radar.spec.ts invite-lifecycle.spec.ts system-update.spec.ts
agente-papeis-operador.spec.ts prova-painel-provedores.spec.ts
followup-linguagem.spec.ts followup-ramos.spec.ts
wizard-do-funcionario.spec.ts mfa-opcional.spec.ts
wizard-do-funcionario.spec.ts mfa-opcional.spec.ts inbox-responder-citando.spec.ts
# ⚠️ A ORDEM DESTA LISTA NÃO DECIDE A ORDEM DE EXECUÇÃO. O Playwright
# ordena os arquivos por CAMINHO, não pela ordem em que são passados na
# linha de comando. Medido no run 31838253496: `marca-logo.spec.ts` estava
+40 -1
View File
@@ -111,7 +111,7 @@ async function removerEcoDoProprioEnvio(
}
const MSG_COLS =
"id, organization_id, conversation_id, channel_session_id, contact_id, external_id, type, direction, status, ack, error_code, error_message, body, media_url, media_mime, media_size_bytes, media_storage_path, sent_via, sent_by_user_id, sent_at, delivered_at, read_at, metadata, edited_at, revoked_at, created_at";
"id, organization_id, conversation_id, channel_session_id, contact_id, external_id, type, direction, status, ack, error_code, error_message, body, media_url, media_mime, media_size_bytes, media_storage_path, sent_via, sent_by_user_id, sent_at, delivered_at, read_at, metadata, edited_at, revoked_at, reply_to_message_id, created_at";
function actorAuditPayload(actor: Actor): {
actorUserId: string | null;
@@ -410,8 +410,43 @@ export async function sendMessageHandler(
}
const now = new Date().toISOString();
// ─── A CITAÇÃO, e a checagem que ela obriga ────────────────────────────────
//
// O id da citada vem do CLIENTE. Sem confirmar que ela é da MESMA conversa,
// alguém poderia citar uma mensagem de outra conversa — e a citação é
// renderizada com o texto, então isso vaza conteúdo de um atendimento para
// dentro de outro. O filtro de conversa é o que fecha isso; o de organização
// vem de brinde por `conversation_id` já ser desta org.
//
// Recusar em silêncio (citar nada) seria pior que recusar alto: quem clicou
// "responder" veria a mensagem sair sem o fio e não saberia por quê.
let citada: { id: string; external_id: string | null } | null = null;
if (input.reply_to_message_id) {
const { data: alvo } = await supabase
.from("messages")
.select("id, external_id")
.eq("id", input.reply_to_message_id)
.eq("organization_id", ctx.organization_id)
.eq("conversation_id", c.id)
.maybeSingle();
if (!alvo) {
throw new ApiError(
422,
"validation_error",
undefined,
ctx.requestId,
"A mensagem citada não é desta conversa.",
);
}
citada = alvo as { id: string; external_id: string | null };
}
const insertRow = {
organization_id: c.organization_id,
// Guardado mesmo quando o canal não sabe citar: o fio existe no NOSSO
// histórico de qualquer jeito, e é o que a tela desenha.
reply_to_message_id: citada?.id ?? null,
conversation_id: c.id,
channel_session_id: c.channel_session_id,
contact_id: c.contact_id,
@@ -596,6 +631,9 @@ export async function sendMessageHandler(
filename,
caption: input.body ?? null,
},
// O id que a PLATAFORMA conhece, lido da linha citada agora — não uma
// cópia guardada no envio, que poderia divergir da linha.
replyToExternalId: citada?.external_id ?? null,
}));
} else if (input.type === "contact") {
const sc = outboundMetadata.shared_contact as
@@ -633,6 +671,7 @@ export async function sendMessageHandler(
providerConversationId: c.provider_conversation_id,
kind: input.type,
body: input.body ?? "",
replyToExternalId: citada?.external_id ?? null,
}));
}
await removerEcoDoProprioEnvio(
+19 -1
View File
@@ -16,6 +16,8 @@ import type { Message, Note } from "@/lib/types/messaging";
interface Props {
conversationId: string | null;
/** Escolher uma mensagem para responder. Sobe até o composer. */
onResponder?: (m: Message) => void;
}
/** Onda 5.2: union de item do thread — mensagem real ou nota interna (nunca vai ao cliente). */
@@ -41,7 +43,7 @@ function dayLabel(d: Date): string {
return format(d, "dd/MM/yyyy", { locale: ptBR });
}
export function ChatThread({ conversationId }: Props) {
export function ChatThread({ conversationId, onResponder }: Props) {
const q = useMessagesRealtime(conversationId);
const notes = useConversationNotes(conversationId);
const bottomRef = useRef<HTMLDivElement | null>(null);
@@ -58,6 +60,16 @@ export function ChatThread({ conversationId }: Props) {
[q.data],
);
/**
* As mensagens por id, para resolver a CITADA sem ir ao servidor.
*
* Uma consulta por bolha citada seria uma cascata de requisições numa
* conversa longa. Aqui o fio sai da lista que já está na tela — e quando a
* citada ficou fora da página carregada, ele simplesmente não aparece, que é
* melhor que segurar a conversa esperando por um texto de enfeite.
*/
const porId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const items: ThreadItem[] = useMemo(
() => mergeThreadItems(messages, notes),
[messages, notes],
@@ -188,6 +200,12 @@ export function ChatThread({ conversationId }: Props) {
key={`msg-${item.data.id}`}
message={item.data}
debugCitations={debugCitations}
onResponder={onResponder}
// A citada sai da MESMA lista já carregada: buscar no servidor
// por cada citação faria uma consulta por bolha. Quando a
// citada é antiga demais e ficou fora da página, o fio some —
// que é melhor que segurar a conversa esperando.
citada={porId.get(item.data.reply_to_message_id ?? "") ?? null}
/>
),
)}
+69 -3
View File
@@ -19,6 +19,7 @@ import { EmojiButton } from "@/components/inbox/composer/EmojiButton";
import { resolveSlash, TemplateMenu } from "@/components/inbox/composer/TemplateMenu";
import { useCreateNote } from "@/hooks/inbox/useCreateNote";
import { useMessageTemplates, type MessageTemplate } from "@/hooks/inbox/useMessageTemplates";
import { X } from "lucide-react";
import { useSendMessage } from "@/hooks/inbox/useSendMessage";
import { useUploadMedia } from "@/hooks/inbox/useUploadMedia";
import { imagemDoClipboard } from "@/lib/inbox/clipboard-image";
@@ -43,6 +44,16 @@ interface Props {
* versão deste bloqueio usava `blockedReason` e levou a nota junto.
*/
janelaFechada?: string | null;
/**
* A mensagem que esta resposta CITA, quando o atendente escolheu responder
* "em cima" de uma. `null` = envio solto, o caso comum.
*
* Vem de fora e não daqui porque quem escolhe é a lista de mensagens: o
* composer só precisa mostrar o que foi escolhido e mandá-lo junto.
*/
respondendo?: { id: string; body: string | null; direction: string } | null;
/** Desfaz a escolha — o `x` da faixa de citação. */
onCancelarResposta?: () => void;
/** Nome do contato da conversa, para interpolar {{nome}}/{{primeiro_nome}} do template escolhido. */
contactName?: string | null;
/** Contato da conversa — excluído do seletor de cartão compartilhado. */
@@ -50,7 +61,16 @@ interface Props {
}
export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
{ conversationId, disabled, blockedReason, janelaFechada, contactName, currentContactId },
{
conversationId,
disabled,
blockedReason,
janelaFechada,
contactName,
currentContactId,
respondendo,
onCancelarResposta,
},
ref,
) {
const t = useT();
@@ -103,8 +123,24 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
return;
}
send.mutate(
{ conversation_id: conversationId, body, type: "text" },
{ onError: restoreOnError },
{
conversation_id: conversationId,
body,
type: "text",
...(respondendo ? { reply_to_message_id: respondendo.id } : {}),
},
{
onSuccess: () => {
setText("");
// A citação vale para UMA mensagem. Mantê-la depois do envio faria a
// próxima frase sair citando algo que o atendente já respondeu.
onCancelarResposta?.();
requestAnimationFrame(() => autoresize());
},
// Do upstream, e fica: sem isto o texto some quando o envio falha, e
// quem escreveu um parágrafo o perde sem ter como recuperá-lo.
onError: restoreOnError,
},
);
}
@@ -212,6 +248,36 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
{t("Nota interna")}
</button>
</div>
{/*
A FAIXA DA CITAÇÃO — o que o atendente escolheu responder.
Fica ACIMA do campo, como no WhatsApp, e não dentro dele: o texto
citado pode ter várias linhas, e empurrá-lo para dentro do campo faria
o que se digita disputar espaço com o que se cita.
`line-clamp-2` porque o objetivo é reconhecer qual mensagem é, não
relê-la — ela está logo acima, no fio.
*/}
{respondendo && mode === "reply" && (
<div className="mb-1 flex items-start gap-2 rounded-md border-l-2 border-primary bg-muted/60 px-2 py-1.5">
<div className="min-w-0 flex-1">
<div className="text-[11px] font-medium text-primary">
{respondendo.direction === "outbound" ? t("Você") : t("Cliente")}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{respondendo.body?.trim() || t("(sem texto)")}
</div>
</div>
<button
type="button"
onClick={onCancelarResposta}
aria-label={t("Cancelar resposta")}
className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
)}
<div className="flex items-end gap-2">
{mode === "reply" && (
<AttachMenu
+93 -4
View File
@@ -20,8 +20,13 @@ import { Composer, type ComposerHandle } from "./Composer";
import { ConversationHeader } from "./ConversationHeader";
import { RetentionNotice } from "./RetentionNotice";
import { CRMSidePanel } from "./CRMSidePanel";
import type { Message as ConversationMensagem } from "@/lib/types/messaging";
import { InboxKeyboardShortcuts } from "./InboxKeyboardShortcuts";
import { ShortcutsHelpDialog } from "./ShortcutsHelpDialog";
import { ChevronLeft, PanelRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
/**
* O QUE CADA ABA SIGNIFICA. Exportada porque é a definição em si — o defeito
@@ -92,6 +97,15 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const [helpOpen, setHelpOpen] = useState(false);
/** A ficha do contato como painel deslizante — só existe abaixo do `xl`. */
const [fichaAberta, setFichaAberta] = useState(false);
/**
* A mensagem escolhida para responder "em cima".
*
* Mora aqui, e não no composer, porque quem ESCOLHE é a lista de mensagens e
* quem MOSTRA é o composer — são irmãos, e o estado comum é do pai.
*/
const [respondendo, setRespondendo] = useState<ConversationMensagem | null>(null);
const composerRef = useRef<ComposerHandle | null>(null);
const filters: ConversationsFilters = useMemo(
@@ -132,12 +146,27 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
const claim = useClaimConversation();
const close = useCloseConversation();
// A leitura da conversa aberta é do upstream e fica: sem ela o contador de
// não-lidas nunca zera para quem abre a conversa.
useMarkAsRead(
selectedConversation?.id ?? null,
selectedConversation?.unread_count_for_assignee ?? 0,
);
const handleSelect = useCallback((id: string) => setSelectedId(id), []);
// Aceita `null`: é o VOLTAR do celular, que limpa a seleção e devolve a lista.
// É um SUPERCONJUNTO do `handleSelect` do upstream — o tipo dele não aceita
// `null`, e sem isso o botão de voltar não teria o que chamar.
//
// A seleção NÃO vive na URL (só o `?filter=` vive) — então este voltar é
// estado local, e o botão de voltar do navegador não desfaz a seleção. É a
// limitação conhecida deste caminho; trocar por URL mudaria o deep-link de
// conversa, que hoje entra por `initialSelectedId` vindo da rota.
const handleSelect = useCallback((id: string | null) => {
setSelectedId(id);
// Sem isto, escolher "responder" numa conversa e trocar para outra levaria
// a citação junto — e a resposta sairia citando mensagem de outro cliente.
setRespondendo(null);
}, []);
const handleVisibleChange = useCallback((ids: string[]) => setVisibleIds(ids), []);
const handleFocusReply = useCallback(() => composerRef.current?.focus(), []);
const handleClaim = useCallback(() => {
@@ -227,7 +256,25 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
// deixava. Margem de 2px não é margem, é sorte.
return (
<div className="grid h-[calc(100dvh-3.5rem-2*var(--space-6))] w-full grid-cols-1 md:grid-cols-[300px_1fr] xl:grid-cols-[272px_1fr_296px] 2xl:grid-cols-[300px_1fr_320px]">
<div className="flex h-full min-h-0 flex-col border-r border-border">
{/*
NO CELULAR, UMA COISA POR VEZ.
Antes as duas colunas caíam empilhadas em `grid-cols-1`: a lista inteira
primeiro e a conversa DEPOIS dela. Para responder era preciso rolar a
lista toda até o fim, e o composer ficava fora da tela — que é o
"incômodo" relatado por quem atende do telefone.
Sem media query em JavaScript de propósito: `useMediaQuery` decide DEPOIS
da hidratação, então a primeira pintura mostra o layout errado e pisca. A
classe condicional é resolvida pelo CSS, na primeira pintura, e some no
`md` — onde as duas colunas cabem juntas e a regra não se aplica.
*/}
<div
className={cn(
"h-full min-h-0 flex-col border-r border-border md:flex",
selectedId ? "hidden" : "flex",
)}
>
<InboxFilters value={filterValue} onChange={setFilterValue} />
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationList
@@ -241,12 +288,52 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
</div>
</div>
<div className="flex h-full min-h-0 flex-col">
<div
className={cn(
"h-full min-h-0 flex-col md:flex",
selectedConversation ? "flex" : "hidden md:flex",
)}
>
{selectedConversation ? (
<>
{/*
A barra que só existe no celular: o caminho de VOLTA e a porta
para a ficha.
Sem o voltar, quem abre uma conversa no telefone fica preso nela —
a lista está escondida e não há gesto que a traga. E a ficha do
contato mora numa coluna que só aparece a partir do `xl`, então no
telefone ela seria inalcançável; aqui ela vira painel deslizante,
com o mesmo componente da coluna (nada de uma segunda versão que
diverge na primeira mudança).
*/}
<div className="flex items-center gap-1 border-b border-border px-1 py-1 md:hidden">
<Button
variant="ghost"
size="sm"
className="h-9 gap-1 px-2"
onClick={() => handleSelect(null)}
>
<ChevronLeft className="size-4" />
Conversas
</Button>
<div className="flex-1" />
<Sheet open={fichaAberta} onOpenChange={setFichaAberta}>
<SheetTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 gap-1 px-2 xl:hidden">
<PanelRight className="size-4" />
Ficha
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[min(22rem,90vw)] overflow-y-auto p-0">
<SheetTitle className="sr-only">Ficha do contato</SheetTitle>
<CRMSidePanel conversation={selectedConversation} />
</SheetContent>
</Sheet>
</div>
<ConversationHeader conversation={selectedConversation} />
<div className="min-h-0 flex-1 overflow-hidden">
<ChatThread conversationId={selectedConversation.id} />
<ChatThread conversationId={selectedConversation.id} onResponder={setRespondendo} />
</div>
<RetentionNotice conversationId={selectedConversation.id} />
{motivoDaJanela && (
@@ -263,6 +350,8 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
janelaFechada={motivoDaJanela}
disabled={selectedConversation.status === "closed"}
contactName={selectedConversation.contacts?.name ?? null}
respondendo={respondendo}
onCancelarResposta={() => setRespondendo(null)}
currentContactId={selectedConversation.contact_id}
/>
</>
+93 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Check, Checks, Robot, WarningOctagon } from "@/lib/ui/icons";
import { ArrowBendUpLeft, Check, Checks, Robot, WarningOctagon } from "@/lib/ui/icons";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { Message } from "@/lib/types/messaging";
@@ -16,6 +16,10 @@ import {
interface Props {
message: Message;
debugCitations?: boolean;
/** Escolher esta mensagem para responder "em cima" dela. */
onResponder?: (m: Message) => void;
/** A mensagem citada por ESTA, quando houver — desenha o fio. */
citada?: Message | null;
}
function AckIndicator({ status }: { status: string }) {
@@ -31,7 +35,7 @@ function AckIndicator({ status }: { status: string }) {
return null;
}
export function MessageBubble({ message, debugCitations }: Props) {
export function MessageBubble({ message, debugCitations, onResponder, citada }: Props) {
const isOutbound = message.direction === "outbound";
const time = format(new Date(message.sent_at), "HH:mm", { locale: ptBR });
const isFailed = message.status === "failed";
@@ -56,7 +60,47 @@ export function MessageBubble({ message, debugCitations }: Props) {
})();
return (
<div className={cn("flex w-full px-4 py-1", isOutbound ? "justify-end" : "justify-start")}>
<div
className={cn(
"group flex w-full items-center gap-1 px-4 py-1",
isOutbound ? "justify-end" : "justify-start",
)}
>
{/*
RESPONDER — aparece ao passar o mouse, como no WhatsApp Web.
Fica FORA da bolha para não disputar espaço com o texto, e do lado de
dentro da conversa (à esquerda no que sai, à direita no que entra), que
é onde a mão já está.
`opacity` e não `hidden`: esconder de verdade faria o layout pular
quando o mouse entra. Em telas de toque não há hover — por isso
`focus-visible` também revela, e o teclado alcança.
*/}
{onResponder && isOutbound && (
<button
type="button"
onClick={() => onResponder(message)}
aria-label="Responder a esta mensagem"
className={cn(
"rounded p-1 text-muted-foreground transition-opacity hover:bg-muted",
// VISÍVEL POR PADRÃO, e escondido só onde EXISTE hover.
//
// A primeira versão era `opacity-0` + `group-hover`, copiando o
// WhatsApp Web. No celular isso deixa o botão invisível para
// sempre: não há como passar o mouse, e `focus-visible` só chega
// por teclado. Ou seja, a função sumia exatamente onde o dono
// deste CRM mais atende.
//
// `@media (hover: hover)` pergunta pelo DISPOSITIVO, não pela
// largura: um tablet largo com toque continua mostrando, e um
// desktop estreito continua escondendo. Largura não é a pergunta.
"opacity-100 [@media(hover:hover)]:opacity-0",
"[@media(hover:hover)]:group-hover:opacity-100 focus-visible:opacity-100",
)}
>
<ArrowBendUpLeft size={14} />
</button>
)}
<div
className={cn(
"max-w-[75%] text-sm",
@@ -71,6 +115,27 @@ export function MessageBubble({ message, debugCitations }: Props) {
isFailed && "border border-destructive",
)}
>
{/*
A CITAÇÃO, dentro da bolha e acima do texto — o fio.
Mostra de quem era e um trecho. `line-clamp-2` porque serve para
reconhecer, não para reler: a original está logo acima no histórico.
*/}
{citada && (
<div
className={cn(
"mb-1 rounded border-l-2 px-2 py-1 text-xs",
isOutbound
? "border-primary-foreground/50 bg-primary-foreground/10"
: "border-primary bg-background/60",
)}
>
<div className="font-medium opacity-80">
{citada.direction === "outbound" ? "Você" : "Cliente"}
</div>
<div className="line-clamp-2 opacity-70">{citada.body?.trim() || "(sem texto)"}</div>
</div>
)}
{senderLabel && (
<div className="mb-0.5 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide opacity-80">
{senderLabel === "IA" ? (
@@ -144,6 +209,31 @@ export function MessageBubble({ message, debugCitations }: Props) {
)}
</div>
</div>
{onResponder && !isOutbound && (
<button
type="button"
onClick={() => onResponder(message)}
aria-label="Responder a esta mensagem"
className={cn(
"rounded p-1 text-muted-foreground transition-opacity hover:bg-muted",
// VISÍVEL POR PADRÃO, e escondido só onde EXISTE hover.
//
// A primeira versão era `opacity-0` + `group-hover`, copiando o
// WhatsApp Web. No celular isso deixa o botão invisível para
// sempre: não há como passar o mouse, e `focus-visible` só chega
// por teclado. Ou seja, a função sumia exatamente onde o dono
// deste CRM mais atende.
//
// `@media (hover: hover)` pergunta pelo DISPOSITIVO, não pela
// largura: um tablet largo com toque continua mostrando, e um
// desktop estreito continua escondendo. Largura não é a pergunta.
"opacity-100 [@media(hover:hover)]:opacity-0",
"[@media(hover:hover)]:group-hover:opacity-100 focus-visible:opacity-100",
)}
>
<ArrowBendUpLeft size={14} />
</button>
)}
</div>
);
}
+3
View File
@@ -20,6 +20,8 @@ interface SendArgs {
template_name?: string;
template_language?: string;
template_values?: Record<string, string>;
/** A mensagem citada — id da NOSSA linha; o handler traduz para o do canal. */
reply_to_message_id?: string;
metadata?: Record<string, unknown>;
}
@@ -59,6 +61,7 @@ export function useSendMessage() {
media_mime: args.media_mime ?? null,
media_size_bytes: null,
media_storage_path: null,
reply_to_message_id: args.reply_to_message_id ?? null,
sent_via: "user",
sent_by_user_id: null,
sent_at: new Date().toISOString(),
+13 -1
View File
@@ -135,6 +135,11 @@ export const wahaAdapter: ChannelAdapter = {
// comportamento visível — proibido nas Fases 0–2.
if (!client) return { externalId: null };
// A estrutura de três caminhos é do upstream (o cartão de contato entrou
// depois da citação). O que se enxerta aqui é o `replyToExternalId` no
// caminho de TEXTO — os outros dois não citam: o WAHA aceita `reply_to` só
// no `sendText`, e mandá-lo nos outros seria pedir para a API ignorar em
// silêncio, que é como se perde uma feature sem ninguém notar.
let res: unknown;
if (envelope.kind === "contact" && envelope.contact) {
const resolvedId = await resolveWhatsappIdForContactCard(
@@ -155,7 +160,14 @@ export const wahaAdapter: ChannelAdapter = {
wahaSendPlanFor(envelope.kind, envelope.media),
);
} else {
res = await client.sendMessage(envelope.sessionRef, envelope.to, envelope.body ?? "");
res = await client.sendMessage(
envelope.sessionRef,
envelope.to,
envelope.body ?? "",
// A citação é enfeite da conversa, nunca condição de envio: quando não
// há, o envio segue igual. Ver `OutboundEnvelope.replyToExternalId`.
envelope.replyToExternalId,
);
}
return { externalId: parseWahaMessageId(res) };
+5
View File
@@ -196,6 +196,11 @@ export const zernioAdapter: ChannelAdapter = {
const body: Record<string, unknown> = {
accountId: creds.accountId,
...(envelope.media ? attachmentFields(envelope) : { message: envelope.body ?? "" }),
// ─── A CITAÇÃO ──────────────────────────────────────────────────────
// `replyTo` recebe o id que a PLATAFORMA conhece — para WhatsApp, o
// `wamid`. É o `external_id` da linha citada, nunca o `id` da nossa
// tabela: o provider nunca viu o nosso. Só entra quando existe.
...(envelope.replyToExternalId ? { replyTo: envelope.replyToExternalId } : {}),
};
const res = await fetch(url, {
+14
View File
@@ -123,6 +123,20 @@ export interface OutboundEnvelope extends ChannelTenantScope {
* fazer (tipicamente, abrir a conversa com template).
*/
providerConversationId?: string | null;
/**
* A mensagem que ESTA responde, pelo id que o PROVIDER conhece.
*
* É o `external_id` da linha citada (para WhatsApp, o `wamid`) — não o `id`
* da nossa tabela, que o provider nunca viu. Quem monta o envelope resolve
* essa tradução; o adapter só repassa.
*
* OPCIONAL, e canal que não sabe citar simplesmente ignora: a citação é
* enfeite da conversa, nunca condição de envio. Um canal recusar a mensagem
* inteira porque não sabe citar seria trocar a mensagem pelo enfeite.
*
* `undefined` = envio solto, que é o caso comum.
*/
replyToExternalId?: string | null;
}
/**
+9
View File
@@ -61,6 +61,15 @@ export const sendMessageSchema = z
* da tela usa. Chave montada de outro jeito é o mismatch voltando.
*/
template_values: z.record(z.string(), z.string()).optional(),
/**
* A mensagem que esta responde — o id da NOSSA linha, não o do provider.
*
* Quem envia conhece o que está na tela, e na tela está o nosso id. A
* tradução para o id que a plataforma entende (`wamid`) é feita no handler,
* lendo a linha apontada: pedir o `wamid` aqui obrigaria a tela a conhecer
* o vocabulário do canal, que é justamente o que o seam existe para evitar.
*/
reply_to_message_id: z.string().uuid().optional(),
})
.refine(
(d) => {
+2
View File
@@ -68,6 +68,8 @@ export interface Message {
edited_at: string | null;
/** Quando o AUTOR apagou para todos. A linha fica; o texto não é mostrado. */
revoked_at: string | null;
/** A mensagem que esta responde (citação). `null` = envio solto. */
reply_to_message_id: string | null;
created_at: string;
}
+1
View File
@@ -40,6 +40,7 @@ export {
ArrowsClockwise,
Dot,
// actions
ArrowBendUpLeft,
List,
Bell,
PaperPlaneTilt,
+33 -2
View File
@@ -322,14 +322,45 @@ export class WahaClient {
}
}
async sendMessage(session: string, chatId: string, text: string): Promise<unknown> {
/**
* `replyTo` = citar uma mensagem, como o "responder em cima" do WhatsApp.
*
* ─── O defeito que isto conserta ──────────────────────────────────────────
*
* A citação já existia de ponta a ponta MENOS aqui: a tela deixava escolher a
* mensagem, o handler resolvia o `external_id` da citada e gravava
* `reply_to_message_id`, e a bolha aparecia pendurada — no CRM. No WhatsApp do
* cliente chegava mensagem solta. Ou seja, a tela prometia uma coisa e o
* aparelho do outro lado mostrava outra, sem nada ficar vermelho.
*
* ─── O formato do id, que é onde isto falha em silêncio ───────────────────
*
* O `reply_to` do WAHA quer o id COMPLETO (`{fromMe}_{chatId}_{bareId}`), não
* o cru. E o WAHA é assimétrico: a resposta de envio devolve o cru (`3EB0…`) e
* o webhook entrega o completo — ver `bareWaMessageId`.
*
* Medido numa instalação real: as 1.734 mensagens de ENTRADA têm o id
* completo, que é o formato certo. E citar o que o cliente disse é o caso que
* importa — quem responde "em cima" está respondendo a ele.
*
* Por isso o id vai como está, sem reconstrução: inventar o prefixo a partir
* da direção acertaria o caso que já funciona e chutaria no resto.
*/
async sendMessage(
session: string,
chatId: string,
text: string,
replyTo?: string | null,
): Promise<unknown> {
const res = await fetch(`${this.baseUrl}/api/sendText`, {
method: "POST",
headers: {
"X-Api-Key": this.apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({ session, chatId, text }),
// Só entra quando existe: mandar `reply_to: null` é pedir para citar
// "nada", e a API não tem por que ser gentil com isso.
body: JSON.stringify({ session, chatId, text, ...(replyTo ? { reply_to: replyTo } : {}) }),
});
if (!res.ok) throw new Error(`waha_${res.status}`);
return res.json();
+21
View File
@@ -13956,4 +13956,25 @@ create unique index if not exists channel_sessions_zernio_account_id_ativo_uniqu
on public.channel_sessions (zernio_account_id)
where archived_at is null and zernio_account_id is not null;
-- ---- a mensagem que responde outra (migration 0168) ----
-- O canal intermediado aceita citação (`replyTo` no envio, com o `wamid` da
-- citada) e o WhatsApp mostra a resposta pendurada na original. Sem guardar
-- QUEM foi citado, o CRM manda a citação para o cliente e não a mostra de volta
-- na própria tela: o atendente vê frases soltas onde o cliente vê um fio.
--
-- FK e não o wamid solto: a pergunta da tela é "qual mensagem NOSSA foi
-- citada?", e a resposta é uma linha desta tabela — inclusive quando ela ainda
-- não tem `external_id` (a nossa, enquanto está `queued`). O id que o provider
-- recebe sai da linha apontada, no envio.
--
-- `set null` no delete: apagar a citada não pode levar junto a resposta, que é
-- conteúdo próprio. Perder o fio é aceitável; perder a resposta é apagar
-- histórico por causa de um ponteiro.
alter table public.messages
add column if not exists reply_to_message_id uuid
references public.messages(id) on delete set null;
create index if not exists messages_reply_to_idx
on public.messages (reply_to_message_id)
where reply_to_message_id is not null;
notify pgrst, 'reload schema';
@@ -0,0 +1,43 @@
-- ============================================================================
-- 0168 — A MENSAGEM QUE RESPONDE OUTRA.
--
-- O canal intermediado aceita citação (`replyTo` no envio, recebendo o `wamid`
-- da mensagem citada), e o WhatsApp mostra a resposta pendurada na original —
-- que é como as pessoas conversam ali. Sem guardar QUEM foi citado, o CRM
-- manda a citação para o cliente e não consegue mostrá-la de volta na própria
-- tela: o atendente vê frases soltas onde o cliente vê um fio.
--
-- ─── Por que uma FK, e não o wamid solto ───────────────────────────────────
--
-- Porque a pergunta da tela é "qual mensagem NOSSA foi citada?", e a resposta é
-- uma linha desta tabela. Guardar o `external_id` do provider faria toda
-- renderização buscar por uma coluna de texto — e, pior, deixaria de funcionar
-- para a citação de uma mensagem que ainda não tem `external_id` (a nossa,
-- enquanto está `queued`). É o `R` da DIRC: a linha já existe, aqui basta o
-- ponteiro.
--
-- O `external_id` que o provider precisa continua saindo da linha apontada, no
-- momento do envio — uma leitura a mais, e nenhuma cópia que possa divergir.
--
-- ─── `on delete set null`, nunca cascade ───────────────────────────────────
--
-- Apagar a mensagem citada não pode levar junto a resposta: a resposta é
-- conteúdo próprio, dita por alguém, e some sozinha só se quem a disse mandar.
-- Perder a citação é aceitável (a tela mostra a resposta sem o fio); perder a
-- resposta seria apagar histórico de atendimento por causa de um ponteiro.
--
-- Índice PARCIAL: a esmagadora maioria das mensagens não cita ninguém, e um
-- índice cheio de NULL só ocupa espaço. O predicado é o mesmo que a tela usa.
-- ============================================================================
alter table public.messages
add column if not exists reply_to_message_id uuid
references public.messages(id) on delete set null;
create index if not exists messages_reply_to_idx
on public.messages (reply_to_message_id)
where reply_to_message_id is not null;
comment on column public.messages.reply_to_message_id is
'A mensagem que esta responde (citação). NULL = envio solto, o caso comum. '
'O id que o provider recebe em `replyTo` sai do `external_id` da linha apontada.';
+1
View File
@@ -203,6 +203,7 @@ aplica.
| `20260820120000` | `0166_indice_do_claim_da_fila` | **O cap global do claim varria `job_queue` inteira a cada rodada.** `claimJobs` (`lib/agent-engine/queue/queue.ts`) abre toda rodada — dentro do advisory lock que serializa os claimers — com `select count(*) from job_queue where status = 'running'`, e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e **não vale**: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. Medido em `pgvector/pgvector:pg17` com este baseline, 50.000 linhas `done` + 4 `running`: **Seq Scan / 715 buffers → Index Only Scan / 3 buffers**, índice de **16 kB**. **O custo não depende de linha viva, depende de bloat:** apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os **mesmos 715 buffers** — ele visita PÁGINA, não tupla —, e nada no produto poda `job_queue`. **O `/healthz` do worker NÃO é consertado por isto, e foi medido, não suposto:** `workers/agent-worker/main.ts` e `lib/agent-engine/obs/metrics.ts` fazem `select status, count(*) from job_queue group by status`, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em `(status)` também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. **O bloco `do $$` não é cerimônia:** `create index if not exists` casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria `job_queue` com outra definição vira `NOTICE: ... already exists, skipping` (NOTICE nem chega ao filtro `ERROR\|FATAL` do `update.sh`: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op **e** faz o `comment on index` acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em `job_queue`; o de outro objeto ele **não apaga** — `raise exception` com a razão escrita, que é o comportamento certo num script que roda sem `ON_ERROR_STOP` (o erro aparece ao operador e o resto do baseline segue). **O `comment on index` é DELATOR:** índice ausente levanta `relation "idx_job_queue_running" does not exist`, texto conferido contra a lista benigna real do `update.sh` (`already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition`) — não casa com nenhum termo, logo chega ao cliente; um `already exists` seria engolido. Guardado por `tests/invariants/queue-cap-global-do-claim.test.ts`, que **extrai o `select` do FONTE de produção** em vez de copiá-lo: índice presente e predicado mudado (`::text` a mais, `lower(status)`, `status in (...)`) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. |
| `20260820160000` | `0165_identificador_de_canal_unico_entre_ativos` | **Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar.** `waha_session_name` e `webhook_path_token` são UNIQUE desde o snapshot; `meta_phone_number_id` (0087) e `zernio_account_id` (0131) nasceram sem. Traz **dois índices únicos PARCIAIS** (`where archived_at is null`), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. **O que a ausência produzia (issue #236):** três consultas de `lib/channels/` resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, `maybeSingle()` **não** devolve "a primeira" — medido contra `@supabase/postgrest-js` 2.112.1, devolve `data: null` + `error PGRST116` (HTTP 406). Os três **descartavam o `error`**, então: os dois resolvedores de credencial caíam no fallback do `.env` (a mensagem saía pela conta de OUTRA instalação) e `meta/ingest.ts` devolvia `no_session` com a rota respondendo 200 — a mensagem recebida era **descartada para as duas organizações**. **Classificação: alta, não crítica** — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: `validatePartnerCredentials` (`lib/channels/connect.ts`) exige que o identificador esteja na lista de contas que a chave informada alcança. **A deduplicação vem ANTES da trava e não é destrutiva:** o `update.sh` do clone roda sem `ON_ERROR_STOP` e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é **RENOMEADA** para `<original>-conflito-<id da sessão>`: continua visível, e como o identificador não existe no provider a varredura de saúde (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na passada seguinte e **abre aviso na Central** (os três estados estão em `STATUS_QUE_AVISAM`). Fica com o identificador a sessão ativa **mais recente** (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. **Idempotente por construção:** o sufixo carrega o `id` (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra `baseline.sql` e `migrations/`), então o `if not exists` — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de `organization_id` nos três sítios (com o `organizationId` atravessando o seam de canal como campo **obrigatório**, para o typecheck cobrar), `error` que deixa de ser descartado, e o invariante `tests/unit/canal-consulta-por-organizacao.test.ts`, que varre `lib/channels/` derivando as colunas de `CHANNEL_SESSION_REF_COLUMNS` — o quarto canal entra na varredura sozinho. |
| `20260820170000` | `0167_poda_da_fila_e_expurgo_do_audit` | **Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT.** `grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete` devolvia **zero linhas**: `job_queue` crescia desde a instalação e nunca encolhia; `api_audit_log` prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os **500 MB** do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no `count(*)` do claim com ZERO linhas vivas, medido na #260). Traz **duas `security definer`** (`fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida`), **três índices** e o cron `data-retention` (diário). **DELETE por idade e não particionamento**: particionar `job_queue` exigiria mexer no claim `FOR UPDATE SKIP LOCKED` e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. **O QUE TEM DONO NÃO SAI, e são três cortes:** `pending`/`running` nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só `done`/`failed`/`dead` (conferidos em `lib/agent-engine/queue/queue.ts`); e **`dead` com aviso ABERTO na Central tem dono** — um humano que não olhou — com o `not exists` **antes** do `limit`, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. **Cascata declarada:** o DELETE leva junto `send_ledger` e `before_send_traces` (FK `on delete cascade`, as duas também sem poda) e apenas anula o ponteiro em `llm_calls`/`lead_checkpoints`/`lead_state_transitions`; os dois consumidores de `send_ledger` sem janela (`countPriorAcceptedSends` → disclosure de IA, e o gate LGPD de 1º toque de prospecção, `before-send.ts:261`) falham **fechado** — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. **A definer do audit não é porta de adulteração, e cada razão é conferível:** (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`, então ela só sabe apagar pela ponta mais velha; (b) o **piso de 90 dias mora no corpo**, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a `service_role`; (d) não amplia o raio de quem já tem a chave (`service_role` já tem `TRUNCATE` na mesma tabela); (e) **registra a própria erosão** — o cron grava `retention.sweep_run` com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. `api_audit_log` **não tinha** GRANT de DELETE para ninguém (nem para `service_role`), e é por isso que o expurgo não podia sair pelo admin client. **Hot/cold em S3 não foi entregue e a doutrina do `CLAUDE.md` foi corrigida** em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`). Índices com **nome próprio da poda** (`idx_audit_expurgo_created_at`) porque `create index if not exists` casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por `create or replace` + `if not exists` + `revoke`. **NÃO MEDIDO:** o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do `test:db`, não numa VPS com histórico de anos. |
| `20260821090000` | `0168_a_mensagem_que_responde_outra` | **A citação: qual mensagem esta responde.** O canal intermediado aceita `replyTo` no envio (recebendo o `wamid` da citada) e o WhatsApp mostra a resposta pendurada na original — que é como as pessoas conversam ali. Sem guardar QUEM foi citado, o CRM manda a citação para o cliente e não consegue mostrá-la de volta na própria tela: o atendente vê frases soltas onde o cliente vê um fio. **FK e não o wamid solto:** a pergunta da tela é "qual mensagem NOSSA foi citada?", e a resposta é uma linha desta tabela; guardar o texto do provider faria toda renderização buscar por coluna de texto e deixaria de funcionar para a citação de uma mensagem que ainda não tem `external_id` (a nossa, enquanto está `queued`). É o `R` da DIRC — a linha existe, aqui basta o ponteiro, e o id que o provider precisa sai dela no momento do envio. **`on delete set null`, nunca cascade:** apagar a citada não pode levar junto a resposta, que é conteúdo próprio de alguém; perder o fio é aceitável, perder a resposta seria apagar histórico por causa de um ponteiro. Índice PARCIAL porque a esmagadora maioria não cita ninguém. Aditiva e idempotente. |
## Reproducibility
+120
View File
@@ -0,0 +1,120 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { test, expect, type Page } from "@playwright/test";
/**
* RESPONDER "EM CIMA" DE UMA MENSAGEM — pela tela, como o atendente faz.
*
* O canal intermediado aceita citação (`replyTo`, recebendo o `wamid`), e o
* WhatsApp mostra a resposta pendurada na original. Todo o caminho novo é
* VISUAL — escolher a mensagem, ver a faixa, cancelar, enviar — e nada disso é
* alcançável por teste de unidade: eles provam que a função existe, não que dá
* para clicá-la.
*
* ─── O que este arquivo cobre, e por que cada caso ──────────────────────────
*
* 1. o botão APARECE (é `opacity-0` até o hover; um `hidden` teria feito o
* layout pular, e um seletor que só olha o DOM passaria mesmo invisível);
* 2. escolher mostra a faixa com o trecho citado;
* 3. o `×` desfaz — sem saída, quem clica por engano fica preso citando;
* 4. trocar de conversa LIMPA a citação. Este é o caso que mais importa: sem
* ele, a resposta sairia citando a mensagem de OUTRO cliente.
*
* Não cobre o que sai na rede: se o `replyTo` chegou ao provider é assunto do
* adapter, e o teste de tela não deve fingir que mede isso.
*/
interface E2ECreds {
password: string;
users: Record<string, { id: string; email: string; role: string }>;
}
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as E2ECreds;
const EVIDENCE = path.join(process.cwd(), ".superpowers/evidence");
/**
* Login simples — e por isso o usuário é o `agent`, nunca o `admin`.
*
* `admin` tem MFA obrigatório (doutrina de Auth), então o login dele para em
* `/login/mfa` e este `waitForURL` nunca resolve. O repo tem um helper próprio
* para esse caso (`helpers/login-admin.ts`), e ele existe justamente porque a
* armadilha já pegou gente antes. As demais specs de inbox usam `agent`, que
* tem o acesso que estes casos precisam.
*/
async function login(page: Page, email: string): Promise<void> {
await page.goto("/login");
await page.locator("#email").fill(email);
await page.locator("#password").fill(creds.password);
await page.getByRole("button", { name: /entrar/i }).click();
await page.waitForURL(/\/app\//);
}
/**
* Abre o inbox e entra numa conversa que TENHA mensagens.
*
* Devolve `false` quando o ambiente não tem nenhuma — e o caso é pulado em vez
* de falhar. Um teste que exige dado semeado por outra spec quebra por ordem de
* execução, não por defeito, e ensina a ignorar o vermelho.
*/
async function abrirConversaComMensagens(page: Page): Promise<boolean> {
await page.goto("/app/inbox?filter=all");
const bolhas = page.locator("[class*='rounded-2xl']");
const primeira = page.locator("li, [role='listitem']").first();
if (await primeira.count()) await primeira.click();
await expect(bolhas.first())
.toBeVisible({ timeout: 8000 })
.catch(() => undefined);
return (await bolhas.count()) > 0;
}
test.describe("responder citando", () => {
test("o botão de responder revela a faixa, e o × a desfaz", async ({ page }) => {
await login(page, creds.users.agent!.email);
const temMensagens = await abrirConversaComMensagens(page);
test.skip(!temMensagens, "ambiente sem conversa com mensagens — nada a citar");
const responder = page.getByRole("button", { name: /Responder a esta mensagem/i }).first();
// O botão vive em `opacity-0` até o hover. `toBeVisible` do Playwright
// considera opacidade 0 como visível, então o hover é o que prova de
// verdade que ele é alcançável — e o clique, que é clicável.
await page.locator("[class*='rounded-2xl']").first().hover();
await expect(responder).toBeVisible();
await responder.click();
// A faixa aparece acima do campo, com o botão de cancelar.
const cancelar = page.getByRole("button", { name: /Cancelar resposta/i });
await expect(cancelar).toBeVisible();
await page.screenshot({
path: path.join(EVIDENCE, "responder-citando-faixa.png"),
fullPage: false,
});
await cancelar.click();
await expect(cancelar).toHaveCount(0);
});
test("trocar de conversa LIMPA a citação", async ({ page }) => {
// Sem isto a resposta sairia citando a mensagem de outro cliente — o pior
// desfecho possível desta feature, e invisível até acontecer com alguém.
await login(page, creds.users.agent!.email);
const temMensagens = await abrirConversaComMensagens(page);
test.skip(!temMensagens, "ambiente sem conversa com mensagens");
await page.locator("[class*='rounded-2xl']").first().hover();
const responder = page.getByRole("button", { name: /Responder a esta mensagem/i }).first();
await responder.click();
await expect(page.getByRole("button", { name: /Cancelar resposta/i })).toBeVisible();
// Volta para a lista e entra em OUTRA conversa.
await page.goto("/app/inbox?filter=all");
await page.waitForTimeout(1200);
await expect(
page.getByRole("button", { name: /Cancelar resposta/i }),
"a citação sobreviveu à troca de conversa",
).toHaveCount(0);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* A CITAÇÃO EXISTIA EM TODA PARTE, MENOS ONDE ELA SAI.
*
* ─── O defeito, visto por quem usa ──────────────────────────────────────────
*
* O caminho estava inteiro: a tela deixa escolher a mensagem, o handler resolve
* o `external_id` da citada, o banco guarda `reply_to_message_id`, e a bolha
* aparece pendurada na original. No CRM.
*
* No WhatsApp do cliente chegava mensagem SOLTA. O adapter do canal por QR
* simplesmente não lia `envelope.replyToExternalId` — o campo existia no
* envelope, o canal intermediado já o usava, e este ignorava.
*
* É o pior formato de defeito deste produto: a tela promete, nada fica
* vermelho, e quem descobre é o atendente quando o cliente responde sem
* entender do que se fala.
*
* ─── O formato do id é onde isto falharia em silêncio ──────────────────────
*
* `reply_to` quer o id COMPLETO (`{fromMe}_{chatId}_{bareId}`). O WAHA é
* assimétrico (ver `bareWaMessageId`): a resposta de envio devolve o cru, o
* webhook devolve o completo. Medido numa instalação real: as 1.734 mensagens
* de ENTRADA têm o completo — e citar o que o CLIENTE disse é o caso que
* importa.
*/
const fetchOriginal = globalThis.fetch;
afterEach(() => {
globalThis.fetch = fetchOriginal;
vi.unstubAllEnvs();
});
/** Captura o corpo que sai para o WAHA. */
function espiao() {
const corpos: Record<string, unknown>[] = [];
globalThis.fetch = vi.fn(async (_u: unknown, init?: unknown) => {
const b = ((init ?? {}) as { body?: string }).body;
if (b) corpos.push(JSON.parse(b) as Record<string, unknown>);
return { ok: true, status: 200, json: async () => ({ id: "3EB0ABC" }), text: async () => "" };
}) as unknown as typeof fetch;
return corpos;
}
async function cliente() {
const { WahaClient } = await import("@/lib/waha/client");
return new WahaClient("http://waha", "chave");
}
const CITADA = "false_127904277102624@lid_3EB060A3E6B358FFFF84DC";
describe("o canal por QR manda a citação", () => {
it("o `reply_to` sai com o id COMPLETO, como a API pede", async () => {
const corpos = espiao();
await (await cliente()).sendMessage("s1", "595@c.us", "hijale no recuerdo bro", CITADA);
expect(corpos[0]?.reply_to, "a citação não saiu — chega mensagem solta").toBe(CITADA);
expect(corpos[0]?.text).toBe("hijale no recuerdo bro");
});
it("sem citação, o campo NÃO vai no corpo", async () => {
// `reply_to: null` é pedir para citar "nada". O envio comum não pode passar
// a carregar um campo vazio só porque o outro caso existe.
const corpos = espiao();
await (await cliente()).sendMessage("s1", "595@c.us", "oi");
expect(Object.keys(corpos[0] ?? {})).not.toContain("reply_to");
});
it("string vazia também não vira citação", async () => {
const corpos = espiao();
await (await cliente()).sendMessage("s1", "595@c.us", "oi", "");
expect(Object.keys(corpos[0] ?? {})).not.toContain("reply_to");
});
});
describe("o adapter repassa o que o envelope traz", () => {
/**
* ⚠️ O CLIENTE É DUBLADO, e não montado a partir de env.
*
* A primeira versão usava `vi.stubEnv` para dar `WAHA_BASE_URL` e
* `WAHA_API_KEY` ao `getWahaClient()`. Passava na minha máquina e FALHOU no
* CI — porque `lib/env.ts` parseia `process.env` uma vez, no carregamento do
* módulo, e o stub chega depois. Sem env, `getWahaClient()` devolve `null`, o
* adapter sai por cima e nenhum corpo é capturado.
*
* Ou seja: o teste dizia "a citação chega" porque a MINHA máquina tinha as
* credenciais. Teste que depende do ambiente de quem roda não prova nada — e
* este mentia para o lado pior, o verde.
*/
it("`replyToExternalId` chega ao corpo do envio", async () => {
const chamadas: unknown[][] = [];
vi.doMock("@/lib/waha/client", async (orig) => ({
...(await orig<Record<string, unknown>>()),
getWahaClient: () => ({
sendMessage: (...a: unknown[]) => {
chamadas.push(a);
return Promise.resolve({ id: "3EB0ABC" });
},
}),
}));
vi.resetModules();
const { wahaAdapter } = await import("@/lib/channels/adapters/waha");
await wahaAdapter.send({
// `organizationId` entrou no envelope na sincronia de 21/08 — o adapter
// não o usa aqui, mas o tipo o exige.
organizationId: "org-1",
sessionRef: "s1",
to: "595@c.us",
kind: "text",
body: "hijale no recuerdo bro",
replyToExternalId: CITADA,
});
// O 4º argumento de `sendMessage` é a citação — o elo que faltava.
expect(chamadas[0]?.[3], "o adapter voltou a ignorar a citação").toBe(CITADA);
vi.doUnmock("@/lib/waha/client");
vi.resetModules();
});
});
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* O BOTÃO DE RESPONDER PRECISA EXISTIR NO CELULAR.
*
* ─── O defeito que este arquivo existe para impedir ─────────────────────────
*
* A primeira versão copiou o WhatsApp Web: `opacity-0` + `group-hover`. Em
* desktop fica elegante; no celular o botão fica INVISÍVEL PARA SEMPRE — não há
* como passar o mouse, e `focus-visible` só chega por teclado.
*
* Ou seja, a função sumia exatamente onde o dono deste CRM mais atende. Foi ele
* quem apontou, olhando a tela do telefone, e não nenhum gate.
*
* ─── Por que a pergunta é `hover`, e não largura ────────────────────────────
*
* `@media (hover: hover)` pergunta pelo DISPOSITIVO. Um tablet largo com toque
* continua mostrando o botão; um desktop com janela estreita continua
* escondendo. Um breakpoint de largura (`md:`) erraria os dois casos, e erraria
* em silêncio.
*
* ─── O que este teste NÃO prova, e por que ele é assim mesmo ───────────────
*
* Ele lê CLASSES, não comportamento — e classe presente não é botão alcançável.
* A prova de verdade seria abrir um browser SEM hover, e o Playwright não sabe
* emular isso: `emulateMedia` cobre `colorScheme`, `reducedMotion` e
* `forcedColors`, não `hover`. `hasTouch` muda os eventos, não a media query.
*
* Então esta é a rede possível, e ela é honesta sobre o que segura: impede a
* REGRESSÃO exata que aconteceu (alguém voltar a esconder sem condicionar ao
* dispositivo). Quem quiser a prova real precisa de um aparelho na mão.
*/
const BOLHA = readFileSync("components/inbox/MessageBubble.tsx", "utf8");
describe("no celular o botão de responder aparece", () => {
it("o padrão é VISÍVEL — esconder é a exceção", () => {
// A ordem importa: `opacity-100` como base e o `0` atrás da media query.
// Invertido, o celular volta a não ver nada.
expect(BOLHA).toMatch(/"opacity-100 \[@media\(hover:hover\)\]:opacity-0"/);
});
it("só esconde onde EXISTE hover", () => {
expect(BOLHA, "voltou a esconder sem perguntar pelo dispositivo").toMatch(
/\[@media\(hover:hover\)\]:group-hover:opacity-100/,
);
});
it("não usa breakpoint de LARGURA para decidir isso", () => {
// `md:opacity-0` erraria o tablet com toque e o desktop estreito — e erraria
// calado, que é o que torna esse tipo de bug caro.
const trechos = [...BOLHA.matchAll(/(?:sm|md|lg|xl):opacity-0/g)];
expect(trechos.map((m) => m[0]), "largura não responde 'tem hover?'").toEqual([]);
});
it("os DOIS botões (entrada e saída) seguem a mesma regra", () => {
// São dois elementos espelhados. Consertar um e esquecer o outro deixaria a
// metade da conversa sem resposta possível no celular.
const comRegra = [...BOLHA.matchAll(/\[@media\(hover:hover\)\]:opacity-0/g)];
expect(comRegra.length, "um dos dois botões ficou de fora").toBe(2);
});
it("o teclado também alcança", () => {
// Quem navega por Tab não tem hover nem toque. Sem isto o botão existe e é
// inalcançável — que é a mesma falha, com outro público.
expect(BOLHA).toMatch(/focus-visible:opacity-100/);
});
});