@johpaz/hive-sdk 0.4.6 → 0.4.8
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/CHANGELOG.md +90 -0
- package/README.md +1 -1
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +83 -4
- package/docs/SECURITY-GUARDRAILS.md +21 -1
- package/docs/UPGRADING.md +20 -0
- package/package.json +1 -1
- package/packages/cli/templates/hive-app/hive.config.ts +7 -0
- package/packages/cli/templates/hive-app/src/main.ts +3 -0
- package/packages/core/src/agent/agent-loop.ts +3 -1
- package/packages/core/src/agent/capability-search.ts +72 -31
- package/packages/core/src/agent/context-compiler.ts +6 -2
- package/packages/core/src/agent/reflector.ts +23 -10
- package/packages/core/src/channels/index.ts +1 -0
- package/packages/core/src/channels/manager.ts +40 -0
- package/packages/core/src/channels/whatsapp-cloud/channel.ts +334 -0
- package/packages/core/src/channels/whatsapp-cloud/client.ts +368 -0
- package/packages/core/src/channels/whatsapp-cloud/index.ts +3 -0
- package/packages/core/src/channels/whatsapp-cloud/webhook.ts +235 -0
- package/packages/core/src/channels/whatsapp.ts +58 -28
- package/packages/core/src/config/loader.ts +3 -1
- package/packages/core/src/gateway/server.ts +24 -0
- package/packages/core/src/index.ts +17 -0
- package/packages/core/src/mcp/MCPClient.ts +9 -0
- package/packages/core/src/mcp/config.ts +2 -1
- package/packages/core/src/mcp/transports/index.ts +44 -1
- package/packages/core/src/storage/catalog.ts +354 -0
- package/packages/core/src/storage/causal-events.ts +50 -19
- package/packages/core/src/storage/hive.ts +6 -1
- package/packages/core/src/storage/index.ts +14 -1
- package/packages/core/src/storage/seed.ts +161 -128
- package/packages/core/src/tools/cron/index.ts +2 -2
- package/packages/core/src/voice/index.ts +2 -0
|
@@ -5,6 +5,7 @@ import { createTelegramChannel, type TelegramConfig } from "./telegram.ts";
|
|
|
5
5
|
import { createDiscordChannel, type DiscordConfig } from "./discord.ts";
|
|
6
6
|
import { createWebChatChannel, type WebChatConfig } from "./webchat.ts";
|
|
7
7
|
import { createWhatsAppChannel, WhatsAppChannel, type WhatsAppConfig } from "./whatsapp.ts";
|
|
8
|
+
import { createWhatsAppCloudChannel, type WhatsAppCloudConfig } from "./whatsapp-cloud/index.ts";
|
|
8
9
|
import { createSlackChannel, type SlackConfig } from "./slack.ts";
|
|
9
10
|
import { col } from "../storage/hive.ts";
|
|
10
11
|
import type { ChannelDoc, AgentDoc, UserIdentityDoc } from "../storage/collections.ts";
|
|
@@ -216,6 +217,25 @@ export class ChannelManager {
|
|
|
216
217
|
break;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
case "whatsapp_cloud":
|
|
221
|
+
// El de empresa: número de WhatsApp Business propio por la API
|
|
222
|
+
// oficial. No abre ninguna conexión — espera el webhook de Meta.
|
|
223
|
+
channel = createWhatsAppCloudChannel({
|
|
224
|
+
enabled: true,
|
|
225
|
+
accountId,
|
|
226
|
+
phoneNumberId: config.phoneNumberId as string,
|
|
227
|
+
accessToken: config.accessToken as string,
|
|
228
|
+
appSecret: config.appSecret as string,
|
|
229
|
+
verifyToken: config.verifyToken as string,
|
|
230
|
+
graphVersion: config.graphVersion as string | undefined,
|
|
231
|
+
dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "allowlist",
|
|
232
|
+
allowFrom: (config.allowFrom as string[]) ?? [],
|
|
233
|
+
sendProgress: (config.sendProgress as boolean) ?? false,
|
|
234
|
+
windowFallbackTemplate:
|
|
235
|
+
config.windowFallbackTemplate as WhatsAppCloudConfig["windowFallbackTemplate"],
|
|
236
|
+
} as WhatsAppCloudConfig);
|
|
237
|
+
break;
|
|
238
|
+
|
|
219
239
|
case "slack":
|
|
220
240
|
channel = createSlackChannel({
|
|
221
241
|
enabled: true,
|
|
@@ -374,6 +394,26 @@ export class ChannelManager {
|
|
|
374
394
|
}
|
|
375
395
|
}
|
|
376
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Entrega al canal correspondiente lo que llega por webhook — hoy, WhatsApp
|
|
399
|
+
* por la API oficial de Meta.
|
|
400
|
+
*
|
|
401
|
+
* Vive acá y no en el gateway porque quién tiene levantada cada cuenta lo
|
|
402
|
+
* sabe el manager; el gateway sólo ve una URL.
|
|
403
|
+
*/
|
|
404
|
+
async handleWebhook(type: string, accountId: string, req: Request): Promise<Response> {
|
|
405
|
+
const channel = this.channels.get(`${type}:${accountId}`) as
|
|
406
|
+
| { handleWebhook?: (req: Request) => Promise<Response> }
|
|
407
|
+
| undefined;
|
|
408
|
+
|
|
409
|
+
if (typeof channel?.handleWebhook !== "function") {
|
|
410
|
+
this.log.warn(`webhook para ${type}:${accountId}, que no está levantado`);
|
|
411
|
+
return new Response("Unknown channel account", { status: 404 });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return channel.handleWebhook(req);
|
|
415
|
+
}
|
|
416
|
+
|
|
377
417
|
getChannelStatus(type: string, accountId: string): { status: string; qrCode?: string } {
|
|
378
418
|
const key = `${type}:${accountId}`;
|
|
379
419
|
const channel = this.channels.get(key);
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de WhatsApp por la API oficial de Meta (Cloud API).
|
|
3
|
+
*
|
|
4
|
+
* Es el camino para empresas: número de WhatsApp Business propio, plantillas de
|
|
5
|
+
* marketing y atribución de los anuncios que abren una conversación. El otro
|
|
6
|
+
* canal de WhatsApp del SDK (`whatsapp`, con Baileys y código QR) no es oficial
|
|
7
|
+
* y sirve para uso personal, no para un negocio.
|
|
8
|
+
*
|
|
9
|
+
* A diferencia de los demás canales, este no abre ninguna conexión: Meta empuja
|
|
10
|
+
* los mensajes a una URL pública. Quien hospeda el canal tiene que enrutarle las
|
|
11
|
+
* peticiones a `handleWebhook()`; el gateway del SDK ya lo hace en
|
|
12
|
+
* `/webhooks/whatsapp-cloud/:accountId`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { BaseChannel, type ChannelConfig, type IncomingMessage, type OutboundMessage } from "../base.ts";
|
|
16
|
+
import { logger } from "../../utils/logger.ts";
|
|
17
|
+
import { updateDoc } from "../../storage/hive.ts";
|
|
18
|
+
import type { ChannelDoc } from "../../storage/collections.ts";
|
|
19
|
+
import {
|
|
20
|
+
CUSTOMER_WINDOW_MS,
|
|
21
|
+
WhatsAppCloudClient,
|
|
22
|
+
WhatsAppCloudError,
|
|
23
|
+
type WhatsAppTemplate,
|
|
24
|
+
} from "./client.ts";
|
|
25
|
+
import {
|
|
26
|
+
parseWebhook,
|
|
27
|
+
safeTokenEquals,
|
|
28
|
+
verifyChallenge,
|
|
29
|
+
verifySignature,
|
|
30
|
+
type WhatsAppInbound,
|
|
31
|
+
} from "./webhook.ts";
|
|
32
|
+
|
|
33
|
+
/** Cuántos ids de mensaje se recuerdan para descartar los reintentos de Meta. */
|
|
34
|
+
const SEEN_LIMIT = 1000;
|
|
35
|
+
|
|
36
|
+
export interface WhatsAppCloudConfig extends ChannelConfig {
|
|
37
|
+
accountId: string;
|
|
38
|
+
/** Id del número en Meta (no el número en sí). */
|
|
39
|
+
phoneNumberId: string;
|
|
40
|
+
/** Token permanente, normalmente de un usuario del sistema. */
|
|
41
|
+
accessToken: string;
|
|
42
|
+
/** Secreto de la app de Meta, con el que se firma cada webhook. */
|
|
43
|
+
appSecret: string;
|
|
44
|
+
/** El token que uno inventa y pega en el panel de Meta al registrar la URL. */
|
|
45
|
+
verifyToken: string;
|
|
46
|
+
graphVersion?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Mandar la narración intermedia del agente como mensajes sueltos.
|
|
49
|
+
*
|
|
50
|
+
* Apagado a propósito: desde el 1/10/2026 Meta cobra cada mensaje de servicio
|
|
51
|
+
* dentro de la ventana, así que narrar el progreso sale caro. En su lugar se
|
|
52
|
+
* renueva el "escribiendo…", que no cuesta nada.
|
|
53
|
+
*/
|
|
54
|
+
sendProgress?: boolean;
|
|
55
|
+
/** Plantilla aprobada para hablarle a alguien fuera de la ventana de 24 h. */
|
|
56
|
+
windowFallbackTemplate?: WhatsAppTemplate;
|
|
57
|
+
/** Inyectable para tests o para salir por un proxy. */
|
|
58
|
+
fetch?: typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WhatsAppCloudState {
|
|
62
|
+
status: "disconnected" | "pending_verification" | "connected" | "error";
|
|
63
|
+
phoneNumberId: string;
|
|
64
|
+
lastWebhookAt?: number;
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class WhatsAppCloudChannel extends BaseChannel {
|
|
69
|
+
name = "whatsapp_cloud";
|
|
70
|
+
accountId: string;
|
|
71
|
+
config: WhatsAppCloudConfig;
|
|
72
|
+
|
|
73
|
+
readonly client: WhatsAppCloudClient;
|
|
74
|
+
|
|
75
|
+
private state: WhatsAppCloudState;
|
|
76
|
+
private log = logger.child("whatsapp-cloud");
|
|
77
|
+
/** Último mensaje entrante por sesión: abre la ventana y da el id para el "escribiendo…". */
|
|
78
|
+
private lastInbound: Map<string, { at: number; messageId: string }> = new Map();
|
|
79
|
+
private seen: Set<string> = new Set();
|
|
80
|
+
|
|
81
|
+
constructor(config: WhatsAppCloudConfig) {
|
|
82
|
+
super();
|
|
83
|
+
this.config = config;
|
|
84
|
+
this.accountId = config.accountId;
|
|
85
|
+
this.client = new WhatsAppCloudClient({
|
|
86
|
+
phoneNumberId: config.phoneNumberId,
|
|
87
|
+
accessToken: config.accessToken,
|
|
88
|
+
graphVersion: config.graphVersion,
|
|
89
|
+
fetch: config.fetch,
|
|
90
|
+
});
|
|
91
|
+
this.state = { status: "disconnected", phoneNumberId: config.phoneNumberId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** La ruta que hay que registrar en el panel de Meta, detrás de HTTPS público. */
|
|
95
|
+
get webhookPath(): string {
|
|
96
|
+
return `/webhooks/whatsapp-cloud/${this.accountId}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async start(): Promise<void> {
|
|
100
|
+
this.running = true;
|
|
101
|
+
// No hay nada que conectar: queda esperando el webhook. Hasta que Meta haga
|
|
102
|
+
// su verificación, el canal está dado de alta pero no recibe nada.
|
|
103
|
+
this.state.status = this.state.lastWebhookAt ? "connected" : "pending_verification";
|
|
104
|
+
this.log.info(`Canal listo — registrá ${this.webhookPath} en la app de Meta`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async stop(): Promise<void> {
|
|
108
|
+
this.running = false;
|
|
109
|
+
this.state.status = "disconnected";
|
|
110
|
+
this.log.info("Canal detenido");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Atiende una petición de Meta: la verificación inicial y cada mensaje.
|
|
115
|
+
*
|
|
116
|
+
* Responde 200 en cuanto el evento es válido, sin esperar el turno del
|
|
117
|
+
* agente: Meta reintenta lo que tarde y termina desuscribiendo la app.
|
|
118
|
+
*/
|
|
119
|
+
async handleWebhook(req: Request): Promise<Response> {
|
|
120
|
+
if (req.method === "GET") {
|
|
121
|
+
const challenge = verifyChallenge(new URL(req.url).searchParams, (token) =>
|
|
122
|
+
safeTokenEquals(token, this.config.verifyToken)
|
|
123
|
+
);
|
|
124
|
+
if (!challenge) {
|
|
125
|
+
this.log.warn("verificación rechazada: token incorrecto");
|
|
126
|
+
return new Response("Verification failed", { status: 403 });
|
|
127
|
+
}
|
|
128
|
+
await this.setStatus("connected");
|
|
129
|
+
return new Response(challenge, { status: 200 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (req.method !== "POST") {
|
|
133
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rawBody = await req.text();
|
|
137
|
+
if (!verifySignature(rawBody, req.headers.get("x-hub-signature-256"), this.config.appSecret)) {
|
|
138
|
+
this.log.warn("firma HMAC inválida — evento descartado");
|
|
139
|
+
return new Response("Invalid signature", { status: 401 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let body: unknown;
|
|
143
|
+
try {
|
|
144
|
+
body = JSON.parse(rawBody);
|
|
145
|
+
} catch {
|
|
146
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.state.lastWebhookAt = Date.now();
|
|
150
|
+
if (this.state.status !== "connected") await this.setStatus("connected");
|
|
151
|
+
|
|
152
|
+
for (const event of parseWebhook(body)) {
|
|
153
|
+
// Un mismo WABA puede tener varios números apuntando a la misma URL.
|
|
154
|
+
if (event.phoneNumberId !== this.config.phoneNumberId) continue;
|
|
155
|
+
|
|
156
|
+
for (const message of event.messages) {
|
|
157
|
+
void this.ingest(message).catch((error) =>
|
|
158
|
+
this.log.error(`no se pudo procesar ${message.id}: ${(error as Error).message}`)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return new Response("EVENT_RECEIVED", { status: 200 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async send(sessionId: string, message: OutboundMessage): Promise<void> {
|
|
167
|
+
const isInterim = message.type === "progress";
|
|
168
|
+
if (isInterim && !this.config.sendProgress) {
|
|
169
|
+
// La narración no se manda: cada mensaje intermedio se cobra. El
|
|
170
|
+
// "escribiendo…" cuenta lo mismo y es gratis.
|
|
171
|
+
await this.startTyping(sessionId).catch(() => {});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const text = message.content ?? message.chunk ?? "";
|
|
176
|
+
if (!text) return;
|
|
177
|
+
|
|
178
|
+
const to = this.toRecipient(sessionId);
|
|
179
|
+
const last = this.lastInbound.get(sessionId);
|
|
180
|
+
const expired = last ? Date.now() - last.at >= CUSTOMER_WINDOW_MS : false;
|
|
181
|
+
|
|
182
|
+
// Sin registro de entrante (por ejemplo tras un reinicio) se intenta igual:
|
|
183
|
+
// la ventana puede estar abierta y quien manda la última palabra es Meta.
|
|
184
|
+
if (!expired) {
|
|
185
|
+
try {
|
|
186
|
+
await this.client.sendText(to, text);
|
|
187
|
+
return;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (!(error instanceof WhatsAppCloudError) || !error.windowClosed) throw error;
|
|
190
|
+
this.log.info(`ventana cerrada para ${to} según Meta`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await this.sendOutsideWindow(to, text);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async sendAudio(sessionId: string, audio: Buffer, mimeType: string): Promise<void> {
|
|
198
|
+
await this.client.sendAudio(this.toRecipient(sessionId), audio, mimeType);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Muestra "escribiendo…". Meta lo apaga al responder o a los 25 segundos. */
|
|
202
|
+
async startTyping(sessionId: string): Promise<void> {
|
|
203
|
+
const last = this.lastInbound.get(sessionId);
|
|
204
|
+
if (!last) return;
|
|
205
|
+
await this.client.markRead(last.messageId, { typing: true }).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** No hace falta apagarlo: responder ya lo apaga. */
|
|
209
|
+
async stopTyping(_sessionId: string): Promise<void> {}
|
|
210
|
+
|
|
211
|
+
async markAsRead(sessionId: string, messageId?: string): Promise<void> {
|
|
212
|
+
const id = messageId ?? this.lastInbound.get(sessionId)?.messageId;
|
|
213
|
+
if (!id) return;
|
|
214
|
+
await this.client.markRead(id).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
getState(): WhatsAppCloudState {
|
|
218
|
+
return { ...this.state };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getConfig(): WhatsAppCloudConfig {
|
|
222
|
+
return { ...this.config };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async ingest(message: WhatsAppInbound): Promise<void> {
|
|
226
|
+
if (!message.id || this.seen.has(message.id)) return;
|
|
227
|
+
this.remember(message.id);
|
|
228
|
+
|
|
229
|
+
if (!this.isUserAllowed(message.from)) {
|
|
230
|
+
this.log.info(`mensaje descartado, ${message.from} no está en la lista permitida`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const sessionId = this.formatSessionId(message.from, "direct");
|
|
235
|
+
this.lastInbound.set(sessionId, { at: Date.now(), messageId: message.id });
|
|
236
|
+
|
|
237
|
+
const incoming: IncomingMessage = {
|
|
238
|
+
sessionId,
|
|
239
|
+
channel: this.name,
|
|
240
|
+
accountId: this.accountId,
|
|
241
|
+
peerId: message.from,
|
|
242
|
+
peerKind: "direct",
|
|
243
|
+
content: message.text ?? "",
|
|
244
|
+
metadata: {
|
|
245
|
+
messageId: message.id,
|
|
246
|
+
timestamp: message.timestamp,
|
|
247
|
+
pushName: message.profileName,
|
|
248
|
+
type: message.type,
|
|
249
|
+
...(message.referral ? { referral: message.referral } : {}),
|
|
250
|
+
...(message.interactive ? { interactive: message.interactive } : {}),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (message.media?.id && message.mediaKind) {
|
|
255
|
+
try {
|
|
256
|
+
const media = await this.client.downloadMedia(message.media.id);
|
|
257
|
+
if (message.mediaKind === "audio") {
|
|
258
|
+
incoming.audio = { buffer: media.buffer, mimeType: media.mimeType };
|
|
259
|
+
if (!incoming.content) incoming.content = "[Audio message]";
|
|
260
|
+
} else if (message.mediaKind === "image" || message.mediaKind === "sticker") {
|
|
261
|
+
incoming.image = {
|
|
262
|
+
buffer: media.buffer,
|
|
263
|
+
mimeType: media.mimeType,
|
|
264
|
+
caption: message.media.caption,
|
|
265
|
+
};
|
|
266
|
+
} else if (message.mediaKind === "document") {
|
|
267
|
+
incoming.document = {
|
|
268
|
+
buffer: media.buffer,
|
|
269
|
+
mimeType: media.mimeType,
|
|
270
|
+
fileName: message.media.fileName ?? media.fileName,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
this.log.warn(`no se pudo bajar el medio ${message.media.id}: ${(error as Error).message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!incoming.content && !incoming.audio && !incoming.image && !incoming.document) {
|
|
279
|
+
this.log.debug(`mensaje ${message.id} de tipo ${message.type} sin contenido utilizable`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Acuse de recibo inmediato: el doble tilde y el "escribiendo…" son lo que
|
|
284
|
+
// le dice a la persona que su mensaje llegó, aunque la respuesta demore.
|
|
285
|
+
void this.client.markRead(message.id, { typing: true }).catch(() => {});
|
|
286
|
+
|
|
287
|
+
await this.handleMessage(incoming);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async sendOutsideWindow(to: string, text: string): Promise<void> {
|
|
291
|
+
const template = this.config.windowFallbackTemplate;
|
|
292
|
+
if (!template) {
|
|
293
|
+
throw new WhatsAppCloudError(
|
|
294
|
+
400,
|
|
295
|
+
131047,
|
|
296
|
+
"Pasaron más de 24 h desde el último mensaje del cliente: Meta sólo acepta " +
|
|
297
|
+
"una plantilla aprobada. Configurá `windowFallbackTemplate` en el canal."
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
this.log.info(`fuera de la ventana: se manda la plantilla "${template.name}" en vez del texto`);
|
|
301
|
+
this.log.debug(`texto no enviado: ${text.slice(0, 120)}`);
|
|
302
|
+
await this.client.sendTemplate(to, template);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** El sessionId es el `wa_id`; se tolera un prefijo por si alguien lo compone. */
|
|
306
|
+
private toRecipient(sessionId: string): string {
|
|
307
|
+
const parts = sessionId.split(":");
|
|
308
|
+
return (parts[parts.length - 1] ?? "").replace(/\D/g, "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private remember(messageId: string): void {
|
|
312
|
+
this.seen.add(messageId);
|
|
313
|
+
if (this.seen.size > SEEN_LIMIT) {
|
|
314
|
+
const oldest = this.seen.values().next().value;
|
|
315
|
+
if (oldest) this.seen.delete(oldest);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async setStatus(status: WhatsAppCloudState["status"]): Promise<void> {
|
|
320
|
+
this.state.status = status;
|
|
321
|
+
try {
|
|
322
|
+
await updateDoc<ChannelDoc>("channels", this.accountId, {
|
|
323
|
+
status,
|
|
324
|
+
last_active: Date.now(),
|
|
325
|
+
});
|
|
326
|
+
} catch {
|
|
327
|
+
// Sin base no se pierde nada: el estado en memoria ya quedó bien.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function createWhatsAppCloudChannel(config: WhatsAppCloudConfig): WhatsAppCloudChannel {
|
|
333
|
+
return new WhatsAppCloudChannel(config);
|
|
334
|
+
}
|