@adatechnology/meta-whatsapp-module 0.2.0-rc.13 → 0.2.0-rc.15

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/index.js CHANGED
@@ -10,6 +10,7 @@ var __dirname = /* @__PURE__ */ getDirname();
10
10
 
11
11
  // src/createMetaWhatsAppModule.ts
12
12
  import { WhatsAppMessageProvider } from "@adatechnology/meta-whatsapp-provider";
13
+ import { FLOW_ACTION_KIND } from "@adatechnology/meta-whatsapp-contracts";
13
14
 
14
15
  // src/repositories/SessionRepository.ts
15
16
  import { and, desc, eq, sql as sql2 } from "drizzle-orm";
@@ -197,6 +198,46 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
197
198
  }, (table) => [
198
199
  uniqueIndex("idx_flow_graphs_company_key").on(table.companyId, table.key)
199
200
  ]);
201
+ var flowMedia = metaWhatsAppSchema.table("flow_media", {
202
+ id: uuid("id").primaryKey().defaultRandom(),
203
+ companyId: uuid("company_id").notNull(),
204
+ flowKey: varchar("flow_key", {
205
+ length: 64
206
+ }).notNull(),
207
+ nodeId: varchar("node_id", {
208
+ length: 64
209
+ }).notNull(),
210
+ uploadId: varchar("upload_id", {
211
+ length: 256
212
+ }).notNull(),
213
+ filename: varchar("filename", {
214
+ length: 512
215
+ }).notNull(),
216
+ mimeType: varchar("mime_type", {
217
+ length: 128
218
+ }).notNull(),
219
+ sizeBytes: integer("size_bytes").notNull(),
220
+ // Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
221
+ // um folder precisa de textos diferentes para cada um.
222
+ caption: text("caption"),
223
+ // Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
224
+ // do folder" é decisão de quem edita, não do banco.
225
+ sortOrder: integer("sort_order").notNull().default(0),
226
+ // Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
227
+ // perderia a ordem e a legenda já ajustadas.
228
+ active: boolean("active").notNull().default(true),
229
+ createdAt: timestamp("created_at", {
230
+ withTimezone: true
231
+ }).notNull().defaultNow(),
232
+ updatedAt: timestamp("updated_at", {
233
+ withTimezone: true
234
+ }).notNull().defaultNow()
235
+ }, (table) => [
236
+ index("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
237
+ // O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
238
+ // receberia o documento repetido.
239
+ uniqueIndex("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
240
+ ]);
200
241
  var settings = metaWhatsAppSchema.table("settings", {
201
242
  companyId: uuid("company_id").primaryKey(),
202
243
  templateName: varchar("template_name", {
@@ -521,12 +562,21 @@ var FlowGraphRepository = class {
521
562
  __name(this, "FlowGraphRepository");
522
563
  }
523
564
  db;
524
- constructor(db) {
565
+ cache;
566
+ // Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
567
+ // banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
568
+ constructor(db, cache) {
525
569
  this.db = db;
570
+ this.cache = cache;
526
571
  }
527
572
  async get(companyId, key) {
573
+ const cached = await this.cache?.read(companyId, key);
574
+ if (cached) return cached;
528
575
  const [row] = await this.db.select().from(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key))).limit(1);
529
- return row ? toContractGraph(row) : void 0;
576
+ if (!row) return void 0;
577
+ const graph = toContractGraph(row);
578
+ await this.cache?.write(companyId, graph);
579
+ return graph;
530
580
  }
531
581
  async list(companyId) {
532
582
  const rows = await this.db.select().from(flowGraphs).where(eq3(flowGraphs.companyId, companyId));
@@ -556,7 +606,9 @@ var FlowGraphRepository = class {
556
606
  showInMenu: graph.showInMenu ?? false,
557
607
  menuOptionLabel: graph.menuOptionLabel
558
608
  }).returning();
559
- return toContractGraph(created);
609
+ const createdGraph = toContractGraph(created);
610
+ await this.cache?.invalidate(companyId, createdGraph.key);
611
+ return createdGraph;
560
612
  }
561
613
  // Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
562
614
  // senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
@@ -570,10 +622,12 @@ var FlowGraphRepository = class {
570
622
  updatedAt: /* @__PURE__ */ new Date()
571
623
  }).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, graph.key), eq3(flowGraphs.version, expectedVersion))).returning();
572
624
  if (rows.length === 0) throw new OptimisticLockError(graph.key);
625
+ await this.cache?.invalidate(companyId, graph.key);
573
626
  return toContractGraph(rows[0]);
