diff options
author | Tom Smeding <tom@tomsmeding.com> | 2024-07-02 22:26:59 +0200 |
---|---|---|
committer | Tom Smeding <tom@tomsmeding.com> | 2024-07-02 22:26:59 +0200 |
commit | c772e52b200da6aa2df3f336b62d10ced87fa934 (patch) | |
tree | ce0090e73dd1d0988ee485cd7dbe1be50ea2311b | |
parent | 43f26e5b23c6bf2d0e7d72ceeb9c02b2ceb7b608 (diff) |
Add statusbot module
-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 + }); + }); + }); +}; |