summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTom Smeding <tom@tomsmeding.com>2024-07-02 22:26:59 +0200
committerTom Smeding <tom@tomsmeding.com>2024-07-02 22:26:59 +0200
commitc772e52b200da6aa2df3f336b62d10ced87fa934 (patch)
treece0090e73dd1d0988ee485cd7dbe1be50ea2311b
parent43f26e5b23c6bf2d0e7d72ceeb9c02b2ceb7b608 (diff)
Add statusbot module
-rw-r--r--modules/statusbot/.gitignore3
-rw-r--r--modules/statusbot/statusbot.js197
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
+ });
+ });
+ });
+};