574
627
  }
575
628
  async delete(companyId, key) {
576
629
  await this.db.delete(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key)));
630
+ await this.cache?.invalidate(companyId, key);
577
631
  }
578
632
  // T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
579
633
  // colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
@@ -593,6 +647,48 @@ var FlowGraphRepository = class {
593
647
  }
594
648
  };
595
649
 
650
+ // src/repositories/FlowGraphCache.ts
651
+ var KEY_PREFIX = "meta-whatsapp:flow-graph";
652
+ var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
653
+ var FlowGraphCache = class {
654
+ static {
655
+ __name(this, "FlowGraphCache");
656
+ }
657
+ provider;
658
+ ttlSeconds;
659
+ constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
660
+ this.provider = provider;
661
+ this.ttlSeconds = ttlSeconds;
662
+ }
663
+ // companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
664
+ // repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
665
+ // grafo de uma empresa para a conversa de outra.
666
+ keyFor(companyId, flowKey) {
667
+ return `${KEY_PREFIX}:${companyId}:${flowKey}`;
668
+ }
669
+ async read(companyId, flowKey) {
670
+ try {
671
+ const cached = await this.provider.get(this.keyFor(companyId, flowKey));
672
+ if (!cached) return void 0;
673
+ return JSON.parse(cached);
674
+ } catch {
675
+ return void 0;
676
+ }
677
+ }
678
+ async write(companyId, graph) {
679
+ try {
680
+ await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
681
+ } catch {
682
+ }
683
+ }
684
+ async invalidate(companyId, flowKey) {
685
+ try {
686
+ await this.provider.delete(this.keyFor(companyId, flowKey));
687
+ } catch {
688
+ }
689
+ }
690
+ };
691
+
596
692
  // src/repositories/SettingsRepository.ts
597
693
  import { eq as eq4 } from "drizzle-orm";
598
694
  function toContractSettings(row) {
@@ -1489,6 +1585,151 @@ var FlowInterpreter = class {
1489
1585
  }
1490
1586
  };
1491
1587
 
