@sjawhar/whatsapp-mcp 1.0.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/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/__tests__/connection.test.d.ts +2 -0
- package/dist/__tests__/connection.test.d.ts.map +1 -0
- package/dist/__tests__/connection.test.js +105 -0
- package/dist/__tests__/connection.test.js.map +1 -0
- package/dist/__tests__/disconnect.test.d.ts +2 -0
- package/dist/__tests__/disconnect.test.d.ts.map +1 -0
- package/dist/__tests__/disconnect.test.js +166 -0
- package/dist/__tests__/disconnect.test.js.map +1 -0
- package/dist/__tests__/download-media.test.d.ts +2 -0
- package/dist/__tests__/download-media.test.d.ts.map +1 -0
- package/dist/__tests__/download-media.test.js +110 -0
- package/dist/__tests__/download-media.test.js.map +1 -0
- package/dist/__tests__/failures/connection-failures.test.d.ts +2 -0
- package/dist/__tests__/failures/connection-failures.test.d.ts.map +1 -0
- package/dist/__tests__/failures/connection-failures.test.js +146 -0
- package/dist/__tests__/failures/connection-failures.test.js.map +1 -0
- package/dist/__tests__/failures/edge-cases.test.d.ts +2 -0
- package/dist/__tests__/failures/edge-cases.test.d.ts.map +1 -0
- package/dist/__tests__/failures/edge-cases.test.js +121 -0
- package/dist/__tests__/failures/edge-cases.test.js.map +1 -0
- package/dist/__tests__/failures/resource-failures.test.d.ts +2 -0
- package/dist/__tests__/failures/resource-failures.test.d.ts.map +1 -0
- package/dist/__tests__/failures/resource-failures.test.js +136 -0
- package/dist/__tests__/failures/resource-failures.test.js.map +1 -0
- package/dist/__tests__/failures/security-failures.test.d.ts +2 -0
- package/dist/__tests__/failures/security-failures.test.d.ts.map +1 -0
- package/dist/__tests__/failures/security-failures.test.js +0 -0
- package/dist/__tests__/failures/security-failures.test.js.map +1 -0
- package/dist/__tests__/helpers/fake-baileys.d.ts +52 -0
- package/dist/__tests__/helpers/fake-baileys.d.ts.map +1 -0
- package/dist/__tests__/helpers/fake-baileys.js +60 -0
- package/dist/__tests__/helpers/fake-baileys.js.map +1 -0
- package/dist/__tests__/helpers/mcp-test-client.d.ts +9 -0
- package/dist/__tests__/helpers/mcp-test-client.d.ts.map +1 -0
- package/dist/__tests__/helpers/mcp-test-client.js +40 -0
- package/dist/__tests__/helpers/mcp-test-client.js.map +1 -0
- package/dist/__tests__/helpers/test-db.d.ts +4 -0
- package/dist/__tests__/helpers/test-db.d.ts.map +1 -0
- package/dist/__tests__/helpers/test-db.js +32 -0
- package/dist/__tests__/helpers/test-db.js.map +1 -0
- package/dist/__tests__/integration/chat-navigation.test.d.ts +2 -0
- package/dist/__tests__/integration/chat-navigation.test.d.ts.map +1 -0
- package/dist/__tests__/integration/chat-navigation.test.js +171 -0
- package/dist/__tests__/integration/chat-navigation.test.js.map +1 -0
- package/dist/__tests__/integration/contacts-flow.test.d.ts +2 -0
- package/dist/__tests__/integration/contacts-flow.test.d.ts.map +1 -0
- package/dist/__tests__/integration/contacts-flow.test.js +144 -0
- package/dist/__tests__/integration/contacts-flow.test.js.map +1 -0
- package/dist/__tests__/integration/media-flow.test.d.ts +2 -0
- package/dist/__tests__/integration/media-flow.test.d.ts.map +1 -0
- package/dist/__tests__/integration/media-flow.test.js +225 -0
- package/dist/__tests__/integration/media-flow.test.js.map +1 -0
- package/dist/__tests__/integration/search-flow.test.d.ts +2 -0
- package/dist/__tests__/integration/search-flow.test.d.ts.map +1 -0
- package/dist/__tests__/integration/search-flow.test.js +44 -0
- package/dist/__tests__/integration/search-flow.test.js.map +1 -0
- package/dist/__tests__/integration/send-message.test.d.ts +2 -0
- package/dist/__tests__/integration/send-message.test.d.ts.map +1 -0
- package/dist/__tests__/integration/send-message.test.js +160 -0
- package/dist/__tests__/integration/send-message.test.js.map +1 -0
- package/dist/__tests__/lock-file.test.d.ts +2 -0
- package/dist/__tests__/lock-file.test.d.ts.map +1 -0
- package/dist/__tests__/lock-file.test.js +63 -0
- package/dist/__tests__/lock-file.test.js.map +1 -0
- package/dist/__tests__/medium-fixes.test.d.ts +2 -0
- package/dist/__tests__/medium-fixes.test.d.ts.map +1 -0
- package/dist/__tests__/medium-fixes.test.js +141 -0
- package/dist/__tests__/medium-fixes.test.js.map +1 -0
- package/dist/__tests__/rate-limit.test.d.ts +2 -0
- package/dist/__tests__/rate-limit.test.d.ts.map +1 -0
- package/dist/__tests__/rate-limit.test.js +193 -0
- package/dist/__tests__/rate-limit.test.js.map +1 -0
- package/dist/__tests__/send-file.test.d.ts +2 -0
- package/dist/__tests__/send-file.test.d.ts.map +1 -0
- package/dist/__tests__/send-file.test.js +237 -0
- package/dist/__tests__/send-file.test.js.map +1 -0
- package/dist/__tests__/smoke.test.d.ts +2 -0
- package/dist/__tests__/smoke.test.d.ts.map +1 -0
- package/dist/__tests__/smoke.test.js +28 -0
- package/dist/__tests__/smoke.test.js.map +1 -0
- package/dist/__tests__/transcribe.test.d.ts +2 -0
- package/dist/__tests__/transcribe.test.d.ts.map +1 -0
- package/dist/__tests__/transcribe.test.js +71 -0
- package/dist/__tests__/transcribe.test.js.map +1 -0
- package/dist/__tests__/zombie.test.d.ts +2 -0
- package/dist/__tests__/zombie.test.d.ts.map +1 -0
- package/dist/__tests__/zombie.test.js +145 -0
- package/dist/__tests__/zombie.test.js.map +1 -0
- package/dist/db.d.ts +53 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +509 -0
- package/dist/db.js.map +1 -0
- package/dist/import-contacts.d.ts +37 -0
- package/dist/import-contacts.d.ts.map +1 -0
- package/dist/import-contacts.js +242 -0
- package/dist/import-contacts.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.d.ts +16 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +65 -0
- package/dist/lock.js.map +1 -0
- package/dist/tools.d.ts +6 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +339 -0
- package/dist/tools.js.map +1 -0
- package/dist/transcribe.d.ts +8 -0
- package/dist/transcribe.d.ts.map +1 -0
- package/dist/transcribe.js +63 -0
- package/dist/transcribe.js.map +1 -0
- package/dist/utils.d.ts +51 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +156 -0
- package/dist/utils.js.map +1 -0
- package/dist/whatsapp.d.ts +50 -0
- package/dist/whatsapp.d.ts.map +1 -0
- package/dist/whatsapp.js +896 -0
- package/dist/whatsapp.js.map +1 -0
- package/package.json +52 -0
- package/patches/@whiskeysockets+baileys+6.7.21.patch +46 -0
- package/patches/libsignal+2.0.1.patch +84 -0
package/dist/whatsapp.js
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import makeWASocket, { DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage, getContentType, initAuthCreds, BufferJSON, proto, } from "@whiskeysockets/baileys";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
import NodeCache from "node-cache";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { toJid, fromJid, mimeFromExtension, mediaCategoryFromMime, validateFilePath, sanitizeFilename, } from "./utils.js";
|
|
7
|
+
import * as db from "./db.js";
|
|
8
|
+
import { transcribeAudio } from "./transcribe.js";
|
|
9
|
+
const __project_root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
10
|
+
const AUTH_DIR = path.join(__project_root, "auth_info");
|
|
11
|
+
const DOWNLOADS_DIR = process.env.DOWNLOADS_DIR || "./downloads/";
|
|
12
|
+
const parsedMaxReconnectAttempts = Number(process.env.MAX_RECONNECT_ATTEMPTS || "10");
|
|
13
|
+
const MAX_RECONNECT_ATTEMPTS = Number.isFinite(parsedMaxReconnectAttempts) && parsedMaxReconnectAttempts > 0
|
|
14
|
+
? Math.floor(parsedMaxReconnectAttempts)
|
|
15
|
+
: 10;
|
|
16
|
+
const parsedZombieTimeoutMs = Number(process.env.ZOMBIE_TIMEOUT_MS || "120000");
|
|
17
|
+
const ZOMBIE_TIMEOUT_MS = Number.isFinite(parsedZombieTimeoutMs) && parsedZombieTimeoutMs > 0
|
|
18
|
+
? Math.floor(parsedZombieTimeoutMs)
|
|
19
|
+
: 120000;
|
|
20
|
+
const parsedMaxSendFailures = Number(process.env.MAX_SEND_FAILURES || "3");
|
|
21
|
+
const MAX_SEND_FAILURES = Number.isFinite(parsedMaxSendFailures) && parsedMaxSendFailures > 0
|
|
22
|
+
? Math.floor(parsedMaxSendFailures)
|
|
23
|
+
: 3;
|
|
24
|
+
const parsedMinSendInterval = Number(process.env.MIN_SEND_INTERVAL_MS || "3000");
|
|
25
|
+
const MIN_SEND_INTERVAL_MS = Number.isFinite(parsedMinSendInterval) && parsedMinSendInterval >= 0
|
|
26
|
+
? Math.floor(parsedMinSendInterval)
|
|
27
|
+
: 3000;
|
|
28
|
+
const parsedSendJitter = Number(process.env.SEND_JITTER_MS || "2000");
|
|
29
|
+
const SEND_JITTER_MS = Number.isFinite(parsedSendJitter) && parsedSendJitter >= 0
|
|
30
|
+
? Math.floor(parsedSendJitter)
|
|
31
|
+
: 2000;
|
|
32
|
+
export class SendRateLimiter {
|
|
33
|
+
lastSendTimestamp = 0;
|
|
34
|
+
minInterval;
|
|
35
|
+
jitterMs;
|
|
36
|
+
constructor(minInterval, jitterMs) {
|
|
37
|
+
this.minInterval = minInterval;
|
|
38
|
+
this.jitterMs = jitterMs;
|
|
39
|
+
}
|
|
40
|
+
async throttle() {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const elapsed = now - this.lastSendTimestamp;
|
|
43
|
+
const jitter = Math.random() * this.jitterMs;
|
|
44
|
+
const delay = Math.max(0, this.minInterval - elapsed) + jitter;
|
|
45
|
+
if (delay > 0) {
|
|
46
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
47
|
+
}
|
|
48
|
+
this.lastSendTimestamp = Date.now();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const sendRateLimiter = new SendRateLimiter(MIN_SEND_INTERVAL_MS, SEND_JITTER_MS);
|
|
52
|
+
const PRE_KEY_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
53
|
+
const PRE_KEY_MAX_FILES = 500;
|
|
54
|
+
const PRE_KEY_KEEP_FILES = 100;
|
|
55
|
+
const logger = pino({ level: "warn" }, pino.destination({ dest: 2, sync: false }));
|
|
56
|
+
// ─── Atomic Multi-File Auth State ───────────────────────────────────
|
|
57
|
+
// Custom replacement for Baileys' useMultiFileAuthState that uses atomic
|
|
58
|
+
// writes (write-to-temp + rename) to prevent JSON corruption from
|
|
59
|
+
// concurrent writes during app state sync. The built-in implementation
|
|
60
|
+
// uses plain writeFile which can produce corrupted files when two events
|
|
61
|
+
// write to the same key file in quick succession.
|
|
62
|
+
let pendingWrites = new Set();
|
|
63
|
+
function trackWrite(p) {
|
|
64
|
+
pendingWrites.add(p);
|
|
65
|
+
p.finally(() => pendingWrites.delete(p));
|
|
66
|
+
return p;
|
|
67
|
+
}
|
|
68
|
+
async function flushPendingWrites() {
|
|
69
|
+
if (pendingWrites.size > 0) {
|
|
70
|
+
console.error(`Flushing ${pendingWrites.size} pending auth write(s)...`);
|
|
71
|
+
await Promise.allSettled([...pendingWrites]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function atomicWrite(filePath, data) {
|
|
75
|
+
const tmpPath = filePath + '.tmp.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(36).slice(2);
|
|
76
|
+
await fs.promises.writeFile(tmpPath, data);
|
|
77
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
78
|
+
}
|
|
79
|
+
async function useAtomicMultiFileAuthState(folder) {
|
|
80
|
+
await fs.promises.mkdir(folder, { recursive: true });
|
|
81
|
+
const writeData = async (data, file) => {
|
|
82
|
+
const filePath = path.join(folder, sanitizeFilename(file));
|
|
83
|
+
await atomicWrite(filePath, JSON.stringify(data, BufferJSON.replacer));
|
|
84
|
+
};
|
|
85
|
+
const readData = async (file) => {
|
|
86
|
+
try {
|
|
87
|
+
const filePath = path.join(folder, sanitizeFilename(file));
|
|
88
|
+
const raw = await fs.promises.readFile(filePath, { encoding: "utf-8" });
|
|
89
|
+
return JSON.parse(raw, BufferJSON.reviver);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const removeData = async (file) => {
|
|
96
|
+
try {
|
|
97
|
+
await fs.promises.unlink(path.join(folder, sanitizeFilename(file)));
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
};
|
|
101
|
+
const creds = (await readData("creds.json")) || initAuthCreds();
|
|
102
|
+
return {
|
|
103
|
+
state: {
|
|
104
|
+
creds,
|
|
105
|
+
keys: {
|
|
106
|
+
get: async (type, ids) => {
|
|
107
|
+
const data = {};
|
|
108
|
+
await Promise.all(ids.map(async (id) => {
|
|
109
|
+
let value = await readData(`${type}-${id}.json`);
|
|
110
|
+
if (type === "app-state-sync-key" && value) {
|
|
111
|
+
value = proto.Message.AppStateSyncKeyData.fromObject(value);
|
|
112
|
+
}
|
|
113
|
+
if (value) {
|
|
114
|
+
data[id] = value;
|
|
115
|
+
}
|
|
116
|
+
}));
|
|
117
|
+
return data;
|
|
118
|
+
},
|
|
119
|
+
set: async (data) => {
|
|
120
|
+
const tasks = [];
|
|
121
|
+
for (const category in data) {
|
|
122
|
+
for (const id in data[category]) {
|
|
123
|
+
const value = data[category][id];
|
|
124
|
+
const file = `${category}-${id}.json`;
|
|
125
|
+
tasks.push(value ? writeData(value, file) : removeData(file));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await Promise.all(tasks);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
saveCreds: () => writeData(creds, "creds.json"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ─── Connection State ───────────────────────────────────────────────
|
|
136
|
+
let sock = null;
|
|
137
|
+
let connectionReady;
|
|
138
|
+
let resolveConnection;
|
|
139
|
+
let rejectConnection;
|
|
140
|
+
let reconnectAttempts = 0;
|
|
141
|
+
let reconnectTimer = null;
|
|
142
|
+
let zombieWatchdog = null;
|
|
143
|
+
let lastConnectionActivity = Date.now();
|
|
144
|
+
let consecutiveSendFailures = 0;
|
|
145
|
+
let preKeyPruneInterval = null;
|
|
146
|
+
// ─── User Identity ──────────────────────────────────────────────────
|
|
147
|
+
let myJid = null;
|
|
148
|
+
let myName = null;
|
|
149
|
+
let myLidJid = null;
|
|
150
|
+
export function getMyInfo() {
|
|
151
|
+
const normalizedJid = myJid ? myJid.replace(/:\d+@/, "@") : null;
|
|
152
|
+
return {
|
|
153
|
+
jid: normalizedJid,
|
|
154
|
+
lidJid: myLidJid,
|
|
155
|
+
name: myName || "You",
|
|
156
|
+
phone: normalizedJid ? fromJid(normalizedJid) : null,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function resetConnectionPromise() {
|
|
160
|
+
connectionReady = new Promise((resolve, reject) => {
|
|
161
|
+
resolveConnection = resolve;
|
|
162
|
+
rejectConnection = reject;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function scheduleReconnect(delayMs) {
|
|
166
|
+
reconnectAttempts++;
|
|
167
|
+
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
168
|
+
console.error(`Fatal: reached MAX_RECONNECT_ATTEMPTS (${MAX_RECONNECT_ATTEMPTS}). ` +
|
|
169
|
+
"Stopping reconnect attempts.");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (reconnectTimer) {
|
|
173
|
+
clearTimeout(reconnectTimer);
|
|
174
|
+
reconnectTimer = null;
|
|
175
|
+
}
|
|
176
|
+
console.error(`Reconnecting in ${delayMs / 1000}s (attempt ${reconnectAttempts})...`);
|
|
177
|
+
resetConnectionPromise();
|
|
178
|
+
reconnectTimer = setTimeout(() => {
|
|
179
|
+
reconnectTimer = null;
|
|
180
|
+
void initWhatsApp();
|
|
181
|
+
}, delayMs);
|
|
182
|
+
}
|
|
183
|
+
function clearZombieWatchdog() {
|
|
184
|
+
if (!zombieWatchdog)
|
|
185
|
+
return;
|
|
186
|
+
clearInterval(zombieWatchdog);
|
|
187
|
+
zombieWatchdog = null;
|
|
188
|
+
}
|
|
189
|
+
function startZombieWatchdog(currentSock) {
|
|
190
|
+
clearZombieWatchdog();
|
|
191
|
+
const checkIntervalMs = Math.max(1000, Math.min(10000, ZOMBIE_TIMEOUT_MS));
|
|
192
|
+
zombieWatchdog = setInterval(() => {
|
|
193
|
+
if (!sock || sock !== currentSock)
|
|
194
|
+
return;
|
|
195
|
+
if (Date.now() - lastConnectionActivity < ZOMBIE_TIMEOUT_MS)
|
|
196
|
+
return;
|
|
197
|
+
clearZombieWatchdog();
|
|
198
|
+
currentSock.end(new Error("Zombie connection detected"));
|
|
199
|
+
}, checkIntervalMs);
|
|
200
|
+
}
|
|
201
|
+
async function sendMessageWithHealthCheck(currentSock, jid, messageContent) {
|
|
202
|
+
await sendRateLimiter.throttle();
|
|
203
|
+
try {
|
|
204
|
+
const result = await currentSock.sendMessage(jid, messageContent);
|
|
205
|
+
consecutiveSendFailures = 0;
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
consecutiveSendFailures++;
|
|
210
|
+
if (consecutiveSendFailures >= MAX_SEND_FAILURES) {
|
|
211
|
+
currentSock.end(new Error("Send health check failed"));
|
|
212
|
+
}
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export async function clearAuthState() {
|
|
217
|
+
try {
|
|
218
|
+
const entries = await fs.promises.readdir(AUTH_DIR);
|
|
219
|
+
await Promise.all(entries.map((entry) => fs.promises.rm(path.join(AUTH_DIR, entry), { recursive: true, force: true })));
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err?.code === "ENOENT")
|
|
223
|
+
return;
|
|
224
|
+
console.error("Failed to clear auth state:", err);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function prunePreKeys(authDir) {
|
|
228
|
+
const files = await fs.promises.readdir(authDir).catch(() => []);
|
|
229
|
+
const preKeyFiles = files.filter(f => f.startsWith('pre-key-') || f.startsWith('sender-key-') || f.startsWith('session-'));
|
|
230
|
+
if (preKeyFiles.length > PRE_KEY_MAX_FILES) {
|
|
231
|
+
const withStats = await Promise.all(preKeyFiles.map(async (f) => ({
|
|
232
|
+
f,
|
|
233
|
+
mtime: (await fs.promises.stat(path.join(authDir, f)).catch(() => ({ mtimeMs: 0 }))).mtimeMs,
|
|
234
|
+
})));
|
|
235
|
+
withStats.sort((a, b) => a.mtime - b.mtime);
|
|
236
|
+
const toDelete = withStats.slice(0, withStats.length - PRE_KEY_KEEP_FILES);
|
|
237
|
+
await Promise.all(toDelete.map(({ f }) => fs.promises.unlink(path.join(authDir, f)).catch(() => { })));
|
|
238
|
+
console.error(`Pruned ${toDelete.length} pre-key/session files (${preKeyFiles.length} -> ${PRE_KEY_KEEP_FILES})`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
export function getReconnectAttempts() {
|
|
242
|
+
return reconnectAttempts;
|
|
243
|
+
}
|
|
244
|
+
export async function handleDisconnect(statusCode, currentSock) {
|
|
245
|
+
const socketState = currentSock ? "socket-present" : "socket-missing";
|
|
246
|
+
console.error(`Connection closed. Status: ${statusCode}. Context: ${socketState}`);
|
|
247
|
+
switch (statusCode) {
|
|
248
|
+
case DisconnectReason.loggedOut: {
|
|
249
|
+
await clearAuthState();
|
|
250
|
+
console.error("WhatsApp logged out. re-scan QR required.");
|
|
251
|
+
rejectConnection(new Error("Logged out from WhatsApp. re-scan QR required."));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case DisconnectReason.forbidden: {
|
|
255
|
+
console.error("Disconnect forbidden (403). Reconnect disabled.");
|
|
256
|
+
rejectConnection(new Error("WhatsApp connection forbidden (403)."));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
case DisconnectReason.multideviceMismatch: {
|
|
260
|
+
console.error("Multi-device mismatch (411). Please update Baileys.");
|
|
261
|
+
rejectConnection(new Error("Multi-device mismatch (411). update Baileys."));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
case DisconnectReason.connectionLost:
|
|
265
|
+
case DisconnectReason.connectionClosed:
|
|
266
|
+
case DisconnectReason.unavailableService: {
|
|
267
|
+
const delay = Math.min(1000 * 2 ** (reconnectAttempts + 1), 30000);
|
|
268
|
+
scheduleReconnect(delay);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case DisconnectReason.connectionReplaced: {
|
|
272
|
+
console.error("Connection replaced by another session. " +
|
|
273
|
+
"If this keeps happening, delete auth_info/ and re-scan the QR code.");
|
|
274
|
+
const rejectCurrentConnection = rejectConnection;
|
|
275
|
+
resetConnectionPromise();
|
|
276
|
+
rejectCurrentConnection(new Error("Connection replaced by another session."));
|
|
277
|
+
const delay = Math.min(10000 * 2 ** reconnectAttempts, 30000);
|
|
278
|
+
scheduleReconnect(delay);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case DisconnectReason.badSession: {
|
|
282
|
+
await clearAuthState();
|
|
283
|
+
console.error("Bad session (500). Clearing auth state and reconnecting.");
|
|
284
|
+
const delay = Math.min(1000 * 2 ** (reconnectAttempts + 1), 30000);
|
|
285
|
+
scheduleReconnect(delay);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
case DisconnectReason.restartRequired: {
|
|
289
|
+
scheduleReconnect(0);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
default: {
|
|
293
|
+
console.error(`Unknown disconnect code: ${statusCode}. Reconnecting with backoff.`);
|
|
294
|
+
const delay = Math.min(1000 * 2 ** (reconnectAttempts + 1), 30000);
|
|
295
|
+
scheduleReconnect(delay);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
resetConnectionPromise();
|
|
301
|
+
/**
|
|
302
|
+
* Mark connection as ready without connecting to WhatsApp.
|
|
303
|
+
* Used when another instance owns the connection and this one reads from SQLite only.
|
|
304
|
+
*/
|
|
305
|
+
export function resolveConnectionAsReadOnly() {
|
|
306
|
+
resolveConnection();
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Initialize the Baileys WhatsApp client.
|
|
310
|
+
* QR codes are printed to stderr so they don't interfere with MCP stdio.
|
|
311
|
+
*/
|
|
312
|
+
export async function initWhatsApp() {
|
|
313
|
+
clearZombieWatchdog();
|
|
314
|
+
// Prune stale pre-key/session files on startup
|
|
315
|
+
await prunePreKeys(AUTH_DIR);
|
|
316
|
+
// Schedule periodic pre-key pruning (every 6 hours)
|
|
317
|
+
if (!preKeyPruneInterval) {
|
|
318
|
+
preKeyPruneInterval = setInterval(() => void prunePreKeys(AUTH_DIR), PRE_KEY_PRUNE_INTERVAL_MS);
|
|
319
|
+
}
|
|
320
|
+
if (sock) {
|
|
321
|
+
await flushPendingWrites();
|
|
322
|
+
sock.ev.removeAllListeners();
|
|
323
|
+
sock.end(undefined);
|
|
324
|
+
sock = null;
|
|
325
|
+
}
|
|
326
|
+
const { state, saveCreds } = await useAtomicMultiFileAuthState(AUTH_DIR);
|
|
327
|
+
const { version } = await fetchLatestBaileysVersion();
|
|
328
|
+
// Only request full history sync on first pairing (when no creds exist yet).
|
|
329
|
+
// On reconnections, history won't be sent by WhatsApp anyway, and requesting
|
|
330
|
+
// it causes Baileys to enter AwaitingInitialSync for 20s before timing out —
|
|
331
|
+
// which leads to 408 disconnects and an endless reconnect loop.
|
|
332
|
+
const isFirstPairing = !state.creds.registered;
|
|
333
|
+
// Wrap keys.set so every auth-state write is tracked and can be awaited
|
|
334
|
+
// before socket close — preventing stale session files on disk.
|
|
335
|
+
const originalKeysSet = state.keys.set.bind(state.keys);
|
|
336
|
+
state.keys.set = (data) => {
|
|
337
|
+
const result = originalKeysSet(data);
|
|
338
|
+
// keys.set returns Awaitable<void> — may be sync or async
|
|
339
|
+
if (result && typeof result.then === "function") {
|
|
340
|
+
const p = result.catch((err) => {
|
|
341
|
+
console.error("Auth key write failed:", err);
|
|
342
|
+
});
|
|
343
|
+
return trackWrite(p);
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
};
|
|
347
|
+
const trackedSaveCreds = () => trackWrite(saveCreds());
|
|
348
|
+
sock = makeWASocket({
|
|
349
|
+
version,
|
|
350
|
+
auth: state,
|
|
351
|
+
logger,
|
|
352
|
+
printQRInTerminal: false,
|
|
353
|
+
browser: ["WhatsApp MCP", "Chrome", "1.0.0"],
|
|
354
|
+
generateHighQualityLinkPreview: false,
|
|
355
|
+
syncFullHistory: isFirstPairing,
|
|
356
|
+
shouldSyncHistoryMessage: () => isFirstPairing,
|
|
357
|
+
keepAliveIntervalMs: 25_000,
|
|
358
|
+
markOnlineOnConnect: false,
|
|
359
|
+
msgRetryCounterCache: new NodeCache({ stdTTL: 60, maxKeys: 500 }),
|
|
360
|
+
userDevicesCache: new NodeCache({ stdTTL: 300, maxKeys: 1000 }),
|
|
361
|
+
});
|
|
362
|
+
lastConnectionActivity = Date.now();
|
|
363
|
+
consecutiveSendFailures = 0;
|
|
364
|
+
startZombieWatchdog(sock);
|
|
365
|
+
// ─── Bind events ──────────────────────────────────────────
|
|
366
|
+
//
|
|
367
|
+
// CRITICAL: We must use sock.ev.process() instead of individual sock.ev.on()
|
|
368
|
+
// listeners. Baileys buffers events during history sync and flushes them as a
|
|
369
|
+
// consolidated map via the internal 'event' emitter. Individual .on() listeners
|
|
370
|
+
// only receive unbuffered events and will MISS the entire history sync payload.
|
|
371
|
+
// sock.ev.process() is the correct API that receives both buffered and unbuffered events.
|
|
372
|
+
sock.ev.on("creds.update", trackedSaveCreds);
|
|
373
|
+
sock.ev.process(async (events) => {
|
|
374
|
+
// ─── Connection Updates ─────────────────────────────────
|
|
375
|
+
if (events["connection.update"]) {
|
|
376
|
+
lastConnectionActivity = Date.now();
|
|
377
|
+
const { connection, lastDisconnect, qr } = events["connection.update"];
|
|
378
|
+
if (qr) {
|
|
379
|
+
console.error("\n=== Scan this QR code in WhatsApp ===");
|
|
380
|
+
console.error("Link a device > QR code\n");
|
|
381
|
+
printQrToStderr(qr);
|
|
382
|
+
}
|
|
383
|
+
if (connection === "close") {
|
|
384
|
+
clearZombieWatchdog();
|
|
385
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
386
|
+
await handleDisconnect(statusCode, sock);
|
|
387
|
+
}
|
|
388
|
+
if (connection === "open") {
|
|
389
|
+
console.error("WhatsApp connected successfully!");
|
|
390
|
+
reconnectAttempts = 0;
|
|
391
|
+
lastConnectionActivity = Date.now();
|
|
392
|
+
consecutiveSendFailures = 0;
|
|
393
|
+
if (sock?.user) {
|
|
394
|
+
myJid = sock.user.id;
|
|
395
|
+
myName = sock.user.name || null;
|
|
396
|
+
myLidJid = sock.user.lid || null;
|
|
397
|
+
// Normalize JID: strip device suffix (e.g. "971525527198:5@s.whatsapp.net" → "971525527198@s.whatsapp.net")
|
|
398
|
+
const normalizedJid = myJid.replace(/:\d+@/, "@");
|
|
399
|
+
const displayName = myName || "You";
|
|
400
|
+
console.error(`Authenticated as: ${displayName} (${normalizedJid}${myLidJid ? `, LID: ${myLidJid}` : ""})`);
|
|
401
|
+
// Store our own identity so our chat shows a name, not a number
|
|
402
|
+
db.upsertContact(normalizedJid, displayName, myName);
|
|
403
|
+
db.upsertChat(normalizedJid, displayName, null, null);
|
|
404
|
+
// Store LID ↔ phone mapping for our own account
|
|
405
|
+
if (myLidJid) {
|
|
406
|
+
db.saveJidMapping(myLidJid, normalizedJid);
|
|
407
|
+
db.upsertContact(myLidJid, displayName, myName);
|
|
408
|
+
db.upsertChat(myLidJid, displayName, null, null);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
console.error("Connection ready");
|
|
412
|
+
resolveConnection();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// ─── History Sync (bulk load after QR scan) ─────────────
|
|
416
|
+
if (events["messaging-history.set"]) {
|
|
417
|
+
const { chats: syncChats, contacts: syncContacts, messages: syncMessages } = events["messaging-history.set"];
|
|
418
|
+
const progress = events["messaging-history.set"].progress;
|
|
419
|
+
const syncType = events["messaging-history.set"].syncType;
|
|
420
|
+
console.error(`History sync: ${syncChats.length} chats, ${syncContacts.length} contacts, ${syncMessages.length} messages (type=${syncType} progress=${progress ?? "?"}%)`);
|
|
421
|
+
db.upsertChats(syncChats);
|
|
422
|
+
db.upsertContacts(syncContacts);
|
|
423
|
+
// Propagate contact names (push names) to the chats table so chat
|
|
424
|
+
// listings show names immediately. The push_name sync (type 4)
|
|
425
|
+
// delivers ~1000 contacts with notify fields — without this step
|
|
426
|
+
// those names only exist in the contacts table and require a JOIN.
|
|
427
|
+
// Also extract LID ↔ phone JID mappings from synced contacts.
|
|
428
|
+
for (const contact of syncContacts) {
|
|
429
|
+
const displayName = contact.name || contact.notify || contact.verifiedName;
|
|
430
|
+
if (displayName && contact.id) {
|
|
431
|
+
db.upsertChat(contact.id, displayName, null, null);
|
|
432
|
+
}
|
|
433
|
+
// Extract LID ↔ phone mapping
|
|
434
|
+
const cLid = contact.lid || (contact.id?.endsWith?.("@lid") ? contact.id : null);
|
|
435
|
+
const cPhone = contact.jid || (contact.id?.endsWith?.("@s.whatsapp.net") ? contact.id : null);
|
|
436
|
+
if (cLid && cPhone) {
|
|
437
|
+
db.saveJidMapping(cLid, cPhone);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Group messages by chat JID and batch-insert
|
|
441
|
+
const byJid = new Map();
|
|
442
|
+
for (const msg of syncMessages) {
|
|
443
|
+
const jid = msg.key.remoteJid;
|
|
444
|
+
if (!jid)
|
|
445
|
+
continue;
|
|
446
|
+
if (!byJid.has(jid))
|
|
447
|
+
byJid.set(jid, []);
|
|
448
|
+
byJid.get(jid).push(msg);
|
|
449
|
+
}
|
|
450
|
+
for (const [jid, msgs] of byJid) {
|
|
451
|
+
db.upsertMessages(jid, msgs);
|
|
452
|
+
if (msgs.length > 0) {
|
|
453
|
+
const latest = msgs[msgs.length - 1];
|
|
454
|
+
db.upsertChat(jid, null, Number(latest.messageTimestamp || 0), 0);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ─── Chat Events ────────────────────────────────────────
|
|
459
|
+
if (events["chats.upsert"]) {
|
|
460
|
+
db.upsertChats(events["chats.upsert"]);
|
|
461
|
+
}
|
|
462
|
+
if (events["chats.update"]) {
|
|
463
|
+
for (const update of events["chats.update"]) {
|
|
464
|
+
if (!update.id)
|
|
465
|
+
continue;
|
|
466
|
+
db.upsertChat(update.id, update.name, update.conversationTimestamp ? Number(update.conversationTimestamp) : null, update.unreadCount);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (events["chats.delete"]) {
|
|
470
|
+
for (const id of events["chats.delete"]) {
|
|
471
|
+
db.deleteChat(id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// ─── LID ↔ Phone JID Mapping ────────────────────────────
|
|
475
|
+
if (events["chats.phoneNumberShare"]) {
|
|
476
|
+
const { lid, jid } = events["chats.phoneNumberShare"];
|
|
477
|
+
if (lid && jid) {
|
|
478
|
+
db.saveJidMapping(lid, jid);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// ─── Contact Events ─────────────────────────────────────
|
|
482
|
+
if (events["contacts.upsert"]) {
|
|
483
|
+
const contacts = events["contacts.upsert"];
|
|
484
|
+
db.upsertContacts(contacts);
|
|
485
|
+
for (const contact of contacts) {
|
|
486
|
+
const name = contact.name || contact.notify;
|
|
487
|
+
if (name) {
|
|
488
|
+
// Store under the primary id
|
|
489
|
+
db.upsertChat(contact.id, name, null, null);
|
|
490
|
+
// ContactAction from app state sync may use LID as id but provide
|
|
491
|
+
// the phone-number JID in the jid field — store under that too
|
|
492
|
+
const phoneJid = contact.jid;
|
|
493
|
+
if (phoneJid && phoneJid !== contact.id) {
|
|
494
|
+
db.upsertContact(phoneJid, contact.name || null, contact.notify || null);
|
|
495
|
+
db.upsertChat(phoneJid, name, null, null);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// Extract LID ↔ phone JID mapping from Contact object.
|
|
499
|
+
// Baileys Contact type has: id (primary), lid? (@lid), jid? (@s.whatsapp.net)
|
|
500
|
+
const cLid = contact.lid || (contact.id.endsWith("@lid") ? contact.id : null);
|
|
501
|
+
const cPhone = contact.jid || (contact.id.endsWith("@s.whatsapp.net") ? contact.id : null);
|
|
502
|
+
if (cLid && cPhone) {
|
|
503
|
+
db.saveJidMapping(cLid, cPhone);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (events["contacts.update"]) {
|
|
508
|
+
for (const update of events["contacts.update"]) {
|
|
509
|
+
if (!update.id)
|
|
510
|
+
continue;
|
|
511
|
+
db.upsertContact(update.id, update.name, update.notify);
|
|
512
|
+
const updatedName = update.name || update.notify;
|
|
513
|
+
if (updatedName) {
|
|
514
|
+
db.upsertChat(update.id, updatedName, null, null);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// ─── Message Events ─────────────────────────────────────
|
|
519
|
+
if (events["messages.upsert"]) {
|
|
520
|
+
const { messages: newMsgs } = events["messages.upsert"];
|
|
521
|
+
for (const msg of newMsgs) {
|
|
522
|
+
const jid = msg.key.remoteJid;
|
|
523
|
+
if (!jid)
|
|
524
|
+
continue;
|
|
525
|
+
// pushName is the sender's WhatsApp display name — use it to populate contacts
|
|
526
|
+
const pushName = msg.pushName;
|
|
527
|
+
const senderJid = msg.key.fromMe ? null : (msg.key.participant || jid);
|
|
528
|
+
if (pushName && senderJid) {
|
|
529
|
+
db.upsertContact(senderJid, null, pushName);
|
|
530
|
+
}
|
|
531
|
+
db.upsertMessage(jid, msg);
|
|
532
|
+
const msgTs = Number(msg.messageTimestamp || 0);
|
|
533
|
+
const chatName = (!msg.key.fromMe && !jid.endsWith("@g.us") && pushName) ? pushName : null;
|
|
534
|
+
db.upsertChat(jid, chatName, msgTs, msg.key.fromMe ? null : undefined);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (events["messages.update"]) {
|
|
538
|
+
for (const { key, update } of events["messages.update"]) {
|
|
539
|
+
const jid = key.remoteJid;
|
|
540
|
+
if (!jid || !key.id)
|
|
541
|
+
continue;
|
|
542
|
+
const merged = { key, message: update.message, messageTimestamp: update.messageTimestamp, participant: update.participant };
|
|
543
|
+
if (merged.message) {
|
|
544
|
+
db.upsertMessage(jid, merged);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Cleanly close the WhatsApp connection.
|
|
552
|
+
*/
|
|
553
|
+
export async function closeWhatsApp() {
|
|
554
|
+
clearZombieWatchdog();
|
|
555
|
+
if (preKeyPruneInterval) {
|
|
556
|
+
clearInterval(preKeyPruneInterval);
|
|
557
|
+
preKeyPruneInterval = null;
|
|
558
|
+
}
|
|
559
|
+
if (reconnectTimer) {
|
|
560
|
+
clearTimeout(reconnectTimer);
|
|
561
|
+
reconnectTimer = null;
|
|
562
|
+
}
|
|
563
|
+
if (sock) {
|
|
564
|
+
await flushPendingWrites();
|
|
565
|
+
sock.ev.removeAllListeners();
|
|
566
|
+
sock.end(undefined);
|
|
567
|
+
sock = null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Resolve a chat name. For groups without a name, fetch metadata from WhatsApp.
|
|
572
|
+
* Like the Go project's GetChatName — resolve inline when needed.
|
|
573
|
+
*/
|
|
574
|
+
async function resolveChatName(jid) {
|
|
575
|
+
// Check if we already have a name in the DB
|
|
576
|
+
const existing = db.getChatName(jid);
|
|
577
|
+
if (existing)
|
|
578
|
+
return existing;
|
|
579
|
+
// For groups, try fetching metadata
|
|
580
|
+
if (jid.endsWith("@g.us") && sock) {
|
|
581
|
+
try {
|
|
582
|
+
const meta = await sock.groupMetadata(jid);
|
|
583
|
+
if (meta.subject) {
|
|
584
|
+
db.upsertChat(jid, meta.subject, null, null);
|
|
585
|
+
return meta.subject;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Group may no longer exist or we're not a member
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// For individual chats, try contact store
|
|
593
|
+
if (jid.endsWith("@s.whatsapp.net")) {
|
|
594
|
+
const contact = db.getContactName(jid);
|
|
595
|
+
if (contact) {
|
|
596
|
+
db.upsertChat(jid, contact, null, null);
|
|
597
|
+
return contact;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
async function getSocket() {
|
|
603
|
+
// During reconnection cycles, sock may be momentarily null while a new
|
|
604
|
+
// socket is being created. Re-await connectionReady to wait for it.
|
|
605
|
+
if (!sock) {
|
|
606
|
+
await connectionReady;
|
|
607
|
+
}
|
|
608
|
+
if (!sock)
|
|
609
|
+
throw new Error("WhatsApp socket not available. This instance is running in read-only mode " +
|
|
610
|
+
"(another MCP server process owns the WhatsApp connection). " +
|
|
611
|
+
"This operation requires an active WhatsApp connection. Try restarting Claude Desktop, " +
|
|
612
|
+
"or delete the lock file at store/.whatsapp.lock if the other process is dead.");
|
|
613
|
+
return sock;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Return the socket if available, or null if running in read-only mode.
|
|
617
|
+
* Used by media download functions that can work without a socket —
|
|
618
|
+
* the socket is only needed as a fallback to re-upload expired CDN URLs.
|
|
619
|
+
*/
|
|
620
|
+
function getSocketOrNull() {
|
|
621
|
+
return sock;
|
|
622
|
+
}
|
|
623
|
+
// ─── Reading Functions ──────────────────────────────────────────────
|
|
624
|
+
export async function getChats(nameFilter, limit) {
|
|
625
|
+
await connectionReady;
|
|
626
|
+
const chats = db.getChats(nameFilter, limit);
|
|
627
|
+
// Resolve names for any chats missing them (like the Go project does inline)
|
|
628
|
+
for (const chat of chats) {
|
|
629
|
+
if (!chat.name && typeof chat.jid === "string") {
|
|
630
|
+
const name = await resolveChatName(chat.jid);
|
|
631
|
+
if (name)
|
|
632
|
+
chat.name = name;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return chats;
|
|
636
|
+
}
|
|
637
|
+
export async function getChat(jid) {
|
|
638
|
+
await connectionReady;
|
|
639
|
+
const normalJid = toJid(jid);
|
|
640
|
+
const chat = db.getChat(normalJid);
|
|
641
|
+
// Resolve name if missing
|
|
642
|
+
if (chat && !chat.name) {
|
|
643
|
+
const name = await resolveChatName(normalJid);
|
|
644
|
+
if (name)
|
|
645
|
+
chat.name = name;
|
|
646
|
+
}
|
|
647
|
+
return chat;
|
|
648
|
+
}
|
|
649
|
+
export async function getMessages(jid, limit = 50) {
|
|
650
|
+
await connectionReady;
|
|
651
|
+
return db.getMessages(toJid(jid), limit);
|
|
652
|
+
}
|
|
653
|
+
export async function searchMessages(query, jid) {
|
|
654
|
+
await connectionReady;
|
|
655
|
+
return db.searchMessages(query, jid ? toJid(jid) : undefined);
|
|
656
|
+
}
|
|
657
|
+
export async function searchContacts(query) {
|
|
658
|
+
await connectionReady;
|
|
659
|
+
return db.searchContacts(query);
|
|
660
|
+
}
|
|
661
|
+
export async function getMessageContext(jid, messageId, count = 5) {
|
|
662
|
+
await connectionReady;
|
|
663
|
+
return db.getMessageContext(toJid(jid), messageId, count);
|
|
664
|
+
}
|
|
665
|
+
// ─── Contact Management ─────────────────────────────────────────────
|
|
666
|
+
export async function updateContact(jid, name) {
|
|
667
|
+
const normalJid = toJid(jid);
|
|
668
|
+
db.upsertContact(normalJid, name, null);
|
|
669
|
+
db.upsertChat(normalJid, name, null, null);
|
|
670
|
+
return { success: true, jid: normalJid, name };
|
|
671
|
+
}
|
|
672
|
+
// ─── Writing Functions ──────────────────────────────────────────────
|
|
673
|
+
export async function deleteChat(jid) {
|
|
674
|
+
await connectionReady;
|
|
675
|
+
const s = await getSocket();
|
|
676
|
+
const normalJid = toJid(jid);
|
|
677
|
+
const lastMsg = db.getLastMessageKey(normalJid);
|
|
678
|
+
if (!lastMsg) {
|
|
679
|
+
throw new Error(`No messages found in chat ${normalJid} — cannot delete an empty chat.`);
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
await s.chatModify({
|
|
683
|
+
delete: true,
|
|
684
|
+
lastMessages: [{
|
|
685
|
+
key: {
|
|
686
|
+
remoteJid: normalJid,
|
|
687
|
+
fromMe: lastMsg.fromMe,
|
|
688
|
+
id: lastMsg.id,
|
|
689
|
+
},
|
|
690
|
+
messageTimestamp: lastMsg.timestamp,
|
|
691
|
+
}],
|
|
692
|
+
}, normalJid);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
if (err.message?.includes("not present")) {
|
|
696
|
+
throw new Error("WhatsApp app state keys haven't synced yet. " +
|
|
697
|
+
"This happens on fresh installs — wait a few minutes and try again, or restart the server.");
|
|
698
|
+
}
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
// Clean up local database
|
|
702
|
+
db.deleteChatMessages(normalJid);
|
|
703
|
+
db.deleteChat(normalJid);
|
|
704
|
+
return { success: true, jid: normalJid };
|
|
705
|
+
}
|
|
706
|
+
export async function deleteMessage(jid, messageId) {
|
|
707
|
+
await connectionReady;
|
|
708
|
+
const s = await getSocket();
|
|
709
|
+
const normalJid = toJid(jid);
|
|
710
|
+
// Look up from_me to build the correct WAMessageKey for deletion
|
|
711
|
+
const fromMe = db.getMessageFromMe(normalJid, messageId);
|
|
712
|
+
if (fromMe === null) {
|
|
713
|
+
throw new Error(`Message ${messageId} not found in chat ${normalJid}`);
|
|
714
|
+
}
|
|
715
|
+
// Delete on WhatsApp servers first — if this fails, local DB stays intact
|
|
716
|
+
await sendMessageWithHealthCheck(s, normalJid, {
|
|
717
|
+
delete: {
|
|
718
|
+
remoteJid: normalJid,
|
|
719
|
+
fromMe,
|
|
720
|
+
id: messageId,
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
// Remove from local database
|
|
724
|
+
db.deleteMessage(normalJid, messageId);
|
|
725
|
+
return { success: true, jid: normalJid, messageId };
|
|
726
|
+
}
|
|
727
|
+
export function getRecipientInfo(jid) {
|
|
728
|
+
const normalJid = toJid(jid);
|
|
729
|
+
const name = db.getContactName(normalJid);
|
|
730
|
+
const phone = fromJid(normalJid);
|
|
731
|
+
return { jid: normalJid, name, phone };
|
|
732
|
+
}
|
|
733
|
+
export async function sendTextMessage(jid, text) {
|
|
734
|
+
await connectionReady;
|
|
735
|
+
const s = await getSocket();
|
|
736
|
+
const normalJid = toJid(jid);
|
|
737
|
+
const sent = await sendMessageWithHealthCheck(s, normalJid, { text });
|
|
738
|
+
return {
|
|
739
|
+
success: true,
|
|
740
|
+
messageId: sent?.key.id,
|
|
741
|
+
to: normalJid,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
export async function sendFileMessage(jid, filePath, caption) {
|
|
745
|
+
const allowedDir = process.env.ALLOWED_SEND_DIR || "./uploads/";
|
|
746
|
+
const maxFileSize = Number(process.env.MAX_SEND_FILE_SIZE || "67108864");
|
|
747
|
+
const validation = validateFilePath(filePath, allowedDir, maxFileSize);
|
|
748
|
+
if (!validation.valid) {
|
|
749
|
+
throw new Error(validation.error);
|
|
750
|
+
}
|
|
751
|
+
const absolutePath = validation.absolutePath;
|
|
752
|
+
await connectionReady;
|
|
753
|
+
const s = await getSocket();
|
|
754
|
+
const normalJid = toJid(jid);
|
|
755
|
+
const mime = mimeFromExtension(absolutePath);
|
|
756
|
+
const category = mediaCategoryFromMime(mime);
|
|
757
|
+
const stream = fs.createReadStream(absolutePath);
|
|
758
|
+
const fileName = path.basename(absolutePath);
|
|
759
|
+
let messageContent;
|
|
760
|
+
switch (category) {
|
|
761
|
+
case "image":
|
|
762
|
+
messageContent = { image: stream, caption, mimetype: mime };
|
|
763
|
+
break;
|
|
764
|
+
case "video":
|
|
765
|
+
messageContent = { video: stream, caption, mimetype: mime };
|
|
766
|
+
break;
|
|
767
|
+
case "audio":
|
|
768
|
+
messageContent = { audio: stream, mimetype: mime, ptt: false };
|
|
769
|
+
break;
|
|
770
|
+
default:
|
|
771
|
+
messageContent = { document: stream, mimetype: mime, fileName, caption };
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
const sent = await sendMessageWithHealthCheck(s, normalJid, messageContent);
|
|
775
|
+
return {
|
|
776
|
+
success: true,
|
|
777
|
+
messageId: sent?.key.id,
|
|
778
|
+
to: normalJid,
|
|
779
|
+
fileType: category,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
// ─── Media Functions ────────────────────────────────────────────────
|
|
783
|
+
export async function downloadMessageMedia(jid, messageId) {
|
|
784
|
+
await connectionReady;
|
|
785
|
+
const normalJid = toJid(jid);
|
|
786
|
+
const blob = db.getMessageBlob(normalJid, messageId);
|
|
787
|
+
if (!blob) {
|
|
788
|
+
throw new Error(`Message ${messageId} not found or has no downloadable media in chat ${normalJid}`);
|
|
789
|
+
}
|
|
790
|
+
const stored = JSON.parse(blob);
|
|
791
|
+
if (!stored.message) {
|
|
792
|
+
throw new Error("Message has no content");
|
|
793
|
+
}
|
|
794
|
+
const s = getSocketOrNull();
|
|
795
|
+
const buffer = await downloadMediaMessage(stored, "buffer", {}, s ? { logger, reuploadRequest: s.updateMediaMessage } : undefined);
|
|
796
|
+
const contentType = getContentType(stored.message);
|
|
797
|
+
const mediaMsg = stored.message[contentType];
|
|
798
|
+
const mimetype = mediaMsg?.mimetype || "application/octet-stream";
|
|
799
|
+
const ext = extensionFromMime(mimetype);
|
|
800
|
+
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
|
|
801
|
+
const fileName = `${sanitizeFilename(messageId)}.${ext}`;
|
|
802
|
+
const outPath = path.join(DOWNLOADS_DIR, fileName);
|
|
803
|
+
if (!path.resolve(outPath).startsWith(path.resolve(DOWNLOADS_DIR) + path.sep)) {
|
|
804
|
+
throw new Error("Path traversal detected");
|
|
805
|
+
}
|
|
806
|
+
fs.writeFileSync(outPath, buffer);
|
|
807
|
+
return {
|
|
808
|
+
success: true,
|
|
809
|
+
filePath: path.resolve(outPath),
|
|
810
|
+
fileName,
|
|
811
|
+
mimeType: mimetype,
|
|
812
|
+
type: contentType,
|
|
813
|
+
size: buffer.length,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
export async function transcribeVoiceNote(jid, messageId) {
|
|
817
|
+
await connectionReady;
|
|
818
|
+
const normalJid = toJid(jid);
|
|
819
|
+
// Validate message type
|
|
820
|
+
const msgType = db.getMessageTypeById(normalJid, messageId);
|
|
821
|
+
if (!msgType) {
|
|
822
|
+
throw new Error(`Message ${messageId} not found in chat ${normalJid}`);
|
|
823
|
+
}
|
|
824
|
+
if (msgType !== "voice_note" && msgType !== "audio") {
|
|
825
|
+
throw new Error(`Message ${messageId} is type "${msgType}", not a voice note or audio message.`);
|
|
826
|
+
}
|
|
827
|
+
// Check cache
|
|
828
|
+
const cached = db.getTranscription(normalJid, messageId);
|
|
829
|
+
if (cached) {
|
|
830
|
+
return {
|
|
831
|
+
success: true,
|
|
832
|
+
messageId,
|
|
833
|
+
chatJid: normalJid,
|
|
834
|
+
transcription: cached,
|
|
835
|
+
cached: true,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// Download audio into RAM (no disk write)
|
|
839
|
+
const blob = db.getMessageBlob(normalJid, messageId);
|
|
840
|
+
if (!blob) {
|
|
841
|
+
throw new Error(`Message ${messageId} has no downloadable media`);
|
|
842
|
+
}
|
|
843
|
+
const stored = JSON.parse(blob);
|
|
844
|
+
if (!stored.message) {
|
|
845
|
+
throw new Error("Message has no content");
|
|
846
|
+
}
|
|
847
|
+
const s = getSocketOrNull();
|
|
848
|
+
const buffer = await downloadMediaMessage(stored, "buffer", {}, s ? { logger, reuploadRequest: s.updateMediaMessage } : undefined);
|
|
849
|
+
const contentType = getContentType(stored.message);
|
|
850
|
+
const mediaMsg = stored.message[contentType];
|
|
851
|
+
const ext = extensionFromMime(mediaMsg?.mimetype || "audio/ogg");
|
|
852
|
+
// Transcribe via Whisper API
|
|
853
|
+
const result = await transcribeAudio(buffer, `${messageId}.${ext}`);
|
|
854
|
+
// Cache in database
|
|
855
|
+
db.saveTranscription(normalJid, messageId, result.text);
|
|
856
|
+
return {
|
|
857
|
+
success: true,
|
|
858
|
+
messageId,
|
|
859
|
+
chatJid: normalJid,
|
|
860
|
+
transcription: result.text,
|
|
861
|
+
language: result.language,
|
|
862
|
+
duration: result.duration,
|
|
863
|
+
cached: false,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
function extensionFromMime(mime) {
|
|
867
|
+
const map = {
|
|
868
|
+
"image/jpeg": "jpg",
|
|
869
|
+
"image/png": "png",
|
|
870
|
+
"image/gif": "gif",
|
|
871
|
+
"image/webp": "webp",
|
|
872
|
+
"video/mp4": "mp4",
|
|
873
|
+
"audio/ogg; codecs=opus": "ogg",
|
|
874
|
+
"audio/ogg": "ogg",
|
|
875
|
+
"audio/mpeg": "mp3",
|
|
876
|
+
"audio/mp4": "m4a",
|
|
877
|
+
"application/pdf": "pdf",
|
|
878
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
879
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
880
|
+
};
|
|
881
|
+
return map[mime] || mime.split("/").pop()?.replace(/[^a-z0-9]/g, "") || "bin";
|
|
882
|
+
}
|
|
883
|
+
// ─── QR Code Rendering ─────────────────────────────────────────────
|
|
884
|
+
function printQrToStderr(qr) {
|
|
885
|
+
// @ts-ignore - qrcode-terminal has no types
|
|
886
|
+
import("qrcode-terminal").then((mod) => {
|
|
887
|
+
const QRC = mod.default || mod;
|
|
888
|
+
QRC.generate(qr, { small: true }, (code) => {
|
|
889
|
+
console.error(code);
|
|
890
|
+
});
|
|
891
|
+
}).catch(() => {
|
|
892
|
+
console.error("QR Data (copy to a QR code generator):");
|
|
893
|
+
console.error(qr);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
//# sourceMappingURL=whatsapp.js.map
|