@adatechnology/conversations-ui 0.1.0-rc.6 → 0.1.0-rc.8

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.
@@ -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
- setMessages(await loadMessagesRef.current(conversationId))
74
- } catch {
75
- // Conversa que ainda não existe é o estado normal do primeiro contato — a API responde erro
76
- // e a tela deve mostrar o transcript vazio, não uma falha.
77
- setMessages([])
142
+ const loaded = await loadMessagesRef.current(conversationId)
143
+ setMessages(loaded)
144
+ setLoadFailure(undefined)
145
+ // 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
- // O bot responde de forma assíncrona e o ping do SSE cobre isso; este refresh é para a
109
- // própria mensagem enviada aparecer na hora, sem esperar o ciclo de evento.
110
- await refresh()
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
- <MessageComposer
143
- onSend={(text) => void handleSend(text)}
144
- placeholder={placeholder ?? 'Escreva como o cliente…'}
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,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
+ })
@@ -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
- async function signWithWebCrypto(params: { rawBody: string; appSecret: string }): Promise<string> {
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
  }
@@ -26,15 +26,24 @@ export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
26
26
  export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
27
27
 
28
28
  export { ConversationPreview } from './ConversationPreview'
29
- export type { ConversationPreviewProps } 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'
30
34
 
31
35
  export {
32
36
  createPreviewWebhookClient,
37
+ signPreviewPayload,
33
38
  assertPreviewEnvironment,
34
39
  PreviewInProductionError,
35
40
  PreviewWebhookRejectedError,
36
41
  } from './createPreviewWebhookClient'
37
- export type { PreviewWebhookClient, CreatePreviewWebhookClientParams } from './createPreviewWebhookClient'
42
+ export type {
43
+ PreviewWebhookClient,
44
+ CreatePreviewWebhookClientParams,
45
+ SendPreviewMediaParams,
46
+ } from './createPreviewWebhookClient'
38
47
 
39
48
  export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
40
49
  export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
@@ -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
+ })
package/src/types.ts CHANGED
@@ -20,9 +20,46 @@ export interface ConversationsFeatures {
20
20
  darkMode?: boolean
21
21
  }
22
22
 
23
+ /**
24
+ * Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
25
+ * espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
26
+ * WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
27
+ */
28
+ export interface InteractiveOption {
29
+ id: string
30
+ title: string
31
+ description?: string
32
+ }
33
+
34
+ export interface InteractiveSection {
35
+ title?: string
36
+ rows?: InteractiveOption[]
37
+ }
38
+
39
+ export interface InteractivePayload {
40
+ type?: 'button' | 'list' | string
41
+ header?: { text?: string }
42
+ body?: { text?: string }
43
+ footer?: { text?: string }
44
+ action?: {
45
+ /** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
46
+ button?: string
47
+ sections?: InteractiveSection[]
48
+ buttons?: { reply?: InteractiveOption }[]
49
+ }
50
+ }
51
+
52
+ /** Como o cliente respondeu a um menu: por botão ou por item de lista. */
53
+ export type InteractiveSelection = {
54
+ readonly kind: 'button' | 'list'
55
+ readonly option: InteractiveOption
56
+ }
57
+
23
58
  export interface MessagePayload {
24
59
  id: string
25
- type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template'
60
+ type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive'
61
+ /** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
62
+ payload?: InteractivePayload | null
26
63
  content?: string
27
64
  caption?: string
28
65
  mediaUrl?: string