@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.0
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-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- 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 +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- 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.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- 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/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 +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -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/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- 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 +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -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 +84 -45
- 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/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 +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -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 +1 -0
- 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/theme.ts +13 -0
- package/src/types.ts +26 -0
- 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/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -6,9 +6,14 @@
|
|
|
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 {
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
type InboundMediaType,
|
|
21
26
|
type InteractiveReplyOption,
|
|
22
27
|
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
28
|
+
import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
23
29
|
|
|
24
30
|
export type PreviewWebhookClient = {
|
|
25
31
|
sendText(text: string): Promise<void>
|
|
@@ -27,6 +33,19 @@ export type PreviewWebhookClient = {
|
|
|
27
33
|
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
28
34
|
sendAudio(mediaId: string): Promise<void>
|
|
29
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>
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
export type SendPreviewMediaParams = {
|
|
@@ -47,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
|
|
|
47
66
|
readonly appSecret: string
|
|
48
67
|
readonly from: string
|
|
49
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
|
|
50
76
|
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
51
77
|
readonly fetchImplementation?: typeof fetch
|
|
52
78
|
}
|
|
53
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
|
+
|
|
54
88
|
export class PreviewInProductionError extends Error {
|
|
55
89
|
constructor() {
|
|
56
90
|
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
@@ -97,6 +131,53 @@ export async function signPreviewPayload(params: { rawBody: string; appSecret: s
|
|
|
97
131
|
|
|
98
132
|
const signWithWebCrypto = signPreviewPayload
|
|
99
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
|
+
|
|
100
181
|
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
101
182
|
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
102
183
|
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
@@ -114,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
114
195
|
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
115
196
|
}
|
|
116
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
|
+
|
|
117
212
|
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
118
213
|
|
|
119
214
|
return {
|
|
@@ -122,5 +217,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
122
217
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
123
218
|
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
124
219
|
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
220
|
+
uploadMedia,
|
|
125
221
|
}
|
|
126
222
|
}
|
package/src/preview/index.ts
CHANGED
|
@@ -25,12 +25,33 @@ export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
|
25
25
|
|
|
26
26
|
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
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'
|
|
48
|
+
|
|
28
49
|
export { ConversationPreview } from './ConversationPreview'
|
|
29
50
|
export { mediaTypeOf } from './ConversationPreview'
|
|
30
51
|
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
31
52
|
|
|
32
|
-
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '
|
|
33
|
-
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '
|
|
53
|
+
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
|
|
54
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
|
|
34
55
|
|
|
35
56
|
export {
|
|
36
57
|
createPreviewWebhookClient,
|
|
@@ -38,6 +59,9 @@ export {
|
|
|
38
59
|
assertPreviewEnvironment,
|
|
39
60
|
PreviewInProductionError,
|
|
40
61
|
PreviewWebhookRejectedError,
|
|
62
|
+
PreviewMediaUploadRejectedError,
|
|
63
|
+
createPreviewMediaPoster,
|
|
64
|
+
DEFAULT_MEDIA_UPLOAD_PATH,
|
|
41
65
|
} from './createPreviewWebhookClient'
|
|
42
66
|
export type {
|
|
43
67
|
PreviewWebhookClient,
|
|
@@ -45,6 +69,13 @@ export type {
|
|
|
45
69
|
SendPreviewMediaParams,
|
|
46
70
|
} from './createPreviewWebhookClient'
|
|
47
71
|
|
|
72
|
+
export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
73
|
+
export type {
|
|
74
|
+
CreatePreviewBridgeClientParams,
|
|
75
|
+
PreviewInboundCommand,
|
|
76
|
+
SendPreviewInboundCommand,
|
|
77
|
+
} from './createPreviewBridgeClient'
|
|
78
|
+
|
|
48
79
|
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
49
80
|
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
50
81
|
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
@@ -52,3 +83,6 @@ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource
|
|
|
52
83
|
export { previewFileBase64 } from './previewMediaSource'
|
|
53
84
|
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
54
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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
|
|
3
|
+
* meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
|
|
4
|
+
* reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
|
|
9
|
+
|
|
10
|
+
import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
11
|
+
|
|
12
|
+
describe('createPreviewMediaUploader', () => {
|
|
13
|
+
it('reexporta o prefixo do contrato, sem cópia própria', () => {
|
|
14
|
+
expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('devolve o id prefixado com o uploadId da rota', async () => {
|
|
18
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
|
|
19
|
+
|
|
20
|
+
const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
|
|
21
|
+
|
|
22
|
+
expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
|
|
23
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
24
|
+
expect(result.filename).toBe('nota.ogg')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('manda o binário em base64 para a rota', async () => {
|
|
28
|
+
let recebido: string | undefined
|
|
29
|
+
const upload = createPreviewMediaUploader({
|
|
30
|
+
upload: async (request) => {
|
|
31
|
+
recebido = request.base64
|
|
32
|
+
return { uploadId: 'k' }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
|
|
37
|
+
|
|
38
|
+
expect(atob(recebido!)).toBe('audio')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
|
|
42
|
+
it('preenche nome e mime quando o arquivo vem sem eles', async () => {
|
|
43
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
44
|
+
|
|
45
|
+
const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
|
|
46
|
+
|
|
47
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
48
|
+
expect(result.filename).toBe('audio.ogg')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
|
|
53
|
+
* aparece na primeira gravação de verdade, nunca no clipe curto do teste.
|
|
54
|
+
*/
|
|
55
|
+
it('converte arquivo grande sem estourar a pilha', async () => {
|
|
56
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
57
|
+
const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
|
|
58
|
+
|
|
59
|
+
expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from 'react'
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
2
|
import type { ConversationsApi, SSEProvider } from './types'
|
|
3
3
|
|
|
4
4
|
interface ConversationsContextValue {
|
|
@@ -17,11 +17,13 @@ export function ConversationsProvider({
|
|
|
17
17
|
sse: SSEProvider
|
|
18
18
|
children: ReactNode
|
|
19
19
|
}) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
20
|
+
// Objeto novo a cada render fazia todo efeito que depende do contexto reexecutar junto — o do
|
|
21
|
+
// `useConversationRealtime` fecha e reabre o SSE da conversa, e cada reabertura pede um ticket
|
|
22
|
+
// novo. Uma tela que renderiza em rajada saturava as 6 conexões do navegador com requisições
|
|
23
|
+
// pendentes que nunca chegavam a servir para nada.
|
|
24
|
+
const value = useMemo(() => ({ api, sse }), [api, sse])
|
|
25
|
+
|
|
26
|
+
return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function useConversations(): ConversationsContextValue | null {
|
package/src/providers/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MessagePayload } from '../types'
|
|
1
|
+
import type { MessagePayload, MessageTranscription } from '../types'
|
|
2
2
|
import type { ConversationChannel } from '../conversationChannel'
|
|
3
3
|
|
|
4
4
|
export interface ListConversationsParams {
|
|
@@ -31,14 +31,38 @@ export interface ListDocumentsParams {
|
|
|
31
31
|
page?: number
|
|
32
32
|
/** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
|
|
33
33
|
limit?: number
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host.
|
|
36
|
+
*
|
|
37
|
+
* Seleção múltipla viaja como lista separada por vírgula em vez de virar `string[]`: mudar o
|
|
38
|
+
* tipo quebraria em compile-time toda implementação de host que já repassa este campo adiante,
|
|
39
|
+
* e o ganho seria nenhum — quem recebe faz `split(',')`.
|
|
40
|
+
*/
|
|
35
41
|
source?: string
|
|
42
|
+
/** Categoria do arquivo (`document`, `image`, `audio`, `video`…), mesma convenção de lista. */
|
|
43
|
+
fileCategory?: string
|
|
44
|
+
/** Recorte por data de recebimento, em `YYYY-MM-DD`. */
|
|
45
|
+
startDate?: string
|
|
46
|
+
endDate?: string
|
|
36
47
|
sortDirection?: 'asc' | 'desc'
|
|
48
|
+
/** Coluna ordenada. Ausente, o host ordena pela data — é o padrão de toda listagem de arquivo. */
|
|
49
|
+
sortField?: string
|
|
50
|
+
/**
|
|
51
|
+
* Filtros que só existem no produto (`clientId`, `unidade`…). O pacote não os interpreta: passa
|
|
52
|
+
* adiante o que o host injetou pelo slot de filtros. É a porta que evita um fork da tela por
|
|
53
|
+
* causa de um `<select>`.
|
|
54
|
+
*/
|
|
55
|
+
extra?: Readonly<Record<string, string | number>>
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
|
|
40
59
|
export interface CompanyDocument extends ConversationDocument {
|
|
41
60
|
conversationId: string
|
|
61
|
+
/**
|
|
62
|
+
* Nome de quem enviou, quando o host o conhece. Opcional porque a biblioteca sempre tem o
|
|
63
|
+
* telefone e nem todo produto tem cadastro por trás dele — ausente, a coluna cai para o número.
|
|
64
|
+
*/
|
|
65
|
+
contactName?: string | null
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
export interface CompanyDocumentPage {
|
|
@@ -110,6 +134,28 @@ export interface ConversationsApi {
|
|
|
110
134
|
* componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
|
|
111
135
|
*/
|
|
112
136
|
getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>
|
|
137
|
+
/**
|
|
138
|
+
* Remove um arquivo da biblioteca. **Opcional por capacidade:** apagar anexo trocado com o
|
|
139
|
+
* cliente é decisão de retenção do produto — instalação que precisa guardar tudo por obrigação
|
|
140
|
+
* legal não implementa, e a tela simplesmente não desenha a lixeira.
|
|
141
|
+
*/
|
|
142
|
+
deleteDocument?(uploadId: string): Promise<void>
|
|
143
|
+
/**
|
|
144
|
+
* Zip de arquivos avulsos da biblioteca, sem conversa de origem única — irmão do
|
|
145
|
+
* `downloadDocumentsArchive`, que é por conversa. Ausente, a seleção em lote não oferece o botão.
|
|
146
|
+
*/
|
|
147
|
+
downloadDocumentsArchiveByIds?(uploadIds: readonly string[]): Promise<Blob>
|
|
148
|
+
/**
|
|
149
|
+
* Envia um arquivo avulso direto pra biblioteca, fora do fluxo de uma conversa. **Opcional por
|
|
150
|
+
* capacidade:** cada host tem seu próprio contrato de upload (base64, multipart, presigned URL) —
|
|
151
|
+
* o pacote não escolhe um formato de payload, só entrega o `File` do input e deixa o host montar
|
|
152
|
+
* a chamada do jeito que seu backend espera. Ausente, a tela de biblioteca não desenha o botão de
|
|
153
|
+
* enviar, em vez de oferecer uma ação que sempre falha.
|
|
154
|
+
*
|
|
155
|
+
* `extra` é o mesmo vocabulário livre do produto que já viaja em `renderFilters` — cliente,
|
|
156
|
+
* unidade, campanha — pra associar o arquivo enviado ao contexto que a tela estava filtrando.
|
|
157
|
+
*/
|
|
158
|
+
uploadDocument?(file: File, extra?: Readonly<Record<string, string | number>>): Promise<ConversationDocument>
|
|
113
159
|
getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
|
|
114
160
|
|
|
115
161
|
/**
|
|
@@ -134,6 +180,17 @@ export interface ConversationsApi {
|
|
|
134
180
|
* nem todo backend expõe a rota — quem não tem continua usando o builder local.
|
|
135
181
|
*/
|
|
136
182
|
exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Transcreve o áudio de uma mensagem e devolve o resultado.
|
|
186
|
+
*
|
|
187
|
+
* **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
|
|
188
|
+
* nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
|
|
189
|
+
* simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
|
|
190
|
+
*
|
|
191
|
+
* `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
|
|
192
|
+
*/
|
|
193
|
+
transcribeAudio?(messageId: string): Promise<MessageTranscription>
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
/**
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a interpolação das mensagens rápidas.
|
|
3
|
+
*
|
|
4
|
+
* O caso que decide o desenho: variável ausente. Deixar `{{nome}}` no texto significa o atendente
|
|
5
|
+
* mandar "Olá {{nome}}!" para o cliente — pior que a saudação sem nome.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
|
|
11
|
+
|
|
12
|
+
describe('applyQuickReplyVariables', () => {
|
|
13
|
+
it('troca a variável pelo valor', () => {
|
|
14
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!', { nome: 'Marina' })).toBe('Olá Marina!')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('aceita espaço dentro das chaves', () => {
|
|
18
|
+
expect(applyQuickReplyVariables('Olá {{ nome }}!', { nome: 'Rita' })).toBe('Olá Rita!')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('troca todas as ocorrências', () => {
|
|
22
|
+
expect(applyQuickReplyVariables('{{nome}}, confirma? Obrigado, {{nome}}.', { nome: 'Ana' })).toBe(
|
|
23
|
+
'Ana, confirma? Obrigado, Ana.',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Nunca vaza o literal para o cliente.
|
|
28
|
+
it('apaga a variável que não foi passada', () => {
|
|
29
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!', {})).toBe('Olá !')
|
|
30
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!')).toBe('Olá !')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('não mexe em texto sem variável', () => {
|
|
34
|
+
expect(applyQuickReplyVariables('Bom dia!', { nome: 'X' })).toBe('Bom dia!')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('resolveQuickReply', () => {
|
|
39
|
+
it('interpola quando o texto é string', () => {
|
|
40
|
+
const resolvido = resolveQuickReply({ key: 'g', label: '👋', text: 'Olá {{nome}}!' }, { nome: 'Rita' })
|
|
41
|
+
|
|
42
|
+
expect(resolvido).toBe('Olá Rita!')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// A função existe para o que a string não resolve: escolher copy por produto, pluralizar, formatar.
|
|
46
|
+
it('chama a função com as variáveis', () => {
|
|
47
|
+
const resolvido = resolveQuickReply(
|
|
48
|
+
{ key: 's', label: '📋', text: (variables) => `Status de ${variables['produto'] ?? 'seu pedido'}` },
|
|
49
|
+
{ produto: 'financiamento' },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(resolvido).toBe('Status de financiamento')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('função sem variáveis não quebra', () => {
|
|
56
|
+
expect(resolveQuickReply({ key: 'c', label: '📞', text: () => 'Posso ligar?' })).toBe('Posso ligar?')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* A distinção que estes testes protegem: janela de sessão e tempo sem resposta medem coisas
|
|
5
|
+
* diferentes, e reaproveitar `windowOf` para o SLA marcaria como atrasada toda conversa já
|
|
6
|
+
* respondida — o alerta apontaria para tudo, ou seja, para nada.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import { REPLY_LATENCY, isReplyOverdue, replyLatencyOf } from './replyLatency'
|
|
12
|
+
|
|
13
|
+
const NOW = new Date('2026-08-29T12:00:00Z').getTime()
|
|
14
|
+
const hoursAgo = (hours: number) => new Date(NOW - hours * 60 * 60 * 1000).toISOString()
|
|
15
|
+
|
|
16
|
+
describe('faixas de espera', () => {
|
|
17
|
+
it('até 6h está dentro do combinado', () => {
|
|
18
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(0), now: NOW })).toBe(
|
|
19
|
+
REPLY_LATENCY.WITHIN,
|
|
20
|
+
)
|
|
21
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(5.9), now: NOW })).toBe(
|
|
22
|
+
REPLY_LATENCY.WITHIN,
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('de 6h a 12h já passou do combinado', () => {
|
|
27
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(6), now: NOW })).toBe(REPLY_LATENCY.LATE)
|
|
28
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(
|
|
29
|
+
REPLY_LATENCY.LATE,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('acima de 12h é crítico — não se confunde com uma espera de 7h', () => {
|
|
34
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(12), now: NOW })).toBe(
|
|
35
|
+
REPLY_LATENCY.CRITICAL,
|
|
36
|
+
)
|
|
37
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(72), now: NOW })).toBe(
|
|
38
|
+
REPLY_LATENCY.CRITICAL,
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('quando não há espera a mostrar', () => {
|
|
44
|
+
it('conversa já respondida não tem selo, por mais antiga que seja', () => {
|
|
45
|
+
// É aqui que o SLA se separa da janela de sessão: `windowOf` marcaria isto como crítico.
|
|
46
|
+
expect(replyLatencyOf({ lastDirection: 'outbound', lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('cliente que nunca escreveu não tem espera', () => {
|
|
50
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: null, now: NOW })).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('direção desconhecida não afirma espera', () => {
|
|
54
|
+
expect(replyLatencyOf({ lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('relógio adiantado não vira conversa crítica', () => {
|
|
58
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(-0.05), now: NOW })).toBe(
|
|
59
|
+
REPLY_LATENCY.WITHIN,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('o que merece alerta', () => {
|
|
65
|
+
it('só o que passou de 6h', () => {
|
|
66
|
+
expect(isReplyOverdue(REPLY_LATENCY.WITHIN)).toBe(false)
|
|
67
|
+
expect(isReplyOverdue(REPLY_LATENCY.LATE)).toBe(true)
|
|
68
|
+
expect(isReplyOverdue(REPLY_LATENCY.CRITICAL)).toBe(true)
|
|
69
|
+
expect(isReplyOverdue(null)).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Há quanto tempo o cliente espera resposta.
|
|
5
|
+
*
|
|
6
|
+
* Não confundir com `conversationWindow`: aquilo é a janela de sessão da plataforma (o que o canal
|
|
7
|
+
* ainda deixa enviar), isto é serviço (quanto o cliente esperou). Os dois divergem justamente no
|
|
8
|
+
* caso que interessa — respondida a conversa, o relógio do SLA para e o da janela continua correndo.
|
|
9
|
+
*
|
|
10
|
+
* A conta sai de dados que a listagem já traz: se a última mensagem foi do cliente, ninguém
|
|
11
|
+
* respondeu ainda e a espera conta desde ela. Se a última foi nossa, não há espera pendente.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
export const REPLY_LATENCY = {
|
|
17
|
+
/** Até 6h — dentro do combinado. */
|
|
18
|
+
WITHIN: 'within',
|
|
19
|
+
/** 6h a 12h — passou do combinado. */
|
|
20
|
+
LATE: 'late',
|
|
21
|
+
/** Acima de 12h. */
|
|
22
|
+
CRITICAL: 'critical',
|
|
23
|
+
} as const
|
|
24
|
+
export type ReplyLatency = (typeof REPLY_LATENCY)[keyof typeof REPLY_LATENCY]
|
|
25
|
+
|
|
26
|
+
export const REPLY_LATENCY_LATE_HOURS = 6
|
|
27
|
+
export const REPLY_LATENCY_CRITICAL_HOURS = 12
|
|
28
|
+
|
|
29
|
+
export type ReplyLatencyParams = {
|
|
30
|
+
/** Direção da última mensagem. Ausente = desconhecida, e aí não se afirma espera. */
|
|
31
|
+
readonly lastDirection?: 'inbound' | 'outbound' | undefined
|
|
32
|
+
readonly lastInboundAt: string | null
|
|
33
|
+
readonly now: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `null` quando não há espera a mostrar — conversa já respondida, ou cliente que nunca escreveu.
|
|
38
|
+
*
|
|
39
|
+
* Devolver uma faixa nesses casos encheria a lista de selos em conversa que não deve nada, e o
|
|
40
|
+
* alerta que aponta para todo lado não aponta para lugar nenhum.
|
|
41
|
+
*/
|
|
42
|
+
export function replyLatencyOf(params: ReplyLatencyParams): ReplyLatency | null {
|
|
43
|
+
if (params.lastDirection !== 'inbound') return null
|
|
44
|
+
if (!params.lastInboundAt) return null
|
|
45
|
+
|
|
46
|
+
const elapsedHours = (params.now - new Date(params.lastInboundAt).getTime()) / HOUR_MS
|
|
47
|
+
// Espera negativa é relógio fora de sincronia entre servidor e navegador, não conversa do futuro:
|
|
48
|
+
// tratar como recém-chegada evita um selo "crítico" nascido de alguns segundos de diferença.
|
|
49
|
+
if (elapsedHours < REPLY_LATENCY_LATE_HOURS) return REPLY_LATENCY.WITHIN
|
|
50
|
+
if (elapsedHours < REPLY_LATENCY_CRITICAL_HOURS) return REPLY_LATENCY.LATE
|
|
51
|
+
return REPLY_LATENCY.CRITICAL
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Só o que passou do combinado merece alerta — é o corte que a lista usa para destacar. */
|
|
55
|
+
export function isReplyOverdue(latency: ReplyLatency | null): boolean {
|
|
56
|
+
return latency === REPLY_LATENCY.LATE || latency === REPLY_LATENCY.CRITICAL
|
|
57
|
+
}
|