@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
  2. package/dist/index.d.ts +142 -9
  3. package/dist/index.js +374 -66
  4. package/dist/preview/index.d.ts +122 -3
  5. package/dist/preview/index.js +137 -21
  6. package/dist/styles.css +112 -0
  7. package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
  8. package/package.json +2 -2
  9. package/src/AudioTranscription.test.tsx +115 -0
  10. package/src/AudioTranscription.tsx +249 -0
  11. package/src/ConversationContextPanel.tsx +205 -52
  12. package/src/ConversationLocalesProvider.tsx +28 -0
  13. package/src/MediaRenderer.tsx +37 -6
  14. package/src/MessageBubble.tsx +26 -3
  15. package/src/MessageComposer.tsx +21 -2
  16. package/src/Wallpaper.tsx +27 -13
  17. package/src/conversationTranscript.test.ts +57 -0
  18. package/src/conversationTranscript.ts +29 -4
  19. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  20. package/src/index.ts +11 -0
  21. package/src/preview/ConversationPreview.tsx +13 -4
  22. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  23. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  24. package/src/preview/createPreviewBridgeClient.ts +37 -1
  25. package/src/preview/createPreviewMediaUploader.ts +82 -0
  26. package/src/preview/createPreviewWebhookClient.test.ts +89 -0
  27. package/src/preview/createPreviewWebhookClient.ts +91 -0
  28. package/src/preview/index.ts +9 -0
  29. package/src/preview/previewMediaUploader.test.ts +61 -0
  30. package/src/providers/types.ts +12 -1
  31. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  32. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  33. package/src/styles.css +122 -0
  34. package/src/types.ts +26 -0
@@ -1,7 +1,9 @@
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, K as ResolveMediaUrl } from '../types-O7kMP1Yn.js';
2
- export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-O7kMP1Yn.js';
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
+ import { ReactNode } from 'react';
4
5
  import { InteractiveReplyOption, InboundMediaType } from '@adatechnology/meta-whatsapp-contracts/testing';
6
+ export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
5
7
 
6
8
  /**
7
9
  * Estado em memória que alimenta o preview de atendimento humano. Mock de API e mock de SSE
@@ -112,6 +114,52 @@ declare const PREVIEW_CONVERSATIONS: readonly ConversationSummary[];
112
114
  declare const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]>>;
113
115
  declare const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDocument[]>>;
114
116
 
117
+ /**
118
+ * Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
119
+ *
120
+ * O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
121
+ * ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
122
+ * lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
123
+ *
124
+ * Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
125
+ * estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
126
+ * a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
127
+ */
128
+ /**
129
+ * Do `contracts`, que este pacote já consome — não uma cópia.
130
+ *
131
+ * A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
132
+ * duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
133
+ * Contrato compartilhado é o que o `contracts` existe para guardar.
134
+ */
135
+
136
+ type PreviewUploadedMedia$1 = {
137
+ readonly mediaId: string;
138
+ readonly mimeType?: string;
139
+ readonly filename?: string;
140
+ };
141
+ type PreviewMediaUploadRequest = {
142
+ readonly base64: string;
143
+ readonly mimeType: string;
144
+ readonly filename: string;
145
+ };
146
+ type CreatePreviewMediaUploaderParams = {
147
+ /**
148
+ * Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
149
+ *
150
+ * Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
151
+ * HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
152
+ * escolher por elas.
153
+ */
154
+ readonly upload: (request: PreviewMediaUploadRequest) => Promise<{
155
+ uploadId: string;
156
+ }>;
157
+ /** Nome usado quando o gravador entrega o áudio sem nome próprio. */
158
+ readonly fallbackFilename?: string;
159
+ readonly fallbackMimeType?: string;
160
+ };
161
+ declare function createPreviewMediaUploader(params: CreatePreviewMediaUploaderParams): (file: File) => Promise<PreviewUploadedMedia$1>;
162
+
115
163
  /**
116
164
  * Cliente que entrega mensagens do preview no webhook real, assinadas com HMAC — a mesma validação
117
165
  * de staging e produção, sem rota alternativa e sem bypass. Do ponto de vista da API, este cliente
@@ -136,6 +184,19 @@ type PreviewWebhookClient = {
136
184
  sendListReply(reply: InteractiveReplyOption): Promise<void>;
137
185
  sendAudio(mediaId: string): Promise<void>;
138
186
  sendMedia(params: SendPreviewMediaParams): Promise<void>;
187
+ /**
188
+ * Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
189
+ *
190
+ * Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
191
+ * sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
192
+ * o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
193
+ * por esquecimento. Cliente montado, microfone na tela.
194
+ *
195
+ * Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
196
+ * o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
197
+ * corretamente não desenha o gravador.
198
+ */
199
+ uploadMedia?(file: File): Promise<PreviewUploadedMedia$1>;
139
200
  };
