feat(inbox): compartilhar contato no composer com cartao clicavel no WhatsApp

Permite enviar cartoes de contato pelo inbox (CRM ou telefone manual), com suporte WAHA e Meta Cloud, resolucao do wa_id via check-exists para o nono digito BR, e abertura da conversa ao tocar no cartao dentro do CRM.
This commit is contained in:
Ian Couto
2026-08-19 14:30:18 -03:00
parent a7afbdd382
commit c16ccd31af
27 changed files with 1372 additions and 81 deletions
@@ -0,0 +1,55 @@
/**
* POST /api/v1/conversations/open-with-contact
*
* Resolve o contato do cartão compartilhado e abre a conversa 1:1 na mesma sessão
* de canal — comportamento equivalente ao toque no contato no WhatsApp.
*/
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { ApiError } from "@/lib/api/types";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { openSharedContactConversation } from "@/lib/messaging/open-shared-contact-conversation";
import { openConversationWithContactSchema, validateRequest } from "@/lib/schemas";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("agent", { requestId, resource: "conversations" });
if (!authz.ok) return authz.response;
let input;
try {
input = await validateRequest(openConversationWithContactSchema, req);
} catch (err) {
if (err instanceof ApiError) {
return fail(err.code, err.message, err.status, {
details: err.details as Record<string, unknown> | undefined,
requestId,
});
}
throw err;
}
try {
const admin = createAdminClient();
const result = await openSharedContactConversation(admin, authz.org.orgId, input);
return ok(result, { requestId });
} catch (err) {
const msg = err instanceof Error ? err.message : "open_failed";
if (msg === "contact_not_found") {
return fail("not_found", "Contato não encontrado.", 404, { requestId });
}
if (msg === "session_not_found") {
return fail("not_found", "Sessão de canal não encontrada.", 404, { requestId });
}
if (msg === "invalid_phone") {
return fail("validation_error", "Telefone inválido.", 422, { requestId });
}
return fail("internal_error", msg, 500, { requestId });
}
}
+74 -51
View File
@@ -21,7 +21,7 @@ import {
import { ARCHIVED_AT, queryTolerantToMissingArchived } from "@/lib/channels/archived";
import { conferirDefinicao } from "@/lib/channels/conferir-definicao";
import { isMediaPathOwnedBy } from "@/lib/messaging/media/upload-validation";
import { wahaContactPayload } from "@/lib/messaging/contact-card";
import { parseDialablePhone, wahaContactPayload } from "@/lib/messaging/contact-card";
import type { ListMessagesQuery, SendMessageInput } from "@/lib/schemas";
import { sendTemplateForSession } from "@/lib/channels/meta/send-template-for-session";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -323,63 +323,86 @@ export async function sendMessageHandler(
if (input.type === "contact") {
const sharedId = input.metadata?.shared_contact_id;
if (typeof sharedId !== "string") {
const inline = input.metadata?.shared_contact;
if (typeof sharedId === "string" && sharedId.length > 0) {
const { data: shared, error: sharedErr } = await supabase
.from("contacts")
.select("id, display_name, name, phone_number, is_anonymized, is_blocked")
.eq("id", sharedId)
.eq("organization_id", ctx.organization_id)
.maybeSingle();
if (sharedErr) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, sharedErr.message);
}
if (!shared) {
throw new ApiError(404, "not_found", undefined, ctx.requestId, "Contato não encontrado.");
}
const row = shared as {
id: string;
display_name: string | null;
name: string | null;
phone_number: string | null;
is_anonymized: boolean;
is_blocked: boolean;
};
if (row.is_anonymized) {
throw new ApiError(
422,
"contact_anonymized",
undefined,
ctx.requestId,
"Contato anonimizado não pode ser compartilhado.",
);
}
if (!row.phone_number) {
throw new ApiError(
422,
"missing_phone_number",
undefined,
ctx.requestId,
"Contato sem telefone para envio como cartão.",
);
}
const displayName = row.display_name ?? row.name ?? row.phone_number;
outboundBody = displayName;
outboundMetadata = {
...outboundMetadata,
shared_contact: {
contact_id: row.id,
name: displayName,
phone_number: row.phone_number,
},
};
} else if (inline && typeof inline === "object" && !Array.isArray(inline)) {
const o = inline as Record<string, unknown>;
const rawPhone = typeof o.phone_number === "string" ? o.phone_number : "";
const phone = parseDialablePhone(rawPhone);
if (!phone) {
throw new ApiError(
422,
"invalid_payload",
undefined,
ctx.requestId,
"Telefone inválido para envio como cartão.",
);
}
const nameRaw = typeof o.name === "string" ? o.name.trim() : "";
const displayName = nameRaw || phone;
outboundBody = displayName;
outboundMetadata = {
...outboundMetadata,
shared_contact: { name: displayName, phone_number: phone },
};
} else {
throw new ApiError(
422,
"invalid_payload",
undefined,
ctx.requestId,
"metadata.shared_contact_id é obrigatório para type contact.",
"Informe metadata.shared_contact_id ou metadata.shared_contact com telefone.",
);
}
const { data: shared, error: sharedErr } = await supabase
.from("contacts")
.select("id, display_name, name, phone_number, is_anonymized, is_blocked")
.eq("id", sharedId)
.eq("organization_id", ctx.organization_id)
.maybeSingle();
if (sharedErr) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, sharedErr.message);
}
if (!shared) {
throw new ApiError(404, "not_found", undefined, ctx.requestId, "Contato não encontrado.");
}
const row = shared as {
id: string;
display_name: string | null;
name: string | null;
phone_number: string | null;
is_anonymized: boolean;
is_blocked: boolean;
};
if (row.is_anonymized) {
throw new ApiError(
422,
"contact_anonymized",
undefined,
ctx.requestId,
"Contato anonimizado não pode ser compartilhado.",
);
}
if (!row.phone_number) {
throw new ApiError(
422,
"missing_phone_number",
undefined,
ctx.requestId,
"Contato sem telefone para envio como cartão.",
);
}
const displayName = row.display_name ?? row.name ?? row.phone_number;
outboundBody = displayName;
outboundMetadata = {
...outboundMetadata,
shared_contact: {
contact_id: row.id,
name: displayName,
phone_number: row.phone_number,
},
};
}
const now = new Date().toISOString();
+35 -2
View File
@@ -12,6 +12,7 @@ import { PaperPlaneTilt } from "@/lib/ui/icons";
import { Button } from "@/components/ui/button";
import { AttachMenu } from "@/components/inbox/composer/AttachMenu";
import { AttachmentPreviewDialog } from "@/components/inbox/composer/AttachmentPreviewDialog";
import { ContactPickerDialog } from "@/components/inbox/composer/ContactPickerDialog";
import { AudioRecorder } from "@/components/inbox/composer/AudioRecorder";
import { DraftReplyButton } from "@/components/inbox/composer/DraftReplyButton";
import { EmojiButton } from "@/components/inbox/composer/EmojiButton";
@@ -44,15 +45,18 @@ interface Props {
janelaFechada?: string | null;
/** 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. */
currentContactId?: string | null;
}
export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
{ conversationId, disabled, blockedReason, janelaFechada, contactName },
{ conversationId, disabled, blockedReason, janelaFechada, contactName, currentContactId },
ref,
) {
const t = useT();
const [text, setText] = useState("");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [contactPickerOpen, setContactPickerOpen] = useState(false);
const [menuDismissed, setMenuDismissed] = useState(false);
const [mode, setMode] = useState<"reply" | "note">("reply");
const taRef = useRef<HTMLTextAreaElement | null>(null);
@@ -209,7 +213,13 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
</button>
</div>
<div className="flex items-end gap-2">
{mode === "reply" && <AttachMenu disabled={respostaBarrada} onPick={setPendingFile} />}
{mode === "reply" && (
<AttachMenu
disabled={respostaBarrada}
onPick={setPendingFile}
onPickContact={() => setContactPickerOpen(true)}
/>
)}
{mode === "reply" && (
<DraftReplyButton conversationId={conversationId} disabled={isDisabled} onDraft={applyDraft} />
)}
@@ -309,6 +319,29 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
}
}}
/>
<ContactPickerDialog
open={contactPickerOpen}
onOpenChange={setContactPickerOpen}
excludeContactId={currentContactId}
sending={send.isPending}
onPick={(payload) => {
send.mutate(
{
conversation_id: conversationId,
type: "contact",
metadata: payload.contactId
? { shared_contact_id: payload.contactId }
: {
shared_contact: {
name: payload.name,
phone_number: payload.phone_number,
},
},
},
{ onSuccess: () => setContactPickerOpen(false) },
);
}}
/>
</>
);
});
+1
View File
@@ -263,6 +263,7 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
janelaFechada={motivoDaJanela}
disabled={selectedConversation.status === "closed"}
contactName={selectedConversation.contacts?.name ?? null}
currentContactId={selectedConversation.contact_id}
/>
</>
) : selectionNotFound ? (
+9 -1
View File
@@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import type { Message } from "@/lib/types/messaging";
import { CitationButton } from "@/components/ai/CitationButton";
import { MediaRenderer } from "@/components/inbox/media/MediaRenderer";
import { ContactCard } from "@/components/inbox/media/ContactCard";
import {
extractCitations,
isAiGeneratedMessage,
@@ -35,6 +36,7 @@ export function MessageBubble({ message, debugCitations }: Props) {
const time = format(new Date(message.sent_at), "HH:mm", { locale: ptBR });
const isFailed = message.status === "failed";
const hasMedia = Boolean(message.media_url || message.media_storage_path);
const isContact = message.type === "contact";
// Figurinha sem caption: sem moldura de bolha (padrão WhatsApp).
const isBareSticker = hasMedia && message.type === "sticker" && !message.body;
// Apagada pelo autor ("apagar para todos"). A linha continua no histórico —
@@ -93,7 +95,13 @@ export function MessageBubble({ message, debugCitations }: Props) {
</div>
)}
{message.body && (
{isContact && !hasMedia && (
<div className={cn(message.body && isContact && "mb-1")}>
<ContactCard message={message} />
</div>
)}
{message.body && !isContact && (
<p className="whitespace-pre-wrap break-words leading-snug">{message.body}</p>
)}
</>
+14 -3
View File
@@ -3,15 +3,16 @@ import { useRef } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { FileText, ImageSquare, Plus } from "@/lib/ui/icons";
import { FileText, ImageSquare, Plus, UserCircle } from "@/lib/ui/icons";
interface Props {
disabled?: boolean;
onPick: (file: File) => void;
onPickContact?: () => void;
}
/** Menu "+" do composer (padrão WhatsApp): Fotos e vídeos / Documento. */
export function AttachMenu({ disabled, onPick }: Props) {
/** Menu "+" do composer (padrão WhatsApp): Fotos e vídeos / Documento / Contato. */
export function AttachMenu({ disabled, onPick, onPickContact }: Props) {
const mediaRef = useRef<HTMLInputElement | null>(null);
const docRef = useRef<HTMLInputElement | null>(null);
@@ -53,6 +54,16 @@ export function AttachMenu({ disabled, onPick }: Props) {
<FileText size={18} weight="duotone" className="text-primary" aria-hidden />
Documento
</button>
{onPickContact && (
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm hover:bg-muted"
onClick={onPickContact}
>
<UserCircle size={18} weight="duotone" className="text-primary" aria-hidden />
Contato
</button>
)}
</PopoverContent>
</Popover>
{/* Os inputs vivem FORA do PopoverContent: o Radix desmonta o conteúdo do
@@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useContactList } from "@/hooks/contacts/useContactList";
import { parseDialablePhone, phoneToWhatsappId } from "@/lib/messaging/contact-card";
import { MagnifyingGlass, UserCircle } from "@/lib/ui/icons";
import { cn } from "@/lib/utils";
import type { Contact } from "@/lib/types/contacts";
/** Contato da base ou informado na hora — o que vai no cartão WhatsApp. */
export interface ContactPickPayload {
contactId?: string;
name: string;
phone_number: string;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Contato da conversa atual — não aparece na lista (não faz sentido compartilhar consigo). */
excludeContactId?: string | null;
sending?: boolean;
onPick: (payload: ContactPickPayload) => void;
}
function displayName(c: Contact): string {
return c.display_name ?? c.name ?? c.phone_number ?? "Sem nome";
}
export function ContactPickerDialog({
open,
onOpenChange,
excludeContactId,
sending,
onPick,
}: Props) {
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
const [manualName, setManualName] = useState("");
const [manualPhone, setManualPhone] = useState("");
useEffect(() => {
if (!open) {
setSearch("");
setDebounced("");
setManualName("");
setManualPhone("");
return;
}
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search, open]);
const searchPhone = parseDialablePhone(debounced);
useEffect(() => {
if (searchPhone) setManualPhone(searchPhone);
}, [searchPhone]);
const list = useContactList({ search: debounced || undefined });
const contacts =
list.data?.pages.flatMap((p) => p.data).filter((c) => {
if (excludeContactId && c.id === excludeContactId) return false;
if (c.is_anonymized) return false;
return Boolean(c.phone_number);
}) ?? [];
const resolvedManualPhone = parseDialablePhone(manualPhone);
const phoneAlreadyInList =
resolvedManualPhone &&
contacts.some(
(c) =>
c.phone_number &&
phoneToWhatsappId(c.phone_number) === phoneToWhatsappId(resolvedManualPhone),
);
function close(v: boolean) {
if (!v) {
setSearch("");
setManualName("");
setManualPhone("");
}
onOpenChange(v);
}
function pickFromDb(c: Contact) {
const phone = c.phone_number!;
onPick({
contactId: c.id,
name: displayName(c),
phone_number: phone,
});
}
function pickManual() {
if (!resolvedManualPhone) return;
onPick({
name: manualName.trim() || resolvedManualPhone,
phone_number: resolvedManualPhone,
});
}
const showManualForm = contacts.length === 0 || Boolean(searchPhone) || manualPhone.length > 0;
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Enviar contato</DialogTitle>
<DialogDescription>
Escolha alguém da base ou informe nome e telefone — como no WhatsApp.
</DialogDescription>
</DialogHeader>
<div className="relative">
<MagnifyingGlass
size={16}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar por nome ou telefone…"
className="pl-8"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto rounded-md border border-border">
{list.isLoading && contacts.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Carregando…</p>
) : contacts.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
{debounced ? "Nenhum contato encontrado na base." : "Nenhum contato com telefone na base."}
</p>
) : (
<ul className="divide-y divide-border">
{contacts.map((c) => (
<li key={c.id}>
<button
type="button"
disabled={sending}
className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm",
"hover:bg-muted disabled:opacity-50",
)}
onClick={() => pickFromDb(c)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted">
<UserCircle size={22} weight="duotone" className="text-primary" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{displayName(c)}</span>
{c.phone_number && (
<span className="block truncate text-xs text-muted-foreground">{c.phone_number}</span>
)}
</span>
</button>
</li>
))}
</ul>
)}
</div>
{list.hasNextPage && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
disabled={list.isFetchingNextPage}
onClick={() => list.fetchNextPage()}
>
{list.isFetchingNextPage ? "Carregando…" : "Carregar mais"}
</Button>
)}
{showManualForm && (
<div className="space-y-3 border-t border-border pt-3">
<p className="text-xs font-medium text-muted-foreground">
{searchPhone && !phoneAlreadyInList
? "Enviar número informado"
: "Ou informe um contato"}
</p>
{searchPhone && !phoneAlreadyInList && (
<button
type="button"
disabled={sending}
className={cn(
"flex w-full items-center gap-3 rounded-md border border-border px-3 py-2.5 text-left text-sm",
"hover:bg-muted disabled:opacity-50",
)}
onClick={pickManual}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted">
<UserCircle size={22} weight="duotone" className="text-primary" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{manualName.trim() || searchPhone}
</span>
<span className="block truncate text-xs text-muted-foreground">{searchPhone}</span>
</span>
</button>
)}
<div className="space-y-1.5">
<Label htmlFor="manual-contact-name">Nome (opcional)</Label>
<Input
id="manual-contact-name"
value={manualName}
onChange={(e) => setManualName(e.target.value)}
placeholder="Como aparece no cartão"
disabled={sending}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="manual-contact-phone">Telefone</Label>
<Input
id="manual-contact-phone"
value={manualPhone}
onChange={(e) => setManualPhone(e.target.value)}
placeholder="+55 32 98479-3302"
disabled={sending}
/>
</div>
<Button
type="button"
className="w-full"
disabled={sending || !resolvedManualPhone}
onClick={pickManual}
>
Enviar contato
</Button>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => close(false)} disabled={sending}>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { UserCircle } from "@/lib/ui/icons";
import { resolveSharedContact } from "@/lib/messaging/contact-card";
import type { Message } from "@/lib/types/messaging";
import { cn } from "@/lib/utils";
interface Props {
message: Message;
}
/** Cartão de contato compartilhado — toque abre a conversa no inbox (mesma sessão). */
export function ContactCard({ message }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const contact = resolveSharedContact(message);
if (!contact) {
return (
<div className="rounded-lg border border-current/20 bg-background/10 px-3 py-2 text-xs opacity-80">
Contato
</div>
);
}
const canOpen = !!(contact.contact_id || contact.phone_number?.trim());
async function handleOpen() {
if (!canOpen || loading) return;
setLoading(true);
try {
const res = await fetch("/api/v1/conversations/open-with-contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
channel_session_id: message.channel_session_id,
contact_id: contact!.contact_id,
phone_number: contact!.phone_number?.trim() || undefined,
name: contact!.name,
}),
});
const json = (await res.json()) as {
data?: { conversation_id: string };
error?: { message?: string };
};
if (!res.ok || !json.data?.conversation_id) {
throw new Error(json.error?.message ?? "Não foi possível abrir a conversa.");
}
router.push(`/app/inbox?id=${json.data.conversation_id}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Não foi possível abrir a conversa.");
} finally {
setLoading(false);
}
}
return (
<button
type="button"
disabled={!canOpen || loading}
onClick={handleOpen}
title={canOpen ? "Abrir conversa com este contato" : undefined}
className={cn(
"flex max-w-[220px] items-center gap-2 rounded-lg border border-current/20 bg-background/10 px-2 py-2 text-left transition-colors",
canOpen && "cursor-pointer hover:bg-background/20 disabled:cursor-wait disabled:opacity-70",
!canOpen && "cursor-default opacity-90",
)}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-background/20">
<UserCircle size={28} weight="duotone" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{contact.name}</span>
{contact.phone_number ? (
<span className="block truncate text-xs opacity-80">{contact.phone_number}</span>
) : null}
</span>
</button>
);
}
+2
View File
@@ -23,6 +23,8 @@ export function MediaRenderer({ message }: { message: Message }) {
return <AudioPlayer messageId={message.id} isOutbound={isOutbound} />;
case "video":
return <VideoMedia messageId={message.id} />;
case "contact":
return null;
default:
return (
<DocumentCard
+2 -1
View File
@@ -20,6 +20,7 @@ interface SendArgs {
template_name?: string;
template_language?: string;
template_values?: Record<string, string>;
metadata?: Record<string, unknown>;
}
interface MessagesPage {
@@ -34,7 +35,7 @@ export function useSendMessage() {
mutationFn: async (input: SendArgs) =>
apiClient.post<{ data: Message }>("/api/v1/messages", input),
onMutate: async (args) => {
if (args.media_storage_path || args.media_url) return {};
if (args.media_storage_path || args.media_url || args.type === "contact") return {};
const queryKey = ["messages", args.conversation_id];
await qc.cancelQueries({ queryKey });
+14 -1
View File
@@ -20,6 +20,7 @@
* o outro canal converte por nós, este não.
*/
import { createAdminClient } from "@/lib/supabase/admin";
import { metaContactsPayload } from "@/lib/messaging/contact-card";
import { resolveMetaCreds } from "../meta/credentials";
import type { ChannelAdapter, ChannelHealth, OutboundEnvelope, RecipientInput } from "../types";
@@ -40,6 +41,15 @@ function toE164Digits(raw: string): string {
import { metaCredsFromEnv } from "../meta/credentials";
export { metaCredsFromEnv as getMetaCreds };
/** `kind: "contact"` → objeto `contacts` da Cloud API. */
function contactPayload(env: OutboundEnvelope): Record<string, unknown> | null {
if (env.kind !== "contact" || !env.contact) return null;
return {
type: "contacts",
contacts: metaContactsPayload(env.contact.fullName, env.contact.phoneNumber),
};
}
/** `kind` do envelope → objeto de mídia da Cloud API. */
function mediaPayload(env: OutboundEnvelope): Record<string, unknown> | null {
if (!env.media) return null;
@@ -172,7 +182,10 @@ export const metaCloudAdapter: ChannelAdapter = {
// o banner de "canal não conectado"; transformar em erro mudaria comportamento.
if (!creds) return { externalId: null };
const corpo = mediaPayload(envelope) ?? { type: "text", text: { body: envelope.body ?? "" } };
const corpo =
contactPayload(envelope) ??
mediaPayload(envelope) ??
{ type: "text", text: { body: envelope.body ?? "" } };
const res = await fetch(
`https://graph.facebook.com/${creds.graphVersion}/${creds.phoneNumberId}/messages`,
+24 -7
View File
@@ -5,9 +5,11 @@
*
* Nenhuma regra de negócio mora neste arquivo (ver `ChannelAdapter` em ../types).
*/
import { wahaContactPayload } from "@/lib/messaging/contact-card";
import { fetchWahaMedia } from "@/lib/messaging/media/waha-source";
import { getWahaClient } from "@/lib/waha/client";
import { wahaSendPlanFor } from "@/lib/waha/media-send";
import { resolveWhatsappIdForContactCard } from "@/lib/waha/resolve-contact-whatsapp-id";
import { bareWaMessageId, parseWahaMessageId } from "@/lib/waha/message-id";
import { resolveWahaChatId } from "@/lib/waha/send";
import type { FetchedMedia } from "@/lib/messaging/media/types";
@@ -133,13 +135,28 @@ export const wahaAdapter: ChannelAdapter = {
// comportamento visível — proibido nas Fases 0–2.
if (!client) return { externalId: null };
const res = envelope.media
? await client.sendMedia(
envelope.sessionRef,
envelope.to,
wahaSendPlanFor(envelope.kind, envelope.media),
)
: await client.sendMessage(envelope.sessionRef, envelope.to, envelope.body ?? "");
let res: unknown;
if (envelope.kind === "contact" && envelope.contact) {
const resolvedId = await resolveWhatsappIdForContactCard(
client,
envelope.sessionRef,
envelope.contact.phoneNumber,
);
const contact = wahaContactPayload(
envelope.contact.fullName,
envelope.contact.phoneNumber,
resolvedId ?? envelope.contact.whatsappId,
);
res = await client.sendContactVcard(envelope.sessionRef, envelope.to, [contact]);
} else if (envelope.media) {
res = await client.sendMedia(
envelope.sessionRef,
envelope.to,
wahaSendPlanFor(envelope.kind, envelope.media),
);
} else {
res = await client.sendMessage(envelope.sessionRef, envelope.to, envelope.body ?? "");
}
return { externalId: parseWahaMessageId(res) };
},
+3
View File
@@ -156,6 +156,9 @@ export const zernioAdapter: ChannelAdapter = {
},
async send(envelope: OutboundEnvelope): Promise<{ externalId: string | null }> {
if (envelope.kind === "contact") {
throw new Error("zernio_contact_not_supported: envio de cartão de contato não suportado neste canal.");
}
const admin = createAdminClient();
const creds = await resolveZernioCreds(admin, envelope.sessionRef);
// LANÇA, não devolve null: com `isConfigured` sempre true, quem desiste é
+7 -2
View File
@@ -81,6 +81,7 @@ async function findContactByVariants(
/** Prévia curta para a lista de conversas. Mídia vira rótulo, nunca URL. */
function previewOf(e: InboundMessageEvent): string {
if (e.type === "text") return (e.text ?? "").slice(0, 120);
if (e.type === "contact") return e.sharedContact?.name ? `👤 ${e.sharedContact.name}` : "[contato]";
if (e.type === "audio") return e.media?.voice ? "🎤 Mensagem de voz" : "🎵 Áudio";
if (e.type === "image") return "📷 Imagem";
if (e.type === "video") return "🎬 Vídeo";
@@ -138,12 +139,16 @@ export async function ingestMetaInbound(
contact_id: contactId as string,
direction: "inbound",
status: "delivered",
// A Meta manda `contacts` (plural); o CHECK do banco espera `contact`.
type: e.type === "text" ? "text" : e.type,
body: e.text,
body: e.type === "contact" ? (e.sharedContact?.name ?? e.text) : e.text,
external_id: e.externalId,
media_mime: e.media?.mime ?? null,
sent_at: e.sentAt.toISOString(),
metadata: e.media ? { meta_media_id: e.media.id, voice: e.media.voice } : {},
metadata: {
...(e.media ? { meta_media_id: e.media.id, voice: e.media.voice } : {}),
...(e.sharedContact ? { shared_contact: e.sharedContact } : {}),
},
})
.select("id")
.maybeSingle();
+14 -4
View File
@@ -17,6 +17,8 @@
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { parseMetaInboundContact, type SharedContact } from "@/lib/messaging/contact-card";
/** Assinatura da Meta: `sha256=<hex>` no header `X-Hub-Signature-256`. */
export function verifyMetaSignature(
rawBody: string,
@@ -75,9 +77,11 @@ export interface InboundMessageEvent {
from: string;
profileName: string | null;
sentAt: Date;
/** `text` | `audio` | `image` | `video` | `document` | `sticker` | … */
/** `text` | `audio` | `image` | `video` | `document` | `sticker` | `contact` | … */
type: string;
text: string | null;
/** Preenchido quando `type === "contact"` (cartão compartilhado). */
sharedContact?: SharedContact | null;
media: {
id: string;
/** A Meta manda URL pronta, com `ext=` de expiração — baixe na hora, não guarde. */
@@ -174,7 +178,9 @@ export function parseMetaWebhook(envelope: MetaWebhookEnvelope): MetaWebhookEven
const perfil = contatos.find((c) => str(c.wa_id) === from);
const tipo = str(raw.type) ?? "unknown";
const corpoMidia = raw[tipo] as Record<string, unknown> | undefined;
const corpoMidia = tipo !== "contacts" ? (raw[tipo] as Record<string, unknown> | undefined) : undefined;
const sharedContact = tipo === "contacts" ? parseMetaInboundContact(raw) : null;
const tipoCrm = tipo === "contacts" ? "contact" : tipo;
out.push({
kind: "inbound_message",
@@ -185,8 +191,12 @@ export function parseMetaWebhook(envelope: MetaWebhookEnvelope): MetaWebhookEven
profileName: str((perfil?.profile as Record<string, unknown> | undefined)?.name),
// A Meta manda epoch em SEGUNDOS, string. Passar direto ao Date daria 1970.
sentAt: new Date(Number(str(raw.timestamp) ?? "0") * 1000),
type: tipo,
text: tipo === "text" ? str((raw.text as Record<string, unknown>)?.body) : null,
type: tipoCrm,
text:
tipoCrm === "text"
? str((raw.text as Record<string, unknown>)?.body)
: sharedContact?.name ?? null,
...(sharedContact ? { sharedContact } : {}),
media:
corpoMidia && str(corpoMidia.id)
? {
+10
View File
@@ -63,6 +63,14 @@ export interface RecipientInput {
waLid?: string | null | undefined;
}
/** Contato compartilhado (vcard) — só `kind: "contact"`. */
export interface OutboundContact {
fullName: string;
phoneNumber: string;
whatsappId: string;
vcard: string;
}
export interface OutboundEnvelope {
/** Identificador da sessão/número no provider (WAHA: nome da sessão). */
sessionRef: string;
@@ -71,6 +79,8 @@ export interface OutboundEnvelope {
kind: OutboundKind;
body?: string;
media?: OutboundMedia;
/** Cartão de contato — preenchido quando `kind === "contact"`. */
contact?: OutboundContact;
/**
* Id que o PROVIDER dá a esta thread, quando ele endereça por thread própria
* em vez de por telefone (`conversations.provider_conversation_id`).
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
buildVcard,
metaContactsPayload,
parseDialablePhone,
parseMetaInboundContact,
parseVcard,
phoneToWhatsappId,
resolveSharedContact,
sharedContactFromMetadata,
wahaContactPayload,
} from "./contact-card";
describe("contact-card", () => {
it("parseVcard extrai nome e telefone", () => {
const vcard = buildVcard("Maria Silva", "+5511999887766");
const parsed = parseVcard(vcard);
expect(parsed?.name).toBe("Maria Silva");
expect(parsed?.phone_number).toBe("+5511999887766");
});
it("sharedContactFromMetadata lê objeto gravado", () => {
const c = sharedContactFromMetadata({
shared_contact: { contact_id: "abc", name: "João", phone_number: "+5511888777666" },
});
expect(c?.contact_id).toBe("abc");
expect(c?.name).toBe("João");
});
it("resolveSharedContact prioriza metadata", () => {
const c = resolveSharedContact({
type: "contact",
body: "BEGIN:VCARD...",
metadata: { shared_contact: { name: "CRM", phone_number: "+5511000000000" } },
});
expect(c?.name).toBe("CRM");
});
it("wahaContactPayload inclui whatsappId só com dígitos", () => {
const p = wahaContactPayload("Ana", "+55 11 99999-8888");
expect(p.whatsappId).toBe(phoneToWhatsappId("+5511999998888"));
expect(p.fullName).toBe("Ana");
});
it("metaContactsPayload monta formatted_name e wa_id", () => {
const [c] = metaContactsPayload("Maria Silva", "+5511999887766");
expect(c?.name.formatted_name).toBe("Maria Silva");
expect(c?.name.first_name).toBe("Maria");
expect(c?.name.last_name).toBe("Silva");
expect(c?.phones[0]?.wa_id).toBe("5511999887766");
});
it("parseMetaInboundContact lê payload da Meta", () => {
const c = parseMetaInboundContact({
contacts: [{
name: { formatted_name: "João", first_name: "João" },
phones: [{ phone: "+5511888777666", wa_id: "5511888777666", type: "CELL" }],
}],
});
expect(c?.name).toBe("João");
expect(c?.phone_number).toBe("+5511888777666");
});
it("parseDialablePhone aceita E.164 com +", () => {
expect(parseDialablePhone("+5532984793302")).toBe("+5532984793302");
});
it("parseDialablePhone rejeita curto demais", () => {
expect(parseDialablePhone("123")).toBeNull();
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Cartão de contato compartilhado no WhatsApp (vcard).
* Usado na saída (montar payload WAHA) e na entrada (parse do body).
*/
export interface SharedContact {
contact_id?: string;
name: string;
phone_number: string;
}
const VCARD_FN = /^FN:(.+)$/m;
const VCARD_TEL = /^TEL[^:]*:(.+)$/m;
/** Extrai nome e telefone de um vCard em texto (mensagem inbound). */
export function parseVcard(body: string | null | undefined): SharedContact | null {
if (!body?.includes("BEGIN:VCARD")) return null;
const name = body.match(VCARD_FN)?.[1]?.trim() ?? null;
const telRaw = body.match(VCARD_TEL)?.[1]?.trim() ?? null;
if (!name && !telRaw) return null;
const phone = telRaw ? normalizePhoneForDisplay(telRaw) : "";
if (!name && !phone) return null;
return { name: name ?? phone, phone_number: phone || telRaw! };
}
/** Lê o contato que nós mesmos gravamos em metadata ao enviar. */
export function sharedContactFromMetadata(
metadata: Record<string, unknown> | null | undefined,
): SharedContact | null {
const raw = metadata?.shared_contact;
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name.trim() : "";
const phone = typeof o.phone_number === "string" ? o.phone_number.trim() : "";
if (!name && !phone) return null;
return {
contact_id: typeof o.contact_id === "string" ? o.contact_id : undefined,
name: name || phone,
phone_number: phone,
};
}
/** Resolve o contato exibível: metadata (outbound) → parse vcard (inbound) → body como nome. */
export function resolveSharedContact(
message: { type: string; body: string | null; metadata: Record<string, unknown> },
): SharedContact | null {
if (message.type !== "contact") return null;
const fromMeta = sharedContactFromMetadata(message.metadata);
if (fromMeta) return fromMeta;
const fromVcard = parseVcard(message.body);
if (fromVcard) return fromVcard;
if (message.body?.trim()) {
return { name: message.body.trim(), phone_number: "" };
}
return null;
}
/** Extrai telefone discável (E.164 com +) de texto livre; null se inválido. */
export function parseDialablePhone(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const digits = phoneToWhatsappId(trimmed);
if (digits.length < 8 || digits.length > 15) return null;
return normalizePhoneForDisplay(trimmed.startsWith("+") ? trimmed : `+${digits}`);
}
/** Só dígitos — whatsappId do WAHA / wa_id da Meta. */
export function phoneToWhatsappId(phone: string): string {
return phone.replace(/\D/g, "");
}
/** Exibição legível; preserva + quando já E.164. */
export function normalizePhoneForDisplay(raw: string): string {
const trimmed = raw.trim();
if (trimmed.startsWith("+")) return trimmed;
const digits = phoneToWhatsappId(trimmed);
return digits.length >= 8 ? `+${digits}` : trimmed;
}
/** Monta vCard mínimo aceito pelo WAHA sendContactVcard. */
export function buildVcard(name: string, phone: string, whatsappId?: string): string {
const waid = whatsappId ?? phoneToWhatsappId(normalizePhoneForDisplay(phone));
const telDisplay = whatsappId ? `+${waid}` : normalizePhoneForDisplay(phone);
const safeName = name.replace(/\n/g, " ").trim() || telDisplay;
return [
"BEGIN:VCARD",
"VERSION:3.0",
`FN:${safeName}`,
`TEL;type=CELL;type=VOICE;waid=${waid}:${telDisplay}`,
"END:VCARD",
].join("\n");
}
/** Payload de um contato para POST /api/sendContactVcard. */
export function wahaContactPayload(name: string, phone: string, whatsappId?: string) {
const waid = whatsappId ?? phoneToWhatsappId(normalizePhoneForDisplay(phone));
const telDisplay = whatsappId ? `+${waid}` : normalizePhoneForDisplay(phone);
const safeName = name.replace(/\n/g, " ").trim() || telDisplay;
return {
fullName: safeName,
phoneNumber: telDisplay,
whatsappId: waid,
vcard: buildVcard(safeName, telDisplay, waid),
};
}
/** Cartão de contato no formato da WhatsApp Cloud API (`type: "contacts"`). */
export function metaContactsPayload(name: string, phone: string) {
const display = normalizePhoneForDisplay(phone);
const waid = phoneToWhatsappId(display);
const safeName = name.replace(/\n/g, " ").trim() || display;
const sp = safeName.indexOf(" ");
const firstName = sp > 0 ? safeName.slice(0, sp) : safeName;
const lastName = sp > 0 ? safeName.slice(sp + 1).trim() : undefined;
return [
{
name: {
formatted_name: safeName,
first_name: firstName,
...(lastName ? { last_name: lastName } : {}),
},
phones: [{ phone: display, type: "CELL", wa_id: waid }],
},
];
}
/** Extrai o primeiro contato de um payload inbound da Meta (`type: "contacts"`). */
export function parseMetaInboundContact(raw: Record<string, unknown>): SharedContact | null {
const arr = raw.contacts;
if (!Array.isArray(arr) || arr.length === 0) return null;
const first = arr[0];
if (!first || typeof first !== "object") return null;
const o = first as Record<string, unknown>;
const nameObj = o.name;
const formatted =
nameObj && typeof nameObj === "object"
? (
(nameObj as Record<string, unknown>).formatted_name ??
(nameObj as Record<string, unknown>).first_name
)
: null;
const safeName = typeof formatted === "string" ? formatted.trim() : "";
const phones = Array.isArray(o.phones) ? o.phones : [];
const phoneEntry = phones[0];
let phone = "";
if (phoneEntry && typeof phoneEntry === "object") {
const p = phoneEntry as Record<string, unknown>;
if (typeof p.phone === "string" && p.phone.trim()) {
phone = normalizePhoneForDisplay(p.phone.trim());
} else if (typeof p.wa_id === "string" && p.wa_id.trim()) {
phone = normalizePhoneForDisplay(p.wa_id.trim());
}
}
if (!safeName && !phone) return null;
return { name: safeName || phone, phone_number: phone };
}
@@ -0,0 +1,107 @@
/**
* Abre (ou reabre) a conversa 1:1 com um contato compartilhado no cartão do inbox.
* Usa a mesma sessão de canal da mensagem onde o cartão apareceu.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { ensureConversation } from "@/lib/automation/start-conversation";
import { phoneLookupVariants } from "@/lib/channels/phone-variants";
import { parseDialablePhone } from "@/lib/messaging/contact-card";
type Admin = SupabaseClient;
export interface OpenSharedContactInput {
channel_session_id: string;
contact_id?: string;
phone_number?: string;
name?: string;
}
export interface OpenSharedContactResult {
conversation_id: string;
contact_id: string;
}
async function findContactByPhoneVariants(
admin: Admin,
orgId: string,
rawPhone: string,
): Promise<{ id: string; phone_number: string } | null> {
const variantes = phoneLookupVariants(rawPhone);
if (variantes.length === 0) return null;
const { data } = await admin
.from("contacts")
.select("id, phone_number")
.eq("organization_id", orgId)
.in("phone_number", variantes)
.limit(1)
.maybeSingle();
return data ?? null;
}
async function resolveContactId(
admin: Admin,
orgId: string,
input: OpenSharedContactInput,
): Promise<string> {
if (input.contact_id) {
const { data, error } = await admin
.from("contacts")
.select("id")
.eq("organization_id", orgId)
.eq("id", input.contact_id)
.maybeSingle();
if (error) throw new Error(error.message);
if (!data) throw new Error("contact_not_found");
return (data as { id: string }).id;
}
const phone = input.phone_number ? parseDialablePhone(input.phone_number) : null;
if (!phone) throw new Error("invalid_phone");
const existente = await findContactByPhoneVariants(admin, orgId, phone);
if (existente) return existente.id;
const waid = phone.replace(/\D/g, "");
const { data: contactId, error: upsertErr } = await admin.rpc(
"fn_upsert_wa_contact" as never,
{
p_org: orgId,
p_kind: "phone",
p_phone: phone,
p_lid: null,
p_chat_id: waid,
p_notify: input.name?.trim() || null,
} as never,
);
if (upsertErr || !contactId) {
throw new Error(upsertErr?.message ?? "contact_upsert_failed");
}
return contactId as string;
}
/** Garante contato + conversa na sessão indicada; reabre conversa fechada se existir. */
export async function openSharedContactConversation(
admin: Admin,
organizationId: string,
input: OpenSharedContactInput,
): Promise<OpenSharedContactResult> {
const { data: session, error: sessErr } = await admin
.from("channel_sessions")
.select("id")
.eq("organization_id", organizationId)
.eq("id", input.channel_session_id)
.maybeSingle();
if (sessErr) throw new Error(sessErr.message);
if (!session) throw new Error("session_not_found");
const contactId = await resolveContactId(admin, organizationId, input);
const conversationId = await ensureConversation(
admin,
organizationId,
contactId,
input.channel_session_id,
);
return { conversation_id: conversationId, contact_id: contactId };
}
+58 -1
View File
@@ -4,6 +4,7 @@ import {
claimConversationSchema,
conversationTagsSchema,
listConversationsQuerySchema,
openConversationWithContactSchema,
patchConversationSchema,
sendMessageSchema,
updateConversationStatusSchema,
@@ -31,7 +32,35 @@ describe("sendMessageSchema", () => {
expect(r.success).toBe(false);
});
it("aceita payload só com media_url", () => {
it("aceita payload type contact com shared_contact_id", () => {
const r = sendMessageSchema.safeParse({
conversation_id: "11111111-1111-4111-8111-111111111111",
type: "contact",
metadata: { shared_contact_id: "22222222-2222-4222-8222-222222222222" },
});
expect(r.success).toBe(true);
});
it("aceita contact com shared_contact inline (telefone avulso)", () => {
const r = sendMessageSchema.safeParse({
conversation_id: "11111111-1111-4111-8111-111111111111",
type: "contact",
metadata: {
shared_contact: { name: "Maria", phone_number: "+5532984793302" },
},
});
expect(r.success).toBe(true);
});
it("rejeita contact sem shared_contact_id nem telefone inline", () => {
const r = sendMessageSchema.safeParse({
conversation_id: "11111111-1111-4111-8111-111111111111",
type: "contact",
});
expect(r.success).toBe(false);
});
it("rejeita payload só com media_url", () => {
const r = sendMessageSchema.safeParse({
conversation_id: "11111111-1111-4111-8111-111111111111",
type: "image",
@@ -142,3 +171,31 @@ describe("patchConversationSchema (G3-05)", () => {
expect(patchConversationSchema.safeParse({}).success).toBe(false);
});
});
describe("openConversationWithContactSchema", () => {
const session = "11111111-1111-4111-8111-111111111111";
it("aceita contact_id", () => {
expect(
openConversationWithContactSchema.safeParse({
channel_session_id: session,
contact_id: "22222222-2222-4222-8222-222222222222",
}).success,
).toBe(true);
});
it("aceita phone_number", () => {
expect(
openConversationWithContactSchema.safeParse({
channel_session_id: session,
phone_number: "+5511999998888",
}).success,
).toBe(true);
});
it("rejeita sem contact_id nem phone_number", () => {
expect(
openConversationWithContactSchema.safeParse({ channel_session_id: session }).success,
).toBe(false);
});
});
+34 -4
View File
@@ -62,10 +62,26 @@ export const sendMessageSchema = z
*/
template_values: z.record(z.string(), z.string()).optional(),
})
.refine((d) => !!d.body || !!d.media_url || !!d.media_storage_path, {
message: "body, media_url or media_storage_path required",
path: ["body"],
});
.refine(
(d) => {
if (d.type === "contact") {
const id = d.metadata?.shared_contact_id;
if (typeof id === "string" && id.length > 0) return true;
const sc = d.metadata?.shared_contact;
if (sc && typeof sc === "object" && !Array.isArray(sc)) {
const phone = (sc as Record<string, unknown>).phone_number;
return typeof phone === "string" && phone.trim().length >= 8;
}
return false;
}
return !!d.body || !!d.media_url || !!d.media_storage_path;
},
{
message:
"body, media_url, media_storage_path, metadata.shared_contact_id or metadata.shared_contact.phone_number required",
path: ["body"],
},
);
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
@@ -115,6 +131,20 @@ export const patchConversationSchema = z
export type PatchConversationInput = z.infer<typeof patchConversationSchema>;
/** POST /conversations/open-with-contact — abrir inbox a partir de cartão de contato. */
export const openConversationWithContactSchema = z
.object({
channel_session_id: z.string().uuid(),
contact_id: z.string().uuid().optional(),
phone_number: z.string().min(8).max(32).optional(),
name: z.string().trim().min(1).max(200).optional(),
})
.refine((d) => !!d.contact_id || !!d.phone_number?.trim(), {
message: "Informe contact_id ou phone_number.",
});
export type OpenConversationWithContactInput = z.infer<typeof openConversationWithContactSchema>;
/**
* Estados TERMINAIS: a conversa acabou e não volta sozinha.
*
+46
View File
@@ -201,6 +201,52 @@ export class WahaClient {
return res.json();
}
/**
* Confere se o número existe no WhatsApp e devolve o chatId canônico.
* Obrigatório antes de vcard em BR — o nono dígito do CRM nem sempre bate com o wa_id.
*/
async checkContactExists(
session: string,
phoneDigits: string,
): Promise<{ numberExists: boolean; chatId?: string | null; pn?: string | null }> {
const url = new URL(`${this.baseUrl}/api/contacts/check-exists`);
url.searchParams.set("session", session);
url.searchParams.set("phone", phoneDigits.replace(/\D/g, ""));
const res = await fetch(url, {
headers: { "X-Api-Key": this.apiKey, Accept: "application/json" },
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`waha_${res.status}: ${body.slice(0, 200)}`);
}
return res.json() as Promise<{ numberExists: boolean; chatId?: string | null; pn?: string | null }>;
}
async sendContactVcard(
session: string,
chatId: string,
contacts: Array<{
fullName: string;
phoneNumber: string;
whatsappId: string;
vcard: string;
}>,
): Promise<unknown> {
const res = await fetch(`${this.baseUrl}/api/sendContactVcard`, {
method: "POST",
headers: {
"X-Api-Key": this.apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({ session, chatId, contacts }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`waha_${res.status}: ${body.slice(0, 200)}`);
}
return res.json();
}
async sendMedia(
session: string,
chatId: string,
+20 -4
View File
@@ -275,6 +275,7 @@ const NOWEB_MESSAGE_KEY_TYPE: Record<string, string> = {
audioMessage: "audio",
documentMessage: "document",
documentWithCaptionMessage: "document",
contactMessage: "contact",
};
export function resolveMessageType(p: WahaPayload): string {
@@ -300,6 +301,20 @@ function notifyNameOf(p: WahaPayload): string | null {
return p._data?.notifyName ?? p._data?.pushName ?? null;
}
/** Corpo textual: WAHA nem sempre preenche `body` em cartões de contato NOWEB. */
function bodyOf(p: WahaPayload): string | null {
if (p.body) return p.body;
const msg = p._data?.message;
if (!msg || typeof msg !== "object") return null;
const cm = msg.contactMessage;
if (cm && typeof cm === "object") {
const o = cm as { vcard?: string; displayName?: string };
if (o.vcard) return o.vcard;
if (o.displayName) return o.displayName;
}
return null;
}
/**
* O telefone REAL de quem escreveu, quando o chat chega como `@lid`.
*
@@ -481,7 +496,8 @@ async function handleInbound(
if (parsed.kind === "group") return; // grupos não fazem binding CRM
if (!p.id) return;
// WAHA emite eventos vazios p/ status/read-receipt/presence — não viram mensagem.
if (!p.body && !mediaUrlOf(p) && !p.hasMedia) return;
const texto = bodyOf(p);
if (!texto && !mediaUrlOf(p) && !p.hasMedia) return;
// Daqui para baixo era para ser uma mensagem de verdade: se o chat não é
// endereçável, PERDEMOS uma — e isso precisa ser contável. O aviso fica depois
// das guardas acima de propósito; antes delas, todo evento de presença viraria
@@ -516,7 +532,7 @@ async function handleInbound(
direction: "inbound",
status: "delivered",
ack: p.ack ?? null,
body: p.body ?? null,
body: texto,
media_url: mediaUrlOf(p),
media_mime: mediaMimeOf(p),
sent_via: "external_device",
@@ -577,7 +593,7 @@ async function handleInbound(
conversationId,
messageId: insertedMessage?.id ?? null,
channelSessionId: session.id,
texto: p.body ?? null,
texto,
nomeDoContato: notifyNameOf(p),
requestId,
origem: "waha_webhook",
@@ -724,7 +740,7 @@ async function handleOutboundFromUserPhone(
direction: "outbound",
status: "sent",
ack: p.ack ?? null,
body: p.body ?? null,
body: bodyOf(p),
media_url: mediaUrlOf(p),
media_mime: mediaMimeOf(p),
sent_via: "external_device",
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { wahaContactPayload } from "@/lib/messaging/contact-card";
import {
resolveWhatsappIdForContactCard,
whatsappIdFromCheckResult,
} from "@/lib/waha/resolve-contact-whatsapp-id";
describe("whatsappIdFromCheckResult", () => {
it("prefere pn sobre chatId", () => {
expect(
whatsappIdFromCheckResult({
numberExists: true,
pn: "553198966398@c.us",
chatId: "70192801575156@lid",
}),
).toBe("553198966398");
});
it("extrai dígitos de chatId @c.us", () => {
expect(
whatsappIdFromCheckResult({
numberExists: true,
chatId: "5531998966398@c.us",
}),
).toBe("5531998966398");
});
});
describe("resolveWhatsappIdForContactCard", () => {
it("tenta variantes BR e usa a primeira que existir", async () => {
const client = {
checkContactExists: vi
.fn()
.mockResolvedValueOnce({ numberExists: false })
.mockResolvedValueOnce({ numberExists: true, pn: "553198966398@c.us" }),
};
const id = await resolveWhatsappIdForContactCard(
client as never,
"sessao-1",
"+5531998966398",
);
expect(id).toBe("553198966398");
expect(client.checkContactExists).toHaveBeenCalledTimes(2);
});
it("retorna null quando nenhuma variante existe", async () => {
const client = {
checkContactExists: vi.fn().mockResolvedValue({ numberExists: false }),
};
const id = await resolveWhatsappIdForContactCard(
client as never,
"sessao-1",
"+5531998966398",
);
expect(id).toBeNull();
});
});
describe("wahaContactPayload com wa_id resolvido", () => {
it("alinha phoneNumber e waid ao id canônico de 12 dígitos", () => {
const p = wahaContactPayload("Maria", "+5531998966398", "553198966398");
expect(p.whatsappId).toBe("553198966398");
expect(p.phoneNumber).toBe("+553198966398");
expect(p.vcard).toContain("waid=553198966398");
expect(p.vcard).toContain("+553198966398");
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Resolve o wa_id canônico para cartão de contato (vcard).
*
* No WhatsApp BR o CRM grava +5531998966398 (13 dígitos) mas o wa_id registrado
* pode ser 553198966398 (12, sem o nono). vCard com waid errado EXIBE o cartão,
* porém o toque no app nativo não abre a conversa — exatamente o bug reportado.
*
* WAHA documenta `GET /api/contacts/check-exists` para isso; tentamos todas as
* variantes de busca (phoneLookupVariants) antes de cair no número bruto.
*/
import { phoneLookupVariants } from "@/lib/channels/phone-variants";
import type { WahaClient } from "./client";
export interface WahaCheckExistsResult {
numberExists: boolean;
chatId?: string | null;
pn?: string | null;
}
/** Extrai só dígitos do JID retornado (`5531…@c.us` ou `@lid`). */
export function whatsappIdFromCheckResult(r: WahaCheckExistsResult): string | null {
const raw = r.pn ?? r.chatId;
if (!raw) return null;
const user = raw.split("@")[0] ?? "";
const digits = user.replace(/\D/g, "");
return digits.length >= 8 && digits.length <= 15 ? digits : null;
}
/** Consulta WAHA; null = não achou ou falhou (caller usa fallback). */
export async function resolveWhatsappIdForContactCard(
client: WahaClient,
session: string,
phone: string,
): Promise<string | null> {
const tried = new Set<string>();
for (const variant of phoneLookupVariants(phone)) {
const digits = variant.replace(/\D/g, "");
if (!digits || tried.has(digits)) continue;
tried.add(digits);
try {
const r = await client.checkContactExists(session, digits);
if (r.numberExists) {
const id = whatsappIdFromCheckResult(r);
if (id) return id;
}
} catch {
// ponytail: falha na consulta não bloqueia envio — adapter cai no wa_id bruto
}
}
return null;
}
+25
View File
@@ -164,6 +164,31 @@ describe("adapter meta_cloud — envio", () => {
).rejects.toThrow(/131009.*formato inválido/);
});
it("contato vai como type:contacts com formatted_name e wa_id", async () => {
configurar();
const spy = stubFetch({ messages: [{ id: "wamid.C" }] });
const r = await a().send({
sessionRef: "ignorado",
to: "5531998966398",
kind: "contact",
contact: {
fullName: "Maria Silva",
phoneNumber: "+5511999887766",
whatsappId: "5511999887766",
vcard: "BEGIN:VCARD…",
},
});
expect(r).toEqual({ externalId: "wamid.C" });
const corpo = JSON.parse(spy.mock.calls[0]![1].body as string) as {
type: string;
contacts: Array<{ name: { formatted_name: string }; phones: Array<{ wa_id: string }> }>;
};
expect(corpo.type).toBe("contacts");
expect(corpo.contacts[0]?.name.formatted_name).toBe("Maria Silva");
expect(corpo.contacts[0]?.phones[0]?.wa_id).toBe("5511999887766");
});
it("resposta sem id devolve externalId null, sem estourar", async () => {
configurar();
stubFetch({ messages: [] });
+40
View File
@@ -116,6 +116,46 @@ describe("parseMetaWebhook", () => {
]);
});
it("cartão de contato inbound vira type contact + sharedContact", () => {
const [e] = parseMetaWebhook({
object: "whatsapp_business_account",
entry: [
{
id: "waba1",
changes: [
{
field: "messages",
value: {
metadata: { phone_number_id: "1103328999528818" },
contacts: [{ wa_id: "5531998966398", profile: { name: "Cliente" } }],
messages: [
{
id: "wamid.C",
from: "5531998966398",
timestamp: "1700000000",
type: "contacts",
contacts: [
{
name: { formatted_name: "Maria Silva", first_name: "Maria", last_name: "Silva" },
phones: [{ phone: "+5511999887766", wa_id: "5511999887766", type: "CELL" }],
},
],
},
],
},
},
],
},
],
});
expect(e).toMatchObject({
kind: "inbound_message",
type: "contact",
text: "Maria Silva",
sharedContact: { name: "Maria Silva", phone_number: "+5511999887766" },
});
});
it("extrai status de entrega, inclusive o erro quando falha", () => {
const eventos = parseMetaWebhook({
object: "whatsapp_business_account",