whats-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +12 -0
- package/.gitlab-ci.yml +54 -0
- package/CHANGELOG.md +38 -0
- package/README.md +205 -0
- package/TODO.md +6 -0
- package/config.json +19 -0
- package/package.json +46 -0
- package/src/.env.example +21 -0
- package/src/admin/cli.js +916 -0
- package/src/admin/service.js +271 -0
- package/src/admin/telegram.js +178 -0
- package/src/admin.js +12 -0
- package/src/config.js +147 -0
- package/src/connection.js +334 -0
- package/src/helpers.js +264 -0
- package/src/http_app.js +267 -0
- package/src/index.js +4 -0
- package/src/main.js +71 -0
- package/src/server.js +67 -0
- package/src/store.js +925 -0
- package/src/tools/analytics.js +157 -0
- package/src/tools/channels.js +215 -0
- package/src/tools/chats.js +291 -0
- package/src/tools/contacts.js +259 -0
- package/src/tools/digest.js +249 -0
- package/src/tools/groups.js +529 -0
- package/src/tools/history-support.js +114 -0
- package/src/tools/labels.js +168 -0
- package/src/tools/messaging.js +510 -0
- package/src/tools/overview.js +416 -0
- package/src/tools/profile.js +155 -0
- package/src/tools/registry.js +105 -0
- package/src/tools/tags.js +104 -0
- package/src/tools/utils.js +325 -0
- package/src/tools/watchlists.js +136 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whats-mcp — Baileys connection manager.
|
|
3
|
+
*
|
|
4
|
+
* Manages the WhatsApp Web socket: authentication, QR handling,
|
|
5
|
+
* auto-reconnect with exponential backoff, and connection state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
default: makeWASocket,
|
|
12
|
+
useMultiFileAuthState,
|
|
13
|
+
makeCacheableSignalKeyStore,
|
|
14
|
+
fetchLatestBaileysVersion,
|
|
15
|
+
DisconnectReason,
|
|
16
|
+
Browsers,
|
|
17
|
+
} = require("@whiskeysockets/baileys");
|
|
18
|
+
const pino = require("pino");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const os = require("os");
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const qrcode = require("qrcode-terminal");
|
|
23
|
+
const { ALL_WA_PATCH_NAMES } = require("@whiskeysockets/baileys");
|
|
24
|
+
const Store = require("./store");
|
|
25
|
+
|
|
26
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** @type {import("@whiskeysockets/baileys").WASocket | null} */
|
|
29
|
+
let sock = null;
|
|
30
|
+
|
|
31
|
+
/** @type {'disconnected' | 'connecting' | 'open' | 'closing'} */
|
|
32
|
+
let connectionState = "disconnected";
|
|
33
|
+
|
|
34
|
+
/** @type {Store} */
|
|
35
|
+
let store = null;
|
|
36
|
+
|
|
37
|
+
/** @type {number} */
|
|
38
|
+
let reconnectAttempts = 0;
|
|
39
|
+
|
|
40
|
+
/** @type {any} */
|
|
41
|
+
let config = null;
|
|
42
|
+
let persistStoreTimer = null;
|
|
43
|
+
let currentAuthPath = null;
|
|
44
|
+
|
|
45
|
+
// ── Logging (stderr only — stdout is MCP JSON-RPC) ──────────────────────────
|
|
46
|
+
|
|
47
|
+
const logger = pino({ level: "silent" });
|
|
48
|
+
const log = (...args) => process.stderr.write(args.join(" ") + "\n");
|
|
49
|
+
|
|
50
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initialise and connect to WhatsApp.
|
|
54
|
+
* @param {object} cfg - Parsed config.json
|
|
55
|
+
* @returns {{ sock: import("@whiskeysockets/baileys").WASocket, store: Store }}
|
|
56
|
+
*/
|
|
57
|
+
async function connect(cfg) {
|
|
58
|
+
config = cfg;
|
|
59
|
+
|
|
60
|
+
const stateDir = (cfg.server?.state_directory || "~/.mcps/whatsapp").replace(
|
|
61
|
+
/^~/,
|
|
62
|
+
os.homedir()
|
|
63
|
+
);
|
|
64
|
+
const authPath = path.join(stateDir, "auth");
|
|
65
|
+
currentAuthPath = authPath;
|
|
66
|
+
const pidFile = path.join(stateDir, "whats-mcp.pid");
|
|
67
|
+
const storeFile = path.join(stateDir, "store.json");
|
|
68
|
+
|
|
69
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
70
|
+
fs.mkdirSync(authPath, { recursive: true });
|
|
71
|
+
|
|
72
|
+
// PID lifecycle
|
|
73
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
74
|
+
const clearPid = () => {
|
|
75
|
+
try {
|
|
76
|
+
const current = fs.readFileSync(pidFile, "utf-8").trim();
|
|
77
|
+
if (current === String(process.pid)) {
|
|
78
|
+
fs.unlinkSync(pidFile);
|
|
79
|
+
}
|
|
80
|
+
} catch (_) {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
process.on("exit", clearPid);
|
|
85
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
86
|
+
process.on("SIGINT", () => process.exit(0));
|
|
87
|
+
|
|
88
|
+
// Store
|
|
89
|
+
store = new Store({
|
|
90
|
+
...(cfg.store || {}),
|
|
91
|
+
onChange: () => {
|
|
92
|
+
if (cfg.store?.persist === false) return;
|
|
93
|
+
if (persistStoreTimer) clearTimeout(persistStoreTimer);
|
|
94
|
+
persistStoreTimer = setTimeout(() => {
|
|
95
|
+
try {
|
|
96
|
+
store.saveSnapshot(storeFile);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.warn({ err }, "Failed to persist store snapshot");
|
|
99
|
+
}
|
|
100
|
+
}, 500);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (cfg.store?.persist !== false) {
|
|
105
|
+
try {
|
|
106
|
+
if (store.loadSnapshot(storeFile)) {
|
|
107
|
+
log(`[WA] Restored store snapshot from ${storeFile}`);
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.warn({ err }, "Failed to restore store snapshot");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Bootstrap dynamic watchlists from config (non-destructive: only imports missing names)
|
|
115
|
+
if (cfg.watchlists && Object.keys(cfg.watchlists).length > 0) {
|
|
116
|
+
const imported = store.importWatchlistsFromConfig(cfg.watchlists);
|
|
117
|
+
if (imported > 0) {
|
|
118
|
+
log(`[WA] Seeded ${imported} watchlist(s) from config into store`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Connect
|
|
123
|
+
await _createSocket(authPath, cfg);
|
|
124
|
+
|
|
125
|
+
return { sock, store };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Request an explicit reconnect of the live WhatsApp socket without restarting
|
|
130
|
+
* the container. This keeps admin pollers and in-memory state stable.
|
|
131
|
+
*/
|
|
132
|
+
async function requestReconnect() {
|
|
133
|
+
if (!config || !currentAuthPath) {
|
|
134
|
+
throw new Error("WhatsApp runtime is not initialized yet.");
|
|
135
|
+
}
|
|
136
|
+
log("[WA] Admin reconnect requested.");
|
|
137
|
+
await _createSocket(currentAuthPath, config);
|
|
138
|
+
return getConnectionInfo();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get the current socket. Throws if not connected.
|
|
143
|
+
* @returns {import("@whiskeysockets/baileys").WASocket}
|
|
144
|
+
*/
|
|
145
|
+
function getSocket() {
|
|
146
|
+
if (!sock || connectionState !== "open") {
|
|
147
|
+
const { WhatsAppError } = require("./helpers");
|
|
148
|
+
throw new WhatsAppError(
|
|
149
|
+
"WhatsApp is not connected. " +
|
|
150
|
+
(connectionState === "connecting"
|
|
151
|
+
? "Connection in progress — please wait or scan the QR code."
|
|
152
|
+
: "Run the server and scan the QR code to connect."),
|
|
153
|
+
"NOT_CONNECTED"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return sock;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Get the current store. */
|
|
160
|
+
function getStore() {
|
|
161
|
+
if (!store) {
|
|
162
|
+
store = new Store();
|
|
163
|
+
}
|
|
164
|
+
return store;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Get connection state info. */
|
|
168
|
+
function getConnectionInfo() {
|
|
169
|
+
return {
|
|
170
|
+
state: connectionState,
|
|
171
|
+
user: sock?.user
|
|
172
|
+
? {
|
|
173
|
+
id: sock.user.id,
|
|
174
|
+
name: sock.user.name || sock.user.verifiedName || undefined,
|
|
175
|
+
phone: sock.user.id?.split(":")[0] || undefined,
|
|
176
|
+
}
|
|
177
|
+
: null,
|
|
178
|
+
store_stats: store?.stats() || null,
|
|
179
|
+
reconnect_attempts: reconnectAttempts,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Internal ─────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/** Whether a reconnect is already in flight (prevents concurrent reconnects). */
|
|
186
|
+
let reconnecting = false;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Gracefully close existing socket before creating a new one.
|
|
190
|
+
*/
|
|
191
|
+
function _cleanupSocket() {
|
|
192
|
+
if (sock) {
|
|
193
|
+
try {
|
|
194
|
+
sock.ev.removeAllListeners();
|
|
195
|
+
sock.end(undefined); // graceful close (no error)
|
|
196
|
+
} catch (_) { /* socket may already be dead */ }
|
|
197
|
+
sock = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function _createSocket(authPath, cfg) {
|
|
202
|
+
// Prevent parallel reconnection races
|
|
203
|
+
if (reconnecting) return;
|
|
204
|
+
reconnecting = true;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
connectionState = "connecting";
|
|
208
|
+
|
|
209
|
+
// Clean up old socket FIRST to avoid 440 (connectionReplaced)
|
|
210
|
+
_cleanupSocket();
|
|
211
|
+
|
|
212
|
+
// Fetch latest WA Web version to avoid 405 errors
|
|
213
|
+
const { version } = await fetchLatestBaileysVersion();
|
|
214
|
+
log(`[WA] Using WA Web version: ${version.join(".")}`);
|
|
215
|
+
|
|
216
|
+
const { state, saveCreds } = await useMultiFileAuthState(authPath);
|
|
217
|
+
|
|
218
|
+
sock = makeWASocket({
|
|
219
|
+
version,
|
|
220
|
+
auth: {
|
|
221
|
+
creds: state.creds,
|
|
222
|
+
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
|
223
|
+
},
|
|
224
|
+
browser: Browsers.ubuntu("Chrome"),
|
|
225
|
+
logger,
|
|
226
|
+
markOnlineOnConnect: cfg.connection?.mark_online_on_connect ?? false,
|
|
227
|
+
generateHighQualityLinkPreview: true,
|
|
228
|
+
syncFullHistory: cfg.connection?.sync_full_history ?? true,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Bind store events
|
|
232
|
+
store.bind(sock);
|
|
233
|
+
|
|
234
|
+
// Credential persistence
|
|
235
|
+
sock.ev.on("creds.update", saveCreds);
|
|
236
|
+
|
|
237
|
+
// Connection state handling
|
|
238
|
+
sock.ev.on("connection.update", async (update) => {
|
|
239
|
+
const { connection, lastDisconnect, qr } = update;
|
|
240
|
+
|
|
241
|
+
if (qr) {
|
|
242
|
+
log("\n[WA] Scan this QR code with WhatsApp (Linked Devices):\n");
|
|
243
|
+
qrcode.generate(qr, { small: true }, (code) => {
|
|
244
|
+
process.stderr.write(code + "\n");
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (connection === "open") {
|
|
249
|
+
connectionState = "open";
|
|
250
|
+
reconnectAttempts = 0;
|
|
251
|
+
const user = sock.user;
|
|
252
|
+
log(
|
|
253
|
+
`[WA] Connected as ${user?.name || "?"} (${user?.id || "?"}) — PID ${process.pid}`
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
if (cfg.connection?.refresh_app_state_on_open ?? true) {
|
|
258
|
+
await sock.resyncAppState(ALL_WA_PATCH_NAMES, true);
|
|
259
|
+
const lastAccountSyncTimestamp = sock.authState?.creds?.lastAccountSyncTimestamp;
|
|
260
|
+
if (lastAccountSyncTimestamp) {
|
|
261
|
+
await sock.cleanDirtyBits("account_sync", lastAccountSyncTimestamp);
|
|
262
|
+
}
|
|
263
|
+
log("[WA] App state refreshed on open.");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const groups = await sock.groupFetchAllParticipating();
|
|
267
|
+
for (const meta of Object.values(groups || {})) {
|
|
268
|
+
if (meta?.id) {
|
|
269
|
+
store.setGroupMeta(meta.id, meta);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
log(`[WA] Preloaded ${Object.keys(groups || {}).length} groups into store.`);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.warn({ err }, "Failed to preload groups after connect");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (connection === "close") {
|
|
279
|
+
connectionState = "disconnected";
|
|
280
|
+
const statusCode =
|
|
281
|
+
lastDisconnect?.error?.output?.statusCode;
|
|
282
|
+
const maxAttempts = cfg.connection?.max_reconnect_attempts ?? 10;
|
|
283
|
+
|
|
284
|
+
// 440 = connectionReplaced — another session took over, don't fight it
|
|
285
|
+
// 401 = loggedOut — need re-pairing
|
|
286
|
+
const noReconnectCodes = new Set([
|
|
287
|
+
DisconnectReason.loggedOut, // 401
|
|
288
|
+
DisconnectReason.connectionReplaced, // 440
|
|
289
|
+
]);
|
|
290
|
+
const shouldReconnect = !noReconnectCodes.has(statusCode);
|
|
291
|
+
|
|
292
|
+
log(
|
|
293
|
+
`[WA] Disconnected (code=${statusCode}). ` +
|
|
294
|
+
(shouldReconnect
|
|
295
|
+
? "Will reconnect..."
|
|
296
|
+
: statusCode === DisconnectReason.loggedOut
|
|
297
|
+
? "Logged out — delete auth to re-pair."
|
|
298
|
+
: "Connection replaced by another session.")
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (shouldReconnect && reconnectAttempts < maxAttempts) {
|
|
302
|
+
reconnectAttempts++;
|
|
303
|
+
const delay = Math.min(
|
|
304
|
+
(cfg.connection?.reconnect_interval_ms || 3000) * Math.pow(1.5, reconnectAttempts - 1),
|
|
305
|
+
30000
|
|
306
|
+
);
|
|
307
|
+
log(`[WA] Reconnect attempt ${reconnectAttempts}/${maxAttempts} in ${Math.round(delay)}ms...`);
|
|
308
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
309
|
+
await _createSocket(authPath, cfg);
|
|
310
|
+
} else if (!shouldReconnect) {
|
|
311
|
+
if (statusCode === DisconnectReason.loggedOut) {
|
|
312
|
+
log("[WA] Session logged out. Remove auth folder and restart to re-pair.");
|
|
313
|
+
} else {
|
|
314
|
+
log("[WA] Connection replaced. Restart the server if needed.");
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
log("[WA] Max reconnect attempts reached. Restart the server manually.");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
} finally {
|
|
322
|
+
reconnecting = false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
connect,
|
|
330
|
+
getSocket,
|
|
331
|
+
getStore,
|
|
332
|
+
getConnectionInfo,
|
|
333
|
+
requestReconnect,
|
|
334
|
+
};
|
package/src/helpers.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whats-mcp — Helpers & utilities.
|
|
3
|
+
*
|
|
4
|
+
* JID formatting, error wrapping, media resolution, common constants.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
|
|
12
|
+
// ── JID helpers ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Normalise a phone number to a WhatsApp personal JID. */
|
|
15
|
+
function phoneToJid(phone) {
|
|
16
|
+
const str = String(phone);
|
|
17
|
+
// If it's already a JID (contains @), return as-is — preserve legacy group JIDs like 1234-5678@g.us
|
|
18
|
+
if (str.includes("@")) return str;
|
|
19
|
+
// Strip +, spaces, dashes, parens from plain phone numbers
|
|
20
|
+
const clean = str.replace(/[+\s\-()]/g, "");
|
|
21
|
+
return `${clean}@s.whatsapp.net`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normalise a group JID. */
|
|
25
|
+
function groupJid(jid) {
|
|
26
|
+
if (jid.includes("@g.us")) return jid;
|
|
27
|
+
return `${jid}@g.us`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Normalise a newsletter/channel JID. */
|
|
31
|
+
function newsletterJid(jid) {
|
|
32
|
+
if (jid.includes("@newsletter")) return jid;
|
|
33
|
+
return `${jid}@newsletter`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract the raw number from a JID. */
|
|
37
|
+
function jidToPhone(jid) {
|
|
38
|
+
return (jid || "").split("@")[0].split(":")[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if JID is a group. */
|
|
42
|
+
function isGroupJid(jid) {
|
|
43
|
+
return (jid || "").endsWith("@g.us");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check if JID is a newsletter/channel. */
|
|
47
|
+
function isNewsletterJid(jid) {
|
|
48
|
+
return (jid || "").includes("@newsletter");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Status broadcast JID. */
|
|
52
|
+
const STATUS_BROADCAST = "status@broadcast";
|
|
53
|
+
|
|
54
|
+
// ── Error helpers ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
class WhatsAppError extends Error {
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} message
|
|
59
|
+
* @param {string} [code]
|
|
60
|
+
*/
|
|
61
|
+
constructor(message, code) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "WhatsAppError";
|
|
64
|
+
this.code = code || "WA_ERROR";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Format an MCP error result. */
|
|
69
|
+
function errResult(message) {
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: "text", text: `❌ ${message}` }],
|
|
72
|
+
isError: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Format an MCP success result. */
|
|
77
|
+
function okResult(data) {
|
|
78
|
+
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
79
|
+
return { content: [{ type: "text", text }] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Media helpers ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a media source — supports:
|
|
86
|
+
* - file:///absolute/path
|
|
87
|
+
* - http(s)://url
|
|
88
|
+
* - base64 data string
|
|
89
|
+
* - local file path
|
|
90
|
+
*
|
|
91
|
+
* Returns an object suitable for Baileys `WAMediaUpload`.
|
|
92
|
+
*/
|
|
93
|
+
function resolveMedia(source) {
|
|
94
|
+
if (!source) throw new WhatsAppError("Media source is required.");
|
|
95
|
+
|
|
96
|
+
// Base64
|
|
97
|
+
if (source.startsWith("data:")) {
|
|
98
|
+
const match = source.match(/^data:[^;]+;base64,(.+)$/);
|
|
99
|
+
if (match) return Buffer.from(match[1], "base64");
|
|
100
|
+
throw new WhatsAppError("Invalid base64 data URI.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Raw base64 (no data: prefix, heuristic)
|
|
104
|
+
if (/^[A-Za-z0-9+/=]{100,}$/.test(source)) {
|
|
105
|
+
return Buffer.from(source, "base64");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// URL
|
|
109
|
+
if (/^https?:\/\//.test(source)) {
|
|
110
|
+
return { url: source };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// file:// protocol
|
|
114
|
+
if (source.startsWith("file://")) {
|
|
115
|
+
const filePath = source.replace("file://", "");
|
|
116
|
+
return fs.readFileSync(filePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Local file path
|
|
120
|
+
if (fs.existsSync(source)) {
|
|
121
|
+
return fs.readFileSync(source);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new WhatsAppError(`Cannot resolve media source: ${source}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse a message key from tool arguments.
|
|
129
|
+
* Accepts either a full key object or individual fields.
|
|
130
|
+
*/
|
|
131
|
+
function parseMessageKey(args) {
|
|
132
|
+
if (args.key) return args.key;
|
|
133
|
+
const { remote_jid, id, from_me, participant } = args;
|
|
134
|
+
if (!remote_jid || !id) {
|
|
135
|
+
throw new WhatsAppError("Message key requires remote_jid and id.");
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
remoteJid: remote_jid,
|
|
139
|
+
id,
|
|
140
|
+
fromMe: from_me ?? false,
|
|
141
|
+
participant: participant || undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Format a WAMessage for display.
|
|
147
|
+
*/
|
|
148
|
+
function formatMessage(msg) {
|
|
149
|
+
if (!msg) return null;
|
|
150
|
+
const key = msg.key || {};
|
|
151
|
+
const content = msg.message || {};
|
|
152
|
+
|
|
153
|
+
// Determine message type and text
|
|
154
|
+
let type = "unknown";
|
|
155
|
+
let text = "";
|
|
156
|
+
if (content.conversation) {
|
|
157
|
+
type = "text";
|
|
158
|
+
text = content.conversation;
|
|
159
|
+
} else if (content.extendedTextMessage) {
|
|
160
|
+
type = "text";
|
|
161
|
+
text = content.extendedTextMessage.text || "";
|
|
162
|
+
} else if (content.imageMessage) {
|
|
163
|
+
type = "image";
|
|
164
|
+
text = content.imageMessage.caption || "[image]";
|
|
165
|
+
} else if (content.videoMessage) {
|
|
166
|
+
type = "video";
|
|
167
|
+
text = content.videoMessage.caption || "[video]";
|
|
168
|
+
} else if (content.audioMessage) {
|
|
169
|
+
type = content.audioMessage.ptt ? "voice_note" : "audio";
|
|
170
|
+
text = "[audio]";
|
|
171
|
+
} else if (content.documentMessage) {
|
|
172
|
+
type = "document";
|
|
173
|
+
text = content.documentMessage.fileName || "[document]";
|
|
174
|
+
} else if (content.stickerMessage) {
|
|
175
|
+
type = "sticker";
|
|
176
|
+
text = "[sticker]";
|
|
177
|
+
} else if (content.locationMessage) {
|
|
178
|
+
type = "location";
|
|
179
|
+
const loc = content.locationMessage;
|
|
180
|
+
text = `[location: ${loc.degreesLatitude}, ${loc.degreesLongitude}]`;
|
|
181
|
+
} else if (content.contactMessage || content.contactsArrayMessage) {
|
|
182
|
+
type = "contact";
|
|
183
|
+
text = "[contact card]";
|
|
184
|
+
} else if (content.reactionMessage) {
|
|
185
|
+
type = "reaction";
|
|
186
|
+
text = content.reactionMessage.text || "";
|
|
187
|
+
} else if (content.pollCreationMessage || content.pollCreationMessageV3) {
|
|
188
|
+
type = "poll";
|
|
189
|
+
const poll = content.pollCreationMessage || content.pollCreationMessageV3;
|
|
190
|
+
text = poll.name || "[poll]";
|
|
191
|
+
} else if (content.protocolMessage) {
|
|
192
|
+
const proto = content.protocolMessage;
|
|
193
|
+
if (proto.type === 0 || proto.type === "REVOKE") {
|
|
194
|
+
type = "deleted";
|
|
195
|
+
text = "[message deleted]";
|
|
196
|
+
} else if (proto.editedMessage) {
|
|
197
|
+
type = "edited";
|
|
198
|
+
text = "[message edited]";
|
|
199
|
+
} else {
|
|
200
|
+
type = "protocol";
|
|
201
|
+
text = "[system message]";
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// Try to find any message type
|
|
205
|
+
const keys = Object.keys(content);
|
|
206
|
+
if (keys.length > 0) {
|
|
207
|
+
type = keys[0].replace("Message", "");
|
|
208
|
+
text = `[${type}]`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
id: key.id,
|
|
214
|
+
from: key.remoteJid,
|
|
215
|
+
from_me: key.fromMe || false,
|
|
216
|
+
participant: key.participant || undefined,
|
|
217
|
+
timestamp: msg.messageTimestamp
|
|
218
|
+
? typeof msg.messageTimestamp === "number"
|
|
219
|
+
? msg.messageTimestamp
|
|
220
|
+
: Number(msg.messageTimestamp)
|
|
221
|
+
: undefined,
|
|
222
|
+
type,
|
|
223
|
+
text,
|
|
224
|
+
push_name: msg.pushName || undefined,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format a chat object for display.
|
|
230
|
+
*/
|
|
231
|
+
function formatChat(chat) {
|
|
232
|
+
return {
|
|
233
|
+
jid: chat.id,
|
|
234
|
+
name: chat.name || chat.subject || jidToPhone(chat.id),
|
|
235
|
+
unread_count: chat.unreadCount || 0,
|
|
236
|
+
is_group: isGroupJid(chat.id),
|
|
237
|
+
is_newsletter: isNewsletterJid(chat.id),
|
|
238
|
+
archived: chat.archived || false,
|
|
239
|
+
pinned: chat.pinned ? true : false,
|
|
240
|
+
muted: chat.mute ? true : false,
|
|
241
|
+
timestamp: chat.conversationTimestamp
|
|
242
|
+
? Number(chat.conversationTimestamp)
|
|
243
|
+
: undefined,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
phoneToJid,
|
|
251
|
+
groupJid,
|
|
252
|
+
newsletterJid,
|
|
253
|
+
jidToPhone,
|
|
254
|
+
isGroupJid,
|
|
255
|
+
isNewsletterJid,
|
|
256
|
+
STATUS_BROADCAST,
|
|
257
|
+
WhatsAppError,
|
|
258
|
+
errResult,
|
|
259
|
+
okResult,
|
|
260
|
+
resolveMedia,
|
|
261
|
+
parseMessageKey,
|
|
262
|
+
formatMessage,
|
|
263
|
+
formatChat,
|
|
264
|
+
};
|