140
201
  type SendPreviewMediaParams = {
141
202
  readonly mediaType: InboundMediaType;
@@ -154,8 +215,20 @@ type CreatePreviewWebhookClientParams = {
154
215
  readonly appSecret: string;
155
216
  readonly from: string;
156
217
  readonly phoneNumberId?: string;
218
+ /**
219
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
220
+ *
221
+ * O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
222
+ * publica a API em outro host ou versiona o caminho.
223
+ */
224
+ readonly mediaUploadUrl?: string;
157
225
  readonly fetchImplementation?: typeof fetch;
158
226
  };
227
+ /** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
228
+ declare class PreviewMediaUploadRejectedError extends Error {
229
+ readonly status: number;
230
+ constructor(status: number);
231
+ }
159
232
  declare class PreviewInProductionError extends Error {
160
233
  constructor();
161
234
  }
@@ -180,6 +253,18 @@ declare function signPreviewPayload(params: {
180
253
  rawBody: string;
181
254
  appSecret: string;
182
255
  }): Promise<string>;
256
+ declare const DEFAULT_MEDIA_UPLOAD_PATH = "/v1/preview/media";
257
+ /**
258
+ * O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
259
+ *
260
+ * O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
261
+ * do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
262
+ */
263
+ declare function createPreviewMediaPoster(params: {
264
+ readonly url: string;
265
+ readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>;
266
+ readonly fetchImplementation?: typeof fetch;
267
+ }): (file: File) => Promise<PreviewUploadedMedia$1>;
183
268
  declare function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient;
184
269
 
185
270
  type ConversationPreviewProps = {
@@ -200,6 +285,7 @@ type ConversationPreviewProps = {
200
285
  * inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
201
286
  * botão que não existe do que um que falha ao ser tocado.
202
287
  */
288
+ /** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
203
289
  uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>;
204
290
  };
205
291
  type PreviewUploadedMedia = {
@@ -211,6 +297,27 @@ type PreviewUploadedMedia = {
211
297
  declare function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'];
212
298
  declare function ConversationPreview({ client, sse, conversationId, loadMessages, placeholder, pollIntervalMs, uploadMedia, }: ConversationPreviewProps): react.JSX.Element;
213
299
 
300
+ type ConversationSimulatorPanelLabels = {
301
+ readonly title: string;
302
+ /** Complementa o telefone no subtítulo, explicando para onde a mensagem realmente vai. */
303
+ readonly destinationHint: string;
304
+ readonly close: string;
305
+ readonly placeholder: string;
306
+ };
307
+ declare const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorPanelLabels;
308
+ type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
309
+ readonly onClose: () => void;
310
+ /**
311
+ * Telefone já formatado para leitura. É o host que formata: máscara de telefone é convenção
312
+ * regional, e o pacote não tem como saber a do produto.
313
+ */
314
+ readonly displayNumber?: string;
315
+ readonly labels?: Partial<ConversationSimulatorPanelLabels>;
316
+ /** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
317
+ readonly headerActions?: ReactNode;
318
+ };
319
+ declare function ConversationSimulatorPanel({ onClose, displayNumber, labels, headerActions, ...previewProps }: ConversationSimulatorPanelProps): react.JSX.Element;
320
+
214
321
  /**
215
322
  * Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
216
323
  * navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
@@ -269,6 +376,18 @@ type CreatePreviewBridgeClientParams = {
269
376
  readonly endpointUrl?: string;
270
377
  readonly headers?: Readonly<Record<string, string>>;
271
378
  readonly fetchImplementation?: typeof fetch;
379
+ /**
380
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
381
+ *
382
+ * Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
383
+ * e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
384
+ */
385
+ readonly mediaUploadUrl?: string;
386
+ /**
387
+ * Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
388
+ * não há origem a derivar, e sem destino o gravador não é desenhado.
389
+ */
390
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia$1>;
272
391
  };
273
392
  declare function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient;
274
393
 
@@ -370,4 +489,4 @@ type MediaTypesPreviewProps = {
370
489
  };
371
490
  declare function MediaTypesPreview({ conversationId, className, }: MediaTypesPreviewProps): react.JSX.Element;
372
491
 
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 };
492
+ export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, ConversationSimulatorPanel, type ConversationSimulatorPanelLabels, type ConversationSimulatorPanelProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewBridgeClientParams, type CreatePreviewMediaUploaderParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, 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 };
@@ -8,7 +8,7 @@ import {
8
8
  DocumentsLibrary,
9
9
  MessageBubble,
10
10
  MessageComposer
11
- } from "../chunk-OIDAIVCH.js";
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 (!uploadMedia) return;
985
+ if (!uploadFile) return;
985
986
  setFailure(void 0);
