/* * Copyright 2020-2022 Netsyms Technologies. * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* * Matrix really, really, really needs better SDK docs. */ console.log("Starting up..."); import sdk from 'matrix-js-sdk'; import fs from 'fs'; import log4js from 'log4js'; import https from 'https'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import request from 'request'; import FileType from 'file-type'; import express from 'express'; import bodyParser from 'body-parser'; // Load settings from config.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); let rawdata = fs.readFileSync(__dirname + '/config.json'); let settings = JSON.parse(rawdata); console.log(__dirname + "/config.json loaded."); var logger = log4js.getLogger(); logger.level = settings.loglevel; logger.info("Log initialized."); var initialsynccomplete = false; /** * From https://github.com/stevekinney/node-phone-formatter * @param {string} phoneNumber * @param {string} formatString * @returns {string} */ function formatPhoneNumber(phoneNumber, formatString) { phoneNumber = phoneNumber.replace( /^[\+\d{1,3}\-\s]*\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, "$1$2$3" ); for (var i = 0, l = phoneNumber.length; i < l; i++) { formatString = formatString.replace("N", phoneNumber[i]); } return formatString; } function checkSMS() { logger.debug("Checking SMS"); const options = { hostname: 'smsapi.voxtelesys.net', port: 443, path: '/api/v1/msgs/inbound?unread_only=true', method: 'GET', headers: { "Authorization": "Bearer " + settings.smsapikey } }; const req = https.request(options, res => { res.on('data', d => { try { var json = JSON.parse(d); var messages = json.results; if (messages.length == 0) { logger.debug("No new SMS messages."); } for (var i = 0; i < messages.length; i++) { var msg = messages[i]; if (settings.smsonlyto.length > 0 && settings.smsonlyto.indexOf(msg.to) != -1) { logger.info("Received SMS from " + msg.from + " for " + msg.to + ": " + msg.body); createOrJoinSMSRoom(msg.from, msg.to, function (roomid) { sendMatrix(roomid, msg.body, msg.media); }); } else { logger.info("Received SMS from " + msg.from + " for " + msg.to + ", ignoring based on smsonlyto list."); } } } catch (ex) { logger.error(ex); } }); }); req.on('error', error => { logger.warning(error); }); req.end(); } /** * Join or create+join a room with alias #SMS_{tel}. If already joined, do nothing. * @param {string} tel * @param {string} ournumber the phone number Matrix messages will be sent from for this room * @param {function} callback function with the room ID as an argument. * @returns {undefined} */ function createOrJoinSMSRoom(tel, ournumber, callback) { var roomName = "#SMS_" + tel + "_" + ournumber + ":" + settings.matrixdomain; logger.debug("Checking if room " + roomName + " exists."); client.getRoomIdForAlias(roomName).then((res) => { logger.debug("Room " + roomName + " exists!"); var inRoom = false; var rooms = client.getRooms(); for (var i = 0; i < rooms.length; i++) { if (rooms[i].roomId == res.room_id) { inRoom = true; break; } } if (inRoom) { // we're already in the room, do nothing logger.debug("Room " + roomName + " already joined."); client.setRoomTag(res.room_id, "u.matrix-bridge-voxtelesys-sms", {tel: tel, ournumber: ournumber, order: 0.5}); callback(res.room_id); } else { // not in the room, join it client.joinRoom(res.room_id).then((room) => { logger.debug("Room " + roomName + " joined."); client.setRoomTag(room.room_id, "u.matrix-bridge-voxtelesys-sms", {tel: tel, ournumber: ournumber, order: 0.5}); callback(room.room_id); }); } return; }).catch((err) => { // room doesn't exist, create it logger.debug(err); logger.debug("Room " + roomName + " does not exist. Creating it now."); var userPowerLevels = {}; for (var i = 0; i < settings.inviteusers.length; i++) { userPowerLevels[settings.inviteusers[i]] = 50; } userPowerLevels["@" + settings["matrixuser"] + ":" + settings["matrixdomain"]] = 100; client.createRoom({ room_alias_name: "SMS_" + tel + "_" + ournumber, preset: "trusted_private_chat", visibility: "private", invite: settings.inviteusers, power_level_content_override: { "events": { "m.room.name": 50, "m.room.power_levels": 50, "m.room.canonical_alias": 100 }, "events_default": 0, "invite": 50, "kick": 50, "notifications": { "room": 50 }, "redact": 50, "state_default": 50, "users": userPowerLevels, "users_default": 50 }, is_direct: true, name: formatPhoneNumber(tel, "(NNN) NNN-NNNN"), topic: "SMS conversation with " + formatPhoneNumber(tel, "(NNN) NNN-NNNN") + " (using " + formatPhoneNumber(ournumber, "(NNN) NNN-NNNN") + ")" }).then((room) => { logger.debug("Room" + roomName + " created with ID " + room.room_id); // The first message or two we send doesn't go through unless we do this. // It just spits out "Error sending event M_FORBIDDEN: Unknown room" instead. createOrJoinSMSRoom(tel, ournumber, callback); }).catch((err) => { logger.error("Could not create " + roomName + "."); logger.error(err); }); }); } function getAndUploadFile(url, callback) { logger.info("Downloading MMS media " + url); // download request({url, encoding: null}, (err, resp, buffer) => { FileType.fromBuffer(buffer).then(function (mimeobj) { logger.debug(mimeobj); // upload logger.info("Uploading MMS media to Matrix " + url); client.uploadContent(buffer, { onlyContentUri: true, rawResponse: false, type: mimeobj.mime }).then((res) => { if (typeof callback == "function") { callback(res, mimeobj.mime); logger.info("Media URI: " + res); } }).catch((err) => { if (typeof callback == "function") { callback(false); } if (err.data.error == "Unknown room") { return; } logger.error(err); }); }); }); } /** * Send a message to a Matrix room. * @param {string} roomid the room to post the message in. * @param {string} body message content. * @param {array} media Array of media URLs to download via HTTP(s) and send to Matrix. * @param {function|undefined} callback passes true when successful, false on failure. * @returns {undefined} */ function sendMatrix(roomid, body, media, callback) { if (Array.isArray(media)) { for (var i = 0; i < media.length; i++) { getAndUploadFile(media[i], function (uri, mimetype) { if (mimetype == "image/jpg" || mimetype == "image/jpeg" || mimetype == "image/png" || mimetype == "image/gif") { var content = { body: "Image", msgtype: "m.image", url: uri, info: { mimetype: mimetype } }; } else { var content = { body: "File", msgtype: "m.file", url: uri, info: { mimetype: mimetype } }; } client.sendEvent(roomid, "m.room.message", content, "").then((res) => { }).catch((err) => { if (err.data.error == "Unknown room") { return; } logger.error(err); }); }); } } if (body != "") { var content = { body: body, msgtype: "m.text" }; client.sendEvent(roomid, "m.room.message", content, "").then((res) => { if (typeof callback == "function") { callback(true); } }).catch((err) => { if (typeof callback == "function") { callback(false); } if (err.data.error == "Unknown room") { return; } logger.error(err); }); } else { if (typeof callback == "function") { callback(true); } } } function sendMatrixNotice(roomid, body, callback) { var content = { body: body, msgtype: "m.notice" }; client.sendEvent(roomid, "m.room.message", content, "").then((res) => { if (typeof callback == "function") { callback(true); } }).catch((err) => { if (typeof callback == "function") { callback(false); } if (err.data.error == "Unknown room") { return; } logger.error(err); }); } /** * Send a SMS to a phone number. * @param {string} number * @param {string} body message content. * @param {function|undefined} callback passes true when successful, false on failure. * @returns {undefined} */ function sendSMS(number, from, body, callback) { if (settings.googleverifiedsms) { // Use Google Verified SMS to add business branding to SMS message } logger.info("Sending SMS to " + number + " from " + from); var data = { to: [number], from: from, body: body }; const jsondata = JSON.stringify(data); const options = { hostname: 'smsapi.voxtelesys.net', port: 443, path: '/api/v1/sms', method: 'POST', headers: { "Authorization": "Bearer " + settings.smsapikey, "Content-Type": "application/json", "Accept": "application/json" } } const req = https.request(options, res => { res.on('data', d => { logger.debug(d.toString('utf8')); }); }); req.on('error', error => { logger.error(error); callback(false); }); req.write(jsondata); req.end(); if (typeof callback == "function") { callback(true); } } /** * Send a SMS to a phone number. * @param {string} number * @param {string} body message content. * @param {function|undefined} callback passes true when successful, false on failure. * @returns {undefined} */ function sendMMS(number, from, mediauri, mimetype, callback) { logger.info("Sending MMS to " + number + " from " + from); var urichunks = mediauri.split("/"); // should result in something like [ "mxc:", "", "matrix.org", "mediaidhere90473473" ] if (urichunks.length < 4) { logger.error("Invalid media uri"); if (typeof callback == "function") { callback(false); } return; } var httpmediaurl = settings.mediaurlpath; httpmediaurl = httpmediaurl.replace("{{server-name}}", urichunks[2]); httpmediaurl = httpmediaurl.replace("{{media-id}}", urichunks[3]); var body = ""; var media = [httpmediaurl]; switch (mimetype) { case "image/jpeg": case "image/png": case "image/gif": case "image/bmp": // These are likely to work, so don't include the URL body = ""; break; default: // Send link to content too body = httpmediaurl; //media = []; } var data = { to: [number], from: from, body: body, media: media }; const jsondata = JSON.stringify(data); const options = { hostname: 'smsapi.voxtelesys.net', port: 443, path: '/api/v1/sms', method: 'POST', headers: { "Authorization": "Bearer " + settings.smsapikey, "Content-Type": "application/json", "Accept": "application/json" } } const req = https.request(options, res => { res.on('data', d => { logger.debug(d.toString('utf8')); }); }); req.on('error', error => { logger.error(error); if (typeof callback == "function") { callback(false); } }); req.write(jsondata); req.end(); if (typeof callback == "function") { callback(true); } } function handleHTTPRequest(req, res) { try { logger.info("Got HTTP request: " + req.url); if (req.url == "/webhook") { try { logger.debug("Got webhook: " + req.body); var msg = req.body; if (msg.type == "mo") { if (settings.smsonlyto.length > 0 && settings.smsonlyto.indexOf(msg.to) != -1) { logger.info("Received SMS from " + msg.from + " for " + msg.to + ": " + msg.body); createOrJoinSMSRoom(msg.from, msg.to, function (roomid) { sendMatrix(roomid, msg.body, msg.media); }); res.sendStatus(204); res.end(); } else { logger.info("Received SMS from " + msg.from + " for " + msg.to + ", ignoring based on smsonlyto list."); res.sendStatus(403); res.end(); } } else { res.sendStatus(403); res.end(); } } catch (ex) { logger.error("Decoding webhook body: " + ex); logger.error(req.body); res.sendStatus(500); res.end(); } } else { try { res.sendStatus(404); res.end(); } catch (err) { logger.error(err); } } } catch (exx) { logger.error(exx); } } const client = sdk.createClient(settings.homeserver); var httpserver = express(); var jsonParser = bodyParser.json(); client.login("m.login.password", {"user": settings.matrixuser, "password": settings.matrixpass}).then((response) => { client.startClient(); logger.info("Plugged into the matrix."); client.once('sync', function (state, prevState, res) { logger.debug("Initial sync complete (" + state + ")"); initialsynccomplete = true; httpserver.post("*", jsonParser, (req, res) => { handleHTTPRequest(req, res); }); httpserver.get("*", (req, res) => { handleHTTPRequest(req, res); }); httpserver.listen(settings.listenport, () => { logger.info("HTTP server listening on port " + settings.listenport); }); logger.info("Up and running."); if (settings.smsinterval > 0) { setInterval(checkSMS, settings.smsinterval * 1000); checkSMS(); } }); client.on("Room.timeline", function (event, room) { if (!initialsynccomplete) { return; // ignore anything while we were offline } if (event.getType() !== "m.room.message") { return; // only use messages } if (client.getUserId() == event.getSender()) { return; // skip own messages to prevent loop } if (event.getContent().body.toLowerCase().startsWith("!sms")) { // capture command to start room for new number const matches = event.getContent().body.match(/([1-9]?[0-9]{10})/g); if (matches.length == 1 || matches.length == 2) { var tel = matches[0]; var ournumber = settings.smsfrom; if (tel.length == 10) { // make it the full number tel = "1" + tel; } if (matches.length == 2) { ournumber = matches[1]; if (ournumber.length == 10) { // make it the full number ournumber = "1" + ournumber; } } logger.info("Got request to start new SMS conversation with " + tel + " using " + ournumber + " from " + event.getSender() + "."); sendMatrixNotice(event.getRoomId(), "Starting conversation with " + tel); createOrJoinSMSRoom(tel, ournumber, function (roomid) { //client.setRoomTag(roomid, "u.matrix-bridge-voxtelesys-sms", {tel: tel, ournumber: ournumber}); }); } return; } if (event.getContent().body.toLowerCase().startsWith("!fixnumbers")) { // capture command to start room for new number const matches = event.getContent().body.match(/([1-9]?[0-9]{10})/g); if (matches.length == 2) { var tel = matches[0]; if (tel.length == 10) { // make it the full number tel = "1" + tel; } var ournumber = matches[1]; if (ournumber.length == 10) { // make it the full number ournumber = "1" + ournumber; } logger.info("Got request from " + event.getSender() + " to set SMS from number to " + ournumber + " for SMS conversation with " + tel + "."); client.setRoomTag(event.getRoomId(), "u.matrix-bridge-voxtelesys-sms", {tel: tel, ournumber: ournumber, order: 0.5}).then(function () { sendMatrixNotice(event.getRoomId(), "Now sending SMS from " + ournumber + " to " + tel + "."); }); } return; } if (event.getContent().body.toLowerCase().startsWith("!fixusers")) { sendMatrixNotice(event.getRoomId(), "Inviting missing users across all SMS chats."); client.getJoinedRooms().then(function (rooms) { var roomlist = rooms.joined_rooms; for (var i = 0; i < roomlist.length; i++) { (function (roomid) { client.getJoinedRoomMembers(roomid).then(function (joined) { var members = Object.keys(joined.joined); for (var j = 0; j < settings.inviteusers.length; j++) { if (members.indexOf(settings.inviteusers[j]) == -1) { logger.info("Inviting missing user " + settings.inviteusers[j] + " to room " + roomid); client.invite(roomid, settings.inviteusers[j]); } } }); })(roomlist[i]); } }); return; } var matches = room.name.match(/SMS_([1-9][0-9]+)(?:_([1-9][0-9]+))?/g); console.log(event.getRoomId()); if (matches == null || (matches.length != 1 && matches.length != 2)) { client.getRoomTags(event.getRoomId()).then((response) => { console.log(response); if (typeof response.tags["u.matrix-bridge-voxtelesys-sms"] != "undefined") { var tel = response.tags["u.matrix-bridge-voxtelesys-sms"].tel; var from = response.tags["u.matrix-bridge-voxtelesys-sms"].ournumber; logger.info("Got message for " + tel + " from " + event.getSender() + ", relaying to " + from + "."); switch (event.getContent().msgtype) { case "m.image": case "m.file": case "m.video": sendMMS( tel, from, event.getContent().url, event.getContent().info.mimetype, function () { client.sendReadReceipt(event, {}); }); break; case "m.text": default: sendSMS( tel, from, event.getContent().body, function () { client.sendReadReceipt(event, {}); }); break; } } else if (typeof response.tags["com.netsyms.matrix-bridge-voxtelesys.sms"] != "undefined") { var tel = response.tags["com.netsyms.matrix-bridge-voxtelesys.sms"].tel; var from = response.tags["com.netsyms.matrix-bridge-voxtelesys.sms"].ournumber; client.setRoomTag(event.getRoomId(), "u.matrix-bridge-voxtelesys-sms", {tel: tel, ournumber: from, order: 0.5}); logger.info("Got message for " + tel + " from " + event.getSender() + ", relaying to " + from + "."); switch (event.getContent().msgtype) { case "m.image": case "m.file": case "m.video": sendMMS( tel, from, event.getContent().url, event.getContent().info.mimetype, function () { client.sendReadReceipt(event, {}); }); break; case "m.text": default: sendSMS( tel, from, event.getContent().body, function () { client.sendReadReceipt(event, {}); }); break; } } else { console.log(response.tags); sendMatrixNotice(room.roomId, "Error: couldn't determine correct number to send SMS from."); } }); } else { if (matches.length == 1) { var tel = matches[0]; logger.info("Got message for " + tel + " from " + event.getSender() + ", relaying."); switch (event.getContent().msgtype) { case "m.image": case "m.file": case "m.video": sendMMS(tel, settings.smsfrom, event.getContent().url, event.getContent().info.mimetype, function () { client.sendReadReceipt(event, {}); }); break; case "m.text": default: sendSMS(tel, settings.smsfrom, event.getContent().body, function () { client.sendReadReceipt(event, {}); }); break; } } else if (matches.length == 2) { var tel = matches[0]; var from = matches[1]; logger.info("Got message for " + tel + " from " + event.getSender() + ", relaying to " + from + "."); switch (event.getContent().msgtype) { case "m.image": case "m.file": case "m.video": sendMMS(tel, from, event.getContent().url, event.getContent().info.mimetype, function () { client.sendReadReceipt(event, {}); }); break; case "m.text": default: sendSMS(tel, settings.smsfrom, event.getContent().body, function () { client.sendReadReceipt(event, {}); }); break; } } } }); });