@adatechnology/conversations-ui 0.1.0-rc.17 → 0.1.0-rc.19

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.
@@ -1,4 +1,5 @@
1
1
  import * as react from 'react';
2
+ import { ReactNode } from 'react';
2
3
  import { NodeProps } from '@xyflow/react';
3
4
  import { LucideIcon } from 'lucide-react';
4
5
  import { FlowConditionOperator, FlowGraphData, FlowNodeData, FlowNodeType, FlowQuestionType, FlowActionKind } from '@adatechnology/meta-whatsapp-contracts';
@@ -9,6 +10,7 @@ declare const BUILT_IN_ACTION_KINDS: {
9
10
  readonly HANDOFF: "handoff";
10
11
  readonly RATE_LIMITED_HANDOFF: "rate_limited_handoff";
11
12
  readonly SEND_PRODUCT_LIST: "send_product_list";
13
+ readonly SEND_MEDIA: "send_media";
12
14
  };
13
15
  declare const CROSS_FLOW_PREFIX = "flow:";
14
16
  declare const isCrossFlowTarget: (target: string) => boolean;
@@ -109,6 +111,8 @@ interface FlowEditorLabels {
109
111
  conditionTrue: string;
110
112
  conditionFalse: string;
111
113
  conditionVariableMissing: string;
114
+ media: string;
115
+ mediaUnavailable: string;
112
116
  };
