From c772e52b200da6aa2df3f336b62d10ced87fa934 Mon Sep 17 00:00:00 2001 From: Tom Smeding Date: Tue, 2 Jul 2024 22:26:59 +0200 Subject: Add statusbot module --- modules/statusbot/.gitignore | 3 + modules/statusbot/statusbot.js | 197 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 modules/statusbot/.gitignore create mode 100644 modules/statusbot/statusbot.js 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 + }); + }); + }); +}; -- cgit v1.2.3-70-g09d2