@adatechnology/conversations-ui 0.1.0-rc.0 → 0.1.0-rc.10
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/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-FWNAWAJP.js +1743 -0
- package/dist/flows/index.js +3 -3
- package/dist/index.d.ts +619 -136
- package/dist/index.js +1064 -1024
- package/dist/preview/index.d.ts +330 -0
- package/dist/preview/index.js +1281 -0
- package/dist/styles.css +198 -0
- package/dist/types-B5C1DLu1.d.ts +365 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +14 -11
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +73 -19
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +112 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/AudioRecorderButton.tsx +125 -0
- package/src/preview/ConversationPreview.tsx +315 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/audioRecorderFormat.test.ts +67 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +126 -0
- package/src/preview/index.ts +54 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConversationsApi` servido pelo store em memória. Como o pacote é headless e recebe a API por
|
|
3
|
+
* injeção, o preview de atendimento humano não precisa de servidor, banco nem Meta: é só outra
|
|
4
|
+
* implementação deste mesmo contrato.
|
|
5
|
+
*
|
|
6
|
+
* Toda resposta é assíncrona e passa por um atraso configurável — API instantânea esconde estados
|
|
7
|
+
* de carregamento, e é neles que a inbox costuma mostrar defeito.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MessagePayload } from '../types'
|
|
11
|
+
import type {
|
|
12
|
+
CompanyDocumentPage,
|
|
13
|
+
ConversationDocumentPage,
|
|
14
|
+
ConversationPage,
|
|
15
|
+
ConversationTemplate,
|
|
16
|
+
ConversationsApi,
|
|
17
|
+
ListConversationsParams,
|
|
18
|
+
} from '../providers/types'
|
|
19
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
20
|
+
import { previewFileBase64, previewFileUrl } from './previewMediaSource'
|
|
21
|
+
import type { PreviewStore } from './previewStore'
|
|
22
|
+
|
|
23
|
+
export type CreateMockConversationsApiParams = {
|
|
24
|
+
readonly store: PreviewStore
|
|
25
|
+
readonly latencyMs?: number
|
|
26
|
+
readonly agentName?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_LATENCY_MS = 120
|
|
30
|
+
|
|
31
|
+
const PREVIEW_AGENT_ID = 'preview-agent'
|
|
32
|
+
|
|
33
|
+
const PREVIEW_TEMPLATES: readonly ConversationTemplate[] = [
|
|
34
|
+
{ name: 'retomada_atendimento', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
35
|
+
{ name: 'lembrete_documentos', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
36
|
+
{ name: 'promocao_taxa', language: 'pt_BR', status: 'PENDING', category: 'MARKETING' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* O mock satisfaz `ConversationsApi`, mas com o retorno de `fetchConversations` ESTREITADO para a
|
|
41
|
+
* forma paginada. Sem isto o contrato — que aceita array ou página — obrigaria todo consumidor do
|
|
42
|
+
* preview a desempacotar uma união que aqui nunca varia.
|
|
43
|
+
*/
|
|
44
|
+
export type MockConversationsApi = Omit<ConversationsApi, 'fetchConversations'> & {
|
|
45
|
+
fetchConversations(params?: ListConversationsParams): Promise<ConversationPage>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createMockConversationsApi(params: CreateMockConversationsApiParams): MockConversationsApi {
|
|
49
|
+
const latencyMs = params.latencyMs ?? DEFAULT_LATENCY_MS
|
|
50
|
+
|
|
51
|
+
async function withLatency<TResult>(produce: () => TResult): Promise<TResult> {
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, latencyMs))
|
|
53
|
+
return produce()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
// Devolve a forma paginada, não o array puro: é a que o contrato passou a oferecer e a que
|
|
58
|
+
// permite o preview desenhar controles de página. O total é contado ANTES do corte — depois
|
|
59
|
+
// dele seria sempre o tamanho da página, e a paginação nunca sairia da primeira.
|
|
60
|
+
fetchConversations(fetchParams): Promise<ConversationPage> {
|
|
61
|
+
return withLatency(() => {
|
|
62
|
+
const conversations = params.store.listConversations({
|
|
63
|
+
waitingHuman: fetchParams?.waitingHuman,
|
|
64
|
+
search: fetchParams?.search,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const limit = fetchParams?.limit ?? conversations.length
|
|
68
|
+
const page = fetchParams?.page ?? 1
|
|
69
|
+
return {
|
|
70
|
+
conversations: conversations.slice((page - 1) * limit, page * limit),
|
|
71
|
+
total: conversations.length,
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
fetchMessages(conversationId, fetchParams): Promise<MessagePayload[]> {
|
|
77
|
+
return withLatency(() => {
|
|
78
|
+
const messages = params.store.listMessages(conversationId)
|
|
79
|
+
const limit = fetchParams?.limit
|
|
80
|
+
return limit ? messages.slice(-limit) : messages
|
|
81
|
+
})
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
sendMessage(conversationId, text): Promise<MessagePayload> {
|
|
85
|
+
return withLatency(() =>
|
|
86
|
+
params.store.appendMessage({ conversationId, content: text, direction: 'outbound', sender: 'agent' }),
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
sendMedia(conversationId, data): Promise<MessagePayload> {
|
|
91
|
+
return withLatency(() =>
|
|
92
|
+
params.store.appendMessage({
|
|
93
|
+
conversationId,
|
|
94
|
+
content: data.caption ?? data.filename,
|
|
95
|
+
direction: 'outbound',
|
|
96
|
+
sender: 'agent',
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
sendTemplate(conversationId, data): Promise<void> {
|
|
102
|
+
return withLatency(() => {
|
|
103
|
+
params.store.appendMessage({
|
|
104
|
+
conversationId,
|
|
105
|
+
// Sem nome, o host está pedindo o template padrão do backend — o mock representa isso
|
|
106
|
+
// pelo que o atendente veria, não por um nome inventado.
|
|
107
|
+
content: `[template] ${data.templateName ?? PREVIEW_TEMPLATES[0]?.name ?? 'padrao'}`,
|
|
108
|
+
direction: 'outbound',
|
|
109
|
+
sender: 'agent',
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
markRead(conversationId): Promise<void> {
|
|
115
|
+
return withLatency(() => params.store.markRead(conversationId))
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
getContext(conversationId): Promise<Record<string, unknown>> {
|
|
119
|
+
return withLatency(() => {
|
|
120
|
+
const conversation = params.store.listConversations().find((item) => item.id === conversationId)
|
|
121
|
+
return {
|
|
122
|
+
currentState: conversation?.currentState ?? 'unknown',
|
|
123
|
+
mode: conversation?.mode ?? 'bot',
|
|
124
|
+
preview: true,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Espelha o backend em busca, filtro de origem, ordenação E paginação. Mock que ignora params
|
|
131
|
+
* faz o painel parecer quebrado aqui e, pior, esconde o caso em que o backend também os ignora
|
|
132
|
+
* — foi exatamente assim que o filtro de origem passou a existir só no contrato.
|
|
133
|
+
*/
|
|
134
|
+
getDocuments(conversationId, documentParams): Promise<ConversationDocumentPage> {
|
|
135
|
+
return withLatency(() => {
|
|
136
|
+
let documents = [...(PREVIEW_DOCUMENTS[conversationId] ?? [])]
|
|
137
|
+
|
|
138
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
139
|
+
if (search) {
|
|
140
|
+
documents = documents.filter((document) => document.filename.toLowerCase().includes(search))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 'team' agrupa agent + bot, como o painel apresenta.
|
|
144
|
+
const source = documentParams?.source
|
|
145
|
+
if (source === 'team') {
|
|
146
|
+
documents = documents.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
147
|
+
} else if (source) {
|
|
148
|
+
documents = documents.filter((document) => document.source === source)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
documents.sort((left, right) =>
|
|
152
|
+
documentParams?.sortDirection === 'asc'
|
|
153
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
154
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Total contado ANTES do corte — depois seria sempre o tamanho da página, e a paginação
|
|
158
|
+
// nunca sairia da primeira.
|
|
159
|
+
const total = documents.length
|
|
160
|
+
const limit = documentParams?.limit ?? total
|
|
161
|
+
const page = documentParams?.page ?? 1
|
|
162
|
+
|
|
163
|
+
return { documents: documents.slice((page - 1) * limit, page * limit), total }
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Zip de mentira: um texto listando o que entraria. Basta para exercitar seleção, botão e o
|
|
169
|
+
* caminho de download no preview, sem arrastar uma lib de compactação para o pacote.
|
|
170
|
+
*/
|
|
171
|
+
downloadDocumentsArchive(conversationId, uploadIds): Promise<Blob> {
|
|
172
|
+
return withLatency(() => {
|
|
173
|
+
const known = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
174
|
+
const names = uploadIds.map((id) => known.find((document) => document.id === id)?.filename ?? id)
|
|
175
|
+
return new Blob([`preview: ${names.length} arquivo(s)\n${names.join('\n')}`], { type: 'application/zip' })
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/** Junta as bibliotecas de todas as conversas do fixture, com a origem de cada arquivo. */
|
|
180
|
+
getAllDocuments(documentParams): Promise<CompanyDocumentPage> {
|
|
181
|
+
return withLatency(() => {
|
|
182
|
+
let all = Object.entries(PREVIEW_DOCUMENTS).flatMap(([conversationId, docs]) =>
|
|
183
|
+
docs.map((document) => ({ ...document, conversationId })),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Mesma regra do backend (`companyDocumentSearch`): o termo casa nome do arquivo OU
|
|
187
|
+
// telefone da conversa, e o telefone só pelos dígitos — o preview mostra o número
|
|
188
|
+
// formatado, então é assim que o atendente vai colá-lo na busca.
|
|
189
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
190
|
+
if (search) {
|
|
191
|
+
const digits = search.replace(/\D/g, '')
|
|
192
|
+
all = all.filter(
|
|
193
|
+
(document) =>
|
|
194
|
+
document.filename.toLowerCase().includes(search) ||
|
|
195
|
+
(digits !== '' && document.conversationId.includes(digits)),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const source = documentParams?.source
|
|
200
|
+
if (source === 'team') {
|
|
201
|
+
all = all.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
202
|
+
} else if (source) {
|
|
203
|
+
all = all.filter((document) => document.source === source)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
all.sort((left, right) =>
|
|
207
|
+
documentParams?.sortDirection === 'asc'
|
|
208
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
209
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const total = all.length
|
|
213
|
+
const limit = documentParams?.limit ?? total
|
|
214
|
+
const page = documentParams?.page ?? 1
|
|
215
|
+
return { documents: all.slice((page - 1) * limit, page * limit), total }
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Devolve os bytes DO TIPO do documento, não uma imagem para tudo: antes, abrir um PDF entregava
|
|
220
|
+
// um PNG rotulado `application/pdf` e o leitor recusava o arquivo. O `uploadId` é a única pista
|
|
221
|
+
// que o contrato dá, então o tipo vem da própria biblioteca.
|
|
222
|
+
getDocumentUrl(uploadId): Promise<string> {
|
|
223
|
+
return withLatency(() => {
|
|
224
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
225
|
+
.flat()
|
|
226
|
+
.find((document) => document.id === uploadId)
|
|
227
|
+
return previewFileUrl(found?.mimeType, found?.filename)
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Caminho da mídia ainda não ingerida: o backend busca na Meta e devolve base64. Resolve pelo
|
|
232
|
+
// id para a bolha receber os bytes DO TIPO dela — devolvendo um PNG para todo id, vídeo e áudio
|
|
233
|
+
// apareciam quebrados na thread mesmo havendo amostra válida do formato.
|
|
234
|
+
getMediaProxyUrl(mediaId): Promise<{ mimeType: string; data: string }> {
|
|
235
|
+
return withLatency(() => {
|
|
236
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
237
|
+
.flat()
|
|
238
|
+
.find((document) => document.id === `preview/inbound/${mediaId}`)
|
|
239
|
+
return previewFileBase64(found?.mimeType, found?.filename)
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
takeover(conversationId): Promise<void> {
|
|
244
|
+
return withLatency(() =>
|
|
245
|
+
params.store.setMode({ conversationId, mode: 'human', assignedUserId: PREVIEW_AGENT_ID }),
|
|
246
|
+
)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
release(conversationId): Promise<void> {
|
|
250
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// Encerrar devolve ao bot como o release, e é de propósito: a diferença entre os dois é a
|
|
254
|
+
// despedida, que o host manda antes de chamar aqui. O mock não a inventa.
|
|
255
|
+
finalize(conversationId): Promise<void> {
|
|
256
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
markAllRead(): Promise<void> {
|
|
260
|
+
return withLatency(() => {
|
|
261
|
+
for (const conversation of params.store.listConversations()) {
|
|
262
|
+
params.store.markRead(conversation.id)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
listTemplates(): Promise<ConversationTemplate[]> {
|
|
268
|
+
return withLatency(() => [...PREVIEW_TEMPLATES])
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SSEProvider` servido pelo store em memória. Mesmo mapeamento de canal do servidor
|
|
3
|
+
* (`conv:<conversationId>` e `global`), para que a UI não perceba a troca.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SSEProvider } from '../providers/types'
|
|
7
|
+
import { createMockEventSource } from './mockEventSource'
|
|
8
|
+
import { conversationChannel, GLOBAL_CHANNEL, type PreviewStore } from './previewStore'
|
|
9
|
+
|
|
10
|
+
export type CreateMockSSEProviderParams = {
|
|
11
|
+
readonly store: PreviewStore
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createMockSSEProvider(params: CreateMockSSEProviderParams): SSEProvider {
|
|
15
|
+
function connect(channel: string): ReturnType<typeof createMockEventSource> {
|
|
16
|
+
const source = createMockEventSource()
|
|
17
|
+
const unsubscribe = params.store.subscribe(channel, (emission) => {
|
|
18
|
+
source.emit(emission.event, emission.payload)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const close = source.close.bind(source)
|
|
22
|
+
// O unsubscribe tem de acontecer no close, senão cada remontagem de componente deixa um
|
|
23
|
+
// listener preso no store e a mesma mensagem chega duplicada na UI.
|
|
24
|
+
source.close = (): void => {
|
|
25
|
+
unsubscribe()
|
|
26
|
+
close()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return source
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
connectConversationStream(conversationId: string) {
|
|
34
|
+
return connect(conversationChannel(conversationId))
|
|
35
|
+
},
|
|
36
|
+
connectGlobalStream() {
|
|
37
|
+
return connect(GLOBAL_CHANNEL)
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O risco aqui não é o cliente montar o payload errado — é o HMAC do WebCrypto divergir do que o
|
|
3
|
+
* servidor calcula com `node:crypto`. Uma divergência de um byte transforma todo envio do preview
|
|
4
|
+
* em 401, então o teste compara as duas implementações diretamente.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHmac } from 'node:crypto'
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
import {
|
|
10
|
+
assertPreviewEnvironment,
|
|
11
|
+
createPreviewWebhookClient,
|
|
12
|
+
PreviewInProductionError,
|
|
13
|
+
PreviewWebhookRejectedError,
|
|
14
|
+
} from './createPreviewWebhookClient'
|
|
15
|
+
|
|
16
|
+
const APP_SECRET = 'dev-app-secret'
|
|
17
|
+
const FROM = '5511988887777'
|
|
18
|
+
const WEBHOOK_URL = 'http://localhost:3000/v1/webhook/whatsapp'
|
|
19
|
+
|
|
20
|
+
type CapturedRequest = {
|
|
21
|
+
body: string
|
|
22
|
+
signature: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createCapturingFetch(status = 200): { fetchImplementation: typeof fetch; captured: CapturedRequest[] } {
|
|
26
|
+
const captured: CapturedRequest[] = []
|
|
27
|
+
|
|
28
|
+
const fetchImplementation = (async (_url: string, init?: RequestInit) => {
|
|
29
|
+
const headers = init?.headers as Record<string, string>
|
|
30
|
+
captured.push({ body: String(init?.body), signature: headers['x-hub-signature-256'] ?? '' })
|
|
31
|
+
return { ok: status >= 200 && status < 300, status } as Response
|
|
32
|
+
}) as unknown as typeof fetch
|
|
33
|
+
|
|
34
|
+
return { fetchImplementation, captured }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('createPreviewWebhookClient', () => {
|
|
38
|
+
it('assina com o mesmo HMAC que o servidor calcula em node:crypto', async () => {
|
|
39
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
40
|
+
const client = createPreviewWebhookClient({
|
|
41
|
+
webhookUrl: WEBHOOK_URL,
|
|
42
|
+
appSecret: APP_SECRET,
|
|
43
|
+
from: FROM,
|
|
44
|
+
fetchImplementation,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await client.sendText('quero 2kg de arroz')
|
|
48
|
+
|
|
49
|
+
const request = captured[0]
|
|
50
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET)
|
|
51
|
+
.update(request?.body ?? '')
|
|
52
|
+
.digest('hex')}`
|
|
53
|
+
expect(request?.signature).toBe(expected)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('envia exatamente os bytes que assinou', async () => {
|
|
57
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
58
|
+
const client = createPreviewWebhookClient({
|
|
59
|
+
webhookUrl: WEBHOOK_URL,
|
|
60
|
+
appSecret: APP_SECRET,
|
|
61
|
+
from: FROM,
|
|
62
|
+
fetchImplementation,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await client.sendButtonReply({ id: 'confirmar', title: 'Confirmar' })
|
|
66
|
+
|
|
67
|
+
const request = captured[0]
|
|
68
|
+
const reparsed = JSON.stringify(JSON.parse(request?.body ?? '{}'))
|
|
69
|
+
expect(request?.body).toBe(reparsed)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('mensagens de texto idêntico produzem assinaturas distintas', async () => {
|
|
73
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
74
|
+
const client = createPreviewWebhookClient({
|
|
75
|
+
webhookUrl: WEBHOOK_URL,
|
|
76
|
+
appSecret: APP_SECRET,
|
|
77
|
+
from: FROM,
|
|
78
|
+
fetchImplementation,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await client.sendText('sim')
|
|
82
|
+
await client.sendText('sim')
|
|
83
|
+
|
|
84
|
+
expect(captured[0]?.signature).not.toBe(captured[1]?.signature)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('propaga a recusa do webhook em vez de engolir', async () => {
|
|
88
|
+
const { fetchImplementation } = createCapturingFetch(401)
|
|
89
|
+
const client = createPreviewWebhookClient({
|
|
90
|
+
webhookUrl: WEBHOOK_URL,
|
|
91
|
+
appSecret: APP_SECRET,
|
|
92
|
+
from: FROM,
|
|
93
|
+
fetchImplementation,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewWebhookRejectedError)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('assertPreviewEnvironment', () => {
|
|
101
|
+
it('recusa montar em produção', () => {
|
|
102
|
+
expect(() => assertPreviewEnvironment(true)).toThrow(PreviewInProductionError)
|
|
103
|
+
expect(() => assertPreviewEnvironment(false)).not.toThrow()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente que entrega mensagens do preview no webhook real, assinadas com HMAC — a mesma validação
|
|
3
|
+
* de staging e produção, sem rota alternativa e sem bypass. Do ponto de vista da API, este cliente
|
|
4
|
+
* é indistinguível da Meta; o que muda é apenas quem assina.
|
|
5
|
+
*
|
|
6
|
+
* Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
|
|
7
|
+
* (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ Isto carrega o app secret de DESENVOLVIMENTO no bundle. Só existe para o docker local, e
|
|
10
|
+
* `assertPreviewEnvironment` recusa rodar em produção — um segredo de dev vazado é irrelevante,
|
|
11
|
+
* mas o hábito de embarcar segredo em frontend não é.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
buildInboundAudioPayload,
|
|
16
|
+
buildInboundInteractivePayload,
|
|
17
|
+
buildInboundMediaPayload,
|
|
18
|
+
buildInboundTextPayload,
|
|
19
|
+
serializeWebhookPayload,
|
|
20
|
+
type InboundMediaType,
|
|
21
|
+
type InteractiveReplyOption,
|
|
22
|
+
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
23
|
+
|
|
24
|
+
export type PreviewWebhookClient = {
|
|
25
|
+
sendText(text: string): Promise<void>
|
|
26
|
+
sendButtonReply(reply: InteractiveReplyOption): Promise<void>
|
|
27
|
+
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
28
|
+
sendAudio(mediaId: string): Promise<void>
|
|
29
|
+
sendMedia(params: SendPreviewMediaParams): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SendPreviewMediaParams = {
|
|
33
|
+
readonly mediaType: InboundMediaType
|
|
34
|
+
/**
|
|
35
|
+
* Id que o host já usa para buscar o arquivo. Não é bytes: o webhook da Meta entrega mídia por
|
|
36
|
+
* referência, e o consumidor baixa depois — mandar base64 aqui simularia um payload que a Meta
|
|
37
|
+
* nunca produz, e o caminho testado deixaria de ser o de produção.
|
|
38
|
+
*/
|
|
39
|
+
readonly mediaId: string
|
|
40
|
+
readonly mimeType?: string
|
|
41
|
+
readonly filename?: string
|
|
42
|
+
readonly caption?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CreatePreviewWebhookClientParams = {
|
|
46
|
+
readonly webhookUrl: string
|
|
47
|
+
readonly appSecret: string
|
|
48
|
+
readonly from: string
|
|
49
|
+
readonly phoneNumberId?: string
|
|
50
|
+
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
51
|
+
readonly fetchImplementation?: typeof fetch
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class PreviewInProductionError extends Error {
|
|
55
|
+
constructor() {
|
|
56
|
+
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
57
|
+
this.name = 'PreviewInProductionError'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class PreviewWebhookRejectedError extends Error {
|
|
62
|
+
constructor(readonly status: number) {
|
|
63
|
+
super(`O webhook recusou a entrega do preview (HTTP ${status}).`)
|
|
64
|
+
this.name = 'PreviewWebhookRejectedError'
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Falha alto em vez de degradar em silêncio: um preview que "quase funciona" em produção é pior
|
|
70
|
+
* que um que se recusa a montar.
|
|
71
|
+
*/
|
|
72
|
+
export function assertPreviewEnvironment(isProduction: boolean): void {
|
|
73
|
+
if (isProduction) throw new PreviewInProductionError()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Assina um texto qualquer com o app secret, no mesmo formato do header da Meta.
|
|
78
|
+
*
|
|
79
|
+
* Exportada porque o preview precisa provar identidade em MAIS de um lugar: além de entregar a
|
|
80
|
+
* mensagem no webhook, ele lê o transcript de volta — e ler pela API de admin exigia uma sessão que
|
|
81
|
+
* a aba do simulador não tem. Assinar a leitura com o segredo que ele já carrega resolve sem token
|
|
82
|
+
* de admin e sem rota aberta.
|
|
83
|
+
*/
|
|
84
|
+
export async function signPreviewPayload(params: { rawBody: string; appSecret: string }): Promise<string> {
|
|
85
|
+
const encoder = new TextEncoder()
|
|
86
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
87
|
+
'raw',
|
|
88
|
+
encoder.encode(params.appSecret),
|
|
89
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
90
|
+
false,
|
|
91
|
+
['sign'],
|
|
92
|
+
)
|
|
93
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(params.rawBody))
|
|
94
|
+
|
|
95
|
+
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const signWithWebCrypto = signPreviewPayload
|
|
99
|
+
|
|
100
|
+
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
101
|
+
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
102
|
+
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
103
|
+
// validação, porque o HMAC cobre os bytes exatos.
|
|
104
|
+
const rawBody = serializeWebhookPayload(payload)
|
|
105
|
+
const signature = await signWithWebCrypto({ rawBody, appSecret: params.appSecret })
|
|
106
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
107
|
+
|
|
108
|
+
const response = await performRequest(params.webhookUrl, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
|
|
111
|
+
body: rawBody,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
|
|
121
|
+
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
122
|
+
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
123
|
+
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
124
|
+
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export separado (`@adatechnology/conversations-ui/preview`) para que fixtures e mocks nunca
|
|
3
|
+
* entrem no bundle de produção de quem consome o pacote.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createPreviewStore, conversationChannel, GLOBAL_CHANNEL } from './previewStore'
|
|
7
|
+
export type {
|
|
8
|
+
PreviewStore,
|
|
9
|
+
CreatePreviewStoreParams,
|
|
10
|
+
PreviewEmission,
|
|
11
|
+
PreviewStoreListener,
|
|
12
|
+
AppendMessageParams,
|
|
13
|
+
SetModeParams,
|
|
14
|
+
ListConversationsFilters,
|
|
15
|
+
} from './previewStore'
|
|
16
|
+
|
|
17
|
+
export { createMockEventSource } from './mockEventSource'
|
|
18
|
+
export type { MockEventSource } from './mockEventSource'
|
|
19
|
+
|
|
20
|
+
export { createMockConversationsApi } from './createMockConversationsApi'
|
|
21
|
+
export type { CreateMockConversationsApiParams } from './createMockConversationsApi'
|
|
22
|
+
|
|
23
|
+
export { createMockSSEProvider } from './createMockSSEProvider'
|
|
24
|
+
export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
25
|
+
|
|
26
|
+
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
|
+
|
|
28
|
+
export { ConversationPreview } from './ConversationPreview'
|
|
29
|
+
export { mediaTypeOf } from './ConversationPreview'
|
|
30
|
+
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
31
|
+
|
|
32
|
+
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from './AudioRecorderButton'
|
|
33
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from './AudioRecorderButton'
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
createPreviewWebhookClient,
|
|
37
|
+
signPreviewPayload,
|
|
38
|
+
assertPreviewEnvironment,
|
|
39
|
+
PreviewInProductionError,
|
|
40
|
+
PreviewWebhookRejectedError,
|
|
41
|
+
} from './createPreviewWebhookClient'
|
|
42
|
+
export type {
|
|
43
|
+
PreviewWebhookClient,
|
|
44
|
+
CreatePreviewWebhookClientParams,
|
|
45
|
+
SendPreviewMediaParams,
|
|
46
|
+
} from './createPreviewWebhookClient'
|
|
47
|
+
|
|
48
|
+
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
49
|
+
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
50
|
+
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
51
|
+
export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
|
|
52
|
+
export { previewFileBase64 } from './previewMediaSource'
|
|
53
|
+
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
54
|
+
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mediaTypeOf } from './ConversationPreview'
|
|
3
|
+
|
|
4
|
+
describe('mediaTypeOf', () => {
|
|
5
|
+
it('deriva imagem, vídeo e áudio pelo prefixo do MIME', () => {
|
|
6
|
+
expect(mediaTypeOf('image/jpeg')).toBe('image')
|
|
7
|
+
expect(mediaTypeOf('video/mp4')).toBe('video')
|
|
8
|
+
expect(mediaTypeOf('audio/webm')).toBe('audio')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('trata o resto como documento, inclusive MIME desconhecido', () => {
|
|
12
|
+
expect(mediaTypeOf('application/pdf')).toBe('document')
|
|
13
|
+
expect(mediaTypeOf('')).toBe('document')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a busca da biblioteca no mock — que é a implementação de referência da UI do SDK e tem de
|
|
3
|
+
* casar com o `companyDocumentSearch` do backend, senão o preview ensina um comportamento que a
|
|
4
|
+
* tela real não entrega.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
10
|
+
import { createPreviewStore } from './previewStore'
|
|
11
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
12
|
+
|
|
13
|
+
function buildApi() {
|
|
14
|
+
const store = createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES })
|
|
15
|
+
return createMockConversationsApi({ store })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function search(term: string): Promise<{ total: number; conversations: string[] }> {
|
|
19
|
+
const page = await buildApi().getAllDocuments!({ search: term })
|
|
20
|
+
return { total: page.total, conversations: [...new Set(page.documents.map((document) => document.conversationId))] }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('busca da biblioteca no mock', () => {
|
|
24
|
+
it('acha por nome do arquivo', async () => {
|
|
25
|
+
const result = await search('contrato')
|
|
26
|
+
|
|
27
|
+
expect(result.total).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('acha por telefone da conversa', async () => {
|
|
31
|
+
const porNumero = await search('94444')
|
|
32
|
+
|
|
33
|
+
expect(porNumero.conversations).toEqual(['5511944443333'])
|
|
34
|
+
expect(porNumero.total).toBeGreaterThan(0)
|
|
35
|
+
// Discrimina de verdade: 98888 é conversa que existe nas fixtures, mas sem arquivo — se o
|
|
36
|
+
// predicado do telefone não estivesse valendo, esta busca devolveria a biblioteca inteira.
|
|
37
|
+
expect((await search('98888')).total).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// O caso real: o atendente copia o número como a tela o mostra.
|
|
41
|
+
it('acha com o telefone formatado, como aparece na tela', async () => {
|
|
42
|
+
const cru = await search('5511944443333')
|
|
43
|
+
const formatado = await search('+55 (11) 94444-3333')
|
|
44
|
+
|
|
45
|
+
expect(formatado.total).toBe(cru.total)
|
|
46
|
+
expect(formatado.total).toBeGreaterThan(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('não devolve nada para número de outra conversa', async () => {
|
|
50
|
+
expect((await search('99999999999')).total).toBe(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Sem dígito, o predicado do telefone não existe — senão qualquer palavra casaria toda conversa.
|
|
54
|
+
it('termo sem dígito filtra só por nome', async () => {
|
|
55
|
+
expect((await search('zzz-inexistente')).total).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|