diff options
| -rw-r--r-- | modules/statusbot/.gitignore | 3 | ||||
| -rw-r--r-- | modules/statusbot/statusbot.js | 197 | 
2 files changed, 200 insertions, 0 deletions
diff --git a/modules/statusbot/.gitignore b/modules/statusbot/.gitignore new file mode 100644 index 0000000..cc941e3 --- /dev/null +++ b/modules/statusbot/.gitignore @@ -0,0 +1,3 @@ +matrix.json +accounts.json +faillog.txt diff --git a/modules/statusbot/statusbot.js b/modules/statusbot/statusbot.js new file mode 100644 index 0000000..0fd9ce5 --- /dev/null +++ b/modules/statusbot/statusbot.js @@ -0,0 +1,197 @@ +// 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 +      }); +    }); +  }); +};  | 
