@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.js CHANGED
@@ -251,6 +251,14 @@ var flowMedia = metaWhatsAppSchema.table("flow_media", {
251
251
  // Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
252
252
  // perderia a ordem e a legenda já ajustadas.
253
253
  active: boolean("active").notNull().default(true),
254
+ // Id do arquivo já subido para a Meta, por número remetente. Sem isto, o MESMO binário subia de
255
+ // novo para cada cliente que passava pelo nó: a Meta aceita reusar o id por 30 dias.
256
+ //
257
+ // Mapa por `phone_number_id`, e não coluna única: o id é escopado ao número que envia, e uma
258
+ // instalação com dois números mandaria o id de um pelo outro. A validade não é gravada de
259
+ // propósito — confiar em "30 dias" calculados erra nos casos de borda, e quem decide é a
260
+ // recusa da Meta, que devolve ao caminho de subir o binário.
261
+ metaMediaIds: jsonb("meta_media_ids").$type().notNull().default({}),
254
262
  createdAt: timestamp("created_at", {
255
263
  withTimezone: true
256
264
  }).notNull().defaultNow(),
@@ -1692,6 +1700,43 @@ function mediaTypeFor2(mimeType) {
1692
1700
  return "document";
1693
1701
  }
1694
1702
  __name(mediaTypeFor2, "mediaTypeFor");
1703
+ async function sendAttachment(params) {
1704
+ const { attachment, channel, to, cache } = params;
1705
+ const common = {
1706
+ to,
1707
+ mimeType: attachment.mimeType,
1708
+ filename: attachment.filename,
1709
+ caption: attachment.caption ?? void 0
1710
+ };
1711
+ const cacheKey = cache ? {
1712
+ flowMediaId: attachment.id,
1713
+ senderKey: cache.senderKey
1714
+ } : void 0;
1715
+ if (cache && cacheKey) {
1716
+ const knownMediaId = await cache.store.get(cacheKey);
1717
+ if (knownMediaId) {
1718
+ try {
1719
+ return await channel.sendMedia({
1720
+ ...common,
1721
+ mediaId: knownMediaId
1722
+ });
1723
+ } catch {
1724
+ await cache.store.clear(cacheKey);
1725
+ }
1726
+ }
1727
+ }
1728
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1729
+ const result = await channel.sendMedia({
1730
+ ...common,
1731
+ buffer
1732
+ });
1733
+ if (cache && cacheKey && result.mediaId) await cache.store.set({
1734
+ ...cacheKey,
1735
+ mediaId: result.mediaId
1736
+ });
1737
+ return result;
1738
+ }
1739
+ __name(sendAttachment, "sendAttachment");
1695
1740
  function createSendMediaAction(params) {
1696
1741
  return async ({ node, session, channel }) => {
1697
1742
  if (!session.flowKey) return;
@@ -1703,13 +1748,14 @@ function createSendMediaAction(params) {
1703
1748
  const attachments = await params.flowMediaRepository.listActive(location);
1704
1749
  for (const attachment of attachments) {
1705
1750
  try {
1706
- const buffer = await params.objectStorage.getObject(attachment.uploadId);
1707
- const { externalMessageId } = await channel.sendMedia({
1751
+ const { externalMessageId } = await sendAttachment({
1752
+ attachment,
1753
+ channel,
1708
1754
  to: session.whatsappNumber,
1709
- buffer,
1710
- mimeType: attachment.mimeType,
1711
- filename: attachment.filename,
1712
- caption: attachment.caption ?? void 0
1755
+ objectStorage: params.objectStorage,
1756
+ ...params.mediaIdCache ? {
1757
+ cache: params.mediaIdCache
1758
+ } : {}
1713
1759
  });
1714
1760
  await params.logMessage.execute({
1715
1761
  companyId: session.companyId,
@@ -1898,6 +1944,42 @@ var FlowMediaRepository = class {
1898
1944
  }
1899
1945
  };
1900
1946
 
1947
+ // src/repositories/FlowMediaIdRepository.ts
1948
+ import { eq as eq7, sql as sql5 } from "drizzle-orm";
1949
+ var FlowMediaIdRepository = class {
1950
+ static {
1951
+ __name(this, "FlowMediaIdRepository");
1952
+ }
1953
+ db;
1954
+ constructor(db) {
1955
+ this.db = db;
1956
+ }
1957
+ async get(params) {
1958
+ const rows = await this.db.select({
1959
+ ids: flowMedia.metaMediaIds
1960
+ }).from(flowMedia).where(eq7(flowMedia.id, params.flowMediaId)).limit(1);
1961
+ return rows[0]?.ids?.[params.senderKey];
1962
+ }
1963
+ /**
1964
+ * Grava só a chave deste número.
1965
+ *
1966
+ * `jsonb_set` no banco, e não ler-alterar-escrever na aplicação: dois clientes passando pelo nó
1967
+ * ao mesmo tempo com números diferentes leriam o mesmo mapa e o último gravaria por cima,
1968
+ * apagando o id do outro. A escrita atômica não tem essa janela.
1969
+ */
1970
+ async set(params) {
1971
+ await this.db.update(flowMedia).set({
1972
+ metaMediaIds: sql5`jsonb_set(${flowMedia.metaMediaIds}, ARRAY[${params.senderKey}::text], to_jsonb(${params.mediaId}::text), true)`
1973
+ }).where(eq7(flowMedia.id, params.flowMediaId));
1974
+ }
1975
+ /** Remove só a chave deste número — o id do outro número continua válido. */
1976
+ async clear(params) {
1977
+ await this.db.update(flowMedia).set({
1978
+ metaMediaIds: sql5`${flowMedia.metaMediaIds} - ${params.senderKey}::text`
1979
+ }).where(eq7(flowMedia.id, params.flowMediaId));
1980
+ }
1981
+ };
1982
+
1901
1983
  // src/channel/WhatsAppChannelAdapter.ts
1902
1984
  import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1903
1985
  import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
@@ -1933,7 +2015,8 @@ var WhatsAppChannelAdapter = class {
1933
2015
  async sendMedia(params) {
1934
2016
  const result = await this.translateErrors(() => this.messages.sendMedia(params));
1935
2017
  return {
1936
- externalMessageId: result.waMessageId
2018
+ externalMessageId: result.waMessageId,
2019
+ mediaId: result.mediaId
1937
2020
  };
1938
2021
  }
1939
2022
  async sendTemplate(params) {
@@ -2002,7 +2085,7 @@ var WhatsAppChannelAdapter = class {
2002
2085
  };
2003
2086
 
2004
2087
  // src/channel/ReceiveWebhook.use-case.ts
2005
- import { whatsAppWebhookPayloadSchema } from "@adatechnology/meta-whatsapp-contracts";
2088
+ import { whatsAppWebhookPayloadSchema, whatsAppTemplateStatusUpdateSchema, whatsAppPhoneNumberQualityUpdateSchema, WHATSAPP_WEBHOOK_FIELDS } from "@adatechnology/meta-whatsapp-contracts";
2006
2089
 
2007
2090
  // src/channel/webhookSecurity.ts
2008
2091
  import { WEBHOOK_CLAIM_TTL_SECONDS, WEBHOOK_NONCE_TTL_SECONDS, buildWebhookDeliveryKey, isValidWebhookChallenge, isValidWebhookSignature } from "@adatechnology/meta-graph-core";
@@ -2034,7 +2117,7 @@ async function confirmWebhookDelivery(params) {
2034
2117
  __name(confirmWebhookDelivery, "confirmWebhookDelivery");
2035
2118
 
2036
2119
  // src/channel/IngestInboundMedia.use-case.ts
2037
- import { eq as eq7, and as and6 } from "drizzle-orm";
2120
+ import { eq as eq8, and as and6 } from "drizzle-orm";
2038
2121
 
2039
2122
  // src/use-cases/TranscribeAudio.use-case.ts
2040
2123
  import { AudioNotIngestedError, MessageNotAudioError, TranscriptionDisabledError } from "@adatechnology/meta-whatsapp-contracts";
@@ -2206,7 +2289,7 @@ var IngestInboundMediaUseCase = class {
2206
2289
  this.transcription = transcription;
2207
2290
  }
2208
2291
  async execute(params) {
2209
- const [message] = await this.db.select().from(messages).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId))).limit(1);
2292
+ const [message] = await this.db.select().from(messages).where(and6(eq8(messages.companyId, params.companyId), eq8(messages.id, params.messageId))).limit(1);
2210
2293
  if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
2211
2294
  const payload = message.payload ?? {};
2212
2295
  if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
@@ -2237,7 +2320,7 @@ var IngestInboundMediaUseCase = class {
2237
2320
  };
2238
2321
  await this.db.update(messages).set({
2239
2322
  payload: updatedPayload
2240
- }).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId)));
2323
+ }).where(and6(eq8(messages.companyId, params.companyId), eq8(messages.id, params.messageId)));
2241
2324
  await this.documentRepository?.link({
2242
2325
  companyId: params.companyId,
2243
2326
  sessionId: message.sessionId,
@@ -2436,6 +2519,13 @@ var ProcessInboundDispatchUseCase = class {
2436
2519
  };
2437
2520
 
2438
2521
  // src/channel/ReceiveWebhook.use-case.ts
2522
+ var EMPTY_RESULT = {
2523
+ messagesProcessed: 0,
2524
+ statusesProcessed: 0,
2525
+ ignoredForeignNumber: 0,
2526
+ accountEventsProcessed: 0,
2527
+ unhandledEvents: 0
2528
+ };
2439
2529
  function extractContent(message) {
2440
2530
  if (message.text?.body) return message.text.body;
2441
2531
  const interactive = message.interactive;
@@ -2461,6 +2551,14 @@ function extractPayload(message) {
2461
2551
  return Object.keys(payload).length > 0 ? payload : null;
2462
2552
  }
2463
2553
  __name(extractPayload, "extractPayload");
2554
+ var ACCOUNT_EVENT_FIELDS = [
2555
+ WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE,
2556
+ WHATSAPP_WEBHOOK_FIELDS.PHONE_NUMBER_QUALITY_UPDATE
2557
+ ];
2558
+ function isAccountEventField(field) {
2559
+ return field !== void 0 && ACCOUNT_EVENT_FIELDS.includes(field);
2560
+ }
2561
+ __name(isAccountEventField, "isAccountEventField");
2464
2562
  var ReceiveWebhookUseCase = class {
2465
2563
  static {
2466
2564
  __name(this, "ReceiveWebhookUseCase");
@@ -2491,17 +2589,36 @@ var ReceiveWebhookUseCase = class {
2491
2589
  });
2492
2590
  if (!claimed) return {
2493
2591
  duplicate: true,
2494
- messagesProcessed: 0,
2495
- statusesProcessed: 0,
2496
- ignoredForeignNumber: 0
2592
+ ...EMPTY_RESULT
2497
2593
  };
2498
2594
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
2499
- const payload = whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2595
+ const parsed = whatsAppWebhookPayloadSchema.safeParse(JSON.parse(rawText));
2596
+ if (!parsed.success) {
2597
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2598
+ field: void 0,
2599
+ reason: "invalid-shape",
2600
+ value: rawText
2601
+ });
2602
+ return {
2603
+ duplicate: false,
2604
+ ...EMPTY_RESULT,
2605
+ unhandledEvents: 1
2606
+ };
2607
+ }
2608
+ const payload = parsed.data;
2500
2609
  let messagesProcessed = 0;
2501
2610
  let statusesProcessed = 0;
2502
2611
  let ignoredForeignNumber = 0;
2612
+ let accountEventsProcessed = 0;
2613
+ let unhandledEvents = 0;
2503
2614
  for (const entry of payload.entry) {
2504
2615
  for (const change of entry.changes) {
2616
+ if (isAccountEventField(change.field)) {
2617
+ const handled = await this.handleAccountEvent(change);
2618
+ if (handled) accountEventsProcessed++;
2619
+ else unhandledEvents++;
2620
+ continue;
2621
+ }
2505
2622
  const targetNumber = change.value.metadata?.phone_number_id;
2506
2623
  if (targetNumber && targetNumber !== this.params.phoneNumberId) {
2507
2624
  ignoredForeignNumber++;
@@ -2515,6 +2632,15 @@ var ReceiveWebhookUseCase = class {
2515
2632
  await this.handleStatus(input.companyId, status);
2516
2633
  statusesProcessed++;
2517
2634
  }
2635
+ const carriedConversationData = (change.value.messages?.length ?? 0) > 0 || (change.value.message_echoes?.length ?? 0) > 0 || (change.value.statuses?.length ?? 0) > 0;
2636
+ if (!carriedConversationData) {
2637
+ unhandledEvents++;
2638
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2639
+ field: change.field,
2640
+ reason: "unknown-field",
2641
+ value: change.value
2642
+ });
2643
+ }
2518
2644
  }
2519
2645
  }
2520
2646
  await confirmWebhookDelivery({
@@ -2525,9 +2651,39 @@ var ReceiveWebhookUseCase = class {
2525
2651
  duplicate: false,
2526
2652
  messagesProcessed,
2527
2653
  statusesProcessed,
2528
- ignoredForeignNumber
2654
+ ignoredForeignNumber,
2655
+ accountEventsProcessed,
2656
+ unhandledEvents
2529
2657
  };
2530
2658
  }
2659
+ // Devolve `false` quando o corpo não bate com o schema do field — o chamador conta como não
2660
+ // tratado. O host é avisado nos dois casos, mas com `reason` diferente.
2661
+ async handleAccountEvent(change) {
2662
+ if (change.field === WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE) {
2663
+ const update = whatsAppTemplateStatusUpdateSchema.safeParse(change.value);
2664
+ if (!update.success) {
2665
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2666
+ field: change.field,
2667
+ reason: "invalid-shape",
2668
+ value: change.value
2669
+ });
2670
+ return false;
2671
+ }
2672
+ await this.params.hooks?.onTemplateStatusUpdate?.(update.data);
2673
+ return true;
2674
+ }
2675
+ const quality = whatsAppPhoneNumberQualityUpdateSchema.safeParse(change.value);
2676
+ if (!quality.success) {
2677
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2678
+ field: change.field,
2679
+ reason: "invalid-shape",
2680
+ value: change.value
2681
+ });
2682
+ return false;
2683
+ }
2684
+ await this.params.hooks?.onPhoneNumberQualityUpdate?.(quality.data);
2685
+ return true;
2686
+ }
2531
2687
  async handleMessage(companyId, message) {
2532
2688
  const saved = await this.params.logMessage.execute({
2533
2689
  companyId,
@@ -2672,6 +2828,14 @@ function createMetaWhatsAppModule(params) {
2672
2828
  objectStorage: providers.objectStorage,
2673
2829
  logMessage,
2674
2830
  startState,
2831
+ // Cache ligado por padrão, com o store do próprio módulo: a tabela é daqui e a coluna é
2832
+ // daqui, então exigir que cada host escrevesse o repositório faria todos reescreverem o
2833
+ // mesmo código — e, até escreverem, o mesmo binário continuaria subindo por cliente. Quem
2834
+ // quiser outro lugar (Redis) troca o `store`; quem não fizer nada já sai sem ressubir.
2835
+ mediaIdCache: {
2836
+ store: new FlowMediaIdRepository(db),
2837
+ senderKey: config.phoneNumberId
2838
+ },
2675
2839
  onError: hooks?.onFlowMediaError
2676
2840
  }));
2677
2841
  }
@@ -2888,6 +3052,7 @@ export {
2888
3052
  FlowGraphCache,
2889
3053
  FlowGraphRepository,
2890
3054
  FlowInterpreter,
3055
+ FlowMediaIdRepository,
2891
3056
  FlowMediaRepository,
2892
3057
  GetFlowGraphUseCase,
2893
3058
  GetLiveFlowPositionsUseCase,