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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * whats-mcp — shared admin helpers.
3
+ */
4
+
5
+ "use strict";
6
+
7
+ const { spawn } = require("child_process");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const { loadConfig } = require("../config");
13
+ const { getConnectionInfo } = require("../connection");
14
+
15
+ function stateDir() {
16
+ const cfg = loadConfig();
17
+ return (cfg.server?.state_directory || "~/.mcps/whatsapp").replace(/^~/, os.homedir());
18
+ }
19
+
20
+ function authDir() {
21
+ return path.join(stateDir(), "auth");
22
+ }
23
+
24
+ function pidFile() {
25
+ return path.join(stateDir(), "whats-mcp.pid");
26
+ }
27
+
28
+ function logFile() {
29
+ return path.join(stateDir(), "whats-mcp.log");
30
+ }
31
+
32
+ function appendAdminLog(message) {
33
+ fs.mkdirSync(stateDir(), { recursive: true });
34
+ const line = `${new Date().toISOString()} ${message}\n`;
35
+ fs.appendFileSync(logFile(), line, "utf-8");
36
+ }
37
+
38
+ function authExists() {
39
+ const dir = authDir();
40
+ return fs.existsSync(dir) && fs.readdirSync(dir).length > 0;
41
+ }
42
+
43
+ function readPid() {
44
+ try {
45
+ return parseInt(fs.readFileSync(pidFile(), "utf-8").trim(), 10);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function isRunning(pid) {
52
+ if (!pid) return false;
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function authSummary() {
62
+ return {
63
+ state_directory: stateDir(),
64
+ auth_directory: authDir(),
65
+ auth_present: authExists(),
66
+ pid: readPid(),
67
+ running: isRunning(readPid()),
68
+ connection: getConnectionInfo(),
69
+ };
70
+ }
71
+
72
+ const PAIRING_RUNTIME = {
73
+ active: false,
74
+ phone: null,
75
+ started_at: null,
76
+ pairing_code: null,
77
+ last_error: null,
78
+ };
79
+
80
+ function statusSummaryText(overrides = {}) {
81
+ const cfg = loadConfig();
82
+ const summary = authSummary();
83
+ const pid = overrides.pid ?? summary.pid;
84
+ const running = overrides.running ?? summary.running;
85
+ const connectionState = overrides.connection_state ?? summary.connection.state;
86
+ const user = overrides.user ?? summary.connection.user;
87
+ const lines = [
88
+ "whats-admin status",
89
+ `- service: ${cfg.server.name} ${cfg.server.version}`,
90
+ `- state directory: ${summary.state_directory}`,
91
+ `- auth persisted: ${summary.auth_present ? "yes" : "no"}`,
92
+ `- server running: ${running ? `yes (pid ${pid})` : "no"}`,
93
+ `- connection state: ${connectionState}`,
94
+ ];
95
+ if (user) {
96
+ lines.push(`- account: ${user.name || "?"} (${user.phone || "?"})`);
97
+ }
98
+ return lines.join("\n");
99
+ }
100
+
101
+ function healthSummaryText() {
102
+ const cfg = loadConfig();
103
+ return [
104
+ "whats-mcp health",
105
+ `- public: ${cfg.server.public_base_url}`,
106
+ `- fallback: ${cfg.server.fallback_base_url}`,
107
+ `- mcp: ${cfg.server.public_base_url}${cfg.server.http_mcp_path}`,
108
+ `- local port: ${cfg.server.http_port}`,
109
+ ].join("\n");
110
+ }
111
+
112
+ function urlsSummary() {
113
+ const cfg = loadConfig();
114
+ return [
115
+ "whats-mcp URLs",
116
+ `- public: ${cfg.server.public_base_url}`,
117
+ `- fallback: ${cfg.server.fallback_base_url}`,
118
+ `- mcp: ${cfg.server.public_base_url}${cfg.server.http_mcp_path}`,
119
+ `- health: ${cfg.server.public_base_url}/health`,
120
+ `- admin status: ${cfg.server.public_base_url}/admin/status`,
121
+ `- admin help: ${cfg.server.public_base_url}/admin/help`,
122
+ ].join("\n");
123
+ }
124
+
125
+ function adminHelpText() {
126
+ return [
127
+ "whats-admin capabilities",
128
+ "- CLI:",
129
+ " - whats-admin status",
130
+ " - whats-admin guide",
131
+ " - whats-admin login [--code] [--phone N]",
132
+ " - whats-admin logout [-f]",
133
+ " - whats-admin server status|stop|restart|reconnect|pid|test",
134
+ " - whats-admin config show|edit|reset|path",
135
+ " - whats-admin logs show|tail|clean|path",
136
+ "- HTTP:",
137
+ " - GET /health",
138
+ " - GET /admin/status",
139
+ " - GET /admin/help",
140
+ " - POST /admin/reconnect",
141
+ " - POST /admin/pair-code {\"phone\":\"33612345678\"}",
142
+ "- Telegram:",
143
+ " - /start",
144
+ " - /help",
145
+ " - /status",
146
+ " - /health",
147
+ " - /urls",
148
+ " - /logs [lines]",
149
+ " - /pair_code <phone>",
150
+ " - /reconnect",
151
+ " - /restart",
152
+ ].join("\n");
153
+ }
154
+
155
+ function normalizePhoneNumber(raw) {
156
+ return String(raw || "").replace(/[^\d]/g, "");
157
+ }
158
+
159
+ function pairingRuntimeStatus() {
160
+ return { ...PAIRING_RUNTIME };
161
+ }
162
+
163
+ function requestPairingCode(phone) {
164
+ const normalizedPhone = normalizePhoneNumber(phone);
165
+ if (!normalizedPhone || !/^\d{8,15}$/.test(normalizedPhone)) {
166
+ throw new Error("Invalid phone number. Use country code; separators are allowed and will be stripped.");
167
+ }
168
+ if (PAIRING_RUNTIME.active) {
169
+ const activeFor = PAIRING_RUNTIME.phone || "another number";
170
+ throw new Error(`A pairing flow is already active for ${activeFor}. Finish or wait for it to time out.`);
171
+ }
172
+
173
+ return new Promise((resolve, reject) => {
174
+ const cliEntry = path.join(__dirname, "..", "admin.js");
175
+ const child = spawn(
176
+ process.execPath,
177
+ [cliEntry, "login", "--code", "--phone", normalizedPhone, "--force"],
178
+ {
179
+ cwd: path.resolve(__dirname, ".."),
180
+ env: { ...process.env, NO_COLOR: "1" },
181
+ stdio: ["ignore", "pipe", "pipe"],
182
+ },
183
+ );
184
+
185
+ PAIRING_RUNTIME.active = true;
186
+ PAIRING_RUNTIME.phone = normalizedPhone;
187
+ PAIRING_RUNTIME.started_at = Math.floor(Date.now() / 1000);
188
+ PAIRING_RUNTIME.pairing_code = null;
189
+ PAIRING_RUNTIME.last_error = null;
190
+
191
+ const cleanup = (errorMessage = null) => {
192
+ PAIRING_RUNTIME.active = false;
193
+ PAIRING_RUNTIME.phone = null;
194
+ PAIRING_RUNTIME.started_at = null;
195
+ if (errorMessage) {
196
+ PAIRING_RUNTIME.last_error = errorMessage;
197
+ }
198
+ };
199
+
200
+ let settled = false;
201
+ const handleChunk = (chunk) => {
202
+ const text = String(chunk || "");
203
+ const plainText = text.replace(/\x1B\[[0-9;]*m/g, "");
204
+ const match = plainText.match(/Pairing Code:\s*([A-Z0-9-]+)/i);
205
+ if (!match || settled) return;
206
+ settled = true;
207
+ const code = match[1];
208
+ PAIRING_RUNTIME.pairing_code = code;
209
+ appendAdminLog(`pairing code generated for ${normalizedPhone}`);
210
+ resolve({
211
+ phone: normalizedPhone,
212
+ code,
213
+ });
214
+ };
215
+
216
+ child.stdout.on("data", handleChunk);
217
+ child.stderr.on("data", handleChunk);
218
+
219
+ child.on("error", (error) => {
220
+ cleanup(error.message || String(error));
221
+ if (!settled) {
222
+ settled = true;
223
+ reject(error);
224
+ }
225
+ });
226
+
227
+ child.on("exit", (code, signal) => {
228
+ const errorMessage =
229
+ code === 0 && !signal
230
+ ? null
231
+ : `pairing helper exited with code=${code ?? "null"} signal=${signal ?? "null"}`;
232
+ cleanup(errorMessage);
233
+ if (!settled) {
234
+ settled = true;
235
+ reject(new Error(PAIRING_RUNTIME.last_error || "Pairing flow ended before a code was produced."));
236
+ }
237
+ });
238
+ });
239
+ }
240
+
241
+ function getLogsText(limit = 50) {
242
+ const file = logFile();
243
+ if (!fs.existsSync(file)) {
244
+ return "No admin log lines available.";
245
+ }
246
+ const lines = fs.readFileSync(file, "utf-8").split(/\r?\n/).filter(Boolean);
247
+ if (lines.length === 0) {
248
+ return "No admin log lines available.";
249
+ }
250
+ return lines.slice(-Math.max(1, limit)).join("\n");
251
+ }
252
+
253
+ module.exports = {
254
+ adminHelpText,
255
+ authDir,
256
+ authExists,
257
+ authSummary,
258
+ appendAdminLog,
259
+ getLogsText,
260
+ healthSummaryText,
261
+ isRunning,
262
+ logFile,
263
+ normalizePhoneNumber,
264
+ pairingRuntimeStatus,
265
+ pidFile,
266
+ requestPairingCode,
267
+ readPid,
268
+ stateDir,
269
+ statusSummaryText,
270
+ urlsSummary,
271
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * whats-mcp — Telegram admin bridge.
3
+ */
4
+
5
+ "use strict";
6
+
7
+ const {
8
+ adminHelpText,
9
+ appendAdminLog,
10
+ getLogsText,
11
+ healthSummaryText,
12
+ requestPairingCode,
13
+ statusSummaryText,
14
+ urlsSummary,
15
+ } = require("./service");
16
+
17
+ const TELEGRAM_RUNTIME = {
18
+ enabled: false,
19
+ started: false,
20
+ thread_alive: false,
21
+ allowed_chat_ids: [],
22
+ allowed_chat_count: 0,
23
+ started_at: null,
24
+ last_poll_at: null,
25
+ last_success_at: null,
26
+ last_update_id: null,
27
+ last_chat_id: null,
28
+ last_command: null,
29
+ last_reply_preview: null,
30
+ last_error: null,
31
+ };
32
+
33
+ let pollerHandle = null;
34
+
35
+ function telegramAdminEnabled() {
36
+ return Boolean(process.env.TELEGRAM_WHATS_HOMELAB_TOKEN && process.env.TELEGRAM_CHAT_IDS);
37
+ }
38
+
39
+ function telegramAdminRuntimeStatus() {
40
+ return { ...TELEGRAM_RUNTIME };
41
+ }
42
+
43
+ async function apiCall(method, body) {
44
+ const token = process.env.TELEGRAM_WHATS_HOMELAB_TOKEN;
45
+ const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
46
+ method: "POST",
47
+ headers: { "content-type": "application/json" },
48
+ body: JSON.stringify(body),
49
+ });
50
+ const payload = await response.json();
51
+ if (!payload.ok) {
52
+ throw new Error(payload.description || `Telegram API call failed: ${method}`);
53
+ }
54
+ return payload.result;
55
+ }
56
+
57
+ async function sendMessage(chatId, text) {
58
+ await apiCall("sendMessage", { chat_id: chatId, text });
59
+ }
60
+
61
+ function parseAllowedChatIds() {
62
+ return String(process.env.TELEGRAM_CHAT_IDS || "")
63
+ .split(",")
64
+ .map((value) => value.trim())
65
+ .filter(Boolean);
66
+ }
67
+
68
+ async function dispatchTelegramCommand(command, args, handlers = {}) {
69
+ const onReconnect = handlers.onReconnect || null;
70
+ const onRestart = handlers.onRestart || null;
71
+ if (command === "/start" || command === "/help") return adminHelpText();
72
+ if (command === "/status") return statusSummaryText();
73
+ if (command === "/health") return healthSummaryText();
74
+ if (command === "/urls") return urlsSummary();
75
+ if (command === "/logs") {
76
+ const limit = Number.parseInt(args[0] || "20", 10);
77
+ return getLogsText(Number.isNaN(limit) ? 20 : limit);
78
+ }
79
+ if (command === "/pair_code") {
80
+ if (!args[0]) {
81
+ return "Usage: /pair_code <phone>";
82
+ }
83
+ const pairing = await requestPairingCode(args[0]);
84
+ return [
85
+ "whats-mcp pairing code",
86
+ `- phone: ${pairing.phone}`,
87
+ `- code: ${pairing.code}`,
88
+ "- go to WhatsApp → Linked Devices → Link with Phone Number",
89
+ "- enter the code on your phone before it expires",
90
+ ].join("\n");
91
+ }
92
+ if (command === "/reconnect") {
93
+ if (onReconnect) {
94
+ await onReconnect();
95
+ }
96
+ return "whats-mcp reconnect requested";
97
+ }
98
+ if (command === "/restart") {
99
+ if (onRestart) {
100
+ setTimeout(() => onRestart(), 1000);
101
+ }
102
+ return "whats-mcp restart requested";
103
+ }
104
+ return "Unknown command. Use /help.";
105
+ }
106
+
107
+ function startTelegramAdmin(handlers = {}) {
108
+ if (pollerHandle || !telegramAdminEnabled()) {
109
+ return;
110
+ }
111
+ const allowedChatIds = parseAllowedChatIds();
112
+ TELEGRAM_RUNTIME.enabled = true;
113
+ TELEGRAM_RUNTIME.started = true;
114
+ TELEGRAM_RUNTIME.thread_alive = true;
115
+ TELEGRAM_RUNTIME.allowed_chat_ids = allowedChatIds;
116
+ TELEGRAM_RUNTIME.allowed_chat_count = allowedChatIds.length;
117
+ TELEGRAM_RUNTIME.started_at = Math.floor(Date.now() / 1000);
118
+
119
+ let offset = 0;
120
+
121
+ const pollOnce = async ({ discardBacklog = false } = {}) => {
122
+ TELEGRAM_RUNTIME.last_poll_at = Math.floor(Date.now() / 1000);
123
+ const updates = await apiCall("getUpdates", {
124
+ timeout: 0,
125
+ offset,
126
+ allowed_updates: ["message"],
127
+ });
128
+ TELEGRAM_RUNTIME.last_success_at = Math.floor(Date.now() / 1000);
129
+ if (discardBacklog) {
130
+ if (updates.length > 0) {
131
+ const latestUpdate = updates[updates.length - 1];
132
+ offset = latestUpdate.update_id + 1;
133
+ TELEGRAM_RUNTIME.last_update_id = latestUpdate.update_id;
134
+ appendAdminLog(`telegram backlog discarded count=${updates.length} last_update_id=${latestUpdate.update_id}`);
135
+ }
136
+ TELEGRAM_RUNTIME.last_error = null;
137
+ return;
138
+ }
139
+ for (const update of updates) {
140
+ offset = update.update_id + 1;
141
+ TELEGRAM_RUNTIME.last_update_id = update.update_id;
142
+ const message = update.message;
143
+ if (!message || !message.text) continue;
144
+ const chatId = String(message.chat.id);
145
+ TELEGRAM_RUNTIME.last_chat_id = chatId;
146
+ if (!allowedChatIds.includes(chatId)) continue;
147
+ const [command, ...args] = message.text.trim().split(/\s+/);
148
+ TELEGRAM_RUNTIME.last_command = command;
149
+ const reply = await dispatchTelegramCommand(command, args, handlers);
150
+ TELEGRAM_RUNTIME.last_reply_preview = reply.slice(0, 120);
151
+ appendAdminLog(`telegram command chat=${chatId} command=${command}`);
152
+ await sendMessage(chatId, reply);
153
+ appendAdminLog(`telegram reply chat=${chatId} preview=${reply.slice(0, 120)}`);
154
+ }
155
+ TELEGRAM_RUNTIME.last_error = null;
156
+ };
157
+
158
+ pollOnce({ discardBacklog: true }).catch((error) => {
159
+ TELEGRAM_RUNTIME.last_error = error.message || String(error);
160
+ appendAdminLog(`telegram bootstrap error ${TELEGRAM_RUNTIME.last_error}`);
161
+ });
162
+
163
+ pollerHandle = setInterval(async () => {
164
+ try {
165
+ await pollOnce();
166
+ } catch (error) {
167
+ TELEGRAM_RUNTIME.last_error = error.message || String(error);
168
+ appendAdminLog(`telegram error ${TELEGRAM_RUNTIME.last_error}`);
169
+ }
170
+ }, 5000);
171
+ }
172
+
173
+ module.exports = {
174
+ dispatchTelegramCommand,
175
+ startTelegramAdmin,
176
+ telegramAdminEnabled,
177
+ telegramAdminRuntimeStatus,
178
+ };
package/src/admin.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { program } = require("./admin/cli");
5
+
6
+ if (require.main === module) {
7
+ program.parse(process.argv);
8
+ }
9
+
10
+ module.exports = {
11
+ program,
12
+ };
package/src/config.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * whats-mcp — Configuration loader.
3
+ *
4
+ * Loads package-internal .env defaults, then config.json, then
5
+ * environment variable overrides.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+
13
+ const CONFIG_FILE = path.join(__dirname, "..", "config.json");
14
+ const PACKAGE_JSON = require(path.join(__dirname, "..", "package.json"));
15
+ const ENV_FILE = path.join(__dirname, ".env");
16
+
17
+ const DEFAULTS = {
18
+ server: {
19
+ state_directory: "~/.mcps/whatsapp",
20
+ http_host: "127.0.0.1",
21
+ http_port: 8092,
22
+ http_mcp_path: "/mcp",
23
+ public_base_url: "https://whats.kpihx-labs.com",
24
+ fallback_base_url: "https://whats.homelab",
25
+ },
26
+ connection: {
27
+ print_qr_in_terminal: true,
28
+ reconnect_interval_ms: 3000,
29
+ max_reconnect_attempts: 10,
30
+ mark_online_on_connect: false,
31
+ sync_full_history: true,
32
+ refresh_app_state_on_open: true,
33
+ },
34
+ store: {
35
+ max_messages_per_chat: 5000,
36
+ max_chats: 1000,
37
+ persist: true,
38
+ },
39
+ logging: {
40
+ level: "error",
41
+ },
42
+ watchlists: {},
43
+ };
44
+
45
+ /**
46
+ * Deep-merge b into a (b wins). Mutates a.
47
+ */
48
+ function _merge(a, b) {
49
+ for (const key of Object.keys(b)) {
50
+ if (
51
+ a[key] &&
52
+ typeof a[key] === "object" &&
53
+ !Array.isArray(a[key]) &&
54
+ typeof b[key] === "object" &&
55
+ !Array.isArray(b[key])
56
+ ) {
57
+ _merge(a[key], b[key]);
58
+ } else {
59
+ a[key] = b[key];
60
+ }
61
+ }
62
+ return a;
63
+ }
64
+
65
+ function _loadEnvDefaults() {
66
+ if (!fs.existsSync(ENV_FILE)) {
67
+ return;
68
+ }
69
+ const raw = fs.readFileSync(ENV_FILE, "utf-8");
70
+ for (const line of raw.split(/\r?\n/)) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed || trimmed.startsWith("#")) {
73
+ continue;
74
+ }
75
+ const idx = trimmed.indexOf("=");
76
+ if (idx === -1) {
77
+ continue;
78
+ }
79
+ const key = trimmed.slice(0, idx).trim();
80
+ const value = trimmed.slice(idx + 1);
81
+ if (!process.env[key]) {
82
+ process.env[key] = value;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Load and return the merged configuration.
89
+ */
90
+ function loadConfig() {
91
+ _loadEnvDefaults();
92
+ const config = JSON.parse(JSON.stringify(DEFAULTS));
93
+ config.server.name = PACKAGE_JSON.name;
94
+ config.server.version = PACKAGE_JSON.version;
95
+
96
+ // Load config.json if it exists
97
+ if (fs.existsSync(CONFIG_FILE)) {
98
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
99
+ const fileConfig = JSON.parse(raw);
100
+ _merge(config, fileConfig);
101
+ }
102
+
103
+ // Environment variable overrides
104
+ if (process.env.WHATSAPP_STATE_DIR) {
105
+ config.server.state_directory = process.env.WHATSAPP_STATE_DIR;
106
+ }
107
+ if (process.env.WHATSAPP_LOG_LEVEL) {
108
+ config.logging.level = process.env.WHATSAPP_LOG_LEVEL;
109
+ }
110
+ if (process.env.WHATSAPP_MAX_RECONNECT) {
111
+ config.connection.max_reconnect_attempts = parseInt(process.env.WHATSAPP_MAX_RECONNECT, 10);
112
+ }
113
+ if (process.env.WHATSAPP_PRINT_QR !== undefined) {
114
+ config.connection.print_qr_in_terminal = process.env.WHATSAPP_PRINT_QR !== "false";
115
+ }
116
+ if (process.env.WHATSAPP_SYNC_FULL_HISTORY !== undefined) {
117
+ config.connection.sync_full_history = process.env.WHATSAPP_SYNC_FULL_HISTORY !== "false";
118
+ }
119
+ if (process.env.WHATSAPP_REFRESH_APP_STATE !== undefined) {
120
+ config.connection.refresh_app_state_on_open = process.env.WHATSAPP_REFRESH_APP_STATE !== "false";
121
+ }
122
+ if (process.env.WHATSAPP_PERSIST_STORE !== undefined) {
123
+ config.store.persist = process.env.WHATSAPP_PERSIST_STORE !== "false";
124
+ }
125
+ if (process.env.WHATSAPP_MAX_MESSAGES_PER_CHAT) {
126
+ config.store.max_messages_per_chat = parseInt(process.env.WHATSAPP_MAX_MESSAGES_PER_CHAT, 10);
127
+ }
128
+ if (process.env.WHATS_MCP_HTTP_HOST) {
129
+ config.server.http_host = process.env.WHATS_MCP_HTTP_HOST;
130
+ }
131
+ if (process.env.WHATS_MCP_HTTP_PORT) {
132
+ config.server.http_port = parseInt(process.env.WHATS_MCP_HTTP_PORT, 10);
133
+ }
134
+ if (process.env.WHATS_MCP_HTTP_MCP_PATH) {
135
+ config.server.http_mcp_path = process.env.WHATS_MCP_HTTP_MCP_PATH;
136
+ }
137
+ if (process.env.WHATS_MCP_PUBLIC_BASE_URL) {
138
+ config.server.public_base_url = process.env.WHATS_MCP_PUBLIC_BASE_URL;
139
+ }
140
+ if (process.env.WHATS_MCP_FALLBACK_BASE_URL) {
141
+ config.server.fallback_base_url = process.env.WHATS_MCP_FALLBACK_BASE_URL;
142
+ }
143
+
144
+ return config;
145
+ }
146
+
147
+ module.exports = { loadConfig, DEFAULTS };