@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-OIDAIVCH.js +1985 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +729 -136
- package/dist/index.js +1418 -1104
- package/dist/preview/index.d.ts +373 -0
- package/dist/preview/index.js +1247 -0
- package/dist/styles.css +198 -0
- package/dist/types-O7kMP1Yn.d.ts +416 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +127 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +64 -21
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +146 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +129 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +325 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +88 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +131 -0
- package/src/preview/index.ts +61 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/quickReply.test.ts +58 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { CONVERSATION_CHANNEL, CHANNEL_FILTER_ALL, channelFiltersFor } from './conversationChannel'
|
|
3
|
+
|
|
4
|
+
describe('channelFiltersFor', () => {
|
|
5
|
+
// O ponto do helper: a barra oferece só o que existe. Oferecer Instagram numa conta que só tem
|
|
6
|
+
// WhatsApp promete um recorte que nunca traz resultado.
|
|
7
|
+
it('lista apenas os canais presentes, com Todos na frente', () => {
|
|
8
|
+
const options = channelFiltersFor([
|
|
9
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
10
|
+
{ channel: CONVERSATION_CHANNEL.INSTAGRAM },
|
|
11
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
expect(options.map((option) => option.value)).toEqual([
|
|
15
|
+
CHANNEL_FILTER_ALL,
|
|
16
|
+
CONVERSATION_CHANNEL.WHATSAPP,
|
|
17
|
+
CONVERSATION_CHANNEL.INSTAGRAM,
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('não oferece filtro quando há um canal só', () => {
|
|
22
|
+
expect(channelFiltersFor([{ channel: CONVERSATION_CHANNEL.WHATSAPP }, {}])).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('não oferece filtro para lista vazia', () => {
|
|
26
|
+
expect(channelFiltersFor([])).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Conversa sem canal é WhatsApp por compatibilidade — não pode virar uma quinta opção fantasma.
|
|
30
|
+
it('trata ausência de canal como whatsapp', () => {
|
|
31
|
+
const options = channelFiltersFor([{}, { channel: CONVERSATION_CHANNEL.WEBCHAT }])
|
|
32
|
+
|
|
33
|
+
expect(options.map((option) => option.value)).toEqual([
|
|
34
|
+
CHANNEL_FILTER_ALL,
|
|
35
|
+
CONVERSATION_CHANNEL.WHATSAPP,
|
|
36
|
+
CONVERSATION_CHANNEL.WEBCHAT,
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Ordem do catálogo: se dependesse da ordem de chegada, a barra se reorganizaria a cada refetch.
|
|
41
|
+
it('mantém ordem estável independente da ordem da lista', () => {
|
|
42
|
+
const first = channelFiltersFor([
|
|
43
|
+
{ channel: CONVERSATION_CHANNEL.WEBCHAT },
|
|
44
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
45
|
+
])
|
|
46
|
+
const second = channelFiltersFor([
|
|
47
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
48
|
+
{ channel: CONVERSATION_CHANNEL.WEBCHAT },
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
expect(first).toEqual(second)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de origem da conversa e o que cada um permite.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque as regras que a inbox precisa respeitar não são do WhatsApp, são **de cada canal**:
|
|
5
|
+
* a janela de sessão, o jeito de reabrir a conversa e o formato do identificador do contato mudam
|
|
6
|
+
* entre WhatsApp, Messenger, Instagram e chat de site. Tratar a regra do WhatsApp como universal
|
|
7
|
+
* faria a UI bloquear o composer num chat de site, onde janela nenhuma existe.
|
|
8
|
+
*
|
|
9
|
+
* `whatsapp` é o padrão em todo lugar: instalações que ainda não informam canal continuam
|
|
10
|
+
* funcionando exatamente como antes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatPhone, phoneCountryFlag } from './lib/phone'
|
|
14
|
+
|
|
15
|
+
export const CONVERSATION_CHANNEL = {
|
|
16
|
+
WHATSAPP: 'whatsapp',
|
|
17
|
+
MESSENGER: 'messenger',
|
|
18
|
+
INSTAGRAM: 'instagram',
|
|
19
|
+
WEBCHAT: 'webchat',
|
|
20
|
+
} as const
|
|
21
|
+
export type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL]
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_CONVERSATION_CHANNEL: ConversationChannel = CONVERSATION_CHANNEL.WHATSAPP
|
|
24
|
+
|
|
25
|
+
/** Como o canal reabre uma conversa fora da janela de sessão. */
|
|
26
|
+
export const REOPEN_MECHANISM = {
|
|
27
|
+
TEMPLATE: 'template',
|
|
28
|
+
TAG: 'tag',
|
|
29
|
+
NONE: 'none',
|
|
30
|
+
} as const
|
|
31
|
+
export type ReopenMechanism = (typeof REOPEN_MECHANISM)[keyof typeof REOPEN_MECHANISM]
|
|
32
|
+
|
|
33
|
+
/** Natureza do identificador do contato — decide como exibi-lo. */
|
|
34
|
+
export const HANDLE_KIND = {
|
|
35
|
+
PHONE: 'phone',
|
|
36
|
+
USERNAME: 'username',
|
|
37
|
+
SESSION: 'session',
|
|
38
|
+
} as const
|
|
39
|
+
export type HandleKind = (typeof HANDLE_KIND)[keyof typeof HANDLE_KIND]
|
|
40
|
+
|
|
41
|
+
export type ChannelCapabilities = {
|
|
42
|
+
readonly label: string
|
|
43
|
+
readonly icon: string
|
|
44
|
+
readonly hasSessionWindow: boolean
|
|
45
|
+
readonly windowHours: number
|
|
46
|
+
readonly reopenMechanism: ReopenMechanism
|
|
47
|
+
readonly handleKind: HandleKind
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelCapabilities>> = {
|
|
51
|
+
[CONVERSATION_CHANNEL.WHATSAPP]: {
|
|
52
|
+
label: 'WhatsApp',
|
|
53
|
+
icon: '💬',
|
|
54
|
+
hasSessionWindow: true,
|
|
55
|
+
windowHours: 24,
|
|
56
|
+
reopenMechanism: REOPEN_MECHANISM.TEMPLATE,
|
|
57
|
+
handleKind: HANDLE_KIND.PHONE,
|
|
58
|
+
},
|
|
59
|
+
[CONVERSATION_CHANNEL.MESSENGER]: {
|
|
60
|
+
// Messenger também tem 24h, mas reabre com message tag — não com template aprovado.
|
|
61
|
+
label: 'Messenger',
|
|
62
|
+
icon: '📨',
|
|
63
|
+
hasSessionWindow: true,
|
|
64
|
+
windowHours: 24,
|
|
65
|
+
reopenMechanism: REOPEN_MECHANISM.TAG,
|
|
66
|
+
handleKind: HANDLE_KIND.USERNAME,
|
|
67
|
+
},
|
|
68
|
+
[CONVERSATION_CHANNEL.INSTAGRAM]: {
|
|
69
|
+
label: 'Instagram',
|
|
70
|
+
icon: '📷',
|
|
71
|
+
hasSessionWindow: true,
|
|
72
|
+
windowHours: 24,
|
|
73
|
+
reopenMechanism: REOPEN_MECHANISM.TAG,
|
|
74
|
+
handleKind: HANDLE_KIND.USERNAME,
|
|
75
|
+
},
|
|
76
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: {
|
|
77
|
+
// Chat próprio: sem intermediário, sem janela. Bloquear o composer aqui seria inventar limite.
|
|
78
|
+
label: 'Chat do site',
|
|
79
|
+
icon: '🌐',
|
|
80
|
+
hasSessionWindow: false,
|
|
81
|
+
windowHours: 0,
|
|
82
|
+
reopenMechanism: REOPEN_MECHANISM.NONE,
|
|
83
|
+
handleKind: HANDLE_KIND.SESSION,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function capabilitiesOf(channel: ConversationChannel | undefined): ChannelCapabilities {
|
|
88
|
+
return CHANNEL_CAPABILITIES[channel ?? DEFAULT_CONVERSATION_CHANNEL]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const CHANNEL_FILTER_ALL = 'all'
|
|
92
|
+
export type ChannelFilter = ConversationChannel | typeof CHANNEL_FILTER_ALL
|
|
93
|
+
|
|
94
|
+
export type ChannelFilterOption = {
|
|
95
|
+
readonly value: ChannelFilter
|
|
96
|
+
readonly label: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Opções derivadas do que existe na lista, não do catálogo inteiro: oferecer Instagram numa conta
|
|
101
|
+
* que só tem WhatsApp promete um recorte que nunca traz resultado.
|
|
102
|
+
*
|
|
103
|
+
* Devolve vazio com menos de dois canais — um filtro de opção única não filtra nada, e a barra só
|
|
104
|
+
* ocuparia espaço. O host usa isso para esconder a seção.
|
|
105
|
+
*/
|
|
106
|
+
export function channelFiltersFor(
|
|
107
|
+
conversations: readonly { readonly channel?: ConversationChannel | undefined }[],
|
|
108
|
+
): ChannelFilterOption[] {
|
|
109
|
+
const present = new Set<ConversationChannel>(
|
|
110
|
+
conversations.map((conversation) => conversation.channel ?? DEFAULT_CONVERSATION_CHANNEL),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if (present.size < 2) return []
|
|
114
|
+
|
|
115
|
+
// Ordem do catálogo, não de chegada: a barra não pode reordenar sozinha a cada refetch.
|
|
116
|
+
const ordered = (Object.keys(CHANNEL_CAPABILITIES) as ConversationChannel[]).filter((channel) => present.has(channel))
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
{ value: CHANNEL_FILTER_ALL, label: 'Todos' },
|
|
120
|
+
...ordered.map((channel) => ({ value: channel, label: CHANNEL_CAPABILITIES[channel].label })),
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type FormatContactHandleParams = {
|
|
125
|
+
readonly handle: string
|
|
126
|
+
readonly channel?: ConversationChannel | undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Exibição do identificador conforme a natureza dele. Formatar tudo como telefone — o que a UI
|
|
131
|
+
* fazia — transforma um `@perfil` do Instagram em dígitos sem sentido.
|
|
132
|
+
*/
|
|
133
|
+
export function formatContactHandle(params: FormatContactHandleParams): string {
|
|
134
|
+
const { handleKind } = capabilitiesOf(params.channel)
|
|
135
|
+
|
|
136
|
+
if (handleKind === HANDLE_KIND.PHONE) return formatPhone(params.handle)
|
|
137
|
+
if (handleKind === HANDLE_KIND.USERNAME) return params.handle.startsWith('@') ? params.handle : `@${params.handle}`
|
|
138
|
+
|
|
139
|
+
// Sessão de chat de site é um id opaco: mostrar o hash inteiro não ajuda ninguém.
|
|
140
|
+
return `Visitante ${params.handle.slice(-6)}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Bandeira só faz sentido quando o identificador é telefone. */
|
|
144
|
+
export function contactFlag(params: FormatContactHandleParams): string {
|
|
145
|
+
return capabilitiesOf(params.channel).handleKind === HANDLE_KIND.PHONE ? phoneCountryFlag(params.handle) : ''
|
|
146
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { buildTranscriptFilename, buildTranscriptText } from './conversationTranscript'
|
|
3
|
+
import type { MessagePayload } from './types'
|
|
4
|
+
|
|
5
|
+
const NUMBER = '5511988887777'
|
|
6
|
+
|
|
7
|
+
function message(partial: Partial<MessagePayload>): MessagePayload {
|
|
8
|
+
return {
|
|
9
|
+
id: 'm1',
|
|
10
|
+
type: 'text',
|
|
11
|
+
direction: 'inbound',
|
|
12
|
+
sender: 'customer',
|
|
13
|
+
timestamp: '2026-07-27T12:00:00.000Z',
|
|
14
|
+
...partial,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('buildTranscriptText', () => {
|
|
19
|
+
it('escreve cabeçalho e uma linha por mensagem', () => {
|
|
20
|
+
const text = buildTranscriptText({
|
|
21
|
+
messages: [
|
|
22
|
+
message({ id: 'm1', content: 'oi' }),
|
|
23
|
+
message({ id: 'm2', content: 'Olá!', direction: 'outbound', sender: 'bot' }),
|
|
24
|
+
],
|
|
25
|
+
whatsappNumber: NUMBER,
|
|
26
|
+
clientName: 'Marina',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(text).toContain('Conversa: Marina')
|
|
30
|
+
expect(text).toContain(`Número: ${NUMBER}`)
|
|
31
|
+
expect(text).toContain('Mensagens: 2')
|
|
32
|
+
expect(text).toContain('Cliente: oi')
|
|
33
|
+
expect(text).toContain('Bot: Olá!')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Sem isto a linha sairia vazia e o histórico esconderia que houve um anexo ali.
|
|
37
|
+
it('marca o tipo quando a mensagem não tem texto', () => {
|
|
38
|
+
const text = buildTranscriptText({ messages: [message({ type: 'audio' })], whatsappNumber: NUMBER })
|
|
39
|
+
|
|
40
|
+
expect(text).toContain('<audio>')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('usa o nome do atendente quando existe, em vez do papel genérico', () => {
|
|
44
|
+
const text = buildTranscriptText({
|
|
45
|
+
messages: [message({ direction: 'outbound', sender: 'agent', agentName: 'Ana', content: 'já separei' })],
|
|
46
|
+
whatsappNumber: NUMBER,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(text).toContain('Ana: já separei')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('cai no número quando não há nome do cliente', () => {
|
|
53
|
+
const text = buildTranscriptText({ messages: [], whatsappNumber: NUMBER })
|
|
54
|
+
|
|
55
|
+
expect(text).toContain(`Conversa: ${NUMBER}`)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('buildTranscriptFilename', () => {
|
|
60
|
+
it('inclui número e data', () => {
|
|
61
|
+
expect(buildTranscriptFilename(NUMBER, new Date('2026-07-27T23:00:00.000Z'))).toBe(
|
|
62
|
+
`conversa-${NUMBER}-2026-07-27.txt`,
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialização do transcript para download. Fica no pacote porque o formato de um histórico de
|
|
3
|
+
* WhatsApp legível não é regra de negócio de ninguém — e porque o host que já tem as mensagens em
|
|
4
|
+
* tela não deveria precisar de rota nova só para salvar um arquivo.
|
|
5
|
+
*
|
|
6
|
+
* Funções puras, separadas do disparo do download: é o que permite testá-las sem DOM.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MessagePayload } from './types'
|
|
10
|
+
|
|
11
|
+
const SENDER_LABEL: Record<MessagePayload['sender'], string> = {
|
|
12
|
+
customer: 'Cliente',
|
|
13
|
+
bot: 'Bot',
|
|
14
|
+
agent: 'Atendente',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type BuildTranscriptTextParams = {
|
|
18
|
+
readonly messages: readonly MessagePayload[]
|
|
19
|
+
readonly whatsappNumber: string
|
|
20
|
+
readonly clientName?: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Formato próximo ao export nativo do WhatsApp (`[data hora] Autor: texto`), que é o que pessoas e
|
|
25
|
+
* ferramentas de suporte já sabem ler.
|
|
26
|
+
*/
|
|
27
|
+
export function buildTranscriptText(params: BuildTranscriptTextParams): string {
|
|
28
|
+
const header = [
|
|
29
|
+
`Conversa: ${params.clientName ?? params.whatsappNumber}`,
|
|
30
|
+
`Número: ${params.whatsappNumber}`,
|
|
31
|
+
`Mensagens: ${params.messages.length}`,
|
|
32
|
+
'',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const lines = params.messages.map((message) => {
|
|
36
|
+
const stamp = new Date(message.timestamp).toLocaleString('pt-BR')
|
|
37
|
+
const author = message.agentName ?? SENDER_LABEL[message.sender]
|
|
38
|
+
// Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
|
|
39
|
+
const body = message.content ?? message.caption ?? `<${message.type}>`
|
|
40
|
+
return `[${stamp}] ${author}: ${body}`
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return [...header, ...lines].join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
|
|
47
|
+
const stamp = generatedAt.toISOString().slice(0, 10)
|
|
48
|
+
return `conversa-${whatsappNumber}-${stamp}.txt`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Dispara o download no navegador. `revokeObjectURL` no fim não é higiene opcional: sem ele cada
|
|
53
|
+
* export retém o blob inteiro em memória até a aba fechar.
|
|
54
|
+
*/
|
|
55
|
+
export function downloadTextFile(filename: string, content: string): void {
|
|
56
|
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
|
57
|
+
const url = URL.createObjectURL(blob)
|
|
58
|
+
const anchor = document.createElement('a')
|
|
59
|
+
|
|
60
|
+
anchor.href = url
|
|
61
|
+
anchor.download = filename
|
|
62
|
+
anchor.click()
|
|
63
|
+
URL.revokeObjectURL(url)
|
|
64
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A classificação de janela decide se o atendente pode mandar texto livre. Errar a fronteira não
|
|
3
|
+
* quebra a tela — faz o WhatsApp recusar o envio depois, longe da causa.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'bun:test'
|
|
7
|
+
import { CONVERSATION_WINDOW, formatStalledFor, windowOf } from './conversationWindow'
|
|
8
|
+
import { CONVERSATION_CHANNEL, contactFlag, formatContactHandle } from './conversationChannel'
|
|
9
|
+
|
|
10
|
+
const NOW = new Date('2026-07-27T12:00:00.000Z').getTime()
|
|
11
|
+
const HOUR = 60 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
function hoursAgo(hours: number): string {
|
|
14
|
+
return new Date(NOW - hours * HOUR).toISOString()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('windowOf', () => {
|
|
18
|
+
it('classifica por faixa de horas desde o último contato do cliente', () => {
|
|
19
|
+
expect(windowOf({ lastInboundAt: hoursAgo(0), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
|
|
20
|
+
expect(windowOf({ lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
|
|
21
|
+
expect(windowOf({ lastInboundAt: hoursAgo(15), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
|
|
22
|
+
expect(windowOf({ lastInboundAt: hoursAgo(22), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
|
|
23
|
+
expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// As fronteiras são exatamente onde o erro custa caro: 24h em ponto já é recusa da Meta.
|
|
27
|
+
it('trata as fronteiras como início da faixa seguinte', () => {
|
|
28
|
+
expect(windowOf({ lastInboundAt: hoursAgo(12), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
|
|
29
|
+
expect(windowOf({ lastInboundAt: hoursAgo(21), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
|
|
30
|
+
expect(windowOf({ lastInboundAt: hoursAgo(24), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Sem inbound não há janela aberta; classificar como expirada evita prometer texto livre.
|
|
34
|
+
it('considera expirada quando o cliente nunca escreveu', () => {
|
|
35
|
+
expect(windowOf({ lastInboundAt: null, now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// O defeito que isto tranca: aplicar a regra do WhatsApp ao chat de site bloquearia o composer
|
|
39
|
+
// num canal onde nada expira.
|
|
40
|
+
it('nunca expira em canal sem janela de sessão', () => {
|
|
41
|
+
expect(windowOf({ lastInboundAt: hoursAgo(720), now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
|
|
42
|
+
CONVERSATION_WINDOW.FRESH,
|
|
43
|
+
)
|
|
44
|
+
expect(windowOf({ lastInboundAt: null, now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
|
|
45
|
+
CONVERSATION_WINDOW.FRESH,
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('mantém a regra do WhatsApp quando o canal não é informado', () => {
|
|
50
|
+
expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(
|
|
51
|
+
windowOf({ lastInboundAt: hoursAgo(30), now: NOW, channel: CONVERSATION_CHANNEL.WHATSAPP }),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('formatContactHandle', () => {
|
|
57
|
+
it('formata telefone no WhatsApp e arroba nas redes', () => {
|
|
58
|
+
expect(formatContactHandle({ handle: '5511988887777', channel: CONVERSATION_CHANNEL.WHATSAPP })).toBe(
|
|
59
|
+
'+55 (11) 98888-7777',
|
|
60
|
+
)
|
|
61
|
+
expect(formatContactHandle({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe(
|
|
62
|
+
'@marina.alves',
|
|
63
|
+
)
|
|
64
|
+
expect(formatContactHandle({ handle: '@ja.tem', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('@ja.tem')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('encurta a sessão anônima do chat de site', () => {
|
|
68
|
+
expect(formatContactHandle({ handle: 'sess_9f2a7c41b8', channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
|
|
69
|
+
'Visitante 7c41b8',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Bandeira em @perfil não significaria nada: o identificador não carrega país.
|
|
74
|
+
it('só devolve bandeira quando o identificador é telefone', () => {
|
|
75
|
+
expect(contactFlag({ handle: '5511988887777' })).toBe('🇧🇷')
|
|
76
|
+
expect(contactFlag({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('formatStalledFor', () => {
|
|
81
|
+
it('formata dias, horas e minutos', () => {
|
|
82
|
+
expect(formatStalledFor(hoursAgo(5 * 24 + 6.2), NOW)).toBe('5d 6h:12m')
|
|
83
|
+
expect(formatStalledFor(hoursAgo(3.5), NOW)).toBe('3h:30m')
|
|
84
|
+
expect(formatStalledFor(hoursAgo(0.25), NOW)).toBe('15m')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('não devolve tempo negativo para carimbo no futuro', () => {
|
|
88
|
+
expect(formatStalledFor(new Date(NOW + HOUR).toISOString(), NOW)).toBe('0m')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Janela de sessão: o intervalo em que o canal aceita mensagem livre do atendente. No WhatsApp são
|
|
3
|
+
* 24h desde o último contato do cliente; fora dela só template. Cada canal tem a sua regra — e há
|
|
4
|
+
* canal sem janela nenhuma — então a política vem de `capabilitiesOf`, não de constante fixa.
|
|
5
|
+
*
|
|
6
|
+
* Mora no SDK porque é regra de plataforma, não de produto: todo projeto que usa este pacote
|
|
7
|
+
* precisa dela para saber o que o atendente ainda consegue fazer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { capabilitiesOf, type ConversationChannel } from './conversationChannel'
|
|
11
|
+
|
|
12
|
+
const MINUTE_MS = 60_000
|
|
13
|
+
const HOUR_MS = 60 * MINUTE_MS
|
|
14
|
+
const DAY_MS = 24 * HOUR_MS
|
|
15
|
+
|
|
16
|
+
export const CONVERSATION_WINDOW = {
|
|
17
|
+
ALL: 'all',
|
|
18
|
+
FRESH: 'fresh',
|
|
19
|
+
WARNING: 'warning',
|
|
20
|
+
CRITICAL: 'critical',
|
|
21
|
+
EXPIRED: 'expired',
|
|
22
|
+
} as const
|
|
23
|
+
export type ConversationWindow = (typeof CONVERSATION_WINDOW)[keyof typeof CONVERSATION_WINDOW]
|
|
24
|
+
|
|
25
|
+
export const WINDOW_FILTERS = [
|
|
26
|
+
{ value: CONVERSATION_WINDOW.ALL, label: 'Todas', dotClass: '' },
|
|
27
|
+
{ value: CONVERSATION_WINDOW.FRESH, label: '<12h', dotClass: 'bg-green-500' },
|
|
28
|
+
{ value: CONVERSATION_WINDOW.WARNING, label: '12-21h', dotClass: 'bg-yellow-500' },
|
|
29
|
+
{ value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', dotClass: 'bg-red-500' },
|
|
30
|
+
{ value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', dotClass: 'bg-gray-400' },
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
// Proporções da janela: 12h e 21h numa janela de 24h, mas expressas como fração para acompanharem
|
|
34
|
+
// canais com janela de outro tamanho.
|
|
35
|
+
const WARNING_RATIO = 0.5
|
|
36
|
+
const CRITICAL_RATIO = 0.875
|
|
37
|
+
|
|
38
|
+
export type WindowOfParams = {
|
|
39
|
+
readonly lastInboundAt: string | null
|
|
40
|
+
readonly now: number
|
|
41
|
+
/** Ausente = `whatsapp`. */
|
|
42
|
+
readonly channel?: ConversationChannel | undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sem `lastInboundAt` o cliente nunca escreveu, então não há janela aberta — classificar como
|
|
47
|
+
* expirada é o comportamento seguro: evita o atendente tentar texto livre e receber recusa.
|
|
48
|
+
*
|
|
49
|
+
* Canal sem janela de sessão (chat de site) é sempre `fresh`: ali nada expira, e marcar expirado
|
|
50
|
+
* bloquearia o composer inventando um limite que a plataforma não impõe.
|
|
51
|
+
*/
|
|
52
|
+
export function windowOf(params: WindowOfParams): ConversationWindow {
|
|
53
|
+
const capabilities = capabilitiesOf(params.channel)
|
|
54
|
+
if (!capabilities.hasSessionWindow) return CONVERSATION_WINDOW.FRESH
|
|
55
|
+
|
|
56
|
+
if (!params.lastInboundAt) return CONVERSATION_WINDOW.EXPIRED
|
|
57
|
+
|
|
58
|
+
const windowMs = capabilities.windowHours * HOUR_MS
|
|
59
|
+
const elapsed = params.now - new Date(params.lastInboundAt).getTime()
|
|
60
|
+
|
|
61
|
+
if (elapsed >= windowMs) return CONVERSATION_WINDOW.EXPIRED
|
|
62
|
+
// Faixas proporcionais à janela do canal, e não fixas em 12h/21h: um canal com janela diferente
|
|
63
|
+
// de 24h teria os avisos nos lugares errados.
|
|
64
|
+
if (elapsed >= windowMs * CRITICAL_RATIO) return CONVERSATION_WINDOW.CRITICAL
|
|
65
|
+
if (elapsed >= windowMs * WARNING_RATIO) return CONVERSATION_WINDOW.WARNING
|
|
66
|
+
return CONVERSATION_WINDOW.FRESH
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatStalledFor(lastAt: string, now: number): string {
|
|
70
|
+
const elapsed = Math.max(0, now - new Date(lastAt).getTime())
|
|
71
|
+
const days = Math.floor(elapsed / DAY_MS)
|
|
72
|
+
const hours = Math.floor((elapsed % DAY_MS) / HOUR_MS)
|
|
73
|
+
const minutes = Math.floor((elapsed % HOUR_MS) / MINUTE_MS)
|
|
74
|
+
|
|
75
|
+
if (days > 0) return `${days}d ${hours}h:${String(minutes).padStart(2, '0')}m`
|
|
76
|
+
if (hours > 0) return `${hours}h:${String(minutes).padStart(2, '0')}m`
|
|
77
|
+
return `${minutes}m`
|
|
78
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda o rótulo de tipo da bolha de documento.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou: `mimeType.split('/')[1].toUpperCase()` transformava um `.docx` em
|
|
5
|
+
* `VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT` — 58 caracteres numa linha que não
|
|
6
|
+
* quebra, esticando a bolha até empurrar a coluna da conversa e estourar o layout de três painéis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import { documentTypeLabel } from './MediaRenderer'
|
|
12
|
+
|
|
13
|
+
describe('documentTypeLabel', () => {
|
|
14
|
+
// O caso que quebrou a tela.
|
|
15
|
+
it('usa a extensão do arquivo em vez do subtipo gigante do Office', () => {
|
|
16
|
+
const rotulo = documentTypeLabel(
|
|
17
|
+
'contrato.docx',
|
|
18
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
expect(rotulo).toBe('DOCX')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('serve para os tipos curtos também', () => {
|
|
25
|
+
expect(documentTypeLabel('nota-fiscal.pdf', 'application/pdf')).toBe('PDF')
|
|
26
|
+
expect(documentTypeLabel('lista.txt', 'text/plain')).toBe('TXT')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Áudio e sticker chegam sem nome de arquivo: o rótulo vem do mimeType, ainda curto.
|
|
30
|
+
it('cai no sufixo do subtipo quando não há nome', () => {
|
|
31
|
+
expect(documentTypeLabel(undefined, 'application/vnd.ms-excel')).toBe('MS-EXCEL')
|
|
32
|
+
expect(documentTypeLabel(undefined, 'application/pdf')).toBe('PDF')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('descarta parâmetro do mimeType', () => {
|
|
36
|
+
expect(documentTypeLabel(undefined, 'audio/ogg; codecs=opus')).toBe('OGG')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Nome sem ponto não tem extensão — o id da mídia, por exemplo.
|
|
40
|
+
it('não confunde nome sem extensão com extensão', () => {
|
|
41
|
+
expect(documentTypeLabel('seed-media-03', 'application/zip')).toBe('ZIP')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('tem rótulo para o caso sem nome e sem tipo', () => {
|
|
45
|
+
expect(documentTypeLabel()).toBe('FILE')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Teto de tamanho: nenhum rótulo pode voltar a esticar a bolha.
|
|
49
|
+
it('nunca passa de 12 caracteres', () => {
|
|
50
|
+
const longo = documentTypeLabel(
|
|
51
|
+
undefined,
|
|
52
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(longo.length).toBeLessThanOrEqual(12)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
|
|
3
|
+
|
|
4
|
+
describe('searchEmojis', () => {
|
|
5
|
+
it('devolve o catálogo inteiro quando não há termo', () => {
|
|
6
|
+
const total = EMOJI_CATEGORIES.reduce((sum, category) => sum + category.entries.length, 0)
|
|
7
|
+
expect(searchEmojis('').length).toBe(total)
|
|
8
|
+
expect(searchEmojis(' ').length).toBe(total)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('acha pela palavra sem acento e com acento', () => {
|
|
12
|
+
const semAcento = searchEmojis('coracao').map((entry) => entry.emoji)
|
|
13
|
+
const comAcento = searchEmojis('coração').map((entry) => entry.emoji)
|
|
14
|
+
|
|
15
|
+
expect(semAcento).toContain('❤️')
|
|
16
|
+
expect(comAcento).toContain('❤️')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('ignora caixa e casa por prefixo, não pelo meio da palavra', () => {
|
|
20
|
+
expect(searchEmojis('CASA').map((entry) => entry.emoji)).toContain('🏠')
|
|
21
|
+
// "sa" está dentro de "casa", mas não é prefixo de nenhuma palavra-chave dela.
|
|
22
|
+
expect(searchEmojis('sa').map((entry) => entry.emoji)).not.toContain('🏠')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('atravessa categorias, não só a aberta', () => {
|
|
26
|
+
const resultados = searchEmojis('pagamento').map((entry) => entry.emoji)
|
|
27
|
+
|
|
28
|
+
expect(resultados).toContain('💳')
|
|
29
|
+
expect(resultados.length).toBeGreaterThan(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('devolve vazio quando nada casa, em vez de cair no catálogo inteiro', () => {
|
|
33
|
+
expect(searchEmojis('xyzabc')).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
})
|