986
987
  try {
987
- const uploaded = await uploadMedia(file);
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: uploadMedia ? (file) => void handleAttach(file) : void 0,
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: uploadMedia ? /* @__PURE__ */ jsx(
1024
+ idleAction: uploadFile ? /* @__PURE__ */ jsx(
1024
1025
  AudioRecorderButton,
1025
1026
  {
1026
1027
  onRecorded: (file) => void handleAttach(file),
@@ -1033,6 +1034,38 @@ function ConversationPreview({
1033
1034
  ] });
1034
1035
  }
1035
1036
 
1037
+ // src/preview/ConversationSimulatorPanel.tsx
1038
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1039
+ var DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS = {
1040
+ title: "Simulador do cliente",
1041
+ destinationHint: "entrega no webhook real",
1042
+ close: "Fechar simulador",
1043
+ placeholder: "Escreva como o cliente\u2026"
1044
+ };
1045
+ function ConversationSimulatorPanel({
1046
+ onClose,
1047
+ displayNumber,
1048
+ labels,
1049
+ headerActions,
1050
+ ...previewProps
1051
+ }) {
1052
+ const text = { ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, ...labels };
1053
+ const subtitle = [displayNumber ?? previewProps.conversationId, text.destinationHint].join(" \xB7 ");
1054
+ return /* @__PURE__ */ jsxs2("aside", { className: "cv-simulator-panel", "aria-label": text.title, children: [
1055
+ /* @__PURE__ */ jsxs2("header", { className: "cv-simulator-panel__header", children: [
1056
+ /* @__PURE__ */ jsxs2("div", { className: "cv-simulator-panel__heading", children: [
1057
+ /* @__PURE__ */ jsx2("h2", { className: "cv-simulator-panel__title", children: text.title }),
1058
+ /* @__PURE__ */ jsx2("p", { className: "cv-simulator-panel__subtitle", children: subtitle })
1059
+ ] }),
1060
+ /* @__PURE__ */ jsxs2("div", { className: "cv-simulator-panel__actions", children: [
1061
+ headerActions,
1062
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: onClose, title: text.close, "aria-label": text.close, className: "cv-simulator-panel__close", children: /* @__PURE__ */ jsx2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M18 6 6 18M6 6l12 12" }) }) })
1063
+ ] })
1064
+ ] }),
1065
+ /* @__PURE__ */ jsx2("div", { className: "cv-simulator-panel__body", children: /* @__PURE__ */ jsx2(ConversationPreview, { ...previewProps, placeholder: text.placeholder }) })
1066
+ ] });
1067
+ }
1068
+
1036
1069
  // src/preview/createPreviewWebhookClient.ts
1037
1070
  import {
1038
1071
  buildInboundAudioPayload,
@@ -1041,6 +1074,38 @@ import {
1041
1074
  buildInboundTextPayload,
1042
1075
  serializeWebhookPayload
1043
1076
  } from "@adatechnology/meta-whatsapp-contracts/testing";
1077
+
1078
+ // src/preview/createPreviewMediaUploader.ts
1079
+ import { PREVIEW_MEDIA_ID_PREFIX } from "@adatechnology/meta-whatsapp-contracts";
1080
+ import { toPreviewMediaId } from "@adatechnology/meta-whatsapp-contracts";
1081
+ var CHUNK_SIZE = 8192;
1082
+ async function fileToBase64(file) {
1083
+ const bytes = new Uint8Array(await file.arrayBuffer());
1084
+ let binary = "";
1085
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
1086
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE));
1087
+ }
1088
+ return btoa(binary);
1089
+ }
1090
+ function createPreviewMediaUploader(params) {
1091
+ const fallbackMimeType = params.fallbackMimeType ?? "audio/ogg";
1092
+ const fallbackFilename = params.fallbackFilename ?? "audio.ogg";
1093
+ return async function uploadPreviewMedia(file) {
1094
+ const mimeType = file.type || fallbackMimeType;
1095
+ const filename = file.name || fallbackFilename;
1096
+ const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename });
1097
+ return { mediaId: toPreviewMediaId(uploadId), mimeType, filename };
1098
+ };
1099
+ }
1100
+
1101
+ // src/preview/createPreviewWebhookClient.ts
1102
+ var PreviewMediaUploadRejectedError = class extends Error {
1103
+ constructor(status) {
1104
+ super(`A rota de m\xEDdia do simulador recusou o upload (HTTP ${status}).`);
1105
+ this.status = status;
1106
+ this.name = "PreviewMediaUploadRejectedError";
1107
+ }
1108
+ };
1044
1109
  var PreviewInProductionError = class extends Error {
1045
1110
  constructor() {
1046
1111
  super("O preview de conversa carrega um app secret e n\xE3o pode ser montado em produ\xE7\xE3o.");
@@ -1070,6 +1135,30 @@ async function signPreviewPayload(params) {
1070
1135
  return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1071
1136
  }
1072
1137
  var signWithWebCrypto = signPreviewPayload;
1138
+ var DEFAULT_MEDIA_UPLOAD_PATH = "/v1/preview/media";
1139
+ function defaultMediaUploadUrl(webhookUrl) {
1140
+ try {
1141
+ return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString();
1142
+ } catch {
1143
+ return DEFAULT_MEDIA_UPLOAD_PATH;
1144
+ }
1145
+ }
1146
+ function createPreviewMediaPoster(params) {
1147
+ return createPreviewMediaUploader({
1148
+ upload: async (request) => {
1149
+ const performRequest = params.fetchImplementation ?? fetch;
1150
+ const extraHeaders = await params.headers?.(request.mimeType) ?? {};
1151
+ const response = await performRequest(params.url, {
1152
+ method: "POST",
1153
+ headers: { "content-type": "application/json", ...extraHeaders },
1154
+ body: JSON.stringify(request)
1155
+ });
1156
+ if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status);
1157
+ const body = await response.json();
1158
+ return { uploadId: body.data.uploadId };
1159
+ }
1160
+ });
1161
+ }
1073
1162
  function createPreviewWebhookClient(params) {
1074
1163
  const sendPayload = async (payload) => {
1075
1164
  const rawBody = serializeWebhookPayload(payload);
@@ -1082,13 +1171,21 @@ function createPreviewWebhookClient(params) {
1082
1171
  });
1083
1172
  if (!response.ok) throw new PreviewWebhookRejectedError(response.status);
1084
1173
  };
1174
+ const uploadMedia = createPreviewMediaPoster({
1175
+ url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
1176
+ headers: async (mimeType) => ({
1177
+ "x-preview-signature": await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret })
1178
+ }),
1179
+ ...params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}
1180
+ });
1085
1181
  const envelope = { from: params.from, phoneNumberId: params.phoneNumberId };
