@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
- package/dist/index.d.ts +142 -9
- package/dist/index.js +374 -66
- package/dist/preview/index.d.ts +122 -3
- package/dist/preview/index.js +137 -21
- package/dist/styles.css +112 -0
- package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
- package/package.json +2 -2
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/ConversationContextPanel.tsx +205 -52
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/MediaRenderer.tsx +37 -6
- package/src/MessageBubble.tsx +26 -3
- package/src/MessageComposer.tsx +21 -2
- package/src/Wallpaper.tsx +27 -13
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +11 -0
- package/src/preview/ConversationPreview.tsx +13 -4
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +89 -0
- package/src/preview/createPreviewBridgeClient.ts +37 -1
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +89 -0
- package/src/preview/createPreviewWebhookClient.ts +91 -0
- package/src/preview/index.ts +9 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/types.ts +12 -1
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/styles.css +122 -0
- package/src/types.ts +26 -0
|
@@ -62,4 +62,61 @@ describe('buildTranscriptFilename', () => {
|
|
|
62
62
|
`conversa-${NUMBER}-2026-07-27.txt`,
|
|
63
63
|
)
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* O áudio saía como `<audio>` no arquivo baixado. Um histórico onde o pedido do cliente aparece
|
|
68
|
+
* como marcador vazio é inútil justamente para o caso que motiva o download: auditoria e repasse.
|
|
69
|
+
*/
|
|
70
|
+
it('escreve a transcrição do áudio no lugar do marcador de tipo', () => {
|
|
71
|
+
const texto = buildTranscriptText({
|
|
72
|
+
whatsappNumber: '5511999999999',
|
|
73
|
+
messages: [
|
|
74
|
+
{
|
|
75
|
+
id: '1',
|
|
76
|
+
type: 'audio',
|
|
77
|
+
direction: 'inbound',
|
|
78
|
+
sender: 'customer',
|
|
79
|
+
timestamp: '2026-07-31T12:00:00.000Z',
|
|
80
|
+
transcription: { status: 'done', text: 'quero dois quilos de arroz' },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(texto).toContain('[áudio] quero dois quilos de arroz')
|
|
86
|
+
expect(texto).not.toContain('<audio>')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('mantém o marcador de tipo quando o áudio não foi transcrito', () => {
|
|
90
|
+
const texto = buildTranscriptText({
|
|
91
|
+
whatsappNumber: '5511999999999',
|
|
92
|
+
messages: [
|
|
93
|
+
{ id: '1', type: 'audio', direction: 'inbound', sender: 'customer', timestamp: '2026-07-31T12:00:00.000Z' },
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(texto).toContain('<audio>')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A rota de export do módulo devolve `createdAt`, não `sentAt` — quem mapeia esperando `sentAt`
|
|
102
|
+
* entrega `undefined` aqui, e o arquivo saía com "Invalid Date" em todas as linhas.
|
|
103
|
+
*/
|
|
104
|
+
it('escreve "data indisponível" em vez de Invalid Date quando o horário não vem', () => {
|
|
105
|
+
const texto = buildTranscriptText({
|
|
106
|
+
whatsappNumber: '5511999999999',
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
id: '1',
|
|
110
|
+
type: 'text',
|
|
111
|
+
direction: 'inbound',
|
|
112
|
+
sender: 'customer',
|
|
113
|
+
content: 'ola',
|
|
114
|
+
timestamp: undefined as unknown as string,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(texto).toContain('data indisponível')
|
|
120
|
+
expect(texto).not.toContain('Invalid Date')
|
|
121
|
+
})
|
|
65
122
|
})
|
|
@@ -33,16 +33,41 @@ export function buildTranscriptText(params: BuildTranscriptTextParams): string {
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const lines = params.messages.map((message) => {
|
|
36
|
-
const stamp = new Date(message.timestamp).toLocaleString('pt-BR')
|
|
37
36
|
const author = message.agentName ?? SENDER_LABEL[message.sender]
|
|
38
|
-
|
|
39
|
-
const body = message.content ?? message.caption ?? `<${message.type}>`
|
|
40
|
-
return `[${stamp}] ${author}: ${body}`
|
|
37
|
+
return `[${formatStamp(message.timestamp)}] ${author}: ${bodyOf(message)}`
|
|
41
38
|
})
|
|
42
39
|
|
|
43
40
|
return [...header, ...lines].join('\n')
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Corpo da linha no arquivo.
|
|
45
|
+
*
|
|
46
|
+
* Áudio transcrito entra com o TEXTO, não como `<audio>`. Quem baixa a conversa quer lê-la, e um
|
|
47
|
+
* histórico onde o pedido do cliente aparece como marcador vazio é inútil justamente para o caso que
|
|
48
|
+
* motiva o download: auditoria e repasse. O prefixo `[áudio]` fica na frente para a linha não passar
|
|
49
|
+
* por mensagem digitada — quem audita precisa saber que aquilo foi falado e transcrito por máquina.
|
|
50
|
+
*/
|
|
51
|
+
function bodyOf(message: MessagePayload): string {
|
|
52
|
+
const transcript = message.transcription?.text?.trim()
|
|
53
|
+
if (message.type === 'audio' && transcript) return `[áudio] ${transcript}`
|
|
54
|
+
|
|
55
|
+
// Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
|
|
56
|
+
return message.content ?? message.caption ?? `<${message.type}>`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `Invalid Date` no arquivo é pior do que data ausente: parece dado corrompido e põe em dúvida o
|
|
61
|
+
* resto do transcript. E acontece de verdade — a rota de export do módulo devolve `createdAt`, não
|
|
62
|
+
* `sentAt`, então quem mapeia esperando `sentAt` recebe `undefined` aqui.
|
|
63
|
+
*/
|
|
64
|
+
function formatStamp(timestamp: string | undefined): string {
|
|
65
|
+
if (!timestamp) return 'data indisponível'
|
|
66
|
+
|
|
67
|
+
const parsed = new Date(timestamp)
|
|
68
|
+
return Number.isNaN(parsed.getTime()) ? 'data indisponível' : parsed.toLocaleString('pt-BR')
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
|
|
47
72
|
const stamp = generatedAt.toISOString().slice(0, 10)
|
|
48
73
|
return `conversa-${whatsappNumber}-${stamp}.txt`
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mantém a conversa no fim, como todo mensageiro.
|
|
5
|
+
*
|
|
6
|
+
* Abrir uma conversa no topo do histórico é errado por um motivo simples: o que interessa ao
|
|
7
|
+
* atendente é a última mensagem, e ele teria que rolar por semanas de conversa para chegar nela.
|
|
8
|
+
*
|
|
9
|
+
* Existe no pacote, e não em cada host, porque a regra tem duas sutilezas que se descobre só
|
|
10
|
+
* errando — o salto instantâneo na troca de conversa e o respeito a quem rolou para trás — e
|
|
11
|
+
* reimplementá-las em cada inbox garante que uma delas fique de fora.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Distância do fim, em pixels, dentro da qual ainda consideramos o operador "acompanhando".
|
|
16
|
+
*
|
|
17
|
+
* Não é zero porque o navegador arredonda `scrollTop` fracionário em telas com zoom ou densidade
|
|
18
|
+
* alta: exigir o fim exato faria o painel achar que o operador rolou para trás sem ele ter tocado
|
|
19
|
+
* em nada, e a próxima mensagem não apareceria.
|
|
20
|
+
*/
|
|
21
|
+
const NEAR_BOTTOM_THRESHOLD_PX = 120
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Toda rolagem aqui é `'auto'` — nunca `'smooth'`.
|
|
25
|
+
*
|
|
26
|
+
* Tentamos suave para mensagem nova e medimos: em ambiente onde a rolagem suave está desligada, o
|
|
27
|
+
* `scrollTo({ behavior: 'smooth' })` **não faz nada e não avisa** — a mensagem nova simplesmente não
|
|
28
|
+
* entra na vista. E não dá para detectar isso pelo `prefers-reduced-motion`: no navegador em que
|
|
29
|
+
* reproduzimos, a media query respondia `false` e o smooth continuava sendo no-op.
|
|
30
|
+
*
|
|
31
|
+
* Trocar uma animação cosmética por garantia de que o operador vê a mensagem é barato: seguir uma
|
|
32
|
+
* mensagem nova salta a altura de uma bolha, que é quase imperceptível de qualquer forma.
|
|
33
|
+
*/
|
|
34
|
+
const SCROLL_BEHAVIOR: ScrollBehavior = 'auto'
|
|
35
|
+
|
|
36
|
+
export type UseScrollToLatestMessageParams = {
|
|
37
|
+
/** Troca de conversa. Muda ⇒ salto instantâneo para o fim. */
|
|
38
|
+
readonly conversationId: string | undefined
|
|
39
|
+
/** Quantidade de mensagens carregadas. Cresce ⇒ acompanha o fim, se o operador estiver lá. */
|
|
40
|
+
readonly messageCount: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type UseScrollToLatestMessageResult = {
|
|
44
|
+
/** Vai no elemento que rola — tipicamente o `ConversationWallpaper`. */
|
|
45
|
+
readonly containerRef: RefObject<HTMLDivElement | null>
|
|
46
|
+
/** Ligue no `onScroll` do mesmo elemento: é o que detecta o operador lendo o histórico. */
|
|
47
|
+
readonly handleScroll: (event: UIEvent<HTMLDivElement>) => void
|
|
48
|
+
/** `true` quando o operador rolou para trás — serve a um botão "ir para a última". */
|
|
49
|
+
readonly isAwayFromBottom: boolean
|
|
50
|
+
readonly scrollToBottom: (behavior?: ScrollBehavior) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useScrollToLatestMessage({
|
|
54
|
+
conversationId,
|
|
55
|
+
messageCount,
|
|
56
|
+
}: UseScrollToLatestMessageParams): UseScrollToLatestMessageResult {
|
|
57
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
58
|
+
const [isAwayFromBottom, setIsAwayFromBottom] = useState(false)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Espelha `isAwayFromBottom` para o efeito de mensagens novas ler o valor atual sem depender dele.
|
|
62
|
+
*
|
|
63
|
+
* Se o efeito dependesse do estado, cada rolagem do operador o re-disparava e o puxava de volta
|
|
64
|
+
* para o fim — exatamente o que a checagem existe para evitar.
|
|
65
|
+
*/
|
|
66
|
+
const isAwayFromBottomRef = useRef(false)
|
|
67
|
+
|
|
68
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = SCROLL_BEHAVIOR) => {
|
|
69
|
+
const container = containerRef.current
|
|
70
|
+
if (!container) return
|
|
71
|
+
container.scrollTo({ top: container.scrollHeight, behavior })
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
|
75
|
+
const target = event.currentTarget
|
|
76
|
+
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
|
77
|
+
const away = distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX
|
|
78
|
+
|
|
79
|
+
isAwayFromBottomRef.current = away
|
|
80
|
+
setIsAwayFromBottom((current) => (current === away ? current : away))
|
|
81
|
+
}, [])
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Qual conversa já recebeu o salto de abertura.
|
|
85
|
+
*
|
|
86
|
+
* É o que separa "abriu a conversa" de "chegou mensagem", e não dá para usar só `conversationId`
|
|
87
|
+
* num efeito próprio: quando a conversa troca, a lista de mensagens ainda está vazia, então um
|
|
88
|
+
* salto ali rola um container sem conteúdo e não faz nada. O salto real precisa esperar a primeira
|
|
89
|
+
* leva de mensagens — e foi exatamente isso que deixou a conversa abrindo no topo.
|
|
90
|
+
*/
|
|
91
|
+
const jumpedForConversationRef = useRef<string | undefined>(undefined)
|
|
92
|
+
|
|
93
|
+
useLayoutEffect(() => {
|
|
94
|
+
const isNewConversation = jumpedForConversationRef.current !== conversationId
|
|
95
|
+
|
|
96
|
+
if (isNewConversation) {
|
|
97
|
+
// Zera antes de qualquer coisa: "rolado para trás" da conversa anterior não pode bloquear o
|
|
98
|
+
// salto de abertura desta.
|
|
99
|
+
isAwayFromBottomRef.current = false
|
|
100
|
+
setIsAwayFromBottom(false)
|
|
101
|
+
|
|
102
|
+
// Sem mensagens ainda — o salto acontece quando a primeira leva chegar.
|
|
103
|
+
if (messageCount === 0) return
|
|
104
|
+
|
|
105
|
+
jumpedForConversationRef.current = conversationId
|
|
106
|
+
/**
|
|
107
|
+
* `'auto'`, sempre. Animar a rolagem por meses de histórico demora, mostra um borrão de
|
|
108
|
+
* mensagens antigas que ninguém pediu, e some por completo onde a rolagem suave está desligada
|
|
109
|
+
* (`prefers-reduced-motion`, alguns navegadores automatizados) — a conversa simplesmente
|
|
110
|
+
* abriria no topo. Abertura é salto, não animação.
|
|
111
|
+
*/
|
|
112
|
+
scrollToBottom(SCROLL_BEHAVIOR)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mensagem nova: acompanha, mas só se o operador já estava no fim.
|
|
118
|
+
*
|
|
119
|
+
* Puxar a rolagem de quem está lendo o histórico é pior do que não mostrar a mensagem — ele perde
|
|
120
|
+
* a posição e não sabe por quê. Quem rolou para trás recebe `isAwayFromBottom` e decide.
|
|
121
|
+
*/
|
|
122
|
+
if (isAwayFromBottomRef.current) return
|
|
123
|
+
scrollToBottom(SCROLL_BEHAVIOR)
|
|
124
|
+
}, [conversationId, messageCount, scrollToBottom])
|
|
125
|
+
|
|
126
|
+
return { containerRef, handleScroll, isAwayFromBottom, scrollToBottom }
|
|
127
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { InteractiveMessage, DEFAULT_INTERACTIVE_MESSAGE_LABELS } from './Intera
|
|
|
3
3
|
export { ConversationWallpaper } from './Wallpaper'
|
|
4
4
|
export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
export { AudioPlayer } from './AudioPlayer'
|
|
6
|
+
export { AudioTranscription } from './AudioTranscription'
|
|
6
7
|
export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
|
|
7
8
|
export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
|
|
8
9
|
export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
@@ -77,6 +78,7 @@ export { ConversationContextPanel, DEFAULT_CONVERSATION_CONTEXT_LABELS } from '.
|
|
|
77
78
|
export type {
|
|
78
79
|
ConversationContextPanelProps,
|
|
79
80
|
ConversationContextEntry,
|
|
81
|
+
ConversationContextStatus,
|
|
80
82
|
ConversationContextPanelLabels,
|
|
81
83
|
ConversationContextPanelClassNames,
|
|
82
84
|
} from './ConversationContextPanel'
|
|
@@ -108,6 +110,7 @@ export { ConversationsProvider, useConversations } from './providers/Conversatio
|
|
|
108
110
|
export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
|
|
109
111
|
export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
|
|
110
112
|
export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
|
|
113
|
+
export { TranscriptionSettingsForm } from './settings/TranscriptionSettingsForm'
|
|
111
114
|
export {
|
|
112
115
|
WhatsAppTemplatesSettings,
|
|
113
116
|
TEMPLATE_SETTINGS_TAB,
|
|
@@ -118,6 +121,7 @@ export { TopicsForm } from './settings/TopicsForm'
|
|
|
118
121
|
// Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
|
|
119
122
|
// produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
|
|
120
123
|
export { useConversationMessages } from './hooks/useConversationMessages'
|
|
124
|
+
export { useScrollToLatestMessage } from './hooks/useScrollToLatestMessage'
|
|
121
125
|
export { useConversationList } from './hooks/useConversationList'
|
|
122
126
|
export { useConversationContext } from './hooks/useConversationContext'
|
|
123
127
|
export { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
@@ -132,6 +136,7 @@ export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './li
|
|
|
132
136
|
|
|
133
137
|
export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
|
|
134
138
|
export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
|
|
139
|
+
export type { MessageTranscription, TranscriptionStatus, TranscriptionMode } from './types'
|
|
135
140
|
export type {
|
|
136
141
|
ConversationsApi,
|
|
137
142
|
SSEProvider,
|
|
@@ -152,6 +157,7 @@ export type { MessageBubbleProps } from './MessageBubble'
|
|
|
152
157
|
export type { ConversationWallpaperProps } from './Wallpaper'
|
|
153
158
|
export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
|
|
154
159
|
export type { AudioPlayerProps } from './AudioPlayer'
|
|
160
|
+
export type { AudioTranscriptionProps } from './AudioTranscription'
|
|
155
161
|
export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
|
|
156
162
|
export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
|
|
157
163
|
export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
|
|
@@ -181,6 +187,10 @@ export type {
|
|
|
181
187
|
WhatsAppCreateTemplateFormLabels,
|
|
182
188
|
} from './settings/WhatsAppCreateTemplateForm'
|
|
183
189
|
export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
|
|
190
|
+
export type {
|
|
191
|
+
TranscriptionSettingsFormProps,
|
|
192
|
+
TranscriptionSettingsFormLabels,
|
|
193
|
+
} from './settings/TranscriptionSettingsForm'
|
|
184
194
|
export type {
|
|
185
195
|
WhatsAppTemplatesSettingsProps,
|
|
186
196
|
WhatsAppTemplatesSettingsLabels,
|
|
@@ -191,6 +201,7 @@ export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/To
|
|
|
191
201
|
export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
|
|
192
202
|
export type { UseConversationListParams, UseConversationListResult } from './hooks/useConversationList'
|
|
193
203
|
export type { UseConversationContextResult } from './hooks/useConversationContext'
|
|
204
|
+
export type { UseScrollToLatestMessageParams, UseScrollToLatestMessageResult } from './hooks/useScrollToLatestMessage'
|
|
194
205
|
export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
|
|
195
206
|
export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
|
|
196
207
|
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
@@ -40,6 +40,7 @@ export type ConversationPreviewProps = {
|
|
|
40
40
|
* inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
|
|
41
41
|
* botão que não existe do que um que falha ao ser tocado.
|
|
42
42
|
*/
|
|
43
|
+
/** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
|
|
43
44
|
uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -246,11 +247,19 @@ export function ConversationPreview({
|
|
|
246
247
|
}
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
/**
|
|
251
|
+
* O cliente do preview sabe subir mídia sozinho; a prop é só para quem quer outro destino.
|
|
252
|
+
*
|
|
253
|
+
* Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
|
|
254
|
+
* o upload — de onde veio a divergência entre dois simuladores da mesma casa.
|
|
255
|
+
*/
|
|
256
|
+
const uploadFile = uploadMedia ?? client.uploadMedia
|
|
257
|
+
|
|
249
258
|
async function handleAttach(file: File): Promise<void> {
|
|
250
|
-
if (!
|
|
259
|
+
if (!uploadFile) return
|
|
251
260
|
setFailure(undefined)
|
|
252
261
|
try {
|
|
253
|
-
const uploaded = await
|
|
262
|
+
const uploaded = await uploadFile(file)
|
|
254
263
|
await client.sendMedia({
|
|
255
264
|
mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
|
|
256
265
|
mediaId: uploaded.mediaId,
|
|
@@ -303,7 +312,7 @@ export function ConversationPreview({
|
|
|
303
312
|
|
|
304
313
|
<MessageComposer
|
|
305
314
|
onSend={(text) => void handleSend(text)}
|
|
306
|
-
onAttach={
|
|
315
|
+
onAttach={uploadFile ? (file) => void handleAttach(file) : undefined}
|
|
307
316
|
/* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
|
|
308
317
|
que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
|
|
309
318
|
microfone está quebrado. */
|
|
@@ -311,7 +320,7 @@ export function ConversationPreview({
|
|
|
311
320
|
isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
|
|
312
321
|
}
|
|
313
322
|
idleAction={
|
|
314
|
-
|
|
323
|
+
uploadFile ? (
|
|
315
324
|
<AudioRecorderButton
|
|
316
325
|
onRecorded={(file) => void handleAttach(file)}
|
|
317
326
|
onFailure={(message) => setFailure(message)}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ConversationSimulatorPanel, type ConversationSimulatorPanelProps } from './ConversationSimulatorPanel'
|
|
5
|
+
import { createMockSSEProvider } from './createMockSSEProvider'
|
|
6
|
+
import { createPreviewStore } from './previewStore'
|
|
7
|
+
|
|
8
|
+
function markupOf(overrides: Partial<ConversationSimulatorPanelProps> = {}): string {
|
|
9
|
+
const store = createPreviewStore({ conversations: [], messages: {} })
|
|
10
|
+
|
|
11
|
+
return renderToStaticMarkup(
|
|
12
|
+
<ConversationSimulatorPanel
|
|
13
|
+
conversationId="5511900000042"
|
|
14
|
+
onClose={() => {}}
|
|
15
|
+
client={{ sendText: async () => {}, sendInteractiveReply: async () => {} } as never}
|
|
16
|
+
sse={createMockSSEProvider({ store })}
|
|
17
|
+
loadMessages={async () => []}
|
|
18
|
+
{...overrides}
|
|
19
|
+
/>,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('ConversationSimulatorPanel', () => {
|
|
24
|
+
it('mostra o telefone formatado pelo host, e não o id cru', () => {
|
|
25
|
+
expect(markupOf({ displayNumber: '+55 (11) 90000-0042' })).toContain('+55 (11) 90000-0042')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('cai no id da conversa quando o host não formata', () => {
|
|
29
|
+
expect(markupOf()).toContain('5511900000042')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('avisa para onde a mensagem vai, para ninguém achar que é conversa de mentira', () => {
|
|
33
|
+
expect(markupOf()).toContain('entrega no webhook real')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('dá nome acessível ao botão de fechar, que só tem ícone', () => {
|
|
37
|
+
expect(markupOf()).toContain('aria-label="Fechar simulador"')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('aceita rótulos parciais do host sem exigir o conjunto inteiro', () => {
|
|
41
|
+
const markup = markupOf({ labels: { title: 'Testar fluxo' } })
|
|
42
|
+
|
|
43
|
+
expect(markup).toContain('Testar fluxo')
|
|
44
|
+
// O que não foi sobrescrito continua no default.
|
|
45
|
+
expect(markup).toContain('aria-label="Fechar simulador"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renderiza ação extra do host no cabeçalho', () => {
|
|
49
|
+
expect(markupOf({ headerActions: <button type="button">Rodar roteiro</button> })).toContain('Rodar roteiro')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('nomeia o próprio aside, para o leitor de tela distinguir do transcript ao lado', () => {
|
|
53
|
+
expect(markupOf()).toContain('aria-label="Simulador do cliente"')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* This source code is proprietary and confidential. Unauthorized copying,
|
|
5
|
+
* modification, distribution, or use of this file, via any medium, is
|
|
6
|
+
* strictly prohibited without prior written permission from Ada Technology.
|
|
7
|
+
*
|
|
8
|
+
* Author: Anderson Filho <andersonfrfilho@gmail.com>
|
|
9
|
+
*
|
|
10
|
+
* Painel lateral do simulador de cliente: a moldura em volta do `ConversationPreview`.
|
|
11
|
+
*
|
|
12
|
+
* Existe como componente do pacote porque cada produto tinha reescrito a mesma moldura —
|
|
13
|
+
* `<aside>`, cabeçalho com título e telefone, botão de fechar — e as cópias divergiram. Uma delas
|
|
14
|
+
* migrou para o cliente-ponte (assinatura no servidor) e a outra ficou assinando no navegador, com
|
|
15
|
+
* o app secret publicado no bundle. É o tipo de correção que precisa chegar a todo mundo de uma vez.
|
|
16
|
+
*
|
|
17
|
+
* Mora DENTRO da tela de conversas, e não numa aba própria, por um motivo prático: o token do
|
|
18
|
+
* atendente costuma viver em `sessionStorage`, que é por aba. Numa aba separada o simulador nascia
|
|
19
|
+
* sem sessão, o transcript nunca carregava, e o sintoma era "mandei e não aconteceu nada".
|
|
20
|
+
*
|
|
21
|
+
* O efeito de cada envio aparece na thread ao lado pelo mesmo stream que a inbox já assina — este
|
|
22
|
+
* painel não abre conexão própria.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ReactNode } from 'react'
|
|
26
|
+
|
|
27
|
+
import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
|
|
28
|
+
|
|
29
|
+
export type ConversationSimulatorPanelLabels = {
|
|
30
|
+
readonly title: string
|
|
31
|
+
/** Complementa o telefone no subtítulo, explicando para onde a mensagem realmente vai. */
|
|
32
|
+
readonly destinationHint: string
|
|
33
|
+
readonly close: string
|
|
34
|
+
readonly placeholder: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorPanelLabels = {
|
|
38
|
+
title: 'Simulador do cliente',
|
|
39
|
+
destinationHint: 'entrega no webhook real',
|
|
40
|
+
close: 'Fechar simulador',
|
|
41
|
+
placeholder: 'Escreva como o cliente…',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
|
|
45
|
+
readonly onClose: () => void
|
|
46
|
+
/**
|
|
47
|
+
* Telefone já formatado para leitura. É o host que formata: máscara de telefone é convenção
|
|
48
|
+
* regional, e o pacote não tem como saber a do produto.
|
|
49
|
+
*/
|
|
50
|
+
readonly displayNumber?: string
|
|
51
|
+
readonly labels?: Partial<ConversationSimulatorPanelLabels>
|
|
52
|
+
/** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
|
|
53
|
+
readonly headerActions?: ReactNode
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ConversationSimulatorPanel({
|
|
57
|
+
onClose,
|
|
58
|
+
displayNumber,
|
|
59
|
+
labels,
|
|
60
|
+
headerActions,
|
|
61
|
+
...previewProps
|
|
62
|
+
}: ConversationSimulatorPanelProps) {
|
|
63
|
+
const text = { ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, ...labels }
|
|
64
|
+
const subtitle = [displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<aside className="cv-simulator-panel" aria-label={text.title}>
|
|
68
|
+
<header className="cv-simulator-panel__header">
|
|
69
|
+
<div className="cv-simulator-panel__heading">
|
|
70
|
+
<h2 className="cv-simulator-panel__title">{text.title}</h2>
|
|
71
|
+
<p className="cv-simulator-panel__subtitle">{subtitle}</p>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="cv-simulator-panel__actions">
|
|
74
|
+
{headerActions}
|
|
75
|
+
<button type="button" onClick={onClose} title={text.close} aria-label={text.close} className="cv-simulator-panel__close">
|
|
76
|
+
{/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
|
|
77
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
78
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
79
|
+
</svg>
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<div className="cv-simulator-panel__body">
|
|
85
|
+
<ConversationPreview {...previewProps} placeholder={text.placeholder} />
|
|
86
|
+
</div>
|
|
87
|
+
</aside>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
19
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
createPreviewMediaPoster,
|
|
21
|
+
defaultMediaUploadUrl,
|
|
22
|
+
type PreviewWebhookClient,
|
|
23
|
+
type SendPreviewMediaParams,
|
|
24
|
+
} from './createPreviewWebhookClient'
|
|
25
|
+
import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
|
|
@@ -50,6 +56,18 @@ export type CreatePreviewBridgeClientParams = {
|
|
|
50
56
|
readonly endpointUrl?: string
|
|
51
57
|
readonly headers?: Readonly<Record<string, string>>
|
|
52
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>
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
|
|
@@ -72,9 +90,26 @@ function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewI
|
|
|
72
90
|
}
|
|
73
91
|
}
|
|
74
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
|
+
|
|
75
109
|
export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
|
|
76
110
|
const send = params.sendCommand ?? buildFetchSender(params)
|
|
77
111
|
const from = params.from
|
|
112
|
+
const uploadMedia = resolveBridgeUpload(params)
|
|
78
113
|
|
|
79
114
|
return {
|
|
80
115
|
sendText: (text) => send({ kind: 'text', from, text }),
|
|
@@ -82,6 +117,7 @@ export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParam
|
|
|
82
117
|
sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
|
|
83
118
|
sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
|
|
84
119
|
sendMedia: (media) => send({ kind: 'media', from, ...media }),
|
|
120
|
+
...(uploadMedia ? { uploadMedia } : {}),
|
|
85
121
|
}
|
|
86
122
|
}
|
|
87
123
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
|
|
3
|
+
*
|
|
4
|
+
* O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
|
|
5
|
+
* ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
|
|
6
|
+
* lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
|
|
7
|
+
*
|
|
8
|
+
* Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
|
|
9
|
+
* estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
|
|
10
|
+
* a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Do `contracts`, que este pacote já consome — não uma cópia.
|
|
15
|
+
*
|
|
16
|
+
* A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
|
|
17
|
+
* duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
|
|
18
|
+
* Contrato compartilhado é o que o `contracts` existe para guardar.
|
|
19
|
+
*/
|
|
20
|
+
export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
|
|
21
|
+
import { toPreviewMediaId } from '@adatechnology/meta-whatsapp-contracts'
|
|
22
|
+
|
|
23
|
+
export type PreviewUploadedMedia = {
|
|
24
|
+
readonly mediaId: string
|
|
25
|
+
readonly mimeType?: string
|
|
26
|
+
readonly filename?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PreviewMediaUploadRequest = {
|
|
30
|
+
readonly base64: string
|
|
31
|
+
readonly mimeType: string
|
|
32
|
+
readonly filename: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CreatePreviewMediaUploaderParams = {
|
|
36
|
+
/**
|
|
37
|
+
* Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
|
|
38
|
+
*
|
|
39
|
+
* Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
|
|
40
|
+
* HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
|
|
41
|
+
* escolher por elas.
|
|
42
|
+
*/
|
|
43
|
+
readonly upload: (request: PreviewMediaUploadRequest) => Promise<{ uploadId: string }>
|
|
44
|
+
/** Nome usado quando o gravador entrega o áudio sem nome próprio. */
|
|
45
|
+
readonly fallbackFilename?: string
|
|
46
|
+
readonly fallbackMimeType?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Converte em blocos, não com `String.fromCharCode(...bytes)` de uma vez.
|
|
51
|
+
*
|
|
52
|
+
* Espalhar centenas de milhares de bytes como argumentos estoura o limite da engine — poucos segundos
|
|
53
|
+
* de áudio já chegam perto. O sintoma seria `RangeError` só nos arquivos grandes: passa no teste com
|
|
54
|
+
* um clipe curto e falha na primeira gravação de verdade.
|
|
55
|
+
*/
|
|
56
|
+
const CHUNK_SIZE = 8192
|
|
57
|
+
|
|
58
|
+
async function fileToBase64(file: File): Promise<string> {
|
|
59
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
60
|
+
let binary = ''
|
|
61
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
|
|
62
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE))
|
|
63
|
+
}
|
|
64
|
+
return btoa(binary)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPreviewMediaUploader(
|
|
68
|
+
params: CreatePreviewMediaUploaderParams,
|
|
69
|
+
): (file: File) => Promise<PreviewUploadedMedia> {
|
|
70
|
+
const fallbackMimeType = params.fallbackMimeType ?? 'audio/ogg'
|
|
71
|
+
const fallbackFilename = params.fallbackFilename ?? 'audio.ogg'
|
|
72
|
+
|
|
73
|
+
return async function uploadPreviewMedia(file: File): Promise<PreviewUploadedMedia> {
|
|
74
|
+
// Gravação de voz chega sem nome, e sem mime em navegador antigo.
|
|
75
|
+
const mimeType = file.type || fallbackMimeType
|
|
76
|
+
const filename = file.name || fallbackFilename
|
|
77
|
+
|
|
78
|
+
const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename })
|
|
79
|
+
|
|
80
|
+
return { mediaId: toPreviewMediaId(uploadId), mimeType, filename }
|
|
81
|
+
}
|
|
82
|
+
}
|