@adatechnology/meta-whatsapp-module 0.2.0-rc.30 → 0.2.0-rc.31

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.cjs CHANGED
@@ -30,6 +30,7 @@ __export(index_exports, {
30
30
  FlowGraphCache: () => FlowGraphCache,
31
31
  FlowGraphRepository: () => FlowGraphRepository,
32
32
  FlowInterpreter: () => FlowInterpreter,
33
+ FlowMediaIdRepository: () => FlowMediaIdRepository,
33
34
  FlowMediaRepository: () => FlowMediaRepository,
34
35
  GetFlowGraphUseCase: () => GetFlowGraphUseCase,
35
36
  GetLiveFlowPositionsUseCase: () => GetLiveFlowPositionsUseCase,
@@ -339,6 +340,14 @@ var flowMedia = metaWhatsAppSchema.table("flow_media", {
339
340
  // Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
340
341
  // perderia a ordem e a legenda já ajustadas.
341
342
  active: (0, import_pg_core.boolean)("active").notNull().default(true),
343
+ // Id do arquivo já subido para a Meta, por número remetente. Sem isto, o MESMO binário subia de
344
+ // novo para cada cliente que passava pelo nó: a Meta aceita reusar o id por 30 dias.
345
+ //
346
+ // Mapa por `phone_number_id`, e não coluna única: o id é escopado ao número que envia, e uma
347
+ // instalação com dois números mandaria o id de um pelo outro. A validade não é gravada de
348
+ // propósito — confiar em "30 dias" calculados erra nos casos de borda, e quem decide é a
349
+ // recusa da Meta, que devolve ao caminho de subir o binário.
350
+ metaMediaIds: (0, import_pg_core.jsonb)("meta_media_ids").$type().notNull().default({}),
342
351
  createdAt: (0, import_pg_core.timestamp)("created_at", {
343
352
  withTimezone: true
344
353
  }).notNull().defaultNow(),
@@ -1780,6 +1789,43 @@ function mediaTypeFor2(mimeType) {
1780
1789
  return "document";
1781
1790
  }
1782
1791
  __name(mediaTypeFor2, "mediaTypeFor");
1792
+ async function sendAttachment(params) {
1793
+ const { attachment, channel, to, cache } = params;
1794
+ const common = {
1795
+ to,
1796
+ mimeType: attachment.mimeType,
1797
+ filename: attachment.filename,
1798
+ caption: attachment.caption ?? void 0
1799
+ };
1800
+ const cacheKey = cache ? {
1801
+ flowMediaId: attachment.id,
1802
+ senderKey: cache.senderKey
1803
+ } : void 0;
1804
+ if (cache && cacheKey) {
1805
+ const knownMediaId = await cache.store.get(cacheKey);
1806
+ if (knownMediaId) {
1807
+ try {
1808
+ return await channel.sendMedia({
1809
+ ...common,
1810
+ mediaId: knownMediaId
1811
+ });
1812
+ } catch {
1813
+ await cache.store.clear(cacheKey);
1814
+ }
1815
+ }
1816
+ }
1817
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1818
+ const result = await channel.sendMedia({
1819
+ ...common,
1820
+ buffer
1821
+ });
1822
+ if (cache && cacheKey && result.mediaId) await cache.store.set({
1823
+ ...cacheKey,
1824
+ mediaId: result.mediaId
1825
+ });
1826
+ return result;
1827
+ }
1828
+ __name(sendAttachment, "sendAttachment");
1783
1829
  function createSendMediaAction(params) {
1784
1830
  return async ({ node, session, channel }) => {
1785
1831
  if (!session.flowKey) return;
@@ -1791,13 +1837,14 @@ function createSendMediaAction(params) {
1791
1837
  const attachments = await params.flowMediaRepository.listActive(location);
1792
1838
  for (const attachment of attachments) {
1793
1839
  try {
1794
- const buffer = await params.objectStorage.getObject(attachment.uploadId);
1795
- const { externalMessageId } = await channel.sendMedia({
1840
+ const { externalMessageId } = await sendAttachment({
1841
+ attachment,
1842
+ channel,
1796
1843
  to: session.whatsappNumber,
1797
- buffer,
1798
- mimeType: attachment.mimeType,
1799
- filename: attachment.filename,
1800
- caption: attachment.caption ?? void 0
1844
+ objectStorage: params.objectStorage,
1845
+ ...params.mediaIdCache ? {
1846
+ cache: params.mediaIdCache
1847
+ } : {}
1801
1848
  });
1802
1849
  await params.logMessage.execute({
1803
1850
  companyId: session.companyId,
@@ -1986,6 +2033,42 @@ var FlowMediaRepository = class {
1986
2033
  }
1987
2034
  };
1988
2035
 
2036
+ // src/repositories/FlowMediaIdRepository.ts
2037
+ var import_drizzle_orm8 = require("drizzle-orm");
2038
+ var FlowMediaIdRepository = class {
2039
+ static {
2040
+ __name(this, "FlowMediaIdRepository");
2041
+ }
2042
+ db;
2043
+ constructor(db) {
2044
+ this.db = db;
2045
+ }
2046
+ async get(params) {
2047
+ const rows = await this.db.select({
2048
+ ids: flowMedia.metaMediaIds
2049
+ }).from(flowMedia).where((0, import_drizzle_orm8.eq)(flowMedia.id, params.flowMediaId)).limit(1);
2050
+ return rows[0]?.ids?.[params.senderKey];
2051
+ }
2052
+ /**
2053
+ * Grava só a chave deste número.
2054
+ *
2055
+ * `jsonb_set` no banco, e não ler-alterar-escrever na aplicação: dois clientes passando pelo nó
2056
+ * ao mesmo tempo com números diferentes leriam o mesmo mapa e o último gravaria por cima,
2057
+ * apagando o id do outro. A escrita atômica não tem essa janela.
2058
+ */
2059
+ async set(params) {
2060
+ await this.db.update(flowMedia).set({
2061
+ metaMediaIds: import_drizzle_orm8.sql`jsonb_set(${flowMedia.metaMediaIds}, ARRAY[${params.senderKey}::text], to_jsonb(${params.mediaId}::text), true)`
2062
+ }).where((0, import_drizzle_orm8.eq)(flowMedia.id, params.flowMediaId));
2063
+ }
2064
+ /** Remove só a chave deste número — o id do outro número continua válido. */
2065
+ async clear(params) {
2066
+ await this.db.update(flowMedia).set({
2067
+ metaMediaIds: import_drizzle_orm8.sql`${flowMedia.metaMediaIds} - ${params.senderKey}::text`
2068
+ }).where((0, import_drizzle_orm8.eq)(flowMedia.id, params.flowMediaId));
2069
+ }
2070
+ };
2071
+
1989
2072
  // src/channel/WhatsAppChannelAdapter.ts
1990
2073
  var import_meta_graph_core = require("@adatechnology/meta-graph-core");
1991
2074
  var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
@@ -2021,7 +2104,8 @@ var WhatsAppChannelAdapter = class {
2021
2104
  async sendMedia(params) {
2022
2105
  const result = await this.translateErrors(() => this.messages.sendMedia(params));
2023
2106
  return {
2024
- externalMessageId: result.waMessageId
2107
+ externalMessageId: result.waMessageId,
2108
+ mediaId: result.mediaId
2025
2109
  };
2026
2110
  }
2027
2111
  async sendTemplate(params) {
@@ -2122,7 +2206,7 @@ async function confirmWebhookDelivery(params) {
2122
2206
  __name(confirmWebhookDelivery, "confirmWebhookDelivery");
2123
2207
 
2124
2208
  // src/channel/IngestInboundMedia.use-case.ts
2125
- var import_drizzle_orm8 = require("drizzle-orm");
2209
+ var import_drizzle_orm9 = require("drizzle-orm");
2126
2210
 
2127
2211
  // src/use-cases/TranscribeAudio.use-case.ts
2128
2212
  var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
@@ -2294,7 +2378,7 @@ var IngestInboundMediaUseCase = class {
2294
2378
  this.transcription = transcription;
2295
2379
  }
2296
2380
  async execute(params) {
2297
- const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId))).limit(1);
2381
+ const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm9.and)((0, import_drizzle_orm9.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm9.eq)(messages.id, params.messageId))).limit(1);
2298
2382
  if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
2299
2383
  const payload = message.payload ?? {};
2300
2384
  if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
@@ -2325,7 +2409,7 @@ var IngestInboundMediaUseCase = class {
2325
2409
  };
2326
2410
  await this.db.update(messages).set({
2327
2411
  payload: updatedPayload
2328
- }).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId)));
2412
+ }).where((0, import_drizzle_orm9.and)((0, import_drizzle_orm9.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm9.eq)(messages.id, params.messageId)));
2329
2413
  await this.documentRepository?.link({
2330
2414
  companyId: params.companyId,
2331
2415
  sessionId: message.sessionId,
@@ -2524,6 +2608,13 @@ var ProcessInboundDispatchUseCase = class {
2524
2608
  };
2525
2609
 
2526
2610
  // src/channel/ReceiveWebhook.use-case.ts
2611
+ var EMPTY_RESULT = {
2612
+ messagesProcessed: 0,
2613
+ statusesProcessed: 0,
2614
+ ignoredForeignNumber: 0,
2615
+ accountEventsProcessed: 0,
2616
+ unhandledEvents: 0
2617
+ };
2527
2618
  function extractContent(message) {
2528
2619
  if (message.text?.body) return message.text.body;
2529
2620
  const interactive = message.interactive;
@@ -2549,6 +2640,14 @@ function extractPayload(message) {
2549
2640
  return Object.keys(payload).length > 0 ? payload : null;
2550
2641
  }
2551
2642
  __name(extractPayload, "extractPayload");
2643
+ var ACCOUNT_EVENT_FIELDS = [
2644
+ import_meta_whatsapp_contracts12.WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE,
2645
+ import_meta_whatsapp_contracts12.WHATSAPP_WEBHOOK_FIELDS.PHONE_NUMBER_QUALITY_UPDATE
2646
+ ];
2647
+ function isAccountEventField(field) {
2648
+ return field !== void 0 && ACCOUNT_EVENT_FIELDS.includes(field);
2649
+ }
2650
+ __name(isAccountEventField, "isAccountEventField");
2552
2651
  var ReceiveWebhookUseCase = class {
2553
2652
  static {
2554
2653
  __name(this, "ReceiveWebhookUseCase");
@@ -2579,17 +2678,36 @@ var ReceiveWebhookUseCase = class {
2579
2678
  });
2580
2679
  if (!claimed) return {
2581
2680
  duplicate: true,
2582
- messagesProcessed: 0,
2583
- statusesProcessed: 0,
2584
- ignoredForeignNumber: 0
2681
+ ...EMPTY_RESULT
2585
2682
  };
2586
2683
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
2587
- const payload = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2684
+ const parsed = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.safeParse(JSON.parse(rawText));
2685
+ if (!parsed.success) {
2686
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2687
+ field: void 0,
2688
+ reason: "invalid-shape",
2689
+ value: rawText
2690
+ });
2691
+ return {
2692
+ duplicate: false,
2693
+ ...EMPTY_RESULT,
2694
+ unhandledEvents: 1
2695
+ };
2696
+ }
2697
+ const payload = parsed.data;
2588
2698
  let messagesProcessed = 0;
2589
2699
  let statusesProcessed = 0;
2590
2700
  let ignoredForeignNumber = 0;
2701
+ let accountEventsProcessed = 0;
2702
+ let unhandledEvents = 0;
2591
2703
  for (const entry of payload.entry) {
2592
2704
  for (const change of entry.changes) {
2705
+ if (isAccountEventField(change.field)) {
2706
+ const handled = await this.handleAccountEvent(change);
2707
+ if (handled) accountEventsProcessed++;
2708
+ else unhandledEvents++;
2709
+ continue;
2710
+ }
2593
2711
  const targetNumber = change.value.metadata?.phone_number_id;
2594
2712
  if (targetNumber && targetNumber !== this.params.phoneNumberId) {
2595
2713
  ignoredForeignNumber++;
@@ -2603,6 +2721,15 @@ var ReceiveWebhookUseCase = class {
2603
2721
  await this.handleStatus(input.companyId, status);
2604
2722
  statusesProcessed++;
2605
2723
  }
2724
+ const carriedConversationData = (change.value.messages?.length ?? 0) > 0 || (change.value.message_echoes?.length ?? 0) > 0 || (change.value.statuses?.length ?? 0) > 0;
2725
+ if (!carriedConversationData) {
2726
+ unhandledEvents++;
2727
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2728
+ field: change.field,
2729
+ reason: "unknown-field",
2730
+ value: change.value
2731
+ });
2732
+ }
2606
2733
  }
2607
2734
  }
2608
2735
  await confirmWebhookDelivery({
@@ -2613,9 +2740,39 @@ var ReceiveWebhookUseCase = class {
2613
2740
  duplicate: false,
2614
2741
  messagesProcessed,
2615
2742
  statusesProcessed,
2616
- ignoredForeignNumber
2743
+ ignoredForeignNumber,
2744
+ accountEventsProcessed,
2745
+ unhandledEvents
2617
2746
  };
2618
2747
  }
2748
+ // Devolve `false` quando o corpo não bate com o schema do field — o chamador conta como não
2749
+ // tratado. O host é avisado nos dois casos, mas com `reason` diferente.
2750
+ async handleAccountEvent(change) {
2751
+ if (change.field === import_meta_whatsapp_contracts12.WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE) {
2752
+ const update = import_meta_whatsapp_contracts12.whatsAppTemplateStatusUpdateSchema.safeParse(change.value);
2753
+ if (!update.success) {
2754
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2755
+ field: change.field,
2756
+ reason: "invalid-shape",
2757
+ value: change.value
2758
+ });
2759
+ return false;
2760
+ }
2761
+ await this.params.hooks?.onTemplateStatusUpdate?.(update.data);
2762
+ return true;
2763
+ }
2764
+ const quality = import_meta_whatsapp_contracts12.whatsAppPhoneNumberQualityUpdateSchema.safeParse(change.value);
2765
+ if (!quality.success) {
2766
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2767
+ field: change.field,
2768
+ reason: "invalid-shape",
2769
+ value: change.value
2770
+ });
2771
+ return false;
2772
+ }
2773
+ await this.params.hooks?.onPhoneNumberQualityUpdate?.(quality.data);
2774
+ return true;
2775
+ }
2619
2776
  async handleMessage(companyId, message) {
2620
2777
  const saved = await this.params.logMessage.execute({
2621
2778
  companyId,
@@ -2760,6 +2917,14 @@ function createMetaWhatsAppModule(params) {
2760
2917
  objectStorage: providers.objectStorage,
2761
2918
  logMessage,
2762
2919
  startState,
2920
+ // Cache ligado por padrão, com o store do próprio módulo: a tabela é daqui e a coluna é
2921
+ // daqui, então exigir que cada host escrevesse o repositório faria todos reescreverem o
2922
+ // mesmo código — e, até escreverem, o mesmo binário continuaria subindo por cliente. Quem
2923
+ // quiser outro lugar (Redis) troca o `store`; quem não fizer nada já sai sem ressubir.
2924
+ mediaIdCache: {
2925
+ store: new FlowMediaIdRepository(db),
2926
+ senderKey: config.phoneNumberId
2927
+ },
2763
2928
  onError: hooks?.onFlowMediaError
2764
2929
  }));
2765
2930
  }
@@ -2977,6 +3142,7 @@ __name(redeemSseTicket, "redeemSseTicket");
2977
3142
  FlowGraphCache,
2978
3143
  FlowGraphRepository,
2979
3144
  FlowInterpreter,
3145
+ FlowMediaIdRepository,
2980
3146
  FlowMediaRepository,
2981
3147
  GetFlowGraphUseCase,
2982
3148
  GetLiveFlowPositionsUseCase,