@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-TV4OQRGH.js +2187 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +862 -136
- package/dist/index.js +1746 -1124
- package/dist/preview/index.d.ts +470 -0
- package/dist/preview/index.js +1329 -0
- package/dist/styles.css +228 -0
- package/dist/types-C6A_9edv.d.ts +456 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +280 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +44 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +96 -22
- package/src/MessageBubble.tsx +77 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +165 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +69 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +122 -0
- package/src/conversationTranscript.ts +89 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +140 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +334 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/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 +194 -0
- package/src/preview/createPreviewWebhookClient.ts +222 -0
- package/src/preview/index.ts +67 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +175 -12
- package/src/quickReply.test.ts +58 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +173 -0
- package/src/types.ts +72 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O risco aqui não é o cliente montar o payload errado — é o HMAC do WebCrypto divergir do que o
|
|
3
|
+
* servidor calcula com `node:crypto`. Uma divergência de um byte transforma todo envio do preview
|
|
4
|
+
* em 401, então o teste compara as duas implementações diretamente.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHmac } from 'node:crypto'
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
import {
|
|
10
|
+
assertPreviewEnvironment,
|
|
11
|
+
createPreviewWebhookClient,
|
|
12
|
+
PreviewInProductionError,
|
|
13
|
+
PreviewMediaUploadRejectedError,
|
|
14
|
+
PreviewWebhookRejectedError,
|
|
15
|
+
} from './createPreviewWebhookClient'
|
|
16
|
+
import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
17
|
+
|
|
18
|
+
const APP_SECRET = 'dev-app-secret'
|
|
19
|
+
const FROM = '5511988887777'
|
|
20
|
+
const WEBHOOK_URL = 'http://localhost:3000/v1/webhook/whatsapp'
|
|
21
|
+
|
|
22
|
+
type CapturedRequest = {
|
|
23
|
+
body: string
|
|
24
|
+
signature: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createCapturingFetch(status = 200): { fetchImplementation: typeof fetch; captured: CapturedRequest[] } {
|
|
28
|
+
const captured: CapturedRequest[] = []
|
|
29
|
+
|
|
30
|
+
const fetchImplementation = (async (_url: string, init?: RequestInit) => {
|
|
31
|
+
const headers = init?.headers as Record<string, string>
|
|
32
|
+
captured.push({ body: String(init?.body), signature: headers['x-hub-signature-256'] ?? '' })
|
|
33
|
+
return { ok: status >= 200 && status < 300, status } as Response
|
|
34
|
+
}) as unknown as typeof fetch
|
|
35
|
+
|
|
36
|
+
return { fetchImplementation, captured }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createPreviewWebhookClient', () => {
|
|
40
|
+
it('assina com o mesmo HMAC que o servidor calcula em node:crypto', async () => {
|
|
41
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
42
|
+
const client = createPreviewWebhookClient({
|
|
43
|
+
webhookUrl: WEBHOOK_URL,
|
|
44
|
+
appSecret: APP_SECRET,
|
|
45
|
+
from: FROM,
|
|
46
|
+
fetchImplementation,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
await client.sendText('quero 2kg de arroz')
|
|
50
|
+
|
|
51
|
+
const request = captured[0]
|
|
52
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET)
|
|
53
|
+
.update(request?.body ?? '')
|
|
54
|
+
.digest('hex')}`
|
|
55
|
+
expect(request?.signature).toBe(expected)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('envia exatamente os bytes que assinou', async () => {
|
|
59
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
60
|
+
const client = createPreviewWebhookClient({
|
|
61
|
+
webhookUrl: WEBHOOK_URL,
|
|
62
|
+
appSecret: APP_SECRET,
|
|
63
|
+
from: FROM,
|
|
64
|
+
fetchImplementation,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await client.sendButtonReply({ id: 'confirmar', title: 'Confirmar' })
|
|
68
|
+
|
|
69
|
+
const request = captured[0]
|
|
70
|
+
const reparsed = JSON.stringify(JSON.parse(request?.body ?? '{}'))
|
|
71
|
+
expect(request?.body).toBe(reparsed)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('mensagens de texto idêntico produzem assinaturas distintas', async () => {
|
|
75
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
76
|
+
const client = createPreviewWebhookClient({
|
|
77
|
+
webhookUrl: WEBHOOK_URL,
|
|
78
|
+
appSecret: APP_SECRET,
|
|
79
|
+
from: FROM,
|
|
80
|
+
fetchImplementation,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await client.sendText('sim')
|
|
84
|
+
await client.sendText('sim')
|
|
85
|
+
|
|
86
|
+
expect(captured[0]?.signature).not.toBe(captured[1]?.signature)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('propaga a recusa do webhook em vez de engolir', async () => {
|
|
90
|
+
const { fetchImplementation } = createCapturingFetch(401)
|
|
91
|
+
const client = createPreviewWebhookClient({
|
|
92
|
+
webhookUrl: WEBHOOK_URL,
|
|
93
|
+
appSecret: APP_SECRET,
|
|
94
|
+
from: FROM,
|
|
95
|
+
fetchImplementation,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewWebhookRejectedError)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('assertPreviewEnvironment', () => {
|
|
103
|
+
it('recusa montar em produção', () => {
|
|
104
|
+
expect(() => assertPreviewEnvironment(true)).toThrow(PreviewInProductionError)
|
|
105
|
+
expect(() => assertPreviewEnvironment(false)).not.toThrow()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* O microfone do simulador depende disto existir no cliente.
|
|
111
|
+
*
|
|
112
|
+
* Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
|
|
113
|
+
* diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
|
|
114
|
+
* pacote: cliente montado, upload assinado, id prefixado.
|
|
115
|
+
*/
|
|
116
|
+
describe('createPreviewWebhookClient.uploadMedia', () => {
|
|
117
|
+
function createUploadFetch(status = 201): {
|
|
118
|
+
fetchImplementation: typeof fetch
|
|
119
|
+
calls: Array<{ url: string; signature: string; body: string }>
|
|
120
|
+
} {
|
|
121
|
+
const calls: Array<{ url: string; signature: string; body: string }> = []
|
|
122
|
+
|
|
123
|
+
const fetchImplementation = (async (url: string, init?: RequestInit) => {
|
|
124
|
+
const headers = (init?.headers ?? {}) as Record<string, string>
|
|
125
|
+
calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
|
|
126
|
+
return {
|
|
127
|
+
ok: status >= 200 && status < 300,
|
|
128
|
+
status,
|
|
129
|
+
json: async () => ({ data: { uploadId: 'upl_123' } }),
|
|
130
|
+
} as Response
|
|
131
|
+
}) as unknown as typeof fetch
|
|
132
|
+
|
|
133
|
+
return { fetchImplementation, calls }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
|
|
137
|
+
|
|
138
|
+
it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
|
|
139
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
140
|
+
const client = createPreviewWebhookClient({
|
|
141
|
+
webhookUrl: WEBHOOK_URL,
|
|
142
|
+
appSecret: APP_SECRET,
|
|
143
|
+
from: FROM,
|
|
144
|
+
fetchImplementation,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const uploaded = await client.uploadMedia(audioFile())
|
|
148
|
+
|
|
149
|
+
expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
|
|
150
|
+
expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
|
|
151
|
+
expect(uploaded.mimeType).toBe('audio/ogg')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
|
|
155
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
156
|
+
const client = createPreviewWebhookClient({
|
|
157
|
+
webhookUrl: WEBHOOK_URL,
|
|
158
|
+
appSecret: APP_SECRET,
|
|
159
|
+
from: FROM,
|
|
160
|
+
fetchImplementation,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await client.uploadMedia(audioFile())
|
|
164
|
+
|
|
165
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
|
|
166
|
+
expect(calls[0]?.signature).toBe(expected)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
|
|
170
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
171
|
+
const client = createPreviewWebhookClient({
|
|
172
|
+
webhookUrl: '/v1/webhook/whatsapp',
|
|
173
|
+
appSecret: APP_SECRET,
|
|
174
|
+
from: FROM,
|
|
175
|
+
fetchImplementation,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await client.uploadMedia(audioFile())
|
|
179
|
+
|
|
180
|
+
expect(calls[0]?.url).toBe('/v1/preview/media')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('avisa com erro próprio quando a rota de mídia recusa', async () => {
|
|
184
|
+
const { fetchImplementation } = createUploadFetch(404)
|
|
185
|
+
const client = createPreviewWebhookClient({
|
|
186
|
+
webhookUrl: WEBHOOK_URL,
|
|
187
|
+
appSecret: APP_SECRET,
|
|
188
|
+
from: FROM,
|
|
189
|
+
fetchImplementation,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await expect(client.uploadMedia(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente que entrega mensagens do preview no webhook real, assinadas com HMAC — a mesma validação
|
|
3
|
+
* de staging e produção, sem rota alternativa e sem bypass. Do ponto de vista da API, este cliente
|
|
4
|
+
* é indistinguível da Meta; o que muda é apenas quem assina.
|
|
5
|
+
*
|
|
6
|
+
* Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
|
|
7
|
+
* (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ 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.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
buildInboundAudioPayload,
|
|
21
|
+
buildInboundInteractivePayload,
|
|
22
|
+
buildInboundMediaPayload,
|
|
23
|
+
buildInboundTextPayload,
|
|
24
|
+
serializeWebhookPayload,
|
|
25
|
+
type InboundMediaType,
|
|
26
|
+
type InteractiveReplyOption,
|
|
27
|
+
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
28
|
+
import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
29
|
+
|
|
30
|
+
export type PreviewWebhookClient = {
|
|
31
|
+
sendText(text: string): Promise<void>
|
|
32
|
+
sendButtonReply(reply: InteractiveReplyOption): Promise<void>
|
|
33
|
+
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
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
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CreatePreviewWebhookClientParams = {
|
|
65
|
+
readonly webhookUrl: string
|
|
66
|
+
readonly appSecret: string
|
|
67
|
+
readonly from: string
|
|
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
|
|
76
|
+
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
77
|
+
readonly fetchImplementation?: typeof fetch
|
|
78
|
+
}
|
|
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
|
+
|
|
88
|
+
export class PreviewInProductionError extends Error {
|
|
89
|
+
constructor() {
|
|
90
|
+
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
91
|
+
this.name = 'PreviewInProductionError'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class PreviewWebhookRejectedError extends Error {
|
|
96
|
+
constructor(readonly status: number) {
|
|
97
|
+
super(`O webhook recusou a entrega do preview (HTTP ${status}).`)
|
|
98
|
+
this.name = 'PreviewWebhookRejectedError'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Falha alto em vez de degradar em silêncio: um preview que "quase funciona" em produção é pior
|
|
104
|
+
* que um que se recusa a montar.
|
|
105
|
+
*/
|
|
106
|
+
export function assertPreviewEnvironment(isProduction: boolean): void {
|
|
107
|
+
if (isProduction) throw new PreviewInProductionError()
|
|
108
|
+
}
|
|
109
|
+
|
|
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> {
|
|
119
|
+
const encoder = new TextEncoder()
|
|
120
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
121
|
+
'raw',
|
|
122
|
+
encoder.encode(params.appSecret),
|
|
123
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
124
|
+
false,
|
|
125
|
+
['sign'],
|
|
126
|
+
)
|
|
127
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(params.rawBody))
|
|
128
|
+
|
|
129
|
+
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
130
|
+
}
|
|
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
|
+
|
|
181
|
+
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
182
|
+
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
183
|
+
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
184
|
+
// validação, porque o HMAC cobre os bytes exatos.
|
|
185
|
+
const rawBody = serializeWebhookPayload(payload)
|
|
186
|
+
const signature = await signWithWebCrypto({ rawBody, appSecret: params.appSecret })
|
|
187
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
188
|
+
|
|
189
|
+
const response = await performRequest(params.webhookUrl, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
|
|
192
|
+
body: rawBody,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
196
|
+
}
|
|
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
|
+
|
|
212
|
+
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
|
|
216
|
+
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
217
|
+
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
218
|
+
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
219
|
+
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
220
|
+
uploadMedia,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export separado (`@adatechnology/conversations-ui/preview`) para que fixtures e mocks nunca
|
|
3
|
+
* entrem no bundle de produção de quem consome o pacote.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createPreviewStore, conversationChannel, GLOBAL_CHANNEL } from './previewStore'
|
|
7
|
+
export type {
|
|
8
|
+
PreviewStore,
|
|
9
|
+
CreatePreviewStoreParams,
|
|
10
|
+
PreviewEmission,
|
|
11
|
+
PreviewStoreListener,
|
|
12
|
+
AppendMessageParams,
|
|
13
|
+
SetModeParams,
|
|
14
|
+
ListConversationsFilters,
|
|
15
|
+
} from './previewStore'
|
|
16
|
+
|
|
17
|
+
export { createMockEventSource } from './mockEventSource'
|
|
18
|
+
export type { MockEventSource } from './mockEventSource'
|
|
19
|
+
|
|
20
|
+
export { createMockConversationsApi } from './createMockConversationsApi'
|
|
21
|
+
export type { CreateMockConversationsApiParams } from './createMockConversationsApi'
|
|
22
|
+
|
|
23
|
+
export { createMockSSEProvider } from './createMockSSEProvider'
|
|
24
|
+
export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
25
|
+
|
|
26
|
+
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
|
+
|
|
28
|
+
export { ConversationPreview } from './ConversationPreview'
|
|
29
|
+
export { mediaTypeOf } from './ConversationPreview'
|
|
30
|
+
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
31
|
+
|
|
32
|
+
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
|
|
33
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
createPreviewWebhookClient,
|
|
37
|
+
signPreviewPayload,
|
|
38
|
+
assertPreviewEnvironment,
|
|
39
|
+
PreviewInProductionError,
|
|
40
|
+
PreviewWebhookRejectedError,
|
|
41
|
+
PreviewMediaUploadRejectedError,
|
|
42
|
+
createPreviewMediaPoster,
|
|
43
|
+
DEFAULT_MEDIA_UPLOAD_PATH,
|
|
44
|
+
} from './createPreviewWebhookClient'
|
|
45
|
+
export type {
|
|
46
|
+
PreviewWebhookClient,
|
|
47
|
+
CreatePreviewWebhookClientParams,
|
|
48
|
+
SendPreviewMediaParams,
|
|
49
|
+
} from './createPreviewWebhookClient'
|
|
50
|
+
|
|
51
|
+
export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
52
|
+
export type {
|
|
53
|
+
CreatePreviewBridgeClientParams,
|
|
54
|
+
PreviewInboundCommand,
|
|
55
|
+
SendPreviewInboundCommand,
|
|
56
|
+
} from './createPreviewBridgeClient'
|
|
57
|
+
|
|
58
|
+
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
59
|
+
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
60
|
+
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
61
|
+
export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
|
|
62
|
+
export { previewFileBase64 } from './previewMediaSource'
|
|
63
|
+
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
64
|
+
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
65
|
+
|
|
66
|
+
export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
67
|
+
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
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EventSource` só existe com um servidor HTTP do outro lado — é justamente o que falta quando se
|
|
3
|
+
* quer a inbox rodando com dados mockados. Este é o objeto mínimo que satisfaz
|
|
4
|
+
* `ConversationEventSource`: assina eventos nomeados, desassina e fecha.
|
|
5
|
+
*
|
|
6
|
+
* Não imita `EventSource` por completo de propósito: um fake "quase real" convida a depender de
|
|
7
|
+
* membros que o pacote não usa, e passa a quebrar a cada mudança de runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConversationEventSource } from '../providers/types'
|
|
11
|
+
|
|
12
|
+
export type MockEventSource = ConversationEventSource & {
|
|
13
|
+
emit(event: string, payload: unknown): void
|
|
14
|
+
readonly closed: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createMockEventSource(): MockEventSource {
|
|
18
|
+
const listeners = new Map<string, Set<(event: MessageEvent) => void>>()
|
|
19
|
+
let closed = false
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
get closed(): boolean {
|
|
23
|
+
return closed
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
|
27
|
+
const typeListeners = listeners.get(type) ?? new Set<(event: MessageEvent) => void>()
|
|
28
|
+
typeListeners.add(listener)
|
|
29
|
+
listeners.set(type, typeListeners)
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
removeEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
|
33
|
+
listeners.get(type)?.delete(listener)
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
close(): void {
|
|
37
|
+
closed = true
|
|
38
|
+
listeners.clear()
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
emit(event: string, payload: unknown): void {
|
|
42
|
+
// Depois de fechado, emitir é no-op: o servidor real também não entrega em socket fechado, e
|
|
43
|
+
// silenciar aqui é o que faz vazamento de listener aparecer como bug de cleanup, não como
|
|
44
|
+
// evento fantasma na UI.
|
|
45
|
+
if (closed) return
|
|
46
|
+
|
|
47
|
+
// `data` é string no fio; entregar o objeto já parseado esconderia erro de serialização que
|
|
48
|
+
// aparece em produção.
|
|
49
|
+
const messageEvent = new MessageEvent(event, { data: JSON.stringify(payload) })
|
|
50
|
+
for (const listener of listeners.get(event) ?? []) listener(messageEvent)
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|