@adatechnology/conversations-ui 0.1.0-rc.16 → 0.1.0-rc.18
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-RNCZO2FM.js → chunk-OIDAIVCH.js} +42 -4
- package/dist/index.d.ts +10 -1
- package/dist/index.js +13 -5
- package/dist/preview/index.d.ts +70 -4
- package/dist/preview/index.js +41 -2
- package/package.json +1 -1
- package/src/ConversationContextPanel.tsx +23 -2
- package/src/MediaRenderer.tsx +32 -11
- package/src/preview/ConversationPreview.tsx +4 -1
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +88 -0
- package/src/preview/createPreviewWebhookClient.ts +8 -3
- package/src/preview/index.ts +7 -0
|
@@ -321,12 +321,35 @@ function MediaRenderer({ message, onLightbox, onResolveUrl, className }) {
|
|
|
321
321
|
const lazy = useLazyMediaUrl(message, onResolveUrl);
|
|
322
322
|
const src = eagerSrc ?? lazy.url;
|
|
323
323
|
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl);
|
|
324
|
-
|
|
324
|
+
function LazyMediaButton({ icon, label }) {
|
|
325
|
+
return /* @__PURE__ */ jsxs3(
|
|
326
|
+
"button",
|
|
327
|
+
{
|
|
328
|
+
onClick: lazy.load,
|
|
329
|
+
disabled: lazy.loading,
|
|
330
|
+
className: "flex min-w-[180px] items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-left transition-colors hover:bg-black/10 disabled:opacity-60 dark:bg-white/10 dark:hover:bg-white/15",
|
|
331
|
+
children: [
|
|
332
|
+
/* @__PURE__ */ jsx5("span", { className: "flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white", children: icon }),
|
|
333
|
+
/* @__PURE__ */ jsx5("span", { className: "truncate text-xs text-gray-600 dark:text-gray-300", children: label })
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
325
338
|
switch (message.type) {
|
|
326
339
|
case "image":
|
|
327
340
|
case "sticker": {
|
|
328
341
|
if (!src && canLazyLoad) {
|
|
329
|
-
return /* @__PURE__ */ jsx5(
|
|
342
|
+
return /* @__PURE__ */ jsx5(
|
|
343
|
+
LazyMediaButton,
|
|
344
|
+
{
|
|
345
|
+
icon: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
|
|
346
|
+
/* @__PURE__ */ jsx5("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }),
|
|
347
|
+
/* @__PURE__ */ jsx5("circle", { cx: "8.5", cy: "8.5", r: "1.5" }),
|
|
348
|
+
/* @__PURE__ */ jsx5("polyline", { points: "21 15 16 10 5 21" })
|
|
349
|
+
] }),
|
|
350
|
+
label: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage
|
|
351
|
+
}
|
|
352
|
+
);
|
|
330
353
|
}
|
|
331
354
|
return /* @__PURE__ */ jsx5("div", { className: "min-w-[200px]", children: src ? /* @__PURE__ */ jsx5("img", { src, alt: message.caption ?? bubble.imageAlt, className: "w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity", onClick: () => onLightbox(src), loading: "lazy" }) : /* @__PURE__ */ jsx5("div", { className: "w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400", children: /* @__PURE__ */ jsxs3("svg", { width: "32", height: "32", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: [
|
|
332
355
|
/* @__PURE__ */ jsx5("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }),
|
|
@@ -336,7 +359,16 @@ function MediaRenderer({ message, onLightbox, onResolveUrl, className }) {
|
|
|
336
359
|
}
|
|
337
360
|
case "video": {
|
|
338
361
|
if (!src && canLazyLoad) {
|
|
339
|
-
return /* @__PURE__ */ jsx5(
|
|
362
|
+
return /* @__PURE__ */ jsx5(
|
|
363
|
+
LazyMediaButton,
|
|
364
|
+
{
|
|
365
|
+
icon: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
|
|
366
|
+
/* @__PURE__ */ jsx5("polygon", { points: "23 7 16 12 23 17 23 7" }),
|
|
367
|
+
/* @__PURE__ */ jsx5("rect", { x: "1", y: "5", width: "15", height: "14", rx: "2", ry: "2" })
|
|
368
|
+
] }),
|
|
369
|
+
label: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo
|
|
370
|
+
}
|
|
371
|
+
);
|
|
340
372
|
}
|
|
341
373
|
return /* @__PURE__ */ jsx5("div", { className: "min-w-[200px]", children: src ? /* @__PURE__ */ jsx5("video", { src, className: "w-full max-h-80 rounded-lg", controls: true, preload: "metadata", children: /* @__PURE__ */ jsx5("track", { kind: "captions" }) }) : /* @__PURE__ */ jsx5("div", { className: "w-full h-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400", children: /* @__PURE__ */ jsxs3("svg", { width: "32", height: "32", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: [
|
|
342
374
|
/* @__PURE__ */ jsx5("polygon", { points: "23 7 16 12 23 17 23 7" }),
|
|
@@ -345,7 +377,13 @@ function MediaRenderer({ message, onLightbox, onResolveUrl, className }) {
|
|
|
345
377
|
}
|
|
346
378
|
case "audio": {
|
|
347
379
|
if (!src && canLazyLoad) {
|
|
348
|
-
return /* @__PURE__ */ jsx5(
|
|
380
|
+
return /* @__PURE__ */ jsx5(
|
|
381
|
+
LazyMediaButton,
|
|
382
|
+
{
|
|
383
|
+
icon: /* @__PURE__ */ jsx5("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "translate-x-0.5", children: /* @__PURE__ */ jsx5("polygon", { points: "5 3 19 12 5 21 5 3" }) }),
|
|
384
|
+
label: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio
|
|
385
|
+
}
|
|
386
|
+
);
|
|
349
387
|
}
|
|
350
388
|
return /* @__PURE__ */ jsx5("div", { className: "min-w-[200px]", children: src ? /* @__PURE__ */ jsx5(AudioPlayer, { src, isMine: message.direction === "outbound" }) : /* @__PURE__ */ jsx5("div", { className: "h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs", children: bubble.mediaUnavailable }) });
|
|
351
389
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -560,6 +560,8 @@ interface ConversationContextEntry {
|
|
|
560
560
|
interface ConversationContextPanelLabels {
|
|
561
561
|
title: string;
|
|
562
562
|
empty: string;
|
|
563
|
+
collapse: string;
|
|
564
|
+
expand: string;
|
|
563
565
|
}
|
|
564
566
|
declare const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels;
|
|
565
567
|
interface ConversationContextPanelClassNames {
|
|
@@ -570,11 +572,18 @@ interface ConversationContextPanelClassNames {
|
|
|
570
572
|
}
|
|
571
573
|
interface ConversationContextPanelProps {
|
|
572
574
|
entries: readonly ConversationContextEntry[];
|
|
575
|
+
/**
|
|
576
|
+
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
577
|
+
*
|
|
578
|
+
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
579
|
+
* painel ocupa altura mostrando quase só travessões, e empurra a conversa — que é o que se veio ver.
|
|
580
|
+
*/
|
|
581
|
+
defaultOpen?: boolean;
|
|
573
582
|
labels?: Partial<ConversationContextPanelLabels>;
|
|
574
583
|
className?: string;
|
|
575
584
|
classNames?: Partial<ConversationContextPanelClassNames>;
|
|
576
585
|
}
|
|
577
|
-
declare function ConversationContextPanel({ entries, labels: labelsOverride, className, classNames, }: ConversationContextPanelProps): react.JSX.Element;
|
|
586
|
+
declare function ConversationContextPanel({ entries, defaultOpen, labels: labelsOverride, className, classNames, }: ConversationContextPanelProps): react.JSX.Element;
|
|
578
587
|
|
|
579
588
|
interface WindowExpiredNoticeLabels {
|
|
580
589
|
title: string;
|
package/dist/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
useConversationDocuments,
|
|
45
45
|
useConversationLocales,
|
|
46
46
|
useConversations
|
|
47
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-OIDAIVCH.js";
|
|
48
48
|
import {
|
|
49
49
|
htmlToWA,
|
|
50
50
|
parseWhatsAppFormatting,
|
|
@@ -1337,10 +1337,13 @@ function useIsNarrow() {
|
|
|
1337
1337
|
import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
1338
1338
|
var DEFAULT_CONVERSATION_CONTEXT_LABELS = {
|
|
1339
1339
|
title: "\u{1F4CB} Suas Sele\xE7\xF5es",
|
|
1340
|
-
empty: "Nada coletado ainda nesta conversa."
|
|
1340
|
+
empty: "Nada coletado ainda nesta conversa.",
|
|
1341
|
+
collapse: "fechar",
|
|
1342
|
+
expand: "abrir"
|
|
1341
1343
|
};
|
|
1342
1344
|
function ConversationContextPanel({
|
|
1343
1345
|
entries,
|
|
1346
|
+
defaultOpen,
|
|
1344
1347
|
labels: labelsOverride,
|
|
1345
1348
|
className,
|
|
1346
1349
|
classNames
|
|
@@ -1349,7 +1352,7 @@ function ConversationContextPanel({
|
|
|
1349
1352
|
const filled = entries.filter((entry) => Boolean(entry.value));
|
|
1350
1353
|
const isNarrow = useIsNarrow();
|
|
1351
1354
|
const [manualOpen, setManualOpen] = useState7(void 0);
|
|
1352
|
-
const open = manualOpen ?? (!isNarrow && filled.length > 0);
|
|
1355
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0);
|
|
1353
1356
|
return /* @__PURE__ */ jsxs11("section", { className: cn("border-b", classNames?.root, className), children: [
|
|
1354
1357
|
/* @__PURE__ */ jsxs11(
|
|
1355
1358
|
"button",
|
|
@@ -1357,7 +1360,11 @@ function ConversationContextPanel({
|
|
|
1357
1360
|
type: "button",
|
|
1358
1361
|
onClick: () => setManualOpen(!open),
|
|
1359
1362
|
"aria-expanded": open,
|
|
1360
|
-
|
|
1363
|
+
title: open ? labels.collapse : labels.expand,
|
|
1364
|
+
className: cn(
|
|
1365
|
+
"flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-gray-50 dark:hover:bg-gray-800",
|
|
1366
|
+
classNames?.toggle
|
|
1367
|
+
),
|
|
1361
1368
|
children: [
|
|
1362
1369
|
/* @__PURE__ */ jsx13("span", { "aria-hidden": true, className: "text-xs", children: open ? "\u25BE" : "\u25B8" }),
|
|
1363
1370
|
/* @__PURE__ */ jsx13("span", { children: labels.title }),
|
|
@@ -1365,7 +1372,8 @@ function ConversationContextPanel({
|
|
|
1365
1372
|
filled.length,
|
|
1366
1373
|
"/",
|
|
1367
1374
|
entries.length
|
|
1368
|
-
] })
|
|
1375
|
+
] }),
|
|
1376
|
+
/* @__PURE__ */ jsx13("span", { "aria-hidden": true, className: "ml-auto text-xs text-gray-500", children: open ? labels.collapse : labels.expand })
|
|
1369
1377
|
]
|
|
1370
1378
|
}
|
|
1371
1379
|
),
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -120,9 +120,14 @@ declare const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDo
|
|
|
120
120
|
* Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
|
|
121
121
|
* (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
|
|
122
122
|
*
|
|
123
|
-
* ⚠️ Isto carrega o app secret
|
|
124
|
-
*
|
|
125
|
-
*
|
|
123
|
+
* ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
|
|
124
|
+
* seja servido — em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
|
|
125
|
+
* equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
|
|
126
|
+
* mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
|
|
127
|
+
* homologação passaria, então a barreira não basta.
|
|
128
|
+
*
|
|
129
|
+
* Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
|
|
130
|
+
* o servidor assina com o segredo que ele já tem.
|
|
126
131
|
*/
|
|
127
132
|
|
|
128
133
|
type PreviewWebhookClient = {
|
|
@@ -206,6 +211,67 @@ type PreviewUploadedMedia = {
|
|
|
206
211
|
declare function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'];
|
|
207
212
|
declare function ConversationPreview({ client, sse, conversationId, loadMessages, placeholder, pollIntervalMs, uploadMedia, }: ConversationPreviewProps): react.JSX.Element;
|
|
208
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
|
|
216
|
+
* navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
|
|
217
|
+
* autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
|
|
218
|
+
* app secret que nunca sai de lá.
|
|
219
|
+
*
|
|
220
|
+
* Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
|
|
221
|
+
* app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
|
|
222
|
+
* acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
|
|
223
|
+
* válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
|
|
224
|
+
* Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
|
|
225
|
+
* servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
|
|
226
|
+
*
|
|
227
|
+
* O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
|
|
228
|
+
* porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
|
|
229
|
+
*/
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
|
|
233
|
+
* se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
|
|
234
|
+
* quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
|
|
235
|
+
*/
|
|
236
|
+
type PreviewInboundCommand = {
|
|
237
|
+
readonly kind: 'text';
|
|
238
|
+
readonly from: string;
|
|
239
|
+
readonly text: string;
|
|
240
|
+
} | {
|
|
241
|
+
readonly kind: 'buttonReply';
|
|
242
|
+
readonly from: string;
|
|
243
|
+
readonly reply: InteractiveReplyOption;
|
|
244
|
+
} | {
|
|
245
|
+
readonly kind: 'listReply';
|
|
246
|
+
readonly from: string;
|
|
247
|
+
readonly reply: InteractiveReplyOption;
|
|
248
|
+
} | {
|
|
249
|
+
readonly kind: 'audio';
|
|
250
|
+
readonly from: string;
|
|
251
|
+
readonly mediaId: string;
|
|
252
|
+
} | ({
|
|
253
|
+
readonly kind: 'media';
|
|
254
|
+
readonly from: string;
|
|
255
|
+
} & SendPreviewMediaParams);
|
|
256
|
+
type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>;
|
|
257
|
+
declare class PreviewBridgeRejectedError extends Error {
|
|
258
|
+
readonly status: number;
|
|
259
|
+
constructor(status: number);
|
|
260
|
+
}
|
|
261
|
+
type CreatePreviewBridgeClientParams = {
|
|
262
|
+
readonly from: string;
|
|
263
|
+
/**
|
|
264
|
+
* Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
|
|
265
|
+
* de token — reimplementar isso aqui só duplicaria a autenticação do produto.
|
|
266
|
+
*/
|
|
267
|
+
readonly sendCommand?: SendPreviewInboundCommand;
|
|
268
|
+
/** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
|
|
269
|
+
readonly endpointUrl?: string;
|
|
270
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
271
|
+
readonly fetchImplementation?: typeof fetch;
|
|
272
|
+
};
|
|
273
|
+
declare function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient;
|
|
274
|
+
|
|
209
275
|
/**
|
|
210
276
|
* Roteiro que mantém o preview vivo: sem tráfego chegando, a inbox é uma tela estática e as
|
|
211
277
|
* transições que o atendente precisa testar (fila de espera enchendo, handoff, devolução ao bot)
|
|
@@ -304,4 +370,4 @@ type MediaTypesPreviewProps = {
|
|
|
304
370
|
};
|
|
305
371
|
declare function MediaTypesPreview({ conversationId, className, }: MediaTypesPreviewProps): react.JSX.Element;
|
|
306
372
|
|
|
307
|
-
export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, 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, type PreviewEmission, PreviewInProductionError, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewMediaResolver, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
|
|
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 };
|
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-OIDAIVCH.js";
|
|
12
12
|
import "../chunk-2AYDBWNE.js";
|
|
13
13
|
|
|
14
14
|
// src/preview/previewStore.ts
|
|
@@ -938,7 +938,7 @@ function ConversationPreview({
|
|
|
938
938
|
return () => clearInterval(timer);
|
|
939
939
|
}, [pollIntervalMs, refresh]);
|
|
940
940
|
useEffect(() => {
|
|
941
|
-
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
941
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
942
942
|
}, [messages]);
|
|
943
943
|
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal]);
|
|
944
944
|
async function refreshWithFollowUps() {
|
|
@@ -1092,6 +1092,43 @@ function createPreviewWebhookClient(params) {
|
|
|
1092
1092
|
};
|
|
1093
1093
|
}
|
|
1094
1094
|
|
|
1095
|
+
// src/preview/createPreviewBridgeClient.ts
|
|
1096
|
+
var PreviewBridgeRejectedError = class extends Error {
|
|
1097
|
+
constructor(status) {
|
|
1098
|
+
super(`A rota de preview do host recusou a entrega (HTTP ${status}).`);
|
|
1099
|
+
this.status = status;
|
|
1100
|
+
this.name = "PreviewBridgeRejectedError";
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
function buildFetchSender(params) {
|
|
1104
|
+
const endpointUrl = params.endpointUrl;
|
|
1105
|
+
if (!endpointUrl) {
|
|
1106
|
+
throw new Error("createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.");
|
|
1107
|
+
}
|
|
1108
|
+
return async (command) => {
|
|
1109
|
+
const performRequest = params.fetchImplementation ?? fetch;
|
|
1110
|
+
const response = await performRequest(endpointUrl, {
|
|
1111
|
+
method: "POST",
|
|
1112
|
+
// `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
|
|
1113
|
+
// bearer não convivem numa escolha default sem quebrar um dos dois.
|
|
1114
|
+
headers: { "content-type": "application/json", ...params.headers },
|
|
1115
|
+
body: JSON.stringify(command)
|
|
1116
|
+
});
|
|
1117
|
+
if (!response.ok) throw new PreviewBridgeRejectedError(response.status);
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
function createPreviewBridgeClient(params) {
|
|
1121
|
+
const send = params.sendCommand ?? buildFetchSender(params);
|
|
1122
|
+
const from = params.from;
|
|
1123
|
+
return {
|
|
1124
|
+
sendText: (text) => send({ kind: "text", from, text }),
|
|
1125
|
+
sendButtonReply: (reply) => send({ kind: "buttonReply", from, reply }),
|
|
1126
|
+
sendListReply: (reply) => send({ kind: "listReply", from, reply }),
|
|
1127
|
+
sendAudio: (mediaId) => send({ kind: "audio", from, mediaId }),
|
|
1128
|
+
sendMedia: (media) => send({ kind: "media", from, ...media })
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1095
1132
|
// src/preview/startPreviewScript.ts
|
|
1096
1133
|
var DEFAULT_PREVIEW_SCRIPT = [
|
|
1097
1134
|
(store) => store.appendMessage({
|
|
@@ -1189,6 +1226,7 @@ export {
|
|
|
1189
1226
|
PREVIEW_DOCUMENTS,
|
|
1190
1227
|
PREVIEW_FILE_SAMPLES,
|
|
1191
1228
|
PREVIEW_MESSAGES,
|
|
1229
|
+
PreviewBridgeRejectedError,
|
|
1192
1230
|
PreviewInProductionError,
|
|
1193
1231
|
PreviewWebhookRejectedError,
|
|
1194
1232
|
assertPreviewEnvironment,
|
|
@@ -1196,6 +1234,7 @@ export {
|
|
|
1196
1234
|
createMockConversationsApi,
|
|
1197
1235
|
createMockEventSource,
|
|
1198
1236
|
createMockSSEProvider,
|
|
1237
|
+
createPreviewBridgeClient,
|
|
1199
1238
|
createPreviewMediaResolver,
|
|
1200
1239
|
createPreviewStore,
|
|
1201
1240
|
createPreviewWebhookClient,
|
package/package.json
CHANGED
|
@@ -21,11 +21,15 @@ export interface ConversationContextEntry {
|
|
|
21
21
|
export interface ConversationContextPanelLabels {
|
|
22
22
|
title: string
|
|
23
23
|
empty: string
|
|
24
|
+
collapse: string
|
|
25
|
+
expand: string
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
|
|
27
29
|
title: '📋 Suas Seleções',
|
|
28
30
|
empty: 'Nada coletado ainda nesta conversa.',
|
|
31
|
+
collapse: 'fechar',
|
|
32
|
+
expand: 'abrir',
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export interface ConversationContextPanelClassNames {
|
|
@@ -37,6 +41,13 @@ export interface ConversationContextPanelClassNames {
|
|
|
37
41
|
|
|
38
42
|
export interface ConversationContextPanelProps {
|
|
39
43
|
entries: readonly ConversationContextEntry[]
|
|
44
|
+
/**
|
|
45
|
+
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
46
|
+
*
|
|
47
|
+
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
48
|
+
* painel ocupa altura mostrando quase só travessões, e empurra a conversa — que é o que se veio ver.
|
|
49
|
+
*/
|
|
50
|
+
defaultOpen?: boolean
|
|
40
51
|
labels?: Partial<ConversationContextPanelLabels>
|
|
41
52
|
className?: string
|
|
42
53
|
classNames?: Partial<ConversationContextPanelClassNames>
|
|
@@ -44,6 +55,7 @@ export interface ConversationContextPanelProps {
|
|
|
44
55
|
|
|
45
56
|
export function ConversationContextPanel({
|
|
46
57
|
entries,
|
|
58
|
+
defaultOpen,
|
|
47
59
|
labels: labelsOverride,
|
|
48
60
|
className,
|
|
49
61
|
classNames,
|
|
@@ -61,7 +73,7 @@ export function ConversationContextPanel({
|
|
|
61
73
|
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
|
62
74
|
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa. O
|
|
63
75
|
// contador no cabeçalho já entrega a informação de relance.
|
|
64
|
-
const open = manualOpen ?? (!isNarrow && filled.length > 0)
|
|
76
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0)
|
|
65
77
|
|
|
66
78
|
return (
|
|
67
79
|
<section className={cn('border-b', classNames?.root, className)}>
|
|
@@ -69,7 +81,11 @@ export function ConversationContextPanel({
|
|
|
69
81
|
type="button"
|
|
70
82
|
onClick={() => setManualOpen(!open)}
|
|
71
83
|
aria-expanded={open}
|
|
72
|
-
|
|
84
|
+
title={open ? labels.collapse : labels.expand}
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-gray-50 dark:hover:bg-gray-800',
|
|
87
|
+
classNames?.toggle,
|
|
88
|
+
)}
|
|
73
89
|
>
|
|
74
90
|
<span aria-hidden className="text-xs">
|
|
75
91
|
{open ? '▾' : '▸'}
|
|
@@ -78,6 +94,11 @@ export function ConversationContextPanel({
|
|
|
78
94
|
<span className={cn('rounded-full bg-gray-200 px-2 text-xs dark:bg-gray-700', classNames?.counter)}>
|
|
79
95
|
{filled.length}/{entries.length}
|
|
80
96
|
</span>
|
|
97
|
+
{/* Rótulo escrito na ponta direita: o caret sozinho não dizia que a linha inteira fecha o
|
|
98
|
+
painel — a pergunta "cadê o botão de fechar?" veio daí. */}
|
|
99
|
+
<span aria-hidden className="ml-auto text-xs text-gray-500">
|
|
100
|
+
{open ? labels.collapse : labels.expand}
|
|
101
|
+
</span>
|
|
81
102
|
</button>
|
|
82
103
|
|
|
83
104
|
{open ? (
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
2
|
import { AudioPlayer } from './AudioPlayer'
|
|
3
3
|
import { FileIcon } from './FileIcon'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
@@ -84,16 +84,35 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
84
84
|
const src = eagerSrc ?? lazy.url
|
|
85
85
|
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* O carregamento sob demanda continua sendo um botão, mas com a forma da mídia que ele vai virar
|
|
89
|
+
* — um link sublinhado no meio da conversa lê como texto da mensagem, não como controle, e é a
|
|
90
|
+
* única bolha que não se parece com o que contém.
|
|
91
|
+
*/
|
|
92
|
+
function LazyMediaButton({ icon, label }: { icon: ReactNode; label: string }) {
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
onClick={lazy.load}
|
|
96
|
+
disabled={lazy.loading}
|
|
97
|
+
className="flex min-w-[180px] items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-left transition-colors hover:bg-black/10 disabled:opacity-60 dark:bg-white/10 dark:hover:bg-white/15"
|
|
98
|
+
>
|
|
99
|
+
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white">
|
|
100
|
+
{icon}
|
|
101
|
+
</span>
|
|
102
|
+
<span className="truncate text-xs text-gray-600 dark:text-gray-300">{label}</span>
|
|
103
|
+
</button>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
88
106
|
|
|
89
107
|
switch (message.type) {
|
|
90
108
|
case 'image':
|
|
91
109
|
case 'sticker': {
|
|
92
110
|
if (!src && canLazyLoad) {
|
|
93
111
|
return (
|
|
94
|
-
<
|
|
95
|
-
{
|
|
96
|
-
|
|
112
|
+
<LazyMediaButton
|
|
113
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg>}
|
|
114
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
|
|
115
|
+
/>
|
|
97
116
|
)
|
|
98
117
|
}
|
|
99
118
|
return (
|
|
@@ -111,9 +130,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
111
130
|
case 'video': {
|
|
112
131
|
if (!src && canLazyLoad) {
|
|
113
132
|
return (
|
|
114
|
-
<
|
|
115
|
-
{
|
|
116
|
-
|
|
133
|
+
<LazyMediaButton
|
|
134
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" /></svg>}
|
|
135
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
|
|
136
|
+
/>
|
|
117
137
|
)
|
|
118
138
|
}
|
|
119
139
|
return (
|
|
@@ -131,9 +151,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
131
151
|
case 'audio': {
|
|
132
152
|
if (!src && canLazyLoad) {
|
|
133
153
|
return (
|
|
134
|
-
<
|
|
135
|
-
{
|
|
136
|
-
|
|
154
|
+
<LazyMediaButton
|
|
155
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
|
|
156
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
|
|
157
|
+
/>
|
|
137
158
|
)
|
|
138
159
|
}
|
|
139
160
|
return (
|
|
@@ -188,7 +188,10 @@ export function ConversationPreview({
|
|
|
188
188
|
}, [pollIntervalMs, refresh])
|
|
189
189
|
|
|
190
190
|
useEffect(() => {
|
|
191
|
-
|
|
191
|
+
// `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
|
|
192
|
+
// visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
|
|
193
|
+
// área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
|
|
194
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
192
195
|
}, [messages])
|
|
193
196
|
|
|
194
197
|
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O que estes testes protegem é a propriedade de segurança da ponte: nenhum caminho pode voltar a
|
|
3
|
+
* exigir segredo no navegador, e o corpo enviado tem que ser a INTENÇÃO — se um refactor passar a
|
|
4
|
+
* mandar payload da Meta montado no cliente, a rota do host vira injetor de webhook arbitrário.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
10
|
+
import type { PreviewInboundCommand } from './createPreviewBridgeClient'
|
|
11
|
+
|
|
12
|
+
const FROM = '5511999999999'
|
|
13
|
+
|
|
14
|
+
function createRecordingClient() {
|
|
15
|
+
const commands: PreviewInboundCommand[] = []
|
|
16
|
+
const client = createPreviewBridgeClient({
|
|
17
|
+
from: FROM,
|
|
18
|
+
sendCommand: async (command) => {
|
|
19
|
+
commands.push(command)
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
return { client, commands }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('createPreviewBridgeClient', () => {
|
|
26
|
+
it('entrega a intenção do cliente, carimbando o remetente em cada comando', async () => {
|
|
27
|
+
const { client, commands } = createRecordingClient()
|
|
28
|
+
|
|
29
|
+
await client.sendText('quero simular')
|
|
30
|
+
await client.sendButtonReply({ id: 'hab_pronto', title: 'Imóvel pronto' })
|
|
31
|
+
await client.sendListReply({ id: 'faixa_2', title: 'Faixa 2' })
|
|
32
|
+
await client.sendAudio('media-1')
|
|
33
|
+
await client.sendMedia({ mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' })
|
|
34
|
+
|
|
35
|
+
expect(commands).toEqual([
|
|
36
|
+
{ kind: 'text', from: FROM, text: 'quero simular' },
|
|
37
|
+
{ kind: 'buttonReply', from: FROM, reply: { id: 'hab_pronto', title: 'Imóvel pronto' } },
|
|
38
|
+
{ kind: 'listReply', from: FROM, reply: { id: 'faixa_2', title: 'Faixa 2' } },
|
|
39
|
+
{ kind: 'audio', from: FROM, mediaId: 'media-1' },
|
|
40
|
+
{ kind: 'media', from: FROM, mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' },
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('nunca embute assinatura nem segredo no que sai do navegador', async () => {
|
|
45
|
+
const { client, commands } = createRecordingClient()
|
|
46
|
+
|
|
47
|
+
await client.sendText('oi')
|
|
48
|
+
|
|
49
|
+
const serialized = JSON.stringify(commands[0])
|
|
50
|
+
expect(serialized).not.toMatch(/sha256=/)
|
|
51
|
+
expect(serialized).not.toMatch(/secret/i)
|
|
52
|
+
expect(commands[0]).not.toHaveProperty('entry')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('posta no endpoint do host com os headers de sessão que o host injeta', async () => {
|
|
56
|
+
const calls: Array<{ url: string; init: RequestInit }> = []
|
|
57
|
+
const client = createPreviewBridgeClient({
|
|
58
|
+
from: FROM,
|
|
59
|
+
endpointUrl: 'https://host.test/api/conversations/preview/inbound',
|
|
60
|
+
headers: { authorization: 'Bearer token-do-painel' },
|
|
61
|
+
fetchImplementation: (async (url: string, init: RequestInit) => {
|
|
62
|
+
calls.push({ url, init })
|
|
63
|
+
return { ok: true } as Response
|
|
64
|
+
}) as unknown as typeof fetch,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await client.sendText('oi')
|
|
68
|
+
|
|
69
|
+
expect(calls[0]?.url).toBe('https://host.test/api/conversations/preview/inbound')
|
|
70
|
+
expect(calls[0]?.init.method).toBe('POST')
|
|
71
|
+
expect(calls[0]?.init.headers).toMatchObject({
|
|
72
|
+
'content-type': 'application/json',
|
|
73
|
+
authorization: 'Bearer token-do-painel',
|
|
74
|
+
})
|
|
75
|
+
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ kind: 'text', from: FROM, text: 'oi' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('converte recusa do host em erro tipado, para o painel poder mostrar o motivo', async () => {
|
|
79
|
+
const client = createPreviewBridgeClient({
|
|
80
|
+
from: FROM,
|
|
81
|
+
endpointUrl: 'https://host.test/preview',
|
|
82
|
+
fetchImplementation: (async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewBridgeRejectedError)
|
|
86
|
+
await expect(client.sendText('oi')).rejects.toThrow(/403/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('recusa configuração sem forma de entregar, em vez de falhar só no primeiro envio', () => {
|
|
90
|
+
expect(() => createPreviewBridgeClient({ from: FROM })).toThrow(/sendCommand.*endpointUrl/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
|
|
3
|
+
* navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
|
|
4
|
+
* autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
|
|
5
|
+
* app secret que nunca sai de lá.
|
|
6
|
+
*
|
|
7
|
+
* Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
|
|
8
|
+
* app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
|
|
9
|
+
* acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
|
|
10
|
+
* válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
|
|
11
|
+
* Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
|
|
12
|
+
* servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
|
|
13
|
+
*
|
|
14
|
+
* O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
|
|
15
|
+
* porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
19
|
+
import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
|
|
23
|
+
* se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
|
|
24
|
+
* quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
|
|
25
|
+
*/
|
|
26
|
+
export type PreviewInboundCommand =
|
|
27
|
+
| { readonly kind: 'text'; readonly from: string; readonly text: string }
|
|
28
|
+
| { readonly kind: 'buttonReply'; readonly from: string; readonly reply: InteractiveReplyOption }
|
|
29
|
+
| { readonly kind: 'listReply'; readonly from: string; readonly reply: InteractiveReplyOption }
|
|
30
|
+
| { readonly kind: 'audio'; readonly from: string; readonly mediaId: string }
|
|
31
|
+
| ({ readonly kind: 'media'; readonly from: string } & SendPreviewMediaParams)
|
|
32
|
+
|
|
33
|
+
export type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>
|
|
34
|
+
|
|
35
|
+
export class PreviewBridgeRejectedError extends Error {
|
|
36
|
+
constructor(readonly status: number) {
|
|
37
|
+
super(`A rota de preview do host recusou a entrega (HTTP ${status}).`)
|
|
38
|
+
this.name = 'PreviewBridgeRejectedError'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type CreatePreviewBridgeClientParams = {
|
|
43
|
+
readonly from: string
|
|
44
|
+
/**
|
|
45
|
+
* Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
|
|
46
|
+
* de token — reimplementar isso aqui só duplicaria a autenticação do produto.
|
|
47
|
+
*/
|
|
48
|
+
readonly sendCommand?: SendPreviewInboundCommand
|
|
49
|
+
/** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
|
|
50
|
+
readonly endpointUrl?: string
|
|
51
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
52
|
+
readonly fetchImplementation?: typeof fetch
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
|
|
56
|
+
const endpointUrl = params.endpointUrl
|
|
57
|
+
if (!endpointUrl) {
|
|
58
|
+
throw new Error('createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return async (command) => {
|
|
62
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
63
|
+
const response = await performRequest(endpointUrl, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
// `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
|
|
66
|
+
// bearer não convivem numa escolha default sem quebrar um dos dois.
|
|
67
|
+
headers: { 'content-type': 'application/json', ...params.headers },
|
|
68
|
+
body: JSON.stringify(command),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (!response.ok) throw new PreviewBridgeRejectedError(response.status)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
|
|
76
|
+
const send = params.sendCommand ?? buildFetchSender(params)
|
|
77
|
+
const from = params.from
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sendText: (text) => send({ kind: 'text', from, text }),
|
|
81
|
+
sendButtonReply: (reply) => send({ kind: 'buttonReply', from, reply }),
|
|
82
|
+
sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
|
|
83
|
+
sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
|
|
84
|
+
sendMedia: (media) => send({ kind: 'media', from, ...media }),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type { InboundMediaType }
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
|
|
7
7
|
* (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
|
|
8
8
|
*
|
|
9
|
-
* ⚠️ Isto carrega o app secret
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
|
|
10
|
+
* seja servido — em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
|
|
11
|
+
* equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
|
|
12
|
+
* mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
|
|
13
|
+
* homologação passaria, então a barreira não basta.
|
|
14
|
+
*
|
|
15
|
+
* Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
|
|
16
|
+
* o servidor assina com o segredo que ele já tem.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import {
|
package/src/preview/index.ts
CHANGED
|
@@ -45,6 +45,13 @@ export type {
|
|
|
45
45
|
SendPreviewMediaParams,
|
|
46
46
|
} from './createPreviewWebhookClient'
|
|
47
47
|
|
|
48
|
+
export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
|
|
49
|
+
export type {
|
|
50
|
+
CreatePreviewBridgeClientParams,
|
|
51
|
+
PreviewInboundCommand,
|
|
52
|
+
SendPreviewInboundCommand,
|
|
53
|
+
} from './createPreviewBridgeClient'
|
|
54
|
+
|
|
48
55
|
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
49
56
|
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
50
57
|
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|