const express = require("express"); const fs = require("fs"); const path = require("path"); const { Client, LocalAuth } = require("whatsapp-web.js"); const qrTerm = require("qrcode-terminal"); const QRCode = require("qrcode"); const PORT = process.env.PORT || 8080; const AUTH_SECRET = process.env.AUTH_SECRET || ""; const LOG_FILE = process.env.LOG_FILE || path.join(__dirname, "bridge.log"); const RULES_FILE = process.env.RULES_FILE || path.join(__dirname, "rules.json"); const MAX_TEXT = 2000; if (!AUTH_SECRET) { console.error("AUTH_SECRET env var is required"); process.exit(1); } function log(line) { const ts = new Date().toISOString().replace("T", " ").slice(0, 19); const full = ts + " | " + line; console.log(full); try { fs.appendFileSync(LOG_FILE, full + "\n"); } catch (e) {} } // ---------- WhatsApp client (no LLM, no OpenClaw) ---------- const client = new Client({ authStrategy: new LocalAuth({ dataPath: path.join(__dirname, ".wwebjs_auth") }), puppeteer: { headless: true, executablePath: process.env.CHROMIUM_PATH || undefined, args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] } }); let waReady = false; let lastQR = null; const sendQueue = []; client.on("qr", s => { lastQR = s; console.log("\n=== SCAN THIS QR WITH WHATSAPP (Settings > Linked Devices) ==="); console.log("=== or open /qr?auth= in a browser ===\n"); qrTerm.generate(s, { small: true }); }); client.on("ready", () => { waReady = true; log("WhatsApp client ready"); sendQueue.splice(0).forEach(fn => fn()); }); client.on("auth_failure", msg => { waReady = false; log("WhatsApp auth failure: " + msg); }); client.on("disconnected", reason => { waReady = false; log("WhatsApp disconnected: " + reason); client.initialize(); }); function sendWA(target, msg) { return new Promise(resolve => { const run = async () => { if (!waReady) { resolve(false); return; } try { const chatId = target.replace(/[^0-9]/g, "") + "@c.us"; await client.sendMessage(chatId, msg); resolve(true); } catch (e) { log("send error to " + target + ": " + e.message); resolve(false); } }; if (waReady) run(); else sendQueue.push(run); }); } client.initialize(); // ---------- Routing ---------- function route(frm, text) { const cfg = JSON.parse(fs.readFileSync(RULES_FILE, "utf8")); const results = []; for (const rule of cfg.routes || []) { if (!frm.toLowerCase().includes(rule.sender_contains)) continue; if (rule.otp_only && !/otp/i.test(text)) continue; for (const t of rule.targets) results.push({ target: t, msg: "[" + rule.name + "] SMS from " + frm + ":\n" + text }); return results; } const fb = cfg.fallback || {}; if (fb.otp_only !== false && !/otp/i.test(text)) return results; for (const t of fb.targets) results.push({ target: t, msg: "SMS from " + frm + ":\n" + text }); return results; } // ---------- HTTP ---------- const app = express(); app.use(express.text({ type: () => true, limit: "64kb" })); app.get("/health", (req, res) => { res.json({ waReady, rules: fs.existsSync(RULES_FILE) ? "loaded" : "missing" }); }); // Lenient JSON: escape raw control chars inside strings (MacroDroid sends real newlines) function lenientParse(raw) { let out = ""; let inStr = false; for (let i = 0; i < raw.length; i++) { const c = raw[i]; if (inStr && c === "\\") { out += c + raw[i + 1]; i++; continue; } if (c === '"') { inStr = !inStr; out += c; continue; } const code = c.charCodeAt(0); if (inStr && code < 32) { out += code === 10 ? "\\n" : code === 13 ? "\\r" : code === 9 ? "\\t" : "\\u" + code.toString(16).padStart(4, "0"); continue; } out += c; } return JSON.parse(out); } // QR as PNG for headless pairing: /qr?auth= app.get("/qr", async (req, res) => { if (req.query.auth !== AUTH_SECRET) return res.status(401).send("unauthorized"); if (waReady) return res.status(200).send("already paired"); if (!lastQR) return res.status(404).send("no QR yet - wait for the qr event"); const png = await QRCode.toBuffer(lastQR, { width: 480 }); res.type("png").send(png); }); app.post("/sms", async (req, res) => { if (req.get("X-Auth") !== AUTH_SECRET) return res.status(401).send("unauthorized"); let body = {}; try { body = lenientParse(req.body || "{}"); } catch (e) { log("JSON parse failed: " + e.message); return res.status(400).send("bad json"); } const frm = String(body.from || "unknown").slice(0, 32); const text = String(body.text || "").slice(0, MAX_TEXT); if (!text) { log(frm + " | EMPTY | dropped"); return res.status(400).send("empty"); } const jobs = route(frm, text); if (!jobs.length) { log(frm + " | DROPPED (no match) | " + text.slice(0, 120)); return res.status(200).send("skipped"); } const results = await Promise.all(jobs.map(j => sendWA(j.target, j.msg))); const ok = results.every(Boolean); log(frm + " | " + (ok ? "sent->" + jobs.map(j => j.target).join(",") : "SEND FAILED") + " | " + text.slice(0, 120)); res.status(ok ? 200 : 500).send(ok ? "sent x" + jobs.length : "send failed"); }); app.listen(PORT, () => log("sms-wa-bridge listening on :" + PORT));