@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.30

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.
Files changed (143) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/chunk-GY472G6E.js +2316 -0
  3. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  4. package/dist/flows/index.d.ts +311 -4
  5. package/dist/flows/index.js +1322 -55
  6. package/dist/index.d.ts +1074 -42
  7. package/dist/index.js +3571 -742
  8. package/dist/preview/index.d.ts +328 -8
  9. package/dist/preview/index.js +902 -115
  10. package/dist/styles.css +819 -0
  11. package/dist/types-De5aN-E_.d.ts +502 -0
  12. package/package.json +3 -3
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +42 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DocumentsLibrary.tsx +382 -0
  27. package/src/EmojiPicker.tsx +70 -55
  28. package/src/FileIcon.test.ts +83 -0
  29. package/src/FileIcon.tsx +88 -11
  30. package/src/InteractiveMessage.test.tsx +41 -0
  31. package/src/InteractiveMessage.tsx +146 -0
  32. package/src/Lightbox.tsx +18 -3
  33. package/src/MediaRenderer.tsx +98 -22
  34. package/src/MessageBubble.test.tsx +41 -0
  35. package/src/MessageBubble.tsx +75 -6
  36. package/src/MessageComposer.test.tsx +35 -0
  37. package/src/MessageComposer.tsx +155 -19
  38. package/src/RichMessageComposer.test.tsx +113 -0
  39. package/src/RichMessageComposer.tsx +551 -0
  40. package/src/SimpleEmojiPicker.tsx +5 -3
  41. package/src/StatusTicks.tsx +1 -1
  42. package/src/Toast.tsx +4 -0
  43. package/src/Tooltip.test.ts +42 -0
  44. package/src/Tooltip.tsx +164 -0
  45. package/src/Wallpaper.test.tsx +21 -0
  46. package/src/Wallpaper.tsx +67 -7
  47. package/src/WhatsAppMessageEditor.tsx +28 -4
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/audioRecorderFormat.test.ts +67 -0
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +543 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/emojiCatalog.test.ts +35 -0
  60. package/src/emojiCatalog.ts +189 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +35 -8
  65. package/src/flows/FlowNodePanel.tsx +149 -38
  66. package/src/flows/FlowPalette.tsx +13 -3
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1003 -0
  70. package/src/flows/flowCanvasModel.test.ts +293 -0
  71. package/src/flows/flowCanvasModel.ts +342 -0
  72. package/src/flows/flowEditorOps.test.ts +241 -0
  73. package/src/flows/flowEditorOps.ts +177 -0
  74. package/src/flows/flowGraph.ts +6 -6
  75. package/src/flows/index.ts +40 -1
  76. package/src/flows/labels.ts +141 -0
  77. package/src/flows/workspaceContract.test.ts +95 -0
  78. package/src/hooks/useContainerWidth.ts +35 -0
  79. package/src/hooks/useConversationActions.ts +56 -0
  80. package/src/hooks/useConversationDocuments.ts +11 -7
  81. package/src/hooks/useConversationList.ts +15 -9
  82. package/src/hooks/useConversationMessages.ts +2 -2
  83. package/src/hooks/useConversationRealtime.ts +10 -8
  84. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  85. package/src/hooks/useUrlFilterState.ts +107 -0
  86. package/src/icon.constant.ts +12 -0
  87. package/src/index.ts +114 -13
  88. package/src/lib/cn.test.ts +29 -0
  89. package/src/lib/composer-formatting.test.ts +78 -0
  90. package/src/lib/composer-formatting.ts +145 -0
  91. package/src/lib/createMediaUrlResolver.ts +33 -0
  92. package/src/lib/paginated.test.ts +33 -0
  93. package/src/lib/paginated.ts +26 -0
  94. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  95. package/src/lib/whatsapp-formatting.tsx +28 -3
  96. package/src/listing/index.tsx +202 -0
  97. package/src/pagination.constant.ts +10 -0
  98. package/src/preview/ConversationPreview.tsx +199 -13
  99. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  100. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  101. package/src/preview/MediaTypesPreview.tsx +87 -0
  102. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  103. package/src/preview/createMockConversationsApi.ts +175 -15
  104. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  105. package/src/preview/createPreviewBridgeClient.ts +124 -0
  106. package/src/preview/createPreviewMediaUploader.ts +82 -0
  107. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  108. package/src/preview/createPreviewWebhookClient.ts +127 -4
  109. package/src/preview/index.ts +33 -3
  110. package/src/preview/mediaTypeOf.test.ts +15 -0
  111. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  112. package/src/preview/preview.test.ts +5 -3
  113. package/src/preview/previewFileSamples.test.ts +151 -0
  114. package/src/preview/previewFileSamples.ts +74 -0
  115. package/src/preview/previewFixtures.ts +288 -1
  116. package/src/preview/previewMediaSource.test.ts +62 -0
  117. package/src/preview/previewMediaSource.ts +91 -0
  118. package/src/preview/previewMediaUploader.test.ts +61 -0
  119. package/src/providers/ConversationsProvider.tsx +8 -6
  120. package/src/providers/types.ts +185 -10
  121. package/src/quickReply.test.ts +58 -0
  122. package/src/settings/MessagesWorkspace.tsx +468 -0
  123. package/src/settings/TopicsForm.tsx +2 -0
  124. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  125. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  126. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  127. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  128. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  129. package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
  130. package/src/styles.css +783 -0
  131. package/src/types.ts +64 -1
  132. package/src/useWaitingNotifications.ts +74 -29
  133. package/src/workspace/BulkTemplateModal.tsx +132 -0
  134. package/src/workspace/ConversationPane.tsx +432 -0
  135. package/src/workspace/ConversationsInboxList.tsx +194 -0
  136. package/src/workspace/ConversationsWorkspace.tsx +346 -0
  137. package/src/workspace/index.ts +12 -0
  138. package/src/workspace/labels.test.ts +17 -0
  139. package/src/workspace/labels.ts +85 -0
  140. package/src/workspace/useConversationsInbox.ts +332 -0
  141. package/dist/chunk-N7B24WYD.js +0 -719
  142. package/dist/chunk-NV2RZ5KT.js +0 -56
  143. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -8,13 +8,18 @@
