@adatechnology/meta-whatsapp-module 0.1.0
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 +1560 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1306 -0
- package/dist/index.d.ts +1306 -0
- package/dist/index.js +1506 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/20260725195853_freezing_switch/migration.sql +41 -0
- package/dist/migrations/20260725195853_freezing_switch/snapshot.json +555 -0
- package/dist/migrations/20260725210958_military_leper_queen/migration.sql +15 -0
- package/dist/migrations/20260725210958_military_leper_queen/snapshot.json +742 -0
- package/dist/migrations/20260725214800_illegal_black_tarantula/migration.sql +5 -0
- package/dist/migrations/20260725214800_illegal_black_tarantula/snapshot.json +810 -0
- package/dist/migrations/20260725234507_normal_viper/migration.sql +10 -0
- package/dist/migrations/20260725234507_normal_viper/snapshot.json +930 -0
- package/package.json +29 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// ../../../node_modules/.pnpm/tsup@8.5.1_@swc+core@1.15.40_@swc+helpers@0.5.23__jiti@2.7.0_postcss@8.5.15_tsx@4.23.1_typescript@5.9.3_yaml@2.9.0/node_modules/tsup/assets/esm_shims.js
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
var getFilename = /* @__PURE__ */ __name(() => fileURLToPath(import.meta.url), "getFilename");
|
|
8
|
+
var getDirname = /* @__PURE__ */ __name(() => path.dirname(getFilename()), "getDirname");
|
|
9
|
+
var __dirname = /* @__PURE__ */ getDirname();
|
|
10
|
+
|
|
11
|
+
// src/createMetaWhatsAppModule.ts
|
|
12
|
+
import { WhatsAppMessageProvider } from "@adatechnology/meta-whatsapp-provider";
|
|
13
|
+
|
|
14
|
+
// src/repositories/SessionRepository.ts
|
|
15
|
+
import { and, desc, eq, sql as sql2 } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
// src/schema/schema.ts
|
|
18
|
+
import { sql } from "drizzle-orm";
|
|
19
|
+
import { pgSchema, uuid, varchar, jsonb, timestamp, text, integer, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
|
|
20
|
+
var metaWhatsAppSchema = pgSchema("meta_whatsapp");
|
|
21
|
+
var sessions = metaWhatsAppSchema.table("sessions", {
|
|
22
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
23
|
+
// Multiempresa: toda entidade carrega companyId, vindo do contexto autenticado do host —
|
|
24
|
+
// nunca de um campo livre do cliente (ver database.md "Consistência e multiempresa").
|
|
25
|
+
companyId: uuid("company_id").notNull(),
|
|
26
|
+
whatsappNumber: varchar("whatsapp_number", {
|
|
27
|
+
length: 20
|
|
28
|
+
}).notNull(),
|
|
29
|
+
// varchar (não enum): a máquina de estados de cada produto evolui de forma independente do módulo.
|
|
30
|
+
currentState: varchar("current_state", {
|
|
31
|
+
length: 64
|
|
32
|
+
}).notNull().default("start"),
|
|
33
|
+
// Posição no grafo, quando o motor de fluxo está em uso — colunas separadas em vez de
|
|
34
|
+
// convencionar "flowKey:nodeId" dentro de currentState: além de ser um contrato implícito que
|
|
35
|
+
// nada garantia, a concatenação estourava o varchar(64) (flow key já é varchar(64) sozinha).
|
|
36
|
+
flowKey: varchar("flow_key", {
|
|
37
|
+
length: 64
|
|
38
|
+
}),
|
|
39
|
+
currentNodeId: varchar("current_node_id", {
|
|
40
|
+
length: 64
|
|
41
|
+
}),
|
|
42
|
+
context: jsonb("context").$type().notNull().default({}),
|
|
43
|
+
mode: varchar("mode", {
|
|
44
|
+
length: 12
|
|
45
|
+
}).notNull().default("bot"),
|
|
46
|
+
assignedUserId: uuid("assigned_user_id"),
|
|
47
|
+
humanRequestedAt: timestamp("human_requested_at", {
|
|
48
|
+
withTimezone: true
|
|
49
|
+
}),
|
|
50
|
+
lastInboundAt: timestamp("last_inbound_at", {
|
|
51
|
+
withTimezone: true
|
|
52
|
+
}),
|
|
53
|
+
lastAgentReadAt: timestamp("last_agent_read_at", {
|
|
54
|
+
withTimezone: true
|
|
55
|
+
}),
|
|
56
|
+
lastActivity: timestamp("last_activity", {
|
|
57
|
+
withTimezone: true
|
|
58
|
+
}).notNull().defaultNow(),
|
|
59
|
+
createdAt: timestamp("created_at", {
|
|
60
|
+
withTimezone: true
|
|
61
|
+
}).notNull().defaultNow(),
|
|
62
|
+
updatedAt: timestamp("updated_at", {
|
|
63
|
+
withTimezone: true
|
|
64
|
+
}).notNull().defaultNow()
|
|
65
|
+
}, (table) => [
|
|
66
|
+
// Único por empresa (não globalmente) — dois hosts diferentes podem atender o mesmo número.
|
|
67
|
+
uniqueIndex("idx_sessions_company_number").on(table.companyId, table.whatsappNumber),
|
|
68
|
+
index("idx_sessions_company_mode").on(table.companyId, table.mode),
|
|
69
|
+
// Alimenta getLiveFlowPositions (agregação por nó do grafo).
|
|
70
|
+
index("idx_sessions_company_flow_node").on(table.companyId, table.flowKey, table.currentNodeId)
|
|
71
|
+
]);
|
|
72
|
+
var messages = metaWhatsAppSchema.table("messages", {
|
|
73
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
74
|
+
companyId: uuid("company_id").notNull(),
|
|
75
|
+
sessionId: uuid("session_id").notNull().references(() => sessions.id, {
|
|
76
|
+
onDelete: "cascade"
|
|
77
|
+
}),
|
|
78
|
+
whatsappNumber: varchar("whatsapp_number", {
|
|
79
|
+
length: 20
|
|
80
|
+
}).notNull(),
|
|
81
|
+
direction: varchar("direction", {
|
|
82
|
+
length: 12
|
|
83
|
+
}).notNull(),
|
|
84
|
+
sender: varchar("sender", {
|
|
85
|
+
length: 12
|
|
86
|
+
}).notNull(),
|
|
87
|
+
agentUserId: uuid("agent_user_id"),
|
|
88
|
+
type: varchar("type", {
|
|
89
|
+
length: 16
|
|
90
|
+
}).notNull().default("text"),
|
|
91
|
+
content: text("content"),
|
|
92
|
+
// T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
|
|
93
|
+
// uploadId dentro deste jsonb.
|
|
94
|
+
payload: jsonb("payload").$type(),
|
|
95
|
+
waMessageId: varchar("wa_message_id", {
|
|
96
|
+
length: 128
|
|
97
|
+
}),
|
|
98
|
+
status: varchar("status", {
|
|
99
|
+
length: 16
|
|
100
|
+
}),
|
|
101
|
+
readAt: timestamp("read_at", {
|
|
102
|
+
withTimezone: true
|
|
103
|
+
}),
|
|
104
|
+
createdAt: timestamp("created_at", {
|
|
105
|
+
withTimezone: true
|
|
106
|
+
}).notNull().defaultNow()
|
|
107
|
+
}, (table) => [
|
|
108
|
+
index("idx_messages_session_created").on(table.sessionId, table.createdAt),
|
|
109
|
+
index("idx_messages_company_number_created").on(table.companyId, table.whatsappNumber, table.createdAt),
|
|
110
|
+
// Parcial e ÚNICO: é o que de fato garante idempotência de entrega. Um SELECT-antes-de-INSERT
|
|
111
|
+
// não basta — a Meta reenvia a mesma entrega e várias instâncias do host processam em
|
|
112
|
+
// paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
|
|
113
|
+
// mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
|
|
114
|
+
// 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`)
|
|
116
|
+
]);
|
|
117
|
+
var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
118
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
119
|
+
companyId: uuid("company_id").notNull(),
|
|
120
|
+
key: varchar("key", {
|
|
121
|
+
length: 64
|
|
122
|
+
}).notNull(),
|
|
123
|
+
label: varchar("label", {
|
|
124
|
+
length: 120
|
|
125
|
+
}).notNull(),
|
|
126
|
+
startNodeId: varchar("start_node_id", {
|
|
127
|
+
length: 64
|
|
128
|
+
}).notNull(),
|
|
129
|
+
nodes: jsonb("nodes").$type().notNull().default({}),
|
|
130
|
+
// Otimista: o editor manda a versão que carregou; salvar com versão desatualizada é
|
|
131
|
+
// rejeitado pelo host (evita duas abas sobrescreverem uma a outra silenciosamente).
|
|
132
|
+
version: integer("version").notNull().default(1),
|
|
133
|
+
showInMenu: boolean("show_in_menu").notNull().default(false),
|
|
134
|
+
menuOptionLabel: varchar("menu_option_label", {
|
|
135
|
+
length: 64
|
|
136
|
+
}),
|
|
137
|
+
createdAt: timestamp("created_at", {
|
|
138
|
+
withTimezone: true
|
|
139
|
+
}).notNull().defaultNow(),
|
|
140
|
+
updatedAt: timestamp("updated_at", {
|
|
141
|
+
withTimezone: true
|
|
142
|
+
}).notNull().defaultNow()
|
|
143
|
+
}, (table) => [
|
|
144
|
+
uniqueIndex("idx_flow_graphs_company_key").on(table.companyId, table.key)
|
|
145
|
+
]);
|
|
146
|
+
var settings = metaWhatsAppSchema.table("settings", {
|
|
147
|
+
companyId: uuid("company_id").primaryKey(),
|
|
148
|
+
templateName: varchar("template_name", {
|
|
149
|
+
length: 128
|
|
150
|
+
}),
|
|
151
|
+
templateLanguage: varchar("template_language", {
|
|
152
|
+
length: 16
|
|
153
|
+
}).notNull().default("pt_BR"),
|
|
154
|
+
// Mapa posicional {{1}}, {{2}}... → token resolvido no envio (ex.: '{clientName}').
|
|
155
|
+
templateVariables: jsonb("template_variables").$type().notNull().default([]),
|
|
156
|
+
welcomeMessage: text("welcome_message"),
|
|
157
|
+
farewellMessage: text("farewell_message"),
|
|
158
|
+
createdAt: timestamp("created_at", {
|
|
159
|
+
withTimezone: true
|
|
160
|
+
}).notNull().defaultNow(),
|
|
161
|
+
updatedAt: timestamp("updated_at", {
|
|
162
|
+
withTimezone: true
|
|
163
|
+
}).notNull().defaultNow()
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// src/repositories/SessionRepository.ts
|
|
167
|
+
var DEFAULT_LIMIT = 20;
|
|
168
|
+
var SessionRepository = class {
|
|
169
|
+
static {
|
|
170
|
+
__name(this, "SessionRepository");
|
|
171
|
+
}
|
|
172
|
+
db;
|
|
173
|
+
constructor(db) {
|
|
174
|
+
this.db = db;
|
|
175
|
+
}
|
|
176
|
+
async getContext(companyId, whatsappNumber) {
|
|
177
|
+
const [row] = await this.db.select().from(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber))).limit(1);
|
|
178
|
+
return row;
|
|
179
|
+
}
|
|
180
|
+
async getOrCreate(companyId, whatsappNumber, startState) {
|
|
181
|
+
const existing = await this.getContext(companyId, whatsappNumber);
|
|
182
|
+
if (existing) return existing;
|
|
183
|
+
const [created] = await this.db.insert(sessions).values({
|
|
184
|
+
companyId,
|
|
185
|
+
whatsappNumber,
|
|
186
|
+
currentState: startState
|
|
187
|
+
}).onConflictDoUpdate({
|
|
188
|
+
target: [
|
|
189
|
+
sessions.companyId,
|
|
190
|
+
sessions.whatsappNumber
|
|
191
|
+
],
|
|
192
|
+
set: {
|
|
193
|
+
lastActivity: sql2`now()`
|
|
194
|
+
}
|
|
195
|
+
}).returning();
|
|
196
|
+
return created;
|
|
197
|
+
}
|
|
198
|
+
async setState(companyId, whatsappNumber, state, context) {
|
|
199
|
+
await this.db.update(sessions).set({
|
|
200
|
+
currentState: state,
|
|
201
|
+
...context !== void 0 ? {
|
|
202
|
+
context
|
|
203
|
+
} : {},
|
|
204
|
+
lastActivity: sql2`now()`,
|
|
205
|
+
updatedAt: sql2`now()`
|
|
206
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
207
|
+
}
|
|
208
|
+
// Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
|
|
209
|
+
// Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
|
|
210
|
+
async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
|
|
211
|
+
await this.db.update(sessions).set({
|
|
212
|
+
flowKey,
|
|
213
|
+
currentNodeId,
|
|
214
|
+
lastActivity: sql2`now()`,
|
|
215
|
+
updatedAt: sql2`now()`
|
|
216
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
217
|
+
}
|
|
218
|
+
// Marca a chegada de uma mensagem DO CLIENTE. É este carimbo — e não lastActivity, que também
|
|
219
|
+
// se move em ações do atendente/bot — que define a janela de 24h do WhatsApp: fora dela, só
|
|
220
|
+
// template aprovado reabre a conversa (ver WindowExpiredError nos contracts).
|
|
221
|
+
async touchInbound(companyId, whatsappNumber) {
|
|
222
|
+
await this.db.update(sessions).set({
|
|
223
|
+
lastInboundAt: sql2`now()`,
|
|
224
|
+
lastActivity: sql2`now()`,
|
|
225
|
+
updatedAt: sql2`now()`
|
|
226
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
227
|
+
}
|
|
228
|
+
// Horas desde a última mensagem do cliente — undefined se ele nunca escreveu. O canal (Fase 5)
|
|
229
|
+
// usa isto para decidir entre mensagem livre e template antes de chamar a Graph API.
|
|
230
|
+
async hoursSinceLastInbound(companyId, whatsappNumber) {
|
|
231
|
+
const session = await this.getContext(companyId, whatsappNumber);
|
|
232
|
+
if (!session?.lastInboundAt) return void 0;
|
|
233
|
+
return (Date.now() - session.lastInboundAt.getTime()) / (1e3 * 60 * 60);
|
|
234
|
+
}
|
|
235
|
+
async setMode(companyId, whatsappNumber, mode, assignedUserId) {
|
|
236
|
+
await this.db.update(sessions).set({
|
|
237
|
+
mode,
|
|
238
|
+
assignedUserId: assignedUserId ?? null,
|
|
239
|
+
updatedAt: sql2`now()`
|
|
240
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
241
|
+
}
|
|
242
|
+
// T3.3 — takeover: atendente assume a conversa, tirando-a do modo bot.
|
|
243
|
+
async takeover(companyId, whatsappNumber, agentUserId) {
|
|
244
|
+
await this.setMode(companyId, whatsappNumber, "human", agentUserId);
|
|
245
|
+
}
|
|
246
|
+
// T3.3 — release: devolve a conversa ao bot.
|
|
247
|
+
async release(companyId, whatsappNumber) {
|
|
248
|
+
await this.setMode(companyId, whatsappNumber, "bot", null);
|
|
249
|
+
}
|
|
250
|
+
async requestHuman(companyId, whatsappNumber) {
|
|
251
|
+
await this.db.update(sessions).set({
|
|
252
|
+
humanRequestedAt: sql2`now()`,
|
|
253
|
+
updatedAt: sql2`now()`
|
|
254
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
255
|
+
}
|
|
256
|
+
async markRead(companyId, whatsappNumber) {
|
|
257
|
+
await this.db.update(sessions).set({
|
|
258
|
+
lastAgentReadAt: sql2`now()`
|
|
259
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
260
|
+
}
|
|
261
|
+
async markAllRead(companyId, userId) {
|
|
262
|
+
const result = await this.db.update(sessions).set({
|
|
263
|
+
lastAgentReadAt: sql2`now()`
|
|
264
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.assignedUserId, userId)));
|
|
265
|
+
return Number(result.rowCount ?? 0);
|
|
266
|
+
}
|
|
267
|
+
async listByContextFilters(companyId, filters) {
|
|
268
|
+
const limit = filters.limit ?? DEFAULT_LIMIT;
|
|
269
|
+
const offset = ((filters.page ?? 1) - 1) * limit;
|
|
270
|
+
const conditions = [
|
|
271
|
+
eq(sessions.companyId, companyId)
|
|
272
|
+
];
|
|
273
|
+
if (filters.waitingHuman) conditions.push(sql2`${sessions.humanRequestedAt} is not null`);
|
|
274
|
+
if (filters.search) conditions.push(sql2`${sessions.whatsappNumber} ilike ${"%" + filters.search + "%"}`);
|
|
275
|
+
const rows = await this.db.select({
|
|
276
|
+
id: sessions.id,
|
|
277
|
+
whatsappNumber: sessions.whatsappNumber,
|
|
278
|
+
mode: sessions.mode,
|
|
279
|
+
assignedUserId: sessions.assignedUserId,
|
|
280
|
+
currentState: sessions.currentState,
|
|
281
|
+
lastActivity: sessions.lastActivity,
|
|
282
|
+
lastInboundAt: sessions.lastInboundAt,
|
|
283
|
+
humanRequestedAt: sessions.humanRequestedAt
|
|
284
|
+
}).from(sessions).where(and(...conditions)).orderBy(desc(sessions.lastActivity)).limit(limit).offset(offset);
|
|
285
|
+
return rows.map((row) => ({
|
|
286
|
+
id: row.id,
|
|
287
|
+
whatsappNumber: row.whatsappNumber,
|
|
288
|
+
lastAt: row.lastActivity.toISOString(),
|
|
289
|
+
lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
|
|
290
|
+
mode: row.mode,
|
|
291
|
+
assignedUserId: row.assignedUserId,
|
|
292
|
+
waitingHuman: row.humanRequestedAt !== null,
|
|
293
|
+
unread: 0,
|
|
294
|
+
currentState: row.currentState
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
// T3.3 — export: transcript completo de uma conversa, mensagens em ordem cronológica.
|
|
298
|
+
async exportConversation(companyId, whatsappNumber) {
|
|
299
|
+
const session = await this.getContext(companyId, whatsappNumber);
|
|
300
|
+
if (!session) return null;
|
|
301
|
+
const rows = await this.db.select().from(messages).where(and(eq(messages.companyId, companyId), eq(messages.sessionId, session.id))).orderBy(messages.createdAt);
|
|
302
|
+
return {
|
|
303
|
+
session,
|
|
304
|
+
messages: rows
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/repositories/MessageRepository.ts
|
|
310
|
+
import { and as and2, eq as eq2, lt } from "drizzle-orm";
|
|
311
|
+
var DEFAULT_LIMIT2 = 50;
|
|
312
|
+
var MessageRepository = class {
|
|
313
|
+
static {
|
|
314
|
+
__name(this, "MessageRepository");
|
|
315
|
+
}
|
|
316
|
+
db;
|
|
317
|
+
constructor(db) {
|
|
318
|
+
this.db = db;
|
|
319
|
+
}
|
|
320
|
+
// Idempotente por (companyId, waMessageId) — o mesmo webhook chega mais de uma vez (a Meta
|
|
321
|
+
// reenvia em instabilidade) e várias instâncias do host processam em paralelo. A garantia é o
|
|
322
|
+
// índice único parcial no banco + onConflictDoNothing, NÃO um SELECT prévio: duas entregas
|
|
323
|
+
// concorrentes passariam as duas pela checagem e inseririam duplicado.
|
|
324
|
+
// Devolve undefined quando a mensagem já existia (nada foi inserido).
|
|
325
|
+
async insertMessage(params) {
|
|
326
|
+
const values = {
|
|
327
|
+
companyId: params.companyId,
|
|
328
|
+
sessionId: params.sessionId,
|
|
329
|
+
whatsappNumber: params.whatsappNumber,
|
|
330
|
+
direction: params.direction,
|
|
331
|
+
sender: params.sender,
|
|
332
|
+
agentUserId: params.agentUserId ?? null,
|
|
333
|
+
type: params.type,
|
|
334
|
+
content: params.content ?? null,
|
|
335
|
+
payload: params.payload ?? null,
|
|
336
|
+
waMessageId: params.waMessageId ?? null,
|
|
337
|
+
status: params.status ?? null
|
|
338
|
+
};
|
|
339
|
+
const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
|
|
340
|
+
return created;
|
|
341
|
+
}
|
|
342
|
+
async updateMessageStatus(companyId, waMessageId, status) {
|
|
343
|
+
const [updated] = await this.db.update(messages).set({
|
|
344
|
+
status,
|
|
345
|
+
...status === "read" ? {
|
|
346
|
+
readAt: /* @__PURE__ */ new Date()
|
|
347
|
+
} : {}
|
|
348
|
+
}).where(and2(eq2(messages.companyId, companyId), eq2(messages.waMessageId, waMessageId))).returning();
|
|
349
|
+
return updated;
|
|
350
|
+
}
|
|
351
|
+
async listByConversation(params) {
|
|
352
|
+
const conditions = [
|
|
353
|
+
eq2(messages.companyId, params.companyId),
|
|
354
|
+
eq2(messages.sessionId, params.sessionId)
|
|
355
|
+
];
|
|
356
|
+
if (params.before) conditions.push(lt(messages.createdAt, new Date(params.before)));
|
|
357
|
+
return this.db.select().from(messages).where(and2(...conditions)).orderBy(messages.createdAt).limit(params.limit ?? DEFAULT_LIMIT2);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// src/repositories/FlowGraphRepository.ts
|
|
362
|
+
import { and as and3, eq as eq3, isNotNull, sql as sql3 } from "drizzle-orm";
|
|
363
|
+
import { flowGraphNodesSchema } from "@adatechnology/meta-whatsapp-contracts";
|
|
364
|
+
var InvalidFlowGraphError = class extends Error {
|
|
365
|
+
static {
|
|
366
|
+
__name(this, "InvalidFlowGraphError");
|
|
367
|
+
}
|
|
368
|
+
validationMessage;
|
|
369
|
+
constructor(key, validationMessage) {
|
|
370
|
+
super(`Grafo do fluxo "${key}" est\xE1 malformado no banco: ${validationMessage}`), this.validationMessage = validationMessage;
|
|
371
|
+
this.name = "InvalidFlowGraphError";
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
function toContractGraph(row) {
|
|
375
|
+
const parsed = flowGraphNodesSchema.safeParse(row.nodes);
|
|
376
|
+
if (!parsed.success) throw new InvalidFlowGraphError(row.key, parsed.error.message);
|
|
377
|
+
return {
|
|
378
|
+
key: row.key,
|
|
379
|
+
label: row.label,
|
|
380
|
+
startNodeId: row.startNodeId,
|
|
381
|
+
version: row.version,
|
|
382
|
+
nodes: parsed.data
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
__name(toContractGraph, "toContractGraph");
|
|
386
|
+
var OptimisticLockError = class extends Error {
|
|
387
|
+
static {
|
|
388
|
+
__name(this, "OptimisticLockError");
|
|
389
|
+
}
|
|
390
|
+
constructor(key) {
|
|
391
|
+
super(`Fluxo "${key}" foi alterado por outra sess\xE3o \u2014 recarregue antes de salvar.`);
|
|
392
|
+
this.name = "OptimisticLockError";
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
var FlowGraphRepository = class {
|
|
396
|
+
static {
|
|
397
|
+
__name(this, "FlowGraphRepository");
|
|
398
|
+
}
|
|
399
|
+
db;
|
|
400
|
+
constructor(db) {
|
|
401
|
+
this.db = db;
|
|
402
|
+
}
|
|
403
|
+
async get(companyId, key) {
|
|
404
|
+
const [row] = await this.db.select().from(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key))).limit(1);
|
|
405
|
+
return row ? toContractGraph(row) : void 0;
|
|
406
|
+
}
|
|
407
|
+
async list(companyId) {
|
|
408
|
+
const rows = await this.db.select().from(flowGraphs).where(eq3(flowGraphs.companyId, companyId));
|
|
409
|
+
return rows.map((row) => ({
|
|
410
|
+
key: row.key,
|
|
411
|
+
label: row.label,
|
|
412
|
+
nodeCount: Object.keys(row.nodes).length,
|
|
413
|
+
showInMenu: row.showInMenu,
|
|
414
|
+
menuOptionLabel: row.menuOptionLabel ?? void 0,
|
|
415
|
+
updatedAt: row.updatedAt.toISOString()
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
// Valida antes de gravar: aceitar um grafo malformado e só reclamar na leitura empurraria a
|
|
419
|
+
// falha para o meio de uma conversa real, em vez de barrar quem salvou.
|
|
420
|
+
assertValidNodes(key, nodes) {
|
|
421
|
+
const parsed = flowGraphNodesSchema.safeParse(nodes);
|
|
422
|
+
if (!parsed.success) throw new InvalidFlowGraphError(key, parsed.error.message);
|
|
423
|
+
}
|
|
424
|
+
async create(companyId, graph) {
|
|
425
|
+
this.assertValidNodes(graph.key, graph.nodes);
|
|
426
|
+
const [created] = await this.db.insert(flowGraphs).values({
|
|
427
|
+
companyId,
|
|
428
|
+
key: graph.key,
|
|
429
|
+
label: graph.label,
|
|
430
|
+
startNodeId: graph.startNodeId,
|
|
431
|
+
nodes: graph.nodes,
|
|
432
|
+
showInMenu: graph.showInMenu ?? false,
|
|
433
|
+
menuOptionLabel: graph.menuOptionLabel
|
|
434
|
+
}).returning();
|
|
435
|
+
return toContractGraph(created);
|
|
436
|
+
}
|
|
437
|
+
// Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
|
|
438
|
+
// senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
|
|
439
|
+
async save(companyId, graph, expectedVersion) {
|
|
440
|
+
this.assertValidNodes(graph.key, graph.nodes);
|
|
441
|
+
const rows = await this.db.update(flowGraphs).set({
|
|
442
|
+
label: graph.label,
|
|
443
|
+
startNodeId: graph.startNodeId,
|
|
444
|
+
nodes: graph.nodes,
|
|
445
|
+
version: expectedVersion + 1,
|
|
446
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
447
|
+
}).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, graph.key), eq3(flowGraphs.version, expectedVersion))).returning();
|
|
448
|
+
if (rows.length === 0) throw new OptimisticLockError(graph.key);
|
|
449
|
+
return toContractGraph(rows[0]);
|
|
450
|
+
}
|
|
451
|
+
async delete(companyId, key) {
|
|
452
|
+
await this.db.delete(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key)));
|
|
453
|
+
}
|
|
454
|
+
// T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
|
|
455
|
+
// colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
|
|
456
|
+
// coberto por idx_sessions_company_flow_node) em vez de trazer toda sessão da empresa para
|
|
457
|
+
// contar em memória.
|
|
458
|
+
async getLiveFlowPositions(companyId) {
|
|
459
|
+
const rows = await this.db.select({
|
|
460
|
+
flowKey: sessions.flowKey,
|
|
461
|
+
nodeId: sessions.currentNodeId,
|
|
462
|
+
count: sql3`count(*)::int`
|
|
463
|
+
}).from(sessions).where(and3(eq3(sessions.companyId, companyId), eq3(sessions.mode, "bot"), isNotNull(sessions.flowKey), isNotNull(sessions.currentNodeId))).groupBy(sessions.flowKey, sessions.currentNodeId);
|
|
464
|
+
return rows.map((row) => ({
|
|
465
|
+
flowKey: row.flowKey,
|
|
466
|
+
nodeId: row.nodeId,
|
|
467
|
+
count: row.count
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// src/repositories/SettingsRepository.ts
|
|
473
|
+
import { eq as eq4 } from "drizzle-orm";
|
|
474
|
+
function toContractSettings(row) {
|
|
475
|
+
return {
|
|
476
|
+
templateName: row.templateName ?? "",
|
|
477
|
+
templateLanguage: row.templateLanguage,
|
|
478
|
+
templateVariables: row.templateVariables,
|
|
479
|
+
welcomeMessage: row.welcomeMessage ?? "",
|
|
480
|
+
farewellMessage: row.farewellMessage ?? ""
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
__name(toContractSettings, "toContractSettings");
|
|
484
|
+
var EMPTY_SETTINGS = {
|
|
485
|
+
templateName: "",
|
|
486
|
+
templateLanguage: "pt_BR",
|
|
487
|
+
templateVariables: [],
|
|
488
|
+
welcomeMessage: "",
|
|
489
|
+
farewellMessage: ""
|
|
490
|
+
};
|
|
491
|
+
var SettingsRepository = class {
|
|
492
|
+
static {
|
|
493
|
+
__name(this, "SettingsRepository");
|
|
494
|
+
}
|
|
495
|
+
db;
|
|
496
|
+
constructor(db) {
|
|
497
|
+
this.db = db;
|
|
498
|
+
}
|
|
499
|
+
// Empresa sem linha de configuração é o estado normal logo após instalar o módulo — devolve
|
|
500
|
+
// os defaults em vez de undefined, para o chamador não precisar tratar "ainda não configurado"
|
|
501
|
+
// como caso especial em todo lugar.
|
|
502
|
+
async get(companyId) {
|
|
503
|
+
const [row] = await this.db.select().from(settings).where(eq4(settings.companyId, companyId)).limit(1);
|
|
504
|
+
return row ? toContractSettings(row) : EMPTY_SETTINGS;
|
|
505
|
+
}
|
|
506
|
+
async save(companyId, update) {
|
|
507
|
+
const current = await this.get(companyId);
|
|
508
|
+
const merged = {
|
|
509
|
+
...current,
|
|
510
|
+
...update
|
|
511
|
+
};
|
|
512
|
+
const [row] = await this.db.insert(settings).values({
|
|
513
|
+
companyId,
|
|
514
|
+
templateName: merged.templateName || null,
|
|
515
|
+
templateLanguage: merged.templateLanguage,
|
|
516
|
+
templateVariables: merged.templateVariables,
|
|
517
|
+
welcomeMessage: merged.welcomeMessage || null,
|
|
518
|
+
farewellMessage: merged.farewellMessage || null
|
|
519
|
+
}).onConflictDoUpdate({
|
|
520
|
+
target: settings.companyId,
|
|
521
|
+
set: {
|
|
522
|
+
templateName: merged.templateName || null,
|
|
523
|
+
templateLanguage: merged.templateLanguage,
|
|
524
|
+
templateVariables: merged.templateVariables,
|
|
525
|
+
welcomeMessage: merged.welcomeMessage || null,
|
|
526
|
+
farewellMessage: merged.farewellMessage || null,
|
|
527
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
528
|
+
}
|
|
529
|
+
}).returning();
|
|
530
|
+
return toContractSettings(row);
|
|
531
|
+
}
|
|
532
|
+
// Resolve {{1}}, {{2}}... a partir do mapa configurado + o contexto da conversa. Ex.: se
|
|
533
|
+
// templateVariables = ['{clientName}', '{city}'], devolve [nome, cidade] na ordem posicional
|
|
534
|
+
// que a Graph API espera nos bodyParameters.
|
|
535
|
+
async resolveTemplateVariables(companyId, context) {
|
|
536
|
+
const { templateVariables } = await this.get(companyId);
|
|
537
|
+
return templateVariables.map((token) => {
|
|
538
|
+
const key = token.replace(/^\{|\}$/g, "");
|
|
539
|
+
const value = context[key];
|
|
540
|
+
return value === void 0 || value === null ? "" : String(value);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// src/use-cases/LogMessage.use-case.ts
|
|
546
|
+
var LogMessageUseCase = class {
|
|
547
|
+
static {
|
|
548
|
+
__name(this, "LogMessageUseCase");
|
|
549
|
+
}
|
|
550
|
+
sessionRepository;
|
|
551
|
+
messageRepository;
|
|
552
|
+
realtime;
|
|
553
|
+
constructor(sessionRepository, messageRepository, realtime) {
|
|
554
|
+
this.sessionRepository = sessionRepository;
|
|
555
|
+
this.messageRepository = messageRepository;
|
|
556
|
+
this.realtime = realtime;
|
|
557
|
+
}
|
|
558
|
+
async execute(params) {
|
|
559
|
+
const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
|
|
560
|
+
const saved = await this.messageRepository.insertMessage({
|
|
561
|
+
...params,
|
|
562
|
+
sessionId: session.id
|
|
563
|
+
});
|
|
564
|
+
if (!saved) return void 0;
|
|
565
|
+
if (params.direction === "inbound") {
|
|
566
|
+
await this.sessionRepository.touchInbound(params.companyId, params.whatsappNumber);
|
|
567
|
+
}
|
|
568
|
+
this.realtime?.emit(`conv:${params.whatsappNumber}`, "message", {
|
|
569
|
+
direction: params.direction,
|
|
570
|
+
sender: params.sender
|
|
571
|
+
});
|
|
572
|
+
this.realtime?.emit("global", "data-changed", {});
|
|
573
|
+
return saved;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/use-cases/SendMessage.use-case.ts
|
|
578
|
+
import { WindowExpiredError } from "@adatechnology/meta-whatsapp-contracts";
|
|
579
|
+
var WHATSAPP_WINDOW_HOURS = 24;
|
|
580
|
+
var SendMessageUseCase = class {
|
|
581
|
+
static {
|
|
582
|
+
__name(this, "SendMessageUseCase");
|
|
583
|
+
}
|
|
584
|
+
channel;
|
|
585
|
+
sessionRepository;
|
|
586
|
+
logMessage;
|
|
587
|
+
objectStorage;
|
|
588
|
+
constructor(channel, sessionRepository, logMessage, objectStorage) {
|
|
589
|
+
this.channel = channel;
|
|
590
|
+
this.sessionRepository = sessionRepository;
|
|
591
|
+
this.logMessage = logMessage;
|
|
592
|
+
this.objectStorage = objectStorage;
|
|
593
|
+
}
|
|
594
|
+
async assertWithinWindow(companyId, whatsappNumber) {
|
|
595
|
+
const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
|
|
596
|
+
if (hours === void 0) throw new WindowExpiredError();
|
|
597
|
+
if (hours >= WHATSAPP_WINDOW_HOURS) throw new WindowExpiredError(hours);
|
|
598
|
+
}
|
|
599
|
+
async sendText(params) {
|
|
600
|
+
await this.assertWithinWindow(params.companyId, params.whatsappNumber);
|
|
601
|
+
const { externalMessageId } = await this.channel.sendText(params.whatsappNumber, params.body);
|
|
602
|
+
return this.logMessage.execute({
|
|
603
|
+
companyId: params.companyId,
|
|
604
|
+
whatsappNumber: params.whatsappNumber,
|
|
605
|
+
direction: "outbound",
|
|
606
|
+
sender: params.sender,
|
|
607
|
+
agentUserId: params.agentUserId ?? null,
|
|
608
|
+
type: "text",
|
|
609
|
+
content: params.body,
|
|
610
|
+
waMessageId: externalMessageId,
|
|
611
|
+
status: "sent",
|
|
612
|
+
startState: params.startState
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
async sendMedia(params) {
|
|
616
|
+
await this.assertWithinWindow(params.companyId, params.whatsappNumber);
|
|
617
|
+
const { externalMessageId } = await this.channel.sendMedia({
|
|
618
|
+
to: params.whatsappNumber,
|
|
619
|
+
buffer: params.buffer,
|
|
620
|
+
mimeType: params.mimeType,
|
|
621
|
+
filename: params.filename,
|
|
622
|
+
caption: params.caption
|
|
623
|
+
});
|
|
624
|
+
const uploadId = this.objectStorage ? (await this.objectStorage.upload({
|
|
625
|
+
buffer: params.buffer,
|
|
626
|
+
mimeType: params.mimeType,
|
|
627
|
+
key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
|
|
628
|
+
})).uploadId : void 0;
|
|
629
|
+
return this.logMessage.execute({
|
|
630
|
+
companyId: params.companyId,
|
|
631
|
+
whatsappNumber: params.whatsappNumber,
|
|
632
|
+
direction: "outbound",
|
|
633
|
+
sender: params.sender,
|
|
634
|
+
agentUserId: params.agentUserId ?? null,
|
|
635
|
+
type: mediaTypeFor(params.mimeType),
|
|
636
|
+
content: params.caption ?? params.filename,
|
|
637
|
+
payload: {
|
|
638
|
+
filename: params.filename,
|
|
639
|
+
mimeType: params.mimeType,
|
|
640
|
+
...uploadId ? {
|
|
641
|
+
uploadId
|
|
642
|
+
} : {}
|
|
643
|
+
},
|
|
644
|
+
waMessageId: externalMessageId,
|
|
645
|
+
status: "sent",
|
|
646
|
+
startState: params.startState
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
// Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
|
|
650
|
+
// para reabri-la.
|
|
651
|
+
async sendTemplate(params) {
|
|
652
|
+
const { externalMessageId } = await this.channel.sendTemplate({
|
|
653
|
+
to: params.whatsappNumber,
|
|
654
|
+
templateName: params.templateName,
|
|
655
|
+
languageCode: params.languageCode ?? "pt_BR",
|
|
656
|
+
bodyParameters: params.bodyParameters
|
|
657
|
+
});
|
|
658
|
+
return this.logMessage.execute({
|
|
659
|
+
companyId: params.companyId,
|
|
660
|
+
whatsappNumber: params.whatsappNumber,
|
|
661
|
+
direction: "outbound",
|
|
662
|
+
sender: params.sender,
|
|
663
|
+
agentUserId: params.agentUserId ?? null,
|
|
664
|
+
type: "template",
|
|
665
|
+
content: params.templateName,
|
|
666
|
+
payload: {
|
|
667
|
+
templateName: params.templateName,
|
|
668
|
+
bodyParameters: params.bodyParameters ?? []
|
|
669
|
+
},
|
|
670
|
+
waMessageId: externalMessageId,
|
|
671
|
+
status: "sent",
|
|
672
|
+
startState: params.startState
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
function mediaTypeFor(mimeType) {
|
|
677
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
678
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
679
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
680
|
+
return "document";
|
|
681
|
+
}
|
|
682
|
+
__name(mediaTypeFor, "mediaTypeFor");
|
|
683
|
+
|
|
684
|
+
// src/use-cases/TakeoverConversation.use-case.ts
|
|
685
|
+
var TakeoverConversationUseCase = class {
|
|
686
|
+
static {
|
|
687
|
+
__name(this, "TakeoverConversationUseCase");
|
|
688
|
+
}
|
|
689
|
+
sessionRepository;
|
|
690
|
+
realtime;
|
|
691
|
+
constructor(sessionRepository, realtime) {
|
|
692
|
+
this.sessionRepository = sessionRepository;
|
|
693
|
+
this.realtime = realtime;
|
|
694
|
+
}
|
|
695
|
+
async execute(params) {
|
|
696
|
+
await this.sessionRepository.takeover(params.companyId, params.whatsappNumber, params.agentUserId);
|
|
697
|
+
this.realtime?.emit(`conv:${params.whatsappNumber}`, "mode-changed", {
|
|
698
|
+
mode: "human",
|
|
699
|
+
assignedUserId: params.agentUserId
|
|
700
|
+
});
|
|
701
|
+
this.realtime?.emit("global", "data-changed", {});
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// src/use-cases/ReleaseConversation.use-case.ts
|
|
706
|
+
var ReleaseConversationUseCase = class {
|
|
707
|
+
static {
|
|
708
|
+
__name(this, "ReleaseConversationUseCase");
|
|
709
|
+
}
|
|
710
|
+
sessionRepository;
|
|
711
|
+
realtime;
|
|
712
|
+
constructor(sessionRepository, realtime) {
|
|
713
|
+
this.sessionRepository = sessionRepository;
|
|
714
|
+
this.realtime = realtime;
|
|
715
|
+
}
|
|
716
|
+
async execute(params) {
|
|
717
|
+
await this.sessionRepository.release(params.companyId, params.whatsappNumber);
|
|
718
|
+
this.realtime?.emit(`conv:${params.whatsappNumber}`, "mode-changed", {
|
|
719
|
+
mode: "bot",
|
|
720
|
+
assignedUserId: null
|
|
721
|
+
});
|
|
722
|
+
this.realtime?.emit("global", "data-changed", {});
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// src/use-cases/ListConversations.use-case.ts
|
|
727
|
+
var ListConversationsUseCase = class {
|
|
728
|
+
static {
|
|
729
|
+
__name(this, "ListConversationsUseCase");
|
|
730
|
+
}
|
|
731
|
+
sessionRepository;
|
|
732
|
+
constructor(sessionRepository) {
|
|
733
|
+
this.sessionRepository = sessionRepository;
|
|
734
|
+
}
|
|
735
|
+
async execute(params) {
|
|
736
|
+
return this.sessionRepository.listByContextFilters(params.companyId, params.filters ?? {});
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// src/use-cases/ListMessages.use-case.ts
|
|
741
|
+
import { SessionNotFoundError } from "@adatechnology/meta-whatsapp-contracts";
|
|
742
|
+
var ListMessagesUseCase = class {
|
|
743
|
+
static {
|
|
744
|
+
__name(this, "ListMessagesUseCase");
|
|
745
|
+
}
|
|
746
|
+
sessionRepository;
|
|
747
|
+
messageRepository;
|
|
748
|
+
constructor(sessionRepository, messageRepository) {
|
|
749
|
+
this.sessionRepository = sessionRepository;
|
|
750
|
+
this.messageRepository = messageRepository;
|
|
751
|
+
}
|
|
752
|
+
async execute(params) {
|
|
753
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
754
|
+
if (!session) throw new SessionNotFoundError(params.whatsappNumber);
|
|
755
|
+
const repoParams = {
|
|
756
|
+
companyId: params.companyId,
|
|
757
|
+
sessionId: session.id,
|
|
758
|
+
limit: params.limit,
|
|
759
|
+
before: params.before
|
|
760
|
+
};
|
|
761
|
+
return this.messageRepository.listByConversation(repoParams);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
// src/use-cases/ExportConversation.use-case.ts
|
|
766
|
+
import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
|
|
767
|
+
var ExportConversationUseCase = class {
|
|
768
|
+
static {
|
|
769
|
+
__name(this, "ExportConversationUseCase");
|
|
770
|
+
}
|
|
771
|
+
sessionRepository;
|
|
772
|
+
constructor(sessionRepository) {
|
|
773
|
+
this.sessionRepository = sessionRepository;
|
|
774
|
+
}
|
|
775
|
+
async execute(params) {
|
|
776
|
+
const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
|
|
777
|
+
if (!result) throw new SessionNotFoundError2(params.whatsappNumber);
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// src/use-cases/FlowGraph.use-cases.ts
|
|
783
|
+
var GetFlowGraphUseCase = class {
|
|
784
|
+
static {
|
|
785
|
+
__name(this, "GetFlowGraphUseCase");
|
|
786
|
+
}
|
|
787
|
+
flowGraphRepository;
|
|
788
|
+
constructor(flowGraphRepository) {
|
|
789
|
+
this.flowGraphRepository = flowGraphRepository;
|
|
790
|
+
}
|
|
791
|
+
async execute(params) {
|
|
792
|
+
return this.flowGraphRepository.get(params.companyId, params.key);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
var ListFlowGraphsUseCase = class {
|
|
796
|
+
static {
|
|
797
|
+
__name(this, "ListFlowGraphsUseCase");
|
|
798
|
+
}
|
|
799
|
+
flowGraphRepository;
|
|
800
|
+
constructor(flowGraphRepository) {
|
|
801
|
+
this.flowGraphRepository = flowGraphRepository;
|
|
802
|
+
}
|
|
803
|
+
async execute(params) {
|
|
804
|
+
return this.flowGraphRepository.list(params.companyId);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
var CreateFlowGraphUseCase = class {
|
|
808
|
+
static {
|
|
809
|
+
__name(this, "CreateFlowGraphUseCase");
|
|
810
|
+
}
|
|
811
|
+
flowGraphRepository;
|
|
812
|
+
constructor(flowGraphRepository) {
|
|
813
|
+
this.flowGraphRepository = flowGraphRepository;
|
|
814
|
+
}
|
|
815
|
+
async execute(params) {
|
|
816
|
+
return this.flowGraphRepository.create(params.companyId, params);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
var SaveFlowGraphUseCase = class {
|
|
820
|
+
static {
|
|
821
|
+
__name(this, "SaveFlowGraphUseCase");
|
|
822
|
+
}
|
|
823
|
+
flowGraphRepository;
|
|
824
|
+
constructor(flowGraphRepository) {
|
|
825
|
+
this.flowGraphRepository = flowGraphRepository;
|
|
826
|
+
}
|
|
827
|
+
async execute(params) {
|
|
828
|
+
return this.flowGraphRepository.save(params.companyId, params.graph, params.expectedVersion);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
var DeleteFlowGraphUseCase = class {
|
|
832
|
+
static {
|
|
833
|
+
__name(this, "DeleteFlowGraphUseCase");
|
|
834
|
+
}
|
|
835
|
+
flowGraphRepository;
|
|
836
|
+
constructor(flowGraphRepository) {
|
|
837
|
+
this.flowGraphRepository = flowGraphRepository;
|
|
838
|
+
}
|
|
839
|
+
async execute(params) {
|
|
840
|
+
return this.flowGraphRepository.delete(params.companyId, params.key);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
var GetLiveFlowPositionsUseCase = class {
|
|
844
|
+
static {
|
|
845
|
+
__name(this, "GetLiveFlowPositionsUseCase");
|
|
846
|
+
}
|
|
847
|
+
flowGraphRepository;
|
|
848
|
+
constructor(flowGraphRepository) {
|
|
849
|
+
this.flowGraphRepository = flowGraphRepository;
|
|
850
|
+
}
|
|
851
|
+
async execute(params) {
|
|
852
|
+
return this.flowGraphRepository.getLiveFlowPositions(params.companyId);
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// src/flows/FlowInterpreter.ts
|
|
857
|
+
import { isCrossFlowTarget, crossFlowKey } from "@adatechnology/meta-whatsapp-contracts";
|
|
858
|
+
var DEFAULT_MAX_STEPS = 50;
|
|
859
|
+
function evaluateCondition(node, context) {
|
|
860
|
+
if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
|
|
861
|
+
const actual = context[node.conditionContextKey];
|
|
862
|
+
const expected = node.conditionValue;
|
|
863
|
+
switch (node.conditionOperator) {
|
|
864
|
+
case "==":
|
|
865
|
+
return String(actual) === expected;
|
|
866
|
+
case "!=":
|
|
867
|
+
return String(actual) !== expected;
|
|
868
|
+
case "contains":
|
|
869
|
+
return typeof actual === "string" && actual.includes(expected);
|
|
870
|
+
case ">":
|
|
871
|
+
case ">=":
|
|
872
|
+
case "<":
|
|
873
|
+
case "<=": {
|
|
874
|
+
const actualNum = Number(actual);
|
|
875
|
+
const expectedNum = Number(expected);
|
|
876
|
+
if (Number.isNaN(actualNum) || Number.isNaN(expectedNum)) return false;
|
|
877
|
+
if (node.conditionOperator === ">") return actualNum > expectedNum;
|
|
878
|
+
if (node.conditionOperator === ">=") return actualNum >= expectedNum;
|
|
879
|
+
if (node.conditionOperator === "<") return actualNum < expectedNum;
|
|
880
|
+
return actualNum <= expectedNum;
|
|
881
|
+
}
|
|
882
|
+
default:
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
__name(evaluateCondition, "evaluateCondition");
|
|
887
|
+
function resolveNext(node, answerId) {
|
|
888
|
+
if (!node.next) return void 0;
|
|
889
|
+
if (typeof node.next === "string") return node.next;
|
|
890
|
+
if (answerId && node.next.byAnswer[answerId]) return node.next.byAnswer[answerId];
|
|
891
|
+
return node.next.default;
|
|
892
|
+
}
|
|
893
|
+
__name(resolveNext, "resolveNext");
|
|
894
|
+
var FlowInterpreter = class {
|
|
895
|
+
static {
|
|
896
|
+
__name(this, "FlowInterpreter");
|
|
897
|
+
}
|
|
898
|
+
actionHandlers = /* @__PURE__ */ new Map();
|
|
899
|
+
registerFlowAction(kind, handler) {
|
|
900
|
+
this.actionHandlers.set(kind, handler);
|
|
901
|
+
}
|
|
902
|
+
async step(input) {
|
|
903
|
+
const node = input.graph.nodes[input.currentNodeId];
|
|
904
|
+
if (!node) return {
|
|
905
|
+
kind: "terminal",
|
|
906
|
+
context: input.context
|
|
907
|
+
};
|
|
908
|
+
if (node.type === "condition") {
|
|
909
|
+
const branch = evaluateCondition(node, input.context) ? "true" : "false";
|
|
910
|
+
return this.moveTo(input, resolveNext(node, branch));
|
|
911
|
+
}
|
|
912
|
+
if (node.type === "action") {
|
|
913
|
+
const handler = node.actionKind ? this.actionHandlers.get(node.actionKind) : void 0;
|
|
914
|
+
if (!handler) return {
|
|
915
|
+
kind: "terminal",
|
|
916
|
+
context: input.context
|
|
917
|
+
};
|
|
918
|
+
const result = await handler({
|
|
919
|
+
node,
|
|
920
|
+
session: input.session,
|
|
921
|
+
channel: input.channel,
|
|
922
|
+
context: input.context
|
|
923
|
+
});
|
|
924
|
+
const contextAfterAction = result?.context ? {
|
|
925
|
+
...input.context,
|
|
926
|
+
...result.context
|
|
927
|
+
} : input.context;
|
|
928
|
+
const nextNodeId2 = result?.next ?? resolveNext(node, void 0);
|
|
929
|
+
return this.moveTo({
|
|
930
|
+
...input,
|
|
931
|
+
context: contextAfterAction
|
|
932
|
+
}, nextNodeId2);
|
|
933
|
+
}
|
|
934
|
+
const isChoice = node.type === "menu" || node.questionType === "choice";
|
|
935
|
+
if (input.userAnswer === void 0) {
|
|
936
|
+
return {
|
|
937
|
+
kind: "awaiting-answer",
|
|
938
|
+
nodeId: input.currentNodeId,
|
|
939
|
+
context: input.context
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const updatedContext = node.contextKey ? {
|
|
943
|
+
...input.context,
|
|
944
|
+
[node.contextKey]: input.userAnswer
|
|
945
|
+
} : input.context;
|
|
946
|
+
const answerId = isChoice ? input.userAnswer : void 0;
|
|
947
|
+
const nextNodeId = resolveNext(node, answerId);
|
|
948
|
+
return this.moveTo({
|
|
949
|
+
...input,
|
|
950
|
+
context: updatedContext
|
|
951
|
+
}, nextNodeId);
|
|
952
|
+
}
|
|
953
|
+
moveTo(input, nextNodeId) {
|
|
954
|
+
if (!nextNodeId) return {
|
|
955
|
+
kind: "terminal",
|
|
956
|
+
context: input.context
|
|
957
|
+
};
|
|
958
|
+
if (isCrossFlowTarget(nextNodeId)) return {
|
|
959
|
+
kind: "cross-flow",
|
|
960
|
+
flowKey: crossFlowKey(nextNodeId),
|
|
961
|
+
context: input.context
|
|
962
|
+
};
|
|
963
|
+
if (!input.graph.nodes[nextNodeId]) return {
|
|
964
|
+
kind: "terminal",
|
|
965
|
+
context: input.context
|
|
966
|
+
};
|
|
967
|
+
return {
|
|
968
|
+
kind: "advanced",
|
|
969
|
+
nodeId: nextNodeId,
|
|
970
|
+
context: input.context
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
// Avança pelo grafo até parar num ponto que exige algo de fora: uma pergunta esperando resposta
|
|
974
|
+
// do cliente, o fim do fluxo, ou um salto para outro fluxo. Nós de condição e ação não pedem
|
|
975
|
+
// input nenhum, então parar neles obrigaria o chamador a reimplementar este laço — e sem
|
|
976
|
+
// proteção, um grafo com ciclo de condições (A→B→A) o prenderia para sempre.
|
|
977
|
+
//
|
|
978
|
+
// O corte por `maxSteps` é uma rede de segurança de runtime, não a defesa principal: um ciclo é
|
|
979
|
+
// erro de autoria e deveria ser barrado na publicação. Como validateGraph (no editor) hoje
|
|
980
|
+
// detecta nó inalcançável mas NÃO detecta ciclo, um grafo cíclico é publicável — então o
|
|
981
|
+
// runtime precisa sobreviver a ele em vez de travar o processo que atende o webhook.
|
|
982
|
+
async run(input, options) {
|
|
983
|
+
const maxSteps = options?.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
984
|
+
const visited = [
|
|
985
|
+
input.currentNodeId
|
|
986
|
+
];
|
|
987
|
+
let current = input;
|
|
988
|
+
let steps = 0;
|
|
989
|
+
while (steps < maxSteps) {
|
|
990
|
+
const result = await this.step(current);
|
|
991
|
+
steps++;
|
|
992
|
+
if (result.kind !== "advanced") return {
|
|
993
|
+
...result,
|
|
994
|
+
visited,
|
|
995
|
+
steps
|
|
996
|
+
};
|
|
997
|
+
visited.push(result.nodeId);
|
|
998
|
+
current = {
|
|
999
|
+
...current,
|
|
1000
|
+
currentNodeId: result.nodeId,
|
|
1001
|
+
context: result.context,
|
|
1002
|
+
userAnswer: void 0
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
return {
|
|
1006
|
+
kind: "max-steps-exceeded",
|
|
1007
|
+
context: current.context,
|
|
1008
|
+
visited,
|
|
1009
|
+
steps
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
// src/channel/WhatsAppChannelAdapter.ts
|
|
1015
|
+
import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
|
|
1016
|
+
import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
|
|
1017
|
+
var WhatsAppChannelAdapter = class {
|
|
1018
|
+
static {
|
|
1019
|
+
__name(this, "WhatsAppChannelAdapter");
|
|
1020
|
+
}
|
|
1021
|
+
messages;
|
|
1022
|
+
constructor(messages2) {
|
|
1023
|
+
this.messages = messages2;
|
|
1024
|
+
}
|
|
1025
|
+
async translateErrors(operation) {
|
|
1026
|
+
try {
|
|
1027
|
+
return await operation();
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
if (error instanceof ProviderWindowExpiredError) throw new WindowExpiredError2();
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
async sendText(to, body) {
|
|
1034
|
+
const result = await this.translateErrors(() => this.messages.sendText(to, body));
|
|
1035
|
+
return {
|
|
1036
|
+
externalMessageId: result.waMessageId
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
async sendMedia(params) {
|
|
1040
|
+
const result = await this.translateErrors(() => this.messages.sendMedia(params));
|
|
1041
|
+
return {
|
|
1042
|
+
externalMessageId: result.waMessageId
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
async sendTemplate(params) {
|
|
1046
|
+
const result = await this.translateErrors(() => this.messages.sendTemplate(params));
|
|
1047
|
+
return {
|
|
1048
|
+
externalMessageId: result.waMessageId
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
async sendInteractiveList(params) {
|
|
1052
|
+
const result = await this.translateErrors(() => this.messages.sendInteractiveList({
|
|
1053
|
+
to: params.to,
|
|
1054
|
+
bodyText: params.body,
|
|
1055
|
+
buttonText: params.buttonLabel,
|
|
1056
|
+
sections: [
|
|
1057
|
+
{
|
|
1058
|
+
title: params.buttonLabel,
|
|
1059
|
+
rows: params.rows
|
|
1060
|
+
}
|
|
1061
|
+
]
|
|
1062
|
+
}));
|
|
1063
|
+
return {
|
|
1064
|
+
externalMessageId: result.waMessageId
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
async fetchMediaAsBase64(mediaId) {
|
|
1068
|
+
return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
// src/channel/ReceiveWebhook.use-case.ts
|
|
1073
|
+
import { whatsAppWebhookPayloadSchema } from "@adatechnology/meta-whatsapp-contracts";
|
|
1074
|
+
|
|
1075
|
+
// src/channel/webhookSecurity.ts
|
|
1076
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
1077
|
+
import { InvalidWebhookSignatureError } from "@adatechnology/meta-whatsapp-contracts";
|
|
1078
|
+
var WEBHOOK_NONCE_TTL_SECONDS = 300;
|
|
1079
|
+
function safeEqualStrings(left, right) {
|
|
1080
|
+
const leftDigest = createHmac("sha256", "constant-time-compare").update(left).digest();
|
|
1081
|
+
const rightDigest = createHmac("sha256", "constant-time-compare").update(right).digest();
|
|
1082
|
+
return timingSafeEqual(leftDigest, rightDigest);
|
|
1083
|
+
}
|
|
1084
|
+
__name(safeEqualStrings, "safeEqualStrings");
|
|
1085
|
+
function verifyWebhookChallenge(params) {
|
|
1086
|
+
if (params.mode !== "subscribe" || !params.token || !params.challenge) {
|
|
1087
|
+
throw new InvalidWebhookSignatureError();
|
|
1088
|
+
}
|
|
1089
|
+
if (!safeEqualStrings(params.token, params.expectedToken)) {
|
|
1090
|
+
throw new InvalidWebhookSignatureError();
|
|
1091
|
+
}
|
|
1092
|
+
return params.challenge;
|
|
1093
|
+
}
|
|
1094
|
+
__name(verifyWebhookChallenge, "verifyWebhookChallenge");
|
|
1095
|
+
function verifyWebhookSignature(params) {
|
|
1096
|
+
const { rawBody, signatureHeader, appSecret } = params;
|
|
1097
|
+
if (!signatureHeader?.startsWith("sha256=")) throw new InvalidWebhookSignatureError();
|
|
1098
|
+
const expected = createHmac("sha256", appSecret).update(rawBody).digest("hex");
|
|
1099
|
+
const received = signatureHeader.slice("sha256=".length);
|
|
1100
|
+
if (!safeEqualStrings(received, expected)) throw new InvalidWebhookSignatureError();
|
|
1101
|
+
}
|
|
1102
|
+
__name(verifyWebhookSignature, "verifyWebhookSignature");
|
|
1103
|
+
async function claimWebhookDelivery(params) {
|
|
1104
|
+
const key = `meta-whatsapp:webhook:${params.signatureHeader}`;
|
|
1105
|
+
return params.nonceStore.setIfAbsent(key, params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
|
|
1106
|
+
}
|
|
1107
|
+
__name(claimWebhookDelivery, "claimWebhookDelivery");
|
|
1108
|
+
|
|
1109
|
+
// src/channel/ReceiveWebhook.use-case.ts
|
|
1110
|
+
function toSessionContract(row) {
|
|
1111
|
+
return {
|
|
1112
|
+
id: row.id,
|
|
1113
|
+
companyId: row.companyId,
|
|
1114
|
+
whatsappNumber: row.whatsappNumber,
|
|
1115
|
+
currentState: row.currentState,
|
|
1116
|
+
context: row.context,
|
|
1117
|
+
mode: row.mode,
|
|
1118
|
+
assignedUserId: row.assignedUserId,
|
|
1119
|
+
humanRequestedAt: row.humanRequestedAt?.toISOString() ?? null,
|
|
1120
|
+
lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
|
|
1121
|
+
lastAgentReadAt: row.lastAgentReadAt?.toISOString() ?? null,
|
|
1122
|
+
lastActivity: row.lastActivity.toISOString(),
|
|
1123
|
+
createdAt: row.createdAt.toISOString(),
|
|
1124
|
+
updatedAt: row.updatedAt.toISOString()
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
__name(toSessionContract, "toSessionContract");
|
|
1128
|
+
function extractContent(message) {
|
|
1129
|
+
if (message.text?.body) return message.text.body;
|
|
1130
|
+
const interactive = message.interactive;
|
|
1131
|
+
if (interactive?.button_reply) return interactive.button_reply.title || interactive.button_reply.id;
|
|
1132
|
+
if (interactive?.list_reply) return interactive.list_reply.title || interactive.list_reply.id;
|
|
1133
|
+
if (message.order) {
|
|
1134
|
+
const items = message.order.product_items.reduce((sum, item) => sum + item.quantity, 0);
|
|
1135
|
+
return `Pedido: ${items} item(ns)`;
|
|
1136
|
+
}
|
|
1137
|
+
return message.image?.caption ?? message.document?.caption ?? null;
|
|
1138
|
+
}
|
|
1139
|
+
__name(extractContent, "extractContent");
|
|
1140
|
+
function extractAnswer(message) {
|
|
1141
|
+
const interactive = message.interactive;
|
|
1142
|
+
if (interactive?.button_reply) return interactive.button_reply.id;
|
|
1143
|
+
if (interactive?.list_reply) return interactive.list_reply.id;
|
|
1144
|
+
return message.text?.body;
|
|
1145
|
+
}
|
|
1146
|
+
__name(extractAnswer, "extractAnswer");
|
|
1147
|
+
function extractPayload(message) {
|
|
1148
|
+
const payload = {};
|
|
1149
|
+
if (message.interactive) payload["interactive"] = message.interactive;
|
|
1150
|
+
if (message.order) payload["order"] = message.order;
|
|
1151
|
+
if (message.image) payload["image"] = message.image;
|
|
1152
|
+
if (message.audio) payload["audio"] = message.audio;
|
|
1153
|
+
if (message.video) payload["video"] = message.video;
|
|
1154
|
+
if (message.document) payload["document"] = message.document;
|
|
1155
|
+
if (message.sticker) payload["sticker"] = message.sticker;
|
|
1156
|
+
if (message.context?.referred_product) payload["referredProduct"] = message.context.referred_product;
|
|
1157
|
+
return Object.keys(payload).length > 0 ? payload : null;
|
|
1158
|
+
}
|
|
1159
|
+
__name(extractPayload, "extractPayload");
|
|
1160
|
+
var ReceiveWebhookUseCase = class {
|
|
1161
|
+
static {
|
|
1162
|
+
__name(this, "ReceiveWebhookUseCase");
|
|
1163
|
+
}
|
|
1164
|
+
params;
|
|
1165
|
+
constructor(params) {
|
|
1166
|
+
this.params = params;
|
|
1167
|
+
}
|
|
1168
|
+
async execute(input) {
|
|
1169
|
+
verifyWebhookSignature({
|
|
1170
|
+
rawBody: input.rawBody,
|
|
1171
|
+
signatureHeader: input.signatureHeader,
|
|
1172
|
+
appSecret: this.params.appSecret
|
|
1173
|
+
});
|
|
1174
|
+
const claimed = await claimWebhookDelivery({
|
|
1175
|
+
nonceStore: this.params.nonceStore,
|
|
1176
|
+
signatureHeader: input.signatureHeader
|
|
1177
|
+
});
|
|
1178
|
+
if (!claimed) return {
|
|
1179
|
+
duplicate: true,
|
|
1180
|
+
messagesProcessed: 0,
|
|
1181
|
+
statusesProcessed: 0
|
|
1182
|
+
};
|
|
1183
|
+
const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
|
|
1184
|
+
const payload = whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
|
|
1185
|
+
let messagesProcessed = 0;
|
|
1186
|
+
let statusesProcessed = 0;
|
|
1187
|
+
for (const entry of payload.entry) {
|
|
1188
|
+
for (const change of entry.changes) {
|
|
1189
|
+
for (const message of change.value.messages ?? []) {
|
|
1190
|
+
await this.handleMessage(input.companyId, message);
|
|
1191
|
+
messagesProcessed++;
|
|
1192
|
+
}
|
|
1193
|
+
for (const status of change.value.statuses ?? []) {
|
|
1194
|
+
await this.handleStatus(input.companyId, status);
|
|
1195
|
+
statusesProcessed++;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
duplicate: false,
|
|
1201
|
+
messagesProcessed,
|
|
1202
|
+
statusesProcessed
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
async handleMessage(companyId, message) {
|
|
1206
|
+
const saved = await this.params.logMessage.execute({
|
|
1207
|
+
companyId,
|
|
1208
|
+
whatsappNumber: message.from,
|
|
1209
|
+
direction: "inbound",
|
|
1210
|
+
sender: "customer",
|
|
1211
|
+
type: message.type,
|
|
1212
|
+
content: extractContent(message),
|
|
1213
|
+
payload: extractPayload(message),
|
|
1214
|
+
waMessageId: message.id,
|
|
1215
|
+
status: "received",
|
|
1216
|
+
startState: this.params.startState
|
|
1217
|
+
});
|
|
1218
|
+
if (!saved) return;
|
|
1219
|
+
const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
|
|
1220
|
+
if (!sessionRow) return;
|
|
1221
|
+
if (sessionRow.mode === "human") return;
|
|
1222
|
+
const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
|
|
1223
|
+
if (outcome?.outcome === "handled") return;
|
|
1224
|
+
void extractAnswer(message);
|
|
1225
|
+
}
|
|
1226
|
+
async handleStatus(companyId, status) {
|
|
1227
|
+
const updated = await this.params.messageRepository.updateMessageStatus(companyId, status.id, status.status);
|
|
1228
|
+
if (!updated) return;
|
|
1229
|
+
this.params.realtime?.emit(`conv:${updated.whatsappNumber}`, "message-status", {
|
|
1230
|
+
waMessageId: status.id,
|
|
1231
|
+
status: status.status
|
|
1232
|
+
});
|
|
1233
|
+
const sessionRow = await this.params.sessionRepository.getContext(companyId, updated.whatsappNumber);
|
|
1234
|
+
await this.params.hooks?.onStatusUpdate?.(status, sessionRow ? toSessionContract(sessionRow) : null);
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
1239
|
+
import { eq as eq5, and as and4 } from "drizzle-orm";
|
|
1240
|
+
var IngestInboundMediaUseCase = class {
|
|
1241
|
+
static {
|
|
1242
|
+
__name(this, "IngestInboundMediaUseCase");
|
|
1243
|
+
}
|
|
1244
|
+
db;
|
|
1245
|
+
channel;
|
|
1246
|
+
objectStorage;
|
|
1247
|
+
constructor(db, channel, objectStorage) {
|
|
1248
|
+
this.db = db;
|
|
1249
|
+
this.channel = channel;
|
|
1250
|
+
this.objectStorage = objectStorage;
|
|
1251
|
+
}
|
|
1252
|
+
async execute(params) {
|
|
1253
|
+
const [message] = await this.db.select().from(messages).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId))).limit(1);
|
|
1254
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
1255
|
+
const payload = message.payload ?? {};
|
|
1256
|
+
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
1257
|
+
return {
|
|
1258
|
+
uploadId: String(payload["uploadId"]),
|
|
1259
|
+
alreadyIngested: true
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
1263
|
+
const buffer = Buffer.from(data, "base64");
|
|
1264
|
+
const { uploadId } = await this.objectStorage.upload({
|
|
1265
|
+
buffer,
|
|
1266
|
+
mimeType: mimeType || params.mimeType,
|
|
1267
|
+
key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
|
|
1268
|
+
});
|
|
1269
|
+
const updatedPayload = {
|
|
1270
|
+
...payload,
|
|
1271
|
+
uploadId,
|
|
1272
|
+
sourceMediaId: params.sourceMediaId,
|
|
1273
|
+
mimeType: mimeType || params.mimeType,
|
|
1274
|
+
...params.filename ? {
|
|
1275
|
+
filename: params.filename
|
|
1276
|
+
} : {}
|
|
1277
|
+
};
|
|
1278
|
+
await this.db.update(messages).set({
|
|
1279
|
+
payload: updatedPayload
|
|
1280
|
+
}).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId)));
|
|
1281
|
+
return {
|
|
1282
|
+
uploadId,
|
|
1283
|
+
alreadyIngested: false
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
function extractMediaDescriptor(message) {
|
|
1288
|
+
const payload = message.payload ?? {};
|
|
1289
|
+
for (const key of [
|
|
1290
|
+
"image",
|
|
1291
|
+
"audio",
|
|
1292
|
+
"video",
|
|
1293
|
+
"document",
|
|
1294
|
+
"sticker"
|
|
1295
|
+
]) {
|
|
1296
|
+
const media = payload[key];
|
|
1297
|
+
if (media?.id) {
|
|
1298
|
+
return {
|
|
1299
|
+
sourceMediaId: media.id,
|
|
1300
|
+
mimeType: media.mime_type ?? "application/octet-stream",
|
|
1301
|
+
filename: media.filename
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return void 0;
|
|
1306
|
+
}
|
|
1307
|
+
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
1308
|
+
|
|
1309
|
+
// src/createMetaWhatsAppModule.ts
|
|
1310
|
+
function createMetaWhatsAppModule(params) {
|
|
1311
|
+
const { db, config, nonceStore, providers = {}, hooks } = params;
|
|
1312
|
+
const startState = params.startState ?? "start";
|
|
1313
|
+
const flowEngineEnabled = params.features?.flowEngine ?? true;
|
|
1314
|
+
const messageProvider = new WhatsAppMessageProvider({
|
|
1315
|
+
accessToken: config.accessToken,
|
|
1316
|
+
phoneNumberId: config.phoneNumberId,
|
|
1317
|
+
wabaId: config.wabaId,
|
|
1318
|
+
apiVersion: config.apiVersion,
|
|
1319
|
+
baseUrl: config.baseUrl
|
|
1320
|
+
});
|
|
1321
|
+
const channel = new WhatsAppChannelAdapter(messageProvider);
|
|
1322
|
+
const sessionRepository = new SessionRepository(db);
|
|
1323
|
+
const messageRepository = new MessageRepository(db);
|
|
1324
|
+
const settingsRepository = new SettingsRepository(db);
|
|
1325
|
+
const flowGraphRepository = new FlowGraphRepository(db);
|
|
1326
|
+
const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime);
|
|
1327
|
+
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
|
|
1328
|
+
const receiveWebhook = new ReceiveWebhookUseCase({
|
|
1329
|
+
appSecret: config.appSecret,
|
|
1330
|
+
nonceStore,
|
|
1331
|
+
sessionRepository,
|
|
1332
|
+
messageRepository,
|
|
1333
|
+
logMessage,
|
|
1334
|
+
startState,
|
|
1335
|
+
hooks,
|
|
1336
|
+
realtime: providers.realtime
|
|
1337
|
+
});
|
|
1338
|
+
const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
|
|
1339
|
+
const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
|
|
1340
|
+
return {
|
|
1341
|
+
channel,
|
|
1342
|
+
// undefined quando providers.objectStorage não foi injetado.
|
|
1343
|
+
ingestInboundMedia,
|
|
1344
|
+
conversations: {
|
|
1345
|
+
log: logMessage,
|
|
1346
|
+
send: sendMessage,
|
|
1347
|
+
takeover: new TakeoverConversationUseCase(sessionRepository, providers.realtime),
|
|
1348
|
+
release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
|
|
1349
|
+
list: new ListConversationsUseCase(sessionRepository),
|
|
1350
|
+
listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
|
|
1351
|
+
export: new ExportConversationUseCase(sessionRepository),
|
|
1352
|
+
repository: sessionRepository
|
|
1353
|
+
},
|
|
1354
|
+
settings: settingsRepository,
|
|
1355
|
+
webhook: {
|
|
1356
|
+
receive: receiveWebhook,
|
|
1357
|
+
// GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
|
|
1358
|
+
verifyChallenge: /* @__PURE__ */ __name((query) => verifyWebhookChallenge({
|
|
1359
|
+
...query,
|
|
1360
|
+
expectedToken: config.webhookVerifyToken
|
|
1361
|
+
}), "verifyChallenge")
|
|
1362
|
+
},
|
|
1363
|
+
// undefined quando features.flowEngine === false.
|
|
1364
|
+
flows: flowInterpreter ? {
|
|
1365
|
+
interpreter: flowInterpreter,
|
|
1366
|
+
registerFlowAction: /* @__PURE__ */ __name((kind, handler) => flowInterpreter.registerFlowAction(kind, handler), "registerFlowAction"),
|
|
1367
|
+
get: new GetFlowGraphUseCase(flowGraphRepository),
|
|
1368
|
+
list: new ListFlowGraphsUseCase(flowGraphRepository),
|
|
1369
|
+
create: new CreateFlowGraphUseCase(flowGraphRepository),
|
|
1370
|
+
save: new SaveFlowGraphUseCase(flowGraphRepository),
|
|
1371
|
+
delete: new DeleteFlowGraphUseCase(flowGraphRepository),
|
|
1372
|
+
livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
|
|
1373
|
+
repository: flowGraphRepository
|
|
1374
|
+
} : void 0,
|
|
1375
|
+
catalog: providers.catalog
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
__name(createMetaWhatsAppModule, "createMetaWhatsAppModule");
|
|
1379
|
+
|
|
1380
|
+
// src/runMigrations.ts
|
|
1381
|
+
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"
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
__name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
|
|
1390
|
+
|
|
1391
|
+
// src/realtime/SseHub.ts
|
|
1392
|
+
import { randomUUID } from "crypto";
|
|
1393
|
+
var TICKET_TTL_SECONDS = 60;
|
|
1394
|
+
var SseHub = class {
|
|
1395
|
+
static {
|
|
1396
|
+
__name(this, "SseHub");
|
|
1397
|
+
}
|
|
1398
|
+
relay;
|
|
1399
|
+
localListeners = /* @__PURE__ */ new Map();
|
|
1400
|
+
// Uma assinatura de relay por CANAL, compartilhada por todos os listeners locais dele — uma
|
|
1401
|
+
// por listener faria o relay entregar N cópias num canal com N conexões abertas.
|
|
1402
|
+
relaySubscriptions = /* @__PURE__ */ new Map();
|
|
1403
|
+
// Identifica esta instância do processo para descartar o próprio eco (pub/sub entrega a
|
|
1404
|
+
// mensagem a todos os inscritos, inclusive quem publicou).
|
|
1405
|
+
instanceId = randomUUID();
|
|
1406
|
+
constructor(relay) {
|
|
1407
|
+
this.relay = relay;
|
|
1408
|
+
}
|
|
1409
|
+
emit(channel, event, payload) {
|
|
1410
|
+
this.dispatchLocal(channel, event, payload);
|
|
1411
|
+
if (this.relay) {
|
|
1412
|
+
const envelope = {
|
|
1413
|
+
originId: this.instanceId,
|
|
1414
|
+
event,
|
|
1415
|
+
payload
|
|
1416
|
+
};
|
|
1417
|
+
this.relay.publish(channel, JSON.stringify(envelope)).catch(() => void 0);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
dispatchLocal(channel, event, payload) {
|
|
1421
|
+
const listeners = this.localListeners.get(channel);
|
|
1422
|
+
if (!listeners) return;
|
|
1423
|
+
for (const listener of listeners) listener(event, payload);
|
|
1424
|
+
}
|
|
1425
|
+
async subscribe(channel, listener) {
|
|
1426
|
+
if (!this.localListeners.has(channel)) this.localListeners.set(channel, /* @__PURE__ */ new Set());
|
|
1427
|
+
this.localListeners.get(channel).add(listener);
|
|
1428
|
+
if (this.relay && !this.relaySubscriptions.has(channel)) {
|
|
1429
|
+
this.relaySubscriptions.set(channel, this.relay.subscribe(channel, (message) => {
|
|
1430
|
+
try {
|
|
1431
|
+
const envelope = JSON.parse(message);
|
|
1432
|
+
if (envelope.originId === this.instanceId) return;
|
|
1433
|
+
this.dispatchLocal(channel, envelope.event, envelope.payload);
|
|
1434
|
+
} catch {
|
|
1435
|
+
}
|
|
1436
|
+
}));
|
|
1437
|
+
}
|
|
1438
|
+
return () => {
|
|
1439
|
+
const listeners = this.localListeners.get(channel);
|
|
1440
|
+
listeners?.delete(listener);
|
|
1441
|
+
if (listeners && listeners.size === 0) {
|
|
1442
|
+
this.localListeners.delete(channel);
|
|
1443
|
+
const pendingUnsubscribe = this.relaySubscriptions.get(channel);
|
|
1444
|
+
this.relaySubscriptions.delete(channel);
|
|
1445
|
+
pendingUnsubscribe?.then((unsubscribe) => unsubscribe()).catch(() => void 0);
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
async function issueSseTicket(store, companyId, whatsappNumber) {
|
|
1451
|
+
const ticket = randomUUID();
|
|
1452
|
+
await store.set(`sse:ticket:${ticket}`, JSON.stringify({
|
|
1453
|
+
companyId,
|
|
1454
|
+
whatsappNumber
|
|
1455
|
+
}), TICKET_TTL_SECONDS);
|
|
1456
|
+
return ticket;
|
|
1457
|
+
}
|
|
1458
|
+
__name(issueSseTicket, "issueSseTicket");
|
|
1459
|
+
async function redeemSseTicket(store, ticket) {
|
|
1460
|
+
const raw = await store.get(`sse:ticket:${ticket}`);
|
|
1461
|
+
if (!raw) return null;
|
|
1462
|
+
await store.delete(`sse:ticket:${ticket}`);
|
|
1463
|
+
return JSON.parse(raw);
|
|
1464
|
+
}
|
|
1465
|
+
__name(redeemSseTicket, "redeemSseTicket");
|
|
1466
|
+
export {
|
|
1467
|
+
CreateFlowGraphUseCase,
|
|
1468
|
+
DeleteFlowGraphUseCase,
|
|
1469
|
+
ExportConversationUseCase,
|
|
1470
|
+
FlowGraphRepository,
|
|
1471
|
+
FlowInterpreter,
|
|
1472
|
+
GetFlowGraphUseCase,
|
|
1473
|
+
GetLiveFlowPositionsUseCase,
|
|
1474
|
+
IngestInboundMediaUseCase,
|
|
1475
|
+
InvalidFlowGraphError,
|
|
1476
|
+
ListConversationsUseCase,
|
|
1477
|
+
ListFlowGraphsUseCase,
|
|
1478
|
+
ListMessagesUseCase,
|
|
1479
|
+
LogMessageUseCase,
|
|
1480
|
+
MessageRepository,
|
|
1481
|
+
OptimisticLockError,
|
|
1482
|
+
ReceiveWebhookUseCase,
|
|
1483
|
+
ReleaseConversationUseCase,
|
|
1484
|
+
SaveFlowGraphUseCase,
|
|
1485
|
+
SendMessageUseCase,
|
|
1486
|
+
SessionRepository,
|
|
1487
|
+
SettingsRepository,
|
|
1488
|
+
SseHub,
|
|
1489
|
+
TakeoverConversationUseCase,
|
|
1490
|
+
WEBHOOK_NONCE_TTL_SECONDS,
|
|
1491
|
+
WhatsAppChannelAdapter,
|
|
1492
|
+
claimWebhookDelivery,
|
|
1493
|
+
createMetaWhatsAppModule,
|
|
1494
|
+
extractMediaDescriptor,
|
|
1495
|
+
flowGraphs,
|
|
1496
|
+
issueSseTicket,
|
|
1497
|
+
messages,
|
|
1498
|
+
metaWhatsAppSchema,
|
|
1499
|
+
redeemSseTicket,
|
|
1500
|
+
runMetaWhatsAppMigrations,
|
|
1501
|
+
sessions,
|
|
1502
|
+
settings,
|
|
1503
|
+
verifyWebhookChallenge,
|
|
1504
|
+
verifyWebhookSignature
|
|
1505
|
+
};
|
|
1506
|
+
//# sourceMappingURL=index.js.map
|