@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bancada de teste manual de mídia — montável em uma linha por qualquer projeto que adote o SDK.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque o defeito que ela pega não é pegável por teste automatizado: "o PDF abre?" depende
|
|
5
|
+
* do leitor do navegador, "o vídeo toca?" do decodificador, e "a aba abre?" da política do Chrome
|
|
6
|
+
* sobre `data:` URL. Teste unitário confere bytes; só o olho confere que o arquivo abre. Sem uma
|
|
7
|
+
* superfície pronta no pacote, cada projeto teria de montar a sua — e, na prática, nenhum montava.
|
|
8
|
+
*
|
|
9
|
+
* Traz o próprio store, o próprio mock e o próprio provider: o host não injeta nada. E não passa
|
|
10
|
+
* `onResolveMediaUrl` em lugar nenhum de propósito — é o `MessageBubble` resolvendo mídia pelo
|
|
11
|
+
* `ConversationsApi` do contexto, então se essa resolução automática quebrar, esta tela mostra.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useMemo } from 'react'
|
|
15
|
+
import { ConversationsProvider } from '../providers/ConversationsProvider'
|
|
16
|
+
import { DocumentsLibrary } from '../DocumentsLibrary'
|
|
17
|
+
import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
|
|
18
|
+
import { MessageBubble } from '../MessageBubble'
|
|
19
|
+
import { ConversationWallpaper } from '../Wallpaper'
|
|
20
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
21
|
+
import { createMockSSEProvider } from './createMockSSEProvider'
|
|
22
|
+
import { createPreviewStore } from './previewStore'
|
|
23
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
24
|
+
|
|
25
|
+
/** A conversa do fixture que carrega uma mensagem de cada tipo aceito. */
|
|
26
|
+
export const MEDIA_TYPES_CONVERSATION_ID = '5511944443333'
|
|
27
|
+
|
|
28
|
+
export type MediaTypesPreviewProps = {
|
|
29
|
+
/** Outra conversa do fixture, se o projeto tiver acrescentado a sua. */
|
|
30
|
+
conversationId?: string
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function MediaTypesPreview({
|
|
35
|
+
conversationId = MEDIA_TYPES_CONVERSATION_ID,
|
|
36
|
+
className,
|
|
37
|
+
}: MediaTypesPreviewProps) {
|
|
38
|
+
const store = useMemo(
|
|
39
|
+
() => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
|
|
40
|
+
[],
|
|
41
|
+
)
|
|
42
|
+
const api = useMemo(() => createMockConversationsApi({ store }), [store])
|
|
43
|
+
const sse = useMemo(() => createMockSSEProvider({ store }), [store])
|
|
44
|
+
|
|
45
|
+
const messages = PREVIEW_MESSAGES[conversationId] ?? []
|
|
46
|
+
const documents = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
47
|
+
const mimeTypes = [...new Set(documents.map((document) => document.mimeType))]
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ConversationsProvider api={api} sse={sse}>
|
|
51
|
+
<div className={className}>
|
|
52
|
+
<header className="border-b px-4 py-3 dark:border-gray-700">
|
|
53
|
+
<h1 className="text-lg font-semibold">Teste manual de mídia</h1>
|
|
54
|
+
<p className="text-sm text-gray-500">
|
|
55
|
+
{documents.length} arquivos, {mimeTypes.length} tipos. Clique no olho para abrir em aba nova e no
|
|
56
|
+
botão da bolha para carregar a mídia na thread — é o que teste automatizado não vê.
|
|
57
|
+
</p>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
61
|
+
<section className="space-y-3">
|
|
62
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Biblioteca da empresa</h2>
|
|
63
|
+
{/* Sem paginar: a bancada serve para ver TODOS os tipos de uma vez. */}
|
|
64
|
+
<DocumentsLibrary perPage={documents.length || 20} />
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section className="space-y-3">
|
|
68
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Painel da conversa</h2>
|
|
69
|
+
<ConversationDocumentsPanel conversationId={conversationId} open perPage={documents.length || 20} />
|
|
70
|
+
|
|
71
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Bolhas na thread</h2>
|
|
72
|
+
<ConversationWallpaper className="max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2">
|
|
73
|
+
{messages.map((message, index) => (
|
|
74
|
+
<MessageBubble
|
|
75
|
+
key={message.id}
|
|
76
|
+
message={message}
|
|
77
|
+
isMine={message.direction === 'outbound'}
|
|
78
|
+
isFirstInGroup={index === 0 || messages[index - 1]?.sender !== message.sender}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</ConversationWallpaper>
|
|
82
|
+
</section>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</ConversationsProvider>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a classificação de falha do simulador do cliente.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou o arquivo: o `refresh` tratava QUALQUER erro ao ler o transcript como
|
|
5
|
+
* "conversa ainda não existe", mostrava thread vazia e não dizia nada. Com sessão ausente (401) o
|
|
6
|
+
* sintoma era o pior possível — a mensagem ia para o webhook, era aceita, e a tela ficava igual.
|
|
7
|
+
* Quem olhava concluía que o envio estava quebrado.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
|
|
12
|
+
import { describeLoadFailure, isNotFound } from './ConversationPreview'
|
|
13
|
+
|
|
14
|
+
class HttpError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
readonly status: number,
|
|
18
|
+
) {
|
|
19
|
+
super(message)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('isNotFound', () => {
|
|
24
|
+
// Primeiro contato: a conversa não existe e transcript vazio é o estado correto, sem alarme.
|
|
25
|
+
it('reconhece 404 como conversa inexistente', () => {
|
|
26
|
+
expect(isNotFound(new HttpError('Conversa não encontrada', 404))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('não confunde 401 com conversa inexistente', () => {
|
|
30
|
+
expect(isNotFound(new HttpError('Unauthorized', 401))).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('erro sem status não é tratado como inexistente', () => {
|
|
34
|
+
expect(isNotFound(new Error('Failed to fetch'))).toBe(false)
|
|
35
|
+
expect(isNotFound(undefined)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Aceita `statusCode` também: nem todo host nomeia o campo igual.
|
|
39
|
+
it('lê statusCode quando é esse o nome do campo', () => {
|
|
40
|
+
expect(isNotFound({ statusCode: 404 })).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('describeLoadFailure', () => {
|
|
45
|
+
it('explica que falta sessão e que a mensagem FOI entregue', () => {
|
|
46
|
+
const mensagem = describeLoadFailure(new HttpError('Unauthorized', 401))
|
|
47
|
+
|
|
48
|
+
expect(mensagem).toContain('Sem sessão')
|
|
49
|
+
// O ponto central: não deixar o operador achar que o envio falhou.
|
|
50
|
+
expect(mensagem).toContain('entregue no webhook')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('trata 403 igual a 401', () => {
|
|
54
|
+
expect(describeLoadFailure(new HttpError('Forbidden', 403))).toContain('Sem sessão')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('preserva a mensagem do host em falha genérica', () => {
|
|
58
|
+
expect(describeLoadFailure(new HttpError('API fora do ar', 500))).toContain('API fora do ar')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('tem texto para erro sem mensagem', () => {
|
|
62
|
+
expect(describeLoadFailure({}).length).toBeGreaterThan(0)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { MessagePayload } from '../types'
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CompanyDocumentPage,
|
|
13
|
+
ConversationDocumentPage,
|
|
14
|
+
ConversationPage,
|
|
15
|
+
ConversationTemplate,
|
|
16
|
+
ConversationsApi,
|
|
17
|
+
ListConversationsParams,
|
|
18
|
+
} from '../providers/types'
|
|
19
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
20
|
+
import { previewFileBase64, previewFileUrl } from './previewMediaSource'
|
|
12
21
|
import type { PreviewStore } from './previewStore'
|
|
13
22
|
|
|
14
|
-
// PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
|
|
15
|
-
const PREVIEW_IMAGE_BASE64 =
|
|
16
|
-
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
|
|
17
|
-
|
|
18
23
|
export type CreateMockConversationsApiParams = {
|
|
19
24
|
readonly store: PreviewStore
|
|
20
25
|
readonly latencyMs?: number
|
|
@@ -23,7 +28,24 @@ export type CreateMockConversationsApiParams = {
|
|
|
23
28
|
|
|
24
29
|
const DEFAULT_LATENCY_MS = 120
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
const PREVIEW_AGENT_ID = 'preview-agent'
|
|
32
|
+
|
|
33
|
+
const PREVIEW_TEMPLATES: readonly ConversationTemplate[] = [
|
|
34
|
+
{ name: 'retomada_atendimento', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
35
|
+
{ name: 'lembrete_documentos', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
36
|
+
{ name: 'promocao_taxa', language: 'pt_BR', status: 'PENDING', category: 'MARKETING' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* O mock satisfaz `ConversationsApi`, mas com o retorno de `fetchConversations` ESTREITADO para a
|
|
41
|
+
* forma paginada. Sem isto o contrato — que aceita array ou página — obrigaria todo consumidor do
|
|
42
|
+
* preview a desempacotar uma união que aqui nunca varia.
|
|
43
|
+
*/
|
|
44
|
+
export type MockConversationsApi = Omit<ConversationsApi, 'fetchConversations'> & {
|
|
45
|
+
fetchConversations(params?: ListConversationsParams): Promise<ConversationPage>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createMockConversationsApi(params: CreateMockConversationsApiParams): MockConversationsApi {
|
|
27
49
|
const latencyMs = params.latencyMs ?? DEFAULT_LATENCY_MS
|
|
28
50
|
|
|
29
51
|
async function withLatency<TResult>(produce: () => TResult): Promise<TResult> {
|
|
@@ -32,7 +54,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
return {
|
|
35
|
-
|
|
57
|
+
// Devolve a forma paginada, não o array puro: é a que o contrato passou a oferecer e a que
|
|
58
|
+
// permite o preview desenhar controles de página. O total é contado ANTES do corte — depois
|
|
59
|
+
// dele seria sempre o tamanho da página, e a paginação nunca sairia da primeira.
|
|
60
|
+
fetchConversations(fetchParams): Promise<ConversationPage> {
|
|
36
61
|
return withLatency(() => {
|
|
37
62
|
const conversations = params.store.listConversations({
|
|
38
63
|
waitingHuman: fetchParams?.waitingHuman,
|
|
@@ -41,7 +66,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
41
66
|
|
|
42
67
|
const limit = fetchParams?.limit ?? conversations.length
|
|
43
68
|
const page = fetchParams?.page ?? 1
|
|
44
|
-
return
|
|
69
|
+
return {
|
|
70
|
+
conversations: conversations.slice((page - 1) * limit, page * limit),
|
|
71
|
+
total: conversations.length,
|
|
72
|
+
}
|
|
45
73
|
})
|
|
46
74
|
},
|
|
47
75
|
|
|
@@ -74,7 +102,9 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
74
102
|
return withLatency(() => {
|
|
75
103
|
params.store.appendMessage({
|
|
76
104
|
conversationId,
|
|
77
|
-
|
|
105
|
+
// Sem nome, o host está pedindo o template padrão do backend — o mock representa isso
|
|
106
|
+
// pelo que o atendente veria, não por um nome inventado.
|
|
107
|
+
content: `[template] ${data.templateName ?? PREVIEW_TEMPLATES[0]?.name ?? 'padrao'}`,
|
|
78
108
|
direction: 'outbound',
|
|
79
109
|
sender: 'agent',
|
|
80
110
|
})
|
|
@@ -96,16 +126,146 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
96
126
|
})
|
|
97
127
|
},
|
|
98
128
|
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Espelha o backend em busca, filtro de origem, ordenação E paginação. Mock que ignora params
|
|
131
|
+
* faz o painel parecer quebrado aqui e, pior, esconde o caso em que o backend também os ignora
|
|
132
|
+
* — foi exatamente assim que o filtro de origem passou a existir só no contrato.
|
|
133
|
+
*/
|
|
134
|
+
getDocuments(conversationId, documentParams): Promise<ConversationDocumentPage> {
|
|
135
|
+
return withLatency(() => {
|
|
136
|
+
let documents = [...(PREVIEW_DOCUMENTS[conversationId] ?? [])]
|
|
137
|
+
|
|
138
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
139
|
+
if (search) {
|
|
140
|
+
documents = documents.filter((document) => document.filename.toLowerCase().includes(search))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 'team' agrupa agent + bot, como o painel apresenta.
|
|
144
|
+
const source = documentParams?.source
|
|
145
|
+
if (source === 'team') {
|
|
146
|
+
documents = documents.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
147
|
+
} else if (source) {
|
|
148
|
+
documents = documents.filter((document) => document.source === source)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
documents.sort((left, right) =>
|
|
152
|
+
documentParams?.sortDirection === 'asc'
|
|
153
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
154
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Total contado ANTES do corte — depois seria sempre o tamanho da página, e a paginação
|
|
158
|
+
// nunca sairia da primeira.
|
|
159
|
+
const total = documents.length
|
|
160
|
+
const limit = documentParams?.limit ?? total
|
|
161
|
+
const page = documentParams?.page ?? 1
|
|
162
|
+
|
|
163
|
+
return { documents: documents.slice((page - 1) * limit, page * limit), total }
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Zip de mentira: um texto listando o que entraria. Basta para exercitar seleção, botão e o
|
|
169
|
+
* caminho de download no preview, sem arrastar uma lib de compactação para o pacote.
|
|
170
|
+
*/
|
|
171
|
+
downloadDocumentsArchive(conversationId, uploadIds): Promise<Blob> {
|
|
172
|
+
return withLatency(() => {
|
|
173
|
+
const known = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
174
|
+
const names = uploadIds.map((id) => known.find((document) => document.id === id)?.filename ?? id)
|
|
175
|
+
return new Blob([`preview: ${names.length} arquivo(s)\n${names.join('\n')}`], { type: 'application/zip' })
|
|
176
|
+
})
|
|
101
177
|
},
|
|
102
178
|
|
|
103
|
-
|
|
104
|
-
|
|
179
|
+
/** Junta as bibliotecas de todas as conversas do fixture, com a origem de cada arquivo. */
|
|
180
|
+
getAllDocuments(documentParams): Promise<CompanyDocumentPage> {
|
|
181
|
+
return withLatency(() => {
|
|
182
|
+
let all = Object.entries(PREVIEW_DOCUMENTS).flatMap(([conversationId, docs]) =>
|
|
183
|
+
docs.map((document) => ({ ...document, conversationId })),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Mesma regra do backend (`companyDocumentSearch`): o termo casa nome do arquivo OU
|
|
187
|
+
// telefone da conversa, e o telefone só pelos dígitos — o preview mostra o número
|
|
188
|
+
// formatado, então é assim que o atendente vai colá-lo na busca.
|
|
189
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
190
|
+
if (search) {
|
|
191
|
+
const digits = search.replace(/\D/g, '')
|
|
192
|
+
all = all.filter(
|
|
193
|
+
(document) =>
|
|
194
|
+
document.filename.toLowerCase().includes(search) ||
|
|
195
|
+
(digits !== '' && document.conversationId.includes(digits)),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const source = documentParams?.source
|
|
200
|
+
if (source === 'team') {
|
|
201
|
+
all = all.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
202
|
+
} else if (source) {
|
|
203
|
+
all = all.filter((document) => document.source === source)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
all.sort((left, right) =>
|
|
207
|
+
documentParams?.sortDirection === 'asc'
|
|
208
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
209
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const total = all.length
|
|
213
|
+
const limit = documentParams?.limit ?? total
|
|
214
|
+
const page = documentParams?.page ?? 1
|
|
215
|
+
return { documents: all.slice((page - 1) * limit, page * limit), total }
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Devolve os bytes DO TIPO do documento, não uma imagem para tudo: antes, abrir um PDF entregava
|
|
220
|
+
// um PNG rotulado `application/pdf` e o leitor recusava o arquivo. O `uploadId` é a única pista
|
|
221
|
+
// que o contrato dá, então o tipo vem da própria biblioteca.
|
|
222
|
+
getDocumentUrl(uploadId): Promise<string> {
|
|
223
|
+
return withLatency(() => {
|
|
224
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
225
|
+
.flat()
|
|
226
|
+
.find((document) => document.id === uploadId)
|
|
227
|
+
return previewFileUrl(found?.mimeType, found?.filename)
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Caminho da mídia ainda não ingerida: o backend busca na Meta e devolve base64. Resolve pelo
|
|
232
|
+
// id para a bolha receber os bytes DO TIPO dela — devolvendo um PNG para todo id, vídeo e áudio
|
|
233
|
+
// apareciam quebrados na thread mesmo havendo amostra válida do formato.
|
|
234
|
+
getMediaProxyUrl(mediaId): Promise<{ mimeType: string; data: string }> {
|
|
235
|
+
return withLatency(() => {
|
|
236
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
237
|
+
.flat()
|
|
238
|
+
.find((document) => document.id === `preview/inbound/${mediaId}`)
|
|
239
|
+
return previewFileBase64(found?.mimeType, found?.filename)
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
takeover(conversationId): Promise<void> {
|
|
244
|
+
return withLatency(() =>
|
|
245
|
+
params.store.setMode({ conversationId, mode: 'human', assignedUserId: PREVIEW_AGENT_ID }),
|
|
246
|
+
)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
release(conversationId): Promise<void> {
|
|
250
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// Encerrar devolve ao bot como o release, e é de propósito: a diferença entre os dois é a
|
|
254
|
+
// despedida, que o host manda antes de chamar aqui. O mock não a inventa.
|
|
255
|
+
finalize(conversationId): Promise<void> {
|
|
256
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
markAllRead(): Promise<void> {
|
|
260
|
+
return withLatency(() => {
|
|
261
|
+
for (const conversation of params.store.listConversations()) {
|
|
262
|
+
params.store.markRead(conversation.id)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
105
265
|
},
|
|
106
266
|
|
|
107
|
-
|
|
108
|
-
return withLatency(() =>
|
|
267
|
+
listTemplates(): Promise<ConversationTemplate[]> {
|
|
268
|
+
return withLatency(() => [...PREVIEW_TEMPLATES])
|
|
109
269
|
},
|
|
110
270
|
}
|
|
111
271
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O que estes testes protegem é a propriedade de segurança da ponte: nenhum caminho pode voltar a
|
|
3
|
+
* exigir segredo no navegador, e o corpo enviado tem que ser a INTENÇÃO — se um refactor passar a
|
|
4
|
+
* mandar payload da Meta montado no cliente, a rota do host vira injetor de webhook arbitrário.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
10
|
+
import type { PreviewInboundCommand } from './createPreviewBridgeClient'
|
|
11
|
+
|
|
12
|
+
const FROM = '5511999999999'
|
|
13
|
+
|
|
14
|
+
function createRecordingClient() {
|
|
15
|
+
const commands: PreviewInboundCommand[] = []
|
|
16
|
+
const client = createPreviewBridgeClient({
|
|
17
|
+
from: FROM,
|
|
18
|
+
sendCommand: async (command) => {
|
|
19
|
+
commands.push(command)
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
return { client, commands }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('createPreviewBridgeClient', () => {
|
|
26
|
+
it('entrega a intenção do cliente, carimbando o remetente em cada comando', async () => {
|
|
27
|
+
const { client, commands } = createRecordingClient()
|
|
28
|
+
|
|
29
|
+
await client.sendText('quero simular')
|
|
30
|
+
await client.sendButtonReply({ id: 'hab_pronto', title: 'Imóvel pronto' })
|
|
31
|
+
await client.sendListReply({ id: 'faixa_2', title: 'Faixa 2' })
|
|
32
|
+
await client.sendAudio('media-1')
|
|
33
|
+
await client.sendMedia({ mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' })
|
|
34
|
+
|
|
35
|
+
expect(commands).toEqual([
|
|
36
|
+
{ kind: 'text', from: FROM, text: 'quero simular' },
|
|
37
|
+
{ kind: 'buttonReply', from: FROM, reply: { id: 'hab_pronto', title: 'Imóvel pronto' } },
|
|
38
|
+
{ kind: 'listReply', from: FROM, reply: { id: 'faixa_2', title: 'Faixa 2' } },
|
|
39
|
+
{ kind: 'audio', from: FROM, mediaId: 'media-1' },
|
|
40
|
+
{ kind: 'media', from: FROM, mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' },
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('nunca embute assinatura nem segredo no que sai do navegador', async () => {
|
|
45
|
+
const { client, commands } = createRecordingClient()
|
|
46
|
+
|
|
47
|
+
await client.sendText('oi')
|
|
48
|
+
|
|
49
|
+
const serialized = JSON.stringify(commands[0])
|
|
50
|
+
expect(serialized).not.toMatch(/sha256=/)
|
|
51
|
+
expect(serialized).not.toMatch(/secret/i)
|
|
52
|
+
expect(commands[0]).not.toHaveProperty('entry')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('posta no endpoint do host com os headers de sessão que o host injeta', async () => {
|
|
56
|
+
const calls: Array<{ url: string; init: RequestInit }> = []
|
|
57
|
+
const client = createPreviewBridgeClient({
|
|
58
|
+
from: FROM,
|
|
59
|
+
endpointUrl: 'https://host.test/api/conversations/preview/inbound',
|
|
60
|
+
headers: { authorization: 'Bearer token-do-painel' },
|
|
61
|
+
fetchImplementation: (async (url: string, init: RequestInit) => {
|
|
62
|
+
calls.push({ url, init })
|
|
63
|
+
return { ok: true } as Response
|
|
64
|
+
}) as unknown as typeof fetch,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await client.sendText('oi')
|
|
68
|
+
|
|
69
|
+
expect(calls[0]?.url).toBe('https://host.test/api/conversations/preview/inbound')
|
|
70
|
+
expect(calls[0]?.init.method).toBe('POST')
|
|
71
|
+
expect(calls[0]?.init.headers).toMatchObject({
|
|
72
|
+
'content-type': 'application/json',
|
|
73
|
+
authorization: 'Bearer token-do-painel',
|
|
74
|
+
})
|
|
75
|
+
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ kind: 'text', from: FROM, text: 'oi' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('converte recusa do host em erro tipado, para o painel poder mostrar o motivo', async () => {
|
|
79
|
+
const client = createPreviewBridgeClient({
|
|
80
|
+
from: FROM,
|
|
81
|
+
endpointUrl: 'https://host.test/preview',
|
|
82
|
+
fetchImplementation: (async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewBridgeRejectedError)
|
|
86
|
+
await expect(client.sendText('oi')).rejects.toThrow(/403/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('recusa configuração sem forma de entregar, em vez de falhar só no primeiro envio', () => {
|
|
90
|
+
expect(() => createPreviewBridgeClient({ from: FROM })).toThrow(/sendCommand.*endpointUrl/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
|
|
3
|
+
* navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
|
|
4
|
+
* autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
|
|
5
|
+
* app secret que nunca sai de lá.
|
|
6
|
+
*
|
|
7
|
+
* Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
|
|
8
|
+
* app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
|
|
9
|
+
* acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
|
|
10
|
+
* válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
|
|
11
|
+
* Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
|
|
12
|
+
* servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
|
|
13
|
+
*
|
|
14
|
+
* O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
|
|
15
|
+
* porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
19
|
+
import {
|
|
20
|
+
createPreviewMediaPoster,
|
|
21
|
+
defaultMediaUploadUrl,
|
|
22
|
+
type PreviewWebhookClient,
|
|
23
|
+
type SendPreviewMediaParams,
|
|
24
|
+
} from './createPreviewWebhookClient'
|
|
25
|
+
import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
|
|
29
|
+
* se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
|
|
30
|
+
* quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
|
|
31
|
+
*/
|
|
32
|
+
export type PreviewInboundCommand =
|
|
33
|
+
| { readonly kind: 'text'; readonly from: string; readonly text: string }
|
|
34
|
+
| { readonly kind: 'buttonReply'; readonly from: string; readonly reply: InteractiveReplyOption }
|
|
35
|
+
| { readonly kind: 'listReply'; readonly from: string; readonly reply: InteractiveReplyOption }
|
|
36
|
+
| { readonly kind: 'audio'; readonly from: string; readonly mediaId: string }
|
|
37
|
+
| ({ readonly kind: 'media'; readonly from: string } & SendPreviewMediaParams)
|
|
38
|
+
|
|
39
|
+
export type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>
|
|
40
|
+
|
|
41
|
+
export class PreviewBridgeRejectedError extends Error {
|
|
42
|
+
constructor(readonly status: number) {
|
|
43
|
+
super(`A rota de preview do host recusou a entrega (HTTP ${status}).`)
|
|
44
|
+
this.name = 'PreviewBridgeRejectedError'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CreatePreviewBridgeClientParams = {
|
|
49
|
+
readonly from: string
|
|
50
|
+
/**
|
|
51
|
+
* Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
|
|
52
|
+
* de token — reimplementar isso aqui só duplicaria a autenticação do produto.
|
|
53
|
+
*/
|
|
54
|
+
readonly sendCommand?: SendPreviewInboundCommand
|
|
55
|
+
/** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
|
|
56
|
+
readonly endpointUrl?: string
|
|
57
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
58
|
+
readonly fetchImplementation?: typeof fetch
|
|
59
|
+
/**
|
|
60
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
|
|
61
|
+
*
|
|
62
|
+
* Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
|
|
63
|
+
* e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
|
|
64
|
+
*/
|
|
65
|
+
readonly mediaUploadUrl?: string
|
|
66
|
+
/**
|
|
67
|
+
* Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
|
|
68
|
+
* não há origem a derivar, e sem destino o gravador não é desenhado.
|
|
69
|
+
*/
|
|
70
|
+
readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
|
|
74
|
+
const endpointUrl = params.endpointUrl
|
|
75
|
+
if (!endpointUrl) {
|
|
76
|
+
throw new Error('createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return async (command) => {
|
|
80
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
81
|
+
const response = await performRequest(endpointUrl, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
// `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
|
|
84
|
+
// bearer não convivem numa escolha default sem quebrar um dos dois.
|
|
85
|
+
headers: { 'content-type': 'application/json', ...params.headers },
|
|
86
|
+
body: JSON.stringify(command),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (!response.ok) throw new PreviewBridgeRejectedError(response.status)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Só existe quando há para onde mandar: rota explícita, ou origem herdada do `endpointUrl`. */
|
|
94
|
+
function resolveBridgeUpload(
|
|
95
|
+
params: CreatePreviewBridgeClientParams,
|
|
96
|
+
): ((file: File) => Promise<PreviewUploadedMedia>) | undefined {
|
|
97
|
+
if (params.uploadMedia) return params.uploadMedia
|
|
98
|
+
|
|
99
|
+
const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : undefined)
|
|
100
|
+
if (!url) return undefined
|
|
101
|
+
|
|
102
|
+
return createPreviewMediaPoster({
|
|
103
|
+
url,
|
|
104
|
+
...(params.headers ? { headers: async () => params.headers ?? {} } : {}),
|
|
105
|
+
...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
|
|
110
|
+
const send = params.sendCommand ?? buildFetchSender(params)
|
|
111
|
+
const from = params.from
|
|
112
|
+
const uploadMedia = resolveBridgeUpload(params)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
sendText: (text) => send({ kind: 'text', from, text }),
|
|
116
|
+
sendButtonReply: (reply) => send({ kind: 'buttonReply', from, reply }),
|
|
117
|
+
sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
|
|
118
|
+
sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
|
|
119
|
+
sendMedia: (media) => send({ kind: 'media', from, ...media }),
|
|
120
|
+
...(uploadMedia ? { uploadMedia } : {}),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type { InboundMediaType }
|