1086
1182
  return {
1087
1183
  sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
1088
1184
  sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
1089
1185
  sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
1090
1186
  sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
1091
- sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media }))
1187
+ sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
1188
+ uploadMedia
1092
1189
  };
1093
1190
  }
1094
1191
 
@@ -1117,15 +1214,27 @@ function buildFetchSender(params) {
1117
1214
  if (!response.ok) throw new PreviewBridgeRejectedError(response.status);
1118
1215
  };
1119
1216
  }
1217
+ function resolveBridgeUpload(params) {
1218
+ if (params.uploadMedia) return params.uploadMedia;
1219
+ const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : void 0);
1220
+ if (!url) return void 0;
1221
+ return createPreviewMediaPoster({
1222
+ url,
1223
+ ...params.headers ? { headers: async () => params.headers ?? {} } : {},
1224
+ ...params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}
1225
+ });
1226
+ }
1120
1227
  function createPreviewBridgeClient(params) {
1121
1228
  const send = params.sendCommand ?? buildFetchSender(params);
1122
1229
  const from = params.from;
1230
+ const uploadMedia = resolveBridgeUpload(params);
1123
1231
  return {
1124
1232
  sendText: (text) => send({ kind: "text", from, text }),
1125
1233
  sendButtonReply: (reply) => send({ kind: "buttonReply", from, reply }),
1126
1234
  sendListReply: (reply) => send({ kind: "listReply", from, reply }),
1127
1235
  sendAudio: (mediaId) => send({ kind: "audio", from, mediaId }),
1128
- sendMedia: (media) => send({ kind: "media", from, ...media })
1236
+ sendMedia: (media) => send({ kind: "media", from, ...media }),
1237
+ ...uploadMedia ? { uploadMedia } : {}
1129
1238
  };
1130
1239
  }
