mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
|
||||
@@ -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 é
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
? {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user