1588
+ // src/flows/createSendMediaAction.ts
1589
+ function mediaTypeFor2(mimeType) {
1590
+ if (mimeType.startsWith("image/")) return "image";
1591
+ if (mimeType.startsWith("audio/")) return "audio";
1592
+ if (mimeType.startsWith("video/")) return "video";
1593
+ return "document";
1594
+ }
1595
+ __name(mediaTypeFor2, "mediaTypeFor");
1596
+ function createSendMediaAction(params) {
1597
+ return async ({ node, session, channel }) => {
1598
+ if (!session.flowKey) return;
1599
+ const location = {
1600
+ companyId: session.companyId,
1601
+ flowKey: session.flowKey,
1602
+ nodeId: node.id
1603
+ };
1604
+ const attachments = await params.flowMediaRepository.listActive(location);
1605
+ for (const attachment of attachments) {
1606
+ try {
1607
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1608
+ const { externalMessageId } = await channel.sendMedia({
1609
+ to: session.whatsappNumber,
1610
+ buffer,
1611
+ mimeType: attachment.mimeType,
1612
+ filename: attachment.filename,
1613
+ caption: attachment.caption ?? void 0
1614
+ });
1615
+ await params.logMessage.execute({
1616
+ companyId: session.companyId,
1617
+ whatsappNumber: session.whatsappNumber,
1618
+ direction: "outbound",
1619
+ sender: "bot",
1620
+ agentUserId: null,
1621
+ type: mediaTypeFor2(attachment.mimeType),
1622
+ content: attachment.caption ?? attachment.filename,
1623
+ payload: {
1624
+ filename: attachment.filename,
1625
+ mimeType: attachment.mimeType,
1626
+ uploadId: attachment.uploadId,
1627
+ flowMediaId: attachment.id
1628
+ },
1629
+ waMessageId: externalMessageId,
1630
+ status: "sent",
1631
+ startState: params.startState
1632
+ });
1633
+ } catch (error) {
1634
+ params.onError?.(error, {
1635
+ flowKey: location.flowKey,
1636
+ nodeId: node.id,
1637
+ uploadId: attachment.uploadId
1638
+ });
1639
+ }
1640
+ }
1641
+ };
1642
+ }
1643
+ __name(createSendMediaAction, "createSendMediaAction");
1644
+
1645
+ // src/repositories/FlowMediaRepository.ts
1646
+ import { and as and5, asc as asc2, eq as eq6, sql as sql4 } from "drizzle-orm";
1647
+ var FlowMediaRepository = class {
1648
+ static {
1649
+ __name(this, "FlowMediaRepository");
1650
+ }
1651
+ db;
1652
+ constructor(db) {
1653
+ this.db = db;
1654
+ }
1655
+ locationFilter(location) {
1656
+ return and5(eq6(flowMedia.companyId, location.companyId), eq6(flowMedia.flowKey, location.flowKey), eq6(flowMedia.nodeId, location.nodeId));
1657
+ }
1658
+ // O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
1659
+ // é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
1660
+ // justamente o material que alguém desligou.
1661
+ async listActive(location) {
1662
+ return this.db.select().from(flowMedia).where(and5(this.locationFilter(location), eq6(flowMedia.active, true))).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
1663
+ }
1664
+ // Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
1665
+ // poder ser religado.
1666
+ async listAll(location) {
1667
+ return this.db.select().from(flowMedia).where(this.locationFilter(location)).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
1668
+ }
1669
+ /**
1670
+ * Anexa um arquivo já existente no storage ao nó.
1671
+ *
1672
+ * `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
1673
+ * editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
1674
+ * quem está montando o fluxo.
1675
+ */
1676
+ async attach(params) {
1677
+ const [row] = await this.db.insert(flowMedia).values({
1678
+ companyId: params.companyId,
1679
+ flowKey: params.flowKey,
1680
+ nodeId: params.nodeId,
1681
+ uploadId: params.uploadId,
1682
+ filename: params.filename,
1683
+ mimeType: params.mimeType,
1684
+ sizeBytes: params.sizeBytes,
1685
+ caption: params.caption ?? null,
1686
+ sortOrder: params.sortOrder ?? 0
1687
+ }).onConflictDoUpdate({
1688
+ target: [
1689
+ flowMedia.companyId,
1690
+ flowMedia.flowKey,
1691
+ flowMedia.nodeId,
1692
+ flowMedia.uploadId
1693
+ ],
1694
+ set: {
1695
+ caption: params.caption ?? null,
1696
+ sortOrder: params.sortOrder ?? 0,
1697
+ active: true,
1698
+ updatedAt: sql4`now()`
1699
+ }
1700
+ }).returning();
1701
+ return row;
1702
+ }
1703
+ async update(params) {
1704
+ const [row] = await this.db.update(flowMedia).set({
1705
+ ...params.caption !== void 0 ? {
1706
+ caption: params.caption
1707
+ } : {},
1708
+ ...params.sortOrder !== void 0 ? {
1709
+ sortOrder: params.sortOrder
1710
+ } : {},
1711
+ ...params.active !== void 0 ? {
1712
+ active: params.active
1713
+ } : {},
1714
+ updatedAt: sql4`now()`
1715
+ }).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id))).returning();
1716
+ return row;
1717
+ }
1718
+ /**
1719
+ * Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
1720
+ * outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
1721
+ * host, que é dono da biblioteca de arquivos.
1722
+ */
1723
+ async detach(params) {
1724
+ await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id)));
1725
+ }
1726
+ // Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
1727
+ async detachRemovedNodes(params) {
1728
+ const condition = params.existingNodeIds.length === 0 ? void 0 : sql4`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
1729
+ await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.flowKey, params.flowKey), condition));
1730
+ }
1731
+ };
1732
+
1492
1733
  // src/channel/WhatsAppChannelAdapter.ts
1493
1734
  import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1494
1735
  import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
@@ -1585,7 +1826,7 @@ async function claimWebhookDelivery(params) {
1585
1826
  __name(claimWebhookDelivery, "claimWebhookDelivery");
1586
1827
 
1587
1828
  // src/channel/IngestInboundMedia.use-case.ts
1588
- import { eq as eq6, and as and5 } from "drizzle-orm";
1829
+ import { eq as eq7, and as and6 } from "drizzle-orm";
1589
1830
  var IngestInboundMediaUseCase = class {
1590
1831
  static {
1591
1832
  __name(this, "IngestInboundMediaUseCase");
@@ -1601,7 +1842,7 @@ var IngestInboundMediaUseCase = class {
1601
1842
  this.documentRepository = documentRepository;
1602
1843
  }
1603
1844
  async execute(params) {
1604
- const [message] = await this.db.select().from(messages).where(and5(eq6(messages.companyId, params.companyId), eq6(messages.id, params.messageId))).limit(1);
1845
+ const [message] = await this.db.select().from(messages).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId))).limit(1);
1605
1846
  if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1606
1847
  const payload = message.payload ?? {};
1607
1848
  if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
@@ -1622,13 +1863,17 @@ var IngestInboundMediaUseCase = class {
1622
1863
  uploadId,
1623
1864
  sourceMediaId: params.sourceMediaId,
1624
1865
  mimeType: mimeType || params.mimeType,
1866
+ // O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
1867
+ // exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
1868
+ // depois custaria uma consulta à tabela de documentos por mensagem renderizada.
1869
+ sizeBytes: buffer.length,
1625
1870
  ...params.filename ? {
1626
1871
  filename: params.filename
1627
1872
  } : {}
1628
1873
  };
1629
1874
  await this.db.update(messages).set({
1630
1875
  payload: updatedPayload
1631
- }).where(and5(eq6(messages.companyId, params.companyId), eq6(messages.id, params.messageId)));
1876
+ }).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId)));
1632
1877
  await this.documentRepository?.link({
1633
1878
  companyId: params.companyId,
1634
1879
  sessionId: message.sessionId,
@@ -1828,8 +2073,11 @@ function createMetaWhatsAppModule(params) {
1828
2073
  const sessionRepository = new SessionRepository(db);
1829
2074
  const messageRepository = new MessageRepository(db);
1830
2075
  const settingsRepository = new SettingsRepository(db);
1831
- const flowGraphRepository = new FlowGraphRepository(db);
2076
+ const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
2077
+ const flowGraphCache = flowGraphCacheFeature && providers.cache ? new FlowGraphCache(providers.cache, typeof flowGraphCacheFeature === "object" ? flowGraphCacheFeature.ttlSeconds ?? DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS : DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) : void 0;
2078
+ const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
1832
2079
  const documentRepository = new DocumentRepository(db);
2080
+ const flowMediaRepository = new FlowMediaRepository(db);
1833
2081
  const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
1834
2082
  const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1835
2083
  const receiveWebhook = new ReceiveWebhookUseCase({
@@ -1843,6 +2091,15 @@ function createMetaWhatsAppModule(params) {
1843
2091
  realtime: providers.realtime
1844
2092
  });
1845
2093
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
2094
+ if (flowInterpreter && providers.objectStorage?.getObject) {
2095
+ flowInterpreter.registerFlowAction(FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2096
+ flowMediaRepository,
2097
+ objectStorage: providers.objectStorage,
2098
+ logMessage,
2099
+ startState,
2100
+ onError: hooks?.onFlowMediaError
2101
+ }));
2102
+ }
1846
2103
  const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository) : void 0;
1847
2104
  const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1848
2105
  return {
@@ -1886,7 +2143,11 @@ function createMetaWhatsAppModule(params) {
1886
2143
  save: new SaveFlowGraphUseCase(flowGraphRepository),
1887
2144
  delete: new DeleteFlowGraphUseCase(flowGraphRepository),
1888
2145
  livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
1889
- repository: flowGraphRepository
2146
+ repository: flowGraphRepository,
2147
+ // Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
2148
+ // (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
2149
+ // consultar a tabela, e só o ENVIO precisa dos bytes.
2150
+ mediaRepository: flowMediaRepository
1890
2151
  } : void 0,
1891
2152
  catalog: providers.catalog
1892
2153
  };
@@ -1985,12 +2246,15 @@ async function redeemSseTicket(store, ticket) {
1985
2246
  __name(redeemSseTicket, "redeemSseTicket");
1986
2247
  export {
1987
2248
  CreateFlowGraphUseCase,
2249
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
1988
2250
  DeleteConversationUseCase,
1989
2251
  DeleteFlowGraphUseCase,
1990
2252
  DocumentRepository,
1991
2253
  ExportConversationUseCase,
2254
+ FlowGraphCache,
1992
2255
  FlowGraphRepository,
1993
2256
  FlowInterpreter,
2257
+ FlowMediaRepository,
1994
2258
  GetFlowGraphUseCase,
1995
2259
  GetLiveFlowPositionsUseCase,
1996
2260
  IngestInboundMediaUseCase,
@@ -2017,9 +2281,11 @@ export {
2017
2281
  WhatsAppChannelAdapter,
2018
2282
  claimWebhookDelivery,
2019
2283
  createMetaWhatsAppModule,
2284
+ createSendMediaAction,
2020
2285
  documents,
2021
2286
  extractMediaDescriptor,
2022
2287
  flowGraphs,
2288
+ flowMedia,
2023
2289
  issueSseTicket,
2024
2290
  messages,
2025
2291
  metaWhatsAppMigrationsFolder,