Add the sms-wa-bridge playbook
This commit is contained in:
1 parent
d248675643
commit
26a0654fea
14 files changed
+1409
No files matched your search
@@ -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));
|
||||
Reference in new issue
Block a user