143 lines
5.1 KiB
JavaScript
143 lines
5.1 KiB
JavaScript
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=<AUTH_SECRET> 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=<AUTH_SECRET>
|
|
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));
|