8
8
  */
9
9
 
10
10
  import type { MessagePayload } from '../types'
11
- import type { ConversationDocument, ConversationsApi, ConversationSummary } from '../providers/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'
12
21
  import type { PreviewStore } from './previewStore'
13
22
 
14
- // PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
15
- const PREVIEW_IMAGE_BASE64 =
16
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
17
-
18
23
  export type CreateMockConversationsApiParams = {
19
24
  readonly store: PreviewStore
20
25
  readonly latencyMs?: number
@@ -23,7 +28,24 @@ export type CreateMockConversationsApiParams = {
23
28
 
24
29
  const DEFAULT_LATENCY_MS = 120
25
30
 
26
- export function createMockConversationsApi(params: CreateMockConversationsApiParams): ConversationsApi {
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 {
27
49
  const latencyMs = params.latencyMs ?? DEFAULT_LATENCY_MS
28
50
 
29
51
  async function withLatency<TResult>(produce: () => TResult): Promise<TResult> {
@@ -32,7 +54,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
32
54
  }
33
55
 
34
56
  return {
35
- fetchConversations(fetchParams): Promise<ConversationSummary[]> {
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> {
36
61
  return withLatency(() => {
37
62
  const conversations = params.store.listConversations({
38
63
  waitingHuman: fetchParams?.waitingHuman,
@@ -41,7 +66,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
41
66
 
42
67
  const limit = fetchParams?.limit ?? conversations.length
43
68
  const page = fetchParams?.page ?? 1
44
- return conversations.slice((page - 1) * limit, page * limit)
69
+ return {
70
+ conversations: conversations.slice((page - 1) * limit, page * limit),
71
+ total: conversations.length,
72
+ }
45
73
  })
46
74
  },
47
75
 
@@ -74,7 +102,9 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
74
102
  return withLatency(() => {
75
103
  params.store.appendMessage({
76
104
  conversationId,
77
- content: `[template] ${data.templateName}`,
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'}`,
78
108
  direction: 'outbound',
79
109
  sender: 'agent',
80
110
  })
@@ -96,16 +126,146 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
96
126
  })
97
127
  },
98
128
 
99
- getDocuments(): Promise<ConversationDocument[]> {
100
- return withLatency(() => [])
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
+ })
101
177
  },
102
178
 
103
- getDocumentUrl(): Promise<string> {
104
- return withLatency(() => `data:image/png;base64,${PREVIEW_IMAGE_BASE64}`)
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
+ })
105
265
  },
106
266
 
107
- getMediaProxyUrl(): Promise<{ mimeType: string; data: string }> {
108
- return withLatency(() => ({ mimeType: 'image/png', data: PREVIEW_IMAGE_BASE64 }))
267
+ listTemplates(): Promise<ConversationTemplate[]> {
268
+ return withLatency(() => [...PREVIEW_TEMPLATES])
109
269
  },
110
270
  }
111
271
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * O que estes testes protegem é a propriedade de segurança da ponte: nenhum caminho pode voltar a
3
+ * exigir segredo no navegador, e o corpo enviado tem que ser a INTENÇÃO — se um refactor passar a
4
+ * mandar payload da Meta montado no cliente, a rota do host vira injetor de webhook arbitrário.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+
9
+ import { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
10
+ import type { PreviewInboundCommand } from './createPreviewBridgeClient'
11
+
12
+ const FROM = '5511999999999'
13
+
14
+ function createRecordingClient() {
15
+ const commands: PreviewInboundCommand[] = []
16
+ const client = createPreviewBridgeClient({
17
+ from: FROM,
18
+ sendCommand: async (command) => {
19
+ commands.push(command)
20
+ },
21
+ })
22
+ return { client, commands }
23
+ }
24
+
25
+ describe('createPreviewBridgeClient', () => {
26
+ it('entrega a intenção do cliente, carimbando o remetente em cada comando', async () => {
27
+ const { client, commands } = createRecordingClient()
28
+
29
+ await client.sendText('quero simular')
30
+ await client.sendButtonReply({ id: 'hab_pronto', title: 'Imóvel pronto' })
31
+ await client.sendListReply({ id: 'faixa_2', title: 'Faixa 2' })
32
+ await client.sendAudio('media-1')
33
+ await client.sendMedia({ mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' })
34
+
35
+ expect(commands).toEqual([
36
+ { kind: 'text', from: FROM, text: 'quero simular' },
37
+ { kind: 'buttonReply', from: FROM, reply: { id: 'hab_pronto', title: 'Imóvel pronto' } },
38
+ { kind: 'listReply', from: FROM, reply: { id: 'faixa_2', title: 'Faixa 2' } },
39
+ { kind: 'audio', from: FROM, mediaId: 'media-1' },
40
+ { kind: 'media', from: FROM, mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' },
41
+ ])
42
+ })
43
+
44
+ it('nunca embute assinatura nem segredo no que sai do navegador', async () => {
45
+ const { client, commands } = createRecordingClient()
46
+
47
+ await client.sendText('oi')
48
+
49
+ const serialized = JSON.stringify(commands[0])
50
+ expect(serialized).not.toMatch(/sha256=/)
51
+ expect(serialized).not.toMatch(/secret/i)
52
+ expect(commands[0]).not.toHaveProperty('entry')
53
+ })
54
+
55
+ it('posta no endpoint do host com os headers de sessão que o host injeta', async () => {
56
+ const calls: Array<{ url: string; init: RequestInit }> = []
57
+ const client = createPreviewBridgeClient({
58
+ from: FROM,
59
+ endpointUrl: 'https://host.test/api/conversations/preview/inbound',
60
+ headers: { authorization: 'Bearer token-do-painel' },
61
+ fetchImplementation: (async (url: string, init: RequestInit) => {
62
+ calls.push({ url, init })
63
+ return { ok: true } as Response
64
+ }) as unknown as typeof fetch,
65
+ })
66
+
67
+ await client.sendText('oi')
68
+
69
+ expect(calls[0]?.url).toBe('https://host.test/api/conversations/preview/inbound')
70
+ expect(calls[0]?.init.method).toBe('POST')
71
+ expect(calls[0]?.init.headers).toMatchObject({
72
+ 'content-type': 'application/json',
73
+ authorization: 'Bearer token-do-painel',
74
+ })
75
+ expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ kind: 'text', from: FROM, text: 'oi' })
76
+ })
77
+
78
+ it('converte recusa do host em erro tipado, para o painel poder mostrar o motivo', async () => {
79
+ const client = createPreviewBridgeClient({
80
+ from: FROM,
81
+ endpointUrl: 'https://host.test/preview',
82
+ fetchImplementation: (async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch,
83
+ })
84
+
85
+ await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewBridgeRejectedError)
86
+ await expect(client.sendText('oi')).rejects.toThrow(/403/)
87
+ })
88
+
89
+ it('recusa configuração sem forma de entregar, em vez de falhar só no primeiro envio', () => {
90
+ expect(() => createPreviewBridgeClient({ from: FROM })).toThrow(/sendCommand.*endpointUrl/)
91
+ })
92
+ })
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
3
+ * navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
4
+ * autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
5
+ * app secret que nunca sai de lá.
6
+ *
7
+ * Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
8
+ * app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
9
+ * acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
10
+ * válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
11
+ * Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
12
+ * servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
13
+ *
14
+ * O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
15
+ * porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
16
+ */
17
+
18
+ import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
19
+ import {
20
+ createPreviewMediaPoster,
21
+ defaultMediaUploadUrl,
22
+ type PreviewWebhookClient,
23
+ type SendPreviewMediaParams,
24
+ } from './createPreviewWebhookClient'
25
+ import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
26
+
27
+ /**
28
+ * Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
29
+ * se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
30
+ * quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
31
+ */
32
+ export type PreviewInboundCommand =
33
+ | { readonly kind: 'text'; readonly from: string; readonly text: string }
34
+ | { readonly kind: 'buttonReply'; readonly from: string; readonly reply: InteractiveReplyOption }
35
+ | { readonly kind: 'listReply'; readonly from: string; readonly reply: InteractiveReplyOption }
36
+ | { readonly kind: 'audio'; readonly from: string; readonly mediaId: string }
37
+ | ({ readonly kind: 'media'; readonly from: string } & SendPreviewMediaParams)
38
+
39
+ export type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>
40
+
41
+ export class PreviewBridgeRejectedError extends Error {
42
+ constructor(readonly status: number) {
43
+ super(`A rota de preview do host recusou a entrega (HTTP ${status}).`)
44
+ this.name = 'PreviewBridgeRejectedError'
45
+ }
46
+ }
47
+
48
+ export type CreatePreviewBridgeClientParams = {
49
+ readonly from: string
50
+ /**
51
+ * Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
52
+ * de token — reimplementar isso aqui só duplicaria a autenticação do produto.
53
+ */
54
+ readonly sendCommand?: SendPreviewInboundCommand
55
+ /** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
56
+ readonly endpointUrl?: string
57
+ readonly headers?: Readonly<Record<string, string>>
58
+ readonly fetchImplementation?: typeof fetch
59
+ /**
60
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
61
+ *
62
+ * Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
63
+ * e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
64
+ */
65
+ readonly mediaUploadUrl?: string
66
+ /**
67
+ * Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
68
+ * não há origem a derivar, e sem destino o gravador não é desenhado.
69
+ */
70
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
71
+ }
72
+
73
+ function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
74
+ const endpointUrl = params.endpointUrl
75
+ if (!endpointUrl) {
76
+ throw new Error('createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.')
77
+ }
78
+
79
+ return async (command) => {
80
+ const performRequest = params.fetchImplementation ?? fetch
81
+ const response = await performRequest(endpointUrl, {
82
+ method: 'POST',
83
+ // `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
84
+ // bearer não convivem numa escolha default sem quebrar um dos dois.
85
+ headers: { 'content-type': 'application/json', ...params.headers },
86
+ body: JSON.stringify(command),
87
+ })
88
+
89
+ if (!response.ok) throw new PreviewBridgeRejectedError(response.status)
90
+ }
91
+ }
92
+
93
+ /** Só existe quando há para onde mandar: rota explícita, ou origem herdada do `endpointUrl`. */
94
+ function resolveBridgeUpload(
95
+ params: CreatePreviewBridgeClientParams,
96
+ ): ((file: File) => Promise<PreviewUploadedMedia>) | undefined {
97
+ if (params.uploadMedia) return params.uploadMedia
98
+
99
+ const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : undefined)
100
+ if (!url) return undefined
101
+
102
+ return createPreviewMediaPoster({
103
+ url,
104
+ ...(params.headers ? { headers: async () => params.headers ?? {} } : {}),
105
+ ...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
106
+ })
107
+ }
108
+
109
+ export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
110
+ const send = params.sendCommand ?? buildFetchSender(params)
111
+ const from = params.from
112
+ const uploadMedia = resolveBridgeUpload(params)
113
+
114
+ return {
115
+ sendText: (text) => send({ kind: 'text', from, text }),
116
+ sendButtonReply: (reply) => send({ kind: 'buttonReply', from, reply }),
117
+ sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
118
+ sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
119
+ sendMedia: (media) => send({ kind: 'media', from, ...media }),
120
+ ...(uploadMedia ? { uploadMedia } : {}),
121
+ }
122
+ }
123
+
124
+ export type { InboundMediaType }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
3
+ *
4
+ * O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
5
+ * ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
6
+ * lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
7
+ *
8
+ * Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
9
+ * estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
10
+ * a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
11
+ */
12
+
13
+ /**
14
+ * Do `contracts`, que este pacote já consome — não uma cópia.
15
+ *
16
+ * A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
17
+ * duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
18
+ * Contrato compartilhado é o que o `contracts` existe para guardar.
19
+ */
20
+ export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
21
+ import { toPreviewMediaId } from '@adatechnology/meta-whatsapp-contracts'
22
+
23
+ export type PreviewUploadedMedia = {
24
+ readonly mediaId: string
25
+ readonly mimeType?: string
26
+ readonly filename?: string
27
+ }
28
+
29
+ export type PreviewMediaUploadRequest = {
30
+ readonly base64: string
31
+ readonly mimeType: string
32
+ readonly filename: string
33
+ }
34
+
35
+ export type CreatePreviewMediaUploaderParams = {
36
+ /**
37
+ * Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
38
+ *
39
+ * Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
40
+ * HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
41
+ * escolher por elas.
42
+ */
43
+ readonly upload: (request: PreviewMediaUploadRequest) => Promise<{ uploadId: string }>
44
+ /** Nome usado quando o gravador entrega o áudio sem nome próprio. */
45
+ readonly fallbackFilename?: string
46
+ readonly fallbackMimeType?: string
47
+ }
48
+
49
+ /**
50
+ * Converte em blocos, não com `String.fromCharCode(...bytes)` de uma vez.
51
+ *
52
+ * Espalhar centenas de milhares de bytes como argumentos estoura o limite da engine — poucos segundos
53
+ * de áudio já chegam perto. O sintoma seria `RangeError` só nos arquivos grandes: passa no teste com
54
+ * um clipe curto e falha na primeira gravação de verdade.
55
+ */
56
+ const CHUNK_SIZE = 8192
57
+
58
+ async function fileToBase64(file: File): Promise<string> {
59
+ const bytes = new Uint8Array(await file.arrayBuffer())
60
+ let binary = ''
61
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
62
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE))
63
+ }
64
+ return btoa(binary)
65
+ }
66
+
67
+ export function createPreviewMediaUploader(
68
+ params: CreatePreviewMediaUploaderParams,
69
+ ): (file: File) => Promise<PreviewUploadedMedia> {
70
+ const fallbackMimeType = params.fallbackMimeType ?? 'audio/ogg'
71
+ const fallbackFilename = params.fallbackFilename ?? 'audio.ogg'
72
+
73
+ return async function uploadPreviewMedia(file: File): Promise<PreviewUploadedMedia> {
74
+ // Gravação de voz chega sem nome, e sem mime em navegador antigo.
75
+ const mimeType = file.type || fallbackMimeType
76
+ const filename = file.name || fallbackFilename
77
+
78
+ const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename })
79
+
80
+ return { mediaId: toPreviewMediaId(uploadId), mimeType, filename }
81
+ }
82
+ }
@@ -10,8 +10,10 @@ import {
10
10
  assertPreviewEnvironment,
11
11
  createPreviewWebhookClient,
12
12
  PreviewInProductionError,
13
+ PreviewMediaUploadRejectedError,
13
14
  PreviewWebhookRejectedError,
14
15
  } from './createPreviewWebhookClient'
