@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.21
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 +100 -3
- package/dist/preview/index.js +89 -7
- package/dist/styles.css +30 -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/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 +6 -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 +37 -0
- package/src/types.ts +26 -0
|
@@ -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
|
+
}
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
assertPreviewEnvironment,
|
|
11
11
|
createPreviewWebhookClient,
|
|
12
12
|
PreviewInProductionError,
|
|
13
|
+
PreviewMediaUploadRejectedError,
|
|
13
14
|
PreviewWebhookRejectedError,
|
|
14
15
|
} from './createPreviewWebhookClient'
|
|
16
|
+
import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
15
17
|
|
|
16
18
|
const APP_SECRET = 'dev-app-secret'
|
|
17
19
|
const FROM = '5511988887777'
|
|
@@ -103,3 +105,90 @@ describe('assertPreviewEnvironment', () => {
|
|
|
103
105
|
expect(() => assertPreviewEnvironment(false)).not.toThrow()
|
|
104
106
|
})
|
|
105
107
|
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* O microfone do simulador depende disto existir no cliente.
|
|
111
|
+
*
|
|
112
|
+
* Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
|
|
113
|
+
* diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
|
|
114
|
+
* pacote: cliente montado, upload assinado, id prefixado.
|
|
115
|
+
*/
|
|
116
|
+
describe('createPreviewWebhookClient.uploadMedia', () => {
|
|
117
|
+
function createUploadFetch(status = 201): {
|
|
118
|
+
fetchImplementation: typeof fetch
|
|
119
|
+
calls: Array<{ url: string; signature: string; body: string }>
|
|
120
|
+
} {
|
|
121
|
+
const calls: Array<{ url: string; signature: string; body: string }> = []
|
|
122
|
+
|
|
123
|
+
const fetchImplementation = (async (url: string, init?: RequestInit) => {
|
|
124
|
+
const headers = (init?.headers ?? {}) as Record<string, string>
|
|
125
|
+
calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
|
|
126
|
+
return {
|
|
127
|
+
ok: status >= 200 && status < 300,
|
|
128
|
+
status,
|
|
129
|
+
json: async () => ({ data: { uploadId: 'upl_123' } }),
|
|
130
|
+
} as Response
|
|
131
|
+
}) as unknown as typeof fetch
|
|
132
|
+
|
|
133
|
+
return { fetchImplementation, calls }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
|
|
137
|
+
|
|
138
|
+
it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
|
|
139
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
140
|
+
const client = createPreviewWebhookClient({
|
|
141
|
+
webhookUrl: WEBHOOK_URL,
|
|
142
|
+
appSecret: APP_SECRET,
|
|
143
|
+
from: FROM,
|
|
144
|
+
fetchImplementation,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const uploaded = await client.uploadMedia(audioFile())
|
|
148
|
+
|
|
149
|
+
expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
|
|
150
|
+
expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
|
|
151
|
+
expect(uploaded.mimeType).toBe('audio/ogg')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
|
|
155
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
156
|
+
const client = createPreviewWebhookClient({
|
|
157
|
+
webhookUrl: WEBHOOK_URL,
|
|
158
|
+
appSecret: APP_SECRET,
|
|
159
|
+
from: FROM,
|
|
160
|
+
fetchImplementation,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await client.uploadMedia(audioFile())
|
|
164
|
+
|
|
165
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
|
|
166
|
+
expect(calls[0]?.signature).toBe(expected)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
|
|
170
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
171
|
+
const client = createPreviewWebhookClient({
|
|
172
|
+
webhookUrl: '/v1/webhook/whatsapp',
|
|
173
|
+
appSecret: APP_SECRET,
|
|
174
|
+
from: FROM,
|
|
175
|
+
fetchImplementation,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await client.uploadMedia(audioFile())
|
|
179
|
+
|
|
180
|
+
expect(calls[0]?.url).toBe('/v1/preview/media')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('avisa com erro próprio quando a rota de mídia recusa', async () => {
|
|
184
|
+
const { fetchImplementation } = createUploadFetch(404)
|
|
185
|
+
const client = createPreviewWebhookClient({
|
|
186
|
+
webhookUrl: WEBHOOK_URL,
|
|
187
|
+
appSecret: APP_SECRET,
|
|
188
|
+
from: FROM,
|
|
189
|
+
fetchImplementation,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await expect(client.uploadMedia(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type InboundMediaType,
|
|
26
26
|
type InteractiveReplyOption,
|
|
27
27
|
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
28
|
+
import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
28
29
|
|
|
29
30
|
export type PreviewWebhookClient = {
|
|
30
31
|
sendText(text: string): Promise<void>
|
|
@@ -32,6 +33,19 @@ export type PreviewWebhookClient = {
|
|
|
32
33
|
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
33
34
|
sendAudio(mediaId: string): Promise<void>
|
|
34
35
|
sendMedia(params: SendPreviewMediaParams): Promise<void>
|
|
36
|
+
/**
|
|
37
|
+
* Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
|
|
38
|
+
*
|
|
39
|
+
* Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
|
|
40
|
+
* sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
|
|
41
|
+
* o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
|
|
42
|
+
* por esquecimento. Cliente montado, microfone na tela.
|
|
43
|
+
*
|
|
44
|
+
* Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
|
|
45
|
+
* o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
|
|
46
|
+
* corretamente não desenha o gravador.
|
|
47
|
+
*/
|
|
48
|
+
uploadMedia?(file: File): Promise<PreviewUploadedMedia>
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export type SendPreviewMediaParams = {
|
|
@@ -52,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
|
|
|
52
66
|
readonly appSecret: string
|
|
53
67
|
readonly from: string
|
|
54
68
|
readonly phoneNumberId?: string
|
|
69
|
+
/**
|
|
70
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
|
|
71
|
+
*
|
|
72
|
+
* O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
|
|
73
|
+
* publica a API em outro host ou versiona o caminho.
|
|
74
|
+
*/
|
|
75
|
+
readonly mediaUploadUrl?: string
|
|
55
76
|
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
56
77
|
readonly fetchImplementation?: typeof fetch
|
|
57
78
|
}
|
|
58
79
|
|
|
80
|
+
/** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
|
|
81
|
+
export class PreviewMediaUploadRejectedError extends Error {
|
|
82
|
+
constructor(readonly status: number) {
|
|
83
|
+
super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
|
|
84
|
+
this.name = 'PreviewMediaUploadRejectedError'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
export class PreviewInProductionError extends Error {
|
|
60
89
|
constructor() {
|
|
61
90
|
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
@@ -102,6 +131,53 @@ export async function signPreviewPayload(params: { rawBody: string; appSecret: s
|
|
|
102
131
|
|
|
103
132
|
const signWithWebCrypto = signPreviewPayload
|
|
104
133
|
|
|
134
|
+
export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
|
|
138
|
+
*
|
|
139
|
+
* Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
|
|
140
|
+
* pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
|
|
141
|
+
* lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
|
|
142
|
+
*/
|
|
143
|
+
export function defaultMediaUploadUrl(webhookUrl: string): string {
|
|
144
|
+
try {
|
|
145
|
+
return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
|
|
146
|
+
} catch {
|
|
147
|
+
return DEFAULT_MEDIA_UPLOAD_PATH
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
|
|
153
|
+
*
|
|
154
|
+
* O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
|
|
155
|
+
* do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
|
|
156
|
+
*/
|
|
157
|
+
export function createPreviewMediaPoster(params: {
|
|
158
|
+
readonly url: string
|
|
159
|
+
readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
|
|
160
|
+
readonly fetchImplementation?: typeof fetch
|
|
161
|
+
}): (file: File) => Promise<PreviewUploadedMedia> {
|
|
162
|
+
return createPreviewMediaUploader({
|
|
163
|
+
upload: async (request) => {
|
|
164
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
165
|
+
const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
|
|
166
|
+
|
|
167
|
+
const response = await performRequest(params.url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
170
|
+
body: JSON.stringify(request),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
|
|
174
|
+
|
|
175
|
+
const body = (await response.json()) as { data: { uploadId: string } }
|
|
176
|
+
return { uploadId: body.data.uploadId }
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
105
181
|
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
106
182
|
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
107
183
|
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
@@ -119,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
119
195
|
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
120
196
|
}
|
|
121
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Assina o MIME, não o binário — mesmo contrato da rota.
|
|
200
|
+
*
|
|
201
|
+
* Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
|
|
202
|
+
* assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
|
|
203
|
+
*/
|
|
204
|
+
const uploadMedia = createPreviewMediaPoster({
|
|
205
|
+
url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
|
|
206
|
+
headers: async (mimeType) => ({
|
|
207
|
+
'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
|
|
208
|
+
}),
|
|
209
|
+
...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
|
|
210
|
+
})
|
|
211
|
+
|
|
122
212
|
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
123
213
|
|
|
124
214
|
return {
|
|
@@ -127,5 +217,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
127
217
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
128
218
|
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
129
219
|
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
220
|
+
uploadMedia,
|
|
130
221
|
}
|
|
131
222
|
}
|
package/src/preview/index.ts
CHANGED
|
@@ -38,6 +38,9 @@ export {
|
|
|
38
38
|
assertPreviewEnvironment,
|
|
39
39
|
PreviewInProductionError,
|
|
40
40
|
PreviewWebhookRejectedError,
|
|
41
|
+
PreviewMediaUploadRejectedError,
|
|
42
|
+
createPreviewMediaPoster,
|
|
43
|
+
DEFAULT_MEDIA_UPLOAD_PATH,
|
|
41
44
|
} from './createPreviewWebhookClient'
|
|
42
45
|
export type {
|
|
43
46
|
PreviewWebhookClient,
|
|
@@ -59,3 +62,6 @@ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource
|
|
|
59
62
|
export { previewFileBase64 } from './previewMediaSource'
|
|
60
63
|
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
61
64
|
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
65
|
+
|
|
66
|
+
export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
67
|
+
export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
|
|
3
|
+
* meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
|
|
4
|
+
* reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
|
|
9
|
+
|
|
10
|
+
import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
11
|
+
|
|
12
|
+
describe('createPreviewMediaUploader', () => {
|
|
13
|
+
it('reexporta o prefixo do contrato, sem cópia própria', () => {
|
|
14
|
+
expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('devolve o id prefixado com o uploadId da rota', async () => {
|
|
18
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
|
|
19
|
+
|
|
20
|
+
const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
|
|
21
|
+
|
|
22
|
+
expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
|
|
23
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
24
|
+
expect(result.filename).toBe('nota.ogg')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('manda o binário em base64 para a rota', async () => {
|
|
28
|
+
let recebido: string | undefined
|
|
29
|
+
const upload = createPreviewMediaUploader({
|
|
30
|
+
upload: async (request) => {
|
|
31
|
+
recebido = request.base64
|
|
32
|
+
return { uploadId: 'k' }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
|
|
37
|
+
|
|
38
|
+
expect(atob(recebido!)).toBe('audio')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
|
|
42
|
+
it('preenche nome e mime quando o arquivo vem sem eles', async () => {
|
|
43
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
44
|
+
|
|
45
|
+
const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
|
|
46
|
+
|
|
47
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
48
|
+
expect(result.filename).toBe('audio.ogg')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
|
|
53
|
+
* aparece na primeira gravação de verdade, nunca no clipe curto do teste.
|
|
54
|
+
*/
|
|
55
|
+
it('converte arquivo grande sem estourar a pilha', async () => {
|
|
56
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
57
|
+
const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
|
|
58
|
+
|
|
59
|
+
expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
|
|
60
|
+
})
|
|
61
|
+
})
|
package/src/providers/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MessagePayload } from '../types'
|
|
1
|
+
import type { MessagePayload, MessageTranscription } from '../types'
|
|
2
2
|
import type { ConversationChannel } from '../conversationChannel'
|
|
3
3
|
|
|
4
4
|
export interface ListConversationsParams {
|
|
@@ -134,6 +134,17 @@ export interface ConversationsApi {
|
|
|
134
134
|
* nem todo backend expõe a rota — quem não tem continua usando o builder local.
|
|
135
135
|
*/
|
|
136
136
|
exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Transcreve o áudio de uma mensagem e devolve o resultado.
|
|
140
|
+
*
|
|
141
|
+
* **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
|
|
142
|
+
* nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
|
|
143
|
+
* simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
|
|
144
|
+
*
|
|
145
|
+
* `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
|
|
146
|
+
*/
|
|
147
|
+
transcribeAudio?(messageId: string): Promise<MessageTranscription>
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
/**
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { TranscriptionSettingsForm } from './TranscriptionSettingsForm'
|
|
5
|
+
import type { TranscriptionSettingsFormProps } from './TranscriptionSettingsForm'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extrai uma tag pelo atributo, em vez de casar regex com a ordem dos atributos: o React SSR emite
|
|
9
|
+
* `checked=""` antes de `value=""`, então `/value="auto"[^>]*checked/` falha num componente correto.
|
|
10
|
+
*/
|
|
11
|
+
function findTag(markup: string, tagPattern: RegExp, contains: string): string | undefined {
|
|
12
|
+
return (markup.match(tagPattern) ?? []).find((tag) => tag.includes(contains))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function render(overrides: Partial<TranscriptionSettingsFormProps> = {}): string {
|
|
16
|
+
return renderToStaticMarkup(
|
|
17
|
+
<TranscriptionSettingsForm
|
|
18
|
+
enabled={false}
|
|
19
|
+
onEnabledChange={() => {}}
|
|
20
|
+
mode="onDemand"
|
|
21
|
+
onModeChange={() => {}}
|
|
22
|
+
onSave={() => {}}
|
|
23
|
+
{...overrides}
|
|
24
|
+
/>,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('TranscriptionSettingsForm', () => {
|
|
29
|
+
it('esconde a escolha de modo enquanto estiver desligado', () => {
|
|
30
|
+
const markup = render({ enabled: false })
|
|
31
|
+
|
|
32
|
+
expect(markup).not.toContain('Quando transcrever')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('mostra as duas opções de modo ao ligar', () => {
|
|
36
|
+
const markup = render({ enabled: true })
|
|
37
|
+
|
|
38
|
+
expect(markup).toContain('Quando transcrever')
|
|
39
|
+
expect(markup).toContain('Quando o atendente pedir')
|
|
40
|
+
expect(markup).toContain('Automaticamente')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('marca o modo atual', () => {
|
|
44
|
+
const markup = render({ enabled: true, mode: 'auto' })
|
|
45
|
+
const radios = /<input type="radio"[^>]*>/g
|
|
46
|
+
|
|
47
|
+
expect(findTag(markup, radios, 'value="auto"')).toContain('checked')
|
|
48
|
+
expect(findTag(markup, radios, 'value="onDemand"')).not.toContain('checked')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* "O ambiente consegue" é diferente de "esta empresa quer". Sem o aviso, o lojista ligaria o
|
|
53
|
+
* interruptor num ambiente sem engine, nada apareceria, e nada explicaria por quê.
|
|
54
|
+
*/
|
|
55
|
+
it('avisa e bloqueia quando a capacidade não existe no servidor', () => {
|
|
56
|
+
const markup = render({ isAvailable: false })
|
|
57
|
+
|
|
58
|
+
expect(markup).toContain('não está disponível neste ambiente')
|
|
59
|
+
expect(findTag(markup, /<input type="checkbox"[^>]*>/g, 'type="checkbox"')).toContain('disabled')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('não avisa nada quando a capacidade existe', () => {
|
|
63
|
+
const markup = render({ isAvailable: true })
|
|
64
|
+
|
|
65
|
+
expect(markup).not.toContain('não está disponível neste ambiente')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('aceita rótulos do host', () => {
|
|
69
|
+
const markup = render({ labels: { sectionTitle: 'Áudio em texto' } })
|
|
70
|
+
|
|
71
|
+
expect(markup).toContain('Áudio em texto')
|
|
72
|
+
expect(markup).not.toContain('Transcrição de áudio')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('desabilita o salvar enquanto salva', () => {
|
|
76
|
+
const markup = render({ saving: true })
|
|
77
|
+
|
|
78
|
+
expect(markup).toContain('Salvando...')
|
|
79
|
+
expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
|
|
80
|
+
})
|
|
81
|
+
})
|