@adatechnology/meta-whatsapp-module 0.2.0-rc.0 → 0.2.0-rc.10

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
@@ -85,9 +85,14 @@ var messages = metaWhatsAppSchema.table("messages", {
85
85
  length: 12
86
86
  }).notNull(),
87
87
  agentUserId: uuid("agent_user_id"),
88
- type: varchar("type", {
89
- length: 16
90
- }).notNull().default("text"),
88
+ // 32, não 16: os tipos da própria Meta são curtos ('text', 'interactive'), mas o host rotula
89
+ type: (
90
+ // a saída com o subtipo que enviou ('interactive_buttons' já tem 19). Apertar aqui só
91
+ // transfere para o consumidor a escolha entre truncar o rótulo e estourar o insert.
92
+ varchar("type", {
93
+ length: 32
94
+ }).notNull().default("text")
95
+ ),
91
96
  content: text("content"),
92
97
  // T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
93
98
  // uploadId dentro deste jsonb.
@@ -101,6 +106,12 @@ var messages = metaWhatsAppSchema.table("messages", {
101
106
  readAt: timestamp("read_at", {
102
107
  withTimezone: true
103
108
  }),
109
+ // Moderação de conteúdo. `null` significa NÃO AVALIADO (moderação desligada, ou mensagem
110
+ // anterior ao recurso) — diferente de `false`, que é avaliado e limpo. Colunas em vez de chave
111
+ // dentro de `payload` porque "listar o que foi sinalizado" é consulta de operação, e índice
112
+ // parcial sobre boolean resolve isso sem cavar jsonb.
113
+ moderationFlagged: boolean("moderation_flagged"),
114
+ moderationTerms: jsonb("moderation_terms").$type(),
104
115
  createdAt: timestamp("created_at", {
105
116
  withTimezone: true
106
117
  }).notNull().defaultNow()
@@ -112,7 +123,10 @@ var messages = metaWhatsAppSchema.table("messages", {
112
123
  // paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
113
124
  // mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
114
125
  // não podem competir entre si por unicidade.
115
- uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`)
126
+ uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`),
127
+ // Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
128
+ // tamanho do transcript.
129
+ index("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(sql`${table.moderationFlagged}`)
116
130
  ]);
117
131
  var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
118
132
  id: uuid("id").primaryKey().defaultRandom(),
@@ -165,6 +179,31 @@ var settings = metaWhatsAppSchema.table("settings", {
165
179
 
166
180
  // src/repositories/SessionRepository.ts
167
181
  var DEFAULT_LIMIT = 20;
182
+ var conversationSummaryProjection = {
183
+ lastContent: sql2`(
184
+ select m.content from ${messages} m
185
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
186
+ order by m.created_at desc limit 1
187
+ )`,
188
+ lastDirection: sql2`(
189
+ select m.direction from ${messages} m
190
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
191
+ order by m.created_at desc limit 1
192
+ )`,
193
+ // Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
194
+ // tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
195
+ unread: sql2`(
196
+ select count(*)::int from ${messages} m
197
+ where m.company_id = ${sessions}.company_id
198
+ and m.session_id = ${sessions}.id
199
+ and m.direction = 'inbound'
200
+ and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
201
+ )`
202
+ };
203
+ function sessionContextPatch(patch) {
204
+ return sql2`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
205
+ }
206
+ __name(sessionContextPatch, "sessionContextPatch");
168
207
  var SessionRepository = class {
169
208
  static {
170
209
  __name(this, "SessionRepository");
@@ -195,6 +234,9 @@ var SessionRepository = class {
195
234
  }).returning();
196
235
  return created;
197
236
  }
237
+ // O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
238
+ // não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
239
+ // objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
198
240
  async setState(companyId, whatsappNumber, state, context) {
199
241
  await this.db.update(sessions).set({
200
242
  currentState: state,
@@ -205,6 +247,25 @@ var SessionRepository = class {
205
247
  updatedAt: sql2`now()`
206
248
  }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
207
249
  }
250
+ // Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
251
+ // mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
252
+ // acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
253
+ // vence a existente; as demais permanecem.
254
+ async patchContext(companyId, whatsappNumber, patch) {
255
+ await this.db.update(sessions).set({
256
+ context: sessionContextPatch(patch),
257
+ lastActivity: sql2`now()`,
258
+ updatedAt: sql2`now()`
259
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
260
+ }
261
+ // Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
262
+ // distinto de existir com context vazio, que devolve o objeto vazio.
263
+ async readContext(companyId, whatsappNumber) {
264
+ const [row] = await this.db.select({
265
+ context: sessions.context
266
+ }).from(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber))).limit(1);
267
+ return row?.context;
268
+ }
208
269
  // Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
209
270
  // Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
210
271
  async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
@@ -280,17 +341,28 @@ var SessionRepository = class {
280
341
  currentState: sessions.currentState,
281
342
  lastActivity: sessions.lastActivity,
282
343
  lastInboundAt: sessions.lastInboundAt,
283
- humanRequestedAt: sessions.humanRequestedAt
344
+ humanRequestedAt: sessions.humanRequestedAt,
345
+ // Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
346
+ // lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
347
+ // dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
348
+ // obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
349
+ ...conversationSummaryProjection
284
350
  }).from(sessions).where(and(...conditions)).orderBy(desc(sessions.lastActivity)).limit(limit).offset(offset);
285
351
  return rows.map((row) => ({
286
352
  id: row.id,
287
353
  whatsappNumber: row.whatsappNumber,
354
+ ...row.lastContent !== null ? {
355
+ lastContent: row.lastContent
356
+ } : {},
357
+ ...row.lastDirection !== null ? {
358
+ lastDirection: row.lastDirection
359
+ } : {},
288
360
  lastAt: row.lastActivity.toISOString(),
289
361
  lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
290
362
  mode: row.mode,
291
363
  assignedUserId: row.assignedUserId,
292
364
  waitingHuman: row.humanRequestedAt !== null,
293
- unread: 0,
365
+ unread: Number(row.unread),
294
366
  currentState: row.currentState
295
367
  }));
296
368
  }
@@ -334,7 +406,9 @@ var MessageRepository = class {
334
406
  content: params.content ?? null,
335
407
  payload: params.payload ?? null,
336
408
  waMessageId: params.waMessageId ?? null,
337
- status: params.status ?? null
409
+ status: params.status ?? null,
410
+ moderationFlagged: params.moderationFlagged ?? null,
411
+ moderationTerms: params.moderationTerms ?? null
338
412
  };
339
413
  const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
340
414
  return created;
@@ -550,16 +624,19 @@ var LogMessageUseCase = class {
550
624
  sessionRepository;
551
625
  messageRepository;
552
626
  realtime;
553
- constructor(sessionRepository, messageRepository, realtime) {
627
+ moderator;
628
+ constructor(sessionRepository, messageRepository, realtime, moderator) {
554
629
  this.sessionRepository = sessionRepository;
555
630
  this.messageRepository = messageRepository;
556
631
  this.realtime = realtime;
632
+ this.moderator = moderator;
557
633
  }
558
634
  async execute(params) {
559
635
  const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
560
636
  const saved = await this.messageRepository.insertMessage({
561
637
  ...params,
562
- sessionId: session.id
638
+ sessionId: session.id,
639
+ ...this.moderationOf(params)
563
640
  });
564
641
  if (!saved) return void 0;
565
642
  if (params.direction === "inbound") {
@@ -572,6 +649,20 @@ var LogMessageUseCase = class {
572
649
  this.realtime?.emit("global", "data-changed", {});
573
650
  return saved;
574
651
  }
652
+ // Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
653
+ // abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
654
+ moderationOf(params) {
655
+ if (!this.moderator || params.direction !== "inbound") return {};
656
+ const text2 = params.content?.trim();
657
+ if (!text2) return {};
658
+ const verdict = this.moderator.inspect(text2);
659
+ return {
660
+ moderationFlagged: verdict.isOffensive,
661
+ moderationTerms: verdict.isOffensive ? [
662
+ ...verdict.matchedTerms
663
+ ] : null
664
+ };
665
+ }
575
666
  };
576
667
 
577
668
  // src/use-cases/SendMessage.use-case.ts
@@ -1113,6 +1204,8 @@ function toSessionContract(row) {
1113
1204
  companyId: row.companyId,
1114
1205
  whatsappNumber: row.whatsappNumber,
1115
1206
  currentState: row.currentState,
1207
+ flowKey: row.flowKey,
1208
+ currentNodeId: row.currentNodeId,
1116
1209
  context: row.context,
1117
1210
  mode: row.mode,
1118
1211
  assignedUserId: row.assignedUserId,
@@ -1323,7 +1416,7 @@ function createMetaWhatsAppModule(params) {
1323
1416
  const messageRepository = new MessageRepository(db);
1324
1417
  const settingsRepository = new SettingsRepository(db);
1325
1418
  const flowGraphRepository = new FlowGraphRepository(db);
1326
- const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime);
1419
+ const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
1327
1420
  const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
1328
1421
  const receiveWebhook = new ReceiveWebhookUseCase({
1329
1422
  appSecret: config.appSecret,
@@ -1379,11 +1472,15 @@ __name(createMetaWhatsAppModule, "createMetaWhatsAppModule");
1379
1472
 
1380
1473
  // src/runMigrations.ts
1381
1474
  import { join } from "path";
1382
- import { migrate } from "drizzle-orm/bun-sql/migrator";
1383
- async function runMetaWhatsAppMigrations(db) {
1384
- await migrate(db, {
1385
- migrationsFolder: join(__dirname, "migrations"),
1386
- migrationsTable: "meta_whatsapp_migrations"
1475
+ var META_WHATSAPP_MIGRATIONS_TABLE = "meta_whatsapp_migrations";
1476
+ function metaWhatsAppMigrationsFolder() {
1477
+ return join(__dirname, "migrations");
1478
+ }
1479
+ __name(metaWhatsAppMigrationsFolder, "metaWhatsAppMigrationsFolder");
1480
+ async function runMetaWhatsAppMigrations(params) {
1481
+ await params.migrate(params.db, {
1482
+ migrationsFolder: metaWhatsAppMigrationsFolder(),
1483
+ migrationsTable: META_WHATSAPP_MIGRATIONS_TABLE
1387
1484
  });
1388
1485
  }
1389
1486
  __name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
@@ -1477,6 +1574,7 @@ export {
1477
1574
  ListFlowGraphsUseCase,
1478
1575
  ListMessagesUseCase,
1479
1576
  LogMessageUseCase,
1577
+ META_WHATSAPP_MIGRATIONS_TABLE,
1480
1578
  MessageRepository,
1481
1579
  OptimisticLockError,
1482
1580
  ReceiveWebhookUseCase,
@@ -1495,6 +1593,7 @@ export {
1495
1593
  flowGraphs,
1496
1594
  issueSseTicket,
1497
1595
  messages,
1596
+ metaWhatsAppMigrationsFolder,
1498
1597
  metaWhatsAppSchema,
1499
1598
  redeemSseTicket,
1500
1599
  runMetaWhatsAppMigrations,