1131
1240
 
@@ -1167,7 +1276,7 @@ function startPreviewScript(params) {
1167
1276
 
1168
1277
  // src/preview/MediaTypesPreview.tsx
1169
1278
  import { useMemo as useMemo2 } from "react";
1170
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1279
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1171
1280
  var MEDIA_TYPES_CONVERSATION_ID = "5511944443333";
1172
1281
  function MediaTypesPreview({
1173
1282
  conversationId = MEDIA_TYPES_CONVERSATION_ID,
@@ -1182,26 +1291,26 @@ function MediaTypesPreview({
1182
1291
  const messages = PREVIEW_MESSAGES[conversationId] ?? [];
1183
1292
  const documents = PREVIEW_DOCUMENTS[conversationId] ?? [];
1184
1293
  const mimeTypes = [...new Set(documents.map((document) => document.mimeType))];
1185
- return /* @__PURE__ */ jsx2(ConversationsProvider, { api, sse, children: /* @__PURE__ */ jsxs2("div", { className, children: [
1186
- /* @__PURE__ */ jsxs2("header", { className: "border-b px-4 py-3 dark:border-gray-700", children: [
1187
- /* @__PURE__ */ jsx2("h1", { className: "text-lg font-semibold", children: "Teste manual de m\xEDdia" }),
1188
- /* @__PURE__ */ jsxs2("p", { className: "text-sm text-gray-500", children: [
1294
+ return /* @__PURE__ */ jsx3(ConversationsProvider, { api, sse, children: /* @__PURE__ */ jsxs3("div", { className, children: [
1295
+ /* @__PURE__ */ jsxs3("header", { className: "border-b px-4 py-3 dark:border-gray-700", children: [
1296
+ /* @__PURE__ */ jsx3("h1", { className: "text-lg font-semibold", children: "Teste manual de m\xEDdia" }),
1297
+ /* @__PURE__ */ jsxs3("p", { className: "text-sm text-gray-500", children: [
1189
1298
  documents.length,
1190
1299
  " arquivos, ",
1191
1300
  mimeTypes.length,
1192
1301
  " tipos. Clique no olho para abrir em aba nova e no bot\xE3o da bolha para carregar a m\xEDdia na thread \u2014 \xE9 o que teste automatizado n\xE3o v\xEA."
1193
1302
  ] })
1194
1303
  ] }),
1195
- /* @__PURE__ */ jsxs2("div", { className: "grid gap-4 p-4 lg:grid-cols-2", children: [
1196
- /* @__PURE__ */ jsxs2("section", { className: "space-y-3", children: [
1197
- /* @__PURE__ */ jsx2("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Biblioteca da empresa" }),
1198
- /* @__PURE__ */ jsx2(DocumentsLibrary, { perPage: documents.length || 20 })
1304
+ /* @__PURE__ */ jsxs3("div", { className: "grid gap-4 p-4 lg:grid-cols-2", children: [
1305
+ /* @__PURE__ */ jsxs3("section", { className: "space-y-3", children: [
1306
+ /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Biblioteca da empresa" }),
1307
+ /* @__PURE__ */ jsx3(DocumentsLibrary, { perPage: documents.length || 20 })
1199
1308
  ] }),
1200
- /* @__PURE__ */ jsxs2("section", { className: "space-y-3", children: [
1201
- /* @__PURE__ */ jsx2("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Painel da conversa" }),
1202
- /* @__PURE__ */ jsx2(ConversationDocumentsPanel, { conversationId, open: true, perPage: documents.length || 20 }),
1203
- /* @__PURE__ */ jsx2("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Bolhas na thread" }),
1204
- /* @__PURE__ */ jsx2(ConversationWallpaper, { className: "max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2", children: messages.map((message, index) => /* @__PURE__ */ jsx2(
1309
+ /* @__PURE__ */ jsxs3("section", { className: "space-y-3", children: [
1310
+ /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Painel da conversa" }),
1311
+ /* @__PURE__ */ jsx3(ConversationDocumentsPanel, { conversationId, open: true, perPage: documents.length || 20 }),
1312
+ /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Bolhas na thread" }),
1313
+ /* @__PURE__ */ jsx3(ConversationWallpaper, { className: "max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2", children: messages.map((message, index) => /* @__PURE__ */ jsx3(
1205
1314
  MessageBubble,
1206
1315
  {
1207
1316
  message,
@@ -1217,7 +1326,10 @@ function MediaTypesPreview({
1217
1326
  export {
1218
1327
  AudioRecorderButton,
1219
1328
  ConversationPreview,
1329
+ ConversationSimulatorPanel,
1220
1330
  DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
1331
+ DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
1332
+ DEFAULT_MEDIA_UPLOAD_PATH,
1221
1333
  DEFAULT_PREVIEW_SCRIPT,
1222
1334
  GLOBAL_CHANNEL,
1223
1335
  MEDIA_TYPES_CONVERSATION_ID,
@@ -1225,9 +1337,11 @@ export {
1225
1337
  PREVIEW_CONVERSATIONS,
1226
1338
  PREVIEW_DOCUMENTS,
1227
1339
  PREVIEW_FILE_SAMPLES,
1340
+ PREVIEW_MEDIA_ID_PREFIX,
1228
1341
  PREVIEW_MESSAGES,
1229
1342
  PreviewBridgeRejectedError,
1230
1343
  PreviewInProductionError,
1344
+ PreviewMediaUploadRejectedError,
1231
1345
  PreviewWebhookRejectedError,
1232
1346
  assertPreviewEnvironment,
1233
1347
  conversationChannel,
@@ -1235,7 +1349,9 @@ export {
1235
1349
  createMockEventSource,
1236
1350
  createMockSSEProvider,
1237
1351
  createPreviewBridgeClient,
1352
+ createPreviewMediaPoster,
1238
1353
  createPreviewMediaResolver,
1354
+ createPreviewMediaUploader,
1239
1355
  createPreviewStore,
1240
1356
  createPreviewWebhookClient,
1241
1357
  mediaTypeOf,
package/dist/styles.css CHANGED
@@ -207,3 +207,115 @@
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
+ }
240
+ .cv-simulator-panel {
241
+ display: flex;
242
+ flex-direction: column;
243
+ width: 24rem;
244
+ flex-shrink: 0;
245
+ border-left: 1px solid rgb(229 231 235);
246
+ background: #fff;
247
+ }
248
+ .cv-simulator-panel__header {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: space-between;
252
+ gap: 0.5rem;
253
+ padding: 0.75rem 1rem;
254
+ border-bottom: 1px solid rgb(229 231 235);
255
+ }
256
+ .cv-simulator-panel__heading {
257
+ min-width: 0;
258
+ }
259
+ .cv-simulator-panel__title {
260
+ font-size: 0.875rem;
261
+ font-weight: 600;
262
+ color: rgb(17 24 39);
263
+ overflow: hidden;
264
+ text-overflow: ellipsis;
265
+ white-space: nowrap;
266
+ }
267
+ .cv-simulator-panel__subtitle {
268
+ font-size: 0.75rem;
269
+ color: rgb(156 163 175);
270
+ overflow: hidden;
271
+ text-overflow: ellipsis;
272
+ white-space: nowrap;
273
+ }
274
+ .cv-simulator-panel__actions {
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 0.25rem;
278
+ }
279
+ .cv-simulator-panel__close {
280
+ display: inline-flex;
281
+ padding: 0.375rem;
282
+ border: 0;
283
+ border-radius: 0.5rem;
284
+ background: transparent;
285
+ color: rgb(156 163 175);
286
+ transition: color 150ms, background-color 150ms;
287
+ }
288
+ .cv-simulator-panel__close:hover {
289
+ color: rgb(55 65 81);
290
+ background: rgb(243 244 246);
291
+ }
292
+ .cv-simulator-panel__body {
293
+ flex: 1;
294
+ min-height: 0;
295
+ }
296
+ .dark .cv-simulator-panel {
297
+ border-left-color: rgb(31 41 55);
298
+ background: rgb(17 24 39);
299
+ }
300
+ .dark .cv-simulator-panel__header {
301
+ border-bottom-color: rgb(31 41 55);
302
+ }
303
+ .dark .cv-simulator-panel__title {
304
+ color: rgb(243 244 246);
305
+ }
306
+ .dark .cv-simulator-panel__subtitle {
307
+ color: rgb(107 114 128);
308
+ }
309
+ .dark .cv-simulator-panel__close:hover {
310
+ color: rgb(229 231 235);
311
+ background: rgb(31 41 55);
312
+ }
313
+ @media (max-width: 1023px) {
314
+ .cv-simulator-panel {
315
+ position: fixed;
316
+ inset: 0;
317
+ width: 100%;
318
+ z-index: 40;
319
+ border-left: 0;
320
+ }
321
+ }
@@ -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 ReopenMechanism as J, type ResolveMediaUrl as K, type ListConversationsParams as L, MediaRenderer as M, capabilitiesOf as N, channelFiltersFor as O, contactFlag as P, formatContactHandle as Q, REOPEN_MECHANISM as R, type SSEProvider as S, 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 };
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 };