16
+ import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
15
17
 
16
18
  const APP_SECRET = 'dev-app-secret'
17
19
  const FROM = '5511988887777'
@@ -34,6 +36,13 @@ function createCapturingFetch(status = 200): { fetchImplementation: typeof fetch
34
36
  return { fetchImplementation, captured }
35
37
  }
36
38
 
39
+ /** `uploadMedia` é opcional no contrato — o cliente do webhook sempre traz, o teste garante isso. */
40
+ function uploadMediaOf(client: { uploadMedia?: (file: File) => Promise<unknown> }) {
41
+ const upload = client.uploadMedia
42
+ if (!upload) throw new Error('createPreviewWebhookClient deveria expor uploadMedia')
43
+ return upload
44
+ }
45
+
37
46
  describe('createPreviewWebhookClient', () => {
38
47
  it('assina com o mesmo HMAC que o servidor calcula em node:crypto', async () => {
39
48
  const { fetchImplementation, captured } = createCapturingFetch()
@@ -103,3 +112,90 @@ describe('assertPreviewEnvironment', () => {
103
112
  expect(() => assertPreviewEnvironment(false)).not.toThrow()
104
113
  })
105
114
  })
115
+
116
+ /**
117
+ * O microfone do simulador depende disto existir no cliente.
118
+ *
119
+ * Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
120
+ * diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
121
+ * pacote: cliente montado, upload assinado, id prefixado.
122
+ */
123
+ describe('createPreviewWebhookClient.uploadMedia', () => {
124
+ function createUploadFetch(status = 201): {
125
+ fetchImplementation: typeof fetch
126
+ calls: Array<{ url: string; signature: string; body: string }>
127
+ } {
128
+ const calls: Array<{ url: string; signature: string; body: string }> = []
129
+
130
+ const fetchImplementation = (async (url: string, init?: RequestInit) => {
131
+ const headers = (init?.headers ?? {}) as Record<string, string>
132
+ calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
133
+ return {
134
+ ok: status >= 200 && status < 300,
135
+ status,
136
+ json: async () => ({ data: { uploadId: 'upl_123' } }),
137
+ } as Response
138
+ }) as unknown as typeof fetch
139
+
140
+ return { fetchImplementation, calls }
141
+ }
142
+
143
+ const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
144
+
145
+ it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
146
+ const { fetchImplementation, calls } = createUploadFetch()
147
+ const client = createPreviewWebhookClient({
148
+ webhookUrl: WEBHOOK_URL,
149
+ appSecret: APP_SECRET,
150
+ from: FROM,
151
+ fetchImplementation,
152
+ })
153
+
154
+ const uploaded = (await uploadMediaOf(client)(audioFile())) as { mediaId: string; mimeType?: string }
155
+
156
+ expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
157
+ expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
158
+ expect(uploaded.mimeType).toBe('audio/ogg')
159
+ })
160
+
161
+ it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
162
+ const { fetchImplementation, calls } = createUploadFetch()
163
+ const client = createPreviewWebhookClient({
164
+ webhookUrl: WEBHOOK_URL,
165
+ appSecret: APP_SECRET,
166
+ from: FROM,
167
+ fetchImplementation,
168
+ })
169
+
170
+ await uploadMediaOf(client)(audioFile())
171
+
172
+ const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
173
+ expect(calls[0]?.signature).toBe(expected)
174
+ })
175
+
176
+ it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
177
+ const { fetchImplementation, calls } = createUploadFetch()
178
+ const client = createPreviewWebhookClient({
179
+ webhookUrl: '/v1/webhook/whatsapp',
180
+ appSecret: APP_SECRET,
181
+ from: FROM,
182
+ fetchImplementation,
183
+ })
184
+
185
+ await uploadMediaOf(client)(audioFile())
186
+
187
+ expect(calls[0]?.url).toBe('/v1/preview/media')
188
+ })
189
+
190
+ it('avisa com erro próprio quando a rota de mídia recusa', async () => {
191
+ const { fetchImplementation } = createUploadFetch(404)
192
+ const client = createPreviewWebhookClient({
193
+ webhookUrl: WEBHOOK_URL,
194
+ appSecret: APP_SECRET,
195
+ from: FROM,
196
+ fetchImplementation,
197
+ })
198
+
199
+ await expect(uploadMediaOf(client)(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
200
+ })
201
+ })