From aca57dba68b9d65fb1a701887eab235f31a2e13a Mon Sep 17 00:00:00 2001 From: nyx Date: Fri, 31 Jan 2025 07:54:49 -0600 Subject: [PATCH] Import --- .clang-format | 19 ++ .editorconfig | 11 ++ .eslintrc.js | 13 ++ .gitignore | 3 + Dockerfile | 10 + README.md | 14 ++ UNLICENSE | 26 +++ config.example.mjs | 16 ++ docker-compose.yml | 8 + index.mjs | 450 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 34 ++++ 11 files changed, 604 insertions(+) create mode 100644 .clang-format create mode 100644 .editorconfig create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 UNLICENSE create mode 100644 config.example.mjs create mode 100644 docker-compose.yml create mode 100644 index.mjs create mode 100644 package.json diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..1f4f375 --- /dev/null +++ b/.clang-format @@ -0,0 +1,19 @@ +--- +BasedOnStyle: LLVM +IndentWidth: 4 +SortIncludes: false +AlignConsecutiveAssignments: true +AlignConsecutiveBitFields: true +AlignConsecutiveMacros: true +AlignEscapedNewlines: true +AllowShortCaseLabelsOnASingleLine: true +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: true +AllowShortLambdasOnASingleLine: true +BinPackParameters: false +IndentCaseBlocks: true +IndentCaseLabels: true +IndentExternBlock: true +IndentGotoLabels: true +--- + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6c54233 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +tab_width = 2 + diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..5c9b72b --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + "env": { + "browser": false, + "es2021": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "rules": { + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1312f08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +/config.mjs diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26e0b3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-slim + +COPY package*.json /tmp/ +RUN cd /tmp && npm install +RUN mkdir -p /opt/app && cp -a /tmp/node_modules /opt/app/ + +WORKDIR /opt/app +COPY . /opt/app + +CMD ["npm", "run", "bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9767acb --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Quotes bot + +A quotes bot integrating with [Imag](https://ari.lt/gh/imag) ! + +See `config.example.mjs` to configure it. + +# Running + +```sh +cp config.example.mjs config.mjs +vim config.mjs # Configure it +docker compose build --no-cache +docker compose up -d +``` diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..51129ad --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,26 @@ +UNLICENSE + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/config.example.mjs b/config.example.mjs new file mode 100644 index 0000000..9db5ce7 --- /dev/null +++ b/config.example.mjs @@ -0,0 +1,16 @@ +"use strict"; + +const config = { + homeserver: + "https://matrix.example.com/" /* The full matrix homeserver, not just the delegated domain */, + token: "..." /* The access token of the bot account */, + prefix: "!q " /* The command !prefix - bot will only respond to messages that are valid commands starting with it */, + imag: "https://imag.example.com/" /* The Imag instance the bot should post to */, + imag_key: "..." /* The Imag instance's access key */, + autojoin: false /* Should the bot auto-join rooms its invited to? */, + admin: "@admin:example.com" /* The administrator of the bot (can use commands such as !join */, + room: "#quotes:example.com" /* The first room to join on bot startup, can be a room ID or alias */, + debug: false /* If debug mode is enabled or not */, +}; + +export default config; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dfda93a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3" +services: + quotes-bot: + build: . + volumes: + - .:/usr/src/app + environment: + - NODE_ENV=production diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..7ec1194 --- /dev/null +++ b/index.mjs @@ -0,0 +1,450 @@ +"use strict"; + +import config from "./config.mjs"; +import { + MatrixClient, + AutojoinRoomsMixin, + RichRepliesPreprocessor, +} from "matrix-bot-sdk"; +import FormData from "form-data"; +import axios from "axios"; +import sizeOf from "image-size"; +import escapeHtml from "escape-html"; + +let user_id; +const client = new MatrixClient(config.homeserver, config.token); + +function get_command_argument(event) { + return event["content"]["body"] + .slice(config.prefix.length) + .split(" ") + .slice(1) + .join(" ") + .trim(); +} + +function dlog(...data) { + if (config.debug) console.info("[DEBUG]", ...data); +} + +async function cmd_quote(room_id, event) { + let caption = get_command_argument(event).substring(0, 128); + + dlog(`Quote ${caption} command invoked in ${room_id} by`, event); + + if (!caption) { + await client.replyText( + room_id, + event, + `Usage: quote - takes the replied to image and uploads it to ${config.imag}`, + ); + return; + } + + let original_message = await client.getEvent( + room_id, + event["content"]["m.relates_to"]["m.in_reply_to"]["event_id"], + ); + + let image_url = original_message.content.url; + + dlog(`Image URL: ${image_url}`); + + if (!image_url || !image_url.startsWith("mxc://")) return; + + let image = await axios.get(client.mxcToHttp(image_url), { + responseType: "arraybuffer", + }); + + let data = new FormData(); + + data.append("desc", caption); + data.append("key", config.imag_key); + data.append("image", image.data, { filename: "quote.jpg" }); + + axios({ + method: "post", + url: config.imag, + headers: { + ...data.getHeaders(), + }, + data: data, + }) + .then((r) => { + dlog(r); + + if (r.status >= 400) + throw new Error( + `Failed to POST the image. Response code: ${r.status}`, + ); + + axios.get(`${config.imag}/api/latest`).then(async (r) => { + await client.replyText( + room_id, + event, + `Quote #${r.data} posted! Check it out at ${config.imag}#${r.data} or ${config.imag}image/${r.data} :)`, + ); + }); + }) + .catch(async (e) => { + console.error(e); + await client.replyText(room_id, event, "Error!"); + }); +} + +async function cmd_get(room_id, event) { + let q = get_command_argument(event); + let image_url, image_id; + + dlog(`Get ${q} command invoked in ${room_id} by`, event); + + if (!q) { + await client.replyText( + room_id, + event, + "Usage: get OR (newest:)(n (result number, starting from 1):) - gets a quote by its ID, or searches for it applying score or newest posted filters, also allows you to set which result to get", + ); + return; + } + + if (q.match(/^\d+$/)) { + image_id = q; + image_url = `${config.imag}image/${image_id}`; + } else { + let newest = q.startsWith("newest:"); + + if (newest) q = q.slice(7); + + let n = 1; + let matches = q.match(/^(\d+):/); + + if (matches !== null) { + n = parseInt(matches[1]); + q = q.replace(/^(\d+:)/, "").trim(); + } + + let results = await axios.get( + `${config.imag}api/search?q=${encodeURIComponent(q)}&s=${newest ? "newest" : "score"}`, + ); + + if (results.status >= 400 || results.data.length < n) { + client.replyText(room_id, event, "No such quotes found."); + return; + } + + image_id = results.data[Math.max(n - 1, 0)].iid; + image_url = `${config.imag}image/${image_id}`; + } + + dlog("Image ID:", image_id); + dlog("Image URL:", image_url); + + let image, metadata; + + try { + image = await axios.get(image_url, { + responseType: "arraybuffer", + }); + } catch (e) { + console.error(e); + await client.replyText(room_id, event, "Failed to fetch the quote."); + return; + } + + try { + metadata = (await axios.get(`${config.imag}api/image/${image_id}`)) + .data; + } catch (e) { + console.error(e); + await client.replyText( + room_id, + event, + "Failed to fetch the quote metadata, so won't sent the associated metadata with it.", + ); + } + + dlog("Metadata:", metadata); + + let buffer = Buffer.from(image.data); + + let dimensions = sizeOf(buffer); + let mime = image.headers["content-type"]; + let ext = mime === "image/png" ? "png" : "jpg"; + + dlog("Dimensions:", dimensions); + dlog("MIME:", mime); + dlog("Extension:", ext); + + let content = { + body: `quote-${image_id}.${ext}`, + info: { + size: image.data.byteLength, + w: dimensions.width, + h: dimensions.height, + mimetype: mime, + }, + msgtype: "m.image", + url: undefined, + "m.relates_to": { + "m.in_reply_to": { + event_id: event["event_id"], + }, + }, + }; + + let mxc = await client.uploadContent(buffer, { + filename: `quote.${ext}`, + type: mime, + }); + + dlog("Image uploaded:", mxc); + + content.url = mxc; + + await client.sendMessage(room_id, content); + + if (metadata) + await client.sendHtmlText( + room_id, + `Quote ${metadata.iid}: "${escapeHtml(metadata.desc)}" +
+
    +
  • Score: ${metadata.score} ${metadata.score < 0 ? "\uD83D\uDC4E" : "\uD83D\uDC4D"}
  • +
  • Created: ${new Date(metadata.created * 1000).toUTCString()}
  • +
  • Edited: ${new Date(metadata.edited * 1000).toUTCString()}
  • +
+
${escapeHtml(metadata.ocr).replaceAll("\n", "
")}
`, + ); +} + +async function cmd_join(room_id, event, action = "join", what = "Joined") { + let room = get_command_argument(event); + + dlog(`Invoked ${action} ${room} command in ${room_id} by`, event); + + if (!room) { + await client.replyText( + room_id, + event, + `Usage: ${action} - ${action} a room`, + ); + return; + } + + try { + dlog(`Resolving ${room}`); + room = await client.resolveRoom(room); + } catch (e) { + console.error(e); + await client.replyText( + room_id, + event, + "Failed to resolve the room. Wrong room ID or alias?", + ); + return; + } + + await client[`${action}Room`](room) + .then(async () => { + await client.replyText(room_id, event, `${what} ${room}`); + }) + .catch(async (e) => { + console.error(e); + await client.replyText( + room_id, + event, + `Failed to ${action} the room. Invalid room ID?`, + ); + }); +} + +async function cmd_leave(room_id, event) { + await cmd_join(room_id, event, "leave", "Left"); +} + +async function cmd_score(room_id, event) { + let n = 10, + ns = get_command_argument(event); + + if (ns && ns.match(/^-?\d+$/)) n = parseInt(ns); + + if (n === 0) { + client.replyText( + room_id, + event, + "Usage: score ", + ); + return; + } + + let all; + + try { + all = (await axios.get(`${config.imag}api/all`)).data; + } catch (e) { + console.error(e); + await client.replyText(room_id, event, "Failed to fetch all quotes."); + } + + let qs; + + if (n > 0) qs = all.slice(0, n); + else if (n < 0) qs = all.slice(n); + + if (qs.length == 1) { + event["content"]["body"] = `${config.prefix}get ${qs[0].iid}`; + return await cmd_get(room_id, event); + } + + let html = "
    "; + + for (let idx = 0; idx < qs.length; ++idx) { + let q = qs[idx]; + html += `
  • Quote #${q.iid} - "${escapeHtml(q.desc)}": ${q.score} ${q.score < 0 ? "\uD83D\uDC4E" : "\uD83D\uDC4D"}
  • `; + } + + html += "
"; + + await client.replyHtmlText(room_id, event, html); +} + +async function on_room_message(room_id, event) { + // debug stuff + + if (config.debug && room_id !== config.room) return; + + // non-debug stuff + + if ( + !event["content"] || + !event["content"]["body"] || + event["sender"] === user_id + ) + return; + + dlog(room_id, event); + + if ( + event["content"]["m.relates_to"] && + event["content"]["m.relates_to"]["m.in_reply_to"] && + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}quote`) + ) + await cmd_quote(room_id, event); + else if ( + event["content"]["body"].toLowerCase().startsWith(`${config.prefix}get`) + ) + await cmd_get(room_id, event); + else if ( + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}source`) + ) + await client.replyText( + room_id, + event, + "https://ari.lt/gh/quotes-bot (contact @ari:ari.lt for any details :))", + ); + else if ( + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}join`) && + event["sender"] === config.admin + ) + await cmd_join(room_id, event); + else if ( + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}leave`) && + event["sender"] === config.admin + ) + await cmd_leave(room_id, event); + else if ( + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}die`) && + event["sender"] === config.admin + ) { + dlog(`Die invoked in ${room_id} by`, event); + await client.replyText(room_id, event, "Goodnight!"); + process.exit(); + } else if ( + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}help`) + ) + await client.replyHtmlText( + room_id, + event, + ` +Available commands:
+
+- quote - post a quote to ${config.imag}
+- get - get a quote
+- source - get the source code of the bot
+- join - join a room (admin only)
+- leave - leave a room (admin only)
+- die - make the bot shut down (admin only)
+- help - print help
+- score - get the quotes with lowest or highest scores +`.trim(), + ); + else if ( + event["content"]["body"] + .toLowerCase() + .startsWith(`${config.prefix}score`) + ) + await cmd_score(room_id, event); + else if ( + event["content"]["body"].toLowerCase().startsWith(`${config.prefix}add`) + ) + await client.replyText( + room_id, + event, + "look at this idiot lmfaoo", + ); +} + +async function main() { + if (!config.room && config.debug) + throw new Error( + "Please set config.room to use debug mode. The quotes bot will only respond to messages in that room.", + ); + + client.addPreprocessor(new RichRepliesPreprocessor(false)); + + if (config.debug) { + config.prefix = `${Math.random()}:${config.prefix}`; + dlog(`Debug prefix: ${config.prefix}`); + } + + if (config.autojoin) { + dlog("Enabling autojoin"); + AutojoinRoomsMixin.setupOnClient(client); + } + + await client.start().then(async () => { + user_id = await client.getUserId(); + console.log(`Bot started! User ID: ${user_id}`); + }); + + if (config.room) { + let r = await client.resolveRoom(config.room); + dlog(`Joining ${config.room} (${r})`); + config.room = r; + await client.joinRoom(config.room); + } else config.warn("No default room set"); + + client.on("room.message", async (room_id, event) => { + try { + await on_room_message(room_id, event); + } catch (e) { + console.error(e); + client.replyText(room_id, event, "Error!"); + } + }); +} + +dlog("Hello, Debug!"); +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b1a6d0d --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "quotes-bot", + "version": "1.0.0", + "description": "A matrix bot integrating with the Imag image board.", + "main": "index.mjs", + "scripts": { + "bot": "node index.mjs" + }, + "repository": { + "type": "git", + "url": "..." + }, + "keywords": [ + "matrix", + "quotes", + "imag", + "matrix", + "quotes" + ], + "author": "...", + "license": "Unlicense", + "devDependencies": { + "@eslint/js": "^9.0.0", + "eslint": "^9.0.0", + "globals": "^15.0.0" + }, + "dependencies": { + "axios": "^1.6.8", + "escape-html": "^1.0.3", + "form-data": "^4.0.0", + "image-size": "^1.1.1", + "matrix-bot-sdk": "^0.7.1" + } +}