@adatechnology/conversations-ui 0.1.0-rc.5 → 0.1.0-rc.7
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-TGTBMMFC.js +1707 -0
- package/dist/index.d.ts +209 -22
- package/dist/index.js +243 -590
- package/dist/preview/index.d.ts +153 -9
- package/dist/preview/index.js +520 -38
- package/dist/{types-C2Yexi8A.d.ts → types-B5C1DLu1.d.ts} +70 -2
- package/package.json +2 -2
- package/src/Avatar.tsx +13 -2
- package/src/ConversationHeader.tsx +18 -0
- package/src/ConversationListItem.tsx +18 -2
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +46 -1
- package/src/FileIcon.tsx +76 -10
- package/src/InteractiveMessage.tsx +126 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MessageBubble.tsx +31 -4
- package/src/MessageComposer.tsx +36 -5
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/index.ts +25 -12
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/preview/AudioRecorderButton.tsx +117 -0
- package/src/preview/ConversationPreview.tsx +184 -15
- 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 +62 -8
- package/src/preview/createPreviewWebhookClient.ts +28 -1
- package/src/preview/index.ts +16 -2
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +140 -8
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/providers/types.ts +17 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/types.ts +38 -1
- package/dist/chunk-YWITIIHD.js +0 -728
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
-
import type { MessagePayload } from '../types'
|
|
16
|
+
import type { InteractiveSelection, MessagePayload } from '../types'
|
|
17
17
|
import type { SSEProvider } from '../providers/types'
|
|
18
18
|
import { MessageBubble } from '../MessageBubble'
|
|
19
19
|
import { MessageComposer } from '../MessageComposer'
|
|
20
20
|
import { DateDivider } from '../DateDivider'
|
|
21
21
|
import { ConversationWallpaper } from '../Wallpaper'
|
|
22
|
-
import type { PreviewWebhookClient } from './createPreviewWebhookClient'
|
|
22
|
+
import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
|
|
23
|
+
import { AudioRecorderButton } from './AudioRecorderButton'
|
|
23
24
|
|
|
24
25
|
export type ConversationPreviewProps = {
|
|
25
26
|
client: PreviewWebhookClient
|
|
@@ -27,12 +28,43 @@ export type ConversationPreviewProps = {
|
|
|
27
28
|
conversationId: string
|
|
28
29
|
loadMessages: (conversationId: string) => Promise<MessagePayload[]>
|
|
29
30
|
placeholder?: string
|
|
31
|
+
/**
|
|
32
|
+
* Recarrega o transcript a cada N ms. Serve a host SEM stream: a resposta do bot é assíncrona, e
|
|
33
|
+
* sem SSE nem polling ela só apareceria no próximo envio — o sintoma é "às vezes ele não
|
|
34
|
+
* responde". Ausente, não faz polling (host com SSE não precisa).
|
|
35
|
+
*/
|
|
36
|
+
pollIntervalMs?: number
|
|
37
|
+
/**
|
|
38
|
+
* Como transformar um arquivo do disco (ou o áudio gravado) na referência que o webhook carrega.
|
|
39
|
+
* O caminho da Meta entrega mídia por `id`, e quem sabe hospedar o arquivo é o host — a SDK não
|
|
40
|
+
* inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
|
|
41
|
+
* botão que não existe do que um que falha ao ser tocado.
|
|
42
|
+
*/
|
|
43
|
+
uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PreviewUploadedMedia = {
|
|
47
|
+
readonly mediaId: string
|
|
48
|
+
readonly mimeType?: string
|
|
49
|
+
readonly filename?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Deriva o tipo de mídia do WhatsApp a partir do MIME do arquivo escolhido. */
|
|
53
|
+
export function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'] {
|
|
54
|
+
if (mimeType.startsWith('image/')) return 'image'
|
|
55
|
+
if (mimeType.startsWith('video/')) return 'video'
|
|
56
|
+
if (mimeType.startsWith('audio/')) return 'audio'
|
|
57
|
+
return 'document'
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
// Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
|
|
33
61
|
// recomeça um grupo (com rabicho e espaçamento maior).
|
|
34
62
|
const GROUPING_WINDOW_MS = 5 * 60 * 1000
|
|
35
63
|
|
|
64
|
+
// Escalonado, não um atraso fixo: a maioria das respostas chega em ~300ms, mas fluxo que consulta
|
|
65
|
+
// catálogo ou IA demora mais — e recarregar três vezes é mais barato que a conversa parecer morta.
|
|
66
|
+
const FOLLOW_UP_REFRESH_MS = [400, 1200, 3000]
|
|
67
|
+
|
|
36
68
|
type RenderedMessage = {
|
|
37
69
|
readonly message: MessagePayload
|
|
38
70
|
readonly isFirstInGroup: boolean
|
|
@@ -55,26 +87,79 @@ function decorate(messages: readonly MessagePayload[]): RenderedMessage[] {
|
|
|
55
87
|
})
|
|
56
88
|
}
|
|
57
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Status HTTP do erro, quando o host o preserva.
|
|
92
|
+
*
|
|
93
|
+
* O contrato não exige um tipo de erro — cada host tem o seu —, então a leitura é estrutural: basta
|
|
94
|
+
* carregar `status` (ou `statusCode`) para ser classificável. Host que joga `new Error(texto)` cai no
|
|
95
|
+
* caminho genérico, que ainda é melhor que silêncio.
|
|
96
|
+
*/
|
|
97
|
+
function statusOf(error: unknown): number | undefined {
|
|
98
|
+
if (typeof error !== 'object' || error === null) return undefined
|
|
99
|
+
const candidate = error as { status?: unknown; statusCode?: unknown }
|
|
100
|
+
const value = candidate.status ?? candidate.statusCode
|
|
101
|
+
return typeof value === 'number' ? value : undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isNotFound(error: unknown): boolean {
|
|
105
|
+
return statusOf(error) === 404
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function describeLoadFailure(error: unknown): string {
|
|
109
|
+
const status = statusOf(error)
|
|
110
|
+
// 401/403 no simulador quase sempre é a aba sem sessão: `sessionStorage` é por aba, e link com
|
|
111
|
+
// `rel="noreferrer"` abre contexto novo que não herda o token do painel.
|
|
112
|
+
if (status === 401 || status === 403) {
|
|
113
|
+
return 'Sem sessão de administrador nesta aba: a mensagem é entregue no webhook, mas o transcript não pode ser lido. Entre no painel nesta mesma aba e reabra o simulador.'
|
|
114
|
+
}
|
|
115
|
+
if (error instanceof Error && error.message) return `Não foi possível ler o transcript: ${error.message}`
|
|
116
|
+
return 'Não foi possível ler o transcript da conversa.'
|
|
117
|
+
}
|
|
118
|
+
|
|
58
119
|
export function ConversationPreview({
|
|
59
120
|
client,
|
|
60
121
|
sse,
|
|
61
122
|
conversationId,
|
|
62
123
|
loadMessages,
|
|
63
124
|
placeholder,
|
|
125
|
+
pollIntervalMs,
|
|
126
|
+
uploadMedia,
|
|
64
127
|
}: ConversationPreviewProps) {
|
|
65
128
|
const [messages, setMessages] = useState<MessagePayload[]>([])
|
|
66
129
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
130
|
+
const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
|
|
131
|
+
// Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
|
|
132
|
+
// ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
|
|
133
|
+
// indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
|
|
134
|
+
// tela é o servidor, que já tem a mensagem gravada.
|
|
135
|
+
const [pendingLocal, setPendingLocal] = useState<MessagePayload[]>([])
|
|
67
136
|
const loadMessagesRef = useRef(loadMessages)
|
|
68
137
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
69
138
|
loadMessagesRef.current = loadMessages
|
|
70
139
|
|
|
71
140
|
const refresh = useCallback(async (): Promise<void> => {
|
|
72
141
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
142
|
+
const loaded = await loadMessagesRef.current(conversationId)
|
|
143
|
+
setMessages(loaded)
|
|
144
|
+
setLoadFailure(undefined)
|
|
145
|
+
// Só descarta o eco quando o servidor de fato devolveu conversa: lista vazia é "não consegui
|
|
146
|
+
// ver nada" (sessão ausente, primeiro contato), e limpar aí apagava da tela a mensagem que o
|
|
147
|
+
// usuário acabou de mandar — o sintoma que este eco existe para evitar.
|
|
148
|
+
if (loaded.length > 0) setPendingLocal([])
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// Conversa que ainda não existe é o estado normal do primeiro contato — transcript vazio, sem
|
|
151
|
+
// alarme. QUALQUER outra falha precisa aparecer: engolir todas era o que transformava sessão
|
|
152
|
+
// expirada (401) em silêncio absoluto, com a thread limpa e o operador concluindo que o envio
|
|
153
|
+
// não funcionou — quando a mensagem tinha sido entregue no webhook.
|
|
154
|
+
if (isNotFound(error)) {
|
|
155
|
+
setMessages([])
|
|
156
|
+
setLoadFailure(undefined)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Não limpa o que já está na tela: perder o histórico por causa de um refresh que falhou é
|
|
161
|
+
// dano maior que o próprio erro.
|
|
162
|
+
setLoadFailure(describeLoadFailure(error))
|
|
78
163
|
}
|
|
79
164
|
}, [conversationId])
|
|
80
165
|
|
|
@@ -95,19 +180,47 @@ export function ConversationPreview({
|
|
|
95
180
|
}
|
|
96
181
|
}, [sse, conversationId, refresh])
|
|
97
182
|
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!pollIntervalMs) return
|
|
185
|
+
const timer = setInterval(() => void refresh(), pollIntervalMs)
|
|
186
|
+
return () => clearInterval(timer)
|
|
187
|
+
}, [pollIntervalMs, refresh])
|
|
188
|
+
|
|
98
189
|
useEffect(() => {
|
|
99
190
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
100
191
|
}, [messages])
|
|
101
192
|
|
|
102
|
-
const rendered = useMemo(() => decorate(messages), [messages])
|
|
193
|
+
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
|
|
194
|
+
|
|
195
|
+
async function refreshWithFollowUps(): Promise<void> {
|
|
196
|
+
await refresh()
|
|
197
|
+
for (const atraso of FOLLOW_UP_REFRESH_MS) {
|
|
198
|
+
setTimeout(() => void refresh(), atraso)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
103
201
|
|
|
104
202
|
async function handleSend(text: string): Promise<void> {
|
|
105
203
|
setFailure(undefined)
|
|
106
204
|
try {
|
|
107
205
|
await client.sendText(text)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
206
|
+
setPendingLocal((current) => [
|
|
207
|
+
...current,
|
|
208
|
+
{
|
|
209
|
+
id: `local-${current.length}-${text.length}`,
|
|
210
|
+
type: 'text',
|
|
211
|
+
content: text,
|
|
212
|
+
// Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
|
|
213
|
+
// como "minha" nesta visão.
|
|
214
|
+
direction: 'inbound',
|
|
215
|
+
sender: 'customer',
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
status: 'sent',
|
|
218
|
+
},
|
|
219
|
+
])
|
|
220
|
+
// O bot responde de forma assíncrona: gravar a resposta leva algumas centenas de ms depois do
|
|
221
|
+
// 200 do webhook. Um refresh único aqui frequentemente chegava ANTES dela, e a conversa ficava
|
|
222
|
+
// com a pergunta sem resposta até o envio seguinte.
|
|
223
|
+
await refreshWithFollowUps()
|
|
111
224
|
} catch (error) {
|
|
112
225
|
// A recusa mais provável é assinatura inválida (segredo divergente do que a API valida), e
|
|
113
226
|
// ela precisa aparecer na tela: silenciada, o sintoma vira "mandei e não aconteceu nada".
|
|
@@ -115,6 +228,37 @@ export function ConversationPreview({
|
|
|
115
228
|
}
|
|
116
229
|
}
|
|
117
230
|
|
|
231
|
+
async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
|
|
232
|
+
setFailure(undefined)
|
|
233
|
+
const reply = { id: selection.option.id, title: selection.option.title }
|
|
234
|
+
try {
|
|
235
|
+
// Botão e lista são payloads diferentes para a Meta (`button_reply` × `list_reply`), e o
|
|
236
|
+
// roteador do fluxo lê campos distintos: tratar os dois como um só faria o menu responder no
|
|
237
|
+
// simulador e falhar no aparelho do cliente.
|
|
238
|
+
await (selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply))
|
|
239
|
+
await refreshWithFollowUps()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function handleAttach(file: File): Promise<void> {
|
|
246
|
+
if (!uploadMedia) return
|
|
247
|
+
setFailure(undefined)
|
|
248
|
+
try {
|
|
249
|
+
const uploaded = await uploadMedia(file)
|
|
250
|
+
await client.sendMedia({
|
|
251
|
+
mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
|
|
252
|
+
mediaId: uploaded.mediaId,
|
|
253
|
+
mimeType: uploaded.mimeType ?? file.type,
|
|
254
|
+
filename: uploaded.filename ?? file.name,
|
|
255
|
+
})
|
|
256
|
+
await refreshWithFollowUps()
|
|
257
|
+
} catch (error) {
|
|
258
|
+
setFailure(error instanceof Error ? error.message : 'Falha ao enviar o arquivo.')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
118
262
|
return (
|
|
119
263
|
<div className="flex h-full min-h-0 flex-col">
|
|
120
264
|
<ConversationWallpaper className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
|
@@ -127,6 +271,11 @@ export function ConversationPreview({
|
|
|
127
271
|
// vista do servidor.
|
|
128
272
|
isMine={message.direction === 'inbound'}
|
|
129
273
|
isFirstInGroup={isFirstInGroup}
|
|
274
|
+
// Só o que o bot ofereceu é tocável: reoferecer as opções da própria mensagem do
|
|
275
|
+
// cliente deixaria o menu clicável para sempre, o que o WhatsApp não faz.
|
|
276
|
+
onInteractiveSelect={
|
|
277
|
+
message.direction === 'outbound' ? (selection) => void handleInteractiveSelect(selection) : undefined
|
|
278
|
+
}
|
|
130
279
|
/>
|
|
131
280
|
</div>
|
|
132
281
|
))}
|
|
@@ -139,10 +288,30 @@ export function ConversationPreview({
|
|
|
139
288
|
</p>
|
|
140
289
|
) : null}
|
|
141
290
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
291
|
+
{/* Separado da falha de ENVIO: são causas diferentes e confundi-las manda o operador
|
|
292
|
+
investigar assinatura de webhook quando o problema é sessão. Amarelo, não vermelho — a
|
|
293
|
+
mensagem foi entregue; o que faltou foi poder ler a conversa de volta. */}
|
|
294
|
+
{loadFailure ? (
|
|
295
|
+
<p role="status" className="px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
|
|
296
|
+
{loadFailure}
|
|
297
|
+
</p>
|
|
298
|
+
) : null}
|
|
299
|
+
|
|
300
|
+
<div className="flex items-end gap-1">
|
|
301
|
+
<div className="min-w-0 flex-1">
|
|
302
|
+
<MessageComposer
|
|
303
|
+
onSend={(text) => void handleSend(text)}
|
|
304
|
+
onAttach={uploadMedia ? (file) => void handleAttach(file) : undefined}
|
|
305
|
+
placeholder={placeholder ?? 'Escreva como o cliente…'}
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
{uploadMedia ? (
|
|
309
|
+
<AudioRecorderButton
|
|
310
|
+
onRecorded={(file) => void handleAttach(file)}
|
|
311
|
+
onFailure={(message) => setFailure(message)}
|
|
312
|
+
/>
|
|
313
|
+
) : null}
|
|
314
|
+
</div>
|
|
146
315
|
</div>
|
|
147
316
|
)
|
|
148
317
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bancada de teste manual de mídia — montável em uma linha por qualquer projeto que adote o SDK.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque o defeito que ela pega não é pegável por teste automatizado: "o PDF abre?" depende
|
|
5
|
+
* do leitor do navegador, "o vídeo toca?" do decodificador, e "a aba abre?" da política do Chrome
|
|
6
|
+
* sobre `data:` URL. Teste unitário confere bytes; só o olho confere que o arquivo abre. Sem uma
|
|
7
|
+
* superfície pronta no pacote, cada projeto teria de montar a sua — e, na prática, nenhum montava.
|
|
8
|
+
*
|
|
9
|
+
* Traz o próprio store, o próprio mock e o próprio provider: o host não injeta nada. E não passa
|
|
10
|
+
* `onResolveMediaUrl` em lugar nenhum de propósito — é o `MessageBubble` resolvendo mídia pelo
|
|
11
|
+
* `ConversationsApi` do contexto, então se essa resolução automática quebrar, esta tela mostra.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useMemo } from 'react'
|
|
15
|
+
import { ConversationsProvider } from '../providers/ConversationsProvider'
|
|
16
|
+
import { DocumentsLibrary } from '../DocumentsLibrary'
|
|
17
|
+
import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
|
|
18
|
+
import { MessageBubble } from '../MessageBubble'
|
|
19
|
+
import { ConversationWallpaper } from '../Wallpaper'
|
|
20
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
21
|
+
import { createMockSSEProvider } from './createMockSSEProvider'
|
|
22
|
+
import { createPreviewStore } from './previewStore'
|
|
23
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
24
|
+
|
|
25
|
+
/** A conversa do fixture que carrega uma mensagem de cada tipo aceito. */
|
|
26
|
+
export const MEDIA_TYPES_CONVERSATION_ID = '5511944443333'
|
|
27
|
+
|
|
28
|
+
export type MediaTypesPreviewProps = {
|
|
29
|
+
/** Outra conversa do fixture, se o projeto tiver acrescentado a sua. */
|
|
30
|
+
conversationId?: string
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function MediaTypesPreview({
|
|
35
|
+
conversationId = MEDIA_TYPES_CONVERSATION_ID,
|
|
36
|
+
className,
|
|
37
|
+
}: MediaTypesPreviewProps) {
|
|
38
|
+
const store = useMemo(
|
|
39
|
+
() => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
|
|
40
|
+
[],
|
|
41
|
+
)
|
|
42
|
+
const api = useMemo(() => createMockConversationsApi({ store }), [store])
|
|
43
|
+
const sse = useMemo(() => createMockSSEProvider({ store }), [store])
|
|
44
|
+
|
|
45
|
+
const messages = PREVIEW_MESSAGES[conversationId] ?? []
|
|
46
|
+
const documents = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
47
|
+
const mimeTypes = [...new Set(documents.map((document) => document.mimeType))]
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ConversationsProvider api={api} sse={sse}>
|
|
51
|
+
<div className={className}>
|
|
52
|
+
<header className="border-b px-4 py-3 dark:border-gray-700">
|
|
53
|
+
<h1 className="text-lg font-semibold">Teste manual de mídia</h1>
|
|
54
|
+
<p className="text-sm text-gray-500">
|
|
55
|
+
{documents.length} arquivos, {mimeTypes.length} tipos. Clique no olho para abrir em aba nova e no
|
|
56
|
+
botão da bolha para carregar a mídia na thread — é o que teste automatizado não vê.
|
|
57
|
+
</p>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
61
|
+
<section className="space-y-3">
|
|
62
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Biblioteca da empresa</h2>
|
|
63
|
+
{/* Sem paginar: a bancada serve para ver TODOS os tipos de uma vez. */}
|
|
64
|
+
<DocumentsLibrary perPage={documents.length || 20} />
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section className="space-y-3">
|
|
68
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Painel da conversa</h2>
|
|
69
|
+
<ConversationDocumentsPanel conversationId={conversationId} open perPage={documents.length || 20} />
|
|
70
|
+
|
|
71
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Bolhas na thread</h2>
|
|
72
|
+
<ConversationWallpaper className="max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2">
|
|
73
|
+
{messages.map((message, index) => (
|
|
74
|
+
<MessageBubble
|
|
75
|
+
key={message.id}
|
|
76
|
+
message={message}
|
|
77
|
+
isMine={message.direction === 'outbound'}
|
|
78
|
+
isFirstInGroup={index === 0 || messages[index - 1]?.sender !== message.sender}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</ConversationWallpaper>
|
|
82
|
+
</section>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</ConversationsProvider>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveRecordingFormat } from './AudioRecorderButton'
|
|
4
|
+
import { DEFAULT_ACCEPTED_FILE_TYPES } from '../MessageComposer'
|
|
5
|
+
|
|
6
|
+
const originalMediaRecorder = (globalThis as Record<string, unknown>).MediaRecorder
|
|
7
|
+
|
|
8
|
+
function stubMediaRecorder(supported: readonly string[] | undefined): void {
|
|
9
|
+
const stub = supported ? { isTypeSupported: (mimeType: string) => supported.includes(mimeType) } : {}
|
|
10
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = stub
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = originalMediaRecorder
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('resolveRecordingFormat', () => {
|
|
18
|
+
it('prefere ogg/opus, que o WhatsApp aceita, sobre webm', () => {
|
|
19
|
+
stubMediaRecorder(['audio/ogg;codecs=opus', 'audio/webm'])
|
|
20
|
+
|
|
21
|
+
expect(resolveRecordingFormat()?.uploadMimeType).toBe('audio/ogg')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('cai para mp4 no navegador que não grava ogg', () => {
|
|
25
|
+
stubMediaRecorder(['audio/mp4', 'audio/webm'])
|
|
26
|
+
|
|
27
|
+
const format = resolveRecordingFormat()
|
|
28
|
+
expect(format?.uploadMimeType).toBe('audio/mp4')
|
|
29
|
+
expect(format?.extension).toBe('m4a')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('devolve indefinido quando o navegador não grava nenhum formato', () => {
|
|
33
|
+
stubMediaRecorder([])
|
|
34
|
+
|
|
35
|
+
expect(resolveRecordingFormat()).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('devolve indefinido sem MediaRecorder no ambiente', () => {
|
|
39
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = undefined
|
|
40
|
+
|
|
41
|
+
expect(resolveRecordingFormat()).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('DEFAULT_ACCEPTED_FILE_TYPES', () => {
|
|
46
|
+
it('cobre Word, Excel e PowerPoint nos dois formatos, legado e OpenXML', () => {
|
|
47
|
+
const accepted = DEFAULT_ACCEPTED_FILE_TYPES.split(',')
|
|
48
|
+
|
|
49
|
+
expect(accepted).toContain('application/msword')
|
|
50
|
+
expect(accepted).toContain('application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
|
51
|
+
expect(accepted).toContain('application/vnd.ms-excel')
|
|
52
|
+
expect(accepted).toContain('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
53
|
+
expect(accepted).toContain('application/vnd.ms-powerpoint')
|
|
54
|
+
expect(accepted).toContain('application/vnd.openxmlformats-officedocument.presentationml.presentation')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('cobre texto, PDF, áudio e vídeo, e não oferece formato que o WhatsApp recusa', () => {
|
|
58
|
+
const accepted = DEFAULT_ACCEPTED_FILE_TYPES.split(',')
|
|
59
|
+
|
|
60
|
+
expect(accepted).toContain('application/pdf')
|
|
61
|
+
expect(accepted).toContain('text/plain')
|
|
62
|
+
expect(accepted).toContain('audio/ogg')
|
|
63
|
+
expect(accepted).toContain('video/mp4')
|
|
64
|
+
expect(DEFAULT_ACCEPTED_FILE_TYPES).not.toContain('.zip')
|
|
65
|
+
expect(DEFAULT_ACCEPTED_FILE_TYPES).not.toContain('.rtf')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a classificação de falha do simulador do cliente.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou o arquivo: o `refresh` tratava QUALQUER erro ao ler o transcript como
|
|
5
|
+
* "conversa ainda não existe", mostrava thread vazia e não dizia nada. Com sessão ausente (401) o
|
|
6
|
+
* sintoma era o pior possível — a mensagem ia para o webhook, era aceita, e a tela ficava igual.
|
|
7
|
+
* Quem olhava concluía que o envio estava quebrado.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
|
|
12
|
+
import { describeLoadFailure, isNotFound } from './ConversationPreview'
|
|
13
|
+
|
|
14
|
+
class HttpError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
readonly status: number,
|
|
18
|
+
) {
|
|
19
|
+
super(message)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('isNotFound', () => {
|
|
24
|
+
// Primeiro contato: a conversa não existe e transcript vazio é o estado correto, sem alarme.
|
|
25
|
+
it('reconhece 404 como conversa inexistente', () => {
|
|
26
|
+
expect(isNotFound(new HttpError('Conversa não encontrada', 404))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('não confunde 401 com conversa inexistente', () => {
|
|
30
|
+
expect(isNotFound(new HttpError('Unauthorized', 401))).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('erro sem status não é tratado como inexistente', () => {
|
|
34
|
+
expect(isNotFound(new Error('Failed to fetch'))).toBe(false)
|
|
35
|
+
expect(isNotFound(undefined)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Aceita `statusCode` também: nem todo host nomeia o campo igual.
|
|
39
|
+
it('lê statusCode quando é esse o nome do campo', () => {
|
|
40
|
+
expect(isNotFound({ statusCode: 404 })).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('describeLoadFailure', () => {
|
|
45
|
+
it('explica que falta sessão e que a mensagem FOI entregue', () => {
|
|
46
|
+
const mensagem = describeLoadFailure(new HttpError('Unauthorized', 401))
|
|
47
|
+
|
|
48
|
+
expect(mensagem).toContain('Sem sessão')
|
|
49
|
+
// O ponto central: não deixar o operador achar que o envio falhou.
|
|
50
|
+
expect(mensagem).toContain('entregue no webhook')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('trata 403 igual a 401', () => {
|
|
54
|
+
expect(describeLoadFailure(new HttpError('Forbidden', 403))).toContain('Sem sessão')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('preserva a mensagem do host em falha genérica', () => {
|
|
58
|
+
expect(describeLoadFailure(new HttpError('API fora do ar', 500))).toContain('API fora do ar')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('tem texto para erro sem mensagem', () => {
|
|
62
|
+
expect(describeLoadFailure({}).length).toBeGreaterThan(0)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { MessagePayload } from '../types'
|
|
11
11
|
import type {
|
|
12
|
+
CompanyDocumentPage,
|
|
12
13
|
ConversationDocumentPage,
|
|
13
14
|
ConversationPage,
|
|
14
15
|
ConversationTemplate,
|
|
@@ -16,12 +17,9 @@ import type {
|
|
|
16
17
|
ListConversationsParams,
|
|
17
18
|
} from '../providers/types'
|
|
18
19
|
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
20
|
+
import { previewFileBase64, previewFileUrl } from './previewMediaSource'
|
|
19
21
|
import type { PreviewStore } from './previewStore'
|
|
20
22
|
|
|
21
|
-
// PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
|
|
22
|
-
const PREVIEW_IMAGE_BASE64 =
|
|
23
|
-
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
|
|
24
|
-
|
|
25
23
|
export type CreateMockConversationsApiParams = {
|
|
26
24
|
readonly store: PreviewStore
|
|
27
25
|
readonly latencyMs?: number
|
|
@@ -178,12 +176,68 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
178
176
|
})
|
|
179
177
|
},
|
|
180
178
|
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
})
|
|
183
217
|
},
|
|
184
218
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
})
|
|
187
241
|
},
|
|
188
242
|
|
|
189
243
|
takeover(conversationId): Promise<void> {
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
import {
|
|
15
15
|
buildInboundAudioPayload,
|
|
16
16
|
buildInboundInteractivePayload,
|
|
17
|
+
buildInboundMediaPayload,
|
|
17
18
|
buildInboundTextPayload,
|
|
18
19
|
serializeWebhookPayload,
|
|
20
|
+
type InboundMediaType,
|
|
19
21
|
type InteractiveReplyOption,
|
|
20
22
|
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
21
23
|
|
|
@@ -24,6 +26,20 @@ export type PreviewWebhookClient = {
|
|
|
24
26
|
sendButtonReply(reply: InteractiveReplyOption): Promise<void>
|
|
25
27
|
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
26
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
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
export type CreatePreviewWebhookClientParams = {
|
|
@@ -57,7 +73,15 @@ export function assertPreviewEnvironment(isProduction: boolean): void {
|
|
|
57
73
|
if (isProduction) throw new PreviewInProductionError()
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
|
|
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> {
|
|
61
85
|
const encoder = new TextEncoder()
|
|
62
86
|
const key = await globalThis.crypto.subtle.importKey(
|
|
63
87
|
'raw',
|
|
@@ -71,6 +95,8 @@ async function signWithWebCrypto(params: { rawBody: string; appSecret: string })
|
|
|
71
95
|
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
72
96
|
}
|
|
73
97
|
|
|
98
|
+
const signWithWebCrypto = signPreviewPayload
|
|
99
|
+
|
|
74
100
|
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
75
101
|
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
76
102
|
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
@@ -95,5 +121,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
95
121
|
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
96
122
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
97
123
|
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
124
|
+
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
98
125
|
}
|
|
99
126
|
}
|