@adatechnology/meta-whatsapp-contracts 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +30 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/testing/index.cjs +36 -1
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +16 -2
- package/dist/testing/index.d.ts +16 -2
- package/dist/testing/index.js +35 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/{webhook.types-DWaAhvNu.d.cts → webhook.types-BsYM6OfC.d.cts} +677 -1
- package/dist/{webhook.types-DWaAhvNu.d.ts → webhook.types-BsYM6OfC.d.ts} +677 -1
- package/package.json +1 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/webhook.types.ts","../src/flow.types.ts","../src/providers.ts","../src/errors.ts","../src/previewMedia.types.ts"],"sourcesContent":["import { z } from 'zod'\n\n// Extraído de financiamento-imobiliario-bot/apps/api/src/modules/webhook/application/use-cases/\n// ReceiveWhatsAppWebhook.use-case.ts:39-113 — shape real do payload da Cloud API da Meta.\n\nexport const whatsAppMediaSchema = z.object({\n id: z.string(),\n mime_type: z.string(),\n sha256: z.string().optional(),\n caption: z.string().optional(),\n filename: z.string().optional(),\n})\nexport type WhatsAppMedia = z.infer<typeof whatsAppMediaSchema>\n\nexport const whatsAppInteractiveSchema = z.object({\n type: z.string(),\n button_reply: z.object({ id: z.string(), title: z.string() }).optional(),\n list_reply: z.object({ id: z.string(), title: z.string() }).optional(),\n})\nexport type WhatsAppInteractive = z.infer<typeof whatsAppInteractiveSchema>\n\nexport const whatsAppOrderSchema = z.object({\n catalog_id: z.string(),\n text: z.string().optional(),\n product_items: z.array(\n z.object({\n product_retailer_id: z.string(),\n quantity: z.number(),\n item_price: z.number(),\n currency: z.string(),\n }),\n ),\n})\nexport type WhatsAppOrder = z.infer<typeof whatsAppOrderSchema>\n\nexport const whatsAppMessageSchema = z.object({\n id: z.string(),\n from: z.string(),\n type: z.string(),\n text: z.object({ body: z.string() }).optional(),\n image: whatsAppMediaSchema.optional(),\n audio: whatsAppMediaSchema.optional(),\n video: whatsAppMediaSchema.optional(),\n document: whatsAppMediaSchema.optional(),\n sticker: whatsAppMediaSchema.optional(),\n interactive: whatsAppInteractiveSchema.optional(),\n // Pedido enviado pelo carrinho do catálogo (WhatsApp Commerce)\n order: whatsAppOrderSchema.optional(),\n // Presente quando o cliente abre um item do catálogo e manda mensagem pela página do produto\n context: z\n .object({\n from: z.string().optional(),\n id: z.string().optional(),\n referred_product: z.object({ catalog_id: z.string(), product_retailer_id: z.string() }).optional(),\n })\n .optional(),\n timestamp: z.string(),\n})\nexport type WhatsAppMessage = z.infer<typeof whatsAppMessageSchema>\n\nexport const whatsAppMessageEchoSchema = z.object({\n id: z.string(),\n from: z.string(),\n timestamp: z.string(),\n type: z.string(),\n})\nexport type WhatsAppMessageEcho = z.infer<typeof whatsAppMessageEchoSchema>\n\nexport const whatsAppMessageStatusSchema = z.enum(['sent', 'delivered', 'read', 'failed'])\nexport type WhatsAppMessageStatusValue = z.infer<typeof whatsAppMessageStatusSchema>\n\nexport const whatsAppStatusSchema = z.object({\n id: z.string(),\n status: whatsAppMessageStatusSchema,\n timestamp: z.string(),\n recipient_id: z.string().optional(),\n})\nexport type WhatsAppStatus = z.infer<typeof whatsAppStatusSchema>\n\n// Nome do campo de webhook como a Meta o envia em `changes[].field`. Só os que temos handler.\nexport const WHATSAPP_WEBHOOK_FIELDS = {\n MESSAGES: 'messages',\n MESSAGE_ECHOES: 'message_echoes',\n TEMPLATE_STATUS_UPDATE: 'message_template_status_update',\n PHONE_NUMBER_QUALITY_UPDATE: 'phone_number_quality_update',\n} as const\nexport type WhatsAppWebhookField = (typeof WHATSAPP_WEBHOOK_FIELDS)[keyof typeof WHATSAPP_WEBHOOK_FIELDS]\n\n// Eventos de nível WABA — não falam de uma conversa, e por isso não trazem `messaging_product`\n// nem `metadata`. Chegam na MESMA rota dos eventos de mensagem, distinguidos só pelo `field`.\n\nexport const whatsAppTemplateStatusEventSchema = z.enum([\n 'APPROVED',\n 'REJECTED',\n 'PENDING',\n 'PAUSED',\n 'PENDING_DELETION',\n 'DISABLED',\n 'FLAGGED',\n])\nexport type WhatsAppTemplateStatusEvent = z.infer<typeof whatsAppTemplateStatusEventSchema>\n\nexport const whatsAppTemplateStatusUpdateSchema = z.object({\n event: whatsAppTemplateStatusEventSchema,\n // A Meta manda o id do template como número em alguns eventos e como string em outros; o resto\n // do sistema trata id como string, então normalizamos na fronteira em vez de espalhar `String()`.\n message_template_id: z.union([z.string(), z.number()]).transform((value) => String(value)),\n message_template_name: z.string(),\n message_template_language: z.string(),\n // Só vem em REJECTED/PAUSED/DISABLED, e a Meta às vezes manda `null` em vez de omitir.\n reason: z.string().nullish(),\n disable_date: z.string().optional(),\n})\nexport type WhatsAppTemplateStatusUpdate = z.infer<typeof whatsAppTemplateStatusUpdateSchema>\n\nexport const whatsAppQualityEventSchema = z.enum(['FLAGGED', 'UNFLAGGED', 'ONBOARDING', 'UPGRADE', 'DOWNGRADE'])\nexport type WhatsAppQualityEvent = z.infer<typeof whatsAppQualityEventSchema>\n\nexport const whatsAppPhoneNumberQualityUpdateSchema = z.object({\n display_phone_number: z.string(),\n event: whatsAppQualityEventSchema,\n // Tier de envio (`TIER_1K`, `TIER_10K`, …). Ausente em evento que não mexe no limite.\n current_limit: z.string().optional(),\n old_limit: z.string().optional(),\n})\nexport type WhatsAppPhoneNumberQualityUpdate = z.infer<typeof whatsAppPhoneNumberQualityUpdateSchema>\n\n// `value` é permissivo de propósito: um `change` carrega uma forma diferente por `field`, e a Meta\n// adiciona campo em versão nova sem aviso. Validar aqui como união fechada faria o webhook inteiro\n// (mensagem de cliente inclusive) morrer por causa de um evento administrativo que nem consumimos.\n// A validação estrita de cada evento acontece no roteamento, contra o schema do seu próprio field.\nexport const whatsAppWebhookValueSchema = z\n .object({\n messaging_product: z.string().optional(),\n messages: z.array(whatsAppMessageSchema).optional(),\n message_echoes: z.array(whatsAppMessageEchoSchema).optional(),\n statuses: z.array(whatsAppStatusSchema).optional(),\n metadata: z.object({ display_phone_number: z.string(), phone_number_id: z.string() }).optional(),\n })\n .passthrough()\nexport type WhatsAppWebhookValue = z.infer<typeof whatsAppWebhookValueSchema>\n\nexport const whatsAppWebhookChangeSchema = z.object({\n // Qual assinatura disparou. Opcional porque payload antigo de fixture não tem, e porque a\n // ausência precisa degradar para \"trata como mensagem\", que é o comportamento histórico.\n field: z.string().optional(),\n value: whatsAppWebhookValueSchema,\n})\nexport type WhatsAppWebhookChange = z.infer<typeof whatsAppWebhookChangeSchema>\n\nexport const whatsAppWebhookPayloadSchema = z.object({\n object: z.string(),\n entry: z.array(\n z.object({\n id: z.string(),\n changes: z.array(whatsAppWebhookChangeSchema),\n }),\n ),\n})\nexport type WhatsAppWebhookPayload = z.infer<typeof whatsAppWebhookPayloadSchema>\n","// Mesmo shape de dados do editor visual (@adatechnology/conversations-ui/flows) — o backend\n// interpreta/persiste o grafo, o frontend edita; os dois lados precisam concordar no formato.\n// `FlowActionKind` é string aberta (não union fechada): o host registra os próprios actions\n// (ex.: 'trigger_simulation' no bot) via registerFlowAction() no módulo (T4.3) — o pacote nunca\n// assume nenhum caso de negócio específico.\nimport { z } from 'zod'\n\nexport type FlowNodeType = 'question' | 'entrada_choice' | 'action' | 'menu' | 'condition'\nexport type FlowQuestionType = 'text' | 'money' | 'date' | 'int' | 'cpf' | 'choice'\nexport type FlowActionKind = string\n\n// Os únicos `actionKind` que o próprio pacote implementa — todo o resto é do host. Ficam aqui, e\n// não no editor, porque o mesmo literal é usado nos dois lados (o editor oferece na paleta, o\n// módulo registra o handler) e duas cópias divergem sem nada acusar.\nexport const FLOW_ACTION_KIND = {\n HANDOFF: 'handoff',\n RATE_LIMITED_HANDOFF: 'rate_limited_handoff',\n SEND_PRODUCT_LIST: 'send_product_list',\n SEND_MEDIA: 'send_media',\n} as const\nexport type FlowConditionOperator = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains'\nexport type FlowNodeNext = string | { byAnswer: Record<string, string>; default: string }\n\n// Destinos \"flow:<key>\" são saltos para outro fluxo — mesma convenção usada pelo editor visual\n// (conversations-ui/flows/flowGraph.ts); backend e frontend precisam concordar no formato.\nexport const CROSS_FLOW_PREFIX = 'flow:'\nexport const isCrossFlowTarget = (target: string): boolean => target.startsWith(CROSS_FLOW_PREFIX)\nexport const crossFlowKey = (target: string): string => target.slice(CROSS_FLOW_PREFIX.length)\n\nexport type FlowNodeData = {\n id: string\n type: FlowNodeType\n // Apelido do nó no editor. Só rótulo: o motor do bot ignora, e serve pra distinguir dois nós do\n // mesmo tipo (\"Enviar tabela de juros\" x \"Enviar contrato\") que sem isso ficam idênticos no card.\n label?: string\n contextKey?: string\n questionType?: FlowQuestionType\n question?: string\n options?: [string, string][]\n actionKind?: FlowActionKind\n // Parâmetros arbitrários que o nó carrega para o handler registrado em `actionKind`. O pacote\n // nunca os interpreta — é o que permite um nó de ação ser configurado pelo editor sem que o\n // vocabulário do produto (o antigo `simulationTemplate`) vaze para dentro do contrato.\n actionParams?: Record<string, unknown>\n directMessage?: string\n fallbackMessage?: string\n conditionContextKey?: string\n conditionOperator?: FlowConditionOperator\n conditionValue?: string\n position?: { x: number; y: number }\n next?: FlowNodeNext\n}\n\nexport interface FlowGraphData {\n key: string\n label: string\n startNodeId: string\n version: number\n nodes: Record<string, FlowNodeData>\n}\n\nexport interface FlowGraphSummary {\n key: string\n label: string\n nodeCount: number\n showInMenu: boolean\n menuOptionLabel?: string\n updatedAt: string\n}\n\n// Retorno de GetLiveFlowPositions (T4.2) — quantas sessões ativas estão em cada nó agora,\n// usado pelo editor para desenhar o \"liveCount\" nos cards (FlowNodeCardData.liveCount).\nexport interface LiveFlowPosition {\n flowKey: string\n nodeId: string\n count: number\n}\n\n// Validação de runtime do grafo. O `nodes` é um jsonb que entrou pelo editor (dado de origem\n// cliente) e sai do banco como `unknown` — sem parse, um grafo malformado só se manifestaria\n// como comportamento estranho lá dentro do interpretador, longe da causa.\n//\n// Permissivo de propósito em dois pontos: `actionKind` é string aberta (o host registra os\n// seus) e `.passthrough()` deixa passar campos extras — um grafo salvo por uma versão mais nova\n// do editor não pode ficar irrecuperável numa versão mais antiga do módulo.\nexport const flowNodeNextSchema = z.union([\n z.string(),\n z.object({ byAnswer: z.record(z.string(), z.string()), default: z.string() }),\n])\n\nexport const flowNodeDataSchema = z\n .object({\n id: z.string(),\n type: z.enum(['question', 'entrada_choice', 'action', 'menu', 'condition']),\n contextKey: z.string().optional(),\n questionType: z.enum(['text', 'money', 'date', 'int', 'cpf', 'choice']).optional(),\n question: z.string().optional(),\n options: z.array(z.tuple([z.string(), z.string()])).optional(),\n actionKind: z.string().optional(),\n actionParams: z.record(z.string(), z.unknown()).optional(),\n directMessage: z.string().optional(),\n fallbackMessage: z.string().optional(),\n conditionContextKey: z.string().optional(),\n conditionOperator: z.enum(['>', '>=', '<', '<=', '==', '!=', 'contains']).optional(),\n conditionValue: z.string().optional(),\n position: z.object({ x: z.number(), y: z.number() }).optional(),\n next: flowNodeNextSchema.optional(),\n })\n .passthrough()\n\nexport const flowGraphNodesSchema = z.record(z.string(), flowNodeDataSchema)\n","import type { FlowActionKind, FlowNodeData } from './flow.types'\nimport type { ConversationSession } from './conversation.types'\n\n/**\n * Tetos da Meta para escolha interativa.\n *\n * Ficam no contrato porque quem escreve o texto da opção precisa deles antes de publicar: passar do\n * limite não degrada nada, a Graph API recusa a mensagem inteira e o cliente vê silêncio. Botão é\n * mais curto que linha de lista, e o emoji conta como caractere.\n */\nexport const WHATSAPP_CHOICE_LIMIT = {\n BUTTONS: 3,\n BUTTON_TITLE_LENGTH: 20,\n LIST_ROWS: 10,\n LIST_ROW_TITLE_LENGTH: 24,\n} as const\n\n// Separa \"conversa\" (agnóstica de canal) de \"canal\" (WhatsApp/Meta) — quem envia/recebe pela\n// Graph API implementa esta porta; o motor de conversa/fluxo nunca fala com a Graph API direto.\ntype SendChannelMediaCommon = {\n to: string\n mimeType: string\n filename: string\n caption?: string\n}\n\n/**\n * Ou o binário, ou um `mediaId` já conhecido — nunca nenhum dos dois.\n *\n * União em vez de dois campos opcionais: com ambos opcionais, \"esqueci de passar os dois\" compila e\n * só quebra na chamada à Meta, em produção.\n */\nexport type SendChannelMediaParams =\n | (SendChannelMediaCommon & { buffer: Buffer; mediaId?: string | undefined })\n | (SendChannelMediaCommon & { buffer?: undefined; mediaId: string })\n\nexport interface ChannelAdapterInterface {\n sendText(to: string, body: string): Promise<{ externalMessageId: string | null }>\n /**\n * Envia arquivo. Com `mediaId` conhecido, o binário não sobe de novo — a Meta aceita reusar o id\n * por 30 dias, e o `mediaId` devolvido é o que permite guardá-lo para os próximos destinatários.\n */\n sendMedia(params: SendChannelMediaParams): Promise<{ externalMessageId: string | null; mediaId?: string | undefined }>\n sendTemplate(params: {\n to: string\n templateName: string\n languageCode: string\n bodyParameters?: string[]\n }): Promise<{ externalMessageId: string | null }>\n sendInteractiveList(params: {\n to: string\n body: string\n buttonLabel: string\n rows: { id: string; title: string }[]\n }): Promise<{ externalMessageId: string | null }>\n /**\n * Botão de resposta rápida — opcional porque a lista sempre serve de alternativa.\n *\n * Exigi-lo na porta quebraria todo dublê de teste e todo canal já escrito, para uma capacidade\n * que nem todo canal tem: quem não implementa continua caindo na lista, que é o que o WhatsApp\n * fazia antes. A Meta limita a 3 botões, e o provider recusa o quarto.\n */\n sendInteractiveButtons?(params: {\n to: string\n body: string\n buttons: { id: string; title: string }[]\n }): Promise<{ externalMessageId: string | null }>\n /**\n * Vitrine de produtos — opcional pelo mesmo motivo do botão: canal que não sabe enviar produto\n * continua funcionando, e a ausência do método é o que desliga o recurso, sem flag `hasCatalog`.\n *\n * A Meta limita a 30 itens por mensagem, distribuídos em até 10 seções; o provider recusa acima\n * disso. Quem monta a lista corta antes — mandar tudo e ver a Graph API recusar a mensagem\n * inteira deixa o cliente no silêncio.\n */\n sendProductList?(params: {\n to: string\n headerText: string\n body: string\n footerText?: string\n sections: { title: string; retailerIds: string[] }[]\n }): Promise<{ externalMessageId: string | null }>\n fetchMediaAsBase64(mediaId: string): Promise<{ data: string; mimeType: string }>\n}\n\n// Resolve identidade do interlocutor (nome do cliente, empresa/tenant) a partir do número —\n// o módulo não conhece a tabela de clientes do host, só pede a este porto quando precisa exibir\n// um nome amigável (ex.: notificações, preview de conversa).\nexport interface SubjectResolverInterface {\n resolve(whatsappNumber: string): Promise<{ displayName?: string; companyId: string } | null>\n}\n\nexport interface CatalogProduct {\n retailerId: string\n name: string\n priceInCents: number\n currency: string\n imageUrl?: string\n availability: 'in stock' | 'out of stock'\n}\n\n// Porta opcional — ver .specs/features/meta-catalog-trio/spec.md §4. Sem injeção, os recursos\n// de produto no canal ficam desligados e o módulo de WhatsApp funciona normalmente.\nexport interface CatalogPort {\n listProducts(params: { catalogId: string; search?: string }): Promise<CatalogProduct[]>\n findProductByRetailerId(retailerId: string): Promise<CatalogProduct | undefined>\n consumeInventory(params: { retailerId: string; quantity: number }): Promise<void>\n}\n\nexport interface ObjectStorageInterface {\n upload(params: { buffer: Buffer; mimeType: string; key: string }): Promise<{ uploadId: string }>\n /**\n * `options.disposition` decide entre abrir no navegador e salvar. Precisa ser resolvido na\n * criação da URL porque entra na assinatura — depois de assinada, o cliente não muda.\n */\n getDownloadUrl(\n uploadId: string,\n options?: { expiresInSeconds?: number; disposition?: 'inline' | 'attachment'; filename?: string },\n ): Promise<string>\n /**\n * Lê o binário de volta. Opcional pelo mesmo motivo do `delete` — quem já implementa este\n * contrato não pode quebrar ao atualizar o pacote — mas sem ele a action `send_media` fica\n * desligada: reenviar um arquivo da biblioteca ao cliente exige os bytes, e `getDownloadUrl`\n * não serve (a Graph API recebe o conteúdo, não uma URL assinada nossa).\n */\n getObject?(uploadId: string): Promise<Buffer>\n /**\n * Apaga o objeto. Opcional para não quebrar quem já implementa este contrato, mas **sem ele não\n * existe exclusão de verdade**: a cascata da FK derruba a linha e deixa o binário órfão no\n * storage, sendo cobrado indefinidamente e sem nada que o alcance.\n *\n * Deve ser idempotente — apagar o que já não existe não é erro, e o job de retenção pode repetir.\n */\n delete?(uploadId: string): Promise<void>\n}\n\n// Abstração de tempo real (SSE, WebSocket, ou nenhum) — o módulo emite eventos por este porto em\n// vez de assumir SseHub; o host pode trocar a implementação ou desligar sem tocar no módulo.\nexport interface RealtimeNotifierInterface {\n emit(channel: string, event: string, payload: Record<string, unknown>): void\n}\n\n/**\n * Cache chave-valor do host (Redis, Memcached, o que for). Injetado como todo o resto de\n * ambiente: o módulo não abre conexão própria nem decide onde o dado mora.\n *\n * Precisa ser COMPARTILHADO entre instâncias. Um cache em memória por processo faria cada\n * instância servir uma versão diferente do mesmo fluxo depois de uma publicação, e a invalidação\n * de uma não alcançaria as outras — o cliente cairia no grafo velho ou no novo conforme o\n * balanceador.\n *\n * Valores são texto: serializar é responsabilidade de quem usa, para o contrato não impor formato.\n */\nexport interface CacheInterface {\n get(key: string): Promise<string | null>\n set(key: string, value: string, ttlSeconds?: number): Promise<void>\n delete(key: string): Promise<void>\n}\n\nexport type FlowActionResult = {\n // Desvia o fluxo para um nó específico, ignorando o `next` declarado no grafo.\n next?: string\n // Chaves a mesclar no contexto da conversa. Sem isto, uma action que produz dado\n // (ex.: 'trigger_simulation' devolvendo o id da simulação) não teria como devolvê-lo ao\n // fluxo — nós seguintes não conseguiriam referenciar o resultado numa condição ou mensagem.\n context?: Record<string, unknown>\n}\n\n// Registro de actions de nó 'action' do fluxo — quem define o comportamento de um `actionKind`\n// (ex.: 'trigger_simulation' no bot) é o host, via este registro, não o pacote (T4.3).\nexport type FlowActionHandler = (params: {\n node: FlowNodeData\n session: ConversationSession\n channel: ChannelAdapterInterface\n context: Record<string, unknown>\n}) => Promise<FlowActionResult | void>\n\nexport interface FlowActionRegistry {\n registerFlowAction(kind: FlowActionKind, handler: FlowActionHandler): void\n}\n","// Hierarquia de erros do módulo — não depende do framework de erros do host (nenhum DomainError\n// importado); o host mapeia estes para o seu próprio formato de resposta no exception filter.\nexport class MetaWhatsAppError extends Error {\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly code: string,\n public readonly details?: Record<string, unknown>,\n ) {\n super(message)\n this.name = 'MetaWhatsAppError'\n }\n}\n\nexport const META_WHATSAPP_ERROR_CODES = {\n WINDOW_EXPIRED: 'META_WHATSAPP_WINDOW_EXPIRED',\n INVALID_SIGNATURE: 'META_WHATSAPP_INVALID_SIGNATURE',\n DUPLICATE_DELIVERY: 'META_WHATSAPP_DUPLICATE_DELIVERY',\n CONFIG_MISSING: 'META_WHATSAPP_CONFIG_MISSING',\n TEMPLATE_NOT_CONFIGURED: 'META_WHATSAPP_TEMPLATE_NOT_CONFIGURED',\n SESSION_NOT_FOUND: 'META_WHATSAPP_SESSION_NOT_FOUND',\n AUDIO_NOT_INGESTED: 'META_WHATSAPP_AUDIO_NOT_INGESTED',\n MESSAGE_NOT_AUDIO: 'META_WHATSAPP_MESSAGE_NOT_AUDIO',\n TRANSCRIPTION_DISABLED: 'META_WHATSAPP_TRANSCRIPTION_DISABLED',\n} as const\n\nexport class WindowExpiredError extends MetaWhatsAppError {\n constructor(public readonly hoursSinceLastMessage?: number) {\n super(\n 'Janela de 24h expirada — não é possível enviar mensagem para este contato fora de template.',\n 400,\n META_WHATSAPP_ERROR_CODES.WINDOW_EXPIRED,\n { hoursSinceLastMessage },\n )\n }\n}\n\n// Assinatura HMAC do webhook não confere — Meta ou um terceiro tentando forjar uma entrega.\nexport class InvalidWebhookSignatureError extends MetaWhatsAppError {\n constructor() {\n super('Assinatura do webhook inválida.', 401, META_WHATSAPP_ERROR_CODES.INVALID_SIGNATURE)\n }\n}\n\n// Meta reenviou uma entrega dentro da janela de replay (nonce já visto) — não é erro do\n// cliente, é comportamento esperado da Cloud API; o host deve responder 200 e ignorar.\nexport class DuplicateWebhookDeliveryError extends MetaWhatsAppError {\n constructor(nonce: string) {\n super('Entrega de webhook duplicada.', 409, META_WHATSAPP_ERROR_CODES.DUPLICATE_DELIVERY, { nonce })\n }\n}\n\nexport class ConfigMissingError extends MetaWhatsAppError {\n constructor(missingField: string) {\n super(`Configuração do WhatsApp ausente: ${missingField}.`, 503, META_WHATSAPP_ERROR_CODES.CONFIG_MISSING, {\n missingField,\n })\n }\n}\n\nexport class TemplateNotConfiguredError extends MetaWhatsAppError {\n constructor() {\n super(\n 'Nenhum template de WhatsApp configurado para reabertura de janela.',\n 503,\n META_WHATSAPP_ERROR_CODES.TEMPLATE_NOT_CONFIGURED,\n )\n }\n}\n\nexport class SessionNotFoundError extends MetaWhatsAppError {\n constructor(whatsappNumber: string) {\n super(\n `Sessão de conversa não encontrada para ${whatsappNumber}.`,\n 404,\n META_WHATSAPP_ERROR_CODES.SESSION_NOT_FOUND,\n { whatsappNumber },\n )\n }\n}\n\n/**\n * Pediram transcrição de um áudio que ainda não foi copiado da Meta para o storage.\n *\n * 409 e não 404: a mensagem existe e o áudio vai chegar — a ingestão é assíncrona e o atendente\n * simplesmente clicou antes de ela terminar. É o único erro de transcrição em que \"tente de novo em\n * alguns segundos\" é a orientação correta para a interface.\n */\nexport class AudioNotIngestedError extends MetaWhatsAppError {\n constructor(messageId: string) {\n super(\n 'Áudio ainda está sendo copiado — tente novamente em alguns segundos.',\n 409,\n META_WHATSAPP_ERROR_CODES.AUDIO_NOT_INGESTED,\n { messageId },\n )\n }\n}\n\n/**\n * Transcrição está desligada para esta empresa.\n *\n * Guarda de última linha, não o caminho normal: o painel lê as configurações e nem desenha o botão\n * quando está desligado. Existe para o caso de a tela estar com dado velho, ou de alguém chamar a\n * rota direto — e é `409` (estado atual conflita com a operação), não `403`: ninguém está sem\n * permissão, o recurso está desligado por escolha e liga a qualquer momento.\n */\nexport class TranscriptionDisabledError extends MetaWhatsAppError {\n constructor() {\n super(\n 'Transcrição de áudio está desligada para esta empresa.',\n 409,\n META_WHATSAPP_ERROR_CODES.TRANSCRIPTION_DISABLED,\n )\n }\n}\n\n/** Pediram transcrição de mensagem que não é áudio. Nunca melhora com retentativa. */\nexport class MessageNotAudioError extends MetaWhatsAppError {\n constructor(messageId: string, type: string) {\n super(\n `Mensagem ${messageId} é do tipo \"${type}\" — só áudio é transcrito.`,\n 422,\n META_WHATSAPP_ERROR_CODES.MESSAGE_NOT_AUDIO,\n { messageId, type },\n )\n }\n}\n","/**\n * Mídia que veio do simulador de conversa, não da Meta.\n *\n * Vive no `contracts` porque é literalmente um contrato entre as duas pontas: o front gera o id, o\n * backend o resolve. Nasceu duplicado num produto (duas constantes iguais, cada uma com um\n * comentário pedindo \"precisa bater com a outra\") e por isso o segundo produto não herdou nada —\n * este pacote é o único lugar em que os dois lados já se encontram.\n *\n * O simulador manda **webhook**, e webhook da Meta carrega referência de mídia (um id), nunca o\n * binário. Então o arquivo gravado no navegador precisa existir em algum lugar que o servidor\n * alcance, e o id precisa dizer onde — é essa a convenção deste arquivo.\n *\n * Existe no pacote, e não em cada host, porque a convenção tem DOIS lados que precisam concordar\n * (quem gera o id no front, quem o resolve no backend). Onde ela ficou no host, o resultado foi\n * exatamente o previsível: duas constantes iguais em pacotes diferentes, cada uma com um comentário\n * pedindo \"precisa bater com a outra\" — e o segundo produto não herdou nada e ficou sem o recurso.\n */\n\n/**\n * Prefixo que marca o id como local.\n *\n * Dois-pontos no fim de propósito: id da Meta é alfanumérico com `_` e `-`, então o separador torna\n * a colisão impossível em vez de improvável.\n */\nexport const PREVIEW_MEDIA_ID_PREFIX = 'preview-upload:'\n\n/** Monta o id que o simulador manda no webhook. */\nexport function toPreviewMediaId(uploadId: string): string {\n return `${PREVIEW_MEDIA_ID_PREFIX}${uploadId}`\n}\n\n/**\n * `uploadId` embutido no id, ou `undefined` quando a mídia veio mesmo da Meta.\n *\n * Devolver `undefined` para id sem prefixo é o que mantém o caminho normal intacto: o adaptador só\n * desvia para o storage quando o prefixo está lá.\n */\nexport function resolvePreviewUploadId(mediaId: string): string | undefined {\n if (!mediaId.startsWith(PREVIEW_MEDIA_ID_PREFIX)) return undefined\n return mediaId.slice(PREVIEW_MEDIA_ID_PREFIX.length) || undefined\n}\n"],"mappings":"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","names":["z","whatsAppMediaSchema","object","id","string","mime_type","sha256","optional","caption","filename","whatsAppInteractiveSchema","type","button_reply","title","list_reply","whatsAppOrderSchema","catalog_id","text","product_items","array","product_retailer_id","quantity","number","item_price","currency","whatsAppMessageSchema","from","body","image","audio","video","document","sticker","interactive","order","context","referred_product","timestamp","whatsAppMessageEchoSchema","whatsAppMessageStatusSchema","enum","whatsAppStatusSchema","status","recipient_id","WHATSAPP_WEBHOOK_FIELDS","MESSAGES","MESSAGE_ECHOES","TEMPLATE_STATUS_UPDATE","PHONE_NUMBER_QUALITY_UPDATE","whatsAppTemplateStatusEventSchema","whatsAppTemplateStatusUpdateSchema","event","message_template_id","union","transform","value","String","message_template_name","message_template_language","reason","nullish","disable_date","whatsAppQualityEventSchema","whatsAppPhoneNumberQualityUpdateSchema","display_phone_number","current_limit","old_limit","whatsAppWebhookValueSchema","messaging_product","messages","message_echoes","statuses","metadata","phone_number_id","passthrough","whatsAppWebhookChangeSchema","field","whatsAppWebhookPayloadSchema","entry","changes","z","FLOW_ACTION_KIND","HANDOFF","RATE_LIMITED_HANDOFF","SEND_PRODUCT_LIST","SEND_MEDIA","CROSS_FLOW_PREFIX","isCrossFlowTarget","target","startsWith","crossFlowKey","slice","length","flowNodeNextSchema","z","union","string","object","byAnswer","record","default","flowNodeDataSchema","id","type","enum","contextKey","optional","questionType","question","options","array","tuple","actionKind","actionParams","unknown","directMessage","fallbackMessage","conditionContextKey","conditionOperator","conditionValue","position","x","number","y","next","passthrough","flowGraphNodesSchema","WHATSAPP_CHOICE_LIMIT","BUTTONS","BUTTON_TITLE_LENGTH","LIST_ROWS","LIST_ROW_TITLE_LENGTH","MetaWhatsAppError","Error","message","statusCode","code","details","name","META_WHATSAPP_ERROR_CODES","WINDOW_EXPIRED","INVALID_SIGNATURE","DUPLICATE_DELIVERY","CONFIG_MISSING","TEMPLATE_NOT_CONFIGURED","SESSION_NOT_FOUND","AUDIO_NOT_INGESTED","MESSAGE_NOT_AUDIO","TRANSCRIPTION_DISABLED","WindowExpiredError","hoursSinceLastMessage","InvalidWebhookSignatureError","DuplicateWebhookDeliveryError","nonce","ConfigMissingError","missingField","TemplateNotConfiguredError","SessionNotFoundError","whatsappNumber","AudioNotIngestedError","messageId","TranscriptionDisabledError","MessageNotAudioError","type","PREVIEW_MEDIA_ID_PREFIX","toPreviewMediaId","uploadId","resolvePreviewUploadId","mediaId","startsWith","undefined","slice","length"]}
|
|
1
|
+
{"version":3,"sources":["../src/webhook.types.ts","../src/flow.types.ts","../src/providers.ts","../src/errors.ts","../src/previewMedia.types.ts"],"sourcesContent":["import { z } from 'zod'\n\n// Extraído de financiamento-imobiliario-bot/apps/api/src/modules/webhook/application/use-cases/\n// ReceiveWhatsAppWebhook.use-case.ts:39-113 — shape real do payload da Cloud API da Meta.\n\nexport const whatsAppMediaSchema = z.object({\n id: z.string(),\n mime_type: z.string(),\n sha256: z.string().optional(),\n caption: z.string().optional(),\n filename: z.string().optional(),\n})\nexport type WhatsAppMedia = z.infer<typeof whatsAppMediaSchema>\n\nexport const whatsAppInteractiveSchema = z.object({\n type: z.string(),\n button_reply: z.object({ id: z.string(), title: z.string() }).optional(),\n list_reply: z.object({ id: z.string(), title: z.string() }).optional(),\n})\nexport type WhatsAppInteractive = z.infer<typeof whatsAppInteractiveSchema>\n\nexport const whatsAppOrderSchema = z.object({\n catalog_id: z.string(),\n text: z.string().optional(),\n product_items: z.array(\n z.object({\n product_retailer_id: z.string(),\n quantity: z.number(),\n item_price: z.number(),\n currency: z.string(),\n }),\n ),\n})\nexport type WhatsAppOrder = z.infer<typeof whatsAppOrderSchema>\n\nexport const whatsAppLocationSchema = z.object({\n latitude: z.number().min(-90).max(90),\n longitude: z.number().min(-180).max(180),\n name: z.string().optional(),\n address: z.string().optional(),\n url: z.string().optional(),\n})\nexport type WhatsAppLocation = z.infer<typeof whatsAppLocationSchema>\n\nexport const whatsAppMessageSchema = z.object({\n id: z.string(),\n from: z.string(),\n type: z.string(),\n text: z.object({ body: z.string() }).optional(),\n image: whatsAppMediaSchema.optional(),\n audio: whatsAppMediaSchema.optional(),\n video: whatsAppMediaSchema.optional(),\n document: whatsAppMediaSchema.optional(),\n sticker: whatsAppMediaSchema.optional(),\n interactive: whatsAppInteractiveSchema.optional(),\n // Pedido enviado pelo carrinho do catálogo (WhatsApp Commerce)\n order: whatsAppOrderSchema.optional(),\n // Coordenada enviada pelo botão \"Localização\" do app do cliente\n location: whatsAppLocationSchema.optional(),\n // Presente quando o cliente abre um item do catálogo e manda mensagem pela página do produto\n context: z\n .object({\n from: z.string().optional(),\n id: z.string().optional(),\n referred_product: z.object({ catalog_id: z.string(), product_retailer_id: z.string() }).optional(),\n })\n .optional(),\n timestamp: z.string(),\n})\nexport type WhatsAppMessage = z.infer<typeof whatsAppMessageSchema>\n\nexport const whatsAppMessageEchoSchema = z.object({\n id: z.string(),\n from: z.string(),\n timestamp: z.string(),\n type: z.string(),\n})\nexport type WhatsAppMessageEcho = z.infer<typeof whatsAppMessageEchoSchema>\n\nexport const whatsAppMessageStatusSchema = z.enum(['sent', 'delivered', 'read', 'failed'])\nexport type WhatsAppMessageStatusValue = z.infer<typeof whatsAppMessageStatusSchema>\n\nexport const whatsAppStatusSchema = z.object({\n id: z.string(),\n status: whatsAppMessageStatusSchema,\n timestamp: z.string(),\n recipient_id: z.string().optional(),\n})\nexport type WhatsAppStatus = z.infer<typeof whatsAppStatusSchema>\n\n// Nome do campo de webhook como a Meta o envia em `changes[].field`. Só os que temos handler.\nexport const WHATSAPP_WEBHOOK_FIELDS = {\n MESSAGES: 'messages',\n MESSAGE_ECHOES: 'message_echoes',\n TEMPLATE_STATUS_UPDATE: 'message_template_status_update',\n PHONE_NUMBER_QUALITY_UPDATE: 'phone_number_quality_update',\n} as const\nexport type WhatsAppWebhookField = (typeof WHATSAPP_WEBHOOK_FIELDS)[keyof typeof WHATSAPP_WEBHOOK_FIELDS]\n\n// Eventos de nível WABA — não falam de uma conversa, e por isso não trazem `messaging_product`\n// nem `metadata`. Chegam na MESMA rota dos eventos de mensagem, distinguidos só pelo `field`.\n\nexport const whatsAppTemplateStatusEventSchema = z.enum([\n 'APPROVED',\n 'REJECTED',\n 'PENDING',\n 'PAUSED',\n 'PENDING_DELETION',\n 'DISABLED',\n 'FLAGGED',\n])\nexport type WhatsAppTemplateStatusEvent = z.infer<typeof whatsAppTemplateStatusEventSchema>\n\nexport const whatsAppTemplateStatusUpdateSchema = z.object({\n event: whatsAppTemplateStatusEventSchema,\n // A Meta manda o id do template como número em alguns eventos e como string em outros; o resto\n // do sistema trata id como string, então normalizamos na fronteira em vez de espalhar `String()`.\n message_template_id: z.union([z.string(), z.number()]).transform((value) => String(value)),\n message_template_name: z.string(),\n message_template_language: z.string(),\n // Só vem em REJECTED/PAUSED/DISABLED, e a Meta às vezes manda `null` em vez de omitir.\n reason: z.string().nullish(),\n disable_date: z.string().optional(),\n})\nexport type WhatsAppTemplateStatusUpdate = z.infer<typeof whatsAppTemplateStatusUpdateSchema>\n\nexport const whatsAppQualityEventSchema = z.enum(['FLAGGED', 'UNFLAGGED', 'ONBOARDING', 'UPGRADE', 'DOWNGRADE'])\nexport type WhatsAppQualityEvent = z.infer<typeof whatsAppQualityEventSchema>\n\nexport const whatsAppPhoneNumberQualityUpdateSchema = z.object({\n display_phone_number: z.string(),\n event: whatsAppQualityEventSchema,\n // Tier de envio (`TIER_1K`, `TIER_10K`, …). Ausente em evento que não mexe no limite.\n current_limit: z.string().optional(),\n old_limit: z.string().optional(),\n})\nexport type WhatsAppPhoneNumberQualityUpdate = z.infer<typeof whatsAppPhoneNumberQualityUpdateSchema>\n\n// `value` é permissivo de propósito: um `change` carrega uma forma diferente por `field`, e a Meta\n// adiciona campo em versão nova sem aviso. Validar aqui como união fechada faria o webhook inteiro\n// (mensagem de cliente inclusive) morrer por causa de um evento administrativo que nem consumimos.\n// A validação estrita de cada evento acontece no roteamento, contra o schema do seu próprio field.\n/**\n * O contato que a Meta manda junto das mensagens. `profile.name` é o nome que a PESSOA escolheu no\n * WhatsApp dela — é o único nome que existe antes de alguém digitar um, e sem ele o cliente criado\n * por mensagem nasce sem nome nenhum.\n *\n * `profile` é opcional: a pessoa pode não ter nome definido, e a Meta omite o objeto nesse caso.\n */\nexport const whatsAppContactSchema = z.object({\n wa_id: z.string(),\n profile: z.object({ name: z.string() }).optional(),\n})\nexport type WhatsAppContact = z.infer<typeof whatsAppContactSchema>\n\nexport const whatsAppWebhookValueSchema = z\n .object({\n messaging_product: z.string().optional(),\n messages: z.array(whatsAppMessageSchema).optional(),\n contacts: z.array(whatsAppContactSchema).optional(),\n message_echoes: z.array(whatsAppMessageEchoSchema).optional(),\n statuses: z.array(whatsAppStatusSchema).optional(),\n metadata: z.object({ display_phone_number: z.string(), phone_number_id: z.string() }).optional(),\n })\n .passthrough()\nexport type WhatsAppWebhookValue = z.infer<typeof whatsAppWebhookValueSchema>\n\nexport const whatsAppWebhookChangeSchema = z.object({\n // Qual assinatura disparou. Opcional porque payload antigo de fixture não tem, e porque a\n // ausência precisa degradar para \"trata como mensagem\", que é o comportamento histórico.\n field: z.string().optional(),\n value: whatsAppWebhookValueSchema,\n})\nexport type WhatsAppWebhookChange = z.infer<typeof whatsAppWebhookChangeSchema>\n\nexport const whatsAppWebhookPayloadSchema = z.object({\n object: z.string(),\n entry: z.array(\n z.object({\n id: z.string(),\n changes: z.array(whatsAppWebhookChangeSchema),\n }),\n ),\n})\nexport type WhatsAppWebhookPayload = z.infer<typeof whatsAppWebhookPayloadSchema>\n\n/**\n * O nome de perfil de quem mandou a mensagem.\n *\n * Casa por `wa_id`. Quando nenhum casa e há UM contato só, é dele: a Meta agrupa as mensagens de um\n * mesmo contato num `change`, e o `wa_id` nem sempre é idêntico ao `from` — no Brasil o nono dígito\n * aparece num e não no outro, e exigir igualdade perderia o nome exatamente nos números móveis.\n *\n * Com dois ou mais contatos sem casamento, devolve `undefined`: pendurar o nome errado numa ficha é\n * pior que ficha sem nome.\n */\nexport function resolveContactProfileName(params: {\n readonly contacts: readonly WhatsAppContact[] | undefined\n readonly from: string\n}): string | undefined {\n const contacts = params.contacts ?? []\n if (contacts.length === 0) return undefined\n\n const exact = contacts.find((contact) => contact.wa_id === params.from)\n if (exact) return exact.profile?.name\n\n return contacts.length === 1 ? contacts[0]?.profile?.name : undefined\n}\n","// Mesmo shape de dados do editor visual (@adatechnology/conversations-ui/flows) — o backend\n// interpreta/persiste o grafo, o frontend edita; os dois lados precisam concordar no formato.\n// `FlowActionKind` é string aberta (não union fechada): o host registra os próprios actions\n// (ex.: 'trigger_simulation' no bot) via registerFlowAction() no módulo (T4.3) — o pacote nunca\n// assume nenhum caso de negócio específico.\nimport { z } from 'zod'\n\nexport type FlowNodeType = 'question' | 'entrada_choice' | 'action' | 'menu' | 'condition'\nexport type FlowQuestionType = 'text' | 'money' | 'date' | 'int' | 'cpf' | 'choice'\nexport type FlowActionKind = string\n\n// Os únicos `actionKind` que o próprio pacote implementa — todo o resto é do host. Ficam aqui, e\n// não no editor, porque o mesmo literal é usado nos dois lados (o editor oferece na paleta, o\n// módulo registra o handler) e duas cópias divergem sem nada acusar.\nexport const FLOW_ACTION_KIND = {\n HANDOFF: 'handoff',\n RATE_LIMITED_HANDOFF: 'rate_limited_handoff',\n SEND_PRODUCT_LIST: 'send_product_list',\n SEND_MEDIA: 'send_media',\n} as const\nexport type FlowConditionOperator = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains'\nexport type FlowNodeNext = string | { byAnswer: Record<string, string>; default: string }\n\n// Destinos \"flow:<key>\" são saltos para outro fluxo — mesma convenção usada pelo editor visual\n// (conversations-ui/flows/flowGraph.ts); backend e frontend precisam concordar no formato.\nexport const CROSS_FLOW_PREFIX = 'flow:'\nexport const isCrossFlowTarget = (target: string): boolean => target.startsWith(CROSS_FLOW_PREFIX)\nexport const crossFlowKey = (target: string): string => target.slice(CROSS_FLOW_PREFIX.length)\n\nexport type FlowNodeData = {\n id: string\n type: FlowNodeType\n // Apelido do nó no editor. Só rótulo: o motor do bot ignora, e serve pra distinguir dois nós do\n // mesmo tipo (\"Enviar tabela de juros\" x \"Enviar contrato\") que sem isso ficam idênticos no card.\n label?: string\n contextKey?: string\n questionType?: FlowQuestionType\n question?: string\n options?: [string, string][]\n actionKind?: FlowActionKind\n // Parâmetros arbitrários que o nó carrega para o handler registrado em `actionKind`. O pacote\n // nunca os interpreta — é o que permite um nó de ação ser configurado pelo editor sem que o\n // vocabulário do produto (o antigo `simulationTemplate`) vaze para dentro do contrato.\n actionParams?: Record<string, unknown>\n directMessage?: string\n fallbackMessage?: string\n conditionContextKey?: string\n conditionOperator?: FlowConditionOperator\n conditionValue?: string\n position?: { x: number; y: number }\n next?: FlowNodeNext\n}\n\nexport interface FlowGraphData {\n key: string\n label: string\n startNodeId: string\n version: number\n nodes: Record<string, FlowNodeData>\n}\n\nexport interface FlowGraphSummary {\n key: string\n label: string\n nodeCount: number\n showInMenu: boolean\n menuOptionLabel?: string\n updatedAt: string\n}\n\n// Retorno de GetLiveFlowPositions (T4.2) — quantas sessões ativas estão em cada nó agora,\n// usado pelo editor para desenhar o \"liveCount\" nos cards (FlowNodeCardData.liveCount).\nexport interface LiveFlowPosition {\n flowKey: string\n nodeId: string\n count: number\n}\n\n// Validação de runtime do grafo. O `nodes` é um jsonb que entrou pelo editor (dado de origem\n// cliente) e sai do banco como `unknown` — sem parse, um grafo malformado só se manifestaria\n// como comportamento estranho lá dentro do interpretador, longe da causa.\n//\n// Permissivo de propósito em dois pontos: `actionKind` é string aberta (o host registra os\n// seus) e `.passthrough()` deixa passar campos extras — um grafo salvo por uma versão mais nova\n// do editor não pode ficar irrecuperável numa versão mais antiga do módulo.\nexport const flowNodeNextSchema = z.union([\n z.string(),\n z.object({ byAnswer: z.record(z.string(), z.string()), default: z.string() }),\n])\n\nexport const flowNodeDataSchema = z\n .object({\n id: z.string(),\n type: z.enum(['question', 'entrada_choice', 'action', 'menu', 'condition']),\n contextKey: z.string().optional(),\n questionType: z.enum(['text', 'money', 'date', 'int', 'cpf', 'choice']).optional(),\n question: z.string().optional(),\n options: z.array(z.tuple([z.string(), z.string()])).optional(),\n actionKind: z.string().optional(),\n actionParams: z.record(z.string(), z.unknown()).optional(),\n directMessage: z.string().optional(),\n fallbackMessage: z.string().optional(),\n conditionContextKey: z.string().optional(),\n conditionOperator: z.enum(['>', '>=', '<', '<=', '==', '!=', 'contains']).optional(),\n conditionValue: z.string().optional(),\n position: z.object({ x: z.number(), y: z.number() }).optional(),\n next: flowNodeNextSchema.optional(),\n })\n .passthrough()\n\nexport const flowGraphNodesSchema = z.record(z.string(), flowNodeDataSchema)\n","import type { FlowActionKind, FlowNodeData } from './flow.types'\nimport type { ConversationSession } from './conversation.types'\n\n/**\n * Tetos da Meta para escolha interativa.\n *\n * Ficam no contrato porque quem escreve o texto da opção precisa deles antes de publicar: passar do\n * limite não degrada nada, a Graph API recusa a mensagem inteira e o cliente vê silêncio. Botão é\n * mais curto que linha de lista, e o emoji conta como caractere.\n */\nexport const WHATSAPP_CHOICE_LIMIT = {\n BUTTONS: 3,\n BUTTON_TITLE_LENGTH: 20,\n LIST_ROWS: 10,\n LIST_ROW_TITLE_LENGTH: 24,\n} as const\n\n// Separa \"conversa\" (agnóstica de canal) de \"canal\" (WhatsApp/Meta) — quem envia/recebe pela\n// Graph API implementa esta porta; o motor de conversa/fluxo nunca fala com a Graph API direto.\ntype SendChannelMediaCommon = {\n to: string\n mimeType: string\n filename: string\n caption?: string\n}\n\n/**\n * Ou o binário, ou um `mediaId` já conhecido — nunca nenhum dos dois.\n *\n * União em vez de dois campos opcionais: com ambos opcionais, \"esqueci de passar os dois\" compila e\n * só quebra na chamada à Meta, em produção.\n */\nexport type SendChannelMediaParams =\n | (SendChannelMediaCommon & { buffer: Buffer; mediaId?: string | undefined })\n | (SendChannelMediaCommon & { buffer?: undefined; mediaId: string })\n\nexport interface ChannelAdapterInterface {\n sendText(to: string, body: string): Promise<{ externalMessageId: string | null }>\n /**\n * Envia arquivo. Com `mediaId` conhecido, o binário não sobe de novo — a Meta aceita reusar o id\n * por 30 dias, e o `mediaId` devolvido é o que permite guardá-lo para os próximos destinatários.\n */\n sendMedia(params: SendChannelMediaParams): Promise<{ externalMessageId: string | null; mediaId?: string | undefined }>\n sendTemplate(params: {\n to: string\n templateName: string\n languageCode: string\n bodyParameters?: string[]\n }): Promise<{ externalMessageId: string | null }>\n sendInteractiveList(params: {\n to: string\n body: string\n buttonLabel: string\n rows: { id: string; title: string }[]\n }): Promise<{ externalMessageId: string | null }>\n /**\n * Botão de resposta rápida — opcional porque a lista sempre serve de alternativa.\n *\n * Exigi-lo na porta quebraria todo dublê de teste e todo canal já escrito, para uma capacidade\n * que nem todo canal tem: quem não implementa continua caindo na lista, que é o que o WhatsApp\n * fazia antes. A Meta limita a 3 botões, e o provider recusa o quarto.\n */\n sendInteractiveButtons?(params: {\n to: string\n body: string\n buttons: { id: string; title: string }[]\n }): Promise<{ externalMessageId: string | null }>\n /**\n * Vitrine de produtos — opcional pelo mesmo motivo do botão: canal que não sabe enviar produto\n * continua funcionando, e a ausência do método é o que desliga o recurso, sem flag `hasCatalog`.\n *\n * A Meta limita a 30 itens por mensagem, distribuídos em até 10 seções; o provider recusa acima\n * disso. Quem monta a lista corta antes — mandar tudo e ver a Graph API recusar a mensagem\n * inteira deixa o cliente no silêncio.\n */\n sendProductList?(params: {\n to: string\n headerText: string\n body: string\n footerText?: string\n sections: { title: string; retailerIds: string[] }[]\n }): Promise<{ externalMessageId: string | null }>\n fetchMediaAsBase64(mediaId: string): Promise<{ data: string; mimeType: string }>\n}\n\n// Resolve identidade do interlocutor (nome do cliente, empresa/tenant) a partir do número —\n// o módulo não conhece a tabela de clientes do host, só pede a este porto quando precisa exibir\n// um nome amigável (ex.: notificações, preview de conversa).\nexport interface SubjectResolverInterface {\n resolve(whatsappNumber: string): Promise<{ displayName?: string; companyId: string } | null>\n}\n\nexport interface CatalogProduct {\n retailerId: string\n name: string\n priceInCents: number\n currency: string\n imageUrl?: string\n availability: 'in stock' | 'out of stock'\n}\n\n// Porta opcional — ver .specs/features/meta-catalog-trio/spec.md §4. Sem injeção, os recursos\n// de produto no canal ficam desligados e o módulo de WhatsApp funciona normalmente.\nexport interface CatalogPort {\n listProducts(params: { catalogId: string; search?: string }): Promise<CatalogProduct[]>\n findProductByRetailerId(retailerId: string): Promise<CatalogProduct | undefined>\n consumeInventory(params: { retailerId: string; quantity: number }): Promise<void>\n}\n\nexport interface ObjectStorageInterface {\n upload(params: { buffer: Buffer; mimeType: string; key: string }): Promise<{ uploadId: string }>\n /**\n * `options.disposition` decide entre abrir no navegador e salvar. Precisa ser resolvido na\n * criação da URL porque entra na assinatura — depois de assinada, o cliente não muda.\n */\n getDownloadUrl(\n uploadId: string,\n options?: { expiresInSeconds?: number; disposition?: 'inline' | 'attachment'; filename?: string },\n ): Promise<string>\n /**\n * Lê o binário de volta. Opcional pelo mesmo motivo do `delete` — quem já implementa este\n * contrato não pode quebrar ao atualizar o pacote — mas sem ele a action `send_media` fica\n * desligada: reenviar um arquivo da biblioteca ao cliente exige os bytes, e `getDownloadUrl`\n * não serve (a Graph API recebe o conteúdo, não uma URL assinada nossa).\n */\n getObject?(uploadId: string): Promise<Buffer>\n /**\n * Apaga o objeto. Opcional para não quebrar quem já implementa este contrato, mas **sem ele não\n * existe exclusão de verdade**: a cascata da FK derruba a linha e deixa o binário órfão no\n * storage, sendo cobrado indefinidamente e sem nada que o alcance.\n *\n * Deve ser idempotente — apagar o que já não existe não é erro, e o job de retenção pode repetir.\n */\n delete?(uploadId: string): Promise<void>\n}\n\n// Abstração de tempo real (SSE, WebSocket, ou nenhum) — o módulo emite eventos por este porto em\n// vez de assumir SseHub; o host pode trocar a implementação ou desligar sem tocar no módulo.\nexport interface RealtimeNotifierInterface {\n emit(channel: string, event: string, payload: Record<string, unknown>): void\n}\n\n/**\n * Cache chave-valor do host (Redis, Memcached, o que for). Injetado como todo o resto de\n * ambiente: o módulo não abre conexão própria nem decide onde o dado mora.\n *\n * Precisa ser COMPARTILHADO entre instâncias. Um cache em memória por processo faria cada\n * instância servir uma versão diferente do mesmo fluxo depois de uma publicação, e a invalidação\n * de uma não alcançaria as outras — o cliente cairia no grafo velho ou no novo conforme o\n * balanceador.\n *\n * Valores são texto: serializar é responsabilidade de quem usa, para o contrato não impor formato.\n */\nexport interface CacheInterface {\n get(key: string): Promise<string | null>\n set(key: string, value: string, ttlSeconds?: number): Promise<void>\n delete(key: string): Promise<void>\n}\n\nexport type FlowActionResult = {\n // Desvia o fluxo para um nó específico, ignorando o `next` declarado no grafo.\n next?: string\n // Chaves a mesclar no contexto da conversa. Sem isto, uma action que produz dado\n // (ex.: 'trigger_simulation' devolvendo o id da simulação) não teria como devolvê-lo ao\n // fluxo — nós seguintes não conseguiriam referenciar o resultado numa condição ou mensagem.\n context?: Record<string, unknown>\n}\n\n// Registro de actions de nó 'action' do fluxo — quem define o comportamento de um `actionKind`\n// (ex.: 'trigger_simulation' no bot) é o host, via este registro, não o pacote (T4.3).\nexport type FlowActionHandler = (params: {\n node: FlowNodeData\n session: ConversationSession\n channel: ChannelAdapterInterface\n context: Record<string, unknown>\n}) => Promise<FlowActionResult | void>\n\nexport interface FlowActionRegistry {\n registerFlowAction(kind: FlowActionKind, handler: FlowActionHandler): void\n}\n","// Hierarquia de erros do módulo — não depende do framework de erros do host (nenhum DomainError\n// importado); o host mapeia estes para o seu próprio formato de resposta no exception filter.\nexport class MetaWhatsAppError extends Error {\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly code: string,\n public readonly details?: Record<string, unknown>,\n ) {\n super(message)\n this.name = 'MetaWhatsAppError'\n }\n}\n\nexport const META_WHATSAPP_ERROR_CODES = {\n WINDOW_EXPIRED: 'META_WHATSAPP_WINDOW_EXPIRED',\n INVALID_SIGNATURE: 'META_WHATSAPP_INVALID_SIGNATURE',\n DUPLICATE_DELIVERY: 'META_WHATSAPP_DUPLICATE_DELIVERY',\n CONFIG_MISSING: 'META_WHATSAPP_CONFIG_MISSING',\n TEMPLATE_NOT_CONFIGURED: 'META_WHATSAPP_TEMPLATE_NOT_CONFIGURED',\n SESSION_NOT_FOUND: 'META_WHATSAPP_SESSION_NOT_FOUND',\n AUDIO_NOT_INGESTED: 'META_WHATSAPP_AUDIO_NOT_INGESTED',\n MESSAGE_NOT_AUDIO: 'META_WHATSAPP_MESSAGE_NOT_AUDIO',\n TRANSCRIPTION_DISABLED: 'META_WHATSAPP_TRANSCRIPTION_DISABLED',\n} as const\n\nexport class WindowExpiredError extends MetaWhatsAppError {\n constructor(public readonly hoursSinceLastMessage?: number) {\n super(\n 'Janela de 24h expirada — não é possível enviar mensagem para este contato fora de template.',\n 400,\n META_WHATSAPP_ERROR_CODES.WINDOW_EXPIRED,\n { hoursSinceLastMessage },\n )\n }\n}\n\n// Assinatura HMAC do webhook não confere — Meta ou um terceiro tentando forjar uma entrega.\nexport class InvalidWebhookSignatureError extends MetaWhatsAppError {\n constructor() {\n super('Assinatura do webhook inválida.', 401, META_WHATSAPP_ERROR_CODES.INVALID_SIGNATURE)\n }\n}\n\n// Meta reenviou uma entrega dentro da janela de replay (nonce já visto) — não é erro do\n// cliente, é comportamento esperado da Cloud API; o host deve responder 200 e ignorar.\nexport class DuplicateWebhookDeliveryError extends MetaWhatsAppError {\n constructor(nonce: string) {\n super('Entrega de webhook duplicada.', 409, META_WHATSAPP_ERROR_CODES.DUPLICATE_DELIVERY, { nonce })\n }\n}\n\nexport class ConfigMissingError extends MetaWhatsAppError {\n constructor(missingField: string) {\n super(`Configuração do WhatsApp ausente: ${missingField}.`, 503, META_WHATSAPP_ERROR_CODES.CONFIG_MISSING, {\n missingField,\n })\n }\n}\n\nexport class TemplateNotConfiguredError extends MetaWhatsAppError {\n constructor() {\n super(\n 'Nenhum template de WhatsApp configurado para reabertura de janela.',\n 503,\n META_WHATSAPP_ERROR_CODES.TEMPLATE_NOT_CONFIGURED,\n )\n }\n}\n\nexport class SessionNotFoundError extends MetaWhatsAppError {\n constructor(whatsappNumber: string) {\n super(\n `Sessão de conversa não encontrada para ${whatsappNumber}.`,\n 404,\n META_WHATSAPP_ERROR_CODES.SESSION_NOT_FOUND,\n { whatsappNumber },\n )\n }\n}\n\n/**\n * Pediram transcrição de um áudio que ainda não foi copiado da Meta para o storage.\n *\n * 409 e não 404: a mensagem existe e o áudio vai chegar — a ingestão é assíncrona e o atendente\n * simplesmente clicou antes de ela terminar. É o único erro de transcrição em que \"tente de novo em\n * alguns segundos\" é a orientação correta para a interface.\n */\nexport class AudioNotIngestedError extends MetaWhatsAppError {\n constructor(messageId: string) {\n super(\n 'Áudio ainda está sendo copiado — tente novamente em alguns segundos.',\n 409,\n META_WHATSAPP_ERROR_CODES.AUDIO_NOT_INGESTED,\n { messageId },\n )\n }\n}\n\n/**\n * Transcrição está desligada para esta empresa.\n *\n * Guarda de última linha, não o caminho normal: o painel lê as configurações e nem desenha o botão\n * quando está desligado. Existe para o caso de a tela estar com dado velho, ou de alguém chamar a\n * rota direto — e é `409` (estado atual conflita com a operação), não `403`: ninguém está sem\n * permissão, o recurso está desligado por escolha e liga a qualquer momento.\n */\nexport class TranscriptionDisabledError extends MetaWhatsAppError {\n constructor() {\n super(\n 'Transcrição de áudio está desligada para esta empresa.',\n 409,\n META_WHATSAPP_ERROR_CODES.TRANSCRIPTION_DISABLED,\n )\n }\n}\n\n/** Pediram transcrição de mensagem que não é áudio. Nunca melhora com retentativa. */\nexport class MessageNotAudioError extends MetaWhatsAppError {\n constructor(messageId: string, type: string) {\n super(\n `Mensagem ${messageId} é do tipo \"${type}\" — só áudio é transcrito.`,\n 422,\n META_WHATSAPP_ERROR_CODES.MESSAGE_NOT_AUDIO,\n { messageId, type },\n )\n }\n}\n","/**\n * Mídia que veio do simulador de conversa, não da Meta.\n *\n * Vive no `contracts` porque é literalmente um contrato entre as duas pontas: o front gera o id, o\n * backend o resolve. Nasceu duplicado num produto (duas constantes iguais, cada uma com um\n * comentário pedindo \"precisa bater com a outra\") e por isso o segundo produto não herdou nada —\n * este pacote é o único lugar em que os dois lados já se encontram.\n *\n * O simulador manda **webhook**, e webhook da Meta carrega referência de mídia (um id), nunca o\n * binário. Então o arquivo gravado no navegador precisa existir em algum lugar que o servidor\n * alcance, e o id precisa dizer onde — é essa a convenção deste arquivo.\n *\n * Existe no pacote, e não em cada host, porque a convenção tem DOIS lados que precisam concordar\n * (quem gera o id no front, quem o resolve no backend). Onde ela ficou no host, o resultado foi\n * exatamente o previsível: duas constantes iguais em pacotes diferentes, cada uma com um comentário\n * pedindo \"precisa bater com a outra\" — e o segundo produto não herdou nada e ficou sem o recurso.\n */\n\n/**\n * Prefixo que marca o id como local.\n *\n * Dois-pontos no fim de propósito: id da Meta é alfanumérico com `_` e `-`, então o separador torna\n * a colisão impossível em vez de improvável.\n */\nexport const PREVIEW_MEDIA_ID_PREFIX = 'preview-upload:'\n\n/** Monta o id que o simulador manda no webhook. */\nexport function toPreviewMediaId(uploadId: string): string {\n return `${PREVIEW_MEDIA_ID_PREFIX}${uploadId}`\n}\n\n/**\n * `uploadId` embutido no id, ou `undefined` quando a mídia veio mesmo da Meta.\n *\n * Devolver `undefined` para id sem prefixo é o que mantém o caminho normal intacto: o adaptador só\n * desvia para o storage quando o prefixo está lá.\n */\nexport function resolvePreviewUploadId(mediaId: string): string | undefined {\n if (!mediaId.startsWith(PREVIEW_MEDIA_ID_PREFIX)) return undefined\n return mediaId.slice(PREVIEW_MEDIA_ID_PREFIX.length) || undefined\n}\n"],"mappings":"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","names":["z","whatsAppMediaSchema","z","object","id","string","mime_type","sha256","optional","caption","filename","whatsAppInteractiveSchema","type","button_reply","title","list_reply","whatsAppOrderSchema","catalog_id","text","product_items","array","product_retailer_id","quantity","number","item_price","currency","whatsAppLocationSchema","latitude","min","max","longitude","name","address","url","whatsAppMessageSchema","from","body","image","audio","video","document","sticker","interactive","order","location","context","referred_product","timestamp","whatsAppMessageEchoSchema","whatsAppMessageStatusSchema","enum","whatsAppStatusSchema","status","recipient_id","WHATSAPP_WEBHOOK_FIELDS","MESSAGES","MESSAGE_ECHOES","TEMPLATE_STATUS_UPDATE","PHONE_NUMBER_QUALITY_UPDATE","whatsAppTemplateStatusEventSchema","whatsAppTemplateStatusUpdateSchema","event","message_template_id","union","transform","value","String","message_template_name","message_template_language","reason","nullish","disable_date","whatsAppQualityEventSchema","whatsAppPhoneNumberQualityUpdateSchema","display_phone_number","current_limit","old_limit","whatsAppContactSchema","wa_id","profile","whatsAppWebhookValueSchema","messaging_product","messages","contacts","message_echoes","statuses","metadata","phone_number_id","passthrough","whatsAppWebhookChangeSchema","field","whatsAppWebhookPayloadSchema","entry","changes","resolveContactProfileName","params","length","undefined","exact","find","contact","z","FLOW_ACTION_KIND","HANDOFF","RATE_LIMITED_HANDOFF","SEND_PRODUCT_LIST","SEND_MEDIA","CROSS_FLOW_PREFIX","isCrossFlowTarget","target","startsWith","crossFlowKey","slice","length","flowNodeNextSchema","z","union","string","object","byAnswer","record","default","flowNodeDataSchema","id","type","enum","contextKey","optional","questionType","question","options","array","tuple","actionKind","actionParams","unknown","directMessage","fallbackMessage","conditionContextKey","conditionOperator","conditionValue","position","x","number","y","next","passthrough","flowGraphNodesSchema","WHATSAPP_CHOICE_LIMIT","BUTTONS","BUTTON_TITLE_LENGTH","LIST_ROWS","LIST_ROW_TITLE_LENGTH","MetaWhatsAppError","Error","message","statusCode","code","details","name","META_WHATSAPP_ERROR_CODES","WINDOW_EXPIRED","INVALID_SIGNATURE","DUPLICATE_DELIVERY","CONFIG_MISSING","TEMPLATE_NOT_CONFIGURED","SESSION_NOT_FOUND","AUDIO_NOT_INGESTED","MESSAGE_NOT_AUDIO","TRANSCRIPTION_DISABLED","WindowExpiredError","hoursSinceLastMessage","InvalidWebhookSignatureError","DuplicateWebhookDeliveryError","nonce","ConfigMissingError","missingField","TemplateNotConfiguredError","SessionNotFoundError","whatsappNumber","AudioNotIngestedError","messageId","TranscriptionDisabledError","MessageNotAudioError","type","PREVIEW_MEDIA_ID_PREFIX","toPreviewMediaId","uploadId","resolvePreviewUploadId","mediaId","startsWith","undefined","slice","length"]}
|
package/dist/testing/index.cjs
CHANGED
|
@@ -27,6 +27,7 @@ __export(testing_exports, {
|
|
|
27
27
|
PREVIEW_WABA_ID: () => PREVIEW_WABA_ID,
|
|
28
28
|
buildInboundAudioPayload: () => buildInboundAudioPayload,
|
|
29
29
|
buildInboundInteractivePayload: () => buildInboundInteractivePayload,
|
|
30
|
+
buildInboundLocationPayload: () => buildInboundLocationPayload,
|
|
30
31
|
buildInboundMediaPayload: () => buildInboundMediaPayload,
|
|
31
32
|
buildInboundTextPayload: () => buildInboundTextPayload,
|
|
32
33
|
serializeWebhookPayload: () => serializeWebhookPayload
|
|
@@ -63,7 +64,17 @@ function buildEnvelope(params) {
|
|
|
63
64
|
},
|
|
64
65
|
messages: [
|
|
65
66
|
params.message
|
|
66
|
-
]
|
|
67
|
+
],
|
|
68
|
+
...params.profileName ? {
|
|
69
|
+
contacts: [
|
|
70
|
+
{
|
|
71
|
+
wa_id: params.from,
|
|
72
|
+
profile: {
|
|
73
|
+
name: params.profileName
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
} : {}
|
|
67
78
|
}
|
|
68
79
|
}
|
|
69
80
|
]
|
|
@@ -151,6 +162,29 @@ function buildInboundMediaPayload(params) {
|
|
|
151
162
|
});
|
|
152
163
|
}
|
|
153
164
|
__name(buildInboundMediaPayload, "buildInboundMediaPayload");
|
|
165
|
+
function buildInboundLocationPayload(params) {
|
|
166
|
+
const { latitude, longitude, name, address, ...envelope } = params;
|
|
167
|
+
return buildEnvelope({
|
|
168
|
+
...envelope,
|
|
169
|
+
message: {
|
|
170
|
+
id: generateWamid(),
|
|
171
|
+
from: params.from,
|
|
172
|
+
type: "location",
|
|
173
|
+
location: {
|
|
174
|
+
latitude,
|
|
175
|
+
longitude,
|
|
176
|
+
...name ? {
|
|
177
|
+
name
|
|
178
|
+
} : {},
|
|
179
|
+
...address ? {
|
|
180
|
+
address
|
|
181
|
+
} : {}
|
|
182
|
+
},
|
|
183
|
+
timestamp: currentTimestamp()
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
__name(buildInboundLocationPayload, "buildInboundLocationPayload");
|
|
154
188
|
function serializeWebhookPayload(payload) {
|
|
155
189
|
return JSON.stringify(payload);
|
|
156
190
|
}
|
|
@@ -163,6 +197,7 @@ __name(serializeWebhookPayload, "serializeWebhookPayload");
|
|
|
163
197
|
PREVIEW_WABA_ID,
|
|
164
198
|
buildInboundAudioPayload,
|
|
165
199
|
buildInboundInteractivePayload,
|
|
200
|
+
buildInboundLocationPayload,
|
|
166
201
|
buildInboundMediaPayload,
|
|
167
202
|
buildInboundTextPayload,
|
|
168
203
|
serializeWebhookPayload
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/testing/index.ts","../../src/testing/inboundPayloads.ts"],"sourcesContent":["/**\n * Export separado (`@adatechnology/meta-whatsapp-contracts/testing`): utilitário de\n * desenvolvimento não entra no grafo de import de quem consome os contratos em produção.\n */\n\nexport {\n buildInboundTextPayload,\n buildInboundInteractivePayload,\n buildInboundAudioPayload,\n buildInboundMediaPayload,\n serializeWebhookPayload,\n PREVIEW_PHONE_NUMBER_ID,\n PREVIEW_WABA_ID,\n PREVIEW_DISPLAY_PHONE_NUMBER,\n PREVIEW_AUDIO_MIME_TYPE,\n} from './inboundPayloads'\n\nexport type {\n BuildInboundTextPayloadParams,\n BuildInboundInteractivePayloadParams,\n BuildInboundAudioPayloadParams,\n BuildInboundMediaPayloadParams,\n InboundMediaType,\n InteractiveReplyOption,\n} from './inboundPayloads'\n","/**\n * Builders de payload inbound para exercitar o webhook em desenvolvimento sem um app da Meta\n * credenciado. Moram aqui, e não no módulo, por uma razão de runtime: o preview do navegador\n * precisa montar payloads, e o módulo depende de `node:crypto` — importá-lo do frontend quebraria\n * o bundle. Este pacote só depende de zod, então roda nos dois lados.\n *\n * São construção de payload, não assinatura: assinar depende do runtime (`node:crypto` no\n * servidor, WebCrypto no navegador) e fica com quem tem o segredo.\n *\n * Por que `id` e `timestamp` são novos a cada chamada: o anti-replay do webhook usa o header de\n * assinatura como nonce, e a assinatura é derivada do corpo cru. Dois payloads idênticos geram a\n * mesma assinatura, e a segunda entrega é descartada como duplicata — numa conversa real (\"sim\"\n * duas vezes) isso apareceria como mensagem engolida, sem erro nenhum para investigar.\n */\n\nimport type { WhatsAppMessage, WhatsAppWebhookPayload } from '../webhook.types'\n\n// Valores de dev que espelham o formato dos reais (15 dígitos) sem apontar para nada da Meta.\nexport const PREVIEW_PHONE_NUMBER_ID = '000000000000000'\nexport const PREVIEW_WABA_ID = '000000000000001'\nexport const PREVIEW_DISPLAY_PHONE_NUMBER = '+5511900000000'\n\n// O que a Meta manda para áudio gravado no app; o pipeline de STT depende do mime para decodificar.\nexport const PREVIEW_AUDIO_MIME_TYPE = 'audio/ogg; codecs=opus'\n\ntype InboundEnvelopeParams = {\n readonly from: string\n readonly phoneNumberId?: string\n readonly displayPhoneNumber?: string\n readonly wabaId?: string\n}\n\n// `globalThis.crypto` em vez de `node:crypto`: é o que existe tanto no navegador quanto no Node\n// 19+/Bun, e é o que mantém estes builders utilizáveis pelo preview do navegador.\nfunction generateWamid(): string {\n // Formato real é `wamid.<base64>`. O prefixo importa para quem faz parsing por prefixo; o corpo\n // só precisa ser único por entrega, que é o que mantém a assinatura — e o nonce — distinta.\n const unique = globalThis.crypto.randomUUID().replaceAll('-', '')\n return `wamid.${unique}`\n}\n\nfunction currentTimestamp(): string {\n return Math.floor(Date.now() / 1000).toString()\n}\n\ntype BuildEnvelopeParams = InboundEnvelopeParams & {\n readonly message: WhatsAppMessage\n}\n\nfunction buildEnvelope(params: BuildEnvelopeParams): WhatsAppWebhookPayload {\n return {\n object: 'whatsapp_business_account',\n entry: [\n {\n id: params.wabaId ?? PREVIEW_WABA_ID,\n changes: [\n {\n value: {\n messaging_product: 'whatsapp',\n metadata: {\n display_phone_number: params.displayPhoneNumber ?? PREVIEW_DISPLAY_PHONE_NUMBER,\n phone_number_id: params.phoneNumberId ?? PREVIEW_PHONE_NUMBER_ID,\n },\n messages: [params.message],\n },\n },\n ],\n },\n ],\n }\n}\n\nexport type BuildInboundTextPayloadParams = InboundEnvelopeParams & {\n readonly text: string\n}\n\nexport function buildInboundTextPayload(params: BuildInboundTextPayloadParams): WhatsAppWebhookPayload {\n const { text, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'text',\n text: { body: text },\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type InteractiveReplyOption = {\n readonly id: string\n readonly title: string\n}\n\n// União exclusiva: uma resposta interativa é de botão OU de lista, nunca das duas nem de nenhuma.\n// O tipo torna o erro impossível em vez de validá-lo em runtime.\nexport type BuildInboundInteractivePayloadParams = InboundEnvelopeParams &\n (\n | { readonly buttonReply: InteractiveReplyOption; readonly listReply?: never }\n | { readonly listReply: InteractiveReplyOption; readonly buttonReply?: never }\n )\n\nexport function buildInboundInteractivePayload(params: BuildInboundInteractivePayloadParams): WhatsAppWebhookPayload {\n const { buttonReply, listReply, ...envelope } = params\n\n const interactive = buttonReply\n ? { type: 'button_reply', button_reply: buttonReply }\n : { type: 'list_reply', list_reply: listReply }\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'interactive',\n interactive,\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type BuildInboundAudioPayloadParams = InboundEnvelopeParams & {\n readonly mediaId: string\n readonly mimeType?: string\n}\n\nexport function buildInboundAudioPayload(params: BuildInboundAudioPayloadParams): WhatsAppWebhookPayload {\n const { mediaId, mimeType, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'audio',\n audio: { id: mediaId, mime_type: mimeType ?? PREVIEW_AUDIO_MIME_TYPE },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/** Tipos de mídia que a Meta entrega por `id`, para o consumidor baixar depois. */\nexport type InboundMediaType = 'image' | 'video' | 'audio' | 'document' | 'sticker'\n\nexport type BuildInboundMediaPayloadParams = InboundEnvelopeParams & {\n readonly mediaType: InboundMediaType\n readonly mediaId: string\n readonly mimeType?: string\n readonly filename?: string\n readonly caption?: string\n}\n\n/**\n * Mídia genérica. O bloco vai com o nome do próprio tipo (`image`, `document`, …) porque é assim que\n * a Meta monta o webhook — e é por esse nome que o consumidor localiza o `id` para baixar o arquivo.\n *\n * `filename` só existe em documento e `caption` só em imagem/vídeo/documento; enviar os dois em\n * áudio geraria um payload que a Meta nunca produz, então eles são omitidos quando vazios.\n */\nexport function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams): WhatsAppWebhookPayload {\n const { mediaType, mediaId, mimeType, filename, caption, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: mediaType,\n [mediaType]: {\n id: mediaId,\n ...(mimeType ? { mime_type: mimeType } : {}),\n ...(filename ? { filename } : {}),\n ...(caption ? { caption } : {}),\n },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/**\n * Serializa o payload uma única vez. A validação assina os bytes exatos recebidos: quem reserializa\n * antes de enviar (ou deixa o cliente HTTP serializar o objeto) muda espaçamento/ordem e derruba a\n * assinatura. Assinar e enviar SEMPRE a string devolvida aqui é o contrato.\n */\nexport function serializeWebhookPayload(payload: WhatsAppWebhookPayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;ACkBO,IAAMA,0BAA0B;AAChC,IAAMC,kBAAkB;AACxB,IAAMC,+BAA+B;AAGrC,IAAMC,0BAA0B;AAWvC,SAASC,gBAAAA;AAGP,QAAMC,SAASC,WAAWC,OAAOC,WAAU,EAAGC,WAAW,KAAK,EAAA;AAC9D,SAAO,SAASJ,MAAAA;AAClB;AALSD;AAOT,SAASM,mBAAAA;AACP,SAAOC,KAAKC,MAAMC,KAAKC,IAAG,IAAK,GAAA,EAAMC,SAAQ;AAC/C;AAFSL;AAQT,SAASM,cAAcC,QAA2B;AAChD,SAAO;IACLC,QAAQ;IACRC,OAAO;MACL;QACEC,IAAIH,OAAOI,UAAUpB;QACrBqB,SAAS;UACP;YACEC,OAAO;cACLC,mBAAmB;cACnBC,UAAU;gBACRC,sBAAsBT,OAAOU,sBAAsBzB;gBACnD0B,iBAAiBX,OAAOY,iBAAiB7B;cAC3C;cACA8B,UAAU;gBAACb,OAAOc;;YACpB;UACF;;MAEJ;;EAEJ;AACF;AArBSf;AA2BF,SAASgB,wBAAwBf,QAAqC;AAC3E,QAAM,EAAEgB,MAAM,GAAGC,SAAAA,IAAajB;AAE9B,SAAOD,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAM;MACNH,MAAM;QAAEI,MAAMJ;MAAK;MACnBK,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAbgBsB;AA4BT,SAASO,+BAA+BtB,QAA4C;AACzF,QAAM,EAAEuB,aAAaC,WAAW,GAAGP,SAAAA,IAAajB;AAEhD,QAAMyB,cAAcF,cAChB;IAAEJ,MAAM;IAAgBO,cAAcH;EAAY,IAClD;IAAEJ,MAAM;IAAcQ,YAAYH;EAAU;AAEhD,SAAOzB,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAM;MACNM;MACAJ,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAjBgB6B;AAwBT,SAASM,yBAAyB5B,QAAsC;AAC7E,QAAM,EAAE6B,SAASC,UAAU,GAAGb,SAAAA,IAAajB;AAE3C,SAAOD,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAM;MACNY,OAAO;QAAE5B,IAAI0B;QAASG,WAAWF,YAAY5C;MAAwB;MACrEmC,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAbgBmC;AAiCT,SAASK,yBAAyBjC,QAAsC;AAC7E,QAAM,EAAEkC,WAAWL,SAASC,UAAUK,UAAUC,SAAS,GAAGnB,SAAAA,IAAajB;AAEzE,SAAOD,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAMe;MACN,CAACA,SAAAA,GAAY;QACX/B,IAAI0B;QACJ,GAAIC,WAAW;UAAEE,WAAWF;QAAS,IAAI,CAAC;QAC1C,GAAIK,WAAW;UAAEA;QAAS,IAAI,CAAC;QAC/B,GAAIC,UAAU;UAAEA;QAAQ,IAAI,CAAC;MAC/B;MACAf,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAlBgBwC;AAyBT,SAASI,wBAAwBC,SAA+B;AACrE,SAAOC,KAAKC,UAAUF,OAAAA;AACxB;AAFgBD;","names":["PREVIEW_PHONE_NUMBER_ID","PREVIEW_WABA_ID","PREVIEW_DISPLAY_PHONE_NUMBER","PREVIEW_AUDIO_MIME_TYPE","generateWamid","unique","globalThis","crypto","randomUUID","replaceAll","currentTimestamp","Math","floor","Date","now","toString","buildEnvelope","params","object","entry","id","wabaId","changes","value","messaging_product","metadata","display_phone_number","displayPhoneNumber","phone_number_id","phoneNumberId","messages","message","buildInboundTextPayload","text","envelope","from","type","body","timestamp","buildInboundInteractivePayload","buttonReply","listReply","interactive","button_reply","list_reply","buildInboundAudioPayload","mediaId","mimeType","audio","mime_type","buildInboundMediaPayload","mediaType","filename","caption","serializeWebhookPayload","payload","JSON","stringify"]}
|
|
1
|
+
{"version":3,"sources":["../../src/testing/index.ts","../../src/testing/inboundPayloads.ts"],"sourcesContent":["/**\n * Export separado (`@adatechnology/meta-whatsapp-contracts/testing`): utilitário de\n * desenvolvimento não entra no grafo de import de quem consome os contratos em produção.\n */\n\nexport {\n buildInboundTextPayload,\n buildInboundInteractivePayload,\n buildInboundAudioPayload,\n buildInboundMediaPayload,\n buildInboundLocationPayload,\n serializeWebhookPayload,\n PREVIEW_PHONE_NUMBER_ID,\n PREVIEW_WABA_ID,\n PREVIEW_DISPLAY_PHONE_NUMBER,\n PREVIEW_AUDIO_MIME_TYPE,\n} from './inboundPayloads'\n\nexport type {\n BuildInboundTextPayloadParams,\n BuildInboundInteractivePayloadParams,\n BuildInboundAudioPayloadParams,\n BuildInboundMediaPayloadParams,\n BuildInboundLocationPayloadParams,\n InboundMediaType,\n InteractiveReplyOption,\n} from './inboundPayloads'\n","/**\n * Builders de payload inbound para exercitar o webhook em desenvolvimento sem um app da Meta\n * credenciado. Moram aqui, e não no módulo, por uma razão de runtime: o preview do navegador\n * precisa montar payloads, e o módulo depende de `node:crypto` — importá-lo do frontend quebraria\n * o bundle. Este pacote só depende de zod, então roda nos dois lados.\n *\n * São construção de payload, não assinatura: assinar depende do runtime (`node:crypto` no\n * servidor, WebCrypto no navegador) e fica com quem tem o segredo.\n *\n * Por que `id` e `timestamp` são novos a cada chamada: o anti-replay do webhook usa o header de\n * assinatura como nonce, e a assinatura é derivada do corpo cru. Dois payloads idênticos geram a\n * mesma assinatura, e a segunda entrega é descartada como duplicata — numa conversa real (\"sim\"\n * duas vezes) isso apareceria como mensagem engolida, sem erro nenhum para investigar.\n */\n\nimport type { WhatsAppMessage, WhatsAppWebhookPayload } from '../webhook.types'\n\n// Valores de dev que espelham o formato dos reais (15 dígitos) sem apontar para nada da Meta.\nexport const PREVIEW_PHONE_NUMBER_ID = '000000000000000'\nexport const PREVIEW_WABA_ID = '000000000000001'\nexport const PREVIEW_DISPLAY_PHONE_NUMBER = '+5511900000000'\n\n// O que a Meta manda para áudio gravado no app; o pipeline de STT depende do mime para decodificar.\nexport const PREVIEW_AUDIO_MIME_TYPE = 'audio/ogg; codecs=opus'\n\ntype InboundEnvelopeParams = {\n readonly from: string\n readonly phoneNumberId?: string\n readonly displayPhoneNumber?: string\n readonly wabaId?: string\n /**\n * Nome de perfil de quem manda, como a Meta o entrega em `contacts`.\n *\n * Ausente = webhook sem `contacts`, que é payload legítimo e precisa continuar sendo testável:\n * um builder que sempre injetasse contato esconderia o caminho em que o nome não vem.\n */\n readonly profileName?: string\n}\n\n// `globalThis.crypto` em vez de `node:crypto`: é o que existe tanto no navegador quanto no Node\n// 19+/Bun, e é o que mantém estes builders utilizáveis pelo preview do navegador.\nfunction generateWamid(): string {\n // Formato real é `wamid.<base64>`. O prefixo importa para quem faz parsing por prefixo; o corpo\n // só precisa ser único por entrega, que é o que mantém a assinatura — e o nonce — distinta.\n const unique = globalThis.crypto.randomUUID().replaceAll('-', '')\n return `wamid.${unique}`\n}\n\nfunction currentTimestamp(): string {\n return Math.floor(Date.now() / 1000).toString()\n}\n\ntype BuildEnvelopeParams = InboundEnvelopeParams & {\n readonly message: WhatsAppMessage\n}\n\nfunction buildEnvelope(params: BuildEnvelopeParams): WhatsAppWebhookPayload {\n return {\n object: 'whatsapp_business_account',\n entry: [\n {\n id: params.wabaId ?? PREVIEW_WABA_ID,\n changes: [\n {\n value: {\n messaging_product: 'whatsapp',\n metadata: {\n display_phone_number: params.displayPhoneNumber ?? PREVIEW_DISPLAY_PHONE_NUMBER,\n phone_number_id: params.phoneNumberId ?? PREVIEW_PHONE_NUMBER_ID,\n },\n messages: [params.message],\n ...(params.profileName\n ? { contacts: [{ wa_id: params.from, profile: { name: params.profileName } }] }\n : {}),\n },\n },\n ],\n },\n ],\n }\n}\n\nexport type BuildInboundTextPayloadParams = InboundEnvelopeParams & {\n readonly text: string\n}\n\nexport function buildInboundTextPayload(params: BuildInboundTextPayloadParams): WhatsAppWebhookPayload {\n const { text, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'text',\n text: { body: text },\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type InteractiveReplyOption = {\n readonly id: string\n readonly title: string\n}\n\n// União exclusiva: uma resposta interativa é de botão OU de lista, nunca das duas nem de nenhuma.\n// O tipo torna o erro impossível em vez de validá-lo em runtime.\nexport type BuildInboundInteractivePayloadParams = InboundEnvelopeParams &\n (\n | { readonly buttonReply: InteractiveReplyOption; readonly listReply?: never }\n | { readonly listReply: InteractiveReplyOption; readonly buttonReply?: never }\n )\n\nexport function buildInboundInteractivePayload(params: BuildInboundInteractivePayloadParams): WhatsAppWebhookPayload {\n const { buttonReply, listReply, ...envelope } = params\n\n const interactive = buttonReply\n ? { type: 'button_reply', button_reply: buttonReply }\n : { type: 'list_reply', list_reply: listReply }\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'interactive',\n interactive,\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type BuildInboundAudioPayloadParams = InboundEnvelopeParams & {\n readonly mediaId: string\n readonly mimeType?: string\n}\n\nexport function buildInboundAudioPayload(params: BuildInboundAudioPayloadParams): WhatsAppWebhookPayload {\n const { mediaId, mimeType, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'audio',\n audio: { id: mediaId, mime_type: mimeType ?? PREVIEW_AUDIO_MIME_TYPE },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/** Tipos de mídia que a Meta entrega por `id`, para o consumidor baixar depois. */\nexport type InboundMediaType = 'image' | 'video' | 'audio' | 'document' | 'sticker'\n\nexport type BuildInboundMediaPayloadParams = InboundEnvelopeParams & {\n readonly mediaType: InboundMediaType\n readonly mediaId: string\n readonly mimeType?: string\n readonly filename?: string\n readonly caption?: string\n}\n\n/**\n * Mídia genérica. O bloco vai com o nome do próprio tipo (`image`, `document`, …) porque é assim que\n * a Meta monta o webhook — e é por esse nome que o consumidor localiza o `id` para baixar o arquivo.\n *\n * `filename` só existe em documento e `caption` só em imagem/vídeo/documento; enviar os dois em\n * áudio geraria um payload que a Meta nunca produz, então eles são omitidos quando vazios.\n */\nexport function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams): WhatsAppWebhookPayload {\n const { mediaType, mediaId, mimeType, filename, caption, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: mediaType,\n [mediaType]: {\n id: mediaId,\n ...(mimeType ? { mime_type: mimeType } : {}),\n ...(filename ? { filename } : {}),\n ...(caption ? { caption } : {}),\n },\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type BuildInboundLocationPayloadParams = InboundEnvelopeParams & {\n readonly latitude: number\n readonly longitude: number\n readonly name?: string\n readonly address?: string\n}\n\nexport function buildInboundLocationPayload(params: BuildInboundLocationPayloadParams): WhatsAppWebhookPayload {\n const { latitude, longitude, name, address, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'location',\n location: {\n latitude,\n longitude,\n ...(name ? { name } : {}),\n ...(address ? { address } : {}),\n },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/**\n * Serializa o payload uma única vez. A validação assina os bytes exatos recebidos: quem reserializa\n * antes de enviar (ou deixa o cliente HTTP serializar o objeto) muda espaçamento/ordem e derruba a\n * assinatura. Assinar e enviar SEMPRE a string devolvida aqui é o contrato.\n */\nexport function serializeWebhookPayload(payload: WhatsAppWebhookPayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":"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","names":["PREVIEW_PHONE_NUMBER_ID","PREVIEW_WABA_ID","PREVIEW_DISPLAY_PHONE_NUMBER","PREVIEW_AUDIO_MIME_TYPE","generateWamid","unique","globalThis","crypto","randomUUID","replaceAll","currentTimestamp","Math","floor","Date","now","toString","buildEnvelope","params","object","entry","id","wabaId","changes","value","messaging_product","metadata","display_phone_number","displayPhoneNumber","phone_number_id","phoneNumberId","messages","message","profileName","contacts","wa_id","from","profile","name","buildInboundTextPayload","text","envelope","type","body","timestamp","buildInboundInteractivePayload","buttonReply","listReply","interactive","button_reply","list_reply","buildInboundAudioPayload","mediaId","mimeType","audio","mime_type","buildInboundMediaPayload","mediaType","filename","caption","buildInboundLocationPayload","latitude","longitude","address","location","serializeWebhookPayload","payload","JSON","stringify"]}
|
package/dist/testing/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { p as WhatsAppWebhookPayload } from '../webhook.types-BsYM6OfC.cjs';
|
|
2
2
|
import 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -25,6 +25,13 @@ type InboundEnvelopeParams = {
|
|
|
25
25
|
readonly phoneNumberId?: string;
|
|
26
26
|
readonly displayPhoneNumber?: string;
|
|
27
27
|
readonly wabaId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Nome de perfil de quem manda, como a Meta o entrega em `contacts`.
|
|
30
|
+
*
|
|
31
|
+
* Ausente = webhook sem `contacts`, que é payload legítimo e precisa continuar sendo testável:
|
|
32
|
+
* um builder que sempre injetasse contato esconderia o caminho em que o nome não vem.
|
|
33
|
+
*/
|
|
34
|
+
readonly profileName?: string;
|
|
28
35
|
};
|
|
29
36
|
type BuildInboundTextPayloadParams = InboundEnvelopeParams & {
|
|
30
37
|
readonly text: string;
|
|
@@ -64,6 +71,13 @@ type BuildInboundMediaPayloadParams = InboundEnvelopeParams & {
|
|
|
64
71
|
* áudio geraria um payload que a Meta nunca produz, então eles são omitidos quando vazios.
|
|
65
72
|
*/
|
|
66
73
|
declare function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams): WhatsAppWebhookPayload;
|
|
74
|
+
type BuildInboundLocationPayloadParams = InboundEnvelopeParams & {
|
|
75
|
+
readonly latitude: number;
|
|
76
|
+
readonly longitude: number;
|
|
77
|
+
readonly name?: string;
|
|
78
|
+
readonly address?: string;
|
|
79
|
+
};
|
|
80
|
+
declare function buildInboundLocationPayload(params: BuildInboundLocationPayloadParams): WhatsAppWebhookPayload;
|
|
67
81
|
/**
|
|
68
82
|
* Serializa o payload uma única vez. A validação assina os bytes exatos recebidos: quem reserializa
|
|
69
83
|
* antes de enviar (ou deixa o cliente HTTP serializar o objeto) muda espaçamento/ordem e derruba a
|
|
@@ -71,4 +85,4 @@ declare function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams
|
|
|
71
85
|
*/
|
|
72
86
|
declare function serializeWebhookPayload(payload: WhatsAppWebhookPayload): string;
|
|
73
87
|
|
|
74
|
-
export { type BuildInboundAudioPayloadParams, type BuildInboundInteractivePayloadParams, type BuildInboundMediaPayloadParams, type BuildInboundTextPayloadParams, type InboundMediaType, type InteractiveReplyOption, PREVIEW_AUDIO_MIME_TYPE, PREVIEW_DISPLAY_PHONE_NUMBER, PREVIEW_PHONE_NUMBER_ID, PREVIEW_WABA_ID, buildInboundAudioPayload, buildInboundInteractivePayload, buildInboundMediaPayload, buildInboundTextPayload, serializeWebhookPayload };
|
|
88
|
+
export { type BuildInboundAudioPayloadParams, type BuildInboundInteractivePayloadParams, type BuildInboundLocationPayloadParams, type BuildInboundMediaPayloadParams, type BuildInboundTextPayloadParams, type InboundMediaType, type InteractiveReplyOption, PREVIEW_AUDIO_MIME_TYPE, PREVIEW_DISPLAY_PHONE_NUMBER, PREVIEW_PHONE_NUMBER_ID, PREVIEW_WABA_ID, buildInboundAudioPayload, buildInboundInteractivePayload, buildInboundLocationPayload, buildInboundMediaPayload, buildInboundTextPayload, serializeWebhookPayload };
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { p as WhatsAppWebhookPayload } from '../webhook.types-BsYM6OfC.js';
|
|
2
2
|
import 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -25,6 +25,13 @@ type InboundEnvelopeParams = {
|
|
|
25
25
|
readonly phoneNumberId?: string;
|
|
26
26
|
readonly displayPhoneNumber?: string;
|
|
27
27
|
readonly wabaId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Nome de perfil de quem manda, como a Meta o entrega em `contacts`.
|
|
30
|
+
*
|
|
31
|
+
* Ausente = webhook sem `contacts`, que é payload legítimo e precisa continuar sendo testável:
|
|
32
|
+
* um builder que sempre injetasse contato esconderia o caminho em que o nome não vem.
|
|
33
|
+
*/
|
|
34
|
+
readonly profileName?: string;
|
|
28
35
|
};
|
|
29
36
|
type BuildInboundTextPayloadParams = InboundEnvelopeParams & {
|
|
30
37
|
readonly text: string;
|
|
@@ -64,6 +71,13 @@ type BuildInboundMediaPayloadParams = InboundEnvelopeParams & {
|
|
|
64
71
|
* áudio geraria um payload que a Meta nunca produz, então eles são omitidos quando vazios.
|
|
65
72
|
*/
|
|
66
73
|
declare function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams): WhatsAppWebhookPayload;
|
|
74
|
+
type BuildInboundLocationPayloadParams = InboundEnvelopeParams & {
|
|
75
|
+
readonly latitude: number;
|
|
76
|
+
readonly longitude: number;
|
|
77
|
+
readonly name?: string;
|
|
78
|
+
readonly address?: string;
|
|
79
|
+
};
|
|
80
|
+
declare function buildInboundLocationPayload(params: BuildInboundLocationPayloadParams): WhatsAppWebhookPayload;
|
|
67
81
|
/**
|
|
68
82
|
* Serializa o payload uma única vez. A validação assina os bytes exatos recebidos: quem reserializa
|
|
69
83
|
* antes de enviar (ou deixa o cliente HTTP serializar o objeto) muda espaçamento/ordem e derruba a
|
|
@@ -71,4 +85,4 @@ declare function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams
|
|
|
71
85
|
*/
|
|
72
86
|
declare function serializeWebhookPayload(payload: WhatsAppWebhookPayload): string;
|
|
73
87
|
|
|
74
|
-
export { type BuildInboundAudioPayloadParams, type BuildInboundInteractivePayloadParams, type BuildInboundMediaPayloadParams, type BuildInboundTextPayloadParams, type InboundMediaType, type InteractiveReplyOption, PREVIEW_AUDIO_MIME_TYPE, PREVIEW_DISPLAY_PHONE_NUMBER, PREVIEW_PHONE_NUMBER_ID, PREVIEW_WABA_ID, buildInboundAudioPayload, buildInboundInteractivePayload, buildInboundMediaPayload, buildInboundTextPayload, serializeWebhookPayload };
|
|
88
|
+
export { type BuildInboundAudioPayloadParams, type BuildInboundInteractivePayloadParams, type BuildInboundLocationPayloadParams, type BuildInboundMediaPayloadParams, type BuildInboundTextPayloadParams, type InboundMediaType, type InteractiveReplyOption, PREVIEW_AUDIO_MIME_TYPE, PREVIEW_DISPLAY_PHONE_NUMBER, PREVIEW_PHONE_NUMBER_ID, PREVIEW_WABA_ID, buildInboundAudioPayload, buildInboundInteractivePayload, buildInboundLocationPayload, buildInboundMediaPayload, buildInboundTextPayload, serializeWebhookPayload };
|
package/dist/testing/index.js
CHANGED
|
@@ -31,7 +31,17 @@ function buildEnvelope(params) {
|
|
|
31
31
|
},
|
|
32
32
|
messages: [
|
|
33
33
|
params.message
|
|
34
|
-
]
|
|
34
|
+
],
|
|
35
|
+
...params.profileName ? {
|
|
36
|
+
contacts: [
|
|
37
|
+
{
|
|
38
|
+
wa_id: params.from,
|
|
39
|
+
profile: {
|
|
40
|
+
name: params.profileName
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
} : {}
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
]
|
|
@@ -119,6 +129,29 @@ function buildInboundMediaPayload(params) {
|
|
|
119
129
|
});
|
|
120
130
|
}
|
|
121
131
|
__name(buildInboundMediaPayload, "buildInboundMediaPayload");
|
|
132
|
+
function buildInboundLocationPayload(params) {
|
|
133
|
+
const { latitude, longitude, name, address, ...envelope } = params;
|
|
134
|
+
return buildEnvelope({
|
|
135
|
+
...envelope,
|
|
136
|
+
message: {
|
|
137
|
+
id: generateWamid(),
|
|
138
|
+
from: params.from,
|
|
139
|
+
type: "location",
|
|
140
|
+
location: {
|
|
141
|
+
latitude,
|
|
142
|
+
longitude,
|
|
143
|
+
...name ? {
|
|
144
|
+
name
|
|
145
|
+
} : {},
|
|
146
|
+
...address ? {
|
|
147
|
+
address
|
|
148
|
+
} : {}
|
|
149
|
+
},
|
|
150
|
+
timestamp: currentTimestamp()
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
__name(buildInboundLocationPayload, "buildInboundLocationPayload");
|
|
122
155
|
function serializeWebhookPayload(payload) {
|
|
123
156
|
return JSON.stringify(payload);
|
|
124
157
|
}
|
|
@@ -130,6 +163,7 @@ export {
|
|
|
130
163
|
PREVIEW_WABA_ID,
|
|
131
164
|
buildInboundAudioPayload,
|
|
132
165
|
buildInboundInteractivePayload,
|
|
166
|
+
buildInboundLocationPayload,
|
|
133
167
|
buildInboundMediaPayload,
|
|
134
168
|
buildInboundTextPayload,
|
|
135
169
|
serializeWebhookPayload
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/testing/inboundPayloads.ts"],"sourcesContent":["/**\n * Builders de payload inbound para exercitar o webhook em desenvolvimento sem um app da Meta\n * credenciado. Moram aqui, e não no módulo, por uma razão de runtime: o preview do navegador\n * precisa montar payloads, e o módulo depende de `node:crypto` — importá-lo do frontend quebraria\n * o bundle. Este pacote só depende de zod, então roda nos dois lados.\n *\n * São construção de payload, não assinatura: assinar depende do runtime (`node:crypto` no\n * servidor, WebCrypto no navegador) e fica com quem tem o segredo.\n *\n * Por que `id` e `timestamp` são novos a cada chamada: o anti-replay do webhook usa o header de\n * assinatura como nonce, e a assinatura é derivada do corpo cru. Dois payloads idênticos geram a\n * mesma assinatura, e a segunda entrega é descartada como duplicata — numa conversa real (\"sim\"\n * duas vezes) isso apareceria como mensagem engolida, sem erro nenhum para investigar.\n */\n\nimport type { WhatsAppMessage, WhatsAppWebhookPayload } from '../webhook.types'\n\n// Valores de dev que espelham o formato dos reais (15 dígitos) sem apontar para nada da Meta.\nexport const PREVIEW_PHONE_NUMBER_ID = '000000000000000'\nexport const PREVIEW_WABA_ID = '000000000000001'\nexport const PREVIEW_DISPLAY_PHONE_NUMBER = '+5511900000000'\n\n// O que a Meta manda para áudio gravado no app; o pipeline de STT depende do mime para decodificar.\nexport const PREVIEW_AUDIO_MIME_TYPE = 'audio/ogg; codecs=opus'\n\ntype InboundEnvelopeParams = {\n readonly from: string\n readonly phoneNumberId?: string\n readonly displayPhoneNumber?: string\n readonly wabaId?: string\n}\n\n// `globalThis.crypto` em vez de `node:crypto`: é o que existe tanto no navegador quanto no Node\n// 19+/Bun, e é o que mantém estes builders utilizáveis pelo preview do navegador.\nfunction generateWamid(): string {\n // Formato real é `wamid.<base64>`. O prefixo importa para quem faz parsing por prefixo; o corpo\n // só precisa ser único por entrega, que é o que mantém a assinatura — e o nonce — distinta.\n const unique = globalThis.crypto.randomUUID().replaceAll('-', '')\n return `wamid.${unique}`\n}\n\nfunction currentTimestamp(): string {\n return Math.floor(Date.now() / 1000).toString()\n}\n\ntype BuildEnvelopeParams = InboundEnvelopeParams & {\n readonly message: WhatsAppMessage\n}\n\nfunction buildEnvelope(params: BuildEnvelopeParams): WhatsAppWebhookPayload {\n return {\n object: 'whatsapp_business_account',\n entry: [\n {\n id: params.wabaId ?? PREVIEW_WABA_ID,\n changes: [\n {\n value: {\n messaging_product: 'whatsapp',\n metadata: {\n display_phone_number: params.displayPhoneNumber ?? PREVIEW_DISPLAY_PHONE_NUMBER,\n phone_number_id: params.phoneNumberId ?? PREVIEW_PHONE_NUMBER_ID,\n },\n messages: [params.message],\n },\n },\n ],\n },\n ],\n }\n}\n\nexport type BuildInboundTextPayloadParams = InboundEnvelopeParams & {\n readonly text: string\n}\n\nexport function buildInboundTextPayload(params: BuildInboundTextPayloadParams): WhatsAppWebhookPayload {\n const { text, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'text',\n text: { body: text },\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type InteractiveReplyOption = {\n readonly id: string\n readonly title: string\n}\n\n// União exclusiva: uma resposta interativa é de botão OU de lista, nunca das duas nem de nenhuma.\n// O tipo torna o erro impossível em vez de validá-lo em runtime.\nexport type BuildInboundInteractivePayloadParams = InboundEnvelopeParams &\n (\n | { readonly buttonReply: InteractiveReplyOption; readonly listReply?: never }\n | { readonly listReply: InteractiveReplyOption; readonly buttonReply?: never }\n )\n\nexport function buildInboundInteractivePayload(params: BuildInboundInteractivePayloadParams): WhatsAppWebhookPayload {\n const { buttonReply, listReply, ...envelope } = params\n\n const interactive = buttonReply\n ? { type: 'button_reply', button_reply: buttonReply }\n : { type: 'list_reply', list_reply: listReply }\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'interactive',\n interactive,\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type BuildInboundAudioPayloadParams = InboundEnvelopeParams & {\n readonly mediaId: string\n readonly mimeType?: string\n}\n\nexport function buildInboundAudioPayload(params: BuildInboundAudioPayloadParams): WhatsAppWebhookPayload {\n const { mediaId, mimeType, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'audio',\n audio: { id: mediaId, mime_type: mimeType ?? PREVIEW_AUDIO_MIME_TYPE },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/** Tipos de mídia que a Meta entrega por `id`, para o consumidor baixar depois. */\nexport type InboundMediaType = 'image' | 'video' | 'audio' | 'document' | 'sticker'\n\nexport type BuildInboundMediaPayloadParams = InboundEnvelopeParams & {\n readonly mediaType: InboundMediaType\n readonly mediaId: string\n readonly mimeType?: string\n readonly filename?: string\n readonly caption?: string\n}\n\n/**\n * Mídia genérica. O bloco vai com o nome do próprio tipo (`image`, `document`, …) porque é assim que\n * a Meta monta o webhook — e é por esse nome que o consumidor localiza o `id` para baixar o arquivo.\n *\n * `filename` só existe em documento e `caption` só em imagem/vídeo/documento; enviar os dois em\n * áudio geraria um payload que a Meta nunca produz, então eles são omitidos quando vazios.\n */\nexport function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams): WhatsAppWebhookPayload {\n const { mediaType, mediaId, mimeType, filename, caption, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: mediaType,\n [mediaType]: {\n id: mediaId,\n ...(mimeType ? { mime_type: mimeType } : {}),\n ...(filename ? { filename } : {}),\n ...(caption ? { caption } : {}),\n },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/**\n * Serializa o payload uma única vez. A validação assina os bytes exatos recebidos: quem reserializa\n * antes de enviar (ou deixa o cliente HTTP serializar o objeto) muda espaçamento/ordem e derruba a\n * assinatura. Assinar e enviar SEMPRE a string devolvida aqui é o contrato.\n */\nexport function serializeWebhookPayload(payload: WhatsAppWebhookPayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":";;;;AAkBO,IAAMA,0BAA0B;AAChC,IAAMC,kBAAkB;AACxB,IAAMC,+BAA+B;AAGrC,IAAMC,0BAA0B;AAWvC,SAASC,gBAAAA;AAGP,QAAMC,SAASC,WAAWC,OAAOC,WAAU,EAAGC,WAAW,KAAK,EAAA;AAC9D,SAAO,SAASJ,MAAAA;AAClB;AALSD;AAOT,SAASM,mBAAAA;AACP,SAAOC,KAAKC,MAAMC,KAAKC,IAAG,IAAK,GAAA,EAAMC,SAAQ;AAC/C;AAFSL;AAQT,SAASM,cAAcC,QAA2B;AAChD,SAAO;IACLC,QAAQ;IACRC,OAAO;MACL;QACEC,IAAIH,OAAOI,UAAUpB;QACrBqB,SAAS;UACP;YACEC,OAAO;cACLC,mBAAmB;cACnBC,UAAU;gBACRC,sBAAsBT,OAAOU,sBAAsBzB;gBACnD0B,iBAAiBX,OAAOY,iBAAiB7B;cAC3C;cACA8B,UAAU;gBAACb,OAAOc;;YACpB;UACF;;MAEJ;;EAEJ;AACF;AArBSf;AA2BF,SAASgB,wBAAwBf,QAAqC;AAC3E,QAAM,EAAEgB,MAAM,GAAGC,SAAAA,IAAajB;AAE9B,SAAOD,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAM;MACNH,MAAM;QAAEI,MAAMJ;MAAK;MACnBK,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAbgBsB;AA4BT,SAASO,+BAA+BtB,QAA4C;AACzF,QAAM,EAAEuB,aAAaC,WAAW,GAAGP,SAAAA,IAAajB;AAEhD,QAAMyB,cAAcF,cAChB;IAAEJ,MAAM;IAAgBO,cAAcH;EAAY,IAClD;IAAEJ,MAAM;IAAcQ,YAAYH;EAAU;AAEhD,SAAOzB,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAM;MACNM;MACAJ,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAjBgB6B;AAwBT,SAASM,yBAAyB5B,QAAsC;AAC7E,QAAM,EAAE6B,SAASC,UAAU,GAAGb,SAAAA,IAAajB;AAE3C,SAAOD,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAM;MACNY,OAAO;QAAE5B,IAAI0B;QAASG,WAAWF,YAAY5C;MAAwB;MACrEmC,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAbgBmC;AAiCT,SAASK,yBAAyBjC,QAAsC;AAC7E,QAAM,EAAEkC,WAAWL,SAASC,UAAUK,UAAUC,SAAS,GAAGnB,SAAAA,IAAajB;AAEzE,SAAOD,cAAc;IACnB,GAAGkB;IACHH,SAAS;MACPX,IAAIhB,cAAAA;MACJ+B,MAAMlB,OAAOkB;MACbC,MAAMe;MACN,CAACA,SAAAA,GAAY;QACX/B,IAAI0B;QACJ,GAAIC,WAAW;UAAEE,WAAWF;QAAS,IAAI,CAAC;QAC1C,GAAIK,WAAW;UAAEA;QAAS,IAAI,CAAC;QAC/B,GAAIC,UAAU;UAAEA;QAAQ,IAAI,CAAC;MAC/B;MACAf,WAAW5B,iBAAAA;IACb;EACF,CAAA;AACF;AAlBgBwC;AAyBT,SAASI,wBAAwBC,SAA+B;AACrE,SAAOC,KAAKC,UAAUF,OAAAA;AACxB;AAFgBD;","names":["PREVIEW_PHONE_NUMBER_ID","PREVIEW_WABA_ID","PREVIEW_DISPLAY_PHONE_NUMBER","PREVIEW_AUDIO_MIME_TYPE","generateWamid","unique","globalThis","crypto","randomUUID","replaceAll","currentTimestamp","Math","floor","Date","now","toString","buildEnvelope","params","object","entry","id","wabaId","changes","value","messaging_product","metadata","display_phone_number","displayPhoneNumber","phone_number_id","phoneNumberId","messages","message","buildInboundTextPayload","text","envelope","from","type","body","timestamp","buildInboundInteractivePayload","buttonReply","listReply","interactive","button_reply","list_reply","buildInboundAudioPayload","mediaId","mimeType","audio","mime_type","buildInboundMediaPayload","mediaType","filename","caption","serializeWebhookPayload","payload","JSON","stringify"]}
|
|
1
|
+
{"version":3,"sources":["../../src/testing/inboundPayloads.ts"],"sourcesContent":["/**\n * Builders de payload inbound para exercitar o webhook em desenvolvimento sem um app da Meta\n * credenciado. Moram aqui, e não no módulo, por uma razão de runtime: o preview do navegador\n * precisa montar payloads, e o módulo depende de `node:crypto` — importá-lo do frontend quebraria\n * o bundle. Este pacote só depende de zod, então roda nos dois lados.\n *\n * São construção de payload, não assinatura: assinar depende do runtime (`node:crypto` no\n * servidor, WebCrypto no navegador) e fica com quem tem o segredo.\n *\n * Por que `id` e `timestamp` são novos a cada chamada: o anti-replay do webhook usa o header de\n * assinatura como nonce, e a assinatura é derivada do corpo cru. Dois payloads idênticos geram a\n * mesma assinatura, e a segunda entrega é descartada como duplicata — numa conversa real (\"sim\"\n * duas vezes) isso apareceria como mensagem engolida, sem erro nenhum para investigar.\n */\n\nimport type { WhatsAppMessage, WhatsAppWebhookPayload } from '../webhook.types'\n\n// Valores de dev que espelham o formato dos reais (15 dígitos) sem apontar para nada da Meta.\nexport const PREVIEW_PHONE_NUMBER_ID = '000000000000000'\nexport const PREVIEW_WABA_ID = '000000000000001'\nexport const PREVIEW_DISPLAY_PHONE_NUMBER = '+5511900000000'\n\n// O que a Meta manda para áudio gravado no app; o pipeline de STT depende do mime para decodificar.\nexport const PREVIEW_AUDIO_MIME_TYPE = 'audio/ogg; codecs=opus'\n\ntype InboundEnvelopeParams = {\n readonly from: string\n readonly phoneNumberId?: string\n readonly displayPhoneNumber?: string\n readonly wabaId?: string\n /**\n * Nome de perfil de quem manda, como a Meta o entrega em `contacts`.\n *\n * Ausente = webhook sem `contacts`, que é payload legítimo e precisa continuar sendo testável:\n * um builder que sempre injetasse contato esconderia o caminho em que o nome não vem.\n */\n readonly profileName?: string\n}\n\n// `globalThis.crypto` em vez de `node:crypto`: é o que existe tanto no navegador quanto no Node\n// 19+/Bun, e é o que mantém estes builders utilizáveis pelo preview do navegador.\nfunction generateWamid(): string {\n // Formato real é `wamid.<base64>`. O prefixo importa para quem faz parsing por prefixo; o corpo\n // só precisa ser único por entrega, que é o que mantém a assinatura — e o nonce — distinta.\n const unique = globalThis.crypto.randomUUID().replaceAll('-', '')\n return `wamid.${unique}`\n}\n\nfunction currentTimestamp(): string {\n return Math.floor(Date.now() / 1000).toString()\n}\n\ntype BuildEnvelopeParams = InboundEnvelopeParams & {\n readonly message: WhatsAppMessage\n}\n\nfunction buildEnvelope(params: BuildEnvelopeParams): WhatsAppWebhookPayload {\n return {\n object: 'whatsapp_business_account',\n entry: [\n {\n id: params.wabaId ?? PREVIEW_WABA_ID,\n changes: [\n {\n value: {\n messaging_product: 'whatsapp',\n metadata: {\n display_phone_number: params.displayPhoneNumber ?? PREVIEW_DISPLAY_PHONE_NUMBER,\n phone_number_id: params.phoneNumberId ?? PREVIEW_PHONE_NUMBER_ID,\n },\n messages: [params.message],\n ...(params.profileName\n ? { contacts: [{ wa_id: params.from, profile: { name: params.profileName } }] }\n : {}),\n },\n },\n ],\n },\n ],\n }\n}\n\nexport type BuildInboundTextPayloadParams = InboundEnvelopeParams & {\n readonly text: string\n}\n\nexport function buildInboundTextPayload(params: BuildInboundTextPayloadParams): WhatsAppWebhookPayload {\n const { text, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'text',\n text: { body: text },\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type InteractiveReplyOption = {\n readonly id: string\n readonly title: string\n}\n\n// União exclusiva: uma resposta interativa é de botão OU de lista, nunca das duas nem de nenhuma.\n// O tipo torna o erro impossível em vez de validá-lo em runtime.\nexport type BuildInboundInteractivePayloadParams = InboundEnvelopeParams &\n (\n | { readonly buttonReply: InteractiveReplyOption; readonly listReply?: never }\n | { readonly listReply: InteractiveReplyOption; readonly buttonReply?: never }\n )\n\nexport function buildInboundInteractivePayload(params: BuildInboundInteractivePayloadParams): WhatsAppWebhookPayload {\n const { buttonReply, listReply, ...envelope } = params\n\n const interactive = buttonReply\n ? { type: 'button_reply', button_reply: buttonReply }\n : { type: 'list_reply', list_reply: listReply }\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'interactive',\n interactive,\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type BuildInboundAudioPayloadParams = InboundEnvelopeParams & {\n readonly mediaId: string\n readonly mimeType?: string\n}\n\nexport function buildInboundAudioPayload(params: BuildInboundAudioPayloadParams): WhatsAppWebhookPayload {\n const { mediaId, mimeType, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'audio',\n audio: { id: mediaId, mime_type: mimeType ?? PREVIEW_AUDIO_MIME_TYPE },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/** Tipos de mídia que a Meta entrega por `id`, para o consumidor baixar depois. */\nexport type InboundMediaType = 'image' | 'video' | 'audio' | 'document' | 'sticker'\n\nexport type BuildInboundMediaPayloadParams = InboundEnvelopeParams & {\n readonly mediaType: InboundMediaType\n readonly mediaId: string\n readonly mimeType?: string\n readonly filename?: string\n readonly caption?: string\n}\n\n/**\n * Mídia genérica. O bloco vai com o nome do próprio tipo (`image`, `document`, …) porque é assim que\n * a Meta monta o webhook — e é por esse nome que o consumidor localiza o `id` para baixar o arquivo.\n *\n * `filename` só existe em documento e `caption` só em imagem/vídeo/documento; enviar os dois em\n * áudio geraria um payload que a Meta nunca produz, então eles são omitidos quando vazios.\n */\nexport function buildInboundMediaPayload(params: BuildInboundMediaPayloadParams): WhatsAppWebhookPayload {\n const { mediaType, mediaId, mimeType, filename, caption, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: mediaType,\n [mediaType]: {\n id: mediaId,\n ...(mimeType ? { mime_type: mimeType } : {}),\n ...(filename ? { filename } : {}),\n ...(caption ? { caption } : {}),\n },\n timestamp: currentTimestamp(),\n },\n })\n}\n\nexport type BuildInboundLocationPayloadParams = InboundEnvelopeParams & {\n readonly latitude: number\n readonly longitude: number\n readonly name?: string\n readonly address?: string\n}\n\nexport function buildInboundLocationPayload(params: BuildInboundLocationPayloadParams): WhatsAppWebhookPayload {\n const { latitude, longitude, name, address, ...envelope } = params\n\n return buildEnvelope({\n ...envelope,\n message: {\n id: generateWamid(),\n from: params.from,\n type: 'location',\n location: {\n latitude,\n longitude,\n ...(name ? { name } : {}),\n ...(address ? { address } : {}),\n },\n timestamp: currentTimestamp(),\n },\n })\n}\n\n/**\n * Serializa o payload uma única vez. A validação assina os bytes exatos recebidos: quem reserializa\n * antes de enviar (ou deixa o cliente HTTP serializar o objeto) muda espaçamento/ordem e derruba a\n * assinatura. Assinar e enviar SEMPRE a string devolvida aqui é o contrato.\n */\nexport function serializeWebhookPayload(payload: WhatsAppWebhookPayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":"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","names":["PREVIEW_PHONE_NUMBER_ID","PREVIEW_WABA_ID","PREVIEW_DISPLAY_PHONE_NUMBER","PREVIEW_AUDIO_MIME_TYPE","generateWamid","unique","globalThis","crypto","randomUUID","replaceAll","currentTimestamp","Math","floor","Date","now","toString","buildEnvelope","params","object","entry","id","wabaId","changes","value","messaging_product","metadata","display_phone_number","displayPhoneNumber","phone_number_id","phoneNumberId","messages","message","profileName","contacts","wa_id","from","profile","name","buildInboundTextPayload","text","envelope","type","body","timestamp","buildInboundInteractivePayload","buttonReply","listReply","interactive","button_reply","list_reply","buildInboundAudioPayload","mediaId","mimeType","audio","mime_type","buildInboundMediaPayload","mediaType","filename","caption","buildInboundLocationPayload","latitude","longitude","address","location","serializeWebhookPayload","payload","JSON","stringify"]}
|