113
117
  palette: {
114
118
  title: string;
@@ -233,14 +237,22 @@ interface FlowNodePanelProps {
233
237
  onChange: (updated: FlowNodeData) => void;
234
238
  onDelete: (nodeId: string) => void;
235
239
  labels?: Partial<FlowEditorLabels>;
240
+ /**
241
+ * Seletor de arquivos do nó `send_media`, renderizado no lugar da mensagem direta.
242
+ *
243
+ * Slot, e não uma lista de arquivos por prop, porque a biblioteca é do host: upload, permissão e
244
+ * URL assinada são dele, e o painel não tem como buscar nada. Ausente, o nó continua editável —
245
+ * só não dá para anexar por aqui.
246
+ */
247
+ renderMediaPicker?: (node: FlowNodeData) => ReactNode;
236
248
  }
237
- declare function FlowNodePanel({ graph, node, issues, otherFlows, onClose, onChange, onDelete, labels: labelsOverride, }: FlowNodePanelProps): react.JSX.Element;
249
+ declare function FlowNodePanel({ graph, node, issues, otherFlows, onClose, onChange, onDelete, labels: labelsOverride, renderMediaPicker, }: FlowNodePanelProps): react.JSX.Element;
238
250
 
239
251
  interface FlowWhatsAppPreviewProps {
240
252
  body: string;
241
253
  options?: [string, string][];
242
254
  labels?: FlowEditorLabels['nodePanel'];
243
255
  }
244
- declare function FlowWhatsAppPreview({ body, options, labels }: FlowWhatsAppPreviewProps): react.JSX.Element;
256
+ declare function FlowWhatsAppPreview({ body, options, labels, }: FlowWhatsAppPreviewProps): react.JSX.Element;
245
257
 
246
258
  export { BUILT_IN_ACTION_KINDS, CONDITION_OPERATORS, CROSS_FLOW_PREFIX, type CollectionChain, DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels, FlowGroupFrame, type FlowGroupFrameData, FlowGroupHeader, type FlowGroupHeaderData, FlowMapCanvas, type FlowMapCanvasProps, FlowMapNode, type FlowMapNodeData, FlowNodeCard, type FlowNodeCardData, FlowNodePanel, type FlowNodePanelProps, FlowPalette, type FlowPaletteActionOption, type FlowPaletteProps, FlowPortalNode, type FlowPortalNodeData, FlowWhatsAppPreview, type FlowWhatsAppPreviewProps, type GraphIssue, NODE_CARD_WIDTH, type NewNodeSpec, WHATSAPP_LIMITS, computeAutoLayout, computeFlowMapLayout, crossFlowKey, crossFlowTargetsOf, estimateNodeHeight, findCollectionChains, flowGroupFrameNodeTypes, flowGroupHeaderNodeTypes, flowMapNodeTypes, flowNodeTypes, flowPortalNodeTypes, isCrossFlowTarget, mergeFlowEditorLabels, nodeLabel, rendersAsButtons, slugifyNodeId, targetsOf, validateGraph };
@@ -5,7 +5,18 @@ import {
5
5
 
6
6
  // src/flows/FlowNodeCard.tsx
7
7
  import { Handle, Position } from "@xyflow/react";
8
- import { MessageCircleQuestion, GitBranch, Zap, ListTree, Diamond, AlertTriangle, AlertCircle, Headset, Clock3, ShoppingBag } from "lucide-react";
8
+ import {
9
+ MessageCircleQuestion,
10
+ GitBranch,
11
+ Zap,
12
+ ListTree,
13
+ Diamond,
14
+ AlertTriangle,
15
+ AlertCircle,
16
+ Headset,
17
+ Clock3,
18
+ ShoppingBag
19
+ } from "lucide-react";
9
20
  import { jsx, jsxs } from "react/jsx-runtime";
10
21
  var NODE_TYPE_COLOR = {
11
22
  question: "border-blue-300 bg-blue-50 dark:bg-blue-950/40 dark:border-blue-800",
@@ -57,7 +68,13 @@ function sourceRows(node, labels) {
57
68
  }
58
69
  function SourceRow({ label, isDefault, handleId }) {
59
70
  return /* @__PURE__ */ jsxs("div", { className: "relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3", children: [
60
- /* @__PURE__ */ jsx("span", { className: `text-xs truncate flex-1 ${isDefault ? "italic text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-200"}`, children: label }),
71
+ /* @__PURE__ */ jsx(
72
+ "span",
73
+ {
74
+ className: `text-xs truncate flex-1 ${isDefault ? "italic text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-200"}`,
75
+ children: label
76
+ }
77
+ ),
61
78
  /* @__PURE__ */ jsx(
62
79
  Handle,
63
80
  {
@@ -132,7 +149,8 @@ var DEFAULT_FLOW_EDITOR_LABELS = {
132
149
  actionKindLabels: {
133
150
  handoff: "Encaminhar para atendimento",
134
151
  rate_limited_handoff: "Encaminhar (limite de simula\xE7\xF5es atingido)",
135
- send_product_list: "Enviar cat\xE1logo de produtos"
152
+ send_product_list: "Enviar cat\xE1logo de produtos",
153
+ send_media: "Enviar arquivos da biblioteca"
136
154
  },
137
155
  conditionOperatorLabels: {
138
156
  ">": "maior que",
@@ -187,7 +205,9 @@ var DEFAULT_FLOW_EDITOR_LABELS = {
187
205
  conditionValue: "Valor de compara\xE7\xE3o",
188
206
  conditionTrue: "Se verdadeiro \u2192",
189
207
  conditionFalse: "Se falso \u2192",
190
- conditionVariableMissing: "Se a vari\xE1vel ainda n\xE3o foi coletada \u2192"
208
+ conditionVariableMissing: "Se a vari\xE1vel ainda n\xE3o foi coletada \u2192",
209
+ media: "Arquivos enviados neste ponto",
210
+ mediaUnavailable: "A biblioteca de arquivos n\xE3o est\xE1 dispon\xEDvel neste painel."
191
211
  },
192
212
  palette: {
193
213
  title: "Adicionar ao fluxo",
@@ -266,12 +286,9 @@ import { ReactFlow, Background, Controls, MarkerType } from "@xyflow/react";
266
286
  import "@xyflow/react/dist/style.css";
267
287
 
268
288
  // src/flows/flowGraph.ts
289
+ import { FLOW_ACTION_KIND } from "@adatechnology/meta-whatsapp-contracts";
269
290
  var CONDITION_OPERATORS = [">", ">=", "<", "<=", "==", "!=", "contains"];
270
- var BUILT_IN_ACTION_KINDS = {
271
- HANDOFF: "handoff",
272
- RATE_LIMITED_HANDOFF: "rate_limited_handoff",
273
- SEND_PRODUCT_LIST: "send_product_list"
274
- };
291
+ var BUILT_IN_ACTION_KINDS = FLOW_ACTION_KIND;
275
292
  var CROSS_FLOW_PREFIX = "flow:";
276
293
  var isCrossFlowTarget = (target) => target.startsWith(CROSS_FLOW_PREFIX);
277
294
  var crossFlowKey = (target) => target.slice(CROSS_FLOW_PREFIX.length);
@@ -527,18 +544,20 @@ function FlowMapCanvas({ graphs, rootKey, onOpenFlow, labels: labelsOverride })
527
544
  const isDark = useIsDarkTheme();
528
545
  const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey]);
529
546
  const nodes = useMemo(
530
- () => Object.values(graphs).map((g) => ({
531
- id: g.key,
532
- type: "flowMapNode",
533
- position: positions[g.key] ?? { x: 0, y: 0 },
534
- data: {
535
- label: g.label,
536
- nodeCount: Object.keys(g.nodes).length,
537
- isRoot: g.key === rootKey,
538
- labels,
539
- onOpen: () => onOpenFlow(g.key)
540
- }
541
- })),
547
+ () => Object.values(graphs).map(
548
+ (g) => ({
549
+ id: g.key,
550
+ type: "flowMapNode",
551
+ position: positions[g.key] ?? { x: 0, y: 0 },
552
+ data: {
553
+ label: g.label,
554
+ nodeCount: Object.keys(g.nodes).length,
555
+ isRoot: g.key === rootKey,
556
+ labels,
557
+ onOpen: () => onOpenFlow(g.key)
558
+ }
559
+ })
560
+ ),
542
561
  [graphs, positions, rootKey, onOpenFlow, labels]
543
562
  );
544
563
  const edges = useMemo(() => {
@@ -590,8 +609,26 @@ function FlowGroupHeader({ data }) {
590
609
  const { label, labels = DEFAULT_FLOW_EDITOR_LABELS, onFocus, onClose } = data;
591
610
  return /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-2 rounded-full border border-cyan-300 dark:border-cyan-700 bg-cyan-50 dark:bg-cyan-950/50 px-3 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-200 shadow-sm whitespace-nowrap", children: [
592
611
  /* @__PURE__ */ jsx5("span", { children: label }),
593
- /* @__PURE__ */ jsx5("button", { type: "button", onClick: onFocus, title: labels.flowGroup.focus, className: "hover:text-blue-600 dark:hover:text-blue-400", children: /* @__PURE__ */ jsx5(Maximize22, { size: 12 }) }),
594
- /* @__PURE__ */ jsx5("button", { type: "button", onClick: onClose, title: labels.flowGroup.close, className: "hover:text-red-600 dark:hover:text-red-400", children: /* @__PURE__ */ jsx5(X, { size: 12 }) })
612
+ /* @__PURE__ */ jsx5(
613
+ "button",
614
+ {
615
+ type: "button",
616
+ onClick: onFocus,
617
+ title: labels.flowGroup.focus,
618
+ className: "hover:text-blue-600 dark:hover:text-blue-400",
619
+ children: /* @__PURE__ */ jsx5(Maximize22, { size: 12 })
620
+ }
621
+ ),
622
+ /* @__PURE__ */ jsx5(
623
+ "button",
624
+ {
625
+ type: "button",
626
+ onClick: onClose,
627
+ title: labels.flowGroup.close,
628
+ className: "hover:text-red-600 dark:hover:text-red-400",
629
+ children: /* @__PURE__ */ jsx5(X, { size: 12 })
630
+ }
631
+ )
595
632
  ] });
596
633
  }
597
634
  var flowGroupHeaderNodeTypes = { flowGroupHeader: FlowGroupHeader };
@@ -753,7 +790,11 @@ import { Plus as Plus2, Trash2, Save, X as X2, AlertTriangle as AlertTriangle2,
753
790
  // src/flows/FlowWhatsAppPreview.tsx
754
791
  import { List } from "lucide-react";
755
792
  import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
756
- function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITOR_LABELS.nodePanel }) {
793
+ function FlowWhatsAppPreview({
794
+ body,
795
+ options,
796
+ labels = DEFAULT_FLOW_EDITOR_LABELS.nodePanel
797
+ }) {
757
798
  if (!body && (!options || options.length === 0)) {
758
799
  return /* @__PURE__ */ jsx8("p", { className: "text-xs text-gray-400 dark:text-gray-500 italic px-1", children: labels.previewPlaceholder });
759
800
  }
@@ -769,7 +810,14 @@ function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITOR_LABEL
769
810
  labels.previewListButton
770
811
  ] }) })
771
812
  ] }),
772
- hasOptions && usesButtons && /* @__PURE__ */ jsx8("div", { className: "mt-1 space-y-1", children: options.map(([id, label]) => /* @__PURE__ */ jsx8("div", { className: "rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm", children: label || /* @__PURE__ */ jsx8("span", { className: "italic text-gray-400", children: labels.previewEmptyOption }) }, id)) }),
813
+ hasOptions && usesButtons && /* @__PURE__ */ jsx8("div", { className: "mt-1 space-y-1", children: options.map(([id, label]) => /* @__PURE__ */ jsx8(
814
+ "div",
815
+ {
816
+ className: "rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm",
817
+ children: label || /* @__PURE__ */ jsx8("span", { className: "italic text-gray-400", children: labels.previewEmptyOption })
818
+ },
819
+ id
820
+ )) }),
773
821
  hasOptions && !usesButtons && /* @__PURE__ */ jsx8("div", { className: "mt-1.5 rounded-lg bg-white dark:bg-gray-700 shadow-sm divide-y divide-gray-100 dark:divide-gray-600 overflow-hidden", children: options.slice(0, WHATSAPP_LIMITS.MAX_LIST_ROWS).map(([id, label]) => /* @__PURE__ */ jsxs7("div", { className: "flex items-center gap-2 px-3 py-1.5 text-sm text-gray-800 dark:text-gray-100", children: [
774
822
  /* @__PURE__ */ jsx8("span", { className: "h-3.5 w-3.5 rounded-full border border-gray-300 dark:border-gray-500 shrink-0" }),
775
823
  label || /* @__PURE__ */ jsx8("span", { className: "italic text-gray-400", children: labels.previewEmptyOption })
@@ -831,17 +879,23 @@ function FlowNodePanel({
831
879
  onClose,
832
880
  onChange,
833
881
  onDelete,
834
- labels: labelsOverride
882
+ labels: labelsOverride,
883
+ renderMediaPicker
835
884
  }) {
836
885
  const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride };
837
886
  const [draft, setDraft] = useState2(node);
838
887
  const otherNodeIds = Object.keys(graph.nodes).filter((id) => id !== node.id);
839
888
  const isFixedLogic = draft.type === "entrada_choice";
840
889
  const isAction = draft.type === "action";
890
+ const isSendMedia = isAction && draft.actionKind === BUILT_IN_ACTION_KINDS.SEND_MEDIA;
841
891
  const isCondition = draft.type === "condition";
842
892
  const isStart = graph.startNodeId === node.id;
843
893
  const nodeIssues = issues.filter((i) => i.nodeId === node.id);
844
- const knownContextKeys = [...new Set(Object.values(graph.nodes).map((n) => n.contextKey).filter((key) => !!key))];
894
+ const knownContextKeys = [
895
+ ...new Set(
896
+ Object.values(graph.nodes).map((n) => n.contextKey).filter((key) => !!key)
897
+ )
898
+ ];
845
899
  const conditionAnswerIds = isCondition ? ["true", "false"] : (draft.options ?? []).map(([id]) => id);
846
900
  function updateNextString(value) {
847
901
  setDraft((prev) => ({ ...prev, next: value }));
@@ -868,7 +922,10 @@ function FlowNodePanel({
868
922
  });
869
923
  }
870
924
  function addOption() {
871
- setDraft((prev) => ({ ...prev, options: [...prev.options ?? [], [String((prev.options?.length ?? 0) + 1), "Nova op\xE7\xE3o"]] }));
925
+ setDraft((prev) => ({
926
+ ...prev,
927
+ options: [...prev.options ?? [], [String((prev.options?.length ?? 0) + 1), "Nova op\xE7\xE3o"]]
928
+ }));
872
929
  }
873
930
  function removeOption(index) {
874
931
  setDraft((prev) => ({ ...prev, options: (prev.options ?? []).filter((_, i) => i !== index) }));
@@ -891,7 +948,14 @@ function FlowNodePanel({
891
948
  isCondition && /* @__PURE__ */ jsx9("p", { className: "text-xs text-cyan-700 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-950/30 rounded-lg p-2", children: labels.nodePanel.conditionNotice }),
892
949
  draft.contextKey && /* @__PURE__ */ jsxs8("div", { children: [
893
950
  /* @__PURE__ */ jsx9("label", { className: "text-xs font-medium text-gray-500 dark:text-gray-400", children: labels.nodePanel.contextKey }),
894
- /* @__PURE__ */ jsx9("input", { value: draft.contextKey, disabled: true, className: "w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500" })
951
+ /* @__PURE__ */ jsx9(
952
+ "input",
953
+ {
954
+ value: draft.contextKey,
955
+ disabled: true,
956
+ className: "w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500"
957
+ }
958
+ )
895
959
  ] }),
896
960
  !isFixedLogic && !isAction && !isCondition && /* @__PURE__ */ jsxs8("div", { children: [
897
961
  /* @__PURE__ */ jsx9("label", { className: "text-xs font-medium text-gray-500 dark:text-gray-400", children: labels.nodePanel.questionType }),
@@ -935,7 +999,10 @@ function FlowNodePanel({
935
999
  "select",
936
1000
  {
937
1001
  value: draft.conditionOperator ?? ">",
938
- onChange: (e) => setDraft((prev) => ({ ...prev, conditionOperator: e.target.value })),
1002
+ onChange: (e) => setDraft((prev) => ({
1003
+ ...prev,
1004
+ conditionOperator: e.target.value
1005
+ })),
939
1006
  className: `w-full mt-1 ${SELECT_CLASSNAME}`,
940
1007
  children: CONDITION_OPERATORS.map((operator) => /* @__PURE__ */ jsx9("option", { value: operator, children: labels.conditionOperatorLabels[operator] ?? operator }, operator))
941
1008
  }
@@ -963,7 +1030,11 @@ function FlowNodePanel({
963
1030
  labels
964
1031
  }
965
1032
  ),
966
- isAction && draft.actionKind !== "send_product_list" && /* @__PURE__ */ jsx9(
1033
+ isSendMedia && /* @__PURE__ */ jsxs8("div", { children: [
1034
+ /* @__PURE__ */ jsx9("label", { className: "text-xs font-medium text-gray-500 dark:text-gray-400", children: labels.nodePanel.media }),
1035
+ /* @__PURE__ */ jsx9("div", { className: "mt-1", children: renderMediaPicker?.(node) ?? /* @__PURE__ */ jsx9("p", { className: "text-xs text-gray-400", children: labels.nodePanel.mediaUnavailable }) })
1036
+ ] }),
1037
+ isAction && draft.actionKind !== "send_product_list" && !isSendMedia && /* @__PURE__ */ jsx9(
967
1038
  WhatsAppTextField,
968
1039
  {
969
1040
  label: labels.nodePanel.directMessage,
@@ -1053,11 +1124,18 @@ function FlowNodePanel({
1053
1124
  ] })
1054
1125
  ] }),
1055
1126
  /* @__PURE__ */ jsxs8("div", { className: "p-4 border-t border-gray-100 dark:border-gray-700 flex gap-2", children: [
1056
- /* @__PURE__ */ jsxs8("button", { onClick: () => onChange(draft), className: "flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700", children: [
1057
- /* @__PURE__ */ jsx9(Save, { size: 14 }),
1058
- " ",
1059
- labels.nodePanel.save
1060
- ] }),
1127
+ /* @__PURE__ */ jsxs8(
1128
+ "button",
1129
+ {
1130
+ onClick: () => onChange(draft),
1131
+ className: "flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700",
1132
+ children: [
1133
+ /* @__PURE__ */ jsx9(Save, { size: 14 }),
1134
+ " ",
1135
+ labels.nodePanel.save
1136
+ ]
1137
+ }
1138
+ ),
1061
1139
  !isStart && /* @__PURE__ */ jsx9(
1062
1140
  "button",
1063
1141
  {
@@ -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 de DESENVOLVIMENTO no bundle. existe para o docker local, e
124
- * `assertPreviewEnvironment` recusa rodar em produção um segredo de dev vazado é irrelevante,
125
- * mas o hábito de embarcar segredo em frontend não é.
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 };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.17",
3
+ "version": "0.1.0-rc.19",
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.6"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.7"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18 || ^19",
@@ -18,10 +18,20 @@ export function FlowGroupHeader({ data }: NodeProps) {
18
18
  return (
19
19
  <div className="flex items-center gap-2 rounded-full border border-cyan-300 dark:border-cyan-700 bg-cyan-50 dark:bg-cyan-950/50 px-3 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-200 shadow-sm whitespace-nowrap">
20
20
  <span>{label}</span>
21
- <button type="button" onClick={onFocus} title={labels.flowGroup.focus} className="hover:text-blue-600 dark:hover:text-blue-400">
21
+ <button
22
+ type="button"
23
+ onClick={onFocus}
24
+ title={labels.flowGroup.focus}
25
+ className="hover:text-blue-600 dark:hover:text-blue-400"
26
+ >
22
27
  <Maximize2 size={12} />
23
28
  </button>
24
- <button type="button" onClick={onClose} title={labels.flowGroup.close} className="hover:text-red-600 dark:hover:text-red-400">
29
+ <button
30
+ type="button"
31
+ onClick={onClose}
32
+ title={labels.flowGroup.close}
33
+ className="hover:text-red-600 dark:hover:text-red-400"
34
+ >
25
35
  <X size={12} />
26
36
  </button>
27
37
  </div>
@@ -27,18 +27,21 @@ export function FlowMapCanvas({ graphs, rootKey, onOpenFlow, labels: labelsOverr
27
27
  const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey])
28
28
 
29
29
  const nodes = useMemo<Node[]>(
30
- () => Object.values(graphs).map((g): Node => ({
31
- id: g.key,
32
- type: 'flowMapNode',
33
- position: positions[g.key] ?? { x: 0, y: 0 },
34
- data: {
35
- label: g.label,
36
- nodeCount: Object.keys(g.nodes).length,
37
- isRoot: g.key === rootKey,
38
- labels,
39
- onOpen: () => onOpenFlow(g.key),
40
- } satisfies FlowMapNodeData,
41
- })),
30
+ () =>
31
+ Object.values(graphs).map(
32
+ (g): Node => ({
33
+ id: g.key,
34
+ type: 'flowMapNode',
35
+ position: positions[g.key] ?? { x: 0, y: 0 },
36
+ data: {
37
+ label: g.label,
38
+ nodeCount: Object.keys(g.nodes).length,
39
+ isRoot: g.key === rootKey,
40
+ labels,
41
+ onOpen: () => onOpenFlow(g.key),
42
+ } satisfies FlowMapNodeData,
43
+ }),
44
+ ),
42
45
  [graphs, positions, rootKey, onOpenFlow, labels],
43
46
  )
44
47
 
@@ -21,7 +21,9 @@ export function FlowMapNode({ data }: NodeProps) {
21
21
  <div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300">
22
22
  <GitBranch size={14} />
23
23
  <span className="text-sm font-semibold truncate">{label}</span>
24
- {isRoot && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />}
24
+ {isRoot && (
25
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />
26
+ )}
25
27
  </div>
26
28
  <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{labels.flowMap.nodeCount(nodeCount)}</p>
27
29
  <button
@@ -1,5 +1,17 @@
1
1
  import { Handle, Position, type NodeProps } from '@xyflow/react'
2
- import { MessageCircleQuestion, GitBranch, Zap, ListTree, Diamond, AlertTriangle, AlertCircle, Headset, Clock3, ShoppingBag, type LucideIcon } from 'lucide-react'
2
+ import {
3
+ MessageCircleQuestion,
4
+ GitBranch,
5
+ Zap,
6
+ ListTree,
7
+ Diamond,
8
+ AlertTriangle,
9
+ AlertCircle,
10
+ Headset,
11
+ Clock3,
12
+ ShoppingBag,
13
+ type LucideIcon,
14
+ } from 'lucide-react'
3
15
  import type { FlowEditorLabels } from './labels'
4
16
  import type { FlowNodeData, GraphIssue } from './flowGraph'
5
17
 
@@ -83,7 +95,9 @@ function sourceRows(node: FlowNodeData, labels: FlowEditorLabels): { id: string;
83
95
  function SourceRow({ label, isDefault, handleId }: { label: string; isDefault: boolean; handleId: string }) {
84
96
  return (
85
97
  <div className="relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3">
86
- <span className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}>
98
+ <span
99
+ className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}
100
+ >
87
101
  {label}
88
102
  </span>
89
103
  <Handle
@@ -98,10 +112,14 @@ function SourceRow({ label, isDefault, handleId }: { label: string; isDefault: b
98
112
  }
99
113
 
100
114
  export function FlowNodeCard({ data }: NodeProps) {
101
- const { node, liveCount, isStart, isSelected, issues, labels, actionKindIcons, onSelect } = data as unknown as FlowNodeCardData
115
+ const { node, liveCount, isStart, isSelected, issues, labels, actionKindIcons, onSelect } =
116
+ data as unknown as FlowNodeCardData
102
117
  const label = nodeLabel(node, labels)
103
118
  const iconMap = { ...DEFAULT_ACTION_KIND_ICON, ...actionKindIcons }
104
- const Icon = node.type === 'action' && node.actionKind ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type]) : NODE_TYPE_ICON[node.type]
119
+ const Icon =
120
+ node.type === 'action' && node.actionKind
121
+ ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type])
122
+ : NODE_TYPE_ICON[node.type]
105
123
  const rows = sourceRows(node, labels)
106
124
  const hasError = issues.some((i) => i.severity === 'error')
107
125
  const hasWarning = !hasError && issues.some((i) => i.severity === 'warning')
@@ -1,8 +1,8 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import { Plus, Trash2, Save, X, AlertTriangle, AlertCircle } from 'lucide-react'
3
3
  import { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
4
4
  import { nodeLabel } from './FlowNodeCard'
5
- import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS } from './flowGraph'
5
+ import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS, BUILT_IN_ACTION_KINDS } from './flowGraph'
6
6
  import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
7
7
  import type { FlowGraphData, FlowNodeData, GraphIssue } from './flowGraph'
8
8
 
@@ -73,6 +73,14 @@ export interface FlowNodePanelProps {
73
73
  onChange: (updated: FlowNodeData) => void
74
74
  onDelete: (nodeId: string) => void
75
75
  labels?: Partial<FlowEditorLabels>
76
+ /**
77
+ * Seletor de arquivos do nó `send_media`, renderizado no lugar da mensagem direta.
78
+ *
79
+ * Slot, e não uma lista de arquivos por prop, porque a biblioteca é do host: upload, permissão e
80
+ * URL assinada são dele, e o painel não tem como buscar nada. Ausente, o nó continua editável —
81
+ * só não dá para anexar por aqui.
82
+ */
83
+ renderMediaPicker?: (node: FlowNodeData) => ReactNode
76
84
  }
77
85
 
78
86
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowNodePanel.tsx —
@@ -86,18 +94,26 @@ export function FlowNodePanel({
86
94
  onChange,
87
95
  onDelete,
88
96
  labels: labelsOverride,
97
+ renderMediaPicker,
89
98
  }: FlowNodePanelProps) {
90
99
  const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
91
100
  const [draft, setDraft] = useState<FlowNodeData>(node)
92
101
  const otherNodeIds = Object.keys(graph.nodes).filter((id) => id !== node.id)
93
102
  const isFixedLogic = draft.type === 'entrada_choice'
94
103
  const isAction = draft.type === 'action'
104
+ const isSendMedia = isAction && draft.actionKind === BUILT_IN_ACTION_KINDS.SEND_MEDIA
95
105
  const isCondition = draft.type === 'condition'
96
106
  const isStart = graph.startNodeId === node.id
97
107
  const nodeIssues = issues.filter((i) => i.nodeId === node.id)
98
108
  // Chaves já usadas por perguntas deste fluxo — sugestão pro campo de variável da condição,
99
109
  // sem travar em texto livre (a variável pode ter vindo de outro fluxo ou de um cálculo derivado).
100
- const knownContextKeys = [...new Set(Object.values(graph.nodes).map((n) => n.contextKey).filter((key): key is string => !!key))]
110
+ const knownContextKeys = [
111
+ ...new Set(
112
+ Object.values(graph.nodes)
113
+ .map((n) => n.contextKey)
114
+ .filter((key): key is string => !!key),
115
+ ),
116
+ ]
101
117
  const conditionAnswerIds = isCondition ? ['true', 'false'] : (draft.options ?? []).map(([id]) => id)
102
118
 
103
119
  function updateNextString(value: string) {
@@ -106,7 +122,8 @@ export function FlowNodePanel({
106
122
 
107
123
  function updateNextByAnswer(answerId: string, value: string) {
108
124
  setDraft((prev) => {
109
- const current = typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
125
+ const current =
126
+ typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
110
127
  return { ...prev, next: { ...current, byAnswer: { ...current.byAnswer, [answerId]: value } } }
111
128
  })
112
129
  }
@@ -129,7 +146,10 @@ export function FlowNodePanel({
129
146
  }
130
147
 
131
148
  function addOption() {
132
- setDraft((prev) => ({ ...prev, options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']] }))
149
+ setDraft((prev) => ({
150
+ ...prev,
151
+ options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']],
152
+ }))
133
153
  }
134
154
 
135
155
  function removeOption(index: number) {
@@ -140,12 +160,16 @@ export function FlowNodePanel({
140
160
  return (
141
161
  <>
142
162
  {otherNodeIds.map((id) => (
143
- <option key={id} value={id}>{truncateLabel(nodeLabel(graph.nodes[id], labels))}</option>
163
+ <option key={id} value={id}>
164
+ {truncateLabel(nodeLabel(graph.nodes[id], labels))}
165
+ </option>
144
166
  ))}
145
167
  {otherFlows.length > 0 && (
146
168
  <optgroup label={labels.nodePanel.otherFlowsGroup}>
147
169
  {otherFlows.map((flow) => (
148
- <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>{flow.label}</option>
170
+ <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>
171
+ {flow.label}
172
+ </option>
149
173
  ))}
150
174
  </optgroup>
151
175
  )}
@@ -157,13 +181,17 @@ export function FlowNodePanel({
157
181
  <div className="fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-xl z-50 flex flex-col">
158
182
  <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
159
183
  <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.nodePanel.title}</h3>
160
- <button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
184
+ <button onClick={onClose} className="text-gray-400 hover:text-gray-600">
185
+ <X size={18} />
186
+ </button>
161
187
  </div>
162
188
 
163
189
  <div className="flex-1 overflow-y-auto p-4 space-y-4">
164
190
  {nodeIssues.length > 0 && (
165
191
  <div className="rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40 p-2.5 space-y-1.5">
166
- {nodeIssues.map((issue, i) => <IssueRow key={i} issue={issue} />)}
192
+ {nodeIssues.map((issue, i) => (
193
+ <IssueRow key={i} issue={issue} />
194
+ ))}
167
195
  </div>
168
196
  )}
169
197
 
@@ -185,21 +213,33 @@ export function FlowNodePanel({
185
213
 
186
214
  {draft.contextKey && (
187
215
  <div>
188
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.contextKey}</label>
189
- <input value={draft.contextKey} disabled className="w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500" />
216
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
217
+ {labels.nodePanel.contextKey}
218
+ </label>
219
+ <input
220
+ value={draft.contextKey}
221
+ disabled
222
+ className="w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500"
223
+ />
190
224
  </div>
191
225
  )}
192
226
 
193
227
  {!isFixedLogic && !isAction && !isCondition && (
194
228
  <div>
195
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.questionType}</label>
229
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
230
+ {labels.nodePanel.questionType}
231
+ </label>
196
232
  <select
197
233
  value={draft.questionType ?? 'text'}
198
- onChange={(e) => setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))}
234
+ onChange={(e) =>
235
+ setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))
236
+ }
199
237
  className={`w-full mt-1 ${SELECT_CLASSNAME}`}
200
238
  >
201
239
  {Object.entries(labels.questionTypeLabels).map(([key, label]) => (
202
- <option key={key} value={key}>{label}</option>
240
+ <option key={key} value={key}>
241
+ {label}
242
+ </option>
203
243
  ))}
204
244
  </select>
205
245
  </div>
@@ -218,7 +258,9 @@ export function FlowNodePanel({
218
258
  {isCondition && (
219
259
  <div className="space-y-3">
220
260
  <div>
221
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionVariable}</label>
261
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
262
+ {labels.nodePanel.conditionVariable}
263
+ </label>
222
264
  <input
223
265
  value={draft.conditionContextKey ?? ''}
224
266
  onChange={(e) => setDraft((prev) => ({ ...prev, conditionContextKey: e.target.value }))}
@@ -226,23 +268,36 @@ export function FlowNodePanel({
226
268
  className={`w-full mt-1 ${INPUT_CLASSNAME}`}
227
269
  />
228
270
  <datalist id="condition-context-keys">
229
- {knownContextKeys.map((key) => <option key={key} value={key} />)}
271
+ {knownContextKeys.map((key) => (
272
+ <option key={key} value={key} />
273
+ ))}
230
274
  </datalist>
231
275
  </div>
232
276
  <div>
233
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionOperator}</label>
277
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
278
+ {labels.nodePanel.conditionOperator}
279
+ </label>
234
280
  <select
235
281
  value={draft.conditionOperator ?? '>'}
236
- onChange={(e) => setDraft((prev) => ({ ...prev, conditionOperator: e.target.value as FlowNodeData['conditionOperator'] }))}
282
+ onChange={(e) =>
283
+ setDraft((prev) => ({
284
+ ...prev,
285
+ conditionOperator: e.target.value as FlowNodeData['conditionOperator'],
286
+ }))
287
+ }
237
288
  className={`w-full mt-1 ${SELECT_CLASSNAME}`}
238
289
  >
239
290
  {CONDITION_OPERATORS.map((operator) => (
240
- <option key={operator} value={operator}>{labels.conditionOperatorLabels[operator] ?? operator}</option>
291
+ <option key={operator} value={operator}>
292
+ {labels.conditionOperatorLabels[operator] ?? operator}
293
+ </option>
241
294
  ))}
242
295
  </select>
243
296
  </div>
244
297
  <div>
245
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionValue}</label>
298
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
299
+ {labels.nodePanel.conditionValue}
300
+ </label>
246
301
  <input
247
302
  value={draft.conditionValue ?? ''}
248
303
  onChange={(e) => setDraft((prev) => ({ ...prev, conditionValue: e.target.value }))}
@@ -262,7 +317,21 @@ export function FlowNodePanel({
262
317
  />
263
318
  )}
264
319
 
265
- {isAction && draft.actionKind !== 'send_product_list' && (
320
+ {/* Os arquivos moram na biblioteca, não no grafo — trocar o material não repassa pelo editor. */}
321
+ {isSendMedia && (
322
+ <div>
323
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.media}</label>
324
+ <div className="mt-1">
325
+ {renderMediaPicker?.(node) ?? (
326
+ <p className="text-xs text-gray-400">{labels.nodePanel.mediaUnavailable}</p>
327
+ )}
328
+ </div>
329
+ </div>
330
+ )}
331
+
332
+ {/* `send_media` fica de fora: o handler do módulo só envia os anexos, então um campo de
333
+ mensagem aqui seria texto que o cliente nunca recebe. A legenda é por arquivo. */}
334
+ {isAction && draft.actionKind !== 'send_product_list' && !isSendMedia && (
266
335
  <WhatsAppTextField
267
336
  label={labels.nodePanel.directMessage}
268
337
  value={draft.directMessage ?? ''}
@@ -283,11 +352,21 @@ export function FlowNodePanel({
283
352
  <div className="space-y-2">
284
353
  {(draft.options ?? []).map(([id, label], i) => (
285
354
  <div key={i} className="flex items-center gap-2">
286
- <input value={id} onChange={(e) => updateOption(i, 0, e.target.value)} placeholder={labels.nodePanel.optionId}
287
- className={`w-16 ${INPUT_CLASSNAME}`} />
288
- <input value={label} onChange={(e) => updateOption(i, 1, e.target.value)} placeholder={labels.nodePanel.optionLabel}
289
- className={`flex-1 ${INPUT_CLASSNAME}`} />
290
- <button onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600"><Trash2 size={14} /></button>
355
+ <input
356
+ value={id}
357
+ onChange={(e) => updateOption(i, 0, e.target.value)}
358
+ placeholder={labels.nodePanel.optionId}
359
+ className={`w-16 ${INPUT_CLASSNAME}`}
360
+ />
361
+ <input
362
+ value={label}
363
+ onChange={(e) => updateOption(i, 1, e.target.value)}
364
+ placeholder={labels.nodePanel.optionLabel}
365
+ className={`flex-1 ${INPUT_CLASSNAME}`}
366
+ />
367
+ <button onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600">
368
+ <Trash2 size={14} />
369
+ </button>
291
370
  </div>
292
371
  ))}
293
372
  </div>
@@ -299,8 +378,11 @@ export function FlowNodePanel({
299
378
  <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.next}</label>
300
379
  <p className="text-[11px] text-gray-400 dark:text-gray-500 mb-1">{labels.nodePanel.nextHint}</p>
301
380
  {typeof draft.next !== 'object' && !isCondition ? (
302
- <select value={typeof draft.next === 'string' ? draft.next : ''} onChange={(e) => updateNextString(e.target.value)}
303
- className={`w-full mt-1 ${SELECT_CLASSNAME}`}>
381
+ <select
382
+ value={typeof draft.next === 'string' ? draft.next : ''}
383
+ onChange={(e) => updateNextString(e.target.value)}
384
+ className={`w-full mt-1 ${SELECT_CLASSNAME}`}
385
+ >
304
386
  <option value="">—</option>
305
387
  {nextNodeOptions()}
306
388
  </select>
@@ -309,10 +391,17 @@ export function FlowNodePanel({
309
391
  {conditionAnswerIds.map((id) => (
310
392
  <div key={id} className="flex items-center gap-2">
311
393
  <span className="text-xs text-gray-500 w-32 shrink-0">
312
- {isCondition ? (id === 'true' ? labels.nodePanel.conditionTrue : labels.nodePanel.conditionFalse) : labels.nodePanel.nextByAnswer(id)}
394
+ {isCondition
395
+ ? id === 'true'
396
+ ? labels.nodePanel.conditionTrue
397
+ : labels.nodePanel.conditionFalse
398
+ : labels.nodePanel.nextByAnswer(id)}
313
399
  </span>
314
- <select value={draft.next && typeof draft.next === 'object' ? draft.next.byAnswer[id] ?? '' : ''} onChange={(e) => updateNextByAnswer(id, e.target.value)}
315
- className={`flex-1 ${SELECT_CLASSNAME}`}>
400
+ <select
401
+ value={draft.next && typeof draft.next === 'object' ? (draft.next.byAnswer[id] ?? '') : ''}
402
+ onChange={(e) => updateNextByAnswer(id, e.target.value)}
403
+ className={`flex-1 ${SELECT_CLASSNAME}`}
404
+ >
316
405
  <option value="">—</option>
317
406
  {nextNodeOptions()}
318
407
  </select>
@@ -322,8 +411,11 @@ export function FlowNodePanel({
322
411
  <span className="text-xs text-gray-500 w-32 shrink-0">
323
412
  {isCondition ? labels.nodePanel.conditionVariableMissing : labels.nodePanel.nextDefault}
324
413
  </span>
325
- <select value={draft.next && typeof draft.next === 'object' ? draft.next.default : ''} onChange={(e) => updateNextDefault(e.target.value)}
326
- className={`flex-1 ${SELECT_CLASSNAME}`}>
414
+ <select
415
+ value={draft.next && typeof draft.next === 'object' ? draft.next.default : ''}
416
+ onChange={(e) => updateNextDefault(e.target.value)}
417
+ className={`flex-1 ${SELECT_CLASSNAME}`}
418
+ >
327
419
  <option value="">—</option>
328
420
  {nextNodeOptions()}
329
421
  </select>
@@ -335,12 +427,17 @@ export function FlowNodePanel({
335
427
  </div>
336
428
 
337
429
  <div className="p-4 border-t border-gray-100 dark:border-gray-700 flex gap-2">
338
- <button onClick={() => onChange(draft)} className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
430
+ <button
431
+ onClick={() => onChange(draft)}
432
+ className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
433
+ >
339
434
  <Save size={14} /> {labels.nodePanel.save}
340
435
  </button>
341
436
  {!isStart && (
342
437
  <button
343
- onClick={() => { if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id) }}
438
+ onClick={() => {
439
+ if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id)
440
+ }}
344
441
  title={labels.nodePanel.delete}
345
442
  className="px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
346
443
  >
@@ -72,7 +72,9 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
72
72
  onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
73
73
  className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
74
74
  >
75
- <span className="flex items-center gap-2"><MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}</span>
75
+ <span className="flex items-center gap-2">
76
+ <MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
77
+ </span>
76
78
  <ChevronRight size={13} className="text-gray-400" />
77
79
  </button>
78
80
  {submenu === 'question' && (
@@ -113,7 +115,9 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
113
115
  onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
114
116
  className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
115
117
  >
116
- <span className="flex items-center gap-2"><Zap size={15} className="text-orange-500" /> {labels.palette.action}</span>
118
+ <span className="flex items-center gap-2">
119
+ <Zap size={15} className="text-orange-500" /> {labels.palette.action}
120
+ </span>
117
121
  <ChevronRight size={13} className="text-gray-400" />
118
122
  </button>
119
123
  {submenu === 'action' && (
@@ -13,7 +13,11 @@ export interface FlowWhatsAppPreviewProps {
13
13
  // opções, botões (≤3) ou lista (4+) — para o editor mostrar exatamente o que o cliente verá.
14
14
  // Consome a mesma bolha/formatação do pacote (parseWhatsAppFormatting, T6.5) — T7.3 elimina a
15
15
  // duplicação de estilo que existia entre este preview e o MessageBubble do bot.
16
- export function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITOR_LABELS.nodePanel }: FlowWhatsAppPreviewProps) {
16
+ export function FlowWhatsAppPreview({
17
+ body,
18
+ options,
19
+ labels = DEFAULT_FLOW_EDITOR_LABELS.nodePanel,
20
+ }: FlowWhatsAppPreviewProps) {
17
21
  if (!body && (!options || options.length === 0)) {
18
22
  return <p className="text-xs text-gray-400 dark:text-gray-500 italic px-1">{labels.previewPlaceholder}</p>
19
23
  }
@@ -25,7 +29,11 @@ export function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITO
25
29
  <div className="rounded-xl bg-[#e5ddd5] dark:bg-gray-900 p-3 space-y-1.5">
26
30
  <div className="max-w-[85%]">
27
31
  <div className="rounded-lg rounded-tl-none bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm whitespace-pre-wrap break-words">
28
- {body ? parseWhatsAppFormatting(body) : <span className="italic text-gray-400">{labels.previewEmptyBody}</span>}
32
+ {body ? (
33
+ parseWhatsAppFormatting(body)
34
+ ) : (
35
+ <span className="italic text-gray-400">{labels.previewEmptyBody}</span>
36
+ )}
29
37
  {hasOptions && !usesButtons && (
30
38
  <div className="mt-2 -mx-3 -mb-2 border-t border-gray-100 dark:border-gray-600">
31
39
  <div className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-cyan-600 dark:text-cyan-400">
@@ -38,7 +46,10 @@ export function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITO
38
46
  {hasOptions && usesButtons && (
39
47
  <div className="mt-1 space-y-1">
40
48
  {options!.map(([id, label]) => (
41
- <div key={id} className="rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm">
49
+ <div
50
+ key={id}
51
+ className="rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm"
52
+ >
42
53
  {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
43
54
  </div>
44
55
  ))}
@@ -13,6 +13,7 @@ import type {
13
13
  FlowNodeData,
14
14
  FlowGraphData,
15
15
  } from '@adatechnology/meta-whatsapp-contracts'
16
+ import { FLOW_ACTION_KIND } from '@adatechnology/meta-whatsapp-contracts'
16
17
 
17
18
  export type {
18
19
  FlowNodeType,
@@ -28,11 +29,10 @@ export const CONDITION_OPERATORS: FlowConditionOperator[] = ['>', '>=', '<', '<=
28
29
 
29
30
  // Kinds de ação genéricos que o pacote conhece de fábrica — o host pode registrar quaisquer
30
31
  // outros via `actionKindLabels`/`actionKinds` nos componentes (ver FlowPalette, labels.ts).
31
- export const BUILT_IN_ACTION_KINDS = {
32
- HANDOFF: 'handoff',
33
- RATE_LIMITED_HANDOFF: 'rate_limited_handoff',
34
- SEND_PRODUCT_LIST: 'send_product_list',
35
- } as const
32
+ // Reexporta o vocabulário do contrato em vez de redeclarar os literais: o editor oferece na
33
+ // paleta exatamente os `actionKind` que o backend sabe interpretar, e duas listas separadas
34
+ // divergiriam em silêncio (um nó publicável que nenhum handler atende).
35
+ export const BUILT_IN_ACTION_KINDS = FLOW_ACTION_KIND
36
36
 
37
37
  // Destinos "flow:<key>" são saltos para outro fluxo, resolvidos pelo motor do host. Duplicado
38
38
  // (em vez de importado do contracts) de propósito: são três linhas triviais e importá-las como
@@ -47,6 +47,8 @@ export interface FlowEditorLabels {
47
47
  conditionTrue: string
48
48
  conditionFalse: string
49
49
  conditionVariableMissing: string
50
+ media: string
51
+ mediaUnavailable: string
50
52
  }
51
53
  palette: {
52
54
  title: string
@@ -87,6 +89,7 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
87
89
  handoff: 'Encaminhar para atendimento',
88
90
  rate_limited_handoff: 'Encaminhar (limite de simulações atingido)',
89
91
  send_product_list: 'Enviar catálogo de produtos',
92
+ send_media: 'Enviar arquivos da biblioteca',
90
93
  },
91
94
  conditionOperatorLabels: {
92
95
  '>': 'maior que',
@@ -143,6 +146,8 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
143
146
  conditionTrue: 'Se verdadeiro →',
144
147
  conditionFalse: 'Se falso →',
145
148
  conditionVariableMissing: 'Se a variável ainda não foi coletada →',
149
+ media: 'Arquivos enviados neste ponto',
150
+ mediaUnavailable: 'A biblioteca de arquivos não está disponível neste painel.',
146
151
  },
147
152
  palette: {
148
153
  title: 'Adicionar ao fluxo',
@@ -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 de DESENVOLVIMENTO no bundle. existe para o docker local, e
10
- * `assertPreviewEnvironment` recusa rodar em produção um segredo de dev vazado é irrelevante,
11
- * mas o hábito de embarcar segredo em frontend não é.
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 {
@@ -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'