@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
package/dist/preview/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { G as MessagePayload, o as ConversationSummary, m as ConversationEventSource, q as ConversationsApi, L as ListConversationsParams, n as ConversationPage, S as SSEProvider, k as ConversationDocument,
|
|
2
|
-
export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-
|
|
1
|
+
import { G as MessagePayload, o as ConversationSummary, m as ConversationEventSource, q as ConversationsApi, L as ListConversationsParams, n as ConversationPage, S as SSEProvider, k as ConversationDocument, N as ResolveMediaUrl } from '../types-C6A_9edv.js';
|
|
2
|
+
export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-C6A_9edv.js';
|
|
3
3
|
import * as react from 'react';
|
|
4
4
|
import { InteractiveReplyOption, InboundMediaType } from '@adatechnology/meta-whatsapp-contracts/testing';
|
|
5
|
+
export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Estado em memória que alimenta o preview de atendimento humano. Mock de API e mock de SSE
|
|
@@ -112,6 +113,52 @@ declare const PREVIEW_CONVERSATIONS: readonly ConversationSummary[];
|
|
|
112
113
|
declare const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]>>;
|
|
113
114
|
declare const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDocument[]>>;
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
|
|
118
|
+
*
|
|
119
|
+
* O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
|
|
120
|
+
* ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
|
|
121
|
+
* lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
|
|
122
|
+
*
|
|
123
|
+
* Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
|
|
124
|
+
* estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
|
|
125
|
+
* a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
|
|
126
|
+
*/
|
|
127
|
+
/**
|
|
128
|
+
* Do `contracts`, que este pacote já consome — não uma cópia.
|
|
129
|
+
*
|
|
130
|
+
* A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
|
|
131
|
+
* duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
|
|
132
|
+
* Contrato compartilhado é o que o `contracts` existe para guardar.
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
type PreviewUploadedMedia$1 = {
|
|
136
|
+
readonly mediaId: string;
|
|
137
|
+
readonly mimeType?: string;
|
|
138
|
+
readonly filename?: string;
|
|
139
|
+
};
|
|
140
|
+
type PreviewMediaUploadRequest = {
|
|
141
|
+
readonly base64: string;
|
|
142
|
+
readonly mimeType: string;
|
|
143
|
+
readonly filename: string;
|
|
144
|
+
};
|
|
145
|
+
type CreatePreviewMediaUploaderParams = {
|
|
146
|
+
/**
|
|
147
|
+
* Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
|
|
148
|
+
*
|
|
149
|
+
* Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
|
|
150
|
+
* HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
|
|
151
|
+
* escolher por elas.
|
|
152
|
+
*/
|
|
153
|
+
readonly upload: (request: PreviewMediaUploadRequest) => Promise<{
|
|
154
|
+
uploadId: string;
|
|
155
|
+
}>;
|
|
156
|
+
/** Nome usado quando o gravador entrega o áudio sem nome próprio. */
|
|
157
|
+
readonly fallbackFilename?: string;
|
|
158
|
+
readonly fallbackMimeType?: string;
|
|
159
|
+
};
|
|
160
|
+
declare function createPreviewMediaUploader(params: CreatePreviewMediaUploaderParams): (file: File) => Promise<PreviewUploadedMedia$1>;
|
|
161
|
+
|
|
115
162
|
/**
|
|
116
163
|
* Cliente que entrega mensagens do preview no webhook real, assinadas com HMAC — a mesma validação
|
|
117
164
|
* de staging e produção, sem rota alternativa e sem bypass. Do ponto de vista da API, este cliente
|
|
@@ -136,6 +183,19 @@ type PreviewWebhookClient = {
|
|
|
136
183
|
sendListReply(reply: InteractiveReplyOption): Promise<void>;
|
|
137
184
|
sendAudio(mediaId: string): Promise<void>;
|
|
138
185
|
sendMedia(params: SendPreviewMediaParams): Promise<void>;
|
|
186
|
+
/**
|
|
187
|
+
* Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
|
|
188
|
+
*
|
|
189
|
+
* Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
|
|
190
|
+
* sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
|
|
191
|
+
* o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
|
|
192
|
+
* por esquecimento. Cliente montado, microfone na tela.
|
|
193
|
+
*
|
|
194
|
+
* Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
|
|
195
|
+
* o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
|
|
196
|
+
* corretamente não desenha o gravador.
|
|
197
|
+
*/
|
|
198
|
+
uploadMedia?(file: File): Promise<PreviewUploadedMedia$1>;
|
|
139
199
|
};
|
|
140
200
|
type SendPreviewMediaParams = {
|
|
141
201
|
readonly mediaType: InboundMediaType;
|
|
@@ -154,8 +214,20 @@ type CreatePreviewWebhookClientParams = {
|
|
|
154
214
|
readonly appSecret: string;
|
|
155
215
|
readonly from: string;
|
|
156
216
|
readonly phoneNumberId?: string;
|
|
217
|
+
/**
|
|
218
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
|
|
219
|
+
*
|
|
220
|
+
* O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
|
|
221
|
+
* publica a API em outro host ou versiona o caminho.
|
|
222
|
+
*/
|
|
223
|
+
readonly mediaUploadUrl?: string;
|
|
157
224
|
readonly fetchImplementation?: typeof fetch;
|
|
158
225
|
};
|
|
226
|
+
/** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
|
|
227
|
+
declare class PreviewMediaUploadRejectedError extends Error {
|
|
228
|
+
readonly status: number;
|
|
229
|
+
constructor(status: number);
|
|
230
|
+
}
|
|
159
231
|
declare class PreviewInProductionError extends Error {
|
|
160
232
|
constructor();
|
|
161
233
|
}
|
|
@@ -180,6 +252,18 @@ declare function signPreviewPayload(params: {
|
|
|
180
252
|
rawBody: string;
|
|
181
253
|
appSecret: string;
|
|
182
254
|
}): Promise<string>;
|
|
255
|
+
declare const DEFAULT_MEDIA_UPLOAD_PATH = "/v1/preview/media";
|
|
256
|
+
/**
|
|
257
|
+
* O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
|
|
258
|
+
*
|
|
259
|
+
* O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
|
|
260
|
+
* do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
|
|
261
|
+
*/
|
|
262
|
+
declare function createPreviewMediaPoster(params: {
|
|
263
|
+
readonly url: string;
|
|
264
|
+
readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>;
|
|
265
|
+
readonly fetchImplementation?: typeof fetch;
|
|
266
|
+
}): (file: File) => Promise<PreviewUploadedMedia$1>;
|
|
183
267
|
declare function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient;
|
|
184
268
|
|
|
185
269
|
type ConversationPreviewProps = {
|
|
@@ -200,6 +284,7 @@ type ConversationPreviewProps = {
|
|
|
200
284
|
* inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
|
|
201
285
|
* botão que não existe do que um que falha ao ser tocado.
|
|
202
286
|
*/
|
|
287
|
+
/** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
|
|
203
288
|
uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>;
|
|
204
289
|
};
|
|
205
290
|
type PreviewUploadedMedia = {
|
|
@@ -269,6 +354,18 @@ type CreatePreviewBridgeClientParams = {
|
|
|
269
354
|
readonly endpointUrl?: string;
|
|
270
355
|
readonly headers?: Readonly<Record<string, string>>;
|
|
271
356
|
readonly fetchImplementation?: typeof fetch;
|
|
357
|
+
/**
|
|
358
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
|
|
359
|
+
*
|
|
360
|
+
* Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
|
|
361
|
+
* e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
|
|
362
|
+
*/
|
|
363
|
+
readonly mediaUploadUrl?: string;
|
|
364
|
+
/**
|
|
365
|
+
* Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
|
|
366
|
+
* não há origem a derivar, e sem destino o gravador não é desenhado.
|
|
367
|
+
*/
|
|
368
|
+
readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia$1>;
|
|
272
369
|
};
|
|
273
370
|
declare function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient;
|
|
274
371
|
|
|
@@ -370,4 +467,4 @@ type MediaTypesPreviewProps = {
|
|
|
370
467
|
};
|
|
371
468
|
declare function MediaTypesPreview({ conversationId, className, }: MediaTypesPreviewProps): react.JSX.Element;
|
|
372
469
|
|
|
373
|
-
export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewBridgeClientParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_PREVIEW_SCRIPT, GLOBAL_CHANNEL, type ListConversationsFilters, MEDIA_TYPES_CONVERSATION_ID, MediaTypesPreview, type MediaTypesPreviewProps, type MockEventSource, PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_FILE_SAMPLES, PREVIEW_MESSAGES, PreviewBridgeRejectedError, type PreviewEmission, PreviewInProductionError, type PreviewInboundCommand, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewInboundCommand, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewBridgeClient, createPreviewMediaResolver, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
|
|
470
|
+
export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewBridgeClientParams, type CreatePreviewMediaUploaderParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_MEDIA_UPLOAD_PATH, DEFAULT_PREVIEW_SCRIPT, GLOBAL_CHANNEL, type ListConversationsFilters, MEDIA_TYPES_CONVERSATION_ID, MediaTypesPreview, type MediaTypesPreviewProps, type MockEventSource, PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_FILE_SAMPLES, PREVIEW_MESSAGES, PreviewBridgeRejectedError, type PreviewEmission, PreviewInProductionError, type PreviewInboundCommand, PreviewMediaUploadRejectedError, type PreviewMediaUploadRequest, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewInboundCommand, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewBridgeClient, createPreviewMediaPoster, createPreviewMediaResolver, createPreviewMediaUploader, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
|
package/dist/preview/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
DocumentsLibrary,
|
|
9
9
|
MessageBubble,
|
|
10
10
|
MessageComposer
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-TV4OQRGH.js";
|
|
12
12
|
import "../chunk-2AYDBWNE.js";
|
|
13
13
|
|
|
14
14
|
// src/preview/previewStore.ts
|
|
@@ -980,11 +980,12 @@ function ConversationPreview({
|
|
|
980
980
|
setFailure(error instanceof Error ? error.message : "Falha ao entregar a resposta no webhook.");
|
|
981
981
|
}
|
|
982
982
|
}
|
|
983
|
+
const uploadFile = uploadMedia ?? client.uploadMedia;
|
|
983
984
|
async function handleAttach(file) {
|
|
984
|
-
if (!
|
|
985
|
+
if (!uploadFile) return;
|
|
985
986
|
setFailure(void 0);
|
|
986
987
|
try {
|
|
987
|
-
const uploaded = await
|
|
988
|
+
const uploaded = await uploadFile(file);
|
|
988
989
|
await client.sendMedia({
|
|
989
990
|
mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
|
|
990
991
|
mediaId: uploaded.mediaId,
|
|
@@ -1018,9 +1019,9 @@ function ConversationPreview({
|
|
|
1018
1019
|
MessageComposer,
|
|
1019
1020
|
{
|
|
1020
1021
|
onSend: (text) => void handleSend(text),
|
|
1021
|
-
onAttach:
|
|
1022
|
+
onAttach: uploadFile ? (file) => void handleAttach(file) : void 0,
|
|
1022
1023
|
placeholder: isRecording ? "Gravando\u2026 toque no quadrado para ouvir" : placeholder ?? "Escreva como o cliente\u2026",
|
|
1023
|
-
idleAction:
|
|
1024
|
+
idleAction: uploadFile ? /* @__PURE__ */ jsx(
|
|
1024
1025
|
AudioRecorderButton,
|
|
1025
1026
|
{
|
|
1026
1027
|
onRecorded: (file) => void handleAttach(file),
|
|
@@ -1041,6 +1042,38 @@ import {
|
|
|
1041
1042
|
buildInboundTextPayload,
|
|
1042
1043
|
serializeWebhookPayload
|
|
1043
1044
|
} from "@adatechnology/meta-whatsapp-contracts/testing";
|
|
1045
|
+
|
|
1046
|
+
// src/preview/createPreviewMediaUploader.ts
|
|
1047
|
+
import { PREVIEW_MEDIA_ID_PREFIX } from "@adatechnology/meta-whatsapp-contracts";
|
|
1048
|
+
import { toPreviewMediaId } from "@adatechnology/meta-whatsapp-contracts";
|
|
1049
|
+
var CHUNK_SIZE = 8192;
|
|
1050
|
+
async function fileToBase64(file) {
|
|
1051
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1052
|
+
let binary = "";
|
|
1053
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
|
|
1054
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE));
|
|
1055
|
+
}
|
|
1056
|
+
return btoa(binary);
|
|
1057
|
+
}
|
|
1058
|
+
function createPreviewMediaUploader(params) {
|
|
1059
|
+
const fallbackMimeType = params.fallbackMimeType ?? "audio/ogg";
|
|
1060
|
+
const fallbackFilename = params.fallbackFilename ?? "audio.ogg";
|
|
1061
|
+
return async function uploadPreviewMedia(file) {
|
|
1062
|
+
const mimeType = file.type || fallbackMimeType;
|
|
1063
|
+
const filename = file.name || fallbackFilename;
|
|
1064
|
+
const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename });
|
|
1065
|
+
return { mediaId: toPreviewMediaId(uploadId), mimeType, filename };
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/preview/createPreviewWebhookClient.ts
|
|
1070
|
+
var PreviewMediaUploadRejectedError = class extends Error {
|
|
1071
|
+
constructor(status) {
|
|
1072
|
+
super(`A rota de m\xEDdia do simulador recusou o upload (HTTP ${status}).`);
|
|
1073
|
+
this.status = status;
|
|
1074
|
+
this.name = "PreviewMediaUploadRejectedError";
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1044
1077
|
var PreviewInProductionError = class extends Error {
|
|
1045
1078
|
constructor() {
|
|
1046
1079
|
super("O preview de conversa carrega um app secret e n\xE3o pode ser montado em produ\xE7\xE3o.");
|
|
@@ -1070,6 +1103,30 @@ async function signPreviewPayload(params) {
|
|
|
1070
1103
|
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1071
1104
|
}
|
|
1072
1105
|
var signWithWebCrypto = signPreviewPayload;
|
|
1106
|
+
var DEFAULT_MEDIA_UPLOAD_PATH = "/v1/preview/media";
|
|
1107
|
+
function defaultMediaUploadUrl(webhookUrl) {
|
|
1108
|
+
try {
|
|
1109
|
+
return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString();
|
|
1110
|
+
} catch {
|
|
1111
|
+
return DEFAULT_MEDIA_UPLOAD_PATH;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function createPreviewMediaPoster(params) {
|
|
1115
|
+
return createPreviewMediaUploader({
|
|
1116
|
+
upload: async (request) => {
|
|
1117
|
+
const performRequest = params.fetchImplementation ?? fetch;
|
|
1118
|
+
const extraHeaders = await params.headers?.(request.mimeType) ?? {};
|
|
1119
|
+
const response = await performRequest(params.url, {
|
|
1120
|
+
method: "POST",
|
|
1121
|
+
headers: { "content-type": "application/json", ...extraHeaders },
|
|
1122
|
+
body: JSON.stringify(request)
|
|
1123
|
+
});
|
|
1124
|
+
if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status);
|
|
1125
|
+
const body = await response.json();
|
|
1126
|
+
return { uploadId: body.data.uploadId };
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1073
1130
|
function createPreviewWebhookClient(params) {
|
|
1074
1131
|
const sendPayload = async (payload) => {
|
|
1075
1132
|
const rawBody = serializeWebhookPayload(payload);
|
|
@@ -1082,13 +1139,21 @@ function createPreviewWebhookClient(params) {
|
|
|
1082
1139
|
});
|
|
1083
1140
|
if (!response.ok) throw new PreviewWebhookRejectedError(response.status);
|
|
1084
1141
|
};
|
|
1142
|
+
const uploadMedia = createPreviewMediaPoster({
|
|
1143
|
+
url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
|
|
1144
|
+
headers: async (mimeType) => ({
|
|
1145
|
+
"x-preview-signature": await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret })
|
|
1146
|
+
}),
|
|
1147
|
+
...params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}
|
|
1148
|
+
});
|
|
1085
1149
|
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId };
|
|
1086
1150
|
return {
|
|
1087
1151
|
sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
|
|
1088
1152
|
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
1089
1153
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
1090
1154
|
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
1091
|
-
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media }))
|
|
1155
|
+
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
1156
|
+
uploadMedia
|
|
1092
1157
|
};
|
|
1093
1158
|
}
|
|
1094
1159
|
|
|
@@ -1117,15 +1182,27 @@ function buildFetchSender(params) {
|
|
|
1117
1182
|
if (!response.ok) throw new PreviewBridgeRejectedError(response.status);
|
|
1118
1183
|
};
|
|
1119
1184
|
}
|
|
1185
|
+
function resolveBridgeUpload(params) {
|
|
1186
|
+
if (params.uploadMedia) return params.uploadMedia;
|
|
1187
|
+
const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : void 0);
|
|
1188
|
+
if (!url) return void 0;
|
|
1189
|
+
return createPreviewMediaPoster({
|
|
1190
|
+
url,
|
|
1191
|
+
...params.headers ? { headers: async () => params.headers ?? {} } : {},
|
|
1192
|
+
...params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1120
1195
|
function createPreviewBridgeClient(params) {
|
|
1121
1196
|
const send = params.sendCommand ?? buildFetchSender(params);
|
|
1122
1197
|
const from = params.from;
|
|
1198
|
+
const uploadMedia = resolveBridgeUpload(params);
|
|
1123
1199
|
return {
|
|
1124
1200
|
sendText: (text) => send({ kind: "text", from, text }),
|
|
1125
1201
|
sendButtonReply: (reply) => send({ kind: "buttonReply", from, reply }),
|
|
1126
1202
|
sendListReply: (reply) => send({ kind: "listReply", from, reply }),
|
|
1127
1203
|
sendAudio: (mediaId) => send({ kind: "audio", from, mediaId }),
|
|
1128
|
-
sendMedia: (media) => send({ kind: "media", from, ...media })
|
|
1204
|
+
sendMedia: (media) => send({ kind: "media", from, ...media }),
|
|
1205
|
+
...uploadMedia ? { uploadMedia } : {}
|
|
1129
1206
|
};
|
|
1130
1207
|
}
|
|
1131
1208
|
|
|
@@ -1218,6 +1295,7 @@ export {
|
|
|
1218
1295
|
AudioRecorderButton,
|
|
1219
1296
|
ConversationPreview,
|
|
1220
1297
|
DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
|
|
1298
|
+
DEFAULT_MEDIA_UPLOAD_PATH,
|
|
1221
1299
|
DEFAULT_PREVIEW_SCRIPT,
|
|
1222
1300
|
GLOBAL_CHANNEL,
|
|
1223
1301
|
MEDIA_TYPES_CONVERSATION_ID,
|
|
@@ -1225,9 +1303,11 @@ export {
|
|
|
1225
1303
|
PREVIEW_CONVERSATIONS,
|
|
1226
1304
|
PREVIEW_DOCUMENTS,
|
|
1227
1305
|
PREVIEW_FILE_SAMPLES,
|
|
1306
|
+
PREVIEW_MEDIA_ID_PREFIX,
|
|
1228
1307
|
PREVIEW_MESSAGES,
|
|
1229
1308
|
PreviewBridgeRejectedError,
|
|
1230
1309
|
PreviewInProductionError,
|
|
1310
|
+
PreviewMediaUploadRejectedError,
|
|
1231
1311
|
PreviewWebhookRejectedError,
|
|
1232
1312
|
assertPreviewEnvironment,
|
|
1233
1313
|
conversationChannel,
|
|
@@ -1235,7 +1315,9 @@ export {
|
|
|
1235
1315
|
createMockEventSource,
|
|
1236
1316
|
createMockSSEProvider,
|
|
1237
1317
|
createPreviewBridgeClient,
|
|
1318
|
+
createPreviewMediaPoster,
|
|
1238
1319
|
createPreviewMediaResolver,
|
|
1320
|
+
createPreviewMediaUploader,
|
|
1239
1321
|
createPreviewStore,
|
|
1240
1322
|
createPreviewWebhookClient,
|
|
1241
1323
|
mediaTypeOf,
|
package/dist/styles.css
CHANGED
|
@@ -207,3 +207,33 @@
|
|
|
207
207
|
max-width: calc(100vw - 1.5rem);
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
|
+
.cv-scrollbar-thin {
|
|
211
|
+
scrollbar-width: thin;
|
|
212
|
+
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
|
|
213
|
+
}
|
|
214
|
+
.cv-scrollbar-thin::-webkit-scrollbar {
|
|
215
|
+
width: 4px;
|
|
216
|
+
height: 4px;
|
|
217
|
+
}
|
|
218
|
+
.cv-scrollbar-thin::-webkit-scrollbar-track {
|
|
219
|
+
background: transparent;
|
|
220
|
+
}
|
|
221
|
+
.cv-scrollbar-thin::-webkit-scrollbar-thumb {
|
|
222
|
+
background: rgba(0, 0, 0, 0.15);
|
|
223
|
+
border-radius: 2px;
|
|
224
|
+
}
|
|
225
|
+
.cv-scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
226
|
+
background: rgba(0, 0, 0, 0.3);
|
|
227
|
+
}
|
|
228
|
+
.dark .cv-scrollbar-thin {
|
|
229
|
+
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
|
230
|
+
}
|
|
231
|
+
.dark .cv-scrollbar-thin::-webkit-scrollbar-thumb {
|
|
232
|
+
background: rgba(255, 255, 255, 0.15);
|
|
233
|
+
}
|
|
234
|
+
.dark .cv-scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
235
|
+
background: rgba(255, 255, 255, 0.3);
|
|
236
|
+
}
|
|
237
|
+
:where(button:not(:disabled), [role=button]:not(:disabled), summary) {
|
|
238
|
+
cursor: pointer;
|
|
239
|
+
}
|
|
@@ -90,19 +90,49 @@ interface MessagePayload {
|
|
|
90
90
|
isOffensive: boolean;
|
|
91
91
|
terms: string[];
|
|
92
92
|
} | null;
|
|
93
|
+
/**
|
|
94
|
+
* Transcrição do áudio, vinda do backend — a UI só exibe, nunca transcreve. Rodar STT no browser
|
|
95
|
+
* exigiria baixar modelo por aba e daria resultado diferente por versão de cliente.
|
|
96
|
+
*
|
|
97
|
+
* `null`/ausente = não avaliado, que é diferente de `'done'` com texto vazio (áudio em silêncio,
|
|
98
|
+
* já processado). É essa distinção que decide se o balão oferece "transcrever" ou "sem fala
|
|
99
|
+
* detectada".
|
|
100
|
+
*/
|
|
101
|
+
transcription?: MessageTranscription | null;
|
|
93
102
|
isFirstInGroup?: boolean;
|
|
94
103
|
isLastInGroup?: boolean;
|
|
95
104
|
}
|
|
105
|
+
type TranscriptionStatus = 'pending' | 'done' | 'failed' | 'unsupported';
|
|
106
|
+
/**
|
|
107
|
+
* Quando transcrever, escolhido nas configurações da empresa. Espelha o
|
|
108
|
+
* `TranscriptionMode` de `@adatechnology/meta-whatsapp-contracts`; declarado aqui para o pacote de
|
|
109
|
+
* UI não obrigar quem só desenha telas a instalar os contratos do backend.
|
|
110
|
+
*/
|
|
111
|
+
type TranscriptionMode = 'auto' | 'onDemand';
|
|
112
|
+
interface MessageTranscription {
|
|
113
|
+
status: TranscriptionStatus;
|
|
114
|
+
text?: string | null;
|
|
115
|
+
/** ISO 639-1 ou nome do idioma, conforme o engine. Exibido como dica, não interpretado. */
|
|
116
|
+
language?: string | null;
|
|
117
|
+
engine?: string | null;
|
|
118
|
+
}
|
|
96
119
|
|
|
97
120
|
type ResolveMediaUrl = (message: MessagePayload) => Promise<string | null>;
|
|
98
121
|
interface MediaRendererProps {
|
|
99
122
|
message: MessagePayload;
|
|
100
123
|
onLightbox: (src: string) => void;
|
|
101
124
|
onResolveUrl?: ResolveMediaUrl;
|
|
125
|
+
/**
|
|
126
|
+
* Pede ao backend a transcrição do áudio desta mensagem. Ausente, o bloco de transcrição só exibe
|
|
127
|
+
* o que já veio pronto — sem oferecer um botão que o host não sabe atender.
|
|
128
|
+
*
|
|
129
|
+
* O que devolver é exibido na hora, sem esperar refetch da lista.
|
|
130
|
+
*/
|
|
131
|
+
onTranscribeAudio?: () => Promise<MessageTranscription | void>;
|
|
102
132
|
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
103
133
|
className?: string;
|
|
104
134
|
}
|
|
105
|
-
declare function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps): react.JSX.Element | null;
|
|
135
|
+
declare function MediaRenderer({ message, onLightbox, onResolveUrl, onTranscribeAudio, className, }: MediaRendererProps): react.JSX.Element | null;
|
|
106
136
|
|
|
107
137
|
/**
|
|
108
138
|
* Gravação de áudio no simulador, pelo microfone do próprio navegador.
|
|
@@ -358,6 +388,16 @@ interface ConversationsApi {
|
|
|
358
388
|
transcript: string;
|
|
359
389
|
filename: string;
|
|
360
390
|
}>;
|
|
391
|
+
/**
|
|
392
|
+
* Transcreve o áudio de uma mensagem e devolve o resultado.
|
|
393
|
+
*
|
|
394
|
+
* **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
|
|
395
|
+
* nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
|
|
396
|
+
* simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
|
|
397
|
+
*
|
|
398
|
+
* `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
|
|
399
|
+
*/
|
|
400
|
+
transcribeAudio?(messageId: string): Promise<MessageTranscription>;
|
|
361
401
|
}
|
|
362
402
|
/**
|
|
363
403
|
* Superfície mínima de stream que o pacote consome — exatamente o que `useConversationRealtime`
|
|
@@ -413,4 +453,4 @@ interface ConversationDocument {
|
|
|
413
453
|
linkedAt: string;
|
|
414
454
|
}
|
|
415
455
|
|
|
416
|
-
export { AudioRecorderButton as A, type ListDocumentsParams as B, CHANNEL_CAPABILITIES as C, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS as D, type MediaRendererProps as E, type FormatContactHandleParams as F, type MessagePayload as G, HANDLE_KIND as H, type InteractiveOption as I, type
|
|
456
|
+
export { AudioRecorderButton as A, type ListDocumentsParams as B, CHANNEL_CAPABILITIES as C, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS as D, type MediaRendererProps as E, type FormatContactHandleParams as F, type MessagePayload as G, HANDLE_KIND as H, type InteractiveOption as I, type MessageTranscription as J, type ReopenMechanism as K, type ListConversationsParams as L, MediaRenderer as M, type ResolveMediaUrl as N, type TranscriptionStatus as O, capabilitiesOf as P, channelFiltersFor as Q, REOPEN_MECHANISM as R, type SSEProvider as S, type TranscriptionMode as T, contactFlag as U, formatContactHandle as V, type AudioRecorderButtonLabels as a, type AudioRecorderButtonProps as b, CHANNEL_FILTER_ALL as c, CONVERSATION_CHANNEL as d, type ChannelCapabilities as e, type ChannelFilter as f, type ChannelFilterOption as g, type CompanyDocument as h, type CompanyDocumentPage as i, type ConversationChannel as j, type ConversationDocument as k, type ConversationDocumentPage as l, type ConversationEventSource as m, type ConversationPage as n, type ConversationSummary as o, type ConversationTemplate as p, type ConversationsApi as q, type ConversationsFeatures as r, type ConversationsTheme as s, type ConversationsUIConfig as t, DEFAULT_CONVERSATION_CHANNEL as u, DEFAULT_MAX_RECORDING_MILLISECONDS as v, type HandleKind as w, type InteractivePayload as x, type InteractiveSection as y, type InteractiveSelection as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.21",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"lucide-react": "^1.21.0",
|
|
33
33
|
"tailwind-merge": "^3.6.0",
|
|
34
|
-
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.
|
|
34
|
+
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.9"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^18 || ^19",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { AudioTranscription } from './AudioTranscription'
|
|
5
|
+
import type { MessageTranscription } from './types'
|
|
6
|
+
|
|
7
|
+
function render(props: {
|
|
8
|
+
transcription?: MessageTranscription | null
|
|
9
|
+
onTranscribe?: () => Promise<void>
|
|
10
|
+
}): string {
|
|
11
|
+
return renderToStaticMarkup(<AudioTranscription {...props} />)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const noop = async () => undefined
|
|
15
|
+
|
|
16
|
+
describe('AudioTranscription', () => {
|
|
17
|
+
it('mostra o texto e oferece copiar', () => {
|
|
18
|
+
const markup = render({ transcription: { status: 'done', text: 'quero dois pães na chapa' } })
|
|
19
|
+
|
|
20
|
+
expect(markup).toContain('quero dois pães na chapa')
|
|
21
|
+
expect(markup).toContain('Copiar')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('deixa o texto selecionável — é o caminho de quem não tem clipboard disponível', () => {
|
|
25
|
+
const markup = render({ transcription: { status: 'done', text: 'oi' } })
|
|
26
|
+
|
|
27
|
+
expect(markup).toContain('select-all')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('não oferece copiar quando não há texto para copiar', () => {
|
|
31
|
+
const markup = render({ transcription: { status: 'done', text: '' } })
|
|
32
|
+
|
|
33
|
+
expect(markup).not.toContain('Copiar')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Silêncio processado é diferente de não transcrito: sem essa distinção o operador clica em
|
|
37
|
+
// transcrever de novo atrás de um texto que não existe.
|
|
38
|
+
it('diz "sem fala detectada" em áudio processado e vazio, sem oferecer transcrever', () => {
|
|
39
|
+
const markup = render({ transcription: { status: 'done', text: ' ' } })
|
|
40
|
+
|
|
41
|
+
expect(markup).toContain('Sem fala detectada')
|
|
42
|
+
expect(markup).not.toContain('Transcrever áudio')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('oferece transcrever quando nada foi avaliado ainda', () => {
|
|
46
|
+
const markup = render({ transcription: null, onTranscribe: noop })
|
|
47
|
+
|
|
48
|
+
expect(markup).toContain('Transcrever áudio')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Mesmo padrão de takeover/release: sem a porta, a afordância não existe — melhor que um botão
|
|
52
|
+
// que estoura no clique.
|
|
53
|
+
it('não desenha nada quando não há transcrição nem forma de pedir uma', () => {
|
|
54
|
+
expect(render({ transcription: null })).toBe('')
|
|
55
|
+
expect(render({})).toBe('')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('mostra falha com convite a tentar de novo', () => {
|
|
59
|
+
const markup = render({ transcription: { status: 'failed' }, onTranscribe: noop })
|
|
60
|
+
|
|
61
|
+
expect(markup).toContain('Falha ao transcrever')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('trata pendente como em andamento — já foi tentado e vai sair', () => {
|
|
65
|
+
const markup = render({ transcription: { status: 'pending' }, onTranscribe: noop })
|
|
66
|
+
|
|
67
|
+
expect(markup).toContain('Transcrevendo...')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('avisa formato não suportado sem oferecer retry — retentar não conserta codec', () => {
|
|
71
|
+
const markup = render({ transcription: { status: 'unsupported' }, onTranscribe: noop })
|
|
72
|
+
|
|
73
|
+
expect(markup).toContain('não suportado')
|
|
74
|
+
expect(markup).not.toContain('Transcrever novamente')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Medido: 1147 caracteres produziram uma bolha de 854px, mais alta que a área visível da conversa.
|
|
79
|
+
* Sem o recolhimento, uma nota de voz longa esconde as mensagens seguintes.
|
|
80
|
+
*/
|
|
81
|
+
it('recolhe transcrição longa e oferece ver o texto completo', () => {
|
|
82
|
+
const markup = render({ transcription: { status: 'done', text: 'palavra '.repeat(60).trim() } })
|
|
83
|
+
|
|
84
|
+
expect(markup).toContain('ver transcrição completa')
|
|
85
|
+
expect(markup).toContain('-webkit-line-clamp')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('não recolhe transcrição curta', () => {
|
|
89
|
+
const markup = render({ transcription: { status: 'done', text: 'quero dois pães' } })
|
|
90
|
+
|
|
91
|
+
expect(markup).not.toContain('ver transcrição completa')
|
|
92
|
+
expect(markup).not.toContain('-webkit-line-clamp')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Recolher é sobre altura na tela; o operador cola o pedido inteiro no sistema interno.
|
|
96
|
+
it('mantém o texto inteiro no DOM mesmo recolhido, para o copiar levar tudo', () => {
|
|
97
|
+
const markup = render({ transcription: { status: 'done', text: 'primeira ' + 'meio '.repeat(70) + 'ultima' } })
|
|
98
|
+
|
|
99
|
+
expect(markup).toContain('primeira')
|
|
100
|
+
expect(markup).toContain('ultima')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('oferece retranscrever quando já há texto e o host sabe transcrever', () => {
|
|
104
|
+
const markup = render({ transcription: { status: 'done', text: 'ruim' }, onTranscribe: noop })
|
|
105
|
+
|
|
106
|
+
expect(markup).toContain('Transcrever novamente')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('não oferece retranscrever quando o host não sabe transcrever', () => {
|
|
110
|
+
const markup = render({ transcription: { status: 'done', text: 'ok' } })
|
|
111
|
+
|
|
112
|
+
expect(markup).toContain('ok')
|
|
113
|
+
expect(markup).not.toContain('Transcrever novamente')
|
|
114
|
+
})
|
|
115
|
+
})
|