Add the sms-wa-bridge playbook

This commit is contained in:
tanshu committed 2026-10-18 21:35:53 +05:30
1 parent d248675643
commit 26a0654fea
14 files changed
+1409

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
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));