@adatechnology/conversations-ui 0.1.0-rc.8 → 0.1.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/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -678
- package/dist/index.d.ts +921 -17
- package/dist/index.js +3677 -678
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-73MW5HNT.js +0 -1717
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* This source code is proprietary and confidential. Unauthorized copying,
|
|
5
|
+
* modification, distribution, or use of this file, via any medium, is
|
|
6
|
+
* strictly prohibited without prior written permission from Ada Technology.
|
|
7
|
+
*
|
|
8
|
+
* Author: Anderson Filho <andersonfrfilho@gmail.com>
|
|
9
|
+
*
|
|
10
|
+
* Painel lateral do simulador de cliente: a moldura em volta do `ConversationPreview`.
|
|
11
|
+
*
|
|
12
|
+
* Existe como componente do pacote porque cada produto tinha reescrito a mesma moldura —
|
|
13
|
+
* `<aside>`, cabeçalho com título e telefone, botão de fechar — e as cópias divergiram. Uma delas
|
|
14
|
+
* migrou para o cliente-ponte (assinatura no servidor) e a outra ficou assinando no navegador, com
|
|
15
|
+
* o app secret publicado no bundle. É o tipo de correção que precisa chegar a todo mundo de uma vez.
|
|
16
|
+
*
|
|
17
|
+
* Mora DENTRO da tela de conversas, e não numa aba própria, por um motivo prático: o token do
|
|
18
|
+
* atendente costuma viver em `sessionStorage`, que é por aba. Numa aba separada o simulador nascia
|
|
19
|
+
* sem sessão, o transcript nunca carregava, e o sintoma era "mandei e não aconteceu nada".
|
|
20
|
+
*
|
|
21
|
+
* O efeito de cada envio aparece na thread ao lado pelo mesmo stream que a inbox já assina — este
|
|
22
|
+
* painel não abre conexão própria.
|
|
23
|
+
*
|
|
24
|
+
* A moldura é a mesma em todo canal — o que muda é o transporte, que entra pelo `client`, e duas
|
|
25
|
+
* frases do cabeçalho: dizer "entrega no webhook real" num chat de site descreveria um caminho que
|
|
26
|
+
* ali não existe, e "escreva como o cliente" num visitante anônimo nomeia alguém que ainda não é.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ReactNode } from 'react'
|
|
30
|
+
|
|
31
|
+
import { CONVERSATION_CHANNEL, DEFAULT_CONVERSATION_CHANNEL, type ConversationChannel } from '../conversationChannel'
|
|
32
|
+
import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
|
|
33
|
+
|
|
34
|
+
export type ConversationSimulatorPanelLabels = {
|
|
35
|
+
readonly title: string
|
|
36
|
+
/** Complementa o identificador no subtítulo, explicando para onde a mensagem realmente vai. */
|
|
37
|
+
readonly destinationHint: string
|
|
38
|
+
readonly close: string
|
|
39
|
+
readonly placeholder: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorPanelLabels = {
|
|
43
|
+
title: 'Simulador do cliente',
|
|
44
|
+
destinationHint: 'entrega no webhook real',
|
|
45
|
+
close: 'Fechar simulador',
|
|
46
|
+
placeholder: 'Escreva como o cliente…',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ChannelWording = Pick<ConversationSimulatorPanelLabels, 'destinationHint' | 'placeholder'>
|
|
50
|
+
|
|
51
|
+
/** Só o que muda de canal para canal; o resto continua vindo do default. */
|
|
52
|
+
const SIMULATOR_PANEL_CHANNEL_WORDING: Readonly<Record<ConversationChannel, ChannelWording>> = {
|
|
53
|
+
[CONVERSATION_CHANNEL.WHATSAPP]: {
|
|
54
|
+
destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
|
|
55
|
+
placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
|
|
56
|
+
},
|
|
57
|
+
[CONVERSATION_CHANNEL.MESSENGER]: {
|
|
58
|
+
destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
|
|
59
|
+
placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
|
|
60
|
+
},
|
|
61
|
+
[CONVERSATION_CHANNEL.INSTAGRAM]: {
|
|
62
|
+
destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
|
|
63
|
+
placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
|
|
64
|
+
},
|
|
65
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: {
|
|
66
|
+
destinationHint: 'entrega na API do chat do site',
|
|
67
|
+
placeholder: 'Escreva como o visitante…',
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rótulos do painel já resolvidos para o canal — útil para o host que monta o cabeçalho por fora. */
|
|
72
|
+
export function simulatorPanelLabelsOf(channel: ConversationChannel | undefined): ConversationSimulatorPanelLabels {
|
|
73
|
+
return {
|
|
74
|
+
...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
|
|
75
|
+
...SIMULATOR_PANEL_CHANNEL_WORDING[channel ?? DEFAULT_CONVERSATION_CHANNEL],
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
|
|
80
|
+
readonly onClose: () => void
|
|
81
|
+
/** Ausente = WhatsApp, que era o único canal antes desta prop existir. */
|
|
82
|
+
readonly channel?: ConversationChannel
|
|
83
|
+
/**
|
|
84
|
+
* Identificador do contato já formatado para leitura — telefone no WhatsApp, apelido no Instagram,
|
|
85
|
+
* "Visitante 3f9c21" no chat do site. É o host que formata: máscara de telefone é convenção
|
|
86
|
+
* regional, e o pacote não tem como saber a do produto.
|
|
87
|
+
*/
|
|
88
|
+
readonly displayHandle?: string
|
|
89
|
+
/** @deprecated Use `displayHandle` — o simulador deixou de ser só telefone. */
|
|
90
|
+
readonly displayNumber?: string
|
|
91
|
+
readonly labels?: Partial<ConversationSimulatorPanelLabels>
|
|
92
|
+
/** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
|
|
93
|
+
readonly headerActions?: ReactNode
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function ConversationSimulatorPanel({
|
|
97
|
+
onClose,
|
|
98
|
+
channel,
|
|
99
|
+
displayHandle,
|
|
100
|
+
displayNumber,
|
|
101
|
+
labels,
|
|
102
|
+
headerActions,
|
|
103
|
+
...previewProps
|
|
104
|
+
}: ConversationSimulatorPanelProps) {
|
|
105
|
+
const text = { ...simulatorPanelLabelsOf(channel), ...labels }
|
|
106
|
+
const subtitle = [displayHandle ?? displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<aside className="cv-simulator-panel" aria-label={text.title}>
|
|
110
|
+
<header className="cv-simulator-panel__header">
|
|
111
|
+
<div className="cv-simulator-panel__heading">
|
|
112
|
+
<h2 className="cv-simulator-panel__title">{text.title}</h2>
|
|
113
|
+
<p className="cv-simulator-panel__subtitle">{subtitle}</p>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="cv-simulator-panel__actions">
|
|
116
|
+
{headerActions}
|
|
117
|
+
<button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
|
|
118
|
+
{/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
|
|
119
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
120
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
121
|
+
</svg>
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</header>
|
|
125
|
+
|
|
126
|
+
<div className="cv-simulator-panel__body">
|
|
127
|
+
<ConversationPreview {...previewProps} placeholder={text.placeholder} />
|
|
128
|
+
</div>
|
|
129
|
+
</aside>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O que estes testes protegem é a propriedade de segurança da ponte: nenhum caminho pode voltar a
|
|
3
|
+
* exigir segredo no navegador, e o corpo enviado tem que ser a INTENÇÃO — se um refactor passar a
|
|
4
|
+
* mandar payload da Meta montado no cliente, a rota do host vira injetor de webhook arbitrário.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
10
|
+
import type { PreviewInboundCommand } from './createPreviewBridgeClient'
|
|
11
|
+
|
|
12
|
+
const FROM = '5511999999999'
|
|
13
|
+
|
|
14
|
+
function createRecordingClient() {
|
|
15
|
+
const commands: PreviewInboundCommand[] = []
|
|
16
|
+
const client = createPreviewBridgeClient({
|
|
17
|
+
from: FROM,
|
|
18
|
+
sendCommand: async (command) => {
|
|
19
|
+
commands.push(command)
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
return { client, commands }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('createPreviewBridgeClient', () => {
|
|
26
|
+
it('entrega a intenção do cliente, carimbando o remetente em cada comando', async () => {
|
|
27
|
+
const { client, commands } = createRecordingClient()
|
|
28
|
+
|
|
29
|
+
await client.sendText('quero simular')
|
|
30
|
+
await client.sendButtonReply({ id: 'hab_pronto', title: 'Imóvel pronto' })
|
|
31
|
+
await client.sendListReply({ id: 'faixa_2', title: 'Faixa 2' })
|
|
32
|
+
await client.sendAudio('media-1')
|
|
33
|
+
await client.sendMedia({ mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' })
|
|
34
|
+
|
|
35
|
+
expect(commands).toEqual([
|
|
36
|
+
{ kind: 'text', from: FROM, text: 'quero simular' },
|
|
37
|
+
{ kind: 'buttonReply', from: FROM, reply: { id: 'hab_pronto', title: 'Imóvel pronto' } },
|
|
38
|
+
{ kind: 'listReply', from: FROM, reply: { id: 'faixa_2', title: 'Faixa 2' } },
|
|
39
|
+
{ kind: 'audio', from: FROM, mediaId: 'media-1' },
|
|
40
|
+
{ kind: 'media', from: FROM, mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' },
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('nunca embute assinatura nem segredo no que sai do navegador', async () => {
|
|
45
|
+
const { client, commands } = createRecordingClient()
|
|
46
|
+
|
|
47
|
+
await client.sendText('oi')
|
|
48
|
+
|
|
49
|
+
const serialized = JSON.stringify(commands[0])
|
|
50
|
+
expect(serialized).not.toMatch(/sha256=/)
|
|
51
|
+
expect(serialized).not.toMatch(/secret/i)
|
|
52
|
+
expect(commands[0]).not.toHaveProperty('entry')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('posta no endpoint do host com os headers de sessão que o host injeta', async () => {
|
|
56
|
+
const calls: Array<{ url: string; init: RequestInit }> = []
|
|
57
|
+
const client = createPreviewBridgeClient({
|
|
58
|
+
from: FROM,
|
|
59
|
+
endpointUrl: 'https://host.test/api/conversations/preview/inbound',
|
|
60
|
+
headers: { authorization: 'Bearer token-do-painel' },
|
|
61
|
+
fetchImplementation: (async (url: string, init: RequestInit) => {
|
|
62
|
+
calls.push({ url, init })
|
|
63
|
+
return { ok: true } as Response
|
|
64
|
+
}) as unknown as typeof fetch,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await client.sendText('oi')
|
|
68
|
+
|
|
69
|
+
expect(calls[0]?.url).toBe('https://host.test/api/conversations/preview/inbound')
|
|
70
|
+
expect(calls[0]?.init.method).toBe('POST')
|
|
71
|
+
expect(calls[0]?.init.headers).toMatchObject({
|
|
72
|
+
'content-type': 'application/json',
|
|
73
|
+
authorization: 'Bearer token-do-painel',
|
|
74
|
+
})
|
|
75
|
+
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ kind: 'text', from: FROM, text: 'oi' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('converte recusa do host em erro tipado, para o painel poder mostrar o motivo', async () => {
|
|
79
|
+
const client = createPreviewBridgeClient({
|
|
80
|
+
from: FROM,
|
|
81
|
+
endpointUrl: 'https://host.test/preview',
|
|
82
|
+
fetchImplementation: (async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewBridgeRejectedError)
|
|
86
|
+
await expect(client.sendText('oi')).rejects.toThrow(/403/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('recusa configuração sem forma de entregar, em vez de falhar só no primeiro envio', () => {
|
|
90
|
+
expect(() => createPreviewBridgeClient({ from: FROM })).toThrow(/sendCommand.*endpointUrl/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
|
|
3
|
+
* navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
|
|
4
|
+
* autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
|
|
5
|
+
* app secret que nunca sai de lá.
|
|
6
|
+
*
|
|
7
|
+
* Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
|
|
8
|
+
* app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
|
|
9
|
+
* acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
|
|
10
|
+
* válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
|
|
11
|
+
* Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
|
|
12
|
+
* servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
|
|
13
|
+
*
|
|
14
|
+
* O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
|
|
15
|
+
* porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
19
|
+
import {
|
|
20
|
+
createPreviewMediaPoster,
|
|
21
|
+
defaultMediaUploadUrl,
|
|
22
|
+
type PreviewWebhookClient,
|
|
23
|
+
type SendPreviewMediaParams,
|
|
24
|
+
} from './createPreviewWebhookClient'
|
|
25
|
+
import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
|
|
29
|
+
* se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
|
|
30
|
+
* quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
|
|
31
|
+
*/
|
|
32
|
+
export type PreviewInboundCommand =
|
|
33
|
+
| { readonly kind: 'text'; readonly from: string; readonly text: string }
|
|
34
|
+
| { readonly kind: 'buttonReply'; readonly from: string; readonly reply: InteractiveReplyOption }
|
|
35
|
+
| { readonly kind: 'listReply'; readonly from: string; readonly reply: InteractiveReplyOption }
|
|
36
|
+
| { readonly kind: 'audio'; readonly from: string; readonly mediaId: string }
|
|
37
|
+
| ({ readonly kind: 'media'; readonly from: string } & SendPreviewMediaParams)
|
|
38
|
+
|
|
39
|
+
export type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>
|
|
40
|
+
|
|
41
|
+
export class PreviewBridgeRejectedError extends Error {
|
|
42
|
+
constructor(readonly status: number) {
|
|
43
|
+
super(`A rota de preview do host recusou a entrega (HTTP ${status}).`)
|
|
44
|
+
this.name = 'PreviewBridgeRejectedError'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CreatePreviewBridgeClientParams = {
|
|
49
|
+
readonly from: string
|
|
50
|
+
/**
|
|
51
|
+
* Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
|
|
52
|
+
* de token — reimplementar isso aqui só duplicaria a autenticação do produto.
|
|
53
|
+
*/
|
|
54
|
+
readonly sendCommand?: SendPreviewInboundCommand
|
|
55
|
+
/** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
|
|
56
|
+
readonly endpointUrl?: string
|
|
57
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
58
|
+
readonly fetchImplementation?: typeof fetch
|
|
59
|
+
/**
|
|
60
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
|
|
61
|
+
*
|
|
62
|
+
* Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
|
|
63
|
+
* e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
|
|
64
|
+
*/
|
|
65
|
+
readonly mediaUploadUrl?: string
|
|
66
|
+
/**
|
|
67
|
+
* Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
|
|
68
|
+
* não há origem a derivar, e sem destino o gravador não é desenhado.
|
|
69
|
+
*/
|
|
70
|
+
readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
|
|
74
|
+
const endpointUrl = params.endpointUrl
|
|
75
|
+
if (!endpointUrl) {
|
|
76
|
+
throw new Error('createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return async (command) => {
|
|
80
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
81
|
+
const response = await performRequest(endpointUrl, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
// `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
|
|
84
|
+
// bearer não convivem numa escolha default sem quebrar um dos dois.
|
|
85
|
+
headers: { 'content-type': 'application/json', ...params.headers },
|
|
86
|
+
body: JSON.stringify(command),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (!response.ok) throw new PreviewBridgeRejectedError(response.status)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Só existe quando há para onde mandar: rota explícita, ou origem herdada do `endpointUrl`. */
|
|
94
|
+
function resolveBridgeUpload(
|
|
95
|
+
params: CreatePreviewBridgeClientParams,
|
|
96
|
+
): ((file: File) => Promise<PreviewUploadedMedia>) | undefined {
|
|
97
|
+
if (params.uploadMedia) return params.uploadMedia
|
|
98
|
+
|
|
99
|
+
const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : undefined)
|
|
100
|
+
if (!url) return undefined
|
|
101
|
+
|
|
102
|
+
return createPreviewMediaPoster({
|
|
103
|
+
url,
|
|
104
|
+
...(params.headers ? { headers: async () => params.headers ?? {} } : {}),
|
|
105
|
+
...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
|
|
110
|
+
const send = params.sendCommand ?? buildFetchSender(params)
|
|
111
|
+
const from = params.from
|
|
112
|
+
const uploadMedia = resolveBridgeUpload(params)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
sendText: (text) => send({ kind: 'text', from, text }),
|
|
116
|
+
sendButtonReply: (reply) => send({ kind: 'buttonReply', from, reply }),
|
|
117
|
+
sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
|
|
118
|
+
sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
|
|
119
|
+
sendMedia: (media) => send({ kind: 'media', from, ...media }),
|
|
120
|
+
...(uploadMedia ? { uploadMedia } : {}),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type { InboundMediaType }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
|
|
3
|
+
*
|
|
4
|
+
* O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
|
|
5
|
+
* ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
|
|
6
|
+
* lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
|
|
7
|
+
*
|
|
8
|
+
* Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
|
|
9
|
+
* estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
|
|
10
|
+
* a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Do `contracts`, que este pacote já consome — não uma cópia.
|
|
15
|
+
*
|
|
16
|
+
* A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
|
|
17
|
+
* duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
|
|
18
|
+
* Contrato compartilhado é o que o `contracts` existe para guardar.
|
|
19
|
+
*/
|
|
20
|
+
export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
|
|
21
|
+
import { toPreviewMediaId } from '@adatechnology/meta-whatsapp-contracts'
|
|
22
|
+
|
|
23
|
+
export type PreviewUploadedMedia = {
|
|
24
|
+
readonly mediaId: string
|
|
25
|
+
readonly mimeType?: string
|
|
26
|
+
readonly filename?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PreviewMediaUploadRequest = {
|
|
30
|
+
readonly base64: string
|
|
31
|
+
readonly mimeType: string
|
|
32
|
+
readonly filename: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CreatePreviewMediaUploaderParams = {
|
|
36
|
+
/**
|
|
37
|
+
* Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
|
|
38
|
+
*
|
|
39
|
+
* Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
|
|
40
|
+
* HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
|
|
41
|
+
* escolher por elas.
|
|
42
|
+
*/
|
|
43
|
+
readonly upload: (request: PreviewMediaUploadRequest) => Promise<{ uploadId: string }>
|
|
44
|
+
/** Nome usado quando o gravador entrega o áudio sem nome próprio. */
|
|
45
|
+
readonly fallbackFilename?: string
|
|
46
|
+
readonly fallbackMimeType?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Converte em blocos, não com `String.fromCharCode(...bytes)` de uma vez.
|
|
51
|
+
*
|
|
52
|
+
* Espalhar centenas de milhares de bytes como argumentos estoura o limite da engine — poucos segundos
|
|
53
|
+
* de áudio já chegam perto. O sintoma seria `RangeError` só nos arquivos grandes: passa no teste com
|
|
54
|
+
* um clipe curto e falha na primeira gravação de verdade.
|
|
55
|
+
*/
|
|
56
|
+
const CHUNK_SIZE = 8192
|
|
57
|
+
|
|
58
|
+
async function fileToBase64(file: File): Promise<string> {
|
|
59
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
60
|
+
let binary = ''
|
|
61
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
|
|
62
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE))
|
|
63
|
+
}
|
|
64
|
+
return btoa(binary)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPreviewMediaUploader(
|
|
68
|
+
params: CreatePreviewMediaUploaderParams,
|
|
69
|
+
): (file: File) => Promise<PreviewUploadedMedia> {
|
|
70
|
+
const fallbackMimeType = params.fallbackMimeType ?? 'audio/ogg'
|
|
71
|
+
const fallbackFilename = params.fallbackFilename ?? 'audio.ogg'
|
|
72
|
+
|
|
73
|
+
return async function uploadPreviewMedia(file: File): Promise<PreviewUploadedMedia> {
|
|
74
|
+
// Gravação de voz chega sem nome, e sem mime em navegador antigo.
|
|
75
|
+
const mimeType = file.type || fallbackMimeType
|
|
76
|
+
const filename = file.name || fallbackFilename
|
|
77
|
+
|
|
78
|
+
const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename })
|
|
79
|
+
|
|
80
|
+
return { mediaId: toPreviewMediaId(uploadId), mimeType, filename }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
assertPreviewEnvironment,
|
|
11
11
|
createPreviewWebhookClient,
|
|
12
12
|
PreviewInProductionError,
|
|
13
|
+
PreviewMediaUploadRejectedError,
|
|
13
14
|
PreviewWebhookRejectedError,
|
|
14
15
|
} from './createPreviewWebhookClient'
|
|
16
|
+
import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
15
17
|
|
|
16
18
|
const APP_SECRET = 'dev-app-secret'
|
|
17
19
|
const FROM = '5511988887777'
|
|
@@ -34,6 +36,13 @@ function createCapturingFetch(status = 200): { fetchImplementation: typeof fetch
|
|
|
34
36
|
return { fetchImplementation, captured }
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** `uploadMedia` é opcional no contrato — o cliente do webhook sempre traz, o teste garante isso. */
|
|
40
|
+
function uploadMediaOf(client: { uploadMedia?: (file: File) => Promise<unknown> }) {
|
|
41
|
+
const upload = client.uploadMedia
|
|
42
|
+
if (!upload) throw new Error('createPreviewWebhookClient deveria expor uploadMedia')
|
|
43
|
+
return upload
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
describe('createPreviewWebhookClient', () => {
|
|
38
47
|
it('assina com o mesmo HMAC que o servidor calcula em node:crypto', async () => {
|
|
39
48
|
const { fetchImplementation, captured } = createCapturingFetch()
|
|
@@ -103,3 +112,90 @@ describe('assertPreviewEnvironment', () => {
|
|
|
103
112
|
expect(() => assertPreviewEnvironment(false)).not.toThrow()
|
|
104
113
|
})
|
|
105
114
|
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* O microfone do simulador depende disto existir no cliente.
|
|
118
|
+
*
|
|
119
|
+
* Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
|
|
120
|
+
* diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
|
|
121
|
+
* pacote: cliente montado, upload assinado, id prefixado.
|
|
122
|
+
*/
|
|
123
|
+
describe('createPreviewWebhookClient.uploadMedia', () => {
|
|
124
|
+
function createUploadFetch(status = 201): {
|
|
125
|
+
fetchImplementation: typeof fetch
|
|
126
|
+
calls: Array<{ url: string; signature: string; body: string }>
|
|
127
|
+
} {
|
|
128
|
+
const calls: Array<{ url: string; signature: string; body: string }> = []
|
|
129
|
+
|
|
130
|
+
const fetchImplementation = (async (url: string, init?: RequestInit) => {
|
|
131
|
+
const headers = (init?.headers ?? {}) as Record<string, string>
|
|
132
|
+
calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
|
|
133
|
+
return {
|
|
134
|
+
ok: status >= 200 && status < 300,
|
|
135
|
+
status,
|
|
136
|
+
json: async () => ({ data: { uploadId: 'upl_123' } }),
|
|
137
|
+
} as Response
|
|
138
|
+
}) as unknown as typeof fetch
|
|
139
|
+
|
|
140
|
+
return { fetchImplementation, calls }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
|
|
144
|
+
|
|
145
|
+
it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
|
|
146
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
147
|
+
const client = createPreviewWebhookClient({
|
|
148
|
+
webhookUrl: WEBHOOK_URL,
|
|
149
|
+
appSecret: APP_SECRET,
|
|
150
|
+
from: FROM,
|
|
151
|
+
fetchImplementation,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const uploaded = (await uploadMediaOf(client)(audioFile())) as { mediaId: string; mimeType?: string }
|
|
155
|
+
|
|
156
|
+
expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
|
|
157
|
+
expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
|
|
158
|
+
expect(uploaded.mimeType).toBe('audio/ogg')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
|
|
162
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
163
|
+
const client = createPreviewWebhookClient({
|
|
164
|
+
webhookUrl: WEBHOOK_URL,
|
|
165
|
+
appSecret: APP_SECRET,
|
|
166
|
+
from: FROM,
|
|
167
|
+
fetchImplementation,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await uploadMediaOf(client)(audioFile())
|
|
171
|
+
|
|
172
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
|
|
173
|
+
expect(calls[0]?.signature).toBe(expected)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
|
|
177
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
178
|
+
const client = createPreviewWebhookClient({
|
|
179
|
+
webhookUrl: '/v1/webhook/whatsapp',
|
|
180
|
+
appSecret: APP_SECRET,
|
|
181
|
+
from: FROM,
|
|
182
|
+
fetchImplementation,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
await uploadMediaOf(client)(audioFile())
|
|
186
|
+
|
|
187
|
+
expect(calls[0]?.url).toBe('/v1/preview/media')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('avisa com erro próprio quando a rota de mídia recusa', async () => {
|
|
191
|
+
const { fetchImplementation } = createUploadFetch(404)
|
|
192
|
+
const client = createPreviewWebhookClient({
|
|
193
|
+
webhookUrl: WEBHOOK_URL,
|
|
194
|
+
appSecret: APP_SECRET,
|
|
195
|
+
from: FROM,
|
|
196
|
+
fetchImplementation,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await expect(uploadMediaOf(client)(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
|
|
200
|
+
})
|
|
201
|
+
})
|