/* * 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 * as sdk from 'matrix-js-sdk'; import matrixcs 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'; import fetch from 'node-fetch'; // Save script start time for ignoring older messages var boottimestamp = Date.now(); var client; var __dirname; var __filename; var initialsynccomplete = false; // Init logging var logger = log4js.getLogger(); logger.level = "debug"; logger.info("Log initialized."); var settings = {}; /** * Load settings from config.json */ function loadSettingsFile() { __filename = fileURLToPath(import.meta.url); __dirname = dirname(__filename); let rawdata = fs.readFileSync(__dirname + '/config.json'); settings = JSON.parse(rawdata); console.log(__dirname + "/config.json loaded."); logger.level = settings.loglevel; } loadSettingsFile(); // https://github.com/matrix-org/matrix-js-sdk/issues/2415#issuecomment-1188812401 matrixcs.request(request); /** * 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++) { let 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.warn(error); }); req.end(); } /** * Connect to the server and get the room ID and stuff * @param {type} alias * @returns {undefined} */ async function getRoomIDFromAlias(alias) { var info = await client.getRoomIdForAlias(alias); if (typeof info.room_id != "undefined") { return info; } var response = await fetch(settings.homeserver + "/_matrix/client/v3/directory/room/" + encodeURIComponent(alias)); if (response.status != 200) { throw new Error("Fetch returned invalid status code " + response.status); } var json = await response.json(); return json; } /** * 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} */ async function createOrJoinSMSRoom(tel, ournumber, callback) { var roomName = "#SMS_" + tel + "_" + ournumber + ":" + settings.matrixdomain; logger.debug("Checking if room " + roomName + " exists."); try { var res = await getRoomIDFromAlias(roomName); logger.debug(JSON.stringify(res)); logger.debug("Room " + roomName + " exists!"); var inRoom = false; if (!res.error) { var rooms = (await client.getJoinedRooms()).joined_rooms; for (var i = 0; i < rooms.length; i++) { if (rooms[i] == 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(res.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"]] = 100; try { var room = await 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") + ")" }); 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 (errr) { logger.error("Could not create " + roomName + "."); logger.error(errr); } } } 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); if (typeof mimeobj == "undefined") { logger.error("Undefined media mimetype, not uploading to Matrix."); return; } // 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} room 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. * @returns {undefined} */ async function sendMatrix(room, body, media) { var roomid = room; if (room.startsWith("#")) { try { roomid = (await getRoomIDFromAlias()).room_id; logger.info("Translated alias " + room + " to room ID " + roomid); } catch (err) { logger.error(err); } } if (Array.isArray(media)) { for (var i = 0; i < media.length; i++) { getAndUploadFile(media[i], async 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 } }; } try { await client.sendEvent(roomid, "m.room.message", content, ""); return true; } catch (err) { if (err.data.error == "Unknown room") { return; } logger.error(err); } }); } } if (body != "") { var content = { body: body, msgtype: "m.text" }; try { await client.sendEvent(roomid, "m.room.message", content, ""); return true; } catch (err) { if (err.data.error == "Unknown room") { return false; } logger.error(err); } } else { return 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 { 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) { logger.info("Sending to room " + 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); } } // Access token empty if (settings.matrixaccesstoken == false) { logger.error("Matrix access token not set."); if (settings.matrixuser == false || settings.matrixpass == false) { logger.error("Config values for matrixuser and/or matrixpass are not valid."); process.exit(1); } logger.error("Attempting to fetch access token for you..."); try { request({ method: "POST", uri: settings.homeserver + "/_matrix/client/v3/login", json: { type: "m.login.password", user: settings.matrixuser, password: settings.matrixpass } }, function (error, resp, body) { if (!error && resp.statusCode == 200) { if (body.access_token) { logger.error("Got access token for you. Writing it to " + __dirname + "/config.json now."); settings.matrixaccesstoken = body.access_token; fs.writeFileSync(__dirname + '/config.json', JSON.stringify(settings, null, 4)); logger.error("Exiting. Please restart me to load new config file."); process.exit(); } } else { logger.error("Couldn't get access token. Get it yourself: curl -XPOST -d '{\"type\":\"m.login.password\", \"user\":\"" + settings.matrixuser + "\", \"password\":\"password here\"}' \"https://matrix.netsyms.net/_matrix/client/v3/login\""); } }); } catch (ex) { logger.error("Couldn't get access token. Get it yourself: curl -XPOST -d '{\"type\":\"m.login.password\", \"user\":\"" + settings.matrixuser + "\", \"password\":\"password here\"}' \"https://matrix.netsyms.net/_matrix/client/v3/login\""); } } else { client = sdk.createClient({baseUrl: settings.homeserver, userId: settings.matrixuser, accessToken: settings.matrixaccesstoken}); var httpserver = express(); var jsonParser = bodyParser.json(); client.startClient({initialSyncLimit: 10}); 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) { try { 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.getTs() < (Date.now() - 1000 * 60 * 60 * 8)) { // Ignore old events (8 hrs), they're probably duplicates or something. logger.warn("Ignoring stale Matrix room event [" + event.getId() + "]: older than 8 hours"); return; } if (event.getTs() < boottimestamp) { logger.warn("Ignoring stale Matrix room event [" + event.getId() + "]: event predates start of this program"); return; } if (event.getContent() == null || typeof event.getContent().body == "undefined" || event.getContent().body == null) { // Apparently this can happen? return; } logger.debug("Got room message (event ID " + event.getId() + ")"); 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 == null) { return; } 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; } else if (event.getContent().body.toLowerCase().replace(/\s/g, "").startsWith("!sms")) { sendMatrixNotice(event.getRoomId(), "Malformed command detected, ignoring."); sendMatrixNotice(event.getRoomId(), "Hint: there aren't supposed to be any spaces before or in the \"!sms\" part."); return; } if (event.getContent().body.toLowerCase().startsWith("!fixusers")) { sendMatrixNotice(event.getRoomId(), "Inviting missing users across all SMS chats. You may need to run this command several times, there's a server limit to how many invites can be sent at once."); 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; } else if (event.getContent().body.toLowerCase().replace(/\s/g, "").startsWith("!fixusers")) { sendMatrixNotice(event.getRoomId(), "Malformed command detected, ignoring."); return; } if (event.getContent().body.toLowerCase().replace(/\s/g, "").startsWith("!")) { sendMatrixNotice(event.getRoomId(), "I'm sorry, but my programming forbids me from sending text messages that start with `!`."); return; } var matches = room.name.match(/SMS_([1-9][0-9]+)(?:_([1-9][0-9]+))?/g); 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, "m.read"); }); break; case "m.text": default: sendSMS( tel, from, event.getContent().body, function () { client.sendReadReceipt(event, "m.read"); }); 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, "m.read"); }); break; case "m.text": default: sendSMS( tel, from, event.getContent().body, function () { client.sendReadReceipt(event, "m.read"); }); 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, "m.read"); }); break; case "m.text": default: sendSMS(tel, settings.smsfrom, event.getContent().body, function () { client.sendReadReceipt(event, "m.read"); }); 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, "m.read"); }); break; case "m.text": default: sendSMS(tel, settings.smsfrom, event.getContent().body, function () { client.sendReadReceipt(event, "m.read"); }); break; } } } } catch (ex) { logger.error("Error handling incoming event: " + ex); } }); }