@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41
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 +2389 -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 +1219 -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 +126 -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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Como o preview entrega mídia para a UI abrir.
|
|
3
|
+
*
|
|
4
|
+
* Separado de `previewFileSamples` (que é gerado) porque aqui está a parte que depende do navegador:
|
|
5
|
+
* **blob URL, não data URL**. O Chrome bloqueia navegação de topo para `data:` desde a v60, então
|
|
6
|
+
* `window.open(dataUrl)` — que é o que os botões "visualizar" e "baixar" fazem — abre aba em branco
|
|
7
|
+
* mesmo com bytes perfeitamente válidos. Em `src` de `<img>`/`<video>` a data URL funcionaria; no
|
|
8
|
+
* `window.open` não, e o mesmo `getDocumentUrl` alimenta os dois.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MessagePayload } from '../types'
|
|
12
|
+
import type { ResolveMediaUrl } from '../MediaRenderer'
|
|
13
|
+
import { resolvePreviewFileSample } from './previewFileSamples'
|
|
14
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
15
|
+
|
|
16
|
+
// Uma blob URL por tipo, reaproveitada: cada `createObjectURL` retém o blob até um `revokeObjectURL`
|
|
17
|
+
// que ninguém chamaria, e a lista redesenha a cada filtro digitado.
|
|
18
|
+
const blobUrlCache = new Map<string, string>()
|
|
19
|
+
|
|
20
|
+
function toBlob(dataUrl: string): Blob {
|
|
21
|
+
const [head, payload] = dataUrl.split(',')
|
|
22
|
+
const mimeType = head!
|
|
23
|
+
.replace(/^data:/, '')
|
|
24
|
+
.replace(/;base64$/, '')
|
|
25
|
+
.replace(/;charset=.*$/, '')
|
|
26
|
+
|
|
27
|
+
if (!head!.endsWith(';base64')) {
|
|
28
|
+
return new Blob([decodeURIComponent(payload!)], { type: mimeType || 'text/plain' })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const binary = atob(payload!)
|
|
32
|
+
const bytes = new Uint8Array(binary.length)
|
|
33
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
34
|
+
return new Blob([bytes], { type: mimeType })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** URL que o preview pode abrir em aba nova. Cai na data URL fora do navegador (teste, SSR). */
|
|
38
|
+
export function previewFileUrl(mimeType: string | undefined, filename?: string): string {
|
|
39
|
+
const dataUrl = resolvePreviewFileSample(mimeType, filename)
|
|
40
|
+
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return dataUrl
|
|
41
|
+
|
|
42
|
+
const cached = blobUrlCache.get(dataUrl)
|
|
43
|
+
if (cached) return cached
|
|
44
|
+
|
|
45
|
+
const blobUrl = URL.createObjectURL(toBlob(dataUrl))
|
|
46
|
+
blobUrlCache.set(dataUrl, blobUrl)
|
|
47
|
+
return blobUrl
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A mesma amostra em base64 cru, como o proxy de mídia do backend a devolveria.
|
|
52
|
+
*
|
|
53
|
+
* Existe porque `getMediaProxyUrl` é o caminho da mídia AINDA NÃO ingerida (só existe o id na Meta),
|
|
54
|
+
* e o contrato pede `{ mimeType, data }` — não URL. Sem isto o mock devolvia o PNG 1x1 para
|
|
55
|
+
* qualquer id, e vídeo e áudio da thread apareciam quebrados apesar de haver amostra válida.
|
|
56
|
+
*/
|
|
57
|
+
export function previewFileBase64(mimeType: string | undefined, filename?: string): { mimeType: string; data: string } {
|
|
58
|
+
const dataUrl = resolvePreviewFileSample(mimeType, filename)
|
|
59
|
+
const [head, payload] = dataUrl.split(',')
|
|
60
|
+
const declared = head!
|
|
61
|
+
.replace(/^data:/, '')
|
|
62
|
+
.replace(/;base64$/, '')
|
|
63
|
+
.replace(/;charset=.*$/, '')
|
|
64
|
+
|
|
65
|
+
if (head!.endsWith(';base64')) return { mimeType: declared, data: payload! }
|
|
66
|
+
return { mimeType: declared, data: btoa(decodeURIComponent(payload!)) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* O `onResolveMediaUrl` que o `MessageBubble` espera.
|
|
71
|
+
*
|
|
72
|
+
* Sem ele, foto, vídeo e áudio da thread ficam parados no placeholder para sempre — o
|
|
73
|
+
* `MediaRenderer` só resolve `uploadId`/`mediaId` por esta porta, de propósito, para o pacote nunca
|
|
74
|
+
* chamar endpoint fixo. O preview não tinha resolvedor nenhum, então nenhuma mídia carregava.
|
|
75
|
+
*/
|
|
76
|
+
export function createPreviewMediaResolver(): ResolveMediaUrl {
|
|
77
|
+
const byId = new Map<string, { mimeType: string; filename: string }>()
|
|
78
|
+
for (const documents of Object.values(PREVIEW_DOCUMENTS)) {
|
|
79
|
+
for (const document of documents) {
|
|
80
|
+
byId.set(document.id, { mimeType: document.mimeType, filename: document.filename })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return async (message: MessagePayload): Promise<string | null> => {
|
|
85
|
+
const reference = message.uploadId ?? (message.mediaId ? `preview/inbound/${message.mediaId}` : undefined)
|
|
86
|
+
if (!reference) return null
|
|
87
|
+
|
|
88
|
+
const known = byId.get(reference)
|
|
89
|
+
return previewFileUrl(known?.mimeType ?? message.mimeType, known?.filename ?? message.filename)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -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,28 +1,196 @@
|
|
|
1
|
-
import type { MessagePayload } from '../types'
|
|
1
|
+
import type { MessagePayload, MessageTranscription } from '../types'
|
|
2
2
|
import type { ConversationChannel } from '../conversationChannel'
|
|
3
3
|
|
|
4
|
+
export interface ListConversationsParams {
|
|
5
|
+
page?: number
|
|
6
|
+
limit?: number
|
|
7
|
+
waitingHuman?: boolean
|
|
8
|
+
search?: string
|
|
9
|
+
/**
|
|
10
|
+
* Recortes que só o produto conhece (tipo de financiamento, carteira, campanha) repassados
|
|
11
|
+
* crus ao backend dele. É o que evita o vocabulário de uma vertical virar campo fixo aqui:
|
|
12
|
+
* o pacote transporta o filtro sem saber o que ele significa.
|
|
13
|
+
*/
|
|
14
|
+
filters?: Record<string, string | undefined>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Página com o total, para a UI conseguir desenhar controles de paginação.
|
|
19
|
+
*
|
|
20
|
+
* `fetchConversations` devolve isto **ou** o array puro de antes: implementações existentes
|
|
21
|
+
* continuam válidas sem mudar uma linha, e quem precisa paginar passa a ter o total. Sem a união
|
|
22
|
+
* seria impossível saber se um retorno curto é a última página ou uma página cheia por acaso.
|
|
23
|
+
*/
|
|
24
|
+
export interface ConversationPage {
|
|
25
|
+
conversations: ConversationSummary[]
|
|
26
|
+
total: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ListDocumentsParams {
|
|
30
|
+
search?: string
|
|
31
|
+
page?: number
|
|
32
|
+
/** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
|
|
33
|
+
limit?: number
|
|
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
|
+
*/
|
|
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
|
|
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>>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
|
|
59
|
+
export interface CompanyDocument extends ConversationDocument {
|
|
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
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CompanyDocumentPage {
|
|
69
|
+
documents: CompanyDocument[]
|
|
70
|
+
total: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ConversationDocumentPage {
|
|
74
|
+
documents: ConversationDocument[]
|
|
75
|
+
total: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Template disponível para envio a partir da inbox. Distinto do `WhatsAppTemplateSummary` de
|
|
80
|
+
* `settings/`, e de propósito: aquele serve ao formulário que **edita** template e carrega o que
|
|
81
|
+
* a edição precisa (`shortId`, `variableCount`); este serve a quem só vai **escolher um para
|
|
82
|
+
* enviar**, e pedir os campos de edição obrigaria todo host a produzi-los sem uso.
|
|
83
|
+
*/
|
|
84
|
+
export interface ConversationTemplate {
|
|
85
|
+
name: string
|
|
86
|
+
language: string
|
|
87
|
+
status: string
|
|
88
|
+
category?: string
|
|
89
|
+
bodyText?: string | null
|
|
90
|
+
}
|
|
91
|
+
|
|
4
92
|
export interface ConversationsApi {
|
|
5
93
|
fetchMessages(conversationId: string, params?: { limit?: number; before?: string }): Promise<MessagePayload[]>
|
|
6
|
-
fetchConversations(params?:
|
|
7
|
-
page?: number
|
|
8
|
-
limit?: number
|
|
9
|
-
waitingHuman?: boolean
|
|
10
|
-
search?: string
|
|
11
|
-
}): Promise<ConversationSummary[]>
|
|
94
|
+
fetchConversations(params?: ListConversationsParams): Promise<ConversationSummary[] | ConversationPage>
|
|
12
95
|
sendMessage(conversationId: string, text: string): Promise<MessagePayload>
|
|
13
96
|
sendMedia(
|
|
14
97
|
conversationId: string,
|
|
15
98
|
data: { base64: string; mimeType: string; filename: string; caption?: string },
|
|
16
99
|
): Promise<MessagePayload>
|
|
100
|
+
/**
|
|
101
|
+
* `templateName` é opcional porque reabrir a janela é a operação, e escolher *qual* template a
|
|
102
|
+
* usa nem sempre é decisão da UI: backends que guardam um template padrão configurado só
|
|
103
|
+
* precisam do "reabra". Exigir o nome obrigaria toda inbox a listar templates antes de poder
|
|
104
|
+
* mandar o primeiro — e a listagem é `listTemplates?`, opcional.
|
|
105
|
+
*/
|
|
17
106
|
sendTemplate(
|
|
18
107
|
conversationId: string,
|
|
19
|
-
data: { templateName
|
|
108
|
+
data: { templateName?: string; languageCode?: string; bodyParams?: string[] },
|
|
20
109
|
): Promise<void>
|
|
21
110
|
markRead(conversationId: string): Promise<void>
|
|
22
111
|
getContext(conversationId: string): Promise<Record<string, unknown>>
|
|
23
|
-
getDocuments(
|
|
24
|
-
|
|
112
|
+
getDocuments(
|
|
113
|
+
conversationId: string,
|
|
114
|
+
params?: ListDocumentsParams,
|
|
115
|
+
): Promise<ConversationDocument[] | ConversationDocumentPage>
|
|
116
|
+
/**
|
|
117
|
+
* `disposition` decide entre abrir no navegador e baixar. É o backend que assina a URL e grava
|
|
118
|
+
* o `Content-Disposition` nela, então a escolha precisa viajar na chamada — depois de assinada
|
|
119
|
+
* não há como o cliente mudá-la. Ausente = o padrão do host.
|
|
120
|
+
*/
|
|
121
|
+
getDocumentUrl(uploadId: string, disposition?: 'inline' | 'attachment'): Promise<string>
|
|
122
|
+
/**
|
|
123
|
+
* Baixa vários arquivos num zip único.
|
|
124
|
+
*
|
|
125
|
+
* **Opcional por capacidade:** montar zip exige o host LER os bytes do storage, o que nem toda
|
|
126
|
+
* instalação faz — as que só assinam URL não conseguem. Ausente, o painel esconde a seleção em
|
|
127
|
+
* lote em vez de oferecer um botão que falha.
|
|
128
|
+
*/
|
|
129
|
+
downloadDocumentsArchive?(conversationId: string, uploadIds: readonly string[]): Promise<Blob>
|
|
130
|
+
/**
|
|
131
|
+
* Biblioteca de TODAS as conversas, para uma tela de Documentos fora do atendimento.
|
|
132
|
+
*
|
|
133
|
+
* Opcional por capacidade: host que só expõe anexo dentro da conversa não implementa, e o
|
|
134
|
+
* componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
|
|
135
|
+
*/
|
|
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>
|
|
25
159
|
getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Operações de atendimento humano. **Opcionais por capacidade, não por descuido:** nem toda
|
|
163
|
+
* inbox tem fila humana — um canal só-bot, ou um chat de site sem operador, não sabe o que é
|
|
164
|
+
* assumir conversa. Quem não implementa não ganha o botão, em vez de ganhar um botão que
|
|
165
|
+
* estoura no clique. Os hooks devolvem `undefined` para a ação ausente, e é isso que a UI
|
|
166
|
+
* consulta para decidir se desenha a afordância.
|
|
167
|
+
*/
|
|
168
|
+
takeover?(conversationId: string): Promise<void>
|
|
169
|
+
release?(conversationId: string): Promise<void>
|
|
170
|
+
/** Encerra o atendimento. Despedida, se houver, é decisão do host — o pacote não a inventa. */
|
|
171
|
+
finalize?(conversationId: string): Promise<void>
|
|
172
|
+
|
|
173
|
+
markAllRead?(): Promise<void>
|
|
174
|
+
listTemplates?(): Promise<ConversationTemplate[]>
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Transcrição completa gerada pelo servidor. Existe ao lado de `buildTranscriptText`, que monta
|
|
178
|
+
* a partir das mensagens já em memória: a tela costuma ter só a última página carregada, e
|
|
179
|
+
* exportar dali entregaria um recorte parcial com cara de histórico inteiro. Opcional porque
|
|
180
|
+
* nem todo backend expõe a rota — quem não tem continua usando o builder local.
|
|
181
|
+
*/
|
|
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>
|
|
26
194
|
}
|
|
27
195
|
|
|
28
196
|
/**
|
|
@@ -76,6 +244,13 @@ export interface ConversationSummary {
|
|
|
76
244
|
waitingHuman: boolean
|
|
77
245
|
unread: number
|
|
78
246
|
currentState: string
|
|
247
|
+
/**
|
|
248
|
+
* Atributos que só o produto conhece e desenha (tipo de financiamento, carteira, campanha). É a
|
|
249
|
+
* contraparte de leitura do `filters` de `ListConversationsParams`: o pacote transporta e nunca
|
|
250
|
+
* interpreta. Sem isto, exibir um selo próprio na linha exigiria o host manter uma segunda
|
|
251
|
+
* consulta paralela à mesma listagem — a implementação duplicada que o pacote existe para evitar.
|
|
252
|
+
*/
|
|
253
|
+
attributes?: Record<string, string | undefined>
|
|
79
254
|
}
|
|
80
255
|
|
|
81
256
|
export interface ConversationDocument {
|
|
@@ -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
|
+
})
|