@diegoaltoworks/chatter 0.27.0 → 0.29.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/bin/wa-pair.js +320 -0
- package/dist/channels/index.d.ts +47 -0
- package/dist/channels/index.d.ts.map +1 -0
- package/dist/channels/index.js +137 -0
- package/dist/channels/index.mjs +105 -0
- package/dist/channels/senders.d.ts +30 -0
- package/dist/channels/senders.d.ts.map +1 -0
- package/dist/channels/senders.test.d.ts +2 -0
- package/dist/channels/senders.test.d.ts.map +1 -0
- package/dist/channels/whatsapp/authState.d.ts +39 -0
- package/dist/channels/whatsapp/authState.d.ts.map +1 -0
- package/dist/channels/whatsapp/authState.test.d.ts +2 -0
- package/dist/channels/whatsapp/authState.test.d.ts.map +1 -0
- package/dist/channels/whatsapp/baileys.d.ts +21 -0
- package/dist/channels/whatsapp/baileys.d.ts.map +1 -0
- package/dist/channels/whatsapp/baileys.test.d.ts +2 -0
- package/dist/channels/whatsapp/baileys.test.d.ts.map +1 -0
- package/dist/channels/whatsapp/channel.d.ts +95 -0
- package/dist/channels/whatsapp/channel.d.ts.map +1 -0
- package/dist/channels/whatsapp/channel.test.d.ts +2 -0
- package/dist/channels/whatsapp/channel.test.d.ts.map +1 -0
- package/dist/channels/whatsapp/crypto.d.ts +10 -0
- package/dist/channels/whatsapp/crypto.d.ts.map +1 -0
- package/dist/channels/whatsapp/crypto.test.d.ts +2 -0
- package/dist/channels/whatsapp/crypto.test.d.ts.map +1 -0
- package/dist/channels/whatsapp/index.d.ts +33 -0
- package/dist/channels/whatsapp/index.d.ts.map +1 -0
- package/dist/channels/whatsapp/index.js +478 -0
- package/dist/channels/whatsapp/index.mjs +428 -0
- package/dist/channels/whatsapp/lease.d.ts +42 -0
- package/dist/channels/whatsapp/lease.d.ts.map +1 -0
- package/dist/channels/whatsapp/lease.test.d.ts +2 -0
- package/dist/channels/whatsapp/lease.test.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +83 -2
- package/dist/index.mjs +83 -2
- package/dist/server.d.ts +21 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +29 -4
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// src/channels/whatsapp/crypto.ts
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
3
|
+
var IV_LENGTH = 12;
|
|
4
|
+
var TAG_LENGTH = 16;
|
|
5
|
+
function deriveKey(secret) {
|
|
6
|
+
return createHash("sha256").update(secret, "utf-8").digest();
|
|
7
|
+
}
|
|
8
|
+
function encrypt(plaintext, secret) {
|
|
9
|
+
const iv = randomBytes(IV_LENGTH);
|
|
10
|
+
const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
|
|
11
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
12
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64");
|
|
13
|
+
}
|
|
14
|
+
function decrypt(payload, secret) {
|
|
15
|
+
const raw = Buffer.from(payload, "base64");
|
|
16
|
+
const iv = raw.subarray(0, IV_LENGTH);
|
|
17
|
+
const tag = raw.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
18
|
+
const ciphertext = raw.subarray(IV_LENGTH + TAG_LENGTH);
|
|
19
|
+
const decipher = createDecipheriv("aes-256-gcm", deriveKey(secret), iv);
|
|
20
|
+
decipher.setAuthTag(tag);
|
|
21
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/channels/whatsapp/authState.ts
|
|
25
|
+
var schemaReady = /* @__PURE__ */ new WeakMap();
|
|
26
|
+
function ensureAuthSchema(client) {
|
|
27
|
+
let ready = schemaReady.get(client);
|
|
28
|
+
if (!ready) {
|
|
29
|
+
ready = client.execute("CREATE TABLE IF NOT EXISTS wa_auth (id TEXT PRIMARY KEY, value TEXT NOT NULL)").catch((error) => {
|
|
30
|
+
schemaReady.delete(client);
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
schemaReady.set(client, ready);
|
|
34
|
+
}
|
|
35
|
+
return ready;
|
|
36
|
+
}
|
|
37
|
+
async function useTursoAuthState(db, secret, sessionId, runtime) {
|
|
38
|
+
await ensureAuthSchema(db);
|
|
39
|
+
const rowId = (id) => sessionId === "default" ? id : `s:${sessionId}/${id}`;
|
|
40
|
+
async function read(id) {
|
|
41
|
+
const result = await db.execute({
|
|
42
|
+
sql: "SELECT value FROM wa_auth WHERE id = ?",
|
|
43
|
+
args: [rowId(id)]
|
|
44
|
+
});
|
|
45
|
+
const row = result.rows[0];
|
|
46
|
+
if (!row) return null;
|
|
47
|
+
return JSON.parse(decrypt(row.value, secret), runtime.bufferJSON.reviver);
|
|
48
|
+
}
|
|
49
|
+
async function write(id, value) {
|
|
50
|
+
const payload = encrypt(JSON.stringify(value, runtime.bufferJSON.replacer), secret);
|
|
51
|
+
await db.execute({
|
|
52
|
+
sql: "INSERT OR REPLACE INTO wa_auth (id, value) VALUES (?, ?)",
|
|
53
|
+
args: [rowId(id), payload]
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function remove(id) {
|
|
57
|
+
await db.execute({ sql: "DELETE FROM wa_auth WHERE id = ?", args: [rowId(id)] });
|
|
58
|
+
}
|
|
59
|
+
const creds = await read("creds") ?? runtime.initAuthCreds();
|
|
60
|
+
return {
|
|
61
|
+
state: {
|
|
62
|
+
creds,
|
|
63
|
+
keys: {
|
|
64
|
+
get: async (type, ids) => {
|
|
65
|
+
const data = {};
|
|
66
|
+
await Promise.all(
|
|
67
|
+
ids.map(async (id) => {
|
|
68
|
+
let value = await read(`${type}-${id}`);
|
|
69
|
+
if (type === "app-state-sync-key" && value) {
|
|
70
|
+
value = runtime.appStateSyncKeyFromObject(
|
|
71
|
+
value
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (value) data[id] = value;
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
return data;
|
|
78
|
+
},
|
|
79
|
+
set: async (data) => {
|
|
80
|
+
const jobs = [];
|
|
81
|
+
for (const category of Object.keys(data)) {
|
|
82
|
+
const entries = data[category];
|
|
83
|
+
if (!entries) continue;
|
|
84
|
+
for (const id of Object.keys(entries)) {
|
|
85
|
+
const value = entries[id];
|
|
86
|
+
jobs.push(value ? write(`${category}-${id}`, value) : remove(`${category}-${id}`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await Promise.all(jobs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
saveCreds: () => write("creds", creds),
|
|
94
|
+
clear: async () => {
|
|
95
|
+
if (sessionId === "default") {
|
|
96
|
+
await db.execute("DELETE FROM wa_auth WHERE id NOT LIKE 's:%'");
|
|
97
|
+
} else {
|
|
98
|
+
await db.execute({
|
|
99
|
+
sql: "DELETE FROM wa_auth WHERE id LIKE ?",
|
|
100
|
+
args: [`s:${sessionId}/%`]
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/channels/whatsapp/baileys.ts
|
|
108
|
+
function wrapMissingBaileysError(cause) {
|
|
109
|
+
return new Error(
|
|
110
|
+
"The WhatsApp channel ('@diegoaltoworks/chatter/whatsapp') requires the optional peer dependency '@whiskeysockets/baileys', which is not installed. Install it with `bun add @whiskeysockets/baileys` (or npm/pnpm/yarn) to use this channel.",
|
|
111
|
+
{ cause }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
var cached;
|
|
115
|
+
function loadBaileys() {
|
|
116
|
+
if (!cached) {
|
|
117
|
+
cached = import("@whiskeysockets/baileys").catch((error) => {
|
|
118
|
+
cached = void 0;
|
|
119
|
+
throw wrapMissingBaileysError(error);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return cached;
|
|
123
|
+
}
|
|
124
|
+
function silentLogger() {
|
|
125
|
+
const noop = () => void 0;
|
|
126
|
+
const logger = {
|
|
127
|
+
level: "silent",
|
|
128
|
+
trace: noop,
|
|
129
|
+
debug: noop,
|
|
130
|
+
info: noop,
|
|
131
|
+
warn: noop,
|
|
132
|
+
error: noop,
|
|
133
|
+
fatal: noop
|
|
134
|
+
};
|
|
135
|
+
logger.child = () => logger;
|
|
136
|
+
return logger;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/channels/whatsapp/lease.ts
|
|
140
|
+
var LEASE_STALE_MS = 9e4;
|
|
141
|
+
var LEASE_HEARTBEAT_MS = 3e4;
|
|
142
|
+
var LEASE_WAIT_MS = 3e4;
|
|
143
|
+
function canAcquireLease(existing, instanceId, now, staleMs = LEASE_STALE_MS) {
|
|
144
|
+
if (!existing) return true;
|
|
145
|
+
if (existing.instanceId === instanceId) return true;
|
|
146
|
+
return now - existing.heartbeatAt >= staleMs;
|
|
147
|
+
}
|
|
148
|
+
var schemaReady2 = /* @__PURE__ */ new WeakMap();
|
|
149
|
+
function ensureWaLeaseSchema(client) {
|
|
150
|
+
let ready = schemaReady2.get(client);
|
|
151
|
+
if (!ready) {
|
|
152
|
+
ready = client.execute(
|
|
153
|
+
`CREATE TABLE IF NOT EXISTS wa_lease (
|
|
154
|
+
session_id TEXT PRIMARY KEY,
|
|
155
|
+
instance_id TEXT NOT NULL,
|
|
156
|
+
heartbeat_at INTEGER NOT NULL
|
|
157
|
+
)`
|
|
158
|
+
).catch((error) => {
|
|
159
|
+
schemaReady2.delete(client);
|
|
160
|
+
throw error;
|
|
161
|
+
});
|
|
162
|
+
schemaReady2.set(client, ready);
|
|
163
|
+
}
|
|
164
|
+
return ready;
|
|
165
|
+
}
|
|
166
|
+
function createTursoWaLeaseStore(client) {
|
|
167
|
+
return {
|
|
168
|
+
async tryAcquire(sessionId, instanceId, now, staleMs) {
|
|
169
|
+
await ensureWaLeaseSchema(client);
|
|
170
|
+
const result = await client.execute({
|
|
171
|
+
sql: `INSERT INTO wa_lease (session_id, instance_id, heartbeat_at) VALUES (?, ?, ?)
|
|
172
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
173
|
+
instance_id = excluded.instance_id,
|
|
174
|
+
heartbeat_at = excluded.heartbeat_at
|
|
175
|
+
WHERE wa_lease.instance_id = excluded.instance_id
|
|
176
|
+
OR wa_lease.heartbeat_at <= excluded.heartbeat_at - ?
|
|
177
|
+
RETURNING instance_id`,
|
|
178
|
+
args: [sessionId, instanceId, now, staleMs]
|
|
179
|
+
});
|
|
180
|
+
const row = result.rows[0];
|
|
181
|
+
return Boolean(row) && row.instance_id === instanceId;
|
|
182
|
+
},
|
|
183
|
+
async release(sessionId, instanceId) {
|
|
184
|
+
await ensureWaLeaseSchema(client);
|
|
185
|
+
await client.execute({
|
|
186
|
+
sql: "DELETE FROM wa_lease WHERE session_id = ? AND instance_id = ?",
|
|
187
|
+
args: [sessionId, instanceId]
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/channels/whatsapp/channel.ts
|
|
194
|
+
var RECONNECT_DELAY_MS = 5e3;
|
|
195
|
+
var RECONNECT_MAX_DELAY_MS = 10 * 60 * 1e3;
|
|
196
|
+
function reconnectDelayMs(failures) {
|
|
197
|
+
return Math.min(RECONNECT_DELAY_MS * 2 ** Math.min(failures, 20), RECONNECT_MAX_DELAY_MS);
|
|
198
|
+
}
|
|
199
|
+
async function shutdownWaSessions(sessions) {
|
|
200
|
+
await Promise.all(
|
|
201
|
+
[...sessions.values()].map(async (handle) => {
|
|
202
|
+
handle.stopHeartbeat();
|
|
203
|
+
handle.sock.end(void 0);
|
|
204
|
+
await handle.release().catch(() => void 0);
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
async function loseSession(sessionId, sessions) {
|
|
209
|
+
const handle = sessions.get(sessionId);
|
|
210
|
+
if (!handle) return;
|
|
211
|
+
sessions.delete(sessionId);
|
|
212
|
+
await shutdownWaSessions(/* @__PURE__ */ new Map([[sessionId, handle]]));
|
|
213
|
+
}
|
|
214
|
+
async function acquireSessionLease(sessionId, deps) {
|
|
215
|
+
const { leaseStore, instanceId, sessions, isStopped, connect } = deps;
|
|
216
|
+
const now = deps.now ?? Date.now;
|
|
217
|
+
const staleMs = deps.staleMs ?? LEASE_STALE_MS;
|
|
218
|
+
const heartbeatMs = deps.heartbeatMs ?? LEASE_HEARTBEAT_MS;
|
|
219
|
+
const waitMs = deps.waitMs ?? LEASE_WAIT_MS;
|
|
220
|
+
const schedule = deps.schedule ?? ((fn, ms) => void setTimeout(fn, ms));
|
|
221
|
+
if (isStopped()) return;
|
|
222
|
+
const retry = () => {
|
|
223
|
+
schedule(
|
|
224
|
+
() => void acquireSessionLease(sessionId, deps).catch(
|
|
225
|
+
(error) => console.error(`WhatsApp[${sessionId}]: lease re-check failed:`, error)
|
|
226
|
+
),
|
|
227
|
+
waitMs
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
let acquired;
|
|
231
|
+
try {
|
|
232
|
+
acquired = await leaseStore.tryAcquire(sessionId, instanceId, now(), staleMs);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.warn(`WhatsApp[${sessionId}]: lease acquire failed, retrying:`, error);
|
|
235
|
+
retry();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!acquired) {
|
|
239
|
+
console.warn(`WhatsApp[${sessionId}]: lease held by another instance, re-checking later`);
|
|
240
|
+
retry();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const heartbeat = setInterval(() => {
|
|
244
|
+
void leaseStore.tryAcquire(sessionId, instanceId, now(), staleMs).then((stillHeld) => {
|
|
245
|
+
if (stillHeld || isStopped()) return;
|
|
246
|
+
console.error(`WhatsApp[${sessionId}]: lost the lease to another instance \u2014 disconnecting`);
|
|
247
|
+
void loseSession(sessionId, sessions).then(retry);
|
|
248
|
+
}).catch((error) => console.warn(`WhatsApp[${sessionId}]: lease heartbeat failed:`, error));
|
|
249
|
+
}, heartbeatMs);
|
|
250
|
+
if (typeof heartbeat === "object" && "unref" in heartbeat) heartbeat.unref();
|
|
251
|
+
sessions.set(sessionId, {
|
|
252
|
+
sock: { end: () => void 0 },
|
|
253
|
+
stopHeartbeat: () => clearInterval(heartbeat),
|
|
254
|
+
release: () => leaseStore.release(sessionId, instanceId)
|
|
255
|
+
});
|
|
256
|
+
try {
|
|
257
|
+
await connect(sessionId);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(`WhatsApp[${sessionId}]: connect failed right after acquiring the lease:`, error);
|
|
260
|
+
await loseSession(sessionId, sessions);
|
|
261
|
+
retry();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function senderNameFor(channelName, sessionId) {
|
|
265
|
+
return sessionId === "default" ? channelName : `${channelName}:${sessionId}`;
|
|
266
|
+
}
|
|
267
|
+
function createWhatsAppChannel(config) {
|
|
268
|
+
const channelName = "whatsapp";
|
|
269
|
+
const sessionIds = config.sessionIds && config.sessionIds.length > 0 ? config.sessionIds : ["default"];
|
|
270
|
+
const now = config.now ?? Date.now;
|
|
271
|
+
const schedule = config.schedule ?? ((fn, ms) => void setTimeout(fn, ms));
|
|
272
|
+
const staleMs = config.staleMs ?? LEASE_STALE_MS;
|
|
273
|
+
const heartbeatMs = config.heartbeatMs ?? LEASE_HEARTBEAT_MS;
|
|
274
|
+
const waitMs = config.waitMs ?? LEASE_WAIT_MS;
|
|
275
|
+
const loadBaileys2 = config.loadBaileys ?? loadBaileys;
|
|
276
|
+
let stopped = false;
|
|
277
|
+
const instanceId = crypto.randomUUID();
|
|
278
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
279
|
+
const failureCounts = /* @__PURE__ */ new Map();
|
|
280
|
+
const registeredSenderNames = /* @__PURE__ */ new Map();
|
|
281
|
+
let senders;
|
|
282
|
+
return {
|
|
283
|
+
name: channelName,
|
|
284
|
+
async start(deps) {
|
|
285
|
+
const db = deps.db;
|
|
286
|
+
const leaseStore = createTursoWaLeaseStore(db);
|
|
287
|
+
senders = deps.senders;
|
|
288
|
+
await loadBaileys2();
|
|
289
|
+
async function connect(sessionId) {
|
|
290
|
+
if (stopped || !sessions.has(sessionId)) return;
|
|
291
|
+
const baileys = await loadBaileys2();
|
|
292
|
+
const runtime = {
|
|
293
|
+
bufferJSON: baileys.BufferJSON,
|
|
294
|
+
initAuthCreds: baileys.initAuthCreds,
|
|
295
|
+
appStateSyncKeyFromObject: (value) => baileys.proto.Message.AppStateSyncKeyData.fromObject(value)
|
|
296
|
+
};
|
|
297
|
+
const { state, saveCreds } = await useTursoAuthState(
|
|
298
|
+
db,
|
|
299
|
+
config.sessionSecret,
|
|
300
|
+
sessionId,
|
|
301
|
+
runtime
|
|
302
|
+
);
|
|
303
|
+
if (!state.creds.registered) {
|
|
304
|
+
console.warn(
|
|
305
|
+
`WhatsApp[${sessionId}]: no paired session found \u2014 pair it first (see docs/channels.md). Re-checking in 60s.`
|
|
306
|
+
);
|
|
307
|
+
schedule(() => void connect(sessionId).catch(() => void 0), 6e4);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const { version } = await baileys.fetchLatestBaileysVersion();
|
|
311
|
+
const logger = silentLogger();
|
|
312
|
+
const sock = baileys.makeWASocket({
|
|
313
|
+
version,
|
|
314
|
+
auth: {
|
|
315
|
+
creds: state.creds,
|
|
316
|
+
// biome-ignore lint/suspicious/noExplicitAny: silentLogger is structurally pino-compatible; Baileys' logger type is not exported
|
|
317
|
+
keys: baileys.makeCacheableSignalKeyStore(state.keys, logger)
|
|
318
|
+
},
|
|
319
|
+
// biome-ignore lint/suspicious/noExplicitAny: silentLogger is structurally pino-compatible; Baileys' logger type is not exported
|
|
320
|
+
logger,
|
|
321
|
+
markOnlineOnConnect: false,
|
|
322
|
+
syncFullHistory: false
|
|
323
|
+
});
|
|
324
|
+
const leaseHandle = sessions.get(sessionId);
|
|
325
|
+
if (leaseHandle) leaseHandle.sock = sock;
|
|
326
|
+
sock.ev.on(
|
|
327
|
+
"creds.update",
|
|
328
|
+
() => void saveCreds().catch(
|
|
329
|
+
(error) => console.warn(`WhatsApp[${sessionId}]: creds save failed:`, error)
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
sock.ev.on("connection.update", (update) => {
|
|
333
|
+
const { connection, lastDisconnect } = update;
|
|
334
|
+
if (connection === "open") {
|
|
335
|
+
failureCounts.set(sessionId, 0);
|
|
336
|
+
console.log(`WhatsApp[${sessionId}]: connected as ${sock.user?.id ?? "unknown"}`);
|
|
337
|
+
const senderName = senderNameFor(channelName, sessionId);
|
|
338
|
+
const sender = {
|
|
339
|
+
sendText: async (chatId, text) => {
|
|
340
|
+
await sock.sendMessage(chatId, { text });
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
deps.senders.register(senderName, sender);
|
|
344
|
+
registeredSenderNames.set(sessionId, senderName);
|
|
345
|
+
}
|
|
346
|
+
if (connection === "close") {
|
|
347
|
+
const senderName = registeredSenderNames.get(sessionId);
|
|
348
|
+
if (senderName) {
|
|
349
|
+
deps.senders.unregister(senderName);
|
|
350
|
+
registeredSenderNames.delete(sessionId);
|
|
351
|
+
}
|
|
352
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
353
|
+
if (statusCode === baileys.DisconnectReason.loggedOut) {
|
|
354
|
+
console.error(
|
|
355
|
+
`WhatsApp[${sessionId}]: session logged out \u2014 re-pair to reconnect (see docs/channels.md).`
|
|
356
|
+
);
|
|
357
|
+
void loseSession(sessionId, sessions);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!stopped && sessions.has(sessionId)) {
|
|
361
|
+
const delay = reconnectDelayMs(failureCounts.get(sessionId) ?? 0);
|
|
362
|
+
failureCounts.set(sessionId, (failureCounts.get(sessionId) ?? 0) + 1);
|
|
363
|
+
console.warn(
|
|
364
|
+
`WhatsApp[${sessionId}]: connection closed (${statusCode ?? "?"}), reconnecting in ${Math.round(delay / 1e3)}s...`
|
|
365
|
+
);
|
|
366
|
+
schedule(
|
|
367
|
+
() => void connect(sessionId).catch(
|
|
368
|
+
(error) => console.error(`WhatsApp[${sessionId}]: reconnect failed:`, error)
|
|
369
|
+
),
|
|
370
|
+
delay
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
if (config.onMessage) {
|
|
376
|
+
sock.ev.on("messages.upsert", async ({ messages, type }) => {
|
|
377
|
+
if (type !== "notify") return;
|
|
378
|
+
for (const message of messages) {
|
|
379
|
+
try {
|
|
380
|
+
await config.onMessage?.({ sessionId, sock, message });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.warn(`WhatsApp[${sessionId}]: onMessage handler failed:`, error);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const sessionId of sessionIds) {
|
|
389
|
+
void acquireSessionLease(sessionId, {
|
|
390
|
+
leaseStore,
|
|
391
|
+
instanceId,
|
|
392
|
+
sessions,
|
|
393
|
+
isStopped: () => stopped,
|
|
394
|
+
connect,
|
|
395
|
+
now,
|
|
396
|
+
staleMs,
|
|
397
|
+
heartbeatMs,
|
|
398
|
+
waitMs,
|
|
399
|
+
schedule
|
|
400
|
+
}).catch((error) => console.error(`WhatsApp[${sessionId}]: failed to start:`, error));
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
async stop() {
|
|
404
|
+
stopped = true;
|
|
405
|
+
for (const senderName of registeredSenderNames.values()) {
|
|
406
|
+
senders?.unregister(senderName);
|
|
407
|
+
}
|
|
408
|
+
registeredSenderNames.clear();
|
|
409
|
+
await shutdownWaSessions(sessions);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export {
|
|
414
|
+
LEASE_HEARTBEAT_MS,
|
|
415
|
+
LEASE_STALE_MS,
|
|
416
|
+
LEASE_WAIT_MS,
|
|
417
|
+
acquireSessionLease,
|
|
418
|
+
canAcquireLease,
|
|
419
|
+
createTursoWaLeaseStore,
|
|
420
|
+
createWhatsAppChannel,
|
|
421
|
+
decrypt,
|
|
422
|
+
encrypt,
|
|
423
|
+
loadBaileys,
|
|
424
|
+
reconnectDelayMs,
|
|
425
|
+
senderNameFor,
|
|
426
|
+
shutdownWaSessions,
|
|
427
|
+
useTursoAuthState
|
|
428
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-overlap protection: at most one running instance may hold a given
|
|
3
|
+
* WhatsApp session's Baileys connection at a time.
|
|
4
|
+
*
|
|
5
|
+
* Rolling deploys can briefly run the outgoing and incoming revision side by
|
|
6
|
+
* side, and Baileys/WhatsApp treats a second concurrent connection as a
|
|
7
|
+
* takeover — it logs the first one out. The lease lives in Turso (`wa_lease`)
|
|
8
|
+
* so it's visible to every instance. Acquiring/renewing is a single atomic
|
|
9
|
+
* upsert — see `createTursoWaLeaseStore` — so two instances racing the same
|
|
10
|
+
* row can never both believe they hold it.
|
|
11
|
+
*/
|
|
12
|
+
import type { Client } from "@libsql/client";
|
|
13
|
+
/** Stale after this long without a heartbeat — a crashed holder's lease frees up. */
|
|
14
|
+
export declare const LEASE_STALE_MS = 90000;
|
|
15
|
+
/** How often a holder renews its lease while connected. */
|
|
16
|
+
export declare const LEASE_HEARTBEAT_MS = 30000;
|
|
17
|
+
/** How long a non-holder waits before re-checking whether the lease freed up. */
|
|
18
|
+
export declare const LEASE_WAIT_MS = 30000;
|
|
19
|
+
export interface WaLeaseRow {
|
|
20
|
+
instanceId: string;
|
|
21
|
+
heartbeatAt: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pure: may `instanceId` take (or keep) the lease right now, given the
|
|
25
|
+
* current row (null = never held)? Mirrors the SQL WHERE clause in
|
|
26
|
+
* `createTursoWaLeaseStore` — keep the two in sync.
|
|
27
|
+
*/
|
|
28
|
+
export declare function canAcquireLease(existing: WaLeaseRow | null, instanceId: string, now: number, staleMs?: number): boolean;
|
|
29
|
+
export interface WaLeaseStore {
|
|
30
|
+
/** Atomically acquires or renews the lease; resolves true iff `instanceId` now holds it. */
|
|
31
|
+
tryAcquire(sessionId: string, instanceId: string, now: number, staleMs: number): Promise<boolean>;
|
|
32
|
+
/** Releases the lease iff still held by `instanceId` (no-op otherwise — a late release must never undo a takeover). */
|
|
33
|
+
release(sessionId: string, instanceId: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A Turso-backed `WaLeaseStore`. The WHERE clause on DO UPDATE encodes
|
|
37
|
+
* `canAcquireLease`: SQLite skips the update (and RETURNING yields no row)
|
|
38
|
+
* when it's false, so a losing racer sees zero rows back — no separate
|
|
39
|
+
* read-then-write, no race window.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createTursoWaLeaseStore(client: Client): WaLeaseStore;
|
|
42
|
+
//# sourceMappingURL=lease.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lease.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/lease.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAE7C,qFAAqF;AACrF,eAAO,MAAM,cAAc,QAAS,CAAC;AACrC,2DAA2D;AAC3D,eAAO,MAAM,kBAAkB,QAAS,CAAC;AACzC,iFAAiF;AACjF,eAAO,MAAM,aAAa,QAAS,CAAC;AAEpC,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,UAAU,GAAG,IAAI,EAC3B,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,MAAuB,GAC/B,OAAO,CAIT;AAED,MAAM,WAAW,YAAY;IAC3B,4FAA4F;IAC5F,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClG,uHAAuH;IACvH,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAuBD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAyBpE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lease.test.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/lease.test.ts"],"names":[],"mappings":""}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export type { ApiKeyOptions, ApiKeyPayload, VerifyResult } from "./auth";
|
|
14
14
|
export { ApiKeyManager } from "./auth";
|
|
15
|
+
export type { Channel } from "./channels";
|
|
15
16
|
export type { ChatBotConfig, ChatButtonConfig, ChatConfig, ChatMessage, ChatMode, StreamCallbacks, } from "./client";
|
|
16
17
|
export { Chat, ChatBot, ChatButton } from "./client";
|
|
17
18
|
export type { AnswerFn, AnswerFnInput, AnswerFnResult, AnswerOptions, AnswerUsage, } from "./core/answer";
|
|
@@ -39,6 +40,7 @@ export { demoRoutes } from "./routes/demo";
|
|
|
39
40
|
export { openaiRoutes } from "./routes/openai";
|
|
40
41
|
export { privateRoutes } from "./routes/private";
|
|
41
42
|
export { publicRoutes } from "./routes/public";
|
|
43
|
+
export type { ChatterApp } from "./server";
|
|
42
44
|
export { createServer } from "./server";
|
|
43
45
|
export type { BotBranding, BotChatConfig, BotIdentity, ChatterConfig, CustomRoutes, EmbeddingChunk, KnowledgeDocument, ServerDependencies, } from "./types";
|
|
44
46
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAIvC,YAAY,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAC1C,YAAY,EACV,aAAa,EACb,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,QAAQ,EACR,eAAe,GAChB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACrD,YAAY,EACV,QAAQ,EACR,aAAa,EACb,cAAc,EACd,aAAa,EACb,WAAW,GACZ,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACzD,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEnF,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3D,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,eAAe,GAChB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEvE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAG/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AACzC,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE3C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,YAAY,EACV,WAAW,EACX,aAAa,EACb,WAAW,EACX,aAAa,EACb,YAAY,EACZ,cAAc,EACd,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -33171,6 +33171,58 @@ var import_hono5 = require("hono");
|
|
|
33171
33171
|
var import_bun = require("hono/bun");
|
|
33172
33172
|
var import_openai2 = __toESM(require("openai"));
|
|
33173
33173
|
|
|
33174
|
+
// src/channels/senders.ts
|
|
33175
|
+
function createSenderRegistry() {
|
|
33176
|
+
const senders = /* @__PURE__ */ new Map();
|
|
33177
|
+
return {
|
|
33178
|
+
register(name, sender) {
|
|
33179
|
+
if (senders.has(name)) {
|
|
33180
|
+
console.warn(`Channel sender "${name}" is already registered; overwriting.`);
|
|
33181
|
+
}
|
|
33182
|
+
senders.set(name, sender);
|
|
33183
|
+
},
|
|
33184
|
+
unregister(name) {
|
|
33185
|
+
senders.delete(name);
|
|
33186
|
+
},
|
|
33187
|
+
available(name) {
|
|
33188
|
+
return senders.has(name);
|
|
33189
|
+
},
|
|
33190
|
+
async sendText(name, chatId, text) {
|
|
33191
|
+
const sender = senders.get(name);
|
|
33192
|
+
if (!sender) return false;
|
|
33193
|
+
try {
|
|
33194
|
+
await sender.sendText(chatId, text);
|
|
33195
|
+
return true;
|
|
33196
|
+
} catch (error51) {
|
|
33197
|
+
console.warn(`Channel sender "${name}" sendText failed:`, error51);
|
|
33198
|
+
return false;
|
|
33199
|
+
}
|
|
33200
|
+
},
|
|
33201
|
+
async sendVoice(name, chatId, payload) {
|
|
33202
|
+
const sender = senders.get(name);
|
|
33203
|
+
if (!sender?.sendVoice) return false;
|
|
33204
|
+
try {
|
|
33205
|
+
await sender.sendVoice(chatId, payload);
|
|
33206
|
+
return true;
|
|
33207
|
+
} catch (error51) {
|
|
33208
|
+
console.warn(`Channel sender "${name}" sendVoice failed:`, error51);
|
|
33209
|
+
return false;
|
|
33210
|
+
}
|
|
33211
|
+
},
|
|
33212
|
+
async sendMedia(name, chatId, payload) {
|
|
33213
|
+
const sender = senders.get(name);
|
|
33214
|
+
if (!sender?.sendMedia) return false;
|
|
33215
|
+
try {
|
|
33216
|
+
await sender.sendMedia(chatId, payload);
|
|
33217
|
+
return true;
|
|
33218
|
+
} catch (error51) {
|
|
33219
|
+
console.warn(`Channel sender "${name}" sendMedia failed:`, error51);
|
|
33220
|
+
return false;
|
|
33221
|
+
}
|
|
33222
|
+
}
|
|
33223
|
+
};
|
|
33224
|
+
}
|
|
33225
|
+
|
|
33174
33226
|
// src/core/widgets.ts
|
|
33175
33227
|
var import_node_fs3 = require("node:fs");
|
|
33176
33228
|
var import_node_path3 = require("node:path");
|
|
@@ -33296,7 +33348,8 @@ async function createServer(config2) {
|
|
|
33296
33348
|
app.get("/demo/chatbot", (0, import_bun.serveStatic)({ path: `${publicDir}/demo-chatbot.html` }));
|
|
33297
33349
|
app.get("/demo/react", (0, import_bun.serveStatic)({ path: `${publicDir}/react-demo.html` }));
|
|
33298
33350
|
}
|
|
33299
|
-
const
|
|
33351
|
+
const senders = createSenderRegistry();
|
|
33352
|
+
const deps = { client, store, db, config: config2, prompts, apiKeyManager, senders };
|
|
33300
33353
|
if (enablePublic) {
|
|
33301
33354
|
app.route("/", publicRoutes(deps));
|
|
33302
33355
|
}
|
|
@@ -33312,8 +33365,36 @@ async function createServer(config2) {
|
|
|
33312
33365
|
if (config2.customRoutes) {
|
|
33313
33366
|
await config2.customRoutes(app, deps);
|
|
33314
33367
|
}
|
|
33368
|
+
const started = [];
|
|
33369
|
+
if (config2.channels && config2.channels.length > 0) {
|
|
33370
|
+
for (const channel of config2.channels) {
|
|
33371
|
+
try {
|
|
33372
|
+
await channel.start(deps);
|
|
33373
|
+
started.push(channel);
|
|
33374
|
+
console.log(`\u2705 Channel "${channel.name}" started`);
|
|
33375
|
+
} catch (error51) {
|
|
33376
|
+
console.error(`\u274C Channel "${channel.name}" failed to start:`, error51);
|
|
33377
|
+
}
|
|
33378
|
+
}
|
|
33379
|
+
}
|
|
33315
33380
|
console.log(`\u2705 ${config2.bot.name} server ready`);
|
|
33316
|
-
return app
|
|
33381
|
+
return Object.assign(app, {
|
|
33382
|
+
// Scoped to this call's started channels, not global — two servers in
|
|
33383
|
+
// one process each stop only their own. A channel's `stop()` throwing
|
|
33384
|
+
// (sync or async) is caught per-channel so one misbehaving channel can't
|
|
33385
|
+
// stop the rest from cleaning up.
|
|
33386
|
+
stopChannels: async () => {
|
|
33387
|
+
await Promise.allSettled(
|
|
33388
|
+
started.map(async (channel) => {
|
|
33389
|
+
try {
|
|
33390
|
+
await channel.stop?.();
|
|
33391
|
+
} catch (error51) {
|
|
33392
|
+
console.error(`Channel "${channel.name}" failed to stop:`, error51);
|
|
33393
|
+
}
|
|
33394
|
+
})
|
|
33395
|
+
);
|
|
33396
|
+
}
|
|
33397
|
+
});
|
|
33317
33398
|
}
|
|
33318
33399
|
// Annotate the CommonJS export names for ESM import in node:
|
|
33319
33400
|
0 && (module.exports = {
|