// Format of the required matrix.json file: // { // "user_id": "@statusbot:tomsmeding.com", // "password": "PASSWORD OF THE USER", // "room_id": "!ROOMID:tomsmeding.com" // } // Furthermore, accounts.json is required in the standard format ([[user, pass]]) // API: // POST /statusbot // { "sender": "who are you", "text": "content of the status message" } // State in this file: the above matrix.json plus: // { // config: the above matrix.json, // home_server: "tomsmeding.com", // nominal server in the user id // matrix_server: "matrix.tomsmeding.com", // actual matrix server address, without port // access_token: "some access token", // req_counter: 1 // counter for transaction requests to the matrix server // } // Saves persist state under key "access_tokens": // { // "@statusbot:tomsmeding.com|!ROOMID:tomsmeding.com": ["access token", req_counter: int] // } const cmn = require("../$common.js"); const fs = require("fs"); const https = require("https"); const URL = require("url"); const bodyParser = require("body-parser"); let moddir = null; const persist = require("node-persist").create({ dir: cmn.persistdir + "/statusbot", continuous: false, interval: false }); persist.initSync(); let persistTokens = persist.getItemSync("access_tokens"); if (!persistTokens) { persistTokens = {}; persist.setItemSync("access_tokens", persistTokens); } // cb: (status: int, body: string) => () function fetch(method, headers, hostname, path, data, cb) { const req = https.request({method, headers, hostname, path}, res => { let body = ""; res.on("data", data => { body += data; }); res.on("end", () => cb(res.statusCode, body)); }); req.on("error", () => cb(null, null)) req.write(data); req.end(); } // cb: (state) => () function augmentConfig(config, cb) { const m = config.user_id.match(/^@[^:]+:(.*)$/); if (!m) { throw new Error("statusbot: Cannot parse matrix ID (must be in @user:domain format)"); } const home_server = m[1]; const pkey = `${config.user_id}|${config.room_id}` let access_token = undefined; let req_counter = undefined; if (pkey in persistTokens) { [access_token, req_counter] = persistTokens[pkey]; } fetch("GET", {}, home_server, "/.well-known/matrix/server", "", (status, body) => { if (status != 200) { throw new Error(`statusbot: Failed getting https://${home_server}/.well-known/matrix/server`); } const mserver = JSON.parse(body)["m.server"]; const m = mserver.match(/^([^:]*)(:443)?$/); if (!m) { throw new Error(`statusbot: Matrix server port not 443 (sorry): <${mserver}>`); } const matrix_server = m[1]; cb({config, home_server, matrix_server, access_token, req_counter}); }); } function updatePersist(state) { persistTokens[`${state.config.user_id}|${state.config.room_id}`] = [state.access_token, state.req_counter]; persist.setItemSync("access_tokens", persistTokens); } // Sets access token in state. // cb: (success: bool, body: string) => () function matrixLogin(state, cb) { const data = JSON.stringify({ type: "m.login.password", identifier: {type: "m.id.user", user: state.config.user_id}, password: state.config.password, }); fetch("POST", {}, state.matrix_server, "/_matrix/client/v3/login", data, (status, body) => { if (status != 200) { cb(false, body); return; } try { const response = JSON.parse(body); state.access_token = response.access_token; state.req_counter = 1; updatePersist(state); cb(true, body); } catch (e) { cb(false, body); } }); } // cb: (status: int, body: string) => () // Status 401: access token invalid // Status 200: success // Anything else: ? (see body?) function matrixSendMsg(state, text, cb) { if (state.access_token == undefined) { cb(401); return; } const headers = {Authorization: `Bearer ${state.access_token}`}; const url = `/_matrix/client/v3/rooms/${state.config.room_id}/send/m.room.message/${state.req_counter}`; state.req_counter++; updatePersist(state); // req_counter changed const data = JSON.stringify({ msgtype: "m.text", body: text, }); fetch("PUT", headers, state.matrix_server, url, data, (status, body) => { cb(status, body); }); } // cb: () => () function logFailure(message, cb) { fs.appendFile(moddir + "/faillog.txt", `[${new Date().toISOString()}] ${message}\n`, err => { if (err) console.error(err); cb(); }); } // Tries to send a message, trying login if the access token is invalid. // cb: (success: bool) => () function matrixSendMsgLogin(state, text, cb) { matrixSendMsg(state, text, (status, body) => { switch (status) { case 200: cb(true); break; case 401: matrixLogin(state, (success, body) => { if (!success) { logFailure(`Failed to log in: ${body}`, () => cb(false)); return; } matrixSendMsg(state, text, (status, body) => { switch (status) { case 200: cb(true); return; case 401: logFailure(`401 even after login: ${body}`, () => cb(false)); break; default: logFailure(`Failed to send message: ${body}`, () => cb(false)); break; } }); }); break; default: logFailure(`Failed to send message: ${body}`, () => cb(false)); break; } }) } module.exports = function(app, io, _moddir) { moddir = _moddir; const config = require("./matrix.json"); const accounts = require("./accounts.json"); augmentConfig(config, state => { app.post("/statusbot", bodyParser.json(), cmn.authgen(accounts), (req, res) => { if (typeof req.body.sender != "string" || typeof req.body.text != "string") { return res.sendStatus(400); } matrixSendMsgLogin(state, `[${req.body.sender}] ${req.body.text}`, success => { if (success) res.sendStatus(200); else res.sendStatus(503); // service unavailable }); }); }); };