@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2372 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1193 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +95 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +225 -17
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -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
|
+
})
|
|
@@ -6,24 +6,59 @@
|
|
|
6
6
|
* Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
|
|
7
7
|
* (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
|
|
8
8
|
*
|
|
9
|
-
* ⚠️ Isto carrega o app secret
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
|
|
10
|
+
* seja servido — em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
|
|
11
|
+
* equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
|
|
12
|
+
* mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
|
|
13
|
+
* homologação passaria, então a barreira não basta.
|
|
14
|
+
*
|
|
15
|
+
* Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
|
|
16
|
+
* o servidor assina com o segredo que ele já tem.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import {
|
|
15
20
|
buildInboundAudioPayload,
|
|
16
21
|
buildInboundInteractivePayload,
|
|
22
|
+
buildInboundMediaPayload,
|
|
17
23
|
buildInboundTextPayload,
|
|
18
24
|
serializeWebhookPayload,
|
|
25
|
+
type InboundMediaType,
|
|
19
26
|
type InteractiveReplyOption,
|
|
20
27
|
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
28
|
+
import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
21
29
|
|
|
22
30
|
export type PreviewWebhookClient = {
|
|
23
31
|
sendText(text: string): Promise<void>
|
|
24
32
|
sendButtonReply(reply: InteractiveReplyOption): Promise<void>
|
|
25
33
|
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
26
34
|
sendAudio(mediaId: string): Promise<void>
|
|
35
|
+
sendMedia(params: SendPreviewMediaParams): Promise<void>
|
|
36
|
+
/**
|
|
37
|
+
* Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
|
|
38
|
+
*
|
|
39
|
+
* Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
|
|
40
|
+
* sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
|
|
41
|
+
* o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
|
|
42
|
+
* por esquecimento. Cliente montado, microfone na tela.
|
|
43
|
+
*
|
|
44
|
+
* Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
|
|
45
|
+
* o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
|
|
46
|
+
* corretamente não desenha o gravador.
|
|
47
|
+
*/
|
|
48
|
+
uploadMedia?(file: File): Promise<PreviewUploadedMedia>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SendPreviewMediaParams = {
|
|
52
|
+
readonly mediaType: InboundMediaType
|
|
53
|
+
/**
|
|
54
|
+
* Id que o host já usa para buscar o arquivo. Não é bytes: o webhook da Meta entrega mídia por
|
|
55
|
+
* referência, e o consumidor baixa depois — mandar base64 aqui simularia um payload que a Meta
|
|
56
|
+
* nunca produz, e o caminho testado deixaria de ser o de produção.
|
|
57
|
+
*/
|
|
58
|
+
readonly mediaId: string
|
|
59
|
+
readonly mimeType?: string
|
|
60
|
+
readonly filename?: string
|
|
61
|
+
readonly caption?: string
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
export type CreatePreviewWebhookClientParams = {
|
|
@@ -31,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
|
|
|
31
66
|
readonly appSecret: string
|
|
32
67
|
readonly from: string
|
|
33
68
|
readonly phoneNumberId?: string
|
|
69
|
+
/**
|
|
70
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
|
|
71
|
+
*
|
|
72
|
+
* O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
|
|
73
|
+
* publica a API em outro host ou versiona o caminho.
|
|
74
|
+
*/
|
|
75
|
+
readonly mediaUploadUrl?: string
|
|
34
76
|
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
35
77
|
readonly fetchImplementation?: typeof fetch
|
|
36
78
|
}
|
|
37
79
|
|
|
80
|
+
/** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
|
|
81
|
+
export class PreviewMediaUploadRejectedError extends Error {
|
|
82
|
+
constructor(readonly status: number) {
|
|
83
|
+
super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
|
|
84
|
+
this.name = 'PreviewMediaUploadRejectedError'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
38
88
|
export class PreviewInProductionError extends Error {
|
|
39
89
|
constructor() {
|
|
40
90
|
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
@@ -57,7 +107,15 @@ export function assertPreviewEnvironment(isProduction: boolean): void {
|
|
|
57
107
|
if (isProduction) throw new PreviewInProductionError()
|
|
58
108
|
}
|
|
59
109
|
|
|
60
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Assina um texto qualquer com o app secret, no mesmo formato do header da Meta.
|
|
112
|
+
*
|
|
113
|
+
* Exportada porque o preview precisa provar identidade em MAIS de um lugar: além de entregar a
|
|
114
|
+
* mensagem no webhook, ele lê o transcript de volta — e ler pela API de admin exigia uma sessão que
|
|
115
|
+
* a aba do simulador não tem. Assinar a leitura com o segredo que ele já carrega resolve sem token
|
|
116
|
+
* de admin e sem rota aberta.
|
|
117
|
+
*/
|
|
118
|
+
export async function signPreviewPayload(params: { rawBody: string; appSecret: string }): Promise<string> {
|
|
61
119
|
const encoder = new TextEncoder()
|
|
62
120
|
const key = await globalThis.crypto.subtle.importKey(
|
|
63
121
|
'raw',
|
|
@@ -71,6 +129,55 @@ async function signWithWebCrypto(params: { rawBody: string; appSecret: string })
|
|
|
71
129
|
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
72
130
|
}
|
|
73
131
|
|
|
132
|
+
const signWithWebCrypto = signPreviewPayload
|
|
133
|
+
|
|
134
|
+
export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
|
|
138
|
+
*
|
|
139
|
+
* Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
|
|
140
|
+
* pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
|
|
141
|
+
* lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
|
|
142
|
+
*/
|
|
143
|
+
export function defaultMediaUploadUrl(webhookUrl: string): string {
|
|
144
|
+
try {
|
|
145
|
+
return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
|
|
146
|
+
} catch {
|
|
147
|
+
return DEFAULT_MEDIA_UPLOAD_PATH
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
|
|
153
|
+
*
|
|
154
|
+
* O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
|
|
155
|
+
* do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
|
|
156
|
+
*/
|
|
157
|
+
export function createPreviewMediaPoster(params: {
|
|
158
|
+
readonly url: string
|
|
159
|
+
readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
|
|
160
|
+
readonly fetchImplementation?: typeof fetch
|
|
161
|
+
}): (file: File) => Promise<PreviewUploadedMedia> {
|
|
162
|
+
return createPreviewMediaUploader({
|
|
163
|
+
upload: async (request) => {
|
|
164
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
165
|
+
const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
|
|
166
|
+
|
|
167
|
+
const response = await performRequest(params.url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
170
|
+
body: JSON.stringify(request),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
|
|
174
|
+
|
|
175
|
+
const body = (await response.json()) as { data: { uploadId: string } }
|
|
176
|
+
return { uploadId: body.data.uploadId }
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
74
181
|
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
75
182
|
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
76
183
|
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
@@ -88,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
88
195
|
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
89
196
|
}
|
|
90
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Assina o MIME, não o binário — mesmo contrato da rota.
|
|
200
|
+
*
|
|
201
|
+
* Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
|
|
202
|
+
* assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
|
|
203
|
+
*/
|
|
204
|
+
const uploadMedia = createPreviewMediaPoster({
|
|
205
|
+
url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
|
|
206
|
+
headers: async (mimeType) => ({
|
|
207
|
+
'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
|
|
208
|
+
}),
|
|
209
|
+
...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
|
|
210
|
+
})
|
|
211
|
+
|
|
91
212
|
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
92
213
|
|
|
93
214
|
return {
|
|
@@ -95,5 +216,7 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
95
216
|
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
96
217
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
97
218
|
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
219
|
+
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
220
|
+
uploadMedia,
|
|
98
221
|
}
|
|
99
222
|
}
|
package/src/preview/index.ts
CHANGED
|
@@ -23,18 +23,66 @@ export type { CreateMockConversationsApiParams } from './createMockConversations
|
|
|
23
23
|
export { createMockSSEProvider } from './createMockSSEProvider'
|
|
24
24
|
export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
25
25
|
|
|
26
|
-
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
26
|
+
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
ConversationSimulatorPanel,
|
|
30
|
+
DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
|
|
31
|
+
simulatorPanelLabelsOf,
|
|
32
|
+
} from './ConversationSimulatorPanel'
|
|
33
|
+
export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
acceptsMediaKind,
|
|
37
|
+
mediaKindOf,
|
|
38
|
+
isConversationSimulatorClient,
|
|
39
|
+
toConversationSimulatorClient,
|
|
40
|
+
SIMULATOR_FILE_MEDIA_KINDS,
|
|
41
|
+
} from './ConversationSimulatorClient'
|
|
42
|
+
export type {
|
|
43
|
+
ConversationSimulatorClient,
|
|
44
|
+
SendSimulatorMediaParams,
|
|
45
|
+
SimulatorMediaKind,
|
|
46
|
+
ToSimulatorClientParams,
|
|
47
|
+
} from './ConversationSimulatorClient'
|
|
27
48
|
|
|
28
49
|
export { ConversationPreview } from './ConversationPreview'
|
|
29
|
-
export
|
|
50
|
+
export { mediaTypeOf } from './ConversationPreview'
|
|
51
|
+
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
52
|
+
|
|
53
|
+
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
|
|
54
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
|
|
30
55
|
|
|
31
56
|
export {
|
|
32
57
|
createPreviewWebhookClient,
|
|
58
|
+
signPreviewPayload,
|
|
33
59
|
assertPreviewEnvironment,
|
|
34
60
|
PreviewInProductionError,
|
|
35
61
|
PreviewWebhookRejectedError,
|
|
62
|
+
PreviewMediaUploadRejectedError,
|
|
63
|
+
createPreviewMediaPoster,
|
|
64
|
+
DEFAULT_MEDIA_UPLOAD_PATH,
|
|
65
|
+
} from './createPreviewWebhookClient'
|
|
66
|
+
export type {
|
|
67
|
+
PreviewWebhookClient,
|
|
68
|
+
CreatePreviewWebhookClientParams,
|
|
69
|
+
SendPreviewMediaParams,
|
|
36
70
|
} from './createPreviewWebhookClient'
|
|
37
|
-
|
|
71
|
+
|
|
72
|
+
export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
73
|
+
export type {
|
|
74
|
+
CreatePreviewBridgeClientParams,
|
|
75
|
+
PreviewInboundCommand,
|
|
76
|
+
SendPreviewInboundCommand,
|
|
77
|
+
} from './createPreviewBridgeClient'
|
|
38
78
|
|
|
39
79
|
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
40
80
|
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
81
|
+
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
82
|
+
export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
|
|
83
|
+
export { previewFileBase64 } from './previewMediaSource'
|
|
84
|
+
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
85
|
+
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
86
|
+
|
|
87
|
+
export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
88
|
+
export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mediaTypeOf } from './ConversationPreview'
|
|
3
|
+
|
|
4
|
+
describe('mediaTypeOf', () => {
|
|
5
|
+
it('deriva imagem, vídeo e áudio pelo prefixo do MIME', () => {
|
|
6
|
+
expect(mediaTypeOf('image/jpeg')).toBe('image')
|
|
7
|
+
expect(mediaTypeOf('video/mp4')).toBe('video')
|
|
8
|
+
expect(mediaTypeOf('audio/webm')).toBe('audio')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('trata o resto como documento, inclusive MIME desconhecido', () => {
|
|
12
|
+
expect(mediaTypeOf('application/pdf')).toBe('document')
|
|
13
|
+
expect(mediaTypeOf('')).toBe('document')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a busca da biblioteca no mock — que é a implementação de referência da UI do SDK e tem de
|
|
3
|
+
* casar com o `companyDocumentSearch` do backend, senão o preview ensina um comportamento que a
|
|
4
|
+
* tela real não entrega.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
10
|
+
import { createPreviewStore } from './previewStore'
|
|
11
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
12
|
+
|
|
13
|
+
function buildApi() {
|
|
14
|
+
const store = createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES })
|
|
15
|
+
return createMockConversationsApi({ store })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function search(term: string): Promise<{ total: number; conversations: string[] }> {
|
|
19
|
+
const page = await buildApi().getAllDocuments!({ search: term })
|
|
20
|
+
return { total: page.total, conversations: [...new Set(page.documents.map((document) => document.conversationId))] }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('busca da biblioteca no mock', () => {
|
|
24
|
+
it('acha por nome do arquivo', async () => {
|
|
25
|
+
const result = await search('contrato')
|
|
26
|
+
|
|
27
|
+
expect(result.total).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('acha por telefone da conversa', async () => {
|
|
31
|
+
const porNumero = await search('94444')
|
|
32
|
+
|
|
33
|
+
expect(porNumero.conversations).toEqual(['5511944443333'])
|
|
34
|
+
expect(porNumero.total).toBeGreaterThan(0)
|
|
35
|
+
// Discrimina de verdade: 98888 é conversa que existe nas fixtures, mas sem arquivo — se o
|
|
36
|
+
// predicado do telefone não estivesse valendo, esta busca devolveria a biblioteca inteira.
|
|
37
|
+
expect((await search('98888')).total).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// O caso real: o atendente copia o número como a tela o mostra.
|
|
41
|
+
it('acha com o telefone formatado, como aparece na tela', async () => {
|
|
42
|
+
const cru = await search('5511944443333')
|
|
43
|
+
const formatado = await search('+55 (11) 94444-3333')
|
|
44
|
+
|
|
45
|
+
expect(formatado.total).toBe(cru.total)
|
|
46
|
+
expect(formatado.total).toBeGreaterThan(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('não devolve nada para número de outra conversa', async () => {
|
|
50
|
+
expect((await search('99999999999')).total).toBe(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Sem dígito, o predicado do telefone não existe — senão qualquer palavra casaria toda conversa.
|
|
54
|
+
it('termo sem dígito filtra só por nome', async () => {
|
|
55
|
+
expect((await search('zzz-inexistente')).total).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -145,8 +145,10 @@ describe('createMockConversationsApi', () => {
|
|
|
145
145
|
const firstPage = await api.fetchConversations({ page: 1, limit: 2 })
|
|
146
146
|
const waiting = await api.fetchConversations({ waitingHuman: true })
|
|
147
147
|
|
|
148
|
-
expect(firstPage).toHaveLength(2)
|
|
149
|
-
|
|
148
|
+
expect(firstPage.conversations).toHaveLength(2)
|
|
149
|
+
// O total conta o conjunto filtrado inteiro, não a fatia — é o que a paginação da UI lê.
|
|
150
|
+
expect(firstPage.total).toBeGreaterThan(2)
|
|
151
|
+
expect(waiting.conversations.every((conversation) => conversation.waitingHuman)).toBe(true)
|
|
150
152
|
})
|
|
151
153
|
|
|
152
154
|
it('markRead zera as não-lidas vistas pela lista', async () => {
|
|
@@ -156,7 +158,7 @@ describe('createMockConversationsApi', () => {
|
|
|
156
158
|
await api.markRead(BOT_CONVERSATION_ID)
|
|
157
159
|
const conversations = await api.fetchConversations()
|
|
158
160
|
|
|
159
|
-
expect(conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
161
|
+
expect(conversations.conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
160
162
|
})
|
|
161
163
|
})
|
|
162
164
|
|